feat(desktop): add enterRecoveryMode and recovery action funnel
- 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
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
import { app } from 'electron';
|
import { app, shell } from 'electron';
|
||||||
import type { BrowserWindow, MenuItemConstructorOptions } 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 =
|
export type RecoveryReasonCode =
|
||||||
| 'load-failed'
|
| 'load-failed'
|
||||||
@@ -256,3 +259,137 @@ export function isBootArmed(): boolean {
|
|||||||
export function handleRendererReady(): void {
|
export function handleRendererReady(): void {
|
||||||
if (bootArmed) clearBootTimer();
|
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<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 });
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user