Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
},
"dependencies": {
"@sentry/electron": "^7.13.0",
"@wcpos/printer": "workspace:*",
Comment thread
wcpos-bot[bot] marked this conversation as resolved.
"axios": "^1.17.0",
"better-sqlite3": "12.10.0",
"bonjour-service": "1.4.1",
Expand Down
21 changes: 21 additions & 0 deletions packages/printer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@wcpos/printer",
"version": "1.9.1",
"description": "Printer encoding and transport for WooCommerce POS",
"author": "kilbot <paul@kilbot.com>",
"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/**"
]
}
98 changes: 98 additions & 0 deletions packages/printer/src/transport/device-key.ts
Original file line number Diff line number Diff line change
@@ -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<Record<ParsedTarget['kind'], TargetConnectionType>>;

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));
}
3 changes: 3 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
packages:
- "."
- "packages/*"
9 changes: 7 additions & 2 deletions src/main/serial-printer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@

// Load module — registers handlers at top level via ipcMain.handle
const { filterSerialPorts, SERIAL_PREFIX } =
require('./serial-printer') as typeof import('./serial-printer');

Check warning on line 109 in src/main/serial-printer.test.ts

View workflow job for this annotation

GitHub Actions / test

A `require()` style import is forbidden

// ──────────────────────────────────────────────────────────────────────────
// filterSerialPorts — darwin
Expand Down Expand Up @@ -185,8 +185,13 @@
];
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
Expand Down
42 changes: 33 additions & 9 deletions src/main/serial-printer.ts
Original file line number Diff line number Diff line change
@@ -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:<path>` — 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];
Expand All @@ -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<SerialPrinterInfo[]> => {
ipcMain.handle('serial-discovery', async (): Promise<DiscoveredSerialPrinter[]> => {
// 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.
Expand All @@ -47,7 +73,7 @@ ipcMain.handle('serial-discovery', async (): Promise<SerialPrinterInfo[]> => {
}
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;
Expand All @@ -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 });
Expand Down
59 changes: 42 additions & 17 deletions src/main/usb-printer.ts
Original file line number Diff line number Diff line change
@@ -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:<vid>:<pid>:<bus>:<address>` — stored as the profile address
id: string; // `usb:<vid>:<pid>:<bus>:<address>` or `winspool:<queue>` 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 {
Expand All @@ -37,16 +63,15 @@ ipcMain.handle('usb-discovery', async (event): Promise<UsbPrinterInfo[]> => {
// 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(
Expand All @@ -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
Expand All @@ -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) =>
Expand Down
9 changes: 7 additions & 2 deletions src/main/winspool-printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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:<queue name>` — stored as the profile address
Expand All @@ -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,
}));
}
Expand Down
8 changes: 7 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,11 @@
"*": ["node_modules/*"]
}
},
"include": ["src/**/*"]
"include": ["src/**/*"],
"ts-node": {
"moduleTypes": {
"../../packages/printer/src/transport/device-key.ts": "cjs",
"packages/printer/src/transport/device-key.ts": "cjs"
}
}
}
Loading