dsh-mobile-plugin 0.4.5 → 0.4.7

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 (43) hide show
  1. package/lib/client.js +3 -2
  2. package/lib/desktop-half-stats.cjs +1 -1
  3. package/lib/git/exec.js +1 -1
  4. package/lib/host-guard.cjs +1 -1
  5. package/lib/host-patches.cjs +2 -2
  6. package/lib/impl.js +2 -7
  7. package/lib/index.js +2 -2
  8. package/lib/mirror/pusher.cjs +2 -2
  9. package/lib/mobile/events.js +3 -3
  10. package/lib/mobile/host-bridge/event-synthesis.js +1 -1
  11. package/lib/mobile/host-bridge/shims.js +2 -2
  12. package/lib/mobile/inbox-clean.js +3 -2
  13. package/lib/mobile/session-log.js +4 -0
  14. package/lib/mobile/session-snapshot.js +1 -1
  15. package/lib/mobile/stream.js +4 -0
  16. package/lib/mobile/wire-cut.js +24 -15
  17. package/lib/relay/apply-ledger.cjs +2 -3
  18. package/lib/relay/core.js +10 -11
  19. package/lib/relay/lan-network.cjs +3 -3
  20. package/lib/relay/lan-proxy.js +4 -3
  21. package/lib/relay/migrate.js +4 -4
  22. package/lib/relay/pairing-policy.js +8 -6
  23. package/lib/relay/port-allocator.cjs +2 -3
  24. package/lib/relay/preview.js +1 -1
  25. package/lib/relay/status.js +4 -4
  26. package/lib/relay/tunnel.js +31 -26
  27. package/lib/relay/verify.js +5 -3
  28. package/lib/routes/control-plane.js +1 -1
  29. package/lib/routes/mobile-api.js +16 -14
  30. package/lib/shared/device-headers.js +5 -1
  31. package/lib/shared/dshtoken.js +4 -4
  32. package/lib/shared/e2ee-request.js +3 -4
  33. package/lib/shared/e2ee.js +2 -1
  34. package/lib/shared/fresh-request.js +1 -2
  35. package/lib/shared/http-util.js +2 -1
  36. package/lib/shared/sse-write.js +1 -1
  37. package/lib/shell/manager.js +1 -1
  38. package/lib/shell/updater.js +2 -1
  39. package/lib/sidecar-facade-worker.cjs +43 -7
  40. package/lib/sidecar-facade.cjs +1 -1
  41. package/lib/sidecar-lifecycle.cjs +3 -3
  42. package/lib/update-registry.cjs +2 -2
  43. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -50,7 +50,7 @@ window.__ModuleLoader__.load({
50
50
  document.head.appendChild(tag);
51
51
  }
52
52
 
53
- /* ── 一:桌面工作区展开态同步(语义与原实现一字未改,只是搬进函数) ── */
53
+ /* ── 一:桌面工作区展开态同步 ── */
54
54
  /** 桌面端持久化键:`dsh.workspace.view.v5`(版本号可能上浮,取版本最大的那个)。 */
55
55
  const STORAGE_KEY_RE = /^dsh\.workspace\.view\.v(\d+)$/;
56
56
  /** 轮询间隔:展开/折叠是低频操作,2s 足够跟手又不费电。 */
@@ -501,7 +501,8 @@ window.__ModuleLoader__.load({
501
501
  * 保持一个薄卡片:一个网页版入口,以及一个可信本机开关,控制网页管理是否短时向公网开放。
502
502
  * 界面本体仍在宿主吐出的 HTML(源码 `React/`,产物 `lib/panel/index.html`)。
503
503
  *
504
- * 本机设置项半边来自渲染器的 `node_modules`,不可热替换;插件更新仍走 DSH 市场。
504
+ * 本机设置项半边来自渲染器的 `node_modules`,不可热替换;插件本体由外壳自己装新版
505
+ * (`lib/update-install.cjs`),刷新页面才看得到这一半的变化。
505
506
  * 开关只影响控制面网页路由,不改移动 API、配对凭据或隧道状态。
506
507
  */
507
508
  function MobileSettingsSection({ t }) {
@@ -17,7 +17,7 @@
17
17
  * - `desktopViewPosts`:同一个半边、同一套 fetch 的**已知可用**对照组(侧栏展开态),
18
18
  * 它涨而别的两个不涨,等于「半边活着,是那两条 effect 没跑」。
19
19
  *
20
- * 刻意不做上限/轮转:三个整数加三个时刻,涨到天荒地老也只是几个数字。
20
+ * 刻意不做上限/轮转:几个整数计数器加对应时刻,涨到天荒地老也只是几个数字。
21
21
  */
22
22
 
23
23
  const state = {
package/lib/git/exec.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * 边界仍然成立;
10
10
  * 2. **失败不当异常抛**。非零退出是常态(没有上游、坏分支名、钩子拒绝),调用方要拿退出码
11
11
  * 和 stderr 判断,所以一律 resolve 成 `{ code, stdout, stderr }`,只有「git 根本没装」
12
- * 与「超时被杀」单独立成 `error` 字段;
12
+ * 「超时被杀」「输出超限」单独立成 `error` 字段;
13
13
  * 3. **按仓库串行**。手机上一次点两下就会并发两次写,撞上 `.git/index.lock` 会互相踩死;
14
14
  * 锁的粒度是仓库根,主仓库与它下面的独立子仓库各排各的队。
15
15
  *
@@ -40,7 +40,7 @@ function describeError(err) {
40
40
  * @param {(line: string) => void} [options.log] 注入日志,便于测试
41
41
  * @param {(code: number) => void} [options.exit] 注入退出,便于测试
42
42
  * @returns {{handleUncaughtException: Function, handleUnhandledRejection: Function,
43
- * record: Function, faults: Function, stats: Function, clear: Function,
43
+ * recordFault: Function, faults: Function, stats: Function, clear: Function,
44
44
  * isFatal: Function}}
45
45
  */
46
46
  function createGuard(options = {}) {
@@ -1,8 +1,8 @@
1
1
  'use strict'
2
2
 
3
3
  /**
4
- * 无头宿主拼 patch 层时的两处修正。抽成独立文件是为了能直接测:host.js 一 import
5
- * 就 `boot()` 整个 Cordis 宿主,测试碰不得。
4
+ * 无头宿主拼 patch 层时的一处修正:摘掉按裸包名挂载本插件的那一行。抽成独立文件是为了能
5
+ * 直接测:host.js 一 import 就 `boot()` 整个 Cordis 宿主,测试碰不得。
6
6
  */
7
7
 
8
8
  /**
package/lib/impl.js CHANGED
@@ -91,8 +91,8 @@ export const RemoteControlSettingsSchema = z.object({
91
91
  lanPort: z.number().step(1).min(1024).max(65535).default(18787),
92
92
  name: z.string().default(''),
93
93
  // Pairing approval policy; see lib/relay/pairing-policy.js for the two modes and
94
- // why 'code' is not the old popup. Kept in the settings schema so the host UI and
95
- // /api/relay/config can change it, and so an unknown value is rejected on write.
94
+ // why 'code' is not the old popup. Kept in the config schema so /api/relay/config
95
+ // can change it, and so an unknown value is rejected on write.
96
96
  pairingPolicy: z.union(pairingApproval.POLICIES.map(value => z.const(value))).default(pairingApproval.DEFAULT_POLICY),
97
97
  // 中转注册密钥(对偶于中转的 TUNNEL_REGISTER_SECRET)。桌面版拿不到 shell
98
98
  // 环境变量,所以它和其他配置一样存在 relay.json(0600),由 /api/relay/config
@@ -465,11 +465,6 @@ function apply(ctx) {
465
465
  hostMetadata,
466
466
  // 取值器而非快照对象:snapshot 会被 applySidecarConfig 整体替换,闭包捕获对象会让
467
467
  // 连接信息永远停在进程启动那一刻。
468
- //
469
- // 曾经这里写的是 `() => sidecarSnapshot` —— 那个标识符在本文件作用域里根本不存在
470
- // (它只在 lib/sidecar-lifecycle.cjs 内部),于是 GET /api/mobile/v1/connection-info
471
- // 一进来就 ReferenceError。同一个 apply() 里给 createStatusView 传的是
472
- // `sidecarLifecycle.getSnapshot`,两处不一致才让这个笔误活了下来。
473
468
  getSidecarSnapshot: sidecarLifecycle.getSnapshot,
474
469
  // 移动端那边的「局域网候选」:私网优先的清单 + 用户手填的直连地址(网卡扫不出的域名)。
475
470
  safeLanCandidates: (port) => phoneCandidates(advertisedCandidates(port), { manual: state.manualLanUrl }),
package/lib/index.js CHANGED
@@ -17,9 +17,9 @@
17
17
  *
18
18
  * ## 更新来源
19
19
  *
20
- * 正式 npm 更新由 `shell/updater.js` 处理(自动安装默认关闭);紧急完整包由
20
+ * 正式 npm 更新由 `shell/updater.js` 处理(自动更新默认开启,面板可关);紧急完整包由
21
21
  * `shell/device-ota.js` 经本机控制面激活,正式版成功接管前保留 OTA 回退包。
22
- * `updateState` 记录 npm 查装结果;`marketResult` 是旧外壳保留的兜底状态。
22
+ * `updateState` 记录查装结果;对外的 `marketResult` 就是它的 `result`。
23
23
  */
24
24
 
25
25
  import path from 'node:path'
@@ -4,8 +4,8 @@
4
4
  * 镜像推送调度器:把宿主的会话状态推到中转,手机端才看得到列表与消息。
5
5
  *
6
6
  * 三条不能丢的约束:隧道没连上就不构建快照(否则 503 刷屏,真故障被淹没);空清单/空快照
7
- * 不许广播(「没取到」与「被删光」形状相同,放行会让手机侧整行消失且零报错);推送成功后
8
- * 先 `sessionSignal.bump` 再 `announceSessionsRevision`,顺序反了手机会来取上一份镜像。
7
+ * 必须过守卫(没有 `emptyConfirmed` 的「没取到」不许推,否则手机侧整行消失且零报错);
8
+ * 镜像先推上去,再广播清单 revision(`announceSessionsRevision`),顺序反了手机会来取上一份镜像。
9
9
  *
10
10
  * 必须是 .cjs:包根声明 type=module,而 lib/mirror/ 没有自己的 `{"type":"commonjs"}`。
11
11
  */
@@ -23,7 +23,7 @@ const DEDUP_WINDOW = 2048;
23
23
  const HEARTBEAT_MS = 15_000;
24
24
 
25
25
  /**
26
- * 诊断日志。失败路径一条都不许静默:手机把兜底轮询降成 60s,宿主日志里却查不到这条流是
26
+ * 诊断日志。失败路径一条都不许静默:手机把兜底轮询降成 3 分钟,宿主日志里却查不到这条流是
27
27
  * 正常收尾、被客户端断开,还是循环抛错。会话级流早有 logStream,这里是它的设备级对偶。
28
28
  */
29
29
  function logDeviceStream(message, error) {
@@ -203,7 +203,7 @@ async function handleDeviceStream(ctx, req, res) {
203
203
  * 连上就先补一帧「当前状态」。
204
204
  *
205
205
  * 这条流没有 Last-Event-ID 续传,断流窗口里发生的清单变更与正文水位必须在重连后补上,
206
- * 否则手机 state 是空的,而兜底轮询已经因为「流活着」被降到 60s。
206
+ * 否则手机 state 是空的,而兜底轮询已经因为「流活着」被降到 3 分钟。
207
207
  *
208
208
  * 只读两条信号总线已经广播过的最近值,既不 bump 也不前移基准,所以不会制造新变更;
209
209
  * App 对同 revision / 同水位幂等,重连补发不会引发重复刷新。
@@ -231,7 +231,7 @@ async function handleDeviceStream(ctx, req, res) {
231
231
  const keepAlive = setInterval(() => {
232
232
  try { res.write(': keepalive\n\n'); } catch { ac.abort(); return; }
233
233
  // 心跳必须是**数据帧**:注释帧 App 看不见,而 App 只有「最近真的收到过帧」才敢把
234
- // 兜底轮询降到 60s(见 app/src/services/viewmodels/signal-liveness.ts)。
234
+ // 兜底轮询降到 3 分钟(见 app/src/services/viewmodels/signal-liveness.ts)。
235
235
  if (!writeFrame({ type: 'device/heartbeat', at: Date.now() })) ac.abort();
236
236
  }, HEARTBEAT_MS);
237
237
  // 定时器不该拖住进程退出(同 lib/sidecar-facade-worker.cjs 的 shutdown)。测试里的
@@ -6,7 +6,7 @@
6
6
  * 旧 mux 一次给出「这个设备正在发生的一切」,0.2.0 没有对应端点(事件已拆成
7
7
  * session/follow、session/control、workspace/follow),这里按 payload 里有没有 sessionId
8
8
  * 合成同形帧:带 sessionId → 该会话的 follow + job 整帧 + 投影整帧;不带 → 设备级的
9
- * 工作区基线 + 全局投影整帧。调用方(stream.js / events.js / index.js 的 mirror)本来就
9
+ * 工作区基线 + 全局投影整帧。调用方(stream.js / events.js / lib/mirror/pusher.cjs)本来就
10
10
  * 按 frame.payload 里的 type 分发,形状对上了它们一行都不用改。
11
11
  */
12
12
 
@@ -134,7 +134,7 @@ async function workspaceListViaFollow(ctx, signal) {
134
134
  }
135
135
  // 流「正常结束却没吐 baseline」不是「你没有工作区」,而是没取到。原先这里返回
136
136
  // ok({ items: [] }),与真成功在下游完全无法区分:宿主正在初始化 / 正忙 / 网关给了
137
- // 空流时都会走到这里,于是 { ok: true, items: [] } 一路畅通地通过 lib/index.js 的
137
+ // 空流时都会走到这里,于是 { ok: true, items: [] } 一路畅通地通过 lib/mirror/pusher.cjs 的
138
138
  // 镜像守卫、被当作权威清单推给中转,手机侧表现为工作区整行消失、会话全塌进
139
139
  // 「未分组」,且中转只会附一个 App 根本不看的 stale 标记,不会有任何报错。
140
140
  // 拿不到就是拿不到,如实走错误通道,让调用方去重试或保留上一次结果。
@@ -159,7 +159,7 @@ function projectionValues(item) {
159
159
  /**
160
160
  * 子代理目录的投影基线预算,比通用合成流(SYNTHETIC_DEADLINE_MS)更紧。
161
161
  *
162
- * 手机端一次请求的总预算只有 8 秒(app/src/api.ts 的 REQUEST_TIMEOUT_MS),走中转还要
162
+ * 手机端一次请求的总预算只有 8 秒(app/src/api/core.ts 的 REQUEST_TIMEOUT_MS),走中转还要
163
163
  * 多一个 RTT。基线开不出来并不致命 —— session.list 摘要内联的是同一份 values,退回它
164
164
  * 就行,所以宁可慢的时候少等几秒,也别把整次 subagents.list 拖成客户端超时。
165
165
  */
@@ -36,8 +36,9 @@ function autoClearFile() {
36
36
  }
37
37
 
38
38
  /**
39
- * 读自动清理开关。读不到 / 读坏了都当「关」—— 无人值守删盘这种事,
40
- * 缺省必须在用户这一侧(与 `updater.js` 的 `autoUpdateEnabled` 同一条口径)。
39
+ * 读自动清理开关。读不到 / 读坏了都当「关」—— 无人值守删盘这种事,缺省必须在用户这一侧。
40
+ * 自动更新那个开关刻意相反(文件不在就当作开,见 `lib/shell/updater.js` 的
41
+ * `autoUpdateEnabled`):装新版可回退,删文件不可回退,两个缺省因此不该相同。
41
42
  */
42
43
  function autoClearEnabled() {
43
44
  try {
@@ -8,6 +8,10 @@
8
8
  * 的 127.0.0.1 上,手机既直连不到、也没有 `<a download>`,所以这里由插件代取并
9
9
  * 原样转发宿主 `apiProxy.downloads.sessionLog` 的 application/zip 流。
10
10
  *
11
+ * 0.2.x 的兼容桥没有这条端点(host-bridge.js 的 `downloads.sessionLog` 直接回
12
+ * `host-unsupported`),所以这一代宿主上本路由只能如实回 502;等宿主把导出能力重新
13
+ * 暴露出来,上面这段转发才真的走得到。
14
+ *
11
15
  * 与桌面客户端保持同一口径:`includeDescendants` 缺省即 true —— 导出的是「当前会话
12
16
  * + 子会话 + 附件」的整棵会话树,而不是单个会话。
13
17
  */
@@ -80,7 +80,7 @@ async function buildStatsSnapshot(ctx, sessionId) {
80
80
  * 0.2.0 把整帧投影从 history/page 的返回值里拆走了,「拉一条历史顺带取投影」这条路断了;
81
81
  * 改走 `apiProxy.sessions.projections({ request: { sessionId } })`,返回值是
82
82
  * `{ asOfSeq, values }`。顺带把 plan 归一化成 planMode(排队条与计划条读它)。
83
- * 历史轮询是每 5 秒一次的热路径,多背一次投影 RPC 划不来,所以统计单独走投影端点。
83
+ * 历史轮询是每 15 秒一次的热路径,多背一次投影 RPC 划不来,所以统计单独走投影端点。
84
84
  */
85
85
  async function statsProjections(ctx, sessionId) {
86
86
  // 显式给 signal:宿主部分方法内部直接读 signal.aborted,不做 undefined 兜底。
@@ -538,6 +538,8 @@ async function handleStream(ctx, req, res) {
538
538
  // 先吸工具名再减:结果自己不带工具名,正文留不留取决于它(见 wire-cut.js)。
539
539
  toolNames.absorb(event);
540
540
  event = cutEvent(event, toolNames);
541
+ // 整条不上线路的事件(`tool/call`、参数被砍空之后的工具增量帧):见 wire-cut.js。
542
+ if (!event) continue;
541
543
  const chunk = event.data && event.data.chunk;
542
544
  if (seq !== null || !chunk || typeof chunk.type !== 'string') {
543
545
  // 持久事件(或不是增量的会话事件):先把攒着的草稿写掉,再按原样写这一帧。
@@ -651,6 +653,8 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, a
651
653
  const e = entry?.event || entry;
652
654
  const resolved = await resolveEventAttachments(ctx, sessionId, e, { referencesOnly: true });
653
655
  const event = cutEvent(resolved, toolNames);
656
+ // 回放与直播同一副眼镜:整条不上线路的事件这里也跳过(见 wire-cut.js)。
657
+ if (!event) continue;
654
658
  // Keep replay data identical to live SSE data: the client reducer consumes
655
659
  // the event's own type (turn/start, assistant/message, etc.).
656
660
  const payload = { type: 'session/event', sessionId, event };
@@ -10,6 +10,10 @@
10
10
  * 料 —— 计划卡读 `exit_plan_mode` 的 `plan`、扩展卡读 `extension_present` 的 `extension`、
11
11
  * 定时任务卡解析 `schedule_create` 结果的 JSON。
12
12
  *
13
+ * 除了「内容」还要收「帧数」:手机只回答「在干什么」,所以参数被砍空之后的工具增量帧
14
+ * (一次调用几十帧、每窗补 2KB)与没人消费的 `tool/call` 事件整条丢掉 —— 一个工具调用
15
+ * 在线上只剩**一帧身份**(`id` + `name`)加一帧结果。
16
+ *
13
17
  * 直播帧循环(stream.js)与历史派生(history-derive.js)调的是同一份实现。同一条规则写两份,
14
18
  * 迟早漂成「直播里还带着推理、拉一次历史又没有了」。
15
19
  */
@@ -111,29 +115,34 @@ function cutAssistantEvent(event) {
111
115
  }
112
116
 
113
117
  /**
114
- * 一条 `tool/call` 事件:与 tool-call 块同一条规则 —— 只为三张轮尾卡的工具留 `arguments`
115
- * (其余一律只留 `callId` / `name`)。宿主把这条事件原样追进会话日志,手机今天收得到每一次
116
- * 调用的完整参数(bash 命令行、文件路径都在里面),而 App 对这条事件没有任何消费者。
117
- * 认不出工具名时按「不留」处理:宁可少一份参数,也不把别的调用原样发出去。
118
+ * 一条 `tool/call` 事件:**整条不下发**。
119
+ *
120
+ * 手机端对这条事件没有任何消费者:调用名由工具块的身份帧带到,状态与图片由 `tool/result`
121
+ * 回填,三张轮尾卡的料取自 `assistant/message` 里的 tool-call 块(见 App 的 `turn-tail-cards`)。
122
+ * 而它带 seq,属于「其余帧补满整份」那一类 —— 一帧就是 2KB 注释,一个长任务几十次调用、
123
+ * 两次报路(宿主→中转→手机)都是白走的。
118
124
  */
119
- function cutToolCallEvent(event) {
120
- const data = event && event.data;
121
- if (!data || typeof data !== 'object') return event;
122
- if (typeof data.name === 'string' && ARGUMENT_KEEP_TOOLS.has(data.name)) return event;
123
- if (data.arguments === undefined) return event;
124
- const { arguments: _droppedArguments, ...rest } = data;
125
- return { ...event, data: rest };
125
+ function cutToolCallEvent() {
126
+ return null;
126
127
  }
127
128
 
128
129
  /**
129
- * 一块流式增量:`tool-call-delta` 的参数正文不下发,帧本身照留(App 靠它知道有工具开始了,
130
- * `index` / `id` / `name` 也就是配对要用的那几个字段)。这条是整个减法里最贵的一笔 ——
131
- * `write` / `edit` 的参数就是整份文件内容,按 token 一帧地推给手机。
130
+ * 一块流式增量:`tool-call-delta` 只留**身份帧**,其余整帧丢掉。
131
+ *
132
+ * 参数正文不下发(`write` / `edit` 的参数就是整份文件内容,按 token 一帧地推),于是同一块
133
+ * 后面那些帧只剩 `{ type, index }` —— 而 `index` 对手机没有用处,它要的是「有个工具开始了」
134
+ * 和那个工具叫什么。这些空壳在线上按 200ms 一窗合并、每窗补到 2KB(见 `stream.js`),
135
+ * 一个改文件的调用光参数流式那十几秒就能白走几十上百 KB。
136
+ *
137
+ * 判据只看这一帧自己带不带身份(`id` / `name`),不记状态:宿主把名字放在第一帧,也有的
138
+ * 宿主分开给,两种都留得住。
132
139
  */
133
140
  function cutAssistantChunkEvent(event) {
134
141
  const data = event && event.data;
135
142
  const chunk = data && data.chunk;
136
143
  if (!chunk || typeof chunk !== 'object' || chunk.type !== 'tool-call-delta') return event;
144
+ const identifies = chunk.id !== undefined || typeof chunk.name === 'string';
145
+ if (!identifies) return null;
137
146
  if (chunk.argumentsDelta === undefined) return event;
138
147
  const { argumentsDelta: _droppedArguments, ...rest } = chunk;
139
148
  return { ...event, data: { ...data, chunk: rest } };
@@ -141,7 +150,7 @@ function cutAssistantChunkEvent(event) {
141
150
 
142
151
  /**
143
152
  * 事件类型 → 这条事件该怎么减:能减的类型走对应的一条规则,其余原样透传。
144
- * 直播帧循环与回放都调它,一次判完,避免两处各判一半。
153
+ * **返回 null = 这条事件整条不上线路**,调用方必须跳过它(见 `stream.js` 的两处)。
145
154
  */
146
155
  function cutEvent(event, names) {
147
156
  if (!event || typeof event !== 'object') return event;
@@ -3,9 +3,8 @@
3
3
  /**
4
4
  * 「按新配置重建隧道失败」的台账:最近一次失败的错误、时间与当时要切过去的地址。
5
5
  *
6
- * 以前装配层写的是 `void syncSidecar().catch(() => undefined)`:换地址失败被整个吞掉,
7
- * 手机上看是「保存成功」然后永久离线,宿主日志里也一个字都没有 —— 用户唯一的线索是「连不上
8
- * 了」,还得自己猜是不是地址填错了。台账只留最近一条,由
6
+ * 换地址失败若被吞掉,手机上看是「保存成功」然后永久离线,宿主日志里也一个字都没有 ——
7
+ * 用户唯一的线索是「连不上了」,还得自己猜是不是地址填错了。台账只留最近一条,由
9
8
  * `/api/mobile/v1/relay/diagnostics` 原样报给手机(「运行状态」那一页读它)。
10
9
  *
11
10
  * 只留一条是刻意的:这里回答的是「刚才那次为什么没成」,不是故障统计(故障统计归
package/lib/relay/core.js CHANGED
@@ -15,15 +15,15 @@ const sharedToken = require('../shared/dshtoken');
15
15
  const paths = require('../shared/paths');
16
16
 
17
17
  const STATE_FILE = paths.relayStateFile();
18
- /** 浏览器/局域网代理签发的 `dsh_lan` Cookie 有效期(30 天)。 */
18
+ /** 局域网代理签发的 `dsh_lan` Cookie 有效期(30 天),也是 `signLanToken` 的默认值。 */
19
19
  const MOBILE_TOKEN_TTL_SEC = 30 * 24 * 3600;
20
20
  /**
21
- * App 侧长期 bearer token 的有效期(1 年)。
21
+ * 全局 bearer token 的有效期(1 年)。
22
22
  *
23
- * App 没有续签流程,这个 token 只能靠宿主轮换 `lanSecret`(`/api/relay/revoke-all`)
24
- * 整体作废;单设备吊销改由 `dshc_` 设备凭据承担(见 `lib/mobile/auth.js`)。
25
- * 旧实现把它写成 10 年,且与 Cookie 的 30 天完全脱钩,`MOBILE_TOKEN_TTL_SEC`
26
- * 这个常量实际上从未被任何人使用过。
23
+ * 当前配对签发的是每设备凭据(`token` / cookie / `deviceCredential` 三者同值),只有隧道
24
+ * `pairRequest` 那条兼容路径还会签这张不带设备身份的 token。它没有续签流程,只能靠宿主
25
+ * 轮换 `lanSecret`(`/api/relay/revoke-all`)整体作废;单设备吊销由 `dshc_` 设备凭据承担
26
+ * (见 `lib/mobile/auth.js`)。
27
27
  */
28
28
  const MOBILE_BEARER_TTL_SEC = 365 * 24 * 3600;
29
29
 
@@ -68,7 +68,7 @@ function loadState() {
68
68
  }
69
69
  }
70
70
  /**
71
- * 配对成功之后换掉配对码与票据(§5.1)。
71
+ * 配对成功之后换掉配对码与票据。
72
72
  *
73
73
  * **两件必须同一个动作**:只换码不换票据,那张截图里的票据还能再配一次;只换票据不换码,
74
74
  * 旧码在旧形状那条路上还能用。所以这里一次改两个字段、一次落盘。
@@ -101,10 +101,9 @@ function normalizeRelayUrl(relayUrl) {
101
101
  /**
102
102
  * 中转地址 → 隧道 WebSocket 地址。
103
103
  *
104
- * 旧实现只是把 `http` 前缀替换成 `ws`,因此 `example.com:8700`、`//relay.example`
105
- * 这类缺协议的输入会生成一个 `new WebSocket()` 直接拒绝的字符串,错误要等到
106
- * 隧道连接阶段才以 `tunnel.state='error'` 的形式浮现。这里提前抛错,让
107
- * `/api/relay/config` 能在保存时就给出明确反馈。
104
+ * 缺协议的输入(`example.com:8700`、`//relay.example`)会被拼成一个 `new WebSocket()`
105
+ * 直接拒绝的字符串,错误要等到隧道连接阶段才以 `tunnel.state='error'` 的形式浮现。
106
+ * 这里提前抛错,让 `/api/relay/config` 能在保存时就给出明确反馈。
108
107
  */
109
108
  function tunnelWsUrl(relayUrl) {
110
109
  const raw = normalizeRelayUrl(relayUrl);
@@ -154,9 +154,9 @@ function toCandidates(list, port, family = 'IPv4') {
154
154
  }))
155
155
  }
156
156
 
157
- // Structured view of the usable LAN endpoints. The relay's `normalizeLanMetadata`
158
- // and the app's `candidateUrls()` both accept this shape, so callers that want
159
- // interface metadata (for diagnostics or smarter ordering) should prefer it.
157
+ // Structured view of the usable LAN endpoints. The app's `candidateUrls()` accepts
158
+ // this shape, so callers that want interface metadata (for diagnostics or smarter
159
+ // ordering) should prefer it.
160
160
  function lanInterfaces(port, interfaces = os.networkInterfaces()) {
161
161
  return toCandidates(collectEndpoints(interfaces, isLanCandidateAddress), port)
162
162
  }
@@ -37,9 +37,10 @@ const WS_UPSTREAM_HANDSHAKE_MS = 10_000;
37
37
  const WS_UPSTREAM_PENDING_MAX_BYTES = 32 * 1024 * 1024;
38
38
  // 局域网直连允许同时活着的上游 SSE 条数。它兜的是**半开连接**:手机走了但 TCP 还没
39
39
  // 被拆掉(飞机模式、睡死、拔线都不会发 FIN),只有这种情况才需要一道闸。
40
- // 与中转路的同名上限(tunnel.js 的 16)刻意不是一个量级:那边所有设备共用一条隧道,
41
- // 设备多就得挤;这边每台设备各有自己的 TCP,实测 3 台设备正常十来条。所以这里留出
42
- // 成倍余量,只当失控闸——正常使用不该碰到它。
40
+ // 与中转路的上限(tunnel.js 的 MAX_UPSTREAM_SSE_CONNECTIONS,同为 64)取同一个数:两条路
41
+ // 通向同一个宿主、同一种资源,一台手机的几个被丢弃连接不该把另一条路挡在 429 外面。
42
+ // 这边每台设备各有自己的 TCP,实测 3 台设备正常十来条,所以这个数只当失控闸——正常使用
43
+ // 不该碰到它。
43
44
  const MAX_UPSTREAM_SSE_CONNECTIONS = 64;
44
45
 
45
46
  function createLanProxy(opts) {
@@ -8,9 +8,9 @@
8
8
  * 换完地址那一刻,所有不在同一个局域网的设备就永远找不到它了 —— 「换服务器之后拿旧地址的
9
9
  * 设备永远学不到新地址」就是缺这一帧。
10
10
  *
11
- * 旧隧道**不立刻关**(两条路都一样):新隧道注册期间它还在服务在途请求;失败时更是只有它
12
- * 还能发撤销。注册完成后再留 `OLD_TUNNEL_LINGER_MS`,让「已受理」或「失败」那一发响应
13
- * 沿着老路回去 —— 这一发的结果是用户填完地址之后唯一的反馈。
11
+ * 旧隧道**不立刻关**(成功、失败两条路都一样):新隧道注册期间它还在服务在途请求;失败时
12
+ * 更是只有它还能发撤销。注册完成后再留 `OLD_TUNNEL_LINGER_MS`,让「已受理」或「失败」那一发
13
+ * 响应沿着老路回去 —— 这一发的结果是用户填完地址之后唯一的反馈。
14
14
  *
15
15
  * 顺序只有这一份实现:worker 的 `configure()` 在检测到中转地址变了之后调 `switchRelay`,
16
16
  * 手机端与控制面两个入口都走同一条下发路径,不各写一套。
@@ -53,7 +53,7 @@ function revoke(tunnel) {
53
53
  * @param {() => Promise<unknown>} opts.open 建一条**新**隧道并返回它的注册 Promise;
54
54
  * 不许先停旧的(旧的还要发撤销、还要把响应送回去)
55
55
  * @param {(tunnel: object) => void} opts.drop 停掉一条隧道
56
- * @returns {Promise<boolean>} 旧隧道上到底登记出去没有
56
+ * @returns {Promise<boolean>} 登记发出且新隧道注册成功才为 true
57
57
  */
58
58
  async function switchRelay({ oldTunnel, relayUrl, open, drop }) {
59
59
  /** 留一段再收旧隧道:那一发「已受理 / 失败原因」的响应还在它上面往回走。 */
@@ -3,13 +3,15 @@
3
3
  /**
4
4
  * 配对授权策略。
5
5
  *
6
- * 无头宿主没有弹窗确认界面,授权改由本地策略决定。两条链路都汇聚到父进程的
7
- * `/api/relay/internal/approval`:隧道收到 pairRequest 时,以及局域网代理的 onApprove。
8
- * sidecar 只接受显式 true,所以 'deny' 与恒定返回 `{ approved: false }` 等价。
6
+ * 无头宿主没有弹窗确认界面,授权改由本地策略决定。三个配对入口最终都落到 `evaluate()`:
7
+ * 移动端 `/pair` 直接调用;局域网代理的 onApprove 与隧道 pairRequest 经 worker 打到主进程的
8
+ * `/api/relay/internal/approval`。旁路只看 `approved` 是否为真,所以 'deny' 与恒定返回
9
+ * `{ approved: false }` 等价。
9
10
  *
10
- * 'code' — 凭配对码放行。两条链路在询问宿主之前都已用恒定时间比较校验过配对码,中转还有
11
- * 失败锁定;配对码只在用户主动索取时展示,可用 `/api/relay/regenerate` 轮换、
12
- * 用 `/api/relay/revoke-all` 连同 lanSecret 一并作废。
11
+ * 'code' — 凭配对码 / 票据放行。`/pair` 与局域网代理在进入授权之前都已用恒定时间比较校验过
12
+ * 配对码(或解开票据),入口另有令牌桶限速(见 `shared/pair-throttle.js`);配对码
13
+ * 只在用户主动索取时展示,可用 `/api/relay/regenerate` 轮换、用
14
+ * `/api/relay/revoke-all` 连同 lanSecret 一并作废。
13
15
  * 'deny' — 拒绝一切新配对。
14
16
  */
15
17
 
@@ -4,9 +4,8 @@
4
4
  * 局域网代理端口的自适应分配。
5
5
  *
6
6
  * 背景:默认端口 18787 是个「谁都能想到」的数字,于是同一台机器上只要还有别的
7
- * 服务先占了它(这台开发机就被 `billion-context start --port 18787` 占着),
8
- * 局域网代理的 `listen` 就会 EADDRINUSE,整条 LAN 直连链路直接死掉——而
9
- * `/api/relay/status` 只会说 `lan.state='error'`,用户看到的现象是「手机配不上对」,
7
+ * 服务先占了它,局域网代理的 `listen` 就会 EADDRINUSE,整条 LAN 直连链路直接死掉
8
+ * ——而 `/api/relay/status` 只会说 `lan.state='error'`,用户看到的现象是「手机配不上对」,
10
9
  * 和真正的原因(端口被占)隔了十万八千里。
11
10
  *
12
11
  * 这里把「挑端口」从配置里解耦出来:先探测首选端口,空闲就用它;被占就让内核
@@ -48,7 +48,7 @@ const MAX_PREVIEW_PENDING_BYTES = 1024 * 1024;
48
48
  /**
49
49
  * 从升级请求的查询串里取 port。
50
50
  *
51
- * 中转的升级分发(`server.js` 的 `server.on('upgrade')`)不看路径,只做鉴权后把
51
+ * 中转的升级分发(Rust relay 的 upgrade 入口)不看路径,只做鉴权后把
52
52
  * `req.url` 原样交给隧道;宿主的 webServer 又只按 pathname 匹配升级路由、忽略查询串
53
53
  * ——所以端口只能这么带,也只能这么读。
54
54
  *
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * 这份 JSON 有三个消费方,形状必须一模一样:桌面设置页、手机首屏都轮询
7
7
  * `/api/mobile/v1/status`,二维码里嵌的也是它的子集。抽出来之后可以直接 require 测试,
8
- * index.js 只剩装配。
8
+ * `impl.js` 只剩装配。
9
9
  *
10
10
  * `TUNNEL_ADMISSION` 靠正则匹配中转返回的错误文案来判断隧道为什么被拒。这是个脆弱契约
11
11
  * (中转改一个字就漏判),但错误文案是当前唯一穿过隧道边界回来的信号,判定放宽到只认关键词。
@@ -13,7 +13,7 @@
13
13
 
14
14
  const os = require('node:os')
15
15
 
16
- // 版本号只有一个来源:包自己的 package.json。以前 index.js 与 hostMetadata() 各拼一次。
16
+ // 版本号只有一个来源:包自己的 package.json。
17
17
  const { version: pluginVersion } = require('../../package.json')
18
18
 
19
19
  // 客户端半边(桌面页面里的 lib/client.js)的活体台账,只读它的计数快照。
@@ -21,12 +21,12 @@ const desktopHalf = require('../desktop-half-stats.cjs')
21
21
 
22
22
  const TUNNEL_ADMISSION = [
23
23
  {
24
- // 中转关连接时用的文案是 'bad registration secret'(server.js 里拼的),
24
+ // 中转关连接时用的文案是 'bad registration secret'(中转侧拼的),
25
25
  // 而 lib/tunnel-registration.js 内部的 reason 拼作 'bad register secret'。
26
26
  // 两种拼法都出现过,所以正则认词干不认具体字母:regist + 任意尾巴 + secret。
27
27
  pattern: /bad regist\w*\s+secret/i,
28
28
  flag: 'registerSecretRequired',
29
- // 这两条拒绝都由 server.js 的准入判定给出(lib/tunnel-registration.js),
29
+ // 这两条拒绝都由中转的准入判定给出(对应 lib/tunnel-registration.js 的口径),
30
30
  // 文案变了这里就会漏判,所以判定正则放宽到只认关键词。
31
31
  //
32
32
  // 插件每 5 秒重试一次,而状态里只有一句「隧道关闭: 1008 bad registration secret」。
@@ -4,7 +4,7 @@
4
4
  * 出站隧道客户端 v2:连接 dsh-mobile-server 中转,把中转发来的 HTTP/WS 请求转发到
5
5
  * 本机 dsh web(127.0.0.1:<port>)。
6
6
  *
7
- * 协议 v2 相对 v1 的变更(见 ../../relay/PROTOCOL.md):
7
+ * 协议 v2 相对 v1 的变更(协议定义见中转仓库 relay-core 的 `PROTOCOL.md`):
8
8
  * - 二进制帧取代 JSON/base64 文本帧(数据通道不再 base64);
9
9
  * - 手机请求 body 流式转发(httpRequest + httpBody*),不再整包缓冲;
10
10
  * - 应用层 ping/pong 心跳(对 NAT/代理更可靠)。
@@ -18,10 +18,10 @@ const { encodeJsonBody } = require('../shared/http-util');
18
18
  const { withViaHeader } = require('../shared/via-header');
19
19
 
20
20
  const HEARTBEAT_INTERVAL_MS = 15_000;
21
- // Relay currently expires an idle tunnel after 60s. Keep a wider client-side
22
- // grace period so scheduling jitter or a delayed pong cannot make the client
23
- // race the relay's timeout.
24
- const PONG_TIMEOUT_MS = 90_000;
21
+ // 中转 60s 收不到 pong 就把这条隧道 terminate 掉(TUNNEL_PONG_TIMEOUT_MS)。宿主必须比它
22
+ // 先动或同时动:反过来就是「被踢的那一刻我们还以为连着」,那段时间手机发来的每个请求都要
23
+ // 白等一次响应头超时才见到失败。45s = 三拍 ping 都没回,迟到的 pong 与调度抖动还容得下。
24
+ const PONG_TIMEOUT_MS = 45_000;
25
25
  const MAX_TUNNEL_BUFFERED_BYTES = 16 * 1024 * 1024;
26
26
  const TUNNEL_BUFFER_LOW_WATER_BYTES = MAX_TUNNEL_BUFFERED_BYTES / 2;
27
27
  const HTTP_RESPONSE_CHUNK_BYTES = 256 * 1024;
@@ -35,9 +35,8 @@ const UPSTREAM_RESPONSE_IDLE_TIMEOUT_MS = 30_000;
35
35
  // Keep reconnect storms from monopolizing the local Host HTTP server.
36
36
  //
37
37
  // 64 与局域网那条直连闸(lan-proxy 的 MAX_UPSTREAM_SSE_CONNECTIONS)取同一个数:两条路
38
- // 通向的是同一个宿主、同一种资源,之前这里 16、那边 64,结果一台手机的几个被丢弃的连接
39
- // 就能把走中转的所有设备一起挡在 429 外面。上限的作用是挡住失控重连,不是给正常多设备
40
- // 使用设配额 —— 一台手机正常只占两条(会话流 + 设备级流)。
38
+ // 通向的是同一个宿主、同一种资源。上限的作用是挡住失控重连,不是给正常多设备使用设配额
39
+ // —— 一台手机正常只占两条(会话流 + 设备级流)。
41
40
  const MAX_UPSTREAM_SSE_CONNECTIONS = 64;
42
41
  class RelayTunnel {
43
42
  /**
@@ -54,7 +53,7 @@ class RelayTunnel {
54
53
  * relay.json(0600)。
55
54
  * @param {() => void} [opts.onRegistered] 注册成功回调(用于状态展示)
56
55
  * @param {(code: number, reason: string) => void} [opts.onClose] 连接断开回调(用于断线重连)
57
- * @param {(msg: object) => void} [opts.onPairRequest] 中转转发来的配对授权请求(配对码已校验)
56
+ * @param {(msg: object) => void} [opts.onPairRequest] 中转转发来的配对授权请求(宿主只按配对策略答复)
58
57
  * @param {(msg: string) => void} [opts.onLog]
59
58
  */
60
59
  constructor(opts) {
@@ -88,6 +87,10 @@ class RelayTunnel {
88
87
  this.httpPumpCursor = 0;
89
88
  this.pendingHttpResponseBytes = 0;
90
89
  this.lastPong = 0;
90
+ // 对端存不存镜像:注册回执里明确说了才改,没说(老中转)按「存」处理,行为与以前一字
91
+ // 不差。纯转发节点(`MIRROR_CACHE=0`)收下整份镜像也只解析完丢掉,worker 据此只把
92
+ // 摘要推上去(lib/sidecar-facade-worker.cjs 的 digestFrame)。
93
+ this.relayMirror = true;
91
94
  // 清除所有授权的等待回调(一次一个);由 requestRevokeAll 注入,revokeAllAck 触发。
92
95
  this._revokeAllAck = null;
93
96
  }
@@ -112,7 +115,7 @@ class RelayTunnel {
112
115
  * 发送队列积压到上限时只丢这一帧,不断开隧道。
113
116
  *
114
117
  * 积压本身是「手机/中转读得慢」,不是隧道坏了;掐掉整条隧道会让所有设备一起掉线再等 5s
115
- * 重连。中转侧的同名判定已经改成只 `return false`,两侧口径必须一致:丢一帧可以恢复,
118
+ * 重连。中转侧同名上限同样只丢弃这一帧、不断开隧道,两侧口径必须一致:丢一帧可以恢复,
116
119
  * 关一条隧道是全网断流。真正的「隧道已死」由心跳兜底:收不到 pong 才 terminate。
117
120
  */
118
121
  dropOverBudget(bytes, frameLength) {
@@ -383,14 +386,10 @@ class RelayTunnel {
383
386
  }
384
387
 
385
388
  /**
386
- * 更新配对码。
389
+ * 更新本地配对码值。
387
390
  *
388
- * **不再告诉中转**(§5.2)。中转那边从来没用过它 —— `ws.rs` 写明「不保存也不校验
389
- * 注册帧里的实例号 / 配对码」,唯一的用处是控制台准入校验的 `license` 字段。删掉之后
390
- * 「中转看得见配对码」这个既成事实就不存在了,而这一轮之后配对码本身也不再是
391
- * 新协议的凭证(票据才是)。
392
- *
393
- * 保留这个方法只因为 `updateRegistration` 那条路还在用它更新本地值。
391
+ * **不发往中转**:`registrationPayload()` 里没有这个字段,中转也不读它,它只留作本进程
392
+ * 内的值(面板显示)。
394
393
  */
395
394
  updatePairingCode(code) {
396
395
  this.pairingCode = code;
@@ -399,8 +398,8 @@ class RelayTunnel {
399
398
  /**
400
399
  * 注册帧的载荷。连接建立时发一次,之后元信息变了再由 updateRegistration 重发。
401
400
  *
402
- * **不带配对码**(§5.2):中转从来没读过它,而带上它就等于把「谁能配这台机器」的
403
- * 一半凭证交给链路上的第三方。删掉零成本。
401
+ * **不带配对码**:中转不读它,带上它等于把「谁能配这台机器」的一半凭证交给链路上的
402
+ * 第三方。
404
403
  */
405
404
  registrationPayload() {
406
405
  return {
@@ -414,12 +413,12 @@ class RelayTunnel {
414
413
  }
415
414
 
416
415
  /**
417
- * 在**现有连接上**重报一次注册信息(配对码 / 名字 / 局域网候选),不断连接。
416
+ * 在**现有连接上**重报一次注册信息(名字 / 局域网候选),不断连接。
418
417
  *
419
418
  * 中转那边同一个实例重复注册是幂等的(不会把自己顶掉),所以这条只是「更新名片」。
420
- * 以前这类变化是把整条隧道拆了重建,代价是手机所有在途请求一起断——真机现场就是
421
- * 「切个网络,接下来几秒点什么都失败」。真正决定「连到哪台」的字段(实例、中转
422
- * 地址、注册密钥)不在这里更新,它们变了必须重建,由 worker 的 configure 判定。
419
+ * 拆隧道重建的代价是手机所有在途请求一起断——真机现场就是「切个网络,接下来几秒点
420
+ * 什么都失败」。真正决定「连到哪台」的字段(实例、中转地址、注册密钥)不在这里更新,
421
+ * 它们变了必须重建,由 worker 的 configure 判定。
423
422
  */
424
423
  updateRegistration(patch = {}) {
425
424
  if (typeof patch.pairingCode === 'string' && patch.pairingCode) this.pairingCode = patch.pairingCode;
@@ -430,8 +429,8 @@ class RelayTunnel {
430
429
  }
431
430
 
432
431
  /**
433
- * 回传桌面端弹窗授权结果:approved + 桌面签发的移动 token(拒绝时无 token)。
434
- * `hostPlatform` 是**宿主自己的** `process.platform`,经中转原样回给手机,
432
+ * 回传配对授权结果:approved + 宿主签发的移动 token(拒绝时无 token)。
433
+ * `hostPlatform` 是**宿主自己的** `process.platform`,随授权结果一起回给手机,
435
434
  * 让配对那一刻就把「这台是什么系统」落盘(设备行不等 status 轮询)。
436
435
  */
437
436
  sendPairConfirm(requestId, approved, token, hostPlatform) {
@@ -467,6 +466,9 @@ class RelayTunnel {
467
466
 
468
467
  /** 建立连接并注册;resolve 于收到 registered,reject 于连接/注册失败 */
469
468
  start() {
469
+ // 每条新连接都从「对端存镜像」重新判定:能走到推送的那一步一定已经收到过回执,
470
+ // 而回执缺这个字段的老中转本来就该按「存」处理。
471
+ this.relayMirror = true;
470
472
  return new Promise((resolve, reject) => {
471
473
  let settled = false;
472
474
  const done = (fn, arg) => {
@@ -487,6 +489,9 @@ class RelayTunnel {
487
489
 
488
490
  const push = createFrameDecoder((frame) => {
489
491
  if (frame.type === T.registered) {
492
+ // 回执里带 `mirror`:对端说不存镜像,就只推摘要(凭据 + 两个 revision),正文不推。
493
+ // 老中转回的是空载荷,`parseJson` 给空对象,于是判定为「存」——与以前完全一样。
494
+ this.relayMirror = parseJson(frame.payload).mirror !== false;
490
495
  clearTimeout(timer);
491
496
  done(resolve);
492
497
  this.onRegistered();
@@ -584,7 +589,7 @@ class RelayTunnel {
584
589
  return;
585
590
  }
586
591
  if (isSse) this.sseClients.add(id);
587
- // 读取隔离:命中本地快照时也走有界发送队列,避免快照响应在buffer高水位时丢帧。
592
+ // 读取隔离:命中本地快照时也走有界发送队列,避免快照响应在缓冲高水位时丢帧。
588
593
  // 例外:带 ?fresh=1 的写后刷新必须回源(判定与实测见 lib/shared/fresh-request.js)。
589
594
  if (this.readSnapshot && !isFreshRequest(msg.path) && (msg.method === 'GET' || msg.method === undefined)) {
590
595
  const body = this.readSnapshot({ method: msg.method, path: msg.path, headers: msg.headers });
@@ -3,9 +3,11 @@
3
3
  /**
4
4
  * 试连中转:拿**新地址**真注册一次,注册上了才算这个地址能用,随后立刻断开。
5
5
  *
6
- * 手机和电脑面板上改中转地址,最坏的结果是这台电脑从网上消失 —— 而改地址的入口本身就挂在
7
- * 那条隧道上。先落盘再重建等于让用户在一次手滑之后自己走回电脑前,所以改之前必须先验证:
8
- * 这个动作就是那个验证。
6
+ * 手机端改中转地址走两阶段(`/api/mobile/v1/relay/config/save` 的 `stage:"prepare"` →
7
+ * `commit`),最坏的结果是这台电脑从网上消失 —— 而改地址的入口本身就挂在
8
+ * 那条隧道上。先落盘再重建等于让用户在一次手滑之后自己走回电脑前,所以落盘之前必须先
9
+ * 验证:这个动作就是那个验证。控制面 `/api/relay/config` 是用户在电脑上直接提交,
10
+ * 走的是「落盘 + 等下发」,不经过这里。
9
11
  *
10
12
  * **临时实例 id**:中转按实例 id 认人,拿正式 id 去试连会把正在服务的那条顶掉(真机上表现
11
13
  * 为「点了一下验证,手机立刻掉线」)。所以用正式 id 加 `~probe~` 加随机段,连完马上断,
@@ -202,7 +202,7 @@ function panelUrlOf(req) {
202
202
  }
203
203
 
204
204
  /**
205
- * @param {object} deps 见 index.js 里 registerControlPlaneRoutes({...}) 的调用点
205
+ * @param {object} deps 见 `lib/impl.js` 里 registerControlPlaneRoutes({...}) 的调用点
206
206
  */
207
207
  function registerControlPlaneRoutes(deps) {
208
208
  const {
@@ -108,8 +108,7 @@ function registerMobileRoutes(deps) {
108
108
  // 地址体检:手机在**配对之前**就要问「这个地址上到底是谁」,那时它手上没有任何
109
109
  // 凭据,所以这条刻意不包 guarded(豁免与理由见 test/mobile-routes.test.cjs 的
110
110
  // PUBLIC_ROUTES)。它能说的话只有服务名与版本——没有实例信息、没有任何能力面。
111
- // 中转侧在 server.js 的同名路径上回 'dsh-mobile-relay',App 靠这个字段分辨
112
- // 「填的是宿主还是中转」。
111
+ // 中转侧在同一路径上回 'dsh-mobile-relay',App 靠这个字段分辨「填的是宿主还是中转」。
113
112
  //
114
113
  // ⚠️ 签名必须是 `(req, res)` **两个**参数:宿主 webServer 就是 `handler(req, res)` 调的,
115
114
  // 上面那条 `guarded(...)` 之所以能收 `(ctx, req, res)`,是因为那三个参数由 guarded 自己补。
@@ -154,18 +153,25 @@ function registerMobileRoutes(deps) {
154
153
  // 「哪台设备还能用」是宿主的判断。
155
154
  //
156
155
  // 旧实现回一句 `{ok:true}` 什么都不做:手机点了「删除设备」、本地删干净了,宿主那份
157
- // 凭据还活着,那枚 token 在到期之前仍然能用。这里按**这次请求出示的那一枚**吊销,
158
- // 不是按 deviceId —— 手机手上只有自己的凭据,让它报 deviceId 等于允许吊销别人。
156
+ // 凭据还活着,那枚 token 在到期之前仍然能用。现在按**出示的这枚凭据属于哪台**整台吊销。
157
+ //
158
+ // 为什么不按请求体里的 deviceId:手机手上只有自己的凭据,让它报 deviceId 等于允许吊销
159
+ // 别人;deviceId 只从库里那条记录取,报别人的号根本没有入口。为什么不只吊销出示的那一枚
160
+ // (2026-10-28 改):同一台手机重配、升级之后名下有好几条活凭据,旧的那条仍然能用,于是
161
+ // 「在手机上删设备」只把面板那一行的条数减一、行还在,用户以为删干净了其实没有。
159
162
  route('/api/mobile/v1/device-credential/revoke', async (_ctx, req, res) => {
160
163
  const presented = req.headers['x-dsh-device-credential']
161
164
  ? String(req.headers['x-dsh-device-credential'])
162
165
  : (/^Bearer\s+([^\s]+)$/.exec(req.headers.authorization || '') || [])[1]
163
166
  const store = credentialsEntry()
164
167
  const instanceId = String(state.instanceId || '')
165
- const revoked = presented ? store.revoke(presented, instanceId) : false
168
+ // 查不到记录(升级前那种纯 HMAC 通行证)就什么都不吊销:它没有记录、也就没有 deviceId,
169
+ // 那种只能靠控制面的「吊销全部」轮换 lanSecret 收掉。
170
+ const record = presented ? store.lookup(presented, instanceId) : null
171
+ const count = record ? store.revokeDevice(instanceId, record.deviceId) : 0
166
172
  // 尽力而为的语义不变:手机那边失败也不阻断本地删除,所以这里始终回 ok,
167
- // 但把「到底吊销了一张没有」如实带上 —— 排查「删了还能用」时这一格是唯一线索。
168
- sendJson(res, 200, { ok: true, revoked })
173
+ // 但把「到底吊销了几条」如实带上 —— 排查「删了还能用」时这一格是唯一线索。
174
+ sendJson(res, 200, { ok: true, revoked: count > 0, count })
169
175
  })
170
176
 
171
177
  // 旧会话升级成 T1(§3.3):新 App 每次启动幂等调一次。判据、密钥归属与「为什么密钥
@@ -220,11 +226,10 @@ function registerMobileRoutes(deps) {
220
226
  route('/api/mobile/v1/session', sessions.handleSession)
221
227
  route('/api/mobile/v1/session/messages', sessions.handleMessages)
222
228
  route('/api/mobile/v1/session/attachment', sessions.handleSessionAttachment)
223
- // 交付文件卡要的字节:只放行本会话 present 声明过的路径(见 mobile/session-file.js)。
229
+ // 会话文件:允许本会话 present 声明的路径及会话工作区内的文件(见 mobile/session-file.js)。
224
230
  route('/api/mobile/v1/session/file', sessionFile.handleSessionFile)
225
- // 右栏文件浏览器「打开文件」:按**绝对路径**取字节,范围是宿主已登记的工作区 ∪ 当前会话
226
- // 的项目目录(见 mobile/file-read.js 与 mobile/allowed-zone.js)。与上一条并存:那条服务
227
- // 交付(卡片、正文链接),放行面跟着一次交付走;这条跟着「我登记过的目录」走。
231
+ // 文件浏览器按绝对路径取字节,范围是宿主已登记的工作区及当前会话的项目目录
232
+ //(见 mobile/file-read.js 与 mobile/allowed-zone.js)。
228
233
  route('/api/mobile/v1/file/read', fileRead.handleFileRead)
229
234
  route('/api/mobile/v1/session/send', sessions.handleSend)
230
235
  // 「这一发到底进没进」:App 的离线发件箱在重发之前先问一句(按 clientMessageId 回查
@@ -284,9 +289,6 @@ function registerMobileRoutes(deps) {
284
289
  route('/api/mobile/v1/workspace/delete', workspaces.handleDeleteWorkspace)
285
290
  route('/api/mobile/v1/workspace/move', workspaces.handleMoveWorkspace)
286
291
  route('/api/mobile/v1/workspace/move-session', workspaces.handleMoveSession)
287
- // App 的 `renameWorkspace()` / `archiveSession()` 早就在打这两个地址
288
- // (`app/src/services/apiPaths.ts` 的 workspaceRename / workspaceArchiveSession),
289
- // 但插件从来没有注册 → 移动端「重命名工作区」和「归档会话」必然 404。
290
292
  route('/api/mobile/v1/workspace/rename', workspaces.handleRenameWorkspace)
291
293
  route('/api/mobile/v1/workspace/archive-session', workspaces.handleArchiveSession)
292
294
  // 会话级开关的反向动作:取消归档 / 置顶 / 取消置顶。桌面端这三件事都有入口
@@ -24,7 +24,11 @@
24
24
  * 认一台手机靠的是它自带的 `deviceId`(`X-Dsh-Device` 那条),不是这几格里的任何一样。
25
25
  */
26
26
 
27
- /** 手机自报身份用的五个头(App 侧在 `app/src/services/viewmodels/authHeaders.ts` 里写)。 */
27
+ /**
28
+ * 手机自报身份用的头名(App 侧在 `app/src/services/viewmodels/authHeaders.ts` 里写)。
29
+ *
30
+ * 新版 App 只发机型 / 平台 / 系统版本 / App 版本四个;名字那一格留给还带它的老版本。
31
+ */
28
32
  const DEVICE_NAME_HEADER = 'x-dsh-device-name';
29
33
  const DEVICE_MODEL_HEADER = 'x-dsh-device-model';
30
34
  const PLATFORM_HEADER = 'x-dsh-platform';
@@ -4,8 +4,8 @@
4
4
  * 插件侧 LAN/mobile token(HMAC-SHA256 base64url)共享实现。
5
5
  *
6
6
  * token 格式:<instanceId>.<exp>.<hmac-sha256(secret, "<instanceId>.<exp>") base64url>
7
- * 其中 exp 为秒级过期时间戳。signLanToken/verifyLanToken 显式收 ttlSec / secret 参数,
8
- * 由调用方显式传入各自历史缺省值(本函数不隐藏调用点差异)。
7
+ * 其中 exp 为秒级过期时间戳。signLanToken 收 ttlSec/nowSec,verifyLanToken 收
8
+ * secret/token/instanceId;有效期由调用方按用途传入,本函数不隐藏调用点差异。
9
9
  */
10
10
 
11
11
  const crypto = require('node:crypto');
@@ -24,8 +24,8 @@ function hmacPayload(secret, payload) {
24
24
 
25
25
  /**
26
26
  * 签发 LAN/mobile token:<instanceId>.<exp>.<sig>。
27
- * 缺省 ttlSec=30d,但消费方必须显式传原值(LAN 主进程 3600、30d 处 30*24*3600),本函数不
28
- * 隐藏调用点的默认值差异。
27
+ * 缺省 ttlSec=30d;调用方按用途传值(`relay/core.js` 的 signLanToken 缺省 30 天,
28
+ * worker 签发 Bearer token 时传 365 天),本函数不隐藏调用点的默认值差异。
29
29
  * @param {string} secret lanSecret(relay 配置里的密钥)
30
30
  * @param {string} instanceId 实例 id(relay 配置)
31
31
  * @param {number} ttlSec 有效期(秒),缺省 30*24*3600
@@ -281,11 +281,10 @@ function unwrapper(wire) {
281
281
  /**
282
282
  * 读原始请求体(信封要求「按线上原样」算摘要,所以不能先 JSON.parse)。
283
283
  *
284
- * 上限按 §8.3(9) 给的数:body 上限按 4/3 放大,判定仍按明文。这里取 8MB,够文件分块那
285
- * 一档的最大片;超了就当 413,别让一个畸形请求把内存吃光。
284
+ * 上限按 §8.3(9) 给的数:body 上限按 4/3 放大,判定仍按明文。最大的明文是上传片
285
+ * (11MB 那一档),信封再加 4/3;明文侧的判定在各自路由里(`readJsonBody(req, ...)`),
286
+ * 两边不共用同一个数。超了就当 413,别让一个畸形请求把内存吃光。
286
287
  */
287
- // 线上那一侧的上限:最大的明文是上传片(11MB 那一档),信封再加 4/3。
288
- // 明文侧的判定在各自路由里(`readJsonBody(req, ...)`),两边不共用同一个数。
289
288
  const MAX_SEALED_BODY = Math.ceil(11 * 1024 * 1024 * 4 / 3);
290
289
 
291
290
  function readRawBody(req, maxBytes = MAX_SEALED_BODY) {
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 链路加密(协议 v2)的字节级原语。App 侧有一份逐字节等价实现(`services/e2ee.ts`),两边的
4
+ * 链路加密(协议 v2)的字节级原语。App 侧有一份逐字节等价实现
5
+ * (`app/src/services/viewmodels/e2ee.ts`),两边的
5
6
  * 正确性靠同一份测试向量保证(`test/e2ee-vectors.json`),不靠互相阅读代码。
6
7
  *
7
8
  * 这一层只做「算字节」,不认识路由与业务:信封放在哪里、什么时候加解密由路由层负责。
@@ -6,8 +6,7 @@
6
6
  // 会话列表缓存。轮询撞上它们无所谓,写后第一次刷新撞上就会把刚做完的事显示成没生效。
7
7
  //
8
8
  // 所以约定 `?fresh=1`:带这个标记的读跳过所有代读副本、直接回源;轮询 / 首屏 / 下拉刷新不带。
9
- // 权威实现在本文件,`dsh-mobile-server/lib/fresh-request.js` 是手工同步副本,两边必须一起改;
10
- // 中转也必须认这个标记,否则 App 就失去了唯一的自救手段。
9
+ // 权威实现在本文件;中转(Rust relay)也认这个标记,否则 App 就失去了唯一的自救手段。
11
10
 
12
11
  const FRESH_QUERY_RE = /(?:^|[?&])fresh=1(?:&|$)/
13
12
 
@@ -69,7 +69,8 @@ function sendJson(res, status, body) {
69
69
  // **协议 v2 的响应不在这里压**:守卫是在 `res.end` 上把 JSON 解出来再封成信封的,
70
70
  // 先压成 gzip 的话那一层只能看到二进制,`JSON.parse` 失败就退化成「原样写出、没封」——
71
71
  // 结果是最大的那批响应(列表 / 消息 / 统计)反而不加密。压缩改由信封写出去之前做,
72
- // 那一层拿到的仍是 JSON 文本(见 `lib/http/routing.js` 的 `res.end` 钩子)。
72
+ // 那一层拿到的仍是 JSON 文本(见 `lib/http/routing.js` 的 `res.end` 钩子与
73
+ // `shared/e2ee-request.js` 的 `sealResponse`)。
73
74
  const sealed = !!(res && res.req && res.req.__dshE2ee);
74
75
  const encoded = encodeJsonBody(text == null ? '' : text, sealed ? undefined : acceptEncoding);
75
76
  if (encoded.gzip) headers['content-encoding'] = 'gzip';
@@ -13,7 +13,7 @@
13
13
  * 控制帧没有 seq 但绝不能丢。设备级流的帧全是幂等信号,丢掉后由调用方断流重连并补发。
14
14
  * - 写不动时:会话流 await 一次 drain(20s 超时收尾);设备级流直接收尾让客户端重连,重连
15
15
  * 时会补发当前状态,所以断开是它的正常收敛路径。
16
- * - 填充:会话流每条补 2048 字节注释(Android RN 的 XHR 会攒小 chunk,不补迟迟不触发
16
+ * - 填充:会话流按 2048 字节的窗口补注释(Android RN 的 XHR 会攒小 chunk,不补迟迟不触发
17
17
  * onprogress);设备级流帧少且小,不补。
18
18
  */
19
19
 
@@ -205,7 +205,7 @@ function evictionRoots(generation) {
205
205
  * 于是「同一路径下的代码被换掉了」时,新入口会拿到旧子模块 —— 实测:改盘上的子模块、
206
206
  * 再 `import(entry?v=…)`,拿到的仍是旧值;先清缓存才拿到新值。
207
207
  *
208
- * 这个坑只在**同一个路径被第二次加载**时出现。正常 OTA 往 `lib/.live/<新 id>/` 里装,
208
+ * 这个坑只在**同一个路径被第二次加载**时出现。正常 OTA 落到 `<profile>/.dsh-mobile-ota/<id>/`,
209
209
  * 路径是新的,踩不到;但基础代(包内 `lib/impl.js`)路径固定 —— 只要 profile 里的插件包
210
210
  * 在运行期间被 pnpm 换过版本,换代回基础代就会拿到上一版实现:新路由没注册、
211
211
  * `appVersion` 报旧版本号,而状态里却写着新版本。
@@ -208,7 +208,8 @@ export async function operation(base, operationId) {
208
208
  const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'rolled-back'])
209
209
 
210
210
  /**
211
- * 一套完整的「装新版」:问市场、请它装、盯到终态。
211
+ * 装新版:盘上那一代带自装模块(`update-install.cjs`)就在 profile 里自己装,否则退回市场
212
+ * 那条路 —— 问市场、请它装、盯到终态。
212
213
  *
213
214
  * **不重启 DSH**:市场报的 `restartRequired` 只往上传,装完由外壳换代解决(见 index.js)。
214
215
  * 装完了但盘上版本没变(市场报成功、文件没落),这里如实返回 `installed: false`,不让调用方
@@ -6,7 +6,7 @@
6
6
  * 宿主不自己开 WebSocket、也不自己监听局域网端口,而是 fork 出这个 worker,用环回 HTTP +
7
7
  * Bearer 令牌对话。隧道崩了、原生模块加载失败都只死一个子进程,宿主仍能如实回报状态。
8
8
  *
9
- * 三条不能忘的语义:
9
+ * 四条不能忘的语义:
10
10
  * 1. 状态既要在 `/status` 上可查,也要在变化那一刻主动 `process.send` 给宿主:健康轮询是
11
11
  * 10s 一次,只靠轮询的话隧道真断后宿主最多 10s 仍以为连着;「断又连」落在两次轮询之间时
12
12
  * 那次全量补推也会漏掉。
@@ -41,6 +41,9 @@ let tunnel = null
41
41
  let lan = null
42
42
  let tunnelState = 'off'
43
43
  let tunnelError = null
44
+ // 当前这条隧道的对端存不存镜像(注册回执里报的)。false ⇒ 宿主只推摘要帧,正文不推。
45
+ // 未知与老中转一律按 true:那是原来的行为,别让一次漏字段把镜像功能悄悄降级。
46
+ let relayMirror = true
44
47
  let lanState = 'off'
45
48
  let lanError = null
46
49
  // 局域网代理**实际**在监听的端口。0 = 还没起来。首选端口被占时会退到内核分配的
@@ -62,7 +65,7 @@ function lanUrlPort() { return lanBoundPort || config.lanPort }
62
65
  function publishState() {
63
66
  process.send?.({
64
67
  type: 'state',
65
- tunnel: { state: tunnelState, error: tunnelError },
68
+ tunnel: { state: tunnelState, error: tunnelError, mirror: relayMirror },
66
69
  lan: { enabled: Boolean(config.lanEnabled), port: lanUrlPort(), requestedPort: config.lanPort, state: lanState, error: lanError },
67
70
  })
68
71
  }
@@ -122,11 +125,40 @@ const TUNNEL_SEND_MAX_BODY = 8 * 1024 * 1024 - 64 * 1024
122
125
  * 裁字段而不是改推送策略:宿主那侧「推什么、什么时候推」一个字都不用改,两侧版本
123
126
  * 不必同时上线(老 worker + 新宿主也照旧工作,只是白推;新 worker + 老宿主也照旧)。
124
127
  */
125
- function frameForTunnel(body) {
128
+ /**
129
+ * 对端不存镜像时上隧道的那一帧:只留中转还会真的读的东西。
130
+ *
131
+ * 纯转发节点(`MIRROR_CACHE=0`)的 `mirror::remember` 只留凭据摘要与时间戳,两份清单正文
132
+ * 解析完就丢——那是每台宿主每 5 秒几十 KB、一天上百 GB 的白推。所以对端在注册回执里说
133
+ * `mirror:false` 之后,清单与工作区各只留 `ok` 和 `revision`:
134
+ *
135
+ * - `ok` 必须留着,中转用它判「这份镜像是不是畸形」;
136
+ * - `revision` 必须留着,它是中转镜像账本里那一版的身份;
137
+ * - `creds` 必须留着,`/push-token` 的鉴权真源就是这本账里的摘要,丢了它上报全 403。
138
+ *
139
+ * 本地快照不受影响:`localSnapshot` 在这一帧被裁之前就已经赋值,局域网直连的读取隔离
140
+ * 读的是它(见 `/tunnel/send` 那一段)。
141
+ */
142
+ function digestFrame(body) {
143
+ const pick = (value) => {
144
+ const out = { ok: true }
145
+ if (value && typeof value === 'object' && typeof value.revision === 'string') out.revision = value.revision
146
+ return out
147
+ }
148
+ return {
149
+ kind: 'sync',
150
+ at: body.at,
151
+ sessions: pick(body.sessions),
152
+ workspaces: pick(body.workspaces),
153
+ creds: Array.isArray(body.creds) ? body.creds : [],
154
+ }
155
+ }
156
+
157
+ function frameForTunnel(body, mirrorOff = false) {
126
158
  if (!body || typeof body !== 'object') return { kind: 'raw', data: body }
127
159
  if (body.kind !== 'sync') return body
128
160
  const { messages, stats, models, ...rest } = body
129
- return rest
161
+ return mirrorOff ? digestFrame(rest) : rest
130
162
  }
131
163
  // /configure 只送 relay.json 那几十个字段,给 64KB 绰绰有余;放宽只会让误用更难发现。
132
164
  const CONFIGURE_MAX_BODY = 64 * 1024
@@ -255,6 +287,8 @@ function stopTunnel() {
255
287
  reconnect.cancel()
256
288
  if (tunnel) tunnel.stop()
257
289
  tunnel = null; tunnelState = 'off'; tunnelError = null
290
+ // 断开即忘掉上一台中转的口径:下次连的可能是另一台,别拿旧的判断去决定推不推正文。
291
+ relayMirror = true
258
292
  publishState()
259
293
  }
260
294
  function startNetworkMonitor() {
@@ -297,8 +331,10 @@ function openTunnel() {
297
331
  // 「bg queue: sidecar facade request failed: 503」刷屏,而隧道其实在正常转发会话)。
298
332
  onRegistered: () => {
299
333
  // 注册成功 = 链路真的通了:连续失败账清零,下次掉线重新从 5s 起算。
334
+ // 回执里的镜像口径(`relayMirror`)跟着状态一起改、一起播,同样只在这道身份闸之内:
335
+ // 旧实例迟到的回执不许改新连接的口径。
300
336
  if (tunnel !== current) return; reconnect.reset()
301
- tunnelState = 'connected'; tunnelError = null; publishState()
337
+ tunnelState = 'connected'; tunnelError = null; relayMirror = current.relayMirror !== false; publishState()
302
338
  },
303
339
  onClose: () => { if (tunnel === current && config.enabled) { tunnelState = 'error'; tunnelError = '连接断开'; publishState(); scheduleReconnect() } },
304
340
  onPairRequest: async (message) => {
@@ -444,7 +480,7 @@ function status() {
444
480
  // 量不到的一律报 null、不拿 0 顶替:0 是「一条都没有」,与「不知道」是两件事。
445
481
  const lanStreams = lan && typeof lan.stats === 'function' ? lan.stats() : null
446
482
  const relaySseOpen = tunnel ? (typeof tunnel.upstreamSseCount === 'number' ? tunnel.upstreamSseCount : null) : 0
447
- return { ok: true, sidecar: true, enabled: Boolean(config.enabled), relayUrl: config.relayUrl || '', instanceId: config.instanceId, name: config.name, localPort: config.upstreamPort, tunnel: { state: tunnelState, error: tunnelError }, lan: { enabled: Boolean(config.lanEnabled), port: lanUrlPort(), requestedPort: config.lanPort, state: lanState, error: lanError }, process: { memoryUsage: process.memoryUsage() }, streams: { lan: lanStreams, relaySseOpen } }
483
+ return { ok: true, sidecar: true, enabled: Boolean(config.enabled), relayUrl: config.relayUrl || '', instanceId: config.instanceId, name: config.name, localPort: config.upstreamPort, tunnel: { state: tunnelState, error: tunnelError, mirror: relayMirror }, lan: { enabled: Boolean(config.lanEnabled), port: lanUrlPort(), requestedPort: config.lanPort, state: lanState, error: lanError }, process: { memoryUsage: process.memoryUsage() }, streams: { lan: lanStreams, relaySseOpen } }
448
484
  }
449
485
  const server = http.createServer(async (req, res) => {
450
486
  try {
@@ -470,7 +506,7 @@ const server = http.createServer(async (req, res) => {
470
506
  const frameType = body && typeof body === 'object' && body.kind === 'push'
471
507
  ? (body.pushFrame === 'register' ? T.pushRegister : T.pushSend)
472
508
  : T.sync;
473
- tunnel.sendJson(frameType, 0, frameForTunnel(body));
509
+ tunnel.sendJson(frameType, 0, frameForTunnel(body, relayMirror === false));
474
510
  return send(res, 202, { ok: true });
475
511
  }
476
512
  return send(res, 503, { ok: false, error: 'tunnel not connected' });
@@ -91,7 +91,7 @@ class SidecarFacade {
91
91
  await this.request('/health')
92
92
  }
93
93
 
94
- // `timeoutMs` 可覆盖:隧道侧的 `/revoke-all` 需要给中转 10s 去逐个断开设备,
94
+ // `timeoutMs` 可覆盖:隧道侧的 `/revoke-all` 要等中转逐个断开设备(调用方给 20s),
95
95
  // 而这里旧实现硬编码 5s,慢中转下宿主永远先超时、`/api/relay/revoke-all` 只好
96
96
  // 报 revoked:0(看起来像「撤销成功但一台都没撤」)。
97
97
  request(pathname, method = 'GET', body, timeoutMs = 5000) {
@@ -27,11 +27,11 @@ const HEALTH_POLL_INTERVAL_MS = 10000;
27
27
  * @param {object} deps
28
28
  * @param {import('cordis').Context} deps.ctx
29
29
  * @param {string} [deps.pluginLibDir] 本代实现所在的 `lib/` 目录。默认按本文件位置推;
30
- * 之所以要能注入,是因为实现现在住在代际目录(`lib/.live/<版本>/lib/`)里,
31
- * `require.resolve` 会解析到代自己那一份,而调用方手上的目录才是最准的。
30
+ * 之所以要能注入,是因为实现可能装在 profile 下的版本目录里(或 profile OTA 回退的那份),
31
+ * `require.resolve` 会解析到别处的同名声骸,而调用方手上的目录才是最准的。
32
32
  * @param {() => string} deps.lanPortInUse 取值器:lanPortAdjusted/effectiveLanPort 会被重新赋值
33
33
  * @param {() => Promise<number>} deps.resolveLanPort
34
- * @param {(cfg: object) => Promise<object>} deps.buildConfig 把它组装的配置交给谁下发(index.js 组)
34
+ * @param {(cfg: object) => Promise<object>} deps.buildConfig 把它组装的配置交给谁下发(`lib/impl.js` 组装)
35
35
  */
36
36
  function createSidecarLifecycle(deps) {
37
37
  const { ctx, lanPortInUse, resolveLanPort, buildConfig } = deps;
@@ -7,8 +7,8 @@
7
7
  * profile 里自己跑包管理器。绕开市场的理由很具体:市场没装、太老或坏了,「检查更新」就整个
8
8
  * 答不出来。
9
9
  *
10
- * 边界:这里只查不装,不碰 profile、不写盘;`source`(npm / link / file:)只有市场知道,传
11
- * 进来的是上次问到的结果,本模块不会为它去敲市场。判据是「线上版本严格大于盘上版本」,线上
10
+ * 边界:这里只查不装,不碰 profile、不写盘;`source` 由调用方给(盘上依赖声明或上次问到的
11
+ * 市场结果),本模块只透传,不会为它去敲市场。判据是「线上版本严格大于盘上版本」,线上
12
12
  * 更旧一律报没有新版——「这次更新会降级」必须由版本比较判定。
13
13
  */
14
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "engines": {