diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 9094eb7..b5801ad 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -952,29 +952,22 @@ app.whenReady().then(() => { startWatchdog() startCommandPoller() - // Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Opt-in - // per machine via services.bitspire.nfc.enable, which is off unless a CCID - // reader is actually fitted: nfc-pcsc's pcsclite binding busy-spins this very - // thread when pcscd is absent and wedges the whole app (see nfc-service.ts). - // nfc-service also re-checks the pcscd socket, so this flag is the coarse - // gate, not the only defence. Cash-out via QR never depends on any of it. - if (process.env.BITSPIRE_NFC_ENABLED === 'true') { - void startNfcReader( - (lnurlw) => { - // Don't log the value — it carries the card's single-use SUN p/c. - console.log(`[NFC] card tapped — lnurlw (${lnurlw.length} chars) → renderer`) - mainWindow?.webContents.send('nfc:card-tapped', lnurlw) - }, - (status: NfcStatus) => { - console.log( - `[NFC] status=${status.state}${status.reader ? ` reader="${status.reader}"` : ''}${status.message ? ` — ${status.message}` : ''}` - ) - mainWindow?.webContents.send('nfc:status', status) - } - ) - } else { - console.log('[NFC] no reader configured (BITSPIRE_NFC_ENABLED not "true") — skipping init') - } + // Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Fully + // best-effort: if the reader/pcscd is absent it just reports 'unavailable' + // and the cash-out QR path is unaffected. + void startNfcReader( + (lnurlw) => { + // Don't log the value — it carries the card's single-use SUN p/c. + console.log(`[NFC] card tapped — lnurlw (${lnurlw.length} chars) → renderer`) + mainWindow?.webContents.send('nfc:card-tapped', lnurlw) + }, + (status: NfcStatus) => { + console.log( + `[NFC] status=${status.state}${status.reader ? ` reader="${status.reader}"` : ''}${status.message ? ` — ${status.message}` : ''}` + ) + mainWindow?.webContents.send('nfc:status', status) + } + ) app.on('activate', () => { // macOS: re-create window when dock icon clicked diff --git a/apps/machine/electron/nfc-service.ts b/apps/machine/electron/nfc-service.ts index b128321..e94ddaf 100644 --- a/apps/machine/electron/nfc-service.ts +++ b/apps/machine/electron/nfc-service.ts @@ -14,7 +14,6 @@ */ import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable' export interface NfcStatus { @@ -141,34 +140,12 @@ 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 diff --git a/deploy/nixos/bitspire-atm.nix b/deploy/nixos/bitspire-atm.nix index 8a85ad4..fffb92e 100644 --- a/deploy/nixos/bitspire-atm.nix +++ b/deploy/nixos/bitspire-atm.nix @@ -132,28 +132,6 @@ in description = "Camera device"; }; }; - - # Contactless (CCID) card reader for Bolt Card taps — ADR-003. - # - # Opt-in, and deliberately defaulted off: only some machines have a reader - # fitted, and on a machine without one the app must not so much as - # initialise nfc-pcsc, because pcsclite busy-spins Electron's main thread - # when pcscd is absent (the full story is in nfc-service.ts). Per-model - # truth lives in `nfcReaderForModel` in flake.nix, since sintra and tejo - # share hardware/upboard.nix but only one of them has a reader. - nfc = { - enable = mkOption { - type = types.bool; - default = false; - description = '' - Enable the Bolt Card reader. Starts pcscd, authorises the `bitspire` - user to talk to it and to the card via polkit, installs the - wedge-recovery unit, and tells the app to initialise NFC at all. - Leave false on machines with no reader fitted; cash-out over QR is - unaffected either way. - ''; - }; - }; }; config = mkIf cfg.enable { @@ -193,70 +171,6 @@ in ELECTRON_DISABLE_GPU=false ''; - # ── Bolt Card reader (services.bitspire.nfc.enable) ───────────────── - # Lifted out of hardware/batm3.nix and hardware/upboard.nix so that "is a - # reader fitted" is one per-machine flag rather than a block copied into - # each hardware file — upboard.nix is shared by sintra (OMNIKEY 5022) and - # tejo (no reader), so a hardware file cannot answer the question. - - # pcscd binds the CCID driver to the reader; the app talks to pcscd's - # socket via nfc-pcsc rather than the USB device directly. Reader-agnostic - # (Feitian KP382 on batm3, HID Global OMNIKEY 5022 on sintra). - services.pcscd.enable = mkIf cfg.nfc.enable true; - - # pcscd gates client access via polkit; without a rule the sandboxed - # `bitspire` service user is "Rejected unauthorized PC/SC client". - # Authorise it to talk to the daemon and the card, and to trigger the - # wedge-recovery unit below. - security.polkit.extraConfig = mkIf cfg.nfc.enable '' - polkit.addRule(function(action, subject) { - if ((action.id == "org.debian.pcsc-lite.access_pcsc" || - action.id == "org.debian.pcsc-lite.access_card") && - subject.user == "bitspire") { - return polkit.Result.YES; - } - }); - polkit.addRule(function(action, subject) { - if (action.id == "org.freedesktop.systemd1.manage-units" && - action.lookup("unit") == "nfc-reader-reset.service" && - subject.user == "bitspire") { - return polkit.Result.YES; - } - }); - ''; - - # NFC reader wedge-recovery. A CCID reader (the Feitian R502-CL especially) - # can wedge: it keeps detecting a card but every APDU returns "card absent - # or mute", and ONLY a USB power-cycle clears it — restarting pcscd or the - # app does not. This oneshot re-binds the reader's USB device (a software - # replug); pcscd + nfc-pcsc then re-detect it on hotplug with no app - # restart (verified on-device). The app (unprivileged `bitspire`) starts it - # via the polkit rule above when it sees repeated read failures. Matches - # the USB CCID interface class (0x0B), so a future reader swap needs no - # config change. - systemd.services.nfc-reader-reset = mkIf cfg.nfc.enable { - description = "Power-cycle a wedged CCID NFC reader (USB re-bind)"; - serviceConfig = { - Type = "oneshot"; - ExecStart = pkgs.writeShellScript "reset-nfc-reader" '' - set -u - found=0 - for iface in /sys/bus/usb/devices/*:*/bInterfaceClass; do - [ -f "$iface" ] || continue - [ "$(${pkgs.coreutils}/bin/cat "$iface" 2>/dev/null)" = "0b" ] || continue - ifname=$(${pkgs.coreutils}/bin/basename "$(${pkgs.coreutils}/bin/dirname "$iface")") - dev=''${ifname%%:*} - echo "reset-nfc-reader: power-cycling CCID reader USB device $dev" >&2 - echo -n "$dev" > /sys/bus/usb/drivers/usb/unbind 2>/dev/null || true - ${pkgs.coreutils}/bin/sleep 2 - echo -n "$dev" > /sys/bus/usb/drivers/usb/bind 2>/dev/null || true - found=1 - done - [ "$found" = 1 ] || { echo "reset-nfc-reader: no CCID reader found" >&2; exit 1; } - ''; - }; - }; - # Main ATM service systemd.services.bitspire = { description = "bitSpire ATM Application"; @@ -267,11 +181,6 @@ in ]; wants = [ "network-online.target" ]; - # Read by electron/main.ts. Lives in the unit rather than the .env - # EnvironmentFile because .env is only written when absent, so a machine - # provisioned months ago would never pick a new value up. - environment.BITSPIRE_NFC_ENABLED = boolToString cfg.nfc.enable; - serviceConfig = { Type = "simple"; User = "bitspire"; diff --git a/deploy/nixos/hardware/batm3.nix b/deploy/nixos/hardware/batm3.nix index ca57f0d..79f1b4d 100644 --- a/deploy/nixos/hardware/batm3.nix +++ b/deploy/nixos/hardware/batm3.nix @@ -85,10 +85,65 @@ cpuFreqGovernor = "performance"; }; - # The Feitian KP382 contactless reader (096e:0608) is declared as a machine - # capability, not here: `nfcReaderForModel` in flake.nix drives - # services.bitspire.nfc.enable, which owns pcscd, the polkit rules and the - # wedge-recovery unit (deploy/nixos/bitspire-atm.nix). + # PC/SC daemon for the Feitian KP382 contactless reader (096e:0608, a CCID + # smart-card reader) used for Bolt Card tap-to-pay on cash-out. Enabling it + # binds the CCID driver to the reader; the app talks to pcscd's socket (via + # nfc-pcsc) rather than the USB device directly. Harmless if no reader is + # attached — pcscd just idles. + services.pcscd.enable = true; + + # pcscd gates client access via polkit; without a rule the sandboxed + # `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize + # it to talk to the daemon and the card. The second rule lets the app trigger + # the NFC reader wedge-recovery service (see nfc-reader-reset below). + security.polkit.extraConfig = '' + polkit.addRule(function(action, subject) { + if ((action.id == "org.debian.pcsc-lite.access_pcsc" || + action.id == "org.debian.pcsc-lite.access_card") && + subject.user == "bitspire") { + return polkit.Result.YES; + } + }); + polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.systemd1.manage-units" && + action.lookup("unit") == "nfc-reader-reset.service" && + subject.user == "bitspire") { + return polkit.Result.YES; + } + }); + ''; + + # NFC reader wedge-recovery. The Feitian R502-CL CCID reader (and, less often, + # any CCID reader) can wedge: it keeps detecting a card but every APDU returns + # "card absent or mute", and ONLY a USB power-cycle clears it — restarting + # pcscd or the app does not. This oneshot re-binds the reader's USB device (a + # software replug); pcscd + nfc-pcsc then re-detect it on hotplug with no app + # restart (verified on-device). The app (unprivileged `bitspire`) starts it via + # the polkit rule above when it sees repeated read failures. Reader-agnostic: + # it matches the USB CCID interface class (0x0B), so it also covers a future + # ACR1252U swap without a config change. + systemd.services.nfc-reader-reset = { + description = "Power-cycle a wedged CCID NFC reader (USB re-bind)"; + serviceConfig = { + Type = "oneshot"; + ExecStart = pkgs.writeShellScript "reset-nfc-reader" '' + set -u + found=0 + for iface in /sys/bus/usb/devices/*:*/bInterfaceClass; do + [ -f "$iface" ] || continue + [ "$(${pkgs.coreutils}/bin/cat "$iface" 2>/dev/null)" = "0b" ] || continue + ifname=$(${pkgs.coreutils}/bin/basename "$(${pkgs.coreutils}/bin/dirname "$iface")") + dev=''${ifname%%:*} + echo "reset-nfc-reader: power-cycling CCID reader USB device $dev" >&2 + echo -n "$dev" > /sys/bus/usb/drivers/usb/unbind 2>/dev/null || true + ${pkgs.coreutils}/bin/sleep 2 + echo -n "$dev" > /sys/bus/usb/drivers/usb/bind 2>/dev/null || true + found=1 + done + [ "$found" = 1 ] || { echo "reset-nfc-reader: no CCID reader found" >&2; exit 1; } + ''; + }; + }; # Disable suspend/hibernate for kiosk systemd.targets = { diff --git a/deploy/nixos/hardware/upboard.nix b/deploy/nixos/hardware/upboard.nix index e2a0ddd..499179e 100644 --- a/deploy/nixos/hardware/upboard.nix +++ b/deploy/nixos/hardware/upboard.nix @@ -91,9 +91,26 @@ cpuFreqGovernor = "performance"; }; - # No pcscd here. This file is shared by sintra (HID Global OMNIKEY 5022 - # fitted) and tejo (no reader), so the reader is declared per model via - # `nfcReaderForModel` in flake.nix → services.bitspire.nfc.enable. + # PC/SC daemon for the HID Global OMNIKEY 5022 contactless reader + # (076b:5022, a CCID smart-card reader) used for Bolt Card tap-to-enter + # (ADR-003). pcscd binds the CCID driver; the app talks to pcscd's socket + # (via nfc-pcsc) rather than the USB device directly. Device-agnostic — + # same wiring as batm3's Feitian KP382; harmless if no reader is attached, + # pcscd just idles. Shared by every upboard machine (sintra, tejo). + services.pcscd.enable = true; + + # pcscd gates client access via polkit; without a rule the sandboxed + # `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize + # it to talk to the daemon and the card. + security.polkit.extraConfig = '' + polkit.addRule(function(action, subject) { + if ((action.id == "org.debian.pcsc-lite.access_pcsc" || + action.id == "org.debian.pcsc-lite.access_card") && + subject.user == "bitspire") { + return polkit.Result.YES; + } + }); + ''; # Disable suspend/hibernate for kiosk systemd.targets = { diff --git a/flake.nix b/flake.nix index 1099ceb..85895d9 100644 --- a/flake.nix +++ b/flake.nix @@ -144,21 +144,6 @@ sintra = "04:00 Europe/Paris"; }; - # Which models have a contactless (CCID) card reader fitted, for Bolt - # Card taps. Same keying caveat as the two tables above. - # - # This can't live in a hardware file: hardware/upboard.nix is shared by - # sintra (HID Global OMNIKEY 5022) and tejo (nothing fitted), so before - # this table the tejo inherited pcscd it had no use for while the douro, - # with its own hardware file, got none and wedged on boot — nfc-pcsc - # busy-spins Electron's main thread when pcscd is absent (see - # apps/machine/electron/nfc-service.ts). Flip a model to true when a - # reader is actually fitted; douro and tejo are planned. - nfcReaderForModel = { - batm3 = true; # Feitian KP382 - sintra = true; # HID Global OMNIKEY 5022 - }; - lib = nixpkgs.lib; # Helper to create a live USB NixOS config for a specific machine model @@ -215,7 +200,6 @@ services.bitspire = { enable = true; appDir = "${atm-app}"; - nfc.enable = nfcReaderForModel.${machineModel} or false; }; # Operator TUI and CLI tools