From bb25a3ba5e84ab6a67af700005c5a57e56ab406a Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 04:30:58 +0200 Subject: [PATCH] feat(desktop): wire recovery store into main process - setMainWindow on createWindow, setMainWindow(null) on closed - attachRecoveryHandlers wires Electron unresponsive/crash/load-fail events - Store subscriber drives tray context menu + macOS app menu + mode-gated recovery-state-changed push to renderer; single applyMenusForState function shared between subscriber and initial fire (no drift) - Old hard-coded createTray Menu and macOS app-menu construction deleted; Win/Linux Edit-only menu retained as one-time setup for keyboard accelerators - requestQuit exported, wired via setOnQuitRequested callback - Recovery IPC handlers: renderer-ready, recovery-action, get-recovery-state - setAppUserModelId('com.backspace.desktop') for Win32 notification attribution - Extended showNotification with optional onClick (existing 2-arg callers unchanged) --- packages/desktop/src/main.ts | 209 ++++++++++++++++++----------------- 1 file changed, 109 insertions(+), 100 deletions(-) diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index c1d9ff6d..0bc14799 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -23,6 +23,18 @@ import { clearInstanceUrl, getPickerPath, } from './instanceUrl'; +import { + recoveryStore, + attachRecoveryHandlers, + setMainWindow, + setOnQuitRequested, + handleRendererReady, + handleRecoveryAction, + isValidRecoveryAction, + buildTrayMenuTemplate, + buildAppMenuTemplate, + type RecoveryState, +} from './recovery'; let mainWindow: BrowserWindow | null = null; const keybindManager = new KeybindManager(); @@ -285,6 +297,8 @@ function createWindow(): void { }, }); + setMainWindow(mainWindow); + attachRecoveryHandlers(mainWindow); keybindManager.setWindow(mainWindow); if (savedState.isMaximized) { @@ -354,6 +368,7 @@ function createWindow(): void { }); mainWindow.on('closed', () => { + setMainWindow(null); mainWindow = null; }); @@ -390,41 +405,9 @@ function createTray(): void { const icon = loadTrayIcon(); tray = new Tray(icon); - const contextMenu = Menu.buildFromTemplate([ - { - label: 'Show Backspace', - click: () => { - mainWindow?.show(); - mainWindow?.focus(); - }, - }, - { - label: 'Hide', - click: () => { - mainWindow?.hide(); - }, - }, - { - label: 'Change Instance', - click: () => { - clearInstanceUrl(); - mainWindow?.loadFile(getPickerPath()); - mainWindow?.show(); - mainWindow?.focus(); - }, - }, - { type: 'separator' }, - { - label: 'Quit', - click: () => { - isQuitting = true; - app.quit(); - }, - }, - ]); - tray.setToolTip('Backspace'); - tray.setContextMenu(contextMenu); + // Context menu is set by the recoveryStore subscriber in app.whenReady, + // which keeps the menu in sync with update/recovery state. tray.on('click', () => { if (mainWindow?.isVisible()) { @@ -438,21 +421,14 @@ function createTray(): void { // ─── Notifications ────────────────────────────────────────────────────────── -function showNotification(title: string, body: string): void { - if (Notification.isSupported()) { - const notification = new Notification({ - title, - body, - silent: false, - }); - - notification.on('click', () => { - mainWindow?.show(); - mainWindow?.focus(); - }); - - notification.show(); - } +function showNotification(title: string, body: string, onClick?: () => void): void { + if (!Notification.isSupported()) return; + const notification = new Notification({ title, body, silent: false }); + notification.on('click', onClick ?? (() => { + mainWindow?.show(); + mainWindow?.focus(); + })); + notification.show(); } // ─── IPC Handlers ─────────────────────────────────────────────────────────── @@ -638,6 +614,21 @@ function registerIpcHandlers(): void { ipcMain.handle('check-accessibility', () => { return keybindManager.checkAccessibility(); }); + + // Recovery / boot-stall protocol + ipcMain.on('renderer-ready', () => { + handleRendererReady(); + }); + + ipcMain.on('recovery-action', (_e, action: unknown) => { + if (!isValidRecoveryAction(action)) { + console.warn('[recovery] ignored unknown action:', action); + return; + } + handleRecoveryAction(action); + }); + + ipcMain.handle('get-recovery-state', () => recoveryStore.get()); } // ─── Auto-Update ──────────────────────────────────────────────────────────── @@ -721,6 +712,23 @@ if (process.platform === 'linux') { app.commandLine.appendSwitch('enable-features', 'PulseaudioLoopbackForScreenShare'); } +// Windows: AppUserModelId so toast notifications attribute correctly to Backspace +// (without this, recovery / update notifications appear under "electron.exe"). +if (process.platform === 'win32') { + app.setAppUserModelId('com.backspace.desktop'); +} + +/** + * Request a clean app quit. Sets the isQuitting flag so the window 'close' + * handler doesn't intercept (which would just hide the window) and then + * triggers app.quit(). Exported so the recovery module can request a quit + * via setOnQuitRequested without reaching into private state. + */ +export function requestQuit(): void { + isQuitting = true; + app.quit(); +} + // Set as default protocol handler app.setAsDefaultProtocolClient('backspace'); @@ -754,57 +762,11 @@ if (!gotTheLock) { // ─── App Lifecycle ────────────────────────────────────────────────────────── app.whenReady().then(async () => { - // macOS application menu with "Change Instance" - if (process.platform === 'darwin') { - const appMenu = Menu.buildFromTemplate([ - { - label: app.name, - submenu: [ - { role: 'about' }, - { type: 'separator' }, - { - label: 'Change Instance', - click: () => { - clearInstanceUrl(); - mainWindow?.loadFile(getPickerPath()); - mainWindow?.show(); - mainWindow?.focus(); - }, - }, - { type: 'separator' }, - { role: 'hide' }, - { role: 'hideOthers' }, - { role: 'unhide' }, - { type: 'separator' }, - { role: 'quit' }, - ], - }, - { - 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' }, - ], - }, - ]); - Menu.setApplicationMenu(appMenu); - } else { - // Win/Linux: frameless window has no menu bar, but we still need an - // application menu so keyboard accelerators (Ctrl+C/V/X/Z/A) work. + // Win/Linux: frameless window has no menu bar, but we still need an + // application menu so keyboard accelerators (Ctrl+C/V/X/Z/A) work. + // The macOS app menu is owned by the recoveryStore subscriber below + // (so it can re-render with current update/recovery state). + if (process.platform !== 'darwin') { Menu.setApplicationMenu(Menu.buildFromTemplate([ { label: 'Edit', @@ -896,6 +858,53 @@ if (!gotTheLock) { registerIpcHandlers(); createWindow(); createTray(); + + // Wire the recovery module's quit callback to the local requestQuit. + setOnQuitRequested(() => requestQuit()); + + // Tray + macOS app-menu actions. Defined once so the subscriber and the + // initial-fire share one implementation (no drift on future menu changes). + const trayActions = { + onShow: () => { mainWindow?.show(); mainWindow?.focus(); }, + onHide: () => mainWindow?.hide(), + onChangeInstance: () => { + clearInstanceUrl(); + mainWindow?.loadFile(getPickerPath()); + mainWindow?.show(); + mainWindow?.focus(); + }, + onCheckForUpdates: () => handleRecoveryAction('check-update'), + onRestartToInstall: () => handleRecoveryAction('install-update'), + onQuit: () => requestQuit(), + }; + + const appMenuActions = { + onChangeInstance: () => { + clearInstanceUrl(); + mainWindow?.loadFile(getPickerPath()); + mainWindow?.show(); + mainWindow?.focus(); + }, + onCheckForUpdates: () => handleRecoveryAction('check-update'), + onRestartToInstall: () => handleRecoveryAction('install-update'), + }; + + const applyMenusForState = (state: RecoveryState): void => { + if (tray) { + tray.setContextMenu(Menu.buildFromTemplate(buildTrayMenuTemplate(state, trayActions))); + } + if (process.platform === 'darwin') { + Menu.setApplicationMenu(Menu.buildFromTemplate(buildAppMenuTemplate(app.name, state, appMenuActions))); + } + // Mode-gated push to renderer (recovery.html subscribes to this). + if (state.mode === 'recovery' && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('recovery-state-changed', state); + } + }; + + recoveryStore.subscribe(applyMenusForState); + applyMenusForState(recoveryStore.get()); // initial fire — subscribers don't auto-fire on subscribe + initAutoUpdater(); // ─── Activity Detection ────────────────────────────────────────────────