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:
parent
74c420fbd3
commit
84d746a0da
7 changed files with 281 additions and 1 deletions
157
apps/machine/electron/nfc-service.ts
Normal file
157
apps/machine/electron/nfc-service.ts
Normal file
|
|
@ -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<Buffer>
|
||||
): Promise<string | null> {
|
||||
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<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 })
|
||||
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue