dsh-plugin-mobile-link 0.6.5 → 0.7.0

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
@@ -80,7 +80,7 @@ dsh plugin --profile web add ./plugins/mobile-link
80
80
  dsh-mobile-link enroll --invite <邀请码> --relay wss://<你的站点>/dsh-link
81
81
  ```
82
82
 
83
- …or register it on the relay host with `relay/admin.py agent-register
83
+ …or register it on the relay host with `dsh-relay admin agent-register
84
84
  --write-config ~/.dsh/mobile-link/agent.json`.
85
85
 
86
86
  2. Mint a pairing code, and show it as a QR the phone camera can read:
package/cordis.patch.yml CHANGED
@@ -32,6 +32,12 @@
32
32
  # (the per-attach `device.summary` is still written).
33
33
  # diagnostics false refuses the phone's phase uploads (`_link/diagnostics`) with
34
34
  # `diagnostics/disabled`; the phone ignores it and stays connected.
35
+ # logs false refuses the phone's runtime logs (`_link/logs`) with
36
+ # `logs/disabled`; the phone keeps them in its own ring and retries.
37
+ # logsFile log spool root (default ~/.dsh/mobile-link/logs). One directory
38
+ # per device, one JSONL per local day, pruned to the last 7 days
39
+ # in the write path itself — there is no timer or background job
40
+ # to keep alive. Read it with `node scripts/dev/ios-logs.mjs`.
35
41
  # dshUrl explicit local DSH base URL override (default: discovery)
36
42
  #
37
43
  # 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,167 @@
