p-spore 0.1.4 → 0.1.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +242 -67
- package/dist/agents/agent-manager.d.ts +3 -3
- package/dist/agents/agent-manager.d.ts.map +1 -1
- package/dist/agents/agent-manager.js +3 -2
- package/dist/agents/agent-manager.js.map +1 -1
- package/dist/application/agent-application.d.ts +29 -5
- package/dist/application/agent-application.d.ts.map +1 -1
- package/dist/application/agent-application.js +444 -14
- package/dist/application/agent-application.js.map +1 -1
- package/dist/articles/diff.d.ts +70 -0
- package/dist/articles/diff.d.ts.map +1 -0
- package/dist/articles/diff.js +330 -0
- package/dist/articles/diff.js.map +1 -0
- package/dist/articles/docx.d.ts +10 -0
- package/dist/articles/docx.d.ts.map +1 -0
- package/dist/articles/docx.js +219 -0
- package/dist/articles/docx.js.map +1 -0
- package/dist/articles/draft.d.ts +18 -0
- package/dist/articles/draft.d.ts.map +1 -0
- package/dist/articles/draft.js +88 -0
- package/dist/articles/draft.js.map +1 -0
- package/dist/articles/edits.d.ts +24 -0
- package/dist/articles/edits.d.ts.map +1 -0
- package/dist/articles/edits.js +54 -0
- package/dist/articles/edits.js.map +1 -0
- package/dist/articles/html.d.ts +10 -0
- package/dist/articles/html.d.ts.map +1 -0
- package/dist/articles/html.js +122 -0
- package/dist/articles/html.js.map +1 -0
- package/dist/articles/plain.d.ts +8 -0
- package/dist/articles/plain.d.ts.map +1 -0
- package/dist/articles/plain.js +69 -0
- package/dist/articles/plain.js.map +1 -0
- package/dist/articles/preview.d.ts +43 -0
- package/dist/articles/preview.d.ts.map +1 -0
- package/dist/articles/preview.js +90 -0
- package/dist/articles/preview.js.map +1 -0
- package/dist/articles/publish.d.ts +10 -0
- package/dist/articles/publish.d.ts.map +1 -0
- package/dist/articles/publish.js +54 -0
- package/dist/articles/publish.js.map +1 -0
- package/dist/articles/receipt.d.ts +16 -0
- package/dist/articles/receipt.d.ts.map +1 -0
- package/dist/articles/receipt.js +27 -0
- package/dist/articles/receipt.js.map +1 -0
- package/dist/articles/store.d.ts +78 -0
- package/dist/articles/store.d.ts.map +1 -0
- package/dist/articles/store.js +308 -0
- package/dist/articles/store.js.map +1 -0
- package/dist/articles/tool.d.ts +14 -0
- package/dist/articles/tool.d.ts.map +1 -0
- package/dist/articles/tool.js +175 -0
- package/dist/articles/tool.js.map +1 -0
- package/dist/articles/writer-context.d.ts +5 -0
- package/dist/articles/writer-context.d.ts.map +1 -0
- package/dist/articles/writer-context.js +56 -0
- package/dist/articles/writer-context.js.map +1 -0
- package/dist/articles/writer.d.ts +18 -0
- package/dist/articles/writer.d.ts.map +1 -0
- package/dist/articles/writer.js +79 -0
- package/dist/articles/writer.js.map +1 -0
- package/dist/articles/zip-write.d.ts +11 -0
- package/dist/articles/zip-write.d.ts.map +1 -0
- package/dist/articles/zip-write.js +63 -0
- package/dist/articles/zip-write.js.map +1 -0
- package/dist/bundled-skills/README.md +17 -0
- package/dist/bundled-skills/aegis-search/SKILL.md +59 -0
- package/dist/bundled-skills/aegis-search/bin/.gitkeep +0 -0
- package/dist/bundled-skills/article-canvas/SKILL.md +28 -0
- package/dist/bundled-skills/binaries.json +23 -0
- package/dist/bundled-skills/doc-extract/SKILL.md +56 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +407 -54
- package/dist/cli/main.js.map +1 -1
- package/dist/config/config.d.ts +43 -0
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +150 -6
- package/dist/config/config.js.map +1 -1
- package/dist/daemon/daemon.d.ts.map +1 -1
- package/dist/daemon/daemon.js +80 -11
- package/dist/daemon/daemon.js.map +1 -1
- package/dist/desktop/application.d.ts +4 -0
- package/dist/desktop/application.d.ts.map +1 -0
- package/dist/desktop/application.js +1688 -0
- package/dist/desktop/application.js.map +1 -0
- package/dist/desktop/article-export.d.ts +12 -0
- package/dist/desktop/article-export.d.ts.map +1 -0
- package/dist/desktop/article-export.js +87 -0
- package/dist/desktop/article-export.js.map +1 -0
- package/dist/desktop/assets/pspore.png +0 -0
- package/dist/desktop/assets/pspore.svg +24 -0
- package/dist/desktop/conversation-smoke.d.ts +5 -0
- package/dist/desktop/conversation-smoke.d.ts.map +1 -0
- package/dist/desktop/conversation-smoke.js +107 -0
- package/dist/desktop/conversation-smoke.js.map +1 -0
- package/dist/desktop/daemon-bridge.d.ts.map +1 -1
- package/dist/desktop/daemon-bridge.js +62 -17
- package/dist/desktop/daemon-bridge.js.map +1 -1
- package/dist/desktop/ipc-contract.d.ts +124 -0
- package/dist/desktop/ipc-contract.d.ts.map +1 -1
- package/dist/desktop/ipc-contract.js +191 -12
- package/dist/desktop/ipc-contract.js.map +1 -1
- package/dist/desktop/local-path.d.ts +15 -0
- package/dist/desktop/local-path.d.ts.map +1 -0
- package/dist/desktop/local-path.js +59 -0
- package/dist/desktop/local-path.js.map +1 -0
- package/dist/desktop/main.d.ts +10 -1
- package/dist/desktop/main.d.ts.map +1 -1
- package/dist/desktop/main.js +122 -530
- package/dist/desktop/main.js.map +1 -1
- package/dist/desktop/native-startup.d.ts +4 -0
- package/dist/desktop/native-startup.d.ts.map +1 -0
- package/dist/desktop/native-startup.js +57 -0
- package/dist/desktop/native-startup.js.map +1 -0
- package/dist/desktop/preload.cjs +28 -1
- package/dist/desktop/renderer/article-panel.d.ts +45 -0
- package/dist/desktop/renderer/article-panel.d.ts.map +1 -0
- package/dist/desktop/renderer/article-panel.js +1126 -0
- package/dist/desktop/renderer/article-panel.js.map +1 -0
- package/dist/desktop/renderer/article-split.d.ts +32 -0
- package/dist/desktop/renderer/article-split.d.ts.map +1 -0
- package/dist/desktop/renderer/article-split.js +48 -0
- package/dist/desktop/renderer/article-split.js.map +1 -0
- package/dist/desktop/renderer/composer-file-references.d.ts +23 -0
- package/dist/desktop/renderer/composer-file-references.d.ts.map +1 -0
- package/dist/desktop/renderer/composer-file-references.js +51 -0
- package/dist/desktop/renderer/composer-file-references.js.map +1 -0
- package/dist/desktop/renderer/conversation.d.ts +23 -0
- package/dist/desktop/renderer/conversation.d.ts.map +1 -0
- package/dist/desktop/renderer/conversation.js +66 -0
- package/dist/desktop/renderer/conversation.js.map +1 -0
- package/dist/desktop/renderer/dom-reconcile.d.ts +15 -0
- package/dist/desktop/renderer/dom-reconcile.d.ts.map +1 -0
- package/dist/desktop/renderer/dom-reconcile.js +33 -0
- package/dist/desktop/renderer/dom-reconcile.js.map +1 -0
- package/dist/desktop/renderer/index.html +211 -34
- package/dist/desktop/renderer/indexing-view.d.ts +22 -0
- package/dist/desktop/renderer/indexing-view.d.ts.map +1 -0
- package/dist/desktop/renderer/indexing-view.js +115 -0
- package/dist/desktop/renderer/indexing-view.js.map +1 -0
- package/dist/desktop/renderer/markdown-view.d.ts +23 -0
- package/dist/desktop/renderer/markdown-view.d.ts.map +1 -0
- package/dist/desktop/renderer/markdown-view.js +436 -0
- package/dist/desktop/renderer/markdown-view.js.map +1 -0
- package/dist/desktop/renderer/motion.d.ts +67 -0
- package/dist/desktop/renderer/motion.d.ts.map +1 -0
- package/dist/desktop/renderer/motion.js +296 -0
- package/dist/desktop/renderer/motion.js.map +1 -0
- package/dist/desktop/renderer/renderer.js +1799 -152
- package/dist/desktop/renderer/renderer.js.map +1 -1
- package/dist/desktop/renderer/session-find.d.ts +18 -0
- package/dist/desktop/renderer/session-find.d.ts.map +1 -0
- package/dist/desktop/renderer/session-find.js +29 -0
- package/dist/desktop/renderer/session-find.js.map +1 -0
- package/dist/desktop/renderer/session-search.d.ts +30 -0
- package/dist/desktop/renderer/session-search.d.ts.map +1 -0
- package/dist/desktop/renderer/session-search.js +296 -0
- package/dist/desktop/renderer/session-search.js.map +1 -0
- package/dist/desktop/renderer/store.d.ts +33 -0
- package/dist/desktop/renderer/store.d.ts.map +1 -1
- package/dist/desktop/renderer/store.js +92 -19
- package/dist/desktop/renderer/store.js.map +1 -1
- package/dist/desktop/renderer/styles.css +928 -268
- package/dist/desktop/renderer/vendor/gsap.min.js +11 -0
- package/dist/desktop/renderer-diagnostics.d.ts +5 -0
- package/dist/desktop/renderer-diagnostics.d.ts.map +1 -0
- package/dist/desktop/renderer-diagnostics.js +47 -0
- package/dist/desktop/renderer-diagnostics.js.map +1 -0
- package/dist/desktop/security.d.ts +26 -1
- package/dist/desktop/security.d.ts.map +1 -1
- package/dist/desktop/security.js +26 -6
- package/dist/desktop/security.js.map +1 -1
- package/dist/desktop/smoke-fixture.d.ts.map +1 -1
- package/dist/desktop/smoke-fixture.js +77 -3
- package/dist/desktop/smoke-fixture.js.map +1 -1
- package/dist/desktop/splash/index.html +25 -0
- package/dist/desktop/splash/splash.d.ts +2 -0
- package/dist/desktop/splash/splash.d.ts.map +1 -0
- package/dist/desktop/splash/splash.js +8 -0
- package/dist/desktop/splash/splash.js.map +1 -0
- package/dist/desktop/splash/styles.css +24 -0
- package/dist/desktop/system-open.d.ts +16 -0
- package/dist/desktop/system-open.d.ts.map +1 -0
- package/dist/desktop/system-open.js +90 -0
- package/dist/desktop/system-open.js.map +1 -0
- package/dist/desktop/validation.d.ts.map +1 -1
- package/dist/desktop/validation.js +99 -21
- package/dist/desktop/validation.js.map +1 -1
- package/dist/desktop/window-security.d.ts +4 -0
- package/dist/desktop/window-security.d.ts.map +1 -0
- package/dist/desktop/window-security.js +26 -0
- package/dist/desktop/window-security.js.map +1 -0
- package/dist/documents/errors.d.ts +12 -0
- package/dist/documents/errors.d.ts.map +1 -0
- package/dist/documents/errors.js +12 -0
- package/dist/documents/errors.js.map +1 -0
- package/dist/documents/extract.d.ts +37 -0
- package/dist/documents/extract.d.ts.map +1 -0
- package/dist/documents/extract.js +90 -0
- package/dist/documents/extract.js.map +1 -0
- package/dist/documents/legacy-office.d.ts +23 -0
- package/dist/documents/legacy-office.d.ts.map +1 -0
- package/dist/documents/legacy-office.js +439 -0
- package/dist/documents/legacy-office.js.map +1 -0
- package/dist/documents/ole.d.ts +20 -0
- package/dist/documents/ole.d.ts.map +1 -0
- package/dist/documents/ole.js +189 -0
- package/dist/documents/ole.js.map +1 -0
- package/dist/documents/ooxml.d.ts +23 -0
- package/dist/documents/ooxml.d.ts.map +1 -0
- package/dist/documents/ooxml.js +197 -0
- package/dist/documents/ooxml.js.map +1 -0
- package/dist/documents/pdf.d.ts +20 -0
- package/dist/documents/pdf.d.ts.map +1 -0
- package/dist/documents/pdf.js +95 -0
- package/dist/documents/pdf.js.map +1 -0
- package/dist/documents/plain-text.d.ts +22 -0
- package/dist/documents/plain-text.d.ts.map +1 -0
- package/dist/documents/plain-text.js +57 -0
- package/dist/documents/plain-text.js.map +1 -0
- package/dist/documents/zip.d.ts +15 -0
- package/dist/documents/zip.d.ts.map +1 -0
- package/dist/documents/zip.js +137 -0
- package/dist/documents/zip.js.map +1 -0
- package/dist/domain/indexing.d.ts +60 -0
- package/dist/domain/indexing.d.ts.map +1 -0
- package/dist/domain/indexing.js +13 -0
- package/dist/domain/indexing.js.map +1 -0
- package/dist/domain/runtime.d.ts +133 -5
- package/dist/domain/runtime.d.ts.map +1 -1
- package/dist/gateway/local-gateway.d.ts +8 -0
- package/dist/gateway/local-gateway.d.ts.map +1 -1
- package/dist/gateway/local-gateway.js +105 -21
- package/dist/gateway/local-gateway.js.map +1 -1
- package/dist/indexing/index-service.d.ts +56 -0
- package/dist/indexing/index-service.d.ts.map +1 -0
- package/dist/indexing/index-service.js +262 -0
- package/dist/indexing/index-service.js.map +1 -0
- package/dist/kernel/loop.d.ts +10 -0
- package/dist/kernel/loop.d.ts.map +1 -1
- package/dist/kernel/loop.js +6 -2
- package/dist/kernel/loop.js.map +1 -1
- package/dist/kernel/reducer.d.ts.map +1 -1
- package/dist/kernel/reducer.js +17 -1
- package/dist/kernel/reducer.js.map +1 -1
- package/dist/kernel/state.d.ts +17 -4
- package/dist/kernel/state.d.ts.map +1 -1
- package/dist/kernel/state.js +2 -0
- package/dist/kernel/state.js.map +1 -1
- package/dist/pi/context-budget.d.ts +8 -0
- package/dist/pi/context-budget.d.ts.map +1 -0
- package/dist/pi/context-budget.js +62 -0
- package/dist/pi/context-budget.js.map +1 -0
- package/dist/pi/pi-runtime-adapter.d.ts +38 -3
- package/dist/pi/pi-runtime-adapter.d.ts.map +1 -1
- package/dist/pi/pi-runtime-adapter.js +551 -56
- package/dist/pi/pi-runtime-adapter.js.map +1 -1
- package/dist/plugins/bundled-skills.d.ts +56 -0
- package/dist/plugins/bundled-skills.d.ts.map +1 -0
- package/dist/plugins/bundled-skills.js +243 -0
- package/dist/plugins/bundled-skills.js.map +1 -0
- package/dist/plugins/extension-manager.d.ts +23 -1
- package/dist/plugins/extension-manager.d.ts.map +1 -1
- package/dist/plugins/extension-manager.js +117 -0
- package/dist/plugins/extension-manager.js.map +1 -1
- package/dist/plugins/types.d.ts +29 -0
- package/dist/plugins/types.d.ts.map +1 -1
- package/dist/plugins/workspace-skills.d.ts +36 -0
- package/dist/plugins/workspace-skills.d.ts.map +1 -0
- package/dist/plugins/workspace-skills.js +309 -0
- package/dist/plugins/workspace-skills.js.map +1 -0
- package/dist/policy/policy.d.ts.map +1 -1
- package/dist/policy/policy.js +22 -3
- package/dist/policy/policy.js.map +1 -1
- package/dist/protocol/client.d.ts +1 -0
- package/dist/protocol/client.d.ts.map +1 -1
- package/dist/protocol/client.js +49 -24
- package/dist/protocol/client.js.map +1 -1
- package/dist/protocol/limits.d.ts +69 -0
- package/dist/protocol/limits.d.ts.map +1 -0
- package/dist/protocol/limits.js +166 -0
- package/dist/protocol/limits.js.map +1 -0
- package/dist/protocol/schema.d.ts +6 -0
- package/dist/protocol/schema.d.ts.map +1 -1
- package/dist/protocol/schema.js.map +1 -1
- package/dist/protocol/server-frame-codec.d.ts +18 -0
- package/dist/protocol/server-frame-codec.d.ts.map +1 -0
- package/dist/protocol/server-frame-codec.js +211 -0
- package/dist/protocol/server-frame-codec.js.map +1 -0
- package/dist/service/legacy-daemon.d.ts +6 -0
- package/dist/service/legacy-daemon.d.ts.map +1 -0
- package/dist/service/legacy-daemon.js +42 -0
- package/dist/service/legacy-daemon.js.map +1 -0
- package/dist/service/service-launch.d.ts +10 -0
- package/dist/service/service-launch.d.ts.map +1 -0
- package/dist/service/service-launch.js +56 -0
- package/dist/service/service-launch.js.map +1 -0
- package/dist/service/system-service.d.ts +57 -0
- package/dist/service/system-service.d.ts.map +1 -0
- package/dist/service/system-service.js +263 -0
- package/dist/service/system-service.js.map +1 -0
- package/dist/sessions/session-catalog.d.ts +45 -0
- package/dist/sessions/session-catalog.d.ts.map +1 -0
- package/dist/sessions/session-catalog.js +270 -0
- package/dist/sessions/session-catalog.js.map +1 -0
- package/dist/sessions/session-file.d.ts +12 -0
- package/dist/sessions/session-file.d.ts.map +1 -0
- package/dist/sessions/session-file.js +47 -0
- package/dist/sessions/session-file.js.map +1 -0
- package/dist/settings/compaction-settings.d.ts +10 -0
- package/dist/settings/compaction-settings.d.ts.map +1 -0
- package/dist/settings/compaction-settings.js +8 -0
- package/dist/settings/compaction-settings.js.map +1 -0
- package/dist/settings/indexing-settings-service.d.ts +16 -0
- package/dist/settings/indexing-settings-service.d.ts.map +1 -0
- package/dist/settings/indexing-settings-service.js +57 -0
- package/dist/settings/indexing-settings-service.js.map +1 -0
- package/dist/settings/indexing-settings.d.ts +30 -0
- package/dist/settings/indexing-settings.d.ts.map +1 -0
- package/dist/settings/indexing-settings.js +58 -0
- package/dist/settings/indexing-settings.js.map +1 -0
- package/dist/settings/llm-settings-service.d.ts +6 -0
- package/dist/settings/llm-settings-service.d.ts.map +1 -1
- package/dist/settings/llm-settings-service.js +137 -12
- package/dist/settings/llm-settings-service.js.map +1 -1
- package/dist/settings/llm-settings.d.ts +68 -2
- package/dist/settings/llm-settings.d.ts.map +1 -1
- package/dist/settings/llm-settings.js +119 -17
- package/dist/settings/llm-settings.js.map +1 -1
- package/dist/settings/model-api.d.ts +10 -0
- package/dist/settings/model-api.d.ts.map +1 -0
- package/dist/settings/model-api.js +10 -0
- package/dist/settings/model-api.js.map +1 -0
- package/dist/storage/home.d.ts +1 -0
- package/dist/storage/home.d.ts.map +1 -1
- package/dist/storage/home.js +37 -2
- package/dist/storage/home.js.map +1 -1
- package/dist/tools/core-tools.d.ts +2 -0
- package/dist/tools/core-tools.d.ts.map +1 -1
- package/dist/tools/core-tools.js +91 -1
- package/dist/tools/core-tools.js.map +1 -1
- package/dist/ui/shared/activity.d.ts +2 -0
- package/dist/ui/shared/activity.d.ts.map +1 -1
- package/dist/ui/shared/activity.js +18 -4
- package/dist/ui/shared/activity.js.map +1 -1
- package/dist/ui/shared/full-message.d.ts +22 -0
- package/dist/ui/shared/full-message.d.ts.map +1 -0
- package/dist/ui/shared/full-message.js +40 -0
- package/dist/ui/shared/full-message.js.map +1 -0
- package/dist/ui/shared/local-links.d.ts +7 -0
- package/dist/ui/shared/local-links.d.ts.map +1 -0
- package/dist/ui/shared/local-links.js +24 -0
- package/dist/ui/shared/local-links.js.map +1 -0
- package/dist/ui/shared/markdown-stream.d.ts +25 -0
- package/dist/ui/shared/markdown-stream.d.ts.map +1 -0
- package/dist/ui/shared/markdown-stream.js +35 -0
- package/dist/ui/shared/markdown-stream.js.map +1 -0
- package/dist/ui/shared/markdown.d.ts +70 -0
- package/dist/ui/shared/markdown.d.ts.map +1 -0
- package/dist/ui/shared/markdown.js +434 -0
- package/dist/ui/shared/markdown.js.map +1 -0
- package/dist/ui/shared/presentation.d.ts +10 -2
- package/dist/ui/shared/presentation.d.ts.map +1 -1
- package/dist/ui/shared/presentation.js +83 -22
- package/dist/ui/shared/presentation.js.map +1 -1
- package/dist/ui/shared/protocol-state.d.ts +19 -0
- package/dist/ui/shared/protocol-state.d.ts.map +1 -1
- package/dist/ui/shared/protocol-state.js +188 -26
- package/dist/ui/shared/protocol-state.js.map +1 -1
- package/dist/ui/shared/virtual-list.d.ts +34 -0
- package/dist/ui/shared/virtual-list.d.ts.map +1 -0
- package/dist/ui/shared/virtual-list.js +143 -0
- package/dist/ui/shared/virtual-list.js.map +1 -0
- package/dist/ui/tui/commands.d.ts +1 -1
- package/dist/ui/tui/commands.d.ts.map +1 -1
- package/dist/ui/tui/commands.js +8 -3
- package/dist/ui/tui/commands.js.map +1 -1
- package/dist/ui/tui/store.d.ts +22 -1
- package/dist/ui/tui/store.d.ts.map +1 -1
- package/dist/ui/tui/store.js +50 -3
- package/dist/ui/tui/store.js.map +1 -1
- package/dist/ui/tui/tui-app.d.ts.map +1 -1
- package/dist/ui/tui/tui-app.js +231 -31
- package/dist/ui/tui/tui-app.js.map +1 -1
- package/dist/ui/tui/view.d.ts +7 -0
- package/dist/ui/tui/view.d.ts.map +1 -1
- package/dist/ui/tui/view.js +161 -57
- package/dist/ui/tui/view.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +13 -2
|
@@ -0,0 +1,1688 @@
|
|
|
1
|
+
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
import { app, BrowserWindow, dialog, ipcMain, screen } from "electron";
|
|
5
|
+
import { ArticleStore } from "../articles/store.js";
|
|
6
|
+
import { resolvePsporeHome } from "../config/config.js";
|
|
7
|
+
import { stopLegacyDaemon } from "../service/legacy-daemon.js";
|
|
8
|
+
import { serviceLaunchSpec } from "../service/service-launch.js";
|
|
9
|
+
import { SystemServiceManager } from "../service/system-service.js";
|
|
10
|
+
import { IndexingSettingsService } from "../settings/indexing-settings-service.js";
|
|
11
|
+
import { LlmSettingsService } from "../settings/llm-settings-service.js";
|
|
12
|
+
import { BYPASS_PERMISSION_MODE } from "../settings/llm-settings.js";
|
|
13
|
+
import { initializePsporeHome } from "../storage/home.js";
|
|
14
|
+
import { PI_VERSION, PSPORE_VERSION } from "../version.js";
|
|
15
|
+
import { exportArticleFile } from "./article-export.js";
|
|
16
|
+
import { decodeLocalPathRequest, resolveLocalPath } from "./local-path.js";
|
|
17
|
+
import { openLocalItem, revealLocalItem } from "./system-open.js";
|
|
18
|
+
import { DaemonBridge } from "./daemon-bridge.js";
|
|
19
|
+
import { DESKTOP_ARTICLE_EXPORT_CHANNEL, DESKTOP_OPEN_LOCAL_PATH_CHANNEL, DESKTOP_BOOTSTRAP_CHANNEL, DESKTOP_DISPATCH_CHANNEL, DESKTOP_FRAME_CHANNEL, DESKTOP_INDEXING_GET_CHANNEL, DESKTOP_ATTACH_CHANNEL, DESKTOP_INDEXING_PICK_CHANNEL, decodeAttachmentKind, DESKTOP_INDEXING_SAVE_CHANNEL, DESKTOP_SETTINGS_GET_CHANNEL, DESKTOP_SETTINGS_SAVE_CHANNEL, decodeArticleExportRequest, decodeDesktopIndexingSettingsUpdate, decodeDesktopLlmSettingsUpdate, } from "./ipc-contract.js";
|
|
20
|
+
import { MIN_ARTICLE_WIDTH, MIN_CONVERSATION_WIDTH } from "./renderer/article-split.js";
|
|
21
|
+
import { DESKTOP_CSP, desktopWindowOptions, isTrustedRendererUrl } from "./security.js";
|
|
22
|
+
import { createDesktopSmokeState } from "./smoke-fixture.js";
|
|
23
|
+
import { hardenWindow } from "./window-security.js";
|
|
24
|
+
import { captureRendererDiagnostics, waitFor, waitForRenderer } from "./renderer-diagnostics.js";
|
|
25
|
+
let arguments_;
|
|
26
|
+
let startup;
|
|
27
|
+
let window;
|
|
28
|
+
let bridge;
|
|
29
|
+
let daemonSupervisor;
|
|
30
|
+
let systemService;
|
|
31
|
+
let settingsService;
|
|
32
|
+
let unsubscribeBridge;
|
|
33
|
+
let rendererUrl = "";
|
|
34
|
+
let exitCode = 0;
|
|
35
|
+
let shuttingDown = false;
|
|
36
|
+
let savingSettings = false;
|
|
37
|
+
let articleRoot = "";
|
|
38
|
+
const smokeOpenedPaths = [];
|
|
39
|
+
const smokeRevealedPaths = [];
|
|
40
|
+
export async function startDesktop(options, startupWindow) {
|
|
41
|
+
arguments_ = options;
|
|
42
|
+
startup = startupWindow;
|
|
43
|
+
const home = resolvePsporeHome(arguments_.home);
|
|
44
|
+
const { paths } = await initializePsporeHome(home);
|
|
45
|
+
articleRoot = join(paths.workspace, "articles");
|
|
46
|
+
const moduleDirectory = fileURLToPath(new URL(".", import.meta.url));
|
|
47
|
+
const preloadPath = join(moduleDirectory, "preload.cjs");
|
|
48
|
+
const rendererPath = join(moduleDirectory, "renderer", "index.html");
|
|
49
|
+
rendererUrl = pathToFileURL(rendererPath).href;
|
|
50
|
+
await startup.setMessage("正在唤醒本地助手");
|
|
51
|
+
if (shuttingDown)
|
|
52
|
+
return;
|
|
53
|
+
if (arguments_.smokeDirectory) {
|
|
54
|
+
// The visual harness owns an isolated temporary daemon and must never
|
|
55
|
+
// register persistent services in the developer's real user manager.
|
|
56
|
+
// The production shell never loads the daemon/model SDK into its process.
|
|
57
|
+
const { DaemonSupervisor } = await import("../daemon/supervisor.js");
|
|
58
|
+
if (shuttingDown)
|
|
59
|
+
return;
|
|
60
|
+
daemonSupervisor = new DaemonSupervisor({ home });
|
|
61
|
+
await daemonSupervisor.start();
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
systemService = new SystemServiceManager({ home, beforeStart: async () => stopLegacyDaemon(home) });
|
|
65
|
+
const launch = await serviceLaunchSpec(home, paths.workspace, {
|
|
66
|
+
execPath: process.execPath,
|
|
67
|
+
moduleUrl: import.meta.url,
|
|
68
|
+
electron: true,
|
|
69
|
+
});
|
|
70
|
+
const ensured = await systemService.ensureRunning(launch);
|
|
71
|
+
if (ensured.lingerWarning)
|
|
72
|
+
process.stderr.write(`pSpore service warning: ${ensured.lingerWarning}\n`);
|
|
73
|
+
if (ensured.recoveredFromFailure) {
|
|
74
|
+
process.stderr.write("pSpore service: cleared a failed unit state before starting the daemon\n");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (shuttingDown)
|
|
78
|
+
return;
|
|
79
|
+
bridge = new DaemonBridge({
|
|
80
|
+
socketPath: paths.socket,
|
|
81
|
+
stagingRoot: join(paths.workspace, "staging"),
|
|
82
|
+
versions: {
|
|
83
|
+
pspore: PSPORE_VERSION,
|
|
84
|
+
pi: PI_VERSION,
|
|
85
|
+
electron: process.versions.electron,
|
|
86
|
+
node: process.versions.node,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
bridge.start();
|
|
90
|
+
settingsService = new LlmSettingsService(home, paths);
|
|
91
|
+
const indexingService = new IndexingSettingsService(home, paths);
|
|
92
|
+
await startup.setMessage("正在整理工作空间");
|
|
93
|
+
if (shuttingDown)
|
|
94
|
+
return;
|
|
95
|
+
window = new BrowserWindow(desktopWindowOptions(preloadPath, primaryWorkArea()));
|
|
96
|
+
// Closing the main window must also close a still-visible startup window.
|
|
97
|
+
window.on("closed", () => requestShutdown(0));
|
|
98
|
+
captureRendererDiagnostics(window);
|
|
99
|
+
hardenWindow(window, rendererUrl);
|
|
100
|
+
installIpc(window, bridge, settingsService, indexingService);
|
|
101
|
+
unsubscribeBridge = bridge.subscribe((frame) => sendFrame(window, frame));
|
|
102
|
+
await window.loadFile(rendererPath);
|
|
103
|
+
await waitForRenderer(window, "document.documentElement.dataset.ready === 'true'", 15_000);
|
|
104
|
+
if (shuttingDown)
|
|
105
|
+
return;
|
|
106
|
+
if (arguments_.smokeDirectory) {
|
|
107
|
+
window.setContentSize(1_920, 1_080);
|
|
108
|
+
window.show();
|
|
109
|
+
startup.close();
|
|
110
|
+
await runSmoke(window, bridge, arguments_.smokeDirectory, arguments_.smokeFixture);
|
|
111
|
+
// Explicit development preview; still uses an isolated smoke home, never systemd.
|
|
112
|
+
if (process.env["PSPORE_SMOKE_KEEP_OPEN"] === "1")
|
|
113
|
+
return;
|
|
114
|
+
await bridge.stop();
|
|
115
|
+
requestShutdown(exitCode);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// The workspace is a full-height application shell, so the first launch fills
|
|
119
|
+
// the display instead of leaving the desktop visible around a floating window.
|
|
120
|
+
showMaximized(window);
|
|
121
|
+
startup.close();
|
|
122
|
+
}
|
|
123
|
+
/** At most this many paths from one chooser, matching the composer's own ceiling. */
|
|
124
|
+
const MAX_ATTACHMENTS = 32;
|
|
125
|
+
/**
|
|
126
|
+
* Where this system keeps the user's own directories. Electron resolves the XDG
|
|
127
|
+
* entries, so a desktop named 桌面 is found without the renderer guessing.
|
|
128
|
+
*/
|
|
129
|
+
function desktopPlaces() {
|
|
130
|
+
const place = (name) => {
|
|
131
|
+
try {
|
|
132
|
+
return app.getPath(name);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
return { desktop: place("desktop"), home: place("home") };
|
|
139
|
+
}
|
|
140
|
+
function primaryWorkArea() {
|
|
141
|
+
try {
|
|
142
|
+
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
|
143
|
+
return { width, height };
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Headless or display-less startup: fall back to the design baseline size.
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function showMaximized(target) {
|
|
151
|
+
if (!target || target.isDestroyed())
|
|
152
|
+
return;
|
|
153
|
+
if (!target.isMaximized())
|
|
154
|
+
target.maximize();
|
|
155
|
+
if (!target.isVisible())
|
|
156
|
+
target.show();
|
|
157
|
+
}
|
|
158
|
+
function installIpc(target, daemonBridge, desktopSettings, indexingSettings) {
|
|
159
|
+
ipcMain.removeHandler(DESKTOP_BOOTSTRAP_CHANNEL);
|
|
160
|
+
ipcMain.removeHandler(DESKTOP_DISPATCH_CHANNEL);
|
|
161
|
+
ipcMain.removeHandler(DESKTOP_SETTINGS_GET_CHANNEL);
|
|
162
|
+
ipcMain.removeHandler(DESKTOP_SETTINGS_SAVE_CHANNEL);
|
|
163
|
+
ipcMain.removeHandler(DESKTOP_INDEXING_GET_CHANNEL);
|
|
164
|
+
ipcMain.removeHandler(DESKTOP_INDEXING_SAVE_CHANNEL);
|
|
165
|
+
ipcMain.removeHandler(DESKTOP_INDEXING_PICK_CHANNEL);
|
|
166
|
+
ipcMain.removeHandler(DESKTOP_ATTACH_CHANNEL);
|
|
167
|
+
ipcMain.removeHandler(DESKTOP_ARTICLE_EXPORT_CHANNEL);
|
|
168
|
+
ipcMain.removeHandler(DESKTOP_OPEN_LOCAL_PATH_CHANNEL);
|
|
169
|
+
ipcMain.handle(DESKTOP_BOOTSTRAP_CHANNEL, (event) => {
|
|
170
|
+
assertTrustedSender(event, target.webContents);
|
|
171
|
+
return { ...daemonBridge.bootstrap(), places: desktopPlaces() };
|
|
172
|
+
});
|
|
173
|
+
ipcMain.handle(DESKTOP_DISPATCH_CHANNEL, async (event, command) => {
|
|
174
|
+
assertTrustedSender(event, target.webContents);
|
|
175
|
+
return daemonBridge.dispatch(command);
|
|
176
|
+
});
|
|
177
|
+
ipcMain.handle(DESKTOP_SETTINGS_GET_CHANNEL, async (event) => {
|
|
178
|
+
assertTrustedSender(event, target.webContents);
|
|
179
|
+
return desktopSettings.get();
|
|
180
|
+
});
|
|
181
|
+
ipcMain.handle(DESKTOP_INDEXING_GET_CHANNEL, async (event) => {
|
|
182
|
+
assertTrustedSender(event, target.webContents);
|
|
183
|
+
return indexingSettings.get();
|
|
184
|
+
});
|
|
185
|
+
ipcMain.handle(DESKTOP_INDEXING_SAVE_CHANNEL, async (event, candidate) => {
|
|
186
|
+
assertTrustedSender(event, target.webContents);
|
|
187
|
+
const update = decodeDesktopIndexingSettingsUpdate(candidate);
|
|
188
|
+
const settings = await indexingSettings.save(update);
|
|
189
|
+
// "Saved" is not "indexing": a switched-on indexer with no directory, or one
|
|
190
|
+
// whose release shipped no binary, does nothing. Saying so here keeps this
|
|
191
|
+
// message from contradicting the status panel.
|
|
192
|
+
const caveat = !settings.enabled
|
|
193
|
+
? ""
|
|
194
|
+
: settings.directories.length === 0
|
|
195
|
+
? " 尚未选择索引目录,索引不会运行。"
|
|
196
|
+
: settings.indexerAvailable
|
|
197
|
+
? ""
|
|
198
|
+
: " 本次发行版未包含索引程序,索引不会运行。";
|
|
199
|
+
// The daemon reads config.indexing once at startup, so a saved change only
|
|
200
|
+
// takes effect after it restarts; the client is told which happened.
|
|
201
|
+
if (systemService) {
|
|
202
|
+
await daemonBridge.stop();
|
|
203
|
+
await systemService.restart();
|
|
204
|
+
daemonBridge.start();
|
|
205
|
+
await waitFor(() => daemonBridge.bootstrap().connection.phase === "connected", 10_000);
|
|
206
|
+
return { settings, applied: true, message: `索引设置已保存并应用,后台服务已重启。${caveat}` };
|
|
207
|
+
}
|
|
208
|
+
return { settings, applied: false, message: `索引设置已保存;重启服务后生效。${caveat}` };
|
|
209
|
+
});
|
|
210
|
+
ipcMain.handle(DESKTOP_ARTICLE_EXPORT_CHANNEL, async (event, candidate) => {
|
|
211
|
+
assertTrustedSender(event, target.webContents);
|
|
212
|
+
const request = decodeArticleExportRequest(candidate);
|
|
213
|
+
return exportArticleFile(new ArticleStore(articleRoot), request, target);
|
|
214
|
+
});
|
|
215
|
+
ipcMain.handle(DESKTOP_OPEN_LOCAL_PATH_CHANNEL, async (event, candidate) => {
|
|
216
|
+
assertTrustedSender(event, target.webContents);
|
|
217
|
+
const request = decodeLocalPathRequest(candidate);
|
|
218
|
+
const path = await resolveLocalPath(request.path, request.action);
|
|
219
|
+
if (arguments_.smokeFixture) {
|
|
220
|
+
if (request.action === "reveal")
|
|
221
|
+
smokeRevealedPaths.push(path);
|
|
222
|
+
else
|
|
223
|
+
smokeOpenedPaths.push(path);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (request.action === "reveal") {
|
|
227
|
+
await revealLocalItem(path);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
await openLocalItem(path);
|
|
231
|
+
});
|
|
232
|
+
ipcMain.handle(DESKTOP_ATTACH_CHANNEL, async (event, candidate) => {
|
|
233
|
+
assertTrustedSender(event, target.webContents);
|
|
234
|
+
const kind = decodeAttachmentKind(candidate);
|
|
235
|
+
const result = await dialog.showOpenDialog(target, {
|
|
236
|
+
title: kind === "directory" ? "选择要引用的文件夹" : "选择要引用的文件",
|
|
237
|
+
// One kind per call: the GTK chooser honours only one of openFile and
|
|
238
|
+
// openDirectory, and answers with directories when given both.
|
|
239
|
+
// Only the path is ever sent; nothing here is read or uploaded.
|
|
240
|
+
properties: kind === "directory"
|
|
241
|
+
? ["openDirectory", "multiSelections"]
|
|
242
|
+
: ["openFile", "multiSelections"],
|
|
243
|
+
});
|
|
244
|
+
if (result.canceled)
|
|
245
|
+
return [];
|
|
246
|
+
const attachments = [];
|
|
247
|
+
for (const path of result.filePaths.slice(0, MAX_ATTACHMENTS)) {
|
|
248
|
+
try {
|
|
249
|
+
// The chooser is asked for one kind, but a symlink can still answer
|
|
250
|
+
// with the other, so the reference says what the path actually is.
|
|
251
|
+
attachments.push({ path, kind: (await stat(path)).isDirectory() ? "directory" : "file" });
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// Gone between choosing and answering; the renderer simply gets fewer.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return attachments;
|
|
258
|
+
});
|
|
259
|
+
ipcMain.handle(DESKTOP_INDEXING_PICK_CHANNEL, async (event) => {
|
|
260
|
+
assertTrustedSender(event, target.webContents);
|
|
261
|
+
// A native chooser is the only way the renderer learns an absolute path: it
|
|
262
|
+
// has no filesystem access of its own.
|
|
263
|
+
const result = await dialog.showOpenDialog(target, {
|
|
264
|
+
title: "选择要索引的目录",
|
|
265
|
+
properties: ["openDirectory", "createDirectory"],
|
|
266
|
+
});
|
|
267
|
+
return result.canceled ? undefined : result.filePaths[0];
|
|
268
|
+
});
|
|
269
|
+
ipcMain.handle(DESKTOP_SETTINGS_SAVE_CHANNEL, async (event, candidate) => {
|
|
270
|
+
assertTrustedSender(event, target.webContents);
|
|
271
|
+
if (savingSettings)
|
|
272
|
+
throw new Error("Desktop settings are already being saved");
|
|
273
|
+
const update = decodeDesktopLlmSettingsUpdate(candidate);
|
|
274
|
+
savingSettings = true;
|
|
275
|
+
try {
|
|
276
|
+
const settings = await desktopSettings.save(update);
|
|
277
|
+
if (systemService) {
|
|
278
|
+
await daemonBridge.stop();
|
|
279
|
+
await systemService.restart();
|
|
280
|
+
daemonBridge.start();
|
|
281
|
+
await waitFor(() => daemonBridge.bootstrap().connection.phase === "connected", 10_000);
|
|
282
|
+
return {
|
|
283
|
+
settings,
|
|
284
|
+
applied: true,
|
|
285
|
+
restartRequired: false,
|
|
286
|
+
message: `设置已保存并应用,后台系统服务已安全重启。${bypassNotice(settings)}`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const ownership = daemonSupervisor?.ownership;
|
|
290
|
+
if (ownership !== "owned") {
|
|
291
|
+
return {
|
|
292
|
+
settings,
|
|
293
|
+
applied: false,
|
|
294
|
+
restartRequired: true,
|
|
295
|
+
message: `设置已安全保存;当前测试使用外部 Daemon,重启后生效。${bypassNotice(settings)}`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
await daemonBridge.stop();
|
|
299
|
+
await daemonSupervisor?.stop();
|
|
300
|
+
await daemonSupervisor?.start();
|
|
301
|
+
daemonBridge.start();
|
|
302
|
+
await waitFor(() => daemonBridge.bootstrap().connection.phase === "connected", 10_000);
|
|
303
|
+
return {
|
|
304
|
+
settings,
|
|
305
|
+
applied: true,
|
|
306
|
+
restartRequired: false,
|
|
307
|
+
message: `设置已保存并应用,测试 Daemon 已安全重启。${bypassNotice(settings)}`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
savingSettings = false;
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
/** Bypassing approvals is a standing risk, so every save restates it. */
|
|
316
|
+
function bypassNotice(settings) {
|
|
317
|
+
return settings.permissionMode === BYPASS_PERMISSION_MODE
|
|
318
|
+
? " 授权确认已绕过:工具会以当前用户权限直接执行。"
|
|
319
|
+
: "";
|
|
320
|
+
}
|
|
321
|
+
function assertTrustedSender(event, expected) {
|
|
322
|
+
if (event.sender !== expected || !isTrustedRendererUrl(event.senderFrame?.url ?? "", rendererUrl)) {
|
|
323
|
+
throw new Error("Rejected IPC from an untrusted renderer");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function sendFrame(target, frame) {
|
|
327
|
+
if (!target || target.isDestroyed() || target.webContents.isDestroyed() || target.webContents.isLoadingMainFrame())
|
|
328
|
+
return;
|
|
329
|
+
target.webContents.send(DESKTOP_FRAME_CHANNEL, frame);
|
|
330
|
+
}
|
|
331
|
+
async function runSmoke(target, daemonBridge, outputDirectory, fixture) {
|
|
332
|
+
if (BrowserWindow.getAllWindows().length !== 1 || BrowserWindow.getAllWindows()[0] !== target)
|
|
333
|
+
throw new Error("Startup window survived the handoff");
|
|
334
|
+
if (!isAbsolute(outputDirectory))
|
|
335
|
+
throw new Error("--smoke-dir must be an absolute path");
|
|
336
|
+
await mkdir(outputDirectory, { recursive: true, mode: 0o700 });
|
|
337
|
+
const linkedFile = join(outputDirectory, "example report.txt");
|
|
338
|
+
const linkedDirectory = join(outputDirectory, "example-directory");
|
|
339
|
+
if (fixture) {
|
|
340
|
+
await writeFile(linkedFile, "A real file opened from an assistant reply.\n", { mode: 0o600 });
|
|
341
|
+
await mkdir(linkedDirectory, { mode: 0o700 });
|
|
342
|
+
}
|
|
343
|
+
target.setContentSize(1_920, 1_080);
|
|
344
|
+
target.show();
|
|
345
|
+
await waitForRenderer(target, "document.documentElement.dataset.ready === 'true'", 10_000);
|
|
346
|
+
await waitFor(() => daemonBridge.bootstrap().connection.phase === "connected", 8_000).catch(() => undefined);
|
|
347
|
+
let reconnectVerified = false;
|
|
348
|
+
let reconnectMasked = false;
|
|
349
|
+
let sessionSearchProbe = { opened: false, matched: false, closed: false };
|
|
350
|
+
if (fixture && daemonBridge.bootstrap().connection.phase === "connected") {
|
|
351
|
+
await daemonBridge.stop();
|
|
352
|
+
// A long run drops and re-establishes the socket constantly; the shell must
|
|
353
|
+
// keep presenting the last good state until the settle window expires,
|
|
354
|
+
// instead of strobing the badge, banner and every connection-gated control.
|
|
355
|
+
await waitForRenderer(target, "document.documentElement.dataset.reconnecting === 'true'", 3_000);
|
|
356
|
+
reconnectMasked = await target.webContents.executeJavaScript("document.documentElement.dataset.connection === 'connected'");
|
|
357
|
+
// A genuine outage still has to surface once the window elapses.
|
|
358
|
+
await waitForRenderer(target, "document.documentElement.dataset.connection === 'disconnected'", 8_000);
|
|
359
|
+
daemonBridge.start();
|
|
360
|
+
await waitFor(() => daemonBridge.bootstrap().connection.phase === "connected", 8_000);
|
|
361
|
+
await waitForRenderer(target, "document.documentElement.dataset.connection === 'connected'", 5_000);
|
|
362
|
+
reconnectVerified = true;
|
|
363
|
+
}
|
|
364
|
+
const runActivity = {
|
|
365
|
+
toolPhase: "unmeasured",
|
|
366
|
+
toolLabel: "",
|
|
367
|
+
streamingPhase: "unmeasured",
|
|
368
|
+
hiddenWhenIdle: false,
|
|
369
|
+
};
|
|
370
|
+
let fixtureState;
|
|
371
|
+
let directQueueVisibleBeforeToolCompletion = false;
|
|
372
|
+
const selection = {
|
|
373
|
+
historyBefore: "",
|
|
374
|
+
historyAfter: "",
|
|
375
|
+
scrollHeldDuringSelection: false,
|
|
376
|
+
streamingBefore: "",
|
|
377
|
+
streamingAfter: "",
|
|
378
|
+
};
|
|
379
|
+
// Nothing is running yet, so the activity line must be absent. Checked here
|
|
380
|
+
// rather than by faking a settle later: the real daemon's session is idle.
|
|
381
|
+
runActivity.hiddenWhenIdle = await target.webContents.executeJavaScript("document.querySelector('#run-activity')?.hidden === true");
|
|
382
|
+
if (fixture) {
|
|
383
|
+
const base = daemonBridge.bootstrap();
|
|
384
|
+
const cursors = { ...base.cursors, "desktop-smoke": 10 };
|
|
385
|
+
fixtureState = createDesktopSmokeState(base.snapshot, false);
|
|
386
|
+
sendFrame(target, { version: 1, kind: "snapshot", snapshot: fixtureState, cursors });
|
|
387
|
+
sendFrame(target, smokeEvent(11, "tool.started", { sessionId: "main", toolCallId: "smoke-tool-1", toolName: "shell", command: "npm run check" }));
|
|
388
|
+
await waitForRenderer(target, "Boolean(document.querySelector('#message-queue [data-queue-mode=direct]'))", 5_000);
|
|
389
|
+
directQueueVisibleBeforeToolCompletion = true;
|
|
390
|
+
// A tool is running and nothing is streaming yet: the state that used to
|
|
391
|
+
// leave the conversation looking dead. Waited for rather than sampled: the
|
|
392
|
+
// queue control appears a render before the activity line catches up, so a
|
|
393
|
+
// loaded machine reads the frame that still says "waiting".
|
|
394
|
+
await waitForRenderer(target, "document.querySelector('#run-activity')?.dataset.phase === 'tool'", 5_000)
|
|
395
|
+
.catch(() => undefined);
|
|
396
|
+
runActivity.toolPhase = await target.webContents.executeJavaScript("document.querySelector('#run-activity')?.dataset.phase ?? 'hidden'");
|
|
397
|
+
runActivity.toolLabel = await target.webContents.executeJavaScript("document.querySelector('#run-activity-label')?.textContent ?? ''");
|
|
398
|
+
sendFrame(target, smokeEvent(12, "tool.completed", {
|
|
399
|
+
sessionId: "main",
|
|
400
|
+
toolCallId: "smoke-tool-1",
|
|
401
|
+
toolName: "shell",
|
|
402
|
+
status: "succeeded",
|
|
403
|
+
output: Array.from({ length: 80 }, (_, index) => `verification line ${index + 1}: pass`).join("\n"),
|
|
404
|
+
}));
|
|
405
|
+
sendFrame(target, {
|
|
406
|
+
version: 1,
|
|
407
|
+
kind: "delta",
|
|
408
|
+
streamId: "desktop-smoke",
|
|
409
|
+
fromSeq: 13,
|
|
410
|
+
seq: 14,
|
|
411
|
+
sessionId: "main",
|
|
412
|
+
itemId: "smoke-item-1",
|
|
413
|
+
channel: "thinking",
|
|
414
|
+
text: "正在核对测试结果、运行时版本与升级边界。",
|
|
415
|
+
});
|
|
416
|
+
sendFrame(target, {
|
|
417
|
+
version: 1,
|
|
418
|
+
kind: "delta",
|
|
419
|
+
streamId: "desktop-smoke",
|
|
420
|
+
fromSeq: 15,
|
|
421
|
+
seq: 17,
|
|
422
|
+
sessionId: "main",
|
|
423
|
+
itemId: "smoke-item-1",
|
|
424
|
+
channel: "text",
|
|
425
|
+
text: "正在归并最终证据;这段文字来自连续 streaming delta。",
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
await waitForRenderer(target, "Number(document.documentElement.dataset.renderSequence || '0') > 0", 5_000);
|
|
429
|
+
if (fixture) {
|
|
430
|
+
await waitForRenderer(target, "Boolean(document.querySelector('.message.streaming'))", 5_000);
|
|
431
|
+
runActivity.streamingPhase = await target.webContents.executeJavaScript("document.querySelector('#run-activity')?.dataset.phase ?? 'hidden'");
|
|
432
|
+
}
|
|
433
|
+
await setRendererTheme(target, "dark");
|
|
434
|
+
let fileDrop;
|
|
435
|
+
let fileDropSize;
|
|
436
|
+
if (fixture) {
|
|
437
|
+
const result = await exerciseComposerFileDrop(target, outputDirectory);
|
|
438
|
+
fileDrop = result.probe;
|
|
439
|
+
fileDropSize = result.imageSize;
|
|
440
|
+
}
|
|
441
|
+
await settlePaint();
|
|
442
|
+
const mainVisual = await target.webContents.executeJavaScript(`(() => {
|
|
443
|
+
const transcript = document.querySelector('#transcript');
|
|
444
|
+
const code = document.querySelector('.message.assistant pre');
|
|
445
|
+
const composerRect = document.querySelector('.composer')?.getBoundingClientRect();
|
|
446
|
+
return {
|
|
447
|
+
transcriptScrollable: Boolean(transcript && transcript.scrollHeight > transcript.clientHeight),
|
|
448
|
+
codeContained: Boolean(code && code.scrollWidth > code.clientWidth && getComputedStyle(code).overflowX !== 'visible'),
|
|
449
|
+
composerVisible: Boolean(composerRect && composerRect.top >= 0 && composerRect.bottom <= innerHeight),
|
|
450
|
+
streamingVisible: Boolean(document.querySelector('.message.streaming')),
|
|
451
|
+
streamingActive: transcript?.getAttribute('aria-busy') === 'true',
|
|
452
|
+
thinkingVisible: Boolean(document.querySelector('.thinking-stream:not([hidden])')),
|
|
453
|
+
thinkingSeparate: Boolean(document.querySelector('.thinking-stream:not([hidden])') && document.querySelector('.message.streaming > .message-body:not([hidden])')),
|
|
454
|
+
thinkingHiddenAfterCompletion: false,
|
|
455
|
+
truncationNoticeVisible: Boolean(document.querySelector('#transcript .message-truncated button')),
|
|
456
|
+
truncationRetryOffered: false,
|
|
457
|
+
legacyComposerModes: document.querySelectorAll('#composer-mode, .mode-select').length,
|
|
458
|
+
// Dragging is not always possible, so a chooser has to be reachable too.
|
|
459
|
+
attachButtonsOffered: [
|
|
460
|
+
document.querySelector('#attach-file-button'),
|
|
461
|
+
document.querySelector('#attach-directory-button'),
|
|
462
|
+
].filter(Boolean).length,
|
|
463
|
+
// Hovering has to say which is which; the icons alone do not.
|
|
464
|
+
attachTooltips: ['#attach-file-button', '#attach-directory-button']
|
|
465
|
+
.map((id) => document.querySelector(id)?.title ?? ''),
|
|
466
|
+
virtualIndices: Array.from(document.querySelectorAll('#transcript > .message')).map((node) => Number(node.dataset.virtualIndex)),
|
|
467
|
+
scrollDistance: transcript ? transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight : -1,
|
|
468
|
+
renderedMessages: document.querySelectorAll('#transcript > .message').length,
|
|
469
|
+
authoritativeMessages: 1008,
|
|
470
|
+
historyBrowseRequestedTop: -1,
|
|
471
|
+
historyBrowseAfterIdleTop: -1,
|
|
472
|
+
historyBrowseAfterDeltaTop: -1,
|
|
473
|
+
tailDistanceAfterReturn: -1,
|
|
474
|
+
directQueueVisibleBeforeToolCompletion: ${directQueueVisibleBeforeToolCompletion},
|
|
475
|
+
followUpQueueVisible: Boolean(document.querySelector('#message-queue [data-queue-mode="follow-up"]'))
|
|
476
|
+
};
|
|
477
|
+
})()`);
|
|
478
|
+
const sessionRail = await target.webContents.executeJavaScript(`(() => {
|
|
479
|
+
const rows = Array.from(document.querySelectorAll('#session-list .session-row'));
|
|
480
|
+
const rootRows = rows.filter((row) => Number(row.querySelector('.session-item')?.dataset.depth ?? '0') === 0);
|
|
481
|
+
const agentRows = rows.filter((row) => Number(row.querySelector('.session-item')?.dataset.depth ?? '0') > 0);
|
|
482
|
+
const first = rootRows[0]?.querySelector('.session-delete');
|
|
483
|
+
const hiddenUntilHover = Boolean(first) && Number(getComputedStyle(first).opacity) === 0;
|
|
484
|
+
const probe = {
|
|
485
|
+
rootOrder: rootRows.map((row) => row.querySelector('.session-item strong')?.firstChild?.textContent?.trim() ?? ''),
|
|
486
|
+
times: rootRows.map((row) => row.querySelector('.session-time')?.textContent ?? ''),
|
|
487
|
+
totalRows: rows.length,
|
|
488
|
+
rootRows: rootRows.length,
|
|
489
|
+
deleteControls: rootRows.filter((row) => row.querySelector('.session-delete')).length,
|
|
490
|
+
agentDeleteControls: agentRows.filter((row) => row.querySelector('.session-delete')).length,
|
|
491
|
+
hiddenUntilHover
|
|
492
|
+
};
|
|
493
|
+
// Deleting a session destroys its transcript, so one click may only arm the
|
|
494
|
+
// control: the row it belongs to has to survive it.
|
|
495
|
+
first?.click();
|
|
496
|
+
return {
|
|
497
|
+
...probe,
|
|
498
|
+
armedLabel: document.querySelector('#session-list .session-delete.armed')?.textContent ?? '',
|
|
499
|
+
armedAfterFirstClick: Boolean(document.querySelector('#session-list .session-row.arming')),
|
|
500
|
+
rowsAfterFirstClick: document.querySelectorAll('#session-list .session-row').length,
|
|
501
|
+
armedAtCapture: false
|
|
502
|
+
};
|
|
503
|
+
})()`);
|
|
504
|
+
// Read the panel without activating its tab: clicking one moves focus, and the
|
|
505
|
+
// approval modal's focus is asserted later from wherever it was left.
|
|
506
|
+
const integrations = await target.webContents.executeJavaScript(`(() => {
|
|
507
|
+
const rows = Array.from(document.querySelectorAll('#integration-list > *'));
|
|
508
|
+
return {
|
|
509
|
+
builtinTools: Array.from(document.querySelectorAll('#integration-list .tool-chips:not(.skill-chips) .tool-chip')).map((chip) => chip.textContent ?? ''),
|
|
510
|
+
skills: rows.map((row) => row.textContent ?? '').filter((text) => text.startsWith('Skill ·')).map((text) => text.trim().slice(0, 40)),
|
|
511
|
+
skillChips: Array.from(document.querySelectorAll('#integration-list .skill-chips .tool-chip')).map((chip) => chip.textContent ?? ''),
|
|
512
|
+
unloadedSkills: rows.map((row) => row.textContent ?? '').filter((text) => text.startsWith('未加载 Skill ·')).map((text) => text.trim().slice(0, 160))
|
|
513
|
+
};
|
|
514
|
+
})()`);
|
|
515
|
+
const indexingPanel = await target.webContents.executeJavaScript(`(() => ({
|
|
516
|
+
phase: document.querySelector('#indexing-panel')?.dataset.phase ?? '',
|
|
517
|
+
phaseLabel: document.querySelector('#indexing-phase')?.textContent ?? '',
|
|
518
|
+
counts: document.querySelector('#indexing-counts')?.textContent ?? '',
|
|
519
|
+
note: document.querySelector('#indexing-note')?.textContent ?? ''
|
|
520
|
+
}))()`);
|
|
521
|
+
// Evidence for the one destructive control in the rail: the armed state is
|
|
522
|
+
// captured on its own, because the main capture is taken after it reverts.
|
|
523
|
+
const railRect = await target.webContents.executeJavaScript(`(() => {
|
|
524
|
+
const rect = document.querySelector('#session-list')?.getBoundingClientRect();
|
|
525
|
+
return rect ? { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(Math.min(rect.height, 220)) } : { x: 0, y: 0, width: 320, height: 220 };
|
|
526
|
+
})()`);
|
|
527
|
+
const armedRailImage = await target.webContents.capturePage(railRect);
|
|
528
|
+
await writeFile(join(outputDirectory, "desktop-session-delete-armed.png"), armedRailImage.toPNG(), { mode: 0o600 });
|
|
529
|
+
sessionRail.armedAtCapture = await target.webContents.executeJavaScript("Boolean(document.querySelector('#session-list .session-row.arming'))");
|
|
530
|
+
// Narrow the window without crossing a breakpoint. Nothing used to re-render
|
|
531
|
+
// here, so the transcript kept the heights it measured at the old width.
|
|
532
|
+
const beforeResize = await target.webContents.executeJavaScript(`(() => ({
|
|
533
|
+
width: document.querySelector('#transcript')?.clientWidth ?? 0,
|
|
534
|
+
scrollHeight: document.querySelector('#transcript')?.scrollHeight ?? 0,
|
|
535
|
+
panels: \`\${document.documentElement.dataset.navigation}/\${document.documentElement.dataset.inspector}\`
|
|
536
|
+
}))()`);
|
|
537
|
+
target.setContentSize(1_700, 1_080);
|
|
538
|
+
await settlePaint();
|
|
539
|
+
await waitForRenderer(target, `(document.querySelector('#transcript')?.scrollHeight ?? 0) !== ${beforeResize.scrollHeight}`, 5_000)
|
|
540
|
+
.catch(() => undefined);
|
|
541
|
+
const afterResize = await target.webContents.executeJavaScript(`(() => ({
|
|
542
|
+
width: document.querySelector('#transcript')?.clientWidth ?? 0,
|
|
543
|
+
scrollHeight: document.querySelector('#transcript')?.scrollHeight ?? 0,
|
|
544
|
+
panels: \`\${document.documentElement.dataset.navigation}/\${document.documentElement.dataset.inspector}\`
|
|
545
|
+
}))()`);
|
|
546
|
+
const inBandResize = {
|
|
547
|
+
beforeWidth: beforeResize.width,
|
|
548
|
+
afterWidth: afterResize.width,
|
|
549
|
+
beforeScrollHeight: beforeResize.scrollHeight,
|
|
550
|
+
afterScrollHeight: afterResize.scrollHeight,
|
|
551
|
+
panelsUnchanged: beforeResize.panels === afterResize.panels,
|
|
552
|
+
};
|
|
553
|
+
target.setContentSize(1_920, 1_080);
|
|
554
|
+
// The reflow is debounced, so it must be allowed to land before anything else
|
|
555
|
+
// touches the transcript; otherwise it resets the scroll mid-assertion.
|
|
556
|
+
await waitForRenderer(target, `(document.querySelector('#transcript')?.scrollHeight ?? 0) === ${beforeResize.scrollHeight}`, 5_000).catch(() => undefined);
|
|
557
|
+
await settlePaint();
|
|
558
|
+
const mainImage = await target.webContents.capturePage();
|
|
559
|
+
await writeFile(join(outputDirectory, "desktop-main-dark-1920x1080.png"), mainImage.toPNG(), { mode: 0o600 });
|
|
560
|
+
if (fixture) {
|
|
561
|
+
mainVisual.historyBrowseRequestedTop = await target.webContents.executeJavaScript(`(() => {
|
|
562
|
+
const transcript = document.querySelector('#transcript');
|
|
563
|
+
if (!transcript) return -1;
|
|
564
|
+
transcript.scrollTop = Math.max(0, transcript.scrollHeight - transcript.clientHeight - Math.max(600, transcript.clientHeight));
|
|
565
|
+
transcript.dispatchEvent(new Event('scroll'));
|
|
566
|
+
return transcript.scrollTop;
|
|
567
|
+
})()`);
|
|
568
|
+
await settlePaint();
|
|
569
|
+
mainVisual.historyBrowseAfterIdleTop = await target.webContents.executeJavaScript("document.querySelector('#transcript')?.scrollTop ?? -1");
|
|
570
|
+
sendFrame(target, {
|
|
571
|
+
version: 1,
|
|
572
|
+
kind: "delta",
|
|
573
|
+
streamId: "desktop-smoke",
|
|
574
|
+
fromSeq: 18,
|
|
575
|
+
seq: 19,
|
|
576
|
+
sessionId: "main",
|
|
577
|
+
itemId: "smoke-item-1",
|
|
578
|
+
channel: "text",
|
|
579
|
+
text: " 浏览历史期间的新输出不应抢走滚动位置。",
|
|
580
|
+
});
|
|
581
|
+
await settlePaint();
|
|
582
|
+
mainVisual.historyBrowseAfterDeltaTop = await target.webContents.executeJavaScript("document.querySelector('#transcript')?.scrollTop ?? -1");
|
|
583
|
+
await target.webContents.executeJavaScript(`(() => {
|
|
584
|
+
const transcript = document.querySelector('#transcript');
|
|
585
|
+
if (!transcript) return;
|
|
586
|
+
transcript.scrollTop = transcript.scrollHeight;
|
|
587
|
+
transcript.dispatchEvent(new Event('scroll'));
|
|
588
|
+
})()`);
|
|
589
|
+
await settlePaint();
|
|
590
|
+
// Copying out of a conversation that is still being written. Every frame
|
|
591
|
+
// used to rebuild the transcript, so a selection survived for one frame at
|
|
592
|
+
// most — the reader had to wait for the reply to finish before they could
|
|
593
|
+
// take anything out of it.
|
|
594
|
+
const streamDelta = async (seq, text) => {
|
|
595
|
+
sendFrame(target, {
|
|
596
|
+
version: 1,
|
|
597
|
+
kind: "delta",
|
|
598
|
+
streamId: "desktop-smoke",
|
|
599
|
+
fromSeq: seq,
|
|
600
|
+
seq,
|
|
601
|
+
sessionId: "main",
|
|
602
|
+
itemId: "smoke-item-1",
|
|
603
|
+
channel: "text",
|
|
604
|
+
text,
|
|
605
|
+
});
|
|
606
|
+
await settlePaint();
|
|
607
|
+
};
|
|
608
|
+
// A second paragraph, so the streaming body has a block that later tokens
|
|
609
|
+
// do not touch. Without one there is nothing in it that could be kept.
|
|
610
|
+
await streamDelta(20, "\n\n第二段:证据编号与耗时。");
|
|
611
|
+
selection.historyBefore = await target.webContents.executeJavaScript(`(() => {
|
|
612
|
+
const body = Array.from(document.querySelectorAll('#transcript > .message:not(.streaming) .message-body'))
|
|
613
|
+
.find((node) => (node.textContent ?? '').trim().length > 24);
|
|
614
|
+
if (!body) return '';
|
|
615
|
+
const range = document.createRange();
|
|
616
|
+
range.selectNodeContents(body);
|
|
617
|
+
const active = getSelection();
|
|
618
|
+
if (!active) return '';
|
|
619
|
+
active.removeAllRanges();
|
|
620
|
+
active.addRange(range);
|
|
621
|
+
return active.toString();
|
|
622
|
+
})()`);
|
|
623
|
+
const scrollBeforeDelta = await target.webContents.executeJavaScript("document.querySelector('#transcript')?.scrollTop ?? -1");
|
|
624
|
+
await streamDelta(21, " 历史选区期间仍在输出。");
|
|
625
|
+
selection.historyAfter = await target.webContents.executeJavaScript("getSelection()?.toString() ?? ''");
|
|
626
|
+
const scrollAfterDelta = await target.webContents.executeJavaScript("document.querySelector('#transcript')?.scrollTop ?? -1");
|
|
627
|
+
selection.scrollHeldDuringSelection = scrollBeforeDelta >= 0 && scrollBeforeDelta === scrollAfterDelta;
|
|
628
|
+
selection.streamingBefore = await target.webContents.executeJavaScript(`(() => {
|
|
629
|
+
const first = document.querySelector('.message.streaming .message-body:not([hidden]) > *');
|
|
630
|
+
if (!first) return '';
|
|
631
|
+
const range = document.createRange();
|
|
632
|
+
range.selectNodeContents(first);
|
|
633
|
+
const active = getSelection();
|
|
634
|
+
if (!active) return '';
|
|
635
|
+
active.removeAllRanges();
|
|
636
|
+
active.addRange(range);
|
|
637
|
+
return active.toString();
|
|
638
|
+
})()`);
|
|
639
|
+
await streamDelta(22, " 又一批 token。");
|
|
640
|
+
selection.streamingAfter = await target.webContents.executeJavaScript("getSelection()?.toString() ?? ''");
|
|
641
|
+
await target.webContents.executeJavaScript("getSelection()?.removeAllRanges(); true");
|
|
642
|
+
await settlePaint();
|
|
643
|
+
sendFrame(target, smokeEvent(23, "transcript.item.finished", {
|
|
644
|
+
sessionId: "main",
|
|
645
|
+
item: {
|
|
646
|
+
id: "smoke-item-1", role: "assistant",
|
|
647
|
+
text: `最终证据已归并。[打开文件](${pathToFileURL(linkedFile).href}),目录 ${linkedDirectory},代码路径 \`${linkedFile}\`。`,
|
|
648
|
+
status: "complete", ts: "2026-08-10T08:10:00.000Z",
|
|
649
|
+
},
|
|
650
|
+
}));
|
|
651
|
+
await waitForRenderer(target, "!document.querySelector('.thinking-stream:not([hidden])')", 5_000);
|
|
652
|
+
await settlePaint();
|
|
653
|
+
await target.webContents.executeJavaScript(`(() => {
|
|
654
|
+
const file = [...document.querySelectorAll('.message.assistant .md-local-link')]
|
|
655
|
+
.find((node) => node.title === ${JSON.stringify(pathToFileURL(linkedFile).href)});
|
|
656
|
+
const directory = [...document.querySelectorAll('.message.assistant .md-local-link')]
|
|
657
|
+
.find((node) => node.title === ${JSON.stringify(linkedDirectory)});
|
|
658
|
+
const code = [...document.querySelectorAll('.message.assistant .md-local-link')]
|
|
659
|
+
.find((node) => node.title === ${JSON.stringify(linkedFile)});
|
|
660
|
+
file?.click();
|
|
661
|
+
directory?.click();
|
|
662
|
+
code?.click();
|
|
663
|
+
})()`);
|
|
664
|
+
await waitFor(() => smokeOpenedPaths.filter((path) => path === linkedFile).length === 2
|
|
665
|
+
&& smokeOpenedPaths.includes(linkedDirectory), 3_000);
|
|
666
|
+
await target.webContents.executeJavaScript(`(() => {
|
|
667
|
+
const file = [...document.querySelectorAll('.message.assistant .md-local-link')]
|
|
668
|
+
.find((node) => node.title === ${JSON.stringify(linkedFile)});
|
|
669
|
+
if (!file) return;
|
|
670
|
+
const rect = file.getBoundingClientRect();
|
|
671
|
+
file.dispatchEvent(new MouseEvent('contextmenu', {
|
|
672
|
+
bubbles: true,
|
|
673
|
+
cancelable: true,
|
|
674
|
+
clientX: rect.left + 8,
|
|
675
|
+
clientY: rect.bottom + 4,
|
|
676
|
+
}));
|
|
677
|
+
})()`);
|
|
678
|
+
await waitForRenderer(target, `(() => {
|
|
679
|
+
const menu = document.querySelector('.local-link-menu');
|
|
680
|
+
const open = menu?.querySelector('[data-action="open"]');
|
|
681
|
+
const reveal = menu?.querySelector('[data-action="reveal"]');
|
|
682
|
+
return Boolean(menu && open && reveal && open.textContent?.includes('打开') && reveal.textContent?.includes('打开所在目录'));
|
|
683
|
+
})()`, 2_000);
|
|
684
|
+
await target.webContents.executeJavaScript(`document.querySelector('.local-link-menu [data-action="reveal"]')?.click()`);
|
|
685
|
+
await waitFor(() => smokeRevealedPaths.includes(linkedFile), 3_000);
|
|
686
|
+
await target.webContents.executeJavaScript(`(() => {
|
|
687
|
+
const code = [...document.querySelectorAll('.message.assistant .md-local-link')]
|
|
688
|
+
.find((node) => node.title === ${JSON.stringify(linkedFile)});
|
|
689
|
+
const icon = code?.nextElementSibling;
|
|
690
|
+
if (!(icon instanceof HTMLButtonElement) || !icon.classList.contains('md-local-reveal')) return;
|
|
691
|
+
icon.click();
|
|
692
|
+
})()`);
|
|
693
|
+
await waitFor(() => smokeRevealedPaths.filter((path) => path === linkedFile).length >= 2, 3_000);
|
|
694
|
+
await writeFile(join(outputDirectory, "desktop-local-links-1920x1080.png"), (await target.webContents.capturePage()).toPNG(), { mode: 0o600 });
|
|
695
|
+
mainVisual.tailDistanceAfterReturn = await target.webContents.executeJavaScript(`(() => {
|
|
696
|
+
const transcript = document.querySelector('#transcript');
|
|
697
|
+
return transcript ? transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight : -1;
|
|
698
|
+
})()`);
|
|
699
|
+
mainVisual.thinkingHiddenAfterCompletion = true;
|
|
700
|
+
// Clicking "载入全文" reaches the daemon through the allowlisted command.
|
|
701
|
+
// The smoke fixture's transcript is not the daemon's, so the request is
|
|
702
|
+
// refused — which is exactly the path that has to leave the button usable.
|
|
703
|
+
await target.webContents.executeJavaScript("document.querySelector('#transcript .message-truncated button')?.click()");
|
|
704
|
+
mainVisual.truncationRetryOffered = await waitForRenderer(target, "(() => { const b = document.querySelector('#transcript .message-truncated button'); return Boolean(b && !b.disabled && b.textContent === '重试载入全文'); })()", 5_000).then(() => true, () => false);
|
|
705
|
+
sessionSearchProbe = await target.webContents.executeJavaScript(`(() => {
|
|
706
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', code: 'KeyF', ctrlKey: true, bubbles: true, cancelable: true }));
|
|
707
|
+
const bar = document.querySelector('#session-search');
|
|
708
|
+
const input = document.querySelector('#session-search-input');
|
|
709
|
+
if (!(input instanceof HTMLInputElement) || bar?.hidden !== false) return { opened: false, matched: false, closed: false };
|
|
710
|
+
input.value = 'virtual rendering fixture';
|
|
711
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
712
|
+
return { opened: true, matched: false, closed: false };
|
|
713
|
+
})()`);
|
|
714
|
+
sessionSearchProbe.matched = await waitForRenderer(target, `(() => {
|
|
715
|
+
const count = document.querySelector('#session-search-count')?.textContent ?? '';
|
|
716
|
+
return count.includes('/') && Boolean(document.querySelector('mark.session-find-hit.current'));
|
|
717
|
+
})()`, 3_000).then(() => true, () => false);
|
|
718
|
+
await target.webContents.executeJavaScript(`(() => {
|
|
719
|
+
document.querySelector('#session-search-close')?.click();
|
|
720
|
+
const transcript = document.querySelector('#transcript');
|
|
721
|
+
if (!transcript) return;
|
|
722
|
+
transcript.scrollTop = transcript.scrollHeight;
|
|
723
|
+
transcript.dispatchEvent(new Event('scroll'));
|
|
724
|
+
})()`);
|
|
725
|
+
sessionSearchProbe.closed = await waitForRenderer(target, `document.querySelector('#session-search')?.hidden === true && !document.querySelector('mark.session-find-hit')`, 2_000).then(() => true, () => false);
|
|
726
|
+
}
|
|
727
|
+
const darkTheme = await readThemeSnapshot(target);
|
|
728
|
+
const typography = await target.webContents.executeJavaScript(`(() => {
|
|
729
|
+
const size = (selector) => Number.parseFloat(getComputedStyle(document.querySelector(selector)).fontSize);
|
|
730
|
+
return {
|
|
731
|
+
body: Number.parseFloat(getComputedStyle(document.body).fontSize),
|
|
732
|
+
conversationTitle: size('.conversation-header h2'),
|
|
733
|
+
messageBody: size('.message-body'),
|
|
734
|
+
composerInput: size('#composer-input'),
|
|
735
|
+
sectionHeading: size('.section-heading h2'),
|
|
736
|
+
sessionPrimary: size('.session-item strong'),
|
|
737
|
+
sessionSecondary: size('.session-item small'),
|
|
738
|
+
denseTitle: size('.dense-item b'),
|
|
739
|
+
auxiliary: size('.eyebrow')
|
|
740
|
+
};
|
|
741
|
+
})()`);
|
|
742
|
+
await setRendererTheme(target, "light");
|
|
743
|
+
await settlePaint();
|
|
744
|
+
const lightTheme = await readThemeSnapshot(target);
|
|
745
|
+
const lightImage = await target.webContents.capturePage();
|
|
746
|
+
await writeFile(join(outputDirectory, "desktop-main-light-1920x1080.png"), lightImage.toPNG(), { mode: 0o600 });
|
|
747
|
+
await setRendererTheme(target, "dark");
|
|
748
|
+
await target.webContents.executeJavaScript("document.querySelector('#settings-button')?.click()");
|
|
749
|
+
await waitForRenderer(target, "document.querySelector('#settings-overlay')?.hidden === false", 5_000);
|
|
750
|
+
await waitForRenderer(target, "document.querySelector('#settings-save')?.disabled === false", 5_000);
|
|
751
|
+
await settlePaint();
|
|
752
|
+
const settingsImage = await target.webContents.capturePage();
|
|
753
|
+
await writeFile(join(outputDirectory, "desktop-settings-1920x1080.png"), settingsImage.toPNG(), { mode: 0o600 });
|
|
754
|
+
const settingsProbe = await target.webContents.executeJavaScript(`(async () => {
|
|
755
|
+
const dialog = document.querySelector('.settings-dialog');
|
|
756
|
+
const dialogRect = dialog?.getBoundingClientRect();
|
|
757
|
+
const settings = await window.pspore.getSettings();
|
|
758
|
+
const provider = document.querySelector('#settings-provider');
|
|
759
|
+
// Checked before the provider is switched: making Base URL required is this
|
|
760
|
+
// probe's doing, not something the panel was filled with.
|
|
761
|
+
const form = document.querySelector('#settings-form');
|
|
762
|
+
const invalidFields = Array.from(form?.elements ?? [])
|
|
763
|
+
.filter((field) => field.willValidate && !field.checkValidity())
|
|
764
|
+
.map((field) => field.id);
|
|
765
|
+
provider.value = 'custom-openai';
|
|
766
|
+
provider.dispatchEvent(new Event('change'));
|
|
767
|
+
// Adding a row is the only way to see the header editor holding anything.
|
|
768
|
+
document.querySelector('#settings-header-add')?.click();
|
|
769
|
+
const headerRows = document.querySelectorAll('#settings-header-list .settings-header-row').length;
|
|
770
|
+
return {
|
|
771
|
+
dialogContained: Boolean(dialogRect && dialogRect.left >= 0 && dialogRect.top >= 0 && dialogRect.right <= innerWidth && dialogRect.bottom <= innerHeight),
|
|
772
|
+
backgroundInert: Boolean(document.querySelector('#app-shell')?.inert),
|
|
773
|
+
apiKeyInputType: document.querySelector('#settings-api-key')?.type || '',
|
|
774
|
+
cookieInputType: document.querySelector('#settings-cookie')?.type || '',
|
|
775
|
+
returnedSecret: Object.prototype.hasOwnProperty.call(settings, 'apiKey') || Object.prototype.hasOwnProperty.call(settings, 'cookie'),
|
|
776
|
+
fieldFontSize: Number.parseFloat(getComputedStyle(provider).fontSize),
|
|
777
|
+
providerOptions: Array.from(provider.options).map((option) => option.value),
|
|
778
|
+
permissionModes: Array.from(document.querySelector('#settings-permission-mode')?.options || []).map((option) => option.value),
|
|
779
|
+
permissionWarningHidden: document.querySelector('#settings-permission-warning')?.hidden !== false,
|
|
780
|
+
bypassWarningShown: (() => {
|
|
781
|
+
const mode = document.querySelector('#settings-permission-mode');
|
|
782
|
+
mode.value = 'allow_all';
|
|
783
|
+
mode.dispatchEvent(new Event('change'));
|
|
784
|
+
const shown = document.querySelector('#settings-permission-warning')?.hidden === false;
|
|
785
|
+
mode.value = 'ask_risky';
|
|
786
|
+
mode.dispatchEvent(new Event('change'));
|
|
787
|
+
return shown && document.querySelector('#settings-permission-warning')?.hidden === true;
|
|
788
|
+
})(),
|
|
789
|
+
customBaseUrlEnabled: !document.querySelector('#settings-base-url')?.disabled && Boolean(document.querySelector('#settings-base-url')?.required),
|
|
790
|
+
customApiEnabled: !document.querySelector('#settings-api')?.disabled && document.querySelector('#settings-api')?.value === 'openai-completions',
|
|
791
|
+
autoCompactionControls: document.querySelector('#settings-auto-compaction')?.checked === settings.autoCompactionEnabled
|
|
792
|
+
&& Number(document.querySelector('#settings-compaction-trigger')?.value) === settings.autoCompactionTriggerTokens
|
|
793
|
+
&& Number(document.querySelector('#settings-compaction-reserve')?.value) === settings.autoCompactionReserveTokens
|
|
794
|
+
&& Number(document.querySelector('#settings-compaction-keep-recent')?.value) === settings.autoCompactionKeepRecentTokens,
|
|
795
|
+
headerRowsAfterAdd: headerRows,
|
|
796
|
+
headerValueInputType: document.querySelector('#settings-header-list .settings-header-value')?.type ?? '',
|
|
797
|
+
formAcceptsPersistedValues: invalidFields.length === 0,
|
|
798
|
+
invalidFields
|
|
799
|
+
};
|
|
800
|
+
})()`);
|
|
801
|
+
await target.webContents.executeJavaScript("document.querySelector('#settings-close')?.click()");
|
|
802
|
+
await waitForRenderer(target, "document.querySelector('#settings-overlay')?.hidden === true", 2_000);
|
|
803
|
+
await target.webContents.executeJavaScript("document.activeElement?.blur()");
|
|
804
|
+
target.setContentSize(1_440, 900);
|
|
805
|
+
await settlePaint();
|
|
806
|
+
const mediumLayout = await readResponsiveLayout(target);
|
|
807
|
+
const mediumImage = await target.webContents.capturePage();
|
|
808
|
+
await writeFile(join(outputDirectory, "desktop-responsive-1440x900.png"), mediumImage.toPNG(), { mode: 0o600 });
|
|
809
|
+
target.setContentSize(1_180, 720);
|
|
810
|
+
await settlePaint();
|
|
811
|
+
const compactLayout = await readResponsiveLayout(target);
|
|
812
|
+
const compactImage = await target.webContents.capturePage();
|
|
813
|
+
await writeFile(join(outputDirectory, "desktop-responsive-1180x720.png"), compactImage.toPNG(), { mode: 0o600 });
|
|
814
|
+
await target.webContents.executeJavaScript("document.querySelector('#inspector-toggle')?.click()");
|
|
815
|
+
await settlePaint();
|
|
816
|
+
const compactInspectorLayout = await readResponsiveLayout(target);
|
|
817
|
+
await target.webContents.executeJavaScript("document.querySelector('#inspector-close')?.click()");
|
|
818
|
+
target.setContentSize(1_920, 1_080);
|
|
819
|
+
await settlePaint();
|
|
820
|
+
// Back at the baseline viewport, before the approval overlay goes up: the
|
|
821
|
+
// panel needs the full width it is designed for and an unblocked page.
|
|
822
|
+
const articlePanel = await exerciseArticlePanel(target);
|
|
823
|
+
let approvalSize;
|
|
824
|
+
if (fixture) {
|
|
825
|
+
const base = daemonBridge.bootstrap();
|
|
826
|
+
sendFrame(target, {
|
|
827
|
+
version: 1,
|
|
828
|
+
kind: "snapshot",
|
|
829
|
+
snapshot: createDesktopSmokeState(base.snapshot, true),
|
|
830
|
+
cursors: { ...base.cursors, "desktop-smoke": 24 },
|
|
831
|
+
});
|
|
832
|
+
await waitForRenderer(target, "document.documentElement.dataset.approval === 'visible'", 5_000);
|
|
833
|
+
// The overlay is marked visible a frame before focus moves into it, so
|
|
834
|
+
// waiting on visibility alone races the assertion below on a loaded machine.
|
|
835
|
+
// Wait for the thing actually being checked instead.
|
|
836
|
+
await waitForRenderer(target, "document.activeElement?.textContent?.trim() === '拒绝'", 5_000);
|
|
837
|
+
await settlePaint();
|
|
838
|
+
const approvalImage = await target.webContents.capturePage();
|
|
839
|
+
await writeFile(join(outputDirectory, "desktop-approval-1920x1080.png"), approvalImage.toPNG(), { mode: 0o600 });
|
|
840
|
+
approvalSize = approvalImage.getSize();
|
|
841
|
+
}
|
|
842
|
+
const probe = await target.webContents.executeJavaScript(`(() => {
|
|
843
|
+
const transcript = document.querySelector('#transcript');
|
|
844
|
+
const code = document.querySelector('.message.assistant pre');
|
|
845
|
+
const composer = document.querySelector('.composer');
|
|
846
|
+
const modal = document.querySelector('.approval-dialog');
|
|
847
|
+
const composerRect = composer?.getBoundingClientRect();
|
|
848
|
+
const modalRect = modal?.getBoundingClientRect();
|
|
849
|
+
return {
|
|
850
|
+
viewport: { width: innerWidth, height: innerHeight, devicePixelRatio },
|
|
851
|
+
globals: {
|
|
852
|
+
process: typeof process,
|
|
853
|
+
require: typeof require,
|
|
854
|
+
Buffer: typeof Buffer,
|
|
855
|
+
electron: typeof electron
|
|
856
|
+
},
|
|
857
|
+
apiKeys: Object.keys(window.pspore || {}).sort(),
|
|
858
|
+
document: {
|
|
859
|
+
scrollWidth: document.documentElement.scrollWidth,
|
|
860
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
861
|
+
shellHeight: document.querySelector('#app-shell')?.getBoundingClientRect().height || 0,
|
|
862
|
+
workspaceBottom: document.querySelector('#main-workspace')?.getBoundingClientRect().bottom || 0,
|
|
863
|
+
bodyOverflow: getComputedStyle(document.body).overflow,
|
|
864
|
+
csp: document.querySelector('meta[http-equiv="Content-Security-Policy"]')?.content || null,
|
|
865
|
+
approval: document.documentElement.dataset.approval || null,
|
|
866
|
+
transcriptScrollable: Boolean(transcript && transcript.scrollHeight > transcript.clientHeight),
|
|
867
|
+
codeContained: Boolean(code && code.scrollWidth > code.clientWidth && getComputedStyle(code).overflowX !== 'visible'),
|
|
868
|
+
composerVisible: Boolean(composerRect && composerRect.top >= 0 && composerRect.bottom <= innerHeight),
|
|
869
|
+
modalContained: Boolean(modalRect && modalRect.left >= 0 && modalRect.top >= 0 && modalRect.right <= innerWidth && modalRect.bottom <= innerHeight),
|
|
870
|
+
backgroundInert: Boolean(document.querySelector('#app-shell')?.inert),
|
|
871
|
+
focusLabel: document.activeElement?.textContent?.trim() || null
|
|
872
|
+
}
|
|
873
|
+
}})()`);
|
|
874
|
+
const ipcProbe = await target.webContents.executeJavaScript(`(async () => {
|
|
875
|
+
const jobs = await window.pspore.dispatch({ action: 'jobs.list' });
|
|
876
|
+
let rejected = false;
|
|
877
|
+
let rejectionMessage = '';
|
|
878
|
+
try {
|
|
879
|
+
await window.pspore.dispatch({ action: 'shell.exec', command: 'id' });
|
|
880
|
+
} catch (error) {
|
|
881
|
+
rejected = true;
|
|
882
|
+
rejectionMessage = String(error?.message || error);
|
|
883
|
+
}
|
|
884
|
+
return { jobsArray: Array.isArray(jobs), rejected, rejectionMessage };
|
|
885
|
+
})()`);
|
|
886
|
+
const report = {
|
|
887
|
+
version: 1,
|
|
888
|
+
capturedAt: new Date().toISOString(),
|
|
889
|
+
connection: daemonBridge.bootstrap().connection,
|
|
890
|
+
daemonOwnership: daemonSupervisor?.ownership ?? "external",
|
|
891
|
+
...(fixture ? {
|
|
892
|
+
localLinks: {
|
|
893
|
+
fileOpened: smokeOpenedPaths.includes(linkedFile),
|
|
894
|
+
directoryOpened: smokeOpenedPaths.includes(linkedDirectory),
|
|
895
|
+
codePathOpened: smokeOpenedPaths.filter((path) => path === linkedFile).length === 2,
|
|
896
|
+
fileRevealed: smokeRevealedPaths.includes(linkedFile),
|
|
897
|
+
iconRevealed: smokeRevealedPaths.filter((path) => path === linkedFile).length >= 2,
|
|
898
|
+
openedPaths: [...smokeOpenedPaths],
|
|
899
|
+
},
|
|
900
|
+
sessionSearch: sessionSearchProbe,
|
|
901
|
+
} : {}),
|
|
902
|
+
reconnectVerified,
|
|
903
|
+
reconnectMasked,
|
|
904
|
+
images: {
|
|
905
|
+
main: mainImage.getSize(),
|
|
906
|
+
light: lightImage.getSize(),
|
|
907
|
+
settings: settingsImage.getSize(),
|
|
908
|
+
medium: mediumImage.getSize(),
|
|
909
|
+
compact: compactImage.getSize(),
|
|
910
|
+
...(fileDropSize ? { fileDrop: fileDropSize } : {}),
|
|
911
|
+
...(approvalSize ? { approval: approvalSize } : {}),
|
|
912
|
+
},
|
|
913
|
+
themes: { dark: darkTheme, light: lightTheme },
|
|
914
|
+
typography,
|
|
915
|
+
mainVisual,
|
|
916
|
+
inBandResize,
|
|
917
|
+
integrations,
|
|
918
|
+
runActivity,
|
|
919
|
+
sessionRail,
|
|
920
|
+
selection,
|
|
921
|
+
indexingPanel,
|
|
922
|
+
articlePanel,
|
|
923
|
+
responsive: { medium: mediumLayout, compact: compactLayout, compactInspector: compactInspectorLayout },
|
|
924
|
+
settings: settingsProbe,
|
|
925
|
+
...(fileDrop ? { fileDrop } : {}),
|
|
926
|
+
probe,
|
|
927
|
+
ipcProbe,
|
|
928
|
+
};
|
|
929
|
+
await writeFile(join(outputDirectory, "desktop-security.json"), `${JSON.stringify(report, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
930
|
+
assertSmokeReport(report, fixture);
|
|
931
|
+
if (fixture) {
|
|
932
|
+
const { exerciseConversation } = await import("./conversation-smoke.js");
|
|
933
|
+
await exerciseConversation(target, daemonBridge.bootstrap(), outputDirectory);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
/** The shell keeps a 10px gutter below the workspace; anything more is dead space. */
|
|
937
|
+
const SHELL_PADDING_TOLERANCE = 12;
|
|
938
|
+
function assertSmokeReport(report, fixture) {
|
|
939
|
+
const failures = [];
|
|
940
|
+
if (report.connection.phase !== "connected")
|
|
941
|
+
failures.push("desktop did not connect to the real daemon");
|
|
942
|
+
if (fixture && !report.reconnectVerified)
|
|
943
|
+
failures.push("desktop reconnect path was not verified");
|
|
944
|
+
if (fixture && (!report.localLinks?.fileOpened || !report.localLinks.directoryOpened || !report.localLinks.codePathOpened)) {
|
|
945
|
+
failures.push("assistant output did not open its linked file, directory and inline-code path");
|
|
946
|
+
}
|
|
947
|
+
if (fixture && !report.localLinks?.fileRevealed) {
|
|
948
|
+
failures.push("the local-link menu did not reveal the file in its directory");
|
|
949
|
+
}
|
|
950
|
+
if (fixture && !report.localLinks?.iconRevealed) {
|
|
951
|
+
failures.push("the folder icon after a local link did not reveal the file");
|
|
952
|
+
}
|
|
953
|
+
if (fixture && (!report.sessionSearch?.opened || !report.sessionSearch.matched || !report.sessionSearch.closed)) {
|
|
954
|
+
failures.push("current-session search did not open, match, and close");
|
|
955
|
+
}
|
|
956
|
+
if (fixture && !report.reconnectMasked)
|
|
957
|
+
failures.push("a brief outage was surfaced instead of being settled");
|
|
958
|
+
if (report.images.main.width !== 1_920 || report.images.main.height !== 1_080)
|
|
959
|
+
failures.push("main capture is not 1920x1080");
|
|
960
|
+
if (report.images.light.width !== 1_920 || report.images.light.height !== 1_080)
|
|
961
|
+
failures.push("light capture is not 1920x1080");
|
|
962
|
+
if (report.images.settings.width !== 1_920 || report.images.settings.height !== 1_080)
|
|
963
|
+
failures.push("settings capture is not 1920x1080");
|
|
964
|
+
if (report.images.medium.width !== 1_440 || report.images.medium.height !== 900)
|
|
965
|
+
failures.push("medium capture is not 1440x900");
|
|
966
|
+
if (report.images.compact.width !== 1_180 || report.images.compact.height !== 720)
|
|
967
|
+
failures.push("compact capture is not 1180x720");
|
|
968
|
+
if (report.themes.dark.colorScheme !== "dark" || report.themes.light.colorScheme !== "light")
|
|
969
|
+
failures.push("theme color-scheme did not switch");
|
|
970
|
+
if (report.themes.dark.backgroundColor === report.themes.light.backgroundColor)
|
|
971
|
+
failures.push("theme canvas colors did not switch");
|
|
972
|
+
if (report.themes.dark.surfaceColor === report.themes.light.surfaceColor)
|
|
973
|
+
failures.push("theme surface colors did not switch");
|
|
974
|
+
const minimumFontSizes = {
|
|
975
|
+
body: 14,
|
|
976
|
+
conversationTitle: 20,
|
|
977
|
+
messageBody: 15,
|
|
978
|
+
composerInput: 14,
|
|
979
|
+
sectionHeading: 15,
|
|
980
|
+
sessionPrimary: 13,
|
|
981
|
+
sessionSecondary: 11.5,
|
|
982
|
+
denseTitle: 12.5,
|
|
983
|
+
auxiliary: 11.5,
|
|
984
|
+
};
|
|
985
|
+
for (const [name, minimum] of Object.entries(minimumFontSizes)) {
|
|
986
|
+
const actual = report.typography[name];
|
|
987
|
+
if (actual === undefined || !Number.isFinite(actual) || actual < minimum)
|
|
988
|
+
failures.push(`${name} font size is below ${minimum}px`);
|
|
989
|
+
}
|
|
990
|
+
if (report.probe.viewport.width !== 1_920 || report.probe.viewport.height !== 1_080)
|
|
991
|
+
failures.push("renderer viewport is not 1920x1080");
|
|
992
|
+
if (report.probe.document.scrollWidth !== 1_920 || report.probe.document.scrollHeight !== 1_080)
|
|
993
|
+
failures.push("document overflows the viewport");
|
|
994
|
+
if (report.probe.document.bodyOverflow !== "hidden")
|
|
995
|
+
failures.push("body overflow is not contained");
|
|
996
|
+
if (Math.abs(report.probe.document.shellHeight - 1_080) > 1)
|
|
997
|
+
failures.push("app shell does not fill the viewport height");
|
|
998
|
+
if (1_080 - report.probe.document.workspaceBottom > SHELL_PADDING_TOLERANCE)
|
|
999
|
+
failures.push("main workspace leaves vertical dead space");
|
|
1000
|
+
if (report.probe.document.csp !== DESKTOP_CSP)
|
|
1001
|
+
failures.push("renderer CSP does not match the strict policy");
|
|
1002
|
+
for (const global of ["process", "require", "Buffer", "electron"]) {
|
|
1003
|
+
if (report.probe.globals[global] !== "undefined")
|
|
1004
|
+
failures.push(`renderer exposes ${global}`);
|
|
1005
|
+
}
|
|
1006
|
+
// Pinned deliberately: the preload is the renderer's only capability surface,
|
|
1007
|
+
// so a new method must be a conscious change rather than an accident.
|
|
1008
|
+
const expectedApi = [
|
|
1009
|
+
"bootstrap",
|
|
1010
|
+
"dispatch",
|
|
1011
|
+
"exportArticle",
|
|
1012
|
+
"getIndexing",
|
|
1013
|
+
"getPathForFile",
|
|
1014
|
+
"getSettings",
|
|
1015
|
+
"openLocalPath",
|
|
1016
|
+
"pickAttachments",
|
|
1017
|
+
"pickIndexDirectory",
|
|
1018
|
+
"saveIndexing",
|
|
1019
|
+
"saveSettings",
|
|
1020
|
+
"subscribe",
|
|
1021
|
+
].join(",");
|
|
1022
|
+
if (report.probe.apiKeys.join(",") !== expectedApi) {
|
|
1023
|
+
failures.push(`preload API surface changed: ${report.probe.apiKeys.join(",")} (expected ${expectedApi})`);
|
|
1024
|
+
}
|
|
1025
|
+
if (!report.ipcProbe.jobsArray)
|
|
1026
|
+
failures.push("allowlisted IPC round trip failed");
|
|
1027
|
+
if (!report.ipcProbe.rejected || !report.ipcProbe.rejectionMessage.includes("not allowlisted"))
|
|
1028
|
+
failures.push("non-allowlisted IPC was not rejected");
|
|
1029
|
+
if (!report.settings.dialogContained || !report.settings.backgroundInert)
|
|
1030
|
+
failures.push("settings dialog is not contained and modal");
|
|
1031
|
+
if (report.settings.apiKeyInputType !== "password" || report.settings.cookieInputType !== "password" || report.settings.returnedSecret)
|
|
1032
|
+
failures.push("settings API exposed an API key or cookie");
|
|
1033
|
+
if (report.settings.fieldFontSize < 14)
|
|
1034
|
+
failures.push("settings field font size is below 14px");
|
|
1035
|
+
if (report.settings.providerOptions.join(",") !== "deepseek,openai,custom-openai")
|
|
1036
|
+
failures.push("settings provider choices changed");
|
|
1037
|
+
if (report.settings.permissionModes.join(",") !== "ask_risky,ask_all,allow_all,deny")
|
|
1038
|
+
failures.push("settings permission modes changed");
|
|
1039
|
+
if (!report.settings.permissionWarningHidden)
|
|
1040
|
+
failures.push("bypass warning is shown while approvals are still enforced");
|
|
1041
|
+
if (!report.settings.bypassWarningShown)
|
|
1042
|
+
failures.push("selecting the bypass mode did not surface its risk warning");
|
|
1043
|
+
if (!report.settings.customBaseUrlEnabled || !report.settings.customApiEnabled)
|
|
1044
|
+
failures.push("custom OpenAI controls are not enabled correctly");
|
|
1045
|
+
if (!report.settings.autoCompactionControls)
|
|
1046
|
+
failures.push("auto-compaction controls do not reflect persisted settings");
|
|
1047
|
+
if (!report.settings.formAcceptsPersistedValues) {
|
|
1048
|
+
// The browser refuses to submit a form with an invalid field, so this makes
|
|
1049
|
+
// the save button do nothing at all.
|
|
1050
|
+
failures.push(`settings panel rejects its own persisted values: ${report.settings.invalidFields.join(", ")}`);
|
|
1051
|
+
}
|
|
1052
|
+
if (report.settings.headerRowsAfterAdd < 1)
|
|
1053
|
+
failures.push("settings panel does not offer a custom request header row");
|
|
1054
|
+
// A header value can be a proxy token, so it is masked like the key and cookie.
|
|
1055
|
+
if (report.settings.headerValueInputType !== "password")
|
|
1056
|
+
failures.push("custom header value is not masked");
|
|
1057
|
+
const expectedResponsive = [
|
|
1058
|
+
["medium", report.responsive.medium, 1_440, 900],
|
|
1059
|
+
["compact", report.responsive.compact, 1_180, 720],
|
|
1060
|
+
["compact inspector", report.responsive.compactInspector, 1_180, 720],
|
|
1061
|
+
];
|
|
1062
|
+
for (const [name, layout, width, height] of expectedResponsive) {
|
|
1063
|
+
if (layout.viewport.width !== width || layout.viewport.height !== height)
|
|
1064
|
+
failures.push(`${name} viewport changed unexpectedly`);
|
|
1065
|
+
if (layout.scrollWidth !== width || layout.scrollHeight !== height)
|
|
1066
|
+
failures.push(`${name} document overflows the viewport`);
|
|
1067
|
+
if (Math.abs(layout.shellHeight - height) > 1)
|
|
1068
|
+
failures.push(`${name} app shell does not fill the viewport height`);
|
|
1069
|
+
if (height - layout.workspaceBottom > SHELL_PADDING_TOLERANCE)
|
|
1070
|
+
failures.push(`${name} main workspace leaves vertical dead space`);
|
|
1071
|
+
if (!layout.composerVisible || layout.conversationWidth < 520)
|
|
1072
|
+
failures.push(`${name} conversation does not use the available space`);
|
|
1073
|
+
if (!layout.inspectorContained)
|
|
1074
|
+
failures.push(`${name} inspector escapes the viewport`);
|
|
1075
|
+
}
|
|
1076
|
+
if (!report.responsive.medium.navigationVisible || report.responsive.medium.inspectorVisible)
|
|
1077
|
+
failures.push("medium layout did not maximize the conversation canvas by default");
|
|
1078
|
+
if (!report.responsive.compact.navigationVisible || report.responsive.compact.inspectorVisible)
|
|
1079
|
+
failures.push("compact layout did not collapse the inspector");
|
|
1080
|
+
if (!report.responsive.compactInspector.inspectorVisible)
|
|
1081
|
+
failures.push("compact inspector drawer did not open");
|
|
1082
|
+
if (fixture) {
|
|
1083
|
+
if (report.images.fileDrop?.width !== 1_920 || report.images.fileDrop.height !== 1_080)
|
|
1084
|
+
failures.push("file-drop capture is not 1920x1080");
|
|
1085
|
+
if (!report.fileDrop?.activeIndicatorVisible)
|
|
1086
|
+
failures.push("file-drop target did not show its active state");
|
|
1087
|
+
if (!report.fileDrop?.inputPreserved || !report.fileDrop.notSubmitted || !report.fileDrop.overlayHiddenAfterDrop)
|
|
1088
|
+
failures.push("file drop submitted or replaced the composer draft");
|
|
1089
|
+
if (report.fileDrop?.referenceCount !== 2 || report.fileDrop.referencedPaths.length !== 2)
|
|
1090
|
+
failures.push("file and directory paths were not added as two references");
|
|
1091
|
+
if (report.fileDrop?.referencedKinds.slice().sort().join(",") !== "directory,file")
|
|
1092
|
+
failures.push("file drop did not preserve file/directory reference kinds");
|
|
1093
|
+
if (report.images.approval?.width !== 1_920 || report.images.approval.height !== 1_080)
|
|
1094
|
+
failures.push("approval capture is not 1920x1080");
|
|
1095
|
+
if (!report.mainVisual.transcriptScrollable || !report.probe.document.transcriptScrollable)
|
|
1096
|
+
failures.push("transcript fixture did not exercise vertical scrolling");
|
|
1097
|
+
// A windowed transcript does not promise that the long-code fixture and
|
|
1098
|
+
// the streaming tail coexist in one DOM window. The approval capture
|
|
1099
|
+
// scroll state independently exercises the code overflow contract.
|
|
1100
|
+
if (!report.probe.document.codeContained)
|
|
1101
|
+
failures.push("long code did not exercise contained horizontal scrolling");
|
|
1102
|
+
if (!report.mainVisual.composerVisible || !report.probe.document.composerVisible)
|
|
1103
|
+
failures.push("composer is not fully visible");
|
|
1104
|
+
if (!report.mainVisual.streamingVisible)
|
|
1105
|
+
failures.push("streaming delta is not visible in the main capture");
|
|
1106
|
+
if (!report.mainVisual.thinkingVisible || !report.mainVisual.thinkingSeparate)
|
|
1107
|
+
failures.push("thinking was not rendered separately from response text");
|
|
1108
|
+
if (!report.mainVisual.thinkingHiddenAfterCompletion)
|
|
1109
|
+
failures.push("thinking did not hide after message completion");
|
|
1110
|
+
// One dialog each: the GTK chooser answers a request for both with
|
|
1111
|
+
// directories only, so a single button could never attach a file.
|
|
1112
|
+
if (report.mainVisual.attachButtonsOffered !== 2) {
|
|
1113
|
+
failures.push("composer does not offer separate file and folder choosers");
|
|
1114
|
+
}
|
|
1115
|
+
if (report.mainVisual.attachTooltips.some((tooltip) => tooltip.length < 4)) {
|
|
1116
|
+
failures.push(`an attach button has no tooltip: ${JSON.stringify(report.mainVisual.attachTooltips)}`);
|
|
1117
|
+
}
|
|
1118
|
+
// Text already on screen has to stay selectable while the reply continues.
|
|
1119
|
+
if (report.selection.historyBefore.length < 12)
|
|
1120
|
+
failures.push("smoke could not select a settled message to copy from");
|
|
1121
|
+
if (report.selection.historyAfter !== report.selection.historyBefore) {
|
|
1122
|
+
failures.push("a streaming token destroyed the selection held on an earlier message");
|
|
1123
|
+
}
|
|
1124
|
+
if (!report.selection.scrollHeldDuringSelection)
|
|
1125
|
+
failures.push("the transcript scrolled out from under a held selection");
|
|
1126
|
+
if (report.selection.streamingBefore.length < 6)
|
|
1127
|
+
failures.push("smoke could not select the settled part of the streaming reply");
|
|
1128
|
+
if (report.selection.streamingAfter !== report.selection.streamingBefore) {
|
|
1129
|
+
failures.push("a streaming token destroyed the selection held on the part it had already written");
|
|
1130
|
+
}
|
|
1131
|
+
if (!report.mainVisual.truncationNoticeVisible)
|
|
1132
|
+
failures.push("a message cut by the publishing ceiling offered no way to load it in full");
|
|
1133
|
+
if (!report.mainVisual.truncationRetryOffered)
|
|
1134
|
+
failures.push("a refused full-text load left its button stuck");
|
|
1135
|
+
if (report.mainVisual.legacyComposerModes !== 0)
|
|
1136
|
+
failures.push("legacy composer mode controls are still visible");
|
|
1137
|
+
if (report.mainVisual.authoritativeMessages < 1_000 || report.mainVisual.renderedMessages >= 40)
|
|
1138
|
+
failures.push("long history was not virtualized");
|
|
1139
|
+
if (report.mainVisual.historyBrowseRequestedTop <= 0)
|
|
1140
|
+
failures.push("history scroll fixture did not move above the transcript tail");
|
|
1141
|
+
if (Math.abs(report.mainVisual.historyBrowseAfterIdleTop - report.mainVisual.historyBrowseRequestedTop) > 64) {
|
|
1142
|
+
failures.push("transcript snapped to the tail without a new message");
|
|
1143
|
+
}
|
|
1144
|
+
if (Math.abs(report.mainVisual.historyBrowseAfterDeltaTop - report.mainVisual.historyBrowseAfterIdleTop) > 64) {
|
|
1145
|
+
failures.push("streaming output stole the history browsing position");
|
|
1146
|
+
}
|
|
1147
|
+
if (report.mainVisual.tailDistanceAfterReturn < 0 || report.mainVisual.tailDistanceAfterReturn > 32) {
|
|
1148
|
+
failures.push("transcript did not resume tail following after returning to the bottom");
|
|
1149
|
+
}
|
|
1150
|
+
if (!report.mainVisual.directQueueVisibleBeforeToolCompletion)
|
|
1151
|
+
failures.push("direct-send queue was hidden until tool completion");
|
|
1152
|
+
if (!report.mainVisual.followUpQueueVisible)
|
|
1153
|
+
failures.push("follow-up queue is not visible alongside direct-send messages");
|
|
1154
|
+
// A run that produces no text still has to look alive, and the tool phase has
|
|
1155
|
+
// to be distinguishable from the streaming one.
|
|
1156
|
+
if (report.runActivity.toolPhase !== "tool") {
|
|
1157
|
+
failures.push(`run activity did not show the tool phase: ${report.runActivity.toolPhase}`);
|
|
1158
|
+
}
|
|
1159
|
+
if (!report.runActivity.toolLabel.includes("shell")) {
|
|
1160
|
+
failures.push(`run activity did not name the running tool: ${report.runActivity.toolLabel}`);
|
|
1161
|
+
}
|
|
1162
|
+
if (report.runActivity.streamingPhase === report.runActivity.toolPhase) {
|
|
1163
|
+
failures.push("run activity does not distinguish streaming from a running tool");
|
|
1164
|
+
}
|
|
1165
|
+
if (!report.runActivity.hiddenWhenIdle)
|
|
1166
|
+
failures.push("run activity was visible while nothing was running");
|
|
1167
|
+
// The panel has to answer "what can this agent do", not only "what was added".
|
|
1168
|
+
for (const expected of ["read", "document", "edit", "search", "shell"]) {
|
|
1169
|
+
if (!report.integrations.builtinTools.includes(expected)) {
|
|
1170
|
+
failures.push(`integrations tab is missing the ${expected} tool`);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
if (report.integrations.skills.length === 0)
|
|
1174
|
+
failures.push("integrations tab lists no installed skill");
|
|
1175
|
+
// A skill the user put in the workspace must be visible, not only trusted installs:
|
|
1176
|
+
// the smoke harness seeds one loadable skill and one without an entry point.
|
|
1177
|
+
for (const expected of ["doc-extract", "smoke-mail"]) {
|
|
1178
|
+
if (!report.integrations.skillChips.includes(expected))
|
|
1179
|
+
failures.push(`integrations tab does not offer the ${expected} skill`);
|
|
1180
|
+
}
|
|
1181
|
+
if (report.integrations.builtinTools.includes("smoke-mail"))
|
|
1182
|
+
failures.push("a skill was rendered as a built-in tool");
|
|
1183
|
+
if (!report.integrations.unloadedSkills.some((row) => row.includes("smoke-draft") && row.includes("SKILL_DRAFT.md"))) {
|
|
1184
|
+
failures.push("integrations tab does not explain why the smoke-draft skill was not loaded");
|
|
1185
|
+
}
|
|
1186
|
+
// Panels stay put inside a band; the transcript still has to re-lay out.
|
|
1187
|
+
if (!report.inBandResize.panelsUnchanged)
|
|
1188
|
+
failures.push("in-band resize crossed a breakpoint, so it tests nothing");
|
|
1189
|
+
if (report.inBandResize.afterWidth >= report.inBandResize.beforeWidth) {
|
|
1190
|
+
failures.push(`in-band resize did not narrow the conversation: ${report.inBandResize.beforeWidth} -> ${report.inBandResize.afterWidth}`);
|
|
1191
|
+
}
|
|
1192
|
+
if (report.inBandResize.afterScrollHeight === report.inBandResize.beforeScrollHeight) {
|
|
1193
|
+
failures.push("transcript kept its old measurements after the window was resized");
|
|
1194
|
+
}
|
|
1195
|
+
if (report.sessionRail.rootRows < 1)
|
|
1196
|
+
failures.push("session rail rendered no root session row");
|
|
1197
|
+
// Newest first: the fixture's later session was created after main, so it
|
|
1198
|
+
// leads even though main is inserted first and used to be pinned there.
|
|
1199
|
+
if (report.sessionRail.rootOrder.join(",") !== "session-later,main") {
|
|
1200
|
+
failures.push(`session rail is not newest-first: ${report.sessionRail.rootOrder.join(", ")}`);
|
|
1201
|
+
}
|
|
1202
|
+
if (report.sessionRail.times.some((time) => time === "")) {
|
|
1203
|
+
failures.push("a root session row rendered without its time");
|
|
1204
|
+
}
|
|
1205
|
+
if (report.sessionRail.deleteControls !== report.sessionRail.rootRows) {
|
|
1206
|
+
failures.push("not every root session row offers a delete control");
|
|
1207
|
+
}
|
|
1208
|
+
// Child agents are cancelled through their own controls; deleting one would
|
|
1209
|
+
// orphan the transcript its root session still owns.
|
|
1210
|
+
if (report.sessionRail.agentDeleteControls !== 0)
|
|
1211
|
+
failures.push("a child agent row offered a session delete control");
|
|
1212
|
+
if (!report.sessionRail.hiddenUntilHover)
|
|
1213
|
+
failures.push("session delete control is visible before the row is hovered");
|
|
1214
|
+
if (!report.sessionRail.armedAfterFirstClick)
|
|
1215
|
+
failures.push("session delete control did not arm on its first click");
|
|
1216
|
+
if (!report.sessionRail.armedLabel.includes("确认"))
|
|
1217
|
+
failures.push("armed session delete control does not ask for confirmation");
|
|
1218
|
+
if (!report.sessionRail.armedAtCapture)
|
|
1219
|
+
failures.push("armed session delete control reverted before it could be captured");
|
|
1220
|
+
if (report.sessionRail.rowsAfterFirstClick !== report.sessionRail.totalRows) {
|
|
1221
|
+
failures.push("a single click on the delete control changed the session rail");
|
|
1222
|
+
}
|
|
1223
|
+
// The status panel must agree with the settings panel that switched it on.
|
|
1224
|
+
if (report.indexingPanel.phase !== "unconfigured")
|
|
1225
|
+
failures.push("indexing panel did not render the unconfigured phase");
|
|
1226
|
+
if (report.indexingPanel.phaseLabel.includes("未启用"))
|
|
1227
|
+
failures.push("indexing panel reported an enabled indexer as switched off");
|
|
1228
|
+
if (!report.indexingPanel.note.includes("已启用"))
|
|
1229
|
+
failures.push("indexing panel did not report the configured enable state");
|
|
1230
|
+
// The document panel: chat keeps a receipt, the article is rendered here.
|
|
1231
|
+
const article = report.articlePanel;
|
|
1232
|
+
if (article.reopenLabel !== "季度复盘")
|
|
1233
|
+
failures.push("document panel was not offered for the session's own article");
|
|
1234
|
+
if (article.version !== "v2")
|
|
1235
|
+
failures.push(`document panel opened ${article.version || "nothing"} instead of the latest version`);
|
|
1236
|
+
if (article.versionChips.join(",") !== "v1,v2")
|
|
1237
|
+
failures.push("document panel did not list both saved versions");
|
|
1238
|
+
if (article.renderedBlocks < 4)
|
|
1239
|
+
failures.push("document panel did not render the article body");
|
|
1240
|
+
if (article.scrollTop !== 0)
|
|
1241
|
+
failures.push("document panel opened part-way down the article instead of at its title");
|
|
1242
|
+
if (!article.comparable)
|
|
1243
|
+
failures.push("document panel could not compare against the version it replaced");
|
|
1244
|
+
if (!article.diffSummary.includes("+3 段"))
|
|
1245
|
+
failures.push(`document panel summarised the change as "${article.diffSummary}"`);
|
|
1246
|
+
if (article.exportLabel !== "导出")
|
|
1247
|
+
failures.push("export formats are offered without a label saying what they are");
|
|
1248
|
+
// The revision view, not a one-line count of what changed.
|
|
1249
|
+
if (article.diffRows < 10)
|
|
1250
|
+
failures.push(`revision view drew ${article.diffRows} lines instead of the whole document`);
|
|
1251
|
+
if (article.removedRows < 1)
|
|
1252
|
+
failures.push("revision view did not show the line the new version dropped");
|
|
1253
|
+
if (article.addedRows < 2)
|
|
1254
|
+
failures.push("revision view did not show the lines the new version added");
|
|
1255
|
+
if (article.inlineMarks < 1)
|
|
1256
|
+
failures.push("revision view marked whole lines instead of the words that changed");
|
|
1257
|
+
if (article.lineViewRows !== article.diffRows)
|
|
1258
|
+
failures.push("line granularity changed which lines the revision shows");
|
|
1259
|
+
if (article.lineViewInlineMarks !== 0)
|
|
1260
|
+
failures.push("line granularity still marked changes inside a line");
|
|
1261
|
+
if (!article.reusedAcrossVersions)
|
|
1262
|
+
failures.push("switching versions rebuilt blocks the new version had not changed");
|
|
1263
|
+
if (!article.clearedOnSessionChange)
|
|
1264
|
+
failures.push("another session's document was left in the panel");
|
|
1265
|
+
if (!article.animatedOpen)
|
|
1266
|
+
failures.push("the document column appeared at full width instead of arriving");
|
|
1267
|
+
if (Math.abs(article.resizedWidth - Math.round(1_920 * 0.34)) > 24) {
|
|
1268
|
+
failures.push(`dragging the boundary left the document ${article.resizedWidth}px wide`);
|
|
1269
|
+
}
|
|
1270
|
+
// Arrow keys reach the same boundary, so the width is not pointer-only.
|
|
1271
|
+
if (article.widthAfterKeyboardStep <= article.resizedWidth) {
|
|
1272
|
+
failures.push("the arrow keys did not widen the document column");
|
|
1273
|
+
}
|
|
1274
|
+
for (const layout of article.split) {
|
|
1275
|
+
const at = `${layout.name} (${layout.viewport.width}x${layout.viewport.height})`;
|
|
1276
|
+
if (!layout.column)
|
|
1277
|
+
failures.push(`at ${at} the document floats over the conversation instead of taking a column`);
|
|
1278
|
+
if (layout.overlap > 0)
|
|
1279
|
+
failures.push(`at ${at} the document covers ${layout.overlap}px of the conversation`);
|
|
1280
|
+
if (layout.conversationWidth < MIN_CONVERSATION_WIDTH - 1) {
|
|
1281
|
+
failures.push(`at ${at} the conversation was squeezed to ${layout.conversationWidth}px`);
|
|
1282
|
+
}
|
|
1283
|
+
if (layout.articleWidth < MIN_ARTICLE_WIDTH - 1)
|
|
1284
|
+
failures.push(`at ${at} the document was squeezed to ${layout.articleWidth}px`);
|
|
1285
|
+
if (layout.conversationOverflow > 1) {
|
|
1286
|
+
failures.push(`at ${at} the conversation's content sticks ${layout.conversationOverflow}px out under the document`);
|
|
1287
|
+
}
|
|
1288
|
+
if (!layout.handle)
|
|
1289
|
+
failures.push(`at ${at} there is no boundary to drag`);
|
|
1290
|
+
if (layout.documentOverflow > 0)
|
|
1291
|
+
failures.push(`at ${at} the split pushed the workspace into a horizontal scrollbar`);
|
|
1292
|
+
}
|
|
1293
|
+
if (article.split.length < 6)
|
|
1294
|
+
failures.push("the split was not measured across reopen and resize");
|
|
1295
|
+
if (!article.reopenAnimated)
|
|
1296
|
+
failures.push("a reopened document appeared at full width instead of arriving");
|
|
1297
|
+
if (!article.splitKeptAcrossResize)
|
|
1298
|
+
failures.push("shrinking the window and growing it back lost the reader's split");
|
|
1299
|
+
if (article.compactDrag.after <= article.compactDrag.before) {
|
|
1300
|
+
failures.push("the boundary did not drag in the smallest window");
|
|
1301
|
+
}
|
|
1302
|
+
if (article.exportFormats.join(",") !== "txt,md,docx,pdf")
|
|
1303
|
+
failures.push("document panel does not offer all four exports");
|
|
1304
|
+
if (article.documentOverflow > 0)
|
|
1305
|
+
failures.push("document panel pushed the workspace into a horizontal scrollbar");
|
|
1306
|
+
if (!report.probe.document.modalContained)
|
|
1307
|
+
failures.push("approval modal escapes the viewport");
|
|
1308
|
+
if (!report.probe.document.backgroundInert)
|
|
1309
|
+
failures.push("approval modal did not make the background inert");
|
|
1310
|
+
if (report.probe.document.focusLabel !== "拒绝")
|
|
1311
|
+
failures.push("approval modal did not receive deterministic focus");
|
|
1312
|
+
}
|
|
1313
|
+
if (failures.length > 0)
|
|
1314
|
+
throw new Error(`Electron smoke validation failed: ${failures.join("; ")}`);
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Measures the open split. A long unbreakable notice is put on the status line
|
|
1318
|
+
* first: the session-switch notice carries a whole session id, and that one line
|
|
1319
|
+
* used to set the conversation's minimum width, so it stopped shrinking and was
|
|
1320
|
+
* clipped under the document instead.
|
|
1321
|
+
*/
|
|
1322
|
+
async function readArticleSplit(target, name) {
|
|
1323
|
+
const layout = await target.webContents.executeJavaScript(`(() => {
|
|
1324
|
+
const notice = document.querySelector('#notice-line');
|
|
1325
|
+
const previous = notice ? notice.textContent : '';
|
|
1326
|
+
if (notice) notice.textContent = '已切换到 session-' + 'f2fb0431d92a4424b705b55b82307dd0'.repeat(4);
|
|
1327
|
+
const conversation = document.querySelector('.conversation');
|
|
1328
|
+
const article = document.querySelector('#article-panel');
|
|
1329
|
+
const handle = document.querySelector('#article-resize');
|
|
1330
|
+
const c = conversation.getBoundingClientRect();
|
|
1331
|
+
const a = article.getBoundingClientRect();
|
|
1332
|
+
let overflow = 0;
|
|
1333
|
+
const parts = '.conversation-header, .conversation-header > *, .session-health > :not([hidden]), .transcript, .composer-wrap, .notice-line, .composer, .composer-toolbar, .composer-actions, #send-button';
|
|
1334
|
+
for (const node of conversation.querySelectorAll(parts)) {
|
|
1335
|
+
const r = node.getBoundingClientRect();
|
|
1336
|
+
if (r.width > 0 && r.height > 0) overflow = Math.max(overflow, r.right - c.right);
|
|
1337
|
+
}
|
|
1338
|
+
const h = handle ? handle.getBoundingClientRect() : { width: 0 };
|
|
1339
|
+
const result = {
|
|
1340
|
+
viewport: { width: innerWidth, height: innerHeight },
|
|
1341
|
+
column: !article.hidden && getComputedStyle(article).position !== 'absolute',
|
|
1342
|
+
overlap: Math.max(0, Math.round(c.right - a.left)),
|
|
1343
|
+
conversationWidth: Math.round(c.width),
|
|
1344
|
+
articleWidth: Math.round(a.width),
|
|
1345
|
+
conversationOverflow: Math.max(0, Math.round(overflow)),
|
|
1346
|
+
handle: Boolean(handle) && h.width > 0 && getComputedStyle(handle).display !== 'none',
|
|
1347
|
+
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth
|
|
1348
|
+
};
|
|
1349
|
+
if (notice) notice.textContent = previous;
|
|
1350
|
+
return result;
|
|
1351
|
+
})()`);
|
|
1352
|
+
return { name, ...layout };
|
|
1353
|
+
}
|
|
1354
|
+
async function exerciseArticlePanel(target) {
|
|
1355
|
+
const empty = {
|
|
1356
|
+
reopenLabel: "",
|
|
1357
|
+
title: "",
|
|
1358
|
+
version: "",
|
|
1359
|
+
versionChips: [],
|
|
1360
|
+
renderedBlocks: 0,
|
|
1361
|
+
scrollTop: -1,
|
|
1362
|
+
diffSummary: "",
|
|
1363
|
+
comparable: false,
|
|
1364
|
+
diffRows: 0,
|
|
1365
|
+
removedRows: 0,
|
|
1366
|
+
addedRows: 0,
|
|
1367
|
+
inlineMarks: 0,
|
|
1368
|
+
lineViewRows: 0,
|
|
1369
|
+
lineViewInlineMarks: 0,
|
|
1370
|
+
reusedAcrossVersions: false,
|
|
1371
|
+
exportLabel: "",
|
|
1372
|
+
clearedOnSessionChange: false,
|
|
1373
|
+
resizedWidth: 0,
|
|
1374
|
+
widthAfterKeyboardStep: 0,
|
|
1375
|
+
animatedOpen: false,
|
|
1376
|
+
exportFormats: [],
|
|
1377
|
+
documentOverflow: 0,
|
|
1378
|
+
split: [],
|
|
1379
|
+
reopenAnimated: false,
|
|
1380
|
+
splitKeptAcrossResize: false,
|
|
1381
|
+
compactDrag: { before: 0, after: 0 },
|
|
1382
|
+
};
|
|
1383
|
+
const offered = await waitForRenderer(target, "document.querySelector('#open-article-button')?.hidden === false", 6_000).then(() => true).catch(() => false);
|
|
1384
|
+
if (!offered)
|
|
1385
|
+
return empty;
|
|
1386
|
+
const reopenLabel = await target.webContents.executeJavaScript("document.querySelector('#open-article-button')?.textContent ?? ''");
|
|
1387
|
+
await target.webContents.executeJavaScript("document.querySelector('#open-article-button')?.click(); true");
|
|
1388
|
+
await waitForRenderer(target, "document.documentElement.dataset.article === 'open'", 6_000);
|
|
1389
|
+
// The column has to arrive, not appear: sampled immediately after the click
|
|
1390
|
+
// it is still narrower than it settles at.
|
|
1391
|
+
const openingWidth = await target.webContents.executeJavaScript("Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--article-width')) || 0");
|
|
1392
|
+
// The version it replaced is fetched after the first paint, so the compare
|
|
1393
|
+
// control only becomes usable once that lands.
|
|
1394
|
+
await waitForRenderer(target, "document.querySelector('#article-view-token')?.disabled === false", 6_000)
|
|
1395
|
+
.catch(() => undefined);
|
|
1396
|
+
await settlePaint();
|
|
1397
|
+
const opened = await target.webContents.executeJavaScript(`(() => {
|
|
1398
|
+
const stage = document.querySelector('#article-stage');
|
|
1399
|
+
return {
|
|
1400
|
+
title: document.querySelector('#article-title')?.textContent ?? '',
|
|
1401
|
+
version: document.querySelector('#article-version')?.textContent ?? '',
|
|
1402
|
+
versionChips: Array.from(document.querySelectorAll('.article-version')).map((node) => node.textContent ?? ''),
|
|
1403
|
+
renderedBlocks: document.querySelectorAll('#article-render .article-block').length,
|
|
1404
|
+
scrollTop: stage ? stage.scrollTop : -1,
|
|
1405
|
+
diffSummary: document.querySelector('#article-diff-summary')?.textContent ?? '',
|
|
1406
|
+
comparable: document.querySelector('#article-view-token')?.disabled === false,
|
|
1407
|
+
exportLabel: document.querySelector('#article-export-label')?.textContent ?? '',
|
|
1408
|
+
exportFormats: Array.from(document.querySelectorAll('#article-panel [data-export]')).map((node) => node.dataset.export ?? ''),
|
|
1409
|
+
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth
|
|
1410
|
+
};
|
|
1411
|
+
})()`);
|
|
1412
|
+
// Reading the revision: the rows the diff drew, and the marks inside the line
|
|
1413
|
+
// that was rewritten rather than replaced.
|
|
1414
|
+
await target.webContents.executeJavaScript("document.querySelector('#article-view-token')?.click(); true");
|
|
1415
|
+
await settlePaint();
|
|
1416
|
+
const revision = await target.webContents.executeJavaScript(`(() => ({
|
|
1417
|
+
diffRows: document.querySelectorAll('#article-render .article-diff-line').length,
|
|
1418
|
+
removedRows: document.querySelectorAll('#article-render .article-diff-line[data-change="remove"]').length,
|
|
1419
|
+
addedRows: document.querySelectorAll('#article-render .article-diff-line[data-change="add"]').length,
|
|
1420
|
+
inlineMarks: document.querySelectorAll('#article-render .article-diff-text ins, #article-render .article-diff-text del').length
|
|
1421
|
+
}))()`);
|
|
1422
|
+
await target.webContents.executeJavaScript("document.querySelector('#article-view-line')?.click(); true");
|
|
1423
|
+
await settlePaint();
|
|
1424
|
+
const lineView = await target.webContents.executeJavaScript(`(() => ({
|
|
1425
|
+
lineViewRows: document.querySelectorAll('#article-render .article-diff-line').length,
|
|
1426
|
+
lineViewInlineMarks: document.querySelectorAll('#article-render .article-diff-text ins, #article-render .article-diff-text del').length
|
|
1427
|
+
}))()`);
|
|
1428
|
+
await target.webContents.executeJavaScript("document.querySelector('#article-view-read')?.click(); true");
|
|
1429
|
+
await settlePaint();
|
|
1430
|
+
// A version lands on top of the one already drawn: the blocks it did not
|
|
1431
|
+
// touch have to be the same elements, not fresh copies of the same words.
|
|
1432
|
+
const reusedAcrossVersions = await target.webContents.executeJavaScript(`(async () => {
|
|
1433
|
+
const stage = document.querySelector('#article-render');
|
|
1434
|
+
const first = stage?.firstElementChild;
|
|
1435
|
+
if (!first) return false;
|
|
1436
|
+
document.querySelector('.article-version[aria-pressed="false"]')?.click();
|
|
1437
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
1438
|
+
const back = document.querySelectorAll('.article-version');
|
|
1439
|
+
back[back.length - 1]?.click();
|
|
1440
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
1441
|
+
return document.querySelector('#article-render')?.firstElementChild === first;
|
|
1442
|
+
})()`);
|
|
1443
|
+
const settledWidth = await target.webContents.executeJavaScript("Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--article-width')) || 0");
|
|
1444
|
+
// Dragging the boundary, and the same boundary reached from the keyboard.
|
|
1445
|
+
const resize = await target.webContents.executeJavaScript(`(async () => {
|
|
1446
|
+
const handle = document.querySelector('#article-resize');
|
|
1447
|
+
const read = () => Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--article-width')) || 0;
|
|
1448
|
+
if (!handle) return { resizedWidth: 0, widthAfterKeyboardStep: 0 };
|
|
1449
|
+
const target = Math.round(innerWidth * 0.34);
|
|
1450
|
+
const options = { bubbles: true, pointerId: 1, button: 0, clientX: innerWidth - target, clientY: 400 };
|
|
1451
|
+
handle.setPointerCapture = () => undefined;
|
|
1452
|
+
handle.releasePointerCapture = () => undefined;
|
|
1453
|
+
handle.hasPointerCapture = () => false;
|
|
1454
|
+
handle.dispatchEvent(new PointerEvent('pointerdown', { ...options, clientX: innerWidth - read() }));
|
|
1455
|
+
handle.dispatchEvent(new PointerEvent('pointermove', options));
|
|
1456
|
+
handle.dispatchEvent(new PointerEvent('pointerup', options));
|
|
1457
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
1458
|
+
const resizedWidth = read();
|
|
1459
|
+
handle.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowLeft' }));
|
|
1460
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
1461
|
+
return { resizedWidth, widthAfterKeyboardStep: read() };
|
|
1462
|
+
})()`);
|
|
1463
|
+
// The split has to survive what used to turn it into a document floating over
|
|
1464
|
+
// a clipped conversation: closing and reopening, and every window size the
|
|
1465
|
+
// shell allows. It stays a column with a boundary to drag throughout.
|
|
1466
|
+
const readWidth = "Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--article-width')) || 0";
|
|
1467
|
+
const split = [await readArticleSplit(target, "1920x1080")];
|
|
1468
|
+
await target.webContents.executeJavaScript("document.querySelector('#article-close')?.click(); true");
|
|
1469
|
+
await waitForRenderer(target, "document.documentElement.dataset.article === undefined", 4_000);
|
|
1470
|
+
await target.webContents.executeJavaScript("document.querySelector('#open-article-button')?.click(); true");
|
|
1471
|
+
const reopeningWidth = await target.webContents.executeJavaScript(readWidth);
|
|
1472
|
+
await settlePaint();
|
|
1473
|
+
const reopened = await readArticleSplit(target, "reopened");
|
|
1474
|
+
split.push(reopened);
|
|
1475
|
+
target.setContentSize(1_440, 900);
|
|
1476
|
+
await settlePaint();
|
|
1477
|
+
split.push(await readArticleSplit(target, "1440x900"));
|
|
1478
|
+
target.setContentSize(1_920, 1_080);
|
|
1479
|
+
await settlePaint();
|
|
1480
|
+
const restored = await readArticleSplit(target, "1920x1080 again");
|
|
1481
|
+
split.push(restored);
|
|
1482
|
+
target.setContentSize(1_180, 720);
|
|
1483
|
+
await settlePaint();
|
|
1484
|
+
split.push(await readArticleSplit(target, "1180x720"));
|
|
1485
|
+
const compactDrag = await target.webContents.executeJavaScript(`(async () => {
|
|
1486
|
+
const handle = document.querySelector('#article-resize');
|
|
1487
|
+
const read = () => Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--article-width')) || 0;
|
|
1488
|
+
const before = read();
|
|
1489
|
+
if (!handle) return { before, after: before };
|
|
1490
|
+
const box = handle.getBoundingClientRect();
|
|
1491
|
+
const start = { bubbles: true, pointerId: 2, button: 0, clientX: box.left + box.width / 2, clientY: 300 };
|
|
1492
|
+
handle.setPointerCapture = () => undefined;
|
|
1493
|
+
handle.releasePointerCapture = () => undefined;
|
|
1494
|
+
handle.hasPointerCapture = () => false;
|
|
1495
|
+
handle.dispatchEvent(new PointerEvent('pointerdown', start));
|
|
1496
|
+
handle.dispatchEvent(new PointerEvent('pointermove', { ...start, clientX: start.clientX - 40 }));
|
|
1497
|
+
handle.dispatchEvent(new PointerEvent('pointerup', { ...start, clientX: start.clientX - 40 }));
|
|
1498
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
1499
|
+
return { before, after: read() };
|
|
1500
|
+
})()`);
|
|
1501
|
+
split.push(await readArticleSplit(target, "1180x720 dragged"));
|
|
1502
|
+
target.setContentSize(1_920, 1_080);
|
|
1503
|
+
await settlePaint();
|
|
1504
|
+
// Switching session has to take the document with it. The panel keeps the
|
|
1505
|
+
// current text to diff the next version against and a pool of rendered
|
|
1506
|
+
// blocks; carried across, they become the previous session's article showing
|
|
1507
|
+
// underneath a new one being written.
|
|
1508
|
+
const clearedOnSessionChange = await target.webContents.executeJavaScript(`(async () => {
|
|
1509
|
+
const rows = Array.from(document.querySelectorAll('#session-list button'));
|
|
1510
|
+
const current = document.querySelector('#session-title')?.textContent ?? '';
|
|
1511
|
+
const other = rows.find((row) => !(row.textContent ?? '').includes(current));
|
|
1512
|
+
if (!other) return false;
|
|
1513
|
+
other.click();
|
|
1514
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
1515
|
+
const cleared = document.querySelectorAll('#article-render .article-block').length === 0
|
|
1516
|
+
&& document.documentElement.dataset.article === undefined
|
|
1517
|
+
&& document.querySelector('#open-article-button')?.hidden === true;
|
|
1518
|
+
const back = rows.find((row) => (row.textContent ?? '').includes(current));
|
|
1519
|
+
back?.click();
|
|
1520
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
1521
|
+
return cleared;
|
|
1522
|
+
})()`);
|
|
1523
|
+
await target.webContents.executeJavaScript("document.querySelector('#article-close')?.click(); true");
|
|
1524
|
+
await waitForRenderer(target, "document.documentElement.dataset.article === undefined", 4_000).catch(() => undefined);
|
|
1525
|
+
await settlePaint();
|
|
1526
|
+
return {
|
|
1527
|
+
...opened,
|
|
1528
|
+
...revision,
|
|
1529
|
+
...lineView,
|
|
1530
|
+
...resize,
|
|
1531
|
+
reopenLabel,
|
|
1532
|
+
reusedAcrossVersions,
|
|
1533
|
+
clearedOnSessionChange,
|
|
1534
|
+
animatedOpen: openingWidth < settledWidth,
|
|
1535
|
+
split,
|
|
1536
|
+
reopenAnimated: reopeningWidth < reopened.articleWidth,
|
|
1537
|
+
splitKeptAcrossResize: Math.abs(restored.articleWidth - reopened.articleWidth) <= 2,
|
|
1538
|
+
compactDrag,
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
async function exerciseComposerFileDrop(target, outputDirectory) {
|
|
1542
|
+
const fixtureRoot = join(outputDirectory, "file-drop-fixture");
|
|
1543
|
+
const filePath = join(fixtureRoot, "dragged file.txt");
|
|
1544
|
+
const directoryPath = join(fixtureRoot, "dragged folder");
|
|
1545
|
+
await mkdir(directoryPath, { recursive: true, mode: 0o700 });
|
|
1546
|
+
await writeFile(filePath, "The renderer must not read this file during drop.\n", { encoding: "utf8", mode: 0o600 });
|
|
1547
|
+
await target.webContents.executeJavaScript(`(() => {
|
|
1548
|
+
const input = document.querySelector('#composer-input');
|
|
1549
|
+
const form = document.querySelector('#composer-form');
|
|
1550
|
+
if (input) input.value = '请检查拖入的上下文';
|
|
1551
|
+
if (form) {
|
|
1552
|
+
form.dataset.smokeSubmitCount = '0';
|
|
1553
|
+
form.addEventListener('submit', () => {
|
|
1554
|
+
form.dataset.smokeSubmitCount = String(Number(form.dataset.smokeSubmitCount || '0') + 1);
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
})()`);
|
|
1558
|
+
const point = await target.webContents.executeJavaScript(`(() => {
|
|
1559
|
+
const rect = document.querySelector('#composer-form')?.getBoundingClientRect();
|
|
1560
|
+
if (!rect) throw new Error('Missing composer drop target');
|
|
1561
|
+
return { x: rect.left + rect.width / 2, y: rect.top + Math.min(rect.height / 2, 56) };
|
|
1562
|
+
})()`);
|
|
1563
|
+
const dragData = {
|
|
1564
|
+
items: [{
|
|
1565
|
+
mimeType: "text/uri-list",
|
|
1566
|
+
data: `${pathToFileURL(filePath).href}\r\n${pathToFileURL(directoryPath).href}`,
|
|
1567
|
+
}],
|
|
1568
|
+
files: [filePath, directoryPath],
|
|
1569
|
+
dragOperationsMask: 1,
|
|
1570
|
+
};
|
|
1571
|
+
const debugging = target.webContents.debugger;
|
|
1572
|
+
const attachedHere = !debugging.isAttached();
|
|
1573
|
+
let dropped = false;
|
|
1574
|
+
try {
|
|
1575
|
+
if (attachedHere)
|
|
1576
|
+
debugging.attach("1.3");
|
|
1577
|
+
await debugging.sendCommand("Input.dispatchDragEvent", { type: "dragEnter", ...point, data: dragData });
|
|
1578
|
+
await debugging.sendCommand("Input.dispatchDragEvent", { type: "dragOver", ...point, data: dragData });
|
|
1579
|
+
await waitForRenderer(target, "document.querySelector('#composer-form')?.dataset.dropActive === 'true'", 2_000);
|
|
1580
|
+
await settlePaint();
|
|
1581
|
+
const activeIndicatorVisible = await target.webContents.executeJavaScript("document.querySelector('#composer-drop-overlay')?.hidden === false");
|
|
1582
|
+
const image = await target.webContents.capturePage();
|
|
1583
|
+
await writeFile(join(outputDirectory, "desktop-file-drop-1920x1080.png"), image.toPNG(), { mode: 0o600 });
|
|
1584
|
+
await debugging.sendCommand("Input.dispatchDragEvent", { type: "drop", ...point, data: dragData });
|
|
1585
|
+
dropped = true;
|
|
1586
|
+
await waitForRenderer(target, "document.querySelectorAll('#composer-references .composer-reference').length === 2", 2_000);
|
|
1587
|
+
await settlePaint();
|
|
1588
|
+
const probe = await target.webContents.executeJavaScript(`(() => ({
|
|
1589
|
+
activeIndicatorVisible: ${Boolean(activeIndicatorVisible)},
|
|
1590
|
+
inputPreserved: document.querySelector('#composer-input')?.value === '请检查拖入的上下文',
|
|
1591
|
+
notSubmitted: document.querySelector('#composer-form')?.dataset.smokeSubmitCount === '0',
|
|
1592
|
+
overlayHiddenAfterDrop: document.querySelector('#composer-drop-overlay')?.hidden === true,
|
|
1593
|
+
referenceCount: document.querySelectorAll('#composer-references .composer-reference').length,
|
|
1594
|
+
referencedPaths: Array.from(document.querySelectorAll('#composer-references .composer-reference-path')).map((node) => node.title),
|
|
1595
|
+
referencedKinds: Array.from(document.querySelectorAll('#composer-references .composer-reference')).map((node) => node.dataset.kind || '')
|
|
1596
|
+
}))()`);
|
|
1597
|
+
return { probe, imageSize: image.getSize() };
|
|
1598
|
+
}
|
|
1599
|
+
finally {
|
|
1600
|
+
if (!dropped && debugging.isAttached()) {
|
|
1601
|
+
await debugging.sendCommand("Input.dispatchDragEvent", { type: "dragCancel", ...point, data: dragData }).catch(() => undefined);
|
|
1602
|
+
}
|
|
1603
|
+
if (attachedHere && debugging.isAttached())
|
|
1604
|
+
debugging.detach();
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
function smokeEvent(seq, type, payload) {
|
|
1608
|
+
return {
|
|
1609
|
+
version: 1,
|
|
1610
|
+
kind: "event",
|
|
1611
|
+
event: {
|
|
1612
|
+
v: 1,
|
|
1613
|
+
kind: "event",
|
|
1614
|
+
id: `desktop-smoke-${seq}`,
|
|
1615
|
+
streamId: "desktop-smoke",
|
|
1616
|
+
seq,
|
|
1617
|
+
ts: new Date(Date.parse("2026-08-10T08:30:00.000Z") + seq).toISOString(),
|
|
1618
|
+
durable: false,
|
|
1619
|
+
type,
|
|
1620
|
+
payload,
|
|
1621
|
+
},
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
async function settlePaint() {
|
|
1625
|
+
await new Promise((resolve) => setTimeout(resolve, 320));
|
|
1626
|
+
}
|
|
1627
|
+
async function setRendererTheme(target, theme) {
|
|
1628
|
+
await target.webContents.executeJavaScript(`(() => {
|
|
1629
|
+
if (document.documentElement.dataset.theme !== ${JSON.stringify(theme)}) {
|
|
1630
|
+
document.querySelector('#theme-toggle')?.click();
|
|
1631
|
+
}
|
|
1632
|
+
})()`);
|
|
1633
|
+
await waitForRenderer(target, `document.documentElement.dataset.theme === ${JSON.stringify(theme)}`, 2_000);
|
|
1634
|
+
}
|
|
1635
|
+
async function readThemeSnapshot(target) {
|
|
1636
|
+
return target.webContents.executeJavaScript(`(() => ({
|
|
1637
|
+
colorScheme: getComputedStyle(document.documentElement).colorScheme,
|
|
1638
|
+
backgroundColor: getComputedStyle(document.body).backgroundColor,
|
|
1639
|
+
surfaceColor: getComputedStyle(document.querySelector('.panel')).backgroundColor
|
|
1640
|
+
}))()`);
|
|
1641
|
+
}
|
|
1642
|
+
async function readResponsiveLayout(target) {
|
|
1643
|
+
return target.webContents.executeJavaScript(`(() => {
|
|
1644
|
+
const visible = (selector) => {
|
|
1645
|
+
const node = document.querySelector(selector);
|
|
1646
|
+
return Boolean(node && getComputedStyle(node).display !== 'none');
|
|
1647
|
+
};
|
|
1648
|
+
const composerRect = document.querySelector('.composer')?.getBoundingClientRect();
|
|
1649
|
+
const conversationRect = document.querySelector('.conversation')?.getBoundingClientRect();
|
|
1650
|
+
const inspectorRect = document.querySelector('#inspector-panel')?.getBoundingClientRect();
|
|
1651
|
+
return {
|
|
1652
|
+
viewport: { width: innerWidth, height: innerHeight },
|
|
1653
|
+
scrollWidth: document.documentElement.scrollWidth,
|
|
1654
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
1655
|
+
shellHeight: document.querySelector('#app-shell')?.getBoundingClientRect().height || 0,
|
|
1656
|
+
workspaceBottom: document.querySelector('#main-workspace')?.getBoundingClientRect().bottom || 0,
|
|
1657
|
+
navigationVisible: visible('#navigation-panel'),
|
|
1658
|
+
inspectorVisible: visible('#inspector-panel'),
|
|
1659
|
+
inspectorContained: !visible('#inspector-panel') || Boolean(inspectorRect && inspectorRect.left >= 0 && inspectorRect.top >= 0 && inspectorRect.right <= innerWidth && inspectorRect.bottom <= innerHeight),
|
|
1660
|
+
conversationWidth: conversationRect?.width || 0,
|
|
1661
|
+
composerVisible: Boolean(composerRect && composerRect.top >= 0 && composerRect.bottom <= innerHeight)
|
|
1662
|
+
};
|
|
1663
|
+
})()`);
|
|
1664
|
+
}
|
|
1665
|
+
export function requestShutdown(code) {
|
|
1666
|
+
if (shuttingDown)
|
|
1667
|
+
return;
|
|
1668
|
+
shuttingDown = true;
|
|
1669
|
+
startup.close();
|
|
1670
|
+
exitCode = code;
|
|
1671
|
+
unsubscribeBridge?.();
|
|
1672
|
+
unsubscribeBridge = undefined;
|
|
1673
|
+
void (async () => {
|
|
1674
|
+
await bridge?.stop();
|
|
1675
|
+
// Production GUI is only a shell. The system service intentionally keeps
|
|
1676
|
+
// sessions, automation, and integrations alive after every window closes.
|
|
1677
|
+
await daemonSupervisor?.stop();
|
|
1678
|
+
})()
|
|
1679
|
+
.catch((error) => {
|
|
1680
|
+
process.stderr.write(`pSpore desktop shutdown: ${toError(error).message}\n`);
|
|
1681
|
+
exitCode = 1;
|
|
1682
|
+
})
|
|
1683
|
+
.finally(() => app.exit(exitCode));
|
|
1684
|
+
}
|
|
1685
|
+
function toError(error) {
|
|
1686
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
1687
|
+
}
|
|
1688
|
+
//# sourceMappingURL=application.js.map
|