mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-09 05:07:53 +00:00
Merge pull request #1560 from BradGroux/fix/titlebar-preference-1531
fix: honor the macOS titlebar double-click preference
This commit is contained in:
commit
74bdd6798d
12 changed files with 163 additions and 34 deletions
40
desktop/src/main/__tests__/titlebar-action.test.ts
Normal file
40
desktop/src/main/__tests__/titlebar-action.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { applyTitlebarAction, resolveTitlebarAction } from '../titlebar-action.js';
|
||||
|
||||
describe('system titlebar action', () => {
|
||||
it.each([
|
||||
['Maximize', 'zoom'],
|
||||
['Fill', 'zoom'],
|
||||
['Minimize', 'minimize'],
|
||||
['None', 'none'],
|
||||
['', 'zoom'],
|
||||
['future-value', 'none'],
|
||||
] as const)('maps macOS preference %s to %s', (preference, action) => {
|
||||
expect(resolveTitlebarAction('darwin', preference)).toBe(action);
|
||||
});
|
||||
it.each(['linux', 'win32'] as const)('preserves maximize on %s', (platform) => {
|
||||
expect(resolveTitlebarAction(platform, 'Minimize')).toBe('zoom');
|
||||
});
|
||||
it('applies only the selected action and restores a zoomed window', () => {
|
||||
let maximized = false;
|
||||
const window = {
|
||||
isMaximized: () => maximized,
|
||||
maximize: vi.fn(() => {
|
||||
maximized = true;
|
||||
}),
|
||||
unmaximize: vi.fn(() => {
|
||||
maximized = false;
|
||||
}),
|
||||
minimize: vi.fn(),
|
||||
};
|
||||
applyTitlebarAction(window as never, 'none');
|
||||
expect(window.maximize).not.toHaveBeenCalled();
|
||||
expect(window.minimize).not.toHaveBeenCalled();
|
||||
expect(applyTitlebarAction(window as never, 'zoom')).toEqual({ maximized: true });
|
||||
expect(applyTitlebarAction(window as never, 'zoom')).toEqual({ maximized: false });
|
||||
applyTitlebarAction(window as never, 'minimize');
|
||||
expect(window.minimize).toHaveBeenCalledOnce();
|
||||
expect(window.maximize).toHaveBeenCalledOnce();
|
||||
expect(window.unmaximize).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -26,7 +26,7 @@ import {
|
|||
type DesktopBridgeResponse,
|
||||
type DesktopConnectionConfigRequest,
|
||||
type DesktopConnectionValidationResult,
|
||||
type DesktopWindowToggleMaximizeResult,
|
||||
type DesktopWindowTitlebarActionResult,
|
||||
} from '../shared/desktop-bridge-contracts.js';
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
|
@ -38,7 +38,7 @@ export type DesktopBridgeHandlerMap = {
|
|||
};
|
||||
|
||||
export interface DesktopWindowControls {
|
||||
toggleMaximize(): DesktopWindowToggleMaximizeResult;
|
||||
performTitlebarAction(): DesktopWindowTitlebarActionResult;
|
||||
}
|
||||
|
||||
async function remoteConnectionDestinationError(serverUrl: string): Promise<string | null> {
|
||||
|
|
@ -291,7 +291,7 @@ export function createDesktopBridgeHandlers(
|
|||
await shell.openExternal(url);
|
||||
return undefined;
|
||||
},
|
||||
toggleWindowMaximize: () => windowControls?.toggleMaximize() ?? { maximized: false },
|
||||
performTitlebarAction: () => windowControls?.performTitlebarAction() ?? { maximized: false },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
safeStorage,
|
||||
shell,
|
||||
screen,
|
||||
systemPreferences,
|
||||
} from 'electron';
|
||||
import path from 'node:path';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
|
@ -15,6 +16,7 @@ import { createRequire } from 'node:module';
|
|||
import { DESKTOP_APP_ID, DESKTOP_APP_NAME, DESKTOP_MIN_WINDOW } from './app-metadata.js';
|
||||
import { registerDesktopBridge } from './bridge.js';
|
||||
import { DesktopCommandDispatcher } from './commands.js';
|
||||
import { applyTitlebarAction, resolveTitlebarAction } from './titlebar-action.js';
|
||||
import { sendAcknowledgedRendererCommand } from './renderer-commands.js';
|
||||
import { extractDeepLinkFromArgv, parseDesktopDeepLink } from './deep-links.js';
|
||||
import { configureDesktopMenu, dispatchDesktopMenuCommand } from './menu.js';
|
||||
|
|
@ -347,18 +349,16 @@ async function boot(): Promise<void> {
|
|||
commandDispatcher,
|
||||
updateService,
|
||||
{
|
||||
toggleMaximize: () => {
|
||||
const window = activeMainWindow();
|
||||
if (!window) {
|
||||
return { maximized: false };
|
||||
}
|
||||
if (window.isMaximized()) {
|
||||
window.unmaximize();
|
||||
} else {
|
||||
window.maximize();
|
||||
}
|
||||
return { maximized: window.isMaximized() };
|
||||
},
|
||||
performTitlebarAction: () =>
|
||||
applyTitlebarAction(
|
||||
activeMainWindow(),
|
||||
resolveTitlebarAction(
|
||||
process.platform,
|
||||
process.platform === 'darwin'
|
||||
? systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string')
|
||||
: ''
|
||||
)
|
||||
),
|
||||
}
|
||||
);
|
||||
refreshDesktopMenu();
|
||||
|
|
|
|||
33
desktop/src/main/titlebar-action.ts
Normal file
33
desktop/src/main/titlebar-action.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
export type TitlebarAction = 'zoom' | 'minimize' | 'none';
|
||||
|
||||
export function resolveTitlebarAction(
|
||||
platform: NodeJS.Platform,
|
||||
preference: string
|
||||
): TitlebarAction {
|
||||
if (platform !== 'darwin') return 'zoom';
|
||||
switch (preference) {
|
||||
case '': // Unset macOS preference uses the standard zoom behavior.
|
||||
case 'Maximize':
|
||||
case 'Fill':
|
||||
return 'zoom';
|
||||
case 'Minimize':
|
||||
return 'minimize';
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTitlebarAction(
|
||||
window: BrowserWindow | null,
|
||||
action: TitlebarAction
|
||||
): { maximized: boolean } {
|
||||
if (!window) return { maximized: false };
|
||||
if (action === 'minimize') window.minimize();
|
||||
if (action === 'zoom') {
|
||||
if (window.isMaximized()) window.unmaximize();
|
||||
else window.maximize();
|
||||
}
|
||||
return { maximized: window.isMaximized() };
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ import type {
|
|||
DesktopSetupDiagnostics,
|
||||
DesktopSupportSnapshot,
|
||||
DesktopUpdateStatus,
|
||||
DesktopWindowToggleMaximizeResult,
|
||||
DesktopWindowTitlebarActionResult,
|
||||
DesktopWorkProductExportRequest,
|
||||
DesktopWorkProductExportResult,
|
||||
} from '../shared/desktop-bridge-contracts.js';
|
||||
|
|
@ -37,7 +37,7 @@ const DESKTOP_BRIDGE_METHODS = {
|
|||
performNotificationAction: { channel: 'desktop:perform-notification-action' },
|
||||
exportWorkProduct: { channel: 'desktop:export-work-product' },
|
||||
openExternal: { channel: 'desktop:open-external' },
|
||||
toggleWindowMaximize: { channel: 'desktop:toggle-window-maximize' },
|
||||
performTitlebarAction: { channel: 'desktop:perform-titlebar-action' },
|
||||
} as const;
|
||||
|
||||
const DESKTOP_BRIDGE_EVENTS = {
|
||||
|
|
@ -90,7 +90,7 @@ export interface VeritasDesktopApi {
|
|||
request: DesktopWorkProductExportRequest
|
||||
): Promise<DesktopWorkProductExportResult>;
|
||||
openExternal(url: string): Promise<void>;
|
||||
toggleWindowMaximize(): Promise<DesktopWindowToggleMaximizeResult>;
|
||||
performTitlebarAction(): Promise<DesktopWindowTitlebarActionResult>;
|
||||
onSetupProgress(listener: BridgeEventListener<'setupProgress'>): () => void;
|
||||
onCommunicationCheck(listener: BridgeEventListener<'communicationCheck'>): () => void;
|
||||
onServerStatus(listener: (status: DesktopStatusSnapshot) => void): () => void;
|
||||
|
|
@ -177,9 +177,9 @@ const api: VeritasDesktopApi = {
|
|||
),
|
||||
openExternal: (url: string) =>
|
||||
invokeDesktop<void>(DESKTOP_BRIDGE_METHODS.openExternal.channel, { url }),
|
||||
toggleWindowMaximize: () =>
|
||||
invokeDesktop<DesktopWindowToggleMaximizeResult>(
|
||||
DESKTOP_BRIDGE_METHODS.toggleWindowMaximize.channel
|
||||
performTitlebarAction: () =>
|
||||
invokeDesktop<DesktopWindowTitlebarActionResult>(
|
||||
DESKTOP_BRIDGE_METHODS.performTitlebarAction.channel
|
||||
),
|
||||
onSetupProgress: (listener) => onDesktopEvent('setupProgress', listener),
|
||||
onCommunicationCheck: (listener) => onDesktopEvent('communicationCheck', listener),
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ export interface DesktopWorkProductExportResult {
|
|||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface DesktopWindowToggleMaximizeResult {
|
||||
export interface DesktopWindowTitlebarActionResult {
|
||||
maximized: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -295,9 +295,9 @@ export const DESKTOP_BRIDGE_METHODS = {
|
|||
dangerous: true,
|
||||
validator: 'openExternal',
|
||||
},
|
||||
toggleWindowMaximize: {
|
||||
performTitlebarAction: {
|
||||
capability: 'shell',
|
||||
channel: 'desktop:toggle-window-maximize',
|
||||
channel: 'desktop:perform-titlebar-action',
|
||||
desktopOnly: true,
|
||||
dangerous: false,
|
||||
},
|
||||
|
|
@ -317,7 +317,7 @@ export const DESKTOP_BRIDGE_METHOD_NAMES = [
|
|||
'performNotificationAction',
|
||||
'exportWorkProduct',
|
||||
'openExternal',
|
||||
'toggleWindowMaximize',
|
||||
'performTitlebarAction',
|
||||
] as const;
|
||||
|
||||
export type DesktopBridgeMethod = (typeof DESKTOP_BRIDGE_METHOD_NAMES)[number];
|
||||
|
|
@ -425,7 +425,7 @@ export interface DesktopBridgeRequestMap {
|
|||
performNotificationAction: DesktopNotificationActionRequest;
|
||||
exportWorkProduct: DesktopWorkProductExportRequest;
|
||||
openExternal: OpenExternalRequest;
|
||||
toggleWindowMaximize: undefined;
|
||||
performTitlebarAction: undefined;
|
||||
}
|
||||
|
||||
export interface DesktopBridgeResponseMap {
|
||||
|
|
@ -442,7 +442,7 @@ export interface DesktopBridgeResponseMap {
|
|||
performNotificationAction: DesktopNotificationActionResult;
|
||||
exportWorkProduct: DesktopWorkProductExportResult;
|
||||
openExternal: undefined;
|
||||
toggleWindowMaximize: DesktopWindowToggleMaximizeResult;
|
||||
performTitlebarAction: DesktopWindowTitlebarActionResult;
|
||||
}
|
||||
|
||||
export interface DesktopBridgeEventPayloadMap {
|
||||
|
|
|
|||
|
|
@ -326,3 +326,8 @@ application menu; View provides text zoom and full screen.
|
|||
Saved window bounds are fitted to the current display work areas at launch or
|
||||
reopen. If a monitor was disconnected, the window returns to the primary display.
|
||||
Maximized windows retain their previous normal bounds for unmaximizing.
|
||||
|
||||
The custom header follows the macOS title-bar double-click preference (zoom/fill,
|
||||
minimize, or no action). Configure it in [Desktop & Dock settings](https://support.apple.com/guide/mac-help/change-desktop-dock-settings-mchlp1119/mac).
|
||||
The native gate records the current preference and verifies its action without
|
||||
changing the operator's system preferences.
|
||||
|
|
|
|||
|
|
@ -159,3 +159,33 @@ export async function verifyNativeWindowMenu(app, page) {
|
|||
.toEqual(normalBounds);
|
||||
return { page: reopened, roles: items, normalBounds };
|
||||
}
|
||||
|
||||
export async function verifyConfiguredTitlebarAction(app, page) {
|
||||
const preference = await app.evaluate(({ systemPreferences }) =>
|
||||
systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string')
|
||||
);
|
||||
await app.evaluate(({ BrowserWindow }) => {
|
||||
const window = BrowserWindow.getAllWindows()[0];
|
||||
window.restore();
|
||||
window.unmaximize();
|
||||
});
|
||||
const state = () =>
|
||||
app.evaluate(({ BrowserWindow }) => ({
|
||||
maximized: BrowserWindow.getAllWindows()[0].isMaximized(),
|
||||
minimized: BrowserWindow.getAllWindows()[0].isMinimized(),
|
||||
}));
|
||||
await expect.poll(state).toEqual({ maximized: false, minimized: false });
|
||||
await page.getByRole('navigation', { name: 'Main navigation' }).dispatchEvent('dblclick');
|
||||
const expected = {
|
||||
maximized: ['', 'Maximize', 'Fill'].includes(preference),
|
||||
minimized: preference === 'Minimize',
|
||||
};
|
||||
await expect.poll(state).toEqual(expected);
|
||||
await app.evaluate(({ BrowserWindow }) => {
|
||||
const window = BrowserWindow.getAllWindows()[0];
|
||||
window.restore();
|
||||
window.unmaximize();
|
||||
window.focus();
|
||||
});
|
||||
return { preference: preference || 'system default', expected };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ 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, verifyNativeWindowMenu } from './menu-commands.mjs';
|
||||
import {
|
||||
verifyNativeMenuCommands,
|
||||
verifyNativeWindowMenu,
|
||||
verifyConfiguredTitlebarAction,
|
||||
} from './menu-commands.mjs';
|
||||
import {
|
||||
fileDigest,
|
||||
evidenceFailures,
|
||||
|
|
@ -653,6 +657,7 @@ async function checkSeededRendererFailures() {
|
|||
}
|
||||
try {
|
||||
await launch();
|
||||
report.titlebarAction = await verifyConfiguredTitlebarAction(app, page);
|
||||
report.menuCommands = await verifyNativeMenuCommands(app, page);
|
||||
const windowMenu = await verifyNativeWindowMenu(app, page);
|
||||
page = windowMenu.page;
|
||||
|
|
|
|||
|
|
@ -266,7 +266,7 @@ function renderDesktopBoard() {
|
|||
Object.defineProperty(window, 'veritasDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
toggleWindowMaximize: vi.fn(),
|
||||
performTitlebarAction: vi.fn(),
|
||||
},
|
||||
});
|
||||
window.localStorage.setItem('veritas.desktop.rightRailOpen', 'false');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, screen, waitFor, within } from '@testing-library/react';
|
||||
import { cleanup, screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { ViewProvider } from '@/contexts/ViewContext';
|
||||
|
|
@ -143,7 +143,7 @@ function renderDesktopHeaderChrome(options: { withBottomPanel?: boolean } = {})
|
|||
Object.defineProperty(window, 'veritasDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
toggleWindowMaximize: vi.fn(),
|
||||
performTitlebarAction: vi.fn(),
|
||||
},
|
||||
});
|
||||
document.documentElement.dataset.client = 'desktop';
|
||||
|
|
@ -381,10 +381,26 @@ describe('layout chrome Mantine migration', () => {
|
|||
expect(container.querySelector('.lucide-panel-right-close')).toBeNull();
|
||||
});
|
||||
|
||||
it('sends titlebar double clicks only from the header background', () => {
|
||||
renderDesktopHeaderChrome();
|
||||
const action = (
|
||||
window as unknown as { veritasDesktop: { performTitlebarAction: ReturnType<typeof vi.fn> } }
|
||||
).veritasDesktop.performTitlebarAction;
|
||||
fireEvent.doubleClick(screen.getByRole('button', { name: 'New Task' }));
|
||||
fireEvent.doubleClick(screen.getByRole('button', { name: 'Settings' }));
|
||||
const input = document.createElement('input');
|
||||
screen.getByRole('navigation', { name: 'Main navigation' }).append(input);
|
||||
fireEvent.doubleClick(input);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
input.remove();
|
||||
fireEvent.doubleClick(screen.getByRole('navigation', { name: 'Main navigation' }));
|
||||
expect(action).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('uses the filled brand treatment with white text for the active desktop navigation item', () => {
|
||||
Object.defineProperty(window, 'veritasDesktop', {
|
||||
configurable: true,
|
||||
value: { toggleWindowMaximize: vi.fn() },
|
||||
value: { performTitlebarAction: vi.fn() },
|
||||
});
|
||||
document.documentElement.dataset.client = 'desktop';
|
||||
window.history.replaceState({}, '', '/drift');
|
||||
|
|
|
|||
|
|
@ -423,9 +423,9 @@ export function Header({
|
|||
}
|
||||
void (
|
||||
window as Window & {
|
||||
veritasDesktop?: { toggleWindowMaximize?: () => Promise<{ maximized: boolean }> };
|
||||
veritasDesktop?: { performTitlebarAction?: () => Promise<{ maximized: boolean }> };
|
||||
}
|
||||
).veritasDesktop?.toggleWindowMaximize?.();
|
||||
).veritasDesktop?.performTitlebarAction?.();
|
||||
},
|
||||
[isDesktopClient]
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue