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). */