From db68e6e244f2f59d5360355958abf724da5ae2d7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 22:09:23 +0200 Subject: [PATCH] fix(cassettes): republish after a dispense the renderer didn't run Two dispense paths bypassed the refresh-and-publish step that cash-out does. The kind-21003 management command persisted the transaction and stopped there, and the operator-command poller runs entirely in the main process, where the renderer cannot see the bays move at all. In both cases the renderer kept serving a stale inventory and the operator's cassette view stayed frozen until the next customer cash-out. The management handler takes an after-hook, and the main process emits 'cassettes:changed' when it mutates the table so the renderer can catch up. Both land on one helper that reloads the inventory and republishes the state document. Co-Authored-By: Claude Fable 5.1 --- apps/machine/electron/main.ts | 5 ++++ apps/machine/electron/preload.ts | 7 +++++ apps/machine/src/stores/atm.ts | 41 ++++++++++++++++++++++++++-- apps/machine/src/types/electron.d.ts | 2 ++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 0331392..f74d534 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -841,6 +841,11 @@ function startCommandPoller(): void { 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) { diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index df4ba0b..78b0e13 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -222,6 +222,13 @@ contextBridge.exposeInMainWorld('electronAPI', { // Bolt Card reader (main process → renderer). removeAllListeners first: a // renderer reload re-runs this, and a duplicated card-tap listener would // trigger the LNURL-withdraw twice. + // The main process changed the cassettes table (an operator-command dispense, + // boot seeding). The renderer reloads its inventory and republishes state. + onCassettesChanged: (callback: () => void) => { + ipcRenderer.removeAllListeners('cassettes:changed') + ipcRenderer.on('cassettes:changed', () => callback()) + }, + onNfcCardTapped: (callback: (lnurlw: string) => void) => { ipcRenderer.removeAllListeners('nfc:card-tapped') ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw)) diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 8751861..ec8b41e 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -72,7 +72,13 @@ async function handleManagementCommand( request: ManagementRequest, dispenseFn: (amounts: { denomination: number; count: number }[]) => Promise, machineIdle: boolean, - currency: string + currency: string, + /** + * Called once the dispense has been persisted. Bills left the bays whether or + * not the dispense completed, so the caller refreshes its inventory and + * republishes the operator's view — this path used to do neither. + */ + onCassettesChanged?: () => Promise ): Promise { if (!isMachineDispenseRequest(request)) return null @@ -130,6 +136,7 @@ async function handleManagementCommand( cassettes: result.cassettes, error: result.error, }) + await onCassettesChanged?.() // Only remediate the original tx if ALL requested bills were dispensed let refRemediated = false @@ -450,6 +457,16 @@ export const useAtmStore = defineStore('atm', () => { */ const persistedInventory = ref>({}) + /** + * The bays moved outside the normal cash-out flow — an operator-command + * dispense, a main-process seed. Refresh the renderer's view and push the + * operator's. Best-effort: a publish failure must not fail the dispense. + */ + async function refreshAndPublishCassettes() { + await reloadPersistedInventory() + await operatorConfigSvc?.publishCassettesState() + } + async function reloadPersistedInventory() { const inv = await loadInventoryFromDb() // Only a failed read is ignored. An empty map used to be skipped too, @@ -709,6 +726,7 @@ export const useAtmStore = defineStore('atm', () => { // Start the machine actor.value.start() setupNfcListener() + setupCassettesChangedListener() console.log('[ATM] State machine initialized') } @@ -922,6 +940,21 @@ export const useAtmStore = defineStore('atm', () => { } } + /** + * The main process can move the bays without the renderer knowing — an + * operator-command dispense runs entirely there, and boot seeding writes the + * table before the store exists. Listen for that and catch up, otherwise the + * renderer serves a stale inventory and the operator's view never updates. + * Idempotent via preload removeAllListeners. + */ + function setupCassettesChangedListener() { + if (!isElectron || !window.electronAPI?.onCassettesChanged) return + window.electronAPI.onCassettesChanged(() => { + console.log('[ATM] Cassettes changed in the main process — refreshing') + void refreshAndPublishCassettes() + }) + } + /** Wire the main-process reader once (idempotent via preload removeAllListeners). */ function setupNfcListener() { if (!isElectron || !window.electronAPI?.onNfcCardTapped) return @@ -1319,7 +1352,8 @@ export const useAtmStore = defineStore('atm', () => { request, (amounts) => hal.atmServices.dispenseCash(amounts), isIdle.value, - fiatCode.value + fiatCode.value, + refreshAndPublishCassettes ) }) @@ -1651,7 +1685,8 @@ export const useAtmStore = defineStore('atm', () => { request, (amounts) => api.halDispense(amounts), isIdle.value, - fiatCode.value + fiatCode.value, + refreshAndPublishCassettes ) }) diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 1ab609c..20e0e6a 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -217,6 +217,8 @@ declare global { onHalBillInserted: (callback: (denomination: number) => void) => void onHalBillRejected: (callback: (reason: string) => void) => void onHalError: (callback: (error: string) => void) => void + /** The main process mutated the cassettes table; reload + republish. */ + onCassettesChanged: (callback: () => void) => void /** Bolt Card reader: a tapped card's lnurlw voucher. */ onNfcCardTapped: (callback: (lnurlw: string) => void) => void /** Bolt Card reader status (ready / reading / error / unavailable). */