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 @@ -36,6 +36,7 @@
"test:serial-printer": "ts-node src/main/serial-printer.test.ts"
},
"dependencies": {
"@wcpos/printer": "workspace:*",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use an installable dependency for standalone workflows

The dependency is added with the workspace protocol, but this repository’s CI/release jobs install after checking out only wcpos/electron: .github/workflows/test.yml runs pnpm install at line 43, and the publish jobs in .github/workflows/tag-and-release.yml run npm install (for example lines 88, 132, 172, and 244). I verified the CLI behavior in a temp package: npm install fails with EUNSUPPORTEDPROTOCOL: Unsupported URL Type "workspace:", while pnpm install fails with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND when the workspace package is absent. This blocks the standalone Electron test/release workflows unless they are changed to check out the monorepo or this dependency is made installable in this repo.

Useful? React with 👍 / 👎.

"@sentry/electron": "^7.13.0",
"axios": "^1.17.0",
"better-sqlite3": "12.10.0",
Expand Down
13 changes: 3 additions & 10 deletions src/main/auth-handler.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
error?: string;
}
export type { AuthPromptParams, AuthResult } from '@wcpos/printer/ipc-channels';

/**
* Parse auth tokens from a redirect URL
Expand Down
13 changes: 13 additions & 0 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ipcMain, type IpcMainInvokeEvent } from 'electron';

import type { IpcInvokeChannels } from '@wcpos/printer/ipc-channels';

export function handleIpc<C extends keyof IpcInvokeChannels>(
channel: C,
handler: (
event: IpcMainInvokeEvent,
args: IpcInvokeChannels[C]['req']
) => Promise<IpcInvokeChannels[C]['res']> | IpcInvokeChannels[C]['res']
): void {
ipcMain.handle(channel, handler as never);
}
13 changes: 4 additions & 9 deletions src/main/print-raw-tcp.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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}`);

Expand Down
20 changes: 6 additions & 14 deletions src/main/printer-discovery.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -12,19 +14,9 @@ interface MdnsServiceLike {
txt?: Record<string, unknown>;
}

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;
Expand Down Expand Up @@ -134,7 +126,7 @@ async function discoverPrinters(timeoutMs: number): Promise<DiscoveredNetworkPri
});
}

ipcMain.handle('printer-discovery', async (_event, args: unknown) => {
handleIpc('printer-discovery', async (_event, args) => {
const request = parseRequest(args);
if (request.action === 'stop') {
stopActiveScan();
Expand Down
13 changes: 6 additions & 7 deletions src/main/serial-printer.ts
Original file line number Diff line number Diff line change
@@ -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:<path>` — 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.
Expand Down Expand Up @@ -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<SerialPrinterInfo[]> => {
handleIpc('serial-discovery', async (): Promise<SerialPrinterInfo[]> => {
// 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 @@ -54,7 +53,7 @@ ipcMain.handle('serial-discovery', async (): Promise<SerialPrinterInfo[]> => {
}
});

ipcMain.handle(
handleIpc(
'print-raw-serial',
async (_event, args: { device: string; data: number[] }): Promise<void> => {
if (!args || typeof args.device !== 'string') {
Expand Down
15 changes: 6 additions & 9 deletions src/main/usb-printer.ts
Original file line number Diff line number Diff line change
@@ -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:<vid>:<pid>:<bus>:<address>` — 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;
Expand All @@ -30,7 +27,7 @@ function isPrinter(d: Device): boolean {
}
}

ipcMain.handle('usb-discovery', async (event): Promise<UsbPrinterInfo[]> => {
handleIpc('usb-discovery', async (event): Promise<UsbPrinterInfo[]> => {
// 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
Expand All @@ -49,7 +46,7 @@ ipcMain.handle('usb-discovery', async (event): Promise<UsbPrinterInfo[]> => {
}));
});

ipcMain.handle(
handleIpc(
'print-raw-usb',
async (_event, args: { device: string; data: number[] }): Promise<void> => {
if (!args || typeof args.device !== 'string') {
Expand Down
49 changes: 17 additions & 32 deletions src/preload.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)
);
Expand Down Expand Up @@ -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
},
};

Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 6 additions & 1 deletion webpack.rules.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import path from 'path';

import type { ModuleOptions } from 'webpack';

export const rules: Required<ModuleOptions>['rules'] = [
Expand All @@ -19,11 +21,14 @@ export const rules: Required<ModuleOptions>['rules'] = [
},
},
{
test: /\.tsx?$/,
test: /\.[cm]?tsx?$/,
exclude: /(node_modules|\.webpack)/,
use: {
loader: 'ts-loader',
options: {
compilerOptions: {
rootDir: path.resolve(__dirname, '../..'),
},
transpileOnly: true,
},
},
Expand Down
Loading