From f8ece73a5aefdffef86d1c7f244bf94f51cad9b9 Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Wed, 17 Jun 2026 20:23:03 +0200 Subject: [PATCH 1/2] feat: unify printer discovery device keys --- package.json | 1 + src/main/serial-printer.test.ts | 9 +++-- src/main/serial-printer.ts | 42 ++++++++++++++++++----- src/main/usb-printer.ts | 59 +++++++++++++++++++++++---------- src/main/winspool-printer.ts | 9 +++-- tsconfig.json | 7 +++- 6 files changed, 96 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index 0fe04f4..c3ba262 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "@sentry/electron": "^7.13.0", + "@wcpos/printer": "workspace:*", "axios": "^1.17.0", "better-sqlite3": "12.10.0", "bonjour-service": "1.4.1", diff --git a/src/main/serial-printer.test.ts b/src/main/serial-printer.test.ts index c2a1ef6..0400937 100644 --- a/src/main/serial-printer.test.ts +++ b/src/main/serial-printer.test.ts @@ -185,8 +185,13 @@ async function main() { ]; 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'); + assert.deepEqual(darwinResult[0], { + id: 'serial:/dev/cu.TM-T88V-SerialPort', + name: 'TM T88V SerialPort', + connectionType: 'bluetooth', + address: 'serial:/dev/cu.TM-T88V-SerialPort', + vendor: 'generic', + }); // ────────────────────────────────────────────────────────────────────────── // print-raw-serial handler — arg validation diff --git a/src/main/serial-printer.ts b/src/main/serial-printer.ts index de84a35..e9d0fab 100644 --- a/src/main/serial-printer.ts +++ b/src/main/serial-printer.ts @@ -1,15 +1,28 @@ import { ipcMain } from 'electron'; import { SerialPort } from 'serialport'; +import { + buildSerialKey, + connectionTypeForTarget, + parseTarget, + SERIAL_PREFIX, +} from '@wcpos/printer/transport/device-key'; + import { logger } from './log'; -export const SERIAL_PREFIX = 'serial:'; +export { SERIAL_PREFIX }; export interface SerialPrinterInfo { id: string; // `serial:` — stored as the profile address name: string; } +interface DiscoveredSerialPrinter extends SerialPrinterInfo { + connectionType: 'bluetooth'; + address: string; + vendor: 'generic'; +} + // 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]; @@ -29,16 +42,29 @@ export function filterSerialPorts(ports: { path: string }[]): SerialPrinterInfo[ const name = stripped === '' || /^\d+$/.test(stripped) ? basename : stripped.replace(/[-_]/g, ' '); return { - id: `${SERIAL_PREFIX}${p.path}`, + id: buildSerialKey(p.path), name, }; }); } +function toDiscoveredSerialPrinter(port: SerialPrinterInfo): DiscoveredSerialPrinter { + const connectionType = connectionTypeForTarget(port.id); + if (connectionType !== 'bluetooth') { + throw new Error(`Unsupported serial discovery device key: ${port.id}`); + } + return { + ...port, + connectionType, + address: port.id, + vendor: 'generic', + }; +} + // 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 => { +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. @@ -47,7 +73,7 @@ ipcMain.handle('serial-discovery', async (): Promise => { } try { const ports = await SerialPort.list(); - return filterSerialPorts(ports); + return filterSerialPorts(ports).map(toDiscoveredSerialPrinter); } catch (err) { logger.error(`serial-discovery failed: ${err instanceof Error ? err.message : String(err)}`); throw err; @@ -66,14 +92,12 @@ ipcMain.handle( ) { throw new Error('Invalid data: must be an array of byte values (0-255)'); } - if (!args.device.startsWith(SERIAL_PREFIX)) { + const target = parseTarget(args.device); + if (target.kind !== 'serial') { 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}`); - } + const portPath = target.path; // 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 }); diff --git a/src/main/usb-printer.ts b/src/main/usb-printer.ts index 8f104f4..c847316 100644 --- a/src/main/usb-printer.ts +++ b/src/main/usb-printer.ts @@ -1,21 +1,47 @@ import { ipcMain } from 'electron'; import { type Device, type Endpoint, getDeviceList, type OutEndpoint, usb } from 'usb'; +import { + buildUsbKey, + connectionTypeForTarget, + parseTarget, +} from '@wcpos/printer/transport/device-key'; + import { logger } from './log'; -import { listSpoolerPrinters, printRawToSpooler, WINSPOOL_PREFIX } from './winspool-printer'; +import { listSpoolerPrinters, printRawToSpooler } from './winspool-printer'; const USB_PRINTER_CLASS = 0x07; interface UsbPrinterInfo { - id: string; // `usb::::
` — stored as the profile address + id: string; // `usb::::
` or `winspool:` profile address name: string; - vendorId?: number; - productId?: number; + connectionType: 'usb' | 'system'; + address: string; + vendor: 'generic'; } function deviceKey(d: Device): string { const { idVendor, idProduct } = d.deviceDescriptor; - return `usb:${idVendor}:${idProduct}:${d.busNumber}:${d.deviceAddress}`; + return buildUsbKey({ + vid: idVendor, + pid: idProduct, + bus: d.busNumber, + address: d.deviceAddress, + }); +} + +function discoveredPrinter(id: string, name: string): UsbPrinterInfo { + const connectionType = connectionTypeForTarget(id); + if (connectionType !== 'usb' && connectionType !== 'system') { + throw new Error(`Unsupported USB discovery device key: ${id}`); + } + return { + id, + name, + connectionType, + address: id, + vendor: 'generic', + }; } function isPrinter(d: Device): boolean { @@ -37,16 +63,15 @@ ipcMain.handle('usb-discovery', async (event): Promise => { // that can never print, so list the installed spooler queues instead; print-raw-usb // routes their `winspool:` keys through the spooler RAW datatype. if (process.platform === 'win32') { - return listSpoolerPrinters(event.sender); + const printers = await listSpoolerPrinters(event.sender); + return printers.map((p) => discoveredPrinter(p.id, p.name)); } return getDeviceList() .filter(isPrinter) - .map((d) => ({ - id: deviceKey(d), - name: `USB printer (${deviceKey(d)})`, - vendorId: d.deviceDescriptor.idVendor, - productId: d.deviceDescriptor.idProduct, - })); + .map((d) => { + const id = deviceKey(d); + return discoveredPrinter(id, `USB printer (${id})`); + }); }); ipcMain.handle( @@ -62,18 +87,18 @@ ipcMain.handle( throw new Error('Invalid data: must be an array of byte values (0-255)'); } - if (args.device.startsWith(WINSPOOL_PREFIX)) { + const target = parseTarget(args.device); + if (target.kind === 'winspool') { if (process.platform !== 'win32') { throw new Error('Spooler device keys are only valid on Windows'); } - const printerName = args.device.slice(WINSPOOL_PREFIX.length); + const printerName = target.queue; await printRawToSpooler(printerName, Buffer.from(args.data)); logger.info(`print-raw-usb spooled ${args.data.length} bytes to "${printerName}"`); return; } - const match = /^usb:(\d+):(\d+):(\d+):(\d+)$/.exec(args.device); - if (!match) throw new Error(`Invalid USB device key: ${args.device}`); + if (target.kind !== 'usb') throw new Error(`Invalid USB device key: ${args.device}`); if (process.platform === 'win32') { // A `usb:` key saved by an older version can never work here (usbprint.sys owns @@ -83,7 +108,7 @@ ipcMain.handle( ); } - const [vid, pid, busNumber, deviceAddress] = match.slice(1).map(Number); + const { vid, pid, bus: busNumber, address: deviceAddress } = target; const device = getDeviceList().find( (d) => diff --git a/src/main/winspool-printer.ts b/src/main/winspool-printer.ts index 4bfdba4..eb10585 100644 --- a/src/main/winspool-printer.ts +++ b/src/main/winspool-printer.ts @@ -4,6 +4,11 @@ import { unlink, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { + buildWinspoolKey, + WINSPOOL_PREFIX as DEVICE_KEY_WINSPOOL_PREFIX, +} from '@wcpos/printer/transport/device-key'; + import { logger } from './log'; import type { WebContents } from 'electron'; @@ -19,7 +24,7 @@ import type { WebContents } from 'electron'; * to the device unmodified. This is the same approach QZ Tray uses. */ -export const WINSPOOL_PREFIX = 'winspool:'; +export const WINSPOOL_PREFIX = DEVICE_KEY_WINSPOOL_PREFIX; export interface SpoolerPrinterInfo { id: string; // `winspool:` — stored as the profile address @@ -42,7 +47,7 @@ export function filterSpoolerPrinters( .filter((p) => !VIRTUAL_PRINTER_NAMES.has(p.name)) .map((p) => ({ // OpenPrinterW needs the queue name, not the display name — the id must carry `name`. - id: `${WINSPOOL_PREFIX}${p.name}`, + id: buildWinspoolKey(p.name), name: p.displayName || p.name, })); } diff --git a/tsconfig.json b/tsconfig.json index b105c9f..ec4bf1c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,10 @@ "*": ["node_modules/*"] } }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "ts-node": { + "moduleTypes": { + "../../packages/printer/src/transport/device-key.ts": "cjs" + } + } } From 5e1c5f14504ed859dc16a3ec647571083bdce98b Mon Sep 17 00:00:00 2001 From: "wcpos-agents[bot]" <2860316+wcpos-agents[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:38:08 +0000 Subject: [PATCH 2/2] fix: provide printer workspace package --- packages/printer/package.json | 21 +++++ packages/printer/src/transport/device-key.ts | 98 ++++++++++++++++++++ pnpm-workspace.yaml | 3 + tsconfig.json | 3 +- 4 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 packages/printer/package.json create mode 100644 packages/printer/src/transport/device-key.ts create mode 100644 pnpm-workspace.yaml diff --git a/packages/printer/package.json b/packages/printer/package.json new file mode 100644 index 0000000..fe9c1a0 --- /dev/null +++ b/packages/printer/package.json @@ -0,0 +1,21 @@ +{ + "name": "@wcpos/printer", + "version": "1.9.1", + "description": "Printer encoding and transport for WooCommerce POS", + "author": "kilbot ", + "license": "MIT", + "main": "./src/index.ts", + "exports": { + "./transport/device-key": "./src/transport/device-key.ts" + }, + "typesVersions": { + "*": { + "transport/device-key": [ + "src/transport/device-key.ts" + ] + } + }, + "files": [ + "src/**" + ] +} diff --git a/packages/printer/src/transport/device-key.ts b/packages/printer/src/transport/device-key.ts new file mode 100644 index 0000000..3d89167 --- /dev/null +++ b/packages/printer/src/transport/device-key.ts @@ -0,0 +1,98 @@ +export const SERIAL_PREFIX = 'serial:'; +export const USB_PREFIX = 'usb:'; +export const WINSPOOL_PREFIX = 'winspool:'; +export const CLOUD_PREFIX = 'cloud:'; +export const SYSTEM_TARGET = 'system'; + +export type ParsedTarget = + | { kind: 'serial'; path: string; raw: string } + | { kind: 'usb'; vid: number; pid: number; bus: number; address: number; raw: string } + | { kind: 'winspool'; queue: string; raw: string } + | { kind: 'cloud'; cloudPrinterId: string; raw: string } + | { kind: 'system'; raw: string } + | { kind: 'uuid'; uuid: string; raw: string } + | { kind: 'unknown'; raw: string }; + +export type TargetConnectionType = 'bluetooth' | 'usb' | 'system' | 'cloud'; + +const USB_KEY_PATTERN = /^usb:(\d+):(\d+):(\d+):(\d+)$/; + +export const TARGET_KIND_CONNECTION_TYPE = { + serial: 'bluetooth', + usb: 'usb', + winspool: 'system', + cloud: 'cloud', + system: 'system', +} as const satisfies Partial>; + +export function parseTarget(value: string): ParsedTarget { + if (value === SYSTEM_TARGET) return { kind: 'system', raw: value }; + + if (value.startsWith(SERIAL_PREFIX)) { + const path = value.slice(SERIAL_PREFIX.length); + return path ? { kind: 'serial', path, raw: value } : { kind: 'unknown', raw: value }; + } + + const usbMatch = USB_KEY_PATTERN.exec(value); + if (usbMatch) { + const [, vid, pid, bus, address] = usbMatch; + return { + kind: 'usb', + vid: Number(vid), + pid: Number(pid), + bus: Number(bus), + address: Number(address), + raw: value, + }; + } + if (value.startsWith(USB_PREFIX)) return { kind: 'unknown', raw: value }; + + if (value.startsWith(WINSPOOL_PREFIX)) { + const queue = value.slice(WINSPOOL_PREFIX.length); + return queue ? { kind: 'winspool', queue, raw: value } : { kind: 'unknown', raw: value }; + } + + if (value.startsWith(CLOUD_PREFIX)) { + const cloudPrinterId = value.slice(CLOUD_PREFIX.length); + return cloudPrinterId + ? { kind: 'cloud', cloudPrinterId, raw: value } + : { kind: 'unknown', raw: value }; + } + + return { kind: 'uuid', uuid: value, raw: value }; +} + +export function buildSerialKey(path: string): string { + return `${SERIAL_PREFIX}${path}`; +} + +export function buildUsbKey(p: { vid: number; pid: number; bus: number; address: number }): string { + return `${USB_PREFIX}${p.vid}:${p.pid}:${p.bus}:${p.address}`; +} + +export function buildWinspoolKey(queue: string): string { + return `${WINSPOOL_PREFIX}${queue}`; +} + +export function buildCloudTarget(cloudPrinterId: string): string { + return `${CLOUD_PREFIX}${cloudPrinterId}`; +} + +export function connectionTypeForParsedTarget( + target: ParsedTarget +): TargetConnectionType | undefined { + if ( + target.kind === 'serial' || + target.kind === 'usb' || + target.kind === 'winspool' || + target.kind === 'cloud' || + target.kind === 'system' + ) { + return TARGET_KIND_CONNECTION_TYPE[target.kind]; + } + return undefined; +} + +export function connectionTypeForTarget(value: string): TargetConnectionType | undefined { + return connectionTypeForParsedTarget(parseTarget(value)); +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..07da0b2 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "." + - "packages/*" diff --git a/tsconfig.json b/tsconfig.json index ec4bf1c..803761c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,8 @@ "include": ["src/**/*"], "ts-node": { "moduleTypes": { - "../../packages/printer/src/transport/device-key.ts": "cjs" + "../../packages/printer/src/transport/device-key.ts": "cjs", + "packages/printer/src/transport/device-key.ts": "cjs" } } }