feat(machine): operator branding — local-file source (issue #47 V1)
Read /var/lib/bitspire/branding/{logo.png,branding.json} on startup and
apply across the renderer. branding.json may set title, theme (one of
the 6 built-ins or "custom"), and a custom_colors map (with optional
.dark overlay) — unset CSS vars fall back to gruvbox.
Wiring:
- electron/main.ts:loadBranding() reads + validates the JSON and
base64-encodes logo.png; surfaced via the existing get-config IPC
- composables/useBranding.ts holds reactive logoUrl/title refs and a
single setBranding() setter — the seam where #48's Nostr-event
source will eventually overlay the local-file source
- composables/useTheme.ts:applyBrandingTheme() handles built-in theme
swap and injects a <style#branding-custom-theme> block for custom
- IdleView binds :src/title; App.vue calls setBranding() before the
maintenance screen renders so "Under Service" wears operator branding
Provisioning: new deploy/nixos/provision-branding.sh rsyncs a local dir
to /var/lib/bitspire/branding/ via sudo-on-the-far-side and restarts
bitspire.service. The existing provision-atm.sh stays focused on .env.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
264cc47e0c
commit
c3353c409b
10 changed files with 268 additions and 3 deletions
|
|
@ -60,6 +60,89 @@ function loadEnvFile() {
|
|||
|
||||
loadEnvFile()
|
||||
|
||||
// Branding loader — reads /var/lib/bitspire/branding/{logo.png,branding.json}
|
||||
// and surfaces them on get-config. Per issue #47: local-file is the V1
|
||||
// source; V2 will overlay a higher-priority Nostr-event source from
|
||||
// satmachineadmin (issue #48). Renderer applies via useBranding().
|
||||
type BrandingConfig = {
|
||||
title: string | null
|
||||
theme: string | null
|
||||
customColors?: Record<string, string>
|
||||
customColorsDark?: Record<string, string>
|
||||
logoDataUrl: string | null
|
||||
}
|
||||
|
||||
const VALID_THEMES = new Set([
|
||||
'gruvbox',
|
||||
'catppuccin',
|
||||
'cyberpunk',
|
||||
'dracula',
|
||||
'nord',
|
||||
'tokyo-night',
|
||||
'custom',
|
||||
])
|
||||
|
||||
function loadBranding(): BrandingConfig | null {
|
||||
const brandingDir = path.join(
|
||||
fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(),
|
||||
'branding'
|
||||
)
|
||||
if (!fs.existsSync(brandingDir)) return null
|
||||
|
||||
let title: string | null = null
|
||||
let theme: string | null = null
|
||||
let customColors: Record<string, string> | undefined
|
||||
let customColorsDark: Record<string, string> | undefined
|
||||
let logoDataUrl: string | null = null
|
||||
|
||||
const jsonPath = path.join(brandingDir, 'branding.json')
|
||||
if (fs.existsSync(jsonPath)) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
|
||||
if (typeof raw.title === 'string') title = raw.title
|
||||
if (typeof raw.theme === 'string' && VALID_THEMES.has(raw.theme)) theme = raw.theme
|
||||
if (raw.custom_colors && typeof raw.custom_colors === 'object') {
|
||||
const { dark, ...flat } = raw.custom_colors as Record<string, unknown>
|
||||
const colors = Object.fromEntries(
|
||||
Object.entries(flat).filter(([, v]) => typeof v === 'string')
|
||||
) as Record<string, string>
|
||||
if (Object.keys(colors).length > 0) customColors = colors
|
||||
if (dark && typeof dark === 'object') {
|
||||
const darkColors = Object.fromEntries(
|
||||
Object.entries(dark as Record<string, unknown>).filter(
|
||||
([, v]) => typeof v === 'string'
|
||||
)
|
||||
) as Record<string, string>
|
||||
if (Object.keys(darkColors).length > 0) customColorsDark = darkColors
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Electron] Failed to parse branding.json:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const logoPath = path.join(brandingDir, 'logo.png')
|
||||
if (fs.existsSync(logoPath)) {
|
||||
try {
|
||||
const buf = fs.readFileSync(logoPath)
|
||||
logoDataUrl = `data:image/png;base64,${buf.toString('base64')}`
|
||||
} catch (e) {
|
||||
console.warn('[Electron] Failed to read logo.png:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
title === null &&
|
||||
theme === null &&
|
||||
!customColors &&
|
||||
!customColorsDark &&
|
||||
logoDataUrl === null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { title, theme, customColors, customColorsDark, logoDataUrl }
|
||||
}
|
||||
|
||||
// Determine if we're in development
|
||||
const isDev =
|
||||
process.env.ELECTRON_FORCE_PROD !== '1' &&
|
||||
|
|
@ -165,6 +248,7 @@ ipcMain.handle('watchdog:pong', () => {
|
|||
missedPongs = 0
|
||||
})
|
||||
|
||||
// pragma: allowlist secret start
|
||||
/**
|
||||
* Get runtime configuration from environment variables
|
||||
* This allows configuration to be set at runtime (not baked in at build time)
|
||||
|
|
@ -172,6 +256,7 @@ ipcMain.handle('watchdog:pong', () => {
|
|||
* SECURITY: Secrets (private key, admin token) are NOT included here.
|
||||
* Use 'get-atm-secrets' for secrets — it's a one-shot handler.
|
||||
*/
|
||||
// pragma: allowlist secret end
|
||||
ipcMain.handle('get-config', () => {
|
||||
return {
|
||||
// LNbits nostr-transport connection (public info only)
|
||||
|
|
@ -199,9 +284,13 @@ ipcMain.handle('get-config', () => {
|
|||
// Fee rates — accepts percentage (5.55) or decimal (0.0555), auto-detected
|
||||
cashInFeeRate: parseFee(process.env.VITE_CASH_IN_FEE || '0.0333'),
|
||||
cashOutFeeRate: parseFee(process.env.VITE_CASH_OUT_FEE || '0.0777'),
|
||||
|
||||
// Operator branding (logo/title/theme) — null when no override
|
||||
branding: loadBranding(),
|
||||
}
|
||||
})
|
||||
|
||||
// pragma: allowlist secret start
|
||||
/**
|
||||
* One-shot secrets handler.
|
||||
*
|
||||
|
|
@ -215,6 +304,7 @@ ipcMain.handle('get-config', () => {
|
|||
* TODO: Move signing/encryption to main process entirely (Phase 2)
|
||||
* so the private key never crosses the IPC boundary.
|
||||
*/
|
||||
// pragma: allowlist secret end
|
||||
let secretsConsumed = false
|
||||
ipcMain.handle('get-atm-secrets', () => {
|
||||
if (secretsConsumed) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue