From 4b6474133ac1fe29bdd7d7125eee3f31b627ae83 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 17 Sep 2026 15:02:59 +0800 Subject: [PATCH 01/17] refactor(desktop): remove standalone plugin manager --- ...6-09-09-desktop-in-place-profile.i18n.yaml | 4 +- .../2026-09-09-desktop-in-place-profile.md | 1 + .../2026-09-09-desktop-in-place-profile.zh.md | 1 + .agents/notes/archived/manifest.json | 3 + ...on-desktop-packaging-and-updates.i18n.yaml | 4 +- ...-electron-desktop-packaging-and-updates.md | 6 +- ...ectron-desktop-packaging-and-updates.zh.md | 6 +- ...led-runtime-and-external-plugins.i18n.yaml | 4 +- ...op-bundled-runtime-and-external-plugins.md | 8 +- ...bundled-runtime-and-external-plugins.zh.md | 8 +- ...mmediate-window-and-direct-start.i18n.yaml | 4 +- ...sktop-immediate-window-and-direct-start.md | 6 +- ...op-immediate-window-and-direct-start.zh.md | 6 +- .../2026-09-10-desktop-web-wrapper.i18n.yaml | 4 +- .../2026-09-10-desktop-web-wrapper.md | 10 +- .../2026-09-10-desktop-web-wrapper.zh.md | 10 +- ...15-desktop-native-fatal-recovery.i18n.yaml | 4 +- ...026-09-15-desktop-native-fatal-recovery.md | 2 +- ...-09-15-desktop-native-fatal-recovery.zh.md | 2 +- ...-15-desktop-profile-core-cleanup.i18n.yaml | 4 +- ...2026-09-15-desktop-profile-core-cleanup.md | 2 +- ...6-09-15-desktop-profile-core-cleanup.zh.md | 2 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 20 +- apps/desktop/README.zh.md | 20 +- apps/desktop/electron-builder.config.mjs | 5 +- apps/desktop/renderer/plugin-manager.css | 108 ------- apps/desktop/renderer/plugin-manager.html | 43 --- apps/desktop/renderer/plugin-manager.js | 130 --------- apps/desktop/src/ipc.ts | 38 +-- apps/desktop/src/locale.ts | 60 +--- apps/desktop/src/main.ts | 127 +------- apps/desktop/src/preload.ts | 29 -- apps/desktop/src/project-manager.ts | 198 +------------ apps/desktop/src/update-coordinator.ts | 29 +- .../desktop/tests/development-project.spec.ts | 10 +- apps/desktop/tests/fatal-recovery.spec.ts | 2 +- .../desktop/tests/installer-packaging.spec.ts | 3 + apps/desktop/tests/locale.spec.ts | 12 +- apps/desktop/tests/main-startup.spec.ts | 75 +---- apps/desktop/tests/plugin-manager.spec.ts | 75 ----- apps/desktop/tests/plugin-pnpm.spec.ts | 119 -------- apps/desktop/tests/preload-app.spec.ts | 4 +- apps/desktop/tests/project-manager.spec.ts | 272 +++--------------- apps/desktop/tests/update-coordinator.spec.ts | 9 +- apps/desktop/tsdown.config.ts | 6 +- 46 files changed, 178 insertions(+), 1321 deletions(-) rename .agents/notes/{implemented => archived}/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml (69%) rename .agents/notes/{implemented => archived}/architecture/2026-09-09-desktop-in-place-profile.md (99%) rename .agents/notes/{implemented => archived}/architecture/2026-09-09-desktop-in-place-profile.zh.md (99%) delete mode 100644 apps/desktop/renderer/plugin-manager.css delete mode 100644 apps/desktop/renderer/plugin-manager.html delete mode 100644 apps/desktop/renderer/plugin-manager.js delete mode 100644 apps/desktop/src/preload.ts delete mode 100644 apps/desktop/tests/plugin-manager.spec.ts delete mode 100644 apps/desktop/tests/plugin-pnpm.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml similarity index 69% rename from .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml rename to .agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml index 8d8ad118e3..b8b5ab8c63 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml +++ b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md -2026-09-09-desktop-in-place-profile.md: 6f0e9598903d922e4b765d785be6dd3aa79ce93c -2026-09-09-desktop-in-place-profile.zh.md: bec79b3b9d4ce5439664959b16695932be4af4cd +2026-09-09-desktop-in-place-profile.md: 57dabc0d03c999b90dc18baa9d833aa90320863a +2026-09-09-desktop-in-place-profile.zh.md: 2b57c49ce45b1937552596cf4f59e0251b2d5774 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md rename to .agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.md index 6f0e959890..57dabc0d03 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md +++ b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.md @@ -1,6 +1,7 @@ # Agent Note: Modify the Desktop profile in place Status: implemented +Archived: 2026-09-17 English | [中文](2026-09-09-desktop-in-place-profile.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md rename to .agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.zh.md index bec79b3b9d..2b57c49ce4 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md +++ b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.zh.md @@ -1,6 +1,7 @@ # Agent Note: 直接修改 Desktop profile Status: implemented +Archived: 2026-09-17 [English](2026-09-09-desktop-in-place-profile.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 0d758eec58..58957b0deb 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -364,6 +364,9 @@ "architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml": "sha256:ba8a62a9fa3263de4162709cb1aaf81f6ad4feee5011d7a5d8b24eb92ad81d30", "architecture/2026-09-02-protocol-specific-model-listing-discovery.md": "sha256:9db9c47559c17f0e2931b9e646f1d2d17b590df4d5699d4e779ed6d7f65fc371", "architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md": "sha256:9a266590d49093a097bcdb5d09865757cdd07625cfaaddce1303046d580551f1", + "architecture/2026-09-09-desktop-in-place-profile.i18n.yaml": "sha256:08543070a32503373d38ca98b7ce96ee60c972302534030c5ba380f19e04fa00", + "architecture/2026-09-09-desktop-in-place-profile.md": "sha256:82bdcad48207c5aecbc9695717aaaa4c8cca90c82ca58884bfa653b60f192290", + "architecture/2026-09-09-desktop-in-place-profile.zh.md": "sha256:f105636c9160c64b12bd1822c8ec81ee744ba27c0b0ab6eeed870561ccd81ce7", "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml": "sha256:8be5b0afd8820c593e2ec254d35fb8c384267814104f6234bb9af824c5f974ba", "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md": "sha256:861e6130e893489271478def5f54f212a106a1580b744e6e40f56356f73c7e10", "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md": "sha256:3549257e06f074591de580ebfaa71284c7c384fb0ffb1963c30258d7a7c26db7", diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index eba3a6c4a8..665c16236a 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: c532db9c3078a8c0cc67935f88457bf06b850141 -2026-08-25-electron-desktop-packaging-and-updates.zh.md: fad71d59ea720542bfdcd15a010ece60b295f2d8 +2026-08-25-electron-desktop-packaging-and-updates.md: 216f3d122cb22d4af9b31371301bc9bb36586233 +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 08d3246409ab4321d7c86e6b7feee46e727afeba diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index c532db9c30..216f3d122c 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-08-25-electron-desktop-packaging-and-updates.zh.md) -Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). +Plugin management and native recovery follow the [shared Web wrapper decision](2026-09-10-desktop-web-wrapper.md). The [Electron runtime decision](2026-09-11-desktop-electron-node-runtime.md) supersedes the separate upstream Node executable; other decisions in this note remain applicable. @@ -59,9 +59,9 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb ## Installation and resolution -Desktop stops the Host before modifying its profile in place. Package failures retain partial changes for explicit repair; profile mutation and package-retry ownership follow the [in-place decision](2026-09-09-desktop-in-place-profile.md). +The shared Web plugin manager owns profile package operations. Electron retains startup preparation and native recovery; the [Web wrapper decision](2026-09-10-desktop-web-wrapper.md) owns this separation. -The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing package transaction; after that worker exits, the stale PID can be recovered. +The process-lifetime Electron lock is the authoritative Desktop owner. Profile preparation and native recovery use an exclusive lock recording the Electron PID; a live owner blocks a competing operation, while a stale PID can be recovered. Web package operations use the shared plugin manager’s locking. Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). The actual Host composes enabled desktop plugins contributing `dsh.client` code when it starts. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index fad71d59ea..08d3246409 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-08-25-electron-desktop-packaging-and-updates.md) | 中文 -profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。 +插件管理和原生恢复遵循[共享 Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)。 [Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。 @@ -59,9 +59,9 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策] ## 安装与解析 -Desktop 在直接修改 profile 前停止 Host。包操作失败后保留部分修改,供显式修复;profile 修改和包重试的职责遵循[直接修改决策](2026-09-09-desktop-in-place-profile.zh.md)。 +共享 Web 插件管理器负责 profile 包操作。Electron 保留启动准备和原生恢复;[Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责这一职责划分。 -进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 包事务;该 worker 退出后,陈旧 PID 才可以恢复。 +Electron 进程生命周期锁是 Desktop 的权威所有者。profile 准备和原生恢复使用记录 Electron PID 的排他锁;存活的所有者阻止竞争操作,失效 PID 可被回收。Web 包操作使用共享插件管理器的锁。 核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。实际 Host 启动时会组合已启用且提供 `dsh.client` 代码的桌面插件。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml index c2dfc82096..62c5aa6ad8 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md -2026-09-08-desktop-bundled-runtime-and-external-plugins.md: b49ae76bfd8c468e9c971f803cf714755978e5b4 -2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 58dd72124c0fdeb53652a600d0d3c143feb1ff3d +2026-09-08-desktop-bundled-runtime-and-external-plugins.md: a5f9266e0e7a8def3f0ff6fc271bddde3db30163 +2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 2cc9fd635102f08e6ca4afb4f086a0cf6b8b05f0 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md index b49ae76bfd..a5f9266e0e 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md) -Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). +Plugin management and native recovery follow the [shared Web wrapper decision](2026-09-10-desktop-web-wrapper.md). The [Electron runtime decision](2026-09-11-desktop-electron-node-runtime.md) supersedes the separate upstream Node executable; other decisions in this note remain applicable. @@ -40,11 +40,11 @@ First launch creates profile metadata and host links without running pnpm, prese Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary profile preparation. Profile cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation. -Dependency mutations use ordinary pnpm add and remove commands, including their configured lifecycle scripts. Installation sources and dependency resolution belong to pnpm. Bundle declarations select automatic activation; patch validation belongs to the bundle loader. Plain dependencies remain installed without activation. The bundled pnpm reads normal user and profile settings, including registry and build permissions. Desktop supplies no runtime build allowlist, strict-build setting, version-range restriction, or fixed profile identity. Unreadable package metadata does not prevent listing, disabling, or removing a dependency. Development uses the same manager against a separate plugin profile, with workspace packages supplied by the development runtime. +The shared [plugin manager](../../../../packages/boot/plugin-manager/README.md) owns supported package specifications, bundle validation, activation, and installation failure handling. The bundled pnpm reads normal user and profile settings. Development uses the same Web manager against a separate Desktop profile, with workspace packages supplied by the development runtime. -Desktop stops the Host before package mutations and waits for pnpm exit before restarting it. Shared cleanup removes only fallback-owned links, preserving pnpm entries. The [in-place decision](2026-09-09-desktop-in-place-profile.md) owns partial failures and explicit recovery. +The shared Web plugin manager owns package mutations and activation; Electron retains profile preparation and native recovery. The [Web wrapper decision](2026-09-10-desktop-web-wrapper.md) owns these responsibilities. -The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. +The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup. The native recovery dialog can disable third-party bundles and back up the profile patch when the Web application cannot start. Installed plugin files remain available for repair. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md index 58dd72124c..2cc9fd6351 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) | 中文 -profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。 +插件管理和原生恢复遵循[共享 Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)。 [Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。 @@ -40,11 +40,11 @@ profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。 共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 profile 准备。profile 清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。 -依赖修改使用普通的 pnpm add 和 remove 命令,并遵循其生命周期脚本配置。安装来源和依赖解析由 pnpm 负责。bundle 声明决定自动启用;patch 验证由 bundle 加载器负责。普通依赖安装后不自动启用。内置 pnpm 读取正常的用户和 profile 设置,包括 registry 和构建权限。Desktop 不提供运行时构建许可列表、严格构建设置、版本范围限制或固定 profile 身份。包元数据不可读时,仍可列出、禁用或删除依赖。开发模式使用同一管理器操作独立的插件 profile,工作区包由开发运行时提供。 +共享[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)负责支持的包规格、bundle 验证、激活和安装失败处理。内置 pnpm 读取正常的用户和 profile 设置。开发模式使用相同的 Web 管理器操作独立 Desktop profile,工作区包由开发运行时提供。 -Desktop 在包变更前停止 Host,并等待 pnpm 退出后重新启动。共享清理仅移除模块补全逻辑拥有的链接,保留 pnpm 条目。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)负责部分失败与显式恢复。 +共享 Web 插件管理器负责包变更和激活;Electron 保留 profile 准备和原生恢复。[Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责这些职责。 -[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定实际 Host 启动和主窗口恢复。用户可以更新、删除、禁用或重新启用插件并重试启动。不兼容插件不会被静默删除或自动降级。 +[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责直接启动 Host。Web 应用无法启动时,原生恢复对话框可禁用第三方 bundle 并备份 profile patch。已安装插件文件保留以供修复。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml index 6a440cf660..438d127244 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md -2026-09-09-desktop-immediate-window-and-direct-start.md: 2d325d5c9f9c3fc8dd83816538d0a1b4b4c5480b -2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 8aebe6bc8c86cc14b78d9dc979eeb902493de8ae +2026-09-09-desktop-immediate-window-and-direct-start.md: a61f3d185056e0e0599cef9eaa72574aeb4b4635 +2026-09-09-desktop-immediate-window-and-direct-start.zh.md: f6d984ac05f1ea627f6e750360d6cdcb52ea9620 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md index 2d325d5c9f..a61f3d1850 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-09-09-desktop-immediate-window-and-direct-start.zh.md) -Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). +Plugin management and native recovery follow the [shared Web wrapper decision](2026-09-10-desktop-web-wrapper.md). ## Problem @@ -16,7 +16,7 @@ Electron creates the main window with the packaged Web loading page before profi Fatal presentation follows [native Desktop recovery](2026-09-15-desktop-native-fatal-recovery.md). Window timing, direct Host startup, and shutdown ownership remain governed here. -Desktop starts the actual Host through the [shared Web runner](2026-09-10-desktop-web-wrapper.md) after preparing the profile in place. Readiness supplies the authenticated Host URL and boot injections. The shell exchanges the URL for a Host cookie, forwards application HTTP requests, and authenticates direct WebSocket requests only for the owned application origin. This carrier adaptation preserves Web route and stream semantics while allowing static HTML to appear before the Host. Package mutations retain pnpm lifecycle scripts and locking. Failures retain partial changes for explicit repair; there is no automatic profile rollback. +Desktop starts the actual Host through the [shared Web runner](2026-09-10-desktop-web-wrapper.md) after preparing the profile in place. Readiness supplies the authenticated Host URL and boot injections. The shell exchanges the URL for a Host cookie, forwards application HTTP requests, and authenticates direct WebSocket requests only for the owned application origin. This carrier adaptation preserves Web route and stream semantics while allowing static HTML to appear before the Host. This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation. @@ -30,4 +30,4 @@ This partially supersedes staged backend probes and waiting to create the main w Users can see startup progress and recover from failures before the product UI is available. A responsive window does not imply that the backend is ready, and startup latency still requires installed-artifact measurement. Profile changes remain in place after activation fails. -Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, plugin management during recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle and transaction tests. +Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, native recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle tests. diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md index 8aebe6bc8c..f6d984ac05 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-09-09-desktop-immediate-window-and-direct-start.md) | 中文 -profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。 +插件管理和原生恢复遵循[共享 Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)。 ## 问题 @@ -16,7 +16,7 @@ Electron 在 profile 校准或 Host 启动前创建带打包 Web 加载页的主 致命错误展示遵循[原生 Desktop 恢复](2026-09-15-desktop-native-fatal-recovery.zh.md)。窗口展示时机、直接启动 Host 和关闭所有权仍由本文规定。 -Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-web-wrapper.zh.md)启动实际 Host。就绪消息提供认证 Host URL 与启动注入。壳使用该 URL 换取 Host cookie,转发应用 HTTP 请求,并仅为归属的应用 origin 认证直接 WebSocket 请求。这一载体适配保留 Web 路由与流语义,同时允许静态 HTML 在 Host 之前显示。包变更保留 pnpm 生命周期脚本与锁。失败保留部分变更以供显式修复,不会自动回滚 profile。 +Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-web-wrapper.zh.md)启动实际 Host。就绪信息提供认证后的 Host URL 和启动注入。桌面壳用该 URL 换取 Host cookie,转发应用 HTTP 请求,并仅为所属应用源认证直接 WebSocket 请求。此承载适配保留 Web 路由和流语义,同时允许静态 HTML 在 Host 就绪前显示。 本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输、资源归属与依赖事务的理由。完整运行时文件验证仍属于打包操作。 @@ -30,4 +30,4 @@ Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop- 用户可以在产品 UI 可用前看到启动进度并从失败中恢复。窗口能够响应不代表后端已经就绪,启动延迟仍需通过已安装产物测量。激活失败后,profile 修改保留在原位。 -验证覆盖 Host 延迟时可见的加载页、新 profile 只启动一次服务进程、同一窗口中的失败与重试、恢复期间的插件管理,以及子进程正在启动时关闭应用。安装后 GUI 证据补充生命周期与事务测试。 +验证覆盖 Host 延迟时可见的加载页、新 profile 仅启动一次服务、同一窗口中的失败与重试、原生恢复,以及子进程启动时关闭。安装后 GUI 证据补充生命周期测试。 diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml index 1a3ff86ba9..321c9c8c58 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md -2026-09-10-desktop-web-wrapper.md: 445c574f86d6e465763603e7d447d92ffefb4f99 -2026-09-10-desktop-web-wrapper.zh.md: a41ce3c2f18237ddf580777e88fe05507998982f +2026-09-10-desktop-web-wrapper.md: 05210e2ab85f0afe6d0275e25f31ce8addf11332 +2026-09-10-desktop-web-wrapper.zh.md: 84e72c9261250b103245f974e1a0546a4898e9ca diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md index 445c574f86..05210e2ab8 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md @@ -14,13 +14,13 @@ Separate Desktop composition and request transport require their own configurati The private Desktop Host invokes the CLI's shared profile runner against the independently owned Desktop profile. The complete Web composition owns authentication, HTTP routes, client assets, RPC, and response streaming. Electron loads packaged static Web assets before the child is ready. Child IPC carries readiness, structured boot injections, and shutdown. The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns local-document HTTP forwarding and authenticated WebSocket access; Web retains application dispatch and stream framing. -The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template's bundles and uses Web's automatic directory-picker selection, keeping application defaults under one owner. Desktop uses a separate default listener port so both applications can run concurrently; profile configuration can override it. Shell windows, menus, plugin management, recovery, and updates remain Electron responsibilities. +The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template and uses its Plugin Manager in the main application. Electron owns windows, menus, native directory selection, recovery, and release updates. -The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled pnpm and explicit package ownership. The [in-place decision](2026-09-09-desktop-in-place-profile.md) retains package transactions and partial-failure recovery. The public CLI continues to reject the reserved Desktop profile. +The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled pnpm and explicit package ownership. The public CLI continues to reject the reserved Desktop profile. Electron profile preparation and native recovery remain available without a working Host. Independent package ownership prevents CLI and Desktop from modifying each other’s installations; it does not define a stricter Desktop plugin policy. Desktop delegates registry, store, Git, tarball, local-path, and ordinary-package installation to pnpm with normal user and profile configuration. The Host inherits `NODE_OPTIONS`, `NODE_PATH`, and npm/pnpm environment variables. User build configuration determines which dependency lifecycle scripts execute. This replaces Desktop-specific source, environment, and build restrictions with the same package-manager and loader responsibilities used by Web. -App-boot owns installed-dependency discovery, installation-first bundle declaration resolution, and bundle-list updates after pnpm succeeds. CLI selects automatic activation; Desktop explicitly preserves bundles disabled through its UI. The policy difference belongs to the visible activation control, while metadata handling and reconciliation remain shared. +The shared Web plugin manager owns installation, activation, errors, and restart requirements. Electron has no separate plugin-management renderer, preload, shell asset route, plugin IPC, or package-mutation runner. Packaging explicitly selects the main entry and application preload so stale build outputs cannot restore the removed bridge. Shared `initProfile` creates missing profile files and preserves existing content. The Host’s `healIsolatedProfileModuleFallback` is the sole owner of installation and bundle projections; package operations use shared `unlinkProfileModuleFallback` to detach only its own links before pnpm. pnpm-managed directories retain priority. Desktop maintains no second runtime-state, lockfile hash, or link reconciliation mechanism. One-time cleanup of `desktop-runtime-state.json` removes only matching recorded links and retires that metadata. @@ -38,12 +38,14 @@ Native directory selection in the local application uses a narrow preload IPC ca **Keep a Desktop link ledger and manifest reconciler.** These duplicate shared profile mechanisms and can reject otherwise usable installations when derived metadata drifts. A single fallback owner can protect pnpm directories without maintaining release identity in the plugin profile. +**Keep a separate native plugin-management page.** It can operate while the Web Host is unavailable, but duplicates package operations, renderer IPC, localization, and backend restart handling. Native recovery already supports disabling third-party bundles and backing up the profile patch without the Host. Reintroduction requires a repair operation that this recovery cannot provide. + **Pin registry and store settings, filter runtime environment, and admit only approved plugin sources.** Those rules constrain execution and package selection, but make the same user configuration behave differently in Desktop and Web. Separate installation ownership remains useful without those restrictions. A Desktop-only restriction requires a distinct product requirement instead of following automatically from packaging or plugin isolation. ## Consequences Desktop inherits Web features through the same boot and serving path. HTTP listener ownership and authentication remain part of application startup. Electron uses the reported Host address and preserves the existing Web document through readiness. The shared Web loading page is available before the Host starts; native recovery remains available when startup fails. -User-selected runtime options, package sources, and permitted lifecycle scripts can affect Host execution, load third-party code, or cause startup failure. Desktop accepts these effects under the same configuration ownership as Web; the signed core runtime does not attest to user-installed plugin code. Package or loading failures retain explicit repair and the native recovery dialog rather than triggering stricter admission checks or automatic rollback. +User-selected runtime options, package sources, and permitted lifecycle scripts can affect Host execution, load third-party code, or cause startup failure. The shared Web manager owns package-failure handling; Electron retains native recovery for fatal startup failures. The signed core runtime does not attest to user-installed plugin code. Verification requires shared-runner coverage, authenticated HTTP asset and API delivery, configuration reload, native directory selection, child shutdown, and recovery after plugin failure. Installed-platform and real-model GUI qualification remain distinct from unit tests; this note records no measured startup or transfer improvement. diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md index a41ce3c2f1..84e72c9261 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md @@ -14,13 +14,13 @@ Status: implemented 私有 Desktop Host 针对独立归属的 Desktop profile 调用 CLI 的共享 profile runner。完整 Web 组合负责认证、HTTP 路由、客户端资源、RPC 与响应流。Electron 在子进程就绪前加载打包静态 Web 资源。子进程 IPC 承载就绪、结构化启动注入与关闭。[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定本地文档 HTTP 转发与认证 WebSocket 访问;Web 保留应用分派与流帧处理。 -共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载及应用生命周期。Desktop 以共享 Web 模板的 bundle 列表初始化 profile,并使用 Web 的自动目录选择机制,让应用默认值由一处维护。Desktop 使用独立的默认监听端口,使两个应用可以同时运行;profile 配置可以覆盖该端口。壳窗口、菜单、插件管理、恢复及更新仍由 Electron 负责。 +共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载和应用生命周期。Desktop 从共享 Web 模板初始化 profile,并在主应用中使用其插件管理器。Electron 负责窗口、菜单、原生目录选择、恢复和发布更新。 -[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 pnpm,以及明确的包归属。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)保留包事务与部分失败恢复。公开 CLI 继续拒绝保留的 Desktop profile。 +[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 pnpm,以及明确的包归属。公开 CLI 继续拒绝保留的 Desktop profile。Electron profile 准备和原生恢复在 Host 不可用时仍可执行。 独立包归属防止 CLI 与 Desktop 修改彼此的安装,不代表 Desktop 采用更严格的插件策略。Desktop 将 registry、store、Git、tarball、本地路径及普通包安装交给 pnpm,并遵循正常用户与 profile 配置。Host 继承 `NODE_OPTIONS`、`NODE_PATH` 及 npm/pnpm 环境变量。用户构建配置决定哪些依赖生命周期脚本可以执行。这以 Web 使用的相同包管理器和加载器职责取代 Desktop 专用的来源、环境及构建限制。 -App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析及 pnpm 成功后的 bundle 列表更新。CLI 选择自动激活;Desktop 显式保留通过 UI 禁用的 bundle。策略差异属于可见的启用控件,元数据处理与协调逻辑仍然共享。 +共享 Web 插件管理器负责安装、激活、错误和重启要求。Electron 不提供独立插件管理渲染器、preload、shell 资源路由、插件 IPC 或包变更执行器。打包明确选择主入口和应用 preload,避免旧构建产物重新带入已删除的桥接。 共享 `initProfile` 创建缺失的 profile 文件并保留现有内容。Host 的 `healIsolatedProfileModuleFallback` 是安装包与 bundle 投影的唯一归属方;包操作在 pnpm 前通过共享 `unlinkProfileModuleFallback` 仅分离它自己拥有的链接。pnpm 管理的目录保持优先。Desktop 不维护第二套运行时状态、锁文件哈希或链接协调机制。`desktop-runtime-state.json` 的一次性清理仅移除与记录匹配的链接,并清除该元数据。 @@ -38,12 +38,14 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析 **保留 Desktop 链接账本与 manifest 协调器。** 这些机制重复共享 profile 逻辑,并可能因派生元数据漂移而拒绝原本可用的安装。单一模块补全归属方可以保护 pnpm 目录,无需在插件 profile 中维护发布身份。 +**保留独立原生插件管理页。** 它可以在 Web Host 不可用时运行,但重复维护包操作、渲染器 IPC、本地化和后端重启处理。原生恢复已能在没有 Host 时禁用第三方 bundle 并备份 profile patch。只有出现此恢复方式无法提供的修复操作时,才应重新引入。 + **固定 registry 与 store、过滤运行时环境,并仅允许批准的插件来源。** 这些规则限制执行和包选择,却使同一用户配置在 Desktop 与 Web 中产生不同行为。独立安装归属无需这些限制仍然有用。Desktop 专用限制需要独立的产品需求,不能仅由打包或插件隔离推导而来。 ## Consequences Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动。Electron 使用报告的 Host 地址,并在就绪前后保留现有 Web 文档。共享 Web 加载页在 Host 启动前可用;原生恢复在启动失败时仍可用。 -用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及原生恢复对话框,不触发更严格的准入检查或自动回滚。 +用户选择的运行时选项、包来源和允许的生命周期脚本可能影响 Host 执行、加载第三方代码或造成启动失败。共享 Web 管理器负责包操作失败处理;Electron 为致命启动失败保留原生恢复。签名核心运行时不为用户安装的插件代码背书。 验证需要覆盖共享 runner、认证 HTTP 资源与 API 传输、配置重载、原生目录选择、子进程关闭及插件失败恢复。安装后平台验收与真实模型 GUI 验收独立于单元测试;本记录不声称已测得启动或传输提升。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml index 859e17061e..c7e1002da3 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md -2026-09-15-desktop-native-fatal-recovery.md: 63ec81991f3e396075fe4ed09a8ddaa87c865862 -2026-09-15-desktop-native-fatal-recovery.zh.md: 05dbad80064706d384eeac6537da073a8f365e7e +2026-09-15-desktop-native-fatal-recovery.md: a9c26ce20840a73ceb9d3cd9c6206d2cf818c43f +2026-09-15-desktop-native-fatal-recovery.zh.md: 00ed91ebfa88463ea961cdcdfda311f4b585774a diff --git a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md index 63ec81991f..a9c26ce208 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md +++ b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md @@ -16,7 +16,7 @@ The first report claims presentation before awaiting the dialog. Later reports r The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. The plugin window exposes package operations only; backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset, plugin-window recovery controls, or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry. -This supersedes recovery-page and reset behavior in the [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md), whose immediate visibility and direct Host startup rationale remain active. The [in-place profile decision](2026-09-09-desktop-in-place-profile.md) still owns package transactions and partial changes. +This supersedes recovery-page and reset behavior in the [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md), whose immediate visibility and direct Host startup rationale remain active. The [Web wrapper decision](2026-09-10-desktop-web-wrapper.md) owns shared plugin management and native profile preparation. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md index 05dbad8006..00ed91ebfa 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md @@ -16,7 +16,7 @@ Electron 在每个应用进程中提供一次原生致命错误对话框。明 Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。插件窗口只暴露包操作;后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置、插件窗口恢复控件或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。 -这取代了[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的恢复页和重置行为;该决策关于立即可见性和直接启动 Host 的理由仍然有效。[原位 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)仍负责包事务和部分变更。 +本决策取代[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的恢复页面和重置行为;其立即显示与直接启动 Host 的理由仍有效。[Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责共享插件管理和原生 profile 准备。 ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.i18n.yaml index 4ac53a23d4..97d7d9c32f 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md -2026-09-15-desktop-profile-core-cleanup.md: 7a5f3eea561751f88025f3d091e90f2b932e4f87 -2026-09-15-desktop-profile-core-cleanup.zh.md: 4a9cad94a89ca16779bd1a69bc4035f9b9c4c12c +2026-09-15-desktop-profile-core-cleanup.md: da189664e2b55a9310b36ed02f0b275e2c1b1370 +2026-09-15-desktop-profile-core-cleanup.zh.md: dec12e4fe7f34beb5931961183041c3f642b44ae diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md b/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md index 7a5f3eea56..da189664e2 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md @@ -12,7 +12,7 @@ Old Desktop profiles contain installed core packages and local tarball dependenc Production Desktop cleans profile copies of packages named in the verified runtime descriptor or the old Desktop package-set record before starting the Host, under the existing profile lock. Cleanup removes matching dependency declarations and pnpm overrides, invalidates the lockfile when package state changes, and unlinks fallback links without deleting their targets. Other plugins, bundle selections, configuration, and session data remain. Development skips cleanup. -The implementation and its temporary enable constant live in `apps/desktop/src/profile-core-cleanup.ts`, with one call in profile preparation. Cleanup runs on every production startup because development or package operations can recreate residue. This qualifies package retention in the [in-place profile decision](../architecture/2026-09-09-desktop-in-place-profile.md); direct writes and failure recovery remain unchanged. +The implementation and its temporary enable constant live in `apps/desktop/src/profile-core-cleanup.ts`, with one call in profile preparation. Cleanup runs on every production startup because development or package operations can recreate residue. This qualifies package retention in the [bundled runtime decision](../architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md); direct writes and failure recovery remain unchanged. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md index 4a9cad94a8..dec12e4fe7 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md @@ -12,7 +12,7 @@ Status: implemented 生产版 Desktop 在启动 Host 前,持有现有 profile 锁,清理已验证运行时描述符或旧 Desktop 包清单记录列出的包副本。清理删除对应依赖声明和 pnpm overrides,在包状态变化时使锁文件失效,并解除回退链接而不删除其目标。其他插件、bundle 选择、配置和会话数据保留。开发模式跳过清理。 -实现与临时启用常量集中在 `apps/desktop/src/profile-core-cleanup.ts`,由 profile 准备流程中的一个调用接入。每次生产启动都执行清理,因为开发模式或包操作可能重新产生残留。这限定了[原地修改 profile 决策](../architecture/2026-09-09-desktop-in-place-profile.zh.md)中的包保留范围;直接写入和失败恢复保持不变。 +实现与临时启用常量集中在 `apps/desktop/src/profile-core-cleanup.ts`,由 profile 准备流程中的一个调用接入。每次生产启动都执行清理,因为开发模式或包操作可能重新产生残留。这限定了[内置运行时决策](../architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的包保留范围;直接写入和失败恢复保持不变。 ## Alternatives considered diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 7c2a3589e2..3a2bbfa585 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 7763152e43cd44a588883b3e346bcaf69e453a40 -README.zh.md: a0758e0c5cc34c5cfd9883d54b490a9eab6f899f +README.md: 42ceae1056acfaa747e576961b622e09cbfe42af +README.zh.md: efd8c8914b11c379afad2204040677801ddf4326 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 7763152e43..42ceae1056 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -34,8 +34,8 @@ Node prepares the bundled interpreters and Python libraries without a system Pyt | Runtime | The application must run without a system Node.js or pnpm installation. | dsh runs under Electron with `ELECTRON_RUN_AS_NODE=1` and `--expose-internals` and every package operation uses the bundled pnpm. Package-manager configuration and the Host environment follow the user's settings. Package scripts use a `node` shell launcher that forwards to Electron. | | Package sources | Core installation at startup adds work even when offline. | `app.asar/dsh` carries a complete production dependency tree; the profile installs only external plugins. | | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. | -| Transport | Web serving and authentication share one implementation. | Electron loads packaged Web assets; the Host supplies boot injections and authenticated APIs. The shell protocol serves plugin management. | -| Plugin changes | Package installation and Host startup can fail. | Desktop stops the Host and modifies the current profile directly. Failures retain partial changes for explicit repair; there is no automatic profile rollback. | +| Transport | Web serving and authentication share one implementation. | Electron loads packaged Web assets; the Host supplies boot injections and authenticated APIs. | +| Plugin changes | Desktop and Web need the same installation and activation behavior. | The main application uses the shared Web Plugin Manager and bundled pnpm. | | Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. | The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md) owns shared Web behavior and Desktop adapters. The [Electron packaging and update decision](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns release identity, signing, and update qualification. @@ -44,13 +44,13 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09 Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. -The application preload exposes boot readiness and fatal startup reporting. The product renderer uses the Web application’s HTTP APIs. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The application preload exposes boot readiness, fatal startup reporting, and native directory selection. Plugin management uses the Web application’s authenticated HTTP APIs; Electron exposes no plugin-management IPC or separate management document. The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles. -Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources. +Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus and native dialogs use the same locale payload; the repository Client UI i18n gate checks these desktop sources. -Electron's native Edit menu supplies undo, redo, cut, copy, paste, and select-all commands and platform shortcuts for the focused window. Right-clicking an editable field opens these commands without shortcut labels, with availability supplied by Chromium; selected read-only text offers Copy. +Electron's application menu provides update checks and Quit. Plugin management uses the main application's Plugins page. The native Edit menu supplies undo, redo, cut, copy, paste, and select-all commands and platform shortcuts for the focused window. Right-clicking an editable field opens these commands without shortcut labels, with availability supplied by Chromium; selected read-only text offers Copy. On macOS the custom application menu also declares the standard File, Window, and application menus, because replacing Electron's default menu drops Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). Windows and Linux keep the application and Edit menus. @@ -61,18 +61,18 @@ The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version 1. The main window displays the shared Web loading page from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files. 2. Before production Host startup, Desktop removes profile copies and fallback links for packages listed by the current runtime or the recorded Desktop package set. It removes their dependency declarations and overrides, and discards the lockfile when cleanup changes package state. Other plugin files, configuration, and versions remain; development skips this cleanup and startup never runs pnpm. 3. Changes to Electron's Node version, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm. -4. Plugin add, update, and remove operations use bundled pnpm with its normal user and profile configuration. Desktop does not override the registry, npmrc, cache, or store, and new profiles add no build allowlist or strict-build setting. The plugin-management page has an inline version form with cancellation; versions and ranges pass to pnpm, including the installed version for a reinstall. Package specs pass to pnpm, including local directories, Git, tarballs, and aliases. Relative paths resolve from the Desktop profile directory. Packages declaring `dsh.bundle.patch` activate as bundles; ordinary dependencies remain installed without activation. Desktop does not scan plugin dependency graphs or validate patch files before Host startup. Custom profile metadata and bundle order are retained. Unreadable installed metadata does not block listing, disabling, or removing dependencies; the list uses the dependency spec when the installed version is unavailable. -5. Plugin changes stop the backend before modifying the current profile. The Host restarts after every attempted package change, including failed package operations. Package or Host startup failures retain modified files and report the error. Desktop creates no staging directories, activation journals, or rollback copies. +4. The main application’s Plugins page uses the shared [plugin manager](../../packages/boot/plugin-manager/README.md) against the Desktop profile. Package operations use bundled pnpm with normal user and profile configuration. +5. The shared manager owns installation errors, activation, and restart requirements. Native recovery can disable third-party bundles even when the Host cannot start. -CLI and Desktop use the same installed-dependency inventory and bundle reconciliation. Bundle declarations resolve with the same installation-first precedence as startup. CLI operations automatically enable installed bundles; Desktop preserves bundles disabled through its UI across updates. Neither path requires readable installed metadata to list or remove a dependency. +The [Web plugin UI](../../packages/client/ui-plugin-manager/README.md) owns the management interface. Desktop profile initialization and recovery preserve installed plugin files. -Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable third-party plugins, back up profile patch, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic. +Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable third-party plugins, back up profile patch, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. There is no startup timeout heuristic. Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines; the complete reported error is written to the Electron console. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely. Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document. -Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; development Host startup restores needed links. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist. +Electron serializes profile preparation and native recovery through `$DSH_HOME/profiles/desktop/lock`. Web plugin operations use the shared manager’s package lock and lifecycle. ## Develop diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a0758e0c5c..efd8c8914b 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -34,8 +34,8 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载 | 运行时 | 应用必须能够在没有系统 Node.js 或 pnpm 的机器上运行。 | dsh 通过设置 `ELECTRON_RUN_AS_NODE=1` 和 `--expose-internals` 的 Electron 运行,所有包操作都使用内置 pnpm。包管理器配置和 Host 环境遵循用户设置。包脚本通过 `node` shell 启动器转发给 Electron。 | | 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `app.asar/dsh` 携带完整生产依赖树;profile 只安装外部插件。 | | 状态归属 | 共享可执行依赖图会让 CLI(命令行界面)与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 | -| 传输 | Web 服务与认证共享一套实现。 | Electron 加载打包的 Web 资源;Host 提供启动注入和经过认证的 API。shell 协议提供插件管理页面。 | -| 插件变更 | 包安装和 Host 启动可能失败。 | Desktop 停止 Host 后直接修改当前 profile。失败保留部分修改供用户修复,不自动回滚 profile。 | +| 传输 | Web 服务与认证共享一套实现。 | Electron 加载打包的 Web 资源;Host 提供启动注入和经过认证的 API。 | +| 插件变更 | Desktop 与 Web 需要一致的安装和激活行为。 | 主应用使用共享 Web 插件管理器和内置 pnpm。 | | 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 | [薄壳决策](../../.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md)负责共享 Web 行为与 Desktop 适配。[Electron 打包与更新决策](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责发布身份、签名及更新验收。 @@ -44,13 +44,13 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 -应用 preload 暴露启动就绪和致命启动失败上报。产品渲染器使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两种渲染器都无法访问文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。 +应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。插件管理使用 Web 应用经过认证的 HTTP API;Electron 不提供插件管理 IPC 或独立管理页面。 产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。 -Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。菜单、原生对话框和插件管理渲染器使用同一份语言数据;仓库 Client UI i18n 检查覆盖这些桌面端源码。 +Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。菜单和原生对话框使用同一份语言数据;仓库 Client UI i18n 检查覆盖这些桌面端源码。 -Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、剪切、复制、粘贴和全选命令及平台快捷键。右键点击可编辑输入区域会打开不带快捷键标注的这些命令,其可用状态由 Chromium 提供;选中的只读文本提供“复制”命令。 +Electron 应用菜单提供检查更新和退出操作。插件管理使用主应用的“插件”页面。原生“编辑”菜单为当前聚焦窗口提供撤销、重做、剪切、复制、粘贴和全选命令及平台快捷键。右键点击可编辑输入区域会打开不带快捷键标注的这些命令,其可用状态由 Chromium 提供;选中的只读文本提供“复制”命令。 macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Windows 和 Linux 保留应用菜单和 Edit 菜单。 @@ -61,18 +61,18 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜 1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。 2. 生产版在启动 Host 前,清理当前运行包清单或已记录 Desktop 包清单中各包的 profile 副本和回退链接,同时删除对应依赖声明与 overrides;清理改变包状态时丢弃锁文件。其他插件文件、配置和版本保留;开发模式跳过此清理,启动时不运行 pnpm。 3. Electron 的 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。 -4. 插件添加、更新和删除使用内置 pnpm 及其正常的用户和 profile 配置。Desktop 不覆盖 registry、npmrc、缓存或 store,新 profile 不添加构建许可列表或严格构建设置。插件管理页提供可取消的行内版本表单;版本和范围交给 pnpm,也允许提交已安装版本以重装。包规格交给 pnpm,包括本地目录、Git、tarball 和别名。相对路径从 Desktop profile 目录解析。声明 `dsh.bundle.patch` 的包作为 bundle 启用;普通依赖安装后不自动启用。Desktop 不扫描插件依赖图,也不在 Host 启动前验证 patch 文件。自定义 profile 元数据和 bundle 顺序会保留。已安装元数据不可读时,仍能列出、禁用和删除依赖;无法读取已安装版本时,列表使用依赖规格。 -5. 插件变更在直接修改当前 profile 前停止后端。每次包变更尝试结束后都会重新启动 Host,包括包操作失败的情况。包操作或 Host 启动失败会保留已修改文件并报告错误。Desktop 不创建 staging 目录、激活日志或回滚副本。 +4. 主应用的“插件”页面通过共享[插件管理器](../../packages/boot/plugin-manager/README.zh.md)操作 Desktop profile。包操作使用内置 pnpm 及正常的用户和 profile 配置。 +5. 共享管理器负责安装错误、激活和重启要求。即使 Host 无法启动,原生恢复仍可禁用第三方 bundle。 -CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。 +[Web 插件 UI](../../packages/client/ui-plugin-manager/README.zh.md)负责管理界面。Desktop profile 初始化和恢复保留已安装插件文件。 -主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。 +主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求不会触发恢复。不通过启动超时推断故障。 原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断;完整的已报告错误写入 Electron 控制台。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。 恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。 -包事务独占 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。pnpm 运行前,共享模块回退辅助函数只删除其拥有的链接,保留 pnpm 管理的目录;开发 Host 在启动时重建所需链接。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备使用独立的构建期允许列表。 +Electron 通过 `$DSH_HOME/profiles/desktop/lock` 串行执行 profile 准备和原生恢复。Web 插件操作使用共享管理器的包锁和生命周期。 ## 开发 diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index ca846818b6..369bd401c1 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -79,9 +79,8 @@ export function createElectronBuilderConfig( return true }, files: [ - 'lib/*.js', - 'lib/*.cjs', - 'renderer/**/*', + 'lib/main.js', + 'lib/preload-app.cjs', 'package.json', { from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] }, // electron-builder excludes a source directory's root node_modules. diff --git a/apps/desktop/renderer/plugin-manager.css b/apps/desktop/renderer/plugin-manager.css deleted file mode 100644 index 89d1c1f939..0000000000 --- a/apps/desktop/renderer/plugin-manager.css +++ /dev/null @@ -1,108 +0,0 @@ -:root { - color-scheme: light dark; - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: Canvas; - color: CanvasText; -} - -body { - margin: 0; -} - -main { - max-width: 680px; - margin: 0 auto; - padding: 32px; -} - -header, -.install-row, -li { - display: flex; - align-items: center; - gap: 12px; -} - -header { - justify-content: space-between; -} - -h1, -h2, -p { - margin-top: 0; -} - -header p { - color: GrayText; -} - -form, -section { - margin-top: 28px; -} - -label { - display: block; - margin-bottom: 8px; - font-weight: 600; -} - -input { - flex: 1; - min-width: 0; - padding: 9px 11px; - border: 1px solid ButtonBorder; - border-radius: 7px; - background: Field; - color: FieldText; -} - -button { - padding: 9px 14px; - border: 1px solid ButtonBorder; - border-radius: 7px; - background: AccentColor; - color: AccentColorText; - cursor: pointer; -} - -button.quiet, -li button { - background: ButtonFace; - color: ButtonText; -} - -button:disabled, -input:disabled { - opacity: 0.55; - cursor: wait; -} - -#status { - min-height: 1.5em; - margin-top: 16px; - color: GrayText; -} - -ul { - margin: 0; - padding: 0; - list-style: none; -} - -li { - justify-content: space-between; - padding: 12px 0; - border-bottom: 1px solid ButtonBorder; -} - -.package-version { - color: GrayText; - margin-left: 8px; -} - -.package-actions { - display: flex; - gap: 8px; -} diff --git a/apps/desktop/renderer/plugin-manager.html b/apps/desktop/renderer/plugin-manager.html deleted file mode 100644 index 5d229648f5..0000000000 --- a/apps/desktop/renderer/plugin-manager.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - -
-
-
-

