From 702f9e4ff11ab5762fc9206d7c7f64550b904108 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:21:24 +0800 Subject: [PATCH] Restore desktop shell dialogs and guard destroyed windows on exit --- apps/desktop/README.i18n.yaml | 4 +-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/main.ts | 5 +++- apps/desktop/src/mandatory-update-window.ts | 1 + apps/desktop/src/update-dialog.ts | 1 + apps/desktop/tests/main-startup.spec.ts | 29 ++++++++++++++++++++- 7 files changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 5ab4b50c54..e2b2c9c25f 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: af71ad6342476669e3dc324328620caf8a24f838 -README.zh.md: 9fcf3fb6b4b6748545a7e4647454acdd950ca318 +README.md: 26b915e80985d9642cb9536d583314b967f087e2 +README.zh.md: 121b2339d1f7936a9e434c7d5a57f014624939fb diff --git a/apps/desktop/README.md b/apps/desktop/README.md index af71ad6342..26b915e809 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -46,7 +46,7 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09 Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. -The application preload exposes boot readiness, fatal startup reporting, and native directory selection. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The application preload exposes boot readiness, fatal startup reporting, and native directory selection. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 9fcf3fb6b4..121b2339d1 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -46,7 +46,7 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 -应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 不提供插件管理 IPC 或独立管理页面。 +应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。 产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。 diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d5bfb1e0b3..5c21833b0e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -397,6 +397,9 @@ async function main(): Promise { protocol.handle(SCHEME, (request) => { const url = new URL(request.url) + // Serves shell URLs declared in update-dialog.ts and mandatory-update-window.ts plus their renderer assets. + // Removing this route leaves those modal windows empty while their parent remains blocked. + if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(), 'renderer')) if (url.hostname === 'app') { if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/') || ['/favicon.svg', '/manifest.webmanifest'].includes(url.pathname)) { @@ -707,7 +710,7 @@ async function main(): Promise { if (quitting) return event.preventDefault() quitting = true - mainWindow?.hide() + if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.hide() updateSchedule.dispose() updateDialog.dispose() mandatoryUI?.dispose() diff --git a/apps/desktop/src/mandatory-update-window.ts b/apps/desktop/src/mandatory-update-window.ts index 2705eeb3fb..a3ca6bcf8f 100644 --- a/apps/desktop/src/mandatory-update-window.ts +++ b/apps/desktop/src/mandatory-update-window.ts @@ -43,6 +43,7 @@ export interface MandatoryUpdateWindowOptions { readonly install: (version: string) => Promise } +// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route. const page = 'dsh-app://shell/mandatory-update.html' /** A modal child blocks the product window without cancelling work in the Host. */ diff --git a/apps/desktop/src/update-dialog.ts b/apps/desktop/src/update-dialog.ts index 6ba2fc269a..f48f70d075 100644 --- a/apps/desktop/src/update-dialog.ts +++ b/apps/desktop/src/update-dialog.ts @@ -30,6 +30,7 @@ export interface UpdateDialogApi { respond(index: number): Promise } +// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route. const page = 'dsh-app://shell/update-dialog.html' /** One replaceable confirmation window; aborted checks and mandatory policy cancel ordinary prompts. */ diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 246dcbcfdf..3df37cb4ae 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -145,6 +145,7 @@ const harness = await vi.hoisted(async () => { set publishUpdate(value: (state: DesktopUpdateState) => DesktopUpdateState) { publishUpdate = value }, dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn() }, openExternal: vi.fn(), + protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Response | Promise) => void>(), applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), disableAllPlugins: vi.fn(async () => { pluginsEnabled = false @@ -207,7 +208,7 @@ vi.mock('electron', () => ({ }, Menu: { setApplicationMenu: harness.menu.setApplicationMenu, buildFromTemplate: harness.menu }, session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } }, - protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() }, + protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle }, powerMonitor: harness.powerMonitor, })) vi.mock('node:fs/promises', async (importOriginal) => { @@ -307,6 +308,19 @@ afterEach(async () => { }) describe('desktop main startup', () => { + it('serves shell dialogs and their assets without forwarding them to the Host', async () => { + await readyForUpdate() + const { serveWebDocument, forwardWebRequest } = await import('../src/web-document.ts') + const handler = harness.protocolHandle.mock.calls[0]![1] + for (const file of ['update-dialog.html', 'update-dialog.js', 'update-dialog.css', 'update-close.svg', 'mandatory-update.html']) { + const request = new Request(`dsh-app://shell/${file}`) + await handler(request) + expect(serveWebDocument).toHaveBeenLastCalledWith(request, join('desktop-test-app', 'renderer')) + } + expect(forwardWebRequest).not.toHaveBeenCalled() + expect((await handler(new Request('dsh-app://unknown/update-dialog.html'))).status).toBe(404) + }) + it.each([ ['darwin', true, 'en-US'], ['darwin', false, 'zh-CN'], @@ -717,6 +731,19 @@ describe('desktop main startup', () => { await harness.quitCompleted.promise }) + it('finishes quitting when the native window is destroyed before its closed listener clears ownership', async () => { + const host = await readyForUpdate() + const window = harness.windows[0]! + window.destroyed = true + window.hide.mockImplementation(() => { throw new TypeError('Object has been destroyed') }) + expect(() => { harness.app.quit() }).not.toThrow() + expect(window.hide).not.toHaveBeenCalled() + await host.stopping.promise + window.emit('closed') + host.exited.resolve() + await harness.quitCompleted.promise + }) + it('shares the failed-check deadline across focus and resume, while explicit checks reset polling', async () => { vi.stubEnv('DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS', '1000') vi.stubEnv('DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS', '4000')