Merge pull request #1554 from BradGroux/fix/macos-menus-1529

fix: restore standard macOS menus and window lifecycle
This commit is contained in:
Brad Groux 2026-09-07 18:59:42 -05:00 • committed by GitHub
commit a29998b569
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 193 additions and 25 deletions

View file

@ -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');
});
});

View file

@ -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) => {

View file

@ -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: [

View file

@ -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.

View file

@ -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 };
}

View file

@ -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) {