dsh-mobile-plugin 0.4.0 → 0.4.4

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 (111) hide show
  1. package/README.md +71 -74
  2. package/dsh-mobile-credentials/lib/credentials.js +47 -24
  3. package/host.js +22 -36
  4. package/lib/client.js +29 -82
  5. package/lib/desktop-view.cjs +5 -10
  6. package/lib/git/read.js +6 -8
  7. package/lib/host-compat.cjs +13 -25
  8. package/lib/host-guard.cjs +9 -39
  9. package/lib/host-patches.cjs +5 -16
  10. package/lib/http/routing.js +7 -27
  11. package/lib/impl.js +6 -12
  12. package/lib/index.js +1 -1
  13. package/lib/mirror/pusher.cjs +33 -85
  14. package/lib/mirror-snapshot.cjs +6 -24
  15. package/lib/mobile/agent-presets.js +6 -26
  16. package/lib/mobile/allowed-zone.js +100 -0
  17. package/lib/mobile/approvals.js +18 -44
  18. package/lib/mobile/archive-clean.js +13 -21
  19. package/lib/mobile/attachments.js +16 -37
  20. package/lib/mobile/channels.js +15 -41
  21. package/lib/mobile/credential-upgrade.js +7 -16
  22. package/lib/mobile/credentials.js +5 -21
  23. package/lib/mobile/directory-browse.js +8 -27
  24. package/lib/mobile/directory-listing.js +10 -23
  25. package/lib/mobile/drive-roots.js +59 -0
  26. package/lib/mobile/envelope.js +4 -15
  27. package/lib/mobile/events.js +35 -65
  28. package/lib/mobile/extensions.js +5 -8
  29. package/lib/mobile/file-read.js +168 -0
  30. package/lib/mobile/git-repos.js +6 -15
  31. package/lib/mobile/git.js +11 -19
  32. package/lib/mobile/history-derive.js +85 -134
  33. package/lib/mobile/host-bridge/endpoints.js +4 -6
  34. package/lib/mobile/host-bridge/event-synthesis.js +21 -35
  35. package/lib/mobile/host-bridge/shims.js +12 -22
  36. package/lib/mobile/host-bridge.js +8 -20
  37. package/lib/mobile/host-settings.js +9 -19
  38. package/lib/mobile/inbox-clean.js +7 -24
  39. package/lib/mobile/local-images.js +48 -23
  40. package/lib/mobile/model-vendors.js +105 -0
  41. package/lib/mobile/monitor.js +8 -17
  42. package/lib/mobile/pairing.js +7 -19
  43. package/lib/mobile/plugin-market.js +475 -0
  44. package/lib/mobile/plugin-self.js +6 -23
  45. package/lib/mobile/plugin-update.js +8 -27
  46. package/lib/mobile/plugins.js +6 -24
  47. package/lib/mobile/questions.js +15 -40
  48. package/lib/mobile/references.js +11 -31
  49. package/lib/mobile/relay-control.js +136 -33
  50. package/lib/mobile/rpc.js +5 -13
  51. package/lib/mobile/schedule.js +8 -33
  52. package/lib/mobile/self-check.cjs +9 -36
  53. package/lib/mobile/session-cache.js +13 -19
  54. package/lib/mobile/session-cwd.js +28 -0
  55. package/lib/mobile/session-file.js +9 -33
  56. package/lib/mobile/session-flags.js +7 -13
  57. package/lib/mobile/session-mirror.js +8 -15
  58. package/lib/mobile/session-models.js +3 -3
  59. package/lib/mobile/session-schedule-mark.js +8 -14
  60. package/lib/mobile/session-send.js +13 -21
  61. package/lib/mobile/session-snapshot.js +4 -6
  62. package/lib/mobile/sessions.js +7 -23
  63. package/lib/mobile/stats-collect.cjs +154 -37
  64. package/lib/mobile/stats-ledger.cjs +258 -75
  65. package/lib/mobile/stats-scheduler.cjs +41 -0
  66. package/lib/mobile/stream.js +39 -130
  67. package/lib/mobile/subagents.js +5 -18
  68. package/lib/mobile/uploads.js +75 -21
  69. package/lib/mobile/vendor-logos.cjs +30 -0
  70. package/lib/mobile/wire-cut.js +212 -0
  71. package/lib/mobile/workspaces.js +20 -8
  72. package/lib/panel/index.html +7 -7
  73. package/lib/read-receipt.cjs +10 -21
  74. package/lib/relay/apply-ledger.cjs +52 -0
  75. package/lib/relay/core.js +14 -3
  76. package/lib/relay/lan-network.cjs +14 -28
  77. package/lib/relay/lan-proxy.js +14 -21
  78. package/lib/relay/migrate.js +90 -0
  79. package/lib/relay/pairing-policy.js +6 -9
  80. package/lib/relay/preview.js +8 -25
  81. package/lib/relay/protocol.js +9 -12
  82. package/lib/relay/status.js +5 -8
  83. package/lib/relay/tunnel.js +9 -14
  84. package/lib/relay/verify.js +52 -0
  85. package/lib/routes/control-plane.js +39 -47
  86. package/lib/routes/mobile-api.js +47 -34
  87. package/lib/session-cleanup.cjs +26 -27
  88. package/lib/shared/credentials-entry.js +4 -11
  89. package/lib/shared/device-headers.js +12 -4
  90. package/lib/shared/dshtoken.js +4 -4
  91. package/lib/shared/e2ee-request.js +1 -1
  92. package/lib/shared/e2ee.js +8 -14
  93. package/lib/shared/fresh-request.js +6 -22
  94. package/lib/shared/http-util.js +3 -4
  95. package/lib/shared/list-revision.js +5 -14
  96. package/lib/shared/session-signal.js +7 -16
  97. package/lib/shared/sse-write.js +14 -20
  98. package/lib/shared/workspace-guard.js +7 -15
  99. package/lib/shell/device-ota.js +4 -6
  100. package/lib/shell/manager.js +12 -27
  101. package/lib/shell/ota-package.js +7 -12
  102. package/lib/shell/runtime.js +8 -16
  103. package/lib/shell/self-check-after-swap.js +9 -23
  104. package/lib/shell/updater.js +15 -28
  105. package/lib/sidecar-facade-worker.cjs +75 -41
  106. package/lib/sidecar-facade.cjs +18 -8
  107. package/lib/sidecar-lifecycle.cjs +21 -10
  108. package/lib/update-install.cjs +24 -62
  109. package/lib/update-registry.cjs +6 -18
  110. package/package.json +1 -1
  111. package/lib/auto-archive.cjs +0 -98
