From 323b9017d7ee475b9e4706747f43ff896635abba Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 04:16:22 +0200 Subject: [PATCH] feat(desktop): add boot-completion timer with packaged-only and URL-protocol guards --- packages/desktop/src/recovery.test.ts | 52 ++++++++++++++++++++- packages/desktop/src/recovery.ts | 65 ++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src/recovery.test.ts b/packages/desktop/src/recovery.test.ts index d463896b..ce96874b 100644 --- a/packages/desktop/src/recovery.test.ts +++ b/packages/desktop/src/recovery.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { MenuItemConstructorOptions } from 'electron'; import { RecoveryStateStore, @@ -6,8 +6,16 @@ import { buildTrayMenuTemplate, buildAppMenuTemplate, type RecoveryState, + armBootTimer, + clearBootTimer, + isBootArmed, } from './recovery'; +// Mock electron with isPackaged=true so the real arm path executes in all tests below. +vi.mock('electron', () => ({ + app: { isPackaged: true }, +})); + describe('RecoveryStateStore', () => { it('returns the initial state', () => { const store = new RecoveryStateStore(); @@ -254,3 +262,45 @@ describe('buildAppMenuTemplate', () => { expect(restartItem!.label).toBe('Restart to Install Update'); }); }); + +interface FakeWebContents { getURL: () => string } +interface FakeWindow { webContents: FakeWebContents; isDestroyed: () => boolean } + +function fakeWin(url: string): FakeWindow { + return { webContents: { getURL: () => url }, isDestroyed: () => false }; +} + +describe('armBootTimer / clearBootTimer', () => { + beforeEach(() => { + clearBootTimer(); + }); + + it('arms when URL is http://', () => { + armBootTimer(fakeWin('http://localhost:3005/') as never); + expect(isBootArmed()).toBe(true); + clearBootTimer(); + }); + + it('arms when URL is https://', () => { + armBootTimer(fakeWin('https://example.com/') as never); + expect(isBootArmed()).toBe(true); + clearBootTimer(); + }); + + it('skips file:// URLs', () => { + armBootTimer(fakeWin('file:///path/to/recovery.html') as never); + expect(isBootArmed()).toBe(false); + }); + + it('skips empty URLs', () => { + armBootTimer(fakeWin('') as never); + expect(isBootArmed()).toBe(false); + }); + + it('clearBootTimer disarms', () => { + armBootTimer(fakeWin('http://localhost/') as never); + expect(isBootArmed()).toBe(true); + clearBootTimer(); + expect(isBootArmed()).toBe(false); + }); +}); diff --git a/packages/desktop/src/recovery.ts b/packages/desktop/src/recovery.ts index bcc44744..d6da9013 100644 --- a/packages/desktop/src/recovery.ts +++ b/packages/desktop/src/recovery.ts @@ -1,4 +1,5 @@ -import type { MenuItemConstructorOptions } from 'electron'; +import { app } from 'electron'; +import type { BrowserWindow, MenuItemConstructorOptions } from 'electron'; export type RecoveryReasonCode = | 'load-failed' @@ -193,3 +194,65 @@ export function buildAppMenuTemplate( }, ]; } + +// --------------------------------------------------------------------------- +// Boot-completion timer +// --------------------------------------------------------------------------- +// Arms when the renderer navigates to an http(s) URL in a packaged build. +// If the renderer does not call rendererReady() within BOOT_TIMEOUT_MS, the +// onBootStallCallback fires and main.ts triggers recovery mode. +// --------------------------------------------------------------------------- + +const BOOT_TIMEOUT_MS = 20_000; +let bootTimer: ReturnType | null = null; +let bootArmed = false; +let onBootStallCallback: (() => void) | null = null; + +/** + * Register the callback fired when the boot timer expires. + * Wired by main.ts to call enterRecoveryMode({ code: 'renderer-stalled', ... }). + * Kept as a setter to avoid a forward-reference cycle: the setter shape mirrors + * setMainWindow/setAutoUpdater and keeps the timer logic independently testable. + */ +export function setOnBootStall(cb: (() => void) | null): void { + onBootStallCallback = cb; +} + +/** + * Arm the boot-completion timer for the given window. + * Only arms in packaged builds (no-ops in dev) and only for http(s):// URLs + * (so file:// picker/recovery pages do not trip the timer). + */ +export function armBootTimer(win: BrowserWindow): void { + if (!app.isPackaged) return; + const url = win.webContents.getURL(); + if (!url.startsWith('http://') && !url.startsWith('https://')) return; + clearBootTimer(); + bootArmed = true; + bootTimer = setTimeout(() => { + bootTimer = null; + if (!bootArmed) return; + bootArmed = false; + onBootStallCallback?.(); + }, BOOT_TIMEOUT_MS); +} + +/** Disarm and clear the boot timer. Called on renderer-ready or window destroy. */ +export function clearBootTimer(): void { + if (bootTimer) clearTimeout(bootTimer); + bootTimer = null; + bootArmed = false; +} + +/** Returns true while the boot timer is armed and waiting for renderer-ready. */ +export function isBootArmed(): boolean { + return bootArmed; +} + +/** + * Called from the renderer-ready IPC handler. + * Disarms the boot timer — the renderer booted successfully. + */ +export function handleRendererReady(): void { + if (bootArmed) clearBootTimer(); +}