bitspire/apps/machine/electron/nfc-service.ts
Padreug ce80d75f95 fix(nfc): bail out when pcscd is absent instead of spinning the main thread
nfc-pcsc's pcsclite binding does not fail when pcscd is not running — it
retries SCardEstablishContext in a tight loop on the calling thread, which
here is Electron's main thread. ~12k stat()s a second on
/run/pcscd/pcscd.comm, event loop dead: the window never paints, the
renderer is never reaped, and the watchdog can't fire because it needs the
same event loop. The douro sat like that for 11 hours at 80% CPU (its
CPUQuota ceiling), ignoring SIGTERM, with nothing in the journal after
[StateStore].

Check the socket exists before touching the binding. This is what makes
the "best-effort, every failure swallowed into a status callback" contract
in the module header true, and it also covers pcscd dying at runtime on a
machine that does have a reader.
2026-09-29 22:49:45 +02:00

273 lines
11 KiB
TypeScript

/**
* NFC reader driver (main process) for Bolt Card tap-to-pay.
*
* Wraps `nfc-pcsc` (PC/SC via the Feitian KP382 CCID reader). On each card
* tap it reads the NTAG424 Type-4 NDEF file over ISO7816 APDUs and extracts
* the `lnurlw://…?p=…&c=…` voucher (the card computes fresh SUN p/c per tap),
* then hands it to the renderer over IPC. The renderer, when showing a
* cash-out invoice, pays it via LNURL-withdraw (see lnurl-withdraw.ts).
*
* Everything here is best-effort and lazy: `nfc-pcsc` is a native addon, so it
* is dynamically imported and every failure is swallowed into a status
* callback. If the reader/library is absent, NFC is simply unavailable and the
* QR path keeps working — cash-out never depends on this.
*/
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable'
export interface NfcStatus {
state: NfcState
reader?: string
message?: string
}
type CardHandler = (lnurlw: string) => void
type StatusHandler = (status: NfcStatus) => void
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e)
}
/** Pull the lnurlw (or a boltcards https scan URL) out of a Type-4 NDEF blob. */
export function extractLnurlw(ndef: Buffer): string | null {
// Robust to record framing: the URI record embeds the literal string; grab
// it directly, bounded to URL-safe characters so we stop at the record end.
const text = ndef.toString('latin1')
const urlChars = "[A-Za-z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+"
const m =
text.match(new RegExp('lnurlw://' + urlChars, 'i')) ||
text.match(new RegExp('https://' + urlChars + '/boltcards/' + urlChars, 'i'))
return m ? m[0] : null
}
const swOk = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00
/** Select an EF by its 2-byte file id and read + parse its NDEF message. */
async function readNdefFile(
send: (bytes: number[]) => Promise<Buffer>,
fid: [number, number]
): Promise<string | null> {
if (!swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, fid[0], fid[1]]))) return null
// 2-byte NLEN header at offset 0.
const lenResp = await send([0x00, 0xb0, 0x00, 0x00, 0x02])
if (!swOk(lenResp)) return null
const nlen = (lenResp[0] << 8) | lenResp[1]
if (nlen <= 0 || nlen > 0x2000) return null
// NDEF message starts at offset 2; read in <=250-byte chunks.
const chunks: Buffer[] = []
let offset = 2
let remaining = nlen
while (remaining > 0) {
const toRead = Math.min(remaining, 0xfa)
const resp = await send([0x00, 0xb0, (offset >> 8) & 0xff, offset & 0xff, toRead])
if (!swOk(resp)) break
const data = resp.subarray(0, resp.length - 2)
if (data.length === 0) break
chunks.push(data)
offset += data.length
remaining -= data.length
}
return extractLnurlw(Buffer.concat(chunks))
}
/**
* Read the NDEF of a Type-4 tag and return the extracted lnurlw, or null.
* `transmit(apdu, maxLen) => Buffer` including the trailing SW1 SW2.
*
* Select the NDEF Tag Application, read the Capability Container to learn the
* real NDEF FileID (NTAG424 Bolt Cards use E104, not the 0004 some tags use),
* then read that file. Falls back to E104/0004 if the CC read is unavailable.
*/
export async function readNdefLnurlw(
transmit: (apdu: Buffer, maxLen: number) => Promise<Buffer>
): Promise<string | null> {
const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256)
// Select the NDEF Tag Application (AID D2760000850101).
if (
!swOk(
await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00])
)
) {
return null
}
// NTAG424 Bolt Cards use NDEF FileID E104. Try it (and 0004) directly to
// minimise APDU round-trips over a flaky RF link; only fall back to reading
// the Capability Container to discover the id if both direct reads fail.
for (const fid of [[0xe1, 0x04] as [number, number], [0x00, 0x04] as [number, number]]) {
const found = await readNdefFile(send, fid)
if (found) return found
}
if (swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x03]))) {
const cc = await send([0x00, 0xb0, 0x00, 0x00, 0x0f])
// CC layout: …[07]=TLV tag 0x04, [08]=len, [09..10]=NDEF FileID.
if (swOk(cc) && cc.length >= 13 && cc[7] === 0x04) {
const found = await readNdefFile(send, [cc[9], cc[10]])
if (found) return found
}
}
return null
}
let stopFn: (() => void) | null = null
// ── Wedge auto-recovery ───────────────────────────────────────────────────
// Cheap CCID readers (the Feitian R502-CL especially) occasionally wedge: they
// keep detecting a card but every APDU returns "card absent or mute", and ONLY
// a USB power-cycle clears it — pcscd/app restarts do NOT. When we see a run of
// consecutive read failures we trigger nfc-reader-reset.service (a root oneshot
// that re-binds the reader's USB device = a software replug); nfc-pcsc then
// re-detects the reader on hotplug with no app restart. The trigger is gated by
// a cooldown so a still-wedged reader can't reset-loop. A quality reader (e.g.
// ACR1252U) wedges far less; this is belt-and-suspenders for any reader.
const WEDGE_FAILURE_THRESHOLD = 3
const RESET_COOLDOWN_MS = 30_000
// Persist across reader re-enumerations (a reset spawns a fresh reader closure).
let lastReaderResetAt = 0
/** Trigger the privileged USB power-cycle of the reader. Best-effort. */
function resetWedgedReader(): void {
// NixOS: the app runs unprivileged as `bitspire`; a polkit rule authorises it
// to start this one unit. systemctl lives at a stable path on the device.
execFile('/run/current-system/sw/bin/systemctl', ['start', 'nfc-reader-reset.service'], () => {
/* best-effort — if it fails the reader stays wedged until a manual reset */
})
}
/**
* 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
// while resolving normally at runtime.
const pkg = 'nfc-pcsc'
mod = (await import(pkg)) as unknown
} catch (e) {
onStatus({ state: 'unavailable', message: `NFC library unavailable: ${errMsg(e)}` })
return () => {}
}
const NFC =
(mod as { NFC?: unknown }).NFC ?? (mod as { default?: { NFC?: unknown } }).default?.NFC
if (typeof NFC !== 'function') {
onStatus({ state: 'unavailable', message: 'NFC library has no NFC export' })
return () => {}
}
let nfc: { on: (e: string, cb: (...a: unknown[]) => void) => void; close?: () => void }
try {
nfc = new (NFC as new () => typeof nfc)()
} catch (e) {
onStatus({ state: 'unavailable', message: `NFC init failed: ${errMsg(e)}` })
return () => {}
}
nfc.on('reader', (reader: unknown) => {
const r = reader as {
name?: string
reader?: { name?: string }
autoProcessing?: boolean
on: (e: string, cb: (...a: unknown[]) => void) => void
transmit: (data: Buffer, maxLen: number) => Promise<Buffer>
}
const name = r.name ?? r.reader?.name ?? 'reader'
// We do our own NDEF APDU read, not nfc-pcsc's UID auto-processing.
r.autoProcessing = false
onStatus({ state: 'ready', reader: name })
// Cooldown after a failed read: these cheap CCID readers can get wedged into
// a present↔empty storm when hammered, so ignore re-detections for a beat
// after a failure. Successful reads don't cool down.
let cooldownUntil = 0
// Consecutive failed reads → wedge detection (see resetWedgedReader above).
// A completed read (Bolt Card or not) proves the reader is healthy and
// clears the count; only a run of thrown transmits trips the reset.
let consecutiveFailures = 0
r.on('card', async () => {
if (Date.now() < cooldownUntil) return
onStatus({ state: 'reading', reader: name })
// Single attempt: retrying hammers a flaky RF link. A read is a few APDU
// round-trips; if the card shifts mid-read the transmit fails and the
// user simply re-taps.
try {
const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen))
consecutiveFailures = 0
if (lnurlw) {
onCard(lnurlw)
return
}
onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' })
} catch (e) {
consecutiveFailures++
if (
consecutiveFailures >= WEDGE_FAILURE_THRESHOLD &&
Date.now() - lastReaderResetAt > RESET_COOLDOWN_MS
) {
// Reader looks wedged — auto power-cycle it (only fix that works).
lastReaderResetAt = Date.now()
consecutiveFailures = 0
onStatus({ state: 'error', reader: name, message: 'reader stuck — auto-resetting…' })
resetWedgedReader()
} else {
onStatus({
state: 'error',
reader: name,
message: 'card read failed — hold steady & retap',
})
}
void e
}
cooldownUntil = Date.now() + 1500
})
r.on('card.off', () => onStatus({ state: 'card-removed', reader: name }))
r.on('error', (err: unknown) =>
onStatus({ state: 'error', reader: name, message: errMsg(err) })
)
r.on('end', () =>
onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' })
)
})
nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) }))
stopFn = () => {
try {
nfc.close?.()
} catch {
/* idempotent */
}
stopFn = null
}
return stopFn
}