From b5e6c663efb0d4582de167e5a8f099a5a25450e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 29 Sep 2026 19:50:25 +0800 Subject: [PATCH] fix: close and recreate macOS windows safely --- .../tasks/20260929-window-close-58c6a9de.md | 60 ++++++ README.md | 1 + electron/agent-browser/electron-adapter.ts | 33 ++- electron/agent-browser/module.ts | 57 ++++-- electron/main/index.ts | 126 +++++++----- electron/main/ipc-handlers.ts | 101 ++++++---- .../ipc/conversation-link-context-menu.ts | 7 +- electron/main/ipc/transcript-export.ts | 16 +- electron/main/main-window-close.ts | 50 +++++ electron/main/menu.ts | 43 ++-- electron/main/tray.ts | 50 ++--- electron/main/updater.ts | 2 +- electron/main/window-provider.ts | 39 ++++ tests/e2e/main-window-lifecycle.spec.ts | 188 ++++++++++++++++++ tests/unit/agent-browser-core.test.ts | 61 ++++++ .../agent-browser-electron-adapter.test.ts | 42 +++- tests/unit/main-window-close.test.ts | 94 +++++++++ tests/unit/main-window-reopen-ipc.test.ts | 143 +++++++++++++ .../main-window-reopen-navigation.test.ts | 138 +++++++++++++ 19 files changed, 1079 insertions(+), 172 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260929-window-close-58c6a9de.md create mode 100644 electron/main/main-window-close.ts create mode 100644 electron/main/window-provider.ts create mode 100644 tests/e2e/main-window-lifecycle.spec.ts create mode 100644 tests/unit/main-window-close.test.ts create mode 100644 tests/unit/main-window-reopen-ipc.test.ts create mode 100644 tests/unit/main-window-reopen-navigation.test.ts diff --git a/.project-docs/30-worklog/tasks/20260929-window-close-58c6a9de.md b/.project-docs/30-worklog/tasks/20260929-window-close-58c6a9de.md new file mode 100644 index 00000000..b79e3ac9 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260929-window-close-58c6a9de.md @@ -0,0 +1,60 @@ +# Task: Fix native window close and macOS fullscreen lifecycle + +## Identity + +- Task ID: 20260929-window-close-58c6a9de +- Mode: Feature +- Branch: codex/20260929-window-close-58c6a9de-window-close +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260929-window-close-58c6a9de +- Base commit: f443f3bc668dfc6c9df111506d739d343ee82bba +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Implement native macOS red close as real window destruction, preserving yellow minimize and green native zoom/fullscreen; fix closing from fullscreen and reopening all window-bound services. + +## Intent And Constraints + +- User explicitly approved changing code after red-close left a black fullscreen Space; manually exiting fullscreen cleared it. Do not replace the installed or running trial app, merge main, publish, or alter concurrent teacher-composer work. Preserve active Main-owned tasks, authentication, project data and persisted drafts. + +## Outcome + +- macOS red close now destroys the window. Fullscreen close waits for native leave-full-screen before closing; repeated close requests are coalesced and Cmd+Q retains the existing quit lifecycle. Yellow minimize and green native zoom/fullscreen behavior are preserved. Windows/Linux retain close-to-tray. +- Main-owned Host API, coding runtime, authentication and task leases remain alive without a window. Dock activation, tray and menu recreate the window with current IPC/dialog owners, updater notifications and browser presentation callbacks; renderer leases are isolated by WebContents and released on destruction. +- The shared browser module survives window replacement, while each native view retains its original owner. Reopening waits for old preview cleanup; an already cancelled open cannot create a late view or clear its successor. Missing host-window mount failures clean up their view. +- README describes the native control behavior. Changes are isolated on the task branch; the primary checkout, installed app and running trial app were not modified or updated. + +## Verification + +- Focused unit verification: 152 passing tests across 12 distinct files covering native close, focus/quit, reopen IPC/menu/tray/dialogs, browser adapter/core/routes, background lifecycle and updater. The final browser core/routes run passed 97 tests. Three delayed-cleanup regressions cover close/reopen, preview replacement and project switching; the latter two failed before the epoch fix (unexpected generation 3 and stale-operation rejection) and passed after it. A fourth regression covers view cleanup on mount failure. +- Two real macOS Electron E2E passed after the final build (11.8 seconds): three window generations/two closes, surviving Main PID, working minimize/zoom and IPC, new native preview attachment/events, old preview destruction, native leave-full-screen before closed, reopened normal window and clean app.quit exit. Fixtures use isolated temporary HOME/userData and loopback preview, never the running user apps. +- corepack pnpm run typecheck passed. Scoped ESLint across every changed TypeScript file, build:vite and git diff --check passed. +- Expanded Main typecheck remains blocked by 66 pre-existing diagnostics. An independent exact-base archive comparison against f443f3bc668dfc6c9df111506d739d343ee82bba found 66 identical diagnostics, zero added/removed (normalizing source line/column and checkout paths); final rerun matched. Logs: /tmp/makelore-window-close-main-types-final.txt and /tmp/makelore-window-close-build-final.log. +- Independent review found and verified the now-fixed close/open race; final review found no remaining blocking findings. Project-doc structure and task-aware drift checks passed. + +## Follow-ups + +- Integrate the committed task branch when requested, then rebuild/update the intended app. This task intentionally delivers code only; live app replacement and main-branch integration were not authorized by the latest request. +- Expanded Main TypeScript baseline errors remain outside this task. + +## Promotion Candidates + +- Targets: .project-docs/30-worklog/current-state.md and .project-docs/20-architecture/system-overview.md. Proposal: record macOS red-close/window-recreation semantics and the process-owned services / window-owned presentation boundary. Evidence: user explicitly approved red close with yellow/green preserved, focused unit coverage and native macOS E2E. Future impact: new services must resolve the current window dynamically and retain active Main leases when windows close; they must not dispose process-scoped runtimes on window destruction. Semantic conflicts: replaces the prior macOS hide-on-close implementation; no accepted ADR requires that behavior. Human confirmation: already supplied for this product change; canonical promotion remains for an integration task. + +## Project Context Loaded + +- Task: 20260929-window-close-58c6a9de; feature mode; owned branch codex/20260929-window-close-58c6a9de-window-close; owned worktree as Identity; base f443f3bc668dfc6c9df111506d739d343ee82bba. Ownership start/status passed. +- Read: AGENTS.md; entry read-before-planning, memory-index and planning-gate; active record; positioning; integrated current-state; decision index and lifecycle/ADR-006 references; system-overview/module-map/data-flow; business-rules; success-criteria; evidence/reflection/commitments indexes. Large historical documents were read with task-relevant lifecycle sections independently reviewed by a subagent. +- Project: Main owns system integration and background work; Renderer consumes snapshots. Existing product has Code, Canvas, Robot and published teacher identities. Template placeholders remain in early positioning and are not task authority. Latest user-approved shortcuts/publishing stay untouched. +- Other local tasks: task-context registry reviewed, with every peer task record read for scope/constraints/promotions. Active teacher-composer work is isolated and concerns teacher input/model contracts; old help-label patch and trial/publication tasks must not be overwritten. Other ready tasks are read-only. No native-close semantic conflict. +- Relevant constraints: active/uncertain runs retain Main leases while window is absent; close removes native preview view/debugger and renderer leases only; reopening hydrates snapshots and never replays mutations. Cmd+Q retains bounded shutdown. No accepted ADR requires close-to-hide on macOS. +- Evidence: installed Main intercepts close with preventDefault + hide without fullscreen lifecycle; exiting fullscreen restored user desktop. Green controls remain native. +- Modules: Main index/close lifecycle, window-bound IPC/dialogs/tray/menu/updater, native browser adapter, focused unit and Electron tests. +- Gate: Passed. + +## Plan + +1. Separate process services from recreatable window bindings; implement safe fullscreen close. +2. Cover repeat close, reopen, leases, dialogs and preview attachment; run focused unit tests, typecheck/lint/build and isolated Electron tests. +3. Review diff, record verified results and prepare a commit/patch for integration. diff --git a/README.md b/README.md index 920532b6..75cf8fd5 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 ## 当前产品状态 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 +- macOS 窗口按钮:红色关闭当前窗口,全屏时先退出全屏再关闭;黄色最小化,绿色保留原生缩放/全屏行为。关窗后应用和进行中的后台任务继续运行,点击 Dock、托盘或菜单可重新打开窗口;`Cmd+Q` 退出应用。 - 桌面文本右键菜单:主 Renderer 窗口由 Electron Main 提供原生“剪切、复制、粘贴、全选”操作;菜单只在可编辑区域或已有文本选择时出现,普通空白区域不显示,也不向 Renderer 暴露剪贴板 IPC。 - AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。 - AI 编程默认入口:进入 Code 后保持在 `/chat`。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 diff --git a/electron/agent-browser/electron-adapter.ts b/electron/agent-browser/electron-adapter.ts index baf4a6f9..aa7896e2 100644 --- a/electron/agent-browser/electron-adapter.ts +++ b/electron/agent-browser/electron-adapter.ts @@ -1,6 +1,7 @@ import type { BrowserWindow, Session, WebContents } from 'electron'; import { WebContentsView, session } from 'electron'; import type { AgentBrowserBounds } from '../../shared/agent-browser'; +import { AgentBrowserFault } from './fault'; import type { AgentBrowserAdapter, AgentBrowserDebuggerPort, @@ -88,11 +89,18 @@ function wrapWebContents(contents: WebContents, isVisible: () => boolean): Agent export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter { private readonly nativeViews = new WeakMap(); + private readonly viewOwners = new WeakMap(); private readonly guardedSessions = new WeakSet(); - constructor(private readonly mainWindow: BrowserWindow) {} + constructor(private readonly mainWindow: BrowserWindow | (() => BrowserWindow | null)) {} + + private currentWindow(): BrowserWindow | null { + const window = typeof this.mainWindow === 'function' ? this.mainWindow() : this.mainWindow; + return window && !window.isDestroyed() ? window : null; + } createView(partition: string): AgentBrowserViewPort { + const owner = this.currentWindow(); const nativeView = new WebContentsView({ webPreferences: { partition, @@ -119,25 +127,29 @@ export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter { const view: AgentBrowserViewPort = { webContents: wrapWebContents(contents, () => nativeView.getVisible()), setBounds: (bounds: AgentBrowserBounds) => - nativeView.setBounds(this.toNativeBounds(bounds)), + nativeView.setBounds(this.toNativeBounds(bounds, owner)), setVisible: (visible: boolean) => nativeView.setVisible(visible), }; this.nativeViews.set(view, nativeView); + if (owner) this.viewOwners.set(view, owner); return view; } mount(view: AgentBrowserViewPort): void { const nativeView = this.requireNativeView(view); - if (!this.mainWindow.isDestroyed()) { - this.mainWindow.contentView.addChildView(nativeView); + const owner = this.viewOwners.get(view); + if (!owner || owner.isDestroyed()) { + throw new AgentBrowserFault('CLOSED', '请先打开麦洛窗口,再查看作品。', true); } + owner.contentView.addChildView(nativeView); } unmount(view: AgentBrowserViewPort): void { const nativeView = this.nativeViews.get(view); - if (!nativeView || this.mainWindow.isDestroyed()) return; + const owner = this.viewOwners.get(view); + if (!nativeView || !owner || owner.isDestroyed()) return; try { - this.mainWindow.contentView.removeChildView(nativeView); + owner.contentView.removeChildView(nativeView); } catch { // Removing an already detached view is harmless during shutdown. } @@ -147,6 +159,7 @@ export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter { const nativeView = this.nativeViews.get(view); if (!nativeView) return; this.nativeViews.delete(view); + this.viewOwners.delete(view); if (!nativeView.webContents.isDestroyed()) { nativeView.webContents.close({ waitForBeforeUnload: false }); } @@ -201,10 +214,12 @@ export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter { }); } - private toNativeBounds(bounds: AgentBrowserBounds): AgentBrowserBounds { - const zoomFactor = this.mainWindow.webContents.getZoomFactor(); + private toNativeBounds(bounds: AgentBrowserBounds, owner: BrowserWindow | null): AgentBrowserBounds { + // Detached release preflight views also work while there is no main window. + if (!owner || owner.isDestroyed()) return bounds; + const zoomFactor = owner.webContents.getZoomFactor(); const zoom = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; - const contentBounds = this.mainWindow.getContentBounds(); + const contentBounds = owner.getContentBounds(); const contentWidth = Math.max(0, Math.trunc(contentBounds.width)); const contentHeight = Math.max(0, Math.trunc(contentBounds.height)); const x = clamp(Math.round(bounds.x * zoom), 0, contentWidth); diff --git a/electron/agent-browser/module.ts b/electron/agent-browser/module.ts index 9146a9da..c33a61fc 100644 --- a/electron/agent-browser/module.ts +++ b/electron/agent-browser/module.ts @@ -312,6 +312,7 @@ export class AgentBrowserModule { } open(input: AgentBrowserOpenInput): Promise { + const epoch = this.queueEpoch; return this.serialize(async () => { this.assertAvailable(); const projectPath = normalizeRequiredPath(input.projectPath); @@ -322,6 +323,7 @@ export class AgentBrowserModule { if (this.record && !samePath(this.record.projectPath, projectPath)) { await this.closeInternal(); + this.assertCommandEpoch(epoch); } if (this.record) { const record = this.record; @@ -335,6 +337,7 @@ export class AgentBrowserModule { } if (record.state !== 'attached') { await this.closeInternal(); + this.assertCommandEpoch(epoch); } } if (this.record) { @@ -346,6 +349,7 @@ export class AgentBrowserModule { : Boolean(record.previewData); if (shouldReplacePreview) { await this.closeInternal(record); + this.assertCommandEpoch(epoch); } } if (this.record) { @@ -357,6 +361,7 @@ export class AgentBrowserModule { try { if (diagnosticOwner) { await this.updateDiagnosticOwner(record, diagnosticOwner, true); + this.assertCommandEpoch(epoch); } if (bounds) { this.applyPresentation(record, input.visible ?? true, bounds); @@ -403,9 +408,14 @@ export class AgentBrowserModule { this.registerListeners(record); record.view.webContents.denyWindowOpen(); - if (bounds) record.view.setBounds(bounds); - record.view.setVisible(record.visible); - this.adapter.mount(record.view); + try { + if (bounds) record.view.setBounds(bounds); + record.view.setVisible(record.visible); + this.adapter.mount(record.view); + } catch (error) { + await this.closeInternal(record); + throw error; + } try { await this.runWhileActive( @@ -413,12 +423,15 @@ export class AgentBrowserModule { record.view.webContents.loadURL(RENDERER_PRIME_URL), OPEN_TIMEOUT_MS, ); + this.assertCommandEpoch(epoch); await this.runWhileActive( record, this.attachDebugger(record, false), OPEN_TIMEOUT_MS, ); + this.assertCommandEpoch(epoch); if (injectProjectData) await this.installPreviewData(record, undefined, targetUrl); + this.assertCommandEpoch(epoch); } catch (error) { await this.closeInternal(record); throw toFault( @@ -432,6 +445,7 @@ export class AgentBrowserModule { try { await this.loadPageUntilReady(record, targetUrl); + this.assertCommandEpoch(epoch); try { record.view.webContents.navigationHistory.clear(); } catch { @@ -658,8 +672,18 @@ export class AgentBrowserModule { async close(projectPath?: string): Promise { if (this.record && projectPath) this.assertProject(this.record, projectPath); this.preemptCommands('开发浏览器已关闭。'); - await this.closeInternal(); - return this.closedSnapshot(); + const previousBarrier = this.lifecycleBarrier; + let releaseBarrier!: () => void; + this.lifecycleBarrier = new Promise((resolveBarrier) => { + releaseBarrier = resolveBarrier; + }); + try { + await previousBarrier; + await this.closeInternal(); + return this.closedSnapshot(); + } finally { + releaseBarrier(); + } } async resetProfile(projectPath: string): Promise { @@ -1709,6 +1733,7 @@ export class AgentBrowserModule { if (expected && this.record !== expected) return; record.state = 'closing'; await this.invalidatePreviewData(record, 'preview_closed'); + if (this.record !== record) return; this.notifyLifecycle({ type: 'closed', projectId: record.projectId, @@ -1812,15 +1837,7 @@ export class AgentBrowserModule { try { await barrier; if (callerSettled) return; - if (epoch !== this.queueEpoch) { - throw new AgentBrowserFault( - 'CLOSED', - '开发浏览器操作已被新的生命周期取代。', - true, - this.record?.generation, - 'unknown', - ); - } + this.assertCommandEpoch(epoch); const value = await operation(); if (!callerSettled) { callerSettled = true; @@ -1842,6 +1859,18 @@ export class AgentBrowserModule { return caller; } + private assertCommandEpoch(epoch: number): void { + if (epoch !== this.queueEpoch) { + throw new AgentBrowserFault( + 'CLOSED', + '开发浏览器操作已被新的生命周期取代。', + true, + this.record?.generation, + 'unknown', + ); + } + } + private serializeWithTimeout( operation: () => Promise, timeoutMs: number, diff --git a/electron/main/index.ts b/electron/main/index.ts index 0b1a3385..d3c58216 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1,4 +1,4 @@ -/** +/** * Electron Main Process Entry * Manages window creation, system tray, and IPC handlers */ @@ -18,6 +18,7 @@ import { logger } from '../utils/logger'; import { initTelemetry } from '../utils/telemetry'; import { isQuitting, setQuitting } from './app-state'; +import { registerMainWindowClose } from './main-window-close'; import { applyProxySettings } from './proxy'; import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup'; import { @@ -204,6 +205,8 @@ let imageWorkspaceModule: DesignWorkspaceModule | null = null; let backgroundLifecycle!: BackgroundLifecycleController; let releaseJobs: ReleaseJobManager | null = null; let codingProducts: CodingProductComposition | null = null; +let windowIpcBindings: ReturnType | null = null; +let unsubscribeAuthSession: (() => void) | null = null; const mainWindowFocusState = createMainWindowFocusState(); const quitLifecycleState = createQuitLifecycleState(); const launchDeepLinkUrl = findNianCodeDeepLinkUrl(process.argv); @@ -337,10 +340,21 @@ function focusMainWindow(): void { focusWindow(mainWindow); } +function ensureMainWindow(): BrowserWindow | null { + if (isQuitting()) return null; + if (mainWindow && !mainWindow.isDestroyed()) return mainWindow; + // Startup creates the first window after storage is ready. Later activations + // recreate only the window, keeping the Host API and running tasks alive. + if (!codingProducts) return null; + return createMainWindow(); +} + function requestMainWindowFocus(reason: string): void { + if (isQuitting()) return; + const window = ensureMainWindow(); const focusRequest = requestSecondInstanceFocus( mainWindowFocusState, - Boolean(mainWindow && !mainWindow.isDestroyed()), + Boolean(window && !window.webContents.isLoadingMainFrame()), ); if (focusRequest === 'focus-now') { @@ -378,30 +392,34 @@ function registerMakeloreProtocolClient(): void { if (!registered) logger.warn(`Failed to register ${NIANCODE_APP_PROTOCOL}:// protocol handler`); } +function sendAuthSession( + session: ReturnType, + reason: WorksSquareSessionChangeReason = 'changed', + previousSession: ReturnType = null, +): void { + if (!session && getWorksSquareSessionRestoreStatus() === 'unavailable') return; + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) { + mainWindow.webContents.send('auth:session-changed', session); + } + if (!session && reason === 'terminal') { + void clearManagedWorksSquareRuntimeBestEffort({ + codingProducts: codingProducts ?? undefined, + imageWorkspace: imageWorkspaceModule ?? undefined, + }, 'terminal session invalidation', previousSession?.accessToken); + } +} + function createMainWindow(): BrowserWindow { const win = createWindow(); - const sendAuthSession = ( - session: ReturnType, - reason: WorksSquareSessionChangeReason = 'changed', - previousSession: ReturnType = null, - ) => { - if (!session && getWorksSquareSessionRestoreStatus() === 'unavailable') return; - if (!win.isDestroyed() && !win.webContents.isDestroyed()) { - win.webContents.send('auth:session-changed', session); - } - if (!session && reason === 'terminal') { - void clearManagedWorksSquareRuntimeBestEffort({ - codingProducts: codingProducts ?? undefined, - imageWorkspace: imageWorkspaceModule ?? undefined, - }, 'terminal session invalidation', previousSession?.accessToken); - } - }; - const unsubscribeAuthSession = subscribeWorksSquareSession(sendAuthSession); + mainWindow = win; + windowIpcBindings?.bindWindow(win); + appUpdater.setMainWindow(win); win.webContents.on('did-finish-load', () => { - sendAuthSession(getWorksSquareSessionSnapshot()); + if (mainWindow === win) sendAuthSession(getWorksSquareSessionSnapshot()); }); const closeAgentBrowserForHostRenderer = (reason: string): void => { + if (mainWindow !== win) return; void agentBrowser?.close().catch((error) => { logger.warn(`Failed to close Agent Browser after ${reason}:`, error); }); @@ -432,14 +450,14 @@ function createMainWindow(): BrowserWindow { win.show(); }); - win.on('close', (event) => { - if (!isQuitting() && !isE2EMode) { - event.preventDefault(); - win.hide(); - } + registerMainWindowClose(win, { + platform: process.platform, + isQuitting, + hideOnClose: process.platform !== 'darwin' && !isE2EMode, }); win.on('hide', () => { + if (mainWindow !== win) return; backgroundLifecycle?.setActivity({ ...backgroundLifecycle.getActivity(), visible: false, @@ -452,6 +470,7 @@ function createMainWindow(): BrowserWindow { }); win.on('show', () => { + if (mainWindow !== win) return; backgroundLifecycle?.setActivity({ ...backgroundLifecycle.getActivity(), visible: true, @@ -459,18 +478,20 @@ function createMainWindow(): BrowserWindow { }); win.on('closed', () => { - unsubscribeAuthSession(); - const browser = agentBrowser; - agentBrowser = null; - void browser?.dispose().catch((error) => { - logger.warn('Failed to dispose Agent Browser after the main window closed:', error); + if (mainWindow !== win) return; + mainWindow = null; + appUpdater.setMainWindow(null); + backgroundLifecycle?.setActivity({ + ...backgroundLifecycle.getActivity(), + visible: false, + }); + // The module belongs to the process, not this window. Disposing it here + // would leave the coding tools and Host API holding an unusable browser. + void agentBrowser?.close().catch((error) => { + logger.warn('Failed to close Agent Browser after the main window closed:', error); }); - if (mainWindow === win) { - mainWindow = null; - } }); - mainWindow = win; return win; } @@ -509,23 +530,23 @@ async function initialize(): Promise { logger.info('AI painting workspace is using the Works Square V2 cloud contract'); // Set application menu - createMenu(); + createMenu(ensureMainWindow); // Create the main window const window = createMainWindow(); - agentBrowser = new AgentBrowserModule(new ElectronAgentBrowserAdapter(window)); + agentBrowser = new AgentBrowserModule(new ElectronAgentBrowserAdapter(() => mainWindow)); // Create system tray if (!isE2EMode) { - createTray(window); + createTray(() => mainWindow, ensureMainWindow); } // Register IPC handlers backgroundLifecycle = new BackgroundLifecycleController({ ...(isE2EMode ? { idleStopMs: 250 } : {}), onSleep: () => { - if (!window.isDestroyed() && !window.webContents.isDestroyed()) { - window.webContents.send('lifecycle:sleep'); + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) { + mainWindow.webContents.send('lifecycle:sleep'); } }, onStopRuntime: async () => await codingProducts?.sleep('background_sleep'), @@ -544,8 +565,8 @@ async function initialize(): Promise { }); releaseJobs.on('status', (status) => { hostEventBus.emit('release-job:status', status); - if (!window.isDestroyed() && !window.webContents.isDestroyed()) { - window.webContents.send('release-job:status', status); + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) { + mainWindow.webContents.send('release-job:status', status); } }); const codingAppPath = app.isPackaged @@ -557,14 +578,14 @@ async function initialize(): Promise { browser: agentBrowser, requestAgentBrowserPresentation: (snapshot) => { hostEventBus.emit('agent-browser:show', snapshot); - if (!window.isDestroyed() && !window.webContents.isDestroyed()) { - window.webContents.send('agent-browser:show', snapshot); + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) { + mainWindow.webContents.send('agent-browser:show', snapshot); } }, publishAgentBrowserState: (snapshot) => { hostEventBus.emit('agent-browser:state', snapshot); - if (!window.isDestroyed() && !window.webContents.isDestroyed()) { - window.webContents.send('agent-browser:state', snapshot); + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) { + mainWindow.webContents.send('agent-browser:state', snapshot); } }, getLocalProxyCredential: () => getHostApiToken() || undefined, @@ -587,7 +608,7 @@ async function initialize(): Promise { const hostApiContext: HostApiContext = { codingProjectStore, eventBus: hostEventBus, - mainWindow: window, + get mainWindow() { return mainWindow; }, agentBrowser, worksSubmissionBinding: worksSubmissionBinding ?? undefined, imageWorkspace, @@ -596,7 +617,8 @@ async function initialize(): Promise { codingProducts, previewDataSession: codingProducts.previewDataSession, }; - registerIpcHandlers(window, backgroundLifecycle, hostApiContext); + windowIpcBindings = registerIpcHandlers(() => mainWindow, backgroundLifecycle, hostApiContext); + unsubscribeAuthSession = subscribeWorksSquareSession(sendAuthSession); void initializeRememberedPassword({ secureStorage: useSecureWorksSquareSessionPersistence ? safeStorage : null, @@ -754,16 +776,12 @@ if (gotTheLock) { // Register activate handler AFTER app is ready to prevent // "Cannot create BrowserWindow before app is ready" on macOS. app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createMainWindow(); - } else { - focusMainWindow(); - } + requestMainWindowFocus('app activation'); }); }); app.on('window-all-closed', () => { - if (process.platform !== 'darwin' || isE2EMode) { + if (process.platform !== 'darwin') { app.quit(); } }); @@ -783,6 +801,8 @@ if (gotTheLock) { return; } + unsubscribeAuthSession?.(); + unsubscribeAuthSession = null; hostEventBus.closeAll(); codingProducts?.previewDataSession?.invalidate('main_shutdown'); hostApiServer?.close(); diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 78866baa..3408fc0a 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -22,6 +22,11 @@ import type { BackgroundLifecycleController, DesktopActivity } from './backgroun import { collectPerformanceSnapshot } from './performance-diagnostics'; import type { HostApiContext } from '../api/context'; import { takeCloudAgentRoute } from './app-deep-link'; +import { resolveMainWindow, type MainWindowProvider } from './window-provider'; + +export interface WindowIpcBindings { + bindWindow(window: BrowserWindow): void; +} type UnifiedRequest = { id?: string; @@ -236,24 +241,41 @@ async function handleUnifiedRequest( } export function registerIpcHandlers( - mainWindow: BrowserWindow, + mainWindow: MainWindowProvider, lifecycle?: BackgroundLifecycleController, hostApiContext?: HostApiContext, -): void { - registerHostApiProxyHandlers(hostApiContext); - registerTranscriptExportHandler(mainWindow); - registerConversationLinkContextMenuHandler(mainWindow); - const rendererLeases = new Set(); - const releaseRendererLeases = (): void => { - if (rendererLeases.size === 0) return; - for (const id of rendererLeases) lifecycle?.releaseLease(id); - rendererLeases.clear(); +): WindowIpcBindings { + const getMainWindow = (): BrowserWindow | null => resolveMainWindow(mainWindow); + const getRequestWindow = (event: Electron.IpcMainInvokeEvent): BrowserWindow | null => ( + (event?.sender && BrowserWindow.fromWebContents(event.sender)) || getMainWindow() + ); + const requireRequestWindow = (event: Electron.IpcMainInvokeEvent): BrowserWindow => { + const window = getRequestWindow(event); + if (!window || window.isDestroyed()) throw new Error('Main window is unavailable'); + return window; }; - const rendererWebContents = mainWindow.webContents; - if (rendererWebContents && typeof rendererWebContents.on === 'function') { - rendererWebContents.on('render-process-gone', releaseRendererLeases); - rendererWebContents.on('destroyed', releaseRendererLeases); - } + registerHostApiProxyHandlers(hostApiContext); + registerTranscriptExportHandler(getMainWindow); + registerConversationLinkContextMenuHandler(getMainWindow); + const leasesByRenderer = new WeakMap>(); + const bindRenderer = (contents: Electron.WebContents): Set => { + const existing = leasesByRenderer.get(contents); + if (existing) return existing; + const leases = new Set(); + leasesByRenderer.set(contents, leases); + const releaseLeases = (): void => { + for (const id of leases) lifecycle?.releaseLease(id); + leases.clear(); + }; + contents.on('render-process-gone', releaseLeases); + contents.on('destroyed', releaseLeases); + return leases; + }; + const bindWindow = (window: BrowserWindow): void => { + if (!window.isDestroyed() && !window.webContents.isDestroyed()) bindRenderer(window.webContents); + }; + const initialWindow = getMainWindow(); + if (initialWindow) bindWindow(initialWindow); ipcMain.handle('app:request', async (_event, request: UnifiedRequest) => ( handleUnifiedRequest(request) )); @@ -270,31 +292,40 @@ export function registerIpcHandlers( }); ipcMain.handle('app:performance', () => collectPerformanceSnapshot(lifecycle)); - ipcMain.handle('lifecycle:activity', (_event, activity: DesktopActivity) => { + ipcMain.handle('lifecycle:activity', (event, activity: DesktopActivity) => { + const window = getMainWindow(); + if (event?.sender && event.sender !== window?.webContents) { + return lifecycle?.getActivity(); + } const nextActivity = activity && typeof activity === 'object' ? activity : { visible: true, module: null }; lifecycle?.setActivity(nextActivity); if ((!nextActivity.visible || nextActivity.module !== 'painting') - && !mainWindow.isDestroyed() - && !mainWindow.webContents.isDestroyed()) { - mainWindow.webContents.send('lifecycle:pause'); + && window + && !window.webContents.isDestroyed()) { + window.webContents.send('lifecycle:pause'); } return lifecycle?.getActivity() ?? nextActivity; }); - ipcMain.handle('lifecycle:lease', (_event, input: { id: string; kind: string; active: boolean }) => { + ipcMain.handle('lifecycle:lease', (event, input: { id: string; kind: string; active: boolean }) => { if (!lifecycle) return { count: 0 }; const id = typeof input?.id === 'string' ? input.id.trim() : ''; if (!id) return { count: lifecycle.getLeaseCount() }; + const contents = event?.sender ?? getMainWindow()?.webContents; + if (!contents || contents.isDestroyed()) return { count: lifecycle.getLeaseCount() }; + const rendererLeases = bindRenderer(contents); + // A late destroyed event from an old renderer must not release a new one's lease. + const leaseId = `renderer:${contents.id}:${id}`; if (input?.active) { lifecycle.acquireLease({ - id, + id: leaseId, kind: typeof input.kind === 'string' && input.kind.trim() ? input.kind.trim() : 'unknown', }); - rendererLeases.add(id); + rendererLeases.add(leaseId); } else { - lifecycle.releaseLease(id); - rendererLeases.delete(id); + lifecycle.releaseLease(leaseId); + rendererLeases.delete(leaseId); } return { count: lifecycle.getLeaseCount() }; }); @@ -330,19 +361,21 @@ export function registerIpcHandlers( ipcMain.handle('shell:showItemInFolder', (_event, path: string) => shell.showItemInFolder(path)); ipcMain.handle('shell:openPath', (_event, path: string) => shell.openPath(path)); - ipcMain.handle('dialog:open', (_event, options: Electron.OpenDialogOptions) => dialog.showOpenDialog(mainWindow, options)); - ipcMain.handle('dialog:save', (_event, options: Electron.SaveDialogOptions) => dialog.showSaveDialog(mainWindow, options)); - ipcMain.handle('dialog:message', (_event, options: Electron.MessageBoxOptions) => dialog.showMessageBox(mainWindow, options)); + ipcMain.handle('dialog:open', (event, options: Electron.OpenDialogOptions) => dialog.showOpenDialog(requireRequestWindow(event), options)); + ipcMain.handle('dialog:save', (event, options: Electron.SaveDialogOptions) => dialog.showSaveDialog(requireRequestWindow(event), options)); + ipcMain.handle('dialog:message', (event, options: Electron.MessageBoxOptions) => dialog.showMessageBox(requireRequestWindow(event), options)); - ipcMain.handle('window:minimize', () => mainWindow.minimize()); - ipcMain.handle('window:maximize', () => { - if (mainWindow.isMaximized()) { - mainWindow.unmaximize(); + ipcMain.handle('window:minimize', (event) => requireRequestWindow(event).minimize()); + ipcMain.handle('window:maximize', (event) => { + const window = requireRequestWindow(event); + if (window.isMaximized()) { + window.unmaximize(); return false; } - mainWindow.maximize(); + window.maximize(); return true; }); - ipcMain.handle('window:close', () => mainWindow.close()); - ipcMain.handle('window:isMaximized', () => mainWindow.isMaximized()); + ipcMain.handle('window:close', (event) => requireRequestWindow(event).close()); + ipcMain.handle('window:isMaximized', (event) => requireRequestWindow(event).isMaximized()); + return { bindWindow }; } diff --git a/electron/main/ipc/conversation-link-context-menu.ts b/electron/main/ipc/conversation-link-context-menu.ts index 2fe963b2..8b1cc46c 100644 --- a/electron/main/ipc/conversation-link-context-menu.ts +++ b/electron/main/ipc/conversation-link-context-menu.ts @@ -7,6 +7,7 @@ import { shell, type MenuItemConstructorOptions, } from 'electron'; +import { resolveMainWindow, type MainWindowProvider } from '../window-provider'; const MAX_LINK_LENGTH = 4_096; const LOCAL_WEB_FILE_PATTERN = /\.(?:html?|xhtml)$/i; @@ -117,7 +118,7 @@ export function createConversationLinkMenuTemplate( throw new Error('Unsupported link context menu request'); } -export function registerConversationLinkContextMenuHandler(mainWindow: BrowserWindow): void { +export function registerConversationLinkContextMenuHandler(mainWindow: MainWindowProvider): void { ipcMain.handle('shell:showLinkContextMenu', (event, request: ConversationLinkContextMenuRequest) => { const template = createConversationLinkMenuTemplate(request, { platform: process.platform, @@ -128,7 +129,7 @@ export function registerConversationLinkContextMenuHandler(mainWindow: BrowserWi writeText: (value) => clipboard.writeText(value), }); const menu = Menu.buildFromTemplate(template); - const ownerWindow = BrowserWindow.fromWebContents(event.sender) ?? mainWindow; - menu.popup({ window: ownerWindow }); + const ownerWindow = BrowserWindow.fromWebContents(event.sender) ?? resolveMainWindow(mainWindow); + menu.popup(ownerWindow ? { window: ownerWindow } : {}); }); } diff --git a/electron/main/ipc/transcript-export.ts b/electron/main/ipc/transcript-export.ts index 8bf364b0..ad458423 100644 --- a/electron/main/ipc/transcript-export.ts +++ b/electron/main/ipc/transcript-export.ts @@ -1,5 +1,6 @@ import { writeFile } from 'node:fs/promises'; -import { dialog, ipcMain, type BrowserWindow } from 'electron'; +import { BrowserWindow, dialog, ipcMain } from 'electron'; +import { resolveMainWindow, type MainWindowProvider } from '../window-provider'; interface TranscriptSaveRequest { defaultPath?: unknown; @@ -15,8 +16,8 @@ function safeDefaultPath(value: unknown): string { return fileName.toLowerCase().endsWith('.md') ? fileName : `${fileName}.md`; } -export function registerTranscriptExportHandler(mainWindow: BrowserWindow): void { - ipcMain.handle('transcript:save', async (_event, request: TranscriptSaveRequest) => { +export function registerTranscriptExportHandler(mainWindow: MainWindowProvider): void { + ipcMain.handle('transcript:save', async (event, request: TranscriptSaveRequest) => { if (typeof request?.markdown !== 'string') { throw new Error('Invalid transcript markdown'); } @@ -26,10 +27,15 @@ export function registerTranscriptExportHandler(mainWindow: BrowserWindow): void let selected: Electron.SaveDialogReturnValue; try { - selected = await dialog.showSaveDialog(mainWindow, { + const owner = (event?.sender && BrowserWindow.fromWebContents(event.sender)) + || resolveMainWindow(mainWindow); + const options: Electron.SaveDialogOptions = { defaultPath: safeDefaultPath(request.defaultPath), filters: [{ name: 'Markdown', extensions: ['md'] }], - }); + }; + selected = owner + ? await dialog.showSaveDialog(owner, options) + : await dialog.showSaveDialog(options); } catch { throw new Error('Failed to save transcript'); } diff --git a/electron/main/main-window-close.ts b/electron/main/main-window-close.ts new file mode 100644 index 00000000..f9f36a53 --- /dev/null +++ b/electron/main/main-window-close.ts @@ -0,0 +1,50 @@ +import type { BrowserWindow } from 'electron'; + +/** Keep a fullscreen Space alive until macOS has finished leaving it. */ +export function registerMainWindowClose( + window: BrowserWindow, + options: { + platform: NodeJS.Platform; + isQuitting: () => boolean; + hideOnClose: boolean; + }, +): void { + let pendingClose = false; + let leavingFullscreen = false; + + const cancelPendingClose = (): void => { + pendingClose = false; + window.removeListener('leave-full-screen', finishClose); + }; + const finishClose = (): void => { + pendingClose = false; + if (!window.isDestroyed() && !options.isQuitting()) window.close(); + }; + + window.on('leave-full-screen', () => { leavingFullscreen = false; }); + window.on('close', (event) => { + if (options.isQuitting()) { + cancelPendingClose(); + return; + } + if (pendingClose) { + event.preventDefault(); + return; + } + if (options.platform === 'darwin' && window.isFullScreen()) { + event.preventDefault(); + pendingClose = true; + window.once('leave-full-screen', finishClose); + if (!leavingFullscreen) { + leavingFullscreen = true; + window.setFullScreen(false); + } + return; + } + if (options.hideOnClose) { + event.preventDefault(); + window.hide(); + } + }); + window.once('closed', cancelPendingClose); +} diff --git a/electron/main/menu.ts b/electron/main/menu.ts index 8f88967b..894c2b83 100644 --- a/electron/main/menu.ts +++ b/electron/main/menu.ts @@ -3,12 +3,18 @@ * Creates the native application menu for macOS/Windows/Linux */ import { Menu, app, BrowserWindow } from 'electron'; +import { sendWhenWindowReady, showMainWindow, type EnsureMainWindow } from './window-provider'; /** * Create application menu */ -export function createMenu(): void { +export function createMenu(ensureMainWindow?: EnsureMainWindow): void { const isMac = process.platform === 'darwin'; + const navigate = (route: string): void => { + const window = ensureMainWindow ? ensureMainWindow() : BrowserWindow.getFocusedWindow(); + showMainWindow(window); + sendWhenWindowReady(window, 'navigate', route); + }; const template: Electron.MenuItemConstructorOptions[] = [ // App menu (macOS only) @@ -22,10 +28,7 @@ export function createMenu(): void { { label: 'Preferences...', accelerator: 'Cmd+,', - click: () => { - const win = BrowserWindow.getFocusedWindow(); - win?.webContents.send('navigate', '/settings'); - }, + click: () => navigate('/settings'), }, { type: 'separator' as const }, { role: 'services' as const }, @@ -47,10 +50,7 @@ export function createMenu(): void { { label: 'New Chat', accelerator: 'CmdOrCtrl+N', - click: () => { - const win = BrowserWindow.getFocusedWindow(); - win?.webContents.send('navigate', '/'); - }, + click: () => navigate('/'), }, { type: 'separator' }, isMac ? { role: 'close' } : { role: 'quit' }, @@ -104,42 +104,27 @@ export function createMenu(): void { { label: 'Dashboard', accelerator: 'CmdOrCtrl+1', - click: () => { - const win = BrowserWindow.getFocusedWindow(); - win?.webContents.send('navigate', '/'); - }, + click: () => navigate('/'), }, { label: 'Sessions', accelerator: 'CmdOrCtrl+2', - click: () => { - const win = BrowserWindow.getFocusedWindow(); - win?.webContents.send('navigate', '/sessions'); - }, + click: () => navigate('/sessions'), }, { label: 'Projects', accelerator: 'CmdOrCtrl+3', - click: () => { - const win = BrowserWindow.getFocusedWindow(); - win?.webContents.send('navigate', '/projects'); - }, + click: () => navigate('/projects'), }, { label: 'Models', accelerator: 'CmdOrCtrl+4', - click: () => { - const win = BrowserWindow.getFocusedWindow(); - win?.webContents.send('navigate', '/models'); - }, + click: () => navigate('/models'), }, { label: 'Settings', accelerator: isMac ? 'Cmd+,' : 'Ctrl+,', - click: () => { - const win = BrowserWindow.getFocusedWindow(); - win?.webContents.send('navigate', '/settings'); - }, + click: () => navigate('/settings'), }, ], }, diff --git a/electron/main/tray.ts b/electron/main/tray.ts index 87d76793..110dc21c 100644 --- a/electron/main/tray.ts +++ b/electron/main/tray.ts @@ -2,10 +2,19 @@ * System Tray Management * Creates and manages the system tray icon and menu */ -import { Tray, Menu, BrowserWindow, app, nativeImage } from 'electron'; +import { Tray, Menu, app, nativeImage } from 'electron'; import { join } from 'path'; +import { + resolveMainWindow, + sendWhenWindowReady, + showMainWindow, + type EnsureMainWindow, + type MainWindowProvider, +} from './window-provider'; let tray: Tray | null = null; +let windowProvider: MainWindowProvider = () => null; +let ensureWindow: EnsureMainWindow | undefined; /** * Resolve the icons directory path (works in both dev and packaged mode) @@ -20,7 +29,10 @@ function getIconsDir(): string { /** * Create system tray icon and menu */ -export function createTray(mainWindow: BrowserWindow): Tray { +export function createTray(mainWindow: MainWindowProvider, ensureMainWindow?: EnsureMainWindow): Tray { + windowProvider = mainWindow; + ensureWindow = ensureMainWindow; + if (tray && !tray.isDestroyed()) return tray; // Use platform-appropriate icon for system tray const iconsDir = getIconsDir(); let iconPath: string; @@ -60,9 +72,9 @@ export function createTray(mainWindow: BrowserWindow): Tray { tray.setToolTip('Makelore - AI Assistant'); const showWindow = () => { - if (mainWindow.isDestroyed()) return; - mainWindow.show(); - mainWindow.focus(); + const window = resolveMainWindow(windowProvider) ?? ensureWindow?.() ?? null; + showMainWindow(window); + return window; }; // Create context menu @@ -93,17 +105,13 @@ export function createTray(mainWindow: BrowserWindow): Tray { { label: 'Open Chat', click: () => { - if (mainWindow.isDestroyed()) return; - mainWindow.show(); - mainWindow.webContents.send('navigate', '/'); + sendWhenWindowReady(showWindow(), 'navigate', '/'); }, }, { label: 'Open Settings', click: () => { - if (mainWindow.isDestroyed()) return; - mainWindow.show(); - mainWindow.webContents.send('navigate', '/settings'); + sendWhenWindowReady(showWindow(), 'navigate', '/settings'); }, }, ], @@ -114,8 +122,7 @@ export function createTray(mainWindow: BrowserWindow): Tray { { label: 'Check for Updates...', click: () => { - if (mainWindow.isDestroyed()) return; - mainWindow.webContents.send('update:check'); + sendWhenWindowReady(showWindow(), 'update:check'); }, }, { @@ -133,21 +140,16 @@ export function createTray(mainWindow: BrowserWindow): Tray { // Click to show window (Windows/Linux) tray.on('click', () => { - if (mainWindow.isDestroyed()) return; - if (mainWindow.isVisible()) { - mainWindow.hide(); + const window = resolveMainWindow(windowProvider); + if (window?.isVisible()) { + window.hide(); } else { - mainWindow.show(); - mainWindow.focus(); + showWindow(); } }); // Double-click to show window (Windows) - tray.on('double-click', () => { - if (mainWindow.isDestroyed()) return; - mainWindow.show(); - mainWindow.focus(); - }); + tray.on('double-click', showWindow); return tray; } @@ -168,5 +170,7 @@ export function destroyTray(): void { if (tray) { tray.destroy(); tray = null; + windowProvider = () => null; + ensureWindow = undefined; } } diff --git a/electron/main/updater.ts b/electron/main/updater.ts index f68d2d31..dd08323d 100644 --- a/electron/main/updater.ts +++ b/electron/main/updater.ts @@ -133,7 +133,7 @@ export class AppUpdater extends EventEmitter { /** * Set the main window for sending update events */ - setMainWindow(window: BrowserWindow): void { + setMainWindow(window: BrowserWindow | null): void { this.mainWindow = window; } diff --git a/electron/main/window-provider.ts b/electron/main/window-provider.ts new file mode 100644 index 00000000..75b2d738 --- /dev/null +++ b/electron/main/window-provider.ts @@ -0,0 +1,39 @@ +import type { BrowserWindow } from 'electron'; + +export type MainWindowProvider = BrowserWindow | (() => BrowserWindow | null); +export type EnsureMainWindow = () => BrowserWindow | null; + +export function resolveMainWindow(provider: MainWindowProvider): BrowserWindow | null { + const window = typeof provider === 'function' ? provider() : provider; + return window && !window.isDestroyed?.() ? window : null; +} + +export function showMainWindow(window: BrowserWindow | null): void { + if (!window || window.isDestroyed()) return; + if (window.isMinimized()) window.restore(); + window.show(); + window.focus(); +} + +/** A newly reopened renderer must finish loading before it can receive menu actions. */ +export function sendWhenWindowReady( + window: BrowserWindow | null, + channel: string, + ...args: unknown[] +): void { + if (!window || window.isDestroyed() || window.webContents.isDestroyed()) return; + const contents = window.webContents; + const send = (): void => { + contents.removeListener('destroyed', cancel); + if (!window.isDestroyed() && !contents.isDestroyed()) contents.send(channel, ...args); + }; + const cancel = (): void => { + contents.removeListener('did-finish-load', send); + }; + if (contents.isLoadingMainFrame() || !contents.getURL()) { + contents.once('did-finish-load', send); + contents.once('destroyed', cancel); + } else { + send(); + } +} diff --git a/tests/e2e/main-window-lifecycle.spec.ts b/tests/e2e/main-window-lifecycle.spec.ts new file mode 100644 index 00000000..d0b4037f --- /dev/null +++ b/tests/e2e/main-window-lifecycle.spec.ts @@ -0,0 +1,188 @@ +import type { ElectronApplication, Page } from '@playwright/test'; +import { mkdir } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; +import type { AgentBrowserSnapshot } from '../../shared/agent-browser'; +import { expect, getStableWindow, test } from './fixtures/electron'; + +async function hostRequest(page: Page, path: string, body?: unknown): Promise { + const response = await page.evaluate(async (request) => { + return await window.electron.ipcRenderer.invoke('hostapi:fetch', request) as { + ok: boolean; + data?: { ok: boolean; status: number; json?: unknown }; + error?: unknown; + }; + }, { path, method: body === undefined ? 'GET' : 'POST', body }); + expect(response, path).toMatchObject({ ok: true, data: { ok: true } }); + return response.data!.json as T; +} + +async function mainWindowState(app: ElectronApplication) { + return await app.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + return win ? { + id: win.id, + minimized: win.isMinimized(), + maximized: win.isMaximized(), + fullScreen: win.isFullScreen(), + visible: win.isVisible(), + } : null; + }); +} + +async function reopenMainWindow(app: ElectronApplication): Promise { + const opened = app.waitForEvent('window'); + await app.evaluate(({ app: electronApp }) => { electronApp.emit('activate'); }); + await opened; + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + await expect.poll(async () => (await mainWindowState(app))?.visible).toBe(true); + return page; +} + +async function quitApplication(app: ElectronApplication): Promise { + const child = app.process(); + const closed = app.waitForEvent('close', { timeout: 15_000 }); + // The native Cmd+Q menu role calls this same Electron quit lifecycle. + await app.evaluate(({ app: electronApp }) => { electronApp.quit(); }); + await closed; + await expect.poll(() => child.exitCode).not.toBeNull(); + expect(child.exitCode).toBe(0); +} + +test('macOS closes and reopens the real window with working controls and a newly attached preview', async ({ + launchElectronApp, + electronTestResources, +}) => { + test.skip(process.platform !== 'darwin', 'macOS keeps the application running after its last window closes'); + const projectPath = join(electronTestResources.rootDir, 'window-lifecycle-project'); + await mkdir(projectPath); + const server = createServer((_request, response) => { + response.setHeader('Content-Type', 'text/html; charset=utf-8'); + response.end('Window lifecycle preview

