diff --git a/package.json b/package.json index 0fe04f4..39b1362 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/index.ts b/src/index.ts index de9b964..78580f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -9,7 +16,7 @@ import { initializeRxdbStorageBridge } from './main/rxdb-storage'; 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'; @@ -34,28 +41,28 @@ if (require('electron-squirrel-startup')) { 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'); @@ -75,9 +82,9 @@ app.on('activate', () => { // 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); } }); diff --git a/src/main/boot.test.ts b/src/main/boot.test.ts new file mode 100644 index 0000000..a5a0496 --- /dev/null +++ b/src/main/boot.test.ts @@ -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 => undefined, + setMainWindow: (): void => {}, + }; + const fakeDeps = { + whenReady: async (): Promise => 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; +}); diff --git a/src/main/boot.ts b/src/main/boot.ts new file mode 100644 index 0000000..09dbff1 --- /dev/null +++ b/src/main/boot.ts @@ -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; + setMainWindow?: (mainWindow: BrowserWindow) => void; +} + +export interface BootDeps { + whenReady: () => Promise; + loadTranslations: () => void | Promise; + clearPendingAppDataOnStartup: () => void | Promise; + installExtensions: () => void | Promise; + initializeRxdbStorageBridge: () => void | Promise; + 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) => void | Promise; +} + +export function createMainWindowContext( + deps: Pick, + ctx: Partial +): 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, + ctx: Partial +): 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 { + await deps.whenReady(); + + const ctx: Partial = {}; + 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; +} diff --git a/src/main/update.ts b/src/main/update.ts index 491e4a2..912974a 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -12,7 +12,6 @@ import { logger } from './log'; import { ProgressBar } from './progress-bar'; import { t } from './translations'; import { createDir, isDevelopment } from './util'; -import { getMainWindow } from './window'; interface Asset { url: string; @@ -37,21 +36,31 @@ const REMIND_LATER_DURATION = 24 * 60 * 60 * 1000; // 24 hours in milliseconds const updateServer = isDevelopment ? 'http://localhost:8080' : 'https://updates.wcpos.com'; const store = new Store(); -export class AutoUpdater { +export interface UpdaterHandle { + init: () => void; + manualCheckForUpdates: (menuItem: MenuItem) => Promise; + setMainWindow: (mainWindow: BrowserWindow) => void; +} + +export class AutoUpdater implements UpdaterHandle { private mainWindow: BrowserWindow; private targetPath: string; private tempDirPath: string; private readonly updateUrl = `${updateServer}/electron/${process.platform}-${process.arch}/${app.getVersion()}`; - constructor() { + constructor(mainWindow: BrowserWindow) { this.targetPath = ''; - this.mainWindow = getMainWindow(); + this.mainWindow = mainWindow; const tempDirPath = path.join(app.getPath('temp'), 'NTWRK'); createDir(tempDirPath); this.tempDirPath = tempDirPath; } + public setMainWindow(mainWindow: BrowserWindow): void { + this.mainWindow = mainWindow; + } + public init() { if (isDevelopment) { logger.info('Skipping auto-update in development mode'); @@ -234,5 +243,24 @@ export class AutoUpdater { } } -// Usage -export const updater = new AutoUpdater(); +let activeUpdater: AutoUpdater | null = null; + +export const setUpdater = (nextUpdater: AutoUpdater): AutoUpdater => { + activeUpdater = nextUpdater; + return nextUpdater; +}; + +const getUpdater = (): AutoUpdater => { + if (!activeUpdater) { + throw new Error('AutoUpdater has not been configured'); + } + + return activeUpdater; +}; + +// Stable menu-facing handle. It resolves to the boot-configured updater at use time. +export const updater: UpdaterHandle = { + init: () => getUpdater().init(), + manualCheckForUpdates: (menuItem: MenuItem) => getUpdater().manualCheckForUpdates(menuItem), + setMainWindow: (mainWindow: BrowserWindow) => getUpdater().setMainWindow(mainWindow), +}; diff --git a/src/main/window.ts b/src/main/window.ts index 8fb000c..d6a24d9 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -21,9 +21,9 @@ if (isDevelopment) { }); } -let mainWindow: BrowserWindow | null; +let mainWindow: BrowserWindow | null = null; -export const createWindow = (): void => { +export const createWindow = (): BrowserWindow => { // Create the browser window. mainWindow = new BrowserWindow({ show: false, @@ -92,6 +92,8 @@ export const createWindow = (): void => { log.error(`Load failed without retry: ${errorDescription}`); } }); + + return mainWindow; }; export const getMainWindow = (): BrowserWindow | null => {