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:
Padreug 2026-05-25 08:03:32 +02:00
commit c3353c409b
10 changed files with 268 additions and 3 deletions

View file

@ -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) {

View file

@ -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<string, string>
customColorsDark?: Record<string, string>
logoDataUrl: string | null
}
/**

View file

@ -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'

View file

@ -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<string>(DEFAULT_LOGO)
const title = ref<string>(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<typeof applyBrandingTheme>[0],
b.customColors,
b.customColorsDark
)
}
export function useBranding() {
return { logoUrl, title }
}

View file

@ -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 <style> block setting the supplied CSS vars on :root and .dark.
* Unset vars fall back to gruvbox defaults per issue #47.
*/
export function applyBrandingTheme(
theme: ThemeId | 'custom' | null,
customColors?: Record<string, string>,
customColorsDark?: Record<string, string>
) {
// Remove any prior injected custom theme — operator can switch to a
// built-in theme and the override should disappear without a reload.
const prior = document.getElementById(CUSTOM_THEME_STYLE_ID)
if (prior) prior.remove()
if (theme === 'custom') {
document.documentElement.removeAttribute('data-theme')
const styleEl = document.createElement('style')
styleEl.id = CUSTOM_THEME_STYLE_ID
const root = customColors ? formatVars(':root', customColors) : ''
const dark = customColorsDark ? formatVars(':root.dark', customColorsDark) : ''
styleEl.textContent = `${root}\n${dark}`.trim()
document.head.appendChild(styleEl)
current.value = 'gruvbox' // baseline beneath the custom overrides
return
}
if (theme && themes.some((t) => t.id === theme)) {
current.value = theme as ThemeId
}
}
function formatVars(selector: string, vars: Record<string, string>): string {
const decls = Object.entries(vars)
.map(([k, v]) => ` ${k}: ${v};`)
.join('\n')
return `${selector} {\n${decls}\n}`
}
function applyColorMode(mode: ColorMode) {
const el = document.documentElement
if (mode === 'system') {

View file

@ -23,6 +23,21 @@ export interface RuntimeConfig {
maintenanceMode: boolean
cashInFeeRate: number
cashOutFeeRate: number
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
branding: BrandingConfig | null
}
/** Operator branding config. Wire payload from Electron IPC; renderer applies via useBranding(). */
export interface BrandingConfig {
title: string | null
/** One of the 6 built-in theme ids, or 'custom'. Main process validates against allowlist. */
theme: string | null
/** When theme === 'custom': flat map of CSS-var → hex value (e.g. `'--primary': '#ff8019'`). */
customColors?: Record<string, string>
/** Optional dark-mode override map; same shape as customColors. */
customColorsDark?: Record<string, string>
/** Base64 data URL of operator-supplied logo.png. Null when no file present. */
logoDataUrl: string | null
}
export interface AtmSecrets {

View file

@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { nip19 } from 'nostr-tools'
import { useAtmStore } from '@/stores/atm'
import { useBranding } from '@/composables/useBranding'
import { initialContext } from '@bitSpire/state-machine'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@ -11,6 +12,7 @@ import QRCode from '@/components/QRCode.vue'
const router = useRouter()
const atmStore = useAtmStore()
const { logoUrl, title: brandTitle } = useBranding()
const lndconnectUrl = import.meta.env.VITE_LNDCONNECT_URL || ''
const showZeusQR = ref(false)
const showLpQR = ref(false)
@ -65,12 +67,12 @@ function handleCashOut() {
class="flex flex-col items-center justify-center gap-1.5 lg:gap-2 pt-6 lg:pt-[4vh] pb-2 lg:pb-[2vh]"
>
<img
src="/logo.png"
alt="Lamassu"
:src="logoUrl"
alt="bitSpire"
class="h-20 w-20 lg:h-[12vh] lg:w-[12vh] drop-shadow-[0_0_24px_var(--primary)]"
:class="{ 'animate-float': atmStore.machineModel !== 'sintra' }"
/>
<h1 class="text-3xl lg:text-[5vh] font-bold text-primary leading-tight">Bitcoinmat</h1>
<h1 class="text-3xl lg:text-[5vh] font-bold text-primary leading-tight">{{ brandTitle }}</h1>
<div class="flex flex-wrap items-center justify-center gap-2 lg:gap-3">
<Badge variant="outline" class="border-success text-success text-[11px] lg:text-lg"
>No KYC</Badge