dsh-plugin-mobile-link 0.6.0 → 0.6.6

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.
package/README.md CHANGED
@@ -30,6 +30,7 @@ plugins/mobile-link/
30
30
  │ ├── dlp.js # DLP v1 frame codec (shared with the relay)
31
31
  │ ├── files.js # phone → computer file transfers (`_link/file*` methods)
32
32
  │ ├── enroll.js # invite-code redemption, writes ~/.dsh/mobile-link/agent.json
33
+ │ ├── journal.js # lifecycle journal: why a relay link was dropped, after the fact
33
34
  │ ├── routes.js # GET /mobile-link/status, POST /pair-code, GET /qr
34
35
  │ ├── qr.js # dependency-free QR SVG
35
36
  │ └── cli.js # dsh-mobile-link: enroll / mint-pair-code
@@ -95,7 +96,10 @@ dsh plugin --profile web add ./plugins/mobile-link
95
96
  else has to be run by hand.
96
97
 
97
98
  `GET /mobile-link/status` reports whether the link is up, which devices are
98
- attached, and whether this computer is registered yet.
99
+ attached, and whether this computer is registered yet. Its `journal` block carries
100
+ the current process's newest lifecycle events (link opened/closed with the reason,
101
+ device attached/detached); the counters in it reset when the plugin hot-reloads, so
102
+ the full history — across restarts — is the JSONL file at `journal.path`.
99
103
 
100
104
  ## Configuration
101
105
 
@@ -108,6 +112,9 @@ plugin's config block (`cordis.patch.yml`), not by editing this file.
108
112
  | `relayUrl` | from `agent.json` | Relay base URL; a bare deployment dials `<relay>/link/agent` |
109
113
  | `agentId` / `agentSecret` | from `agent.json` | Override the enrolled identity |
110
114
  | `stateFile` | `~/.dsh/mobile-link/agent.json` | Where the identity lives |
115
+ | `journalFile` | `~/.dsh/mobile-link/link-events.jsonl` | Lifecycle journal (rotated once at 256 KiB); `false` keeps it in memory only |
116
+ | `slowRequestMs` | `2000` | A slower single request gets its own `request.slow` entry; `0` disables those (per-attach `device.summary` is still written) |
117
+ | `diagnostics` | `true` | `false` refuses the phone's phase uploads; the phone ignores the refusal and stays connected |
111
118
  | `dshUrl` | discovery | Explicit local DSH base URL (usually unnecessary — the host hands the connector its own address) |
112
119
  | `heartbeatMs` | `20000` | DLP ping interval |
113
120
 
package/cordis.patch.yml CHANGED
@@ -20,6 +20,18 @@
20
20
  # agentId overrides ~/.dsh/mobile-link/agent.json
21
21
  # agentSecret overrides ~/.dsh/mobile-link/agent.json
22
22
  # stateFile agent identity file (default ~/.dsh/mobile-link/agent.json)
23
+ # journalFile lifecycle journal (default ~/.dsh/mobile-link/link-events.jsonl,
24
+ # rotated once at 256 KiB). Every link open/close carries its
25
+ # reason, and every device attach/detach is recorded, so "why
26
+ # did the connector drop the relay link" stays answerable after
27
+ # the fact; the newest entries also ride along in
28
+ # GET /mobile-link/status as `journal`. `false` keeps the ring
29
+ # in memory only, no file.
30
+ # slowRequestMs single-request threshold (default 2000). A request slower than
31
+ # this gets its own `request.slow` entry; 0 disables those entries
32
+ # (the per-attach `device.summary` is still written).
33
+ # diagnostics false refuses the phone's phase uploads (`_link/diagnostics`) with
34
+ # `diagnostics/disabled`; the phone ignores it and stays connected.
23
35
  # dshUrl explicit local DSH base URL override (default: discovery)
24
36
  #
25
37
  # Address resolution order: this config block, then DSH_RELAY_URL (or
package/lib/client.js CHANGED
@@ -11,6 +11,7 @@
11
11
  /** 路由用相对路径:宿主把插件路由挂在同一个源上。 */
12
12
  const STATUS_ROUTE = 'mobile-link/status'
13
13
  const PAIR_ROUTE = 'mobile-link/pair-code'
14
+ const REGENERATE_ROUTE = 'mobile-link/pair-code/regenerate'
14
15
  const SLOT_ENTRY = 'sidebar.footer.action'
15
16
  const SLOT_OVERLAY = 'shell.overlay'
