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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
37
apps/machine/src/composables/useBranding.ts
Normal file
37
apps/machine/src/composables/useBranding.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
15
apps/machine/src/types/electron.d.ts
vendored
15
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/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/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`) |
|
||||
| `/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) |
|
||||
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@ in
|
|||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.dataDir} 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
|
||||
|
|
|
|||
61
deploy/nixos/provision-branding.sh
Executable file
61
deploy/nixos/provision-branding.sh
Executable 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. ==="
|
||||
Loading…
Add table
Add a link
Reference in a new issue