dsh-plugin-mobile-link 0.3.0 → 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 +30 -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 +102 -48
- package/lib/pairing.js +1 -1
- package/lib/router.js +288 -4
- 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,13 +12,60 @@
|
|
|
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
|
|
|
38
|
+
/**
|
|
39
|
+
* 手机不在时一直替它留 `$events`,直到设备被撤销或进程退出。
|
|
40
|
+
*
|
|
41
|
+
* 为什么需要:手机是唯一客户端时,它一断,DSH 那边就没人持有这条流了——
|
|
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`)。
|
|
49
|
+
*
|
|
50
|
+
* 代价是这段时间里 DSH 认为"设备还在",会话的 Agent Context 不会因此释放;
|
|
51
|
+
* 也就是"挂着的提问所在会话的 Agent Context 长期不释放"。owner 已裁定这不是
|
|
52
|
+
* 风险(电脑一直运行),所以不加上限、也不为此写额外的清理机制。
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/** 离线期间最多替一台设备攒多少条 `$events` 项(只攒 waterfall/cancel)。 */
|
|
56
|
+
export const DEFAULT_EVENTS_BACKLOG = 50
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 手机不在时要攒下来的 `$events` 项:只有"待回答的提问"和"提问作废"两种。
|
|
60
|
+
*
|
|
61
|
+
* 别的一律不攒——emit 类的都是"某事刚发生"的通知,几分钟后补发只会让 App
|
|
62
|
+
* 对着旧事件发通知(例如一条早就结束的 turn)。
|
|
63
|
+
*/
|
|
64
|
+
export function isBufferedEvent(value) {
|
|
65
|
+
if (!value || typeof value !== 'object') return false
|
|
66
|
+
return value.type === 'waterfall' || value.type === 'cancel'
|
|
67
|
+
}
|
|
68
|
+
|
|
22
69
|
export function messageOf(error) {
|
|
23
70
|
return error instanceof Error ? error.message : String(error)
|
|
24
71
|
}
|
|
@@ -34,8 +81,15 @@ export class DeviceRouter {
|
|
|
34
81
|
* @param {(deviceId: string, frame: object) => boolean} options.send deliver one frame to one device
|
|
35
82
|
* @param {() => void} [options.onChange] called whenever the status snapshot changes
|
|
36
83
|
*/
|
|
37
|
-
constructor({
|
|
84
|
+
constructor({
|
|
85
|
+
dsh, send, logger = console, onChange = () => {}, now = Date.now, protocolVersion = 1,
|
|
86
|
+
eventsBacklog = DEFAULT_EVENTS_BACKLOG, notify = () => {},
|
|
87
|
+
} = {}) {
|
|
38
88
|
this.dsh = dsh
|
|
89
|
+
this.eventsBacklog = eventsBacklog
|
|
90
|
+
// 「跑完了 / 待你回应」这类事件到了就报一声(见 `notifyFor`)。默认空函数,
|
|
91
|
+
// 测试与不关心提醒的调用方可以不管它。帧由 link.js 发(router 不发帧)。
|
|
92
|
+
this.notify = notify
|
|
39
93
|
// Reported to the app so it can tell which wire revision it is talking to.
|
|
40
94
|
this.protocolVersion = protocolVersion
|
|
41
95
|
this.send = send
|
|
@@ -47,6 +101,13 @@ export class DeviceRouter {
|
|
|
47
101
|
this.dedupe = new WaterfallDedupe({ now })
|
|
48
102
|
this.lastError = undefined
|
|
49
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
|
+
})
|
|
50
111
|
this.git = new GitBridge(logger)
|
|
51
112
|
}
|
|
52
113
|
|
|
@@ -72,6 +133,18 @@ export class DeviceRouter {
|
|
|
72
133
|
await this.detachDevice(deviceId, frame.reason)
|
|
73
134
|
return
|
|
74
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
|
+
}
|
|
75
148
|
if (!deviceId) return
|
|
76
149
|
const problem = deviceFrameError(frame)
|
|
77
150
|
if (problem) {
|
|
@@ -91,16 +164,110 @@ export class DeviceRouter {
|
|
|
91
164
|
case 'eventResult':
|
|
92
165
|
await this.handleEventResult(deviceId, frame)
|
|
93
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
|
|
94
179
|
default:
|
|
95
180
|
/* Unknown frame types are ignored by design (spec §3). */
|
|
96
181
|
return
|
|
97
182
|
}
|
|
98
183
|
}
|
|
99
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
|
+
|
|
100
256
|
// ── device lifecycle ────────────────────────────────────────────────────
|
|
101
257
|
|
|
102
258
|
async attachDevice(deviceId, info = {}) {
|
|
103
|
-
if (!nonEmptyString(deviceId)
|
|
259
|
+
if (!nonEmptyString(deviceId)) return
|
|
260
|
+
const existing = this.devices.get(deviceId)
|
|
261
|
+
if (existing) {
|
|
262
|
+
// 回头客:手机又连上了。流和攒下的提问都还在,交给 handleOpen 补发。
|
|
263
|
+
if (existing.offlineSince !== undefined) {
|
|
264
|
+
existing.offlineSince = undefined
|
|
265
|
+
this.logger.info?.(
|
|
266
|
+
`mobile-link: device ${deviceId} reattached with ${existing.eventsPending.size} unanswered question(s)`)
|
|
267
|
+
this.#changed()
|
|
268
|
+
}
|
|
269
|
+
return
|
|
270
|
+
}
|
|
104
271
|
this.devices.set(deviceId, {
|
|
105
272
|
deviceId,
|
|
106
273
|
name: info?.name,
|
|
@@ -110,18 +277,65 @@ export class DeviceRouter {
|
|
|
110
277
|
readyValue: undefined,
|
|
111
278
|
eventsMuxId: undefined,
|
|
112
279
|
dlpIds: new Set(),
|
|
280
|
+
// 手机不在时的"值班"状态:offlineSince 有值 = 这条流在替它留着;
|
|
281
|
+
// eventsPending 是**还没被回答**的提问(按 eventId 存)。
|
|
282
|
+
// 注意它不是一个"送一次就清空"的队列:送过也要留着,否则 App 再重启一次
|
|
283
|
+
// (或崩一次)这条提问就永远丢了——2026-09-22 验收时正是这么发现的。
|
|
284
|
+
offlineSince: undefined,
|
|
285
|
+
eventsPending: new Map(),
|
|
113
286
|
})
|
|
114
287
|
this.logger.info?.(`mobile-link: device ${deviceId} attached (${info?.name ?? 'unknown'})`)
|
|
115
288
|
this.#changed()
|
|
116
289
|
await this.ensureEventsStream(deviceId)
|
|
117
290
|
}
|
|
118
291
|
|
|
292
|
+
/**
|
|
293
|
+
* 手机断了。
|
|
294
|
+
*
|
|
295
|
+
* `reason === 'revoked'`(中转发来的"这台设备被撤销了")是**放下**:撤掉它全部的流、
|
|
296
|
+
* 删掉记录——不再有人会回来拿那些提问了,继续值班只是白占一条流。
|
|
297
|
+
*
|
|
298
|
+
* 别的 reason(socket closed、backpressure…)一律是"手机暂时不在":普通流(文件、
|
|
299
|
+
* 会话流)立刻撤,`$events` 留着替它值班——**一直留**,没有时限(见 `$events`
|
|
300
|
+
* 那段的说明)。这也是「撤销即放下」是唯一出口的原因。
|
|
301
|
+
*/
|
|
119
302
|
async detachDevice(deviceId, reason) {
|
|
303
|
+
const record = this.devices.get(deviceId)
|
|
304
|
+
if (!record) return
|
|
305
|
+
if (reason === 'revoked') {
|
|
306
|
+
await this.#dropDevice(deviceId)
|
|
307
|
+
this.logger.info?.(`mobile-link: device ${deviceId} 已被撤销,放下(不再替它值班)`)
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
// 除了 `$events`,其它流(文件、会话流)立刻撤掉:它们没有"值班"的意义,
|
|
311
|
+
// 留着只会占资源。`$events` 留着,见上面那段说明。
|
|
312
|
+
const keep = []
|
|
313
|
+
for (const entry of this.streams.byDevice(deviceId)) {
|
|
314
|
+
if (entry.endpoint === EVENTS_ENDPOINT) {
|
|
315
|
+
keep.push(entry)
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
this.streams.removeByMux(entry.muxId)
|
|
319
|
+
this.dsh.cancelStream(entry.muxId)
|
|
320
|
+
}
|
|
321
|
+
record.dlpIds.clear()
|
|
322
|
+
for (const entry of keep) record.dlpIds.add(entry.dlpId)
|
|
323
|
+
|
|
324
|
+
if (record.offlineSince === undefined) record.offlineSince = this.now()
|
|
325
|
+
this.logger.info?.(
|
|
326
|
+
`mobile-link: device ${deviceId} detached (${reason ?? 'closed'});`
|
|
327
|
+
+ '$events 流一直替它留着,直到设备被撤销或进程退出')
|
|
328
|
+
this.#changed()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 真的把这台设备放下:撤全部流、删记录。唯一调用点是撤销与进程收尾。 */
|
|
332
|
+
async #dropDevice(deviceId) {
|
|
120
333
|
const record = this.devices.get(deviceId)
|
|
121
334
|
if (!record) return
|
|
122
335
|
for (const muxId of this.streams.removeDevice(deviceId)) this.dsh.cancelStream(muxId)
|
|
123
336
|
this.devices.delete(deviceId)
|
|
124
|
-
this.logger.info?.(
|
|
337
|
+
this.logger.info?.(
|
|
338
|
+
`mobile-link: 放下设备 ${deviceId}(丢掉 ${record.eventsPending.size} 条没人回答的提问)`)
|
|
125
339
|
this.#changed()
|
|
126
340
|
}
|
|
127
341
|
|
|
@@ -253,6 +467,17 @@ export class DeviceRouter {
|
|
|
253
467
|
if (record.readyValue !== undefined) {
|
|
254
468
|
this.send(deviceId, { t: 'item', id: frame.id, value: record.readyValue })
|
|
255
469
|
}
|
|
470
|
+
// 还没被回答的提问在这里补发:ready 已经发过,所以 App 拿得到 clientId,
|
|
471
|
+
// 补发的提问是可以被回答的。**送出去不清空**——App 可能又崩/又重启,
|
|
472
|
+
// 只要还没人回答,下次连上还得再给一遍(App 侧按 eventId 去重)。
|
|
473
|
+
if (record.eventsPending.size > 0) {
|
|
474
|
+
const pending = [...record.eventsPending.values()]
|
|
475
|
+
this.logger.info?.(
|
|
476
|
+
`mobile-link: replaying ${pending.length} unanswered question(s) to ${deviceId}`)
|
|
477
|
+
for (const value of pending) {
|
|
478
|
+
this.send(deviceId, { t: 'item', id: frame.id, value })
|
|
479
|
+
}
|
|
480
|
+
}
|
|
256
481
|
return
|
|
257
482
|
}
|
|
258
483
|
const muxId = `s:${deviceId}:${frame.id}`
|
|
@@ -302,6 +527,8 @@ export class DeviceRouter {
|
|
|
302
527
|
})
|
|
303
528
|
return
|
|
304
529
|
}
|
|
530
|
+
// 答过了就不再是"待答":删掉它,免得下次重连又把已经回答过的问题推给 App。
|
|
531
|
+
record.eventsPending.delete(eventId)
|
|
305
532
|
// The clientId is always the one from *this device's* $events ready frame
|
|
306
533
|
// (spec §4.1.5); any clientId the device echoed is ignored on purpose.
|
|
307
534
|
if (!this.dedupe.claim(eventId)) return
|
|
@@ -339,6 +566,31 @@ export class DeviceRouter {
|
|
|
339
566
|
forwardEvent(deviceId, value) {
|
|
340
567
|
const record = this.devices.get(deviceId)
|
|
341
568
|
if (!record) return
|
|
569
|
+
// 只有"待回答的提问"值得留:别的都是"某事刚发生"的通知,补发只会让 App
|
|
570
|
+
// 对着几分钟前的事件发通知(例如一条早就结束的 turn)。
|
|
571
|
+
// 待答表:不管手机在不在都记下来,直到**有人回答**或 host 作废它。
|
|
572
|
+
// 只记在线时的转发是不够的——App 收下提问后崩一次/重启一次,那条提问就再也没人
|
|
573
|
+
// 记得了(host 认为已经投给这条流了,不会重发)。连接器记着,重连时再给一遍。
|
|
574
|
+
if (nonEmptyString(value?.eventId)) {
|
|
575
|
+
if (value.type === 'cancel') record.eventsPending.delete(value.eventId)
|
|
576
|
+
else if (value.type === 'waterfall') {
|
|
577
|
+
record.eventsPending.set(value.eventId, value)
|
|
578
|
+
if (record.eventsPending.size > this.eventsBacklog) {
|
|
579
|
+
// 超上限时丢最老的(Map 保持插入顺序)。
|
|
580
|
+
const oldest = record.eventsPending.keys().next().value
|
|
581
|
+
record.eventsPending.delete(oldest)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// 「报一声」与「要不要攒」是两件事:攒只对 waterfall/cancel,报的是"跑完了"与
|
|
586
|
+
// "待你回应"两类。这里放在离线早退**之前**,所以在不在线都会报——
|
|
587
|
+
// 手机在线时它自己会弹本地通知,这条 notify 到了中转会被判"在线不推"(不然重复)。
|
|
588
|
+
this.notifyFor(value)
|
|
589
|
+
if (record.offlineSince !== undefined && isBufferedEvent(value)) {
|
|
590
|
+
this.logger.debug?.(
|
|
591
|
+
`mobile-link: kept ${value.type} ${value.event ?? value.eventId ?? ''} for offline ${deviceId}`)
|
|
592
|
+
return
|
|
593
|
+
}
|
|
342
594
|
if (value && value.type === 'ready' && nonEmptyString(value.clientId)) {
|
|
343
595
|
record.clientId = value.clientId
|
|
344
596
|
record.readyValue = value
|
|
@@ -350,6 +602,36 @@ export class DeviceRouter {
|
|
|
350
602
|
if (record.dlpIds.size === 0) this.send(deviceId, { t: 'event', value })
|
|
351
603
|
}
|
|
352
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
|
+
|
|
353
635
|
onStreamError(muxId, error) {
|
|
354
636
|
const entry = this.streams.entryOfMux(muxId)
|
|
355
637
|
if (!entry) return
|
|
@@ -411,6 +693,8 @@ export class DeviceRouter {
|
|
|
411
693
|
// Which build that phone reported on its last handshake.
|
|
412
694
|
client: record.client ?? null,
|
|
413
695
|
connectedAt: record.connectedAt,
|
|
696
|
+
offlineSince: record.offlineSince ?? null,
|
|
697
|
+
unansweredEvents: record.eventsPending.size,
|
|
414
698
|
eventsReady: Boolean(record.clientId),
|
|
415
699
|
openStreams: record.dlpIds.size,
|
|
416
700
|
}))
|
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",
|