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 +1 -1
- package/cordis.patch.yml +6 -0
- package/lib/client.js +101 -41
- package/lib/dir-listing.js +167 -0
- package/lib/dlp.js +28 -2
- package/lib/enroll.js +2 -2
- package/lib/files.js +118 -12
- package/lib/git.js +20 -5
- package/lib/hello.js +53 -3
- package/lib/history/compress.js +40 -0
- package/lib/history/coverage.js +21 -0
- package/lib/history/fold-summary.js +472 -0
- package/lib/history/fold.js +184 -0
- package/lib/history/session-snapshot.js +75 -24
- package/lib/history/summary-window.js +168 -0
- package/lib/history.js +243 -16
- package/lib/index.js +29 -0
- package/lib/jobs-roster.js +377 -0
- package/lib/link.js +68 -7
- package/lib/logs.js +242 -0
- package/lib/pairing.js +60 -9
- package/lib/router.js +170 -5
- package/lib/routes.js +34 -2
- package/lib/session-digest.js +59 -4
- package/lib/state.js +3 -3
- package/lib/status.js +4 -1
- package/lib/unicode.js +116 -0
- package/package.json +1 -1
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
|
|
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
|
-
/**
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
368
|
-
React.createElement('div', { className: 'dsh-mobile-link-
|
|
369
|
-
|
|
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 ? '取码中…'
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
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
|