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
|
|
@ -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) {
|
||||
|
|
|
|||
66
apps/machine/electron/nfc-service.test.ts
Normal file
66
apps/machine/electron/nfc-service.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue