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() 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 // Determine if we're in development
const isDev = const isDev =
process.env.ELECTRON_FORCE_PROD !== '1' && process.env.ELECTRON_FORCE_PROD !== '1' &&
@ -165,6 +248,7 @@ ipcMain.handle('watchdog:pong', () => {
missedPongs = 0 missedPongs = 0
}) })
// pragma: allowlist secret start
/** /**
* Get runtime configuration from environment variables * Get runtime configuration from environment variables
* This allows configuration to be set at runtime (not baked in at build time) * 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. * SECURITY: Secrets (private key, admin token) are NOT included here.
* Use 'get-atm-secrets' for secrets — it's a one-shot handler. * Use 'get-atm-secrets' for secrets — it's a one-shot handler.
*/ */
// pragma: allowlist secret end
ipcMain.handle('get-config', () => { ipcMain.handle('get-config', () => {
return { return {
// LNbits nostr-transport connection (public info only) // 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 // Fee rates — accepts percentage (5.55) or decimal (0.0555), auto-detected
cashInFeeRate: parseFee(process.env.VITE_CASH_IN_FEE || '0.0333'), cashInFeeRate: parseFee(process.env.VITE_CASH_IN_FEE || '0.0333'),
cashOutFeeRate: parseFee(process.env.VITE_CASH_OUT_FEE || '0.0777'), 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. * One-shot secrets handler.
* *
@ -215,6 +304,7 @@ ipcMain.handle('get-config', () => {
* TODO: Move signing/encryption to main process entirely (Phase 2) * TODO: Move signing/encryption to main process entirely (Phase 2)
* so the private key never crosses the IPC boundary. * so the private key never crosses the IPC boundary.
*/ */
// pragma: allowlist secret end
let secretsConsumed = false let secretsConsumed = false
ipcMain.handle('get-atm-secrets', () => { ipcMain.handle('get-atm-secrets', () => {
if (secretsConsumed) { if (secretsConsumed) {

View file

@ -32,6 +32,15 @@ export interface RuntimeConfig {
allowMockFallback: boolean allowMockFallback: boolean
operatorPubkeys: string operatorPubkeys: string
maintenanceMode: boolean 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 { useRoute } from 'vue-router'
import { useAtmStore } from '@/stores/atm' import { useAtmStore } from '@/stores/atm'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import { setBranding } from '@/composables/useBranding'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Sun, Moon } from 'lucide-vue-next' import { Sun, Moon } from 'lucide-vue-next'
@ -38,6 +39,11 @@ onMounted(async () => {
// Check maintenance mode before initializing anything // Check maintenance mode before initializing anything
const config = isElectron ? await window.electronAPI?.getConfig() : null 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 = const maintenanceMode =
config?.maintenanceMode || import.meta.env.VITE_MAINTENANCE_MODE === 'true' 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) { function applyColorMode(mode: ColorMode) {
const el = document.documentElement const el = document.documentElement
if (mode === 'system') { if (mode === 'system') {

View file

@ -23,6 +23,21 @@ export interface RuntimeConfig {
maintenanceMode: boolean maintenanceMode: boolean
cashInFeeRate: number cashInFeeRate: number
cashOutFeeRate: 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 { export interface AtmSecrets {

View file

@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useAtmStore } from '@/stores/atm' import { useAtmStore } from '@/stores/atm'
import { useBranding } from '@/composables/useBranding'
import { initialContext } from '@bitSpire/state-machine' import { initialContext } from '@bitSpire/state-machine'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -11,6 +12,7 @@ import QRCode from '@/components/QRCode.vue'
const router = useRouter() const router = useRouter()
const atmStore = useAtmStore() const atmStore = useAtmStore()
const { logoUrl, title: brandTitle } = useBranding()
const lndconnectUrl = import.meta.env.VITE_LNDCONNECT_URL || '' const lndconnectUrl = import.meta.env.VITE_LNDCONNECT_URL || ''
const showZeusQR = ref(false) const showZeusQR = ref(false)
const showLpQR = 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]" 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 <img
src="/logo.png" :src="logoUrl"
alt="Lamassu" alt="bitSpire"
class="h-20 w-20 lg:h-[12vh] lg:w-[12vh] drop-shadow-[0_0_24px_var(--primary)]" class="h-20 w-20 lg:h-[12vh] lg:w-[12vh] drop-shadow-[0_0_24px_var(--primary)]"
:class="{ 'animate-float': atmStore.machineModel !== 'sintra' }" :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"> <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" <Badge variant="outline" class="border-success text-success text-[11px] lg:text-lg"
>No KYC</Badge >No KYC</Badge

View file

@ -157,6 +157,7 @@ Production ATMs on `main` continue to read `main`'s flake (no `?ref=` pin → re
| `/var/lib/bitspire/.env` | bitspire:bitspire, 0600 | Runtime config — `VITE_RELAY_URL`, `VITE_LNBITS_SERVER_PUBKEY`, `VITE_LNBITS_HTTP_URL`, `VITE_ATM_PRIVATE_KEY`, … | | `/var/lib/bitspire/.env` | bitspire:bitspire, 0600 | Runtime config — `VITE_RELAY_URL`, `VITE_LNBITS_SERVER_PUBKEY`, `VITE_LNBITS_HTTP_URL`, `VITE_ATM_PRIVATE_KEY`, … |
| `/var/lib/bitspire/state.db` | bitspire:bitspire | SQLite — cassette inventory, cashbox state, transaction history | | `/var/lib/bitspire/state.db` | bitspire:bitspire | SQLite — cassette inventory, cashbox state, transaction history |
| `/var/lib/bitspire/logs/` | bitspire:bitspire, 0750 | Service logs (if app writes them) | | `/var/lib/bitspire/logs/` | bitspire:bitspire, 0750 | Service logs (if app writes them) |
| `/var/lib/bitspire/branding/` | bitspire:bitspire, 0755 | Operator branding override (logo.png + branding.json) — see issue #47 |
| `/opt/bitspire/` | bitspire:bitspire | Optional override drop for app assets (mostly unused — app comes from `/nix/store`) | | `/opt/bitspire/` | bitspire:bitspire | Optional override drop for app assets (mostly unused — app comes from `/nix/store`) |
| `/etc/bitspire/config.env` | root:root | Static config emitted by the NixOS module (RELAY_URL, LNBITS_HTTP_URL — informational; the renderer reads `/var/lib/bitspire/.env` instead) | | `/etc/bitspire/config.env` | root:root | Static config emitted by the NixOS module (RELAY_URL, LNBITS_HTTP_URL — informational; the renderer reads `/var/lib/bitspire/.env` instead) |

View file

@ -119,6 +119,8 @@ in
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0750 bitspire bitspire -" "d ${cfg.dataDir} 0750 bitspire bitspire -"
"d ${cfg.dataDir}/logs 0750 bitspire bitspire -" "d ${cfg.dataDir}/logs 0750 bitspire bitspire -"
# Operator branding override target (issue #47); empty by default
"d ${cfg.dataDir}/branding 0755 bitspire bitspire -"
]; ];
# Environment file for ATM configuration # Environment file for ATM configuration

View file

@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Provision operator branding (logo + branding.json) to a deployed bitSpire ATM.
# Composes with provision-atm.sh — that one wires LNbits creds, this one wires
# operator look-and-feel. Per issue #47.
#
# Usage:
# bash provision-branding.sh <branding-dir> # SSH to localhost:2222 (QEMU)
# bash provision-branding.sh <branding-dir> 192.168.1.50 # SSH to a real ATM on the LAN
# bash provision-branding.sh <branding-dir> 192.168.1.50 22 # custom SSH port
#
# Expected files in <branding-dir>:
# logo.png — operator logo (any reasonable size; rendered ~12vh)
# branding.json — { title, theme, custom_colors{...}, custom_colors.dark{...} }
#
# Either file is optional; the on-disk schema in /var/lib/bitspire/branding/
# matches whatever <branding-dir> contains (rsync --delete).
set -euo pipefail
BRANDING_DIR="${1:-}"
ATM_HOST="${2:-localhost}"
ATM_SSH_PORT="${3:-2222}"
ATM_USER="bitspire"
REMOTE_DIR="/var/lib/bitspire/branding"
if [ -z "$BRANDING_DIR" ]; then
echo "Usage: $0 <branding-dir> [host] [port]" >&2
echo " $0 ./acme-brand (QEMU on localhost:2222)" >&2
echo " $0 ./acme-brand 192.168.1.50 (real ATM)" >&2
exit 1
fi
if [ ! -d "$BRANDING_DIR" ]; then
echo "ERROR: branding directory not found: $BRANDING_DIR" >&2
exit 1
fi
echo "=== Provisioning branding to $ATM_HOST:$ATM_SSH_PORT ==="
echo "Local dir : $BRANDING_DIR"
echo "Remote dir: $REMOTE_DIR"
echo ""
# Show what we're about to push so the operator can sanity-check before sync.
ls -la "$BRANDING_DIR"
echo ""
# rsync over SSH. --delete to clean stale files (a removed logo.png should
# disappear, not linger). sudo on the remote side because /var/lib/bitspire
# is owned by the bitspire service user, not the SSH user.
rsync -avz --delete \
--rsync-path="sudo rsync" \
-e "ssh -o StrictHostKeyChecking=no -p $ATM_SSH_PORT" \
"$BRANDING_DIR/" \
"$ATM_USER@$ATM_HOST:$REMOTE_DIR/"
# Restart the service so loadBranding() re-runs and the renderer picks up the
# new files. V2 (Nostr-event source) will eliminate the restart need.
ssh -o StrictHostKeyChecking=no -p "$ATM_SSH_PORT" "$ATM_USER@$ATM_HOST" \
"sudo systemctl restart bitspire"
echo ""
echo "=== Branding provisioned. Service restarted. ==="