diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 9b4815d..ac15f6e 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -43,6 +43,7 @@ import { } from './state-store.js' import { initializeHal, type HalInstance } from './hal-service.js' import { executeLnurlWithdraw } from './lnurl-withdraw.js' +import { startNfcReader, type NfcStatus } from './nfc-service.js' // ESM equivalent of __dirname const __filename = fileURLToPath(import.meta.url) @@ -828,6 +829,14 @@ app.whenReady().then(() => { startWatchdog() startCommandPoller() + // 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) => mainWindow?.webContents.send('nfc:card-tapped', lnurlw), + (status) => mainWindow?.webContents.send('nfc:status', status) + ) + app.on('activate', () => { // macOS: re-create window when dock icon clicked if (BrowserWindow.getAllWindows().length === 0) { diff --git a/apps/machine/electron/nfc-service.test.ts b/apps/machine/electron/nfc-service.test.ts new file mode 100644 index 0000000..3023d36 --- /dev/null +++ b/apps/machine/electron/nfc-service.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi } from 'vitest' +import { extractLnurlw, readNdefLnurlw } from './nfc-service' + +const LNURLW = + 'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788' + +/** Build a Type-4 NDEF message with a single URI record carrying `uri`. */ +function ndefUriMessage(uri: string): Buffer { + const uriBytes = Buffer.from(uri, 'ascii') + const payload = Buffer.concat([Buffer.from([0x00]), uriBytes]) // 0x00 = no prefix + // D1 = MB|ME|SR, TNF=well-known; type length 1; payload length; 'U' + return Buffer.concat([Buffer.from([0xd1, 0x01, payload.length, 0x55]), payload]) +} + +describe('extractLnurlw', () => { + it('pulls an lnurlw:// URI out of an NDEF record', () => { + expect(extractLnurlw(ndefUriMessage(LNURLW))).toBe(LNURLW) + }) + it('pulls a boltcards https scan URL', () => { + const https = 'https://lnbits.l484.com/boltcards/api/v1/scan/x?p=aa&c=bb' + expect(extractLnurlw(ndefUriMessage(https))).toBe(https) + }) + it('stops at the record boundary (no trailing binary)', () => { + const msg = Buffer.concat([ndefUriMessage(LNURLW), Buffer.from([0x00, 0xfe, 0x01])]) + expect(extractLnurlw(msg)).toBe(LNURLW) + }) + it('returns null when there is no lnurl', () => { + expect(extractLnurlw(Buffer.from('just some text', 'ascii'))).toBeNull() + }) +}) + +describe('readNdefLnurlw', () => { + const SW_OK = Buffer.from([0x90, 0x00]) + + it('runs the Type-4 read sequence and returns the lnurlw', async () => { + const msg = ndefUriMessage(LNURLW) + const nlen = msg.length + const transmit = vi + .fn() + .mockResolvedValueOnce(SW_OK) // select NDEF app + .mockResolvedValueOnce(SW_OK) // select NDEF file + .mockResolvedValueOnce(Buffer.concat([Buffer.from([(nlen >> 8) & 0xff, nlen & 0xff]), SW_OK])) // NLEN + .mockResolvedValueOnce(Buffer.concat([msg, SW_OK])) // NDEF message + + const out = await readNdefLnurlw(transmit) + expect(out).toBe(LNURLW) + // First APDU selects the NDEF application (AID D2760000850101). + expect(Buffer.from((transmit.mock.calls[0][0] as Buffer)).toString('hex')).toContain( + 'd2760000850101' + ) + }) + + it('returns null if selecting the NDEF app fails', async () => { + const transmit = vi.fn().mockResolvedValue(Buffer.from([0x6a, 0x82])) // file not found SW + expect(await readNdefLnurlw(transmit)).toBeNull() + }) + + it('returns null on an empty NDEF file', async () => { + const transmit = vi + .fn() + .mockResolvedValueOnce(SW_OK) + .mockResolvedValueOnce(SW_OK) + .mockResolvedValueOnce(Buffer.concat([Buffer.from([0x00, 0x00]), SW_OK])) // NLEN = 0 + expect(await readNdefLnurlw(transmit)).toBeNull() + }) +}) diff --git a/apps/machine/electron/nfc-service.ts b/apps/machine/electron/nfc-service.ts new file mode 100644 index 0000000..e7e3f00 --- /dev/null +++ b/apps/machine/electron/nfc-service.ts @@ -0,0 +1,157 @@ +/** + * 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. + */ + +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 +} + +/** + * Read the NDEF file of a Type-4 tag and return the extracted lnurlw, or null. + * `transmit(apdu, maxLen) => Buffer` including the trailing SW1 SW2. + */ +export async function readNdefLnurlw( + transmit: (apdu: Buffer, maxLen: number) => Promise +): Promise { + const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256) + const ok = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00 + + // 1) Select the NDEF Tag Application (AID D2760000850101). + if (!ok(await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]))) { + return null + } + // 2) Select the NDEF file (EF 0x0004). + if (!ok(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0x00, 0x04]))) return null + // 3) Read the 2-byte NLEN header. + const lenResp = await send([0x00, 0xb0, 0x00, 0x00, 0x02]) + if (!ok(lenResp)) return null + const nlen = (lenResp[0] << 8) | lenResp[1] + if (nlen <= 0 || nlen > 0x2000) return null + // 4) Read the NDEF message (starts at offset 2), 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 (!ok(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)) +} + +let stopFn: (() => void) | null = null + +/** + * Start listening for Bolt Card taps. Idempotent. Returns a stop function. + * Never throws — failures surface via onStatus. + */ +export async function startNfcReader( + onCard: CardHandler, + onStatus: StatusHandler +): Promise<() => void> { + if (stopFn) return stopFn + + 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 + } + 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 }) + + r.on('card', async () => { + onStatus({ state: 'reading', reader: name }) + try { + const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen)) + if (lnurlw) onCard(lnurlw) + else onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' }) + } catch (e) { + onStatus({ state: 'error', reader: name, message: errMsg(e) }) + } + }) + 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 +} diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index d65cb80..8be0ed8 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -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 platform: NodeJS.Platform diff --git a/apps/machine/package.json b/apps/machine/package.json index de432cb..7f68db8 100644 --- a/apps/machine/package.json +++ b/apps/machine/package.json @@ -35,6 +35,7 @@ "clsx": "^2.1.1", "lucide-vue-next": "^0.563.0", "marked": "^17.0.5", + "nfc-pcsc": "^0.8.1", "nostr-tools": "^2.10.0", "pinia": "^2.2.0", "qr": "^0.6.0", diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index b77f850..d391944 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -146,6 +146,12 @@ declare global { onHalBillInserted: (callback: (denomination: number) => void) => void onHalBillRejected: (callback: (reason: string) => void) => void onHalError: (callback: (error: string) => void) => void + /** Bolt Card reader: a tapped card's lnurlw voucher. */ + onNfcCardTapped: (callback: (lnurlw: string) => void) => void + /** Bolt Card reader status (ready / reading / error / unavailable). */ + onNfcStatus: ( + callback: (status: { state: string; reader?: string; message?: string }) => void + ) => void onWatchdogPing: (callback: () => void) => void watchdogPong: () => Promise platform: NodeJS.Platform diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fe2b33..3f91de1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: marked: specifier: ^17.0.5 version: 17.0.5 + nfc-pcsc: + specifier: ^0.8.1 + version: 0.8.1 nostr-tools: specifier: ^2.10.0 version: 2.19.4(typescript@5.9.3) @@ -897,6 +900,9 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@pokusew/pcsclite@0.6.0': + resolution: {integrity: sha512-jX7zRXM2Or5Pms1AFjNtawsXDjLiZOzOUo7Sf0put7Pnq/EKIR9g0KvTx62HtwdPpVP6hWHGydUTHgIi9PxodQ==} + '@rollup/rollup-android-arm-eabi@4.56.0': resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==} cpu: [arm] @@ -2484,6 +2490,9 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2496,6 +2505,9 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + nfc-pcsc@0.8.1: + resolution: {integrity: sha512-wEfacG0dwPVZOG/WY28Mk3P4Q+yz6q7LnjpnZvdFddx3iXavEXiGhftRZXBtudr0NrzH1MrGWSkWq77tef7BMA==} + node-abi@3.87.0: resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} engines: {node: '>=10'} @@ -2966,7 +2978,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me temp-file@3.4.0: resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} @@ -3760,6 +3772,11 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@pokusew/pcsclite@0.6.0': + dependencies: + bindings: 1.5.0 + nan: 2.28.0 + '@rollup/rollup-android-arm-eabi@4.56.0': optional: true @@ -5506,12 +5523,18 @@ snapshots: muggle-string@0.4.1: {} + nan@2.28.0: {} + nanoid@3.3.11: {} napi-build-utils@2.0.0: {} negotiator@0.6.4: {} + nfc-pcsc@0.8.1: + dependencies: + '@pokusew/pcsclite': 0.6.0 + node-abi@3.87.0: dependencies: semver: 7.7.3