mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-09 21:27:53 +00:00
Merge pull request #1554 from BradGroux/fix/macos-menus-1529
fix: restore standard macOS menus and window lifecycle
This commit is contained in:
commit
a29998b569
6 changed files with 193 additions and 25 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue