Files
backspace/packages/desktop/src/recovery.ts
T
Jannis Braun f481e1fe9e license: relicense to AGPL-3.0-only with commercial dual-license
- LICENSE -> verbatim GNU AGPL-3.0; add LICENSE-COMMERCIAL.md + SECURITY.md
- CLA -> exclusive-license grant (contributors keep copyright); add README
  anti-rugpull covenant + relicense record
- NOTICE / README / CONTRIBUTING / CLAUDE.md / package.json x5 updated;
  contact routed through GitHub (no email placeholders)
- AGPL section 13 source offer: operator-configurable BACKSPACE_SOURCE_URL +
  build-injected commit; sourceCodeUrl+commit on /api/instance/info;
  SourceCodeLink on login/register/settings/desktop; docs + .env.example updated
2026-07-01 16:38:22 +02:00

520 lines
18 KiB
TypeScript

import { app, shell } from 'electron';
import type { BrowserWindow, MenuItemConstructorOptions } from 'electron';
import type { AppUpdater } from 'electron-updater';
import path from 'path';
import { loadInstanceUrl, clearInstanceUrl, getPickerPath } from './instanceUrl';
export type RecoveryReasonCode =
| 'load-failed'
| 'render-gone'
| 'unresponsive'
| 'renderer-stalled';
export type UpdateState =
| 'idle'
| 'checking'
| 'downloading'
| 'downloaded'
| 'error';
export interface RecoveryState {
mode: 'normal' | 'recovery';
reason: { code: RecoveryReasonCode; detail: string } | null;
updateState: UpdateState;
updateVersion: string | null;
lastUpdateError: { message: string; code: string | null; at: number } | null;
lastCheckResult: 'up-to-date' | 'failed' | null;
}
const INITIAL_STATE: RecoveryState = {
mode: 'normal',
reason: null,
updateState: 'idle',
updateVersion: null,
lastUpdateError: null,
lastCheckResult: null,
};
export class RecoveryStateStore {
// Freeze so the initial get() return cannot be mutated by callers.
private state: RecoveryState = Object.freeze({ ...INITIAL_STATE }) as RecoveryState;
private listeners = new Set<(s: RecoveryState) => void>();
private inRecoveryMode = false;
get(): Readonly<RecoveryState> {
return this.state;
}
update(partial: Partial<RecoveryState>): void {
// Freeze so consumers (incl. subscribers, IPC-cloned renderer reads via
// get-recovery-state) cannot mutate the shared state through the live
// reference returned by get(). Compile-time Readonly<> is a hint only.
this.state = Object.freeze({ ...this.state, ...partial }) as RecoveryState;
// Snapshot before iterating: a listener can subscribe/unsubscribe others
// (or itself) during notification without affecting the current notify pass.
const snapshot = Array.from(this.listeners);
for (const cb of snapshot) {
try {
cb(this.state);
} catch (err) {
console.error('[recovery] listener threw:', err);
}
}
}
subscribe(cb: (s: RecoveryState) => void): () => void {
this.listeners.add(cb);
return () => { this.listeners.delete(cb); };
}
isInRecoveryMode(): boolean {
return this.inRecoveryMode;
}
markRecoveryEntered(): void {
this.inRecoveryMode = true;
}
markRecoveryExited(): void {
this.inRecoveryMode = false;
}
}
export const recoveryStore = new RecoveryStateStore();
export function extractErrorCode(err: unknown): string | null {
if (typeof err === 'object' && err !== null && 'code' in err) {
const code = (err as { code: unknown }).code;
return typeof code === 'string' ? code : null;
}
return null;
}
interface MenuActions {
onShow: () => void;
onHide: () => void;
onChangeInstance: () => void;
onCheckForUpdates: () => void;
onRestartToInstall: () => void;
// AGPL-3.0 § 13: open the Corresponding Source of the running instance.
onOpenSource: () => void;
onQuit: () => void;
}
function checkForUpdatesItem(state: RecoveryState, click: () => void): MenuItemConstructorOptions {
switch (state.updateState) {
case 'checking':
return { id: 'check-for-updates', label: 'Checking for Updates…', enabled: false };
case 'downloading':
return { id: 'check-for-updates', label: 'Downloading Update…', enabled: false };
case 'downloaded':
return { id: 'check-for-updates', label: 'Update Ready', enabled: false };
case 'error':
return { id: 'check-for-updates', label: 'Check for Updates… (last attempt failed)', enabled: true, click };
case 'idle':
default:
return { id: 'check-for-updates', label: 'Check for Updates…', enabled: true, click };
}
}
export function buildTrayMenuTemplate(
state: RecoveryState,
actions?: Partial<MenuActions>,
): MenuItemConstructorOptions[] {
const items: MenuItemConstructorOptions[] = [
{ label: 'Show Backspace', click: actions?.onShow },
{ label: 'Hide', click: actions?.onHide },
{ type: 'separator' },
checkForUpdatesItem(state, () => actions?.onCheckForUpdates?.()),
];
if (state.updateState === 'downloaded') {
items.push({
id: 'restart-to-install',
label: 'Restart to Install Update',
enabled: true,
click: actions?.onRestartToInstall,
});
}
items.push(
{ type: 'separator' },
{ label: 'Change Instance', click: actions?.onChangeInstance },
{ label: 'Source code (AGPL)', click: actions?.onOpenSource },
{ type: 'separator' },
{ label: 'Quit', click: actions?.onQuit },
);
return items;
}
export function buildAppMenuTemplate(
appName: string,
state: RecoveryState,
actions?: Partial<MenuActions>,
): MenuItemConstructorOptions[] {
const appSubmenu: MenuItemConstructorOptions[] = [
{ role: 'about' },
{ label: 'Source code (AGPL)', click: () => actions?.onOpenSource?.() },
{ type: 'separator' },
checkForUpdatesItem(state, () => actions?.onCheckForUpdates?.()),
];
if (state.updateState === 'downloaded') {
appSubmenu.push({
id: 'restart-to-install',
label: 'Restart to Install Update',
enabled: true,
click: actions?.onRestartToInstall,
});
}
appSubmenu.push(
{ type: 'separator' },
{ label: 'Change Instance', click: actions?.onChangeInstance },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
);
return [
{ label: appName, submenu: appSubmenu },
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
{ type: 'separator' },
{ role: 'front' },
],
},
];
}
// ---------------------------------------------------------------------------
// 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;
// Tracks whether the renderer-ready ping has arrived for the current top-level
// navigation. Reset on did-navigate (in attachRecoveryHandlers).
//
// Why this matters: in real SPAs, useEffect fires during document load
// (microtask after bundle execution), which is BEFORE did-finish-load (which
// fires after window.onload). Without this flag, the ping arrives when
// bootArmed=false (no-op), then did-finish-load arms a fresh timer that
// nothing clears → 20s later, false renderer-stalled recovery.
let pingReceivedThisNav = false;
/**
* 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;
// Critical: if the renderer-ready ping arrived before did-finish-load
// (typical SPA timing — useEffect runs in microtask before window.onload),
// do not arm. Otherwise the timer fires 20s later despite the renderer
// being healthy.
if (pingReceivedThisNav) 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;
}
/**
* Reset all module-level boot-timer state.
* Exported for test isolation only — do not call from production code.
* Production code resets pingReceivedThisNav exclusively via the
* did-navigate handler in attachRecoveryHandlers.
*/
export function resetBootTimerStateForTest(): void {
if (bootTimer) clearTimeout(bootTimer);
bootTimer = null;
bootArmed = false;
pingReceivedThisNav = 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.
* Sets pingReceivedThisNav so that if did-finish-load fires after the ping
* (typical SPA timing), armBootTimer will short-circuit rather than arming a
* timer that nothing will clear.
*/
export function handleRendererReady(): void {
pingReceivedThisNav = true;
if (bootArmed) clearBootTimer();
}
// ---------------------------------------------------------------------------
// Reference setters
// ---------------------------------------------------------------------------
// recovery.ts holds its own refs to the live Electron objects it needs.
// Setters are used instead of direct imports to keep recovery.ts free of
// circular dependencies on main.ts.
// ---------------------------------------------------------------------------
let mainWindowRef: BrowserWindow | null = null;
let autoUpdaterRef: AppUpdater | null = null;
let onQuitRequestedCallback: (() => void) | null = null;
export function setMainWindow(win: BrowserWindow | null): void {
mainWindowRef = win;
}
export function setAutoUpdater(au: AppUpdater | null): void {
autoUpdaterRef = au;
}
/**
* Wired by main.ts to call its own requestQuit() (which sets isQuitting + app.quit()).
* Kept as a callback so recovery.ts doesn't import from main.ts (would create a cycle).
*/
export function setOnQuitRequested(cb: (() => void) | null): void {
onQuitRequestedCallback = cb;
}
// ---------------------------------------------------------------------------
// Recovery mode entry
// ---------------------------------------------------------------------------
export function enterRecoveryMode(reason: { code: RecoveryReasonCode; detail: string }): void {
recoveryStore.update({ mode: 'recovery', reason });
console.log(`[recovery] entered: ${reason.code}${reason.detail}`);
if (recoveryStore.isInRecoveryMode()) {
// Already in recovery — state.reason updated for display, no re-navigation.
// Loop prevention: if recovery.html itself fails to load, this guard keeps
// us from infinite re-loading. User-visible outcome is contained failure
// (blank window, escape via tray Quit) — acceptable because a corrupt
// recovery.html means a corrupt build.
return;
}
recoveryStore.markRecoveryEntered();
if (!mainWindowRef || mainWindowRef.isDestroyed()) return;
mainWindowRef.loadFile(path.join(__dirname, '..', 'resources', 'recovery.html'));
// Force-show even when launched hidden (--hidden via autostart) — recovery
// must be visible regardless of prior visibility state.
mainWindowRef.show();
mainWindowRef.focus();
}
// Wire the boot-stall callback at module load time. The setter pattern from
// Task 6 exists to avoid a forward reference (armBootTimer is defined before
// enterRecoveryMode and tests target it in isolation).
setOnBootStall(() => {
enterRecoveryMode({
code: 'renderer-stalled',
detail: `no rendererReady within 20000ms`,
});
});
// ---------------------------------------------------------------------------
// Recovery action funnel
// ---------------------------------------------------------------------------
export type RecoveryAction =
| 'reload'
| 'check-update'
| 'install-update'
| 'change-instance'
| 'open-releases'
| 'quit';
const VALID_ACTIONS: ReadonlySet<string> = new Set([
'reload',
'check-update',
'install-update',
'change-instance',
'open-releases',
'quit',
]);
export function isValidRecoveryAction(action: unknown): action is RecoveryAction {
return typeof action === 'string' && VALID_ACTIONS.has(action);
}
export function handleRecoveryAction(action: RecoveryAction): void {
switch (action) {
case 'reload': {
const url = loadInstanceUrl();
// Optimistic exit — clear recovery state BEFORE loading. If the load
// fails, did-fail-load re-enters recovery. If it stalls, boot timer fires.
recoveryStore.markRecoveryExited();
recoveryStore.update({ mode: 'normal', reason: null });
console.log('[recovery] exited (reload)');
if (!url) {
mainWindowRef?.loadFile(getPickerPath());
return;
}
mainWindowRef?.loadURL(url);
return;
}
case 'check-update': {
recoveryStore.update({ updateState: 'checking', lastCheckResult: null });
autoUpdaterRef?.checkForUpdates().catch(() => { /* check-phase errors stay silent */ });
return;
}
case 'install-update': {
// Defense in depth: only act when an update is actually downloaded. The
// recovery.html UI hides the Restart button unless updateState='downloaded',
// but a buggy/malicious renderer could send this action at any time.
if (recoveryStore.get().updateState !== 'downloaded') return;
// Direct quitAndInstall — does NOT rely on autoInstallOnAppQuit.
// Force-kill-fix: if user reaches recovery and clicks here, install
// happens cleanly even if the on-quit hook would otherwise be bypassed.
autoUpdaterRef?.quitAndInstall();
return;
}
case 'change-instance': {
// Non-destructive: don't clear the saved URL here. The picker pre-fills it
// and offers Cancel — the URL is only overwritten when the user explicitly
// Connects to a different one. (clearInstanceUrl IPC remains for explicit
// "disconnect" operations from the web settings UI.)
recoveryStore.markRecoveryExited();
recoveryStore.update({ mode: 'normal', reason: null });
console.log('[recovery] exited (change-instance)');
mainWindowRef?.loadFile(getPickerPath());
// Ensure visible — tray clicks may happen with window hidden, and the
// recovery surface should also remain visible during the navigation.
// When invoked from recovery.html (window already showing), these are
// idempotent no-ops.
mainWindowRef?.show();
mainWindowRef?.focus();
return;
}
case 'open-releases': {
shell.openExternal('https://github.com/TheZwiss/backspace/releases/latest');
return;
}
case 'quit': {
onQuitRequestedCallback?.();
return;
}
}
}
// ---------------------------------------------------------------------------
// webContents event handlers
// ---------------------------------------------------------------------------
// Attaches Electron BrowserWindow event listeners that feed into recovery mode.
// All closure-local state (pendingArm, unresponsiveTimer) is scoped to the
// function call so each window attachment gets its own independent instance.
// ---------------------------------------------------------------------------
const UNRESPONSIVE_GRACE_MS = 10_000;
export function attachRecoveryHandlers(win: BrowserWindow): void {
let pendingArm = false;
let unresponsiveTimer: ReturnType<typeof setTimeout> | null = null;
// did-navigate fires only for top-level non-same-document navigation.
// SPA in-page routing (history.pushState, hash) fires did-navigate-in-page,
// which we deliberately ignore — keeps the boot timer from misfiring on
// every channel switch.
win.webContents.on('did-navigate', () => {
clearBootTimer();
pendingArm = true;
pingReceivedThisNav = false; // reset for new navigation
});
win.webContents.on('did-finish-load', () => {
if (pendingArm) {
pendingArm = false;
armBootTimer(win);
}
});
win.webContents.on('did-fail-load',
(_e, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (!isMainFrame) return; // ignore sub-resource failures (favicon, broken <img>)
if (errorCode === -3) return; // ERR_ABORTED — intentional nav interruption
enterRecoveryMode({
code: 'load-failed',
detail: `${errorCode} ${errorDescription} @ ${validatedURL}`,
});
});
win.webContents.on('render-process-gone', (_e, details) => {
if (details.reason === 'clean-exit') return; // normal quit, ignore
enterRecoveryMode({
code: 'render-gone',
detail: details.reason, // crashed | killed | oom | launch-failed | integrity-failure
});
});
// unresponsive grace period — Chrome's own pattern. Renderers often recover
// from a long sync op or GC pause within seconds; only enter recovery if
// the renderer stays stuck for the full grace window.
win.webContents.on('unresponsive', () => {
if (unresponsiveTimer) return;
unresponsiveTimer = setTimeout(() => {
unresponsiveTimer = null;
enterRecoveryMode({ code: 'unresponsive', detail: `main thread blocked >${UNRESPONSIVE_GRACE_MS}ms` });
}, UNRESPONSIVE_GRACE_MS);
});
win.webContents.on('responsive', () => {
if (unresponsiveTimer) {
clearTimeout(unresponsiveTimer);
unresponsiveTimer = null;
}
});
}