Merge pull request 'feat(machine): connectivity auto-recovery + on-screen Retry' (#82) from feat/connection-recovery into dev
Reviewed-on: #82
This commit is contained in:
commit
c36c2fb1c4
6 changed files with 169 additions and 34 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -124,6 +124,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
// then relaunch so the normal boot flow pairs it.
|
||||
saveSpireSeed: (seed: string): Promise<void> => ipcRenderer.invoke('state:save-spire-seed', seed),
|
||||
relaunchApp: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
|
||||
// Reload the renderer to re-attempt initialization (connectivity recovery).
|
||||
recoverApp: (): Promise<void> => ipcRenderer.invoke('app:recover'),
|
||||
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: {
|
||||
|
|
@ -243,6 +245,7 @@ declare global {
|
|||
resetForRepair: () => Promise<void>
|
||||
saveSpireSeed: (seed: string) => Promise<void>
|
||||
relaunchApp: () => Promise<void>
|
||||
recoverApp: () => Promise<void>
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||
eventCreatedAt: number
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
|
@ -158,8 +158,71 @@ onMounted(async () => {
|
|||
|
||||
onUnmounted(() => {
|
||||
atmStore.stopPricePolling()
|
||||
stopRecoveryWatch()
|
||||
})
|
||||
|
||||
// ── Connectivity recovery (ADR-002 amendment 2026-08-04) ──────────────────
|
||||
// A connectivity-type init failure lands on "ATM Unavailable" and, without
|
||||
// this, stays there forever (init is one-shot; the nostr reconnect only helps
|
||||
// AFTER a first successful connect). We recover by reloading the renderer —
|
||||
// which re-runs this whole init from a clean JS context while the main process
|
||||
// keeps HAL (see main.ts app:recover). Not for the operator/self-clearing
|
||||
// states: `unpaired` shows the pairing wizard, `awaiting-fees` clears itself on
|
||||
// the operator's fee-config event, `maintenance` is operator-set.
|
||||
const NON_RECOVERABLE = new Set(['maintenance', 'awaiting-fees', 'unpaired'])
|
||||
const isRecoverable = computed(
|
||||
() => !!atmStore.initError && !NON_RECOVERABLE.has(atmStore.initError)
|
||||
)
|
||||
const recovering = ref(false)
|
||||
const RECOVERY_RETRY_MS = 45_000
|
||||
let recoveryTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function triggerRecovery() {
|
||||
if (recovering.value) return
|
||||
recovering.value = true
|
||||
console.log('[App] Attempting connectivity recovery (renderer reload)')
|
||||
if (window.electronAPI?.recoverApp) {
|
||||
void window.electronAPI.recoverApp() // main reloads renderer → fresh init
|
||||
} else {
|
||||
location.reload() // browser-dev fallback
|
||||
}
|
||||
}
|
||||
|
||||
function onOnline() {
|
||||
// Network came back — recover immediately rather than waiting for the timer.
|
||||
triggerRecovery()
|
||||
}
|
||||
|
||||
function startRecoveryWatch() {
|
||||
stopRecoveryWatch()
|
||||
window.addEventListener('online', onOnline)
|
||||
// Safety net for the online-but-relay-unreachable case (navigator.onLine only
|
||||
// reflects a local route, not relay reachability).
|
||||
recoveryTimer = setInterval(triggerRecovery, RECOVERY_RETRY_MS)
|
||||
}
|
||||
|
||||
function stopRecoveryWatch() {
|
||||
window.removeEventListener('online', onOnline)
|
||||
if (recoveryTimer !== null) {
|
||||
clearInterval(recoveryTimer)
|
||||
recoveryTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Operator-facing "Retry" button on the maintenance screen. */
|
||||
function retryNow() {
|
||||
triggerRecovery()
|
||||
}
|
||||
|
||||
watch(
|
||||
isRecoverable,
|
||||
(recoverable) => {
|
||||
if (recoverable) startRecoveryWatch()
|
||||
else stopRecoveryWatch()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function toggleLiveServices() {
|
||||
if (atmStore.useLiveServices) {
|
||||
// Switch to mock
|
||||
|
|
@ -211,6 +274,19 @@ function toggleLiveServices() {
|
|||
>
|
||||
{{ atmStore.initError }}
|
||||
</p>
|
||||
|
||||
<!-- Manual recovery for a connectivity failure; auto-recovery also runs
|
||||
in the background (online event + backoff). Not shown for operator/
|
||||
self-clearing states (maintenance / awaiting-fees / unpaired). -->
|
||||
<Button
|
||||
v-if="isRecoverable"
|
||||
size="kiosk"
|
||||
:disabled="recovering"
|
||||
class="mt-4"
|
||||
@click="retryNow"
|
||||
>
|
||||
{{ recovering ? 'Retrying…' : 'Retry' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
|
|
|||
2
apps/machine/src/types/electron.d.ts
vendored
2
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -101,6 +101,8 @@ declare global {
|
|||
resetForRepair: () => Promise<void>
|
||||
saveSpireSeed: (seed: string) => Promise<void>
|
||||
relaunchApp: () => Promise<void>
|
||||
/** Reload the renderer to re-attempt initialization (connectivity recovery). */
|
||||
recoverApp: () => Promise<void>
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||
eventCreatedAt: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue