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
48 changes: 44 additions & 4 deletions apps/desktop/src/main/__tests__/browser-message-box.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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');
Expand All @@ -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),
Expand All @@ -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<void>((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 [
Expand Down Expand Up @@ -295,6 +325,7 @@ interface FakeBrowserWindow {
options?: BrowserWindowConstructorOptions;
loadedUrl(): string;
shown(): boolean;
shownInactive(): boolean;
focused(): boolean;
destroyed(): boolean;
deniesWindowOpen(): boolean;
Expand All @@ -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;
Expand All @@ -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;
},
Expand All @@ -343,6 +382,7 @@ function fakeBrowserWindow(input: {
webContents,
loadedUrl: () => loadedUrl,
shown: () => shown,
shownInactive: () => shownInactive,
focused: () => focused,
destroyed: () => destroyed,
deniesWindowOpen: () => deniesWindowOpen,
Expand Down
36 changes: 31 additions & 5 deletions apps/desktop/src/main/__tests__/startup-progress-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand All @@ -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<void>((resolve, reject) => {
Expand All @@ -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); },
Expand All @@ -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; },
Expand Down Expand Up @@ -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();
}
});
41 changes: 38 additions & 3 deletions apps/desktop/src/main/__tests__/workhub-presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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();
}
});
19 changes: 14 additions & 5 deletions apps/desktop/src/main/browser-message-box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
});
Expand All @@ -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;
Expand Down Expand Up @@ -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),
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -124,7 +125,7 @@ if (!app.requestSingleInstanceLock()) {
cancelId: 0,
},
undefined,
{ locale },
{ locale, revealMode: startupRevealMode() },
);
})
.catch((error) => {
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 5 additions & 4 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -380,16 +380,16 @@ const desktopDiagnostics: DesktopDiagnosticsDeps = {
writeClipboard: (report) => clipboard.writeText(report),
};
let resolveBrowserDialogParent = desktopStartupProgressWindow;
let resolveBrowserDialogAppearance = async (): Promise<BrowserMessageBoxAppearance> => ({
let resolveBrowserDialogAppearance = async (): Promise<BrowserMessageBoxTheme> => ({
locale: resolveSystemUiLocale(app.getPreferredSystemLanguages()),
palette: "default",
});

async function showDesktopMessageBox(
options: MessageBoxOptions,
override?: Partial<BrowserMessageBoxAppearance>,
override?: Partial<BrowserMessageBoxTheme>,
): Promise<MessageBoxReturnValue> {
const appearance = { ...(await resolveBrowserDialogAppearance()), ...override };
const appearance = { ...(await resolveBrowserDialogAppearance()), ...override, revealMode };
return showBrowserMessageBox(options, resolveBrowserDialogParent(), appearance);
}

Expand Down Expand Up @@ -919,6 +919,7 @@ const workHubControl = createWorkHubControl({
let workHubEnabled = false;
const workHubPresentation = createWorkHubPresentation({
isEnabled: () => workHubEnabled,
revealMode,
mainWindow: () => mainWindowController.browserWindow(),
ensureMainWindow: async () => {
await quitCoordinator.focusOrCreateWindow();
Expand Down
Loading