1
0
Эх сурвалжийг харах

feat(desktop): load bundled Web UI before backend readiness

07akioni 3 өдөр өмнө
parent
commit
4feb87da34
48 өөрчлөгдсөн 473 нэмэгдсэн , 122 устгасан
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml
  5. 2 2
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
  6. 2 2
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md
  7. 1 1
      apps/desktop-host/src/index.ts
  8. 2 2
      apps/desktop/README.i18n.yaml
  9. 2 2
      apps/desktop/README.md
  10. 2 2
      apps/desktop/README.zh.md
  11. 3 1
      apps/desktop/src/host-process.ts
  12. 1 0
      apps/desktop/src/ipc.ts
  13. 44 6
      apps/desktop/src/main.ts
  14. 4 0
      apps/desktop/src/preload-app.ts
  15. 74 0
      apps/desktop/src/web-document.ts
  16. 65 15
      apps/desktop/tests/main-startup.spec.ts
  17. 14 0
      apps/desktop/tests/preload-app.spec.ts
  18. 63 0
      apps/desktop/tests/web-document.spec.ts
  19. 25 2
      apps/web/src/main.ts
  20. 2 2
      docs/architecture.i18n.yaml
  21. 1 1
      docs/architecture.md
  22. 1 1
      docs/architecture.zh.md
  23. 2 2
      docs/event-producer-consumer.i18n.yaml
  24. 3 2
      docs/event-producer-consumer.md
  25. 3 2
      docs/event-producer-consumer.zh.md
  26. 2 2
      packages/api/gateway/README.i18n.yaml
  27. 2 0
      packages/api/gateway/README.md
  28. 2 0
      packages/api/gateway/README.zh.md
  29. 2 1
      packages/api/gateway/src/client/stream-client.ts
  30. 14 0
      packages/api/gateway/tests/gateway.client.spec.ts
  31. 2 2
      packages/client/connection/README.i18n.yaml
  32. 2 0
      packages/client/connection/README.md
  33. 2 0
      packages/client/connection/README.zh.md
  34. 2 0
      packages/client/connection/src/client/index.ts
  35. 2 2
      packages/client/web/README.i18n.yaml
  36. 2 0
      packages/client/web/README.md
  37. 2 0
      packages/client/web/README.zh.md
  38. 9 3
      packages/client/web/package.json
  39. 52 0
      packages/client/web/src/apply-injections.ts
  40. 1 0
      packages/client/web/src/index.ts
  41. 30 0
      packages/client/web/tests/boot.client.spec.ts
  42. 3 0
      packages/client/web/tsconfig.json
  43. 1 1
      packages/client/web/tsdown.config.ts
  44. 2 1
      packages/experimental/webworker-runtime/package.json
  45. 2 54
      packages/experimental/webworker-runtime/src/client/apply-injections.ts
  46. 3 0
      packages/experimental/webworker-runtime/tsconfig.json
  47. 9 3
      pnpm-lock.yaml
  48. 1 0
      tsconfig.base.json

+ 2 - 2
.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: a03f3f02de88db3bdc3362a75e82b6356e44c6b4
-2026-09-09-desktop-immediate-window-and-direct-start.zh.md: e82c6677577fa963df5f8b0a61cb21fa15e66bc7
+2026-09-09-desktop-immediate-window-and-direct-start.md: 461f82719fabba67bd93c4c1f2a37d02fac1c06b
+2026-09-09-desktop-immediate-window-and-direct-start.zh.md: efbeeb20993fdc6baeb11ca1ea1d1af5501567dc

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md

@@ -12,11 +12,11 @@ Waiting for backend readiness leaves users without a window during profile prepa
 
 ## Decision
 
-Electron creates the main window with a local loading page before profile reconciliation or Host startup. The page depends only on packaged shell assets and receives starting, ready, or error state through the owned preload. Readiness loads the product UI in that window; startup failures display diagnostics and available recovery actions. Closing during loading cancels further startup work and waits for the pending child to exit.
+Electron creates the main window with the packaged Web loading page before profile reconciliation or Host startup. The Web entry draws its boot page before awaiting Host readiness. The owned preload delivers structured boot injections, and the existing document activates its client plugins after they are applied; startup failures display diagnostics and available recovery actions. Closing during loading cancels further startup work and waits for the pending child to exit.
 
 The main window owns recovery because the failed Host cannot supply its own controls. Error pages retain diagnostics, restart, and reinstallation guidance. Disabling plugins and resetting Desktop are available with loaded runtime metadata and available resources, including in development mode. Reset removes all profile contents except its held lock, without a backup; shared product data and the Harness-home environment file remain intact. The profile directory remains in place so another transaction cannot acquire a replacement lock during cleanup. Self-contained recovery controls use intercepted form navigation when preload is unavailable. A crashed renderer invalidates the navigation cache so the startup page loads again.
 
-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 URL loaded by the main window. 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. Package mutations retain pnpm lifecycle scripts and locking. Failures retain partial changes for explicit repair; there is no automatic profile rollback.
 
 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.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md

@@ -12,11 +12,11 @@ profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in
 
 ## 决策
 
-Electron 在 profile 校准或 Host 启动前创建带本地加载页的主窗口。该页面仅依赖已打包的壳资源,并通过自有 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;启动失败时显示诊断和可用恢复操作。加载期间关闭窗口会取消后续启动工作,并等待正在启动的子进程退出。
+Electron 在 profile 校准或 Host 启动前创建带打包 Web 加载页的主窗口。Web 入口先显示启动页,再等待 Host 就绪。自有 preload 交付结构化启动注入,现有文档应用注入后激活客户端插件;启动失败时显示诊断和可用恢复操作。加载期间关闭窗口会取消后续启动工作,并等待正在启动的子进程退出。
 
 主窗口提供恢复操作,因为失败的 Host 无法提供自身控件。错误页保留诊断、重启和重装指导。加载了运行时元数据且资源可用时,包括开发模式,才提供禁用插件和重置 Desktop。重置会删除 profile 中除所持锁文件外的所有内容,不保留备份;共享产品数据和 Harness-home 环境文件保持完整。profile 目录保持原位,避免清理期间另一事务获取替代锁。preload 不可用时,独立恢复控件使用被拦截的表单导航。渲染进程崩溃会使导航缓存失效,以重新加载启动页。
 
-Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-web-wrapper.zh.md)启动实际 Host。就绪消息提供主窗口加载的认证 URL。包变更保留 pnpm 生命周期脚本与锁。失败保留部分变更以供显式修复,不会自动回滚 profile。
+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。
 
 本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输、资源归属与依赖事务的理由。完整运行时文件验证仍属于打包操作。
 

+ 2 - 2
.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: bc07f32309c62fe75131bbee45d5efd208911a47
-2026-09-10-desktop-web-wrapper.zh.md: 35bc634ef6edc80f68ba7789d47e35f18b3a7e2e
+2026-09-10-desktop-web-wrapper.md: 5b8df22e520af752ac1b8fefbf2cf2442ce17eca
+2026-09-10-desktop-web-wrapper.zh.md: 44c79d8c914f0b2bdeb69dbeb09f22ce84efe9c8

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md

@@ -12,7 +12,7 @@ Separate Desktop composition and request transport require their own configurati
 
 ## Decision
 
-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 the authenticated URL reported by the child. Child IPC carries readiness and shutdown; application requests travel directly over HTTP.
+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 patch-reload policy 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.
 
@@ -38,7 +38,7 @@ This partially supersedes the private composition and portless transport in the
 
 ## Consequences
 
-Desktop inherits Web features through the same boot and serving path. HTTP listener ownership and authentication remain part of application startup, and Electron must load the ready URL instead of assuming a port or translating requests. The independent loading and recovery window remains available before the Web application starts.
+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; independent recovery resources remain 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 independent recovery UI rather than triggering stricter admission checks or automatic rollback.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 ## Decision
 
