Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ import {
tryAcquireInteractiveRootOwner,
} from '@maka/storage/root-authority';
import { openInteractiveRuntimePolicyStoresForWrite } from '@maka/storage/runtime-policy-stores';
import { buildFixtureEnv, isCiLinuxDisplay } from '../../../scripts/fixture-env.mjs';
import {
buildFixtureEnv,
inactiveWindowPlatformArgs,
isCiLinuxDisplay,
} from '../../../scripts/fixture-env.mjs';
import { closeElectronApplication } from '../../../scripts/electron-lifecycle.mjs';

const DESKTOP_ROOT = process.cwd();
Expand Down Expand Up @@ -457,17 +461,20 @@ async function withE2eWindow(
// Legacy E2E specs assert Chinese labels and should not inherit the CI
// host locale. E2e-fixture workspaces use the explicit renderer override.
if (locale && !e2eFixtureScenario) await seedE2eLocale(userDataDir, locale);
// xvfb throttles a hidden window's compositor to ~1fps. Geometry fixtures
// opt in locally; every fixture is visible on isolated CI X.
const visibleWindow = showWindow || isCiLinuxDisplay();
app = await electron.launch({
args: ['.'],
// A visible fixture window is revealed inactively, which needs XWayland
// on a native Wayland session.
args: ['.', ...(visibleWindow ? inactiveWindowPlatformArgs() : [])],
cwd: DESKTOP_ROOT,
env: buildFixtureEnv(userDataDir, homeDir, {
scenario: e2eFixtureScenario,
locale,
platform,
scrollMotion,
// xvfb throttles a hidden window's compositor to ~1fps. Geometry
// fixtures opt in locally; every fixture is visible on isolated CI X.
showWindow: showWindow || isCiLinuxDisplay(),
showWindow: visibleWindow,
}),
});
app.on('console', (message) => {
Expand Down Expand Up @@ -525,7 +532,11 @@ async function setPromptRailWindowVisible(
await worker.app.evaluate(({ BrowserWindow }, shouldShow) => {
const window = BrowserWindow.getAllWindows()[0];
if (!window) throw new Error('the prompt-rail BrowserWindow is missing');
if (shouldShow) window.show();
// showInactive, not show: this worker window is re-revealed between every
// test in the file, and show() activates the app each time — a suite run
// would yank the developer's foreground away a dozen times over. The
// window still needs to be on screen for the compositor.
if (shouldShow) window.showInactive();
else window.hide();
}, visible);
}
Expand Down
185 changes: 185 additions & 0 deletions apps/desktop/src/main/__tests__/window-reveal-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { resolveDockPresentation } from '../dock-presentation.js';
import {
createWindowRevealGate,
resolveWindowRevealMode,
showWindowOnceReady,
type FocusableRevealableWindow,
} from '../window-reveal.js';

/**
* Fake BrowserWindow that records every call the reveal gate makes, so
* "revealed but never activated" can be asserted without an Electron runtime.
*/
function fakeWindow(): FocusableRevealableWindow & {
calls: string[];
visible: boolean;
destroyed: boolean;
minimized: boolean;
} {
const win = {
calls: [] as string[],
visible: false,
destroyed: false,
minimized: false,
isDestroyed: () => win.destroyed,
isVisible: () => win.visible,
isMinimized: () => win.minimized,
show() {
win.calls.push('show');
win.visible = true;
},
showInactive() {
win.calls.push('showInactive');
win.visible = true;
},
restore() {
win.calls.push('restore');
win.minimized = false;
},
focus() {
win.calls.push('focus');
},
maximize() {
win.calls.push('maximize');
win.visible = true;
},
};
return win;
}

describe('resolveWindowRevealMode', () => {
it('leaves a product run active', () => {
assert.equal(resolveWindowRevealMode(false, false, false), 'active');
// A stray MAKA_E2E_SHOW_WINDOW outside an E2E run changes nothing.
assert.equal(resolveWindowRevealMode(false, true, false), 'active');
});

it('hides an E2E run that did not ask for a window', () => {
assert.equal(resolveWindowRevealMode(true, false, false), 'hidden');
});

it('gives an E2E run that asked for a window pixels, not focus', () => {
assert.equal(resolveWindowRevealMode(true, true, false), 'inactive');
});

it('ignores a stray E2E flag in a packaged build', () => {
// Both consumers read this one answer, so a packaged build cannot end up
// with a window that takes focus and a dock that hides its tile.
assert.equal(resolveWindowRevealMode(true, false, true), 'active');
assert.equal(resolveWindowRevealMode(true, true, true), 'active');
assert.equal(
resolveDockPresentation('darwin', resolveWindowRevealMode(true, true, true)),
'icon',
);
});
});

describe('resolveDockPresentation', () => {
it('has no dock off macOS', () => {
for (const mode of ['hidden', 'inactive', 'active'] as const) {
assert.equal(resolveDockPresentation('win32', mode), 'none');
assert.equal(resolveDockPresentation('linux', mode), 'none');
}
});

it('shows the brand mark only for a product run', () => {
assert.equal(resolveDockPresentation('darwin', 'active'), 'icon');
});

it('stays an accessory app for every E2E run, visible window included', () => {
assert.equal(resolveDockPresentation('darwin', 'hidden'), 'hide');
assert.equal(resolveDockPresentation('darwin', 'inactive'), 'hide');
});
});

describe('showWindowOnceReady', () => {
it('reveals an active run with show()', () => {
const win = fakeWindow();
showWindowOnceReady(win, 'active');
assert.deepEqual(win.calls, ['show']);
});

it('reveals an inactive run without activating the app', () => {
const win = fakeWindow();
showWindowOnceReady(win, 'inactive');
assert.deepEqual(win.calls, ['showInactive']);
});

it('never reveals a hidden run', () => {
const win = fakeWindow();
showWindowOnceReady(win, 'hidden');
assert.deepEqual(win.calls, []);
});

it('ignores a destroyed or already visible window', () => {
const destroyed = fakeWindow();
destroyed.destroyed = true;
showWindowOnceReady(destroyed, 'inactive');
assert.deepEqual(destroyed.calls, []);

const shown = fakeWindow();
shown.visible = true;
showWindowOnceReady(shown, 'inactive');
assert.deepEqual(shown.calls, []);
});
});

describe('createWindowRevealGate', () => {
it('flushes a deferred focus request as a real activation for a product run', () => {
const gate = createWindowRevealGate('active');
const win = fakeWindow();
gate.requestFocus(win);
assert.deepEqual(win.calls, []);
gate.markReady(win);
assert.deepEqual(win.calls, ['show', 'show', 'focus']);
});

it('answers a focus request on an inactive run with a reveal and nothing more', () => {
const gate = createWindowRevealGate('inactive');
const win = fakeWindow();
gate.requestFocus(win);
gate.markReady(win);
assert.deepEqual(win.calls, ['showInactive']);
// A focus request after readiness must not raise the app either.
gate.requestFocus(win);
assert.deepEqual(win.calls, ['showInactive']);
});

it('reveals inactively before maximizing, so the maximize cannot raise the app', () => {
const gate = createWindowRevealGate('inactive');
const win = fakeWindow();
gate.requestMaximize(win);
gate.markReady(win);
assert.deepEqual(win.calls, ['showInactive', 'maximize']);
});

it('keeps a hidden run hidden on every path', () => {
const gate = createWindowRevealGate('hidden');
const win = fakeWindow();
gate.requestFocus(win);
gate.requestMaximize(win);
gate.markReady(win);
assert.deepEqual(win.calls, []);
});
});
5 changes: 3 additions & 2 deletions apps/desktop/src/main/desktop-shell-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ import { applyAppIcon } from './app-icon-surface.js';
import { installApplicationMenu } from './application-menu.js';
import { resolveDockPresentation } from './dock-presentation.js';
import type { createMainWindowController } from './main-window.js';
import type { WindowRevealMode } from './window-reveal.js';

interface DesktopShellPresentationDeps {
readonly startHidden: boolean;
readonly revealMode: WindowRevealMode;
readonly mainWindowController: ReturnType<typeof createMainWindowController>;
readonly focusOrCreateWindow: () => void;
readonly onIconError: (error: unknown) => void;
Expand All @@ -36,7 +37,7 @@ export function installDesktopShellPresentation(
): void {
const dockPresentation = resolveDockPresentation(
process.platform,
deps.startHidden,
deps.revealMode,
);
if (app.dock) {
if (dockPresentation === 'hide') {
Expand Down
16 changes: 11 additions & 5 deletions apps/desktop/src/main/dock-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,21 @@
* under the License.
*/

import type { WindowRevealMode } from './window-reveal.js';

/**
* What the macOS dock should show for this run.
*
* Its own module, free of an `electron` import, so the rule can be tested
* without launching Electron. The branch this replaced lived inside
* `app.whenReady()` and keyed off a re-derivation of the start-hidden
* condition that had already drifted from the real one — it missed the
* `MAKA_E2E_SHOW_WINDOW` escape hatch, so a window a developer explicitly
* asked to see still launched as an accessory app.
* condition that had already drifted from the real one.
*
* Keyed off the run's reveal mode, not off "does a window appear": an E2E run
* that asks for a visible window (`MAKA_E2E_SHOW_WINDOW`) wants pixels, not
* the foreground — the compositor throttles a hidden window and geometry
* assertions need a real layout, and neither needs a dock tile. Only a real
* `active` run gets one.
*
* - `hide`: run as an accessory app. No dock tile, no dock bounce, and it
* never becomes frontmost, so a capture or E2E run cannot steal focus from
Expand All @@ -37,8 +43,8 @@
*/
export function resolveDockPresentation(
platform: NodeJS.Platform,
startHidden: boolean,
revealMode: WindowRevealMode,
): 'hide' | 'icon' | 'none' {
if (platform !== 'darwin') return 'none';
return startHidden ? 'hide' : 'icon';
return revealMode === 'active' ? 'icon' : 'hide';
}
21 changes: 10 additions & 11 deletions apps/desktop/src/main/main-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
reloadMainRendererProcess,
} from './main-renderer-process-gone.js';
import { isDarkAppearance, isThemePreference, toNativeThemeSource } from './theme-source.js';
import { createWindowRevealGate } from './window-reveal.js';
import { createWindowRevealGate, type WindowRevealMode } from './window-reveal.js';
import { createWindowsMaximizeRendererSync } from './windows-maximize-renderer-sync.js';
import {
parseDesktopSessionResourceKey,
Expand Down Expand Up @@ -97,7 +97,7 @@ interface MainWindowControllerDeps {
settingsStore: SettingsReader;
// main.ts computes this from the same isE2e gate that also guards userData
// and the fake backend, so main-window.ts owns no env policy of its own.
startHidden: boolean;
revealMode: WindowRevealMode;
onClose?: () => void;
onRendererProcessGone: (details: Electron.RenderProcessGoneDetails) => void | Promise<void>;
}
Expand Down Expand Up @@ -165,22 +165,21 @@ const titleBarOverlayOptions = (
});

export function createMainWindowController(deps: MainWindowControllerDeps): MainWindowController {
const { workspaceRoot, e2eFixture, settingsStore, startHidden } = deps;
const { workspaceRoot, e2eFixture, settingsStore } = deps;
const liveBrowserScopes = new Map<string, { hostId: string; targetEpoch: string }>();

// PR-SHOW-AFTER-FIRST-COMMIT: windows launched hidden (startHidden covers
// PR-SHOW-AFTER-FIRST-COMMIT: windows launched hidden (`hidden` covers
// e2e-fixture capture and E2E — see main.ts) must never be revealed;
// e2e-fixture captures run on the hidden window and E2E drives it headless.
// `!app.isPackaged` mirrors the original creation-time gate so a packaged
// build ignores a stray startHidden flag. The fallback timer, the
// renderer-ready IPC, and focus() all route their show() through this
// predicate via the reveal gate below.
const keepHiddenForE2eFixture = !app.isPackaged && startHidden;
// A run that asked for a visible window is `inactive`: it reveals, but never
// activates the app. The fallback timer, the renderer-ready IPC, and focus()
// all route their show() through this mode via the reveal gate below.
const revealMode: WindowRevealMode = deps.revealMode;
// ChatGPT Pro review P2: focus() (second-instance / activate) used to call
// mainWindow.show() directly, bypassing the reveal gate — re-launching or
// clicking the dock icon during the pre-commit window would flash the
// skeleton anyway. The gate defers those focus requests until markReady.
const revealGate = createWindowRevealGate(keepHiddenForE2eFixture);
const revealGate = createWindowRevealGate(revealMode);
let showFallbackTimer: NodeJS.Timeout | undefined;
let rendererRecoveryReadiness:
| {
Expand All @@ -197,7 +196,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main
};
const armShowFallbackTimer = (target: BrowserWindow): void => {
clearShowFallbackTimer();
if (keepHiddenForE2eFixture || target.isDestroyed() || target.isVisible()) return;
if (revealMode === 'hidden' || target.isDestroyed() || target.isVisible()) return;
showFallbackTimer = setTimeout(() => {
showFallbackTimer = undefined;
if (!target.isDestroyed()) revealGate.markReady(target);
Expand Down
13 changes: 8 additions & 5 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ import {
type ReconnectableReadIpcMain,
} from "./ipc-reconnect-policy.js";
import { createMainWindowController } from "./main-window.js";
import { resolveWindowRevealMode } from "./window-reveal.js";
import type { DesktopRuntimeHostIdentity } from "../preload/bridge-contract.js";
import {
captureDesktopDiagnosticEnvironment,
Expand Down Expand Up @@ -472,15 +473,17 @@ function ensureMcpReady(): Promise<void> {
return mcpStartup;
}
const keepSystemAwake = createKeepSystemAwakeController(powerSaveBlocker);
const startHidden =
(Boolean(e2eFixture) || isIsolatedE2e) &&
process.env.MAKA_E2E_SHOW_WINDOW !== "1";
const revealMode = resolveWindowRevealMode(
Boolean(e2eFixture) || isIsolatedE2e,
process.env.MAKA_E2E_SHOW_WINDOW === "1",
app.isPackaged,
);
let onMainWindowClose = (): void => {};
const mainWindowController = createMainWindowController({
workspaceRoot,
e2eFixture,
settingsStore,
startHidden,
revealMode,
onClose: () => onMainWindowClose(),
onRendererProcessGone: async (details) => {
const diagnosticInput = createDesktopMainRendererDiagnosticInput({
Expand Down Expand Up @@ -1896,7 +1899,7 @@ function wireLifecycle(): void {
resumeQuit: () => app.quit(),
});
installDesktopShellPresentation({
startHidden,
revealMode,
mainWindowController,
focusOrCreateWindow: quitCoordinator.focusOrCreateWindow,
onIconError: (error) =>
Expand Down
Loading