Merge pull request #1569 from BradGroux/feat/native-settings-window-1542

feat: open macOS Settings in a reusable modeless window
This commit is contained in:
Brad Groux 2026-09-07 19:14:09 -05:00 • committed by GitHub
commit e3936b06ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1219 additions and 161 deletions

View file

@ -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<string, (event: unknown, request: unknown) => Promise<unknown>>();
const ipc = {
handle: (channel: string, handler: (event: unknown, request: unknown) => Promise<unknown>) =>
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');

View file

@ -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();

View file

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

View file

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

View file

@ -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<typeof createDesktopPaths> | null = null;
@ -309,17 +316,63 @@ async function boot(): Promise<void> {
),
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<void> {
: ''
)
),
}
},
(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', () => {

View file

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

View file

@ -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<void> | 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<boolean> {
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<DesktopCommandDispatchResult> {
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<void>((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.',
};
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,8 @@
/* global window */
/* 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';
import { expect } from '@playwright/test';
/** Uses the installed native menu, preload, and real mounted application. */
@ -28,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();
@ -85,26 +91,46 @@ 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()
BrowserWindow.getAllWindows()
.find(
(window) => !new URL(window.webContents.getURL()).searchParams.has('desktop-settings')
)
.webContents.getZoomFactor()
);
await clickRole('zoomIn');
await expect.poll(zoom).toBeGreaterThan(1);
@ -114,32 +140,65 @@ export async function verifyNativeWindowMenu(app, page) {
await expect.poll(zoom).toBeLessThan(1);
await clickRole('resetZoom');
await expect.poll(zoom).toBe(1);
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().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);
resolve(true);
});
}),
event
);
await clickRole('togglefullscreen');
await transition;
}
await expect
.poll(() =>
app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFullScreen())
)
.toBe(true);
await clickRole('togglefullscreen');
await expect
.poll(() =>
app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFullScreen())
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;
@ -151,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 };
}
@ -165,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');
@ -182,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();

View file

@ -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;
@ -671,7 +689,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);

View file

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

View file

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

View file

@ -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() {
<ViewProvider>
<IdentityProvider>
<DesktopShellProvider>
<DesktopAwareAppShell
authStatus={authStatus}
refreshStatus={refreshStatus}
showDesktopOnboarding={showDesktopOnboarding}
setShowDesktopOnboarding={setShowDesktopOnboarding}
/>
{isNativeSettingsWindow() ? (
<Suspense fallback={<div>Loading Settings…</div>}>
<NativeSettingsWindow />
</Suspense>
) : (
<DesktopAwareAppShell
authStatus={authStatus}
refreshStatus={refreshStatus}
showDesktopOnboarding={showDesktopOnboarding}
setShowDesktopOnboarding={setShowDesktopOnboarding}
/>
)}
</DesktopShellProvider>
</IdentityProvider>
</ViewProvider>

View file

@ -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<FeatureSettings>((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(() =>

View file

@ -176,6 +176,13 @@ describe('SettingsDialog Mantine shell', () => {
);
});
it('renders shared Settings as a modeless native surface', () => {
renderWithProviders(<SettingsDialog open presentation="window" onOpenChange={vi.fn()} />);
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(<SettingsDialog open onOpenChange={vi.fn()} />);
const input = screen.getByRole('combobox', { name: 'Search settings' });

View file

@ -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 }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
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();
});
});

View file

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

View file

@ -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 (
<main className="p-6" role="alert">
Settings read permission is required.
</main>
);
return (
<SettingsDialog
open
presentation="window"
onOpenChange={(open) => {
if (!open) window.close();
}}
defaultTab={target.section}
defaultControl={target.control}
/>
);
}

View file

@ -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<TabId>('general');
const [pendingFocus, setPendingFocus] = useState<{ section: string; controlId?: string } | null>(
@ -620,40 +622,26 @@ export function SettingsDialog({
);
};
return (
<Modal
variant="authoring"
compound
opened={open}
onClose={() => onOpenChange(false)}
title={
<Group gap="xs" wrap="nowrap">
<Text component="span" size="sm" fw={600}>
Settings
const title = (
<Group gap="xs" wrap="nowrap">
<Text component="span" size="sm" fw={600}>
Settings
</Text>
{isBoardOnly && <UiPill>Board Only</UiPill>}
{saveError && (
<Group gap="xs" role="alert">
<Text size="xs" c="red">
Changes not saved.
</Text>
{isBoardOnly && <UiPill>Board Only</UiPill>}
{saveError && (
<Group gap="xs" role="alert">
<Text size="xs" c="red">
Changes not saved.
</Text>
<UiAction variant="quiet" onClick={retrySave}>
Retry
</UiAction>
</Group>
)}
<UiAction variant="quiet" onClick={retrySave}>
Retry
</UiAction>
</Group>
}
centered
trapFocus
returnFocus
closeButtonProps={{ 'aria-label': 'Close settings' }}
classNames={{
content: 'settings-dialog-content h-dvh',
header: 'settings-dialog-header',
body: 'settings-dialog-body',
}}
>
)}
</Group>
);
const content = (
<>
<ErrorBoundary level="section">
<div ref={dialogContentRef} className="settings-dialog flex h-full min-h-0">
<input
@ -884,6 +872,37 @@ export function SettingsDialog({
</UiAction>
</OverlayFooter>
</Modal>
</>
);
if (presentation === 'window')
return (
<main
data-settings-window
className="flex h-dvh flex-col bg-background text-foreground"
aria-label="Settings"
>
<header className="shrink-0 border-b px-4 py-3">{title}</header>
<div className="min-h-0 flex-1">{content}</div>
</main>
);
return (
<Modal
variant="authoring"
compound
opened={open}
onClose={() => 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}
</Modal>
);
}

View file

@ -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]);

View file

@ -168,6 +168,18 @@ export function IdentityProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(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,

View file

@ -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<void>) {
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]);
}

View file

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

View file

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