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 +101 -41
- package/lib/dlp.js +10 -1
- package/lib/hello.js +31 -3
- package/lib/history.js +73 -8
- package/lib/link.js +22 -4
- package/lib/pairing.js +60 -9
- package/lib/routes.js +34 -2
- package/lib/status.js +4 -1
- package/package.json +1 -1
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
|
}
|
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 {
|
|
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
|
|
31
|
-
return
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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(
|
|
296
|
-
: { snippet: String(
|
|
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)
|
|
352
|
+
if (hits.length >= limit) {
|
|
353
|
+
// 装满了:这一条已经收进本页,下一页从它的**前一条**继续——不能从整块之前
|
|
354
|
+
// (会丢块尾),也不能从这一条本身(会重复)。
|
|
355
|
+
resumeFrom = doc.seq - 1
|
|
356
|
+
break
|
|
357
|
+
}
|
|
299
358
|
}
|
|
300
|
-
to =
|
|
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
|
-
/**
|
|
657
|
-
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|