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)
This commit is contained in:
Jannis Braun
2026-05-03 04:30:58 +02:00
parent 1c4bde3029
commit bb25a3ba5e
+109 -100
View File
@@ -23,6 +23,18 @@ import {
clearInstanceUrl, clearInstanceUrl,
getPickerPath, getPickerPath,
} from './instanceUrl'; } from './instanceUrl';
import {
recoveryStore,
attachRecoveryHandlers,
setMainWindow,
setOnQuitRequested,
handleRendererReady,
handleRecoveryAction,
isValidRecoveryAction,
buildTrayMenuTemplate,
buildAppMenuTemplate,
type RecoveryState,
} from './recovery';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
const keybindManager = new KeybindManager(); const keybindManager = new KeybindManager();
@@ -285,6 +297,8 @@ function createWindow(): void {
}, },
}); });
setMainWindow(mainWindow);
attachRecoveryHandlers(mainWindow);
keybindManager.setWindow(mainWindow); keybindManager.setWindow(mainWindow);
if (savedState.isMaximized) { if (savedState.isMaximized) {
@@ -354,6 +368,7 @@ function createWindow(): void {
}); });
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
setMainWindow(null);
mainWindow = null; mainWindow = null;
}); });
@@ -390,41 +405,9 @@ function createTray(): void {
const icon = loadTrayIcon(); const icon = loadTrayIcon();
tray = new Tray(icon); 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.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', () => { tray.on('click', () => {
if (mainWindow?.isVisible()) { if (mainWindow?.isVisible()) {
@@ -438,21 +421,14 @@ function createTray(): void {
// ─── Notifications ────────────────────────────────────────────────────────── // ─── Notifications ──────────────────────────────────────────────────────────
function showNotification(title: string, body: string): void { function showNotification(title: string, body: string, onClick?: () => void): void {
if (Notification.isSupported()) { if (!Notification.isSupported()) return;
const notification = new Notification({ const notification = new Notification({ title, body, silent: false });
title, notification.on('click', onClick ?? (() => {
body, mainWindow?.show();
silent: false, mainWindow?.focus();
}); }));
notification.show();
notification.on('click', () => {
mainWindow?.show();
mainWindow?.focus();
});
notification.show();
}
} }
// ─── IPC Handlers ─────────────────────────────────────────────────────────── // ─── IPC Handlers ───────────────────────────────────────────────────────────
@@ -638,6 +614,21 @@ function registerIpcHandlers(): void {
ipcMain.handle('check-accessibility', () => { ipcMain.handle('check-accessibility', () => {
return keybindManager.checkAccessibility(); 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 ──────────────────────────────────────────────────────────── // ─── Auto-Update ────────────────────────────────────────────────────────────
@@ -721,6 +712,23 @@ if (process.platform === 'linux') {
app.commandLine.appendSwitch('enable-features', 'PulseaudioLoopbackForScreenShare'); 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 // Set as default protocol handler
app.setAsDefaultProtocolClient('backspace'); app.setAsDefaultProtocolClient('backspace');
@@ -754,57 +762,11 @@ if (!gotTheLock) {
// ─── App Lifecycle ────────────────────────────────────────────────────────── // ─── App Lifecycle ──────────────────────────────────────────────────────────
app.whenReady().then(async () => { app.whenReady().then(async () => {
// macOS application menu with "Change Instance" // Win/Linux: frameless window has no menu bar, but we still need an
if (process.platform === 'darwin') { // application menu so keyboard accelerators (Ctrl+C/V/X/Z/A) work.
const appMenu = Menu.buildFromTemplate([ // The macOS app menu is owned by the recoveryStore subscriber below
{ // (so it can re-render with current update/recovery state).
label: app.name, if (process.platform !== 'darwin') {
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.
Menu.setApplicationMenu(Menu.buildFromTemplate([ Menu.setApplicationMenu(Menu.buildFromTemplate([
{ {
label: 'Edit', label: 'Edit',
@@ -896,6 +858,53 @@ if (!gotTheLock) {
registerIpcHandlers(); registerIpcHandlers();
createWindow(); createWindow();
createTray(); 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(); initAutoUpdater();
// ─── Activity Detection ──────────────────────────────────────────────── // ─── Activity Detection ────────────────────────────────────────────────