-

-
- -
-
- -
- - -
-
- -

-
-

-
    -

    -
    -
    - - - diff --git a/apps/desktop/renderer/plugin-manager.js b/apps/desktop/renderer/plugin-manager.js deleted file mode 100644 index 6b6c4c542f..0000000000 --- a/apps/desktop/renderer/plugin-manager.js +++ /dev/null @@ -1,130 +0,0 @@ -const api = window.dshDesktop - -async function main() { - const locale = await api.locale() - const messages = locale.messages - const message = (key, values = {}) => messages[key].replaceAll(/\{([^{}]+)\}/gu, (placeholder, name) => values[name] ?? placeholder) - document.documentElement.lang = locale.id - document.querySelector('#page-title').textContent = messages.pluginManagerTitle - document.querySelector('#title').textContent = messages.pluginManagerTitle - document.querySelector('#description').textContent = messages.pluginManagerDescription - document.querySelector('#refresh').textContent = messages.refresh - document.querySelector('#package-label').textContent = messages.npmPackage - document.querySelector('#install').textContent = messages.install - document.querySelector('#installed-heading').textContent = messages.installed - document.querySelector('#empty').textContent = messages.noPlugins - - - const list = document.querySelector('#plugins') - const empty = document.querySelector('#empty') - const status = document.querySelector('#status') - const form = document.querySelector('#install-form') - const input = document.querySelector('#package-spec') - const refresh = document.querySelector('#refresh') - const updateForm = document.querySelector('#update-form') - const updateVersion = document.querySelector('#update-version') - let updateName = '' - let updateTrigger - document.querySelector('#update-submit').textContent = messages.update - document.querySelector('#update-cancel').textContent = messages.cancel - - function setBusy(busy, statusMessage = '') { - for (const control of document.querySelectorAll('button, input')) control.disabled = busy - status.textContent = statusMessage - } - - async function render() { - const plugins = await api.plugins.list() - list.replaceChildren(...plugins.map(plugin => { - const item = document.createElement('li') - const identity = document.createElement('span') - const version = document.createElement('span') - version.className = 'package-version' - version.textContent = plugin.enabled ? plugin.version : `${plugin.version} · ${messages.disabled}` - identity.append(document.createTextNode(plugin.name), version) - const remove = document.createElement('button') - remove.type = 'button' - remove.textContent = messages.remove - remove.addEventListener('click', () => void run( - () => api.plugins.remove(plugin.name), - message('removing', { name: plugin.name }), - )) - const update = document.createElement('button') - update.type = 'button' - update.textContent = messages.update - update.addEventListener('click', () => { - updateName = plugin.name - updateTrigger = update - document.querySelector('#update-label').textContent = message('targetVersion', { name: plugin.name }) - updateVersion.value = plugin.version - updateForm.hidden = false - updateVersion.focus() - updateVersion.select() - }) - const actions = document.createElement('span') - actions.className = 'package-actions' - const toggle = document.createElement('button') - toggle.type = 'button' - toggle.textContent = plugin.enabled ? messages.disable : messages.enable - toggle.addEventListener('click', () => void run( - () => api.plugins.toggle(plugin.name, !plugin.enabled), messages.changingActivation, - )) - actions.append(toggle, update, remove) - item.append(identity, actions) - return item - })) - empty.hidden = plugins.length !== 0 - } - - async function run(operation, statusMessage) { - setBusy(true, statusMessage) - try { - await operation() - await render() - status.textContent = messages.operationComplete - } catch (error) { - status.textContent = error instanceof Error ? error.message : String(error) - } finally { - setBusy(false, status.textContent) - } - } - - async function load(statusMessage, success) { - setBusy(true, statusMessage) - try { - await render() - status.textContent = success - } catch (error) { - status.textContent = error instanceof Error ? error.message : String(error) - } finally { - setBusy(false, status.textContent) - } - } - - form.addEventListener('submit', (event) => { - event.preventDefault() - const spec = input.value.trim() - if (spec === '') return - void run(async () => { - await api.plugins.add(spec) - input.value = '' - }, message('installing', { spec })) - }) - updateForm.addEventListener('submit', (event) => { - event.preventDefault() - const version = updateVersion.value.trim() - if (version === '') return - const name = updateName - updateForm.hidden = true - void run(() => api.plugins.update(name, version), message('updating', { name })) - }) - document.querySelector('#update-cancel').addEventListener('click', () => { - updateForm.hidden = true - updateTrigger.focus() - }) - refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed)) - - await load(messages.loadingPlugins, '') -} - -void main() diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 9c7329d0d4..86ccfce897 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,52 +1,16 @@ /** Typed preload operations exposed only by the Electron shell. */ import type { IpcMainInvokeEvent } from 'electron' -import type { DesktopPluginRecord } from './project-manager.ts' -import type { DesktopLocale } from './locale.ts' /** IPC channel names kept private to the desktop application bundle. */ export const DESKTOP_IPC = { - localeGet: 'dsh-desktop:locale-get', boot: 'dsh-desktop:boot', bootFailed: 'dsh-desktop:boot-failed', directoryPick: 'dsh-desktop:directory-pick', - pluginsList: 'dsh-desktop:plugins-list', - pluginsAdd: 'dsh-desktop:plugins-add', - pluginsRemove: 'dsh-desktop:plugins-remove', - pluginsUpdate: 'dsh-desktop:plugins-update', - pluginsToggle: 'dsh-desktop:plugins-toggle', - updatesCheck: 'dsh-desktop:updates-check', - updatesInstall: 'dsh-desktop:updates-install', - updatesState: 'dsh-desktop:updates-state', nativeThemeSet: 'dsh-desktop:native-theme-set', } as const -/** Desktop release update state rendered by desktop-owned UI. */ -export interface DesktopUpdateState { - readonly phase: 'idle' | 'checking' | 'available' | 'installing' | 'ready' | 'error' - readonly version?: string - readonly message?: string -} - -/** Narrow bridge exposed through context isolation. */ -export interface DshDesktopApi { - readonly protocolVersion: 1 - locale(): Promise - readonly plugins: { - list(): Promise - add(spec: string): Promise - remove(name: string): Promise - update(name: string, version: string): Promise - toggle(name: string, enabled: boolean): Promise - } - readonly updates: { - check(): Promise - install(): Promise - subscribe(listener: (state: DesktopUpdateState) => void): () => void - } -} - -/** Scheme of Desktop-owned application and shell documents. */ +/** Scheme of Desktop-owned application documents. */ export const SCHEME = 'dsh-app' /** diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 06b7e97eb6..175f8a08a8 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -10,7 +10,6 @@ export const en = { restartApplication: 'Restart', recoveryOperationFailed: 'The recovery operation failed', disableThirdPartyPlugins: 'Disable third-party plugins, back up profile patch, and restart', - pluginsMenu: 'Desktop Plugins…', checkUpdatesMenu: 'Check for Updates…', updateCheckFailedTitle: 'Update Check Failed', unknownError: 'Unknown error', @@ -22,29 +21,6 @@ export const en = { installAndRestart: 'Install and Restart', later: 'Later', updateFailedTitle: 'Update Failed', - pluginManagerTitle: 'Desktop Plugins', - pluginWindowTitle: 'DeepSeek Harness — Desktop Plugins', - pluginManagerDescription: 'Plugins are installed only in the Desktop node_modules and are managed by the bundled pnpm.', - refresh: 'Refresh', - enable: 'Enable', - disable: 'Disable', - disabled: 'Disabled', - changingActivation: 'Changing plugin activation…', - npmPackage: 'npm package', - install: 'Install', - installed: 'Installed', - noPlugins: 'No Desktop plugins are installed.', - remove: 'Remove', - update: 'Update', - cancel: 'Cancel', - targetVersion: 'Enter the target version for {name}', - removing: 'Removing {name}…', - updating: 'Updating {name}…', - installing: 'Installing {spec}…', - operationComplete: 'Done. The Desktop backend has restarted.', - refreshing: 'Refreshing…', - refreshed: 'Plugin list refreshed.', - loadingPlugins: 'Reading Desktop plugins…', } as const /** Every Desktop locale supplies the complete English key set. */ @@ -60,7 +36,6 @@ export const zh = { restartApplication: '重启', recoveryOperationFailed: '恢复操作失败', disableThirdPartyPlugins: '禁用第三方插件、备份 profile patch 并重启', - pluginsMenu: '桌面插件…', checkUpdatesMenu: '检查更新…', updateCheckFailedTitle: '更新检查失败', unknownError: '未知错误', @@ -72,42 +47,13 @@ export const zh = { installAndRestart: '安装并重启', later: '稍后', updateFailedTitle: '更新失败', - pluginManagerTitle: '桌面插件', - pluginWindowTitle: 'DeepSeek Harness — 桌面插件', - pluginManagerDescription: '插件只安装到桌面端自己的 node_modules,并由内置 pnpm 管理。', - refresh: '刷新', - enable: '启用', - disable: '禁用', - disabled: '已禁用', - changingActivation: '正在更改插件启用状态…', - npmPackage: 'npm 包', - install: '安装', - installed: '已安装', - noPlugins: '还没有安装桌面插件。', - remove: '移除', - update: '更新', - cancel: '取消', - targetVersion: '输入 {name} 的目标版本', - removing: '正在移除 {name}…', - updating: '正在更新 {name}…', - installing: '正在安装 {spec}…', - operationComplete: '操作完成,桌面后端已重新启动。', - refreshing: '正在刷新…', - refreshed: '插件列表已刷新。', - loadingPlugins: '正在读取桌面插件…', } as const satisfies DesktopMessages -/** Locale payload exposed to the Desktop-owned renderer. */ -export interface DesktopLocale { - readonly id: 'en' | 'zh-CN' - readonly messages: DesktopMessages -} - /** Resolve Electron's locale to one shipped Desktop dictionary. */ -export function resolveDesktopLocale(locale: string): DesktopLocale { +export function resolveDesktopLocale(locale: string): DesktopMessages { return locale.toLowerCase().startsWith('zh') - ? { id: 'zh-CN', messages: zh } - : { id: 'en', messages: en } + ? zh + : en } /** Replace named placeholders in one locale-owned message. */ diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4eb65c0521..98ff08e23c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,7 @@ /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */ -import { readFile, writeFile } from 'node:fs/promises' -import { extname, join, normalize, resolve, sep } from 'node:path' +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { app, @@ -13,15 +13,14 @@ import { protocol, session, shell, - type IpcMainInvokeEvent, type MenuItemConstructorOptions, } from 'electron' import { resolveDesktopPaths } from './paths.ts' -import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts' +import { DesktopProjectManager } from './project-manager.ts' import { DesktopHostProcess } from './host-process.ts' import { installDesktopDirectoryPicker } from './directory-picker.ts' import { DesktopBackendController } from './backend-controller.ts' -import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts' +import { DESKTOP_IPC, SCHEME, assertDesktopSender } from './ipc.ts' import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' @@ -32,7 +31,7 @@ let focusPrimaryWindow = (): void => {} let stopForRecovery = async (): Promise => {} let shuttingDown = false const recovery = new DesktopFatalRecovery({ - messages: () => resolveDesktopLocale(app.getLocale()).messages, + messages: () => resolveDesktopLocale(app.getLocale()), show: options => dialog.showMessageBox(options), stop: () => { shuttingDown = true; return stopForRecovery() }, disablePlugins: async () => { @@ -62,13 +61,6 @@ protocol.registerSchemesAsPrivileged([{ }, }]) -const MIME: Readonly> = { - '.css': 'text/css; charset=utf-8', - '.html': 'text/html; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.svg': 'image/svg+xml', -} - interface RuntimeResources { readonly nodeBin: string readonly node: string @@ -159,26 +151,6 @@ function createWindow(preload: string, show = false): BrowserWindow { return window } -async function serveShellAsset(request: Request): Promise { - if (request.method !== 'GET' && request.method !== 'HEAD') return new Response(null, { status: 405 }) - const root = resolve(app.getAppPath(), 'renderer') - const url = new URL(request.url) - let pathname: string - try { - pathname = decodeURIComponent(url.pathname) - } catch { - return new Response(null, { status: 400 }) - } - const target = resolve(normalize(join(root, pathname))) - if (target !== root && !target.startsWith(root + sep)) return new Response(null, { status: 403 }) - try { - const body = request.method === 'HEAD' ? null : await readFile(target) - return new Response(body, { headers: { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' } }) - } catch { - return new Response(null, { status: 404 }) - } -} - async function main(): Promise { const resources = runtimeResources() const paths = resolveDesktopPaths() @@ -188,13 +160,9 @@ async function main(): Promise { let quitting = false let startup: Promise | undefined let mainWindow: BrowserWindow | undefined - let pluginWindow: BrowserWindow | undefined let shellInstallerOwnsQuit = false - let updateState: DesktopUpdateState = { phase: 'idle' } - const locale = resolveDesktopLocale(app.getLocale()) - const messages = locale.messages + const messages = resolveDesktopLocale(app.getLocale()) const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url)) - const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url)) const applicationUrl = `${SCHEME}://app/` let hostUrl: string | undefined let hostCookie: string | undefined @@ -235,19 +203,6 @@ async function main(): Promise { if (state.phase === 'error') reportFatal(new Error(state.message)) }) - const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => { - updateState = state - for (const window of BrowserWindow.getAllWindows()) { - window.webContents.send(DESKTOP_IPC.updatesState, state) - } - return state - } - - const hooks: DesktopProjectHooks = { - beforeChange: () => backend.stop(), - afterChange: () => backend.start(async () => {}), - } - stopForRecovery = () => backend.close() const reconcileBackend = (): Promise => { @@ -265,7 +220,6 @@ async function main(): Promise { } const updates = new DesktopUpdateCoordinator( - publishUpdate, async () => { shellInstallerOwnsQuit = true await backend.stop() @@ -284,7 +238,6 @@ async function main(): Promise { } return forwardWebRequest(request, hostUrl, hostCookie) } - if (url.hostname === 'shell') return serveShellAsset(request) return Promise.resolve(new Response(null, { status: 404 })) }) @@ -319,60 +272,11 @@ async function main(): Promise { callback({ requestHeaders: { ...headers, origin: target.origin, cookie: hostCookie, 'sec-fetch-site': 'same-origin' } }) }) - const mutate = async (event: IpcMainInvokeEvent, mutation: Parameters[0]): Promise => { - assertDesktopSender(event, ['shell']) - await startup?.catch(() => undefined) - if (recovery.active) throw new Error(messages.fatalSummary) - try { - await manager.mutate(mutation, hooks) - } finally { - if (backend.state.phase === 'ready') { - navigation = undefined - await navigateMain(applicationUrl).catch(reportFatal) - } - } - } - - ipcMain.handle(DESKTOP_IPC.localeGet, (event) => { - assertDesktopSender(event, ['shell']) - return locale - }) // Only the main window may synchronize its palette with the native material. ipcMain.on(DESKTOP_IPC.nativeThemeSet, (event, source: unknown) => { if (mainWindow === undefined || event.sender !== mainWindow.webContents) return if (source === 'light' || source === 'dark' || source === 'system') nativeTheme.themeSource = source }) - ipcMain.handle(DESKTOP_IPC.pluginsList, (event) => { - assertDesktopSender(event, ['shell']) - return manager.listPlugins() - }) - ipcMain.handle(DESKTOP_IPC.pluginsAdd, (event, spec: unknown) => { - if (typeof spec !== 'string') throw new Error('dsh desktop: plugin spec must be a string') - return mutate(event, { type: 'plugin-add', spec }) - }) - ipcMain.handle(DESKTOP_IPC.pluginsRemove, (event, name: unknown) => { - if (typeof name !== 'string') throw new Error('dsh desktop: plugin name must be a string') - return mutate(event, { type: 'plugin-remove', name }) - }) - ipcMain.handle(DESKTOP_IPC.pluginsUpdate, (event, name: unknown, version: unknown) => { - if (typeof name !== 'string' || typeof version !== 'string') { - throw new Error('dsh desktop: plugin name and version must be strings') - } - return mutate(event, { type: 'plugin-update', name, version }) - }) - ipcMain.handle(DESKTOP_IPC.pluginsToggle, (event, name: unknown, enabled: unknown) => { - if (typeof name !== 'string' || typeof enabled !== 'boolean') throw new Error('dsh desktop: invalid plugin activation request') - return mutate(event, { type: 'plugin-toggle', name, enabled }) - }) - ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => { - assertDesktopSender(event, ['shell']) - return updates.check() - }) - ipcMain.handle(DESKTOP_IPC.updatesInstall, async (event) => { - assertDesktopSender(event, ['shell']) - await updates.install() - }) - const checkAndPrompt = async (manual: boolean): Promise => { const state = await updates.check() if (state.phase === 'error') { @@ -415,19 +319,6 @@ async function main(): Promise { } } - const openPluginWindow = (): void => { - if (pluginWindow !== undefined && !pluginWindow.isDestroyed()) { - pluginWindow.focus() - return - } - pluginWindow = createWindow(managementPreload) - pluginWindow.setSize(900, 620) - pluginWindow.setTitle(messages.pluginWindowTitle) - pluginWindow.once('ready-to-show', () => { pluginWindow?.show() }) - pluginWindow.once('closed', () => { pluginWindow = undefined }) - void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`) - } - // A custom application menu replaces Electron's default menu, so macOS needs // its standard menus and application hide commands declared explicitly. const darwin = process.platform === 'darwin' @@ -440,11 +331,6 @@ async function main(): Promise { Menu.setApplicationMenu(Menu.buildFromTemplate([{ label: darwin ? app.name : messages.application, submenu: [ - { - label: messages.pluginsMenu, - accelerator: 'CmdOrCtrl+,', - click: openPluginWindow, - }, { label: messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } }, { type: 'separator' }, ...hideCommands, @@ -507,7 +393,6 @@ async function main(): Promise { if (mainWindow !== undefined && development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { mainWindow.webContents.openDevTools({ mode: 'detach' }) } - publishUpdate(updateState) setTimeout(() => { void checkAndPrompt(false) }, 10_000) } diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts deleted file mode 100644 index c8e1eb1452..0000000000 --- a/apps/desktop/src/preload.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** Context-isolated renderer bridge for desktop package and update operations. */ - -import { contextBridge, ipcRenderer } from 'electron' -import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.ts' -import { markDocumentPlatform } from './preload-platform.ts' - -const api: DshDesktopApi = { - protocolVersion: 1, - locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as Promise extends Promise ? T : never>, - plugins: { - list: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsList) as Promise extends Promise ? T : never>, - add: spec => ipcRenderer.invoke(DESKTOP_IPC.pluginsAdd, spec) as Promise, - remove: name => ipcRenderer.invoke(DESKTOP_IPC.pluginsRemove, name) as Promise, - toggle: (name, enabled) => ipcRenderer.invoke(DESKTOP_IPC.pluginsToggle, name, enabled) as Promise, - update: (name, version) => ipcRenderer.invoke(DESKTOP_IPC.pluginsUpdate, name, version) as Promise, - }, - updates: { - check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise, - install: () => ipcRenderer.invoke(DESKTOP_IPC.updatesInstall) as Promise, - subscribe(listener) { - const handle = (_event: Electron.IpcRendererEvent, state: DesktopUpdateState): void => { listener(state) } - ipcRenderer.on(DESKTOP_IPC.updatesState, handle) - return () => { ipcRenderer.off(DESKTOP_IPC.updatesState, handle) } - }, - }, -} - -markDocumentPlatform() -contextBridge.exposeInMainWorld('dshDesktop', api) diff --git a/apps/desktop/src/project-manager.ts b/apps/desktop/src/project-manager.ts index 3bbb975430..2482ce67d4 100644 --- a/apps/desktop/src/project-manager.ts +++ b/apps/desktop/src/project-manager.ts @@ -1,10 +1,8 @@ -/** In-place owner of the desktop profile and its pnpm operations. */ +/** Desktop profile initialization and native recovery. */ -import { spawn } from 'node:child_process' import { existsSync, fsyncSync, - ftruncateSync, lstatSync, mkdirSync, openSync, @@ -15,7 +13,6 @@ import { writeSync, } from 'node:fs' import { join } from 'node:path' -import { desktopNodeEnvironment } from './node-environment.ts' import { DESKTOP_HOST_PACKAGE, desktopCorePackageOverrides, @@ -23,56 +20,18 @@ import { } from './core-package-set.ts' import type { DesktopPaths } from './paths.ts' import type { DesktopRelease } from './release.ts' -import { readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts' +import { readDesktopRuntime } from './runtime-tree.ts' import { - initProfile, PROFILE_TEMPLATES, readProfilePlugins, reconcileProfilePlugins, sanitizeProfile, - unlinkProfileModuleFallback, writeProfileBundles, type ProfileTemplate, + initProfile, PROFILE_TEMPLATES, sanitizeProfile, type ProfileTemplate, } from '@deepseek-ai/dsh-app-boot' import { migrateDesktopProfileLinks } from './profile-packages.ts' import { cleanProfileCorePackages } from './profile-core-cleanup.ts' -/** Desktop plugin record derived from the installed profile. */ -export interface DesktopPluginRecord { - readonly name: string - /** Installed version, or the dependency spec when installed metadata is unavailable. */ - readonly version: string - readonly enabled: boolean -} - -/** Exact executables the desktop shell bundles. */ -export interface DesktopRuntimeExecutables { - readonly node: string - readonly nodeBin?: string - readonly pnpm: string - readonly dsh: string -} - -/** Hooks that stop the backend before profile writes and restart it after each attempted change. */ -export interface DesktopProjectHooks { - /** Stop the active backend and await process exit before modifying its files. */ - beforeChange(): Promise - /** Start the current profile even when package preparation failed with partial writes. */ - afterChange(): Promise -} - -/** Supported dependency mutation. */ -export type DesktopProjectMutation = - | { readonly type: 'plugin-add'; readonly spec: string } - | { readonly type: 'plugin-remove'; readonly name: string } - | { readonly type: 'plugin-update'; readonly name: string; readonly version: string } - | { readonly type: 'plugin-toggle'; readonly name: string; readonly enabled: boolean } - const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime' const DSH_PACKAGE = '@deepseek-ai/dsh' const CORE_BUILD_PACKAGE = '@deepseek-ai/dsh-subprocess-local' const WEB_PROFILE = PROFILE_TEMPLATES.web as ProfileTemplate const WORKSPACE_SETTINGS = 'nodeLinker: hoisted\nautoInstallPeers: false\n' -const MAX_PNPM_DIAGNOSTIC_BYTES = 64 * 1024 - -function errorOf(reason: unknown, fallback: string): Error { - return reason instanceof Error ? reason : new Error(fallback) -} - function writeJson(path: string, value: unknown): void { writeFileSync(path, `${JSON.stringify(value, undefined, 2)}\n`, { mode: 0o600 }) } @@ -99,28 +58,17 @@ function migrateProfileSettings(projectDir: string): void { } } -/** Desktop npm project manager with direct writes and no rollback. */ +/** Initializes the Desktop profile and disables third-party bundles during recovery. */ export class DesktopProjectManager { - private lockDescriptor: number | undefined - private descriptor: DesktopRuntimeDescriptor | undefined - /** * @param paths - Electron-owned package state and reserved desktop profile paths. - * @param runtime - absolute Electron and pnpm entry paths. + * @param runtime - location of the bundled application runtime. */ constructor( readonly paths: DesktopPaths, - readonly runtime: DesktopRuntimeExecutables, + readonly runtime: { readonly dsh: string }, ) {} - /** Read the active desktop plugin inventory. */ - listPlugins(): readonly DesktopPluginRecord[] { - if (!existsSync(this.paths.profile)) return [] - return readProfilePlugins(this.pluginLocation(this.paths.profile)).dependencies - .map(({ name, version, enabled }) => ({ name, version, enabled })) - .sort((left, right) => left.name.localeCompare(right.name)) - } - /** * Back up the profile patch and disable third-party bundles without loading application resources. * The caller must stop the Host first. @@ -130,145 +78,20 @@ export class DesktopProjectManager { return this.withLock(() => sanitizeProfile('dsh', this.paths.profile, WEB_PROFILE.bundles)) } - /** Read the dsh version supplied by this application's verified resources. */ - dshVersion(): string { - return this.currentRuntime().release.version - } - - private currentRuntime(): DesktopRuntimeDescriptor { - if (this.descriptor === undefined) throw new Error('desktop project: runtime metadata has not been loaded') - return this.descriptor - } - - private readRuntime(): DesktopRuntimeDescriptor { - this.descriptor = undefined - return readDesktopRuntime(this.runtime.dsh) - } - - private pluginLocation(profileDir: string) { - return { binName: 'dsh', profileDir, installAnchor: join(this.runtime.dsh, 'node_modules', DSH_PACKAGE, 'package.json') } - } - /** * Load application metadata and prepare the external plugin profile without installing packages. * @param production - Remove application-owned profile packages before packaged Host startup. */ async applyRelease(production = false): Promise { await this.withLock(() => { - this.descriptor = this.readRuntime() - cleanProfileCorePackages(this.paths.profile, this.descriptor.sharedPackages.map(entry => entry.name), production) + const descriptor = readDesktopRuntime(this.runtime.dsh) + cleanProfileCorePackages(this.paths.profile, descriptor.sharedPackages.map(entry => entry.name), production) migrateProfileSettings(this.paths.profile) migrateDesktopProfileLinks(this.paths.profile) createPluginProfile(this.paths.profile) }) } - /** Modify the current profile while its backend is stopped; failures retain partial changes. */ - async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise { - await this.withLock(async () => { - this.currentRuntime() - if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') - await hooks.beforeChange() - try { - await this.applyMutation(this.paths.profile, mutation) - } catch (error) { - try { await hooks.afterChange() } catch (restartError) { - throw new AggregateError([error, restartError], 'Desktop package operation and backend restart failed') - } - throw error - } - await hooks.afterChange() - }) - } - - private async applyMutation(projectDir: string, mutation: DesktopProjectMutation): Promise { - const location = this.pluginLocation(projectDir) - const before = readProfilePlugins(location) - const bundles = before.manifest.dsh?.profile?.bundles ?? [] - switch (mutation.type) { - case 'plugin-add': - case 'plugin-update': { - const spec = mutation.type === 'plugin-add' ? mutation.spec : `${mutation.name}@${mutation.version}` - unlinkProfileModuleFallback(projectDir) - await this.runPnpm(projectDir, ['add', '--', spec]) - reconcileProfilePlugins({ ...location, before, preserveDisabled: true }) - return - } - case 'plugin-remove': - unlinkProfileModuleFallback(projectDir) - await this.runPnpm(projectDir, ['remove', '--', mutation.name]) - reconcileProfilePlugins({ ...location, before, preserveDisabled: true }) - return - case 'plugin-toggle': - writeProfileBundles(projectDir, before.manifest, mutation.enabled - ? bundles.includes(mutation.name) ? bundles : [...bundles, mutation.name] - : bundles.filter(name => name !== mutation.name)) - return - default: - mutation satisfies never - } - } - - private async runPnpm(projectDir: string, args: readonly string[]): Promise { - await new Promise((settle, reject) => { - const child = spawn(this.runtime.node, ['--expose-internals', this.runtime.pnpm, ...args], { - cwd: projectDir, - env: desktopNodeEnvironment(this.runtime.node, this.runtime.nodeBin, process.env), - stdio: ['ignore', 'pipe', 'pipe'], - }) - let failure: Error | undefined - let diagnostics = '' - let completed = false - const appendDiagnostics = (chunk: string): void => { - diagnostics = (diagnostics + chunk).slice(-MAX_PNPM_DIAGNOSTIC_BYTES) - } - child.stdout.setEncoding('utf8') - child.stdout.on('data', appendDiagnostics) - child.stderr.setEncoding('utf8') - child.stderr.on('data', appendDiagnostics) - const complete = (settleChild: () => void): void => { - if (completed) return - completed = true - try { - this.writeLockOwner(process.pid) - } catch (error) { - reject(errorOf(error, 'desktop project: failed to return the package transaction lock to Electron')) - return - } - settleChild() - } - child.once('error', (error) => { failure = error }) - child.once('close', (code, signal) => { - complete(() => { - if (failure !== undefined) { reject(failure); return } - if (code === 0) { - settle() - return - } - reject(new Error( - `desktop project: pnpm exited with ${String(code ?? signal)}${diagnostics.trim() === '' ? '' : `: ${diagnostics.trim()}`}`, - )) - }) - }) - try { - if (child.pid === undefined) throw new Error('desktop project: pnpm did not report a process id') - this.writeLockOwner(child.pid) - } catch (error) { - failure = errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm') - child.kill('SIGKILL') - } - }) - } - - private writeLockOwner(pid: number): void { - const descriptor = this.lockDescriptor - if (descriptor === undefined) throw new Error('desktop project: package transaction lost its lock') - const content = Buffer.from(`${String(pid)}\n`) - ftruncateSync(descriptor, 0) - writeSync(descriptor, content, 0, content.byteLength, 0) - fsyncSync(descriptor) - } - private async withLock(operation: () => T | Promise): Promise { mkdirSync(this.paths.profile, { recursive: true, mode: 0o700 }) let descriptor: number @@ -298,11 +121,10 @@ export class DesktopProjectManager { } } try { - this.lockDescriptor = descriptor - this.writeLockOwner(process.pid) + writeSync(descriptor, `${String(process.pid)}\n`) + fsyncSync(descriptor) return await operation() } finally { - this.lockDescriptor = undefined closeSync(descriptor) unlinkSync(this.paths.lock) } diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index be629228d8..1a6af1b68f 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -4,9 +4,15 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' import { app } from 'electron' import electronUpdater, { type AppUpdater } from 'electron-updater' -import type { DesktopUpdateState } from './ipc.ts' const { autoUpdater } = electronUpdater +/** Result of checking or installing a Desktop release. */ +export interface DesktopUpdateState { + readonly phase: 'idle' | 'available' | 'ready' | 'error' + readonly version?: string + readonly message?: string +} + /** Checks, downloads, and installs one complete Desktop release. */ export class DesktopUpdateCoordinator { private availableVersion: string | undefined @@ -14,13 +20,11 @@ export class DesktopUpdateCoordinator { private installOperation: Promise | undefined /** - * @param publish - state sink for every desktop window. * @param beforeRestart - stop application-owned processes before replacement. * @param updater - Electron artifact updater; replaceable for tests. * @param enabled - whether this packaged process carries updater configuration. */ constructor( - private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState, private readonly beforeRestart: () => Promise = async () => {}, private readonly updater: AppUpdater = autoUpdater, private readonly enabled: () => boolean = () => ( @@ -50,24 +54,23 @@ export class DesktopUpdateCoordinator { } private async doCheck(): Promise { - this.publish({ phase: 'checking' }) try { if (!this.enabled()) { this.availableVersion = undefined - return this.publish({ phase: 'idle' }) + return { phase: 'idle' } } const result = await this.updater.checkForUpdates() const version = result?.isUpdateAvailable === true ? result.updateInfo.version : undefined this.availableVersion = version return version === undefined - ? this.publish({ phase: 'idle' }) - : this.publish({ phase: 'available', version }) + ? { phase: 'idle' } + : { phase: 'available', version } } catch (error) { this.availableVersion = undefined - return this.publish({ + return { phase: 'error', message: error instanceof Error ? error.message : String(error), - }) + } } } @@ -76,20 +79,18 @@ export class DesktopUpdateCoordinator { if (version === undefined) { throw new Error('desktop update: no verified update is available') } - this.publish({ phase: 'installing', version }) try { await this.updater.downloadUpdate() this.availableVersion = undefined - const ready = this.publish({ phase: 'ready', version }) await this.beforeRestart() this.updater.quitAndInstall(false, true) - return ready + return { phase: 'ready', version } } catch (error) { - return this.publish({ + return { phase: 'error', version, message: error instanceof Error ? error.message : String(error), - }) + } } } } diff --git a/apps/desktop/tests/development-project.spec.ts b/apps/desktop/tests/development-project.spec.ts index a68bcf6af5..0fe54ab1a3 100644 --- a/apps/desktop/tests/development-project.spec.ts +++ b/apps/desktop/tests/development-project.spec.ts @@ -6,7 +6,6 @@ import { prepareDevelopmentProject } from '../scripts/development-project.ts' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' import { DesktopProjectManager } from '../src/project-manager.ts' import { resolveDesktopPaths } from '../src/paths.ts' -import { writePackage } from './runtime-fixture.ts' import type { DesktopRelease } from '../src/release.ts' const roots: string[] = [] @@ -69,16 +68,9 @@ describe('desktop development project', () => { expect(manifest.dependencies['@deepseek-ai/dsh']).toBe('1.2.3') expect(manifest.dependencies['@deepseek-ai/dsh-desktop-host']).toBe('1.2.3') const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, 'home')), { - node: process.execPath, pnpm: join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs'), dsh: project, + dsh: project, }) - const hooks = { beforeChange: async () => {}, afterChange: async () => {} } await manager.applyRelease() - const local = writePackage(join(root, 'packages'), 'development-plugin', { dsh: { bundle: { patch: 'bundle.yml' } } }) - writeFileSync(join(local, 'bundle.yml'), '[]\n') - await manager.mutate({ type: 'plugin-add', spec: `file:${local}` }, hooks) - expect(manager.listPlugins()).toEqual([{ name: 'development-plugin', version: '1.0.0', enabled: true }]) - await manager.mutate({ type: 'plugin-remove', name: 'development-plugin' }, hooks) - expect(manager.listPlugins()).toEqual([]) await manager.disableAllPlugins() expect(readFileSync(join(cli, 'package.json'), 'utf8')).toBe('{"name":"@deepseek-ai/dsh","version":"1.2.3"}\n') expect(readFileSync(join(host, 'lib', 'index.js'), 'utf8')).toBe('') diff --git a/apps/desktop/tests/fatal-recovery.spec.ts b/apps/desktop/tests/fatal-recovery.spec.ts index adc1954f44..e52e7a58fa 100644 --- a/apps/desktop/tests/fatal-recovery.spec.ts +++ b/apps/desktop/tests/fatal-recovery.spec.ts @@ -7,7 +7,7 @@ function fixture(locale = 'en') { const choice = Promise.withResolvers<{ response: number; checkboxChecked: boolean }>() const stopped = Promise.withResolvers() const operations = { - messages: () => resolveDesktopLocale(locale).messages, + messages: () => resolveDesktopLocale(locale), show: vi.fn((_options: MessageBoxOptions) => choice.promise), stop: vi.fn(() => stopped.promise), disablePlugins: vi.fn(async () => {}), diff --git a/apps/desktop/tests/installer-packaging.spec.ts b/apps/desktop/tests/installer-packaging.spec.ts index 94e3ae1fa0..fb9de826fe 100644 --- a/apps/desktop/tests/installer-packaging.spec.ts +++ b/apps/desktop/tests/installer-packaging.spec.ts @@ -27,6 +27,9 @@ describe('installer preparation preserves application dependencies', () => { try { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(env, platform, 'x64') + expect(config.files.filter(file => typeof file === 'string')).toEqual([ + 'lib/main.js', 'lib/preload-app.cjs', 'package.json', + ]) const packager = new Packager({ projectDir: tmpdir() }) // A foreign source-build target avoids rebuilding modules; the real dependency ownership decision still runs. Object.defineProperties(packager, { diff --git a/apps/desktop/tests/locale.spec.ts b/apps/desktop/tests/locale.spec.ts index de7a7a7048..1bf6cf0bc8 100644 --- a/apps/desktop/tests/locale.spec.ts +++ b/apps/desktop/tests/locale.spec.ts @@ -1,13 +1,12 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { en, formatDesktopMessage, resolveDesktopLocale, zh } from '../src/locale.ts' describe('desktop locale dictionaries', () => { it('ships the same key set in English and Chinese', () => { expect(Object.keys(zh)).toEqual(Object.keys(en)) - expect(resolveDesktopLocale('zh-Hans-CN')).toEqual({ id: 'zh-CN', messages: zh }) - expect(resolveDesktopLocale('en-US')).toEqual({ id: 'en', messages: en }) - expect(resolveDesktopLocale('fr-FR')).toEqual({ id: 'en', messages: en }) + expect(resolveDesktopLocale('zh-Hans-CN')).toEqual(zh) + expect(resolveDesktopLocale('en-US')).toEqual(en) + expect(resolveDesktopLocale('fr-FR')).toEqual(en) }) it('formats named values without consuming unknown placeholders', () => { @@ -15,9 +14,4 @@ describe('desktop locale dictionaries', () => { .toBe('plugin@1.2.3 {missing}') }) - it('keeps visible plugin-manager HTML copy in the locale dictionaries', () => { - const html = readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8') - const staticText = [...html.matchAll(/>([^<]*\p{L}[^<]*) match[1]?.trim()) - expect(staticText).toEqual([]) - }) }) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 4856b0a5cb..7b4839d568 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -89,6 +89,7 @@ const harness = await vi.hoisted(async () => { windows, hosts, handlers, app, FakeWindow, FakeHost, failWindow(error: Error) { windowFailure = error }, popup, + protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Promise) => void>(), socketHeaders: vi.fn(), menu: { setApplicationMenu: vi.fn(), @@ -101,7 +102,6 @@ const harness = await vi.hoisted(async () => { }, openExternal: vi.fn(), applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), - mutateFailure: vi.fn<() => void>(), disableAllPlugins: vi.fn(async () => { pluginsEnabled = false return 'desktop-test-profile/cordis.patch.yml.bak-1789555200000' @@ -110,7 +110,6 @@ const harness = await vi.hoisted(async () => { get hostStarted() { return hostStarted }, get navigated() { return navigated }, get dialogShown() { return dialogShown }, get quitCompleted() { return quitCompleted }, nextNavigation() { navigated = deferred(); return navigated.promise }, - nextHostStart() { hostStarted = deferred(); return hostStarted.promise }, get pluginsEnabled() { return pluginsEnabled }, set pluginsEnabled(value: boolean) { pluginsEnabled = value }, reset() { @@ -136,26 +135,13 @@ vi.mock('electron', () => ({ }, Menu: harness.menu, session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } }, - protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() }, + protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle }, })) vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) })) vi.mock('../src/project-manager.ts', () => ({ DesktopProjectManager: class { readonly applyRelease = harness.applyRelease disableAllPlugins = harness.disableAllPlugins - async mutate(_mutation: unknown, hooks: { beforeChange(): Promise; afterChange(): Promise }) { - await hooks.beforeChange() - try { - harness.mutateFailure() - harness.pluginsEnabled = false - } catch (error) { - try { await hooks.afterChange() } catch (restartError) { - throw new AggregateError([error, restartError], 'Desktop package operation and backend restart failed') - } - throw error - } - await hooks.afterChange() - } }, })) @@ -165,7 +151,7 @@ vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: vi.fn function invoke(channel: string, ...args: unknown[]): unknown { const handler = harness.handlers.get(channel) if (handler === undefined) throw new Error(`missing handler ${channel}`) - return handler({ senderFrame: { url: channel === DESKTOP_IPC.boot ? 'dsh-app://app/' : 'dsh-app://shell/plugin-manager.html' } }, ...args) + return handler({ senderFrame: { url: 'dsh-app://app/' } }, ...args) } beforeEach(() => { @@ -195,6 +181,16 @@ afterEach(async () => { }) describe('desktop main startup', () => { + it('exposes application IPC and rejects the removed shell document', async () => { + await import('../src/main.ts') + await harness.preparing.promise + expect([...harness.handlers.keys()].sort()).toEqual([ + DESKTOP_IPC.boot, DESKTOP_IPC.bootFailed, DESKTOP_IPC.directoryPick, + ].sort()) + const handler = harness.protocolHandle.mock.calls[0]![1] + expect((await handler(new Request('dsh-app://shell/plugin-manager.html'))).status).toBe(404) + }) + it.each(['darwin', 'win32', 'linux'] as const)('limits native titlebar styling to macOS on %s', async (platform) => { vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) await import('../src/main.ts') @@ -225,8 +221,8 @@ describe('desktop main startup', () => { : ['Application', 'editMenu']) const application = template[0]!.submenu as MenuItemConstructorOptions[] expect(application.map(describeItem)).toEqual(platform === 'darwin' - ? [en.pluginsMenu, en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit'] - : [en.pluginsMenu, en.checkUpdatesMenu, 'separator', 'quit']) + ? [en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit'] + : [en.checkUpdatesMenu, 'separator', 'quit']) expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce() }) @@ -375,46 +371,6 @@ describe('desktop main startup', () => { expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('Desktop page failed to load') }) - it('returns package-operation failures locally without opening fatal recovery', async () => { - await import('../src/main.ts') - await harness.preparing.promise - harness.prepared.resolve() - await harness.hostStarted.promise - const host = harness.hosts[0]! - host.ready.resolve() - await Promise.resolve(invoke(DESKTOP_IPC.boot)) - harness.mutateFailure.mockImplementationOnce(() => { throw new Error('package write failed') }) - host.exited.resolve() - const nextStarted = harness.nextHostStart() - const failure = expect(invoke(DESKTOP_IPC.pluginsAdd, 'example-plugin')).rejects.toThrow('package write failed') - await nextStarted - harness.hosts[1]!.ready.resolve() - await failure - expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() - expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/', 'dsh-app://app/']) - }) - - it('opens fatal recovery when the Host cannot restart after a package change', async () => { - await import('../src/main.ts') - await harness.preparing.promise - harness.prepared.resolve() - await harness.hostStarted.promise - const host = harness.hosts[0]! - host.ready.resolve() - await Promise.resolve(invoke(DESKTOP_IPC.boot)) - host.exited.resolve() - const nextStarted = harness.nextHostStart() - const failure = expect(invoke(DESKTOP_IPC.pluginsAdd, 'example-plugin')).rejects.toThrow('new Host failed') - await nextStarted - const replacement = harness.hosts[1]! - replacement.exited.resolve() - replacement.ready.reject(new Error('new Host failed')) - await failure - expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce() - expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('new Host failed') - expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/']) - }) - it('offers all recovery choices when resources fail before the Host starts', async () => { await import('../src/main.ts') await harness.preparing.promise @@ -545,7 +501,6 @@ describe('desktop main startup', () => { expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/']) expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce() expect(harness.handlers.has('dsh-desktop:backend-retry')).toBe(false) - await expect(invoke(DESKTOP_IPC.pluginsAdd, 'example-plugin')).rejects.toThrow('The application could not start or stopped unexpectedly.') expect(harness.hosts).toHaveLength(1) }) diff --git a/apps/desktop/tests/plugin-manager.spec.ts b/apps/desktop/tests/plugin-manager.spec.ts deleted file mode 100644 index e4a86ab397..0000000000 --- a/apps/desktop/tests/plugin-manager.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { readFileSync } from 'node:fs' -import { runInContext } from 'node:vm' -import { JSDOM } from 'jsdom' -import { expect, it, vi } from 'vitest' -import { resolveDesktopLocale } from '../src/locale.ts' - -it('keeps disabled packages visible without an in-page recovery panel', async () => { - const dom = new JSDOM(readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' }) - let enabled = false - const toggle = vi.fn(async (_name: string, active: boolean) => { enabled = active }) - const api = { - locale: async () => resolveDesktopLocale('en'), - plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled }], toggle }, - } - Object.defineProperty(dom.window, 'dshDesktop', { value: api }) - try { - runInContext(readFileSync(new URL('../renderer/plugin-manager.js', import.meta.url), 'utf8'), dom.getInternalVMContext()) - const document = dom.window.document - await expect.poll(() => document.querySelector('#plugins li')?.textContent).toContain('example-plugin') - expect(document.querySelector('#recovery')).toBeNull() - expect(document.querySelector('#plugins li')?.textContent).toMatchInlineSnapshot('"example-plugin1.0.0 · DisabledEnableUpdateRemove"') - document.querySelector('#plugins li button')?.click() - await expect.poll(() => toggle.mock.calls).toEqual([['example-plugin', true]]) - await expect.poll(() => document.querySelector('#plugins li')?.textContent).toBe('example-plugin1.0.0DisableUpdateRemove') - } finally { dom.window.close() } -}) - -it.each(['en', 'zh-CN'])('updates through the inline form, including the installed version (%s)', async (language) => { - const dom = new JSDOM(readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' }) - const locale = resolveDesktopLocale(language) - const update = vi.fn(async () => {}) - Object.defineProperty(dom.window, 'dshDesktop', { value: { - locale: async () => locale, - plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled: true }], update }, - } }) - try { - runInContext(readFileSync(new URL('../renderer/plugin-manager.js', import.meta.url), 'utf8'), dom.getInternalVMContext()) - const document = dom.window.document - await expect.poll(() => document.querySelector('#plugins li')?.textContent).toContain('example-plugin') - const updateButton = () => document.querySelectorAll('#plugins li button')[1]! - const form = document.querySelector('#update-form')! - const version = document.querySelector('#update-version')! - updateButton().click() - expect(form.hidden).toBe(false) - expect(version.value).toBe('1.0.0') - expect(document.activeElement).toBe(version) - expect(document.querySelector('#update-label')?.textContent).toBe(locale.messages.targetVersion.replace('{name}', 'example-plugin')) - expect(document.querySelector('#update-cancel')?.textContent).toBe(locale.messages.cancel) - if (language === 'en') { - expect({ label: document.querySelector('#update-label')?.textContent, - actions: [...form.querySelectorAll('button')].map(button => button.textContent) }).toMatchInlineSnapshot(` - { - "actions": [ - "Update", - "Cancel", - ], - "label": "Enter the target version for example-plugin", - } - `) - } - document.querySelector('#update-cancel')!.click() - expect(form.hidden).toBe(true) - expect(document.activeElement).toBe(updateButton()) - expect(update).not.toHaveBeenCalled() - for (const target of ['1.0.0', '^2.0.0']) { - updateButton().click() - version.value = target - form.requestSubmit() - await expect.poll(() => document.querySelector('#status')?.textContent).toBe(locale.messages.operationComplete) - expect(update).toHaveBeenLastCalledWith('example-plugin', target) - expect(form.hidden).toBe(true) - } - expect(update).toHaveBeenCalledTimes(2) - } finally { dom.window.close() } -}) diff --git a/apps/desktop/tests/plugin-pnpm.spec.ts b/apps/desktop/tests/plugin-pnpm.spec.ts deleted file mode 100644 index 9c279e41c4..0000000000 --- a/apps/desktop/tests/plugin-pnpm.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { createHash } from 'node:crypto' -import { execFileSync } from 'node:child_process' -import { createServer } from 'node:http' -import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { c } from 'tar' -import { expect, it } from 'vitest' -import { healIsolatedProfileModuleFallback, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot' -import { DesktopProjectManager, type DesktopProjectHooks } from '../src/project-manager.ts' -import { resolveDesktopPaths } from '../src/paths.ts' -import { runtimeFixture, writePackage } from './runtime-fixture.ts' - -it('installs a real pnpm graph and executes scripts approved by user configuration', async () => { - // pnpm resolves its cwd natively; use the same spelling for Windows 8.3 temp paths. - const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'desktop-real-pnpm-'))) - const server = createServer() - const archives = new Map() - try { - for (const name of ['fixture-plugin', 'fixture-script-dependency']) { - const path = writePackage(join(root, 'packages'), name, name === 'fixture-plugin' - ? { dependencies: { 'fixture-script-dependency': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, dsh: { bundle: { patch: 'bundle.yml' } } } - : { scripts: { install: 'node install.cjs' } }, 'export {identity} from "@deepseek-ai/cordis"') - writeFileSync(join(path, 'bundle.yml'), '[]\n') - writeFileSync(join(path, 'install.cjs'), 'require("node:fs").writeFileSync("built.json", JSON.stringify({node:process.execPath}))') - const tarball = join(root, `${name}.tgz`) - await c({ file: tarball, cwd: join(path, '..'), gzip: true }, [name]) - archives.set(name, readFileSync(tarball)) - } - await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) - const address = server.address() - if (address === null || typeof address === 'string') throw new Error('fixture registry has no TCP address') - const origin = `http://127.0.0.1:${address.port}` - server.on('request', (request, response) => { - const name = request.url?.slice(1).replace(/\.tgz$/u, '') ?? '' - const archive = archives.get(name) - if (archive === undefined) { response.writeHead(404); response.end(); return } - if (request.url?.endsWith('.tgz')) { response.end(archive); return } - response.setHeader('content-type', 'application/json') - response.end(JSON.stringify({ name, 'dist-tags': { latest: '1.0.0' }, versions: { '1.0.0': { - name, version: '1.0.0', dist: { tarball: `${origin}/${name}.tgz`, integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}` }, - ...(name === 'fixture-plugin' ? { dependencies: { 'fixture-script-dependency': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' } } : { scripts: { install: 'node install.cjs' } }), - } }, time: { '1.0.0': '2020-01-01T00:00:00.000Z' } })) - }) - const dsh = join(root, 'dsh') - runtimeFixture(dsh) - const pnpm = join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs') - const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm, dsh }) - const installAnchor = join(dsh, 'node_modules', '@deepseek-ai', 'dsh', 'package.json') - const hooks: DesktopProjectHooks = { - beforeChange: async () => {}, - afterChange: async () => { - const profile = loadProfileDirectory('desktop test', manager.paths.profile, installAnchor) - healIsolatedProfileModuleFallback({ installAnchor, profile }) - }, - } - await manager.applyRelease() - writeFileSync(join(manager.paths.profile, '.npmrc'), `registry=${origin}\n`) - writeFileSync(join(manager.paths.profile, 'pnpm-workspace.yaml'), `packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\nstoreDir: ${JSON.stringify(join(root, 'store'))}\nallowBuilds:\n fixture-script-dependency: true\n`) - await manager.mutate({ type: 'plugin-add', spec: 'fixture-plugin@1.0.0' }, hooks) - expect(manager.listPlugins()).toEqual([{ name: 'fixture-plugin', version: '1.0.0', enabled: true }]) - const builtPath = join(manager.paths.profile, 'node_modules/fixture-script-dependency/built.json') - if (!existsSync(builtPath)) { - const config = (name: string): string => execFileSync(process.execPath, [pnpm, 'config', 'get', name], { - cwd: manager.paths.profile, encoding: 'utf8', - }).trim() - throw new Error(`pnpm install script produced no output: ignoreScripts=${config('ignoreScripts')}, allowBuilds=${config('allowBuilds')}`) - } - const built = JSON.parse(readFileSync(builtPath, 'utf8')) as { node: string } - expect(realpathSync(built.node)).toBe(realpathSync(process.execPath)) - const entry = join(dsh, 'identity.mjs') - writeFileSync(entry, `import {identity} from '@deepseek-ai/cordis'; import {identity as plugin} from ${JSON.stringify(pathToFileURL(join(manager.paths.profile, 'node_modules/fixture-plugin/index.js')).href)}; console.log(identity === plugin)`) - expect(execFileSync(process.execPath, [entry], { encoding: 'utf8' }).trim()).toBe('true') - await manager.mutate({ type: 'plugin-update', name: 'fixture-plugin', version: '^1.0.0' }, hooks) - expect(manager.listPlugins()[0]?.version).toBe('1.0.0') - await manager.mutate({ type: 'plugin-remove', name: 'fixture-plugin' }, hooks) - expect(manager.listPlugins()).toEqual([]) - } finally { - server.closeAllConnections() - if (server.listening) await new Promise((resolve, reject) => { - server.close((error) => { - if (error !== undefined) reject(error) - else resolve() - }) - }) - rmSync(root, { recursive: true, force: true }) - } -}, 30_000) - -it.each(['directory', 'file', 'tarball', 'plain'] as const)('installs a %s source through pnpm', async (source) => { - const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'desktop-local-pnpm-'))) - try { - const dsh = join(root, 'dsh') - runtimeFixture(dsh) - const packageDir = writePackage(join(root, 'local packages'), 'local-plugin', { - ...(source === 'plain' ? {} : { dsh: { bundle: { patch: 'missing.yml' } } }), - peerDependencies: { '@deepseek-ai/cordis': '^999.0.0' }, - }) - const tarball = join(root, 'local-plugin.tgz') - await c({ file: tarball, cwd: join(packageDir, '..'), gzip: true }, ['local-plugin']) - const spec = source === 'directory' ? packageDir : source === 'file' ? `file:${packageDir}` : tarball - const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { - node: process.execPath, pnpm: join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs'), dsh, - }) - const hooks: DesktopProjectHooks = { beforeChange: async () => {}, afterChange: async () => {} } - await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec }, hooks) - expect(manager.listPlugins()).toEqual([{ name: 'local-plugin', version: '1.0.0', enabled: source !== 'plain' }]) - expect(existsSync(join(manager.paths.profile, 'node_modules/local-plugin/missing.yml'))).toBe(false) - await manager.disableAllPlugins() - expect(manager.listPlugins()[0]?.enabled).toBe(false) - await manager.mutate({ type: 'plugin-remove', name: 'local-plugin' }, hooks) - expect(manager.listPlugins()).toEqual([]) - expect(existsSync(join(packageDir, 'package.json'))).toBe(true) - } finally { - rmSync(root, { recursive: true, force: true }) - } -}, 30_000) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index a3c42d38e1..a3dfb59810 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -11,7 +11,7 @@ vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() }) -it.each(['dsh-app://app/index.html', 'dsh-app://shell/plugin-manager.html'])('exposes the carrier marker to %s', async (url) => { +it.each(['dsh-app://app/index.html', 'dsh-app://unowned/index.html'])('exposes the carrier marker to %s', async (url) => { vi.stubGlobal('location', new URL(url)) await import('../src/preload-app.ts') expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 }) @@ -39,7 +39,7 @@ it('exposes a directory picker only to the local application document', async () electron.ipcRenderer.invoke.mockResolvedValue('/workspace') await expect(api.pick()).resolves.toBe('/workspace') expect(electron.ipcRenderer.invoke).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.directoryPick) - for (const url of ['dsh-app://shell/startup.html', 'https://example.com/']) { + for (const url of ['dsh-app://unowned/index.html', 'https://example.com/']) { vi.resetModules() electron.contextBridge.exposeInMainWorld.mockClear() vi.stubGlobal('location', new URL(url)) diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts index 05a1a615c5..7e750a3fb1 100644 --- a/apps/desktop/tests/project-manager.spec.ts +++ b/apps/desktop/tests/project-manager.spec.ts @@ -1,73 +1,45 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { resolveDesktopPaths } from '../src/paths.ts' -import { DesktopProjectManager, type DesktopProjectHooks } from '../src/project-manager.ts' +import { DesktopProjectManager } from '../src/project-manager.ts' +import { readProfilePlugins } from '@deepseek-ai/dsh-app-boot' import { runtimeFixture } from './runtime-fixture.ts' const roots: string[] = [] -const releaseWorkers: Array<() => Promise> = [] function temporaryRoot(): string { const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-test-')) roots.push(root) return root } -function writeFakePnpm(root: string): string { - const path = join(root, 'pnpm.mjs') - writeFileSync(path, ` -import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -const args = process.argv.slice(2) -const project = process.cwd() -const command = args.find(value => ['install', 'add', 'remove', 'rebuild'].includes(value)) -appendFileSync(${JSON.stringify(join(root, 'pnpm-log.jsonl'))}, JSON.stringify({args, registry: process.env.NPM_CONFIG_REGISTRY, userconfig: process.env.NPM_CONFIG_USERCONFIG, pnpmHome: process.env.PNPM_HOME, configHome: process.env.XDG_CONFIG_HOME}) + '\\n') -if (command !== 'rebuild') { - const manifestPath = join(project, 'package.json') - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) - if (command === 'add') { - const spec = args.includes('--') ? args[args.indexOf('--') + 1] : args[args.indexOf(command) + 1] - const index = spec.lastIndexOf('@') - const name = index > 0 ? spec.slice(0, index) : spec - manifest.dependencies[name] = index > 0 ? spec.slice(index + 1) : '1.0.0' +function seedPlugin(manager: DesktopProjectManager): void { + const path = join(manager.paths.profile, 'package.json') + const manifest = JSON.parse(readFileSync(path, 'utf8')) as { + dependencies: Record + dsh: { profile: { bundles: string[] } } } - if (command === 'remove') delete manifest.dependencies[args[args.indexOf('--') + 1]] - writeFileSync(manifestPath, JSON.stringify(manifest)) - rmSync(join(project, 'node_modules'), { recursive: true, force: true }) - for (const [name, version] of Object.entries(manifest.dependencies)) { - const packageRoot = join(project, 'node_modules', name) - mkdirSync(packageRoot, { recursive: true }) - writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({name, version, - peerDependencies: {'@deepseek-ai/cordis': '^1.0.0'}, dsh: {bundle: {patch: './bundle.yml'}}})) - writeFileSync(join(packageRoot, 'bundle.yml'), '[]\\n') - } - writeFileSync(join(project, 'pnpm-lock.yaml'), JSON.stringify(manifest.dependencies)) + manifest.dependencies.plugin = '1.0.0' + manifest.dsh.profile.bundles.push('plugin') + writeFileSync(path, JSON.stringify(manifest)) + const directory = join(manager.paths.profile, 'node_modules/plugin') + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: 'plugin', version: '1.0.0', dsh: { bundle: { patch: 'bundle.yml' } } })) + writeFileSync(join(directory, 'bundle.yml'), '[]\n') } -`) - return path -} -function hooks(overrides: Partial = {}): DesktopProjectHooks { - return { beforeChange: async () => {}, afterChange: async () => {}, ...overrides } +function plugins(manager: DesktopProjectManager) { + return readProfilePlugins({ binName: 'dsh', profileDir: manager.paths.profile, + installAnchor: join(manager.runtime.dsh, 'node_modules/@deepseek-ai/dsh/package.json') }).dependencies + .map(({ name, version, enabled }) => ({ name, version, enabled })) } function setup(): { root: string; manager: DesktopProjectManager } { const root = temporaryRoot() const dsh = join(root, 'resources', 'dsh') runtimeFixture(dsh) - return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm: writeFakePnpm(root), dsh }) } + return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { dsh }) } } -function calls(root: string): { args: string[]; registry: string }[] { - const path = join(root, 'pnpm-log.jsonl') - return existsSync(path) ? readFileSync(path, 'utf8').trim().split('\n').map(line => JSON.parse(line) as { args: string[]; registry: string }) : [] -} -afterEach(async () => { - const cleanups = releaseWorkers.splice(0) - const directories = roots.splice(0) - const results = await Promise.allSettled(cleanups.map(cleanup => cleanup())) - for (const root of directories) rmSync(root, { recursive: true, force: true }) - vi.unstubAllEnvs() - const failures: unknown[] = results.flatMap((result): unknown[] => result.status === 'rejected' ? [result.reason] : []) - if (failures.length > 0) throw new AggregateError(failures, 'desktop worker cleanup failed') +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) describe('desktop external plugin profile', () => { @@ -85,7 +57,7 @@ describe('desktop external plugin profile', () => { it('reuses plugin files without scanning manifests and can disable them', async () => { const { manager } = setup() await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + seedPlugin(manager) const manifest = join(manager.paths.profile, 'node_modules/plugin/package.json') writeFileSync(manifest, '{broken') await expect(manager.applyRelease()).resolves.toBeUndefined() @@ -95,9 +67,9 @@ describe('desktop external plugin profile', () => { }) it('disables every third-party bundle without reading a broken plugin patch declaration', async () => { - const { root, manager } = setup() + const { manager } = setup() await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + seedPlugin(manager) const patch = join(manager.paths.profile, 'node_modules/plugin/bundle.yml') unlinkSync(patch) await manager.disableAllPlugins() @@ -105,14 +77,13 @@ describe('desktop external plugin profile', () => { dsh: { profile: { bundles: string[] } } }).dsh.profile.bundles).not.toContain('plugin') expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true) - expect(calls(root)).toHaveLength(1) await expect(manager.applyRelease()).resolves.toBeUndefined() }) it('disables plugins before runtime initialization and backs up the patch while preserving package files', async () => { const { manager } = setup() await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + seedPlugin(manager) const patch = join(manager.paths.profile, 'cordis.patch.yml') writeFileSync(patch, ': broken') const uninitialized = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: 'missing-runtime' }) @@ -151,47 +122,6 @@ describe('desktop external plugin profile', () => { expect(existsSync(manager.paths.lock)).toBe(false) }) - it.each(['missing', 'malformed', 'unversioned'] as const)('lists, disables, and removes a package with %s metadata', async (damage) => { - const { manager } = setup() - await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) - const path = join(manager.paths.profile, 'node_modules/plugin/package.json') - if (damage === 'missing') unlinkSync(path) - else writeFileSync(path, damage === 'malformed' ? '{broken' : '{}') - expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }]) - await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: false }, hooks()) - expect(manager.listPlugins()[0]?.enabled).toBe(false) - await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) - expect(manager.listPlugins()).toEqual([]) - }) - - it('preserves custom profile metadata and bundle order during plugin changes', async () => { - const { manager } = setup() - await manager.applyRelease() - const path = join(manager.paths.profile, 'package.json') - writeFileSync(path, JSON.stringify({ name: 'custom', private: false, custom: true, - dependencies: {}, dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle'] } } })) - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) - expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ name: 'custom', private: false, custom: true, - dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle', 'plugin'] } } }) - await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) - expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ - dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle'] } } }) - }) - - it('passes user registry and pnpm configuration through to the bundled executable', async () => { - const { root, manager } = setup() - vi.stubEnv('NPM_CONFIG_REGISTRY', 'https://packages.example.test/') - vi.stubEnv('NPM_CONFIG_USERCONFIG', join(root, 'user.npmrc')) - vi.stubEnv('PNPM_HOME', join(root, 'user-pnpm')) - vi.stubEnv('XDG_CONFIG_HOME', join(root, 'user-config')) - await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@next' }, hooks()) - expect(calls(root)[0]).toMatchObject({ registry: 'https://packages.example.test/', - userconfig: join(root, 'user.npmrc'), pnpmHome: join(root, 'user-pnpm'), configHome: join(root, 'user-config'), - args: ['add', '--', 'plugin@next'] }) - }) - it('initializes the same pnpm settings as a Web profile without a Desktop build allowlist', async () => { const { manager } = setup() await manager.applyRelease() @@ -215,8 +145,6 @@ describe('desktop external plugin profile', () => { writeFileSync(path, custom) await manager.applyRelease() expect(readFileSync(path, 'utf8')).toBe(custom) - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) - expect(manager.listPlugins()[0]?.name).toBe('plugin') }) it('reports damaged application metadata as a reinstall failure', async () => { @@ -238,11 +166,10 @@ describe('desktop external plugin profile', () => { }) it('initializes and restarts offline without executing pnpm', async () => { - const { root, manager } = setup() + const { manager } = setup() await expect(manager.applyRelease()).resolves.toBeUndefined() await expect(manager.applyRelease()).resolves.toBeUndefined() - expect(manager.listPlugins()).toEqual([]) - expect(calls(root)).toEqual([]) + expect(plugins(manager)).toEqual([]) expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: {} }) }) @@ -254,7 +181,7 @@ describe('desktop external plugin profile', () => { }) it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => { - const { root, manager } = setup() + const { manager } = setup() if (operation === 'changed') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{}') if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n') if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '') @@ -263,173 +190,44 @@ describe('desktop external plugin profile', () => { const relaunched = new DesktopProjectManager(manager.paths, manager.runtime) await expect(relaunched.applyRelease()).resolves.toBeUndefined() expect(existsSync(manager.paths.profile)).toBe(true) - expect(calls(root)).toEqual([]) - }) - - it('installs plugins with pnpm script settings', async () => { - const { root, manager } = setup() - await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) - expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0', enabled: true }]) - expect(calls(root).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([ - ['add', '--', '@scope/plugin@2.0.0'], - ]) - expect(calls(root).every(call => call.registry === process.env.NPM_CONFIG_REGISTRY)).toBe(true) - expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: { '@scope/plugin': '2.0.0' } }) - await expect(manager.applyRelease()).resolves.toBeUndefined() - expect(calls(root)).toHaveLength(1) - }) - - it('retains pnpm-installed host package copies instead of rejecting their dependency placement', async () => { - const { manager } = setup() - await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: '@deepseek-ai/cordis@2.0.0' }, hooks()) - expect(manager.listPlugins()).toEqual([{ name: '@deepseek-ai/cordis', version: '2.0.0', enabled: false }]) - await expect(manager.applyRelease()).resolves.toBeUndefined() - await manager.mutate({ type: 'plugin-remove', name: '@deepseek-ai/cordis' }, hooks()) - }) - - it('retains disabled plugin versions through updates and enables them explicitly', async () => { - const { root, manager } = setup() - await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) - await manager.disableAllPlugins() - expect(calls(root)).toHaveLength(1) - expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) - await manager.mutate({ type: 'plugin-update', name: 'plugin', version: '1.1.0' }, hooks()) - expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.1.0', enabled: false }]) - await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: true }, hooks()) - expect(manager.listPlugins()[0]?.enabled).toBe(true) - await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) - expect(manager.listPlugins()).toEqual([]) }) it('keeps plugin files and patches through a compatible release and application relocation', async () => { const { root, manager } = setup() await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + seedPlugin(manager) writeFileSync(join(manager.paths.profile, 'cordis.patch.yml'), '[]\n') const nextRoot = join(root, 'relocated', 'dsh') runtimeFixture(nextRoot, '1.1.0') const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: nextRoot }) await expect(next.applyRelease()).resolves.toBeUndefined() - expect(next.listPlugins()).toEqual(manager.listPlugins()) - expect(next.dshVersion()).toBe('1.1.0') + expect(plugins(next)).toEqual(plugins(manager)) expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n') - expect(calls(root)).toHaveLength(1) expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n') }) it('preserves plugin files without running pnpm when bundled Node changes', async () => { const { root, manager } = setup() await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + seedPlugin(manager) const dsh = join(root, 'new-node') runtimeFixture(dsh, '1.1.0', '24.18.0') const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) await next.applyRelease() - expect(calls(root)).toHaveLength(1) - expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }]) + expect(plugins(next)).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }]) }) it('allows peer version mismatches to reach Host startup and remain available for recovery', async () => { const { root, manager } = setup() await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + seedPlugin(manager) const dsh = join(root, 'next-major') runtimeFixture(dsh, '2.0.0') const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) await expect(next.applyRelease()).resolves.toBeUndefined() - expect(next.dshVersion()).toBe('2.0.0') await next.disableAllPlugins() - expect(next.dshVersion()).toBe('2.0.0') - expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) + expect(plugins(next)).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) }) - it.each(['before', 'after'] as const)('retains direct writes when the %s change hook fails', async (phase) => { - const { manager } = setup() - await manager.applyRelease() - let starts = 0 - await expect(manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({ - beforeChange: async () => { - expect(manager.listPlugins()).toEqual([]) - if (phase === 'before') throw new Error('before failed') - }, - afterChange: async () => { starts++; throw new Error('after failed') }, - }))).rejects.toThrow(`${phase} failed`) - expect(manager.listPlugins()).toEqual(phase === 'before' ? [] : [{ name: 'plugin', version: '1.0.0', enabled: true }]) - expect(starts).toBe(phase === 'before' ? 0 : 1) - }) - it('keeps partial package changes available for recovery after pnpm fails', async () => { - const { root, manager } = setup() - await manager.applyRelease() - const failingPnpm = join(root, 'failing.mjs') - writeFileSync(failingPnpm, `await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)}); process.exitCode = 1`) - const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm }) - await worker.applyRelease() - let starts = 0 - await expect(worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({ - afterChange: async () => { starts++ }, - }))).rejects.toThrow(/pnpm exited with 1/u) - expect(worker.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) - expect(starts).toBe(1) - expect(existsSync(manager.paths.lock)).toBe(false) - writeFileSync(join(manager.paths.profile, 'desktop-packages-pending'), '') - await manager.applyRelease() - await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: false }, hooks({ afterChange: async () => { starts++ } })) - expect(starts).toBe(2) - await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) - expect(manager.listPlugins()).toEqual([]) - }) - - it('retains both package and restart errors when the changed profile cannot start', async () => { - const { root, manager } = setup() - await manager.applyRelease() - const failingPnpm = join(root, 'failing-restart.mjs') - writeFileSync(failingPnpm, 'process.exitCode = 1') - const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm }) - await worker.applyRelease() - const afterChange = vi.fn(async () => { throw new Error('Host initialization failed') }) - const failure = await worker.mutate({ type: 'plugin-add', spec: 'missing' }, hooks({ afterChange })) - .catch((error: unknown) => error) - expect(failure).toBeInstanceOf(AggregateError) - expect((failure as AggregateError).errors.map((error: Error) => error.message)) - .toEqual([expect.stringContaining('pnpm exited with 1'), 'Host initialization failed']) - expect(afterChange).toHaveBeenCalledOnce() - expect(existsSync(manager.paths.lock)).toBe(false) - }) - - it('holds the transaction lock until the pnpm worker exits', async ({ task, signal }) => { - const { root, manager } = setup() - await manager.applyRelease() - const ready = join(root, 'ready') - const release = join(root, 'release') - const blocker = join(root, 'blocking.mjs') - writeFileSync(blocker, `import {existsSync, writeFileSync} from 'node:fs'; import {setTimeout as sleep} from 'node:timers/promises'; writeFileSync(${JSON.stringify(ready)}, String(process.pid)); while (!existsSync(${JSON.stringify(release)})) await sleep(10); await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)})`) - const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: blocker }) - await worker.applyRelease() - const pending = worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) - // Teardown observes failures even if the runner has abandoned the test body. - const completed = pending.then(value => ({ value }), (error: unknown) => ({ error })) - releaseWorkers.push(async () => { - writeFileSync(release, 'continue') - const outcome = await completed - if ('error' in outcome) throw outcome.error - }) - try { - // Child startup shares the test budget; an aborted poll must not resume ownership assertions. - await expect.poll(() => { - signal.throwIfAborted() - return existsSync(ready) - }, { timeout: task.timeout }).toBe(true) - signal.throwIfAborted() - expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(readFileSync(ready, 'utf8')) - await expect(manager.applyRelease()).rejects.toThrow(/another package transaction/u) - } finally { - writeFileSync(release, 'continue') - await pending - } - expect(existsSync(manager.paths.lock)).toBe(false) - }) }) diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index 272ef03a1d..7e39a0db45 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AppUpdater } from 'electron-updater' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' import { parseDesktopRelease } from '../src/release.ts' -import type { DesktopUpdateState } from '../src/ipc.ts' vi.mock('electron', () => ({ app: { isPackaged: false } })) vi.mock('electron-updater', () => ({ @@ -43,7 +42,6 @@ describe('desktop release metadata', () => { describe('desktop update coordinator', () => { it('installs one Electron release and restarts after download', async () => { - const states: DesktopUpdateState[] = [] const downloadUpdate = vi.fn(async () => []) const quitAndInstall = vi.fn() const beforeRestart = vi.fn(async () => {}) @@ -58,10 +56,6 @@ describe('desktop update coordinator', () => { quitAndInstall, } as unknown as AppUpdater const coordinator = new DesktopUpdateCoordinator( - (state) => { - states.push(state) - return state - }, beforeRestart, updater, () => true, @@ -72,7 +66,6 @@ describe('desktop update coordinator', () => { expect(downloadUpdate).toHaveBeenCalledOnce() expect(beforeRestart).toHaveBeenCalledOnce() expect(quitAndInstall).toHaveBeenCalledWith(false, true) - expect(states.map(state => state.phase)).toEqual(['checking', 'available', 'installing', 'ready']) }) it('queues install behind an in-flight check instead of returning the check result', async () => { @@ -88,7 +81,7 @@ describe('desktop update coordinator', () => { downloadUpdate, quitAndInstall: vi.fn(), } as unknown as AppUpdater - const coordinator = new DesktopUpdateCoordinator(state => state, async () => {}, updater, () => true) + const coordinator = new DesktopUpdateCoordinator( async () => {}, updater, () => true) const checking = coordinator.check() const installing = coordinator.install() diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index d34cf1bf26..55d366cd06 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -12,9 +12,9 @@ export default defineConfig([ clean: false, deps: { neverBundle: ['electron'] }, }, - ...(['preload', 'preload-app'] as const).map(name => ({ + { // Sandboxed Electron preloads run as CommonJS even though the application package is ESM. - entry: { [name]: `lib/types/${name}.js` }, + entry: { 'preload-app': 'lib/types/preload-app.js' }, outDir: 'lib', format: ['cjs'] as const, platform: 'node' as const, @@ -23,5 +23,5 @@ export default defineConfig([ dts: false, clean: false, deps: { neverBundle: ['electron'] }, - })), + }, ]) From 9fed351d3df0f06d98de0936d5ad841223284e13 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:38:59 +0800 Subject: [PATCH 02/17] feat(client): add Sidebar Browser --- .../2026-09-16-sidebar-browser.i18n.yaml | 6 + .../feature/2026-09-16-sidebar-browser.md | 73 +++++++ .../feature/2026-09-16-sidebar-browser.zh.md | 73 +++++++ .../ui-sidebar-browser/README.i18n.yaml | 6 + packages/client/ui-sidebar-browser/README.md | 114 ++++++++++ .../client/ui-sidebar-browser/README.zh.md | 114 ++++++++++ .../client/ui-sidebar-browser/package.json | 66 ++++++ .../src/client/browser/BrowserController.ts | 179 ++++++++++++++++ .../src/client/browser/BrowserFrame.ts | 90 ++++++++ .../src/client/browser/BrowserNavigation.ts | 190 +++++++++++++++++ .../src/client/browser/store.ts | 38 ++++ .../src/client/browser/url.ts | 58 +++++ .../src/client/definition.tsx | 26 +++ .../ui-sidebar-browser/src/client/index.ts | 46 ++++ .../ui-sidebar-browser/src/client/locales.ts | 61 ++++++ .../src/client/view/Browser.module.css | 167 +++++++++++++++ .../src/client/view/BrowserBody.tsx | 142 +++++++++++++ .../src/client/view/BrowserTitle.tsx | 17 ++ .../ui-sidebar-browser/src/css-modules.d.ts | 4 + .../client/ui-sidebar-browser/src/index.ts | 4 + .../tests/apply.client.spec.ts | 82 +++++++ .../tests/browser-body.client.spec.tsx | 200 ++++++++++++++++++ .../tests/browser-controller.client.spec.ts | 127 +++++++++++ .../tests/browser-frame.client.spec.ts | 29 +++ .../tests/browser-navigation.client.spec.ts | 78 +++++++ .../tests/browser-title.client.spec.tsx | 32 +++ .../tests/store.client.spec.ts | 21 ++ .../tests/url.client.spec.ts | 42 ++++ .../client/ui-sidebar-browser/tsconfig.json | 21 ++ .../ui-sidebar-browser/tsdown.config.ts | 3 + .../src/client/contract/params.ts | 2 +- .../src/client/slot-catalog.ts | 2 + pnpm-lock.yaml | 45 ++++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 2 + tsconfig.client.json | 1 + 36 files changed, 2161 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-16-sidebar-browser.md create mode 100644 .agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md create mode 100644 packages/client/ui-sidebar-browser/README.i18n.yaml create mode 100644 packages/client/ui-sidebar-browser/README.md create mode 100644 packages/client/ui-sidebar-browser/README.zh.md create mode 100644 packages/client/ui-sidebar-browser/package.json create mode 100644 packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts create mode 100644 packages/client/ui-sidebar-browser/src/client/browser/BrowserFrame.ts create mode 100644 packages/client/ui-sidebar-browser/src/client/browser/BrowserNavigation.ts create mode 100644 packages/client/ui-sidebar-browser/src/client/browser/store.ts create mode 100644 packages/client/ui-sidebar-browser/src/client/browser/url.ts create mode 100644 packages/client/ui-sidebar-browser/src/client/definition.tsx create mode 100644 packages/client/ui-sidebar-browser/src/client/index.ts create mode 100644 packages/client/ui-sidebar-browser/src/client/locales.ts create mode 100644 packages/client/ui-sidebar-browser/src/client/view/Browser.module.css create mode 100644 packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx create mode 100644 packages/client/ui-sidebar-browser/src/client/view/BrowserTitle.tsx create mode 100644 packages/client/ui-sidebar-browser/src/css-modules.d.ts create mode 100644 packages/client/ui-sidebar-browser/src/index.ts create mode 100644 packages/client/ui-sidebar-browser/tests/apply.client.spec.ts create mode 100644 packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx create mode 100644 packages/client/ui-sidebar-browser/tests/browser-controller.client.spec.ts create mode 100644 packages/client/ui-sidebar-browser/tests/browser-frame.client.spec.ts create mode 100644 packages/client/ui-sidebar-browser/tests/browser-navigation.client.spec.ts create mode 100644 packages/client/ui-sidebar-browser/tests/browser-title.client.spec.tsx create mode 100644 packages/client/ui-sidebar-browser/tests/store.client.spec.ts create mode 100644 packages/client/ui-sidebar-browser/tests/url.client.spec.ts create mode 100644 packages/client/ui-sidebar-browser/tsconfig.json create mode 100644 packages/client/ui-sidebar-browser/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml new file mode 100644 index 0000000000..34047faa9e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-sidebar-browser.md +2026-09-16-sidebar-browser.md: ce4362f42b7664a6943712ec716d550b658afdc1 +2026-09-16-sidebar-browser.zh.md: 4ba6f27f8458a8e17b37b86ab667c608133e5bb9 diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md new file mode 100644 index 0000000000..ce4362f42b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md @@ -0,0 +1,73 @@ +# Agent Note: Sandboxed Sidebar browser + +Status: implemented + +English | [中文](2026-09-16-sidebar-browser.zh.md) + +## Problem + +The right Sidebar can preview addressed workspace files, but it has no independent surface for visiting a Web page. Opening a page outside the application loses the Sidebar's split, float, and tab lifecycle. Treating an arbitrary page like Document Preview content also obscures a different trust model: a Web page controls a live browsing context, while a document renderer receives bytes selected for one preview. + +A parent page cannot inspect or drive a cross-origin iframe's internal history. The supported behavior must not imply that capability. + +## Decision + +`@deepseek-ai/dsh-client-ui-sidebar-browser` registers the multi-instance `browser` right-Sidebar tab type. `SidebarRightTabParamsMap.browser` accepts an optional initial URL so another Client plugin can open a Browser without importing this package's runtime values. + +The address parser accepts `https:` and `http:` only for `localhost`, `[::1]`, and `127.0.0.0/8`; a host name without a scheme becomes HTTPS. It rejects embedded credentials, the application's own origin, malformed addresses, public HTTP, `file:` URLs, and every other scheme. The controller additionally refuses loopback Web targets while that tab's sandbox is enabled. Document Preview remains the local-file surface. + +The current carrier is an iframe in both Web and Desktop. Its default Web policy is `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`, without download or top-navigation capability; popups escape the sandbox. Same-origin lets the visited origin use its own cookies and Web storage; it does not make a cross-origin target same-origin with DSH. The iframe sends no referrer and adds no package-owned Permissions Policy, so browser defaults and user grants apply. A rightmost toolbar toggle removes the sandbox attribute and allows controller-directed loopback navigation for that tab occurrence; the mode is not persisted and renders a warning while active. An unsandboxed page that reaches the DSH origin can access that origin's Web data. The package performs no Host-side URL probe or proxy. + +Each tab receives one `BrowserController` class. Its command interface contains only `loadUrl`, `goBack`, `goForward`, and `reload`; it owns address validation and the `BrowserNavigation` state machine. The `BrowserFrame` interface owns transient sandbox and document state plus carrier operations, and `IframeImpl` implements it for the current iframe carrier. Slot injection exposes keyed frame state through `useBrowserFrame` and supplies plain callbacks, so the React body receives neither the controller nor an observable source; it owns only the editable draft and iframe DOM. A future `ElectronWebViewImpl` can implement the same interface without putting URL or carrier state in the component. + +`BrowserNavigation` keeps the canonical current URL, controlled-load revision, navigation state, and a bounded sequence with its index. A new address drops the forward branch; Back and Forward move the index; Reload recreates the last application-known URL without adding history. A remounted body reloads the latest application-known URL and consults its optional initial URL only before the first controlled target. The Session-scoped store only persists immutable snapshots from that class for title rendering and application reload, and removes the tab bucket when that occurrence ends. + +Browser state is presentation state. It does not enter the Session log, model request, resource model, or DockKit layout operations. The existing [right Sidebar infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md), [tab type contract](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md), [resource model](../architecture/2026-09-05-client-resource-model.md), and [Document Preview operations](../architecture/2026-09-08-document-preview-operations.md) retain their existing responsibilities. + +## Web navigation states + +The Web carrier treats only the first iframe `load` for a controlled revision as confirmation of the application-known URL. A later `load` for that revision proves that the document changed but cannot reveal the new cross-origin URL. Events carrying an older revision are ignored. + +| State | Entry | Address and controls | +|---|---|---| +| `empty` | The tab has no controlled target. | The address is empty. Back, Forward, Reload, and external-open are disabled. | +| `loading` | Address submission, an application-history move, or Reload starts a new revision. | The requested URL remains authoritative. Back and Forward follow application-history bounds; Reload remains available; external-open follows the known target protocol. | +| `known` | The first iframe `load` arrives for the current revision. | The requested URL remains authoritative even when that first load includes an HTTP redirect. The controls follow the same known-target rules as `loading`. | +| `unknown` | A second or later iframe `load` arrives for the current revision. | The last controlled URL is muted and marked `URL changed`. Back and Forward are disabled because iframe exposes no cross-origin `canGoBack` or `canGoForward`; external-open is disabled. Reload starts a new revision at the last controlled URL. | +| `failed` | The current target is refused by carrier policy before the iframe loads. | The known target remains editable and the failure is shown. History, Reload, and external-open continue to follow the known target. | + +Address editing is available in every state. An invalid or sandbox-blocked draft reports an address failure without changing the current navigation state. Focusing the unknown address hides its marker and reveals Go; Enter and Go both start a controlled load. Changing sandbox mode reloads the last controlled Web target under a new revision; restoring the sandbox refuses a current loopback target. `pushState`, `replaceState`, and fragment changes that emit no iframe `load` remain unobservable. + +## Deferred Electron carrier + +Electron `` support is designed but is not registered or tested. The controller keeps the same four commands, while a separate per-tab view object owns attachment, detachment, state observation, and target identity. This follows Playwright's Android WebView split: `AndroidWebView` is a target and lifecycle handle identified by package, process, and debugging socket, while `page()` returns the regular `Page` that owns Web navigation and DOM commands; device-level input remains on `AndroidDevice`. + +The Desktop design enables `webviewTag` only on the application window. Its isolated preload receives an unguessable per-window capability, and every Browser tab appends a fresh UUID to form a distinct non-persistent partition. The main process accepts only an initial `about:blank` guest carrying that capability, removes any preload, and forces sandbox, context isolation, disabled Node integration in all frames, Web security, secure-content checks, disabled nested webviews, and disabled plugins. + +The main process allows page-initiated main-frame navigation and redirects only to credential-free HTTPS or explicit loopback HTTP. Requests may use HTTPS, secure WebSocket, data, and Blob URLs, plus HTTP and insecure WebSocket only on loopback; direct file, public HTTP, custom-protocol, extension, and privileged requests are cancelled. Permission checks and requests, display capture, device grants, downloads, popup windows, and drag-and-drop navigation are denied. + +The view object keeps an inactive guest connected in an owned hidden DOM host and moves it back into the visible placeholder without recreation. This retains page and target identity across Sidebar body remounts. Because `` participates in renderer layout and compositing, ordinary DOM dialogs, menus, tooltips, and drag previews can cover it. `WebContentsView` remains unsuitable because it is a native child surface: CSS cannot cover it, and every overlay or animation would require main-process visibility and bounds synchronization. + +Each guest is a distinct WebContents and CDP target. Development may expose Electron's process-wide remote-debugging port and select the guest target explicitly. Production keeps that endpoint disabled; browser-use or computer-use requires an authenticated broker that binds one authorized tab to its WebContents and uses `webContents.debugger` or an equivalently scoped transport without publishing every application target. + +## Alternatives considered + +**Add a Host embeddability probe and persist the sandbox preference.** Rejected because fetching arbitrary targets on the Host adds an SSRF path, the probe can disagree with later redirects, and a persistent global escape makes later tabs inherit an unsafe choice. Browser instead offers an explicit per-tab, non-persistent sandbox toggle with a visible warning. + +**Treat parent-owned history as the complete Web model.** Rejected because it silently leaves a stale address and enabled actions after an in-frame navigation. The bounded parent-owned history remains useful while the current controlled URL is known; the explicit `unknown` state removes claims that the iframe API cannot support. + +**Support `file:` URLs in Browser.** Rejected because local files already belong to Document Preview, while browser navigation has a different trust model. Browser refuses the protocol instead of acquiring filesystem or Workspace Files access. + +**Proxy HTTPS pages through the Host.** Rejected because a compatible proxy would have to rewrite URLs, CSP, cookies, modules, streams, forms, and downloads while turning the Host into a general outbound requester. + +**Implement the Electron carrier in the initial Browser change.** Deferred so the first implementation does not enable a new Electron guest surface without packaged-app evidence for overlay stacking, target lifetime, cookie isolation, and every permission denial. + +## Verification + +Unit tests cover protocol parsing, controller commands and lifecycle, deterministic navigation-state transitions, bounded history, and plugin disposal. The keyless Web scenario boots the shipped composition and exercises HTTPS, Back, Forward, sandbox control, unknown navigation, and protocol refusal. + +## Consequences + +The Browser adds no Electron privilege and behaves identically in current Web and Desktop builds. Many HTTPS sites refuse iframe embedding or require downloads or top-level navigation withheld by the default sandbox. Disabling the sandbox trades those protections for compatibility and exposes loopback services to controller-directed navigation, but it does not add Electron or Node APIs. The URL gate cannot prevent an embedded page from choosing its own destination. A later iframe load exposes that navigation occurred but not its cross-origin URL; History API and fragment changes can remain completely invisible. The deferred Electron carrier requires real packaged-app verification before it can become current behavior. + +Site-cookie behavior follows the user's browser and is not isolated per Browser tab. Local files are rejected and remain owned by Document Preview. Persisted URLs can contain sensitive query or fragment values, so users must not enter credentials they do not want retained in application-local browser storage. diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md new file mode 100644 index 0000000000..4ba6f27f84 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md @@ -0,0 +1,73 @@ +# Agent Note: 沙箱化 Sidebar 浏览器 + +Status: implemented + +[English](2026-09-16-sidebar-browser.md) | 中文 + +## Problem + +右侧 Sidebar 可以预览已寻址的工作区文件,但没有用于访问网页的独立界面。在应用外打开会丢失 Sidebar 的分栏、浮动与 tab 生命周期。把任意网页当成 Document Preview 内容,也会混淆两种不同的信任模型:网页控制一个活跃浏览上下文,而文档 renderer 只接收为一次预览选定的字节。 + +父页面不能检查或驱动跨域 iframe 的内部 history。支持的行为不能暗示具备这种能力。 + +## Decision + +`@deepseek-ai/dsh-client-ui-sidebar-browser` 注册可多开的右侧 Sidebar `browser` tab 类型。`SidebarRightTabParamsMap.browser` 接受可选初始 URL,使其他 Client 插件无须导入本包运行时值即可打开 Browser。 + +地址解析器接受 `https:`,以及仅指向 `localhost`、`[::1]` 与 `127.0.0.0/8` 的 `http:`;不带 scheme 的主机名补为 HTTPS。它拒绝内嵌凭据、应用自身 origin、畸形地址、公共 HTTP、`file:` URL,以及所有其他 scheme。该 tab 的 sandbox 启用时,controller 还会拒绝 loopback Web 目标。本地文件继续由 Document Preview 负责。 + +当前 Web 与 Desktop 都使用 iframe 载体。它的默认 Web 策略是 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`,不具备下载或顶层导航能力;popup 会脱离 sandbox。same-origin 允许被访问的 origin 使用自己的 Cookie 与 Web storage;它不会让跨域目标与 DSH 变成同源。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。最右侧 toolbar 开关会移除 sandbox attribute,并为当前 tab occurrence 允许 controller 发起 loopback 导航;该模式不持久化,启用期间持续显示警告。未受 sandbox 约束的页面一旦到达 DSH origin,就可以访问该 origin 的 Web 数据。本包不执行 Host 侧 URL probe 或代理。 + +每个 tab 获得一个 `BrowserController` class。它的命令接口只有 `loadUrl`、`goBack`、`goForward` 与 `reload`;它负责地址校验和 `BrowserNavigation` 状态机。`BrowserFrame` 接口负责临时 sandbox 与 document 状态以及载体操作,`IframeImpl` 为当前 iframe 载体实现该接口。Slot injection 通过 `useBrowserFrame` 提供按 key 索引的 frame 状态,并提供普通 callback,因此 React body 不接收 controller 或 observable source;它只负责可编辑草稿与 iframe DOM。未来的 `ElectronWebViewImpl` 可以实现相同接口,而不把 URL 或载体状态放进组件。 + +`BrowserNavigation` 保存 canonical 当前 URL、受控加载 revision、导航状态,以及有上限的序列与当前位置。新地址丢弃 forward 分支;后退和前进移动 index;刷新重建应用最后已知的 URL 且不增加 history。body 重挂载时会重新加载应用最后已知的 URL,并且仅在尚无受控目标时使用可选初始 URL。Session-scoped store 只持久化该 class 的 immutable snapshot,供标题渲染与应用刷新恢复,并在该 tab occurrence 结束时删除 bucket。 + +Browser 状态只属于呈现层,不进入 Session log、模型请求、resource model 或 DockKit layout operation。现有的[右侧 Sidebar 基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)、[tab 类型契约](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)、[resource model](../architecture/2026-09-05-client-resource-model.zh.md)和[文档预览操作](../architecture/2026-09-08-document-preview-operations.zh.md)继续负责各自现有职责。 + +## Web 导航状态 + +对于一个受控 revision,Web 载体只把 iframe 的第一次 `load` 当作应用已知 URL 的确认。同一 revision 后续发生 `load`,只能证明文档已经变化,无法给出新的跨域 URL。携带旧 revision 的事件会被忽略。 + +| 状态 | 进入条件 | 地址与控件 | +|---|---|---| +| `empty` | tab 没有受控目标。 | 地址为空;后退、前进、刷新与外部打开均禁用。 | +| `loading` | 地址提交、应用 history 移动或刷新启动新的 revision。 | 请求 URL 仍是权威地址;后退和前进遵循应用 history 范围;刷新保持可用;外部打开遵循已知目标的协议。 | +| `known` | 当前 revision 收到 iframe 的第一次 `load`。 | 即使第一次加载包含 HTTP redirect,请求 URL 仍是权威地址。控件规则与 `loading` 的已知目标规则相同。 | +| `unknown` | 当前 revision 收到 iframe 的第二次或后续 `load`。 | 最后一个受控 URL 变灰,并标记 `URL 已变化`。iframe 不提供跨域 `canGoBack` 或 `canGoForward`,因此后退和前进禁用;外部打开禁用。刷新以最后一个受控 URL 启动新的 revision。 | +| `failed` | iframe 加载前,当前目标被载体策略拒绝。 | 已知目标保持可编辑并显示失败信息。history、刷新与外部打开继续遵循已知目标。 | + +每个状态都允许编辑地址。无效或被 sandbox 阻止的草稿只报告地址错误,不改变当前导航状态。聚焦 unknown 地址会隐藏标记并显示前往按钮;回车与前往都会启动受控加载。切换 sandbox 模式会使用新的 revision 重新加载最后一个受控 Web 目标;恢复 sandbox 会拒绝当前 loopback 目标。不产生 iframe `load` 的 `pushState`、`replaceState` 与 fragment 变化仍不可观察。 + +## Deferred Electron carrier + +Electron `` 支持已经完成设计,但当前不注册也不测试。controller 保持相同的四个命令,独立的 per-tab view 对象负责 attachment、detachment、状态观察与 target identity。这沿用 Playwright 的 Android WebView 划分:`AndroidWebView` 是由 package、process 与调试 socket 标识的 target 和生命周期 handle,`page()` 返回负责 Web 导航与 DOM 命令的普通 `Page`;设备级输入仍属于 `AndroidDevice`。 + +Desktop 设计只在应用窗口启用 `webviewTag`。其隔离 preload 获得不可猜的 per-window capability,每个 Browser tab 再追加新的 UUID,形成独立非持久化 partition。主进程只接受携带该 capability 且初始地址为 `about:blank` 的 guest,删除任何 preload,并强制启用 sandbox 与 context isolation、在所有 frame 禁用 Node integration、启用 Web security 与安全内容检查、禁用嵌套 webview 和 plugin。 + +主进程只允许页面发起的 main-frame 导航与重定向前往不含凭据的 HTTPS 或显式 loopback HTTP。请求可以使用 HTTPS、安全 WebSocket、data 与 Blob URL,也可仅在 loopback 使用 HTTP 与不安全 WebSocket;直接 file、公共 HTTP、自定义协议、extension 与特权请求都会被取消。permission 检查与请求、显示捕获、设备授权、下载、弹窗和拖放导航全部拒绝。 + +view 对象把非活动 guest 保持连接并停放在自有隐藏 DOM host 中,再把它移回可见占位符而不重建。这会在 Sidebar body remount 后保留页面与 target identity。因为 `` 参与 renderer 布局与合成,普通 DOM dialog、menu、tooltip 与拖拽预览可以覆盖它。`WebContentsView` 仍不合适,因为它是原生 child surface:CSS 无法覆盖它,每个 overlay 或动画都需要主进程同步 visibility 与 bounds。 + +每个 guest 都是独立 WebContents 和 CDP target。开发环境可以开放 Electron 的进程级 remote-debugging port,并明确选择 guest target。生产环境保持该 endpoint 关闭;browser-use 或 computer-use 需要一个经过认证的 broker,把一个已授权 tab 绑定到其 WebContents,并使用 `webContents.debugger` 或等价的 scoped transport,而不公开全部应用 target。 + +## Alternatives considered + +**增加 Host embeddability probe 并持久化 sandbox 偏好。** 不采用,因为由 Host 请求任意目标会新增 SSRF 路径,probe 可能与后续重定向结果不一致,而持久化的全局逃生开关会让后续 tab 继承不安全选择。Browser 改为提供显式、按 tab、非持久化的 sandbox 开关,并持续显示警告。 + +**把 parent-owned history 当成完整 Web 模型。** 不采用,因为 iframe 内导航后,地址会悄然陈旧,而相关操作仍显示可用。当前受控 URL 已知时,有上限的 parent history 仍然有用;显式 `unknown` 状态会移除 iframe API 无法支持的能力声明。 + +**在 Browser 中支持 `file:` URL。** 不采用,因为本地文件已由 Document Preview 负责,而 browser 导航使用不同的信任模型。Browser 直接拒绝该协议,不获取文件系统或 Workspace Files 能力。 + +**通过 Host 代理 HTTPS 页面。** 不采用,因为兼容代理必须重写 URL、CSP、Cookie、module、stream、form 与 download,同时会把 Host 变成通用出站请求器。 + +**在首个 Browser 变更中实现 Electron 载体。** 延期处理,避免在没有打包应用证据覆盖 overlay stacking、target lifetime、Cookie 隔离与全部 permission 拒绝路径时启用新的 Electron guest surface。 + +## Verification + +单元测试覆盖协议解析、controller 命令与生命周期、确定性导航状态转换、有界 history 和插件 disposal。Keyless Web 场景启动随附 composition,并覆盖 HTTPS、后退、前进、sandbox 控制、unknown 导航和协议拒绝。 + +## Consequences + +Browser 不增加 Electron 权限,并在当前 Web 与 Desktop 构建中保持相同行为。很多 HTTPS 站点拒绝 iframe 嵌入,或者依赖默认 sandbox 不提供的下载或顶层导航。关闭 sandbox 会用这些保护换取兼容性,并允许 controller 发起 loopback 导航,但不会增加 Electron 或 Node API。URL 检查无法阻止 iframe 内页面自行选择目标。后续 iframe load 能表明已经发生导航,但无法给出跨域 URL;History API 与 fragment 变化可能完全不可见。延期的 Electron 载体必须通过真实打包应用验证,才能成为当前行为。 + +站点 Cookie 行为遵循用户浏览器,并不按 Browser tab 隔离。本地文件会被拒绝,并继续由 Document Preview 负责。持久化 URL 可能含敏感 query 或 fragment,因此用户不应在地址栏输入不希望保留在应用本地浏览器存储中的凭据。 diff --git a/packages/client/ui-sidebar-browser/README.i18n.yaml b/packages/client/ui-sidebar-browser/README.i18n.yaml new file mode 100644 index 0000000000..2dfa6c16f5 --- /dev/null +++ b/packages/client/ui-sidebar-browser/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-browser/README.md +README.md: 12c50ceae7d2d7dd6c7c35ef94deaa13dea766ad +README.zh.md: c482ca8ec5d864a9c854b84d7d8fe4cea124d84f diff --git a/packages/client/ui-sidebar-browser/README.md b/packages/client/ui-sidebar-browser/README.md new file mode 100644 index 0000000000..12c50ceae7 --- /dev/null +++ b/packages/client/ui-sidebar-browser/README.md @@ -0,0 +1,114 @@ +--- +description: "Right-Sidebar browser tabs for isolated HTTPS pages and opt-in loopback services." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-browser + +English | [中文](README.zh.md) + +## Summary + +Browse HTTPS pages and opt-in loopback HTTP services inside independent right-Sidebar tabs. The current carrier is an iframe with application-managed history in both Web and Desktop. The package never injects Electron or Node access into visited content. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The shipped Web and Desktop compositions already mount this package. Open **Browser** from the right-Sidebar guide, then enter an HTTPS URL or a host name that should become HTTPS. Disable that tab's sandbox before opening a loopback HTTP URL. Each guide action creates another Browser tab. + +### When to choose it + +Choose Browser for a Web page that should remain beside the current Session. Choose [Document Preview](../ui-sidebar-documentpreview/README.md) for local files, and use the explicit external-browser action when a site refuses iframe embedding or needs browser capabilities this package withholds. + +### Minimal configuration + +The package has no configuration. A custom Web composition mounts its Host companion; the Client loader then discovers the browser entry declared by the package manifest: + +```yaml +- id: ui-sidebar-browser + name: '@deepseek-ai/dsh-client-ui-sidebar-browser' +``` + +Client plugins can open a tab through `ctx.sidebarRight.openTab('browser', { params: { url } })`. The optional URL passes the same validation as address-bar input before navigation. + +The toolbar provides Back, Forward, Reload, Go, Open in system browser, and a rightmost per-tab sandbox toggle. Disabling the sandbox is temporary, displays a warning, and permits controller-directed loopback navigation. The external action accepts a known HTTPS or loopback HTTP target. The tab title is the Web host. + +----- + + +## Understand the implementation + +
    +Implementation internals — click to expand + +### Protocol policy + +The address parser accepts HTTPS and HTTP only for `localhost`, `[::1]`, and `127.0.0.0/8`. The controller permits those loopback targets only while the current tab's sandbox is disabled. It rejects public HTTP, `file:` URLs, script/data/blob input, embedded credentials, the DSH application origin, and malformed addresses. Document Preview owns local-file rendering. + +### Iframe carrier + +Web and Desktop use `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"` by default, without download or top-navigation capability. Popups leave the sandbox. The visited origin can use its own cookies and Web storage but a cross-origin target cannot read DSH DOM, storage, or API responses. The iframe sends no referrer and adds no package-owned Permissions Policy, so browser defaults and user grants apply. The toolbar can remove the sandbox for the current tab occurrence; the choice is not persisted. An unsandboxed page that reaches the DSH origin can access that origin's Web data. The package does not proxy or probe remote pages. + +Web records toolbar submissions and typed tab opens. A navigation state machine treats the first iframe load for each controlled revision as known and a later load as proof that the page changed to an unreadable URL. In that unknown state the address is marked, Back, Forward, and external-open are disabled, and Reload returns to the last controlled URL. A remounted body reloads the latest application-known URL and uses its optional initial URL only before the first controlled target. History API and fragment changes that emit no iframe load remain invisible. + +### Controller + +Each tab receives one `BrowserController` class. Its public commands are `loadUrl`, `goBack`, `goForward`, and `reload`; address validation and history mutation stay behind that object. Its `BrowserNavigation` class owns the serializable URL state machine. The `BrowserFrame` interface owns transient sandbox and document state plus carrier operations, and `IframeImpl` implements that interface for the current iframe carrier. Slot injection exposes keyed frame state through `useBrowserFrame` and supplies plain callbacks, so the React body receives neither the controller nor an observable source; it keeps only the editable draft and iframe DOM. + +The controller interface does not depend on iframe APIs. A future `ElectronWebViewImpl` can implement `BrowserFrame` while owning `` attachment and target identity. That deferred carrier is documented in the same Sidebar Browser decision, but is not registered or tested today. + +
    + +----- + + +## Further Exploration + +- [Right Sidebar](../../../docs/subsystems/sidebar-right.md) — tab composition, navigation, and lifecycle. +- [Document Preview](../ui-sidebar-documentpreview/README.md) — local source, Markdown, images, HTML, and PDF rendering. +- [Sidebar Browser decision](../../../.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md) — current iframe behavior, controller ownership, and deferred Electron carrier. + +----- + + +## Model Experience + +None, as Browser tabs are user-facing presentation state and register no tool, prompt section, or Session event. + +#### KV Cache effect + +None; browsing does not enter a model request. + +## Known Limitations and Deferred Work + + + +The isolation policy deliberately gives up some browser compatibility: + +- Many sites refuse iframe embedding or need downloads or top-level navigation withheld by the default sandbox. Disabling the sandbox trades those restrictions for compatibility and permits controller-directed loopback navigation. It does not isolate the visited origin's cookies per Browser tab and cannot prevent an in-frame page from choosing its own next URL. +- A later iframe load reveals that navigation occurred but not the new cross-origin URL. History API and fragment changes may remain invisible; Web Back and Forward are unavailable after the state becomes unknown. +- Local files are rejected and remain owned by Document Preview. +- The proposed Electron `` carrier, per-tab cookie partitions, native history, and target-specific CDP connection are not implemented. + + +### Dev Note + +
    +Working context for maintainers — click to expand + +None. + +
    + +**Runtime invariant:** No companion is published. `BrowserNavigation` is the sole URL-state writer; the store receives its immutable snapshots, and focused controller and component tests exercise publication and cleanup directly. diff --git a/packages/client/ui-sidebar-browser/README.zh.md b/packages/client/ui-sidebar-browser/README.zh.md new file mode 100644 index 0000000000..c482ca8ec5 --- /dev/null +++ b/packages/client/ui-sidebar-browser/README.zh.md @@ -0,0 +1,114 @@ +--- +description: "右侧 Sidebar 浏览器 tab:隔离访问 HTTPS 页面与选择性开放的 loopback 服务。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-browser + +[English](README.md) | 中文 + +## 概述 + +在独立的右侧 Sidebar tab 中浏览 HTTPS 页面与选择性开放的 loopback HTTP 服务。当前 Web 与 Desktop 都使用 iframe 和应用维护的 history。本包不会向被访问内容注入 Electron 或 Node 能力。 + +## 目录 + +- [使用本包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +随附的 Web 与 Desktop composition 已挂载本包。从右侧 Sidebar guide 打开 **浏览器**,再输入 HTTPS URL 或应补全为 HTTPS 的主机名。打开 loopback HTTP URL 前,需要关闭该 tab 的 sandbox。每次 guide 操作都会创建一个新的 Browser tab。 + +### 何时选择 + +当 Web 页面需要保留在当前 Session 旁时,选择 Browser。本地文件使用 [Document Preview](../ui-sidebar-documentpreview/README.zh.md);站点拒绝 iframe 嵌入或需要本包不授予的浏览器 capability 时,使用明确的外部浏览器操作。 + +### 最小配置 + +本包没有配置字段。自定义 Web composition 挂载 Host companion;随后 Client loader 会发现 package manifest 声明的浏览器入口: + +```yaml +- id: ui-sidebar-browser + name: '@deepseek-ai/dsh-client-ui-sidebar-browser' +``` + +Client 插件可以调用 `ctx.sidebarRight.openTab('browser', { params: { url } })` 打开 tab。可选 URL 会在导航前接受与地址栏输入相同的校验。 + +工具栏提供后退、前进、刷新、前往、在系统浏览器中打开,以及最右侧的逐 tab sandbox 开关。关闭 sandbox 是临时选择,会显示警告并允许 controller 发起 loopback 导航。外部打开接受已知的 HTTPS 或 loopback HTTP 目标。tab 标题显示 Web 主机名。 + +----- + + +## 了解实现 + +
    +实现细节——点击展开 + +### 协议策略 + +地址解析器接受 HTTPS,以及仅指向 `localhost`、`[::1]` 与 `127.0.0.0/8` 的 HTTP。controller 只在当前 tab 的 sandbox 关闭时允许这些 loopback 目标。公共 HTTP、`file:` URL、脚本/data/blob 输入、内嵌凭据、DSH 应用自身 origin 和畸形地址会被拒绝。本地文件由 Document Preview 负责渲染。 + +### Iframe 载体 + +Web 与 Desktop 默认使用 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`,不授予下载或顶层导航能力。popup 会脱离 sandbox。被访问的 origin 可以使用自身 Cookie 与 Web storage,但跨域目标无法读取 DSH DOM、storage 或 API 响应。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。toolbar 可以为当前 tab occurrence 移除 sandbox;该选择不持久化。未受 sandbox 约束的页面一旦到达 DSH origin,就可以访问该 origin 的 Web 数据。本包不代理或探测远程页面。 + +Web 记录 toolbar 提交和 typed tab 打开。导航状态机把每个受控 revision 的第一次 iframe load 视为已知,把后续 load 视为页面已经变化到不可读取 URL 的证据。进入 unknown 状态后,地址会显示标记,后退、前进和外部打开会禁用,刷新则返回最后一个受控 URL。body 重挂载时会重新加载应用最后已知的 URL,并且仅在尚无受控目标时使用可选初始 URL。不产生 iframe load 的 History API 与 fragment 变化仍不可见。 + +### Controller + +每个 tab 获得一个 `BrowserController` class。它的公开命令只有 `loadUrl`、`goBack`、`goForward` 与 `reload`;地址校验与 history 变更均由该对象封装。它的 `BrowserNavigation` class 拥有可序列化的 URL 状态机。`BrowserFrame` 接口负责临时 sandbox 与 document 状态以及载体操作,`IframeImpl` 为当前 iframe 载体实现该接口。Slot injection 通过 `useBrowserFrame` 提供按 key 索引的 frame 状态,并提供普通 callback,因此 React body 不接收 controller 或 observable source;它只保留可编辑草稿与 iframe DOM。 + +Controller 接口不依赖 iframe API。未来的 `ElectronWebViewImpl` 可以实现 `BrowserFrame`,并持有 `` attachment 与 target identity。该延期载体记录在同一份 Sidebar Browser 决策中,当前不注册也不测试。 + +
    + +----- + + +## 延伸阅读 + +- [右侧 Sidebar](../../../docs/subsystems/sidebar-right.zh.md)——tab composition、导航与生命周期。 +- [Document Preview](../ui-sidebar-documentpreview/README.zh.md)——本地源码、Markdown、图片、HTML 与 PDF 渲染。 +- [Sidebar Browser 决策](../../../.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md)——当前 iframe 行为、controller 所有权与延期 Electron 载体。 + +----- + + +## 模型体验 + +无。Browser tab 是用户侧呈现状态,不注册工具、prompt section 或 Session event。 + +#### KV Cache 影响 + +无;浏览内容不进入模型请求。 + +## 已知限制与延期工作 + + + +隔离策略有意放弃部分浏览器兼容性: + +- 很多站点拒绝 iframe 嵌入,或需要默认 sandbox 不授予的下载与顶层导航。关闭 sandbox 会用这些限制换取兼容性,并允许 controller 发起 loopback 导航。该模式不会按 Browser tab 隔离被访问 origin 的 Cookie,也无法阻止 iframe 内页面自行选择后续 URL。 +- 后续 iframe load 能表明发生了导航,但无法给出新的跨域 URL。History API 与 fragment 变化可能仍不可见;状态变成 unknown 后,Web 的后退与前进不可用。 +- 本地文件会被拒绝,并继续由 Document Preview 负责。 +- 拟议的 Electron `` 载体、per-tab Cookie partition、原生 history 和 target-specific CDP 连接尚未实现。 + + +### 开发备注 + +
    +维护者工作上下文——点击展开 + +无。 + +
    + +**运行时不变量:** 不发布 companion。`BrowserNavigation` 是唯一的 URL 状态写入方;store 接收它的 immutable snapshot,controller 与组件的聚焦测试直接覆盖发布与清理。 diff --git a/packages/client/ui-sidebar-browser/package.json b/packages/client/ui-sidebar-browser/package.json new file mode 100644 index 0000000000..32037f09b2 --- /dev/null +++ b/packages/client/ui-sidebar-browser/package.json @@ -0,0 +1,66 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-sidebar-browser", + "description": "Sandboxed Web browser tabs for the right Sidebar", + "version": "0.1.6-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-sidebar-browser" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-client-ui-session" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts b/packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts new file mode 100644 index 0000000000..83134b7c96 --- /dev/null +++ b/packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts @@ -0,0 +1,179 @@ +/** Per-tab Browser controller. */ +import type { BoundActions } from '@deepseek-ai/dsh-client-store' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' +import { IframeImpl } from './BrowserFrame.ts' +import type { BrowserFrame, BrowserFrameState } from './BrowserFrame.ts' +import { BrowserNavigation } from './BrowserNavigation.ts' +import type { BrowserTabState } from './BrowserNavigation.ts' +import type { BrowserStore } from './store.ts' +import { isLoopbackHostname, parseBrowserAddress } from './url.ts' +import type { BrowserTarget } from './url.ts' + +/** Construction dependencies for one tab-scoped Browser controller. */ +export interface BrowserControllerOptions { + readonly tabId: TabId + readonly signal: AbortSignal + readonly applicationOrigin: string + readonly initial?: BrowserTabState + readonly actions: BoundActions +} + +/** Owns one Browser tab's URL state, loading lifecycle, and four navigation commands. */ +export class BrowserController { + /** Renderer-facing iframe and sandbox state. */ + readonly frame: BrowserFrame + private readonly navigation: BrowserNavigation + private disposed = false + + /** + * @param options - tab identity, persistence writer, URL origin, and lifetime. + */ + constructor(private readonly options: BrowserControllerOptions) { + this.navigation = new BrowserNavigation(options.initial) + this.frame = new IframeImpl( + () => { this.reload() }, + (revision) => { this.frameLoaded(revision) }, + ) + options.signal.addEventListener('abort', () => { this.dispose() }, { once: true }) + } + + /** + * Validate and load one address, replacing a same-address load with Reload. + * @param value - address-bar or typed-tab value. + */ + loadUrl(value: string): void { + if (this.disposed) return + const parsed = parseBrowserAddress(value, this.options.applicationOrigin) + if (!parsed.ok) { + this.navigation.addressFailed(parsed.reason) + this.publish() + return + } + if (this.loopbackBlocked(parsed.target)) { + this.navigation.addressFailed('loopback') + this.publish() + return + } + const current = BrowserNavigation.current(this.navigation.snapshot) + if (current?.url === parsed.target.url) { + this.reload() + return + } + this.start(this.navigation.navigate(parsed.target)) + } + + /** Move to the preceding application-known address when Web history remains usable. */ + goBack(): void { + if (this.disposed) return + const request = this.navigation.back() + if (request !== undefined) this.start(request) + } + + /** Move to the following application-known address when Web history remains usable. */ + goForward(): void { + if (this.disposed) return + const request = this.navigation.forward() + if (request !== undefined) this.start(request) + } + + /** Reload the last application-known address without adding history. */ + reload(): void { + if (this.disposed) return + const request = this.navigation.reload() + if (request !== undefined) this.start(request) + } + + private start(request: NonNullable): void { + this.publish() + this.frame.clearDocument() + if (this.loopbackBlocked(request.target)) { + this.navigation.requestBlocked(request.revision, 'loopback') + this.publish() + return + } + this.frame.setDocument({ target: request.target, src: request.target.url, revision: request.revision }) + } + + private frameLoaded(revision: number): void { + if (this.disposed) return + const previous = this.navigation.snapshot + this.navigation.frameLoaded(revision) + if (this.navigation.snapshot !== previous) this.publish() + } + + private loopbackBlocked(target: BrowserTarget): boolean { + return this.frame.getSnapshot().sandboxed + && isLoopbackHostname(new URL(target.url).hostname) + } + + private publish(): void { + this.options.actions.replace(this.options.tabId, this.navigation.snapshot) + } + + private dispose(): void { + this.disposed = true + this.frame.clearDocument() + } +} + +/** Browser commands and keyed frame state injected into the tab body. */ +export interface BrowserInjected { + readonly keyedHooks: { + readonly browserFrame: (key: string) => HostObservable | undefined + } + /** + * @param tabId - tab occurrence. + * @param signal - occurrence lifetime. + * @param applicationOrigin - current application origin. + * @param initial - persisted tab state. + */ + mount(tabId: TabId, signal: AbortSignal, applicationOrigin: string, initial?: BrowserTabState): void + /** @param tabId - tab occurrence. @param value - address-bar or local-document link value. */ + loadUrl(tabId: TabId, value: string): void + /** @param tabId - tab occurrence. */ + goBack(tabId: TabId): void + /** @param tabId - tab occurrence. */ + goForward(tabId: TabId): void + /** @param tabId - tab occurrence. */ + reload(tabId: TabId): void + /** @param tabId - tab occurrence. */ + toggleSandbox(tabId: TabId): void + /** @param tabId - tab occurrence. @param revision - rendered document revision. */ + reportLoaded(tabId: TabId, revision: number): void +} + +/** + * Bind Browser controllers to one Session and its persistence writer. + * @param actions - Browser store mutation face. + * @returns a per-tab controller registry exposed as plain Slot callbacks. + */ +export function createBrowserControllers( + actions: BoundActions, +): BrowserInjected { + const controllers = new Map() + const controller = (tabId: TabId): BrowserController | undefined => controllers.get(tabId)?.controller + return { + keyedHooks: { browserFrame: key => controller(key as TabId)?.frame }, + mount(tabId, signal, applicationOrigin, initial) { + const held = controllers.get(tabId) + if (held?.signal === signal) return + const created = new BrowserController({ + tabId, signal, applicationOrigin, actions, + ...(initial === undefined ? {} : { initial }), + }) + controllers.set(tabId, { signal, controller: created }) + signal.addEventListener('abort', () => { + if (controllers.get(tabId)?.controller !== created) return + controllers.delete(tabId) + actions.forget(tabId) + }, { once: true }) + }, + loadUrl: (tabId, value) => { controller(tabId)?.loadUrl(value) }, + goBack: (tabId) => { controller(tabId)?.goBack() }, + goForward: (tabId) => { controller(tabId)?.goForward() }, + reload: (tabId) => { controller(tabId)?.reload() }, + toggleSandbox: (tabId) => { controller(tabId)?.frame.toggleSandbox() }, + reportLoaded: (tabId, revision) => { controller(tabId)?.frame.reportLoaded(revision) }, + } +} diff --git a/packages/client/ui-sidebar-browser/src/client/browser/BrowserFrame.ts b/packages/client/ui-sidebar-browser/src/client/browser/BrowserFrame.ts new file mode 100644 index 0000000000..2d2b1a9c0e --- /dev/null +++ b/packages/client/ui-sidebar-browser/src/client/browser/BrowserFrame.ts @@ -0,0 +1,90 @@ +/** BrowserFrame interface and the current iframe implementation. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' +import type { BrowserTarget } from './url.ts' + +/** One prepared document rendered by a BrowserFrame implementation. */ +export interface BrowserDocument { + readonly target: BrowserTarget + readonly src: string + readonly revision: number +} + +/** Current renderer-facing state for one Browser tab. */ +export interface BrowserFrameState { + readonly document: BrowserDocument | undefined + readonly sandboxed: boolean +} + +/** Browser rendering operations shared by Web and future Electron implementations. */ +export interface BrowserFrame extends HostObservable { + /** Toggle sandbox enforcement for this tab occurrence. */ + toggleSandbox(): void + /** @param document - current prepared document. */ + setDocument(document: BrowserDocument): void + /** @returns the detached document, if one existed. */ + clearDocument(): BrowserDocument | undefined + /** @param revision - rendered document revision reported by the carrier. */ + reportLoaded(revision: number): void +} + +/** Owns transient iframe and sandbox-toggle state independently from URL navigation. */ +export class IframeImpl implements BrowserFrame { + private readonly store: SnapshotStore = createSnapshotStore({ + document: undefined, + sandboxed: true, + }) + + /** + * @param sandboxChanged - applies the new policy to the current controlled target. + * @param documentLoaded - reports an iframe load to the navigation state machine. + */ + constructor( + private readonly sandboxChanged: (sandboxed: boolean) => void, + private readonly documentLoaded: (revision: number) => void, + ) {} + + /** @returns the immutable renderer snapshot. */ + getSnapshot = (): BrowserFrameState => this.store.getSnapshot() + + /** + * Subscribe to iframe or sandbox-mode changes. + * @param listener - invalidation callback. + * @returns the unsubscribe function. + */ + subscribe = (listener: () => void): (() => void) => this.store.subscribe(listener) + + /** Toggle sandbox enforcement for this tab occurrence. */ + toggleSandbox(): void { + const current = this.store.getSnapshot() + const sandboxed = !current.sandboxed + this.store.set({ ...current, sandboxed }) + this.sandboxChanged(sandboxed) + } + + /** + * Publish a prepared frame from the owning controller. + * @param document - current prepared document. + * @internal + */ + setDocument(document: BrowserDocument): void { + this.store.set({ ...this.store.getSnapshot(), document }) + } + + /** + * Remove and return the previous frame for resource cleanup. + * @returns the detached frame, if one existed. + * @internal + */ + clearDocument(): BrowserDocument | undefined { + const document = this.store.getSnapshot().document + if (document !== undefined) this.store.set({ ...this.store.getSnapshot(), document: undefined }) + return document + } + + /** @param revision - rendered document revision reported by the iframe. */ + reportLoaded(revision: number): void { + this.documentLoaded(revision) + } +} diff --git a/packages/client/ui-sidebar-browser/src/client/browser/BrowserNavigation.ts b/packages/client/ui-sidebar-browser/src/client/browser/BrowserNavigation.ts new file mode 100644 index 0000000000..751eb24197 --- /dev/null +++ b/packages/client/ui-sidebar-browser/src/client/browser/BrowserNavigation.ts @@ -0,0 +1,190 @@ +/** URL, history, and observability state for one Browser tab. */ +import type { BrowserAddressFailure, BrowserTarget } from './url.ts' + +/** Maximum retained application-known navigation entries per tab. */ +export const MAX_BROWSER_HISTORY = 100 + +/** One canonical address in the application-managed Web history. */ +export type BrowserHistoryEntry = BrowserTarget + +/** Whether the current carrier document still corresponds to an application-known URL. */ +export type BrowserNavigationStatus = + | { readonly status: 'empty' } + | { readonly status: 'loading'; readonly revision: number } + | { readonly status: 'known'; readonly revision: number } + | { readonly status: 'unknown'; readonly revision: number } + | { readonly status: 'failed'; readonly revision: number } + +/** Address-policy or loading failure shown below the toolbar. */ +export type BrowserFailure = + { readonly kind: 'address'; readonly reason: BrowserAddressFailure } + +/** One Browser tab's serializable URL state. */ +export interface BrowserTabState { + readonly entries: readonly BrowserHistoryEntry[] + readonly index: number + /** Last application-directed load; carrier observations do not rewrite it. */ + readonly request: { readonly revision: number; readonly target: BrowserTarget } | undefined + readonly navigation: BrowserNavigationStatus + readonly failure: BrowserFailure | undefined +} + +/** + * Owns the application-known URL history and the iframe observation state machine. + * The first load for a request keeps its URL authoritative; another load marks it unknown. + */ +export class BrowserNavigation { + private value: BrowserTabState + + /** + * @param initial - persisted state restored for this tab, or a fresh empty state. + */ + constructor(initial: BrowserTabState = BrowserNavigation.empty()) { + this.value = initial + } + + /** + * Create state before a tab has a controlled navigation target. + * @returns empty serializable state. + */ + static empty(): BrowserTabState { + return { entries: [], index: -1, request: undefined, navigation: { status: 'empty' }, failure: undefined } + } + + /** + * Read the selected application-history entry. + * @param state - serializable tab state. + * @returns the current target, if any. + */ + static current(state: BrowserTabState | undefined): BrowserHistoryEntry | undefined { + return state === undefined || state.index < 0 ? undefined : state.entries[state.index] + } + + /** + * Test whether the Web carrier can use the preceding application-history entry. + * @param state - serializable tab state. + * @returns whether Back is available. + */ + static canGoBack(state: BrowserTabState): boolean { + return state.navigation.status !== 'unknown' && state.index > 0 + } + + /** + * Test whether the Web carrier can use the following application-history entry. + * @param state - serializable tab state. + * @returns whether Forward is available. + */ + static canGoForward(state: BrowserTabState): boolean { + return state.navigation.status !== 'unknown' + && state.index >= 0 + && state.index < state.entries.length - 1 + } + + /** Current immutable serializable state. */ + get snapshot(): BrowserTabState { + return this.value + } + + /** Whether the Web iframe can safely use the application-owned Back entry. */ + get canGoBack(): boolean { + return BrowserNavigation.canGoBack(this.value) + } + + /** Whether the Web iframe can safely use the application-owned Forward entry. */ + get canGoForward(): boolean { + return BrowserNavigation.canGoForward(this.value) + } + + /** + * Add a controlled target and discard its stale forward branch. + * @param target - validated canonical target. + * @returns the new load request. + */ + navigate(target: BrowserTarget): NonNullable { + const entries = [...this.value.entries.slice(0, this.value.index + 1), target] + if (entries.length > MAX_BROWSER_HISTORY) entries.splice(0, entries.length - MAX_BROWSER_HISTORY) + return this.request(target, { ...this.value, entries, index: entries.length - 1 }) + } + + /** + * Select the preceding application-known target. + * @returns a new load request, or undefined when unavailable. + */ + back(): BrowserTabState['request'] { + if (!this.canGoBack) return undefined + const index = this.value.index - 1 + const target = this.value.entries[index] as BrowserHistoryEntry + return this.request(target, { ...this.value, index }) + } + + /** + * Select the following application-known target. + * @returns a new load request, or undefined when unavailable. + */ + forward(): BrowserTabState['request'] { + if (!this.canGoForward) return undefined + const index = this.value.index + 1 + const target = this.value.entries[index] as BrowserHistoryEntry + return this.request(target, { ...this.value, index }) + } + + /** + * Start another load of the last application-known target. + * @returns a new load request, or undefined before the first target. + */ + reload(): BrowserTabState['request'] { + const target = BrowserNavigation.current(this.value) + return target === undefined ? undefined : this.request(target, this.value) + } + + /** + * Record an invalid address without changing the active document state. + * @param reason - parser refusal. + */ + addressFailed(reason: BrowserAddressFailure): void { + this.value = { ...this.value, failure: { kind: 'address', reason } } + } + + /** + * Record a frame load for its captured revision. + * @param revision - revision bound to the rendered frame. + */ + frameLoaded(revision: number): void { + const navigation = this.value.navigation + if (navigation.status === 'empty' || navigation.revision !== revision) return + if (navigation.status === 'loading') { + this.value = { ...this.value, navigation: { status: 'known', revision } } + } else if (navigation.status === 'known') { + this.value = { ...this.value, navigation: { status: 'unknown', revision } } + } + } + + /** + * Refuse a current request after its target becomes disallowed by carrier policy. + * @param revision - blocked request revision. + * @param reason - address-policy refusal. + */ + requestBlocked(revision: number, reason: BrowserAddressFailure): void { + const navigation = this.value.navigation + if (navigation.status !== 'loading' || navigation.revision !== revision) return + this.value = { + ...this.value, + navigation: { status: 'failed', revision }, + failure: { kind: 'address', reason }, + } + } + + private request( + target: BrowserTarget, + basis: BrowserTabState, + ): NonNullable { + const request = { revision: (this.value.request?.revision ?? 0) + 1, target } + this.value = { + ...basis, + request, + navigation: { status: 'loading', revision: request.revision }, + failure: undefined, + } + return request + } +} diff --git a/packages/client/ui-sidebar-browser/src/client/browser/store.ts b/packages/client/ui-sidebar-browser/src/client/browser/store.ts new file mode 100644 index 0000000000..4702f7c7c1 --- /dev/null +++ b/packages/client/ui-sidebar-browser/src/client/browser/store.ts @@ -0,0 +1,38 @@ +/** Persisted Browser tab snapshots shared by the body and title slots. */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { BrowserTabState } from './BrowserNavigation.ts' + +/** All Browser tabs in one Session-scoped store. */ +export interface BrowserState { + byTab: Record +} + +type BrowserActions = { + replace: (draft: BrowserState, tabId: TabId, state: BrowserTabState) => void + forget: (draft: BrowserState, tabId: TabId) => void +} + +/** + * Declare the Session-scoped Browser persistence store. + * @returns a fresh store handle for Slot registration. + */ +export function createBrowserStore(): EngineStoreHandle { + return defineStore({ + init: (): BrowserState => ({ byTab: {} }), + persist: 'dsh.sidebar-browser.v1', + actions: { + replace: (draft, tabId: TabId, state: BrowserTabState) => { draft.byTab[tabId] = state }, + forget: (draft, tabId: TabId) => { + const byTab: BrowserState['byTab'] = {} + for (const [id, state] of Object.entries(draft.byTab) as [TabId, BrowserTabState][]) { + if (id !== tabId) byTab[id] = state + } + draft.byTab = byTab + }, + }, + }) +} + +/** Browser store handle shared by body and title registrations. */ +export type BrowserStore = ReturnType diff --git a/packages/client/ui-sidebar-browser/src/client/browser/url.ts b/packages/client/ui-sidebar-browser/src/client/browser/url.ts new file mode 100644 index 0000000000..34cc74a6c4 --- /dev/null +++ b/packages/client/ui-sidebar-browser/src/client/browser/url.ts @@ -0,0 +1,58 @@ +/** Address parsing for the Sidebar browser's HTTPS and loopback HTTP allowlist. */ + +/** Maximum accepted address length; this bounds persisted navigation state. */ +export const MAX_BROWSER_URL_LENGTH = 16 * 1024 + +/** A normalized Browser navigation target. */ +export type BrowserTarget = + | { readonly kind: 'https'; readonly url: string; readonly title: string } + | { readonly kind: 'http'; readonly url: string; readonly title: string } + +/** Why an address was refused before navigation. */ +export type BrowserAddressFailure = 'empty' | 'invalid' | 'protocol' | 'credentials' | 'application-origin' | 'loopback' + +/** Result of parsing an address-bar value. */ +export type BrowserAddressResult = + | { readonly ok: true; readonly target: BrowserTarget } + | { readonly ok: false; readonly reason: BrowserAddressFailure } + +/** + * Parse one address-bar value into the fixed protocol allowlist. + * @param input - user or typed-open input. + * @param applicationOrigin - current DSH document origin, blocked for HTTPS. + * @returns a canonical target or the refusal reason. + */ +export function parseBrowserAddress(input: string, applicationOrigin?: string): BrowserAddressResult { + const trimmed = input.trim() + if (trimmed === '') return { ok: false, reason: 'empty' } + if (trimmed.length > MAX_BROWSER_URL_LENGTH) return { ok: false, reason: 'invalid' } + const explicitScheme = /^[A-Za-z][A-Za-z\d+.-]*:\/\//u.test(trimmed) + const nonHierarchicalScheme = /^(?:about|blob|data|javascript|mailto|tel|view-source):/iu.test(trimmed) + const candidate = explicitScheme || nonHierarchicalScheme ? trimmed : `https://${trimmed}` + let url: URL + try { url = new URL(candidate) } catch { return { ok: false, reason: 'invalid' } } + if (url.username !== '' || url.password !== '') return { ok: false, reason: 'credentials' } + if (url.protocol === 'https:' || (url.protocol === 'http:' && isLoopbackHostname(url.hostname))) { + if (applicationOrigin !== undefined && applicationOrigin !== 'null') { + try { + if (url.origin === new URL(applicationOrigin).origin) return { ok: false, reason: 'application-origin' } + } catch { + // An unavailable application origin cannot grant access to a target. + } + } + return { ok: true, target: { kind: url.protocol === 'https:' ? 'https' : 'http', url: url.href, title: url.hostname } } + } + return { ok: false, reason: 'protocol' } +} + +/** + * Test whether a normalized URL host belongs to the local loopback interface. + * @param hostname - URL hostname with IPv6 brackets retained. + * @returns whether the hostname is localhost, IPv6 loopback, or IPv4 127/8. + */ +export function isLoopbackHostname(hostname: string): boolean { + if (hostname === 'localhost' || hostname === '[::1]') return true + const octets = hostname.split('.') + return octets.length === 4 && octets[0] === '127' + && octets.every(part => /^\d{1,3}$/u.test(part) && Number(part) <= 255) +} diff --git a/packages/client/ui-sidebar-browser/src/client/definition.tsx b/packages/client/ui-sidebar-browser/src/client/definition.tsx new file mode 100644 index 0000000000..f176d15ec4 --- /dev/null +++ b/packages/client/ui-sidebar-browser/src/client/definition.tsx @@ -0,0 +1,26 @@ +/** Static Browser tab type and guide declaration. */ +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' +import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type {} from './locales.ts' + +/** Browser tab kind. */ +export const BROWSER_KIND = 'browser' + +/** Browser implementation identity and keyed Slot dispatch key. */ +export const BROWSER_ID = '@deepseek-ai/dsh-client-ui-sidebar-browser' + +/** Build the Browser type with locale-live copy. */ +export function browserDefinition(t: TranslateNS<'sidebarBrowser'>): SidebarRightTabDefinition { + return { + id: BROWSER_ID, + kind: BROWSER_KIND, + multiple: true, + priority: 'builtin', + title: () => t('type.label'), + guide: [{ + id: 'new', order: 30, title: () => t('guide.title'), + description: () => t('guide.description'), icon: IconGlobeOutline14, + }], + } +} diff --git a/packages/client/ui-sidebar-browser/src/client/index.ts b/packages/client/ui-sidebar-browser/src/client/index.ts new file mode 100644 index 0000000000..8af71de477 --- /dev/null +++ b/packages/client/ui-sidebar-browser/src/client/index.ts @@ -0,0 +1,46 @@ +/** Register the HTTPS Browser tab type in the right Sidebar. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import { BrowserBody } from './view/BrowserBody.tsx' +import { BrowserTitle } from './view/BrowserTitle.tsx' +import { createBrowserControllers } from './browser/BrowserController.ts' +import { BROWSER_ID, browserDefinition } from './definition.tsx' +import { en, zh } from './locales.ts' +import { createBrowserStore } from './browser/store.ts' + +export type { BrowserBodyProps } from './view/BrowserBody.tsx' +export type { BrowserInjected } from './browser/BrowserController.ts' +export type { BrowserDocument, BrowserFrame, BrowserFrameState } from './browser/BrowserFrame.ts' +export type { BrowserFailure, BrowserHistoryEntry, BrowserNavigationStatus, BrowserTabState } from './browser/BrowserNavigation.ts' +export type { SidebarBrowserKey } from './locales.ts' +export type { BrowserState } from './browser/store.ts' +export type { BrowserAddressFailure, BrowserAddressResult, BrowserTarget } from './browser/url.ts' + +declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' { + interface SidebarRightTabParamsMap { + /** Optional initial Browser URL. */ + browser: { readonly url?: string } + } +} + +/** Required Browser services. */ +export const inject = ['slots', 'locale', 'sidebarRightTabs'] + +/** Register the Browser type, localized guide entry, body, and title. */ +export function apply(ctx: Context): void { + const namespace = 'sidebarBrowser' + const t = ctx.locale.bind(namespace) + const store = createBrowserStore() + ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'ui-sidebar-browser.copy') + ctx.effect(() => ctx.sidebarRightTabs.register(browserDefinition(t)), 'ui-sidebar-browser.type') + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab', key: BROWSER_ID, locale: namespace, store, + inject: (_sessionId, actions) => createBrowserControllers(actions), + }, BrowserBody)), 'ui-sidebar-browser.body') + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab.title', key: BROWSER_ID, store, + }, BrowserTitle)), 'ui-sidebar-browser.title') +} diff --git a/packages/client/ui-sidebar-browser/src/client/locales.ts b/packages/client/ui-sidebar-browser/src/client/locales.ts new file mode 100644 index 0000000000..7d22b4c5da --- /dev/null +++ b/packages/client/ui-sidebar-browser/src/client/locales.ts @@ -0,0 +1,61 @@ +/** Locale-owned Browser tab copy. */ +export const zh = { + 'type.label': '浏览器', + 'guide.title': '浏览器', + 'guide.description': '浏览 HTTPS 网页或 loopback 服务', + 'address.placeholder': '输入 HTTPS 或本地 HTTP 地址', + 'address.changed': 'URL 已变化', + back: '后退', + forward: '前进', + reload: '刷新', + go: '前往', + external: '在系统浏览器中打开', + 'sandbox.disable': '关闭沙箱限制并允许 loopback', + 'sandbox.enable': '恢复沙箱限制并禁止 loopback', + 'sandbox.warning': '沙箱限制已关闭;页面若到达 DSH 同源地址,可以访问该 origin 的网页数据。', + start: '输入 HTTPS 地址开始浏览', + loading: '正在打开…', + 'error.empty': '请输入地址。', + 'error.invalid': '这个地址无效或过长。', + 'error.protocol': '只支持 HTTPS 和 loopback HTTP 地址;本地文件请使用文档预览。', + 'error.credentials': '地址不能包含用户名或密码。', + 'error.application-origin': '不能在嵌入浏览器中打开 DSH 应用自身。', + 'error.loopback': '关闭沙箱限制后才能打开 loopback 地址。', + 'web.unknown': '页面已在 iframe 内跳转;Web 模式无法读取当前 URL。', +} satisfies Record + +/** Browser dictionary key union. */ +export type SidebarBrowserKey = keyof typeof zh + +/** English dictionary with the same keys. */ +export const en = { + 'type.label': 'Browser', + 'guide.title': 'Browser', + 'guide.description': 'Browse HTTPS pages or loopback services', + 'address.placeholder': 'Enter an HTTPS or local HTTP address', + 'address.changed': 'URL changed', + back: 'Back', + forward: 'Forward', + reload: 'Reload', + go: 'Go', + external: 'Open in system browser', + 'sandbox.disable': 'Disable sandbox and allow loopback', + 'sandbox.enable': 'Restore sandbox and block loopback', + 'sandbox.warning': 'Sandbox restrictions are disabled; a page that reaches the DSH origin can access its Web data.', + start: 'Enter an HTTPS address to start browsing', + loading: 'Opening…', + 'error.empty': 'Enter an address.', + 'error.invalid': 'That address is invalid or too long.', + 'error.protocol': 'Only HTTPS and loopback HTTP addresses are supported; use Document Preview for local files.', + 'error.credentials': 'Addresses cannot contain a username or password.', + 'error.application-origin': 'The embedded browser cannot open the DSH application itself.', + 'error.loopback': 'Disable sandbox restrictions before opening a loopback address.', + 'web.unknown': 'The page navigated inside the iframe; Web mode cannot read its current URL.', +} satisfies Record + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Sidebar Browser labels, navigation controls, and failures. */ + sidebarBrowser: SidebarBrowserKey + } +} diff --git a/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css b/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css new file mode 100644 index 0000000000..0914d8e6f5 --- /dev/null +++ b/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css @@ -0,0 +1,167 @@ +.root { + display: flex; + flex: 1 1 auto; + flex-direction: column; + height: 100%; + min-height: 0; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-base); +} + +.toolbar { + display: flex; + flex: 0 0 auto; + gap: 4px; + align-items: center; + box-sizing: border-box; + height: 38px; + padding: 5px 6px; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); +} + +.tool { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + color: var(--dsw-alias-label-secondary); + background: transparent; + border: 0; + border-radius: 6px; + cursor: pointer; +} + +.tool:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.tool:disabled { + color: var(--dsw-alias-label-quaternary); + cursor: default; +} + +.sandboxOff { + color: var(--dsw-alias-state-error-primary); + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); +} + +.addressBox { + position: relative; + flex: 1 1 auto; + min-width: 0; +} + +.address { + width: 100%; + height: 28px; + box-sizing: border-box; + padding: 0 34px 0 9px; + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xxs-12); + background: var(--dsw-alias-bg-layer-1); + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 6px; +} + +.addressGo { + position: absolute; + top: 0; + right: 0; + visibility: hidden; + opacity: 0; +} + +.addressBox:focus-within .addressGo { + visibility: visible; + opacity: 1; +} + +.addressUnknown { + color: var(--dsw-alias-label-tertiary); + background: var(--dsw-alias-bg-layer-2); +} + +.addressUnknown:focus { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-layer-1); +} + +.addressChanged { + position: absolute; + top: 6px; + right: 8px; + padding-left: 8px; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xxxs-11); + line-height: 16px; + pointer-events: none; + background: var(--dsw-alias-bg-layer-2); +} + +.addressBox:focus-within .addressChanged { + visibility: hidden; + opacity: 0; +} + +.address:focus { + outline: 1px solid var(--dsw-alias-brand-primary-new-colorprimary-new-color); + outline-offset: -1px; +} + +.frame { + display: flex; + flex: 1 1 auto; + width: 100%; + min-height: 0; + border: 0; + background: var(--dsw-alias-bg-base); +} + +.start { + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + min-height: 0; + padding: 24px; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); + text-align: center; +} + +.failure { + flex: 0 0 auto; + padding: 6px 12px; + color: var(--dsw-alias-state-error-primary); + font: var(--dsw-font-xxxs-11); + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); +} + +.sandboxWarning { + flex: 0 0 auto; + padding: 6px 12px; + color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)); + font: var(--dsw-font-xxxs-11); + background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 8%, transparent); +} + +.limit { + flex: 0 0 auto; + margin: 0; + padding: 4px 10px; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xxxs-11); + text-overflow: ellipsis; + white-space: nowrap; + border-top: 0.5px solid var(--dsw-alias-border-l3); +} + +.titleIcon { + flex: 0 0 auto; + margin-right: 4px; +} diff --git a/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx b/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx new file mode 100644 index 0000000000..ddd31cc8b5 --- /dev/null +++ b/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx @@ -0,0 +1,142 @@ +/** Browser toolbar and Web iframe renderer. */ +import { useEffect, useRef, useState } from 'react' +import type { FormEvent, ReactNode } from 'react' +import { + IconChevronLeftOutline14, + IconChevronRightOutline14, + IconLinkOutline14, + IconRefreshOutline14, + IconRightUpOutline16, + IconShieldOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { BrowserInjected } from '../browser/BrowserController.ts' +import type { BrowserFrameState } from '../browser/BrowserFrame.ts' +import { BrowserNavigation } from '../browser/BrowserNavigation.ts' +import type { BrowserAddressFailure } from '../browser/url.ts' +import type { BrowserStore } from '../browser/store.ts' +import css from './Browser.module.css' + +/** Fixed Web iframe sandbox; popups escape the sandbox while top navigation remains absent. */ +export const WEB_BROWSER_SANDBOX = 'allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox' + +const INITIAL_BROWSER_FRAME: BrowserFrameState = { document: undefined, sandboxed: true } + +/** Browser body props assembled by the tab seat. */ +export type BrowserBodyProps = PropsRuntime<'sidebar.right.pane.tab'> + & PropsStore + & PropsLocale<'sidebarBrowser'> + & InjectFace + +/** Translate one parser refusal without matching display strings in logic. */ +function failureText(reason: BrowserAddressFailure, t: BrowserBodyProps['t']): string { + return t(`error.${reason}`) +} + +function useBrowserDraft( + controlledUrl: string | undefined, + requestId: number | undefined, +): readonly [string, (value: string) => void] { + const [edit, setEdit] = useState<{ readonly requestId: number | undefined; readonly value: string }>() + const value = edit !== undefined && edit.requestId === requestId ? edit.value : controlledUrl ?? '' + return [value, (draft) => { setEdit({ requestId, value: draft }) }] +} + +/** Browser tab renderer for a controller-owned URL state and Web iframe carrier. */ +export function BrowserBody(props: BrowserBodyProps): ReactNode { + const { + goBack, goForward, loadUrl, mount, reload, reportLoaded, toggleSandbox, + useBrowserFrame, useStore, useTabInfo, t, + } = props + const { tab } = useTabInfo() + const state = useStore(snapshot => snapshot.byTab[tab.id]) ?? BrowserNavigation.empty() + const initialState = useRef(state) + const initialUrl = useRef(tab.navigation.params?.url) + const current = BrowserNavigation.current(state) + const [draft, setDraft] = useBrowserDraft(current?.url, state.request?.revision) + const [mountCount, setMountCount] = useState(0) + + useEffect(() => { + mount(tab.id, tab.signal, window.location.origin, initialState.current) + setMountCount(count => count + 1) + }, [mount, tab.id, tab.signal]) + + useEffect(() => { + if (mountCount === 0) return + const resumed = BrowserNavigation.current(initialState.current) + if (resumed !== undefined) { + reload(tab.id) + return + } + const url = initialUrl.current + if (url !== undefined) loadUrl(tab.id, url) + }, [loadUrl, mountCount, reload, tab.id]) + + const frameState = useBrowserFrame(tab.id) ?? INITIAL_BROWSER_FRAME + const { document, sandboxed } = frameState + + const navigationUnknown = state.navigation.status === 'unknown' + const externalUrl = navigationUnknown ? undefined : current?.url + const submit = (event: FormEvent): void => { event.preventDefault(); loadUrl(tab.id, draft) } + const failure = state.failure === undefined ? undefined : failureText(state.failure.reason, t) + const placeholder = current === undefined + ? t('start') + : state.navigation.status === 'failed' ? undefined : t('loading') + + return ( +
    +
    + + + +
    + { setDraft(event.currentTarget.value) }} + /> + {navigationUnknown && {t('address.changed')}} + +
    + + +
    + {!sandboxed &&
    {t('sandbox.warning')}
    } + {failure !== undefined &&
    {failure}
    } + {document === undefined + ?
    {placeholder}
    + :