From e8a1de51f26405a64fc9d713bf5c28b34de762eb Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 13:19:37 -0500 Subject: [PATCH 1/2] fix: restore standard macOS menus and window lifecycle --- desktop/src/main/__tests__/menu.test.ts | 38 ++++++++++++ desktop/src/main/index.ts | 26 ++++++-- desktop/src/main/menu.ts | 62 +++++++++++++------ docs/DESKTOP-RELEASE.md | 5 ++ scripts/native-ui/menu-commands.mjs | 82 +++++++++++++++++++++++++ scripts/native-ui/run.mjs | 5 +- 6 files changed, 193 insertions(+), 25 deletions(-) diff --git a/desktop/src/main/__tests__/menu.test.ts b/desktop/src/main/__tests__/menu.test.ts index 7033b281..b1fed2ca 100644 --- a/desktop/src/main/__tests__/menu.test.ts +++ b/desktop/src/main/__tests__/menu.test.ts @@ -141,6 +141,7 @@ describe('desktop native menu', () => { 'Veritas Kanban', 'File', 'editMenu', + 'View', 'Navigate', 'Desktop', 'windowMenu', @@ -174,3 +175,40 @@ describe('desktop native menu', () => { expect(template.some((item) => item.role === 'help')).toBe(false); }); }); + +describe('standard platform menu roles', () => { + const roles = (platform: NodeJS.Platform) => + createDesktopMenuTemplate({ + platform, + status: status(), + dispatch: vi.fn(), + copyVersionInfo: vi.fn(), + openHelp: vi.fn(), + }).flatMap((item) => (Array.isArray(item.submenu) ? item.submenu : [item])); + it('declares standard macOS roles and accelerators while retaining custom actions', () => { + const items = roles('darwin'); + for (const [role, accelerator] of [ + ['quit', 'CommandOrControl+Q'], + ['close', 'CommandOrControl+W'], + ['hide', 'Command+H'], + ['hideOthers', 'Command+Alt+H'], + ['resetZoom', 'CommandOrControl+0'], + ['zoomIn', 'CommandOrControl+Plus'], + ['zoomOut', 'CommandOrControl+-'], + ['togglefullscreen', 'Control+Command+F'], + ]) { + expect(items.find((item) => item.role === role)).toMatchObject({ accelerator }); + } + expect(items.some((item) => item.role === 'services')).toBe(true); + expect(items.some((item) => item.role === 'unhide')).toBe(true); + // The native quit role emits app.before-quit, which owns managed shutdown. + expect(items.find((item) => item.role === 'quit')?.click).toBeUndefined(); + }); + it.each(['linux', 'win32'] as const)('does not install Mac-only roles on %s', (platform) => { + const items = roles(platform); + expect( + items.some((item) => ['services', 'hide', 'hideOthers', 'unhide'].includes(item.role ?? '')) + ).toBe(false); + expect(items.find((item) => item.role === 'togglefullscreen')?.accelerator).toBe('F11'); + }); +}); diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index c5cd7157..f570a8bf 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -402,13 +402,31 @@ app.on('before-quit', (event) => { }); app.on('window-all-closed', () => { - app.quit(); + if (process.platform !== 'darwin') app.quit(); }); +let reopeningWindow = false; app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - void boot(); - } + if (activeMainWindow() || reopeningWindow || quitting) return; + reopeningWindow = true; + void (async () => { + // Closing the last Mac window keeps its managed server and IPC handlers alive. + if (runtime && windowStatePaths) { + const savedState = await readDesktopWindowState(windowStatePaths); + if (quitting) return; + mainWindow = createMainWindow(savedState); + await mainWindow.loadURL(runtime.getRendererOrigin()); + flushPendingDeepLinks(); + } else { + await boot(); + } + })() + .catch((error: unknown) => { + showDesktopError(error instanceof Error ? error.message : 'Unable to reopen the window.'); + }) + .finally(() => { + reopeningWindow = false; + }); }); process.on('uncaughtException', (error) => { diff --git a/desktop/src/main/menu.ts b/desktop/src/main/menu.ts index c45037b5..2641f99f 100644 --- a/desktop/src/main/menu.ts +++ b/desktop/src/main/menu.ts @@ -27,6 +27,7 @@ export function configureDesktopMenu(options: ConfigureDesktopMenuOptions): void export function createDesktopMenuTemplate( options: ConfigureDesktopMenuOptions ): MenuItemConstructorOptions[] { + const isMac = (options.platform ?? process.platform) === 'darwin'; const command = (name: DesktopCommandName): MenuItemConstructorOptions => { const definition = DESKTOP_COMMAND_REGISTRY[name]; return { @@ -37,25 +38,24 @@ export function createDesktopMenuTemplate( }; }; - const macosMenus: MenuItemConstructorOptions[] = - (options.platform ?? process.platform) === 'darwin' - ? [ - { role: 'windowMenu' }, - { - role: 'help', - submenu: [ - { - label: 'Veritas Kanban Help', - click: () => options.openHelp(), - }, - { - ...command('open-onboarding'), - label: 'Show Setup && Diagnostics', - }, - ], - }, - ] - : []; + const macosMenus: MenuItemConstructorOptions[] = isMac + ? [ + { role: 'windowMenu' }, + { + role: 'help', + submenu: [ + { + label: 'Veritas Kanban Help', + click: () => options.openHelp(), + }, + { + ...command('open-onboarding'), + label: 'Show Setup && Diagnostics', + }, + ], + }, + ] + : []; return [ { @@ -76,7 +76,17 @@ export function createDesktopMenuTemplate( command('download-update'), command('install-update'), { type: 'separator' }, - command('quit'), + ...(isMac + ? [ + { role: 'services' as const }, + { type: 'separator' as const }, + { role: 'hide' as const, accelerator: 'Command+H' }, + { role: 'hideOthers' as const, accelerator: 'Command+Alt+H' }, + { role: 'unhide' as const }, + { type: 'separator' as const }, + ] + : []), + { role: 'quit', accelerator: 'CommandOrControl+Q' }, ], }, { @@ -86,9 +96,21 @@ export function createDesktopMenuTemplate( command('import-data'), command('export-data'), command('create-backup'), + { type: 'separator' }, + { role: 'close', accelerator: 'CommandOrControl+W' }, ], }, { role: 'editMenu' }, + { + label: 'View', + submenu: [ + { role: 'resetZoom', accelerator: 'CommandOrControl+0' }, + { role: 'zoomIn', accelerator: 'CommandOrControl+Plus' }, + { role: 'zoomOut', accelerator: 'CommandOrControl+-' }, + { type: 'separator' }, + { role: 'togglefullscreen', accelerator: isMac ? 'Control+Command+F' : 'F11' }, + ], + }, { label: 'Navigate', submenu: [ diff --git a/docs/DESKTOP-RELEASE.md b/docs/DESKTOP-RELEASE.md index 7f2fe4b6..87cac51c 100644 --- a/docs/DESKTOP-RELEASE.md +++ b/docs/DESKTOP-RELEASE.md @@ -317,3 +317,8 @@ menu and the mounted renderer. File data commands and Debug Bundle open the exis Maintenance flow; they do not automatically export, restore, or create files. Renderer commands wait for an acknowledgement. Finish setup and unlock the workspace before using them; unavailable actions display a reason instead of reporting success. + +On macOS, Command-W closes the window while the managed server stays running. +Reopen the window from the Dock. Command-Q quits and stops the managed server. +Standard Hide, Hide Others, Show All and Services commands are available in the +application menu; View provides text zoom and full screen. diff --git a/scripts/native-ui/menu-commands.mjs b/scripts/native-ui/menu-commands.mjs index 65d8b6f5..393dd8fd 100644 --- a/scripts/native-ui/menu-commands.mjs +++ b/scripts/native-ui/menu-commands.mjs @@ -61,3 +61,85 @@ export async function verifyNativeMenuCommands(app, page) { assert(unsupported.message); return results; } + +/** Native roles must be wired in Electron, including a live managed-server lifecycle. */ +export async function verifyNativeWindowMenu(app, page) { + const items = await app.evaluate(({ Menu }) => { + const flatten = (menu) => + menu.items.flatMap((item) => [ + { role: item.role, accelerator: item.accelerator }, + ...(item.submenu ? flatten(item.submenu) : []), + ]); + return flatten(Menu.getApplicationMenu()); + }); + for (const role of [ + 'quit', + 'close', + 'hide', + 'hideOthers', + 'unhide', + 'services', + 'resetZoom', + 'zoomIn', + 'zoomOut', + 'togglefullscreen', + ]) { + assert( + items.some((item) => item.role === role), + `Missing native role: ${role}` + ); + } + const clickRole = (role) => + app.evaluate(({ Menu }, role) => { + const find = (menu) => { + for (const item of menu.items) { + if (item.role === role) return item; + const nested = item.submenu && find(item.submenu); + if (nested) return nested; + } + }; + const item = find(Menu.getApplicationMenu()); + if (!item?.enabled) throw new Error(`Role unavailable: ${role}`); + item.click(); + }, role); + const zoom = () => + app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].webContents.getZoomFactor() + ); + await clickRole('zoomIn'); + await expect.poll(zoom).toBeGreaterThan(1); + await clickRole('resetZoom'); + await expect.poll(zoom).toBe(1); + await clickRole('zoomOut'); + await expect.poll(zoom).toBeLessThan(1); + await clickRole('resetZoom'); + await expect.poll(zoom).toBe(1); + await clickRole('togglefullscreen'); + await expect + .poll(() => + app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFullScreen()) + ) + .toBe(true); + await clickRole('togglefullscreen'); + await expect + .poll(() => + app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFullScreen()) + ) + .toBe(false); + const before = await page.evaluate(() => window.veritasDesktop.getConnectionStatus()); + const closed = page.waitForEvent('close'); + await clickRole('close'); + await closed; + assert.equal(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length), 0); + const opened = app.waitForEvent('window'); + await app.evaluate(({ app }) => app.emit('activate')); + const reopened = await opened; + await expect(reopened.getByRole('button', { name: 'Settings', exact: true })).toBeVisible(); + const after = await reopened.evaluate(() => window.veritasDesktop.getConnectionStatus()); + assert.equal( + after.server.pid, + before.server.pid, + 'Closing the window restarted the managed server' + ); + return { page: reopened, roles: items }; +} diff --git a/scripts/native-ui/run.mjs b/scripts/native-ui/run.mjs index b34d9f34..4b7c48ce 100644 --- a/scripts/native-ui/run.mjs +++ b/scripts/native-ui/run.mjs @@ -6,7 +6,7 @@ import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { expect } from '@playwright/test'; import { createNativeSession } from './session.mjs'; -import { verifyNativeMenuCommands } from './menu-commands.mjs'; +import { verifyNativeMenuCommands, verifyNativeWindowMenu } from './menu-commands.mjs'; import { fileDigest, evidenceFailures, @@ -654,6 +654,9 @@ async function checkSeededRendererFailures() { try { await launch(); report.menuCommands = await verifyNativeMenuCommands(app, page); + const windowMenu = await verifyNativeWindowMenu(app, page); + page = windowMenu.page; + report.menuRoles = windowMenu.roles; await persist(); for (const mode of modes) { for (const state of states) { From c24c6a9e63361e4fdbc21dc46f2180547c45fe3c Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 18:51:49 -0500 Subject: [PATCH 2/2] chore: run checks against the main branch target