From b7274d5b6d425c8d31f13808712e0ebf5dd490f8 Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Wed, 17 Jun 2026 20:16:53 +0200 Subject: [PATCH] feat: derive print ipc channels from registry --- package.json | 1 + src/main/auth-handler.ts | 13 +++------- src/main/ipc.ts | 13 ++++++++++ src/main/print-raw-tcp.ts | 13 +++------- src/main/printer-discovery.ts | 20 +++++--------- src/main/serial-printer.ts | 13 +++++----- src/main/usb-printer.ts | 15 +++++------ src/preload.ts | 49 ++++++++++++----------------------- src/types.d.ts | 4 +++ webpack.rules.ts | 7 ++++- 10 files changed, 66 insertions(+), 82 deletions(-) create mode 100644 src/main/ipc.ts diff --git a/package.json b/package.json index 0fe04f4..dd6057f 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "test:serial-printer": "ts-node src/main/serial-printer.test.ts" }, "dependencies": { + "@wcpos/printer": "workspace:*", "@sentry/electron": "^7.13.0", "axios": "^1.17.0", "better-sqlite3": "12.10.0", diff --git a/src/main/auth-handler.ts b/src/main/auth-handler.ts index bc9f5dc..4a45426 100644 --- a/src/main/auth-handler.ts +++ b/src/main/auth-handler.ts @@ -1,18 +1,11 @@ import { BrowserWindow, ipcMain } from 'electron'; +import type { AuthPromptParams, AuthResult } from '@wcpos/printer/ipc-channels'; + import { logger as log } from './log'; import { getMainWindow } from './window'; -interface AuthPromptParams { - authUrl: string; - redirectUri: string; -} - -interface AuthResult { - type: 'success' | 'error' | 'dismiss' | 'cancel'; - params?: Record; - error?: string; -} +export type { AuthPromptParams, AuthResult } from '@wcpos/printer/ipc-channels'; /** * Parse auth tokens from a redirect URL diff --git a/src/main/ipc.ts b/src/main/ipc.ts new file mode 100644 index 0000000..6328514 --- /dev/null +++ b/src/main/ipc.ts @@ -0,0 +1,13 @@ +import { ipcMain, type IpcMainInvokeEvent } from 'electron'; + +import type { IpcInvokeChannels } from '@wcpos/printer/ipc-channels'; + +export function handleIpc( + channel: C, + handler: ( + event: IpcMainInvokeEvent, + args: IpcInvokeChannels[C]['req'] + ) => Promise | IpcInvokeChannels[C]['res'] +): void { + ipcMain.handle(channel, handler as never); +} diff --git a/src/main/print-raw-tcp.ts b/src/main/print-raw-tcp.ts index 03c1845..14b9237 100644 --- a/src/main/print-raw-tcp.ts +++ b/src/main/print-raw-tcp.ts @@ -1,18 +1,13 @@ import * as net from 'net'; -import { ipcMain } from 'electron'; - +import { handleIpc } from './ipc'; import { logger } from './log'; -ipcMain.handle('print-raw-tcp', async (_event, args: unknown) => { +handleIpc('print-raw-tcp', async (_event, args) => { if (!args || typeof args !== 'object') { throw new Error('Invalid arguments: expected an object'); } - const { host, port, data } = args as { - host: unknown; - port: unknown; - data: unknown; - }; + const { host, port, data } = args; if (!host || typeof host !== 'string') { throw new Error('Invalid host: must be a non-empty string'); @@ -24,7 +19,7 @@ ipcMain.handle('print-raw-tcp', async (_event, args: unknown) => { throw new Error('Invalid data: must be an array of byte values (0-255)'); } - const buffer = Buffer.from(data as number[]); + const buffer = Buffer.from(data); logger.info(`Sending ${buffer.length} bytes to ${host}:${port}`); diff --git a/src/main/printer-discovery.ts b/src/main/printer-discovery.ts index 8f932ce..b3d4002 100644 --- a/src/main/printer-discovery.ts +++ b/src/main/printer-discovery.ts @@ -1,6 +1,8 @@ -import { ipcMain } from 'electron'; import Bonjour from 'bonjour-service'; +import type { DiscoveredNetworkPrinter, IpcInvokeChannels } from '@wcpos/printer/ipc-channels'; + +import { handleIpc } from './ipc'; import { logger } from './log'; interface MdnsServiceLike { @@ -12,19 +14,9 @@ interface MdnsServiceLike { txt?: Record; } -interface DiscoveredNetworkPrinter { - id: string; - name: string; - connectionType: 'network'; - address: string; - port: number; - vendor: 'epson' | 'star' | 'generic'; -} +export type { DiscoveredNetworkPrinter } from '@wcpos/printer/ipc-channels'; -interface PrinterDiscoveryRequest { - action?: 'start' | 'stop'; - timeoutMs?: number; -} +type PrinterDiscoveryRequest = IpcInvokeChannels['printer-discovery']['req']; const SERVICE_TYPES = ['printer', 'pdl-datastream', 'ipp', 'ipps', 'star']; const DEFAULT_SCAN_TIMEOUT_MS = 4000; @@ -134,7 +126,7 @@ async function discoverPrinters(timeoutMs: number): Promise { +handleIpc('printer-discovery', async (_event, args) => { const request = parseRequest(args); if (request.action === 'stop') { stopActiveScan(); diff --git a/src/main/serial-printer.ts b/src/main/serial-printer.ts index de84a35..00d5e20 100644 --- a/src/main/serial-printer.ts +++ b/src/main/serial-printer.ts @@ -1,14 +1,13 @@ -import { ipcMain } from 'electron'; import { SerialPort } from 'serialport'; +import type { SerialPrinterInfo } from '@wcpos/printer/ipc-channels'; + +import { handleIpc } from './ipc'; import { logger } from './log'; export const SERIAL_PREFIX = 'serial:'; -export interface SerialPrinterInfo { - id: string; // `serial:` — stored as the profile address - name: string; -} +export type { SerialPrinterInfo } from '@wcpos/printer/ipc-channels'; // 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. @@ -38,7 +37,7 @@ export function filterSerialPorts(ports: { path: string }[]): SerialPrinterInfo[ // 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 => { +handleIpc('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. @@ -54,7 +53,7 @@ ipcMain.handle('serial-discovery', async (): Promise => { } }); -ipcMain.handle( +handleIpc( 'print-raw-serial', async (_event, args: { device: string; data: number[] }): Promise => { if (!args || typeof args.device !== 'string') { diff --git a/src/main/usb-printer.ts b/src/main/usb-printer.ts index 8f104f4..dd473d8 100644 --- a/src/main/usb-printer.ts +++ b/src/main/usb-printer.ts @@ -1,17 +1,14 @@ -import { ipcMain } from 'electron'; import { type Device, type Endpoint, getDeviceList, type OutEndpoint, usb } from 'usb'; +import type { UsbPrinterInfo } from '@wcpos/printer/ipc-channels'; + +import { handleIpc } from './ipc'; import { logger } from './log'; import { listSpoolerPrinters, printRawToSpooler, WINSPOOL_PREFIX } from './winspool-printer'; const USB_PRINTER_CLASS = 0x07; -interface UsbPrinterInfo { - id: string; // `usb::::
` — stored as the profile address - name: string; - vendorId?: number; - productId?: number; -} +export type { UsbPrinterInfo } from '@wcpos/printer/ipc-channels'; function deviceKey(d: Device): string { const { idVendor, idProduct } = d.deviceDescriptor; @@ -30,7 +27,7 @@ function isPrinter(d: Device): boolean { } } -ipcMain.handle('usb-discovery', async (event): Promise => { +handleIpc('usb-discovery', async (event): Promise => { // Windows: libusb can enumerate USB printers but cannot claim them — plug-and-play // binds usbprint.sys (or a vendor driver) to every USB printer, and libusb I/O // requires a WinUSB-class driver. Listing libusb devices here would offer printers @@ -49,7 +46,7 @@ ipcMain.handle('usb-discovery', async (event): Promise => { })); }); -ipcMain.handle( +handleIpc( 'print-raw-usb', async (_event, args: { device: string; data: number[] }): Promise => { if (!args || typeof args.device !== 'string') { diff --git a/src/preload.ts b/src/preload.ts index 5b308db..dc95db7 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,5 +1,13 @@ import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; +import { + DYNAMIC_ON_PATTERNS, + INVOKE_CHANNELS, + ON_CHANNELS, + RXDB_IPC_CHANNEL_PREFIX, + SEND_CHANNELS, +} from '@wcpos/printer/ipc-channels'; + import { deserializeRxdbIpcMessage, hasBulkWriteAttachmentBlobs, @@ -19,11 +27,9 @@ contextBridge.exposeInMainWorld('electron', { version: ipcRenderer.sendSync('getAppVersionSync'), }); -// Must match IPC_RENDERER_KEY_PREFIX from 'rxdb/plugins/electron' used in src/main/rxdb-storage.ts -const RXDB_IPC_CHANNEL_PREFIX = 'rxdb-ipc-renderer-storage|'; const isRxdbStorageChannel = (channel: string) => channel.startsWith(RXDB_IPC_CHANNEL_PREFIX); -const isAllowedChannel = (channel: string, validChannels: (string | RegExp)[]) => +const isAllowedChannel = (channel: string, validChannels: readonly (string | RegExp)[]) => validChannels.some((matcher) => typeof matcher === 'string' ? matcher === channel : matcher.test(channel) ); @@ -86,29 +92,13 @@ function forgetWrappedRxdbListener( const ipc = { render: { // From render to main. - send: [ - 'clearData', - 'print-external-url', - 'open-external-url', - 'bluetooth-device-selected', - ] as string[], + send: SEND_CHANNELS, // From main to render. - on: ['system-resume', 'bluetooth-devices'] as string[], // System events from main process + on: ON_CHANNELS, // System events from main process // From render to main and back again. - invoke: [ - 'sqlite', - 'axios', - 'rxStorage', - 'auth:prompt', - 'print-raw-tcp', - 'printer-discovery', - 'usb-discovery', - 'print-raw-usb', - 'serial-discovery', - 'print-raw-serial', - ] as string[], + invoke: INVOKE_CHANNELS, // From main to render, once - once: [] as string[], // We'll handle dynamic channels separately + once: [] as const, // We'll handle dynamic channels separately }, }; @@ -117,7 +107,7 @@ const ipc = { */ contextBridge.exposeInMainWorld('ipcRenderer', { send(channel: string, args: unknown) { - const validChannels = ipc.render.send; + const validChannels: readonly string[] = ipc.render.send; if (validChannels.includes(channel)) { ipcRenderer.send(channel, args); } else { @@ -150,7 +140,7 @@ contextBridge.exposeInMainWorld('ipcRenderer', { }; } - const validChannels = [/^onBeforePrint-/, /^onAfterPrint-/, /^onPrintError-/, ...ipc.render.on]; + const validChannels = [...DYNAMIC_ON_PATTERNS, ...ipc.render.on]; if (isAllowedChannel(channel, validChannels)) { const subscription = (_event: IpcRendererEvent, ...args: unknown[]) => func(...args); ipcRenderer.on(channel, subscription); @@ -190,19 +180,14 @@ contextBridge.exposeInMainWorld('ipcRenderer', { throw Error(`Channel ${channel} is not allowed`); }, invoke(channel: string, args: unknown) { - const validChannels = ipc.render.invoke; + const validChannels: readonly string[] = ipc.render.invoke; if (validChannels.includes(channel)) { return ipcRenderer.invoke(channel, args); } return Promise.reject(new Error(`Channel ${channel} is not allowed`)); }, once(channel: string, func: (...args: unknown[]) => void) { - const validChannels = [ - /^onBeforePrint-/, - /^onAfterPrint-/, - /^onPrintError-/, - ...ipc.render.once, - ]; + const validChannels = [...DYNAMIC_ON_PATTERNS, ...ipc.render.once]; if (isAllowedChannel(channel, validChannels)) { ipcRenderer.once(channel, (_event: IpcRendererEvent, ...args: unknown[]) => func(...args)); } else { diff --git a/src/types.d.ts b/src/types.d.ts index 66320ff..569b524 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -2,6 +2,10 @@ declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string; declare const MAIN_WINDOW_WEBPACK_ENTRY: string; +interface Window { + ipcRenderer: import('@wcpos/printer/ipc-channels').TypedIpcRenderer; +} + // Type declarations for packages without @types declare module 'semver' { export function gt( diff --git a/webpack.rules.ts b/webpack.rules.ts index 2287fe6..ccca170 100644 --- a/webpack.rules.ts +++ b/webpack.rules.ts @@ -1,3 +1,5 @@ +import path from 'path'; + import type { ModuleOptions } from 'webpack'; export const rules: Required['rules'] = [ @@ -19,11 +21,14 @@ export const rules: Required['rules'] = [ }, }, { - test: /\.tsx?$/, + test: /\.[cm]?tsx?$/, exclude: /(node_modules|\.webpack)/, use: { loader: 'ts-loader', options: { + compilerOptions: { + rootDir: path.resolve(__dirname, '../..'), + }, transpileOnly: true, }, },