Restore desktop shell dialogs and guard destroyed windows on exit
This commit is contained in:
1 parent
3d2876f046
commit
702f9e4ff1
7 files changed
+38
-6
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -397,6 +397,9 @@ async function main(): Promise<void> {
|
||||
|
||||
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<void> {
|
||||
if (quitting) return
|
||||
event.preventDefault()
|
||||
quitting = true
|
||||
mainWindow?.hide()
|
||||
if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.hide()
|
||||
updateSchedule.dispose()
|
||||
updateDialog.dispose()
|
||||
mandatoryUI?.dispose()
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface MandatoryUpdateWindowOptions {
|
||||
readonly install: (version: string) => Promise<DesktopUpdateState>
|
||||
}
|
||||
|
||||
// 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. */
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface UpdateDialogApi {
|
||||
respond(index: number): Promise<void>
|
||||
}
|
||||
|
||||
// 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. */
|
||||
|
||||
@@ -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<Response>) => 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')
|
||||
|
||||
Reference in new issue
Block a user