@@ -3,19 +3,17 @@
3
3
  /**
4
4
  * 控制面路由(`/api/relay/*`):桌面端设置页与宿主本机运维动作走这里。
5
5
  *
6
- * 与移动端路由表(lib/routes/mobile-api.js)是刻意的两套:控制面只认本机来源
7
- * (localOnly),移动端只认 token(guarded)。这条不对称是有原因的——控制面会读
8
- * 进程级错误账本(含堆栈)并且能改配置、能吊销全部授权,暴露给中转等于把桌面端的
9
- * 后门开到公网;反过来,移动端请求可能来自中转,根本没有本机 socket 可言。
6
+ * 与移动端路由表是刻意的两套:控制面只认本机来源(localOnly),移动端只认 token(guarded)。
7
+ * 控制面会读进程级错误账本(含堆栈)、能改配置、能吊销全部授权,暴露给中转等于把后门开到
8
+ * 公网;反过来移动端请求可能来自中转,根本没有本机 socket 可言。
10
9
  *
11
- * 抽出来的原因与移动端路由表一样:它们原本是 apply() 闭包里连着 130 多行的注册语句,
12
- * 混在 sidecar 生命周期与镜像推送之间,改一条路由要在 1000 行文件里找。依赖以
13
- * 取值器/函数注入,不捕获对象——闭包里那些 `let`(sidecarSnapshot、lanPortAdjusted、
14
- * sidecarState…)会被整体替换或重新赋值。`getSidecar` 尤其重要:sidecar 会被自重启换掉,
15
- * 拿到实例本身就等于拿到一份指向旧 facade 的快照。
10
+ * 依赖以取值器 / 函数注入,不捕获对象:闭包里那些 `let`(sidecarSnapshot、sidecarState…)
11
+ * 会被整体替换。`getSidecar` 尤其重要:sidecar 会被自重启换掉,拿到实例就等于拿到旧 facade
12
+ * 的快照。
16
13
  *
17
- * 插件更新那两条:`check` 自己问 npm 注册表(不要求外壳在场),`apply` 只委托外壳的
18
- * `apply`(判定 / 装 / 换代 / 落账同一层)——各自的取舍写在路由头上。
14
+ * 改中转地址(`POST /api/relay/config`)与手机端写的是同一份配置、走同一条下发路径;「先登记
15
+ * 迁移、再重建隧道」的顺序在 worker 的 configure 里(见 ../relay/migrate.js),这里只把那次
16
+ * 下发的失败原因如实带回给面板。
19
17
  */
20
18
 
21
19
  const fs = require('node:fs');
@@ -99,14 +97,10 @@ function createWebAccessLocalOnly({ localOnly, trustedLocalUi, webAccessWindow,
99
97
  }
100
98
 
101
99
  /**
102
- * 面板页(`React/` 那份 React 源码构建成的单文件 HTML)在包里的位置。
100
+ * 面板页(`React/` 构建成的单文件 HTML)在包里的位置。
103
101
  *
104
- * 用 `__dirname` 而不是包根,是为了「谁跟着这份路由一起走」:HTML 是**提交物**,和
105
- * `lib/` 在同一份安装里,装哪个版本就带哪个版本的页面。
106
- *
107
- * 这段注释过去写的是「控制面路由住在实现代 `lib/.live/<releaseId>/` 里,改版式不用重装」——
108
- * 那套「稳定外壳 + 可热替换的代」随插件 OTA 在 2026-10 一起删了(A5-02)。现在改版式只有
109
- * 一条路:`React/` 里重建 → 提交产物 → 发新版本 → 市场里更新 → 完整重启 DSH。
102
+ * 用 `__dirname` 而不是包根:HTML 是提交物,和 `lib/` 在同一份安装里,装哪个版本就带哪个
103
+ * 版本的页面。改版式只有一条路:`React/` 里重建 → 提交产物 → 发新版本 → 完整重启 DSH。
110
104
  */
111
105
  const PANEL_FILE = path.join(__dirname, '..', 'panel', 'index.html');
112
106
 
@@ -145,20 +139,15 @@ function panelHtml() {
145
139
  }
146
140
 
147
141
  /**
148
- * 面板读不到时,说清楚**是哪一种读不到**。
149
- *
150
- * 这条是给「装了但打不开」的现场用的。过去这里只有一句「去 `React/` 里跑一次
151
- * pnpm run build」,那是**开发树**才遇得到的情况;装到别人机器上时它几乎总是另外两件事:
142
+ * 面板读不到时,说清楚是哪一种读不到。
152
143
  *
153
- * 1. **安装目录整个不见了**(2026-10-10 实测现场):在插件市场里装/更新过、DSH 还没完整
154
- * 重启,跑着的仍是旧实例,而旧实例所在的 pnpm 目录已经被这次安装删掉了 —— 代码在内存里,
155
- * 所以路由照常响应,`fs` 一读就 ENOENT,面板页于是吐一段 JSON。解法是**重启 DSH**,
156
- * 不是去跑构建。
157
- * 2. **这份安装里根本没有产物**:0.1.47 及更早的版本还没有这一页(`files` 白名单当时
158
- * 没收 `lib/` 下的 html),老包里点开就是这条。解法是**更新到当前版本**。
144
+ * 两种情形解法完全不同,所以分开报,并把路径与版本一起带上:
145
+ * 1. 安装目录整个不见了——市场里装/更新过、DSH 还没完整重启,跑着的仍是旧实例,而旧实例
146
+ * 所在的目录已被这次安装删掉。代码在内存里所以路由照常响应,`fs` 一读就 ENOENT。解法是
147
+ * 重启 DSH,不是去跑构建。
148
+ * 2. 这份安装里根本没有产物——老版本还没有这一页。解法是更新到当前版本。
159
149
  *
160
- * 两件事的解法完全不同,所以分开报,并把路径与版本一起带上 —— 下次看到这段报错,一眼就知道
161
- * 该重启还是该升级。三种情形由 `test/panel-artifact.test.cjs` 钉住。
150
+ * 三种情形由 `test/panel-artifact.test.cjs` 钉住。
162
151
  *
163
152
  * @param {{panelFile?: string, packageDir?: string}} [paths] 只给测试喂临时目录用;运行时走默认路径
164
153
  * @returns {{reason: 'install-gone'|'artifact-missing'|'unreadable', message: string}}
@@ -358,18 +347,16 @@ function registerControlPlaneRoutes(deps) {
358
347
  registerRoute({ kind: 'exact', path: '/api/relay/addresses', handler: localOnly('GET', async (_req, res) => {
359
348
  const port = lanPortInUse()
360
349
  const preferred = typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : ''
361
- sendJson(res, 200, { ok: true, port, preferred, lan: lanCandidates(port), public: publicCandidates(port), ipv6: ipv6Candidates(port) })
350
+ // `manual` 是用户手填的直连地址(内网穿透域名):网卡扫不出来,所以单列一格让面板回显与修改。
351
+ const manual = typeof state.manualLanUrl === 'string' ? state.manualLanUrl : ''
352
+ sendJson(res, 200, { ok: true, port, preferred, manual, lan: lanCandidates(port), public: publicCandidates(port), ipv6: ipv6Candidates(port) })
362
353
  }) })
363
354
  /**
364
355
  * 公用中转候选的测速(`POST /api/relay/probe-relays`,body `{ urls: string[] }`)。
365
356
  *
366
- * **为什么必须由宿主代测**:引导页在浏览器里打开,直连那些中转域名会被 CORS 挡住
367
- * ——读到的是浏览器拦截,不是真实耗时,而且会把可用入口误判成不通。宿主是本机进程,
368
- * 出站没有 CORS 可言,测出来的才是用户实际能用的那条。
369
- *
370
- * 判定分两步:`/healthz` 回 2xx **且** `/api/mobile/v1/health` 回的 `service` 是 `dsh-mobile-relay`。
371
- * 只看 `/healthz` 不行——任何静态站都可能回 200,随手敲一个域名就被当成「可用中转」。
372
- * 超时的照实报 `null`(前端按「不通」排在后面),不编造数字。
357
+ * 必须由宿主代测:引导页在浏览器里,直连那些域名会被 CORS 挡住,读到的是浏览器拦截而不是
358
+ * 真实耗时。判定分两步:`/healthz` 回 2xx 且 `/api/mobile/v1/health` 回的 `service` 是
359
+ * `dsh-mobile-relay`——只看 `/healthz` 的话任何静态站都会被当成可用中转。超时照实报 null。
373
360
  */
374
361
  registerRoute({ kind: 'exact', path: '/api/relay/probe-relays', handler: localOnly('POST', async (req, res) => {
375
362
  const body = await readJson(req)
@@ -392,7 +379,7 @@ function registerControlPlaneRoutes(deps) {
392
379
  return { url, ms: null }
393
380
  }
394
381
  // **只认运维意义上「这一台确实是中转」**:`/healthz` 回 200 的站点满互联网都是,
395
- // 光看它等于把 qq.com 也当可用中转(2026-10-24 用户实测随手敲一个就过)。
382
+ // 光看它等于把随便一个域名也当可用中转。
396
383
  // 中转自己那条地址体检 `/api/mobile/v1/health` 会带回 `service`,对不上就不是它。
397
384
  const who = await fetch(`${url}/api/mobile/v1/health`, { signal: controller.signal, redirect: 'follow' })
398
385
  .then((r) => (r.ok ? r.json() : null))
@@ -538,7 +525,7 @@ function registerControlPlaneRoutes(deps) {
538
525
  /**
539
526
  * `?relayUrl=` 是**只看不写**的预览:按这个候选地址生成二维码,但一个字都不落盘。
540
527
  *
541
- * 给「自建中转填完就想看到码」用(2026-10-24 用户要求)。以前那版是「填完就把地址写进
528
+ * 给「自建中转填完就想看到码」用。以前那版是「填完就把地址写进
542
529
  * 配置」,等于拿一条还没验过的地址覆盖正在用的设置;现在预览走这里,真写入只在用户
543
530
  * 明确按下「连接中转」时发生。
544
531
  */
@@ -670,8 +657,13 @@ function registerControlPlaneRoutes(deps) {
670
657
  patch.manualLanUrl = body.manualLanUrl.trim().replace(/\/+$/, '')
671
658
  }
672
659
  await settings.update(patch)
673
- await syncSidecar().catch(() => undefined)
674
- sendJson(res, 200, status())
660
+ // 换中转地址时宿主会**先在旧隧道上登记迁移、再重建**(顺序在 worker 的 configure 里,
661
+ // 见 lib/relay/migrate.js)—— 面板与手机走的是同一条下发路径,不需要各自实现一遍。
662
+ // 失败不能再像以前那样 `.catch(() => undefined)` 吞掉:面板上这一发是用户填完地址之后
663
+ // 唯一的反馈,如实报出来才谈得上「改坏了能看见」。
664
+ let applyError = null
665
+ try { await syncSidecar() } catch (error) { applyError = String(error?.message || error) }
666
+ sendJson(res, 200, { ...status(), ...(applyError ? { applyError } : {}) })
675
667
  }) })
676
668
  // 端口自检:面板上那颗「检查端口」用的。真实地试绑一次,回答「本机现在能不能监听它」,
677
669
  // 探针就是局域网代理挑端口用的那一把,所以它给的答案和真正 listen 的结果是同一件事。
@@ -703,9 +695,9 @@ function registerControlPlaneRoutes(deps) {
703
695
  await syncSidecar()
704
696
  sendJson(res, 200, status())
705
697
  }) })
