Skip to content
Open
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
7 changes: 3 additions & 4 deletions apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,9 @@ test('routes the first-paint IPC only to the active Renderer recovery listener',
mainWindowSource.indexOf('setTitlebarControlsVisible(sender', readyHandlerStart),
);
const reloadStart = mainWindowSource.indexOf(' async reloadMainRenderer() {');
const reloadHandler = mainWindowSource.slice(
reloadStart,
mainWindowSource.indexOf(' send: safeSendToRenderer', reloadStart),
);
const reloadEnd = mainWindowSource.indexOf(' isRenderer:', reloadStart);
assert.ok(reloadStart >= 0 && reloadEnd > reloadStart);
const reloadHandler = mainWindowSource.slice(reloadStart, reloadEnd);

assert.match(
ipcHandler,
Expand Down
40 changes: 36 additions & 4 deletions apps/desktop/src/main/__tests__/workhub-presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,11 +625,12 @@ test('rejects unowned/subframe IPC and buffers navigation until main subscribes'
h.controller.dispose();
});

test('application broadcasts include registered auxiliary renderers', async () => {
test('application broadcasts reach each live workspace and auxiliary renderer once', async () => {
const entry = fileURLToPath(new URL('../../../src/main/main-window.ts', import.meta.url));
const output = await build({ entryPoints: [entry], bundle: false, write: false, format: 'cjs', platform: 'node', define: { 'import.meta.dirname': JSON.stringify('/app/dist/main') } });
const module = { exports: {} as { createMainWindowController: typeof createMainWindowController } };
const auxiliaries = new Set<Electron.WebContents>();
const workspaces = new Set<Electron.WebContents>();
runInNewContext(output.outputFiles[0]!.text, {
module, exports: module.exports, process,
require: (specifier: string) => specifier.endsWith('/auxiliary-window-registry.js')
Expand All @@ -638,7 +639,13 @@ test('application broadcasts include registered auxiliary renderers', async () =
renderers: () => [...auxiliaries],
},
}
: { createWindowRevealGate: () => ({}) },
: specifier.endsWith('/workspace-window-registry.js')
? {
workspaceWindowRegistry: {
entries: () => [...workspaces].map((webContents) => ({ window: { webContents } })),
},
}
: { createWindowRevealGate: () => ({}) },
});
const controller = module.exports.createMainWindowController({
workspaceRoot: '/workspace', e2eFixture: null, revealMode: 'hidden',
Expand All @@ -650,12 +657,37 @@ test('application broadcasts include registered auxiliary renderers', async () =
isDestroyed: () => false,
send: (channel: string) => { messages.push(channel); },
}) as unknown as Electron.WebContents;
const workspaceMessages: string[] = [];
const workspaceRenderer = Object.assign(new EventEmitter(), {
isDestroyed: () => false,
send: (channel: string) => { workspaceMessages.push(channel); },
}) as unknown as Electron.WebContents;
const auxiliaryMessages: string[] = [];
const auxiliaryRenderer = Object.assign(new EventEmitter(), {
isDestroyed: () => false,
send: (channel: string) => { auxiliaryMessages.push(channel); },
}) as unknown as Electron.WebContents;
const destroyedRenderer = Object.assign(new EventEmitter(), {
isDestroyed: () => true,
send: () => { assert.fail('A destroyed renderer cannot receive broadcasts'); },
}) as unknown as Electron.WebContents;
auxiliaries.add(renderer);
controller.send('settings:changed');
auxiliaries.add(auxiliaryRenderer);
workspaces.add(renderer);
workspaces.add(workspaceRenderer);
workspaces.add(destroyedRenderer);
assert.equal(controller.send('settings:changed'), true);
assert.deepEqual(messages, ['settings:changed']);
assert.deepEqual(workspaceMessages, ['settings:changed']);
assert.deepEqual(auxiliaryMessages, ['settings:changed']);
auxiliaries.delete(renderer);
controller.send('settings:changed');
auxiliaries.delete(auxiliaryRenderer);
workspaces.delete(renderer);
workspaces.delete(workspaceRenderer);
assert.equal(controller.send('settings:changed'), false);
assert.deepEqual(messages, ['settings:changed']);
assert.deepEqual(workspaceMessages, ['settings:changed']);
assert.deepEqual(auxiliaryMessages, ['settings:changed']);
});

test('control shows a passive card only after it is painted and preserves manual conversation geometry', async () => {
Expand Down
219 changes: 219 additions & 0 deletions apps/desktop/src/main/__tests__/workspace-window-creation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/*
* 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 { EventEmitter } from 'node:events';
import { readFile } from 'node:fs/promises';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runInNewContext } from 'node:vm';
import { test } from 'node:test';
import { transform } from 'esbuild';
import { createWorkspaceWindowRegistry } from '../workspace-window-registry.js';
import { createWindowRevealGate } from '../window-reveal.js';
import { SAFE_MIN_HEIGHT, SAFE_MIN_WIDTH } from '../window-state.js';
import type { MainWindowController } from '../main-window.js';

type TestDisplay = { workArea: Electron.Rectangle; scaleFactor: number };

async function harness() {
const registry = createWorkspaceWindowRegistry();
const windows: FakeWindow[] = [];
let settingsReads = 0;
let releaseSettings!: () => void;
let settings = new Promise<void>((resolve) => { releaseSettings = resolve; });
const closedCounts: number[] = [];
const savedBounds: Electron.Rectangle[] = [];
const primary: TestDisplay = { workArea: { x: 0, y: 0, width: 1500, height: 1000 }, scaleFactor: 1 };
const displays = { primary, matching: primary };
const matchingCalls: Electron.Rectangle[] = [];
const dialogCalls: { kind: 'open' | 'save'; parent: unknown }[] = [];
const auxiliaryRenderers = new Set<Contents>();
const nativeParents = new Map<Contents, FakeWindow>();
class Contents extends EventEmitter {
session = {};
destroyed = false;
url = '';
isDestroyed() { return this.destroyed; }
setWindowOpenHandler() {}
getURL() { return this.url; }
executeJavaScript() { return Promise.resolve(); }
send(_channel: string, command: unknown) { this.emit('command', command); }
}
class FakeWindow extends EventEmitter {
static fromWebContents(contents: Contents) {
return nativeParents.get(contents) ?? windows.find((window) =>
!window.isDestroyed() && window.webContents === contents) ?? null;
}
readonly id = windows.length + 1;
private readonly contents = new Contents();
destroyed = false;
visible = false;
private bounds: Electron.Rectangle;
readonly contentView = {};
get webContents() {
if (this.destroyed) throw new Error('Object has been destroyed');
return this.contents;
}
constructor(readonly options: Electron.BrowserWindowConstructorOptions) {
super();
this.bounds = { x: options.x ?? 0, y: options.y ?? 0, width: options.width ?? 1240, height: options.height ?? 820 };
windows.push(this);
}
isDestroyed() { return this.destroyed; }
isVisible() { return this.visible; }
isFocused() { return false; }
isMinimized() { return false; }
isMaximized() { return false; }
getBounds() { return { ...this.bounds }; }
setBounds(bounds: Electron.Rectangle) { this.bounds = { ...bounds }; }
getNormalBounds() { return this.getBounds(); }
async loadURL(url: string) { this.contents.url = url; this.contents.emit('did-finish-load'); this.emit('ready-to-show'); }
close() {
const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
this.emit('close', event);
if (!event.defaultPrevented) this.destroy();
}
destroy() {
this.destroyed = true; this.contents.destroyed = true;
this.contents.emit('destroyed'); this.emit('closed');
}
}
const modules: Record<string, unknown> = {
electron: { app: {}, BrowserWindow: FakeWindow, nativeTheme: { shouldUseDarkColors: false },
dialog: {
showOpenDialog: async (parent: unknown) => { dialogCalls.push({ kind: 'open', parent }); return { canceled: false, filePaths: ['/selected.txt'] }; },
showSaveDialog: async (parent: unknown) => { dialogCalls.push({ kind: 'save', parent }); return { canceled: false, filePath: '/saved.txt' }; },
},
screen: {
getAllDisplays: () => [displays.primary, displays.matching],
getPrimaryDisplay: () => displays.primary,
getDisplayMatching: (bounds: Electron.Rectangle) => { matchingCalls.push(bounds); return displays.matching; },
} },
'node:fs/promises': { mkdir: async () => undefined },
'node:path': path,
'@maka/core/settings': { appIconForTheme: () => 'default' },
'./main-window-drop-guard.js': { MAIN_WINDOW_DROP_GUARD_SCRIPT: '' },
'./app-icon-surface.js': { applyInitialWindowAppIcon() {}, readableAppIconPath: () => '' },
'./external-link-guard.js': { isExternalUrl: () => false },
'./window-state.js': { readSavedBounds: async (_root: string, defaults: unknown) => defaults, writeSavedBounds: async (_root: string, bounds: Electron.Rectangle) => { savedBounds.push(bounds); }, SAFE_MIN_WIDTH, SAFE_MIN_HEIGHT },
'./browser/controller.js': {}, './browser/view-manager.js': {},
'./main-window-permission-policy.js': { installMainWindowPermissionPolicy() {} },
'./main-renderer-loader.js': { resolveMainRendererEntry: () => ({ url: 'http://localhost/index.html' }), loadMainRenderer: (window: FakeWindow) => window.loadURL('http://localhost/index.html') },
'./main-renderer-dev-cache.js': { clearDevRendererHttpCache: async () => undefined },
'./main-renderer-process-gone.js': { observeMainRendererProcessGone() {} },
'./theme-source.js': { isDarkAppearance: () => false, toNativeThemeSource: () => 'system' },
'./window-reveal.js': { createWindowRevealGate },
'./windows-maximize-renderer-sync.js': { createWindowsMaximizeRendererSync: () => () => undefined },
'./auxiliary-window-registry.js': { auxiliaryWindowRegistry: {
renderers: () => auxiliaryRenderers.values(),
rendererParent: (contents: Contents) => !contents.isDestroyed() && auxiliaryRenderers.has(contents) ? {} : undefined,
} },
'./workspace-window-registry.js': { workspaceWindowRegistry: registry },
'../shared/runtime-host-identity.js': {},
};
const source = await readFile(fileURLToPath(new URL('../../../src/main/main-window.ts', import.meta.url)), 'utf8');
const compiled = await transform(source, { loader: 'ts', format: 'cjs', target: 'es2022', define: { 'import.meta.dirname': JSON.stringify('/virtual/main') } });
const module = { exports: {} as { createMainWindowController: (deps: unknown) => MainWindowController } };
runInNewContext(compiled.code, {
module, exports: module.exports,
require: (id: string) => { if (!(id in modules)) throw new Error(`Unexpected import ${id}`); return modules[id]; },
process: { platform: 'linux', env: {} }, setTimeout, clearTimeout, console,
});
const controller = module.exports.createMainWindowController({
workspaceRoot: '/virtual', e2eFixture: null, revealMode: 'hidden',
settingsStore: { get: async () => { settingsReads += 1; await settings; return { appearance: { theme: 'auto' } }; } },
onRendererProcessGone() {},
onClosed: () => { closedCounts.push(registry.entries().length); },
});
return {
controller, registry, windows, displays, matchingCalls, closedCounts, savedBounds,
dialogCalls, auxiliaryRenderers, nativeParents,
createRenderer: () => new Contents(),
createAuxiliaryWindow: () => new FakeWindow({ show: false }),
releaseSettings: () => releaseSettings(),
pauseSettings: () => { settings = new Promise<void>((resolve) => { releaseSettings = resolve; }); },
settingsReads: () => settingsReads,
};
}

test('WorkHub file dialogs follow its registered renderer from docked to floating', async () => {
const h = await harness();
h.releaseSettings();
await h.controller.createWindow(new AbortController().signal);
const main = h.windows[0]!;
const workHub = h.createRenderer();
h.auxiliaryRenderers.add(workHub);
h.nativeParents.set(workHub, main);
assert.equal((await h.controller.showOpenDialog({}, workHub as never)).canceled, false);
assert.equal((await h.controller.showSaveDialog({}, workHub as never)).canceled, false);
const floating = h.createAuxiliaryWindow();
h.nativeParents.set(workHub, floating);
main.destroy();
assert.equal((await h.controller.showOpenDialog({}, workHub as never)).canceled, false);
assert.equal((await h.controller.showSaveDialog({}, workHub as never)).canceled, false);
assert.deepEqual(h.dialogCalls, [
{ kind: 'open', parent: main }, { kind: 'save', parent: main },
{ kind: 'open', parent: floating }, { kind: 'save', parent: floating },
]);
});

test('file dialogs reject unknown or destroyed auxiliary senders and missing or destroyed parents', async () => {
const h = await harness();
const sender = h.createRenderer();
const parent = h.createAuxiliaryWindow();
h.nativeParents.set(sender, parent);
const assertCancelled = async () => {
assert.equal((await h.controller.showOpenDialog({}, sender as never)).canceled, true);
assert.equal((await h.controller.showSaveDialog({}, sender as never)).canceled, true);
assert.equal(h.dialogCalls.length, 0);
};
await assertCancelled();
h.auxiliaryRenderers.add(sender);
h.nativeParents.delete(sender);
await assertCancelled();
h.nativeParents.set(sender, parent);
parent.destroy();
await assertCancelled();
h.nativeParents.set(sender, h.createAuxiliaryWindow());
sender.destroyed = true;
await assertCancelled();
});

test('concurrent main creation shares the asynchronous construction and one registered native window', async () => {
const h = await harness();
const signal = new AbortController().signal;
const first = h.controller.createWindow(signal);
const second = h.controller.createWindow(signal);
assert.equal(first, second);
assert.equal(h.settingsReads(), 1);
assert.equal(h.windows.length, 0);
h.releaseSettings();
await Promise.all([first, second]);
assert.equal(h.windows.length, 1);
assert.equal(h.registry.main()?.window, h.windows[0]);
assert.equal(h.controller.browserWindow(), h.windows[0]);
assert.equal(h.registry.main()?.controller.browserWindow(), h.windows[0]);
await h.controller.createWindow(signal);
assert.equal(h.windows.length, 1);
h.windows[0]!.destroy();
await Promise.all([h.controller.createWindow(signal), h.controller.createWindow(signal)]);
assert.equal(h.windows.length, 2);
assert.equal(h.registry.main()?.window, h.windows[1]);
});
48 changes: 48 additions & 0 deletions apps/desktop/src/main/__tests__/workspace-window-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* 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 { EventEmitter } from 'node:events';
import { test } from 'node:test';
import { createWorkspaceWindowRegistry } from '../workspace-window-registry.js';

test('commands target only the active workspace, and main close preserves session window identity', () => {
const registry = createWorkspaceWindowRegistry();
const delivered: string[] = [];
const make = (id: string, kind: 'main' | 'session') => {
const window = Object.assign(new EventEmitter(), {
isDestroyed: () => false,
isFocused: () => false,
webContents: { isDestroyed: () => false, send: () => delivered.push(id) },
});
registry.register({ context: { kind, windowId: id } as never, window: window as never, controller: {} as never });
return window;
};
const main = make('1', 'main');
const child = make('2', 'session');
child.emit('focus');
registry.sendCommand('window:command', { id: 'search' });
assert.deepEqual(delivered, ['2']);
assert.equal(registry.forSender(child.webContents as never)?.context.windowId, '2');
assert.equal(registry.forSender({ isDestroyed: () => false } as never), undefined);
main.emit('closed');
assert.equal(registry.main(), undefined);
assert.equal(registry.entries().length, 1);
assert.throws(() => { make('3', 'main'); make('4', 'main'); }, /already exists/);
});
6 changes: 3 additions & 3 deletions apps/desktop/src/main/app-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,10 @@ export function registerAppClientIpc(
): void {
const { mainWindowController, e2eFixture, updateService } = deps;
targetIpc.handle('window:popupMenu', (event, input: unknown) => {
if (!mainWindowController.isMainRenderer(event.sender) || event.senderFrame !== event.sender.mainFrame) {
throw new Error('Native menus require the main renderer');
if (!mainWindowController.isWorkspaceRenderer(event.sender) || event.senderFrame !== event.sender.mainFrame) {
throw new Error('Native menus require a workspace renderer');
}
const window = mainWindowController.browserWindow();
const window = mainWindowController.browserWindowFor(event.sender);
return window ? popupNativeMenu(window, input) : null;
});
targetIpc.handle('window:setTitlebarControlsVisible', (event, visible: unknown): void => {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/desktop-shell-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function installDesktopShellPresentation(
isPackaged: app.isPackaged,
dispatch: (command) => {
if (deps.mainWindowController.hasOpenWindows()) {
deps.mainWindowController.send('window:command', { id: command });
deps.mainWindowController.sendCommand('window:command', { id: command });
} else {
deps.focusOrCreateWindow();
}
Expand Down
Loading