bitspire/apps/machine/electron/main.ts
Patrick Mulligan 64582e7fe6 feat(machine): Bolt Card (NFC) tap-to-receive on cash-in
Tap-to-receive for the buy flow, the receive counterpart to #83's
cash-out tap-to-pay. A Bolt Card only emits an lnurlw withdraw voucher
(wrong direction to deposit into it), so the tap is used as an
authenticated identity (external_id + SUN p/c) to resolve the card
wallet's lnurlp/Lightning Address; the ATM then pays an invoice for the
payout over its existing nostr transport.

- electron/lnurl-pay.ts: resolveCardInvoice() — resolve card -> pay
  target -> LUD-16/LUD-06 -> BOLT11. scanUrlToResolver() is the single
  HTTPS-today / nostr-tomorrow transport seam. 13 tests.
- IPC lnurl:pay-card (main-process HTTPS to dodge renderer CORS) +
  preload/electron.d.ts surface.
- stores/atm.ts: handleBoltCardReceive() settles via the existing
  payInvoice -> PAYMENT_RECEIVED path; the one NFC listener now routes
  the same tap by flow (cash-out pulls, cash-in receives).
- CashInView.vue: NFC status + dev tap input.
- docs/boltcard-receive-resolver.md: spec for the custom LNbits
  /boltcards/api/v1/pay/<id> resolver endpoint (omni-private side).

Card issuance is unchanged — same NDEF/keys/external_id; receive is a
server-side reading of the same tap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 00:30:13 +02:00

881 lines
31 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,
getBootstrapPublishedAt,
markBootstrapPublished,
resetBootstrapGate,
resetForRepair,
applyOperatorCassettesConfig,
getFeeConfig,
getLastKnownFeeConfigCreatedAt,
applyFeeConfig,
getBunkerBinding,
saveBunkerBinding,
clearBunkerBinding,
type OperatorCassettesPayload,
type FeeConfigPayload,
type FeeConfigRow,
type ApplyResult,
type StoredBunkerBinding,
} from './state-store.js'
import { initializeHal, type HalInstance } from './hal-service.js'
import { executeLnurlWithdraw } from './lnurl-withdraw.js'
import { resolveCardInvoice } from './lnurl-pay.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 }
}
// 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(),
}
})
// 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 bootstrap gate 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-bootstrap-gate', (): void => {
resetBootstrapGate()
})
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)
}
)
// 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-bootstrap-published-at', (): number | null => getBootstrapPublishedAt())
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => {
markBootstrapPublished(unixTimestamp)
})
ipcMain.handle(
'state:apply-operator-cassettes-config',
(_event, payload: OperatorCassettesPayload, eventCreatedAt: number): ApplyResult =>
applyOperatorCassettesConfig(payload, eventCreatedAt)
)
// 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,
})
// 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' }
})
})