706
- // ── 插件更新(2026-10 搬回自更新之后重新有这四条)──
698
+ // ── 插件更新 ──
707
699
  //
708
- // 分工(2026-10-11 起):**查归插件自己**(问 npm 注册表,`../update-registry.cjs`)、
700
+ // 分工:**查归插件自己**(问 npm 注册表,`../update-registry.cjs`)、
709
701
  // **装也归插件自己**(在 profile 里跑它自己的包管理器,`../update-install.cjs`),
710
702
  // **换代码留给外壳**(`lib/index.js` 的 runtime)。市场不再是这条路上的任何一环。
711
703
  //
@@ -733,7 +725,7 @@ function registerControlPlaneRoutes(deps) {
733
725
  // 拿不到(无头部署 / 没有外壳的那一版)就退回这一代自己的版本号。
734
726
  registerRoute({ kind: 'exact', path: '/api/relay/update/check', handler: localOnly('POST', async (_req, res) => {
735
727
  const runtime = pluginRuntime()
736
- // 形状必须和上面那条 `update/status` **一模一样**(含 `available`)。2026-10-11 的真机现场:
728
+ // 形状必须和上面那条 `update/status` **一模一样**(含 `available`):
737
729
  // 这条只回 `runtime.status()`,面板拿它 `setHost()` 覆盖掉好状态,`available` 变成 undefined,
738
730
  // 卡片当场从「宿主模式」掉进取降级那一半 —— 用户看到的现象是「有新版本却没有『立即更新』」。
739
731
  const snapshot = runtime ? { available: true, ...runtime.status() } : null
@@ -757,7 +749,7 @@ function registerControlPlaneRoutes(deps) {
757
749
  // 装器装新版、装完就地换代,并把这次结果写进它那本 `lastApply` 账 —— 面板「最近一次更新」
758
750
  // 与那颗「强制更新」按钮的「上次没装成」判据读的都是这本账。
759
751
  //
760
- // 为什么这条路由不再自己去装(2026-10-12 改):从前它自己调 `installLatestIfNeeded` 再
752
+ // 为什么这条路由不再自己去装:从前它自己调 `installLatestIfNeeded` 再
761
753
  // `reload`,判定确实和自动更新是同一套(外壳那条路最后也走盘上那一代的同一个函数),
762
754
  // **但结果没人落账** —— 手动(含强制)更新装完,外壳的账一动不动,面板那一行永远停在上一次
763
755
  // 自动更新的结果上。判定与记账必须是同一个动作,所以这里只负责传参、把外壳记完账的状态
@@ -828,7 +820,7 @@ function registerControlPlaneRoutes(deps) {
828
820
  }
829
821
  return sendJson(res, 200, { ok: outcome.installed || outcome.skipped, data: outcome })
830
822
  }) })
831
- // 自动更新开关(默认关)。开=外壳每 10 分钟问一次有没有新版,有就自己装并就地切换
823
+ // 自动更新开关(默认关)。开=外壳每 2 分钟问一次有没有新版,有就自己装并就地切换
832
824
  // (轮询住在外壳里,因为「没人看着也要发生」;装与换代用的就是上面这两条同一套实现)。
833
825
  registerRoute({ kind: 'exact', path: '/api/relay/update/auto', handler: localOnly('POST', async (req, res) => {
834
826
  const runtime = pluginRuntime()
@@ -3,20 +3,12 @@
3
3
  /**
4
4
  * 移动端 API 路由表(`/api/mobile/v1/*`)。
5
5
  *
6
- * 这块此前是 lib/index.js 里连续 120 多行的注册语句,与控制面路由、sidecar 生命周期、
7
- * 镜像推送调度器混在同一个 apply() 闭包里。它本身没有任何逻辑——绝大部分是「路径 → 已
8
- * 写好的 handler」的纯映射,只有 status / connection-info / desktop-view 三条带一点内联
9
- * 逻辑——但混在装配代码里有两个代价:
6
+ * 它本身没有逻辑,绝大部分是「路径 → 已写好的 handler」的纯映射,只有 status /
7
+ * connection-info / desktop-view 三条带一点内联逻辑。抽成一张表,是为了能一眼确认「App 打的
8
+ * 某个地址到底注册没有」——历史上 `workspace/rename` 与 `workspace/archive-session` 就是
9
+ * App 一直在打、插件从来没注册,手机端必然 404;这张表也能直接对着 App 侧 apiPaths.ts 核对。
10
10
  *
11
- * 1. 想确认「App 打的某个地址到底注册没有」必须人工通读 apply()。
12
- * 历史上 `workspace/rename` 与 `workspace/archive-session` 就是 App 一直在打、
13
- * 插件从来没注册,手机端必然 404。
14
- * 2. 改一条路径要在 1000 行文件里找。
15
- *
16
- * 现在它是一张可以一眼读完、可以对着 App 侧 apiPaths.ts 核对的表。
17
- *
18
- * 依赖注入只留三样:注册器、移动端守卫、以及两个会变的值。handler 们直接 require,
19
- * 因为它们本来就只是被这张表挂上去的。
11
+ * 依赖注入只留注册器、移动端守卫与几个收益于取值器的值;handler 们直接 require。
20
12
  */
21
13
 
22
14
  const { sendJson } = require('../http/routing.js');
@@ -25,6 +17,7 @@ const { orderCandidates, withManualCandidate } = require('../relay/lan-network.c
25
17
  const sessions = require('../mobile/sessions.js');
26
18
  const sessionModelOptions = require('../mobile/session-model-options.js');
27
19
  const sessionFile = require('../mobile/session-file.js');
20
+ const fileRead = require('../mobile/file-read.js');
28
21
  const stats = require('../mobile/stats.js');
29
22
  const agentPresets = require('../mobile/agent-presets.js');
30
23
  const stream = require('../mobile/stream.js');
@@ -47,6 +40,7 @@ const schedule = require('../mobile/schedule.js');
47
40
  const pushNotifyRoute = require('../mobile/push-notify-route.js');const plugins = require('../mobile/plugins.js');
48
41
  const pluginUpdate = require('../mobile/plugin-update.js');
49
42
  const pluginSelf = require('../mobile/plugin-self.js');
43
+ const pluginMarket = require('../mobile/plugin-market.js');
50
44
  const { createRelayControlHandlers } = require('../mobile/relay-control.js');
51
45
  const uploads = require('../mobile/uploads.js');
52
46
  const inboxClean = require('../mobile/inbox-clean.js');
@@ -67,25 +61,17 @@ const API_VERSION = '1';
67
61
  * @param {(handler: Function) => Function} deps.guarded 移动端守卫(token + 500 信封)
68
62
  * @param {object} deps.state 内存里的中转状态(本进程唯一真值)
69
63
  * @param {() => object} deps.hostMetadata 宿主元信息(隧道准入状态等)
70
- * @param {() => object} deps.getSidecarSnapshot 取快照的**取值器**。
71
- * 这里必须传函数而不是对象:sidecarSnapshot 是会被 applySidecarConfig 整体替换的
72
- * let 变量,闭包捕获对象会让 handler 永远读到启动那一刻的旧快照(lan.updatedAt 停在
73
- * 进程启动时间,手机端看连接信息像失联了一样)。
64
+ * @param {() => object} deps.getSidecarSnapshot 取快照的取值器,必须传函数:快照会被整体替换,
65
+ * 闭包捕获对象会永远读到启动那一刻的旧快照。
74
66
  * @param {(port: number) => Array<object>} deps.safeLanCandidates 局域网候选地址
75
- * @param {(port: number) => Array<object>} deps.safeIpv6Candidates 公网 IPv6 直连候选。
76
- * 与上面那份并列、不并进它:手机拿这份和中转实测比快慢,掺进局域网那份就等于让它
77
- * 跟「同一个 Wi-Fi」共用一套失败退避(手机刚离开家里 Wi-Fi 时正是最该试它的时候)。
78
- * @param {() => number} deps.lanPortInUse 手机该拨的局域网端口。**必须用取值器**:
79
- * sidecar 自己 bind 到的端口可能与本进程解析出的不同(两条端口自适应),只认
80
- * `state.lanPort` 会把这个接口变成「报一个没人监听的端口」——真机实测过 50933
81
- * 死地址把手机逼回公网中转(见 impl.js 的 advertisedLanPort)。
82
- * @param {(route: { path: string, handler: Function }) => void} deps.registerUpgradeRoute
83
- * 交给 ctx.webServer 的**升级**路由注册器(`registerUpgrade`)。与 registerRoute 是
84
- * 两套名字空间:同一个 path 可以既有一条普通路由又有一条升级路由,但升级路由重复注册
85
- * 会抛错。
86
- * @param {object} deps.preview 预览字节管道服务(lib/relay/preview.js)。它自带鉴权与
87
- * 端口边界,所以**不能**包在 guarded 里——guarded 回的是 JSON,而升级请求需要的是
88
- * 一次拒绝或一次握手。
67
+ * @param {(port: number) => Array<object>} deps.safeIpv6Candidates 公网 IPv6 直连候选,与上一份
68
+ * 并列:手机拿它和中转比快慢,掺进局域网那份会共用失败退避。
69
+ * @param {() => number} deps.lanPortInUse 手机该拨的局域网端口,必须用取值器:sidecar 实绑的
70
+ * 端口可能与 state 里解析出的不同。
71
+ * @param {(route: { path: string, handler: Function }) => void} deps.registerUpgradeRoute 升级路由
72
+ * 注册器;与 registerRoute 是两套名字空间,同一 path 可以两者都有,但升级路由重复注册会抛错。
73
+ * @param {object} deps.preview 预览字节管道服务。它自带鉴权与端口边界,不能包在 guarded 里——
74
+ * 升级请求需要的是一次拒绝或一次握手,不是 JSON。
89
75
  */
90
76
  function registerMobileRoutes(deps) {
91
77
  const { registerRoute, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, safeIpv6Candidates, lanPortInUse, preview, core, pairingApproval, rotatePairing } = deps;
@@ -122,7 +108,7 @@ function registerMobileRoutes(deps) {
122
108
  // 凭据,所以这条刻意不包 guarded(豁免与理由见 test/mobile-routes.test.cjs 的
123
109
  // PUBLIC_ROUTES)。它能说的话只有服务名与版本——没有实例信息、没有任何能力面。
124
110
  // 中转侧在 server.js 的同名路径上回 'dsh-mobile-relay',App 靠这个字段分辨
125
- // 「填的是宿主还是中转」(消费方 app/src/services/addressProbe.ts)。
111
+ // 「填的是宿主还是中转」。
126
112
  //
127
113
  // ⚠️ 签名必须是 `(req, res)` **两个**参数:宿主 webServer 就是 `handler(req, res)` 调的,
128
114
  // 上面那条 `guarded(...)` 之所以能收 `(ctx, req, res)`,是因为那三个参数由 guarded 自己补。
@@ -235,6 +221,10 @@ function registerMobileRoutes(deps) {
235
221
  route('/api/mobile/v1/session/attachment', sessions.handleSessionAttachment)
236
222
  // 交付文件卡要的字节:只放行本会话 present 声明过的路径(见 mobile/session-file.js)。
237
223
  route('/api/mobile/v1/session/file', sessionFile.handleSessionFile)
224
+ // 右栏文件浏览器「打开文件」:按**绝对路径**取字节,范围是宿主已登记的工作区 ∪ 当前会话
225
+ // 的项目目录(见 mobile/file-read.js 与 mobile/allowed-zone.js)。与上一条并存:那条服务
226
+ // 交付(卡片、正文链接),放行面跟着一次交付走;这条跟着「我登记过的目录」走。
227
+ route('/api/mobile/v1/file/read', fileRead.handleFileRead)
238
228
  route('/api/mobile/v1/session/send', sessions.handleSend)
239
229
  // 正文水位探针:App 进对话前先问一句「这条会话的正文到哪个水位了」,跟本地缓存水位
240
230
  // 一比就知道要不要重拉全量历史(几十字节换几兆)。**只读信号快照**:信号里没有就如实
@@ -278,6 +268,9 @@ function registerMobileRoutes(deps) {
278
268
  // 「宿主最近收到的快照」;从未收到过时返回 view: null,App 按桌面的折叠缺省处理。
279
269
  route('/api/mobile/v1/desktop-view', async (_ctx, _req, res) => sendJson(res, 200, { ok: true, view: desktopView.snapshot() }))
280
270
  route('/api/mobile/v1/directory/list', workspaces.handleListDirectory)
271
+ // 盘符层:宿主 browse 后端只列目录内容、不枚举盘符,主目录在 C 盘时手机端看不到别的盘
272
+ // (Windows 多硬盘现场)。枚举在被控端做,见 mobile/drive-roots.js。
273
+ route('/api/mobile/v1/directory/roots', workspaces.handleDirectoryRoots)
281
274
  // 右栏文件浏览器要的那一份:文件与文件夹都列、带类型/大小/修改时间,见
282
275
  // mobile/directory-listing.js。与上面那条**并存**:那条是「挑工作区目录」用的,只列
283
276
  // 能进去的目录,行为不变。
@@ -410,6 +403,25 @@ function registerMobileRoutes(deps) {
410
403
  route('/api/mobile/v1/plugin/self', pluginSelf.handlePluginSelf)
411
404
  route('/api/mobile/v1/plugin/self/auto', pluginSelf.handlePluginAuto)
412
405
 
406
+ // 这台电脑上**别的插件**:装了哪些、装 / 启用 / 升级(见 mobile/plugin-market.js)。
407
+ //
408
+ // 不自己跑包管理器:宿主里本来就有一套插件管理器(桌面端 设置 → 插件 那个页面就是它的
409
+ // 界面),装包、版本兼容检查、依赖脚本授权、装失败回滚、要不要重启才生效,全由它判断。
410
+ // 这里只搬结果。**没有卸载入口**:卸载要重启电脑上的 DSH,手机上没有在电脑前的那双手;
411
+ // 承载手机连接的这个包另外不给停用,只留原有的自更新那条路。
412
+ //
413
+ // 装一次可能几十秒到几分钟,所以 install / update 只回 202 + requestId,进度另开一条
414
+ // 轮询 —— 手机切后台、断线重连都不该把一次安装拖断。安装时的 npm 源由 registries 那条
415
+ // 给选择器:主源 / 备用源 / pnpm 实际读到的那个都原样报上来。
416
+ route('/api/mobile/v1/plugin/market', pluginMarket.handlePluginMarket)
417
+ route('/api/mobile/v1/plugin/market/registries', pluginMarket.handlePluginMarketRegistries)
418
+ route('/api/mobile/v1/plugin/market/inspect', pluginMarket.handlePluginMarketInspect)
419
+ route('/api/mobile/v1/plugin/market/install', pluginMarket.handlePluginMarketInstall)
420
+ route('/api/mobile/v1/plugin/market/update', pluginMarket.handlePluginMarketUpdate)
421
+ route('/api/mobile/v1/plugin/market/progress', pluginMarket.handlePluginMarketProgress)
422
+ route('/api/mobile/v1/plugin/market/cancel', pluginMarket.handlePluginMarketCancel)
423
+ route('/api/mobile/v1/plugin/market/enabled', pluginMarket.handlePluginMarketEnabled)
424
+
413
425
  // 插件自更新:APP 的「应用更新」页给这台宿主升级插件版本(见 mobile/plugin-update.js)。
414
426
  // 与桌面控制面 `/api/relay/update/*` 同一套实现,差别只在入口:那三条 localOnly,手机
415
427
  // 永远打不到,而手机上聊天时「有对话在跑」那道闸几乎总关着 —— 自动更新等于不跑。
@@ -432,8 +444,9 @@ function registerMobileRoutes(deps) {
432
444
 
433
445
  // 分享文件的落地通道(App 把手机分享进来的文件分片传上来,见 mobile/uploads.js)。
434
446
  // 手机端没有「把文件交给宿主」的路:图片能进 session.send 的 base64 内容块,其它格式
435
- // 一律进不去。四条端点只做「收字节 → 落盘到 ~/.dsh/mobile-inbox/<日期>/<文件名>」,
436
- // 落盘路径由 App 写成 @提及 再随消息发给宿主,所以这里不认识工作区、也不认识会话。
447
+ // 一律进不去。四条端点只做「收字节 → 落盘」:默认落到 `~/.dsh/mobile-inbox/<日期>/<文件名>`,
448
+ // 落盘路径由 App 写成 @提及 再随消息发给宿主;带 `destPath` 时落到那个目录(右栏的
449
+ // 「传到这里」),它必须在 mobile/allowed-zone.js 的允许区内。
437
450
  route('/api/mobile/v1/upload/begin', uploads.handleUploadBegin)
438
451
  route('/api/mobile/v1/upload/chunk', uploads.handleUploadChunk)
439
452
  route('/api/mobile/v1/upload/finish', uploads.handleUploadFinish)
@@ -1,30 +1,19 @@
1
1
  /**
2
2
  * 会话数据清理:按 sessionId 删除 dshHome 下的 sessions/** 目录与 project cache。
3
3
  *
4
- * 两个入口共用同一套落地代码:DSH agent 工具 registerSessionCleanupTool(action=delete
5
- * 必须 confirm:true 且只针对已归档)与移动 API /workspace/clear-archived(archive-clean.js)。
4
+ * 两个入口共用同一套落地代码:DSH agent 工具 `registerSessionCleanupTool`(action=delete 必须
5
+ * confirm 且只针对已归档)与移动 API `/workspace/clear-archived`。
6
6
  *
7
- * **两个入口都不在进程里删磁盘**:只把 id 排进待删队列(dshHome 下的
8
- * mobile-archived-clear.json),真正的删除放在宿主下次启动时做,见
9
- * deleteQueuedArchivedSessions。宿主没有「删除会话」接口,进程活着的时候它手上那些会话
10
- * 随时可能被读或写,文件不在就是一次未捕获的 ENOENT —— 宿主对未捕获异常是零容忍的,
11
- * 整个进程退出,桌面端跟着消失(2026-10-20 就是这么闪退的)。
7
+ * 两个入口都不在进程里删磁盘:只把 id 排进待删队列(dshHome 下的
8
+ * `mobile-archived-clear.json`),真正的删除放在宿主下次启动时做。宿主没有「删除会话」接口,
9
+ * 进程活着时那些会话随时可能被读或写,文件不在就是一次未捕获的 ENOENT,整个进程退出。
12
10
  *
13
- * 认哪份文件、怎么读 id 都以宿主自己的规则为准,不按目录名猜:
14
- * - 文件名 = `session[.vN].jsonl[.zstd]`(宿主 `dsh-session-format` 的
15
- * CANONICAL_LOG_FILENAME + compressionSuffix:0 代不带 vN,压缩时才带 .zstd)。
16
- * 曾经只认 `session.jsonl.zstd`,于是 0.2.0 真在用的 `session.v4.jsonl.zstd`
17
- * 一个都认不到 —— 表现是「清理已归档」恒回 `deleted: 0`,静默不删。
18
- * - 解压用 Node 自带的 zstd(DSH 自己就是这么读写日志的,宿主并不需要系统 zstd
19
- * 命令),不再 shell 出去:没装 zstd 命令的机器上它一缺就是 readHeader 静默返回
20
- * undefined,整条删除路径空转。id 仍然取自日志第一行那条 header。
21
- * - **进程活着的时候不删日志、也不擦宿主的归档集合**;两件事都放到宿主启动时
22
- * (pruneOrphanedArchivedSessionsOnce)。擦集合的理由写在那个函数上:桌面端的会话
23
- * 列表是连上宿主时拉的一份快照 + 之后的事件增量,进程里擦集合等于把已经删掉、却还
24
- * 留在快照里的行放回正常列表。删日志的理由见上面那段:进程里删 = 宿主随时可能踩空。
25
- * - **Windows 上目录删不掉 ≠ 会话没删掉**:宿主对它打开过的会话一直握着 `session.lock`
26
- * 的写句柄,日志 unlink 成功后那个名字要等句柄关闭才消失(delete-pending),rmdir
27
- * 于是报 ENOTEMPTY。判据改成「目录里还有没有规范名日志」:没有就是删成了。
11
+ * 认哪份文件、怎么读 id 以宿主规则为准,不按目录名猜:
12
+ * - 文件名是 `session[.vN].jsonl[.zstd]`(0 代不带 vN,压缩时才带 .zstd);
13
+ * - 解压用 Node 自带的 zstd,不 shell 出去,id 取自日志第一行的 header;
14
+ * - 进程活着时不删日志、也不擦宿主的归档集合,两件事都放到宿主启动时;
15
+ * - Windows 上目录删不掉不等于会话没删掉:宿主握着 `session.lock` 时名字要等句柄关闭才
16
+ * 消失,所以判据是「目录里还有没有规范名日志」。
28
17
  */
29
18
  'use strict'
30
19
 
@@ -46,6 +35,8 @@ const HEADER_MAX_BYTES = 64 * 1024
46
35
 
47
36
  const { envelopeValue, unwrap } = require('./mobile/envelope.js')
48
37
  const { rememberDeletedSessionIds } = require('./mobile/session-cache.js')
38
+ // 删之前那一刀统计补采:与手机那个「清理已归档」按钮共用同一份预算(见 mobile/stats-collect.cjs)。
39
+ const { collectStats, DELETING_COLLECT_BUDGET_MS } = require('./mobile/stats-collect.cjs')
49
40
  // 收件目录的启动清理与这里共用同一个「宿主刚起来」的窗口,实现见 mobile/inbox-clean.js。
50
41
  const { clearInboxOnce } = require('./mobile/inbox-clean.js')
51
42
 
@@ -198,9 +189,9 @@ async function archivedIdsWithoutLogs(archivedSessionIds) {
198
189
  /**
199
190
  * 盘上已经不剩任何会话数据的 id:目录没了,或者目录里只剩宿主锁着的 `session.lock`。
200
191
  *
201
- * 只给移动端的「清理所有已归档」补账用:更早一次删除如果删到一半抛错(2026-10-18 前
202
- * Windows 的 ENOTEMPTY 就是这样),已经删掉的那批 id 没能记进本进程的删除集,而宿主的
203
- * 进程内清单还挂着它们 —— 再点清理只会回 `deleted: 0`,手机上永远清不掉。
192
+ * 只给移动端的「清理所有已归档」补账用:更早一次删除如果删到一半抛错(Windows 上目录非空
193
+ * 时就是这样),已经删掉的那批 id 没能记进本进程的删除集,而宿主的进程内清单还挂着它们 ——
194
+ * 再点清理只会回 `deleted: 0`,手机上永远清不掉。
204
195
  *
205
196
  * 判据比 archivedIdsWithoutLogs 更宽(那个只认规范名日志),但**不许宽到把迁移中途的
206
197
  * 临时文件当成没有数据**:目录里只要还有任何非锁文件(含子目录)就算「还在」,读不出来
@@ -234,7 +225,7 @@ function sessionIdsWithoutAnyData(sessionIds) {
234
225
  * deleteQueuedArchivedSessions。原因是宿主没有「删除会话」这个接口:进程活着的时候,
235
226
  * 它内存里那些会话随时可能被读或写(列一遍会话、续写一条事件、开一条还没落盘的空白会话),
236
227
  * 文件不在就是一次没人接的 ENOENT —— 宿主对未捕获异常零容忍,进程直接退出,桌面端窗口
237
- * 跟着消失(2026-10-20 真机就是这么闪退的:清理删到一条 0 轮次的空白归档会话)。
228
+ * 跟着消失(清理删到一条 0 轮次的空白归档会话就会这样)。
238
229
  *
239
230
  * 队列是**并集**语义:同一批 id 点两次、两次清理之间换了批次,都只是往同一张表里加。
240
231
  */
@@ -345,7 +336,7 @@ async function pruneOrphanedArchivedSessions(ctx) {
345
336
  *
346
337
  * 这两件事都只能在启动这一刻做:
347
338
  * - **删日志**:宿主没有「删除会话」接口,进程活着时它随时可能去开那些文件;文件不在
348
- * 就是一次未捕获的 ENOENT,宿主直接退出(2026-10-20 桌面端闪退就是这条)。
339
+ * 就是一次未捕获的 ENOENT,宿主直接退出。
349
340
  * - **擦归档集合**:桌面端的会话列表是「连上宿主时拉的一份快照 + 之后的事件增量」,
350
341
  * 进程活着的时候擦集合,宿主会把「归档集合变了」广播给桌面端,桌面端就把它快照里
351
342
  * 那些其实已经没有日志、本来被归档藏着的行放回正常列表 —— 用户报的正是「清理后
@@ -419,6 +410,14 @@ async function cleanupSessions(ctx, args) {
419
410
  }
420
411
  if (args.confirm !== true) throw new Error('永久删除需要 confirm: true')
421
412
  const ids = selected.map(item => item.sessionId)
413
+ // 删之前先把这批会话的用量采进统计账本:日志一没,那部分用量就再也读不出来了。手机那个
414
+ // 「清理已归档」按钮一直有这一刀,这条工具路以前没有 —— 走它删掉的会话,上次采集之后的
415
+ // 那一截增量就白丢。采不上不挡删除:统计偏旧好过会话删不掉。
416
+ try {
417
+ await collectStats(ctx, { sessionIds: ids, force: true, skipPreview: true, budgetMs: DELETING_COLLECT_BUDGET_MS })
418
+ } catch {
419
+ /* 统计是旁路 */
420
+ }
422
421
  // 与移动端那条路由同一口径:进程里只排队,**日志留到宿主下次启动时删**。进程活着时
423
422
  // 删日志,宿主随时可能去开一条已经不存在的文件,那是未捕获异常(会带走整个宿主)。
424
423
  queueArchivedClearIds(ids)
@@ -3,18 +3,11 @@
3
3
  /**
4
4
  * 凭据包 `dsh-mobile-credentials` 的入口。
5
5
  *
6
- * 它在盘上有**两代布局**:
6
+ * 它在盘上有两代布局:新布局嵌在插件包内(一代自包含,回滚时凭据跟着一起回滚),旧布局与
7
+ * 插件包同级。往哪里落由外壳的资产重映射决定,而外壳在进程启动时就被 Node 缓存住、换不掉;
8
+ * 迁移期新旧外壳可能各按一种布局放置,只认一条路就会「装完激活才炸 Cannot find module」。
7
9
  *
8
- * - 新布局:嵌在插件包内 —— `<插件包>/dsh-mobile-credentials/lib/credentials.js`
9
- * (一代自包含,回滚时凭据版本跟着一起回滚);
10
- * - 旧布局:与插件包同级 —— `node_modules/dsh-mobile-credentials/…`,
11
- * 代际目录下则是 `<代>/dsh-mobile-credentials/…`。
12
- *
13
- * 往哪里落由**外壳**(`lib/shell/ota.js` 的资产重映射)决定,而外壳在进程启动时就被 Node
14
- * 缓存住、换不掉。布局迁移那一次,旧外壳把凭据放进同级目录、新实现却按嵌套路径 require,
15
- * 结果是「装完激活才炸 Cannot find module」——热更半装成功,留下的一代还是残缺的。
16
- *
17
- * 所以这里两条路都试,谁在就用谁:旧外壳配新实现也能跑起来,布局迁移不再是地雷。
10
+ * 所以这里两条路都试,谁在就用谁。
18
11
  */
19
12
 
20
13
  /** 新布局:`<插件包>/dsh-mobile-credentials/`(本文件在 `<插件包>/lib/shared/`)。 */
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 手机每次请求顺带上来的身份:设备名、平台、系统版本、App 版本。
4
+ * 手机每次请求顺带上来的身份:设备名、机型、平台、系统版本、App 版本。
5
5
  *
6
6
  * ## 为什么要有这几个头
7
7
  *
@@ -13,18 +13,20 @@
13
13
  * 3. 面板把「加密」读成「已是最新」,其实那只说明这枚凭据带设备密钥。
14
14
  *
15
15
  * 身份属于「每一次请求都顺手知道」的信息,不该是一张一次性的照片。现在手机在统一的鉴权头
16
- * 里带上这四样,宿主在鉴权通过那一刻(`lib/impl.js` 的 `onAuthorized`)把它们写回凭据记录。
16
+ * 里带上这几样,宿主在鉴权通过那一刻(`lib/impl.js` 的 `onAuthorized`)把它们写回凭据记录。
17
17
  *
18
18
  * ## 口径
19
19
  *
20
20
  * - **缺一个就少写一个**:老版本 App 根本不带这些头,那时必须保持记录里原来的值,
21
21
  * 绝不能把已经有的名字冲成空串(那会把一个好好的设备变成「未知设备」)。
22
22
  * - 头的值是手机自报的,长度交给凭据库的 `truncate` 截断,这里不重复做校验。
23
- * - 名字是**用户自己起的**,不参与任何判断,只用于显示。
23
+ * - 名字是**用户自己起的**,不参与任何判断,只用于显示;机型是手机自己报的,同理只用于显示。
24
+ * 认一台手机靠的是它自带的 `deviceId`(`X-Dsh-Device` 那条),不是这几格里的任何一样。
24
25
  */
25
26
 
26
- /** 手机自报身份用的四个头(App 侧在 `app/src/services/viewmodels/authHeaders.ts` 里写)。 */
27
+ /** 手机自报身份用的五个头(App 侧在 `app/src/services/viewmodels/authHeaders.ts` 里写)。 */
27
28
  const DEVICE_NAME_HEADER = 'x-dsh-device-name';
29
+ const DEVICE_MODEL_HEADER = 'x-dsh-device-model';
28
30
  const PLATFORM_HEADER = 'x-dsh-platform';
29
31
  const OS_VERSION_HEADER = 'x-dsh-os-version';
30
32
  const APP_VERSION_HEADER = 'x-dsh-app-version';
@@ -46,6 +48,11 @@ function deviceMetaFromHeaders(req) {
46
48
  const meta = {};
47
49
  const name = headerText(headers, DEVICE_NAME_HEADER);
48
50
  if (name) meta.name = name;
51
+ // 机型号只在这个位置补得回来:它跟名字不一样,不随用户改,所以只在**配对之后第一次
52
+ // 请求**时真正写进去 —— 但老记录(这一格出生前配的对)不会有第二次配对,全靠这条头
53
+ // 自愈。缺了就不写,别把已经记下的机型冲成空。
54
+ const model = headerText(headers, DEVICE_MODEL_HEADER);
55
+ if (model) meta.model = model;
49
56
  const platform = headerText(headers, PLATFORM_HEADER);
50
57
  if (platform) meta.platform = platform;
51
58
  const osVersion = headerText(headers, OS_VERSION_HEADER);
@@ -57,6 +64,7 @@ function deviceMetaFromHeaders(req) {
57
64
 
58
65
  module.exports = {
59
66
  APP_VERSION_HEADER,
67
+ DEVICE_MODEL_HEADER,
60
68
  DEVICE_NAME_HEADER,
61
69
  OS_VERSION_HEADER,
62
70
  PLATFORM_HEADER,
@@ -24,10 +24,10 @@ function hmacPayload(secret, payload) {
24
24
 
25
25
  /**
26
26
  * 签发 LAN/mobile token:<instanceId>.<exp>.<sig>。
27
- * 缺省 ttlSec=30d(与 relay core / browser core 历史缺省一致),但**消费方必须显式传原值**
28
- * (LAN 主进程 3600、30d 处 30*24*3600),本函数不隐藏调用点的默认值差异。
29
- * @param {string} secret lanSecret(~/.dsh/relay.json 密钥)
30
- * @param {string} instanceId 实例 id(~/.dsh/relay.json)
27
+ * 缺省 ttlSec=30d,但消费方必须显式传原值(LAN 主进程 3600、30d 处 30*24*3600),本函数不
28
+ * 隐藏调用点的默认值差异。
29
+ * @param {string} secret lanSecret(relay 配置里的密钥)
30
+ * @param {string} instanceId 实例 id(relay 配置)
31
31
  * @param {number} ttlSec 有效期(秒),缺省 30*24*3600
32
32
  * @param {number} [nowSec] 秒级「当前时间」覆盖(测试/主进程签名用);缺省 Date.now()/1000
33
33
  * @returns {string}
@@ -291,7 +291,7 @@ const MAX_SEALED_BODY = Math.ceil(11 * 1024 * 1024 * 4 / 3);
291
291
  function readRawBody(req, maxBytes = MAX_SEALED_BODY) {
292
292
  // **没有请求体的请求不能等 `end`**:SSE 客户端(XHR)发出来的 GET 有时不会像普通
293
293
  // fetch 那样把请求流收尾,而守卫在读体这一步就把处理器挡住了 —— 表现是「手机一直在
294
- // 重连、宿主侧连开流日志都没有」(2026-10-24 现场)。GET/HEAD 且既没有 content-length
294
+ // 重连、宿主侧连开流日志都没有」。GET/HEAD 且既没有 content-length
295
295
  // 也没有 transfer-encoding,就是没有体,直接回空。
296
296
  const method = String((req && req.method) || 'GET').toUpperCase();
297
297
  const headers = (req && req.headers) || {};
@@ -1,22 +1,16 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 链路加密(协议 v2)的字节级原语。App 侧有一份逐字节等价实现(`services/e2ee.ts`),
5
- * 两边的正确性靠同一份测试向量保证(`test/e2ee-vectors.json`),不靠互相阅读代码。
4
+ * 链路加密(协议 v2)的字节级原语。App 侧有一份逐字节等价实现(`services/e2ee.ts`),两边的
5
+ * 正确性靠同一份测试向量保证(`test/e2ee-vectors.json`),不靠互相阅读代码。
6
6
  *
7
- * 这一层只做「算字节」,不认识路由、不认识业务:信封放在哪里、什么时候加解密,由
8
- * `http/routing.js`(宿主侧)与 `services/http.ts`(App 侧)负责。
7
+ * 这一层只做「算字节」,不认识路由与业务:信封放在哪里、什么时候加解密由路由层负责。
8
+ * 算法只用三样、两端现成:AES-256-GCM、HKDF-SHA256、HMAC-SHA256。
9
9
  *
10
- * 算法只用三样,且都是两端现成的:AES-256-GCM、HKDF-SHA256、HMAC-SHA256。
11
- *
12
- * **AEAD 用 AES-256-GCM 而不是 ChaCha20-Poly1305**(2026-10-24 改):桌面宿主跑在
13
- * Electron 里,那份 Node 链接的是 BoringSSL,`crypto.getCiphers()` 里根本没有
14
- * `chacha20-poly1305`(实测 `createCipheriv` 直接 `Unknown cipher`,`openssl 0.0.0`)。
15
- * 换过来之前,每一条协议 v2 的请求在宿主侧都会在解封那一步抛错 —— 表现成扫码配对
16
- * 一律「配对码错误」、已配对设备全线 401,而两端的离线测试(用真实 node:crypto)
17
- * 全绿,因为测试进程的 Node 是带 chacha 的那一种。
18
- * 不引 HPKE、不引新依赖 —— 理由(Node 不暴露 `crypto.hpke`,App 侧也没有
19
- * 现成的 X25519,而二维码里那张一次性票据已经解决了「怎么秘密交付密钥」)。
10
+ * AEAD 必须用 AES-256-GCM 而不是 ChaCha20-Poly1305:桌面宿主跑在 Electron 里,那份 Node
11
+ * 链接 BoringSSL,`crypto.getCiphers()` 里没有 chacha20-poly1305,解封会直接抛错,表现成
12
+ * 扫码配对一律「配对码错误」。不引 HPKE 也不引新依赖:Node 不暴露 `crypto.hpke`,App 侧也没有
13
+ * 现成的 X25519,而二维码里那张一次性票据已经解决了「怎么秘密交付密钥」。
20
14
  */
21
15
 
22
16
  const crypto = require('node:crypto');
@@ -1,29 +1,13 @@
1
1
  'use strict'
2
2
 
3
- // 「读己所写」(read-your-writes) 标记:移动端做完一次写操作之后紧接着的那次列表刷新,
4
- // 必须反映出这次写入,不能被任何「代读副本」顶掉。
3
+ // 「读己所写」标记:写操作之后紧接着的那次列表刷新必须反映这次写入,不能被任何代读副本顶掉。
5
4
  //
6
- // 背景(真机缺陷,探针实测):移动端的读请求会经过两层代读副本——
7
- // 1. sidecar 的镜像快照(mirror-snapshot.cjs 判定新鲜度)直接把 /sessions、
8
- // /workspaces 回给手机,根本不连宿主插件;桌面侧 500ms 推一次,所以正常最多
9
- // 旧 500ms;
10
- // 2. 宿主插件自己的 10s 会话列表缓存(lib/mobile/sessions.js 的
11
- // SESSION_LIST_CACHE_MS)。
12
- // 两层对「每 15s 一次的轮询」都无所谓,但写操作之后的第一次刷新撞上任何一层,就会把
13
- // 用户刚做完的事显示成没生效。
5
+ // 读请求会经过两层代读副本:sidecar 的镜像快照(桌面侧 500ms 推一次)与宿主插件自己的 10s
6
+ // 会话列表缓存。轮询撞上它们无所谓,写后第一次刷新撞上就会把刚做完的事显示成没生效。
14
7
  //
15
- // 实测(Mac 宿主 + MI 9,时间戳相对点击「归档」按钮):
16
- // + 311ms HOST archive-session session-63acd828-… ← 宿主侧已经改对了
17
- // + 375ms SNAPSHOT /api/mobile/v1/sessions fresh ← App 的刷新读到 +286ms 推来的旧镜像
18
- // +17233ms SNAPSHOT /api/mobile/v1/sessions fresh ← 直到 15s 轮询才拿到新列表
19
- //
20
- // 于是约定 `?fresh=1`:带这个标记的读跳过所有代读副本,直接回源宿主插件。
21
- // 轮询、首屏、下拉刷新不带,快路径照旧。
22
- //
23
- // 权威实现在本文件;`dsh-mobile-server/lib/fresh-request.js` 是它的手工同步副本,
24
- // 两侧由 `test/shared-helpers-parity.test.cjs` 钉住。**改一边必须改另一边。**
25
- // 中转侧同样必须认这个标记:中转自己也持有镜像与响应缓存,它若把带 fresh 的读
26
- // 当普通读缓存掉,App 的下拉刷新就失去了唯一的自救手段。
8
+ // 所以约定 `?fresh=1`:带这个标记的读跳过所有代读副本、直接回源;轮询 / 首屏 / 下拉刷新不带。
9
+ // 权威实现在本文件,`dsh-mobile-server/lib/fresh-request.js` 是手工同步副本,两边必须一起改;
10
+ // 中转也必须认这个标记,否则 App 就失去了唯一的自救手段。
27
11
 
28
12
  const FRESH_QUERY_RE = /(?:^|[?&])fresh=1(?:&|$)/
29
13
 
@@ -100,11 +100,10 @@ function sendHtml(res, status, html) {
100
100
  }
101
101
 
102
102
  /**
103
- * 读取请求体并 JSON.parse;超限/非法 JSON/IO 错误 → null。
103
+ * 读取请求体并 JSON.parse;超限 / 非法 JSON / IO 错误 → null。
104
104
  *
105
- * **协议 v2 的请求体已经被守卫解开了**(`req.body`),这里直接返回它:那条请求的字节流
106
- * 早被读走拿去验签与解封,再读只会拿到空串,于是所有 POST 都退化成「请求体不是合法
107
- * JSON」。上限按**明文**判(与线上字节的 4/3 放大分开算,见 §8.3(9))。
105
+ * 协议 v2 的请求体已经被守卫解开了(`req.body`),这里直接返回它:那条请求的字节流早被读走
106
+ * 拿去验签与解封,再读只会拿到空串。上限按明文判(线上字节按 4/3 放大,两边分开算)。
108
107
  *
109
108
  * @param {object} req node:http IncomingMessage
110
109
  * @param {number} [maxBytes] 请求体上限(字节,按明文),缺省 344KB