Isolated local preview

'); + }); + await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve); }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing local preview port'); + const url = `http://127.0.0.1:${address.port}/`; + + try { + const app = await launchElectronApp({ skipSetup: true }); + let page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + const pid = app.process().pid; + expect(await app.evaluate(({ app: electronApp }) => electronApp.getPath('userData'))) + .toBe(electronTestResources.userDataDir); + const { project } = await hostRequest<{ project: { id: string } }>(page, '/api/coding/projects/open', { projectPath }); + const windowIds = new Set(); + + for (let cycle = 0; cycle < 3; cycle += 1) { + const state = await mainWindowState(app); + expect(state).not.toBeNull(); + expect(windowIds.has(state!.id)).toBe(false); + windowIds.add(state!.id); + expect(app.process().pid).toBe(pid); + expect(app.process().exitCode).toBeNull(); + expect(await page.evaluate(() => window.electron.ipcRenderer.invoke('app:platform'))).toBe('darwin'); + expect(await page.evaluate(() => window.electron.ipcRenderer.invoke('window:isMaximized'))).toBe(false); + + await page.evaluate(() => window.electron.ipcRenderer.invoke('window:minimize')); + await expect.poll(async () => (await mainWindowState(app))?.minimized).toBe(true); + await app.evaluate(({ BrowserWindow }) => { BrowserWindow.getAllWindows()[0].restore(); }); + await expect.poll(async () => (await mainWindowState(app))?.minimized).toBe(false); + await page.evaluate(() => window.electron.ipcRenderer.invoke('window:maximize')); + await expect.poll(async () => (await mainWindowState(app))?.maximized).toBe(true); + await page.evaluate(() => window.electron.ipcRenderer.invoke('window:maximize')); + await expect.poll(async () => (await mainWindowState(app))?.maximized).toBe(false); + + // Hold only this renderer's test lease while inspecting a preview outside + // the Code screen; closing the renderer must release it automatically. + await page.evaluate(async () => { + await window.electron.ipcRenderer.invoke('lifecycle:lease', { + id: 'window-lifecycle-preview', kind: 'test-preview', active: true, + }); + const state = window as typeof window & { __windowLifecycleBrowserEvents?: unknown[] }; + state.__windowLifecycleBrowserEvents = []; + window.electron.ipcRenderer.on('agent-browser:show', (...args) => { + state.__windowLifecycleBrowserEvents!.push(args.at(-1)); + }); + }); + const result = await hostRequest<{ browser: AgentBrowserSnapshot }>(page, '/api/agent-browser/open', { + project_id: project.id, + url, + visible: true, + bounds: { x: 20, y: 70, width: 420, height: 300 }, + }); + expect(result.browser).toMatchObject({ state: 'attached', visible: true, url }); + await expect.poll(async () => await page.evaluate(() => ( + window as typeof window & { __windowLifecycleBrowserEvents?: unknown[] } + ).__windowLifecycleBrowserEvents?.length)).toBeGreaterThan(0); + const previewId = await app.evaluate(({ BrowserWindow, WebContentsView }, previewUrl) => { + const win = BrowserWindow.getAllWindows()[0]; + const view = win.contentView.children.find((child) => ( + child instanceof WebContentsView && child.webContents.getURL() === previewUrl + )); + if (!(view instanceof WebContentsView)) throw new Error('Preview was not attached to the current window'); + if (!view.getVisible()) throw new Error('Attached preview was not visible'); + return view.webContents.id; + }, url); + + if (cycle === 2) break; + const closed = page.waitForEvent('close'); + if (cycle === 0) { + // BrowserWindow.close follows the native red traffic-light close path. + await app.evaluate(({ BrowserWindow }) => { BrowserWindow.getAllWindows()[0].close(); }); + } else { + await page.evaluate(() => { + void window.electron.ipcRenderer.invoke('window:close').catch(() => undefined); + }); + } + await closed; + await expect.poll(async () => await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)).toBe(0); + await expect.poll(async () => await app.evaluate(({ webContents }, id) => Boolean(webContents.fromId(id)), previewId)).toBe(false); + expect(app.process().exitCode).toBeNull(); + page = await reopenMainWindow(app); + } + + await quitApplication(app); + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); + +test('macOS leaves native fullscreen before closing and can reopen and quit normally', async ({ launchElectronApp }) => { + test.skip(process.platform !== 'darwin', 'Native macOS fullscreen transition'); + const app = await launchElectronApp({ skipSetup: true }); + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + + await app.evaluate(async ({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + const entered = new Promise((resolve) => { win.once('enter-full-screen', () => resolve()); }); + win.setFullScreen(true); + await entered; + }); + expect((await mainWindowState(app))?.fullScreen).toBe(true); + + const events = await app.evaluate(async ({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + const sequence: string[] = []; + return await new Promise((resolve) => { + // Record the native event before the production listener is allowed to + // synchronously finish closing the window in response to it. + win.prependOnceListener('leave-full-screen', () => { sequence.push('leave-full-screen'); }); + win.once('closed', () => { + sequence.push('closed'); + resolve(sequence); + }); + win.close(); + }); + }); + expect(events).toEqual(['leave-full-screen', 'closed']); + await expect.poll(async () => await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)).toBe(0); + expect(app.process().exitCode).toBeNull(); + await reopenMainWindow(app); + expect(await mainWindowState(app)).toMatchObject({ fullScreen: false, minimized: false, visible: true }); + await quitApplication(app); +}); diff --git a/tests/unit/agent-browser-core.test.ts b/tests/unit/agent-browser-core.test.ts index 00d03e8b..e7c759f5 100644 --- a/tests/unit/agent-browser-core.test.ts +++ b/tests/unit/agent-browser-core.test.ts @@ -1742,6 +1742,67 @@ describe('AgentBrowserModule', () => { expect(adapter.resetPartitions).toEqual([agentBrowserPartition(projectPath)]); }); + it.each(['close', 'replace-preview', 'switch-project'] as const)( + 'waits for old preview cleanup and cancels stale opens when reopening after %s', + async (scenario) => { + const adapter = new FakeAdapter(); + const preview = new FakePreviewDataSession(); + adapter.onCreate = (view) => { + view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({ + identifier: 'script-root', + })); + }; + const module = new AgentBrowserModule(adapter, { previewDataSession: preview }); + await module.open({ + projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, + }); + let finishCleanup!: () => void; + const cleanup = new Promise((resolve) => { finishCleanup = resolve; }); + const cleanupStarted = vi.fn(async () => await cleanup); + adapter.views[0].webContents.debugger.responders.set( + 'Page.removeScriptToEvaluateOnNewDocument', cleanupStarted, + ); + const superseded = scenario === 'close' ? undefined : module.open({ + projectId: 'clock', + projectPath: scenario === 'switch-project' ? `${projectPath}-other` : projectPath, + url: 'http://localhost:3001/', + }); + const supersededResult = superseded?.catch((error: unknown) => error); + if (superseded) await vi.waitFor(() => expect(cleanupStarted).toHaveBeenCalledOnce()); + + const closing = module.close(); + await vi.waitFor(() => expect(cleanupStarted).toHaveBeenCalledOnce()); + const reopening = module.open({ + projectId: 'clock', projectPath, url: 'http://localhost:3002/', + }); + await Promise.resolve(); + expect(adapter.views).toHaveLength(1); + + finishCleanup(); + await expect(closing).resolves.toMatchObject({ state: 'closed' }); + if (supersededResult) await expect(supersededResult).resolves.toMatchObject({ code: 'CLOSED' }); + await expect(reopening).resolves.toMatchObject({ + state: 'attached', generation: 2, url: 'http://localhost:3002/', + }); + expect(adapter.views).toHaveLength(2); + expect(adapter.destroyed).toBe(1); + expect(adapter.views[1].webContents.destroyed).toBe(false); + }, + ); + + it('cleans up a new view if its host window has already closed', async () => { + const adapter = new FakeAdapter(); + vi.spyOn(adapter, 'mount').mockImplementation(() => { throw new Error('window-closed'); }); + const module = new AgentBrowserModule(adapter); + + await expect(module.open({ + projectId: 'clock', projectPath, url: 'http://localhost:3001/', + })).rejects.toThrow('window-closed'); + expect(adapter.destroyed).toBe(1); + expect(adapter.views[0].webContents.destroyed).toBe(true); + await expect(module.getSnapshot()).resolves.toMatchObject({ state: 'closed' }); + }); + it('does not reopen a project partition until profile reset has finished', async () => { const adapter = new FakeAdapter(); let finishReset: (() => void) | undefined; diff --git a/tests/unit/agent-browser-electron-adapter.test.ts b/tests/unit/agent-browser-electron-adapter.test.ts index a2ff96cd..73b4ac70 100644 --- a/tests/unit/agent-browser-electron-adapter.test.ts +++ b/tests/unit/agent-browser-electron-adapter.test.ts @@ -106,7 +106,7 @@ describe('ElectronAgentBrowserAdapter', () => { }); it.each([false, true])('captures a page without presenting a hidden native view (visible=%s)', async (visible) => { - const adapter = new ElectronAgentBrowserAdapter({} as never); + const adapter = new ElectronAgentBrowserAdapter(() => null); const view = adapter.createView('browser-screenshot'); const native = electronMocks.nativeViews[0]; native.getVisible.mockReturnValue(visible); @@ -184,6 +184,46 @@ describe('ElectronAgentBrowserAdapter', () => { ).toHaveBeenCalledOnce(); }); + it('binds new views to the reopened window and never detaches them from an old owner', () => { + const makeWindow = () => ({ + isDestroyed: vi.fn().mockReturnValue(false), + contentView: { addChildView: vi.fn(), removeChildView: vi.fn() }, + webContents: { getZoomFactor: vi.fn().mockReturnValue(1) }, + getContentBounds: vi.fn().mockReturnValue({ width: 900, height: 650 }), + }); + const first = makeWindow(); + const second = makeWindow(); + let current: ReturnType | null = first; + const adapter = new ElectronAgentBrowserAdapter(() => current as never); + const oldView = adapter.createView('first'); + adapter.mount(oldView); + first.isDestroyed.mockReturnValue(true); + current = null; + adapter.unmount(oldView); + adapter.destroy(oldView); + current = second; + const newView = adapter.createView('second'); + adapter.mount(newView); + newView.setBounds({ x: 10, y: 10, width: 800, height: 600 }); + // A late old-view cleanup cannot remove a view from the replacement window. + adapter.unmount(oldView); + expect(second.contentView.addChildView).toHaveBeenCalledWith(electronMocks.nativeViews[1]); + expect(second.contentView.removeChildView).not.toHaveBeenCalled(); + expect(first.contentView.addChildView).toHaveBeenCalledTimes(1); + expect(electronMocks.nativeViews[1].setBounds).toHaveBeenCalledWith({ x: 10, y: 10, width: 800, height: 600 }); + }); + + it('allows detached preflight views but refuses visible mounting without a window', () => { + const adapter = new ElectronAgentBrowserAdapter(() => null); + const view = adapter.createView('detached'); + view.setBounds({ x: 0, y: 0, width: 390, height: 844 }); + expect(electronMocks.nativeViews[0].setBounds).toHaveBeenCalledWith({ x: 0, y: 0, width: 390, height: 844 }); + expect(() => adapter.mount(view)).toThrow('请先打开麦洛窗口'); + expect(() => adapter.unmount(view)).not.toThrow(); + adapter.destroy(view); + expect((electronMocks.nativeViews[0].webContents as { close: unknown }).close).toHaveBeenCalledOnce(); + }); + it('denies downloads and clears only the requested project partition', async () => { const adapter = new ElectronAgentBrowserAdapter({ isDestroyed: vi.fn().mockReturnValue(false), diff --git a/tests/unit/main-window-close.test.ts b/tests/unit/main-window-close.test.ts new file mode 100644 index 00000000..388c01ee --- /dev/null +++ b/tests/unit/main-window-close.test.ts @@ -0,0 +1,94 @@ +import { EventEmitter } from 'node:events'; +import { describe, expect, it, vi } from 'vitest'; +import { registerMainWindowClose } from '@electron/main/main-window-close'; + +class WindowStub extends EventEmitter { + fullscreen = false; + destroyed = false; + hide = vi.fn(); + setFullScreen = vi.fn(); + isFullScreen = () => this.fullscreen; + isDestroyed = () => this.destroyed; + close = vi.fn(() => { + const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; + this.emit('close', event); + if (!event.defaultPrevented) { + this.destroyed = true; + this.emit('closed'); + } + }); + leaveFullscreen(): void { + this.fullscreen = false; + this.emit('leave-full-screen'); + } +} + +function setup(fullscreen = false) { + const window = new WindowStub(); + window.fullscreen = fullscreen; + let quitting = false; + registerMainWindowClose(window as never, { + platform: 'darwin', isQuitting: () => quitting, hideOnClose: false, + }); + return { window, quit: () => { quitting = true; window.close(); } }; +} + +describe('native main window close', () => { + it('really destroys a normal macOS window instead of hiding it', () => { + const { window } = setup(); + window.close(); + expect(window.destroyed).toBe(true); + expect(window.hide).not.toHaveBeenCalled(); + expect(window.setFullScreen).not.toHaveBeenCalled(); + }); + + it('waits for the fullscreen exit before closing, including repeated clicks', () => { + const { window } = setup(true); + const order: string[] = []; + window.prependListener('leave-full-screen', () => order.push('left fullscreen')); + window.on('closed', () => order.push('closed')); + window.close(); + window.close(); + expect(window.destroyed).toBe(false); + expect(window.hide).not.toHaveBeenCalled(); + expect(window.setFullScreen).toHaveBeenCalledExactlyOnceWith(false); + window.leaveFullscreen(); + expect(window.destroyed).toBe(true); + expect(order).toEqual(['left fullscreen', 'closed']); + }); + + it('does not delay a real app quit while fullscreen exit is pending', () => { + const { window, quit } = setup(true); + window.close(); + quit(); + expect(window.destroyed).toBe(true); + window.leaveFullscreen(); + expect(window.close).toHaveBeenCalledTimes(2); + }); + + it('lets Cmd+Q close a fullscreen window without starting another transition', () => { + const { window, quit } = setup(true); + quit(); + expect(window.destroyed).toBe(true); + expect(window.setFullScreen).not.toHaveBeenCalled(); + }); + + it('cancels deferred closing if the window was destroyed externally', () => { + const { window } = setup(true); + window.close(); + window.destroyed = true; + window.emit('closed'); + window.leaveFullscreen(); + expect(window.close).toHaveBeenCalledTimes(1); + }); + + it('preserves existing close-to-tray behavior on other platforms', () => { + const window = new WindowStub(); + registerMainWindowClose(window as never, { + platform: 'win32', isQuitting: () => false, hideOnClose: true, + }); + window.close(); + expect(window.destroyed).toBe(false); + expect(window.hide).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/main-window-reopen-ipc.test.ts b/tests/unit/main-window-reopen-ipc.test.ts new file mode 100644 index 00000000..532456c6 --- /dev/null +++ b/tests/unit/main-window-reopen-ipc.test.ts @@ -0,0 +1,143 @@ +import { EventEmitter } from 'node:events'; +import type { BrowserWindow, WebContents } from 'electron'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BackgroundLifecycleController } from '../../electron/main/background-lifecycle'; + +function createWindow(id: number) { + const contents = Object.assign(new EventEmitter(), { + id, + isDestroyed: vi.fn(() => false), + send: vi.fn(), + }); + return { + id, + webContents: contents, + isDestroyed: vi.fn(() => false), + minimize: vi.fn(), + maximize: vi.fn(), + unmaximize: vi.fn(), + isMaximized: vi.fn(() => false), + close: vi.fn(), + }; +} + +async function setup() { + type Handler = (event: { sender: WebContents }, input?: unknown) => unknown; + const handlers = new Map(); + const owners = new Map(); + const showSaveDialog = vi.fn().mockResolvedValue({ canceled: true }); + const popup = vi.fn(); + const handle = vi.fn((channel: string, handler: Handler) => { + if (handlers.has(channel)) throw new Error(`duplicate channel: ${channel}`); + handlers.set(channel, handler); + }); + vi.doMock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp/test'), quit: vi.fn() }, + ipcMain: { handle }, + BrowserWindow: { fromWebContents: (contents: WebContents) => owners.get(contents) ?? null }, + dialog: { showSaveDialog, showOpenDialog: vi.fn(), showMessageBox: vi.fn() }, + shell: { openExternal: vi.fn(), openPath: vi.fn(), showItemInFolder: vi.fn() }, + clipboard: { writeText: vi.fn() }, + Menu: { buildFromTemplate: vi.fn(() => ({ popup })) }, + })); + vi.doMock('../../electron/main/ipc/host-api-proxy', () => ({ registerHostApiProxyHandlers: vi.fn() })); + vi.doMock('../../electron/main/proxy', () => ({ applyProxySettings: vi.fn() })); + vi.doMock('../../electron/main/launch-at-startup', () => ({ syncLaunchAtStartupSettingFromStore: vi.fn() })); + vi.doMock('../../electron/utils/store', () => ({ + getAllSettings: vi.fn(), getSetting: vi.fn(), resetSettings: vi.fn(), setSetting: vi.fn(), + })); + vi.doMock('../../electron/services/providers/provider-service', () => ({ getProviderService: vi.fn() })); + vi.doMock('../../electron/services/providers/provider-validation', () => ({ validateApiKeyWithProvider: vi.fn() })); + vi.doMock('../../electron/main/admin-access', () => ({ + isAdminSessionUnlocked: vi.fn(), lockAdminSession: vi.fn(), verifyAdminPassword: vi.fn(), + })); + vi.doMock('../../electron/main/performance-diagnostics', () => ({ collectPerformanceSnapshot: vi.fn() })); + vi.doMock('../../electron/main/app-deep-link', () => ({ takeCloudAgentRoute: vi.fn() })); + + const first = createWindow(1); + const second = createWindow(2); + owners.set(first.webContents, first as unknown as BrowserWindow); + owners.set(second.webContents, second as unknown as BrowserWindow); + let current: BrowserWindow | null = first as unknown as BrowserWindow; + const leases = new Set(); + const lifecycle = { + acquireLease: vi.fn(({ id }: { id: string }) => { leases.add(id); }), + releaseLease: vi.fn((id: string) => { leases.delete(id); }), + getLeaseCount: () => leases.size, + getActivity: vi.fn(() => ({ visible: true, module: 'programming' })), + setActivity: vi.fn(), + }; + const { registerIpcHandlers } = await import('../../electron/main/ipc-handlers'); + const bindings = registerIpcHandlers(() => current, lifecycle as unknown as BackgroundLifecycleController); + const invoke = (channel: string, window: ReturnType, input?: unknown) => { + const handler = handlers.get(channel); + if (!handler) throw new Error(`missing channel: ${channel}`); + return handler({ sender: window.webContents as unknown as WebContents }, input); + }; + const reopen = () => { + first.isDestroyed.mockReturnValue(true); + current = second as unknown as BrowserWindow; + bindings.bindWindow(current); + }; + return { first, second, bindings, handle, lifecycle, leases, invoke, reopen, showSaveDialog, popup }; +} + +describe('IPC after closing and reopening the main window', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('controls the replacement window without registering any IPC channel again', async () => { + const ctx = await setup(); + const registeredCount = ctx.handle.mock.calls.length; + ctx.reopen(); + ctx.bindings.bindWindow(ctx.second as unknown as BrowserWindow); + ctx.invoke('window:minimize', ctx.second); + ctx.invoke('window:maximize', ctx.second); + ctx.invoke('window:close', ctx.second); + expect(ctx.second.minimize).toHaveBeenCalledOnce(); + expect(ctx.second.maximize).toHaveBeenCalledOnce(); + expect(ctx.second.close).toHaveBeenCalledOnce(); + expect(ctx.first.close).not.toHaveBeenCalled(); + expect(ctx.handle).toHaveBeenCalledTimes(registeredCount); + expect(ctx.second.webContents.listenerCount('destroyed')).toBe(1); + }); + + it('parents dialogs and link menus to the reopened renderer', async () => { + const ctx = await setup(); + ctx.reopen(); + await ctx.invoke('dialog:save', ctx.second, { defaultPath: 'new.md' }); + expect(ctx.showSaveDialog).toHaveBeenLastCalledWith(ctx.second, { defaultPath: 'new.md' }); + await ctx.invoke('transcript:save', ctx.second, { markdown: '# Transcript', defaultPath: 'chat.md' }); + expect(ctx.showSaveDialog).toHaveBeenLastCalledWith(ctx.second, expect.objectContaining({ defaultPath: 'chat.md' })); + ctx.invoke('shell:showLinkContextMenu', ctx.second, { kind: 'external', target: 'https://example.com' }); + expect(ctx.popup).toHaveBeenCalledWith({ window: ctx.second }); + }); + + it('releases each renderer lease without releasing main-owned or replacement leases', async () => { + const ctx = await setup(); + ctx.lifecycle.acquireLease({ id: 'coding-run:background' }); + ctx.invoke('lifecycle:lease', ctx.first, { id: 'paint', kind: 'painting', active: true }); + ctx.reopen(); + ctx.invoke('lifecycle:lease', ctx.second, { id: 'paint', kind: 'painting', active: true }); + expect(ctx.leases.size).toBe(3); + ctx.first.webContents.emit('destroyed'); + expect(ctx.leases).toEqual(new Set(['coding-run:background', 'renderer:2:paint'])); + ctx.second.webContents.emit('render-process-gone'); + ctx.second.webContents.emit('destroyed'); + expect(ctx.leases).toEqual(new Set(['coding-run:background'])); + expect(ctx.lifecycle.releaseLease).toHaveBeenCalledTimes(2); + }); + + it('ignores stale activity from the closed renderer and notifies the current renderer', async () => { + const ctx = await setup(); + ctx.reopen(); + ctx.invoke('lifecycle:activity', ctx.first, { visible: false, module: 'painting' }); + expect(ctx.lifecycle.setActivity).not.toHaveBeenCalled(); + ctx.invoke('lifecycle:activity', ctx.second, { visible: true, module: 'programming' }); + expect(ctx.lifecycle.setActivity).toHaveBeenCalledWith({ visible: true, module: 'programming' }); + expect(ctx.second.webContents.send).toHaveBeenCalledWith('lifecycle:pause'); + expect(ctx.first.webContents.send).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/main-window-reopen-navigation.test.ts b/tests/unit/main-window-reopen-navigation.test.ts new file mode 100644 index 00000000..9bca3f16 --- /dev/null +++ b/tests/unit/main-window-reopen-navigation.test.ts @@ -0,0 +1,138 @@ +import { EventEmitter } from 'node:events'; +import type { BrowserWindow } from 'electron'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +function createWindow(loading = false) { + const contents = Object.assign(new EventEmitter(), { + isDestroyed: vi.fn(() => false), + isLoadingMainFrame: vi.fn(() => loading), + getURL: vi.fn(() => loading ? '' : 'file:///index.html'), + send: vi.fn(), + }); + return { + webContents: contents, + isDestroyed: vi.fn(() => false), + isVisible: vi.fn(() => false), + isMinimized: vi.fn(() => false), + restore: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + focus: vi.fn(), + }; +} + +async function setup() { + const trays: FakeTray[] = []; + const menus: Electron.MenuItemConstructorOptions[][] = []; + class FakeTray extends EventEmitter { + destroyed = false; + setToolTip = vi.fn(); + setContextMenu = vi.fn(); + isDestroyed = () => this.destroyed; + destroy = () => { this.destroyed = true; }; + constructor() { + super(); + trays.push(this); + } + } + const getFocusedWindow = vi.fn(() => null); + vi.doMock('electron', () => ({ + app: { isPackaged: false, name: 'Makelore', quit: vi.fn() }, + Tray: FakeTray, + Menu: { + buildFromTemplate: vi.fn((template: Electron.MenuItemConstructorOptions[]) => { + menus.push(template); + return template; + }), + setApplicationMenu: vi.fn(), + }, + BrowserWindow: { getFocusedWindow }, + nativeImage: { createFromPath: vi.fn(() => ({ isEmpty: () => false, setTemplateImage: vi.fn() })) }, + })); + const { createTray } = await import('../../electron/main/tray'); + const { createMenu } = await import('../../electron/main/menu'); + const { sendWhenWindowReady } = await import('../../electron/main/window-provider'); + return { trays, menus, createTray, createMenu, sendWhenWindowReady }; +} + +function findItem(items: Electron.MenuItemConstructorOptions[], label: string): Electron.MenuItemConstructorOptions { + for (const item of items) { + if (item.label === label) return item; + if (Array.isArray(item.submenu)) { + try { return findItem(item.submenu, label); } catch { /* Search the next submenu. */ } + } + } + throw new Error(`Missing menu item ${label}`); +} + +function click(items: Electron.MenuItemConstructorOptions[], label: string): void { + const handler = findItem(items, label).click; + expect(handler).toBeTypeOf('function'); + (handler as () => void)(); +} + +describe('menu and tray after closing the main window', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('reopens a window from New Chat and waits for the renderer before navigating', async () => { + const ctx = await setup(); + const window = createWindow(true); + const ensure = vi.fn(() => window as unknown as BrowserWindow); + ctx.createMenu(ensure); + click(ctx.menus[0], 'New Chat'); + expect(ensure).toHaveBeenCalledOnce(); + expect(window.show).toHaveBeenCalledOnce(); + expect(window.webContents.send).not.toHaveBeenCalled(); + window.webContents.emit('did-finish-load'); + expect(window.webContents.send).toHaveBeenCalledExactlyOnceWith('navigate', '/'); + expect(window.webContents.listenerCount('destroyed')).toBe(0); + }); + + it('reuses the tray while resolving a replacement window and restoring a minimized window', async () => { + const ctx = await setup(); + const oldWindow = createWindow(); + let current: BrowserWindow | null = oldWindow as unknown as BrowserWindow; + const replacement = createWindow(true); + replacement.isMinimized.mockReturnValue(true); + const ensure = vi.fn(() => { + current = replacement as unknown as BrowserWindow; + return current; + }); + const tray = ctx.createTray(() => current, ensure); + oldWindow.isDestroyed.mockReturnValue(true); + expect(ctx.createTray(() => current, ensure)).toBe(tray); + expect(ctx.trays).toHaveLength(1); + click(ctx.menus[0], 'Open Settings'); + expect(ensure).toHaveBeenCalledOnce(); + expect(replacement.restore).toHaveBeenCalledOnce(); + expect(oldWindow.show).not.toHaveBeenCalled(); + expect(replacement.webContents.send).not.toHaveBeenCalled(); + replacement.webContents.emit('did-finish-load'); + expect(replacement.webContents.send).toHaveBeenCalledWith('navigate', '/settings'); + }); + + it('opens a fresh window from the tray click when no window exists', async () => { + const ctx = await setup(); + const replacement = createWindow(); + const ensure = vi.fn(() => replacement as unknown as BrowserWindow); + ctx.createTray(() => null, ensure); + ctx.trays[0].emit('click'); + expect(ensure).toHaveBeenCalledOnce(); + expect(replacement.show).toHaveBeenCalledOnce(); + expect(replacement.focus).toHaveBeenCalledOnce(); + }); + + it('cancels a pending navigation if the replacement window closes before loading', async () => { + const ctx = await setup(); + const window = createWindow(true); + ctx.sendWhenWindowReady(window as unknown as BrowserWindow, 'navigate', '/settings'); + window.isDestroyed.mockReturnValue(true); + window.webContents.emit('destroyed'); + window.webContents.emit('did-finish-load'); + expect(window.webContents.send).not.toHaveBeenCalled(); + expect(window.webContents.listenerCount('did-finish-load')).toBe(0); + }); +});