diff --git a/forge.config.ts b/forge.config.ts index 59d7567..26f02c0 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -26,7 +26,7 @@ const isOnGithubActions = process.env.CI === 'true'; // other wcpos apps. Keep this in sync if the Flathub app id changes. const LINUX_APP_ID = 'com.wcpos.main'; -const runtimeExternalDependencies = ['usb', 'node-gyp-build']; +const runtimeExternalDependencies = ['usb', 'serialport', 'node-gyp-build', 'debug', 'ms']; async function copyRuntimeExternalDependency(packageName: string, buildPath: string) { const sourcePackageJsonPath = require.resolve(`${packageName}/package.json`); @@ -78,6 +78,18 @@ const config: ForgeConfig = { for (const packageName of runtimeExternalDependencies) { await copyRuntimeExternalDependency(packageName, buildPath); } + + // serialport re-exports all @serialport/* sub-packages (parsers, bindings, stream). + // Some of these do not export `./package.json` so they cannot be copied individually + // via require.resolve; copy the whole @serialport namespace directory instead. + const serialportNamespaceSrc = path.join( + path.dirname(require.resolve('serialport/package.json')), + '..', + '@serialport' + ); + const serialportNamespaceDest = path.join(buildPath, 'node_modules', '@serialport'); + await remove(serialportNamespaceDest); + await copy(serialportNamespaceSrc, serialportNamespaceDest, { dereference: true }); }, postMake: async (forgeConfig, makeResults) => { // Having a space in the name is not allowed on GitHub releases diff --git a/package.json b/package.json index a7d1801..2e5130c 100644 --- a/package.json +++ b/package.json @@ -24,14 +24,16 @@ "publish-app": "electron-forge publish", "lint": "eslint src --ext .js,.jsx,.ts,.tsx", "lint:fix": "eslint src --ext .js,.jsx,.ts,.tsx --fix", - "test": "pnpm run test:preload && pnpm run test:printer-discovery && pnpm run test:winspool-printer && pnpm run test:rxdb-ipc-attachments && pnpm run test:image-cache && pnpm run test:package-runtime-externals", + "test": "pnpm run test:preload && pnpm run test:printer-discovery && pnpm run test:winspool-printer && pnpm run test:rxdb-ipc-attachments && pnpm run test:image-cache && pnpm run test:package-runtime-externals && pnpm run test:bluetooth-select && pnpm run test:serial-printer", "ts:check": "tsc --noEmit", "test:preload": "ts-node src/preload.test.ts", "test:rxdb-ipc-attachments": "ts-node src/rxdb-ipc-attachments.test.ts", "test:image-cache": "ts-node src/main/image-cache.test.ts", "test:printer-discovery": "ts-node src/main/printer-discovery.test.ts", "test:winspool-printer": "ts-node src/main/winspool-printer.test.ts", - "test:package-runtime-externals": "ts-node --transpile-only test/package-runtime-externals.test.ts" + "test:package-runtime-externals": "ts-node --transpile-only test/package-runtime-externals.test.ts", + "test:bluetooth-select": "ts-node src/main/bluetooth-select.test.ts", + "test:serial-printer": "ts-node src/main/serial-printer.test.ts" }, "dependencies": { "@sentry/electron": "^7.13.0", @@ -45,6 +47,7 @@ "rxdb-premium": "17.2.0", "rxjs": "7.8.2", "semver": "^7.8.4", + "serialport": "13.0.0", "usb": "2.18.0" }, "devDependencies": { diff --git a/src/index.ts b/src/index.ts index f1e5bcb..de9b964 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import './main/axios'; import './main/image-cache'; import './main/print-external-url'; import './main/print-raw-tcp'; +import './main/serial-printer'; import './main/usb-printer'; import './main/printer-discovery'; import './main/basePath'; diff --git a/src/main/bluetooth-select.test.ts b/src/main/bluetooth-select.test.ts new file mode 100644 index 0000000..9e70e6b --- /dev/null +++ b/src/main/bluetooth-select.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import Module from 'node:module'; + +type ModuleWithMutableLoad = typeof Module & { + _load: (request: string, parent: NodeModule | null, isMain: boolean) => unknown; +}; + +const fakeIpcMain = new EventEmitter(); + +const mutableModule = Module as ModuleWithMutableLoad; +const originalLoad = mutableModule._load; +mutableModule._load = function patchedLoad( + request: string, + parent: NodeModule | null, + isMain: boolean +) { + if (request === 'electron') return { ipcMain: fakeIpcMain }; + if (request === './log') { + return { logger: { error() {}, info() {}, warn() {}, debug() {} } }; + } + return originalLoad.call(this, request, parent, isMain); +}; + +try { + const { registerBluetoothSelection } = + require('./bluetooth-select') as typeof import('./bluetooth-select'); + + // Fake BrowserWindow: webContents is an EventEmitter with a send() recorder. + class FakeWebContents extends EventEmitter { + sent: { channel: string; payload: unknown }[] = []; + send(channel: string, payload: unknown) { + this.sent.push({ channel, payload }); + } + } + const webContents = new FakeWebContents(); + const win = new EventEmitter() as EventEmitter & { webContents: FakeWebContents }; + win.webContents = webContents; + + registerBluetoothSelection(win as never); + + const calls: string[][] = []; + const makeCallback = (label: string) => { + const received: string[] = []; + calls.push(received); + return (deviceId: string) => received.push(`${label}:${deviceId}`); + }; + const noopEvent = { preventDefault() {} }; + + // The chooser event fires repeatedly during one session — exactly ONE reply + // listener must exist regardless of firing count. + webContents.emit( + 'select-bluetooth-device', + noopEvent, + [{ deviceId: 'a', deviceName: 'Printer A' }], + makeCallback('first') + ); + webContents.emit( + 'select-bluetooth-device', + noopEvent, + [ + { deviceId: 'a', deviceName: 'Printer A' }, + { deviceId: 'b', deviceName: 'Printer B' }, + ], + makeCallback('second') + ); + assert.equal(fakeIpcMain.listenerCount('bluetooth-device-selected'), 1); + + // Candidates are forwarded to the renderer on every firing. + assert.equal(webContents.sent.length, 2); + assert.deepEqual(webContents.sent[1], { + channel: 'bluetooth-devices', + payload: [ + { id: 'a', name: 'Printer A' }, + { id: 'b', name: 'Printer B' }, + ], + }); + + // A reply invokes ONLY the latest callback, exactly once. + fakeIpcMain.emit('bluetooth-device-selected', { sender: webContents }, 'b'); + assert.deepEqual(calls[0], []); + assert.deepEqual(calls[1], ['second:b']); + + // A second reply with no pending chooser is dropped, not crashed. + fakeIpcMain.emit('bluetooth-device-selected', { sender: webContents }, 'b'); + assert.deepEqual(calls[1], ['second:b']); + + // Replies from another window's webContents are ignored. + webContents.emit( + 'select-bluetooth-device', + noopEvent, + [{ deviceId: 'c', deviceName: 'Printer C' }], + makeCallback('third') + ); + fakeIpcMain.emit('bluetooth-device-selected', { sender: new FakeWebContents() }, 'c'); + assert.deepEqual(calls[2], []); + + // Window close removes the IPC listener entirely. + win.emit('closed'); + assert.equal(fakeIpcMain.listenerCount('bluetooth-device-selected'), 0); + + console.log('bluetooth-select tests passed'); +} finally { + mutableModule._load = originalLoad; +} diff --git a/src/main/bluetooth-select.ts b/src/main/bluetooth-select.ts index d7f5f02..5818795 100644 --- a/src/main/bluetooth-select.ts +++ b/src/main/bluetooth-select.ts @@ -1,20 +1,45 @@ -import { type BrowserWindow, ipcMain } from 'electron'; +import { type BrowserWindow, ipcMain, type IpcMainEvent } from 'electron'; import { logger } from './log'; -/** Wire Bluetooth device selection for a window. Call once per BrowserWindow after creation. */ +/** + * Wire Bluetooth device selection for a window. Call once per BrowserWindow after creation. + * + * Chromium fires `select-bluetooth-device` repeatedly during one requestDevice() chooser + * session as the candidate list grows. Only the LATEST callback may be invoked, exactly + * once — so a single persistent reply listener holds a single pending callback, instead + * of queueing one `ipcMain.once` per firing (stale FIFO listeners ate real selections). + */ export function registerBluetoothSelection(window: BrowserWindow): void { + let pendingCallback: ((deviceId: string) => void) | null = null; + window.webContents.on('select-bluetooth-device', (event, devices, callback) => { event.preventDefault(); + logger.debug(`[bluetooth] select-bluetooth-device fired with ${devices.length} device(s)`); + pendingCallback = callback; // Surface candidates to the renderer picker. window.webContents.send( 'bluetooth-devices', devices.map((d) => ({ id: d.deviceId, name: d.deviceName })) ); - // Renderer replies with the chosen deviceId (or '' to cancel). - ipcMain.once('bluetooth-device-selected', (_e, deviceId: string) => { - logger.info(`Bluetooth device selected: ${deviceId || '(cancelled)'}`); - callback(deviceId); - }); + }); + + // Renderer replies with the chosen deviceId ('' cancels the chooser). + const onSelected = (event: IpcMainEvent, deviceId: string) => { + if (event.sender !== window.webContents) return; + if (!pendingCallback) { + logger.info('[bluetooth] selection received with no pending chooser — ignored'); + return; + } + logger.info(`[bluetooth] device selected: ${deviceId || '(cancelled)'}`); + const callback = pendingCallback; + pendingCallback = null; + callback(deviceId); + }; + + ipcMain.on('bluetooth-device-selected', onSelected); + window.on('closed', () => { + ipcMain.removeListener('bluetooth-device-selected', onSelected); + pendingCallback = null; }); } diff --git a/src/main/serial-printer.test.ts b/src/main/serial-printer.test.ts new file mode 100644 index 0000000..c2a1ef6 --- /dev/null +++ b/src/main/serial-printer.test.ts @@ -0,0 +1,427 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import Module from 'node:module'; + +type ModuleWithMutableLoad = typeof Module & { + _load: (request: string, parent: NodeModule | null, isMain: boolean) => unknown; +}; + +// ── Fake ipcMain ────────────────────────────────────────────────────────────── +const handlers = new Map unknown>(); +const fakeIpcMain = { + handle(channel: string, fn: (...args: unknown[]) => unknown) { + handlers.set(channel, fn); + }, +}; + +// ── Fake SerialPort ─────────────────────────────────────────────────────────── +type PortCallback = (err?: Error | null) => void; + +interface PortCall { + method: string; + arg?: unknown; +} + +class FakeSerialPort extends EventEmitter { + static listResult: { path: string }[] = []; + static listError: Error | null = null; + static instances: FakeSerialPort[] = []; + + readonly path: string; + readonly baudRate: number; + isOpen = false; + calls: PortCall[] = []; + + // Control knobs set per-test + openError: Error | null = null; + writeError: Error | null = null; + drainError: Error | null = null; + + constructor(opts: { path: string; baudRate: number; autoOpen: boolean }) { + super(); + this.path = opts.path; + this.baudRate = opts.baudRate; + FakeSerialPort.instances.push(this); + } + + static list(): Promise<{ path: string }[]> { + if (FakeSerialPort.listError) return Promise.reject(FakeSerialPort.listError); + return Promise.resolve(FakeSerialPort.listResult); + } + + open(cb: PortCallback) { + this.calls.push({ method: 'open' }); + if (this.openError) { + cb(this.openError); + return; + } + this.isOpen = true; + cb(null); + } + + write(buf: Buffer, cb: PortCallback) { + this.calls.push({ method: 'write', arg: Array.from(buf) }); + cb(this.writeError ?? null); + } + + drain(cb: PortCallback) { + this.calls.push({ method: 'drain' }); + cb(this.drainError ?? null); + } + + close(cb?: () => void) { + this.calls.push({ method: 'close' }); + this.isOpen = false; + cb?.(); + } +} + +// ── Module patching ─────────────────────────────────────────────────────────── +const mutableModule = Module as ModuleWithMutableLoad; +const originalLoad = mutableModule._load; + +function installDefaultLoad() { + mutableModule._load = function patchedLoad( + request: string, + parent: NodeModule | null, + isMain: boolean + ) { + if (request === 'electron') return { ipcMain: fakeIpcMain }; + if (request === './log') { + return { logger: { error() {}, info() {}, warn() {}, debug() {} } }; + } + if (request === 'serialport') return { SerialPort: FakeSerialPort }; + return originalLoad.call(this, request, parent, isMain); + }; +} + +function setPlatform(platform: string) { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); +} + +const originalPlatform = process.platform; + +async function main() { + installDefaultLoad(); + + // Load module — registers handlers at top level via ipcMain.handle + const { filterSerialPorts, SERIAL_PREFIX } = + require('./serial-printer') as typeof import('./serial-printer'); + + // ────────────────────────────────────────────────────────────────────────── + // filterSerialPorts — darwin + // ────────────────────────────────────────────────────────────────────────── + + setPlatform('darwin'); + + // Only /dev/cu.* pass on macOS; /dev/tty.* are dropped; noise patterns excluded + assert.deepEqual( + filterSerialPorts([ + { path: '/dev/tty.Bluetooth-Incoming-Port' }, + { path: '/dev/cu.Bluetooth-Incoming-Port' }, // noise pattern also drops this + { path: '/dev/tty.usbserial-0001' }, + { path: '/dev/cu.usbserial-0001' }, + { path: '/dev/cu.TM-P20-SerialPort' }, + ]), + [ + { id: 'serial:/dev/cu.usbserial-0001', name: 'usbserial 0001' }, + { id: 'serial:/dev/cu.TM-P20-SerialPort', name: 'TM P20 SerialPort' }, + ] + ); + + // Noise-pattern test: Bluetooth-Incoming-Port is excluded + const noisy = filterSerialPorts([{ path: '/dev/cu.Bluetooth-Incoming-Port' }]); + assert.equal(noisy.length, 0, 'Bluetooth-Incoming-Port should be filtered out'); + + // Name mapping: hyphen/underscore → space + const mapped = filterSerialPorts([{ path: '/dev/cu.TM-T20III-Receipt' }]); + assert.deepEqual(mapped, [{ id: 'serial:/dev/cu.TM-T20III-Receipt', name: 'TM T20III Receipt' }]); + + // ────────────────────────────────────────────────────────────────────────── + // filterSerialPorts — linux rfcomm naming + // ────────────────────────────────────────────────────────────────────────── + + setPlatform('linux'); + + // /dev/rfcomm0 → stripped is '0' (just digits) → fall back to basename 'rfcomm0' + const rfcomm = filterSerialPorts([{ path: '/dev/rfcomm0' }, { path: '/dev/rfcomm12' }]); + assert.deepEqual(rfcomm, [ + { id: 'serial:/dev/rfcomm0', name: 'rfcomm0' }, + { id: 'serial:/dev/rfcomm12', name: 'rfcomm12' }, + ]); + + // /dev/ttyUSB0 on Linux (no darwin filter): regex strips cu./tty./rfcomm but not plain 'tty', + // so the full basename 'ttyUSB0' is used as-is (not all-digits, so fallback doesn't apply). + const linuxUsb = filterSerialPorts([{ path: '/dev/ttyUSB0' }]); + assert.deepEqual(linuxUsb, [{ id: 'serial:/dev/ttyUSB0', name: 'ttyUSB0' }]); + + // ────────────────────────────────────────────────────────────────────────── + // SERIAL_PREFIX constant + // ────────────────────────────────────────────────────────────────────────── + assert.equal(SERIAL_PREFIX, 'serial:'); + + // ────────────────────────────────────────────────────────────────────────── + // serial-discovery handler — win32 returns [] without calling list() + // ────────────────────────────────────────────────────────────────────────── + const discoveryHandler = handlers.get('serial-discovery') as () => Promise; + assert.ok(discoveryHandler, 'serial-discovery handler must be registered'); + + FakeSerialPort.instances.length = 0; + FakeSerialPort.listResult = [{ path: '/dev/cu.Printer-1' }]; + + setPlatform('win32'); + const win32Result = await discoveryHandler(); + assert.deepEqual(win32Result, [], 'serial-discovery should return [] on win32'); + // list() must not be called on win32 + assert.equal(FakeSerialPort.instances.length, 0, 'SerialPort.list should not be called on win32'); + + // ────────────────────────────────────────────────────────────────────────── + // serial-discovery handler — non-win32 calls SerialPort.list + // ────────────────────────────────────────────────────────────────────────── + setPlatform('darwin'); + FakeSerialPort.listResult = [ + { path: '/dev/tty.Bluetooth-Incoming-Port' }, + { path: '/dev/cu.TM-T88V-SerialPort' }, + ]; + const darwinResult = (await discoveryHandler()) as { id: string; name: string }[]; + assert.equal(darwinResult.length, 1, 'darwin discovery should return 1 filtered port'); + assert.equal(darwinResult[0].id, 'serial:/dev/cu.TM-T88V-SerialPort'); + assert.equal(darwinResult[0].name, 'TM T88V SerialPort'); + + // ────────────────────────────────────────────────────────────────────────── + // print-raw-serial handler — arg validation + // ────────────────────────────────────────────────────────────────────────── + const printHandler = handlers.get('print-raw-serial') as ( + _e: null, + args: unknown + ) => Promise; + assert.ok(printHandler, 'print-raw-serial handler must be registered'); + + // missing args + await assert.rejects( + () => printHandler(null, null), + /Invalid arguments/, + 'should reject missing args' + ); + + // wrong prefix + await assert.rejects( + () => printHandler(null, { device: 'usb:1:2:3:4', data: [0x1b, 0x40] }), + /Invalid serial device key/, + 'should reject non-serial device key' + ); + + // non-byte data + await assert.rejects( + () => printHandler(null, { device: 'serial:/dev/cu.Printer', data: [0, 300] }), + /Invalid data/, + 'should reject out-of-range byte values' + ); + + // non-array data + await assert.rejects( + () => printHandler(null, { device: 'serial:/dev/cu.Printer', data: 'hello' }), + /Invalid data/, + 'should reject non-array data' + ); + + // ────────────────────────────────────────────────────────────────────────── + // print-raw-serial handler — happy path: open → write → drain → close + // ────────────────────────────────────────────────────────────────────────── + FakeSerialPort.instances.length = 0; + setPlatform('darwin'); + + await printHandler(null, { device: 'serial:/dev/cu.TestPrinter', data: [0x1b, 0x40, 0x0a] }); + + assert.equal(FakeSerialPort.instances.length, 1, 'exactly one SerialPort should be constructed'); + const happyPort = FakeSerialPort.instances[0]; + assert.equal(happyPort.path, '/dev/cu.TestPrinter'); + assert.equal(happyPort.baudRate, 9600); + assert.deepEqual( + happyPort.calls.map((c) => c.method), + ['open', 'write', 'drain', 'close'], + 'calls should be open → write → drain → close' + ); + assert.deepEqual( + happyPort.calls[1].arg, + [0x1b, 0x40, 0x0a], + 'write should receive the data buffer' + ); + + // ────────────────────────────────────────────────────────────────────────── + // print-raw-serial handler — write failure still calls close + // ────────────────────────────────────────────────────────────────────────── + + // Re-require with a FakeSerialPort variant that fails on write + delete require.cache[require.resolve('./serial-printer')]; + handlers.clear(); + + class WriteFailSerialPort extends FakeSerialPort { + constructor(opts: { path: string; baudRate: number; autoOpen: boolean }) { + super(opts); + this.writeError = new Error('write failed intentionally'); + } + } + + mutableModule._load = function patchedLoadWriteFail( + request: string, + parent: NodeModule | null, + isMain: boolean + ) { + if (request === 'electron') return { ipcMain: fakeIpcMain }; + if (request === './log') { + return { logger: { error() {}, info() {}, warn() {}, debug() {} } }; + } + if (request === 'serialport') return { SerialPort: WriteFailSerialPort }; + return originalLoad.call(this, request, parent, isMain); + }; + + require('./serial-printer'); + + const printHandlerWriteFail = handlers.get('print-raw-serial') as ( + _e: null, + args: unknown + ) => Promise; + + FakeSerialPort.instances.length = 0; + await assert.rejects( + () => + printHandlerWriteFail(null, { + device: 'serial:/dev/cu.TestPrinter', + data: [0x1b, 0x40], + }), + /write failed intentionally/, + 'should propagate write error' + ); + + assert.ok( + FakeSerialPort.instances.length > 0 && + FakeSerialPort.instances[FakeSerialPort.instances.length - 1].calls.some( + (c) => c.method === 'close' + ), + 'close should be called even when write fails' + ); + + // ────────────────────────────────────────────────────────────────────────── + // print-raw-serial handler — ghost-print guard: open() resolves after timeout + // ────────────────────────────────────────────────────────────────────────── + // Arrange: use a FakeSerialPort that captures the open callback without + // calling it immediately (simulating a slow device handshake that exceeds + // the print timeout). The module's PRINT_TIMEOUT_MS export is set to a + // small value so the test runs fast. + delete require.cache[require.resolve('./serial-printer')]; + handlers.clear(); + + let capturedOpenCallback: PortCallback | null = null; + + class DeferredOpenSerialPort extends FakeSerialPort { + open(cb: PortCallback) { + this.calls.push({ method: 'open' }); + // Capture a wrapper that sets isOpen before calling the original callback, + // matching what the real SerialPort does when it finally opens. + capturedOpenCallback = (err?: Error | null): void => { + if (!err) this.isOpen = true; + cb(err); + }; + } + } + + mutableModule._load = function patchedLoadDeferred( + request: string, + parent: NodeModule | null, + isMain: boolean + ) { + if (request === 'electron') return { ipcMain: fakeIpcMain }; + if (request === './log') { + return { logger: { error() {}, info() {}, warn() {}, debug() {} } }; + } + if (request === 'serialport') return { SerialPort: DeferredOpenSerialPort }; + return originalLoad.call(this, request, parent, isMain); + }; + + // Shrink the timeout to 10ms so the test completes immediately. + // printConfig is an exported mutable object — mutating its property affects the + // live module scope because the handler reads printConfig.timeoutMs at call time. + const { printConfig } = require('./serial-printer') as typeof import('./serial-printer'); + const originalTimeoutMs = printConfig.timeoutMs; + printConfig.timeoutMs = 10; + + const ghostPrintHandler = handlers.get('print-raw-serial') as ( + _e: null, + args: unknown + ) => Promise; + + FakeSerialPort.instances.length = 0; + capturedOpenCallback = null; + + // Start the print — it will time out in 10ms before open() resolves. + // Immediately attach a catch handler to prevent Node from treating the + // eventual rejection as unhandled before assert.rejects observes it. + const ghostPrintPromise = ghostPrintHandler(null, { + device: 'serial:/dev/cu.GhostPrinter', + data: [0x1b, 0x40], + }); + // Suppress unhandled-rejection noise; assert.rejects below will still see the error. + ghostPrintPromise.catch((_err: unknown): void => undefined); + + // Assert the promise rejects with a timeout error (also waits for it to settle) + await assert.rejects( + () => ghostPrintPromise, + /timed out/, + 'ghost-print: should reject with timeout when open() takes too long' + ); + + // Now fire the late open callback — simulates the device finally opening + // after the renderer has already seen a failure + assert.ok(capturedOpenCallback !== null, 'ghost-print: open callback should have been captured'); + (capturedOpenCallback as PortCallback)(null); + + // Give the late callback a tick to run cleanup + await new Promise((res) => setTimeout(res, 10)); + + assert.equal(FakeSerialPort.instances.length, 1, 'ghost-print: exactly one port created'); + const ghostPort = FakeSerialPort.instances[0]; + + // write must NEVER have been called — the guard prevented the ghost print + assert.ok( + !ghostPort.calls.some((c) => c.method === 'write'), + 'ghost-print: write must not be called after timeout' + ); + + // close must be called — the guard closes the now-open port + assert.ok( + ghostPort.calls.some((c) => c.method === 'close'), + 'ghost-print: close must be called to release the port after late open' + ); + + // Restore printConfig.timeoutMs to its original value for subsequent tests + printConfig.timeoutMs = originalTimeoutMs; + + // ────────────────────────────────────────────────────────────────────────── + // print-raw-serial handler — empty path: 'serial:' with no path component + // ────────────────────────────────────────────────────────────────────────── + // Re-use the last loaded module's handler (deferred-open variant is fine for + // validation tests — the handler rejects before constructing a SerialPort) + await assert.rejects( + () => ghostPrintHandler(null, { device: 'serial:', data: [0x1b] }), + /Invalid serial device key/, + 'empty path: device "serial:" with no port path should be rejected' + ); + + // Restore default loader + installDefaultLoad(); + + console.log('serial-printer tests passed'); +} + +main() + .then(() => { + setPlatform(originalPlatform); + mutableModule._load = originalLoad; + }) + .catch((error) => { + setPlatform(originalPlatform); + mutableModule._load = originalLoad; + console.error(error); + process.exit(1); + }); diff --git a/src/main/serial-printer.ts b/src/main/serial-printer.ts new file mode 100644 index 0000000..de84a35 --- /dev/null +++ b/src/main/serial-printer.ts @@ -0,0 +1,151 @@ +import { ipcMain } from 'electron'; +import { SerialPort } from 'serialport'; + +import { logger } from './log'; + +export const SERIAL_PREFIX = 'serial:'; + +export interface SerialPrinterInfo { + id: string; // `serial:` — stored as the profile address + name: string; +} + +// macOS ships virtual ports that can never be a printer; /dev/tty.* are the +// blocking call-in devices — we only offer the call-out /dev/cu.* counterparts. +const NOISE_PATTERNS = [/Bluetooth-Incoming-Port/i, /debug-console/i, /wlan-debug/i]; + +export function filterSerialPorts(ports: { path: string }[]): SerialPrinterInfo[] { + return ports + .filter((p) => { + if (process.platform === 'darwin' && !p.path.startsWith('/dev/cu.')) return false; + return !NOISE_PATTERNS.some((re) => re.test(p.path)); + }) + .map((p) => { + // Strip common prefixes for a human-readable name. If stripping leaves + // an empty string or a plain number (e.g. rfcomm0 → '0'), fall back to + // the last path component so names like 'rfcomm0' remain legible. + const basename = p.path.split('/').pop() ?? p.path; + const stripped = basename.replace(/^(cu\.|tty\.|rfcomm)/, ''); + const name = + stripped === '' || /^\d+$/.test(stripped) ? basename : stripped.replace(/[-_]/g, ' '); + return { + id: `${SERIAL_PREFIX}${p.path}`, + name, + }; + }); +} + +// Exported as a mutable object so tests can override timeoutMs without a module reload. +export const printConfig = { timeoutMs: 20_000 }; // renderer gives up at 30s — fail first with a real error + +ipcMain.handle('serial-discovery', async (): Promise => { + // Windows: OS-paired Bluetooth Classic printers are enumerated and printed via the + // spooler (winspool path). Offering serial ports here would list COM ports that are + // already covered, leading to duplicate entries or permission conflicts. + if (process.platform === 'win32') { + return []; + } + try { + const ports = await SerialPort.list(); + return filterSerialPorts(ports); + } catch (err) { + logger.error(`serial-discovery failed: ${err instanceof Error ? err.message : String(err)}`); + throw err; + } +}); + +ipcMain.handle( + 'print-raw-serial', + async (_event, args: { device: string; data: number[] }): Promise => { + if (!args || typeof args.device !== 'string') { + throw new Error('Invalid arguments: expected { device: string, data: number[] }'); + } + if ( + !Array.isArray(args.data) || + !args.data.every((b) => Number.isInteger(b) && b >= 0 && b <= 255) + ) { + throw new Error('Invalid data: must be an array of byte values (0-255)'); + } + if (!args.device.startsWith(SERIAL_PREFIX)) { + throw new Error(`Invalid serial device key: ${args.device}`); + } + + const portPath = args.device.slice(SERIAL_PREFIX.length); + if (portPath === '') { + throw new Error(`Invalid serial device key: ${args.device}`); + } + // SPP (Bluetooth Classic) virtual serial ports ignore baud rate — 9600 is the + // conventional default and what most receipt printer SDKs use. + const port = new SerialPort({ path: portPath, baudRate: 9600, autoOpen: false }); + + let settled = false; + let timeoutHandle: ReturnType | undefined; + + const cleanup = (): Promise => + new Promise((res) => { + if (!port.isOpen) { + res(); + return; + } + port.close(() => res()); + }); + + const printPromise = new Promise((resolve, reject) => { + const finish = (err?: Error) => { + if (settled) return; + settled = true; + if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); + if (err) { + logger.error(`print-raw-serial to ${portPath} failed: ${err.message}`); + reject(err); + } else { + resolve(); + } + }; + + timeoutHandle = setTimeout(() => { + void cleanup().finally(() => + finish( + new Error(`Serial print to "${portPath}" timed out after ${printConfig.timeoutMs}ms`) + ) + ); + }, printConfig.timeoutMs); + + port.open((openErr) => { + if (openErr) { + void cleanup().finally(() => finish(openErr)); + return; + } + + // The timeout may have fired while open() was still pending. + // If already settled, close the now-open port and bail out to + // prevent a ghost print after the renderer has already seen a failure. + if (settled) { + void cleanup(); + return; + } + + port.write(Buffer.from(args.data), (writeErr) => { + if (writeErr) { + void cleanup().finally(() => finish(writeErr)); + return; + } + + port.drain((drainErr) => { + if (drainErr) { + void cleanup().finally(() => finish(drainErr)); + return; + } + + void cleanup().finally(() => { + logger.info(`print-raw-serial sent ${args.data.length} bytes to ${portPath}`); + finish(); + }); + }); + }); + }); + }); + + return printPromise; + } +); diff --git a/src/preload.ts b/src/preload.ts index 27ee515..5b308db 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -104,6 +104,8 @@ const ipc = { 'printer-discovery', 'usb-discovery', 'print-raw-usb', + 'serial-discovery', + 'print-raw-serial', ] as string[], // From main to render, once once: [] as string[], // We'll handle dynamic channels separately diff --git a/test/package-runtime-externals.test.ts b/test/package-runtime-externals.test.ts index 4c32a44..0872b88 100644 --- a/test/package-runtime-externals.test.ts +++ b/test/package-runtime-externals.test.ts @@ -56,6 +56,26 @@ async function main() { 'function', 'packaged main process should load usb and its native binding dependencies' ); + + // serialport — OS-paired Bluetooth Classic serial path + const serialportEntry = packagedRequire.resolve('serialport'); + assert.ok( + serialportEntry.startsWith(path.join(buildPath, 'node_modules', 'serialport')), + `packaged main process should resolve serialport from packaged node_modules, got ${serialportEntry}` + ); + // Verify @serialport/* namespace was copied alongside serialport + const serialportNamespacePath = path.join(buildPath, 'node_modules', '@serialport'); + assert.ok( + fs.existsSync(serialportNamespacePath), + 'packageAfterPrune should copy @serialport namespace for serialport dependencies' + ); + // Verify the module loads correctly from the packaged copy, including the transitive dep chain + const { SerialPort } = packagedRequire('serialport') as typeof import('serialport'); + assert.equal( + typeof SerialPort.list, + 'function', + 'serialport should load with SerialPort.list available' + ); } finally { fs.rmSync(buildPath, { recursive: true, force: true }); } diff --git a/webpack.main.config.ts b/webpack.main.config.ts index 01d6876..e13b0e3 100644 --- a/webpack.main.config.ts +++ b/webpack.main.config.ts @@ -24,5 +24,5 @@ export const mainConfig: WebpackConfiguration = { // statically follow the `NODE_USB_PATH ||` dynamic path, so it emits nothing for it. // Externalizing `usb` keeps it a runtime `require('usb')` resolved from node_modules, // where node-gyp-build finds the electron-rebuilt build/Release/usb_bindings.node. - externals: ['aws-sdk', 'mock-aws-s3', 'nock', 'usb'], + externals: ['aws-sdk', 'mock-aws-s3', 'nock', 'serialport', 'usb'], };