diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index fc48d87..ac31a6a 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -404,6 +404,17 @@ ipcMain.handle('app:relaunch', (): void => { 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() +}) + // State persistence IPC handlers ipcMain.handle('state:load-cassettes', () => loadCassettes()) ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes)) @@ -480,6 +491,17 @@ 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 diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index a811eab..41c54df 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -124,6 +124,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // then relaunch so the normal boot flow pairs it. saveSpireSeed: (seed: string): Promise => ipcRenderer.invoke('state:save-spire-seed', seed), relaunchApp: (): Promise => ipcRenderer.invoke('app:relaunch'), + // Reload the renderer to re-attempt initialization (connectivity recovery). + recoverApp: (): Promise => ipcRenderer.invoke('app:recover'), applyOperatorCassettesConfig: ( payload: { @@ -243,6 +245,7 @@ declare global { resetForRepair: () => Promise saveSpireSeed: (seed: string) => Promise relaunchApp: () => Promise + recoverApp: () => Promise applyOperatorCassettesConfig: ( payload: { positions: Record }, eventCreatedAt: number diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index 2b89eb7..d9bce92 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -1,5 +1,5 @@