feat(machine): NFC Bolt Card reader driver + IPC (main process)

Main-process driver over nfc-pcsc (PC/SC). On tap it reads the NTAG424
Type-4 NDEF file via ISO7816 APDUs (select NDEF app D2760000850101 →
select file → ReadBinary NLEN + message) and extracts the lnurlw voucher
(fresh SUN p/c per tap), forwarding it to the renderer on `nfc:card-tapped`
(+ `nfc:status`). Lazy, guarded import — a missing reader/pcscd just
reports 'unavailable', never breaking the cash-out QR path. Preload
removeAllListeners guards against a double payment-trigger on renderer reload.

- electron/nfc-service.ts: startNfcReader() + readNdefLnurlw()/extractLnurlw().
- electron/nfc-service.test.ts: 7 tests (NDEF URI extraction, Type-4 read
  sequence incl. AID select, empty-file + select-fail handling).
- main.ts start + IPC forward; preload + electron.d.ts listeners.
- add nfc-pcsc dep (native @pokusew/pcsclite; nix build handling next).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-08-05 04:42:07 +02:00
commit 84d746a0da
7 changed files with 281 additions and 1 deletions

View file

@ -195,6 +195,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('hal:error', (_event, error) => callback(error))
},
// Bolt Card reader (main process → renderer). removeAllListeners first: a
// renderer reload re-runs this, and a duplicated card-tap listener would
// trigger the LNURL-withdraw twice.
onNfcCardTapped: (callback: (lnurlw: string) => void) => {
ipcRenderer.removeAllListeners('nfc:card-tapped')
ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw))
},
onNfcStatus: (
callback: (status: { state: string; reader?: string; message?: string }) => void
) => {
ipcRenderer.removeAllListeners('nfc:status')
ipcRenderer.on('nfc:status', (_event, status) => callback(status))
},
// Watchdog heartbeat (main process → renderer → main process)
onWatchdogPing: (callback: () => void) => {
ipcRenderer.on('watchdog:ping', () => callback())
@ -295,6 +309,10 @@ declare global {
onHalBillInserted: (callback: (denomination: number) => void) => void
onHalBillRejected: (callback: (reason: string) => void) => void
onHalError: (callback: (error: string) => void) => void
onNfcCardTapped: (callback: (lnurlw: string) => void) => void
onNfcStatus: (
callback: (status: { state: string; reader?: string; message?: string }) => void
) => void
onWatchdogPing: (callback: () => void) => void
watchdogPong: () => Promise<void>
platform: NodeJS.Platform