diff --git a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts index c97624df3b6..192e10f4f49 100644 --- a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts +++ b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts @@ -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, diff --git a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts index 518cac6238f..e02e552ee71 100644 --- a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts @@ -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(); + const workspaces = new Set(); runInNewContext(output.outputFiles[0]!.text, { module, exports: module.exports, process, require: (specifier: string) => specifier.endsWith('/auxiliary-window-registry.js') @@ -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', @@ -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 () => { diff --git a/apps/desktop/src/main/__tests__/workspace-window-creation.test.ts b/apps/desktop/src/main/__tests__/workspace-window-creation.test.ts new file mode 100644 index 00000000000..bfee2715946 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workspace-window-creation.test.ts @@ -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((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(); + const nativeParents = new Map(); + 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 = { + 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((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]); +}); diff --git a/apps/desktop/src/main/__tests__/workspace-window-registry.test.ts b/apps/desktop/src/main/__tests__/workspace-window-registry.test.ts new file mode 100644 index 00000000000..1eb888c1ffe --- /dev/null +++ b/apps/desktop/src/main/__tests__/workspace-window-registry.test.ts @@ -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/); +}); diff --git a/apps/desktop/src/main/app-ipc-main.ts b/apps/desktop/src/main/app-ipc-main.ts index 7f06ae91cf7..6d049d814a2 100644 --- a/apps/desktop/src/main/app-ipc-main.ts +++ b/apps/desktop/src/main/app-ipc-main.ts @@ -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 => { diff --git a/apps/desktop/src/main/desktop-shell-presentation.ts b/apps/desktop/src/main/desktop-shell-presentation.ts index 91afa20ce36..21f33fa3295 100644 --- a/apps/desktop/src/main/desktop-shell-presentation.ts +++ b/apps/desktop/src/main/desktop-shell-presentation.ts @@ -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(); } diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index a7043d9998f..a92d5a5168a 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -18,7 +18,7 @@ */ import { MAIN_WINDOW_DROP_GUARD_SCRIPT } from './main-window-drop-guard.js'; -import { app, BrowserWindow, dialog, nativeTheme, screen, shell, type View, webFrameMain } from 'electron'; +import { app, BrowserWindow, dialog, nativeTheme, screen, shell, webFrameMain } from 'electron'; import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { appIconForTheme, type AppSettings } from '@maka/core/settings'; @@ -40,6 +40,7 @@ import { isDarkAppearance, isThemePreference, toNativeThemeSource } from './them import { createWindowRevealGate, type WindowRevealMode } from './window-reveal.js'; import { createWindowsMaximizeRendererSync } from './windows-maximize-renderer-sync.js'; import { auxiliaryWindowRegistry } from './auxiliary-window-registry.js'; +import { workspaceWindowRegistry } from './workspace-window-registry.js'; import { parseDesktopSessionResourceKey, } from '../shared/runtime-host-identity.js'; @@ -48,15 +49,14 @@ type SettingsReader = { get(): Promise; }; -export interface MainWindowController { +export interface WorkspaceWindowController { createWindow(signal: AbortSignal): Promise; /** * Reload only the crashed main Renderer, preserving the Desktop process, * Runtime Host, background services, and current BrowserWindow. */ reloadMainRenderer(): Promise; - send(channel: string, ...args: unknown[]): void; - isMainRenderer(contents: Electron.WebContents): boolean; + isRenderer(contents: Electron.WebContents): boolean; // PR-SHOW-AFTER-FIRST-COMMIT: reveal the hidden window after the renderer's // first React commit. Idempotent + e2e-fixture-safe (see notifyRendererReady). notifyRendererReady( @@ -66,10 +66,6 @@ export interface MainWindowController { setTitlebarControlsVisible(sender: Electron.WebContents, visible: unknown): void; setThemeSource(sender: Electron.WebContents, themePref: unknown): void; setTitleBarOverlayTheme(sender: Electron.WebContents, theme: unknown): void; - showOpenDialog(options: Electron.OpenDialogOptions): Promise; - showSaveDialog(options: Electron.SaveDialogOptions): Promise; - getBrowserViews(parentForSession: (sessionId: string) => Electron.View | undefined): BrowserViewManager; - disposeBrowserViews(): Promise; hasOpenWindows(): boolean; focus(): void; /** @@ -111,14 +107,12 @@ interface MainWindowControllerDeps { onWindowConstructed?: () => void; } -let mainWindow: BrowserWindow | null = null; -let browserViews: BrowserViewManager | undefined; /** Broadcast once to each live owned renderer, even if the main window is closed. * Returns whether at least one IPC send was issued, not a renderer acknowledgment. */ export function safeSendToRenderer(channel: string, ...args: unknown[]): boolean { const recipients = new Set(auxiliaryWindowRegistry.renderers()); - if (mainWindow && !mainWindow.isDestroyed()) recipients.add(mainWindow.webContents); + for (const entry of workspaceWindowRegistry.entries()) recipients.add(entry.window.webContents); let emitted = false; for (const contents of recipients) { if (!contents.isDestroyed()) { @@ -169,9 +163,128 @@ const titleBarOverlayOptions = ( height: TITLEBAR_OVERLAY_HEIGHT, }); +export interface MainWindowController extends Omit { + readonly registry: typeof workspaceWindowRegistry; + isMainRenderer(contents: Electron.WebContents): boolean; + isWorkspaceRenderer(contents: Electron.WebContents): boolean; + browserWindowFor(sender: Electron.WebContents): BrowserWindow | undefined; + send(channel: string, ...args: unknown[]): void; + sendCommand(channel: string, ...args: unknown[]): boolean; + showOpenDialog(options: Electron.OpenDialogOptions, sender?: Electron.WebContents): Promise; + showSaveDialog(options: Electron.SaveDialogOptions, sender?: Electron.WebContents): Promise; + getBrowserViews(parentForSession: (sessionId: string) => Electron.View | undefined): BrowserViewManager; + disposeBrowserViews(): Promise; +} + +/** Application-scoped service. Every workspace uses the same window implementation. */ export function createMainWindowController(deps: MainWindowControllerDeps): MainWindowController { - const { workspaceRoot, e2eFixture, settingsStore } = deps; + const main = createWorkspaceWindowController(deps); + let browserViews: BrowserViewManager | undefined; const liveBrowserScopes = new Map(); + function getBrowserViews( + parentForSession: (sessionId: string) => Electron.View | undefined, + ): BrowserViewManager { + if (!browserViews) { + browserViews = new BrowserViewManager({ + create: (sessionId) => { + const parent = parentForSession(sessionId); + if (!parent) throw new Error('Embedded browser used without an active renderer parent.'); + return new BrowserViewController(parent, sessionId, (sid, state) => { + const ref = parseDesktopSessionResourceKey(sid); + safeSendToRenderer( + 'browser:state', + { hostId: ref.hostId, targetEpoch: ref.targetEpoch }, + { sessionId: ref.sessionId, state }, + ); + }); + }, + onLiveChange: (sessionIds) => { + const groups = new Map[]>(); + for (const sessionId of sessionIds) { + const ref = parseDesktopSessionResourceKey(sessionId); + const key = JSON.stringify([ref.targetEpoch, ref.hostId]); + const group = groups.get(key) ?? []; + group.push(ref); + groups.set(key, group); + } + const keys = new Set([...liveBrowserScopes.keys(), ...groups.keys()]); + for (const key of keys) { + const group = groups.get(key) ?? []; + const first = group[0]; + const scope = first + ? { hostId: first.hostId, targetEpoch: first.targetEpoch } + : liveBrowserScopes.get(key); + if (!scope) continue; + safeSendToRenderer( + 'browser:live', + scope, + { sessionIds: group.map(({ sessionId }) => sessionId) }, + ); + if (first) liveBrowserScopes.set(key, scope); + else liveBrowserScopes.delete(key); + } + }, + }); + } + return browserViews; + } + + async function disposeBrowserViews(): Promise { + await browserViews?.disposeAll(); + } + + function resolveDialogParent(sender?: Electron.WebContents): BrowserWindow | undefined { + if (!sender) return workspaceWindowRegistry.active()?.window; + const workspace = workspaceWindowRegistry.forSender(sender); + if (workspace) return workspace.window; + if (!auxiliaryWindowRegistry.rendererParent(sender)) return undefined; + const parent = BrowserWindow.fromWebContents(sender); + return parent && !parent.isDestroyed() ? parent : undefined; + } + + return { + ...main, + registry: workspaceWindowRegistry, + isMainRenderer: (contents) => main.isRenderer(contents), + isWorkspaceRenderer: (contents) => workspaceWindowRegistry.forSender(contents) !== undefined, + browserWindowFor: (sender) => workspaceWindowRegistry.forSender(sender)?.window, + send: safeSendToRenderer, + sendCommand: (channel, ...args) => workspaceWindowRegistry.sendCommand(channel, ...args), + hasOpenWindows: () => workspaceWindowRegistry.entries().length > 0, + notifyRendererReady(sender, frame) { + workspaceWindowRegistry.forSender(sender)?.controller.notifyRendererReady(sender, frame); + }, + setTitlebarControlsVisible(sender, visible) { + workspaceWindowRegistry.forSender(sender)?.controller.setTitlebarControlsVisible(sender, visible); + }, + setThemeSource(sender, theme) { + workspaceWindowRegistry.forSender(sender)?.controller.setThemeSource(sender, theme); + }, + setTitleBarOverlayTheme(sender, theme) { + workspaceWindowRegistry.forSender(sender)?.controller.setTitleBarOverlayTheme(sender, theme); + }, + showOpenDialog(options, sender) { + const parent = resolveDialogParent(sender); + if (sender && !parent) return Promise.resolve({ canceled: true, filePaths: [] }); + return parent ? dialog.showOpenDialog(parent, options) : dialog.showOpenDialog(options); + }, + showSaveDialog(options, sender) { + const parent = resolveDialogParent(sender); + if (sender && !parent) return Promise.resolve({ canceled: true, filePath: '' }); + return parent ? dialog.showSaveDialog(parent, options) : dialog.showSaveDialog(options); + }, + getBrowserViews, + disposeBrowserViews, + isFocused: () => workspaceWindowRegistry.focused() !== undefined, + }; +} + +function createWorkspaceWindowController( + deps: MainWindowControllerDeps, +): WorkspaceWindowController { + let mainWindow: BrowserWindow | null = null; + let creatingWindow: Promise | undefined; + const { workspaceRoot, e2eFixture, settingsStore } = deps; // PR-SHOW-AFTER-FIRST-COMMIT: windows launched hidden (`hidden` covers // e2e-fixture capture and E2E — see main.ts) must never be revealed; @@ -227,59 +340,16 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main }); }; - function getBrowserViews( - parentForSession: (sessionId: string) => Electron.View | undefined, - ): BrowserViewManager { - if (!browserViews) { - browserViews = new BrowserViewManager({ - create: (sessionId) => { - const parent = parentForSession(sessionId); - if (!parent) throw new Error('Embedded browser used without an active renderer parent.'); - return new BrowserViewController(parent, sessionId, (sid, state) => { - const ref = parseDesktopSessionResourceKey(sid); - safeSendToRenderer( - 'browser:state', - { hostId: ref.hostId, targetEpoch: ref.targetEpoch }, - { sessionId: ref.sessionId, state }, - ); - }); - }, - onLiveChange: (sessionIds) => { - const groups = new Map[]>(); - for (const sessionId of sessionIds) { - const ref = parseDesktopSessionResourceKey(sessionId); - const key = JSON.stringify([ref.targetEpoch, ref.hostId]); - const group = groups.get(key) ?? []; - group.push(ref); - groups.set(key, group); - } - const keys = new Set([...liveBrowserScopes.keys(), ...groups.keys()]); - for (const key of keys) { - const group = groups.get(key) ?? []; - const first = group[0]; - const scope = first - ? { hostId: first.hostId, targetEpoch: first.targetEpoch } - : liveBrowserScopes.get(key); - if (!scope) continue; - safeSendToRenderer( - 'browser:live', - scope, - { sessionIds: group.map(({ sessionId }) => sessionId) }, - ); - if (first) liveBrowserScopes.set(key, scope); - else liveBrowserScopes.delete(key); - } - }, - }); - } - return browserViews; - } - - async function disposeBrowserViews(): Promise { - await browserViews?.disposeAll(); + function createWindow(signal: AbortSignal): Promise { + if (creatingWindow) return creatingWindow; + if (mainWindow && !mainWindow.isDestroyed()) return Promise.resolve(); + const operation = createWindowNow(signal); + creatingWindow = operation; + void operation.finally(() => { if (creatingWindow === operation) creatingWindow = undefined; }).catch(() => undefined); + return operation; } - async function createWindow(signal: AbortSignal): Promise { + async function createWindowNow(signal: AbortSignal): Promise { if (signal.aborted) return; // Restore previously-saved bounds when available; first launch and // legacy installs both fall back to the default 1240x820 frame. After @@ -425,6 +495,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main ); mainWindowShutdownSignal = signal; observeRendererProcess(mainWindow, signal); + workspaceWindowRegistry.register({ context: { kind: 'main', windowId: String(mainWindow.id) }, window: mainWindow, controller }); deps.onWindowConstructed?.(); // The designed `.maka-preload` surface is the loading UI: reveal on the // first painted frame instead of waiting out the whole React mount. @@ -522,7 +593,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main : { ...mainWindow.getBounds(), isMaximized: false }; void writeSavedBounds(workspaceRoot, final); }); - mainWindow.once('closed', () => deps.onClosed?.()); + mainWindow.once('closed', () => { mainWindow = null; deps.onClosed?.(); }); // Dev-server cache hygiene (issue #4775) — see main-renderer-dev-cache.ts // for why a stale immutable dep-chunk graph must never survive into a new @@ -540,12 +611,12 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // remain hidden for their whole lifecycle. armShowFallbackTimer(mainWindow); if (process.env.MAKA_REAL_WINDOW_SMOKE === '1') { - emitRealWindowSmokeDiagnostic('after-load'); - setTimeout(() => emitRealWindowSmokeDiagnostic('settled-1000ms'), 1000); + emitRealWindowSmokeDiagnostic(mainWindow, 'after-load'); + setTimeout(() => emitRealWindowSmokeDiagnostic(mainWindow, 'settled-1000ms'), 1000); } } - return { + const controller: WorkspaceWindowController = { createWindow, async reloadMainRenderer() { const target = mainWindow; @@ -609,8 +680,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main } } }, - send: safeSendToRenderer, - isMainRenderer: (contents) => !!mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents === contents, + isRenderer: (contents) => !!mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents === contents, notifyRendererReady(sender, senderFrame) { if (!mainWindow || mainWindow.isDestroyed() || sender !== mainWindow.webContents) return; const recovery = rendererRecoveryReadiness; @@ -649,18 +719,6 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main if (!isTitleBarOverlayTheme(theme)) return; mainWindow.setTitleBarOverlay(titleBarOverlayOptions(theme.isDark, theme.backgroundColor)); }, - showOpenDialog(options) { - return mainWindow - ? dialog.showOpenDialog(mainWindow, options) - : dialog.showOpenDialog(options); - }, - showSaveDialog(options) { - return mainWindow - ? dialog.showSaveDialog(mainWindow, options) - : dialog.showSaveDialog(options); - }, - getBrowserViews, - disposeBrowserViews, hasOpenWindows() { return mainWindow !== null && !mainWindow.isDestroyed(); }, @@ -706,6 +764,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main return !!mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused(); }, }; + return controller; } function rendererFrameIdentity(frame: Electron.WebFrameMain): MainRendererFrameIdentity { @@ -767,8 +826,7 @@ function e2eFixtureWindowBounds( return defaults; } -function emitRealWindowSmokeDiagnostic(stage: string): void { - const target = mainWindow; +function emitRealWindowSmokeDiagnostic(target: BrowserWindow | null, stage: string): void { if (!target) { console.log(`[real-window-smoke] diagnostic ${JSON.stringify({ stage, windowExists: false })}`); return; diff --git a/apps/desktop/src/main/pet-pack-import.ts b/apps/desktop/src/main/pet-pack-import.ts index 4657e400a05..242e0b680bd 100644 --- a/apps/desktop/src/main/pet-pack-import.ts +++ b/apps/desktop/src/main/pet-pack-import.ts @@ -208,11 +208,11 @@ export function registerPetPackIpc(input: { } }, ); - input.ipcMain.handle('pets:importLocalDirectory', async (): Promise => { + input.ipcMain.handle('pets:importLocalDirectory', async (event): Promise => { const selection = await input.mainWindowController.showOpenDialog({ title: nativeFileDialogCopy(await input.resolveLocale()).importCustomPet, properties: ['openDirectory'], - }); + }, event.sender); if (selection.canceled || selection.filePaths.length === 0) { return { ok: false, reason: 'cancelled' }; } diff --git a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts index 1a34e2ea100..b7cb8aa31ae 100644 --- a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts @@ -168,7 +168,7 @@ export function registerRuntimeHostArtifactsIpc( const result = await deps.mainWindowController.showSaveDialog({ title: ARTIFACT_DIALOG_COPY[deps.uiLocale()].saveAs(artifact.name), defaultPath: artifact.name, - }); + }, _event.sender); if (result.canceled || !result.filePath) { return { ok: false, reason: "canceled" }; } diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 3df1a5d7727..1f6c805c22a 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -2019,7 +2019,7 @@ function registerPersistentClientIpc(): void { }), ); registerDesktopDiagnosticsIpc({ ipcMain, ...desktopDiagnostics }); - ipcMain.handle('directories:pick', async () => { + ipcMain.handle('directories:pick', async (event) => { const local = runtimeHostManager?.entries().find( (state) => state.target.profile.kind === 'local', ); @@ -2028,7 +2028,7 @@ function registerPersistentClientIpc(): void { const result = await mainWindowController.showOpenDialog({ title: nativeFileDialogCopy(await desktopLocale.resolve()).referenceFolder, properties: ['openDirectory'], - }); + }, event.sender); if (result.canceled || !result.filePaths[0]) return { ok: false, reason: 'cancelled' }; return { ok: true, reference: { hostId, path: result.filePaths[0] } }; }); @@ -2036,7 +2036,7 @@ function registerPersistentClientIpc(): void { const result = await mainWindowController.showOpenDialog({ title: nativeFileDialogCopy(await desktopLocale.resolve()).addAttachments, properties: ["openFile", "multiSelections"], - }); + }, event.sender); if (result.canceled || !result.filePaths[0]) return { ok: false, reason: "cancelled" }; const { stat } = await import("node:fs/promises"); @@ -2163,7 +2163,9 @@ async function prepareRuntimeHostDesktopQuit(): Promise<'ready' | 'cancelled'> { return dialog.decisions[response] === 'quit'; }, }); - if (preparation === 'ready') mainWindowController.browserWindow()?.destroy(); + if (preparation === 'ready') { + for (const entry of mainWindowController.registry.entries()) entry.window.destroy(); + } return preparation; } diff --git a/apps/desktop/src/main/runtime-host-config-ipc-main.ts b/apps/desktop/src/main/runtime-host-config-ipc-main.ts index 42b65e93033..fee8bc35b15 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -105,7 +105,7 @@ export function registerRuntimeHostConfigIpc( title: CONFIG_DIALOG_COPY[deps.uiLocale()].exportTitle, defaultPath: `maka-config-${today}.json`, filters: [{ name: 'Maka Config', extensions: ['json'] }], - }); + }, _event.sender); if (result.canceled || !result.filePath) { return { ok: false as const, reason: 'canceled' as const }; } @@ -134,7 +134,7 @@ export function registerRuntimeHostConfigIpc( title: CONFIG_DIALOG_COPY[deps.uiLocale()].importTitle, properties: ['openFile'], filters: [{ name: 'Maka Config', extensions: ['json'] }], - }); + }, _event.sender); const filePath = result.filePaths?.[0]; if (result.canceled || !filePath) { return { ok: false as const, reason: 'canceled' as const }; diff --git a/apps/desktop/src/main/runtime-host-session-bundle-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-bundle-ipc-main.ts index 0dd32d8922a..fe91f22153b 100644 --- a/apps/desktop/src/main/runtime-host-session-bundle-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-bundle-ipc-main.ts @@ -18,7 +18,7 @@ */ import { createHash } from 'node:crypto'; -import type { IpcMain } from 'electron'; +import type { IpcMain, WebContents } from 'electron'; import type { SessionChangedReason } from '@maka/core/session'; import { RuntimeHostOperationError } from '@maka/runtime-host/client'; import type { @@ -53,12 +53,12 @@ interface DialogController { title?: string; defaultPath?: string; filters?: Array<{ name: string; extensions: string[] }>; - }): Promise<{ canceled: boolean; filePath?: string }>; + }, sender?: WebContents): Promise<{ canceled: boolean; filePath?: string }>; showOpenDialog(options: { title?: string; properties?: string[]; filters?: Array<{ name: string; extensions: string[] }>; - }): Promise<{ canceled: boolean; filePaths: string[] }>; + }, sender?: WebContents): Promise<{ canceled: boolean; filePaths: string[] }>; } export interface RuntimeHostSessionBundleIpcDeps { @@ -84,7 +84,7 @@ export function registerRuntimeHostSessionBundleIpc( const picked = await deps.mainWindowController.showSaveDialog({ defaultPath: `${bundleFileName(suggestedName)}.${BUNDLE_EXTENSION}`, filters: [{ name: 'Maka session', extensions: [BUNDLE_EXTENSION] }], - }); + }, _event.sender); if (picked.canceled || !picked.filePath) { return { ok: false, reason: 'canceled' } satisfies SessionBundleExportIpcResult; } @@ -109,11 +109,11 @@ export function registerRuntimeHostSessionBundleIpc( } }); - ipcMain.handle('session-bundle:import', async () => { + ipcMain.handle('session-bundle:import', async (_event) => { const picked = await deps.mainWindowController.showOpenDialog({ properties: ['openFile'], filters: [{ name: 'Maka session', extensions: [BUNDLE_EXTENSION] }], - }); + }, _event.sender); const source = picked.filePaths[0]; if (picked.canceled || !source) { return { ok: false, reason: 'canceled' } satisfies SessionBundleImportIpcResult; diff --git a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts index 4a03fd831ae..486fc77f6eb 100644 --- a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts @@ -238,7 +238,7 @@ export function registerRuntimeHostSkillsIpc( }, ); - deps.ipcMain.handle("skills:sources:importLocalFile", async () => { + deps.ipcMain.handle("skills:sources:importLocalFile", async (event) => { if (deps.allowLocalPaths === false) { throw new Error("Local Skill import is unavailable for a remote Runtime Host"); } @@ -251,7 +251,7 @@ export function registerRuntimeHostSkillsIpc( { name: "Skill Markdown", extensions: ["md"] }, { name: copy.allFiles, extensions: ["*"] }, ], - }); + }, event.sender); if (result.canceled || result.filePaths.length === 0) { return { ok: false as const, reason: "cancelled" as const }; } diff --git a/apps/desktop/src/main/workspace-window-registry.ts b/apps/desktop/src/main/workspace-window-registry.ts new file mode 100644 index 00000000000..8f886d067cd --- /dev/null +++ b/apps/desktop/src/main/workspace-window-registry.ts @@ -0,0 +1,75 @@ +/* + * 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 type { BrowserWindow, WebContents } from 'electron'; +import type { WorkspaceWindowController } from './main-window.js'; + +export interface WorkspaceWindowEntry { + readonly window: BrowserWindow; + readonly controller: WorkspaceWindowController; + readonly context: { readonly kind: 'main' | 'session'; readonly windowId: string }; +} + +/** Only trusted workspace surfaces participate; auxiliary windows remain separate. */ +export function createWorkspaceWindowRegistry() { + const windows = new Map(); + let lastFocusedId: string | undefined; + return { + register(entry: WorkspaceWindowEntry): void { + if (entry.window.isDestroyed()) throw new Error('Cannot register a destroyed workspace window'); + if (entry.context.kind === 'main' && [...windows.values()].some((item) => item.context.kind === 'main')) { + throw new Error('The main workspace window already exists'); + } + windows.set(entry.context.windowId, entry); + entry.window.on('focus', () => { lastFocusedId = entry.context.windowId; }); + entry.window.once('closed', () => { + windows.delete(entry.context.windowId); + if (lastFocusedId === entry.context.windowId) lastFocusedId = undefined; + }); + }, + entries(): WorkspaceWindowEntry[] { + return [...windows.values()].filter((entry) => !entry.window.isDestroyed()); + }, + forSender(sender: WebContents): WorkspaceWindowEntry | undefined { + if (sender.isDestroyed()) return undefined; + return this.entries().find((entry) => entry.window.webContents === sender); + }, + get(windowId: string): WorkspaceWindowEntry | undefined { + const entry = windows.get(windowId); + return entry && !entry.window.isDestroyed() ? entry : undefined; + }, + main(): WorkspaceWindowEntry | undefined { + return this.entries().find((entry) => entry.context.kind === 'main'); + }, + focused(): WorkspaceWindowEntry | undefined { + return this.entries().find((entry) => entry.window.isFocused()); + }, + active(): WorkspaceWindowEntry | undefined { + return this.focused() ?? (lastFocusedId === undefined ? undefined : this.get(lastFocusedId)) ?? this.main(); + }, + sendCommand(channel: string, ...args: unknown[]): boolean { + const target = this.active(); + if (!target || target.window.webContents.isDestroyed()) return false; + target.window.webContents.send(channel, ...args); + return true; + }, + }; +} + +export const workspaceWindowRegistry = createWorkspaceWindowRegistry();