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:
+104
-95
@@ -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,22 +421,15 @@ 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,
|
|
||||||
silent: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
notification.on('click', () => {
|
|
||||||
mainWindow?.show();
|
mainWindow?.show();
|
||||||
mainWindow?.focus();
|
mainWindow?.focus();
|
||||||
});
|
}));
|
||||||
|
|
||||||
notification.show();
|
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"
|
|
||||||
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
|
// 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.
|
// 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([
|
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 ────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user