dsh-plugin-mobile-link 0.6.5 → 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/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
  }
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
  *
@@ -74,6 +95,10 @@ export const SERVER_CAPABILITIES = [
74
95
  // scan actually reached — so a caller whose page was filtered empty can still
75
96
  // advance instead of asking for the same stretch forever (spec 009 T039).
76
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',
77
102
  ]
78
103
 
79
104
  export function isHelloMethod(method) {
@@ -111,6 +136,9 @@ export function parseClientInfo(args) {
111
136
  export function helloPayload({ protocolVersion, agentId, name } = {}) {
112
137
  return {
113
138
  serverVersion: SERVER_VERSION,
139
+ // 宿主 DSH 的版本:手机据此显示「DSH 版本」,中转据此记录这台电脑跑的是哪个宿主。
140
+ // 老客户端不认识这个键,忽略即可;取不到时是 null(如实表示"不知道")。
141
+ hostVersion: hostVersion(),
114
142
  capabilities: SERVER_CAPABILITIES,
115
143
  protocolVersion,
116
144
  agentId: agentId ?? null,
package/lib/history.js CHANGED
@@ -46,6 +46,10 @@ export const FIND_METHOD = '_link/sessionFind'
46
46
  /// does not read the field keeps the old cursor rule.
47
47
  export const HISTORY_CAPABILITIES = [
48
48
  'history-window', 'session-search', 'history-role-filter', 'history-scan-boundary',
49
+ // search-progress: a scanned page also carries `totalChunks` / `remainingChunks`,
50
+ // so the reader can be told "已扫 k/N 片" from the first page on. Purely additive:
51
+ // `totalChunks` comes from the log's newest seq, which a scan already fetches.
52
+ 'search-progress',
49
53
  ]
50
54
 
51
55
  export function isHistoryMethod(method) {
@@ -59,7 +63,15 @@ export function isHistoryMethod(method) {
59
63
  export const SEARCH_CHUNK = 2000
60
64
  export const MAX_SEARCH_CHUNKS_PER_CALL = 4
61
65
  export const DEFAULT_SEARCH_LIMIT = 50
62
- export const MIN_QUERY_LENGTH = 2
66
+ /// 查询下限:**1 个字符**(owner 2026-10-17 决定)。
67
+ ///
68
+ /// 原来卡在 2 个字,理由是「一个字几乎命中整段历史、翻不完」。这个代价在 2026-10-17
69
+ /// 变成可接受:搜索有了分页与片数进度(`search-progress`,界面显示「已扫 k/N 片」),
70
+ /// 一页仍只回 50 条、一次调用仍最多扫 4 块(≈1.3 秒),所以下限降到 1。
71
+ ///
72
+ /// 仍然拒绝的是**空关键词且没带 roles**:那不是搜索,是一次没有范围的全表扫描
73
+ /// (「浏览我的提问」必须带 roles,见 `browsing`)。
74
+ export const MIN_QUERY_LENGTH = 1
63
75
 
64
76
  function idOfAddress(address) {
65
77
  if (address === null || typeof address !== 'object') return undefined
@@ -86,6 +98,29 @@ function parseRoles(value) {
86
98
  return { roles }
87
99
  }
88
100
 
101
+ /// 一条文档里所有可读正文:宿主给的 `text`、`data.text`、`data.content[].text`
102
+ /// (blocks 形态,可能**不止一段**)、`data.message.content[].text`,以及工具结果的
103
+ /// `output` / `result` / `stdout` / `stderr`。
104
+ ///
105
+ /// 为什么自己做匹配、不把 `{kind:'text'}` 交给宿主:宿主的文本过滤只看它自己认定的那一段,
106
+ /// 2026-10-17 真机实测(搜"我"/"的"拿不到全部用户输入)说明会漏掉 `content` 里靠后的部分。
107
+ /// 改成"取回这一段全部文档、在这里逐字匹配"之后,命中集合只由我们自己的规则决定。
108
+ function docText(doc) {
109
+ const data = doc?.data ?? {}
110
+ const parts = []
111
+ const push = (value) => {
112
+ if (typeof value === 'string' && value.length > 0) parts.push(value)
113
+ }
114
+ push(doc?.text)
115
+ push(data.text)
116
+ for (const blocks of [data.content, data.message?.content]) {
117
+ if (!Array.isArray(blocks)) continue
118
+ for (const block of blocks) push(block?.text)
119
+ }
120
+ for (const key of ['output', 'result', 'stdout', 'stderr']) push(data[key])
121
+ return parts.join('\n')
122
+ }
123
+
89
124
  /// A hit snippet with the highlight offsets the phone's list already renders.
90
125
  ///
91
126
  /// Offsets, not ranges: the value crosses the wire and a character offset stays
@@ -217,7 +252,8 @@ export function createHistoryService({ logger = console } = {}) {
217
252
  // 这时不按文本过滤,只按 seq 段扫,按角色筛。
218
253
  const browsing = query.length === 0 && roles !== undefined
219
254
  if (!browsing && query.length < MIN_QUERY_LENGTH) {
220
- return failure('link/query-too-short', `query must be at least ${MIN_QUERY_LENGTH} characters`)
255
+ return failure('link/query-too-short',
256
+ `query must be at least ${MIN_QUERY_LENGTH} character (or pass roles to browse)`)
221
257
  }
222
258
  const limit = Number.isSafeInteger(Number(args.limit)) && Number(args.limit) > 0
223
259
  ? Math.min(Number(args.limit), 200) : DEFAULT_SEARCH_LIMIT
@@ -261,15 +297,25 @@ export function createHistoryService({ logger = console } = {}) {
261
297
  logger.info?.(`mobile-link: full-text index unavailable, scanning instead: ${messageOf(error)}`)
262
298
  }
263
299
  }
300
+ const needle = query.toLowerCase()
264
301
  const from = parseSearchCursor(args.cursor)
265
- const ceiling = from !== undefined ? from : await newestSeq(sessionId)
302
+ // `newestSeq` also carries the progress denominator below; it is cached for
303
+ // 5s, and a scan would have fetched it anyway on the first page.
304
+ const newest = await newestSeq(sessionId)
305
+ // 游标是上一页留下的 seq,可能**越过**当前最新(日志被裁剪过、或手机拿着旧游标回来)。
306
+ // 越界时不夹住的话,扫描会从一个不存在的 seq 往下空跑满 4 块,`remainingChunks`
307
+ // 还会算出比 `totalChunks` 更大的数(实测 996/2),界面的"已扫 k/N"随即变成假的。
308
+ const cursorSeq = from !== undefined && newest >= 0 ? Math.min(from, newest) : from
309
+ const ceiling = cursorSeq !== undefined ? cursorSeq : newest
310
+ // 片数(进度的分母):由日志最新 seq 算出,与游标无关,所以第一页就是精确的。
311
+ const total = newest >= 0 ? Math.floor(newest / chunk) + 1 : 0
266
312
  const hits = []
267
313
  let to = ceiling
268
314
  let scanned = 0
269
315
  while (to >= 0 && hits.length < limit && scanned < MAX_SEARCH_CHUNKS_PER_CALL) {
270
316
  const chunkFrom = Math.max(0, to - chunk + 1)
317
+ // 只按 seq 段取文档;正文匹配在下面自己做(见 `docText` 的头注)。
271
318
  const filters = [{ kind: 'seq', from: chunkFrom, to }]
272
- if (!browsing) filters.push({ kind: 'text', text: query })
273
319
  const docs = await withTimeout(
274
320
  sessionQuery.filterEvents(sessionId, filters),
275
321
  HOST_CALL_TIMEOUT_MS,
@@ -278,6 +324,12 @@ export function createHistoryService({ logger = console } = {}) {
278
324
  scanned += 1
279
325
  // Newest first inside the chunk: the reader asked about something recent
280
326
  // far more often than about something old.
327
+ //
328
+ // **游标必须落在"这一页读到哪儿"**,不能一律跳到整块之前:一页在块中途装满时,
329
+ // 块尾还没读过的文档会被永久跳过——2026-10-17 owner 报的"全局搜索不是用户提问的超集"
330
+ // 就是这条(用户提问正好落在片尾时被吞掉;索引路径不受影响)。
331
+ // `resumeFrom` 默认 = 整块之前(读完这块才成立),中途停下时改成"下一条还没读的 seq"。
332
+ let resumeFrom = chunkFrom - 1
281
333
  for (let index = docs.length - 1; index >= 0; index -= 1) {
282
334
  const doc = docs[index]
283
335
  // **已知局限**:这里只能按文档判角色,而宿主的 `filterEvents` 文档不带 `data.source`,
@@ -291,13 +343,20 @@ export function createHistoryService({ logger = console } = {}) {
291
343
  if (roles !== undefined && (role === undefined || !roles.has(role))) continue
292
344
  const display = displayRoleOf(doc)
293
345
  if (display === undefined) continue
346
+ const text = docText(doc)
347
+ if (!browsing && !text.toLowerCase().includes(needle)) continue
294
348
  hits.push({ seq: doc.seq, role: display, ...(query.length > 0
295
- ? makeSnippet(doc.text, query)
296
- : { snippet: String(doc.text ?? '').slice(0, 72).replace(/\s+/g, ' ').trim(),
349
+ ? makeSnippet(text, query)
350
+ : { snippet: String(text).slice(0, 72).replace(/\s+/g, ' ').trim(),
297
351
  highlightStart: 0, highlightLength: 0 }) })
298
- if (hits.length >= limit) break
352
+ if (hits.length >= limit) {
353
+ // 装满了:这一条已经收进本页,下一页从它的**前一条**继续——不能从整块之前
354
+ // (会丢块尾),也不能从这一条本身(会重复)。
355
+ resumeFrom = doc.seq - 1
356
+ break
357
+ }
299
358
  }
300
- to = chunkFrom - 1
359
+ to = resumeFrom
301
360
  }
302
361
  const hasMore = to >= 0 && hits.length >= limit ? true : to >= 0
303
362
  return {
@@ -307,6 +366,12 @@ export function createHistoryService({ logger = console } = {}) {
307
366
  cursor: hasMore ? String(to) : null,
308
367
  hasMore,
309
368
  scannedChunks: scanned,
369
+ // 进度:片数是**扫描单位**(一段 `chunk` 个 seq,从新往旧),不是命中页数。
370
+ // `totalChunks` 由日志最新 seq 算出,所以从第一页起就是精确的;命中的**条数**
371
+ // 仍然要扫到最早那一片才定稿(不开宿主全文索引的必然结果)。
372
+ totalChunks: total,
373
+ // 剩余片数不可能多于总片数:越界游标、被裁剪的日志都不该让进度超过 100%。
374
+ remainingChunks: to >= 0 ? Math.min(Math.floor(to / chunk) + 1, total) : 0,
310
375
  source: 'scan',
311
376
  },
312
377
  }
package/lib/link.js CHANGED
@@ -22,12 +22,12 @@ import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
22
22
  import { handoffFromEndpoint, writeHandoff } from './handoff.js'
23
23
  import { LinkJournal, defaultJournalPath } from './journal.js'
24
24
  import { statusSnapshot } from './status.js'
25
- import { mintPairCode } from './pairing.js'
25
+ import { mintPairCode, regeneratePairCode } from './pairing.js'
26
26
  import {
27
27
  DEFAULT_EVENTS_BACKLOG, DEFAULT_SLOW_REQUEST_MS,
28
28
  EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf,
29
29
  } from './router.js'
30
- import { SERVER_VERSION, SERVER_CAPABILITIES } from './hello.js'
30
+ import { SERVER_VERSION, SERVER_CAPABILITIES, hostVersion } from './hello.js'
31
31
  import { MissingIdentity, resolveIdentity } from './state.js'
32
32
  import { connect as wsConnect } from './ws.js'
33
33
 
@@ -420,6 +420,10 @@ export class MobileLinkAgent extends EventEmitter {
420
420
  agentId: identity.agentId,
421
421
  attempt: this.reconnectAttempts,
422
422
  sinceCloseMs: this._lastClosedAt === undefined ? undefined : this.now() - this._lastClosedAt,
423
+ // 这次连接属于哪套版本:连接器 + 宿主 DSH。一连接一行,事后从本机流水就能
424
+ // 认出"出问题的那天跑的是哪两个版本",不必再去翻安装记录。
425
+ serverVersion: SERVER_VERSION,
426
+ hostVersion: hostVersion(),
423
427
  })
424
428
  this.emit('status')
425
429
 
@@ -606,6 +610,10 @@ export class MobileLinkAgent extends EventEmitter {
606
610
  name: this.identity?.agentName || undefined,
607
611
  dshPort: this.dsh?.endpoint?.port,
608
612
  protocol: PROTOCOL_VERSION,
613
+ // 这台电脑的两段版本。中转据此按 agent 记一行(变化才写),手机据此
614
+ // 在「关于」里显示 DSH 版本——只在控制帧上走一次,不进任何业务流。
615
+ version: SERVER_VERSION,
616
+ hostVersion: hostVersion(),
609
617
  },
610
618
  })
611
619
  }
@@ -653,12 +661,22 @@ export class MobileLinkAgent extends EventEmitter {
653
661
 
654
662
  // ── relay-side helpers ──────────────────────────────────────────────────
655
663
 
656
- /** Ask the relay to mint a pairing code for this agent (relay NOTES.md §2). */
657
- async mintPairCode({ ttlMs = 10 * 60 * 1000, fetchImpl = fetch } = {}) {
664
+ /**
665
+ * Ask the relay to mint a pairing code for this agent (relay NOTES.md §2).
666
+ *
667
+ * 032:默认持久(`ttlMs: null`);传秒数得到限时码。
668
+ */
669
+ async mintPairCode({ ttlMs = null, fetchImpl = fetch } = {}) {
658
670
  const identity = this.identity ?? await resolveIdentity(this.config)
659
671
  return mintPairCode({ identity, ttlMs, fetchImpl })
660
672
  }
661
673
 
674
+ /** 用户点「重新生成」:作废旧码、铸新的,**不碰已配对设备**(032)。 */
675
+ async regeneratePairCode({ fetchImpl = fetch } = {}) {
676
+ const identity = this.identity ?? await resolveIdentity(this.config)
677
+ return regeneratePairCode({ identity, fetchImpl })
678
+ }
679
+
662
680
  /** Snapshot for `GET /mobile-link/status`(载荷本身在 status.js,便于守住"只增不改")。 */
663
681
  status() {
664
682
  return statusSnapshot(this)
package/lib/pairing.js CHANGED
@@ -1,25 +1,34 @@
1
1
  /**
2
- * Pairing helper: ask the relay to mint a one-time code and build the QR
3
- * payload the desktop shows. Spec §2.1 plus docs/RELAY-NOTES.md §2 (the endpoint the
4
- * spec's table is missing).
2
+ * Pairing helper: ask the relay to mint a pairing code and build the QR payload
3
+ * the desktop shows. Spec §2.1 plus docs/RELAY-NOTES.md §2 (the endpoint the spec's
4
+ * table is missing).
5
+ *
6
+ * 032:默认铸**持久码**(不过期,直到用户点「重新生成」)。传 `ttlMs` 仍然得到一张
7
+ * 限时码——那是老行为,留给还用它的调用方与老测试。
5
8
  */
6
9
 
7
- import { pairCodeEndpoint, qrPayload } from './dlp.js'
10
+ import { pairCodeEndpoint, pairCodeRegenerateEndpoint, qrPayload } from './dlp.js'
8
11
 
9
12
  /**
10
13
  * @param {object} options
11
14
  * @param {{agentId: string, agentSecret: string, relayUrl: string}} options.identity
12
- * @param {number} [options.ttlMs] requested lifetime (the relay caps it at 1 hour)
15
+ * @param {number|null} [options.ttlMs] requested lifetime; `null`/省略 = 持久码
13
16
  * @param {typeof fetch} [options.fetchImpl] injectable for tests
14
17
  */
15
- export async function mintPairCode({ identity, ttlMs = 10 * 60 * 1000, fetchImpl = fetch }) {
18
+ export async function mintPairCode({ identity, ttlMs = null, fetchImpl = fetch }) {
19
+ // 032:**持久码那条路不能把 `ttlMs` 放进请求体**。中转的判据是 `"ttlMs" not in body`
20
+ // (`relay/api.py` 的 `pair_code`):带一个 `ttlMs: null` 会被读成"限时、但没给时长",
21
+ // 于是落到中转的默认 10 分钟——面板显示倒计时,码十分钟后就废(用户看到的就是这个)。
22
+ const payload = ttlMs == null
23
+ ? { agentId: identity.agentId }
24
+ : { agentId: identity.agentId, ttlMs }
16
25
  const response = await fetchImpl(pairCodeEndpoint(identity.relayUrl), {
17
26
  method: 'POST',
18
27
  headers: {
19
28
  'content-type': 'application/json',
20
29
  authorization: `Bearer ${identity.agentSecret}`,
21
30
  },
22
- body: JSON.stringify({ agentId: identity.agentId, ttlMs }),
31
+ body: JSON.stringify(payload),
23
32
  })
24
33
  let body
25
34
  try {
@@ -32,8 +41,50 @@ export async function mintPairCode({ identity, ttlMs = 10 * 60 * 1000, fetchImpl
32
41
  }
33
42
  return {
34
43
  code: body.code,
35
- expiresAt: body.expiresAt,
36
- ttlMs: body.ttlMs ?? ttlMs,
44
+ expiresAt: body.expiresAt ?? null,
45
+ ttlMs: body.ttlMs ?? ttlMs ?? null,
46
+ persistent: body.persistent === true || body.expiresAt == null,
47
+ // 「已配对 N/8」以中转的库为准:连接器只知道自己这次见过的设备。
48
+ maxPairedDevices: Number(body.maxPairedDevices ?? 0) || null,
49
+ pairedDevices: Number(body.pairedDevices ?? 0),
50
+ relayUrl: identity.relayUrl,
51
+ agentId: identity.agentId,
52
+ qrPayload: qrPayload({ relay: identity.relayUrl, code: body.code }),
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 用户点「重新生成」:让中转作废旧码、铸一枚新的。
58
+ *
59
+ * **不碰已配对的设备**(032 FR-002):那台手机/浏览器靠自己的 deviceToken 活着,
60
+ * 换码只影响"还没来配的人"。所以这个动作可以放心点,不需要提醒"会把手机踢下线"。
61
+ */
62
+ export async function regeneratePairCode({ identity, fetchImpl = fetch }) {
63
+ const response = await fetchImpl(pairCodeRegenerateEndpoint(identity.relayUrl), {
64
+ method: 'POST',
65
+ headers: {
66
+ 'content-type': 'application/json',
67
+ authorization: `Bearer ${identity.agentSecret}`,
68
+ },
69
+ body: JSON.stringify({ agentId: identity.agentId }),
70
+ })
71
+ let body
72
+ try {
73
+ body = await response.json()
74
+ } catch {
75
+ throw new Error(`relay returned HTTP ${response.status} for /pair/code/regenerate`)
76
+ }
77
+ if (!response.ok || body?.ok !== true) {
78
+ throw new Error(body?.error?.message ?? `relay refused the regeneration (HTTP ${response.status})`)
79
+ }
80
+ return {
81
+ code: body.code,
82
+ expiresAt: null,
83
+ ttlMs: null,
84
+ persistent: true,
85
+ revoked: Number(body.revoked ?? 0),
86
+ maxPairedDevices: Number(body.maxPairedDevices ?? 0) || null,
87
+ pairedDevices: Number(body.pairedDevices ?? 0),
37
88
  relayUrl: identity.relayUrl,
38
89
  agentId: identity.agentId,
39
90
  qrPayload: qrPayload({ relay: identity.relayUrl, code: body.code }),
package/lib/routes.js CHANGED
@@ -94,6 +94,7 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, hist
94
94
  const guard = makeGuard(ctx, logger)
95
95
  const STATUS_ROUTE = '/mobile-link/status'
96
96
  const PAIR_ROUTE = '/mobile-link/pair-code'
97
+ const REGENERATE_ROUTE = '/mobile-link/pair-code/regenerate'
97
98
  const disposers = []
98
99
 
99
100
  disposers.push(ctx.webServer.register({
@@ -131,10 +132,14 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, hist
131
132
  sendJson(res, 400, { ok: false, message: 'invalid JSON body' })
132
133
  return
133
134
  }
134
- const ttlMs = ttlFrom(body.ttlMs, Number(config.pairTtlMs) || 10 * 60 * 1000)
135
+ // 032:调用方**没提** ttlMs 就要一张持久码(面板走这条路);显式给了才限时,
136
+ // 那是老行为,留给还在用的调用方。
137
+ const wantsTtl = Object.prototype.hasOwnProperty.call(body, 'ttlMs')
138
+ const ttlMs = wantsTtl ? ttlFrom(body.ttlMs, Number(config.pairTtlMs) || 10 * 60 * 1000) : null
135
139
  try {
136
140
  const minted = await agent.mintPairCode({ ttlMs })
137
- logger.info?.(`mobile-link: minted a pairing code for ${minted.agentId}`)
141
+ logger.info?.(`mobile-link: minted a ${minted.persistent ? 'persistent' : 'time-limited'}`
142
+ + ` pairing code for ${minted.agentId}`)
138
143
  // The picture travels with the code. It used to be a route of its own
139
144
  // (`GET /mobile-link/qr`) that a person opened in a browser; the pairing
140
145
  // entry is now the connector's own panel in the DSH client, so the one
@@ -148,5 +153,32 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, hist
148
153
  },
149
154
  }))
150
155
 
156
+ disposers.push(ctx.webServer.register({
157
+ kind: 'exact',
158
+ path: REGENERATE_ROUTE,
159
+ handler: async (req, res) => {
160
+ if (guard(req, res, { requireFence: true })) return
161
+ if (req.method !== 'POST') {
162
+ res.statusCode = 405
163
+ res.setHeader('allow', 'POST')
164
+ res.end()
165
+ return
166
+ }
167
+ const body = await readJsonBody(req)
168
+ if (body === undefined) {
169
+ sendJson(res, 400, { ok: false, message: 'invalid JSON body' })
170
+ return
171
+ }
172
+ try {
173
+ const minted = await agent.regeneratePairCode()
174
+ logger.info?.(`mobile-link: regenerated the pairing code for ${minted.agentId}`
175
+ + ` (revoked ${minted.revoked})`)
176
+ sendJson(res, 200, { ok: true, ...minted, svg: qrSvg(minted.qrPayload) })
177
+ } catch (error) {
178
+ sendJson(res, 502, { ok: false, code: 'relay/unavailable', message: String(error?.message ?? error) })
179
+ }
180
+ },
181
+ }))
182
+
151
183
  return disposers
152
184
  }
package/lib/status.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * 函数只读 agent 的字段,不持有状态,因此可以直接被单测调用。
7
7
  */
8
8
 
9
- import { SERVER_CAPABILITIES, SERVER_VERSION } from './hello.js'
9
+ import { SERVER_CAPABILITIES, SERVER_VERSION, hostVersion } from './hello.js'
10
10
 
11
11
  const PROTOCOL_VERSION = 1
12
12
 
@@ -93,6 +93,9 @@ export function statusSnapshot(agent) {
93
93
  // Same facts as `_link/hello`, which is the channel the app actually uses:
94
94
  // this route only exists on the direct path.
95
95
  serverVersion: SERVER_VERSION,
96
+ // 宿主 DSH 的版本(`null` = 这个宿主没把版本放进环境)。与 `serverVersion`
97
+ // 是两个命名空间:前者是连接器,后者是跑它的 DSH。
98
+ hostVersion: hostVersion(),
96
99
  capabilities: SERVER_CAPABILITIES,
97
100
  enroll: {
98
101
  // A phone (or the user's browser) can read this to find out that the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-link",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "Put your PC's DeepSeek Harness in your pocket: dials a relay over WSS so the native iOS client reaches this machine even without a public IP.",
5
5
  "keywords": [
6
6
  "dsh",