From 0c489701fc16ec9e41f1ca4c1c3cba143d299617 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 04:20:22 +0200 Subject: [PATCH] feat(desktop): add enterRecoveryMode and recovery action funnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mainWindow/autoUpdater/quit handover via setter pattern (no main.ts imports) - Optimistic recovery exit on Reload — re-enters via did-fail-load if reload fails - install-update calls autoUpdater.quitAndInstall() directly (force-kill fix) - Strict allowlist validation in handleRecoveryAction --- packages/desktop/src/recovery.ts | 139 ++++++++++++++++++++++++++++++- 1 file changed, 138 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/recovery.ts b/packages/desktop/src/recovery.ts index d6da9013..da15ee5b 100644 --- a/packages/desktop/src/recovery.ts +++ b/packages/desktop/src/recovery.ts @@ -1,5 +1,8 @@ -import { app } from 'electron'; +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' @@ -256,3 +259,137 @@ export function isBootArmed(): boolean { export function handleRendererReady(): void { 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 }); + + 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 = 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 }); + 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': { + // 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': { + clearInstanceUrl(); + recoveryStore.markRecoveryExited(); + recoveryStore.update({ mode: 'normal', reason: null }); + mainWindowRef?.loadFile(getPickerPath()); + return; + } + case 'open-releases': { + shell.openExternal('https://github.com/TheZwiss/backspace/releases/latest'); + return; + } + case 'quit': { + onQuitRequestedCallback?.(); + return; + } + } +}