Skip to content
Merged
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
14 changes: 13 additions & 1 deletion forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
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';
Expand All @@ -29,7 +30,7 @@
// }

// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require('electron-squirrel-startup')) {

Check warning on line 33 in src/index.ts

View workflow job for this annotation

GitHub Actions / test

A `require()` style import is forbidden
app.quit();
}

Expand Down
105 changes: 105 additions & 0 deletions src/main/bluetooth-select.test.ts
Original file line number Diff line number Diff line change
@@ -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');

Check warning on line 27 in src/main/bluetooth-select.test.ts

View workflow job for this annotation

GitHub Actions / test

A `require()` style import is forbidden

// 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;
}
39 changes: 32 additions & 7 deletions src/main/bluetooth-select.ts
Original file line number Diff line number Diff line change
@@ -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;
});
}
Loading
Loading