bitspire/apps/machine/electron/main.ts
Padreug c889f7f0df feat(cassettes): consume operator operations instead of counts
The machine now owns its bay counts outright. The operator publishes what
it did — a refill in notes added, an empty, a recount, a denomination
change — and this process applies it to the total it already holds.

Both sides used to write the same value over a transport that never tells
a writer it lost. Addressable events order by created_at at second
granularity with ties broken on event id, and a relay returns OK for an
event it then discards, so a dashboard form loaded before a dispense
silently discarded that dispense and neither side could detect it. A
value with one writer cannot be clobbered.

Schema v13 adds cassette_ops, the dedup ledger. A delta applied twice is
wrong and addressable events are re-delivered on every reconnect, so the
operator mints an id per operation and this table records the ones
applied. That also retires the created_at watermark on this path: it was
the only replay defence under absolute counts, but it drops an
out-of-order event whole, operations included, where per-op ids let the
unseen ones through and no-op the rest.

A window is applied oldest-first by `at`, ties broken by id, in one
transaction with the count mutation. A recount then a refill is not the
same as the reverse, and a crash mid-apply must roll back to a coherent
count rather than a partial one.

A malformed op or one naming a bay this machine does not have is neither
applied nor recorded, so it stays pending on the operator's dashboard.
That is the honest outcome. Recording it as applied would stop the noise
by telling the operator their refill landed.

The state document gains applied_ops, seq and schema_version. applied_ops
is the acknowledgement leg — echoing the ids back is the only way the
operator can tell an operation that landed from one merely sent. seq is
bumped on every local count change from any cause, so a reader can reject
a regression without trusting either clock.
2026-09-23 12:55:52 +02:00

993 lines
36 KiB
TypeScript