16
17
  const ENTRY_ID = 'mobile-link-entry'
@@ -69,16 +70,39 @@ function report(ctx, message) {
69
70
  function describe(error) {
70
71
  return error instanceof Error ? error.message : String(error)
71
72
  }
72
- /** 样式表:页面上只留一份,退场时随插件 fiber 一起清掉。 */
73
+ /** 样式节点上的"认领者"标记:谁最后认领,谁负责撤。 */
74
+ const STYLE_OWNER_ATTR = 'data-owner'
75
+ /** 认领序号:同一次 enable 一个 token,跨热更两代不会撞。 */
76
+ let styleOwnerSeq = 0
77
+
78
+ /**
79
+ * 样式表:页面上只留一份,**但所有权要认**。
80
+ *
81
+ * 为什么不能"存在就复用、退场就删":热更时两代插件可能同时在世,且顺序可能是
82
+ * "新一代先起来认领同一个 `<style>`、上一代后退场"。上一代若无条件删节点,新面板就
83
+ * 变成没有样式的裸 HTML——2026-10-17 owner 看到配对面板"突然不像原版"就是这个。
84
+ * 所以认领时写下自己的 token,退场时只撤自己认领的那一份。
85
+ */
73
86
  function ensureStyle() {
74
87
  if (typeof document === 'undefined' || !document.head) return null
75
- const existing = document.getElementById(STYLE_ID)
76
- if (existing) return existing
77
- const style = document.createElement('style')
78
- style.id = STYLE_ID
79
- style.textContent = CSS
80
- document.head.appendChild(style)
81
- return style
88
+ const token = `o${++styleOwnerSeq}-${Date.now()}`
89
+ let node = document.getElementById(STYLE_ID)
90
+ if (!node) {
91
+ node = document.createElement('style')
92
+ node.id = STYLE_ID
93
+ node.textContent = CSS
94
+ document.head.appendChild(node)
95
+ }
96
+ node.setAttribute?.(STYLE_OWNER_ATTR, token)
97
+ return { node, token }
98
+ }
99
+
100
+ /** 退场:只有这个节点仍归本次 enable 时才撤掉它。 */
101
+ function releaseStyle(style) {
102
+ const node = style?.node
103
+ if (!node) return
104
+ if (node.getAttribute?.(STYLE_OWNER_ATTR) !== style.token) return
105
+ node.remove?.()
82
106
  }
83
107
  /** 极小的外部状态容器:按钮与小窗是两个独立注册项,需要共享的只有几个字段。 */
84
108
  function createStore(initial) {
@@ -176,41 +200,44 @@ function createPanel() {
176
200
  lastStatusAt: 0,
177
201
  }
178
202
  }
179
- /**
180
- * 这张码是不是已经被兑换掉了:只看"在线手机台数比取码时多了"(问中转要动 relay,spec 010 禁止)。
181
- * 只用来触发换码,不承担旧实现那种"猜这张码还能不能用"的职责。
182
- */
183
- function swapIfSpent(panel) {
184
- const code = panel.store.get().code
185
- if (!code || panel.disposed) return
186
- const count = Number(panel.store.get().status?.deviceCount ?? 0)
187
- if (count > code.mintedDeviceCount) void refreshCode(panel)
188
- }
189
-
190
203
  async function loadStatus(panel) {
191
204
  try {
192
205
  const status = await requestJson(STATUS_ROUTE, { method: 'GET' })
193
206
  if (panel.disposed) return
194
207
  panel.store.set({ status, statusError: null })
195
- swapIfSpent(panel)
196
208
  } catch (error) {
197
209
  if (panel.disposed) return
198
210
  panel.store.set({ statusError: describe(error) })
199
211
  }
200
212
  }
201
- /** 取一张新码。**不复用上一张**:复用正是"扫到已兑换的码"的来源。 */
202
- async function refreshCode(panel) {
213
+ /**
214
+ * 取一张码。
215
+ *
216
+ * 032 起默认是**持久码**:它一直有效,直到用户点「重新生成」。所以这里有两个与旧实现
217
+ * 相反的行为,都是有意的:
218
+ *
219
+ * * **复用已有的那张**——旧实现每次"换一张",因为一次性码用过就废;持久码换掉等于
220
+ * 把用户刚发给别人的那张悄悄作废。
221
+ * * **不再因为"配过一台"就自动换码**——同一张码本来就是要给多台设备用的。
222
+ */
223
+ async function refreshCode(panel, { regenerate = false } = {}) {
203
224
  if (panel.disposed || panel.store.get().busy) return
204
225
  if (needsEnroll(panel.store.get().status)) {
205
226
  panel.store.set({ code: null, codeError: null })
206
227
  return
207
228
  }
229
+ // 只有**持久码**才复用:限时码到期就得重新取一张(老行为),否则屏幕上会留一张死码。
230
+ if (!regenerate && panel.store.get().code?.persistent) return
208
231
  panel.store.set({ busy: true, codeError: null })
209
232
  try {
210
- const body = await requestJson(PAIR_ROUTE, {
233
+ // 两条意图分得很清楚:
234
+ // * `regenerate`:用户点「重新生成」→ 让中转**作废旧码**再铸一张(新端点)。
235
+ // * 其余(首次取码、限时码到期续期)→ 走原来的取码端点。老中转没有 regenerate,
236
+ // 续期这条路必须留在老端点上,否则连着老中转的连接器会在这里失败。
237
+ const body = await requestJson(regenerate ? REGENERATE_ROUTE : PAIR_ROUTE, {
211
238
  method: 'POST',
212
239
  headers: { 'content-type': 'application/json' },
213
- body: JSON.stringify({ ttlMs: CODE_TTL_MS }),
240
+ body: JSON.stringify({}),
214
241
  })
215
242
  if (panel.disposed) return
216
243
  panel.store.set({
@@ -218,9 +245,13 @@ async function refreshCode(panel) {
218
245
  code: body.code,
219
246
  qrPayload: body.qrPayload,
220
247
  svg: body.svg,
221
- expiresAt: Number(body.expiresAt),
222
- ttlMs: Number(body.ttlMs) || CODE_TTL_MS,
223
- mintedDeviceCount: Number(panel.store.get().status?.deviceCount ?? 0),
248
+ // 持久码没有 expiresAt:界面上不再有倒计时(见 codeSection)。
249
+ expiresAt: body.expiresAt == null ? null : Number(body.expiresAt),
250
+ ttlMs: Number(body.ttlMs) || null,
251
+ persistent: body.persistent === true || body.expiresAt == null,
252
+ maxPairedDevices: Number(body.maxPairedDevices ?? 0) || null,
253
+ pairedDevices: Number(body.pairedDevices ?? 0),
254
+ revoked: Number(body.revoked ?? 0),
224
255
  },
225
256
  codeError: null,
226
257
  })
@@ -241,11 +272,17 @@ function tick(panel) {
241
272
  panel.lastStatusAt = now
242
273
  void loadStatus(panel)
243
274
  }
275
+ // 持久码不因时间换码(032):只有用户点「重新生成」才换。限时码(老中转/老调用方)
276
+ // 仍然按原来的规则在快过期时换一张,免得把一张马上就要失效的码摆在屏幕上。
244
277
  const code = panel.store.get().code
245
- if (code && code.expiresAt - now <= CODE_FLOOR_MS) void refreshCode(panel)
278
+ if (code && !code.persistent && code.expiresAt != null && code.expiresAt - now <= CODE_FLOOR_MS) {
279
+ void refreshCode(panel)
280
+ }
246
281
  }
247
282
  function openPanel(panel) {
248
283
  if (panel.disposed) return
284
+ // 自愈:样式若在某次热更里被带走(见 ensureStyle 的头注),打开面板时补回来。
285
+ if (!panel.style?.node?.isConnected) panel.style = ensureStyle()
249
286
  panel.store.set({ open: true, now: Date.now(), copied: false })
250
287
  panel.lastStatusAt = Date.now()
251
288
  void loadStatus(panel)
@@ -253,7 +290,9 @@ function openPanel(panel) {
253
290
  }
254
291
  function closePanel(panel) {
255
292
  if (panel.disposed) return
256
- panel.store.set({ open: false, code: null, codeError: null, copied: false })
293
+ // 032:**不丢码**。持久码要一直有效,关掉面板再打开必须还是同一张——否则用户刚发给
294
+ // 别人的码就被这次开关悄悄换掉了(旧实现丢码是对的,因为那时码是一次性的)。
295
+ panel.store.set({ open: false, codeError: null, copied: false })
257
296
  }
258
297
  function disposePanel(panel) {
259
298
  panel.disposed = true
@@ -263,7 +302,7 @@ function disposePanel(panel) {
263
302
  panel.idleTimer = null
264
303
  panel.tickTimer = null
265
304
  panel.copiedTimer = null
266
- panel.style?.remove?.()
305
+ releaseStyle(panel.style)
267
306
  panel.style = null
268
307
  }
269
308
  /** 插件启用时建立的页面资源;返回退场时用的清理函数。 */
@@ -295,14 +334,20 @@ function copyCode(panel) {
295
334
  done()
296
335
  }
297
336
  }
298
- /** 控制器:视图只读它的 store、只调它的动作;"码什么时候作废"只在 tick/swapIfSpent 定义。 */
337
+ /**
338
+ * 控制器:视图只读它的 store、只调它的动作。
339
+ *
340
+ * 032 起"码什么时候换"只有一处:用户点「重新生成」(`refreshCode({regenerate:true})`)。
341
+ * 以前还有一条"配过一台就自动换一张"的规则,持久码下那条是错的——同一张码本来就
342
+ * 要配多台设备。
343
+ */
299
344
  function createController() {
300
345
  const panel = createPanel()
301
346
  return {
302
347
  store: panel.store,
303
348
  open: () => openPanel(panel),
304
349
  close: () => closePanel(panel),
305
- refreshCode: () => refreshCode(panel),
350
+ refreshCode: (options) => refreshCode(panel, options),
306
351
  copy: () => copyCode(panel),
307
352
  toggleError: () => panel.store.set({ showError: !panel.store.get().showError }),
308
353
  start: () => startPanel(panel),
@@ -351,22 +396,35 @@ function enrollSection(React, status) {
351
396
  React.createElement('div', { className: 'dsh-mobile-link-mono dsh-mobile-link-v', style: { textAlign: 'left' } },
352
397
  status?.enroll?.command ?? 'dsh-mobile-link enroll --invite <邀请码> --relay <中转地址>'))
353
398
  }
354
- /** 二维码卡:明文码、图案、倒数条,以及换一张与复制。 */
399
+ /**
400
+ * 二维码卡。
401
+ *
402
+ * 032 之前这里是"倒计时 + 换一张"(一次性码,10 分钟)。现在默认是**持久码**:
403
+ * 所以卡片上写"长期有效,直到你点重新生成",并且不再有进度条——一个永远走不到头的
404
+ * 倒计时只会让人以为码快过期了。限时码(老中转/显式请求)仍然显示倒计时与剩余时间。
405
+ */
355
406
  function codeSection(React, controller, ui, state) {
356
407
  const code = state.code
357
408
  const children = []
358
409
  if (code) {
359
- const remaining = Math.max(0, code.expiresAt - state.now)
360
- const percent = Math.max(0, Math.min(100, (remaining / (code.ttlMs || CODE_TTL_MS)) * 100))
361
410
  children.push(React.createElement('div', { className: 'dsh-mobile-link-code', key: 'code' }, code.code))
362
411
  // 二维码:宿主给的 SVG 直接进 data URI,界面不碰图案本身。
363
412
  children.push(React.createElement('img', {
364
413
  key: 'qr', className: 'dsh-mobile-link-qr', alt: '配对二维码',
365
414
  src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(String(code.svg))}`,
366
415
  }))
367
- children.push(React.createElement('div', { className: 'dsh-mobile-link-count', key: 'count' },
368
- React.createElement('div', { className: 'dsh-mobile-link-bar' }, React.createElement('i', { style: { width: `${percent.toFixed(1)}%` } })),
369
- React.createElement('span', null, `有效期至 ${clockText(code.expiresAt)}(还剩 ${remainingText(remaining)})`)))
416
+ if (code.persistent || code.expiresAt == null) {
417
+ children.push(React.createElement('div', { className: 'dsh-mobile-link-count', key: 'count' },
418
+ React.createElement('span', null,
419
+ `长期有效 · 直到你点「重新生成」${code.maxPairedDevices
420
+ ? `(已配对 ${code.pairedDevices ?? 0}/${code.maxPairedDevices})` : ''}`)))
421
+ } else {
422
+ const remaining = Math.max(0, code.expiresAt - state.now)
423
+ const percent = Math.max(0, Math.min(100, (remaining / (code.ttlMs || CODE_TTL_MS)) * 100))
424
+ children.push(React.createElement('div', { className: 'dsh-mobile-link-count', key: 'count' },
425
+ React.createElement('div', { className: 'dsh-mobile-link-bar' }, React.createElement('i', { style: { width: `${percent.toFixed(1)}%` } })),
426
+ React.createElement('span', null, `有效期至 ${clockText(code.expiresAt)}(还剩 ${remainingText(remaining)})`)))
427
+ }
370
428
  } else if (state.codeError) {
371
429
  children.push(React.createElement('div', { className: 'dsh-mobile-link-note dsh-mobile-link-error', key: 'err' }, state.codeError))
372
430
  } else {
@@ -375,8 +433,9 @@ function codeSection(React, controller, ui, state) {
375
433
  }
376
434
  const buttons = [React.createElement(ui.Button, {
377
435
  key: 'swap', variant: 'primary', type: 'button', disabled: Boolean(state.busy),
378
- onClick: () => void controller.refreshCode(),
379
- }, state.busy ? '取码中…' : (state.codeError ? '重试' : '换一张'))]
436
+ onClick: () => void controller.refreshCode({ regenerate: Boolean(state.code) }),
437
+ }, state.busy ? '取码中…'
438
+ : (state.codeError ? '重试' : (state.code ? '重新生成' : '取一张码')))]
380
439
  if (code) {
381
440
  buttons.push(React.createElement(ui.Button, {
382
441
  key: 'copy', variant: 'outline', type: 'button', onClick: () => controller.copy(),
@@ -421,7 +480,8 @@ function panelBody(React, controller, ui, state) {
421
480
  const error = errorSection(React, controller, ui, state.status, state.showError)
422
481
  if (error) children.push(error)
423
482
  children.push(React.createElement('div', { className: 'dsh-mobile-link-note', key: 'hint' },
424
- '用 DSH Mobile App 扫码配对:一张码只能配一台手机,被兑换或剩余不足 1 分钟会自动换新。'
483
+ '用 DSH Mobile App 扫码配对:这串码长期有效,可以配多台设备(手机/浏览器)。'
484
+ + '换掉它点「重新生成」——已经在用的设备不会掉线,只有拿旧码来配的人需要重新扫。'
425
485
  + '相机不方便时,也可以把上面那串配对码手动输进 App 连接页底部的手输入口。'))
426
486
  return React.createElement('div', { className: 'dsh-mobile-link-body' }, children)
427
487
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * 一次接入的计时与有界耗时汇总(spec 020)。
3
+ *
4
+ * 职责(只做这三件,别的都不做):
5
+ * 1. **阶段计时**:一次设备接入走完「接入 → `_link/hello` → 首个数据请求 → 首个响应」各用了多少毫秒;
6
+ * 没发生的阶段必须是 `null` 并在 `missing` 里列出——**不许记 0**(0 与"没发生"在判读时是两回事)。
7
+ * 2. **慢请求**:单次请求超过阈值时产出一条 `request.slow`(方法名、耗时、字节数)。
8
+ * 3. **有界汇总**:一次接入的 `requests / slow / maxMs / p50Ms / p95Ms / top3`,
9
+ * 用**定长水库**估分位,数组长度不随请求数增长(否则"记日志"本身会变成新的性能问题)。
10
+ *
11
+ * 本模块是纯逻辑:它不认识 socket、不认识 journal,事件由上层通过 `onEvent` 注入
12
+ * (宪法原则 VII.1:能力注入,不在内部读全局状态)。
13
+ *
14
+ * 契约:`docs/diagnostics-events.md`
15
+ * 数据模型:`specs/020-link-diagnostics/data-model.md` §3–§5
16
+ */
17
+
18
+ /** 水库样本容量:估 p50/p95 够用,且不随请求数增长。 */
19
+ export const RESERVOIR_SIZE = 256
20
+ /** 每累计这么多请求写一条汇总(加上断开时那条,构成"条数常数级"的上界)。 */
21
+ export const SUMMARY_EVERY = 200
22
+ /** `top3` 保留几条最慢的请求。 */
23
+ export const TOP_SLOWEST = 3
24
+
25
+ /** 阶段名(对外,出现在 `missing` 里)。 */
26
+ export const PHASES = Object.freeze({
27
+ helloMs: 'hello',
28
+ firstRequestMs: 'first-request',
29
+ firstResponseMs: 'first-response',
30
+ })
31
+
32
+ /** 握手方法名(与 `hello.js` 同口径;这里只用来区分"握手"与"数据请求")。 */
33
+ const HELLO_METHOD = '_link/hello'
34
+
35
+ /** 最近秩分位数:样本少的时候比插值更保守,也更容易在单测里核对。 */
36
+ function percentile(sorted, fraction) {
37
+ if (sorted.length === 0) return 0
38
+ const rank = Math.ceil(fraction * sorted.length)
39
+ return sorted[Math.max(0, Math.min(sorted.length - 1, rank - 1))]
40
+ }
41
+
42
+ export class DeviceTimeline {
43
+ /**
44
+ * @param {object} [options]
45
+ * @param {string} [options.deviceId]
46
+ * @param {number} [options.slowRequestMs] 超过它才记 `request.slow`;0 表示关闭该逐条记录
47
+ * @param {() => number} [options.now]
48
+ * @param {(kind: string, detail: Record<string, unknown>) => void} [options.onEvent]
49
+ */
50
+ constructor({ deviceId, slowRequestMs = 2000, now = Date.now, onEvent = () => {} } = {}) {
51
+ this.deviceId = deviceId
52
+ this.slowRequestMs = Number(slowRequestMs) || 0
53
+ this.now = now
54
+ this.onEvent = onEvent
55
+ this.attachedAt = now()
56
+ this.helloAt = undefined
57
+ this.firstRequestAt = undefined
58
+ this.firstResponseAt = undefined
59
+ this.requests = 0
60
+ this.slow = 0
61
+ this.maxMs = 0
62
+ /** 定长水库:满了以后按步长覆盖(确定性的系统抽样,避免引入随机数让单测发抖)。 */
63
+ this._samples = []
64
+ this._sampleCursor = 0
65
+ /** 最慢的若干条,长度恒 ≤ TOP_SLOWEST。 */
66
+ this._top = []
67
+ this._phaseEmitted = false
68
+ }
69
+
70
+ /** 收到 `_link/hello`(只在第一次生效)。 */
71
+ noteHello(at = this.now()) {
72
+ if (this.helloAt === undefined) this.helloAt = at
73
+ }
74
+
75
+ /**
76
+ * 一次请求收尾。
77
+ *
78
+ * @param {{method: string, startedAt: number, ms: number, bytes: number}} request
79
+ */
80
+ noteRequest({ method, startedAt, ms, bytes }) {
81
+ const isHello = method === HELLO_METHOD
82
+ if (isHello) this.noteHello(startedAt)
83
+ if (!isHello && this.firstRequestAt === undefined) this.firstRequestAt = startedAt
84
+ // 只认**数据请求**的响应:握手响应不算。否则 `firstResponseMs` 会小于 `firstRequestMs`
85
+ // (握手先回、数据请求后发),三段的阶梯就读不出来了。
86
+ if (!isHello && this.firstResponseAt === undefined) this.firstResponseAt = startedAt + ms
87
+
88
+ this.requests += 1
89
+ this.maxMs = Math.max(this.maxMs, ms)
90
+ this.#sample(ms)
91
+ this.#keepTop({ method, ms, bytes })
92
+ if (this.slowRequestMs > 0 && ms > this.slowRequestMs) {
93
+ this.slow += 1
94
+ // 字段叫 elapsedMs 而不是 ms:`ms` 是信封上的**时刻**,同名会把时刻冲掉(见 journal.js)。
95
+ this.onEvent('request.slow', { deviceId: this.deviceId, method, elapsedMs: ms, bytes })
96
+ }
97
+ if (this.requests % SUMMARY_EVERY === 0) this.emitSummary()
98
+ this.#maybeEmitPhase()
99
+ }
100
+
101
+ /** 阶段明细(`missing` 里的阶段名,其 `phases.*Ms` 必为 null)。 */
102
+ phaseDetail() {
103
+ const relative = (at) => (at === undefined ? null : at - this.attachedAt)
104
+ const phases = {
105
+ helloMs: relative(this.helloAt),
106
+ firstRequestMs: relative(this.firstRequestAt),
107
+ firstResponseMs: relative(this.firstResponseAt),
108
+ }
109
+ const missing = Object.entries(phases)
110
+ .filter(([, value]) => value === null)
111
+ .map(([key]) => PHASES[key])
112
+ return { deviceId: this.deviceId, phases, missing }
113
+ }
114
+
115
+ /** 汇总明细(分位来自定长水库)。 */
116
+ summaryDetail() {
117
+ const sorted = [...this._samples].sort((a, b) => a - b)
118
+ return {
119
+ deviceId: this.deviceId,
120
+ requests: this.requests,
121
+ slow: this.slow,
122
+ maxMs: this.maxMs,
123
+ p50Ms: percentile(sorted, 0.5),
124
+ p95Ms: percentile(sorted, 0.95),
125
+ top3: this._top.map((entry) => ({ ...entry })),
126
+ }
127
+ }
128
+
129
+ /** 断开时调用:补齐阶段事件(若还没写过)并在有请求时才写汇总。 */
130
+ finish() {
131
+ if (!this._phaseEmitted) {
132
+ this._phaseEmitted = true
133
+ this.onEvent('device.phase', this.phaseDetail())
134
+ }
135
+ if (this.requests > 0) this.emitSummary()
136
+ }
137
+
138
+ /** 写一条汇总(供断开与每 `SUMMARY_EVERY` 个请求两个时机调用)。 */
139
+ emitSummary() {
140
+ this.onEvent('device.summary', this.summaryDetail())
141
+ }
142
+
143
+ #sample(ms) {
144
+ if (this._samples.length < RESERVOIR_SIZE) {
145
+ this._samples.push(ms)
146
+ return
147
+ }
148
+ const index = this._sampleCursor % RESERVOIR_SIZE
149
+ this._samples[index] = ms
150
+ this._sampleCursor += 1
151
+ }
152
+
153
+ #keepTop(entry) {
154
+ this._top.push(entry)
155
+ this._top.sort((a, b) => b.ms - a.ms)
156
+ if (this._top.length > TOP_SLOWEST) this._top.length = TOP_SLOWEST
157
+ }
158
+
159
+ #maybeEmitPhase() {
160
+ if (this._phaseEmitted) return
161
+ // 三段**全齐**才写;缺哪一段就等 `finish()` 时按 missing 写(读者要知道"没发生",而不是看到 0)。
162
+ //
163
+ // 为什么要求全齐而不是"有数据请求就写":App 的真实顺序是**先探针(session/list)再握手**
164
+ // (`ConnectionStore.connect()` 先 `sessions()`,再在 `install()` 里发 `_link/hello`)。
165
+ // 按旧条件,阶段事件会在探针那一刻写出来、`missing: ['hello']`,于是最有用的
166
+ // 「attach→hello 用了多久」在事件里永远是空的——2026-10-13 09:28 的中转运行实测到了这一条。
167
+ if (this.helloAt === undefined) return
168
+ if (this.firstRequestAt === undefined || this.firstResponseAt === undefined) return
169
+ this._phaseEmitted = true
170
+ this.onEvent('device.phase', this.phaseDetail())
171
+ }
172
+ }
package/lib/dlp.js CHANGED
@@ -10,7 +10,11 @@
10
10
  export const MAX_FRAME_BYTES = 32 * 1024 * 1024
11
11
 
12
12
  export const DEVICE_TO_AGENT = new Set(['req', 'open', 'cancel', 'eventResult', 'ping', 'hello'])
13
- export const AGENT_TO_DEVICE = new Set(['res', 'item', 'end', 'streamError', 'event', 'hostStatus', 'pong', 'error'])
13
+ export const AGENT_TO_DEVICE = new Set(['res', 'item', 'end', 'streamError', 'event', 'hostStatus', 'pong', 'error',
14
+ // 032:账户级状态由**中转自己**发给设备(会话已读水位、提醒开关这类),
15
+ // 连接器既不产生也不转发它,但这张表是契约镜像,缺一项就与
16
+ // `docs/relay-contract.json` 对不上(`npm test` 会红)。
17
+ 'accountState'])
14
18
  /** Relay-internal control frames carrying device lifecycle (see docs/RELAY-NOTES.md). */
15
19
  export const RELAY_CONTROL = new Set(['deviceAttach', 'deviceDetach'])
16
20
  /**
@@ -167,6 +171,11 @@ export function pairCodeEndpoint(relayUrl) {
167
171
  return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'pair/code')
168
172
  }
169
173
 
174
+ /** 重新生成配对码(032):作废旧码、铸一枚新的,不影响已配对设备。 */
175
+ export function pairCodeRegenerateEndpoint(relayUrl) {
176
+ return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'pair/code/regenerate')
177
+ }
178
+
170
179
  /** The relay's self-service enrollment endpoint (`POST /agents/enroll`). */
171
180
  export function enrollEndpoint(relayUrl) {
172
181
  return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'agents/enroll')
package/lib/hello.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * calling something that would come back as a 404 from the Host.
12
12
  */
13
13
 
14
- import { createRequire } from 'node:module'
14
+ import { readFileSync } from 'node:fs'
15
15
 
16
16
  /** Reserved method the app calls once per connection. */
17
17
  export const HELLO_METHOD = '_link/hello'
@@ -22,18 +22,39 @@ export const HELLO_METHOD = '_link/hello'
22
22
  * Read from package.json rather than written out twice: the two drifting apart
23
23
  * is exactly the kind of thing nobody notices until a support question depends
24
24
  * on it (they had drifted: the manifest said 0.1.0 and this said 0.2.0).
25
+ *
26
+ * 用 `readFileSync` 而不是 `require('../package.json')`:`require` 会把 JSON 缓存在
27
+ * CJS 模块表里,而这个文件走热更(模块 URL 带参数重新求值,**缓存表不清**)。实测
28
+ * 2026-10-17:DSH 进程 10-12 起就一直报 `0.6.0`,而仓库 10-13 已经升到 `0.6.5`——
29
+ * 报的还是进程启动那天的版本号。读文件没有这层缓存,热更后一问就是真值。
25
30
  */
26
31
  export const SERVER_VERSION = readManifestVersion()
27
32
 
28
33
  function readManifestVersion() {
29
34
  try {
30
- const require = createRequire(import.meta.url)
31
- return require('../package.json').version ?? '0.0.0'
35
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
36
+ return typeof manifest.version === 'string' && manifest.version ? manifest.version : '0.0.0'
32
37
  } catch {
33
38
  return '0.0.0'
34
39
  }
35
40
  }
36
41
 
42
+ /**
43
+ * 宿主 DSH 的版本,取不到时为 `null`。
44
+ *
45
+ * 为什么读环境变量而不是 package.json:连接器跑在宿主进程里,宿主把自己的版本放在
46
+ * `DSH_CLIENT_VERSION`(桌面版与 `dsh web` 都设);而连接器是从仓库 / npm 链接进档案的,
47
+ * 它自己的 `package.json` 里没有宿主的版本,从 `node_modules` 里猜路径又会随打包形态变
48
+ * (Electron 的 asar 路径 fs 未必读得到)。读不到就如实报 null,不编一个数。
49
+ *
50
+ * 用函数而不是常量:热更会重新求值模块,但环境变量在进程生命周期内可能被测试替换;
51
+ * 每次问都读一次,代价可以忽略。
52
+ */
53
+ export function hostVersion() {
54
+ const value = process.env.DSH_CLIENT_VERSION
55
+ return typeof value === 'string' && value.trim() ? value.trim() : null
56
+ }
57
+
37
58
  /**
38
59
  * Capabilities the app may rely on.
39
60
  *
@@ -62,6 +83,10 @@ export const SERVER_CAPABILITIES = [
62
83
  // session-search: it answers `_link/sessionFind` (in-session literal search). The
63
84
  // phone switches channels on these and falls back to `session/page` without them.
64
85
  'history-window', 'session-search',
86
+ // session-digest: 连接器答 `_link/sessionDigest`(`session/list` 的精简同形副本,
87
+ // 去掉 App 侧零读取点的字段、可按要求 gzip)。手机侧按这条能力切换,缺了就回退到
88
+ // `session/list`——spec 021 只做连接器侧,切换在后续规格。
89
+ 'session-digest',
65
90
  // history-role-filter: `_link/sessionWindow` accepts `roles` (e.g. just the
66
91
  // "me" role) and returns only matching records; seq cursor semantics are
67
92
  // unchanged (spec 009 T031).
@@ -70,6 +95,10 @@ export const SERVER_CAPABILITIES = [
70
95
  // scan actually reached — so a caller whose page was filtered empty can still
71
96
  // advance instead of asking for the same stretch forever (spec 009 T039).
72
97
  'history-scan-boundary',
98
+ // search-progress: 扫描式搜索的每一页都带 `totalChunks` / `remainingChunks`,
99
+ // 界面据此从第一页起就能显示「已扫 k/N 片」(026 之后的 App 才读)。
100
+ 'search-progress',
101
+ 'device-subprotocol-auth', 'persistent-pair-code', 'account-state', 'account-state-push',
73
102
  ]
74
103
 
75
104
  export function isHelloMethod(method) {
@@ -107,6 +136,9 @@ export function parseClientInfo(args) {
107
136
  export function helloPayload({ protocolVersion, agentId, name } = {}) {
108
137
  return {
109
138
  serverVersion: SERVER_VERSION,
139
+ // 宿主 DSH 的版本:手机据此显示「DSH 版本」,中转据此记录这台电脑跑的是哪个宿主。
140
+ // 老客户端不认识这个键,忽略即可;取不到时是 null(如实表示"不知道")。
141
+ hostVersion: hostVersion(),
110
142
  capabilities: SERVER_CAPABILITIES,
111
143
  protocolVersion,
112
144
  agentId: agentId ?? null,