diff --git a/apps/machine/electron/nfc-service.ts b/apps/machine/electron/nfc-service.ts index e94ddaf..b128321 100644 --- a/apps/machine/electron/nfc-service.ts +++ b/apps/machine/electron/nfc-service.ts @@ -14,6 +14,7 @@ */ import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable' export interface NfcStatus { @@ -140,12 +141,34 @@ function resetWedgedReader(): void { * Start listening for Bolt Card taps. Idempotent. Returns a stop function. * Never throws — failures surface via onStatus. */ +/** + * pcsc-lite's client socket, created by pcscd. + * + * When pcscd is NOT running, the pcsclite binding inside nfc-pcsc does not + * fail — it retries SCardEstablishContext in a tight loop on the calling + * thread (~12k stat()s per second on this path), and that thread is Electron's + * main thread. The event loop then stops turning entirely: the window never + * paints, the dead renderer is never reaped, and main.ts's watchdog can't fire + * either, so nothing recovers it. A douro with no reader fitted sat wedged + * like that for 11 hours, ignoring SIGTERM. + * + * Checking for the socket first is what makes the "best-effort" contract in + * this module's header actually true. It also covers pcscd dying at runtime on + * a machine that does have a reader. + */ +const PCSCD_SOCKET = '/run/pcscd/pcscd.comm' + export async function startNfcReader( onCard: CardHandler, onStatus: StatusHandler ): Promise<() => void> { if (stopFn) return stopFn + if (!existsSync(PCSCD_SOCKET)) { + onStatus({ state: 'unavailable', message: `pcscd not running (${PCSCD_SOCKET} absent)` }) + return () => {} + } + let mod: unknown try { // Non-literal specifier: nfc-pcsc ships no types; keep it `any` to tsc