dsh-plugin-mobile-link 0.3.1 → 0.4.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 +29 -3
- package/cordis.patch.yml +0 -1
- package/lib/cli.js +7 -3
- package/lib/dlp.js +21 -1
- package/lib/dsh-client.js +162 -61
- package/lib/files-out.js +425 -0
- package/lib/files.js +43 -14
- package/lib/handoff.js +53 -0
- package/lib/hello.js +8 -0
- package/lib/index.js +65 -11
- package/lib/link.js +90 -55
- package/lib/pairing.js +1 -1
- package/lib/router.js +190 -32
- package/lib/routes.js +1 -1
- package/lib/state.js +5 -14
- package/lib/status.js +101 -0
- package/package.json +1 -1
package/lib/router.js
CHANGED
|
@@ -12,25 +12,45 @@
|
|
|
12
12
|
import {
|
|
13
13
|
StreamTable, WaterfallDedupe, deviceFrameError, deviceIdOf, nonEmptyString, resultFrame,
|
|
14
14
|
} from './dlp.js'
|
|
15
|
-
import { FileInbox, isFileMethod } from './files.js'
|
|
15
|
+
import { FileInbox, FILE_BEGIN, FILE_CHUNK, FILE_END, isFileMethod } from './files.js'
|
|
16
|
+
import { FileFetcher, FSGET_CANCEL } from './files-out.js'
|
|
16
17
|
import { GitBridge, isGitMethod } from './git.js'
|
|
17
18
|
import { isHelloMethod, helloPayload, parseClientInfo } from './hello.js'
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 后台大文件上传的桥接帧(relay → 连接器),名字见 `docs/relay-contract.json`
|
|
22
|
+
* 的 `agentControl`。转义成 `FileInbox` 的三件套,见 `handleFsPutBegin` 那段注释。
|
|
23
|
+
*/
|
|
24
|
+
export const FSPUT_BEGIN = 'fsPutBegin'
|
|
25
|
+
export const FSPUT_CHUNK = 'fsPutChunk'
|
|
26
|
+
export const FSPUT_END = 'fsPutEnd'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 后台大文件下载的桥接帧(relay → 连接器),同一族的另一半:relay 终结
|
|
30
|
+
* `GET /files/down` 并慢慢读,这里驱动 host 的 `workspaceFiles/readBytes`
|
|
31
|
+
* 一片一片回。实现体在 `lib/files-out.js`。
|
|
32
|
+
*/
|
|
33
|
+
export const FSGET_BEGIN = 'fsGetBegin'
|
|
34
|
+
|
|
19
35
|
export const EVENTS_ENDPOINT = '$events'
|
|
20
36
|
export const EVENTS_RESULT = '$events/result'
|
|
21
37
|
|
|
22
38
|
/**
|
|
23
|
-
*
|
|
39
|
+
* 手机不在时一直替它留 `$events`,直到设备被撤销或进程退出。
|
|
24
40
|
*
|
|
25
41
|
* 为什么需要:手机是唯一客户端时,它一断,DSH 那边就没人持有这条流了——
|
|
26
|
-
* 提问(waterfall)会随 Agent Context
|
|
27
|
-
*
|
|
28
|
-
*
|
|
42
|
+
* 提问(waterfall)会随 Agent Context 释放被撤掉,用户回到手机再也看不到——
|
|
43
|
+
* 这正是留流的原因。留着流就等于"代手机值班":这期间来的提问先攒着,手机回来补发。
|
|
44
|
+
*
|
|
45
|
+
* **没有时限**:这里曾经有一个 15 分钟的宽限期(`DEFAULT_EVENTS_GRACE_MS`),
|
|
46
|
+
* 到点撤流;2026-09-28 的 R-1 把它拆掉了——手机离线超过 15 分钟提问就永远丢了,
|
|
47
|
+
* 而那正是"值班"最该起作用的时候。唯一的出口是 `reason === 'revoked'`(见
|
|
48
|
+
* `detachDevice`),以及进程退出(`teardownAll`)。
|
|
29
49
|
*
|
|
30
50
|
* 代价是这段时间里 DSH 认为"设备还在",会话的 Agent Context 不会因此释放;
|
|
31
|
-
*
|
|
51
|
+
* 也就是"挂着的提问所在会话的 Agent Context 长期不释放"。owner 已裁定这不是
|
|
52
|
+
* 风险(电脑一直运行),所以不加上限、也不为此写额外的清理机制。
|
|
32
53
|
*/
|
|
33
|
-
export const DEFAULT_EVENTS_GRACE_MS = 15 * 60 * 1000
|
|
34
54
|
|
|
35
55
|
/** 离线期间最多替一台设备攒多少条 `$events` 项(只攒 waterfall/cancel)。 */
|
|
36
56
|
export const DEFAULT_EVENTS_BACKLOG = 50
|
|
@@ -63,14 +83,13 @@ export class DeviceRouter {
|
|
|
63
83
|
*/
|
|
64
84
|
constructor({
|
|
65
85
|
dsh, send, logger = console, onChange = () => {}, now = Date.now, protocolVersion = 1,
|
|
66
|
-
|
|
67
|
-
setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout,
|
|
86
|
+
eventsBacklog = DEFAULT_EVENTS_BACKLOG, notify = () => {},
|
|
68
87
|
} = {}) {
|
|
69
88
|
this.dsh = dsh
|
|
70
|
-
this.eventsGraceMs = eventsGraceMs
|
|
71
89
|
this.eventsBacklog = eventsBacklog
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
// 「跑完了 / 待你回应」这类事件到了就报一声(见 `notifyFor`)。默认空函数,
|
|
91
|
+
// 测试与不关心提醒的调用方可以不管它。帧由 link.js 发(router 不发帧)。
|
|
92
|
+
this.notify = notify
|
|
74
93
|
// Reported to the app so it can tell which wire revision it is talking to.
|
|
75
94
|
this.protocolVersion = protocolVersion
|
|
76
95
|
this.send = send
|
|
@@ -82,6 +101,13 @@ export class DeviceRouter {
|
|
|
82
101
|
this.dedupe = new WaterfallDedupe({ now })
|
|
83
102
|
this.lastError = undefined
|
|
84
103
|
this.fileInbox = new FileInbox(logger)
|
|
104
|
+
// 下载桥(`fsGetBegin`):它自己经 `this.dsh.rpc` 读 host,回复只带 bid。
|
|
105
|
+
// `send(undefined, frame)` 与 `fsPut*` 同路——relay 靠 bid 关联回 HTTP 请求。
|
|
106
|
+
this.fileFetcher = new FileFetcher({
|
|
107
|
+
send: (frame) => this.send(undefined, frame),
|
|
108
|
+
rpc: (method, args) => dsh.rpc(method, args),
|
|
109
|
+
logger,
|
|
110
|
+
})
|
|
85
111
|
this.git = new GitBridge(logger)
|
|
86
112
|
}
|
|
87
113
|
|
|
@@ -107,6 +133,18 @@ export class DeviceRouter {
|
|
|
107
133
|
await this.detachDevice(deviceId, frame.reason)
|
|
108
134
|
return
|
|
109
135
|
}
|
|
136
|
+
// **Before the `deviceId` guard, not after it.** The download bridge family
|
|
137
|
+
// is addressed by `bid` alone — the relay is the one that terminates the
|
|
138
|
+
// HTTP request and it knows which device it is for; putting a `deviceId` on
|
|
139
|
+
// these frames would break the correlation the whole family rests on. So a
|
|
140
|
+
// cancel legitimately arrives with no `deviceId`, and the guard below would
|
|
141
|
+
// have dropped it silently: the connector would read the whole file for a
|
|
142
|
+
// reader that left. Found by probe (the run kept pumping past the cancel),
|
|
143
|
+
// which is why this is a test and not a comment.
|
|
144
|
+
if (frame.t === FSGET_CANCEL) {
|
|
145
|
+
this.handleFsGetCancel(frame)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
110
148
|
if (!deviceId) return
|
|
111
149
|
const problem = deviceFrameError(frame)
|
|
112
150
|
if (problem) {
|
|
@@ -126,22 +164,103 @@ export class DeviceRouter {
|
|
|
126
164
|
case 'eventResult':
|
|
127
165
|
await this.handleEventResult(deviceId, frame)
|
|
128
166
|
return
|
|
167
|
+
case FSPUT_BEGIN:
|
|
168
|
+
this.handleFsPutBegin(deviceId, frame)
|
|
169
|
+
return
|
|
170
|
+
case FSPUT_CHUNK:
|
|
171
|
+
this.handleFsPutChunk(frame)
|
|
172
|
+
return
|
|
173
|
+
case FSPUT_END:
|
|
174
|
+
this.handleFsPutEnd(frame)
|
|
175
|
+
return
|
|
176
|
+
case FSGET_BEGIN:
|
|
177
|
+
this.handleFsGetBegin(frame)
|
|
178
|
+
return
|
|
129
179
|
default:
|
|
130
180
|
/* Unknown frame types are ignored by design (spec §3). */
|
|
131
181
|
return
|
|
132
182
|
}
|
|
133
183
|
}
|
|
134
184
|
|
|
185
|
+
// ── background-transfer bridge (relay → connector) ──────────────────────
|
|
186
|
+
//
|
|
187
|
+
// 一族 agent 侧控制帧(`docs/relay-contract.json` 的 `agentControl`),由 relay 的
|
|
188
|
+
// `PUT /files/up` 驱动。它们**不跨到设备**——手机走的是普通 HTTPS,relay 终结它、
|
|
189
|
+
// 再把字节泵到这条 agent WSS 上,落盘仍然是连接器的活(中转不落盘)。
|
|
190
|
+
//
|
|
191
|
+
// 实现体复用 `FileInbox`:它的 begin/chunk/end 语义正好是这三帧要的。
|
|
192
|
+
// **上行回复只带 `bid`,不带 `deviceId`**——relay 靠 `bid` 关联回那个 HTTP 请求。
|
|
193
|
+
|
|
194
|
+
handleFsPutBegin(deviceId, frame) {
|
|
195
|
+
// `deviceId` 只用来在出错时记账;**回复一律不带 deviceId**——relay 是靠
|
|
196
|
+
// `bid` 把它对回那个 HTTP 请求的(见 `handleRelayFrame` 那段注释)。
|
|
197
|
+
void deviceId
|
|
198
|
+
try {
|
|
199
|
+
this.fileInbox.handle(FILE_BEGIN, {
|
|
200
|
+
transferId: frame.bid, sessionId: frame.sessionId, name: frame.name, bytes: frame.bytes,
|
|
201
|
+
})
|
|
202
|
+
this.send(undefined, { t: 'fsPutAck', bid: frame.bid, received: 0 })
|
|
203
|
+
} catch (error) {
|
|
204
|
+
this.sendFsError(frame.bid, error)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
handleFsPutChunk(frame) {
|
|
209
|
+
// **不回 ack**:整条链是流水线,一片一个 RTT 会把吞吐毁掉(roof 见
|
|
210
|
+
// `02-design.md` §4.4 第 3 条)。出错用 `fsErr` 中断。
|
|
211
|
+
try {
|
|
212
|
+
this.fileInbox.handle(FILE_CHUNK, {
|
|
213
|
+
transferId: frame.bid, seq: frame.seq, data: frame.data,
|
|
214
|
+
})
|
|
215
|
+
} catch (error) {
|
|
216
|
+
this.sendFsError(frame.bid, error)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
handleFsPutEnd(frame) {
|
|
221
|
+
try {
|
|
222
|
+
const done = this.fileInbox.handle(FILE_END, { transferId: frame.bid })
|
|
223
|
+
this.send(undefined, { t: 'fsPutDone', bid: frame.bid, path: done.path, bytes: done.bytes })
|
|
224
|
+
} catch (error) {
|
|
225
|
+
this.sendFsError(frame.bid, error)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 开一次下载。**不 await**:分片要在 relay 读响应体时陆续发出去,一个 await
|
|
231
|
+
* 会把这条 socket 上其它帧全挡住(与上传那条流水线的理由相同)。
|
|
232
|
+
*/
|
|
233
|
+
handleFsGetBegin(frame) {
|
|
234
|
+
this.fileFetcher.start(frame)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* relay 叫停一次下载读取(手机挂断 / 额度截断)。
|
|
239
|
+
*
|
|
240
|
+
* 只置 `cancelled`,**不回任何帧**:这不是一次请求,取消一个已经没人要的
|
|
241
|
+
* 传输也没有可报的错。`#drive` 在每次窗口返回后检查这个标志,所以最多再
|
|
242
|
+
* 多读一个窗口就停——不是立刻,但也不是读完整个文件。
|
|
243
|
+
*/
|
|
244
|
+
handleFsGetCancel(frame) {
|
|
245
|
+
const bid = String(frame?.bid ?? '')
|
|
246
|
+
if (!bid) return
|
|
247
|
+
this.fileFetcher.cancel(bid)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** One failure shape for the whole family; the relay turns it into an HTTP error. */
|
|
251
|
+
sendFsError(bid, error) {
|
|
252
|
+
this.logger.warn?.(`mobile-link: file bridge ${bid} failed: ${messageOf(error)}`)
|
|
253
|
+
this.send(undefined, { t: 'fsErr', bid, ...errorObject('file/rejected', messageOf(error)) })
|
|
254
|
+
}
|
|
255
|
+
|
|
135
256
|
// ── device lifecycle ────────────────────────────────────────────────────
|
|
136
257
|
|
|
137
258
|
async attachDevice(deviceId, info = {}) {
|
|
138
259
|
if (!nonEmptyString(deviceId)) return
|
|
139
260
|
const existing = this.devices.get(deviceId)
|
|
140
261
|
if (existing) {
|
|
141
|
-
//
|
|
262
|
+
// 回头客:手机又连上了。流和攒下的提问都还在,交给 handleOpen 补发。
|
|
142
263
|
if (existing.offlineSince !== undefined) {
|
|
143
|
-
this.clearTimeoutFn(existing.offlineTimer)
|
|
144
|
-
existing.offlineTimer = undefined
|
|
145
264
|
existing.offlineSince = undefined
|
|
146
265
|
this.logger.info?.(
|
|
147
266
|
`mobile-link: device ${deviceId} reattached with ${existing.eventsPending.size} unanswered question(s)`)
|
|
@@ -163,7 +282,6 @@ export class DeviceRouter {
|
|
|
163
282
|
// 注意它不是一个"送一次就清空"的队列:送过也要留着,否则 App 再重启一次
|
|
164
283
|
// (或崩一次)这条提问就永远丢了——2026-09-22 验收时正是这么发现的。
|
|
165
284
|
offlineSince: undefined,
|
|
166
|
-
offlineTimer: undefined,
|
|
167
285
|
eventsPending: new Map(),
|
|
168
286
|
})
|
|
169
287
|
this.logger.info?.(`mobile-link: device ${deviceId} attached (${info?.name ?? 'unknown'})`)
|
|
@@ -171,11 +289,26 @@ export class DeviceRouter {
|
|
|
171
289
|
await this.ensureEventsStream(deviceId)
|
|
172
290
|
}
|
|
173
291
|
|
|
292
|
+
/**
|
|
293
|
+
* 手机断了。
|
|
294
|
+
*
|
|
295
|
+
* `reason === 'revoked'`(中转发来的"这台设备被撤销了")是**放下**:撤掉它全部的流、
|
|
296
|
+
* 删掉记录——不再有人会回来拿那些提问了,继续值班只是白占一条流。
|
|
297
|
+
*
|
|
298
|
+
* 别的 reason(socket closed、backpressure…)一律是"手机暂时不在":普通流(文件、
|
|
299
|
+
* 会话流)立刻撤,`$events` 留着替它值班——**一直留**,没有时限(见 `$events`
|
|
300
|
+
* 那段的说明)。这也是「撤销即放下」是唯一出口的原因。
|
|
301
|
+
*/
|
|
174
302
|
async detachDevice(deviceId, reason) {
|
|
175
303
|
const record = this.devices.get(deviceId)
|
|
176
304
|
if (!record) return
|
|
305
|
+
if (reason === 'revoked') {
|
|
306
|
+
await this.#dropDevice(deviceId)
|
|
307
|
+
this.logger.info?.(`mobile-link: device ${deviceId} 已被撤销,放下(不再替它值班)`)
|
|
308
|
+
return
|
|
309
|
+
}
|
|
177
310
|
// 除了 `$events`,其它流(文件、会话流)立刻撤掉:它们没有"值班"的意义,
|
|
178
|
-
// 留着只会占资源。`$events`
|
|
311
|
+
// 留着只会占资源。`$events` 留着,见上面那段说明。
|
|
179
312
|
const keep = []
|
|
180
313
|
for (const entry of this.streams.byDevice(deviceId)) {
|
|
181
314
|
if (entry.endpoint === EVENTS_ENDPOINT) {
|
|
@@ -188,28 +321,21 @@ export class DeviceRouter {
|
|
|
188
321
|
record.dlpIds.clear()
|
|
189
322
|
for (const entry of keep) record.dlpIds.add(entry.dlpId)
|
|
190
323
|
|
|
191
|
-
if (record.offlineSince === undefined)
|
|
192
|
-
record.offlineSince = this.now()
|
|
193
|
-
record.offlineTimer = this.setTimeoutFn(() => {
|
|
194
|
-
void this.#expireOfflineDevice(deviceId)
|
|
195
|
-
}, this.eventsGraceMs)
|
|
196
|
-
// 定时器不该把进程钉住(CLI 与测试里尤其明显)。
|
|
197
|
-
record.offlineTimer?.unref?.()
|
|
198
|
-
}
|
|
324
|
+
if (record.offlineSince === undefined) record.offlineSince = this.now()
|
|
199
325
|
this.logger.info?.(
|
|
200
326
|
`mobile-link: device ${deviceId} detached (${reason ?? 'closed'});`
|
|
201
|
-
+
|
|
327
|
+
+ '$events 流一直替它留着,直到设备被撤销或进程退出')
|
|
202
328
|
this.#changed()
|
|
203
329
|
}
|
|
204
330
|
|
|
205
|
-
/**
|
|
206
|
-
async #
|
|
331
|
+
/** 真的把这台设备放下:撤全部流、删记录。唯一调用点是撤销与进程收尾。 */
|
|
332
|
+
async #dropDevice(deviceId) {
|
|
207
333
|
const record = this.devices.get(deviceId)
|
|
208
|
-
if (!record
|
|
334
|
+
if (!record) return
|
|
209
335
|
for (const muxId of this.streams.removeDevice(deviceId)) this.dsh.cancelStream(muxId)
|
|
210
336
|
this.devices.delete(deviceId)
|
|
211
337
|
this.logger.info?.(
|
|
212
|
-
`mobile-link:
|
|
338
|
+
`mobile-link: 放下设备 ${deviceId}(丢掉 ${record.eventsPending.size} 条没人回答的提问)`)
|
|
213
339
|
this.#changed()
|
|
214
340
|
}
|
|
215
341
|
|
|
@@ -236,8 +362,6 @@ export class DeviceRouter {
|
|
|
236
362
|
teardownAll() {
|
|
237
363
|
const muxIds = []
|
|
238
364
|
for (const deviceId of [...this.devices.keys()]) {
|
|
239
|
-
const record = this.devices.get(deviceId)
|
|
240
|
-
this.clearTimeoutFn(record?.offlineTimer)
|
|
241
365
|
muxIds.push(...this.streams.removeDevice(deviceId))
|
|
242
366
|
this.devices.delete(deviceId)
|
|
243
367
|
}
|
|
@@ -458,6 +582,10 @@ export class DeviceRouter {
|
|
|
458
582
|
}
|
|
459
583
|
}
|
|
460
584
|
}
|
|
585
|
+
// 「报一声」与「要不要攒」是两件事:攒只对 waterfall/cancel,报的是"跑完了"与
|
|
586
|
+
// "待你回应"两类。这里放在离线早退**之前**,所以在不在线都会报——
|
|
587
|
+
// 手机在线时它自己会弹本地通知,这条 notify 到了中转会被判"在线不推"(不然重复)。
|
|
588
|
+
this.notifyFor(value)
|
|
461
589
|
if (record.offlineSince !== undefined && isBufferedEvent(value)) {
|
|
462
590
|
this.logger.debug?.(
|
|
463
591
|
`mobile-link: kept ${value.type} ${value.event ?? value.eventId ?? ''} for offline ${deviceId}`)
|
|
@@ -474,6 +602,36 @@ export class DeviceRouter {
|
|
|
474
602
|
if (record.dlpIds.size === 0) this.send(deviceId, { t: 'event', value })
|
|
475
603
|
}
|
|
476
604
|
|
|
605
|
+
/**
|
|
606
|
+
* 要不要为这条 `$events` 项报一声(就是「提醒手机」)。
|
|
607
|
+
*
|
|
608
|
+
* 只有两类,判据都是现成的:
|
|
609
|
+
*
|
|
610
|
+
* - **跑完了**:`emit: api-session/status(sid, false)`。只能从这条 `emit` 认——
|
|
611
|
+
* `turn/end` 不在 `$events` 的转发白名单里(`docs/DSH-PROTOCOL.md:3141-3165`;
|
|
612
|
+
* `turn/end` 在 `:3229`,那是 `session/follow` 的事件),在这条流上永远看不到。
|
|
613
|
+
* - **待你回应**:两种 `waterfall` 都算——`user-questions/request`(`:3164`)与
|
|
614
|
+
* `approval/request`(`:3145`),两者都让 host 停在那儿等人。App 侧本来就两种都认
|
|
615
|
+
* (`Support/HostEventHub.swift:191-198`)。
|
|
616
|
+
*
|
|
617
|
+
* `api-session/error`(`:3150`)**有意不推**:它不一定是"跑完了"(也可能是一轮中途
|
|
618
|
+
* 报错后继续),拿它当"跑完了"会发出一堆噪音提醒。这是决定,不是漏做。
|
|
619
|
+
*
|
|
620
|
+
* 不做去抖、不做队列、不做重试:一轮才一条,发失败(中转断了)就丢。`turnEnd` 丢一条
|
|
621
|
+
* 不值得为它做重试;`attention` 丢不了,它同时被记账,手机回来会补发。
|
|
622
|
+
*/
|
|
623
|
+
notifyFor(value) {
|
|
624
|
+
if (!value || typeof value !== 'object') return
|
|
625
|
+
if (value.type === 'emit' && value.event === 'api-session/status' && value.args?.[1] === false) {
|
|
626
|
+
const sid = String(value.args[0] ?? '')
|
|
627
|
+
if (sid) this.notify({ kind: 'turnEnd', sid })
|
|
628
|
+
return
|
|
629
|
+
}
|
|
630
|
+
if (value.type === 'waterfall') {
|
|
631
|
+
this.notify({ kind: 'attention', sid: value.agentId, eid: value.eventId })
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
477
635
|
onStreamError(muxId, error) {
|
|
478
636
|
const entry = this.streams.entryOfMux(muxId)
|
|
479
637
|
if (!entry) return
|
package/lib/routes.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* POST /mobile-link/pair-code mint a pairing code (and its QR payload)
|
|
6
6
|
* GET /mobile-link/qr mint a pairing code and render it as a QR SVG
|
|
7
7
|
*
|
|
8
|
-
* All sit behind DSH's own request fence,
|
|
8
|
+
* All sit behind DSH's own request fence, like every other plugin route:
|
|
9
9
|
* `ctx.get('connection')?.requestRejection?.(req)` performs the Host/Origin check
|
|
10
10
|
* and browser authentication. `/status` stays readable when the fence service is
|
|
11
11
|
* missing (it is diagnostic); the other two mint a credential, so they fail
|
package/lib/state.js
CHANGED
|
@@ -45,23 +45,14 @@ export function defaultStatePath() {
|
|
|
45
45
|
return join(dshHome(), 'mobile-link', 'agent.json')
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
export function defaultEndpointFile() {
|
|
49
|
-
return join(dshHome(), 'desktop-shell', 'endpoint.json')
|
|
50
|
-
}
|
|
51
|
-
|
|
52
48
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
49
|
+
* 连接器**自己写出**的交接文件:给跑在宿主进程之外的脚本用(它们没有进程内上下文,
|
|
50
|
+
* 拿不到注入的服务)。消费者与格式见 `contracts/out-of-process-handoff.md`。
|
|
55
51
|
*
|
|
56
|
-
*
|
|
57
|
-
* (observed after the shell quit and re-attached to an already running `dsh
|
|
58
|
-
* web`), and a machine whose host was started as a bare `dsh web --port 0` only
|
|
59
|
-
* ever sees the URL printed into this log. Both spellings appear here, so the
|
|
60
|
-
* log is a usable last resort — without it the connector sits at the default
|
|
61
|
-
* port with no launch token and every phone request fails.
|
|
52
|
+
* 注意:这不是连接器自己的发现来源——发现走宿主进程内的注入(见 `dsh-client.js`)。
|
|
62
53
|
*/
|
|
63
|
-
export function
|
|
64
|
-
return join(dshHome(), '
|
|
54
|
+
export function defaultHandoffFile() {
|
|
55
|
+
return join(dshHome(), 'mobile-link', 'endpoint.json')
|
|
65
56
|
}
|
|
66
57
|
|
|
67
58
|
/** @returns {Promise<Record<string, unknown> | undefined>} */
|
package/lib/status.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /mobile-link/status` 的快照。
|
|
3
|
+
*
|
|
4
|
+
* 单独成文件的目的:这份载荷是**对外契约**(`contracts/status-endpoint.md` 规定了
|
|
5
|
+
* "只增不改"),把它和传输/生命周期代码分开,改动时更容易看出"是不是又动了老字段"。
|
|
6
|
+
* 函数只读 agent 的字段,不持有状态,因此可以直接被单测调用。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SERVER_CAPABILITIES, SERVER_VERSION } from './hello.js'
|
|
10
|
+
|
|
11
|
+
const PROTOCOL_VERSION = 1
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 失败分类 → 用户可读的一句话 + 文档锚点(FR-012)。
|
|
15
|
+
*
|
|
16
|
+
* 锚点标题与 `docs/ONBOARDING.md` 里的小节标题**逐字一致**,这样"锚点存在"可以机械核对。
|
|
17
|
+
*/
|
|
18
|
+
export const DSH_HINTS = {
|
|
19
|
+
config: { hint: '你手工配置的本机地址不对或已失效', docAnchor: '手工指定本机地址' },
|
|
20
|
+
'host-service': { hint: '当前宿主没有把本机访问方式交给插件(多为插件没装进正在运行的宿主)', docAnchor: '装在哪里:档案与插件入口' },
|
|
21
|
+
DSH_WEB_URL: { hint: '环境变量里的本机地址已过期', docAnchor: '手工指定本机地址' },
|
|
22
|
+
unreachable: { hint: '本机 DSH 不可达;请确认宿主正在运行', docAnchor: '排查:连不上时看这里' },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 宿主形态 → 用户可读的一句话 + 文档锚点(FR-012:分类必须可行动,不能只给分类名)。
|
|
27
|
+
*
|
|
28
|
+
* 锚点标题与 `docs/ONBOARDING.md` 的小节标题逐字一致,`test/status.test.js` 会核对。
|
|
29
|
+
*/
|
|
30
|
+
export const HOST_HINTS = {
|
|
31
|
+
desktop: { hint: '你正在用官方桌面版(连接器已装进它的档案)', docAnchor: '安装:官方桌面版' },
|
|
32
|
+
web: { hint: '你正在用命令行 web 版', docAnchor: '安装:命令行 web 版' },
|
|
33
|
+
unknown: { hint: '无法识别当前宿主;若连不上,请按"排查"一节处理', docAnchor: '排查:连不上时看这里' },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 本机 DSH 侧的诊断块(老字段一个不少,新字段只增)。 */
|
|
37
|
+
function dshBlock(agent) {
|
|
38
|
+
const errorKind = agent.dshError ? (agent.dsh?.errorKind ?? 'unreachable') : null
|
|
39
|
+
const entry = errorKind ? DSH_HINTS[errorKind] : undefined
|
|
40
|
+
return {
|
|
41
|
+
endpoint: agent.dsh?.endpoint?.base ?? null,
|
|
42
|
+
source: agent.dsh?.endpoint?.source ?? null,
|
|
43
|
+
port: agent.dsh?.endpoint?.port ?? null,
|
|
44
|
+
authenticated: Boolean(agent.dsh?.cookie),
|
|
45
|
+
muxUp: Boolean(agent.dsh?.muxReady),
|
|
46
|
+
error: agent.dshError ?? null,
|
|
47
|
+
// 新增(只增不改):失败分类 + 用户可读的一句话 + 文档锚点。
|
|
48
|
+
errorKind,
|
|
49
|
+
hint: entry?.hint ?? null,
|
|
50
|
+
docAnchor: entry?.docAnchor ?? null,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {import('./link.js').MobileLinkAgent} agent
|
|
56
|
+
* @returns {Record<string, unknown>} 可直接序列化给 `/status` 的对象
|
|
57
|
+
*/
|
|
58
|
+
export function statusSnapshot(agent) {
|
|
59
|
+
const hostEntry = HOST_HINTS[agent.hostForm] ?? HOST_HINTS.unknown
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
enabled: agent.enabled !== false,
|
|
63
|
+
// 新增(只增不改):宿主形态 + 它对应的一句话与文档锚点,
|
|
64
|
+
// 决定用户该按哪条说明安装/排障(FR-012)。
|
|
65
|
+
host: agent.hostForm,
|
|
66
|
+
hostHint: hostEntry.hint,
|
|
67
|
+
hostDocAnchor: hostEntry.docAnchor,
|
|
68
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
69
|
+
// Same facts as `_link/hello`, which is the channel the app actually uses:
|
|
70
|
+
// this route only exists on the direct path.
|
|
71
|
+
serverVersion: SERVER_VERSION,
|
|
72
|
+
capabilities: SERVER_CAPABILITIES,
|
|
73
|
+
enroll: {
|
|
74
|
+
// A phone (or the user's browser) can read this to find out that the
|
|
75
|
+
// computer half is installed but not yet registered, and what to run.
|
|
76
|
+
registered: !agent.needsEnroll,
|
|
77
|
+
needsEnroll: agent.needsEnroll,
|
|
78
|
+
stateFile: agent.identity?.stateFile ?? agent.config.stateFile ?? null,
|
|
79
|
+
relayUrl: agent.config.relayUrl ?? null,
|
|
80
|
+
command: agent.needsEnroll ? agent.enrollHint() : null,
|
|
81
|
+
hint: agent.needsEnroll
|
|
82
|
+
? '这台电脑还没有登记到中转。请带上邀请码运行一次登记命令,然后重启 DSH。'
|
|
83
|
+
: null,
|
|
84
|
+
},
|
|
85
|
+
state: agent.state,
|
|
86
|
+
connected: agent.state === 'connected',
|
|
87
|
+
relayUrl: agent.identity?.relayUrl ?? agent.config.relayUrl ?? null,
|
|
88
|
+
agentId: agent.identity?.agentId ?? agent.config.agentId ?? null,
|
|
89
|
+
agentName: agent.identity?.agentName ?? null,
|
|
90
|
+
stateFile: agent.identity?.stateFile ?? agent.config.stateFile ?? null,
|
|
91
|
+
dsh: dshBlock(agent),
|
|
92
|
+
devices: agent.router.snapshot(),
|
|
93
|
+
deviceCount: agent.devices.size,
|
|
94
|
+
openStreams: agent.streams.size,
|
|
95
|
+
pendingWaterfalls: agent.dedupe.size,
|
|
96
|
+
lastError: agent.failure ?? null,
|
|
97
|
+
startedAt: agent.startedAt ?? null,
|
|
98
|
+
connectedAt: agent.connectedAt ?? null,
|
|
99
|
+
reconnectAttempts: agent.reconnectAttempts,
|
|
100
|
+
}
|
|
101
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-link",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|