diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index e7c16b4..c154505 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -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 + customColorsDark?: Record + 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 | undefined + let customColorsDark: Record | 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 + const colors = Object.fromEntries( + Object.entries(flat).filter(([, v]) => typeof v === 'string') + ) as Record + if (Object.keys(colors).length > 0) customColors = colors + if (dark && typeof dark === 'object') { + const darkColors = Object.fromEntries( + Object.entries(dark as Record).filter( + ([, v]) => typeof v === 'string' + ) + ) as Record + 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) { diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index b88fe35..cdaf6ae 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -32,6 +32,15 @@ export interface RuntimeConfig { allowMockFallback: boolean operatorPubkeys: string maintenanceMode: boolean + branding: BrandingConfig | null +} + +export interface BrandingConfig { + title: string | null + theme: string | null + customColors?: Record + customColorsDark?: Record + logoDataUrl: string | null } /** diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index 5d6ea9e..df0d352 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref, computed } from 'vue' import { useRoute } from 'vue-router' import { useAtmStore } from '@/stores/atm' import { useTheme } from '@/composables/useTheme' +import { setBranding } from '@/composables/useBranding' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Sun, Moon } from 'lucide-vue-next' @@ -38,6 +39,11 @@ onMounted(async () => { // Check maintenance mode before initializing anything const config = isElectron ? await window.electronAPI?.getConfig() : null + + // Apply operator branding before maintenance screen renders, so the + // "Under Service" page already wears the operator's logo/title/theme. + setBranding(config?.branding ?? null) + const maintenanceMode = config?.maintenanceMode || import.meta.env.VITE_MAINTENANCE_MODE === 'true' diff --git a/apps/machine/src/composables/useBranding.ts b/apps/machine/src/composables/useBranding.ts new file mode 100644 index 0000000..5e48136 --- /dev/null +++ b/apps/machine/src/composables/useBranding.ts @@ -0,0 +1,37 @@ +import { ref } from 'vue' +import type { BrandingConfig } from '@/types/electron' +import { applyBrandingTheme } from './useTheme' + +// Reactive branding state — sourced from a BrandingSource (V1: local file +// via Electron IPC; V2 per issue #48: replaceable Nostr event from +// satmachineadmin, which wins when both are present). +const DEFAULT_LOGO = '/logo.png' +const DEFAULT_TITLE = 'Bitcoinmat' + +const logoUrl = ref(DEFAULT_LOGO) +const title = ref(DEFAULT_TITLE) + +/** + * Apply a branding payload to the live UI. Pass null to revert to defaults. + * Single mutation point — V2's Nostr-event source calls the same setter when + * a replaceable event arrives, so live updates work without a reload. + */ +export function setBranding(b: BrandingConfig | null) { + if (!b) { + logoUrl.value = DEFAULT_LOGO + title.value = DEFAULT_TITLE + applyBrandingTheme(null) + return + } + logoUrl.value = b.logoDataUrl || DEFAULT_LOGO + title.value = b.title || DEFAULT_TITLE + applyBrandingTheme( + b.theme as Parameters[0], + b.customColors, + b.customColorsDark + ) +} + +export function useBranding() { + return { logoUrl, title } +} diff --git a/apps/machine/src/composables/useTheme.ts b/apps/machine/src/composables/useTheme.ts index cf02764..2735a94 100644 --- a/apps/machine/src/composables/useTheme.ts +++ b/apps/machine/src/composables/useTheme.ts @@ -58,6 +58,48 @@ function applyTheme(id: ThemeId) { } } +const CUSTOM_THEME_STYLE_ID = 'branding-custom-theme' + +/** + * Apply operator-supplied branding theme. Built-in id → swap data-theme. + * 'custom' → strip data-theme (fall through to gruvbox baseline) and + * inject a