@linxin666/dsh-remote-web-ui 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +46 -8
  3. package/README.zh.md +7 -2
  4. package/lib/client.js +103 -58
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +704 -61
  7. package/lib/mobile.js +874 -64
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/index.d.ts.map +1 -1
  10. package/lib/types/client/index.js +71 -19
  11. package/lib/types/client/remote-channel.d.ts +4 -5
  12. package/lib/types/client/remote-channel.d.ts.map +1 -1
  13. package/lib/types/client/remote-channel.js +13 -28
  14. package/lib/types/http.d.ts +37 -8
  15. package/lib/types/http.d.ts.map +1 -1
  16. package/lib/types/http.js +76 -11
  17. package/lib/types/index.d.ts.map +1 -1
  18. package/lib/types/index.js +25 -1
  19. package/lib/types/mobile/api.d.ts +57 -0
  20. package/lib/types/mobile/api.d.ts.map +1 -1
  21. package/lib/types/mobile/api.js +29 -0
  22. package/lib/types/mobile/markdown.d.ts +7 -1
  23. package/lib/types/mobile/markdown.d.ts.map +1 -1
  24. package/lib/types/mobile/markdown.js +16 -7
  25. package/lib/types/mobile/markdown.test.js +8 -0
  26. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  27. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  28. package/lib/types/mobile/mobile-styles.js +325 -6
  29. package/lib/types/mobile/views/ChatView.d.ts +10 -0
  30. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  31. package/lib/types/mobile/views/ChatView.js +205 -8
  32. package/lib/types/mobile/views/ChatView.stream.test.d.ts +2 -0
  33. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +1 -0
  34. package/lib/types/mobile/views/ChatView.stream.test.js +205 -0
  35. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  36. package/lib/types/mobile/views/WorkspaceView.js +46 -5
  37. package/lib/types/mobile/views/WorkspaceView.test.js +129 -2
  38. package/lib/types/mobile-api.d.ts +9 -3
  39. package/lib/types/mobile-api.d.ts.map +1 -1
  40. package/lib/types/mobile-api.js +61 -16
  41. package/lib/types/mobile-pending.d.ts +38 -0
  42. package/lib/types/mobile-pending.d.ts.map +1 -0
  43. package/lib/types/mobile-pending.js +59 -0
  44. package/lib/types/paired-model-catalog.d.ts +17 -0
  45. package/lib/types/paired-model-catalog.d.ts.map +1 -0
  46. package/lib/types/paired-model-catalog.js +311 -0
  47. package/lib/types/pairing.d.ts +13 -4
  48. package/lib/types/pairing.d.ts.map +1 -1
  49. package/lib/types/pairing.js +13 -5
  50. package/lib/types/remote-api.d.ts.map +1 -1
  51. package/lib/types/remote-channel-boot.d.ts +26 -0
  52. package/lib/types/remote-channel-boot.d.ts.map +1 -0
  53. package/lib/types/remote-channel-boot.js +128 -0
  54. package/lib/types/remote-channel-rules.d.ts +40 -0
  55. package/lib/types/remote-channel-rules.d.ts.map +1 -0
  56. package/lib/types/remote-channel-rules.js +32 -0
  57. package/lib/types/routes.d.ts.map +1 -1
  58. package/lib/types/routes.js +12 -23
  59. package/lib/types/uuid-polyfill.d.ts +10 -0
  60. package/lib/types/uuid-polyfill.d.ts.map +1 -0
  61. package/lib/types/uuid-polyfill.js +27 -0
  62. package/package.json +1 -1
  63. package/src/client/index.ts +72 -18
  64. package/src/client/remote-channel.ts +18 -28
  65. package/src/http.ts +87 -12
  66. package/src/index.ts +27 -2
  67. package/src/mobile/api.ts +91 -0
  68. package/src/mobile/markdown.test.ts +9 -0
  69. package/src/mobile/markdown.ts +17 -7
  70. package/src/mobile/mobile-styles.ts +326 -6
  71. package/src/mobile/views/ChatView.stream.test.tsx +231 -0
  72. package/src/mobile/views/ChatView.tsx +328 -10
  73. package/src/mobile/views/WorkspaceView.test.tsx +163 -2
  74. package/src/mobile/views/WorkspaceView.tsx +152 -26
  75. package/src/mobile-api.ts +59 -17
  76. package/src/mobile-pending.ts +88 -0
  77. package/src/paired-model-catalog.ts +272 -0
  78. package/src/pairing.ts +13 -5
  79. package/src/remote-api.ts +0 -1
  80. package/src/remote-channel-boot.ts +131 -0
  81. package/src/remote-channel-rules.ts +62 -0
  82. package/src/routes.ts +13 -24
  83. package/src/uuid-polyfill.ts +28 -0
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # node scripts/verify-docs.mjs --write <dir>
5
- README.md: d3195d3b8a3dc58cf077538b0e18455b7d1706a6
6
- README.zh.md: c0a06bf7ae0b49460e6f4aa250b42f2c227b4e0a
5
+ README.md: edd272ef9b98700efc6ff465c5cb0dcc2e2d9648
6
+ README.zh.md: 53f1f72524ad3072ba2526108f7fc650403a5e0a
package/README.md CHANGED
@@ -53,18 +53,45 @@ that probes and runs the update.
53
53
  itself — pairing does not gate `/api` (no plugin can; the fence is the
54
54
  SDK's own seam). The posture probe below reports that stance when `/api`
55
55
  is reachable without pairing.
56
+ - **Paired model catalog**: authenticated paired devices can use `GET
57
+ /api/pair/model-catalog`, `POST /api/pair/model-catalog/discover`, and
58
+ `POST /api/pair/model-catalog/upsert` to inspect and adopt models for an
59
+ existing active `llm-pi-ai` provider only. The capability fixes the
60
+ provider settings address internally and cannot create providers or read or
61
+ change credentials, general settings, endpoints, headers, or arbitrary
62
+ configuration. The exact routes are served ahead of the connection
63
+ plugin's `/api` prefix, so a paired LAN or tunnel client may call them
64
+ directly, and they carry the same Host/Origin trust fence as the sibling
65
+ `/api/pair` endpoints: loopback, the advertised LAN literals, or the
66
+ configured public base URL. Through the `/remote` desktop channel all
67
+ `/api/pair/*` paths stay loopback-only. Adoption is refused while the
68
+ provider's live model catalog is unavailable or unknown. A provider whose
69
+ resolved `models` list is absent or empty keeps inheriting its installed
70
+ catalog until an unknown custom model must materialize that catalog;
71
+ existing model overrides are preserved and translated into the resulting
72
+ entries. Malformed or conflicting model profiles are refused instead of
73
+ being destructively rewritten. Stop or device
74
+ revocation disables it immediately; the
75
+ generic `settings.*`, `credentials.*`, and `llm.discoverModels` RPC methods
76
+ remain loopback-only.
56
77
  - **Remote desktop channel**: with `requirePairingForLan` on (default), a
57
78
  desktop Web GUI opened at the LAN URL or through the tunnel transparently
58
- rides `/remote` — the same UI, gated by the same pairing cookie. Browser
79
+ rides `/remote` — the same UI, gated by the same pairing cookie. The
80
+ rewrite installs twice-removed from the race it fixes: the host inlines a
81
+ small classic script right after the opening `<head>` tag
82
+ (`webserver/index-inject`), so fetch/WebSocket/EventSource and resource
83
+ `src` rewrites are active before any boot entry (the connection plugin
84
+ opens its event streams first); the browser half then adopts the installed
85
+ seat instead of patching twice. Loopback origins skip the patch entirely. Browser
59
86
  requests under `/api`, `/sidebar`, `/git`, and `/pet`, including the known
60
87
  event, terminal, and SSH WebSockets, are re-issued to the local web server
61
88
  without forwarding the remote Origin or pairing cookie; the authenticated
62
89
  proxy supplies its own same-origin browser marker for sibling route fences. The
63
90
  SDK's loopback-only privileged methods (native dialogs, the settings and
64
- credentials plane) stay unreachable from a paired remote desktop, and the
65
- `/api/pair/*`, `/api/update/*`, `/api/plugin-manager/*`,
66
- `/api/dsh-desktop-launcher/*` and `/api/dsh-web-ui-settings/*` control
67
- endpoints stay loopback-only. Unpaired desktop browsers get a persistent
91
+ credentials plane) stay unreachable from a paired remote desktop;
92
+ `/api/pair/*` (including the paired model-catalog routes),
93
+ `/api/update/*`, `/api/plugin-manager/*`, `/api/dsh-desktop-launcher/*`
94
+ and `/api/dsh-web-ui-settings/*` control endpoints stay loopback-only. Unpaired desktop browsers get a persistent
68
95
  full-page block instead of data (the page keys off the `unpaired` error
69
96
  code, not every 403). The block retires once a gated call succeeds or the
