bitspire/apps/machine/electron/main.ts
Padreug 4f68ddc40b refactor(machine): drop VITE_LNBITS_HTTP_URL — lnurl now arrives populated from LNbits (#57 gap 2)
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>
2026-06-01 20:33:28 +02:00

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' }
})
})