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/__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 60d2615c5c..d8e7a6565f 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), @@ -879,3 +883,34 @@ 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.hidden, `a cold summon reveals nothing in ${mode}`); + await h.command(h.views[0]!.webContents, 'ready'); + 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/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 2242f354c4..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); } @@ -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..1bb265e57b 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,24 @@ 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( +/** + * 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', 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 +65,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 +102,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 29e8091c72..5d5f613953 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; @@ -101,11 +104,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { if (!view || view.webContents.isDestroyed() || !rendererReady || !parent || parent.isDestroyed()) return; // A cold summon stays hidden until the renderer has mounted its composer. // Reuse focusPending so hide/disable can cancel it before ready arrives. - if (placement === 'floating' && progressRequest === undefined) { - if (parent.isMinimized()) parent.restore(); - parent.show(); - parent.focus(); - } + if (placement === 'floating' && progressRequest === undefined) focusWindow(parent, deps.revealMode); if (!parent.isVisible()) return; if (placement === 'docked' && (!host.visible || host.occluded)) return; view.webContents.focus(); @@ -395,9 +394,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; @@ -520,7 +517,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(); }