1
+ /**
2
+ * `_link/dirListing`:`workspaceFiles/list` 的同形增强副本,本机补 `mtimeMs`。
3
+ *
4
+ * 为什么要它:宿主 `workspaceFiles/list` 的条目只有 `name/type/size?`(宿主类型见
5
+ * `@deepseek-ai/dsh-api-workspace-files` 的 `WorkspaceDirectoryEntry`),
6
+ * 没有任何时间字段;`workspaceFiles/stat` 的 `version` 是 opaque token(类型注释明确
7
+ * "never parsed"),推不出时间。用户要的是"哪个文件最近被动过",所以由电脑侧补。
8
+ *
9
+ * 三条纪律(与 `session-digest.js` 同款):
10
+ * 1. **清单的所有者仍是宿主**:连接器只往条目上加一个字段,不删、不改、不重排;
11
+ * 宿主报错时错误信封逐字透传。
12
+ * 2. **拿不到就原样返回**:root 缺失/相对/越界、单条目 stat 失败、超出总预算——
13
+ * 一律退回未增强的原始清单。装饰永不弄坏清单,目录本身一定能打开。
14
+ * 3. **不提速、不重试**:一个条目最多 stat 一次;没有缓存(mtime 会变,缓存就要失效
15
+ * 逻辑,而列目录本来就是低频动作)。
16
+ *
17
+ * 符号链接用 `fs.stat`(跟随链接)报**目标**的时间——宿主列的 `type`/`size` 本来就是
18
+ * 按目标解析的(类型注释:"A symlink reports the type of its destination"),
19
+ * `lstat` 报的链接自身时间只在重指时变化,回答不了"内容什么时候改的"。
20
+ */
21
+
22
+ import { stat } from 'node:fs/promises'
23
+ import { isAbsolute, resolve, sep } from 'node:path'
24
+
25
+ /** 方法名(设备发起;`_link/` 前缀 = 连接器自答,永不转发宿主)。 */
26
+ export const DIR_LISTING_METHOD = '_link/dirListing'
27
+
28
+ /** 宿主的清单方法:连接器只读它,不改它。 */
29
+ const HOST_LIST_METHOD = 'workspaceFiles/list'
30
+
31
+ /** 可调的上限。`statBudgetMs` 是全部条目补时间的总预算(见 §2.2 失败语义表)。 */
32
+ export const LIMITS = Object.freeze({
33
+ statBudgetMs: 2000,
34
+ })
35
+
36
+ export function isDirListingMethod(method) {
37
+ return method === DIR_LISTING_METHOD
38
+ }
39
+
40
+ /**
41
+ * 给一份宿主目录清单补上 `mtimeMs`。
42
+ *
43
+ * 认两种输入形状(与 `digestSessionList` 同款):宿主信封 `{ ok, value: { entries } }`
44
+ * 以及直接给的 `{ entries }`。**返回的始终是同形状的值**,调用方不必再猜信封在哪一层。
45
+ * 结构不是预期的样子就原样返回入参——装饰不许把"看不懂"变成"改掉"。
46
+ *
47
+ * @param {object} options
48
+ * @param {{ rpc: (method: string, args: object) => Promise<unknown> }} options.dsh 宿主 RPC
49
+ * @param {object} options.args 设备发来的参数(`workspaceFileScopeId` / `path` / `root`)
50
+ * @param {{ debug?: (message: string) => void, warn?: (message: string) => void }} [options.logger]
51
+ * @param {(path: string) => Promise<{ mtimeMs: number }>} [options.statImpl] 可注入,供单测
52
+ * @param {number} [options.budgetMs] 总预算,缺省 `LIMITS.statBudgetMs`
53
+ * @returns {Promise<unknown>} 增强后的同形值;降级时是原始值
54
+ */
55
+ export async function dirListing({ dsh, args, logger, statImpl = stat, budgetMs = LIMITS.statBudgetMs }) {
56
+ const request = args ?? {}
57
+ const result = await dsh.rpc(HOST_LIST_METHOD, {
58
+ workspaceFileScopeId: request.workspaceFileScopeId,
59
+ path: typeof request.path === 'string' ? request.path : '.',
60
+ })
61
+
62
+ const entries = listingEntries(result)
63
+ if (entries === null) {
64
+ logger?.debug?.('dir-listing: host answered a shape this connector does not decorate')
65
+ return result
66
+ }
67
+
68
+ const directory = resolveDirectory(request.root, entries.path)
69
+ if (directory === null) {
70
+ // root 缺失 / 非绝对 / 宿主回的 path 越出 root:不是错误,只是补不了时间。
71
+ logger?.debug?.(`dir-listing: cannot resolve the listed directory from root ${JSON.stringify(request.root)}`)
72
+ return result
73
+ }
74
+
75
+ const decorated = await withBudget(
76
+ Promise.all(entries.items.map((entry) => decorate(entry, directory, statImpl))),
77
+ budgetMs
78
+ )
79
+ if (decorated === null) {
80
+ logger?.warn?.(`dir-listing: stat budget of ${budgetMs}ms ran out, answering the plain listing`)
81
+ return result
82
+ }
83
+ return replaceEntries(result, decorated)
84
+ }
85
+
86
+ /**
87
+ * 取出清单本体(`{ entries: [...], path }`),形状不对时返回 null。
88
+ *
89
+ * 信封可能是 `result` 本身,也可能是 `result.value`——按"哪一层有 entries 数组"判定,
90
+ * 与 `digestSessionList` 同一读法。
91
+ */
92
+ function listingEntries(result) {
93
+ if (result === null || typeof result !== 'object') return null
94
+ if (result.ok !== true) return null
95
+ const container = Array.isArray(result.entries) ? result : result.value
96
+ if (container === null || typeof container !== 'object' || !Array.isArray(container.entries)) return null
97
+ return { path: container.path, items: container.entries }
98
+ }
99
+
100
+ /** 按原形状放回增强后的条目数组。 */
101
+ function replaceEntries(result, items) {
102
+ if (Array.isArray(result.entries)) return { ...result, entries: items }
103
+ return { ...result, value: { ...result.value, entries: items } }
104
+ }
105
+
106
+ /**
107
+ * 宿主回的 `path` 词法解析成绝对目录;解析不了(要求降级)时返回 null。
108
+ *
109
+ * - `path` 可以是相对的(`""` 表根、`"docs"`)也可以是绝对的(App/宿主都有先例)。
110
+ * - 结果必须位于 root 之内:`resolved === root || resolved.startsWith(root + sep)`。
111
+ * 只比 `startsWith(root)` 会把 `/a/b-sibling` 当成 `/a/b` 的子路径。
112
+ * - **不做 realpath 越界拦截**(设计 §2.2 决策 1):符号链接的目标可以合法地在工作区
113
+ * 之外,宿主今天已经按目标报 `type`/`size`,这里保持同一语义。
114
+ */
115
+ function resolveDirectory(root, echoedPath) {
116
+ if (typeof root !== 'string' || !isAbsolute(root)) return null
117
+ const base = resolve(root)
118
+ const echoed = typeof echoedPath === 'string' ? echoedPath : ''
119
+ const resolved = isAbsolute(echoed) ? resolve(echoed) : resolve(base, echoed)
120
+ if (resolved !== base && !resolved.startsWith(base + sep)) return null
121
+ return resolved
122
+ }
123
+
124
+ /**
125
+ * 一条条目:stat 成功且时间可用就加 `mtimeMs`,其余情况原样返回。
126
+ *
127
+ * 条目名逐段校验:含 `/`、`\` 或等于 `.` / `..` 的名字直接跳过(宿主契约说是 basename,
128
+ * 这里是纵深防御,防的是异常回包,不是防用户)。跳过的意思是"不给它补时间",
129
+ * **不是把条目从清单里删掉**——清单的所有者是宿主。
130
+ */
131
+ async function decorate(entry, directory, statImpl) {
132
+ if (entry === null || typeof entry !== 'object') return entry
133
+ if (typeof entry.name !== 'string' || !isPlainName(entry.name)) return entry
134
+ try {
135
+ const info = await statImpl(resolve(directory, entry.name))
136
+ const mtimeMs = info?.mtimeMs
137
+ if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return entry
138
+ const rounded = Math.round(mtimeMs)
139
+ if (!Number.isFinite(rounded)) return entry
140
+ return { ...entry, mtimeMs: rounded }
141
+ } catch {
142
+ // 断链、权限、竞态删除:这个条目没有时间,其余条目照旧。
143
+ return entry
144
+ }
145
+ }
146
+
147
+ function isPlainName(name) {
148
+ return name.length > 0 && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\')
149
+ }
150
+
151
+ /**
152
+ * 给整批 stat 一个总预算。超时返回 `null`(调用方据此降级),
153
+ * **不取消**后台的 stat——它们会自然收尾,取消反而要给每条挂一个 signal。
154
+ */
155
+ async function withBudget(work, budgetMs) {
156
+ if (!(budgetMs > 0)) return await work
157
+ let timer
158
+ const expiry = new Promise((resolve) => {
159
+ timer = setTimeout(() => resolve(null), budgetMs)
160
+ })
161
+ try {
162
+ const outcome = await Promise.race([work, expiry])
163
+ return outcome === null ? null : outcome
164
+ } finally {
165
+ clearTimeout(timer)
166
+ }
167
+ }
package/lib/dlp.js CHANGED
@@ -6,11 +6,17 @@
6
6
  * Wire contract: docs/RELAY-PROTOCOL.md §3.
