Closes gap 2 from coord log 2026-06-01T18:30Z. The LNbits withdraw extension's nostr-transport RPC now populates `link.lnurl` from `settings.lnbits_baseurl` (aiolabs/withdraw#1 / commit e9d911e), so the ATM no longer needs a separate HTTP URL on the wire to compose the LNURL-withdraw callback itself. What goes: - `VITE_LNBITS_HTTP_URL` env var (renderer + Electron main) - `lnbitsHttpUrl` field on `LightningConfig`, `RuntimeConfig`, and the Window mirror in `src/types/electron.d.ts` - The manual `${lnbitsHttpUrl}/withdraw/api/v1/lnurl/${unique_hash}` composition in `generateLnurlWithdraw` - The `encodeLnurl` bech32 helper in `lightning.ts` (LNbits returns bech32-encoded; we just `.toUpperCase()` to match BOLT/LNURL convention) - `@scure/base` dep from `apps/machine/package.json` (only used by the removed helper; clink still uses it directly) - The `lnbitsHttpUrl` option + `LNBITS_HTTP_URL=…` env var + boot echo in `deploy/nixos/bitspire-atm.nix` - Doc references in CLAUDE.md, README.md, deploy/nixos/README.md, docs/architecture-comparison.md, and the lightning-check skill What stays: - `link.lnurl` consumption, with an explicit error if LNbits returns null (which signals `LNBITS_BASEURL` is unset on the server side — better to fail clearly than silently) - The receiver-side bech32 uppercasing (LNbits returns lowercase per the standard library) Why this is a net win: - Removes a config-drift surface — if LNbits's external URL moved (DNS, port, reverse-proxy rewrite), every ATM in the field would stop issuing redeemable LNURL-withdraw QRs until reconfigured. Now LNbits derives its own URL from `settings.lnbits_baseurl`, one source of truth. - Removes an extra provisioning step. No more `LNBITS_HTTP_URL=…` before running `provision-atm.sh`; the relay + server pubkey suffice. - Removes the misleading boot echo that triggered the §`18:30Z` smoke triage confusion ("LNbits HTTP: <url>" read like ATM-→-LNbits connectivity, when it was only ever a URL embedded in customer QRs). Also adds a `# pragma: allowlist secret` marker above the `VITE_ATM_PRIVATE_KEY` doc block in `.env.example` so the global secret scanner stops false-positiving on the documentation prose. Workspace typecheck + 24/24 apps/machine tests still green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
747 lines
25 KiB
TypeScript
747 lines
25 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,
|
|
applyOperatorCassettesConfig,
|
|
getFeeConfig,
|
|
getLastKnownFeeConfigCreatedAt,
|
|
applyFeeConfig,
|
|
type OperatorCassettesPayload,
|
|
type FeeConfigPayload,
|
|
type FeeConfigRow,
|
|
type ApplyResult,
|
|
} from './state-store.js'
|
|
import { initializeHal, type HalInstance } from './hal-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
|
|
}
|
|
|
|
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
|
|
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
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777',
|
|
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 { atmPrivateKey: '' }
|
|
}
|
|
secretsConsumed = true
|
|
return {
|
|
atmPrivateKey: process.env.VITE_ATM_PRIVATE_KEY || '',
|
|
}
|
|
})
|
|
|
|
// 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 {
|
|
// 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
|
|
}
|
|
const denomination = pendingBillDenomination
|
|
pendingBillDenomination = null
|
|
halInstance.stackBill()
|
|
mainWindow?.webContents.send('hal:bill-inserted', denomination)
|
|
})
|
|
|
|
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()
|
|
|
|
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' }
|
|
})
|
|
})
|