-私有 Desktop Host 针对独立归属的 Desktop profile 调用 CLI 的共享 profile runner。完整 Web 组合负责认证、HTTP 路由、客户端资源、RPC 与响应流。Electron 加载子进程报告的认证 URL。子进程 IPC 承载就绪与关闭;应用请求直接通过 HTTP 传输
+私有 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 列表和 patch 重载策略初始化 profile,并使用 Web 的自动目录选择机制,让应用默认值由一处维护。Desktop 使用独立的默认监听端口,使两个应用可以同时运行;profile 配置可以覆盖该端口。壳窗口、菜单、插件管理、恢复及更新仍由 Electron 负责。
 
@@ -38,7 +38,7 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析
 
 ## Consequences
 
-Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动,Electron 必须加载就绪 URL,而不是假设端口或转换请求。独立加载与恢复窗口在 Web 应用启动前仍可用。
+Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动。Electron 使用报告的 Host 地址,并在就绪前后保留现有 Web 文档。共享 Web 加载页在 Host 启动前可用;独立恢复资源在启动失败时仍可用。
 
 用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及独立恢复 UI,不触发更严格的准入检查或自动回滚。
 

+ 1 - 1
apps/desktop-host/src/index.ts

@@ -37,7 +37,7 @@ async function main(): Promise<void> {
     root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'),
   })
   const url = ctx.connection.authenticatedUrl(`http://127.0.0.1:${String(ctx.webServer.port)}`)
-  if (process.connected) process.send?.({ type: 'ready', url }, (error) => { if (error !== null) console.error(error) })
+  if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) })
 }
 
 if (import.meta.main) {

+ 2 - 2
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: 7a33d86aaded04a6e79a070a16e2179c9aa09379
-README.zh.md: 0e03bdc501102d34de66fab722fc0a4e84f24f1f
+README.md: 2793dae3a6bd0ca9ce5b4a7c13e539a861741994
+README.zh.md: 2beb07eb7580154a3aafb6fbea37157529e8d4f8

+ 2 - 2
apps/desktop/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron loads its authenticated HTTP URL. Web owns client assets, API routes, and response streaming; Node IPC carries child readiness and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
+The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
 
 ## Key technical decisions
 
@@ -51,7 +51,7 @@ Electron's native Edit menu supplies undo, redo, cut, copy, paste, and select-al
 
 The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version, Electron's Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
 
-1. The main window displays a local loading page before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files. The actual Host starts once and supplies missing module links through the shared profile runner.
+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. The actual Host starts once and supplies missing module links through the shared profile runner.
 2. On application upgrades, the shared profile runner refreshes its owned module links without checking plugin peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run.
 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.

+ 2 - 2
apps/desktop/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 加载其带认证的 HTTP URL。Web 负责客户端资源、API 路由与响应流;Node IPC 承载子进程就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。
+桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。
 
 ## 关键技术决策
 
@@ -51,7 +51,7 @@ Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、
 
 签名资源中的 `resources/app.asar/dsh/desktop-runtime.json` 绑定 shell 版本、Electron 的 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
 
-1. 主窗口在 profile 准备或后端启动前显示本地加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。实际 Host 仅启动一次,并通过共享 profile runner 补全缺失的模块链接。
+1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。实际 Host 仅启动一次,并通过共享 profile runner 补全缺失的模块链接。
 2. 应用升级时,共享 profile runner 刷新其拥有的模块链接,不检查插件 peer 要求。插件文件、配置、版本与锁文件保留原位;不运行 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 顺序会保留。已安装元数据不可读时,仍能列出、禁用和删除依赖;无法读取已安装版本时,列表使用依赖规格。

+ 3 - 1
apps/desktop/src/host-process.ts

@@ -7,6 +7,7 @@ import { desktopNodeEnvironment } from './node-environment.ts'
 interface ReadyEvent {
   readonly type: 'ready'
   readonly url: string
+  readonly injections?: readonly unknown[] | undefined
 }
 
 interface FatalEvent {
@@ -47,6 +48,7 @@ async function exitsWithin(exit: Promise<void>, milliseconds: number): Promise<b
 /** Browser authentication URL reported by the running Web application. */
 export interface DesktopHostReady {
   readonly url: string
+  readonly injections?: readonly unknown[] | undefined
 }
 
 /** One Web backend running under the Electron executable in Node mode. */
@@ -114,7 +116,7 @@ export class DesktopHostProcess {
         child.kill('SIGTERM')
         return
       }
-      if (message.type === 'ready') this.readyResolve({ url: message.url })
+      if (message.type === 'ready') this.readyResolve({ url: message.url, injections: message.injections })
       else this.fail(new Error(message.message))
     })
     child.once('error', (error) => { this.fail(error) })

+ 1 - 0
apps/desktop/src/ipc.ts

@@ -7,6 +7,7 @@ import type { DesktopBackendState } from './backend-controller.ts'
 /** IPC channel names kept private to the desktop application bundle. */
 export const DESKTOP_IPC = {
   localeGet: 'dsh-desktop:locale-get',
+  boot: 'dsh-desktop:boot',
   pluginsList: 'dsh-desktop:plugins-list',
   pluginsAdd: 'dsh-desktop:plugins-add',
   pluginsRemove: 'dsh-desktop:plugins-remove',

+ 44 - 6
apps/desktop/src/main.ts

@@ -10,6 +10,7 @@ import {
   ipcMain,
   Menu,
   protocol,
+  session,
   shell,
   type IpcMainInvokeEvent,
   type MenuItemConstructorOptions,
@@ -24,6 +25,7 @@ import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
 import { claimDesktopSingleInstance } from './single-instance.ts'
 import { DesktopUpdateCoordinator } from './update-coordinator.ts'
 import { desktopErrorState } from './startup-error.ts'
+import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
 import { startupFailureDocument } from './startup-document.ts'
 
 const SCHEME = 'dsh-app'
@@ -51,7 +53,7 @@ protocol.registerSchemesAsPrivileged([{
     standard: true,
     secure: true,
     supportFetchAPI: true,
-    corsEnabled: false,
+    corsEnabled: true,
     stream: true,
     codeCache: true,
   },
@@ -200,7 +202,10 @@ async function main(): Promise<void> {
   const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
   const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url))
   const startupUrl = `${SCHEME}://shell/startup.html`
-  let applicationUrl = startupUrl
+  const applicationUrl = `${SCHEME}://app/`
+  let hostUrl: string | undefined
+  let hostCookie: string | undefined
+  let injections: readonly unknown[] = []
   let navigation: { window: BrowserWindow; url: string; promise: Promise<void> } | undefined
   let emergencyDocument = false
 
@@ -244,7 +249,10 @@ async function main(): Promise<void> {
     return {
       start: async () => {
         const ready = await host.start()
-        applicationUrl = ready.url
+        hostCookie = await authenticateWebHost(ready.url)
+        hostUrl = ready.url
+        if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections')
+        injections = ready.injections
       },
       stop: () => host.stop(),
     }
@@ -296,11 +304,11 @@ async function main(): Promise<void> {
   const reconcileBackend = (): Promise<void> => {
     startup ??= (async () => {
       pageError = undefined
-      await navigateMain(startupUrl)
+      await navigateMain(applicationUrl)
       await backend.start(async () => {
         await manager.applyRelease()
       })
-      if (backend.host !== undefined) await navigateMain(applicationUrl)
+      // The existing Web document resumes through the boot IPC response.
     })().catch(async (error: unknown) => {
       await showStartupError(error)
       throw error
@@ -318,6 +326,16 @@ async function main(): Promise<void> {
 
   protocol.handle(SCHEME, (request) => {
     const url = new URL(request.url)
+    if (url.hostname === 'app') {
+      if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/')
+        || ['/favicon.svg', '/manifest.webmanifest'].includes(url.pathname)) {
+        return serveWebDocument(request, join(resources.dsh, 'node_modules', '@deepseek-ai', 'dsh-web-frontend', 'dist'))
+      }
+      if (backend.host === undefined || hostUrl === undefined || hostCookie === undefined) {
+        return Promise.resolve(new Response(null, { status: 503 }))
+      }
+      return forwardWebRequest(request, hostUrl, hostCookie)
+    }
     if (url.hostname === 'shell') return serveShellAsset(request).then((response) => {
       if (response.status >= 400 && ['/startup.html', '/startup.js', '/startup.css'].includes(url.pathname)) {
         void showEmergencyError(new Error(`Desktop recovery resource could not be loaded: ${url.pathname} (HTTP ${response.status})`))
@@ -328,6 +346,26 @@ async function main(): Promise<void> {
     return Promise.resolve(new Response(null, { status: 404 }))
   })
 
+  ipcMain.handle(DESKTOP_IPC.boot, async (event) => {
+    assertDesktopSender(event, ['app'])
+    await startup
+    if (backend.host === undefined || hostUrl === undefined) throw new Error('Desktop Host is unavailable')
+    return { injections, streamBaseUrl: new URL(hostUrl).origin }
+  })
+
+  session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['ws://127.0.0.1/*'] }, (details, callback) => {
+    if (hostUrl === undefined || hostCookie === undefined || details.webContentsId !== mainWindow?.webContents.id) {
+      callback({})
+      return
+    }
+    const target = new URL(hostUrl)
+    const requested = new URL(details.url)
+    if (requested.host !== target.host) { callback({}); return }
+    const headers = Object.fromEntries(Object.entries(details.requestHeaders).map(([name, value]) => [name.toLowerCase(), value]))
+    if (headers.origin !== 'dsh-app://app') { callback({ cancel: true }); return }
+    callback({ requestHeaders: { ...headers, origin: target.origin, cookie: hostCookie, 'sec-fetch-site': 'same-origin' } })
+  })
+
   const mutate = async (event: IpcMainInvokeEvent, mutation: Parameters<DesktopProjectManager['mutate']>[0]): Promise<void> => {
     assertDesktopSender(event, ['shell'])
     await startup?.catch(() => undefined)
@@ -495,7 +533,7 @@ async function main(): Promise<void> {
     const window = mainWindow
     if (window === undefined || window.isDestroyed()) {
       createMainWindow()
-      void navigateMain(backendState().phase === 'ready' ? applicationUrl : startupUrl)
+      void navigateMain(applicationUrl)
         .catch((error: unknown) => { console.error(error) })
       return
     }

+ 4 - 0
apps/desktop/src/preload-app.ts

@@ -20,5 +20,9 @@ const startup: DshDesktopStartupApi = {
   resetConfiguration: () => ipcRenderer.invoke(DESKTOP_IPC.configurationReset) as Promise<void>,
 }
 
+if (location.protocol === 'dsh-app:' && location.hostname === 'app') {
+  contextBridge.exposeInMainWorld('dshDesktopBoot', { ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise<unknown> })
+}
+
 contextBridge.exposeInMainWorld('dshDesktop', location.protocol === 'dsh-app:' && location.hostname === 'shell'
   ? startup : { protocolVersion: 1 })

+ 74 - 0
apps/desktop/src/web-document.ts

@@ -0,0 +1,74 @@
+/** Local Web document and authenticated HTTP forwarding for the application window. */
+import { readFile } from 'node:fs/promises'
+import { extname, resolve, sep } from 'node:path'
+
+const MIME: Readonly<Record<string, string>> = {
+  '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
+  '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json',
+  '.woff2': 'font/woff2', '.png': 'image/png', '.ico': 'image/x-icon',
+}
+const BOOT = '<script>globalThis.__DSH_BOOT_READY__ = Promise.withResolvers()</script>'
+
+/**
+ * Read an application-owned static asset; the index waits for asynchronous Host injections.
+ * @param request - Local application request.
+ * @param root - Packaged Web dist directory.
+ * @returns Static response, or a missing/invalid path response.
+ */
+export async function serveWebDocument(request: Request, root: string): Promise<Response> {
+  if (!['GET', 'HEAD'].includes(request.method)) return new Response(null, { status: 405 })
+  const url = new URL(request.url)
+  let pathname: string
+  try { pathname = decodeURIComponent(url.pathname) } catch { return new Response(null, { status: 400 }) }
+  const target = resolve(root, '.' + (pathname === '/' ? '/index.html' : pathname))
+  const directory = resolve(root)
+  if (!target.startsWith(directory + sep)) return new Response(null, { status: 403 })
+  let body: Buffer
+  try { body = await readFile(target) } catch (error) {
+    if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Response(null, { status: 404 })
+    throw error
+  }
+  const content = pathname === '/' || pathname === '/index.html'
+    ? body.toString().replace('<head>', '<head>' + BOOT) : new Uint8Array(body)
+  return new Response(request.method === 'HEAD' ? null : content, {
+    headers: { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' },
+  })
+}
+
+/**
+ * Exchange the Host launch URL for an authority-bound browser cookie.
+ * @param url - Authenticated URL reported by the owned Host process.
+ * @returns Cookie header for requests forwarded to that Host.
+ */
+export async function authenticateWebHost(url: string): Promise<string> {
+  const response = await fetch(url, { redirect: 'manual' })
+  const cookie = response.headers.get('set-cookie')
+  await response.body?.cancel()
+  if (response.status !== 303 || cookie === null) throw new Error('Desktop Host authentication failed')
+  const end = cookie.indexOf(';')
+  return end < 0 ? cookie : cookie.slice(0, end)
+}
+
+/**
+ * Forward local application requests to its authenticated Host, preserving streaming and cancellation.
+ * @param request - Request from the application origin.
+ * @param host - Owned Host URL.
+ * @param cookie - Host-issued authentication cookie.
+ * @returns Host response without network-only encoding headers.
+ */
+export async function forwardWebRequest(request: Request, host: string, cookie: string): Promise<Response> {
+  const source = new URL(request.url)
+  const origin = request.headers.get('origin')
+  if (origin !== null && origin !== 'dsh-app://app') return new Response(null, { status: 403 })
+  const target = new URL(host)
+  target.pathname = source.pathname
+  target.search = source.search
+  const headers = new Headers(request.headers)
+  for (const name of ['host', 'origin', 'cookie', 'sec-fetch-site']) headers.delete(name)
+  headers.set('cookie', cookie)
+  const init = { method: request.method, headers, body: request.body, signal: request.signal, duplex: 'half', redirect: 'manual' as const }
+  const response = await fetch(target, init)
+  const outgoing = new Headers(response.headers)
+  for (const name of ['content-encoding', 'content-length', 'set-cookie']) outgoing.delete(name)
+  return new Response(response.body, { status: response.status, headers: outgoing })
+}

+ 65 - 15
apps/desktop/tests/main-startup.spec.ts

@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { join } from 'node:path'
 import { DESKTOP_IPC } from '../src/ipc.ts'
 
+vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
+
 const harness = await vi.hoisted(async () => {
   const { EventEmitter } = await import('node:events')
   function deferred() {
@@ -24,6 +26,7 @@ const harness = await vi.hoisted(async () => {
     destroyed = false
     readonly urls: string[] = []
     readonly webContents = Object.assign(new EventEmitter(), {
+      id: 42,
       setWindowOpenHandler: vi.fn(),
       openDevTools: vi.fn(),
       getURL: () => this.urls.at(-1) ?? '',
@@ -39,7 +42,7 @@ const harness = await vi.hoisted(async () => {
     isMinimized() { return false }
     async loadURL(url: string) {
       this.urls.push(url)
-      if (url.startsWith('http://')) navigated.resolve()
+      if (url === 'dsh-app://app/') navigated.resolve()
     }
     static getAllWindows() { return windows.filter(window => !window.destroyed) }
     close() { this.destroyed = true; this.emit('closed') }
@@ -49,7 +52,7 @@ const harness = await vi.hoisted(async () => {
     readonly ready = deferred()
     readonly exited = deferred()
     readonly stopping = deferred()
-    readonly start = vi.fn(() => { hostStarted.resolve(); return this.ready.promise.then(() => ({ url: this.url })) })
+    readonly start = vi.fn(() => { hostStarted.resolve(); return this.ready.promise.then(() => ({ url: this.url, injections: [] })) })
     readonly stop = vi.fn(() => {
       this.stopping.resolve()
       this.ready.reject(new Error('child stopped'))
@@ -81,6 +84,7 @@ const harness = await vi.hoisted(async () => {
   return {
     windows, hosts, handlers, app, FakeWindow, FakeHost,
     popup,
+    socketHeaders: vi.fn(),
     menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn(() => ({ popup })) },
     dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() },
     openExternal: vi.fn(),
@@ -89,6 +93,7 @@ const harness = await vi.hoisted(async () => {
     get preparing() { return preparing }, get prepared() { return prepared },
     get hostStarted() { return hostStarted }, get navigated() { return navigated },
     get errorPublished() { return errorPublished }, 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 },
@@ -111,6 +116,7 @@ vi.mock('electron', () => ({
     handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { harness.handlers.set(channel, handler) },
   },
   Menu: harness.menu,
+  session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } },
   protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() },
 }))
 vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) }))
@@ -162,6 +168,49 @@ afterEach(async () => {
 })
 
 describe('desktop main startup', () => {
+  it('attaches Host socket credentials only to the owned application origin and window', async () => {
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    harness.prepared.resolve()
+    await harness.hostStarted.promise
+    harness.hosts[0]!.ready.resolve()
+    await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
+    const handler = harness.socketHeaders.mock.calls[0]![1] as (
+      details: { url: string; webContentsId: number; requestHeaders: Record<string, string> },
+      callback: (result: unknown) => void,
+    ) => void
+    const callback = vi.fn()
+    const details = { url: 'ws://127.0.0.1:3080/api/remote.mux', webContentsId: 42, requestHeaders: { Origin: 'dsh-app://app' } }
+    handler(details, callback)
+    expect(callback).toHaveBeenLastCalledWith({ requestHeaders: {
+      origin: 'http://127.0.0.1:3080', cookie: 'test-cookie', 'sec-fetch-site': 'same-origin',
+    } })
+    handler({ ...details, requestHeaders: { Origin: 'https://other.example' } }, callback)
+    expect(callback).toHaveBeenLastCalledWith({ cancel: true })
+    handler({ ...details, webContentsId: 43 }, callback)
+    expect(callback).toHaveBeenLastCalledWith({})
+    handler({ ...details, url: 'ws://127.0.0.1:9999/api/remote.mux' }, callback)
+    expect(callback).toHaveBeenLastCalledWith({})
+  })
+
+  it('holds boot injections until the Host is ready and rejects foreign boot callers', async () => {
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    const handler = harness.handlers.get(DESKTOP_IPC.boot)!
+    await expect(handler({ senderFrame: { url: 'https://other.example/' } })).rejects.toThrow('unowned renderer')
+    let settled = false
+    const boot = Promise.resolve(handler({ senderFrame: { url: 'dsh-app://app/' } })).then((value) => { settled = true; return value })
+    await Promise.resolve()
+    expect(settled).toBe(false)
+    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
+    harness.prepared.resolve()
+    await harness.hostStarted.promise
+    expect(settled).toBe(false)
+    harness.hosts[0]!.ready.resolve()
+    await expect(boot).resolves.toEqual({ injections: [], streamBaseUrl: 'http://127.0.0.1:3080' })
+    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
+  })
+
   it('offers native editing actions on right-click and only copy for selected read-only text', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
@@ -187,7 +236,7 @@ describe('desktop main startup', () => {
     expect(harness.menu.buildFromTemplate).not.toHaveBeenCalled()
   })
 
-  it('navigates to each Host authentication URL when retry replaces a failed backend', async () => {
+  it('keeps a local document while retry connects a replacement Host', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
     harness.prepared.resolve()
@@ -197,7 +246,7 @@ describe('desktop main startup', () => {
     first.ready.resolve()
     await harness.navigated.promise
     const window = harness.windows[0]!
-    expect(window.urls.at(-1)).toBe(first.url)
+    expect(window.urls).toEqual(['dsh-app://app/'])
     await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
     first.onFailure!(new Error('backend exited'))
     await harness.errorPublished.promise
@@ -212,7 +261,7 @@ describe('desktop main startup', () => {
     await retry
     expect(harness.windows).toHaveLength(1)
     expect(window.urls).toEqual([
-      'dsh-app://shell/startup.html', first.url, 'dsh-app://shell/startup.html', replacement.url,
+      'dsh-app://app/', 'dsh-app://shell/startup.html', 'dsh-app://app/',
     ])
     expect(harness.openExternal).not.toHaveBeenCalled()
   })
@@ -237,7 +286,7 @@ describe('desktop main startup', () => {
     expect(harness.openExternal).toHaveBeenCalledWith('https://example.com/document')
     harness.openExternal.mockClear()
     const internal = { preventDefault: vi.fn() }
-    window.webContents.emit('will-navigate', internal, 'http://127.0.0.1:3080/session/task-1')
+    window.webContents.emit('will-navigate', internal, 'dsh-app://app/session/task-1')
     expect(internal.preventDefault).not.toHaveBeenCalled()
     expect(harness.openExternal).not.toHaveBeenCalled()
   })
@@ -275,7 +324,7 @@ describe('desktop main startup', () => {
     const window = harness.windows[0]!
     window.webContents.emit('render-process-gone', {}, { reason: 'crashed' })
     await harness.errorPublished.promise
-    expect(window.urls).toEqual(['dsh-app://shell/startup.html', 'dsh-app://shell/startup.html'])
+    expect(window.urls).toEqual(['dsh-app://app/', 'dsh-app://shell/startup.html'])
     expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', message: 'Desktop renderer exited: crashed' })
   })
 
@@ -289,15 +338,16 @@ describe('desktop main startup', () => {
     harness.hosts[0]!.ready.resolve()
     await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
     const started = harness.nextHostStart()
+    const navigated = harness.nextNavigation()
     const event = { preventDefault: vi.fn() }
     window.webContents.emit('will-navigate', event, `dsh-recovery://${action}/?`)
     await harness.hosts[0]!.stopping.promise
     harness.hosts[0]!.exited.resolve()
     await started
     harness.hosts[1]!.ready.resolve()
-    await harness.navigated.promise
+    await navigated
     expect(event.preventDefault).toHaveBeenCalled()
-    expect(window.urls.at(-1)).toBe('http://127.0.0.1:3080/?token=test')
+    expect(window.urls.at(-1)).toBe('dsh-app://app/')
   })
 
   it('allows a full profile reset for an unclassified startup failure', async () => {
@@ -374,14 +424,14 @@ describe('desktop main startup', () => {
     expect(harness.windows).toHaveLength(1)
     const window = harness.windows[0]!
     expect(window.options.show).toBe(true)
-    expect(window.urls).toEqual(['dsh-app://shell/startup.html'])
+    expect(window.urls).toEqual(['dsh-app://app/'])
     expect(harness.hosts).toHaveLength(0)
     const retry = invoke(DESKTOP_IPC.backendRetry)
     const secondRetry = invoke(DESKTOP_IPC.backendRetry)
     harness.prepared.resolve()
     await harness.hostStarted.promise
     expect(harness.hosts).toHaveLength(1)
-    expect(window.urls).toEqual(['dsh-app://shell/startup.html'])
+    expect(window.urls).toEqual(['dsh-app://app/'])
     harness.hosts[0]!.ready.resolve()
     await Promise.all([retry, secondRetry, harness.navigated.promise])
     expect(harness.applyRelease).toHaveBeenCalledTimes(1)
@@ -394,7 +444,7 @@ describe('desktop main startup', () => {
     })
     expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
     expect(harness.windows).toHaveLength(1)
-    expect(window.urls).toEqual(['dsh-app://shell/startup.html', 'http://127.0.0.1:3080/?token=test'])
+    expect(window.urls).toEqual(['dsh-app://app/'])
     expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' })
   })
 
@@ -425,7 +475,7 @@ describe('desktop main startup', () => {
     await harness.errorPublished.promise
     await failedRetry
     expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed', profileRecovery: true })
-    expect(harness.windows[0]!.urls).toEqual(['dsh-app://shell/startup.html'])
+    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/', 'dsh-app://shell/startup.html'])
     const nextStarted = harness.nextHostStart()
     const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
     await nextStarted
@@ -433,7 +483,7 @@ describe('desktop main startup', () => {
     harness.hosts[1]!.ready.resolve()
     await retry
     expect(harness.windows).toHaveLength(1)
-    expect(harness.windows[0]!.urls.at(-1)).toBe('http://127.0.0.1:3080/?token=test')
+    expect(harness.windows[0]!.urls.at(-1)).toBe('dsh-app://app/')
     expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
   })
 
@@ -453,7 +503,7 @@ describe('desktop main startup', () => {
     host.exited.resolve()
     await harness.quitCompleted.promise
     expect(host.stop).toHaveBeenCalledTimes(1)
-    expect(window.urls).toEqual(['dsh-app://shell/startup.html'])
+    expect(window.urls).toEqual(['dsh-app://app/'])
     expect(harness.windows).toHaveLength(1)
   })
 })

+ 14 - 0
apps/desktop/tests/preload-app.spec.ts

@@ -37,3 +37,17 @@ it('provides startup controls and a removable state subscription to shell docume
   expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.backendState, handler)
   expect(api).not.toHaveProperty('plugins')
 })
+
+
+it('exposes asynchronous boot only to the local application document', async () => {
+  vi.stubGlobal('location', new URL('dsh-app://app/'))
+  await import('../src/preload-app.ts')
+  const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktopBoot')?.[1] as { ready(): Promise<unknown> }
+  await api.ready()
+  expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.boot)
+  vi.resetModules()
+  electron.contextBridge.exposeInMainWorld.mockClear()
+  vi.stubGlobal('location', new URL('https://other.example/'))
+  await import('../src/preload-app.ts')
+  expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === 'dshDesktopBoot')).toBe(false)
+})

+ 63 - 0
apps/desktop/tests/web-document.spec.ts

@@ -0,0 +1,63 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { authenticateWebHost, forwardWebRequest, serveWebDocument } from '../src/web-document.ts'
+
+const roots: string[] = []
+afterEach(async () => {
+  vi.unstubAllGlobals()
+  await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
+})
+
+it('serves the Web entry and assets without starting or contacting a Host', async () => {
+  const root = await mkdtemp(join(tmpdir(), 'desktop-web-'))
+  roots.push(root)
+  await mkdir(join(root, 'assets'))
+  await writeFile(join(root, 'index.html'), '<html><head></head><body><script src="assets/entry.js"></script></body></html>')
+  await writeFile(join(root, 'assets/entry.js'), 'globalThis.entryLoaded = true')
+  const fetch = vi.fn()
+  vi.stubGlobal('fetch', fetch)
+  const response = await serveWebDocument(new Request('dsh-app://app/'), root)
+  const html = await response.text()
+  expect(html.indexOf('Promise.withResolvers()')).toBeLessThan(html.indexOf('assets/entry.js'))
+  expect(await (await serveWebDocument(new Request('dsh-app://app/assets/entry.js'), root)).text()).toContain('entryLoaded')
+  expect(fetch).not.toHaveBeenCalled()
+  expect((await serveWebDocument(new Request('dsh-app://app/%2e%2e%2fprivate'), root)).status).toBe(403)
+  expect((await serveWebDocument(new Request('dsh-app://app/missing.js'), root)).status).toBe(404)
+})
+
+it('requires the Host authentication exchange and retains only its cookie value', async () => {
+  const fetch = vi.fn().mockResolvedValueOnce(new Response(null, { status: 303, headers: { 'set-cookie': 'session=owned; HttpOnly; SameSite=Strict' } }))
+    .mockResolvedValueOnce(new Response('unauthorized', { status: 401 }))
+  vi.stubGlobal('fetch', fetch)
+  expect(await authenticateWebHost('http://127.0.0.1:1234/?token=owned')).toBe('session=owned')
+  await expect(authenticateWebHost('http://127.0.0.1:1234/')).rejects.toThrow('authentication failed')
+})
+
+it('forwards upload bytes and cancellation with Host credentials while keeping the response streaming', async () => {
+  const body = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('stream')); controller.close() } })
+  const fetch = vi.fn().mockResolvedValue(new Response(body, { headers: { 'content-encoding': 'gzip', 'set-cookie': 'private' } }))
+  vi.stubGlobal('fetch', fetch)
+  const request = new Request('dsh-app://app/api/upload?name=file', {
+    method: 'POST', body: 'upload bytes', headers: { origin: 'dsh-app://app', cookie: 'untrusted' },
+  })
+  const response = await forwardWebRequest(request, 'http://127.0.0.1:1234/?token=secret', 'session=owned')
+  const [target, init] = fetch.mock.calls[0] as unknown as [URL, RequestInit]
+  expect(target.href).toBe('http://127.0.0.1:1234/api/upload?name=file')
+  expect(new Headers(init.headers).get('cookie')).toBe('session=owned')
+  expect(new Headers(init.headers).get('origin')).toBeNull()
+  expect(init.signal).toBe(request.signal)
+  expect(init.body).toBe(request.body)
+  expect(response.headers.get('set-cookie')).toBeNull()
+  expect(response.headers.get('content-encoding')).toBeNull()
+  expect(await response.text()).toBe('stream')
+})
+
+it('refuses another page origin without forwarding its request', async () => {
+  const fetch = vi.fn()
+  vi.stubGlobal('fetch', fetch)
+  const response = await forwardWebRequest(new Request('dsh-app://app/api/read', { headers: { origin: 'https://other.example' } }), 'http://127.0.0.1:1234/', 'session=owned')
+  expect(response.status).toBe(403)
+  expect(fetch).not.toHaveBeenCalled()
+})

+ 25 - 2
apps/web/src/main.ts

@@ -1,6 +1,29 @@
 /** Browser entry for the Web client. */
-import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
+import { AppWebEntry, applyIndexInjections } from '@deepseek-ai/dsh-client-web'
 
 const el = document.getElementById('root')
 if (el === null) throw new Error('web app: missing #root')
-void new AppWebEntry(el).run()
+const entry = new AppWebEntry(el)
+interface DesktopBootGlobal {
+  dshDesktopBoot?: {
+    ready(): Promise<{ injections: Parameters<typeof applyIndexInjections>[0]; streamBaseUrl: string }>
+  }
+}
+const desktop = (globalThis as DesktopBootGlobal).dshDesktopBoot
+if (desktop !== undefined) {
+  const gate = (globalThis as { __DSH_BOOT_READY__?: PromiseWithResolvers<void> }).__DSH_BOOT_READY__
+  if (gate === undefined) throw new Error('desktop web: boot readiness is missing')
+  void desktop.ready().then(async ({ injections, streamBaseUrl }) => {
+    const transport = globalThis as { __DSH_TRANSPORT__?: { ownsHost: boolean; streamBaseUrl: string } }
+    transport.__DSH_TRANSPORT__ = { ownsHost: true, streamBaseUrl }
+    await applyIndexInjections(injections, src => new Promise<void>((resolve, reject) => {
+      const script = document.createElement('script')
+      script.src = src
+      script.onload = () => { resolve() }
+      script.onerror = () => { reject(new Error(`desktop web: failed to load ${src}`)) }
+      document.head.append(script)
+    }))
+    gate.resolve()
+  }).catch((error: unknown) => { gate.reject(error) })
+}
+void entry.run()

+ 2 - 2
docs/architecture.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 docs/architecture.md
-architecture.md: 59934ca09daff44d260bad16e028a0122f3d0555
-architecture.zh.md: 5c04e4c290ab2ff818b7463a6f766fe57fcd800a
+architecture.md: a476bb99bb79d2df0544fe032fb1fc06b6d0cbb7
+architecture.zh.md: a14748ce0b4220a04f05f79121e1a9e27fa360b8

+ 1 - 1
docs/architecture.md

@@ -50,7 +50,7 @@ The Python SDK follows the same application architecture. Its runtime wheel pack
 
 The [Electron desktop application](../apps/desktop/README.md) carries its exact dsh production runtime in signed resources and owns the reserved `$DSH_HOME/profiles/desktop`. Shared profile helpers initialize its files, reconcile installed bundles, and project missing dependencies without replacing pnpm-owned packages. CLI and Desktop share product data, while executable packages, activation choices, and lockfiles remain separate. The public CLI cannot manage Desktop’s profile.
 
-Electron starts the private Desktop Host under bundled upstream Node.js. The Host invokes the shared CLI profile runner and complete Web application; Electron loads its authenticated HTTP URL. Web serves RPC, streams, and client assets, while Node IPC carries readiness, fatal errors, and shutdown. Desktop defaults to port `19387`; profile configuration can override it. Shell-owned UI runs plugin transactions through bundled pnpm with normal user and profile configuration.
+Electron starts the private Desktop Host in Electron Node mode. The Host invokes the shared CLI profile runner and complete Web application. The window immediately loads packaged Web assets and waits for boot injections before activating client plugins in the same document. Web owns RPC and streams; the desktop carrier connects the local page to the authenticated Host. Node IPC carries boot injections, readiness, fatal errors, and shutdown. Desktop defaults to port `19387`; profile configuration can override it. Shell-owned UI runs plugin transactions through bundled pnpm with normal user and profile configuration.
 
 ## Core packages
 

+ 1 - 1
docs/architecture.zh.md

@@ -50,7 +50,7 @@ Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI
 
 [Electron 桌面应用](../apps/desktop/README.zh.md)在签名资源中携带精确匹配的 dsh 生产运行时,并拥有保留的 `$DSH_HOME/profiles/desktop`。共享 profile helper 初始化其文件、协调已安装 bundle,并补全缺失依赖而不替换 pnpm 拥有的包。CLI 与 Desktop 共享产品数据,可执行包、启用选择与锁文件保持独立。公开 CLI 不能管理 Desktop profile。
 
-Electron 使用内置上游 Node.js 启动私有 Desktop Host。Host 调用共享 CLI profile runner 与完整 Web 应用,Electron 加载其认证 HTTP URL。Web 提供 RPC、流及客户端资源,Node IPC 承载就绪、致命错误与关闭。Desktop 默认端口为 `19387`,profile 配置可覆盖。壳拥有的 UI 通过内置 pnpm 执行插件事务,并遵循正常用户与 profile 配置。
+Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共享 CLI profile runner 与完整 Web 应用。窗口立即加载打包 Web 资源,等待启动注入后在同一文档中激活客户端插件。Web 负责 RPC 与流;桌面载体将本地页面连接到已认证的 Host。Node IPC 承载启动注入、就绪、致命错误与关闭。Desktop 默认端口为 `19387`,profile 配置可覆盖。壳拥有的 UI 通过内置 pnpm 执行插件事务,并遵循正常用户与 profile 配置。
 
 ## 核心包
 

+ 2 - 2
docs/event-producer-consumer.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 docs/event-producer-consumer.md
-event-producer-consumer.md: f2f4795cf9187adc72ce98d35a452930cba57f35
-event-producer-consumer.zh.md: ed8766bfb45088a7160f51d07f0d6a4fbe2e85a9
+event-producer-consumer.md: 294fdfc288692385650d34d0aa0d272ef7919bb0
+event-producer-consumer.zh.md: cfb27d76b5a63dd477b49840e97c09be91179ed5

+ 3 - 2
docs/event-producer-consumer.md

@@ -81,10 +81,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 
 | Event string | Dispatchers | Listeners |
 | --- | --- | --- |
-| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
+| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), `ui-renderer`, [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
 | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
-| `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |
+| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) |
 | `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
+| `slots/changed` | `ui-renderer` (`emit`) | - |
 
 Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.

+ 3 - 2
docs/event-producer-consumer.zh.md

@@ -83,10 +83,11 @@
 
 | Event string | Dispatchers | Listeners |
 | --- | --- | --- |
-| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
+| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), `ui-renderer`, [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
 | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
-| `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |
+| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) |
 | `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
+| `slots/changed` | `ui-renderer` (`emit`) | - |
 
 Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.

+ 2 - 2
packages/api/gateway/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 packages/api/gateway/README.md
-README.md: 561081c3a5de716e563eaa9764f387e329365504
-README.zh.md: 9588b194efc64d14a17a69323a14d6118d0e7d7e
+README.md: ee571582fb4599aaca789dc6471a1febeb5d20ce
+README.zh.md: 8a4054551a5db23bdce6fcb08c5323d96f2f680d

+ 2 - 0
packages/api/gateway/README.md

@@ -39,6 +39,8 @@ Host composition can register one application event source through `registerRemo
 <a id="client-service-clientremote-ctx-key-remote"></a>
 ## Client service: `ClientRemote` (ctx key: `remote`)
 
+The browser carrier accepts the shell-owned stream origin described by [Connection](../../client/connection/README.md#use-this-package); logical stream framing and lifecycle remain unchanged.
+
 `ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.<namespace>` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable.
 
 Each unary call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Unary results and every stream item are validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject.

+ 2 - 0
packages/api/gateway/README.zh.md

@@ -39,6 +39,8 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source
 <a id="client-service-clientremote-ctx-key-remote"></a>
 ## Client 服务:`ClientRemote`(ctx key:`remote`)
 
+浏览器载体接受 [Connection](../../client/connection/README.zh.md#use-this-package) 定义的 shell 所拥有的流 origin;逻辑流帧与生命周期保持一致。
+
 `ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.<namespace>` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。
 
 每次一元调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。一元结果和每个流项都经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。

+ 2 - 1
packages/api/gateway/src/client/stream-client.ts

@@ -303,7 +303,8 @@ class StreamInbox {
 
 function remoteStreamUrl(): string {
   const location = (globalThis as { location?: { origin?: string } }).location
-  const base = location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE
+  const transport = (globalThis as { __DSH_TRANSPORT__?: { streamBaseUrl?: string } }).__DSH_TRANSPORT__
+  const base = transport?.streamBaseUrl ?? (location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE)
   const url = new URL(REMOTE_STREAM_MUX_PATH, base)
   url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
   return url.href

+ 14 - 0
packages/api/gateway/tests/gateway.client.spec.ts

@@ -2341,6 +2341,20 @@ describe('Client Typert API', () => {
 })
 
 describe('Remote stream client carrier lifecycle', () => {
+  it('connects to the shell-owned Host while the document uses a local asset origin', async () => {
+    await withFakeWebSocket('dsh-app://app', async () => {
+      vi.stubGlobal('__DSH_TRANSPORT__', { streamBaseUrl: 'http://127.0.0.1:43210' })
+      const client = new RemoteStreamMuxClient()
+      try {
+        client.start()
+        expect(FakeWebSocket.sockets[0]!.url).toBe('ws://127.0.0.1:43210/api/remote.mux')
+      } finally {
+        await client.close()
+        vi.unstubAllGlobals()
+      }
+    })
+  })
+
   it('requires the transport owner to start the physical carrier', async () => {
     const client = new RemoteStreamMuxClient()
     await expect(client.open('feed/follow', {}, new AbortController().signal)

+ 2 - 2
packages/client/connection/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 packages/client/connection/README.md
-README.md: c8c2b2f6edb0ce43a07a664bcc2b3c9ece047b7b
-README.zh.md: 2c25c0d3520fd7e5d3eeeec98c41cc1d3d4d25d8
+README.md: 19dc12b4946353634a0f71a566564454cf1f8de8
+README.zh.md: b02631005fd50a82733545c5a537728be47d3d80

+ 2 - 0
packages/client/connection/README.md

@@ -25,6 +25,8 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con
 <a id="use-this-package"></a>
 ## Use this package
 
+A static desktop page can provide `__DSH_TRANSPORT__.streamBaseUrl` for the HTTP origin of its owned Host. The Gateway uses that origin for its WebSocket while HTTP transport remains independently selected. The desktop carrier owns authentication; setting the origin alone grants no access.
+
 The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md).
 
 -----

+ 2 - 0
packages/client/connection/README.zh.md

@@ -25,6 +25,8 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
+静态桌面页面可以通过 `__DSH_TRANSPORT__.streamBaseUrl` 提供其所拥有 Host 的 HTTP origin。Gateway 将该 origin 用于 WebSocket,HTTP 传输仍独立选择。桌面载体负责认证;仅设置 origin 不会授予访问权限。
+
 浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。
 
 -----

+ 2 - 0
packages/client/connection/src/client/index.ts

@@ -102,6 +102,8 @@ export interface ClientTransportHooks {
    * transport can set this; served pages never carry the global at all.
    */
   ownsHost?: boolean
+  /** HTTP origin of a shell-owned Host when its WebSocket uses a different page origin. */
+  streamBaseUrl?: string
 }
 
 /** Page global carrying {@link ClientTransportHooks}; absent in the served web app. */

+ 2 - 2
packages/client/web/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 packages/client/web/README.md
-README.md: 7bdcec9e2a964b746cbd808497f9222e2b4150fa
-README.zh.md: a7243e52a7bababebb75a0ee9f4e22a99fa83337
+README.md: c994f725b7a0eec1a694f1e544a0f979f5fcc31d
+README.zh.md: 5fc5d574961500e781b670877295b7efb187b3fa

+ 2 - 0
packages/client/web/README.md

@@ -27,6 +27,8 @@ English | [中文](README.zh.md)
 
 Use it when you assemble the browser application: `apps/web`'s Vite entry runs `new AppWebEntry(container).run()` against the mount point, and the boot page carries the user through activation. Ordinary browser callers pass no options. A pre-injected page transport is the default ahead of the `seams` override: when `globalThis.__DSH_TRANSPORT__` carries `loadBundle`, the module stage adopts it as the bundle transport and skips the immediate-tier HTTP prefetch, while explicit `seams` still win (for example jsdom tests, where external `<script>` execution cannot reach the page context).
 
+Static application pages install `__DSH_BOOT_READY__` before the entry runs. The boot page renders immediately while `run()` waits; the page owner applies the Host rows with `applyIndexInjections` (also exported from `./injections`) and resolves the deferred after all scripts finish. A rejected deferred renders a boot failure. Desktop and WebWorker share this interpreter. Static boot consumes structured rows; server-side `tapIndex` HTML transforms apply only to served documents.
+
 The shell base styles apply automatic CJK/Latin spacing to ordinary content in supporting browsers. Semantic code and terminal, diff, read, and search output containers retain literal source spacing and column alignment; browsers without `text-autospace` support ignore both declarations.
 
 ### What boot looks like

+ 2 - 0
packages/client/web/README.zh.md

@@ -27,6 +27,8 @@ kind: "package-library"
 
 组装浏览器应用时使用它:`apps/web` 的 Vite 入口对挂载点运行 `new AppWebEntry(container).run()`,启动页会在激活过程中向用户展示进度。普通浏览器调用方不传任何选项。默认使用预注入的页面传输,除非提供 `seams` 覆盖:当 `globalThis.__DSH_TRANSPORT__` 携带 `loadBundle` 时,模块阶段将其采纳为 bundle 传输并跳过 `immediately` 层级的 HTTP 预取,而显式 `seams` 仍然优先(例如外部 `<script>` 执行无法到达页面上下文的 jsdom 测试)。
 
+静态应用页面在入口运行前设置 `__DSH_BOOT_READY__`。启动页立即显示,`run()` 保持等待;页面所有者通过 `applyIndexInjections`(也从 `./injections` 导出)应用 Host 注入,并在所有脚本完成后解除等待。等待被拒绝时显示启动失败。Desktop 与 WebWorker 共享这个解释器。静态启动使用结构化注入;服务端 `tapIndex` HTML 转换只作用于服务端提供的文档。
+
 外壳基础样式会在支持的浏览器中为普通内容自动添加中西文间距。语义化代码以及终端、diff、读取和搜索输出容器会保留源码中的原始间距和列对齐;不支持 `text-autospace` 的浏览器会忽略这两项声明。
 
 ### 启动过程是怎样的

+ 9 - 3
packages/client/web/package.json

@@ -19,7 +19,11 @@
       "default": "./lib/index.js"
     },
     "./src/*": "./src/*",
-    "./package.json": "./package.json"
+    "./package.json": "./package.json",
+    "./injections": {
+      "types": "./lib/types/apply-injections.d.ts",
+      "default": "./lib/apply-injections.js"
+    }
   },
   "license": "MIT",
   "devDependencies": {
@@ -35,7 +39,8 @@
     "@deepseek-ai/cordis": "workspace:^",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
-    "typescript": "^6.0.3"
+    "typescript": "^6.0.3",
+    "@deepseek-ai/dsh-host-webserver": "workspace:^"
   },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
@@ -43,6 +48,7 @@
   "files": [
     "lib/index.js",
     "lib/**/*.css",
-    "lib/types/**/*.d.ts"
+    "lib/types/**/*.d.ts",
+    "lib/apply-injections.js"
   ]
 }

+ 52 - 0
packages/client/web/src/apply-injections.ts

@@ -0,0 +1,52 @@
+/**
+ * Page-side interpreter for the structured index injection table. The served
+ * form renders the same rows into index.html text; a static worker page has
+ * no served HTML, so it executes the table directly. Rows execute strictly in
+ * table order, so a global row lands before the scripts that read it.
+ */
+import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
+
+function assertNever(row: never): never {
+  throw new Error(`web boot: unknown index injection row ${JSON.stringify(row)}`)
+}
+
+/**
+ * Execute every row in table order.
+ * @param rows - Injection table from the boot payload.
+ * @param loadScript - Executes one script-src row through the page owner's asset transport.
+ */
+export async function applyIndexInjections(
+  rows: readonly IndexInjection[],
+  loadScript: (src: string) => Promise<void>,
+): Promise<void> {
+  for (const row of rows) {
+    switch (row.kind) {
+      case 'global':
+        (globalThis as Record<string, unknown>)[row.name] = row.value
+        break
+      case 'script': {
+        const el = document.createElement('script')
+        el.textContent = row.text
+        ;(row.placement === 'head' ? document.head : document.body).append(el)
+        break
+      }
+      case 'script-src':
+        await loadScript(row.src)
+        break
+      case 'script-preload':
+        // Some transports cannot preload without executing; loadScript owns the actual request.
+        break
+      case 'style': {
+        const el = document.createElement('style')
+        el.textContent = row.text
+        document.head.append(el)
+        break
+      }
+      case 'html':
+        (row.placement === 'head' ? document.head : document.body).insertAdjacentHTML('beforeend', row.html)
+        break
+      default:
+        assertNever(row)
+    }
+  }
+}

+ 1 - 0
packages/client/web/src/index.ts

@@ -9,3 +9,4 @@
 export { AppWebEntry, type BootSeams } from './boot.ts'
 export { getStaticModules } from './seed.ts'
 export { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS, type PlatformModule } from './platform.ts'
+export { applyIndexInjections } from './apply-injections.ts'

+ 30 - 0
packages/client/web/tests/boot.client.spec.ts

@@ -218,3 +218,33 @@ describe('plugin activation', () => {
     await entry.dispose()
   })
 })
+
+it('draws the shared boot page before Host injections and resumes without replacing the document', async () => {
+  const gate = Promise.withResolvers<undefined>()
+  vi.stubGlobal('__DSH_BOOT_READY__', gate)
+  const container = document.createElement('div')
+  document.body.append(container)
+  const create = vi.fn(() => { throw new Error('injections consumed') })
+  const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+  const entry = new AppWebEntry(container)
+  const boot = entry.run()
+  try {
+    const page = container.querySelector('[data-dsh-boot]')
+    const spinner = container.querySelector('[data-dsh-boot-spinner]')
+    expect(spinner).not.toBeNull()
+    await Promise.resolve()
+    expect(create).not.toHaveBeenCalled()
+    expect(error).not.toHaveBeenCalled()
+    installFacade(create)
+    gate.resolve(undefined)
+    await boot
+    expect(create).toHaveBeenCalledOnce()
+    expect(container.querySelector('[data-dsh-boot]')).toBe(page)
+    expect(container.textContent).toContain('injections consumed')
+  } finally {
+    gate.resolve(undefined)
+    await boot
+    await entry.dispose()
+    vi.unstubAllGlobals()
+  }
+})

+ 3 - 0
packages/client/web/tsconfig.json

@@ -31,6 +31,9 @@
     },
     {
       "path": "../ui-renderer"
+    },
+    {
+      "path": "../../host/webserver"
     }
   ],
   "exclude": [

+ 1 - 1
packages/client/web/tsdown.config.ts

@@ -2,5 +2,5 @@ import { staticLinked } from '../tsdown.client.ts'
 
 export default staticLinked(
   '@deepseek-ai/dsh-client-web',
-  ['lib/types/index.js'],
+  ['lib/types/index.js', 'lib/types/apply-injections.js'],
 )

+ 2 - 1
packages/experimental/webworker-runtime/package.json

@@ -34,7 +34,8 @@
     "acorn": "^8.17.0",
     "buffer": "^6.0.3",
     "picomatch": "^4.0.4",
-    "readable-stream": "^4.7.0"
+    "readable-stream": "^4.7.0",
+    "@deepseek-ai/dsh-client-web": "workspace:^"
   },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",

+ 2 - 54
packages/experimental/webworker-runtime/src/client/apply-injections.ts

@@ -1,54 +1,2 @@
-/**
- * Page-side interpreter for the structured index injection table. The served
- * form renders the same rows into index.html text; a static worker page has
- * no served HTML, so it executes the table directly. Rows execute strictly in
- * table order, so a global row lands before the scripts that read it.
- */
-import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
-
-function assertNever(row: never): never {
-  throw new Error(`webworker-runtime: unknown index injection row ${JSON.stringify(row)}`)
-}
-
-/**
- * Execute every row in table order.
- * @param rows - Injection table from the boot payload.
- * @param loadScript - Executes one script-src row; the tunnel's `loadBundle`,
- * because the row URLs (`/plugins/...`) resolve only through the worker.
- */
-export async function applyIndexInjections(
-  rows: readonly IndexInjection[],
-  loadScript: (src: string) => Promise<void>,
-): Promise<void> {
-  for (const row of rows) {
-    switch (row.kind) {
-      case 'global':
-        (globalThis as Record<string, unknown>)[row.name] = row.value
-        break
-      case 'script': {
-        const el = document.createElement('script')
-        el.textContent = row.text
-        ;(row.placement === 'head' ? document.head : document.body).append(el)
-        break
-      }
-      case 'script-src':
-        await loadScript(row.src)
-        break
-      case 'script-preload':
-        // The worker tunnel has no browser URL to warm without also executing
-        // the script; loadScript handles the real request when the row arrives.
-        break
-      case 'style': {
-        const el = document.createElement('style')
-        el.textContent = row.text
-        document.head.append(el)
-        break
-      }
-      case 'html':
-        (row.placement === 'head' ? document.head : document.body).insertAdjacentHTML('beforeend', row.html)
-        break
-      default:
-        assertNever(row)
-    }
-  }
-}
+/** Shared page-side interpreter for asynchronous Host startup. */
+export { applyIndexInjections } from '@deepseek-ai/dsh-client-web/injections'

+ 3 - 0
packages/experimental/webworker-runtime/tsconfig.json

@@ -37,6 +37,9 @@
     },
     {
       "path": "../../util/crypto"
+    },
+    {
+      "path": "../../client/web"
     }
   ]
 }

+ 9 - 3
pnpm-lock.yaml

@@ -534,7 +534,7 @@ importers:
         version: link:../../packages/util/home-paths
       '@electron/get':
         specifier: ^5.1.0
-        version: 5.1.0
+        version: 5.1.0(supports-color@9.4.0)
       '@electron/notarize':
         specifier: 2.5.0
         version: 2.5.0
@@ -4834,6 +4834,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-host-webserver':
+        specifier: workspace:^
+        version: link:../../host/webserver
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -6320,6 +6323,9 @@ importers:
 
   packages/experimental/webworker-runtime:
     dependencies:
+      '@deepseek-ai/dsh-client-web':
+        specifier: workspace:^
+        version: link:../../client/web
       '@deepseek-ai/dsh-util-crypto':
         specifier: workspace:^
         version: link:../../util/crypto
@@ -20159,7 +20165,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  '@electron/get@5.1.0':
+  '@electron/get@5.1.0(supports-color@9.4.0)':
     dependencies:
       debug: 4.4.3(supports-color@9.4.0)
       env-paths: 3.0.0
@@ -23328,7 +23334,7 @@ snapshots:
   electron@44.0.0:
     dependencies:
       '@electron-internal/extract-zip': 1.0.5
-      '@electron/get': 5.1.0
+      '@electron/get': 5.1.0(supports-color@9.4.0)
       '@types/node': 24.13.3
     transitivePeerDependencies:
       - supports-color

+ 1 - 0
tsconfig.base.json

@@ -247,6 +247,7 @@
       "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": ["./packages/client/ui-settings-plugin-inventory/src"],
       "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": ["./packages/client/ui-settings-unarchive-sessions/src"],
       "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"],
+      "@deepseek-ai/dsh-client-web/injections": ["./packages/client/web/src/apply-injections.ts"],
       "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"],
       "@deepseek-ai/dsh-schedule/client": ["./packages/schedule/schedule/src/client.ts"],
       "@deepseek-ai/dsh-client-ui-directory-picker-browse": ["./packages/client/ui-directory-picker-browse/src"],