7
7
  */
8
8
 
9
+ import { repairLoneSurrogatesWithCount } from './unicode.js'
10
+
9
11
  /** Frame ceiling aligned with DSH's image-attachment limit (spec §5). */
10
12
  export const MAX_FRAME_BYTES = 32 * 1024 * 1024
11
13
 
12
14
  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'])
15
+ export const AGENT_TO_DEVICE = new Set(['res', 'item', 'end', 'streamError', 'event', 'hostStatus', 'pong', 'error',
16
+ // 032:账户级状态由**中转自己**发给设备(会话已读水位、提醒开关这类),
17
+ // 连接器既不产生也不转发它,但这张表是契约镜像,缺一项就与
18
+ // `docs/relay-contract.json` 对不上(`npm test` 会红)。
19
+ 'accountState'])
14
20
  /** Relay-internal control frames carrying device lifecycle (see docs/RELAY-NOTES.md). */
15
21
  export const RELAY_CONTROL = new Set(['deviceAttach', 'deviceDetach'])
16
22
  /**
@@ -36,8 +42,23 @@ export const RELAY_TO_AGENT_CONTROL = new Set(['fsGetCancel'])
36
42
 
37
43
  const ID_FRAMES = new Set(['req', 'open', 'cancel', 'res', 'item', 'end', 'streamError', 'eventResult'])
38
44
 
45
+ /**
46
+ * 出站帧的唯一序列化出口。
47
+ *
48
+ * 为什么要过一遍代理项修复:宿主会给我们**截断过**的预览(`turnOutline` 的
49
+ * prompt/response 截到 50/120 个码元),截断点落在 emoji 中间时字符串里就有
50
+ * 落单代理项,`JSON.stringify` 会忠实写成 `"\ud83d…"`。中转能把它修回 U+FFFD,
51
+ * 但解码方(iOS `JSONDecoder`)遇到这种转义会整条拒绝,所以生产端先把帧修好。
52
+ */
39
53
  export function encodeFrame(frame) {
40
- return JSON.stringify(frame)
54
+ return encodeFrameChecked(frame).text
55
+ }
56
+
57
+ /** `encodeFrame` 的带诊断版本:`repaired` 是这条帧里修掉的落单代理项条数。 */
58
+ export function encodeFrameChecked(frame) {
59
+ const raw = JSON.stringify(frame)
60
+ const { text, repaired } = repairLoneSurrogatesWithCount(raw)
61
+ return { text, repaired }
41
62
  }
42
63
 
43
64
  /**
@@ -167,6 +188,11 @@ export function pairCodeEndpoint(relayUrl) {
167
188
  return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'pair/code')
168
189
  }
169
190
 
191
+ /** 重新生成配对码(032):作废旧码、铸一枚新的,不影响已配对设备。 */
192
+ export function pairCodeRegenerateEndpoint(relayUrl) {
193
+ return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'pair/code/regenerate')
194
+ }
195
+
170
196
  /** The relay's self-service enrollment endpoint (`POST /agents/enroll`). */
171
197
  export function enrollEndpoint(relayUrl) {
172
198
  return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'agents/enroll')
package/lib/enroll.js CHANGED
@@ -2,11 +2,11 @@
2
2
  * First-run enrollment: turn an invite code into a durable agent identity.
3
3
  *
4
4
  * Until now an identity could only be provisioned by whoever owns the relay
5
- * (`relay/admin.py agent-register --write-config`). That does not distribute:
5
+ * (`dsh-relay admin agent-register --write-config`). That does not distribute:
6
6
  * somebody else installs DSH plus this connector on their own computer and has
7
7
  * to be handed a credential by the operator, out of band.
8
8
  *
9
- * So the relay grew `POST /agents/enroll` (relay/api.py), which redeems a
9
+ * So the relay grew `POST /agents/enroll` (relay-go/internal/httpapi), which redeems a
10
10
  * one-time invite code for a fresh `agentId` / `agentSecret`. This module is the
11
11
  * connector half: one call, then the identity is written to the same
12
12
  * `~/.dsh/mobile-link/agent.json` (mode 0600) that `agent-register` writes, so