feat(desktop): add boot-completion timer with packaged-only and URL-protocol guards
This commit is contained in:
@@ -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 type { MenuItemConstructorOptions } from 'electron';
|
||||||
import {
|
import {
|
||||||
RecoveryStateStore,
|
RecoveryStateStore,
|
||||||
@@ -6,8 +6,16 @@ import {
|
|||||||
buildTrayMenuTemplate,
|
buildTrayMenuTemplate,
|
||||||
buildAppMenuTemplate,
|
buildAppMenuTemplate,
|
||||||
type RecoveryState,
|
type RecoveryState,
|
||||||
|
armBootTimer,
|
||||||
|
clearBootTimer,
|
||||||
|
isBootArmed,
|
||||||
} from './recovery';
|
} 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', () => {
|
describe('RecoveryStateStore', () => {
|
||||||
it('returns the initial state', () => {
|
it('returns the initial state', () => {
|
||||||
const store = new RecoveryStateStore();
|
const store = new RecoveryStateStore();
|
||||||
@@ -254,3 +262,45 @@ describe('buildAppMenuTemplate', () => {
|
|||||||
expect(restartItem!.label).toBe('Restart to Install Update');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { MenuItemConstructorOptions } from 'electron';
|
import { app } from 'electron';
|
||||||
|
import type { BrowserWindow, MenuItemConstructorOptions } from 'electron';
|
||||||
|
|
||||||
export type RecoveryReasonCode =
|
export type RecoveryReasonCode =
|
||||||
| 'load-failed'
|
| '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<typeof setTimeout> | 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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user