70
97
  channel itself is torn down — turning `requirePairingForLan` off (or the
@@ -223,9 +250,16 @@ need **no harness source changes** — the phone's RPC calls ride the
223
250
  plugin's `/m/api` proxy (which delegates to the host ApiProxy service and
224
251
  pages `session.list` itself), so the tunneled Host never has to enter the
225
252
  connection plugin's trust fence. The phone is gated by its paired-device
226
- cookie and an explicit method allowlist (settings/credentials/host-action
227
- domains are never reachable from the phone; model reads/writes are limited
228
- to the advisory `session.models` / `session.selectModel` pair, agent preset
253
+ cookie and an explicit method allowlist. The allowlist constrains the
254
+ `/m/api` proxy alone: the paired-device cookie itself also passes the global
255
+ api/gate, so a paired device is a full-control credential for the host `/api`
256
+ surface — settings/credentials/host-action domains stay unreachable only
257
+ because the SDK pins those privileged methods to loopback. Pairing is full
258
+ device trust; the `/m/api` proxy's model
259
+ reads/writes are limited to the advisory `session.models` /
260
+ `session.selectModel` pair. Separately, the exact paired-only model-catalog
261
+ routes may adopt models for an existing eligible `llm-pi-ai` provider; they
262
+ cannot create providers or access credentials or general settings. Agent preset
229
263
  access to read-only `agentPreset.list`, creation to `session.create`
230
264
  (workspace id plus an optional id from that roster — the phone never names a
231
265
  working directory of its own), and the permission picker only ever sends the
@@ -458,6 +492,10 @@ accept → UI. Only `publicBaseUrl` (plugin config) names the tunneled host;
458
492
  `--trusted-host` is not part of this pairing flow. The desktop panel still
459
493
  opens at `http://127.0.0.1`.
460
494
 
495
+ ## Security model
496
+
497
+ - Mobile unary routes and the mux event stream require a live paired-device session. A missing or revoked session receives HTTP 403 with a JSON rejection carrying `error.code: "unpaired"`; the browser's `EventSource` API exposes only the stream failure, not that response body.
498
+
461
499
  ## Known Limitations and Deferred Work
462
500
 
463
501
  - **Revocation is per-request**: a paired phone whose request is already in
package/README.zh.md CHANGED
@@ -13,7 +13,8 @@
13
13
  - **手机侧**:扫码将手机与一次性、限时令牌绑定,并落地到 **`/m/` 独立移动端界面**——一款专为小屏设计的轻客户端(见[截图](#截图)),而不是把桌面 UI 塞进手机。该页面可安装为 PWA;每个已安装应用使用自己的已配对设备 cookie,存储隔离的移动端 Web App 需要在该上下文打开新的二维码链接,或粘贴桌面端新复制的链接完成配对。链接携带 `workspace` 参数,手机落地到桌面正在查看的同一工作区。
14
14
  - **PC 侧(远程桌面配对)**:同一份二维码链接和配对令牌也能配对 PC 浏览器——把手机侧配对流扩展到桌面 Web GUI。从面板复制链接,在另一台电脑的浏览器打开(局域网 URL 或隧道 URL 均可);接受往返后,完整 Web GUI 在那台设备上运行——工作区、会话、聊天、切模型——受围栏保护的同源流量走门控的 `/remote` 通道,而不是直接调用仅 loopback 的 host 路由。手机进 `/m/`,PC 进桌面 UI:一枚令牌、一套配对流、两种界面。未配对的 PC 只看到不渲染底层数据的完整阻断页,其中提供电脑配对步骤与「重新检测」操作。
15
15
  - **安全**:一个有效的一次性令牌(刷新会使旧链接失效;已接受的令牌不可复用;令牌会过期)。停止会撤销每一台已配对设备与当前令牌——已配对设备在下一次请求时被切断。配对是本插件对自己远程通道的访问控制:手机走 `/m/api`,非 loopback origin 打开的桌面浏览器走 `/remote`。未配对调用方在 `requirePairingForLan` 开启(默认)时会在读取请求体之前就被 `/remote` 拒绝。loopback(127.0.0.1)继续直接使用 `/api`。默认的远程桌面路径不使用 `--trusted-host`:连接插件的 `/api` 围栏对公网和局域网主机保持关闭,已配对 PC 改走 `/remote`。`--trusted-host` 是 SDK 的另一种用法,会让该主机被信任访问 `/api` 本身——配对不门控 `/api`(任何插件都无法做到;围栏是 SDK 自己的接缝)。当 `/api` 无需配对即可到达时,下方姿态探测会报告该姿态。
16
- - **远程桌面通道**:`requirePairingForLan` 开启(默认)时,经局域网 URL 或隧道打开的桌面 Web GUI 透明地改走 `/remote`——同一套 UI,同一枚配对 cookie 门控。浏览器中 `/api`、`/sidebar`、`/git`、`/pet` 下的请求,以及已知的事件、终端与 SSH WebSocket,会在不转发远程 Origin 或配对 cookie 的前提下重新发给本机 Web Server;认证反代会为兄弟路由围栏提供自己生成的同源浏览器标记。SDK 的 loopback 专属特权方法(原生对话框、settings 与 credentials 面)对已配对远程桌面保持不可达,`/api/pair/*`、`/api/update/*`、`/api/plugin-manager/*`、`/api/dsh-desktop-launcher/*` 与 `/api/dsh-web-ui-settings/*` 控制端点保持仅 loopback。未配对的桌面浏览器看到持久的全页阻断界面而不是数据(阻断页按错误码 `unpaired` 触发,不是任意 403)。阻断页在任一门控调用成功后,或通道本身被拆除时退出——把 `requirePairingForLan` 关掉(或关闭插件)也会清掉通道短暂激活期间弹出的提示。
16
+ - **已配对模型目录**:已认证的已配对设备可使用 `GET /api/pair/model-catalog`、`POST /api/pair/model-catalog/discover` 和 `POST /api/pair/model-catalog/upsert`,仅查看并采纳一个现有、活跃的 `llm-pi-ai` provider 的模型。该能力在内部固定 provider 的 settings 地址,不能创建 provider,也不能读取或修改凭据、通用 settings、端点、header 或任意配置。这三条精确路由在 connection 插件的 `/api` 前缀之前匹配,已配对的局域网或隧道客户端可直接调用,并套用与 `/api/pair` 兄弟端点相同的 Host/Origin 信任围栏:仅接受 loopback、广告的局域网字面量或配置的公网 base URL。经 `/remote` 桌面通道访问时,所有 `/api/pair/*` 路径仍保持仅 loopback。当 provider 的实时模型目录不可用或未知时,采纳会被拒绝。解析后的 `models` 列表缺失或为空时,provider 会继续继承已安装目录;只有加入未知的自定义模型时才会实体化完整目录,现有模型 override 会保留并转换到对应条目。格式错误或相互冲突的模型配置会被拒绝,不会被破坏性重写。停止或撤销设备会立即禁用它;通用 `settings.*`、`credentials.*` 和 `llm.discoverModels` RPC 方法仍然仅限 loopback。
17
+ - **远程桌面通道**:`requirePairingForLan` 开启(默认)时,经局域网 URL 或隧道打开的桌面 Web GUI 透明地改走 `/remote`——同一套 UI,同一枚配对 cookie 门控。改写补丁在解析期即生效:宿主把一段内联 classic script 注入到 `<head>` 开标签之后(`webserver/index-inject`),使 fetch/WebSocket/EventSource 与资源 `src` 改写在任何启动条目执行前就绪(连接插件最先打开事件流);浏览器半边随后接管已安装的 seat 而非重复打补丁。loopback 来源完全跳过该补丁。浏览器中 `/api`、`/sidebar`、`/git`、`/pet` 下的请求,以及已知的事件、终端与 SSH WebSocket,会在不转发远程 Origin 或配对 cookie 的前提下重新发给本机 Web Server;认证反代会为兄弟路由围栏提供自己生成的同源浏览器标记。SDK 的 loopback 专属特权方法(原生对话框、settings 与 credentials 面)对已配对远程桌面保持不可达;`/api/pair/*`(包括三条模型目录路由)、`/api/update/*`、`/api/plugin-manager/*`、`/api/dsh-desktop-launcher/*` 与 `/api/dsh-web-ui-settings/*` 控制端点保持仅 loopback。未配对的桌面浏览器看到持久的全页阻断界面而不是数据(阻断页按错误码 `unpaired` 触发,不是任意 403)。阻断页在任一门控调用成功后,或通道本身被拆除时退出——把 `requirePairingForLan` 关掉(或关闭插件)也会清掉通道短暂激活期间弹出的提示。
17
18
  - **姿态探测**:插件用伪造 Host 头(公网 base 与每个 LAN base)探测 SDK 的 `/api` 围栏。403 是默认姿态(围栏关闭,远程访问走配对)。任何非 403 结果——`--trusted-host`,或 `--host 0.0.0.0` 下 SDK 的局域网自动信任——都会以 CRITICAL 日志与配对面板红色横幅呈现,因此 SDK 的 `/api` 信任姿态是被看见的,而不是被假设的。探测失败会丢掉进行中的目标缓存,相同 origin 会在下次触发时重试。
18
19
  - **实时状态**:桌面面板经 SSE 流实时镜像配对状态(等待 → 已连接 → 已断开),并展示已授权设备名单。
19
20
  - **远程更新**:侧边栏加载后,底部的下载触发按钮(手机图标左侧)会静默探测 npm registry 上已安装的 `@linxin666/dsh-*` 全家桶版本;发现可自动更新的新版本时,按钮显示圆点并提供“发现新版本,检查更新”提示。点击按钮打开更新面板;未安装聚合包时,检查和更新覆盖 profile 中 registry 管理的全部 `@linxin666/*` 直接依赖,本地 link / file 开发依赖会被跳过。当存在较新版本时,面板展示版本对比并等待确认——点击「开始更新」后执行更新(在所属 dsh profile 内 `pnpm update --latest`;pnpm 缺失时依次回退 `corepack pnpm`、`npx --yes pnpm`,Windows 上经 `cmd.exe` 执行以解析 npm 安装的 `.cmd` shim;由仅 loopback 的 `/api/update/status` + `/api/update/run` 端点驱动)并请求重启 dsh web 以生效。pnpm 绿色退出后还会对照 registry 复核已装版本:绿色退出但版本纹丝不动(例如 pnpm 的 `minimumReleaseAge` 门禁静默跳过同日发布的新版本)会报告为「未更新成功」并附配置指引,而不是误报成功。锚点自身是本地 link 安装(开发模式)时,只报告 npm 状态而不更新。
@@ -71,7 +72,7 @@ dsh plugin --profile web add link:$(pwd)/packages/dsh-remote-web-ui
71
72
  6. 桌面徽标实时翻到 已连接;手机离开时回落到离线/断开。
72
73
  7. 刷新二维码 使旧链接失效并铸一枚新的。停止 撤销移动端访问:已配对设备下一次请求 403,包括其实时流。
73
74
 
74
- 该移动端界面完全自包含在本插件内:`/m/` 页面及其数据通道(`/m/api`)由插件自己的路由伺服,**无需任何 harness 源码改动**——手机的 RPC 调用走插件的 `/m/api` 代理(它委托给 host 的 ApiProxy 服务并自己分页 `session.list`),因此被隧道化的 Host 永远不必进入连接插件的信任围栏。手机受其已配对设备 cookie 与显式方法白名单门控(settings/credentials/host-action 域手机永远不可达;模型读写限制于建议性的 `session.models` / `session.selectModel` 对,Agent 预设访问限制于只读 `agentPreset.list`,创建限制于 `session.create`(工作区 id 加清单中的可选 id——手机绝不自命名工作目录),权限选择器只通过已放行的 `session.prompt` 发送模式无关的 `/permission` 命令);实时流在 `/m/api/events.mux` 上经 Server-Sent Events 送达。规范的 `/m/` 页面拥有同 scope 的 manifest 与 Service Worker;其缓存仅包含静态壳和离线页,绝不包含移动端 API 响应、会话数据或命令。
75
+ 该移动端界面完全自包含在本插件内:`/m/` 页面及其数据通道(`/m/api`)由插件自己的路由伺服,**无需任何 harness 源码改动**——手机的 RPC 调用走插件的 `/m/api` 代理(它委托给 host 的 ApiProxy 服务并自己分页 `session.list`),因此被隧道化的 Host 永远不必进入连接插件的信任围栏。手机受其已配对设备 cookie 与 `/m/api` 显式方法白名单门控(白名单只约束 `/m/api` 代理本身:该 cookie 同时通过全局 api/gate,对宿主 `/api` 面是全量控制凭证——settings/credentials/host-action 域仅因 SDK 把这些特权方法钉为仅 loopback 才不可达。配对即对设备的完全信任;`/m/api` 代理的模型读写限制于建议性的 `session.models` / `session.selectModel` 对;另外,精确的已配对模型目录路由可为一个现有、合格的 `llm-pi-ai` provider 采纳模型,但不能创建 provider,也不能访问凭据或通用 settings;Agent 预设访问限制于只读 `agentPreset.list`,创建限制于 `session.create`(工作区 id 加清单中的可选 id——手机绝不自命名工作目录),权限选择器只通过已放行的 `session.prompt` 发送模式无关的 `/permission` 命令);实时流在 `/m/api/events.mux` 上经 Server-Sent Events 送达。规范的 `/m/` 页面拥有同 scope 的 manifest 与 Service Worker;其缓存仅包含静态壳和离线页,绝不包含移动端 API 响应、会话数据或命令。
75
76
 
76
77
  ### 行为说明
77
78
 
@@ -168,6 +169,10 @@ pnpm run build
168
169
 
169
170
  公网路径是经隧道的同一往返(见「通过互联网远程访问」):loopback mint → 手机或 PC 打开公网二维码 URL → accept → UI。只有 `publicBaseUrl`(插件配置)命名隧道主机;`--trusted-host` 不属于这条配对流。桌面面板仍在 `http://127.0.0.1` 打开。
170
171
 
172
+ ## 安全模型
173
+
174
+ - 移动端普通接口与 mux 事件流都要求有效的已配对设备会话。会话缺失或已撤销时返回 HTTP 403,并使用带 `error.code: "unpaired"` 的 JSON 拒绝信封;浏览器 `EventSource` API 只暴露事件流失败,不暴露该响应体。
175
+
171
176
  ## 已知限制与待办
172
177
 
173
178
  - **撤销是逐请求的**:已配对手机请求已在 停止 落地时在途,完成该请求;下一个 403。
package/lib/client.js CHANGED
@@ -3781,6 +3781,38 @@ window.__ModuleLoader__.load({
3781
3781
  page.replaceState(`${url.pathname}${url.search}${url.hash}`);
3782
3782
  }
3783
3783
  //#endregion
3784
+ //#region src/remote-channel-rules.ts
3785
+ /**
3786
+ * The remote-channel rewrite contract as pure data (issue #987): both the
3787
+ * browser patch (client/remote-channel.ts) and the parse-time boot patch
3788
+ * (remote-channel-boot.ts, inlined into index.html by the host) decide from
3789
+ * these tables, so the two can never drift apart.
3790
+ * @module @linxin666/dsh-remote-web-ui/remote-channel-rules
3791
+ */
3792
+ /** The gated mirror prefix (must match src/remote-methods.ts). */
3793
+ const REMOTE_PREFIX = "/remote";
3794
+ /** The live rule set. */
3795
+ const REMOTE_CHANNEL_RULES = {
3796
+ remotePrefix: REMOTE_PREFIX,
3797
+ apiPrefix: "/api/",
3798
+ pairPrefix: "/api/pair/",
3799
+ updatePrefix: "/api/update/",
3800
+ desktopLauncherPrefix: "/api/dsh-desktop-launcher",
3801
+ settingsBridgePrefix: "/api/dsh-web-ui-settings",
3802
+ sidebarPrefix: "/sidebar/",
3803
+ gitPrefix: "/git/",
3804
+ petPrefix: "/pet/",
3805
+ wsPaths: [
3806
+ "/api/events.mux",
3807
+ "/api/events.host",
3808
+ "/sidebar/ws/terminal",
3809
+ "/sidebar/ws/agent-terminals",
3810
+ "/api/dsh-ssh/terminal"
3811
+ ]
3812
+ };
3813
+ /** The window global the boot patch publishes its seat under. */
3814
+ const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
3815
+ //#endregion
3784
3816
  //#region src/client/remote-channel.ts
3785
3817
  /**
3786
3818
  * The remote desktop channel — browser half. On a non-loopback origin (LAN
@@ -3807,23 +3839,7 @@ window.__ModuleLoader__.load({
3807
3839
  * Pure helpers are exported for unit tests; `installRemoteChannel` patches
3808
3840
  * the given window and returns their restore.
3809
3841
  */
3810
- /** The gated mirror prefix (must match src/remote-methods.ts). */
3811
- const REMOTE_PREFIX = "/remote";
3812
- const API_PREFIX = "/api/";
3813
- const PAIR_PREFIX = "/api/pair/";
3814
- const UPDATE_PREFIX = "/api/update/";
3815
- const DESKTOP_LAUNCHER_PREFIX = "/api/dsh-desktop-launcher";
3816
- const SETTINGS_BRIDGE_PREFIX = "/api/dsh-web-ui-settings";
3817
- const SIDEBAR_PREFIX = "/sidebar/";
3818
- const GIT_PREFIX = "/git/";
3819
- const PET_PREFIX = "/pet/";
3820
- const WS_PATHS = /* @__PURE__ */ new Set([
3821
- "/api/events.mux",
3822
- "/api/events.host",
3823
- "/sidebar/ws/terminal",
3824
- "/sidebar/ws/agent-terminals",
3825
- "/api/dsh-ssh/terminal"
3826
- ]);
3842
+ const RULES = REMOTE_CHANNEL_RULES;
3827
3843
  /** Decide whether a remote desktop channel is required from local or host policy. */
3828
3844
  function remoteChannelRequired(hostname, snapshot, hostPairingPolicy) {
3829
3845
  if (isLoopbackHostname(hostname)) return false;
@@ -3847,14 +3863,14 @@ window.__ModuleLoader__.load({
3847
3863
  * @param pathname - the request URL pathname.
3848
3864
  */
3849
3865
  function shouldRewriteFetchPath(pathname) {
3850
- if (pathname.startsWith(PAIR_PREFIX)) return false;
3851
- if (pathname.startsWith(UPDATE_PREFIX)) return false;
3852
- if (pathname === DESKTOP_LAUNCHER_PREFIX || pathname.startsWith(`${DESKTOP_LAUNCHER_PREFIX}/`)) return false;
3853
- if (pathname === SETTINGS_BRIDGE_PREFIX || pathname.startsWith(`${SETTINGS_BRIDGE_PREFIX}/`)) return false;
3854
- if (pathname.startsWith(API_PREFIX)) return true;
3855
- if (pathname.startsWith(SIDEBAR_PREFIX) || pathname === "/sidebar") return true;
3856
- if (pathname.startsWith(GIT_PREFIX) || pathname === "/git") return true;
3857
- if (pathname.startsWith(PET_PREFIX) || pathname === "/pet") return true;
3866
+ if (pathname.startsWith(RULES.pairPrefix)) return false;
3867
+ if (pathname.startsWith(RULES.updatePrefix)) return false;
3868
+ if (pathname === RULES.desktopLauncherPrefix || pathname.startsWith(`${RULES.desktopLauncherPrefix}/`)) return false;
3869
+ if (pathname === RULES.settingsBridgePrefix || pathname.startsWith(`${RULES.settingsBridgePrefix}/`)) return false;
3870
+ if (pathname.startsWith(RULES.apiPrefix)) return true;
3871
+ if (pathname.startsWith(RULES.sidebarPrefix) || pathname === "/sidebar") return true;
3872
+ if (pathname.startsWith(RULES.gitPrefix) || pathname === "/git") return true;
3873
+ if (pathname.startsWith(RULES.petPrefix) || pathname === "/pet") return true;
3858
3874
  return false;
3859
3875
  }
3860
3876
  /**
@@ -3862,7 +3878,7 @@ window.__ModuleLoader__.load({
3862
3878
  * @param pathname - the WebSocket URL pathname.
3863
3879
  */
3864
3880
  function shouldRewriteWsPath(pathname) {
3865
- return WS_PATHS.has(pathname);
3881
+ return RULES.wsPaths.includes(pathname);
3866
3882
  }
3867
3883
  /** The gated twin of one fenced path (`/remote` + original pathname). */
3868
3884
  function rewritePath(pathname) {
@@ -4098,10 +4114,16 @@ window.__ModuleLoader__.load({
4098
4114
  * @param ctx - client root context.
4099
4115
  */
4100
4116
  function apply(ctx) {
4101
- ctx.effect(() => ctx.locale.register(NS, {
4102
- zh,
4103
- en
4104
- }), "remote-web-ui: dictionaries");
4117
+ ctx.effect(() => {
4118
+ try {
4119
+ return ctx.locale.register(NS, {
4120
+ zh,
4121
+ en
4122
+ });
4123
+ } catch {
4124
+ return () => {};
4125
+ }
4126
+ }, "remote-web-ui: dictionaries");
4105
4127
  const t = ctx.locale.bind(NS);
4106
4128
  const settingsScope = (ctx.get("webUiSettings") ?? ctx.settingsScope).bind({ namespace: REMOTE_WEB_UI_NS });
4107
4129
  const enabled = () => {
@@ -4111,10 +4133,12 @@ window.__ModuleLoader__.load({
4111
4133
  ctx.slots.inject("sidebar.remote", () => {
4112
4134
  let disposeEntry;
4113
4135
  const syncEntry = () => {
4114
- if (enabled() && disposeEntry === void 0) disposeEntry = ctx.slots.register({
4115
- name: "sidebar.remote",
4116
- locale: NS
4117
- }, RemoteEntry);
4136
+ if (enabled() && disposeEntry === void 0) try {
4137
+ disposeEntry = ctx.slots.register({
4138
+ name: "sidebar.remote",
4139
+ locale: NS
4140
+ }, RemoteEntry);
4141
+ } catch {}
4118
4142
  else if (!enabled() && disposeEntry !== void 0) {
4119
4143
  disposeEntry();
4120
4144
  disposeEntry = void 0;
@@ -4130,11 +4154,13 @@ window.__ModuleLoader__.load({
4130
4154
  ctx.slots.inject("sidebar.footer.action", () => {
4131
4155
  let disposeEntry;
4132
4156
  const syncEntry = () => {
4133
- if (enabled() && disposeEntry === void 0) disposeEntry = ctx.slots.register({
4134
- name: "sidebar.footer.action",
4135
- id: "remote-web-ui",
4136
- locale: NS
4137
- }, FooterRemoteEntry);
4157
+ if (enabled() && disposeEntry === void 0) try {
4158
+ disposeEntry = ctx.slots.register({
4159
+ name: "sidebar.footer.action",
4160
+ id: "remote-web-ui",
4161
+ locale: NS
4162
+ }, FooterRemoteEntry);
4163
+ } catch {}
4138
4164
  else if (!enabled() && disposeEntry !== void 0) {
4139
4165
  disposeEntry();
4140
4166
  disposeEntry = void 0;
@@ -4149,17 +4175,21 @@ window.__ModuleLoader__.load({
4149
4175
  });
4150
4176
  const remoteSettings = new RemoteSettingsCardController(settingsScope);
4151
4177
  ctx.slots.inject("web-ui.plugin.item", () => {
4152
- const unregister = ctx.slots.register({
4153
- name: "web-ui.plugin.item",
4154
- id: "remote-web-ui",
4155
- order: 90,
4156
- locale: NS,
4157
- inject: () => remoteSettings.inject()
4158
- }, RemoteSettingsCard);
4159
- return () => {
4160
- remoteSettings.dispose();
4161
- unregister();
4162
- };
4178
+ try {
4179
+ const unregister = ctx.slots.register({
4180
+ name: "web-ui.plugin.item",
4181
+ id: "remote-web-ui",
4182
+ order: 90,
4183
+ locale: NS,
4184
+ inject: () => remoteSettings.inject()
4185
+ }, RemoteSettingsCard);
4186
+ return () => {
4187
+ remoteSettings.dispose();
4188
+ unregister();
4189
+ };
4190
+ } catch {
4191
+ return () => {};
4192
+ }
4163
4193
  });
4164
4194
  let disposeRuntime;
4165
4195
  const syncRuntime = () => {
@@ -4216,19 +4246,34 @@ window.__ModuleLoader__.load({
4216
4246
  showFenceNotice();
4217
4247
  };
4218
4248
  const channelActive = () => remoteChannelRequired(window.location.hostname, settingsScope.getSnapshot(), hostPairingPolicy);
4249
+ const bootSeat = () => window[REMOTE_CHANNEL_BOOT_GLOBAL];
4219
4250
  const syncChannel = () => {
4220
4251
  const transition = channelTransition(channelActive(), disposeChannel !== void 0);
4221
- if (transition === "install") disposeChannel = ctx.effect(() => {
4222
- return installRemoteChannel(window, {
4223
- onUnpaired: handleUnpaired,
4224
- onPaired: hideFenceNotice
4225
- });
4226
- }, "remote-web-ui: remote desktop channel");
4227
- else if (transition === "retire" && disposeChannel !== void 0) {
4252
+ if (transition === "install") {
4253
+ const seat = bootSeat();
4254
+ if (seat !== void 0) {
4255
+ seat.onUnpaired = handleUnpaired;
4256
+ seat.onPaired = hideFenceNotice;
4257
+ if (seat.pendingUnpaired) {
4258
+ seat.pendingUnpaired = false;
4259
+ handleUnpaired();
4260
+ }
4261
+ disposeChannel = ctx.effect(() => () => {
4262
+ seat.onUnpaired = null;
4263
+ seat.onPaired = null;
4264
+ }, "remote-web-ui: remote desktop channel (boot patch)");
4265
+ } else disposeChannel = ctx.effect(() => {
4266
+ return installRemoteChannel(window, {
4267
+ onUnpaired: handleUnpaired,
4268
+ onPaired: hideFenceNotice
4269
+ });
4270
+ }, "remote-web-ui: remote desktop channel");
4271
+ } else if (transition === "retire" && disposeChannel !== void 0) {
4228
4272
  disposeChannel();
4229
4273
  disposeChannel = void 0;
4274
+ bootSeat()?.restore();
4230
4275
  hideFenceNotice();
4231
- }
4276
+ } else if (transition === "none" && !channelActive()) bootSeat()?.restore();
4232
4277
  };
4233
4278
  settingsScope.subscribe(syncChannel);
4234
4279
  syncChannel();