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
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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue