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
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"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 && pnpm run test:bluetooth-select && pnpm run test:serial-printer",
"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 && pnpm run test:boot",
"ts:check": "tsc --noEmit",
"test:preload": "ts-node src/preload.test.ts",
"test:rxdb-ipc-attachments": "ts-node src/rxdb-ipc-attachments.test.ts",
Expand All @@ -33,7 +33,8 @@
"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:bluetooth-select": "ts-node src/main/bluetooth-select.test.ts",
"test:serial-printer": "ts-node src/main/serial-printer.test.ts"
"test:serial-printer": "ts-node src/main/serial-printer.test.ts",
"test:boot": "ts-node src/main/boot.test.ts"
},
"dependencies": {
"@sentry/electron": "^7.13.0",
Expand Down
59 changes: 33 additions & 26 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { app, BrowserWindow, powerMonitor } from 'electron';

import {
type AppContext,
boot,
type BootDeps,
createMainWindowContext,
wireMainWindowConsumers,
} from './main/boot';
import { initAuthHandler } from './main/auth-handler';
import { clearPendingAppDataOnStartup } from './main/clear-data';
import { installExtensions } from './main/extensions';
Expand All @@ -9,7 +16,7 @@
import { registerMenu } from './main/menu';
import { initProtocolHandling } from './main/protocol';
import { loadTranslations } from './main/translations';
import { updater } from './main/update';
import { AutoUpdater, setUpdater } from './main/update';
import { createWindow, getMainWindow } from './main/window';
import './main/database';
import './main/axios';
Expand All @@ -30,32 +37,32 @@
// }

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

Check warning on line 40 in src/index.ts

View workflow job for this annotation

GitHub Actions / test

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

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app
.whenReady()
.then(loadTranslations)
.then(clearPendingAppDataOnStartup)
.then(installExtensions)
.then(initializeRxdbStorageBridge)
.then(() => {
logger.info('Starting app');
createWindow();
const mainWindow = getMainWindow();
if (mainWindow) registerBluetoothSelection(mainWindow);
initAuthHandler();
if (process.env.NODE_ENV === 'development') {
// force protocol handling in development
// forge will handle this in production
initProtocolHandling();
}
registerMenu();
updater.init(); // must be after createWindow
const bootDeps: BootDeps = {
whenReady: () => app.whenReady(),
loadTranslations,
clearPendingAppDataOnStartup,
installExtensions,
initializeRxdbStorageBridge,
createWindow,
getMainWindow,
registerBluetoothSelection,
initAuthHandler,
initProtocolHandling,
registerMenu,
createUpdater: (mainWindow) => setUpdater(new AutoUpdater(mainWindow)),
isDevelopment: process.env.NODE_ENV === 'development',
logger,
};

let appContext: AppContext | null = null;

boot(bootDeps)
.then((context) => {
appContext = context;
})
.catch((err) => {
logger.error('Error starting app');
Expand All @@ -75,9 +82,9 @@
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
const mainWindow = getMainWindow();
if (mainWindow) registerBluetoothSelection(mainWindow);
const context = appContext ?? {};
createMainWindowContext(bootDeps, context);
wireMainWindowConsumers(bootDeps, context);
}
});

Expand Down
113 changes: 113 additions & 0 deletions src/main/boot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import Module from 'node:module';

type ModuleWithMutableLoad = typeof Module & {
_load: (request: string, parent: NodeModule | null, isMain: boolean) => unknown;
};

const mutableModule = Module as ModuleWithMutableLoad;
const originalLoad = mutableModule._load;
mutableModule._load = function patchedLoad(
request: string,
parent: NodeModule | null,
isMain: boolean
) {
if (request === 'electron') {
return {
BrowserWindow: class FakeBrowserWindow {},
app: { whenReady: () => Promise.resolve() },
};
}
if (request === './log') {
return { logger: { error() {}, info() {}, warn() {}, debug() {} } };
}
return originalLoad.call(this, request, parent, isMain);
};

(async () => {
try {
const fakeWindow = { id: 'main-window' };
const calls: string[] = [];
const updaterWindows: unknown[] = [];
const mark =
(name: string): (() => void) =>
() => {
calls.push(name);
};
const fakeUpdater = {
init: mark('updater-init'),
manualCheckForUpdates: async (): Promise<void> => undefined,
setMainWindow: (): void => {},
};
const fakeDeps = {
whenReady: async (): Promise<void> => undefined,
loadTranslations: mark('translations'),
clearPendingAppDataOnStartup: mark('clear-pending-app-data'),
installExtensions: mark('install-extensions'),
initializeRxdbStorageBridge: mark('storage-bridge'),
createWindow: () => {
calls.push('create-window');
return fakeWindow as never;
},
getMainWindow: () => fakeWindow as never,
registerBluetoothSelection: mark('bluetooth-selection'),
initAuthHandler: mark('auth-handler'),
initProtocolHandling: mark('protocol-handling'),
registerMenu: mark('menu'),
createUpdater: (mainWindow: unknown) => {
updaterWindows.push(mainWindow);
return fakeUpdater;
},
isDevelopment: true,
logger: { info() {}, warn() {}, error() {} },
};

// eslint-disable-next-line @typescript-eslint/no-require-imports -- test installs Module._load fakes before loading boot.ts
const { bootPlan, boot } = require('./boot') as {
bootPlan: (deps: typeof fakeDeps) => { name: string }[];
boot: (deps: typeof fakeDeps) => Promise<{
mainWindow: typeof fakeWindow;
updater: typeof fakeUpdater;
}>;
};

const phaseNames = bootPlan(fakeDeps).map((phase) => phase.name);
assert.deepEqual(phaseNames, [
'translations',
'clear-pending-app-data',
'install-extensions',
'storage-bridge',
'create-window',
'bluetooth-selection',
'auth-handler',
'protocol-handling',
'menu',
'updater-init',
]);

const indexOf = (name: string) => {
const index = phaseNames.indexOf(name);
assert.notEqual(index, -1, `Missing boot phase: ${name}`);
return index;
};
assert.ok(indexOf('translations') < indexOf('storage-bridge'));
assert.ok(indexOf('storage-bridge') < indexOf('create-window'));
assert.ok(indexOf('create-window') < indexOf('bluetooth-selection'));
assert.ok(indexOf('create-window') < indexOf('auth-handler'));
assert.ok(indexOf('create-window') < indexOf('protocol-handling'));
assert.ok(indexOf('create-window') < indexOf('menu'));
assert.ok(indexOf('menu') < indexOf('updater-init'));

const context = await boot(fakeDeps);
assert.equal(context.mainWindow, fakeWindow);
assert.equal(context.updater, fakeUpdater);
assert.deepEqual(updaterWindows, [fakeWindow]);
assert.deepEqual(calls, phaseNames);
console.log('boot tests passed');
} finally {
mutableModule._load = originalLoad;
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
150 changes: 150 additions & 0 deletions src/main/boot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type { BrowserWindow, MenuItem } from 'electron';

/** Everything downstream wiring needs from startup. Grows if more shared handles appear. */
export interface AppContext {
mainWindow: BrowserWindow;
updater: AppUpdater;
}

export interface AppUpdater {
init: () => void;
manualCheckForUpdates: (menuItem: MenuItem) => Promise<void>;
setMainWindow?: (mainWindow: BrowserWindow) => void;
}

export interface BootDeps {
whenReady: () => Promise<unknown>;
loadTranslations: () => void | Promise<void>;
clearPendingAppDataOnStartup: () => void | Promise<void>;
installExtensions: () => void | Promise<void>;
initializeRxdbStorageBridge: () => void | Promise<void>;
createWindow: () => BrowserWindow | null | void;
getMainWindow: () => BrowserWindow | null;
registerBluetoothSelection: (window: BrowserWindow) => void;
initAuthHandler: () => void;
initProtocolHandling: () => void;
registerMenu: () => void;
createUpdater: (mainWindow: BrowserWindow) => AppUpdater;
isDevelopment: boolean;
logger: {
info: (...args: unknown[]) => void;
warn?: (...args: unknown[]) => void;
};
}

/**
* The phases run, in order. Each entry is the human-readable name plus the thunk.
* Exported so a test can assert the sequence without launching Electron.
*/
export interface BootPhase {
name: string;
run: (ctx: Partial<AppContext>) => void | Promise<void>;
}

export function createMainWindowContext(
deps: Pick<BootDeps, 'createWindow' | 'getMainWindow' | 'logger'>,
ctx: Partial<AppContext>
): BrowserWindow | null {
const createdWindow = deps.createWindow();
const mainWindow = createdWindow || deps.getMainWindow();

if (!mainWindow) {
deps.logger.warn?.('Main window was not available after createWindow');
return null;
}

ctx.mainWindow = mainWindow;
ctx.updater?.setMainWindow?.(mainWindow);

return mainWindow;
}

export function wireMainWindowConsumers(
deps: Pick<BootDeps, 'registerBluetoothSelection'>,
ctx: Partial<AppContext>
): void {
if (ctx.mainWindow) {
deps.registerBluetoothSelection(ctx.mainWindow);
}
}

/** The canonical, ordered boot plan. Pure data — safe to import and inspect in a test. */
export function bootPlan(deps: BootDeps): BootPhase[] {
return [
{
name: 'translations',
run: () => deps.loadTranslations(),
},
{
name: 'clear-pending-app-data',
run: () => deps.clearPendingAppDataOnStartup(),
},
{
name: 'install-extensions',
run: () => deps.installExtensions(),
},
{
name: 'storage-bridge',
run: () => deps.initializeRxdbStorageBridge(),
},
{
name: 'create-window',
run: (ctx) => {
deps.logger.info('Starting app');
createMainWindowContext(deps, ctx);
},
},
{
name: 'bluetooth-selection',
run: (ctx) => wireMainWindowConsumers(deps, ctx),
},
{
name: 'auth-handler',
run: () => deps.initAuthHandler(),
},
{
name: 'protocol-handling',
run: () => {
if (deps.isDevelopment) {
// Force protocol handling in development; Forge handles this in production.
deps.initProtocolHandling();
}
},
},
{
name: 'menu',
run: () => deps.registerMenu(),
},
{
name: 'updater-init',
run: (ctx) => {
if (!ctx.mainWindow) {
deps.logger.warn?.('Skipping updater init because no main window exists');
return;
}

ctx.updater = deps.createUpdater(ctx.mainWindow);
ctx.updater.init();
},
},
];
}

/** Executes bootPlan() against app.whenReady() and returns the assembled AppContext. */
export async function boot(deps: BootDeps): Promise<AppContext> {
await deps.whenReady();

const ctx: Partial<AppContext> = {};
for (const phase of bootPlan(deps)) {
await phase.run(ctx);
}

if (!ctx.mainWindow) {
throw new Error('Main window was not created during boot');
}
if (!ctx.updater) {
throw new Error('Auto updater was not configured during boot');
}

return ctx as AppContext;
}
Loading
Loading