From bb067b55ce473393052dc718d1b943017cdbaecf Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 11 Sep 2026 17:31:08 +0800 Subject: [PATCH 1/2] fix(desktop): route every e2e window reveal through the reveal gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4579 resolved a reveal mode for the run — `hidden`, `inactive`, `active` — and routed the main window's own reveal gate and the dock rule through it. It did not reach the windows that other subsystems reveal on their own, so a local suite run still lost the foreground: a WorkHub summon or dock called `show()` + `focus()` on the floating panel and on Desktop, and the startup progress window did the same whenever a Runtime Host handoff asked for attention. Those calls sat in `workhub-presentation.ts` and `startup-progress-window.ts`, neither of which could see the mode, so the authority existed and simply was not consulted. The already-resolved mode is threaded into both — `WorkHubPresentationDeps` takes it from `runtime-host-boot.ts` alongside the main window controller, and `createStartupProgressWindow` takes it from `startup-presentation.ts`, which resolves it for its own branding decision already. The gate's private `focusNow` becomes the exported `focusWindow`, so the reveal-and-activate rule stays in `window-reveal.ts` with one implementation; `showWindowInactive` covers the reveals that are deliberately quiet even in the product (WorkHub's progress card, the startup window's ordinary appearance) and that only had to learn to stay hidden. No new flag, no second resolution. Production is unchanged: `active` still un-minimizes, shows and focuses at every one of these sites, and the progress card still appears without activating. Hosted CI is unchanged too — those runs are `inactive` on Xvfb, where the window is on screen either way. Only the local foreground behavior changes, and only for a run that already declared itself an E2E run. `browser-message-box.ts` still reveals unconditionally. It is a modal question the app puts to the user; a run that raises one is blocked on an answer rather than quietly stealing focus, so it keeps the product behavior. Generated-by: Claude Code --- .../__tests__/startup-progress-window.test.ts | 36 ++++++++++++++--- .../__tests__/workhub-presentation.test.ts | 39 +++++++++++++++++-- apps/desktop/src/main/runtime-host-boot.ts | 1 + apps/desktop/src/main/startup-presentation.ts | 18 ++++++--- .../src/main/startup-progress-window.ts | 16 ++++---- apps/desktop/src/main/window-reveal.ts | 38 ++++++++++++------ apps/desktop/src/main/workhub-presentation.ts | 13 +++---- 7 files changed, 120 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/main/__tests__/startup-progress-window.test.ts b/apps/desktop/src/main/__tests__/startup-progress-window.test.ts index a432c21663..3c1511776b 100644 --- a/apps/desktop/src/main/__tests__/startup-progress-window.test.ts +++ b/apps/desktop/src/main/__tests__/startup-progress-window.test.ts @@ -23,13 +23,17 @@ import { test } from 'node:test'; import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'; import type { HostHandoffView } from '@maka/runtime-host/client'; import { createStartupProgressWindow, renderStartupProgressHtml } from '../startup-progress-window.js'; +import type { WindowRevealMode } from '../window-reveal.js'; -function harness() { +function harness(revealMode: WindowRevealMode = 'active') { let resolveLoad!: () => void; let rejectLoad!: (error: Error) => void; let destroyed = false; let minimized = false; let visible = false; + let shown = 0; + let shownInactive = 0; + let focused = 0; let copied = 0; let copiedHandoff: HostHandoffView | undefined; let documentUrl = ''; @@ -53,9 +57,10 @@ function harness() { destroy() { destroyed = true; }, minimize() { minimized = true; }, restore() { minimized = false; }, - showInactive() { visible = true; }, - show() { visible = true; }, - focus() {}, + isVisible: () => visible, + showInactive() { shownInactive += 1; visible = true; }, + show() { shown += 1; visible = true; }, + focus() { focused += 1; }, loadURL: (url: string) => { documentUrl = url; return new Promise((resolve, reject) => { @@ -65,7 +70,7 @@ function harness() { }, }); const progress = createStartupProgressWindow({ - locale: 'en', dark: false, icon: '/test/icon.png', + locale: 'en', dark: false, icon: '/test/icon.png', revealMode, createWindow(input) { options = input; return window as unknown as BrowserWindow; }, copyDiagnostics(_phase, handoff) { copied += 1; copiedHandoff = handoff; }, onError(error) { errors.push(error); }, @@ -79,6 +84,7 @@ function harness() { get destroyed() { return destroyed; }, get minimized() { return minimized; }, get visible() { return visible; }, + get reveals() { return { shown, shownInactive, focused }; }, get openWindow() { return openWindow; }, get contentSize() { return contentSize; }, setMeasuredHeight(height: number) { measuredHeight = height; }, @@ -204,3 +210,23 @@ test('live handoff accepts only current allowed actions and copies the current d assert.equal(h.copiedHandoff?.diagnostic, view.diagnostic); h.progress.close(); }); + +test('an automated run never lets a handoff pull the app to the front', async () => { + const attention: HostHandoffView = { revision: 'first', target: { name: 'local', location: 'local' }, + state: 'attention', reason: 'busy', mayExitNaturally: false, actions: ['cancel'], defaultAction: 'cancel' }; + const expected = { + hidden: { shown: 0, shownInactive: 0, focused: 0 }, + inactive: { shown: 0, shownInactive: 1, focused: 0 }, + active: { shown: 3, shownInactive: 0, focused: 3 }, + } as const; + for (const mode of ['hidden', 'inactive', 'active'] as const) { + const h = harness(mode); + h.progress.handoff(attention, () => {}, 'en'); + h.resolveLoad(); await flush(); + h.progress.handoff({ ...attention, revision: 'second', state: 'progress' }, () => {}, 'en'); + h.progress.handoff({ ...attention, revision: 'third' }, () => {}, 'en'); + h.progress.focus(); + assert.deepEqual(h.reveals, expected[mode], mode); + h.progress.close(); + } +}); diff --git a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts index a755cf1465..3f48bedfcd 100644 --- a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts @@ -27,10 +27,11 @@ import { build } from 'esbuild'; import { deferred } from '@maka/core/test-only/async-primitives'; import type { createMainWindowController } from '../main-window.js'; import type { createWorkHubPresentation } from '../workhub-presentation.js'; +import type { WindowRevealMode } from '../window-reveal.js'; const source = fileURLToPath(new URL('../../../src/main/workhub-presentation.ts', import.meta.url)); -async function harness(animate = false, displayFrequency = 60) { +async function harness(animate = false, displayFrequency = 60, revealMode: WindowRevealMode = 'active') { let enabled = true; let mainRequests = 0; let mainAvailable = true; @@ -105,8 +106,10 @@ async function harness(animate = false, displayFrequency = 60) { setBounds(bounds: typeof this.bounds) { this.bounds = bounds; this.emit('resize'); } setVisibleOnAllWorkspaces() {} setMaximizable() {} - show() { this.visible = true; this.emit('show'); } - showInactive() { this.visible = true; } + shown = 0; + shownInactive = 0; + show() { this.shown++; this.visible = true; this.emit('show'); } + showInactive() { this.shownInactive++; this.visible = true; } hide() { this.visible = false; } resizable = true; setResizable(value: boolean) { this.resizable = value; } @@ -147,6 +150,7 @@ async function harness(animate = false, displayFrequency = 60) { const controller = module.exports.createWorkHubPresentation({ mainWindow: () => mainAvailable ? main as unknown as Electron.BrowserWindow : undefined, isEnabled: () => enabled, + revealMode, ensureMainWindow: async () => { mainRequests++; openingStarted.resolve(); await opening; mainAvailable = true; return main as unknown as Electron.BrowserWindow; }, mainModuleDirectory: '/app/dist/main', preloadPath: '/app/dist/preload/preload.cjs', onError: (error) => errors.push(error), @@ -843,3 +847,32 @@ test('late progress measurements and send acknowledgements cannot revive a dismi assert.equal(h.controller.getSnapshot().progressRequest, second); h.controller.dispose(); }); + +const REVEALS = { + hidden: { shown: 0, shownInactive: 0, focused: 0 }, + inactive: { shown: 0, shownInactive: 1, focused: 0 }, + active: { shown: 1, shownInactive: 0, focused: 1 }, +} as const; +const reveals = (win: { shown: number; shownInactive: number; focused: number }) => + ({ shown: win.shown, shownInactive: win.shownInactive, focused: win.focused }); + +test('summoning and docking honor the run reveal mode', async () => { + for (const mode of ['hidden', 'inactive', 'active'] as const) { + const h = await harness(false, 60, mode); + await h.controller.show(); + assert.deepEqual(reveals(h.windows[1]!), REVEALS[mode], `detach in ${mode}`); + await h.command(h.views[0]!.webContents, 'dock'); + assert.deepEqual(reveals(h.main), REVEALS[mode], `dock in ${mode}`); + h.controller.dispose(); + } +}); + +test('the progress card stays hidden in a hidden run and inactive everywhere else', async () => { + for (const mode of ['hidden', 'inactive', 'active'] as const) { + const h = await harness(false, 60, mode); + await h.controller.prepareControl('turn'); + await h.command(h.views[0]!.webContents, 'progress-ready', h.controller.getSnapshot().progressRequest); + assert.deepEqual(reveals(h.windows[1]!), mode === 'hidden' ? REVEALS.hidden : REVEALS.inactive, mode); + h.controller.dispose(); + } +}); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 2242f354c4..1e3838c646 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -919,6 +919,7 @@ const workHubControl = createWorkHubControl({ let workHubEnabled = false; const workHubPresentation = createWorkHubPresentation({ isEnabled: () => workHubEnabled, + revealMode, mainWindow: () => mainWindowController.browserWindow(), ensureMainWindow: async () => { await quitCoordinator.focusOrCreateWindow(); diff --git a/apps/desktop/src/main/startup-presentation.ts b/apps/desktop/src/main/startup-presentation.ts index 9f5a5376db..95df579b62 100644 --- a/apps/desktop/src/main/startup-presentation.ts +++ b/apps/desktop/src/main/startup-presentation.ts @@ -24,7 +24,7 @@ import { readableAppIconPath } from './app-icon-surface.js'; import { installApplicationMenu } from './application-menu.js'; import { installDesktopStartupBranding } from './desktop-shell-presentation.js'; import { isIsolatedE2e } from './startup-context.js'; -import { resolveWindowRevealMode } from './window-reveal.js'; +import { resolveWindowRevealMode, type WindowRevealMode } from './window-reveal.js'; import { createStartupProgressWindow, type StartupPhase, @@ -36,15 +36,19 @@ let handoffUsesStartup = false; const focus = () => progress?.focus(); -/** Called after ready, before importing the asynchronous Runtime Host boot. */ -export function showDesktopStartupProgress( - copyDiagnostics: (phase: StartupPhase) => void | Promise, -): void { - const revealMode = resolveWindowRevealMode( +function startupRevealMode(): WindowRevealMode { + return resolveWindowRevealMode( isIsolatedE2e || Boolean(process.env.MAKA_E2E_FIXTURE), process.env.MAKA_E2E_SHOW_WINDOW === '1', app.isPackaged, ); +} + +/** Called after ready, before importing the asynchronous Runtime Host boot. */ +export function showDesktopStartupProgress( + copyDiagnostics: (phase: StartupPhase) => void | Promise, +): void { + const revealMode = startupRevealMode(); installDesktopStartupBranding(revealMode); // Automated runs retain their one-main-window contract and never steal focus. if (revealMode !== 'active') return; @@ -56,6 +60,7 @@ export function showDesktopStartupProgress( locale: resolveSystemUiLocale(app.getPreferredSystemLanguages()), dark: nativeTheme.shouldUseDarkColors, icon: readableAppIconPath('default'), + revealMode, createWindow: (options) => new BrowserWindow(options), copyDiagnostics: (phase, handoff) => handoff ? clipboard.writeText(JSON.stringify(handoff, null, 2)) : copyDiagnostics(phase), @@ -92,6 +97,7 @@ export function createDesktopHostHandoffSurface(resolveLocale: () => Promise new BrowserWindow(options), copyDiagnostics: () => clipboard.writeText(JSON.stringify(latest, null, 2)), onError: (error) => console.error('[runtime-host] handoff presentation failed:', error), diff --git a/apps/desktop/src/main/startup-progress-window.ts b/apps/desktop/src/main/startup-progress-window.ts index 6bc3c2ac01..1634e69532 100644 --- a/apps/desktop/src/main/startup-progress-window.ts +++ b/apps/desktop/src/main/startup-progress-window.ts @@ -22,6 +22,7 @@ import { MAKA_WORDMARK_PATH } from '@maka/core/maka-wordmark'; import type { UiLocale } from '@maka/core/ui-locale'; import { formatHostHandoff, type HostHandoffView, type HostHandoffAction } from '@maka/runtime-host/client'; import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'; +import { focusWindow, showWindowInactive, type WindowRevealMode } from './window-reveal.js'; export type StartupPhase = | 'prepare' | 'storage' | 'connect' | 'package' @@ -83,6 +84,8 @@ export function createStartupProgressWindow(input: { locale: UiLocale; dark: boolean; icon: string; + /** How far this run may go when the window asks for attention. */ + revealMode: WindowRevealMode; createWindow(options: BrowserWindowConstructorOptions): BrowserWindow; copyDiagnostics(phase: StartupPhase, handoff?: HostHandoffView): void | Promise; onError(error: unknown): void; @@ -174,8 +177,8 @@ export function createStartupProgressWindow(input: { if (closed || win.isDestroyed()) return; loaded = true; publish(); - if (handoff?.view.state === 'attention') { win.show(); win.focus(); } - else win.showInactive(); + if (handoff?.view.state === 'attention') focusWindow(win, input.revealMode); + else showWindowInactive(win, input.revealMode); }).catch((error) => { input.onError(error); handoff?.submit(handoff.view.revision, 'cancel'); @@ -188,10 +191,7 @@ export function createStartupProgressWindow(input: { const needsAttention = handoff?.view.state !== 'attention' && view.state === 'attention'; handoff = { view, submit, locale }; publish(); - if (loaded && needsAttention) { - if (win.isMinimized()) win.restore(); - win.show(); win.focus(); - } + if (loaded && needsAttention) focusWindow(win, input.revealMode); }, clearHandoff() { handoff = undefined; @@ -199,9 +199,7 @@ export function createStartupProgressWindow(input: { }, focus() { if (closed || !loaded || win.isDestroyed()) return; - if (win.isMinimized()) win.restore(); - win.show(); - win.focus(); + focusWindow(win, input.revealMode); }, close, window: () => closed || win.isDestroyed() ? undefined : win, diff --git a/apps/desktop/src/main/window-reveal.ts b/apps/desktop/src/main/window-reveal.ts index 8be37cd444..0e1c1f91ec 100644 --- a/apps/desktop/src/main/window-reveal.ts +++ b/apps/desktop/src/main/window-reveal.ts @@ -89,6 +89,15 @@ export function showWindowOnceReady(win: RevealableWindow | null, mode: WindowRe else win.show(); } +/** + * Reveal without activating, whatever the mode — for a window whose reveal is + * deliberately quiet even in the product (WorkHub's progress card, the startup + * progress window). `hidden` still shows nothing. + */ +export function showWindowInactive(win: RevealableWindow | null, mode: WindowRevealMode): void { + showWindowOnceReady(win, mode === 'hidden' ? 'hidden' : 'inactive'); +} + /** Focus surface for deferred focus requests (see createWindowRevealGate). */ export interface FocusableRevealableWindow extends RevealableWindow { isMinimized(): boolean; @@ -97,6 +106,23 @@ export interface FocusableRevealableWindow extends RevealableWindow { maximize(): void; } +/** + * Reveal `win` and take the foreground, as far as `mode` allows: `active` + * un-minimizes, shows and focuses; `inactive` answers a focus request with a + * reveal and nothing more; `hidden` does nothing at all. + */ +export function focusWindow(win: FocusableRevealableWindow | null, mode: WindowRevealMode): void { + if (mode === 'hidden') return; + if (!win || win.isDestroyed()) return; + if (mode === 'inactive') { + showWindowOnceReady(win, mode); + return; + } + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); +} + export interface WindowRevealGate { /** Re-arm for a freshly created window (macOS recreate after close-all). */ reset(): void; @@ -138,17 +164,7 @@ export function createWindowRevealGate(mode: WindowRevealMode): WindowRevealGate let pendingFocus = false; let pendingMaximize = false; - const focusNow = (win: FocusableRevealableWindow | null): void => { - if (mode === 'hidden') return; - if (!win || win.isDestroyed()) return; - if (mode === 'inactive') { - showWindowOnceReady(win, mode); - return; - } - if (win.isMinimized()) win.restore(); - win.show(); - win.focus(); - }; + const focusNow = (win: FocusableRevealableWindow | null): void => focusWindow(win, mode); const maximizeNow = (win: FocusableRevealableWindow | null): void => { if (mode === 'hidden') return; diff --git a/apps/desktop/src/main/workhub-presentation.ts b/apps/desktop/src/main/workhub-presentation.ts index 982abe97db..425a84d0a9 100644 --- a/apps/desktop/src/main/workhub-presentation.ts +++ b/apps/desktop/src/main/workhub-presentation.ts @@ -22,6 +22,7 @@ import type { WorkHubHost, WorkHubMainNavigation, WorkHubPresentationSnapshot } import { parseDesktopSessionKey } from '../shared/runtime-host-identity.js'; import { loadMainRenderer, resolveMainRendererEntry } from './main-renderer-loader.js'; import { installMainWindowPermissionPolicy } from './main-window-permission-policy.js'; +import { focusWindow, showWindowInactive, type WindowRevealMode } from './window-reveal.js'; const COMMAND = 'workhub-presentation:command'; const SHORTCUT = 'CommandOrControl+Shift+K'; @@ -32,6 +33,8 @@ export interface WorkHubPresentationDeps { ensureMainWindow(): Promise; /** Applied client settings; showing the window must not wait for storage. */ isEnabled(): boolean; + /** How far this run may go when a WorkHub command reveals a window. */ + revealMode: WindowRevealMode; mainModuleDirectory: string; viteDevServerUrl?: string; preloadPath: string; @@ -336,9 +339,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { const current = target.getBounds(); if (bounds.x !== current.x || bounds.y !== current.y || bounds.width !== current.width || bounds.height !== current.height) target.setBounds(bounds); fitFloating(); - if (target.isMinimized()) target.restore(); - target.show(); - target.focus(); + focusWindow(target, deps.revealMode); focusComposer(); changed(); } @@ -390,9 +391,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { if (disposed) throw new Error('WorkHub presentation is disposed'); if (revision !== presentationRevision || (navigation.kind === 'workhub' && !deps.isEnabled())) return; attachMainWindow(main); - if (main.isMinimized()) main.restore(); - main.show(); - main.focus(); + focusWindow(main, deps.revealMode); if (mainReady.has(main.webContents)) main.webContents.send('workhub-presentation:open-main', navigation); else pendingNavigation.set(main.webContents, { navigation, revision }); return main; @@ -515,7 +514,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { if (isMain) throw new Error('Only the WorkHub view can present its progress'); if (typeof payload !== 'number' || !Number.isSafeInteger(payload)) throw new Error('Invalid progress request'); if (payload === progressRequest && payload === presentationRevision && deps.isEnabled()) { - floating?.showInactive(); + showWindowInactive(floating ?? null, deps.revealMode); view?.webContents.setBackgroundThrottling(true); changed(); } From 67ce2ad603089c19505083a95ccbc2e25db67f18 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 00:48:08 +0800 Subject: [PATCH 2/2] fix(desktop): reveal the startup message box through the reveal authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `presentBrowserMessageBox` finished its load by calling `win.show()` and `win.focus()` directly, the last reveal in the main process outside `window-reveal.ts`. Two callers reach it before the reveal mode is otherwise consulted: the dev single-instance loser dialog (`shouldShowLoserDialog` does not look at the e2e flags at all) and the fatal-startup diagnostic box. In a `hidden` run both put a real window on screen, which is exactly what the rest of this PR removes. The dialog now takes the mode as part of its appearance and reveals through `focusWindow`. `BrowserMessageBoxAppearance` splits into the theme the rendered document consumes (locale, palette, dark) and the reveal mode, so the HTML builder keeps its narrow input and every caller of the window path has to answer the reveal question. `main.ts` supplies `startupRevealMode()`, which is now exported: all three of its inputs, `app.isPackaged` included, read correctly before ready. `runtime-host-boot.ts` supplies the mode it already resolved, so its two appearance producers stay theme-only. `hidden` is the one mode that changes an outcome rather than only a reveal: an unrevealed dialog has nobody to answer it, so it settles as its cancel id instead of leaving the caller pending forever — the loser instance would otherwise never reach its `app.exit`. In production the mode is `active` and the only difference is the `isDestroyed()`/`isMinimized()` guards `focusWindow` adds, neither of which can fire on a dialog that was just created and loaded. The reviewer also flagged `showInactive()` in cursor-overlay-window.ts, pip-window.ts and permission-overlay-controller.ts. Those are left alone: no current spec reaches them, and an inactive reveal cannot take the foreground, which is the property this PR defends. Generated-by: Claude Code --- .../__tests__/browser-message-box.test.ts | 48 +++++++++++++++++-- apps/desktop/src/main/browser-message-box.ts | 19 ++++++-- apps/desktop/src/main/main.ts | 8 +++- apps/desktop/src/main/runtime-host-boot.ts | 8 ++-- apps/desktop/src/main/startup-presentation.ts | 7 ++- 5 files changed, 74 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/__tests__/browser-message-box.test.ts b/apps/desktop/src/main/__tests__/browser-message-box.test.ts index 488c2c66f8..cbb8e717f8 100644 --- a/apps/desktop/src/main/__tests__/browser-message-box.test.ts +++ b/apps/desktop/src/main/__tests__/browser-message-box.test.ts @@ -36,6 +36,8 @@ import { showBrowserMessageBoxWithRuntime, } from '../browser-message-box.js'; +const ACTIVE_APPEARANCE = { locale: 'en', revealMode: 'active' } as const; + test('falls back natively and never attaches to an inaccessible parent', async () => { const options = { message: 'Recover Maka' }; const nativeResult = { response: 0, checkboxChecked: false }; @@ -65,7 +67,7 @@ test('falls back natively and never attaches to an inaccessible parent', async ( const failedWindow = fakeBrowserWindow({ loadError: failure }); let reported: unknown; assert.equal( - await showBrowserMessageBoxWithRuntime(options, parent, { locale: 'en' }, { + await showBrowserMessageBoxWithRuntime(options, parent, ACTIVE_APPEARANCE, { ...runtimeBase, createWindow: (windowOptions) => { assert.equal(windowOptions.parent, parent); @@ -97,7 +99,7 @@ test('drives the BrowserWindow lifecycle through a safe response URL', async () const presentation = showBrowserMessageBoxWithRuntime( { message: 'Recover Maka', buttons: ['Recover', 'Cancel'], cancelId: 1 }, parent, - { locale: 'en', dark: true }, + { ...ACTIVE_APPEARANCE, dark: true }, { shouldUseDarkColors: false, createWindow: (options) => { @@ -143,7 +145,7 @@ test('maps close to cancel and falls back after each BrowserWindow presentation const closeResult = showBrowserMessageBoxWithRuntime( { message: 'Recover Maka', buttons: ['Recover', 'Cancel'], cancelId: 1 }, undefined, - { locale: 'en' }, + ACTIVE_APPEARANCE, runtimeForWindow(closed), ); closed.window.emit('closed'); @@ -158,7 +160,7 @@ test('maps close to cancel and falls back after each BrowserWindow presentation const result = showBrowserMessageBoxWithRuntime( { message: 'Recover Maka' }, undefined, - { locale: 'en' }, + ACTIVE_APPEARANCE, runtimeForWindow(presented, { presentationTimeoutMs: scenario === 'timeout' ? 1 : undefined, onBrowserError: (error) => errors.push(error), @@ -177,6 +179,34 @@ test('maps close to cancel and falls back after each BrowserWindow presentation } }); +test('reveals the dialog only as far as the run reveal mode allows', async () => { + const active = fakeBrowserWindow(); + const shownResult = showBrowserMessageBoxWithRuntime( + { message: 'Recover Maka', buttons: ['Recover', 'Cancel'], cancelId: 1 }, + undefined, + ACTIVE_APPEARANCE, + runtimeForWindow(active), + ); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(active.shown(), true); + assert.equal(active.focused(), true); + active.window.emit('closed'); + await shownResult; + + const hidden = fakeBrowserWindow(); + const hiddenResult = await showBrowserMessageBoxWithRuntime( + { message: 'Recover Maka', buttons: ['Recover', 'Cancel'], cancelId: 1 }, + undefined, + { locale: 'en', revealMode: 'hidden' }, + runtimeForWindow(hidden), + ); + assert.equal(hidden.shown(), false); + assert.equal(hidden.shownInactive(), false); + assert.equal(hidden.focused(), false); + // Nobody can answer a dialog that was never revealed. + assert.deepEqual(hiddenResult, { response: 1, checkboxChecked: false }); +}); + test('accepts only an in-range response URL produced by the dialog', () => { assert.equal(parseBrowserMessageBoxResponse('maka-dialog://response/1', 3), 1); for (const value of [ @@ -295,6 +325,7 @@ interface FakeBrowserWindow { options?: BrowserWindowConstructorOptions; loadedUrl(): string; shown(): boolean; + shownInactive(): boolean; focused(): boolean; destroyed(): boolean; deniesWindowOpen(): boolean; @@ -306,6 +337,7 @@ function fakeBrowserWindow(input: { } = {}): FakeBrowserWindow { let loadedUrl = ''; let shown = false; + let shownInactive = false; let focused = false; let destroyed = false; let deniesWindowOpen = false; @@ -331,9 +363,16 @@ function fakeBrowserWindow(input: { destroyed = true; }, setBounds() {}, + isVisible: () => shown || shownInactive, + isMinimized: () => false, + restore() {}, + maximize() {}, show: () => { shown = true; }, + showInactive: () => { + shownInactive = true; + }, focus: () => { focused = true; }, @@ -343,6 +382,7 @@ function fakeBrowserWindow(input: { webContents, loadedUrl: () => loadedUrl, shown: () => shown, + shownInactive: () => shownInactive, focused: () => focused, destroyed: () => destroyed, deniesWindowOpen: () => deniesWindowOpen, diff --git a/apps/desktop/src/main/browser-message-box.ts b/apps/desktop/src/main/browser-message-box.ts index 0c5519dc96..d3f1af108b 100644 --- a/apps/desktop/src/main/browser-message-box.ts +++ b/apps/desktop/src/main/browser-message-box.ts @@ -32,6 +32,7 @@ import type { Rectangle, } from 'electron'; import { resolveOverlayAssetDir } from './overlay-assets.js'; +import { focusWindow, type WindowRevealMode } from './window-reveal.js'; const RESPONSE_URL_PREFIX = 'maka-dialog://response/'; const DIALOG_WIDTH = 520; @@ -43,12 +44,18 @@ const DIALOG_DESIGN_TOKENS_FILE = 'browser-dialog-design-tokens.css'; let cachedDialogDesignTokens: string | undefined; let activeBrowserMessageBoxPresentations = 0; -export interface BrowserMessageBoxAppearance { +/** Everything the rendered dialog document needs; nothing about revealing it. */ +export interface BrowserMessageBoxTheme { readonly locale: UiLocale; readonly palette?: ThemePalette; readonly dark?: boolean; } +export interface BrowserMessageBoxAppearance extends BrowserMessageBoxTheme { + /** How far this run may go when the dialog asks to be seen. */ + readonly revealMode: WindowRevealMode; +} + export interface BrowserMessageBoxRuntime { readonly shouldUseDarkColors: boolean; readonly createWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow; @@ -223,9 +230,11 @@ async function presentBrowserMessageBox( true, ); if (settled || win.isDestroyed()) return; - win.show(); - win.focus(); + focusWindow(win, appearance.revealMode); clearPresentationTimeout(); + // A run that may not reveal the dialog has nobody to answer it. + // Settle it as a cancel rather than leave the caller pending forever. + if (appearance.revealMode === 'hidden') finish(presentation.cancelId); }) .catch(fail); }); @@ -249,7 +258,7 @@ interface BrowserMessageBoxPresentation { function normalizeBrowserMessageBoxPresentation( options: MessageBoxOptions, - appearance: BrowserMessageBoxAppearance & { readonly dark: boolean }, + appearance: BrowserMessageBoxTheme & { readonly dark: boolean }, ): BrowserMessageBoxPresentation { const buttons = options.buttons?.length ? [...options.buttons] : ['OK']; const cancelId = validButtonId(options.cancelId, buttons.length) ? options.cancelId : 0; @@ -340,7 +349,7 @@ export function parseBrowserMessageBoxResponse( export function buildBrowserMessageBoxHtml( options: MessageBoxOptions, - appearance: BrowserMessageBoxAppearance & { readonly dark: boolean }, + appearance: BrowserMessageBoxTheme & { readonly dark: boolean }, ): string { return renderBrowserMessageBoxHtml( normalizeBrowserMessageBoxPresentation(options, appearance), diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index b90ce61c82..18c412193a 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -49,6 +49,7 @@ import { registerPreviousMainProcessDiagnosticsIpc } from './desktop-diagnostics import { showBrowserMessageBox } from './browser-message-box.js'; import { showDesktopStartupProgress, + startupRevealMode, updateDesktopStartupProgress, desktopStartupProgressWindow, } from './startup-presentation.js'; @@ -124,7 +125,7 @@ if (!app.requestSingleInstanceLock()) { cancelId: 0, }, undefined, - { locale }, + { locale, revealMode: startupRevealMode() }, ); }) .catch((error) => { @@ -249,7 +250,10 @@ if (!app.requestSingleInstanceLock()) { mainLogs: () => mainProcessLogBuffer.snapshot(), writeClipboard: (report) => clipboard.writeText(report), showMessageBox: (options) => - showBrowserMessageBox(options, desktopStartupProgressWindow(), { locale }), + showBrowserMessageBox(options, desktopStartupProgressWindow(), { + locale, + revealMode: startupRevealMode(), + }), }); } } finally { diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 1e3838c646..a82669b29a 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -105,7 +105,7 @@ import { releaseBrowserSession } from "./browser/session.js"; import { isBrowserMessageBoxPresentationActive, showBrowserMessageBox, - type BrowserMessageBoxAppearance, + type BrowserMessageBoxTheme, } from "./browser-message-box.js"; import { createE2eFixtureBotOnboardingAdapters } from "./bot-onboarding-e2e-fixture.js"; import { resolveBuildInfo } from "./build-info.js"; @@ -380,16 +380,16 @@ const desktopDiagnostics: DesktopDiagnosticsDeps = { writeClipboard: (report) => clipboard.writeText(report), }; let resolveBrowserDialogParent = desktopStartupProgressWindow; -let resolveBrowserDialogAppearance = async (): Promise => ({ +let resolveBrowserDialogAppearance = async (): Promise => ({ locale: resolveSystemUiLocale(app.getPreferredSystemLanguages()), palette: "default", }); async function showDesktopMessageBox( options: MessageBoxOptions, - override?: Partial, + override?: Partial, ): Promise { - const appearance = { ...(await resolveBrowserDialogAppearance()), ...override }; + const appearance = { ...(await resolveBrowserDialogAppearance()), ...override, revealMode }; return showBrowserMessageBox(options, resolveBrowserDialogParent(), appearance); } diff --git a/apps/desktop/src/main/startup-presentation.ts b/apps/desktop/src/main/startup-presentation.ts index 95df579b62..1bb265e57b 100644 --- a/apps/desktop/src/main/startup-presentation.ts +++ b/apps/desktop/src/main/startup-presentation.ts @@ -36,7 +36,12 @@ let handoffUsesStartup = false; const focus = () => progress?.focus(); -function startupRevealMode(): WindowRevealMode { +/** + * The run's reveal mode as it reads before the Runtime Host boot resolves its + * own copy. Every input is available pre-ready (`app.isPackaged` included), so + * a dialog raised during startup can consult the same answer the windows do. + */ +export function startupRevealMode(): WindowRevealMode { return resolveWindowRevealMode( isIsolatedE2e || Boolean(process.env.MAKA_E2E_FIXTURE), process.env.MAKA_E2E_SHOW_WINDOW === '1',