From 0249194e33f96efc0d0c703150634e92ac092e3b Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 13:26:36 -0500 Subject: [PATCH 1/2] fix: honor the macOS titlebar double-click preference --- .../main/__tests__/titlebar-action.test.ts | 40 +++++++++++++++++++ desktop/src/main/bridge.ts | 6 +-- desktop/src/main/index.ts | 24 +++++------ desktop/src/main/titlebar-action.ts | 33 +++++++++++++++ desktop/src/preload/index.ts | 12 +++--- .../src/shared/desktop-bridge-contracts.ts | 12 +++--- docs/DESKTOP-RELEASE.md | 5 +++ scripts/native-ui/menu-commands.mjs | 30 ++++++++++++++ scripts/native-ui/run.mjs | 7 +++- web/src/__tests__/KanbanBoard.test.tsx | 2 +- .../__tests__/layout-chrome-mantine.test.tsx | 22 ++++++++-- web/src/components/layout/Header.tsx | 4 +- 12 files changed, 163 insertions(+), 34 deletions(-) create mode 100644 desktop/src/main/__tests__/titlebar-action.test.ts create mode 100644 desktop/src/main/titlebar-action.ts diff --git a/desktop/src/main/__tests__/titlebar-action.test.ts b/desktop/src/main/__tests__/titlebar-action.test.ts new file mode 100644 index 00000000..96c38382 --- /dev/null +++ b/desktop/src/main/__tests__/titlebar-action.test.ts @@ -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(); + }); +}); diff --git a/desktop/src/main/bridge.ts b/desktop/src/main/bridge.ts index c259ba43..9d1260ce 100644 --- a/desktop/src/main/bridge.ts +++ b/desktop/src/main/bridge.ts @@ -26,7 +26,7 @@ import { type DesktopBridgeResponse, type DesktopConnectionConfigRequest, type DesktopConnectionValidationResult, - type DesktopWindowToggleMaximizeResult, + type DesktopWindowTitlebarActionResult, } from '../shared/desktop-bridge-contracts.js'; type MaybePromise = T | Promise; @@ -38,7 +38,7 @@ export type DesktopBridgeHandlerMap = { }; export interface DesktopWindowControls { - toggleMaximize(): DesktopWindowToggleMaximizeResult; + performTitlebarAction(): DesktopWindowTitlebarActionResult; } async function remoteConnectionDestinationError(serverUrl: string): Promise { @@ -289,7 +289,7 @@ export function createDesktopBridgeHandlers( await shell.openExternal(url); return undefined; }, - toggleWindowMaximize: () => windowControls?.toggleMaximize() ?? { maximized: false }, + performTitlebarAction: () => windowControls?.performTitlebarAction() ?? { maximized: false }, }; } diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 100429e4..1ec41a32 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -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 { 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(); diff --git a/desktop/src/main/titlebar-action.ts b/desktop/src/main/titlebar-action.ts new file mode 100644 index 00000000..a5bf8adb --- /dev/null +++ b/desktop/src/main/titlebar-action.ts @@ -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() }; +} diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts index 24598b81..005d54a9 100644 --- a/desktop/src/preload/index.ts +++ b/desktop/src/preload/index.ts @@ -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; openExternal(url: string): Promise; - toggleWindowMaximize(): Promise; + performTitlebarAction(): Promise; 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(DESKTOP_BRIDGE_METHODS.openExternal.channel, { url }), - toggleWindowMaximize: () => - invokeDesktop( - DESKTOP_BRIDGE_METHODS.toggleWindowMaximize.channel + performTitlebarAction: () => + invokeDesktop( + DESKTOP_BRIDGE_METHODS.performTitlebarAction.channel ), onSetupProgress: (listener) => onDesktopEvent('setupProgress', listener), onCommunicationCheck: (listener) => onDesktopEvent('communicationCheck', listener), diff --git a/desktop/src/shared/desktop-bridge-contracts.ts b/desktop/src/shared/desktop-bridge-contracts.ts index abf19577..dbfb02aa 100644 --- a/desktop/src/shared/desktop-bridge-contracts.ts +++ b/desktop/src/shared/desktop-bridge-contracts.ts @@ -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 { diff --git a/docs/DESKTOP-RELEASE.md b/docs/DESKTOP-RELEASE.md index f66ee1b2..906d7492 100644 --- a/docs/DESKTOP-RELEASE.md +++ b/docs/DESKTOP-RELEASE.md @@ -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. diff --git a/scripts/native-ui/menu-commands.mjs b/scripts/native-ui/menu-commands.mjs index 75886e77..59fd1059 100644 --- a/scripts/native-ui/menu-commands.mjs +++ b/scripts/native-ui/menu-commands.mjs @@ -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 }; +} diff --git a/scripts/native-ui/run.mjs b/scripts/native-ui/run.mjs index 4b7c48ce..57603fb0 100644 --- a/scripts/native-ui/run.mjs +++ b/scripts/native-ui/run.mjs @@ -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; diff --git a/web/src/__tests__/KanbanBoard.test.tsx b/web/src/__tests__/KanbanBoard.test.tsx index d118db93..6be76738 100644 --- a/web/src/__tests__/KanbanBoard.test.tsx +++ b/web/src/__tests__/KanbanBoard.test.tsx @@ -267,7 +267,7 @@ function renderDesktopBoard() { Object.defineProperty(window, 'veritasDesktop', { configurable: true, value: { - toggleWindowMaximize: vi.fn(), + performTitlebarAction: vi.fn(), }, }); window.localStorage.setItem('veritas.desktop.rightRailOpen', 'false'); diff --git a/web/src/__tests__/layout-chrome-mantine.test.tsx b/web/src/__tests__/layout-chrome-mantine.test.tsx index 0969f987..6ba61fcd 100644 --- a/web/src/__tests__/layout-chrome-mantine.test.tsx +++ b/web/src/__tests__/layout-chrome-mantine.test.tsx @@ -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 } } + ).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'); diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 2c34fb00..9f3dfc85 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -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] ); From d6cd74acf2b1669677b4c51f77335780502392aa Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 18:51:56 -0500 Subject: [PATCH 2/2] chore: run checks against the main branch target