Compare commits
No commits in common. "bb2ad396289a0daacc3ad5251fc2e3da386aba32" and "92a82712dafb6a4d1c38cd324a4a63f825d4ad6f" have entirely different histories.
bb2ad39628
...
92a82712da
6 changed files with 95 additions and 160 deletions
|
|
@ -952,29 +952,22 @@ app.whenReady().then(() => {
|
||||||
startWatchdog()
|
startWatchdog()
|
||||||
startCommandPoller()
|
startCommandPoller()
|
||||||
|
|
||||||
// Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Opt-in
|
// Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Fully
|
||||||
// per machine via services.bitspire.nfc.enable, which is off unless a CCID
|
// best-effort: if the reader/pcscd is absent it just reports 'unavailable'
|
||||||
// reader is actually fitted: nfc-pcsc's pcsclite binding busy-spins this very
|
// and the cash-out QR path is unaffected.
|
||||||
// thread when pcscd is absent and wedges the whole app (see nfc-service.ts).
|
void startNfcReader(
|
||||||
// nfc-service also re-checks the pcscd socket, so this flag is the coarse
|
(lnurlw) => {
|
||||||
// gate, not the only defence. Cash-out via QR never depends on any of it.
|
// Don't log the value — it carries the card's single-use SUN p/c.
|
||||||
if (process.env.BITSPIRE_NFC_ENABLED === 'true') {
|
console.log(`[NFC] card tapped — lnurlw (${lnurlw.length} chars) → renderer`)
|
||||||
void startNfcReader(
|
mainWindow?.webContents.send('nfc:card-tapped', lnurlw)
|
||||||
(lnurlw) => {
|
},
|
||||||
// Don't log the value — it carries the card's single-use SUN p/c.
|
(status: NfcStatus) => {
|
||||||
console.log(`[NFC] card tapped — lnurlw (${lnurlw.length} chars) → renderer`)
|
console.log(
|
||||||
mainWindow?.webContents.send('nfc:card-tapped', lnurlw)
|
`[NFC] status=${status.state}${status.reader ? ` reader="${status.reader}"` : ''}${status.message ? ` — ${status.message}` : ''}`
|
||||||
},
|
)
|
||||||
(status: NfcStatus) => {
|
mainWindow?.webContents.send('nfc:status', status)
|
||||||
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')
|
|
||||||
}
|
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
// macOS: re-create window when dock icon clicked
|
// macOS: re-create window when dock icon clicked
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFile } from 'node:child_process'
|
import { execFile } from 'node:child_process'
|
||||||
import { existsSync } from 'node:fs'
|
|
||||||
|
|
||||||
export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable'
|
export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable'
|
||||||
export interface NfcStatus {
|
export interface NfcStatus {
|
||||||
|
|
@ -141,34 +140,12 @@ function resetWedgedReader(): void {
|
||||||
* Start listening for Bolt Card taps. Idempotent. Returns a stop function.
|
* Start listening for Bolt Card taps. Idempotent. Returns a stop function.
|
||||||
* Never throws — failures surface via onStatus.
|
* 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(
|
export async function startNfcReader(
|
||||||
onCard: CardHandler,
|
onCard: CardHandler,
|
||||||
onStatus: StatusHandler
|
onStatus: StatusHandler
|
||||||
): Promise<() => void> {
|
): Promise<() => void> {
|
||||||
if (stopFn) return stopFn
|
if (stopFn) return stopFn
|
||||||
|
|
||||||
if (!existsSync(PCSCD_SOCKET)) {
|
|
||||||
onStatus({ state: 'unavailable', message: `pcscd not running (${PCSCD_SOCKET} absent)` })
|
|
||||||
return () => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mod: unknown
|
let mod: unknown
|
||||||
try {
|
try {
|
||||||
// Non-literal specifier: nfc-pcsc ships no types; keep it `any` to tsc
|
// Non-literal specifier: nfc-pcsc ships no types; keep it `any` to tsc
|
||||||
|
|
|
||||||
|
|
@ -132,28 +132,6 @@ in
|
||||||
description = "Camera device";
|
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 {
|
config = mkIf cfg.enable {
|
||||||
|
|
@ -193,70 +171,6 @@ in
|
||||||
ELECTRON_DISABLE_GPU=false
|
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
|
# Main ATM service
|
||||||
systemd.services.bitspire = {
|
systemd.services.bitspire = {
|
||||||
description = "bitSpire ATM Application";
|
description = "bitSpire ATM Application";
|
||||||
|
|
@ -267,11 +181,6 @@ in
|
||||||
];
|
];
|
||||||
wants = [ "network-online.target" ];
|
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 = {
|
serviceConfig = {
|
||||||
Type = "simple";
|
Type = "simple";
|
||||||
User = "bitspire";
|
User = "bitspire";
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,65 @@
|
||||||
cpuFreqGovernor = "performance";
|
cpuFreqGovernor = "performance";
|
||||||
};
|
};
|
||||||
|
|
||||||
# The Feitian KP382 contactless reader (096e:0608) is declared as a machine
|
# PC/SC daemon for the Feitian KP382 contactless reader (096e:0608, a CCID
|
||||||
# capability, not here: `nfcReaderForModel` in flake.nix drives
|
# smart-card reader) used for Bolt Card tap-to-pay on cash-out. Enabling it
|
||||||
# services.bitspire.nfc.enable, which owns pcscd, the polkit rules and the
|
# binds the CCID driver to the reader; the app talks to pcscd's socket (via
|
||||||
# wedge-recovery unit (deploy/nixos/bitspire-atm.nix).
|
# 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
|
# Disable suspend/hibernate for kiosk
|
||||||
systemd.targets = {
|
systemd.targets = {
|
||||||
|
|
|
||||||
|
|
@ -91,9 +91,26 @@
|
||||||
cpuFreqGovernor = "performance";
|
cpuFreqGovernor = "performance";
|
||||||
};
|
};
|
||||||
|
|
||||||
# No pcscd here. This file is shared by sintra (HID Global OMNIKEY 5022
|
# PC/SC daemon for the HID Global OMNIKEY 5022 contactless reader
|
||||||
# fitted) and tejo (no reader), so the reader is declared per model via
|
# (076b:5022, a CCID smart-card reader) used for Bolt Card tap-to-enter
|
||||||
# `nfcReaderForModel` in flake.nix → services.bitspire.nfc.enable.
|
# (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
|
# Disable suspend/hibernate for kiosk
|
||||||
systemd.targets = {
|
systemd.targets = {
|
||||||
|
|
|
||||||
16
flake.nix
16
flake.nix
|
|
@ -144,21 +144,6 @@
|
||||||
sintra = "04:00 Europe/Paris";
|
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;
|
lib = nixpkgs.lib;
|
||||||
|
|
||||||
# Helper to create a live USB NixOS config for a specific machine model
|
# Helper to create a live USB NixOS config for a specific machine model
|
||||||
|
|
@ -215,7 +200,6 @@
|
||||||
services.bitspire = {
|
services.bitspire = {
|
||||||
enable = true;
|
enable = true;
|
||||||
appDir = "${atm-app}";
|
appDir = "${atm-app}";
|
||||||
nfc.enable = nfcReaderForModel.${machineModel} or false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
# Operator TUI and CLI tools
|
# Operator TUI and CLI tools
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue