From 4231390280d2d98128a5253fcade51d95acd72cb Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 13:44:41 -0500 Subject: [PATCH 1/3] test: exercise native menu accelerators in packaged application --- scripts/native-ui/contrast.mjs | 3 +- scripts/native-ui/menu-commands.mjs | 71 ++++++++++++++++++++--------- scripts/native-ui/run.mjs | 2 +- 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/scripts/native-ui/contrast.mjs b/scripts/native-ui/contrast.mjs index 22d2089a..75268ccc 100644 --- a/scripts/native-ui/contrast.mjs +++ b/scripts/native-ui/contrast.mjs @@ -51,12 +51,13 @@ export async function verifyRouteContrast(page, route) { targets.push(['selected-filter', page.getByRole('button', { name: 'all', exact: true })]); if (route === 'operations') { const code = page.locator('main code').first(); - await expect(code).toBeVisible(); // Requires the real seeded blocked task, never an empty-state pass. + await expect(code).toBeVisible(); // Requires the real seeded completed task, never an empty-state pass. targets.push(['task-id', code]); } const results = []; for (const [label, target] of targets) { await expect(target).toBeVisible(); + await target.scrollIntoViewIfNeeded(); for (const state of label === 'task-id' ? ['normal'] : ['normal', 'hover', 'focus']) { if (state === 'hover') await target.hover(); if (state === 'focus') { diff --git a/scripts/native-ui/menu-commands.mjs b/scripts/native-ui/menu-commands.mjs index 59fd1059..923ae1aa 100644 --- a/scripts/native-ui/menu-commands.mjs +++ b/scripts/native-ui/menu-commands.mjs @@ -1,5 +1,7 @@ -/* global window */ +/* global window, setTimeout, clearTimeout */ import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; import { expect } from '@playwright/test'; /** Uses the installed native menu, preload, and real mounted application. */ @@ -85,23 +87,39 @@ export async function verifyNativeWindowMenu(app, page) { 'togglefullscreen', ]) { assert( - items.some((item) => item.role === role), + items.some((item) => item.role?.toLowerCase() === role.toLowerCase()), `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 clickRole = async (role) => { + const shortcuts = { + zoomIn: '+', + resetZoom: '0', + zoomOut: '-', + togglefullscreen: 'f', + close: 'w', + }; + assert(shortcuts[role], `No native shortcut defined for ${role}`); + const modifiers = + role === 'togglefullscreen' ? '{control down, command down}' : '{command down}'; + // Target the disposable packaged process by PID, never the installed app name. + await promisify(execFile)( + 'osascript', + [ + '-e', + `on run argv + tell application "System Events" + set targetProcess to first application process whose unix id is (item 1 of argv as integer) + set frontmost of targetProcess to true + tell targetProcess to keystroke (item 2 of argv) using ${modifiers} + end tell + end run`, + String(app.process().pid), + shortcuts[role], + ], + { timeout: 10000 } + ); + }; const zoom = () => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.getZoomFactor() @@ -114,13 +132,22 @@ export async function verifyNativeWindowMenu(app, page) { 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'); + for (const event of ['enter-full-screen', 'leave-full-screen']) { + const transition = app.evaluate( + ({ BrowserWindow }, event) => + new Promise((resolve, reject) => { + const window = BrowserWindow.getAllWindows()[0]; + const timeout = setTimeout(() => reject(new Error(`No ${event} event`)), 10000); + window.once(event, () => { + clearTimeout(timeout); + resolve(true); + }); + }), + event + ); + await clickRole('togglefullscreen'); + await transition; + } await expect .poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFullScreen()) diff --git a/scripts/native-ui/run.mjs b/scripts/native-ui/run.mjs index 56de0394..2743fa41 100644 --- a/scripts/native-ui/run.mjs +++ b/scripts/native-ui/run.mjs @@ -671,7 +671,7 @@ try { const response = await fetch(`/api/tasks/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: 'blocked' }), + body: JSON.stringify({ status: 'done' }), }); if (!response.ok) throw new Error(`Fixture status failed: ${response.status}`); }, fixtureTask.id); From 6844f23833ee7ef121bbee085dc01f92f2038909 Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 15:00:48 -0500 Subject: [PATCH 2/3] feat: use a reusable native Settings window on macOS --- .../main/__tests__/bridge-contracts.test.ts | 26 +++ desktop/src/main/__tests__/navigation.test.ts | 32 +++- .../main/__tests__/settings-window.test.ts | 125 +++++++++++++++ desktop/src/main/bridge.ts | 9 +- desktop/src/main/index.ts | 105 ++++++++++-- desktop/src/main/navigation.ts | 18 ++- desktop/src/main/settings-window.ts | 149 ++++++++++++++++++ desktop/src/preload/index.ts | 14 +- docs/DESKTOP-RELEASE.md | 10 ++ scripts/docs-media/run.mjs | 24 ++- scripts/native-ui/menu-commands.mjs | 116 +++++++++++--- scripts/native-ui/run.mjs | 76 +++++---- scripts/native-ui/settings-window.mjs | 114 ++++++++++++++ web/src/App.tsx | 28 +++- .../feature-settings-writes.test.tsx | 36 +++++ .../settings-dialog-mantine.test.tsx | 7 + .../__tests__/settings-window-sync.test.tsx | 63 ++++++++ web/src/components/layout/Header.tsx | 68 ++++++-- .../settings/NativeSettingsWindow.tsx | 77 +++++++++ .../components/settings/SettingsDialog.tsx | 83 ++++++---- web/src/hooks/useAuth.tsx | 5 + web/src/hooks/useIdentity.tsx | 12 ++ web/src/hooks/useSettingsWindowSync.ts | 49 ++++++ web/src/lib/desktop-settings.ts | 31 ++++ web/src/lib/feature-settings-writes.ts | 20 ++- 25 files changed, 1162 insertions(+), 135 deletions(-) create mode 100644 desktop/src/main/__tests__/settings-window.test.ts create mode 100644 desktop/src/main/settings-window.ts create mode 100644 scripts/native-ui/settings-window.mjs create mode 100644 web/src/__tests__/settings-window-sync.test.tsx create mode 100644 web/src/components/settings/NativeSettingsWindow.tsx create mode 100644 web/src/hooks/useSettingsWindowSync.ts create mode 100644 web/src/lib/desktop-settings.ts diff --git a/desktop/src/main/__tests__/bridge-contracts.test.ts b/desktop/src/main/__tests__/bridge-contracts.test.ts index a55a61fc..cf0a5594 100644 --- a/desktop/src/main/__tests__/bridge-contracts.test.ts +++ b/desktop/src/main/__tests__/bridge-contracts.test.ts @@ -132,6 +132,32 @@ describe('desktop bridge contracts', () => { expect(registered.size).toBe(DESKTOP_BRIDGE_METHOD_NAMES.length); }); + it('enforces the native sender check before invoking bridge operations', async () => { + const registered = new Map Promise>(); + const ipc = { + handle: (channel: string, handler: (event: unknown, request: unknown) => Promise) => + registered.set(channel, handler), + } as unknown as IpcMain; + const nativeRuntime = runtime(); + const allowed = {}; + registerDesktopBridge( + ipc, + nativeRuntime, + shell(), + false, + '6.1.7', + undefined, + undefined, + undefined, + (event) => event === allowed + ); + const readStatus = registered.get(DESKTOP_BRIDGE_METHODS.getConnectionStatus.channel); + if (!readStatus) throw new Error('Missing connection-status handler'); + await expect(readStatus({}, undefined)).rejects.toThrow('cannot use the desktop bridge'); + expect(nativeRuntime.snapshot).not.toHaveBeenCalled(); + await expect(readStatus(allowed, undefined)).resolves.toMatchObject({ profile: 'fresh' }); + }); + it('reports the Electron application version through the desktop bridge', () => { const bridgeHandlers = createDesktopBridgeHandlers(runtime(), shell(), true, '6.0.1'); diff --git a/desktop/src/main/__tests__/navigation.test.ts b/desktop/src/main/__tests__/navigation.test.ts index e5cb5a25..f0bda571 100644 --- a/desktop/src/main/__tests__/navigation.test.ts +++ b/desktop/src/main/__tests__/navigation.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import type { Shell } from 'electron'; +import type { IpcMainInvokeEvent, Shell, WebContents } from 'electron'; -import { hasSameOriginNavigation, openValidatedExternalUrl } from '../navigation.js'; +import { + hasSameOriginNavigation, + isOwnedDesktopSender, + openValidatedExternalUrl, +} from '../navigation.js'; function shell(): Shell { return { @@ -23,6 +27,30 @@ describe('desktop navigation guards', () => { expect(hasSameOriginNavigation('not a url', 'http://127.0.0.1:3000')).toBe(false); }); + it('accepts only an owned main frame at the app origin, with a separate generated-status-page exception', () => { + const owner = { + mainFrame: { url: 'http://127.0.0.1:3000/?desktop-settings=1' }, + isDestroyed: () => false, + getURL: () => 'data:text/html,status', + } as unknown as WebContents; + const event = { sender: owner, senderFrame: owner.mainFrame } as IpcMainInvokeEvent; + expect(isOwnedDesktopSender(event, [owner], 'http://127.0.0.1:3000')).toBe(true); + expect( + isOwnedDesktopSender( + { ...event, senderFrame: { url: owner.mainFrame.url } } as IpcMainInvokeEvent, + [owner], + 'http://127.0.0.1:3000' + ) + ).toBe(false); + expect(isOwnedDesktopSender(event, [], 'http://127.0.0.1:3000')).toBe(false); + expect(isOwnedDesktopSender(event, [owner], 'http://127.0.0.1:4000')).toBe(false); + Object.assign(owner.mainFrame, { url: 'data:text/html,status' }); + expect(isOwnedDesktopSender(event, [owner], 'http://127.0.0.1:3000')).toBe(false); + expect(isOwnedDesktopSender(event, [owner], 'http://127.0.0.1:3000', owner)).toBe(true); + Object.assign(owner.mainFrame, { url: 'data:text/html,unrecognized' }); + expect(isOwnedDesktopSender(event, [owner], 'http://127.0.0.1:3000', owner)).toBe(false); + }); + it('reuses the safe external URL validator before opening OS handlers', async () => { const fakeShell = shell(); diff --git a/desktop/src/main/__tests__/settings-window.test.ts b/desktop/src/main/__tests__/settings-window.test.ts new file mode 100644 index 00000000..d65befec --- /dev/null +++ b/desktop/src/main/__tests__/settings-window.test.ts @@ -0,0 +1,125 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { BrowserWindow, IpcMain } from 'electron'; +import { SettingsWindowController } from '../settings-window.js'; + +function harness() { + const ipc = new EventEmitter(); + let destroyed = false; + let quitting = false; + const contents = Object.assign(new EventEmitter(), { + mainFrame: {}, + isDestroyed: () => destroyed, + send: vi.fn(), + }); + const window = Object.assign(new EventEmitter(), { + webContents: contents, + isDestroyed: () => destroyed, + loadURL: vi.fn(async () => {}), + hide: vi.fn(), + show: vi.fn(), + focus: vi.fn(), + restore: vi.fn(), + isMinimized: () => true, + destroy: vi.fn(() => { + destroyed = true; + contents.emit('destroyed'); + window.emit('closed'); + }), + }); + const createWindow = vi.fn(() => window as unknown as BrowserWindow); + const returnFocus = vi.fn(); + const controller = new SettingsWindowController({ + ipc: ipc as IpcMain, + createWindow, + origin: () => 'http://127.0.0.1:43210/', + quitting: () => quitting, + returnFocus, + readyTimeoutMs: 100, + }); + const event = { sender: contents, senderFrame: contents.mainFrame }; + const ready = () => ipc.emit('desktop:menu-command-ready', event); + const acknowledge = (accepted = true) => { + for (const [, request] of contents.send.mock.calls) + ipc.emit('desktop:menu-command-result', event, { requestId: request.requestId, accepted }); + }; + return { + ipc, + window, + contents, + controller, + ready, + acknowledge, + createWindow, + returnFocus, + event, + quit: () => { + quitting = true; + }, + }; +} +afterEach(() => vi.useRealTimers()); +describe('native Settings ownership', () => { + it('reuses one window for concurrent requests, waits for its renderer, and hides on close', async () => { + const h = harness(); + const first = h.controller.open({ command: 'open-settings' }); + const second = h.controller.open({ command: 'open-settings' }); + expect(h.createWindow).toHaveBeenCalledOnce(); + expect(h.window.loadURL).toHaveBeenCalledWith('http://127.0.0.1:43210/?desktop-settings=1'); + h.ipc.emit('desktop:menu-command-ready', { ...h.event, senderFrame: {} }); + await Promise.resolve(); + expect(h.contents.send).not.toHaveBeenCalled(); + h.ready(); + await Promise.resolve(); + expect(h.window.show).not.toHaveBeenCalled(); + h.acknowledge(); + expect((await first).accepted).toBe(true); + expect((await second).accepted).toBe(true); + const event = { preventDefault: vi.fn() }; + h.window.emit('close', event); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(h.window.hide).toHaveBeenCalledOnce(); + expect(h.returnFocus).toHaveBeenCalledOnce(); + const third = h.controller.open({ command: 'open-settings' }); + await Promise.resolve(); + h.acknowledge(); + await third; + expect(h.createWindow).toHaveBeenCalledOnce(); + h.quit(); + const quitEvent = { preventDefault: vi.fn() }; + h.window.emit('close', quitEvent); + expect(quitEvent.preventDefault).not.toHaveBeenCalled(); + h.window.destroy(); + expect(h.ipc.listenerCount('desktop:menu-command-ready')).toBe(0); + }); + it('waits for pending-save acknowledgement before quit and retains a rejected editing session', async () => { + const h = harness(); + const opening = h.controller.open({ command: 'open-settings' }); + h.ready(); + await Promise.resolve(); + h.acknowledge(); + await opening; + const quitting = h.controller.prepareToQuit(); + await Promise.resolve(); + expect(h.contents.send.mock.lastCall?.[1].payload).toEqual({ flushPending: true }); + h.acknowledge(false); + expect(await quitting).toBe(false); + expect(h.window.destroy).not.toHaveBeenCalled(); + h.ipc.emit('desktop:menu-command-not-ready', h.event); + expect(await h.controller.prepareToQuit()).toBe(true); + h.window.destroy(); + }); + it('fails closed and discards only a never-ready renderer when authentication/setup is unavailable', async () => { + vi.useFakeTimers(); + const h = harness(); + const opening = h.controller.open({ command: 'open-settings' }); + await vi.advanceTimersByTimeAsync(100); + expect(await opening).toMatchObject({ + accepted: false, + message: expect.stringContaining('unlock'), + }); + expect(h.window.destroy).toHaveBeenCalledOnce(); + expect(h.controller.getWindow()).toBeNull(); + expect(h.ipc.listenerCount('desktop:menu-command-ready')).toBe(0); + }); +}); diff --git a/desktop/src/main/bridge.ts b/desktop/src/main/bridge.ts index d99f726c..3962dce4 100644 --- a/desktop/src/main/bridge.ts +++ b/desktop/src/main/bridge.ts @@ -1,4 +1,4 @@ -import type { IpcMain, Shell } from 'electron'; +import type { IpcMain, IpcMainInvokeEvent, Shell } from 'electron'; import { lookup } from 'node:dns/promises'; import { blockedRemoteConnectionDestinationReason } from '@veritas-kanban/shared'; @@ -303,7 +303,8 @@ export function registerDesktopBridge( appVersion: string, commandDispatcher?: DesktopCommandDispatcher, updateService?: DesktopUpdateService, - windowControls?: DesktopWindowControls + windowControls?: DesktopWindowControls, + authorizeSender?: (event: IpcMainInvokeEvent) => boolean ): void { const handlers = createDesktopBridgeHandlers( runtime, @@ -321,8 +322,10 @@ export function registerDesktopBridge( const validator = DESKTOP_BRIDGE_METHOD_VALIDATORS[method] as ((payload: unknown) => unknown) | undefined; - ipcMain.handle(definition.channel, async (_event, request: unknown) => { + ipcMain.handle(definition.channel, async (event, request: unknown) => { try { + if (authorizeSender && !authorizeSender(event)) + throw new Error('This window cannot use the desktop bridge.'); return await handler(validator ? validator(request) : request); } catch (error) { // Do not attach the original cause to errors crossing the desktop bridge. diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 1ec41a32..11fb191e 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, clipboard, + dialog, ipcMain, Notification, safeStorage, @@ -17,10 +18,15 @@ import { DESKTOP_APP_ID, DESKTOP_APP_NAME, DESKTOP_MIN_WINDOW } from './app-meta import { registerDesktopBridge } from './bridge.js'; import { DesktopCommandDispatcher } from './commands.js'; import { applyTitlebarAction, resolveTitlebarAction } from './titlebar-action.js'; +import { SettingsWindowController, SETTINGS_WINDOW_COMMANDS } from './settings-window.js'; import { sendAcknowledgedRendererCommand } from './renderer-commands.js'; import { extractDeepLinkFromArgv, parseDesktopDeepLink } from './deep-links.js'; import { configureDesktopMenu, dispatchDesktopMenuCommand } from './menu.js'; -import { hasSameOriginNavigation, openValidatedExternalUrl } from './navigation.js'; +import { + hasSameOriginNavigation, + isOwnedDesktopSender, + openValidatedExternalUrl, +} from './navigation.js'; import { DesktopNotificationCenter, ElectronNotificationAdapter } from './notifications.js'; import { createDesktopPaths, resolveRepoRoot } from './paths.js'; import { findAvailablePort } from './ports.js'; @@ -57,6 +63,7 @@ const DESKTOP_HELP_URL = let mainWindow: BrowserWindow | null = null; let runtime: DesktopRuntime | null = null; +let settingsWindow: SettingsWindowController | null = null; let commandDispatcher: DesktopCommandDispatcher | null = null; let updateService: DesktopUpdateService | null = null; let windowStatePaths: ReturnType | null = null; @@ -309,17 +316,63 @@ async function boot(): Promise { ), forceDevUpdateConfig: process.env.VERITAS_DESKTOP_UPDATER_FORCE_DEV === 'true', emitStatus: (status) => { - activeMainWindow()?.webContents.send(DESKTOP_BRIDGE_EVENTS.updateStatus.channel, status); + for (const window of [activeMainWindow(), settingsWindow?.getWindow()]) + window?.webContents.send(DESKTOP_BRIDGE_EVENTS.updateStatus.channel, status); refreshDesktopMenu(); }, }); + const activeRuntime = runtime; + const nativeSettings = new SettingsWindowController({ + ipc: ipcMain, + origin: () => activeRuntime.getRendererOrigin(), + quitting: () => quitting, + returnFocus: () => activeMainWindow()?.focus(), + createWindow: () => { + const area = screen.getPrimaryDisplay().workArea; + const window = new BrowserWindow({ + title: 'Settings — Veritas Kanban', + width: Math.min(1040, area.width), + height: Math.min(800, area.height), + minWidth: Math.min(640, area.width), + minHeight: Math.min(480, area.height), + show: false, + backgroundColor: '#111318', + webPreferences: { + preload: path.join(__dirname, '../preload/index.cjs'), + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + }); + window.webContents.setWindowOpenHandler(({ url }) => { + void openValidatedExternalUrl(shell, url); + return { action: 'deny' }; + }); + window.webContents.on('will-navigate', (event, url) => { + const parsed = new URL(url); + if ( + !hasSameOriginNavigation(url, activeRuntime.getRendererOrigin()) || + parsed.searchParams.get('desktop-settings') !== '1' + ) { + event.preventDefault(); + void openValidatedExternalUrl(shell, url); + } + }); + return window; + }, + }); + + settingsWindow = nativeSettings; + commandDispatcher = new DesktopCommandDispatcher({ runtime, shell, quit: () => app.quit(), sendRendererCommand: (command) => - sendAcknowledgedRendererCommand(ipcMain, activeMainWindow()?.webContents, command), + process.platform === 'darwin' && SETTINGS_WINDOW_COMMANDS.has(command.command) + ? nativeSettings.open(command) + : sendAcknowledgedRendererCommand(ipcMain, activeMainWindow()?.webContents, command), checkForUpdates: () => updateService?.checkForUpdates() ?? Promise.resolve(updateServiceFallback(packaged)), downloadUpdate: () => @@ -359,11 +412,19 @@ async function boot(): Promise { : '' ) ), - } + }, + (event) => + isOwnedDesktopSender( + event, + [activeMainWindow()?.webContents, settingsWindow?.getWindow()?.webContents], + activeRuntime.getRendererOrigin(), + activeMainWindow()?.webContents + ) ); refreshDesktopMenu(); runtime.on('status', (status) => { - activeMainWindow()?.webContents.send(DESKTOP_BRIDGE_EVENTS.serverStatus.channel, status); + for (const window of [activeMainWindow(), settingsWindow?.getWindow()]) + window?.webContents.send(DESKTOP_BRIDGE_EVENTS.serverStatus.channel, status); refreshDesktopMenu(); }); @@ -406,13 +467,37 @@ app.on('second-instance', (_event, argv) => { } }); +let preparingQuit = false; app.on('before-quit', (event) => { - quitting = true; - if (runtime && !shutdownStarted) { - event.preventDefault(); - shutdownStarted = true; - void runtime.stop().finally(() => app.quit()); + if (shutdownStarted) { + quitting = true; + return; } + event.preventDefault(); + if (preparingQuit) return; + preparingQuit = true; + void (async () => { + try { + if (settingsWindow && !(await settingsWindow.prepareToQuit())) { + await dialog.showMessageBox({ + type: 'warning', + title: 'Settings are not saved', + message: 'Wait for Settings to finish saving, or retry the failed save, then quit again.', + buttons: ['Return to Settings'], + }); + return; + } + quitting = true; + shutdownStarted = true; + try { + await runtime?.stop(); + } finally { + app.quit(); + } + } finally { + preparingQuit = false; + } + })(); }); app.on('window-all-closed', () => { diff --git a/desktop/src/main/navigation.ts b/desktop/src/main/navigation.ts index 6aa2348a..3eda9cf6 100644 --- a/desktop/src/main/navigation.ts +++ b/desktop/src/main/navigation.ts @@ -1,4 +1,4 @@ -import type { Shell } from 'electron'; +import type { IpcMainInvokeEvent, Shell, WebContents } from 'electron'; import { redactDesktopBridgeError, @@ -23,3 +23,19 @@ export async function openValidatedExternalUrl(shell: Shell, rawUrl: string): Pr return false; } } + +/** Native IPC belongs only to a known top-level app renderer at the current origin. */ +export function isOwnedDesktopSender( + event: IpcMainInvokeEvent, + owners: Array, + origin: string, + statusOwner?: WebContents +): boolean { + const owner = owners.find((candidate) => candidate === event.sender); + if (!owner || owner.isDestroyed() || event.senderFrame !== owner.mainFrame) return false; + const url = event.senderFrame.url; + return ( + hasSameOriginNavigation(url, origin) || + (owner === statusOwner && url === owner.getURL() && url.startsWith('data:text/html')) + ); +} diff --git a/desktop/src/main/settings-window.ts b/desktop/src/main/settings-window.ts new file mode 100644 index 00000000..b79d13d6 --- /dev/null +++ b/desktop/src/main/settings-window.ts @@ -0,0 +1,149 @@ +import type { BrowserWindow, IpcMain, IpcMainEvent } from 'electron'; +import type { + DesktopCommandDispatchRequest, + DesktopCommandDispatchResult, +} from '../shared/desktop-bridge-contracts.js'; +import { sendAcknowledgedRendererCommand } from './renderer-commands.js'; + +export const SETTINGS_WINDOW_COMMANDS = new Set([ + 'open-settings', + 'import-data', + 'export-data', + 'create-backup', + 'create-debug-bundle', + 'test-squad-webhook', +]); + +/** One modeless window. Closing hides its renderer so queued edits and retries survive. */ +export class SettingsWindowController { + private window: BrowserWindow | null = null; + private loading: Promise | null = null; + private rendererReady = false; + + constructor( + private readonly options: { + ipc: IpcMain; + createWindow(): BrowserWindow; + origin(): string; + quitting(): boolean; + returnFocus(): void; + readyTimeoutMs?: number; + } + ) {} + + getWindow() { + return this.window && !this.window.isDestroyed() ? this.window : null; + } + + async prepareToQuit(): Promise { + const window = this.getWindow(); + if (!window) return true; + try { + await this.loading; + } catch { + return true; + } + if (!this.rendererReady) return true; // AuthGuard has unmounted the editing session. + const result = await sendAcknowledgedRendererCommand( + this.options.ipc, + window.webContents, + { command: 'open-settings', source: 'menu', payload: { flushPending: true } }, + 15000 + ); + if (!result.accepted && !window.isDestroyed()) { + if (window.isMinimized()) window.restore(); + window.show(); + window.focus(); + } + return result.accepted; + } + + async open(request: DesktopCommandDispatchRequest): Promise { + let window = this.getWindow(); + if (!window) { + window = this.options.createWindow(); + this.window = window; + const createdWindow = window; + const target = window.webContents; + this.rendererReady = false; + const fromTarget = (event: IpcMainEvent) => + event.sender === target && event.senderFrame === target.mainFrame; + const markReady = (event: IpcMainEvent) => { + if (fromTarget(event)) this.rendererReady = true; + }; + const markNotReady = (event: IpcMainEvent) => { + if (fromTarget(event)) this.rendererReady = false; + }; + this.options.ipc.on('desktop:menu-command-ready', markReady); + this.options.ipc.on('desktop:menu-command-not-ready', markNotReady); + target.once('destroyed', () => { + this.options.ipc.off('desktop:menu-command-ready', markReady); + this.options.ipc.off('desktop:menu-command-not-ready', markNotReady); + }); + window.on('close', (event) => { + if (this.options.quitting()) return; + event.preventDefault(); + createdWindow.hide(); + this.options.returnFocus(); + }); + window.once('closed', () => { + if (this.window === window) { + this.window = null; + this.loading = null; + } + }); + const url = new URL(this.options.origin()); + url.searchParams.set('desktop-settings', '1'); + // Register before loading: the authenticated renderer announces its command listener. + this.loading = new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + this.options.ipc.off('desktop:menu-command-ready', ready); + target.off('destroyed', destroyed); + }; + const fail = (message: string) => { + cleanup(); + reject(new Error(message)); + }; + const ready = (event: IpcMainEvent) => { + if (event.sender !== target || event.senderFrame !== target.mainFrame) return; + cleanup(); + resolve(); + }; + const destroyed = () => fail('Settings closed before it was ready.'); + const timer = setTimeout( + () => fail('Finish setup or unlock the workspace before opening Settings.'), + this.options.readyTimeoutMs ?? 8000 + ); + this.options.ipc.on('desktop:menu-command-ready', ready); + target.once('destroyed', destroyed); + void createdWindow + .loadURL(url.href) + .catch(() => fail('Settings could not load. Try opening it again.')); + }); + } + try { + await this.loading; + const result = await sendAcknowledgedRendererCommand( + this.options.ipc, + window.webContents, + request + ); + if (result.accepted && !window.isDestroyed()) { + if (window.isMinimized()) window.restore(); + window.show(); + window.focus(); + } + return result; + } catch (error) { + // A never-ready window owns no acknowledged editing session and can be recreated. + if (!window.isDestroyed()) window.destroy(); + return { + command: request.command, + accepted: false, + handledBy: 'unsupported', + message: error instanceof Error ? error.message : 'Settings is unavailable.', + }; + } + } +} diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts index 005d54a9..b3444421 100644 --- a/desktop/src/preload/index.ts +++ b/desktop/src/preload/index.ts @@ -98,7 +98,9 @@ export interface VeritasDesktopApi { onUpdateStatus(listener: BridgeEventListener<'updateStatus'>): () => void; onNotificationAction(listener: BridgeEventListener<'notificationAction'>): () => void; onMenuCommand( - listener: (request: DesktopCommandDispatchRequest) => { accepted: boolean; message?: string } + listener: ( + request: DesktopCommandDispatchRequest + ) => { accepted: boolean; message?: string } | Promise<{ accepted: boolean; message?: string }> ): () => void; onUploadProgress(listener: BridgeEventListener<'uploadProgress'>): () => void; onWorkProductExportProgress( @@ -188,13 +190,13 @@ const api: VeritasDesktopApi = { onUpdateStatus: (listener) => onDesktopEvent('updateStatus', listener), onNotificationAction: (listener) => onDesktopEvent('notificationAction', listener), onMenuCommand: (listener) => { - const handler = ( + const handler = async ( _event: Electron.IpcRendererEvent, request: DesktopCommandDispatchRequest & { requestId?: string } ) => { if (!request.requestId) return; try { - const result = listener(request); + const result = await listener(request); ipcRenderer.send('desktop:menu-command-result', { requestId: request.requestId, accepted: result?.accepted === true, @@ -209,7 +211,11 @@ const api: VeritasDesktopApi = { } }; ipcRenderer.on('desktop:menu-command', handler); - return () => ipcRenderer.off('desktop:menu-command', handler); + ipcRenderer.send('desktop:menu-command-ready'); + return () => { + ipcRenderer.off('desktop:menu-command', handler); + ipcRenderer.send('desktop:menu-command-not-ready'); + }; }, onUploadProgress: (listener) => onDesktopEvent('uploadProgress', listener), onWorkProductExportProgress: (listener) => onDesktopEvent('workProductExportProgress', listener), diff --git a/docs/DESKTOP-RELEASE.md b/docs/DESKTOP-RELEASE.md index 906d7492..e6da1067 100644 --- a/docs/DESKTOP-RELEASE.md +++ b/docs/DESKTOP-RELEASE.md @@ -331,3 +331,13 @@ 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. + +### Settings window ownership + +On macOS, Settings and Command-comma focus one reusable, modeless native window. The main board remains usable. Browser clients and other desktop platforms retain the shared modal presentation. Settings links and native Import, Export, Backup, and Debug Bundle commands select the corresponding shared section after the authenticated renderer acknowledges the request. + +The native window uses the existing isolated, sandboxed preload with Node integration disabled. Bridge calls and command receipts are restricted to owned top-level renderers at the current application origin; generated startup status pages are a separate main-window-only case. Settings navigation stays in the settings surface, and external links use the existing validated OS opener. + +Closing Settings hides its renderer and restores focus to the board, retaining pending edits and failed writes for retry. Reopening restores that session. Quit waits for its serialized settings writes before stopping the local server. A failed or stalled save keeps the app open and directs the user back to Settings. Normal native close, minimize, restore, and focus controls apply to this window independently of the board's saved bounds. + +The two windows share the existing browser profile. Appearance uses the existing local preference store. Settings/configuration changes send same-origin invalidation notices, without values or credentials, and each renderer refetches through its authenticated API. Sign-in changes refresh each window's auth guard; workspace selection follows the shared profile. Hidden Settings continues to receive updates and remains subject to the same server permissions. diff --git a/scripts/docs-media/run.mjs b/scripts/docs-media/run.mjs index 384c7d8e..d2f560ff 100644 --- a/scripts/docs-media/run.mjs +++ b/scripts/docs-media/run.mjs @@ -6,6 +6,7 @@ import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import { chromium, expect } from '@playwright/test'; +import { nativeSettingsPage, closeNativeSettings } from '../native-ui/settings-window.mjs'; import { createNativeSession } from '../native-ui/session.mjs'; import { contentSizes, fileDigest, packageDigest } from '../native-ui/contract.mjs'; import { maintainedAssets, mediaSchema, mediaEvidenceFailures } from './verify.mjs'; @@ -111,15 +112,15 @@ async function capture() { scaleFactor: 1, }; } - const native = await app.evaluate(async ({ BrowserWindow, screen }) => { - const win = BrowserWindow.getAllWindows().find((w) => w.isVisible()); + const native = await app.evaluate(async ({ BrowserWindow, screen }, url) => { + const win = BrowserWindow.getAllWindows().find((w) => w.webContents.getURL() === url); return { bounds: win.getBounds(), contentBounds: win.getContentBounds(), scaleFactor: screen.getDisplayMatching(win.getBounds()).scaleFactor, png: (await win.capturePage()).toPNG().toString('base64'), }; - }); + }, page.url()); assert.equal(native.contentBounds.width, contentSizes.normal.width); assert.equal(native.contentBounds.height, contentSizes.normal.height); return { @@ -286,8 +287,20 @@ try { await openTask(); await still('task-workspace.png'); await button('Close task workspace').click(); + const boardPage = page; await button('Settings').click(); - const settings = page.getByRole('dialog', { name: /^Settings(?: Board Only)?$/ }); + page = await nativeSettingsPage(app); + await app.evaluate( + ({ BrowserWindow }, size) => { + const window = BrowserWindow.getAllWindows().find( + (window) => window.webContents.getURL() === size.url + ); + window.webContents.setZoomFactor(1); + window.setContentSize(size.width, size.height); + }, + { ...contentSizes.normal, url: page.url() } + ); + const settings = page.getByRole('main', { name: 'Settings', exact: true }); await expect(settings).toBeVisible(); await expect(settings.getByRole('heading', { name: 'General', exact: true })).toBeVisible(); await still('settings-navigation.png'); @@ -300,7 +313,8 @@ try { await expect(settings.getByRole('heading', { name, exact: true })).toBeVisible(); await still(file); } - await button('Close settings').click(); + await closeNativeSettings(app, page); + page = boardPage; await button('Command palette').click(); await expect(page.getByRole('textbox', { name: 'Search commands' })).toBeVisible(); await still('command-palette.png'); diff --git a/scripts/native-ui/menu-commands.mjs b/scripts/native-ui/menu-commands.mjs index 923ae1aa..8e2b13b9 100644 --- a/scripts/native-ui/menu-commands.mjs +++ b/scripts/native-ui/menu-commands.mjs @@ -1,4 +1,5 @@ /* global window, setTimeout, clearTimeout */ +import { nativeSettingsPage, closeNativeSettings } from './settings-window.mjs'; import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; @@ -30,22 +31,25 @@ export async function verifyNativeMenuCommands(app, page) { if (!item || !item.enabled) throw new Error(`Native command unavailable: ${label}`); item.click(); }, label); + if (surface === 'Settings') { + const settings = await nativeSettingsPage(app); + await expect(page.getByRole('dialog')).toHaveCount(0); + if (['Import', 'Export', 'Create Backup', 'Create Debug Bundle'].includes(label)) { + await expect( + settings.getByRole('heading', { name: 'Maintenance', exact: true }) + ).toBeVisible(); + await expect( + settings.getByRole('button', { name: 'Debug Bundle', exact: true }) + ).toBeVisible(); + } + results.push({ label, surface, nativeSettingsWindow: true, mainDialogs: 0 }); + await closeNativeSettings(app, settings); + continue; + } const dialog = page.getByRole('dialog'); await expect(dialog).toHaveCount(1); await expect(dialog).toBeVisible(); - if (surface === 'Settings') { - await expect( - dialog.getByRole('button', { name: 'Close settings', exact: true }) - ).toBeVisible(); - if (['Import', 'Export', 'Create Backup', 'Create Debug Bundle'].includes(label)) { - await expect( - dialog.getByRole('heading', { name: 'Maintenance', exact: true }) - ).toBeVisible(); - await expect( - dialog.getByRole('button', { name: 'Debug Bundle', exact: true }) - ).toBeVisible(); - } - } else if (surface === 'Command Center') { + if (surface === 'Command Center') { await expect(dialog.getByRole('textbox', { name: 'Search commands' })).toBeVisible(); } else if (surface === 'Search') { await expect(dialog.getByRole('textbox', { name: 'Search Veritas' })).toBeVisible(); @@ -122,7 +126,11 @@ export async function verifyNativeWindowMenu(app, page) { }; const zoom = () => app.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows()[0].webContents.getZoomFactor() + BrowserWindow.getAllWindows() + .find( + (window) => !new URL(window.webContents.getURL()).searchParams.has('desktop-settings') + ) + .webContents.getZoomFactor() ); await clickRole('zoomIn'); await expect.poll(zoom).toBeGreaterThan(1); @@ -136,7 +144,9 @@ export async function verifyNativeWindowMenu(app, page) { const transition = app.evaluate( ({ BrowserWindow }, event) => new Promise((resolve, reject) => { - const window = BrowserWindow.getAllWindows()[0]; + const window = BrowserWindow.getAllWindows().find( + (window) => !new URL(window.webContents.getURL()).searchParams.has('desktop-settings') + ); const timeout = setTimeout(() => reject(new Error(`No ${event} event`)), 10000); window.once(event, () => { clearTimeout(timeout); @@ -150,23 +160,45 @@ export async function verifyNativeWindowMenu(app, page) { } await expect .poll(() => - app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFullScreen()) + app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows() + .find( + (window) => !new URL(window.webContents.getURL()).searchParams.has('desktop-settings') + ) + .isFullScreen() + ) ) .toBe(false); const normalBounds = await app.evaluate(({ BrowserWindow }) => { - const window = BrowserWindow.getAllWindows()[0]; + const window = BrowserWindow.getAllWindows().find( + (window) => !new URL(window.webContents.getURL()).searchParams.has('desktop-settings') + ); const bounds = window.getNormalBounds(); window.maximize(); return bounds; }); await expect - .poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isMaximized())) + .poll(() => + app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows() + .find( + (window) => !new URL(window.webContents.getURL()).searchParams.has('desktop-settings') + ) + .isMaximized() + ) + ) .toBe(true); 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); + assert.equal( + await app.evaluate( + ({ BrowserWindow }) => + BrowserWindow.getAllWindows().filter((window) => window.isVisible()).length + ), + 0 + ); const opened = app.waitForEvent('window'); await app.evaluate(({ app }) => app.emit('activate')); const reopened = await opened; @@ -178,11 +210,31 @@ export async function verifyNativeWindowMenu(app, page) { 'Closing the window restarted the managed server' ); await expect - .poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isMaximized())) + .poll(() => + app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows() + .find( + (window) => !new URL(window.webContents.getURL()).searchParams.has('desktop-settings') + ) + .isMaximized() + ) + ) .toBe(true); - await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].unmaximize()); + await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows() + .find((window) => !new URL(window.webContents.getURL()).searchParams.has('desktop-settings')) + .unmaximize() + ); await expect - .poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].getBounds())) + .poll(() => + app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows() + .find( + (window) => !new URL(window.webContents.getURL()).searchParams.has('desktop-settings') + ) + .getBounds() + ) + ) .toEqual(normalBounds); return { page: reopened, roles: items, normalBounds }; } @@ -192,14 +244,24 @@ export async function verifyConfiguredTitlebarAction(app, page) { systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string') ); await app.evaluate(({ BrowserWindow }) => { - const window = BrowserWindow.getAllWindows()[0]; + const window = BrowserWindow.getAllWindows().find( + (window) => !new URL(window.webContents.getURL()).searchParams.has('desktop-settings') + ); window.restore(); window.unmaximize(); }); const state = () => app.evaluate(({ BrowserWindow }) => ({ - maximized: BrowserWindow.getAllWindows()[0].isMaximized(), - minimized: BrowserWindow.getAllWindows()[0].isMinimized(), + maximized: BrowserWindow.getAllWindows() + .find( + (window) => !new URL(window.webContents.getURL()).searchParams.has('desktop-settings') + ) + .isMaximized(), + minimized: BrowserWindow.getAllWindows() + .find( + (window) => !new URL(window.webContents.getURL()).searchParams.has('desktop-settings') + ) + .isMinimized(), })); await expect.poll(state).toEqual({ maximized: false, minimized: false }); await page.getByRole('navigation', { name: 'Main navigation' }).dispatchEvent('dblclick'); @@ -209,7 +271,9 @@ export async function verifyConfiguredTitlebarAction(app, page) { }; await expect.poll(state).toEqual(expected); await app.evaluate(({ BrowserWindow }) => { - const window = BrowserWindow.getAllWindows()[0]; + const window = BrowserWindow.getAllWindows().find( + (window) => !new URL(window.webContents.getURL()).searchParams.has('desktop-settings') + ); window.restore(); window.unmaximize(); window.focus(); diff --git a/scripts/native-ui/run.mjs b/scripts/native-ui/run.mjs index 2743fa41..9bc6cb23 100644 --- a/scripts/native-ui/run.mjs +++ b/scripts/native-ui/run.mjs @@ -5,6 +5,11 @@ import { spawnSync } from 'node:child_process'; import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { expect } from '@playwright/test'; +import { + nativeSettingsPage, + closeNativeSettings, + verifyNativeSettingsWindow, +} from './settings-window.mjs'; import { createNativeSession } from './session.mjs'; import { verifyRouteContrast } from './contrast.mjs'; import { @@ -86,11 +91,11 @@ async function launch() { async function resize(width, height) { await app.evaluate( ({ BrowserWindow }, size) => { - const window = BrowserWindow.getAllWindows().find((w) => w.isVisible()); + const window = BrowserWindow.getAllWindows().find((w) => w.webContents.getURL() === size.url); window.webContents.setZoomFactor(1); window.setContentSize(size.width, size.height); }, - { width, height } + { width, height, url: page.url() } ); await expect.poll(() => page.evaluate(() => [innerWidth, innerHeight])).toEqual([width, height]); } @@ -109,7 +114,7 @@ async function configure(mode) { } async function metrics() { const geometry = await page.evaluate(() => { - const shell = document.querySelector('.desktop-app-shell'); + const shell = document.querySelector('.desktop-app-shell, [data-settings-window]'); if (!shell) throw new Error('Missing production desktop shell'); const rect = (el) => { const b = el.getBoundingClientRect(); @@ -198,8 +203,8 @@ async function capture(entry) { entry.route = new URL(page.url()).pathname; entry.theme = await page.locator('html').getAttribute('data-mantine-color-scheme'); entry.geometry = await metrics(); - const native = await app.evaluate(async ({ BrowserWindow, screen }) => { - const window = BrowserWindow.getAllWindows().find((w) => w.isVisible()); + const native = await app.evaluate(async ({ BrowserWindow, screen }, url) => { + const window = BrowserWindow.getAllWindows().find((w) => w.webContents.getURL() === url); return { bounds: window.getBounds(), contentBounds: window.getContentBounds(), @@ -207,7 +212,7 @@ async function capture(entry) { scaleFactor: screen.getDisplayMatching(window.getBounds()).scaleFactor, png: (await window.capturePage()).toPNG().toString('base64'), }; - }); + }, page.url()); const name = `${entry.id.replaceAll('/', '--')}.png`; await writeFile(path.join(output, name), Buffer.from(native.png, 'base64')); delete native.png; @@ -292,17 +297,25 @@ async function exercise(state, mode, shot) { await expect(page).toHaveURL(`${origin}/`); } else if (state.startsWith('settings-')) { const tab = settingsSections.find((label) => state === `settings-${label.toLowerCase()}`); + const board = page; const opener = button('Settings'); await opener.click(); - const dialog = page.locator('.settings-dialog-content'); - await dialog.getByRole('tab', { name: tab, exact: true }).click(); - await expect(dialog.getByRole('tab', { name: tab, exact: true })).toHaveAttribute( - 'aria-selected', - 'true' - ); - await expect(dialog.getByRole('heading', { name: tab, exact: true })).toBeVisible(); - await shot(); - await dismiss(dialog, opener); + page = await nativeSettingsPage(app); + try { + await resize(mode.width, mode.height); + const settings = page.getByRole('main', { name: 'Settings', exact: true }); + await settings.getByRole('tab', { name: tab, exact: true }).click(); + await expect(settings.getByRole('tab', { name: tab, exact: true })).toHaveAttribute( + 'aria-selected', + 'true' + ); + await expect(settings.getByRole('heading', { name: tab, exact: true })).toBeVisible(); + await shot({ windowRole: 'settings' }); + await closeNativeSettings(app, page); + } finally { + page = board; + } + await expect(opener).toBeFocused(); } else if (state === 'left-rail' || state === 'right-rail') { const side = state === 'left-rail' ? 'left' : 'right'; const expand = button(`Expand ${side} sidebar`); @@ -426,12 +439,10 @@ async function exercise(state, mode, shot) { await expect( button(`${before === 'true' ? 'Expand' : 'Collapse'} ${side} sidebar`) ).toHaveAttribute('aria-expanded', before === 'true' ? 'false' : 'true'); - } else if ( - name === 'New Task' || - name === 'Settings' || - name === 'Search' || - name === 'Command palette' - ) { + } else if (name === 'Settings') { + await control.click(); + await closeNativeSettings(app, await nativeSettingsPage(app)); + } else if (name === 'New Task' || name === 'Search' || name === 'Command palette') { await control.click(); const dialog = page.getByRole('dialog', { name: { @@ -498,15 +509,21 @@ async function exercise(state, mode, shot) { page.getByRole('heading', { name: `Native acceptance ${mode.id}`, exact: true }) ).toBeVisible(); } else if (state === 'confirmation') { + const board = page; await button('Settings').click(); - const settings = page.locator('.settings-dialog-content'); - const reset = settings.getByRole('button', { name: 'Reset All', exact: true }); - await reset.click(); - const dialog = page.getByRole('dialog', { name: 'Reset all settings?', exact: true }); - await expect(dialog.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused(); - await shot(); - await dismiss(dialog, reset); - await dismiss(settings, button('Settings')); + page = await nativeSettingsPage(app); + try { + await resize(mode.width, mode.height); + const reset = page.getByRole('button', { name: 'Reset All', exact: true }); + await reset.click(); + const dialog = page.getByRole('dialog', { name: 'Reset all settings?', exact: true }); + await expect(dialog.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused(); + await shot({ windowRole: 'settings' }); + await dismiss(dialog, reset); + await closeNativeSettings(app, page); + } finally { + page = board; + } } else if (state === 'search' || state === 'command-palette') { const palette = state === 'command-palette'; if (palette) await page.keyboard.press('Meta+k'); @@ -663,6 +680,7 @@ try { await launch(); report.titlebarAction = await verifyConfiguredTitlebarAction(app, page); report.menuCommands = await verifyNativeMenuCommands(app, page); + report.settingsWindow = await verifyNativeSettingsWindow(app, page); const windowMenu = await verifyNativeWindowMenu(app, page); page = windowMenu.page; report.menuRoles = windowMenu.roles; diff --git a/scripts/native-ui/settings-window.mjs b/scripts/native-ui/settings-window.mjs new file mode 100644 index 00000000..a969905b --- /dev/null +++ b/scripts/native-ui/settings-window.mjs @@ -0,0 +1,114 @@ +/* global window */ +import assert from 'node:assert/strict'; +import { expect } from '@playwright/test'; + +export async function nativeSettingsPage(app) { + let page; + await expect + .poll(() => { + const matches = app + .windows() + .filter( + (candidate) => new URL(candidate.url()).searchParams.get('desktop-settings') === '1' + ); + assert(matches.length <= 1, 'More than one native Settings renderer'); + page = matches[0]; + return Boolean(page); + }) + .toBe(true); + await expect(page.getByRole('main', { name: 'Settings', exact: true })).toBeVisible(); + await expect + .poll(() => + app.evaluate( + ({ BrowserWindow }, url) => + BrowserWindow.getAllWindows() + .find((window) => window.webContents.getURL() === url) + ?.isVisible(), + page.url() + ) + ) + .toBe(true); + return page; +} + +export async function closeNativeSettings(app, page) { + await app.evaluate(({ BrowserWindow }, url) => { + const window = BrowserWindow.getAllWindows().find( + (window) => window.webContents.getURL() === url + ); + if (!window) throw new Error('Settings window disappeared'); + window.close(); + }, page.url()); + await expect + .poll(() => + app.evaluate( + ({ BrowserWindow }, url) => + BrowserWindow.getAllWindows() + .find((window) => window.webContents.getURL() === url) + ?.isVisible(), + page.url() + ) + ) + .toBe(false); +} + +/** Feature journey: menu reuse, modeless board, safe synchronization and retained edits. */ +export async function verifyNativeSettingsWindow(app, board) { + const request = { command: 'open-settings', source: 'shortcut', payload: { section: 'general' } }; + const results = await board.evaluate( + async (request) => + Promise.all([ + window.veritasDesktop.dispatchCommand(request), + window.veritasDesktop.dispatchCommand(request), + ]), + request + ); + assert( + results.every((result) => result.accepted), + 'Settings dispatch was not acknowledged' + ); + const settings = await nativeSettingsPage(app); + await expect(board.getByRole('dialog')).toHaveCount(0); + await board.getByRole('button', { name: 'New Task', exact: true }).click(); + await expect(board.getByRole('dialog')).toHaveCount(1); + await board.keyboard.press('Escape'); + const input = settings.getByRole('textbox', { name: 'Display Name (Squad Chat)', exact: true }); + await expect(input).toBeVisible(); + const changed = 'Native Settings fixture'; + await input.fill(changed); + await closeNativeSettings(app, settings); + await expect + .poll(async () => + board.evaluate(async () => { + const response = await fetch('/api/settings/features'); + const body = await response.json(); + return (body.data ?? body).general.humanDisplayName; + }) + ) + .toBe(changed); + const result = await board.evaluate( + (request) => window.veritasDesktop.dispatchCommand(request), + request + ); + assert(result.accepted); + assert.equal( + await nativeSettingsPage(app), + settings, + 'Close/reopen replaced the editing renderer' + ); + await expect(input).toHaveValue(changed); + const frames = await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().map((window) => ({ + settings: new URL(window.webContents.getURL()).searchParams.get('desktop-settings') === '1', + visible: window.isVisible(), + preferences: window.webContents.getLastWebPreferences(), + })) + ); + assert.equal(frames.filter((frame) => frame.settings).length, 1); + const preferences = frames.find((frame) => frame.settings).preferences; + assert.equal(preferences.nodeIntegration, false); + assert.equal(preferences.contextIsolation, true); + assert.equal(preferences.sandbox, true); + await closeNativeSettings(app, settings); + return { reusedWindow: true, boardUsable: true, retainedEdit: true, sandboxed: true }; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index c9178a4d..1857dd41 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,8 @@ import { lazy, Suspense, useEffect, useState, useRef } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { resetFeatureSettingsWrites } from './lib/feature-settings-writes'; +import { isNativeSettingsWindow } from './lib/desktop-settings'; +import { useSettingsWindowSync } from './hooks/useSettingsWindowSync'; import { Box } from '@mantine/core'; import { Header } from './components/layout/Header'; import { Toaster } from './components/ui/toaster'; @@ -25,6 +27,12 @@ import { DesktopLeftSidebar } from './components/layout/DesktopLeftSidebar'; import { DesktopBottomPanel } from './components/layout/DesktopBottomPanel'; import { RunSessionShareView } from './components/task/RunSessionSharesSection'; +const NativeSettingsWindow = lazy(() => + import('./components/settings/NativeSettingsWindow').then((mod) => ({ + default: mod.NativeSettingsWindow, + })) +); + const LAZY_VIEW_COMPONENTS = Object.fromEntries( NAVIGATION_VIEWS.map((definition) => { if (!definition.loadComponent) { @@ -208,6 +216,8 @@ function AppContent() { }; }, [queryClient]); + useSettingsWindowSync(); + // Connect to WebSocket for real-time task updates const { isConnected, connectionState, reconnectAttempt, reconnect } = useTaskSync(); const { status: authStatus, refreshStatus } = useAuth(); @@ -237,12 +247,18 @@ function AppContent() { - + {isNativeSettingsWindow() ? ( + Loading Settings…}> + + + ) : ( + + )} diff --git a/web/src/__tests__/feature-settings-writes.test.tsx b/web/src/__tests__/feature-settings-writes.test.tsx index 88ea3b54..c8262673 100644 --- a/web/src/__tests__/feature-settings-writes.test.tsx +++ b/web/src/__tests__/feature-settings-writes.test.tsx @@ -15,6 +15,7 @@ import { useUpdateFeatureSettings, } from '@/hooks/useFeatureSettings'; import { + getFeatureSettingsWrites, resetFeatureSettingsWrites, FEATURE_SETTINGS_QUERY_KEY, } from '@/lib/feature-settings-writes'; @@ -54,6 +55,41 @@ describe('feature settings write ownership', () => { vi.useRealTimers(); }); + it('flushes edits on native quit and waits for newer edits queued during an active save', async () => { + const writer = getFeatureSettingsWrites(client); + let finish!: (settings: FeatureSettings) => void; + mocks.update.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + writer.enqueue({ general: { humanDisplayName: 'First' } }, 500); + const settled = vi.fn(); + const ending = writer.settle().then(settled); + expect(mocks.update).toHaveBeenCalledOnce(); + writer.enqueue({ general: { humanDisplayName: 'Latest' } }, 500); + expect(settled).not.toHaveBeenCalled(); + finish(stored); + await ending; + expect(mocks.update).toHaveBeenCalledTimes(2); + expect(stored.general.humanDisplayName).toBe('Latest'); + expect(settled).toHaveBeenCalledOnce(); + }); + + it('blocks native quit on failed persistence without silently retrying', async () => { + const writer = getFeatureSettingsWrites(client); + mocks.update.mockRejectedValueOnce(new Error('Offline')); + writer.enqueue({ general: { humanDisplayName: 'Keep me' } }, 500); + await expect(writer.settle()).rejects.toThrow('Offline'); + await expect(writer.settle()).rejects.toThrow('Offline'); + expect(mocks.update).toHaveBeenCalledOnce(); + expect(writer.overlay(stored).general.humanDisplayName).toBe('Keep me'); + writer.retry(); + await writer.settle(); + expect(stored.general.humanDisplayName).toBe('Keep me'); + }); + it('persists a pending edit after immediate tab or Settings unmount', async () => { const hook = renderHook(() => useDebouncedFeatureUpdate(), { wrapper }); act(() => diff --git a/web/src/__tests__/settings-dialog-mantine.test.tsx b/web/src/__tests__/settings-dialog-mantine.test.tsx index 64d214ea..f5420bc2 100644 --- a/web/src/__tests__/settings-dialog-mantine.test.tsx +++ b/web/src/__tests__/settings-dialog-mantine.test.tsx @@ -176,6 +176,13 @@ describe('SettingsDialog Mantine shell', () => { ); }); + it('renders shared Settings as a modeless native surface', () => { + renderWithProviders(); + expect(screen.getByRole('main', { name: 'Settings' })).toBeDefined(); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByRole('combobox', { name: 'Search settings' })).toBeDefined(); + }); + it('finds an existing appearance control and focuses it through keyboard selection', async () => { renderWithProviders(); const input = screen.getByRole('combobox', { name: 'Search settings' }); diff --git a/web/src/__tests__/settings-window-sync.test.tsx b/web/src/__tests__/settings-window-sync.test.tsx new file mode 100644 index 00000000..bcc6f60d --- /dev/null +++ b/web/src/__tests__/settings-window-sync.test.tsx @@ -0,0 +1,63 @@ +import { cleanup, renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + notifyDesktopAuthChange, + SETTINGS_CHANGED_EVENT, + useDesktopAuthSync, + useSettingsWindowSync, +} from '@/hooks/useSettingsWindowSync'; + +const channels: FakeChannel[] = []; +class FakeChannel { + onmessage?: (event: { data: unknown }) => void; + postMessage = vi.fn((data: unknown) => { + for (const other of channels) + if (other !== this && other.name === this.name) other.onmessage?.({ data }); + }); + close = vi.fn(); + constructor(public name: string) { + channels.push(this); + } +} +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + channels.length = 0; + delete (window as Window & { veritasDesktop?: unknown }).veritasDesktop; +}); +describe('native window synchronization', () => { + it('sends only invalidation notices and never treats a payload as authoritative cached settings', () => { + vi.stubGlobal('BroadcastChannel', FakeChannel); + Object.assign(window, { veritasDesktop: {} }); + const client = new QueryClient(); + const invalidate = vi.spyOn(client, 'invalidateQueries'); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const hook = renderHook(() => useSettingsWindowSync(), { wrapper }); + const channel = channels[0]; + window.dispatchEvent(new Event(SETTINGS_CHANGED_EVENT)); + expect(channel.postMessage).toHaveBeenCalledWith('invalidate'); + channel.onmessage?.({ data: { settings: { untrusted: true } } }); + expect(invalidate).not.toHaveBeenCalled(); + channel.onmessage?.({ data: 'invalidate' }); + expect(invalidate).toHaveBeenCalledOnce(); + hook.unmount(); + expect(channel.close).toHaveBeenCalledOnce(); + client.clear(); + }); + it('keeps the auth refresh listener independent of the authenticated editing session', () => { + vi.stubGlobal('BroadcastChannel', FakeChannel); + Object.assign(window, { veritasDesktop: {} }); + const refresh = vi.fn(async () => {}); + const hook = renderHook(() => useDesktopAuthSync(refresh)); + notifyDesktopAuthChange(); + expect(channels[0].postMessage).toHaveBeenCalledWith('refresh'); + channels[0].onmessage?.({ data: 'refresh' }); + expect(refresh).toHaveBeenCalledOnce(); + hook.unmount(); + expect(channels[0].close).toHaveBeenCalledOnce(); + }); +}); diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 97f46d49..1a65da00 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -1,3 +1,5 @@ +import { desktopSettingsBridge } from '@/lib/desktop-settings'; +import { toast } from '@/hooks/useToast'; import { parseSettingsSupportHash } from '@/components/settings/settings-search-index'; import { flushSync } from 'react-dom'; import { @@ -250,7 +252,7 @@ export function Header({ openSquadChatPanel(); }, [openSquadChatPanel, toggleBottomPanel, usesWorkbenchChat]); - const openSettingsDialog = useCallback( + const openBrowserSettingsDialog = useCallback( (section?: string, control?: string) => { markPanelLoaded('settings'); setSettingsControl(control); @@ -260,17 +262,51 @@ export function Header({ [markPanelLoaded] ); - const openSecuritySettings = useCallback(() => { - markPanelLoaded('settings'); - setSettingsTab('security'); - setSettingsOpen(true); - }, [markPanelLoaded]); - - const openIdentitySettings = useCallback(() => { - markPanelLoaded('settings'); - setSettingsTab('multi-user'); - setSettingsOpen(true); - }, [markPanelLoaded]); + const openSettingsDialog = useCallback( + (section?: string, control?: string) => { + const bridge = desktopSettingsBridge(); + if (!bridge?.getAppInfo || !bridge.dispatchCommand) { + openBrowserSettingsDialog(section, control); + return; + } + const dispatchSettings = bridge.dispatchCommand; + void bridge + .getAppInfo() + .then(async (info) => { + if (info.platform !== 'darwin') { + openBrowserSettingsDialog(section, control); + return; + } + const result = await dispatchSettings({ + command: 'open-settings', + source: 'renderer', + payload: { section, control }, + }); + if (!result.accepted) + toast({ + title: 'Settings could not open', + description: result.message, + variant: 'destructive', + }); + }) + .catch(() => + toast({ + title: 'Settings could not open', + description: 'The desktop window is unavailable. Try opening Settings again.', + variant: 'destructive', + }) + ); + }, + [openBrowserSettingsDialog] + ); + const openSecuritySettings = useCallback( + () => openSettingsDialog('security'), + [openSettingsDialog] + ); + const openIdentitySettings = useCallback( + () => openSettingsDialog('multi-user'), + [openSettingsDialog] + ); const renderNavigationAction = (item: NavigationItem) => { const Icon = VIEW_ICONS[item.icon]; @@ -376,7 +412,7 @@ export function Header({ else message = 'Task write permission is required to create a task.'; break; case 'open-settings': - if (canOpenSettings) action = () => openSettingsDialog(); + if (canOpenSettings) action = () => openBrowserSettingsDialog(); else message = 'Settings read permission is required to open Settings.'; break; case 'open-search': @@ -398,11 +434,11 @@ export function Header({ case 'create-backup': case 'create-debug-bundle': if (canOpenSettings && hasPermission('backup:read')) - action = () => openSettingsDialog('maintenance'); + action = () => openBrowserSettingsDialog('maintenance'); else message = 'Settings and backup read permission are required to open Maintenance.'; break; case 'test-squad-webhook': - if (canOpenSettings) action = () => openSettingsDialog('notifications'); + if (canOpenSettings) action = () => openBrowserSettingsDialog('notifications'); else message = 'Open Notifications in Settings to configure and test external delivery.'; break; } @@ -415,7 +451,7 @@ export function Header({ canOpenSettings, hasPermission, openCreateDialog, - openSettingsDialog, + openBrowserSettingsDialog, openSearchDialog, onOpenCommandCenter, onOpenDiagnostics, diff --git a/web/src/components/settings/NativeSettingsWindow.tsx b/web/src/components/settings/NativeSettingsWindow.tsx new file mode 100644 index 00000000..c9f27bc4 --- /dev/null +++ b/web/src/components/settings/NativeSettingsWindow.tsx @@ -0,0 +1,77 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { getFeatureSettingsWrites } from '@/lib/feature-settings-writes'; +import { useEffect, useState } from 'react'; +import { flushSync } from 'react-dom'; +import { SettingsDialog } from './SettingsDialog'; +import { useIdentity } from '@/hooks/useIdentity'; +import { desktopSettingsBridge } from '@/lib/desktop-settings'; + +/** Same authenticated settings content, without a modal or board mounted underneath. */ +export function NativeSettingsWindow() { + const { hasPermission, isLoading, error, authContext } = useIdentity(); + const writer = getFeatureSettingsWrites(useQueryClient()); + const [target, setTarget] = useState<{ section?: string; control?: string }>({}); + const allowed = + Boolean(authContext) && + !error && + (hasPermission('settings:read') || hasPermission('admin:manage')); + useEffect(() => { + if (isLoading) return; + return desktopSettingsBridge()?.onMenuCommand?.(async (request) => { + if (request.command === 'open-settings' && request.payload?.flushPending === true) { + try { + await writer.settle(); + return { accepted: true }; + } catch { + return { + accepted: false, + message: 'Settings changes are not saved. Retry the save before quitting.', + }; + } + } + if (!allowed) + return { + accepted: false, + message: 'Settings read permission is required to open Settings.', + }; + let section = + typeof request.payload?.section === 'string' ? request.payload.section : undefined; + const control = + typeof request.payload?.control === 'string' ? request.payload.control : undefined; + if ( + ['import-data', 'export-data', 'create-backup', 'create-debug-bundle'].includes( + request.command + ) + ) { + if (!hasPermission('backup:read')) + return { + accepted: false, + message: 'Backup read permission is required to open Maintenance.', + }; + section = 'maintenance'; + } else if (request.command === 'test-squad-webhook') section = 'notifications'; + else if (request.command !== 'open-settings') + return { accepted: false, message: 'Use the main window for this action.' }; + flushSync(() => setTarget({ section: section ?? target.section ?? 'general', control })); + return { accepted: true }; + }); + }, [allowed, hasPermission, isLoading, writer, target.section]); + + if (!allowed) + return ( +
+ Settings read permission is required. +
+ ); + return ( + { + if (!open) window.close(); + }} + defaultTab={target.section} + defaultControl={target.control} + /> + ); +} diff --git a/web/src/components/settings/SettingsDialog.tsx b/web/src/components/settings/SettingsDialog.tsx index 4f043a47..12426ef9 100644 --- a/web/src/components/settings/SettingsDialog.tsx +++ b/web/src/components/settings/SettingsDialog.tsx @@ -258,6 +258,7 @@ interface SettingsDialogProps { onOpenChange: (open: boolean) => void; defaultTab?: string; defaultControl?: string; + presentation?: 'modal' | 'window'; } // ============ Main Settings Dialog ============ @@ -267,6 +268,7 @@ export function SettingsDialog({ onOpenChange, defaultTab, defaultControl, + presentation = 'modal', }: SettingsDialogProps) { const [activeTab, setActiveTab] = useState('general'); const [pendingFocus, setPendingFocus] = useState<{ section: string; controlId?: string } | null>( @@ -620,40 +622,26 @@ export function SettingsDialog({ ); }; - return ( - onOpenChange(false)} - title={ - - - Settings + const title = ( + + + Settings + + {isBoardOnly && Board Only} + {saveError && ( + + + Changes not saved. - {isBoardOnly && Board Only} - {saveError && ( - - - Changes not saved. - - - Retry - - - )} + + Retry + - } - centered - trapFocus - returnFocus - closeButtonProps={{ 'aria-label': 'Close settings' }} - classNames={{ - content: 'settings-dialog-content h-dvh', - header: 'settings-dialog-header', - body: 'settings-dialog-body', - }} - > + )} + + ); + const content = ( + <>
+ + ); + if (presentation === 'window') + return ( +
+
{title}
+
{content}
+
+ ); + return ( + onOpenChange(false)} + title={title} + centered + trapFocus + returnFocus + closeButtonProps={{ 'aria-label': 'Close settings' }} + classNames={{ + content: 'settings-dialog-content h-dvh', + header: 'settings-dialog-header', + body: 'settings-dialog-body', + }} + > + {content} ); } diff --git a/web/src/hooks/useAuth.tsx b/web/src/hooks/useAuth.tsx index 1cda3059..01bd52e2 100644 --- a/web/src/hooks/useAuth.tsx +++ b/web/src/hooks/useAuth.tsx @@ -1,3 +1,4 @@ +import { useDesktopAuthSync, notifyDesktopAuthChange } from './useSettingsWindowSync'; import { useState, useEffect, useCallback, createContext, useContext, type ReactNode } from 'react'; import type { AuthStatus } from '@veritas-kanban/shared'; import { apiFetch } from '@/lib/api/helpers'; @@ -53,6 +54,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []); + useDesktopAuthSync(refreshStatus); + // Check auth status on mount useEffect(() => { refreshStatus(); @@ -83,6 +86,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { body: JSON.stringify({ password, rememberMe }), }); await refreshStatus(); + notifyDesktopAuthChange(); return { success: true }; } catch (err) { console.error('[Auth] Login failed:', err); @@ -96,6 +100,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { await apiFetch('/api/auth/logout', { method: 'POST' }); } finally { + notifyDesktopAuthChange(); await refreshStatus(); } }, [refreshStatus]); diff --git a/web/src/hooks/useIdentity.tsx b/web/src/hooks/useIdentity.tsx index 712cc007..2bfb8fc9 100644 --- a/web/src/hooks/useIdentity.tsx +++ b/web/src/hooks/useIdentity.tsx @@ -168,6 +168,18 @@ export function IdentityProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const [activeWorkspaceId, setActiveWorkspaceId] = useState(readStoredWorkspaceId); + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.key !== ACTIVE_WORKSPACE_STORAGE_KEY || event.storageArea !== window.localStorage) + return; + setActiveWorkspaceId(event.newValue); + void queryClient.invalidateQueries({ queryKey: ['auth', 'context'] }); + void queryClient.invalidateQueries({ queryKey: ['identity'] }); + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, [queryClient]); + const authQuery = useQuery({ queryKey: ['auth', 'context'], queryFn: identityApi.getAuthContext, diff --git a/web/src/hooks/useSettingsWindowSync.ts b/web/src/hooks/useSettingsWindowSync.ts new file mode 100644 index 00000000..9c7e80c4 --- /dev/null +++ b/web/src/hooks/useSettingsWindowSync.ts @@ -0,0 +1,49 @@ +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { desktopSettingsBridge } from '@/lib/desktop-settings'; + +export const SETTINGS_CHANGED_EVENT = 'veritas:settings-saved'; + +/** Only invalidation notices cross windows; each renderer refetches with its own authorization. */ +export function useSettingsWindowSync() { + const client = useQueryClient(); + useEffect(() => { + if (!desktopSettingsBridge() || typeof BroadcastChannel === 'undefined') return; + const channel = new BroadcastChannel('veritas-settings-invalidation-v1'); + const publish = () => channel.postMessage('invalidate'); + const unsubscribe = client.getMutationCache().subscribe((event) => { + if (event.type === 'updated' && event.action.type === 'success') publish(); + }); + window.addEventListener(SETTINGS_CHANGED_EVENT, publish); + channel.onmessage = (event) => { + if (event.data === 'invalidate') void client.invalidateQueries(); + }; + return () => { + unsubscribe(); + window.removeEventListener(SETTINGS_CHANGED_EVENT, publish); + channel.close(); + }; + }, [client]); +} + +const AUTH_CHANGED_EVENT = 'veritas:desktop-auth-changed'; +export function notifyDesktopAuthChange() { + window.dispatchEvent(new Event(AUTH_CHANGED_EVENT)); +} + +/** Lives outside AuthGuard so another window can lock and later unlock this renderer. */ +export function useDesktopAuthSync(refresh: () => Promise) { + useEffect(() => { + if (!desktopSettingsBridge() || typeof BroadcastChannel === 'undefined') return; + const channel = new BroadcastChannel('veritas-auth-invalidation-v1'); + const publish = () => channel.postMessage('refresh'); + channel.onmessage = (event) => { + if (event.data === 'refresh') void refresh(); + }; + window.addEventListener(AUTH_CHANGED_EVENT, publish); + return () => { + window.removeEventListener(AUTH_CHANGED_EVENT, publish); + channel.close(); + }; + }, [refresh]); +} diff --git a/web/src/lib/desktop-settings.ts b/web/src/lib/desktop-settings.ts new file mode 100644 index 00000000..8162bda8 --- /dev/null +++ b/web/src/lib/desktop-settings.ts @@ -0,0 +1,31 @@ +import type { + DesktopCommandDispatchRequest, + DesktopCommandDispatchResult, +} from '../../../desktop/src/shared/desktop-bridge-contracts'; + +export function desktopSettingsBridge() { + return ( + window as Window & { + veritasDesktop?: { + getAppInfo?: () => Promise<{ platform: string }>; + dispatchCommand?: ( + request: DesktopCommandDispatchRequest + ) => Promise; + onMenuCommand?: ( + listener: ( + request: DesktopCommandDispatchRequest + ) => + | { accepted: boolean; message?: string } + | Promise<{ accepted: boolean; message?: string }> + ) => () => void; + }; + } + ).veritasDesktop; +} + +export function isNativeSettingsWindow() { + return ( + Boolean(desktopSettingsBridge()) && + new URLSearchParams(window.location.search).get('desktop-settings') === '1' + ); +} diff --git a/web/src/lib/feature-settings-writes.ts b/web/src/lib/feature-settings-writes.ts index dc30e947..3d860bab 100644 --- a/web/src/lib/feature-settings-writes.ts +++ b/web/src/lib/feature-settings-writes.ts @@ -27,6 +27,7 @@ function overlay(settings: FeatureSettings, patch: FeatureSettingsPatch): Featur /** One serialized writer per query cache; tab unmounts do not own pending work. */ class FeatureSettingsWrites { private pending: FeatureSettingsPatch = {}; + private settleWaiters: Array<{ resolve: () => void; reject: (error: Error) => void }> = []; private disposed = false; private activeWaiters: Array<{ resolve: (settings: FeatureSettings) => void; @@ -100,11 +101,23 @@ class FeatureSettingsWrites { void this.flush(); }; + /** Flush pending work before a native window session ends; failures require explicit retry. */ + settle = (): Promise => { + if (this.disposed) return Promise.reject(new Error('The settings session has ended')); + if (this.snapshot.error) return Promise.reject(this.snapshot.error); + if (!this.active && Object.keys(this.pending).length === 0) return Promise.resolve(); + return new Promise((resolve, reject) => { + this.settleWaiters.push({ resolve, reject }); + void this.flush(); + }); + }; + dispose() { this.disposed = true; this.dismissFailure?.(); if (this.timer) clearTimeout(this.timer); const error = new Error('The settings session has ended'); + this.settleWaiters.splice(0).forEach((waiter) => waiter.reject(error)); [...this.waiters, ...this.activeWaiters].forEach(({ reject }) => reject(error)); this.waiters = []; this.activeWaiters = []; @@ -131,9 +144,13 @@ class FeatureSettingsWrites { this.dismissFailure?.(); this.dismissFailure = undefined; this.client.setQueryData(FEATURE_SETTINGS_QUERY_KEY, overlay(saved, this.pending)); + if (typeof window !== 'undefined') window.dispatchEvent(new Event('veritas:settings-saved')); waiters.forEach(({ resolve }) => resolve(saved)); this.publish(); - if (Object.keys(this.pending).length > 0 && !this.timer) void this.flush(); + if (Object.keys(this.pending).length === 0) + this.settleWaiters.splice(0).forEach((waiter) => waiter.resolve()); + if (Object.keys(this.pending).length > 0 && (!this.timer || this.settleWaiters.length > 0)) + void this.flush(); } catch (cause) { if (this.disposed) return; this.activeWaiters = []; @@ -145,6 +162,7 @@ class FeatureSettingsWrites { this.timer = undefined; [...waiters, ...this.waiters].forEach(({ reject }) => reject(error)); this.waiters = []; + this.settleWaiters.splice(0).forEach((waiter) => waiter.reject(error)); this.publish(error); this.dismissFailure?.(); this.dismissFailure = this.onFailure?.(error); From 67eca85894b68ea13878bc5737cf9e4354e02a25 Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 19:10:17 -0500 Subject: [PATCH 3/3] test: check rendered Settings content in its native window --- scripts/validate-release-native.test.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/validate-release-native.test.mjs b/scripts/validate-release-native.test.mjs index a661c41d..6cf811cc 100644 --- a/scripts/validate-release-native.test.mjs +++ b/scripts/validate-release-native.test.mjs @@ -242,10 +242,13 @@ test('settings captures wait for rendered page content instead of the loading sk const documentationShot = documentationCapture.indexOf("still('settings-navigation.png')"); assert(documentationReady >= 0 && documentationReady < documentationShot); - const selectedTab = nativeCapture.indexOf("'aria-selected',\n 'true'"); + const selectedTab = nativeCapture.search(/'aria-selected',\s*'true'/); const renderedHeading = nativeCapture.indexOf( - "dialog.getByRole('heading', { name: tab, exact: true })" + "settings.getByRole('heading', { name: tab, exact: true })" + ); + const settingsShot = nativeCapture.indexOf( + "await shot({ windowRole: 'settings' });", + renderedHeading ); - const settingsShot = nativeCapture.indexOf('await shot();', renderedHeading); assert(selectedTab >= 0 && selectedTab < renderedHeading && renderedHeading < settingsShot); });