/**
* Electron Main Process
*
* Creates the ATM kiosk window and handles IPC with the renderer.
* In production, runs in kiosk mode (fullscreen, no decorations).
* HAL hardware drivers run here (Node.js environment).
*/
import { app, BrowserWindow, ipcMain, session } from 'electron'
import path from 'node:path'
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import {
initDatabase,
closeDatabase,
loadCassettes,
setCassettes,
getInventory,
recordTransaction,
remediateTransaction,
emptyCashbox,
getCashbox,
getPendingCommand,
markCommandExecuting,
completeCommand,
getLastKnownConfigCreatedAt,
getCountsUncertainSince,
getLastStatePublishedAt,
markCountsUncertain,
markStatePublished,
resetStatePublishWatermark,
resetForRepair,
applyOperatorCassetteOps,
getAppliedOpIds,
getCassetteStateSeq,
getFeeConfig,
getLastKnownFeeConfigCreatedAt,
applyFeeConfig,
getBunkerBinding,
saveBunkerBinding,
clearBunkerBinding,
type CassetteOp,
type ApplyOpsResult,
type FeeConfigPayload,
type FeeConfigRow,
type ApplyResult,
type StoredBunkerBinding,
} from './state-store.js'
import { initializeHal, type HalInstance } from './hal-service.js'
import {
executeLnurlWithdraw,
executeWithdrawCallback,
type WithdrawStep,
} from './lnurl-withdraw.js'
import { resolveCardInvoice, resolveInvoiceFromPayStep, type PayStep } from './lnurl-pay.js'
import { openCardSession, type OpenCardSessionResult } from './boltcard-session.js'
import { startNfcReader, type NfcStatus } from './nfc-service.js'
// ESM equivalent of __dirname
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// Load .env file manually (Electron main process doesn't have Vite's env loading)
function loadEnvFile() {
const envPath = path.join(__dirname, '..', '.env')
try {
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf-8')
for (const line of envContent.split('\n')) {
const trimmed = line.trim()
if (trimmed && !trimmed.startsWith('#')) {
const [key, ...valueParts] = trimmed.split('=')
if (key && valueParts.length > 0) {
process.env[key] = valueParts.join('=')
}
}
}
console.log('[Electron] Loaded .env file from:', envPath)
}
} catch (e) {
console.warn('[Electron] Failed to load .env file:', e)
}
}
loadEnvFile()
// Branding loader — reads /var/lib/bitspire/branding/{logo.png,
// logo-dark.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
/** Optional dark-mode logo variant. Renderer falls back to logoDataUrl
* when null. */
logoDarkDataUrl: string | null
}
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
let logoDarkDataUrl: 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
// No theme-name validation here: the renderer's `themes` list (plus its
// 'custom' branch) is the single source of truth. Pass the string through
// and let useTheme's applyBrandingTheme ignore anything it doesn't know.
if (typeof raw.theme === 'string') 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)
}
}
const logoDarkPath = path.join(brandingDir, 'logo-dark.png')
if (fs.existsSync(logoDarkPath)) {
try {
const buf = fs.readFileSync(logoDarkPath)
logoDarkDataUrl = `data:image/png;base64,${buf.toString('base64')}`
} catch (e) {
console.warn('[Electron] Failed to read logo-dark.png:', e)
}
}
if (
title === null &&
theme === null &&
!customColors &&
!customColorsDark &&
logoDataUrl === null &&
logoDarkDataUrl === null
) {
return null
}
return { title, theme, customColors, customColorsDark, logoDataUrl, logoDarkDataUrl }
}
// Access-control config loader (ADR-003). Env toggles the gate; an optional
// /var/lib/bitspire/access.json carries the salt + allow-list. Defaults OFF —
// a machine with neither env nor file behaves as if there is no access layer.
// The allow-list shape mirrors the renderer's AllowListEntry (authorize.ts);
// duplicated here to avoid a cross-project (electron↔renderer) import.
interface AccessAllowListEntry {
idHash: string
role: 'user' | 'operator'
pinHash?: string
label?: string
}
function loadAccessControl() {
// Env provides defaults; access.json (writable, operator-provisioned — same
// spirit as branding/) overrides them, so the gate can be toggled on a
// deployed machine by dropping a file + restarting the service, with no image
// rebuild. Defaults OFF.
let enabled = process.env.ACCESS_CONTROL_ENABLED === 'true'
// Dev unlock is OFF unless explicitly enabled: a gated machine must not ship
// a visible bypass button by default.
let devUnlock = process.env.ACCESS_DEV_UNLOCK === 'true'
let openEnrollment = process.env.ACCESS_OPEN_ENROLLMENT === 'true'
let salt = process.env.ACCESS_SALT || ''
let allowList: AccessAllowListEntry[] = []
const jsonPath = path.join(
fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(),
'access.json'
)
if (fs.existsSync(jsonPath)) {
try {
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
if (typeof raw.enabled === 'boolean') enabled = raw.enabled
if (typeof raw.devUnlock === 'boolean') devUnlock = raw.devUnlock
if (typeof raw.openEnrollment === 'boolean') openEnrollment = raw.openEnrollment
if (typeof raw.salt === 'string' && raw.salt) salt = raw.salt
if (Array.isArray(raw.allowList)) {
allowList = (raw.allowList as unknown[]).filter(
(e): e is AccessAllowListEntry =>
!!e &&
typeof (e as AccessAllowListEntry).idHash === 'string' &&
((e as AccessAllowListEntry).role === 'user' ||
(e as AccessAllowListEntry).role === 'operator')
)
}
} catch (e) {
console.warn('[Electron] Failed to parse access.json:', e)
}
}
// A gated machine needs a stable salt for deterministic hashing. Fall back to
// a fixed default (prototype); production should provision a real salt.
if (!salt) salt = 'bitspire-access-v1'
return { enabled, devUnlock, openEnrollment, salt, allowList }
}
// Determine if we're in development
const isDev =
process.env.ELECTRON_FORCE_PROD !== '1' &&
(process.env.NODE_ENV === 'development' || !app.isPackaged)
// Window dimensions (landscape ATM display)
const WINDOW_WIDTH = 1920
const WINDOW_HEIGHT = 1080
let mainWindow: BrowserWindow | null = null
function createWindow() {
mainWindow = new BrowserWindow({
width: WINDOW_WIDTH,
height: WINDOW_HEIGHT,
resizable: isDev, // Allow resize in dev for debugging
frame: isDev, // Show window frame in dev
kiosk: !isDev, // Kiosk mode in production
fullscreen: !isDev, // Ensure fullscreen even if kiosk fails
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
})
// Load the app
if (isDev) {
// Development: load from Vite dev server
const devServerUrl = process.env.VITE_DEV_SERVER_URL || 'http://localhost:1420'
mainWindow.loadURL(devServerUrl)
mainWindow.webContents.openDevTools()
} else {
// Production: load from built files
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'))
}
mainWindow.on('closed', () => {
mainWindow = null
})
}
// =============================================================================
// Renderer Watchdog
// Detects renderer crashes, unresponsiveness, and JS-level death.
// Reloads the renderer (preserving HAL state in main process).
// Three layers: render-process-gone (instant), unresponsive (Chromium), heartbeat (JS-level).
// =============================================================================
function reloadRenderer() {
if (!mainWindow) return
console.log('[Watchdog] Reloading renderer...')
secretsConsumed = false // allow re-init after reload
if (isDev) {
const devServerUrl = process.env.VITE_DEV_SERVER_URL || 'http://localhost:1420'
mainWindow.loadURL(devServerUrl)
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'))
}
}
let missedPongs = 0
let heartbeatInterval: ReturnType<typeof setInterval> | null = null
function startWatchdog() {
if (!mainWindow) return
// Layer 1: Chromium renderer process crashed or was killed
mainWindow.webContents.on('render-process-gone', (_event, details) => {
console.error('[Watchdog] Renderer gone:', details.reason)
missedPongs = 0
setTimeout(() => reloadRenderer(), 1000)
})
// Layer 2: Renderer stopped processing events (Chromium-detected)
mainWindow.webContents.on('unresponsive', () => {
console.error('[Watchdog] Renderer unresponsive, reloading...')
missedPongs = 0
reloadRenderer()
})
// Layer 3: IPC heartbeat — catches dead renderer JS while Chromium lives
heartbeatInterval = setInterval(() => {
if (!mainWindow) return
if (missedPongs >= 2) {
console.error('[Watchdog] Heartbeat: 2 pings unanswered, reloading...')
missedPongs = 0
reloadRenderer()
return
}
missedPongs++
mainWindow.webContents.send('watchdog:ping')
}, 30_000)
}
// IPC Handlers
ipcMain.handle('get-version', () => {
return app.getVersion()
})
// Watchdog pong: renderer confirms it's alive
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)
*
* 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). Empty when
// unprovisioned — the renderer then falls through to the pairing seed's
// relay (aiolabs/bitspire#70). A non-empty default here would win via the
// env-first precedence and override the seed.
relayUrl: process.env.VITE_RELAY_URL || '',
lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '',
appId: process.env.VITE_APP_ID || '',
// Hardware configuration
machineModel: process.env.VITE_LAMASSU_MACHINE_MODEL || 'sintra',
fiatCode: process.env.VITE_LAMASSU_FIAT_CODE || 'USD',
validatorDevice: process.env.VITE_LAMASSU_VALIDATOR_DEVICE,
dispenserDevice: process.env.VITE_LAMASSU_DISPENSER_DEVICE,
cassettes: process.env.VITE_LAMASSU_CASSETTES,
// SECURITY: In production (packaged app), mock fallback is always disabled.
// Only allow it in development mode, and only when explicitly opted in via env.
allowMockFallback: isDev && process.env.VITE_ALLOW_MOCK_FALLBACK === 'true',
// Operator identity (comma-separated hex pubkeys)
operatorPubkeys: process.env.VITE_OPERATOR_PUBKEYS || '',
// Maintenance mode — show "out of service" screen
maintenanceMode: process.env.VITE_MAINTENANCE_MODE === 'true',
// Fee rates — operator-pushed via Nostr (kind-30078 `bitspire-fees:<atm_pubkey>`)
// from satmachineadmin; see aiolabs/lamassu-next#57. No env-var fallback —
// first boot without a persisted fee config (and no inbound event) shows
// a maintenance screen until the operator publishes initial config.
// Operator branding (logo/title/theme) — null when no override
branding: loadBranding(),
// Access-control gate (ADR-003) — `enabled` defaults false (no gate).
accessControl: loadAccessControl(),
}
})
// pragma: allowlist secret start
/**
* One-shot secrets handler.
*
* Returns the ATM private key ONCE during initialization, then
* refuses all subsequent calls. This limits the window for XSS or
* compromised dependencies to steal secrets via IPC.
*
* (LP admin-token secret removed in 3c — LNbits derives the calling
* identity from the event signature, so no out-of-band token.)
*
* 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) {
console.warn('[Electron] SECURITY: get-atm-secrets called after secrets already consumed')
return { spireSeed: '', bunkerBinding: null }
}
secretsConsumed = true
// The spire pairing seed (one-shot connect token inside) + the persisted
// bunker binding (transport key). The renderer resolves these into a
// BunkerSigner; see services/signer-resolver.ts (aiolabs/bitspire#52).
return {
spireSeed: process.env.VITE_SPIRE_SEED || '',
bunkerBinding: getBunkerBinding(),
}
})
// Bunker binding persistence — the renderer writes the binding after a
// successful pairing (connectNewSeed), and resets the publish watermark so the
// new operator receives the spire's hello-event (aiolabs/bitspire#52 / #56).
ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBinding): void => {
saveBunkerBinding(binding)
})
ipcMain.handle('state:clear-bunker-binding', (): void => {
clearBunkerBinding()
})
ipcMain.handle('state:reset-state-publish-watermark', (): void => {
resetStatePublishWatermark()
})
ipcMain.handle('state:reset-for-repair', (): void => {
resetForRepair()
})
// QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a
// spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the
// runtime .env so the next boot's signer-resolver redeems it (connectNewSeed)
// exactly as if it had been provisioned. We deliberately do NOT pair here —
// persisting + relaunching reuses the single, tested pairing path rather than
// duplicating it in the renderer.
function runtimeEnvPath(): string {
const base = fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd()
return path.join(base, '.env')
}
ipcMain.handle('state:save-spire-seed', (_event, seed: string): void => {
const trimmed = (seed || '').trim()
if (!trimmed) throw new Error('save-spire-seed: empty seed')
const envPath = runtimeEnvPath()
const line = `VITE_SPIRE_SEED=${trimmed}`
let lines: string[] = []
if (fs.existsSync(envPath)) {
lines = fs.readFileSync(envPath, 'utf8').split('\n')
}
const idx = lines.findIndex((l) => l.startsWith('VITE_SPIRE_SEED='))
if (idx >= 0) {
lines[idx] = line
} else {
// Drop a trailing empty element so we don't accumulate blank lines.
if (lines.length && lines[lines.length - 1] === '') lines.pop()
lines.push(line)
}
fs.writeFileSync(envPath, lines.join('\n') + '\n', { mode: 0o600 })
// Keep this process's view in sync so get-atm-secrets reflects the new seed
// even before relaunch (belt-and-suspenders; relaunch re-reads from disk).
process.env.VITE_SPIRE_SEED = trimmed
console.log('[Pairing] Spire seed persisted to', envPath)
})
// Relaunch the kiosk so the new seed is picked up by a clean boot. Under
// systemd (bitspire.service) the exit triggers an automatic restart; in dev
// Electron's relaunch re-spawns the process.
ipcMain.handle('app:relaunch', (): void => {
console.log('[Pairing] Relaunching to apply new pairing')
app.relaunch()
app.exit(0)
})
// Connectivity recovery: reload the renderer to re-run init from a clean slate
// (fresh JS context → no leaked actors/subscriptions), while preserving HAL in
// this main process (reloadRenderer resets secretsConsumed so get-atm-secrets
// works again, and hal:init is idempotent). The renderer calls this when it's
// stuck on a connectivity-type "ATM Unavailable" and the network returns, or
// when the operator taps the on-screen Retry (ADR-002 amendment 2026-08-04).
ipcMain.handle('app:recover', (): void => {
console.log('[Recovery] Reloading renderer to re-attempt initialization')
reloadRenderer()
})
// Bolt Card cash-out: pull payment for the current invoice from a tapped card
// via LNURL-withdraw. Runs in the main process (Node fetch) to dodge renderer
// CORS. Returns once the card accepts; settlement arrives via the invoice
// watcher. See lnurl-withdraw.ts.
ipcMain.handle(
'lnurl:withdraw',
async (
_event,
args: { lnurlw: string; bolt11: string; amountMsat?: number }
): Promise<{ ok: boolean; reason?: string }> => {
return executeLnurlWithdraw(args.lnurlw, args.bolt11, { amountMsat: args.amountMsat })
}
)
// Bolt Card cash-in (receive): resolve a tapped card + payout amount to a
// BOLT11 on the card wallet, which the renderer then pays over the nostr
// transport (stores/atm.ts payInvoice). HTTPS to the card host runs here in the
// main process to dodge renderer CORS. See lnurl-pay.ts.
ipcMain.handle(
'lnurl:pay-card',
async (
_event,
args: { lnurlw: string; amountMsat: number }
): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => {
return resolveCardInvoice(args.lnurlw, args.amountMsat)
}
)
// Bolt Card tap-to-enter (ADR-003): open a verified session for a tapped card.
// Spends the tap's SUN once and returns balance + fiat + the withdraw/pay
// second steps the session reuses at Complete. See boltcard-session.ts.
ipcMain.handle(
'lnurl:open-card-session',
async (_event, args: { lnurlw: string }): Promise<OpenCardSessionResult> => {
return openCardSession(args.lnurlw)
}
)
// Session variants of the two Complete paths: no tap, no p/c — just the
// hit-keyed second step the session already holds.
ipcMain.handle(
'lnurl:withdraw-session',
async (
_event,
args: { withdraw: WithdrawStep; bolt11: string; amountMsat?: number }
): Promise<{ ok: boolean; reason?: string }> => {
return executeWithdrawCallback(args.withdraw, args.bolt11, { amountMsat: args.amountMsat })
}
)
ipcMain.handle(
'lnurl:pay-session',
async (
_event,
args: { pay: PayStep; amountMsat: number }
): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => {
return resolveInvoiceFromPayStep(args.pay, args.amountMsat)
}
)
// State persistence IPC handlers
ipcMain.handle('state:load-cassettes', () => loadCassettes())
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
ipcMain.handle('state:get-inventory', () => getInventory())
ipcMain.handle('state:get-cashbox', () => getCashbox())
ipcMain.handle('state:record-transaction', (_event, tx) => recordTransaction(tx))
ipcMain.handle('state:empty-cashbox', () => emptyCashbox())
ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedByTxid: string) =>
remediateTransaction(txid, remediatedByTxid)
)
// Operator-config consumer (aiolabs/lamassu-next#56) — meta watermark
// + atomic apply for kind-30078 cassette-config events
ipcMain.handle('state:get-last-known-config-created-at', (): number =>
getLastKnownConfigCreatedAt()
)
ipcMain.handle('state:get-last-state-published-at', (): number | null => getLastStatePublishedAt())
ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCountsUncertainSince())
ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => {
markCountsUncertain(unixTimestamp)
})
ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => {
markStatePublished(unixTimestamp)
})
ipcMain.handle(
'state:apply-operator-cassette-ops',
(_event, ops: CassetteOp[]): ApplyOpsResult => applyOperatorCassetteOps(ops)
)
ipcMain.handle('state:get-applied-op-ids', (_event, limit?: number): string[] =>
getAppliedOpIds(limit)
)
ipcMain.handle('state:get-cassette-state-seq', (): number => getCassetteStateSeq())
// Operator-fees consumer (aiolabs/lamassu-next#57) — persisted singleton
// fee config + per-d-tag replay watermark + atomic apply for kind-30078
// `bitspire-fees:<atm_pubkey>` events. Independent from the cassette
// watermark/apply path per the d-tag-per-lifecycle convention.
ipcMain.handle('state:get-fee-config', (): FeeConfigRow | null => getFeeConfig())
ipcMain.handle('state:get-last-known-fee-config-created-at', (): number =>
getLastKnownFeeConfigCreatedAt()
)
ipcMain.handle(
'state:apply-fee-config',
(_event, payload: FeeConfigPayload, eventCreatedAt: number): ApplyResult =>
applyFeeConfig(payload, eventCreatedAt)
)
// Support pages — read .md files from /var/lib/bitspire/support/
ipcMain.handle('support:get-pages', () => {
const supportDir = path.join(
fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(),
'support'
)
if (!fs.existsSync(supportDir)) return []
const files = fs
.readdirSync(supportDir)
.filter((f) => f.endsWith('.md'))
.sort()
return files.map((file) => {
const content = fs.readFileSync(path.join(supportDir, file), 'utf-8')
const titleMatch = content.match(/^#\s+(.+)/m)
return {
id: file.replace('.md', ''),
title: titleMatch ? titleMatch[1] : file.replace('.md', ''),
content,
}
})
})
// =============================================================================
// HAL Hardware IPC Handlers
// HAL runs in the main process (Node.js) because it needs serialport.
// The renderer communicates via IPC for all hardware operations.
// =============================================================================
let halInstance: HalInstance | null = null
let pendingBillDenomination: number | null = null
ipcMain.handle('hal:init', async (_event, config) => {
try {
// Idempotent: HAL lives in this (long-lived) main process, but the renderer
// re-runs full init on every reload — the watchdog's crash-recovery reload
// and the connectivity-recovery reload (app:recover) both re-invoke this.
// initializeHal opens serial ports without closing prior handles, so
// re-entering it would double-open the validator/dispenser. Reuse the
// existing instance instead; its validator event wiring already targets the
// (reloaded) mainWindow, so the reloaded renderer keeps receiving bill events.
if (halInstance) {
console.log('[Electron] HAL already initialized — reusing existing instance')
return { success: true }
}
// Override cassette config with DB values (operator may have changed them via atm-tui
// or via an operator-config publish from satmachineadmin). Pass per-position so the
// HAL knows about every bay including duplicates of the same denomination — real
// machines load N cassettes of one denomination for cash-out throughput.
const dbCassettes = loadCassettes()
if (dbCassettes.length > 0) {
config.dispenser.cassettes = dbCassettes
.slice()
.sort((a, b) => a.position - b.position)
.map((c) => ({
position: c.position,
denomination: c.denomination,
count: c.count,
}))
console.log('[Electron] Using DB cassettes for HAL init:', config.dispenser.cassettes)
}
halInstance = await initializeHal(config)
// Wire validator events → forward to renderer via IPC
// Bills go to escrow first ('hold' mode); the renderer decides to
// stack or reject by calling hal:stack-bill or hal:reject-bill.
halInstance.connectValidator({
shouldAcceptBill: (_denomination: number) => {
// Hold in escrow — renderer decides asynchronously
return 'hold' as const
},
onBillRead: (denomination: number) => {
// Bill is in escrow, notify renderer to decide
pendingBillDenomination = denomination
mainWindow?.webContents.send('hal:bill-read', denomination)
},
onBillInserted: (denomination: number) => {
mainWindow?.webContents.send('hal:bill-inserted', denomination)
},
onBillRejected: (reason: string) => {
mainWindow?.webContents.send('hal:bill-rejected', reason)
},
onError: (error: string) => {
mainWindow?.webContents.send('hal:error', error)
},
})
console.log('[Electron] HAL initialized via IPC')
return { success: true }
} catch (error: any) {
console.error('[Electron] HAL init failed:', error)
return { success: false, error: error.message }
}
})
// Bug found with the aid of Seoyoung at Trece Cielos
ipcMain.handle(
'hal:dispense',
async (_event, amounts: { denomination: number; count: number }[]) => {
if (!halInstance) throw new Error('HAL not initialized')
// Validate input from renderer (untrusted)
if (!Array.isArray(amounts) || amounts.length === 0) {
throw new Error('Invalid dispense request: amounts must be a non-empty array')
}
const inventory = halInstance.getInventory()
for (const item of amounts) {
if (typeof item.denomination !== 'number' || typeof item.count !== 'number') {
throw new Error('Invalid dispense request: denomination and count must be numbers')
}
if (!Number.isInteger(item.count) || item.count <= 0) {
throw new Error(
`Invalid count for denomination ${item.denomination}: must be a positive integer`
)
}
if (!(item.denomination in inventory)) {
throw new Error(`No cassette loaded with denomination: ${item.denomination}`)
}
if (item.count > (inventory[item.denomination] ?? 0)) {
throw new Error(
`Insufficient bills for denomination ${item.denomination}: requested ${item.count}, available ${inventory[item.denomination] ?? 0}`
)
}
}
return await halInstance.dispenseCash(amounts)
}
)
ipcMain.handle('hal:wait-for-bills-removed', async () => {
// This is handled inside dispenseCash already
return true
})
ipcMain.handle('hal:enable-validator', () => {
if (halInstance) halInstance.enableValidator()
})
ipcMain.handle('hal:disable-validator', () => {
if (halInstance) halInstance.disableValidator()
})
ipcMain.handle('hal:stack-bill', () => {
if (!halInstance) return
if (pendingBillDenomination === null) {
console.warn('[Electron] hal:stack-bill called with no bill in escrow — ignoring')
return
}
pendingBillDenomination = null
// Credit is NOT sent here. hal-service fires onBillInserted (forwarded
// as 'hal:bill-inserted') only on the validator's `billsValid`
// stacked-confirmation — a stack command can still fail or return the
// bill (aiolabs/bitspire#58).
halInstance.stackBill()
})
ipcMain.handle('hal:reject-bill', () => {
if (!halInstance) return
if (pendingBillDenomination === null) {
console.warn('[Electron] hal:reject-bill called with no bill in escrow — ignoring')
return
}
pendingBillDenomination = null
halInstance.rejectBill()
})
ipcMain.handle('hal:get-inventory', () => {
if (!halInstance) return {}
return halInstance.getInventory()
})
ipcMain.handle(
'hal:reload-cassettes',
async (
_event,
cassettes: { position: number; denomination: number; count?: number }[]
): Promise<{ ok: boolean; error?: string }> => {
if (!halInstance) {
return { ok: false, error: 'HAL not initialized' }
}
try {
await halInstance.setCassettes(cassettes)
return { ok: true }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
console.error('[Electron] hal:reload-cassettes failed:', msg)
return { ok: false, error: msg }
}
}
)
ipcMain.handle('hal:cleanup', async () => {
if (halInstance) {
await halInstance.cleanup()
halInstance = null
}
})
// =============================================================================
// Operator Command Queue Poller
// Watches for pending commands inserted by the TUI or other local tools.
// =============================================================================
let commandPollInterval: ReturnType<typeof setInterval> | null = null
function startCommandPoller(): void {
commandPollInterval = setInterval(async () => {
try {
const cmd = getPendingCommand()
if (!cmd) return
markCommandExecuting(cmd.id)
console.log('[CommandQueue] Executing command:', cmd.id, cmd.command)
try {
const parsed = JSON.parse(cmd.command) as {
action: string
bills?: { denomination: number; count: number }[]
ref_txid?: string
}
if (parsed.action !== 'dispense') {
completeCommand(
cmd.id,
JSON.stringify({ error: `Unknown action: ${parsed.action}` }),
true
)
return
}
if (!halInstance) {
completeCommand(cmd.id, JSON.stringify({ error: 'HAL not initialized' }), true)
return
}
if (!parsed.bills || parsed.bills.length === 0) {
completeCommand(cmd.id, JSON.stringify({ error: 'No bills specified' }), true)
return
}
const result = await halInstance.dispenseCash(parsed.bills)
const txid = `manual-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const totalFiatCents = parsed.bills.reduce((s, b) => s + b.denomination * b.count * 100, 0)
const fiatCode = process.env.VITE_LAMASSU_FIAT_CODE || 'USD'
recordTransaction({
txid,
type: 'manual_dispense',
status: result.dispensed ? 'complete' : 'dispense_error',
fiatCents: totalFiatCents,
sats: 0,
feeSats: 0,
feeFraction: 0,
exchangeRate: 0,
currency: fiatCode,
bills: parsed.bills,
cassettes: result.cassettes,
error: result.error,
})
// This dispense happened entirely in the main process, so the renderer
// has no idea the bays moved — it would keep serving a stale inventory
// and would never republish the operator's view. Tell it.
mainWindow?.webContents.send('cassettes:changed')
// Only remediate the original tx if ALL requested bills were dispensed
let refRemediated = false
if (parsed.ref_txid && result.dispensed) {
refRemediated = remediateTransaction(parsed.ref_txid, txid)
}
completeCommand(
cmd.id,
JSON.stringify({
txid,
dispensed: result.dispensed,
ref_remediated: refRemediated,
error: result.error,
})
)
console.log('[CommandQueue] Command complete:', cmd.id, 'txid:', txid)
} catch (error) {
const msg = error instanceof Error ? error.message : 'Command failed'
completeCommand(cmd.id, JSON.stringify({ error: msg }), true)
console.error('[CommandQueue] Command failed:', cmd.id, msg)
}
} catch (e) {
// Don't crash the poller on DB errors
}
}, 2000)
}
// App lifecycle
app.whenReady().then(() => {
// Enforce Content Security Policy via HTTP headers (defense-in-depth alongside meta tag)
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: http: https:; img-src 'self' data: blob:; font-src 'self'; frame-src 'none'; object-src 'none'",
],
},
})
})
initDatabase()
// Seed cassettes from env/preset if DB table is empty.
// This ensures recordTransaction() can decrement cassette counts
// even before the operator explicitly sets them via the UI.
const existing = loadCassettes()
if (existing.length === 0) {
let seedCassettes: { denomination: number; count: number }[] = []
// Priority 1: explicit VITE_LAMASSU_CASSETTES env var
const cassettesJson = process.env.VITE_LAMASSU_CASSETTES
if (cassettesJson) {
try {
seedCassettes = JSON.parse(cassettesJson)
} catch (e) {
console.warn('[Electron] Failed to parse VITE_LAMASSU_CASSETTES:', e)
}
}
// Priority 2: default presets per model
if (seedCassettes.length === 0) {
const model = process.env.VITE_LAMASSU_MACHINE_MODEL || 'sintra'
const presets: Record<string, { denomination: number; count: number }[]> = {
douro: [
{ denomination: 100, count: 50 },
{ denomination: 200, count: 50 },
],
sintra: [{ denomination: 20, count: 50 }],
tejo: [
{ denomination: 5, count: 500 },
{ denomination: 20, count: 500 },
{ denomination: 50, count: 500 },
{ denomination: 100, count: 500 },
],
gaia: [{ denomination: 20, count: 50 }],
batm3: [
{ denomination: 20, count: 400 },
{ denomination: 1, count: 400 },
],
}
seedCassettes = presets[model] || []
}
if (seedCassettes.length > 0) {
setCassettes(seedCassettes)
console.log('[Electron] Seeded cassettes from config:', seedCassettes)
}
}
createWindow()
startWatchdog()
startCommandPoller()
// Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Fully
// best-effort: if the reader/pcscd is absent it just reports 'unavailable'
// and the cash-out QR path is unaffected.
void startNfcReader(
(lnurlw) => {
// Don't log the value — it carries the card's single-use SUN p/c.
console.log(`[NFC] card tapped — lnurlw (${lnurlw.length} chars) → renderer`)
mainWindow?.webContents.send('nfc:card-tapped', lnurlw)
},
(status: NfcStatus) => {
console.log(
`[NFC] status=${status.state}${status.reader ? ` reader="${status.reader}"` : ''}${status.message ? ` — ${status.message}` : ''}`
)
mainWindow?.webContents.send('nfc:status', status)
}
)
app.on('activate', () => {
// macOS: re-create window when dock icon clicked
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (heartbeatInterval) clearInterval(heartbeatInterval)
if (commandPollInterval) clearInterval(commandPollInterval)
closeDatabase()
// Quit on all platforms (ATM doesn't need macOS dock behavior)
app.quit()
})
// Security: prevent new window creation
app.on('web-contents-created', (_, contents) => {
contents.setWindowOpenHandler(() => {
return { action: 'deny' }
})
})