dsh-plugin-mobile-link 0.7.0 → 0.8.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/lib/link.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import { EventEmitter } from 'node:events'
8
8
 
9
9
  import { DshClient } from './dsh-client.js'
10
- import { agentEndpoint, decodeFrame, encodeFrameChecked, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
10
+ import { agentEndpoint, decodeFrame, deviceListEndpoint, deviceRevokeEndpoint, encodeFrameChecked, isPlaceholderRelayUrl, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
11
11
 
12
12
  /**
13
13
  * 进程级登记表:**同一个进程里只允许有一条到中转的 agent 连接**。
@@ -18,7 +18,7 @@ import { agentEndpoint, decodeFrame, encodeFrameChecked, nextBackoff, normalizeR
18
18
  * 挂在 `globalThis` 上是因为模块重载会把模块作用域清掉,只有进程级才留得住。
19
19
  */
20
20
  const LIVE_AGENT = Symbol.for('dsh.mobile-link.live-agent')
21
- import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
21
+ import { EnrollError, enrollAgent, enrollCommand, inviteFrom, reportMachineAddress } from './enroll.js'
22
22
  import { handoffFromEndpoint, writeHandoff } from './handoff.js'
23
23
  import { LinkJournal, defaultJournalPath } from './journal.js'
24
24
  import { LogSpool, defaultLogsRoot } from './logs.js'
@@ -30,7 +30,7 @@ import {
30
30
  } from './router.js'
31
31
  import { SERVER_VERSION, SERVER_CAPABILITIES, hostVersion } from './hello.js'
32
32
  import { createJobsRoster } from './jobs-roster.js'
33
- import { MissingIdentity, resolveIdentity } from './state.js'
33
+ import { MissingIdentity, defaultStatePath, readState, resolveIdentity } from './state.js'
34
34
  import { connect as wsConnect } from './ws.js'
35
35
 
36
36
  const PROTOCOL_VERSION = 1
@@ -64,7 +64,7 @@ export class MobileLinkAgent extends EventEmitter {
64
64
  slowRequestMs = DEFAULT_SLOW_REQUEST_MS,
65
65
  // 关掉手机推送的接收(回退闸);见 contracts/connector-diagnostics-method.md §4。
66
66
  diagnostics = true,
67
- inviteCode, fetchImpl = fetch, env = process.env,
67
+ inviteCode, defaultRelayUrl, fetchImpl = fetch, env = process.env,
68
68
  // 生命周期流水(见 journal.js):`journal` 传实例(测试注入),`journalPath`
69
69
  // 传路径(`defaultJournalPath()` 之外的位置),false 表示只留内存不落盘。
70
70
  journal, journalPath,
@@ -76,7 +76,7 @@ export class MobileLinkAgent extends EventEmitter {
76
76
  eventsBacklog = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_BACKLOG, DEFAULT_EVENTS_BACKLOG),
77
77
  } = {}) {
78
78
  super()
79
- this.config = { relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, inviteCode }
79
+ this.config = { relayUrl, defaultRelayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, inviteCode }
80
80
  this.logger = logger
81
81
  this.heartbeatMs = heartbeatMs
82
82
  this.pongTimeoutMs = pongTimeoutMs
@@ -173,6 +173,8 @@ export class MobileLinkAgent extends EventEmitter {
173
173
  this._lastPong = 0
174
174
  this._loop = undefined
175
175
  this._loopActive = false
176
+ /** Single-flight guard for panel enrollment: two clicks redeem one code. */
177
+ this._enrolling = undefined
176
178
  this._watchdog = undefined
177
179
  this._lastProgressAt = 0
178
180
  this._abort = new AbortController()
@@ -285,7 +287,26 @@ export class MobileLinkAgent extends EventEmitter {
285
287
  }
286
288
  }
287
289
  globalThis[LIVE_AGENT] = this
288
- if (this._loop) return this
290
+ // 只认"正在跑"的 loop:loop 可能因为未登记而结束,此时 `_loop` 还留着已完成的
291
+ // promise;拿它当"已在运行"会让面板登记完之后再也起不来。
292
+ if (this._loopActive) return this
293
+ // 显式 start 表示"要跑":把 stop() 留下的栅栏放下来;不要靠 #launch 里
294
+ // 那个"正在停止就别启动"的保护来代劳。
295
+ this._stopping = false
296
+ this.#launch()
297
+ return this
298
+ }
299
+
300
+ /**
301
+ * Start one run of the reconnect loop.
302
+ *
303
+ * The single owner of "how a loop starts": `start()` calls it on boot and
304
+ * `enroll()` calls it after the identity is written. Never copy the loop
305
+ * bootstrapping into a second path — a divergent copy would skip the watchdog,
306
+ * the journal entry or the LIVE_AGENT bookkeeping.
307
+ */
308
+ #launch() {
309
+ if (this._stopping) return
289
310
  this._stopping = false
290
311
  this._abort = new AbortController()
291
312
  this.startedAt = this.now()
@@ -311,7 +332,6 @@ export class MobileLinkAgent extends EventEmitter {
311
332
  }).finally(() => {
312
333
  this._loopActive = false
313
334
  })
314
- return this
315
335
  }
316
336
 
317
337
  async stop() {
@@ -397,7 +417,13 @@ export class MobileLinkAgent extends EventEmitter {
397
417
  * starts) and because `status()` reports whether it is still outstanding.
398
418
  */
399
419
  async enrollNow() {
400
- const relayUrl = this.config.relayUrl
420
+ // A packaged install may pin only `defaultRelayUrl` (the panel prefill) and
421
+ // leave `relayUrl` at the placeholder; boot-time enrollment should still use
422
+ // the default rather than dial example.com.
423
+ const configured = this.config.relayUrl
424
+ const relayUrl = configured && !isPlaceholderRelayUrl(configured)
425
+ ? configured
426
+ : (this.config.defaultRelayUrl || configured)
401
427
  const settled = await enrollAgent({
402
428
  relayUrl,
403
429
  inviteCode: inviteFrom({ inviteCode: this.config.inviteCode, env: this.env }),
@@ -406,15 +432,151 @@ export class MobileLinkAgent extends EventEmitter {
406
432
  fetchImpl: this.fetchImpl,
407
433
  logger: this.logger,
408
434
  })
435
+ this.identity = settled
409
436
  this.needsEnroll = false
410
437
  this.lastError = undefined
411
438
  this.emit('status')
412
439
  return settled
413
440
  }
414
441
 
442
+ /**
443
+ * Redeem an invite code supplied by the panel, then start the link in place.
444
+ *
445
+ * This is the only path that may enroll *while the process is running*. It is
446
+ * single-flight (a double click must not consume two codes), refuses to replace
447
+ * an existing identity, and writes nothing on failure. On success the loop is
448
+ * relaunched here, so the person does not have to restart DSH.
449
+ *
450
+ * @param {{inviteCode: string, relayUrl: string, agentName?: string}} options
451
+ */
452
+ enroll({ inviteCode, relayUrl, agentName } = {}) {
453
+ if (this._enrolling) return this._enrolling
454
+ this._enrolling = this.#enrollOnce({ inviteCode, relayUrl, agentName })
455
+ .finally(() => { this._enrolling = undefined })
456
+ return this._enrolling
457
+ }
458
+
459
+ async #enrollOnce({ inviteCode, relayUrl, agentName }) {
460
+ if (this.enabled === false) {
461
+ throw new EnrollError('enroll/disabled', 'mobile-link: the connector is disabled; enable it before enrolling')
462
+ }
463
+ if (this._stopping) {
464
+ throw new EnrollError('enroll/unavailable', 'mobile-link: the connector is stopping; try again')
465
+ }
466
+ const stateFile = this.config.stateFile || defaultStatePath()
467
+ const existing = await readState(stateFile)
468
+ if (existing?.agentId && existing?.agentSecret) {
469
+ // Never replace a working identity behind the user's back: that would
470
+ // silently unpair every phone that is already attached.
471
+ throw new EnrollError('enroll/already-registered',
472
+ `mobile-link: this computer is already registered (${existing.agentId}); refusing to replace it`,
473
+ { status: 409 })
474
+ }
475
+
476
+ const settled = await enrollAgent({
477
+ relayUrl,
478
+ inviteCode,
479
+ name: agentName,
480
+ stateFile,
481
+ fetchImpl: this.fetchImpl,
482
+ logger: this.logger,
483
+ })
484
+
485
+ // Publish the fresh identity before relaunching: `#enrollPending()` consults
486
+ // `this.identity`, and a stale value would make the new loop redeem the
487
+ // configured/env invite a second time on top of what the panel just wrote.
488
+ this.identity = settled
489
+ this.needsEnroll = false
490
+ this.lastError = undefined
491
+ this.reconnectAttempts = 0
492
+ this.emit('status')
493
+
494
+ if (this.enabled !== false && !this._stopping) {
495
+ // If the previous loop is unwinding, let it finish before starting a new
496
+ // one; two loops would fight over the same relay agent session.
497
+ if (this._loopActive) await this._loop?.catch(() => {})
498
+ if (!this._loopActive && !this._stopping) this.#launch()
499
+ }
500
+ return settled
501
+ }
502
+
503
+ /**
504
+ * List every paired device for this agent, including ones that are offline.
505
+ * The router only knows attached devices; the panel needs the full roster so
506
+ * "已配对 N/M" and the visible rows describe the same set.
507
+ */
508
+ async listPairedDevices() {
509
+ const identity = this.identity ?? await resolveIdentity(this.config)
510
+ let response
511
+ try {
512
+ response = await this.fetchImpl(deviceListEndpoint(identity.relayUrl), {
513
+ method: 'GET',
514
+ headers: { authorization: `Bearer ${identity.agentSecret}` },
515
+ signal: AbortSignal.timeout?.(15_000),
516
+ })
517
+ } catch (error) {
518
+ throw new Error(`mobile-link: cannot reach the relay to list devices: ${messageOf(error)}`)
519
+ }
520
+ let body
521
+ try {
522
+ body = await response.json()
523
+ } catch {
524
+ throw new Error(`mobile-link: the relay answered HTTP ${response.status} to /devices`)
525
+ }
526
+ if (!response.ok || body?.ok !== true) {
527
+ throw new Error(body?.error?.message ?? `mobile-link: the relay rejected the device list (HTTP ${response.status})`)
528
+ }
529
+ return {
530
+ devices: Array.isArray(body.devices) ? body.devices : [],
531
+ maxPairedDevices: body.maxPairedDevices ?? null,
532
+ pairedDevices: body.pairedDevices ?? null,
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Revoke one paired device through the relay, authenticated with this agent's
538
+ * secret. The relay enforces that the device belongs to this agent and drops
539
+ * its socket; the local router learns about it via `deviceDetach`.
540
+ *
541
+ * @param {string} deviceId
542
+ */
543
+ async revokeDevice(deviceId) {
544
+ const id = typeof deviceId === 'string' ? deviceId.trim() : ''
545
+ if (!id) throw new Error('mobile-link: a deviceId is required')
546
+ const identity = this.identity ?? await resolveIdentity(this.config)
547
+ let response
548
+ try {
549
+ response = await this.fetchImpl(deviceRevokeEndpoint(identity.relayUrl), {
550
+ method: 'POST',
551
+ headers: {
552
+ 'content-type': 'application/json',
553
+ authorization: `Bearer ${identity.agentSecret}`,
554
+ },
555
+ body: JSON.stringify({ deviceId: id }),
556
+ signal: AbortSignal.timeout?.(15_000),
557
+ })
558
+ } catch (error) {
559
+ throw new Error(`mobile-link: cannot reach the relay to revoke ${id}: ${messageOf(error)}`)
560
+ }
561
+ let body
562
+ try {
563
+ body = await response.json()
564
+ } catch {
565
+ throw new Error(`mobile-link: the relay answered HTTP ${response.status} to /devices/revoke`)
566
+ }
567
+ if (!response.ok || body?.ok !== true) {
568
+ throw new Error(body?.error?.message ?? `mobile-link: the relay rejected the revoke (HTTP ${response.status})`)
569
+ }
570
+ return { deviceId: body.deviceId ?? id }
571
+ }
572
+
415
573
  /** What a person has to run to register this computer. */
416
574
  enrollHint() {
417
- return enrollCommand(this.config.relayUrl, this.config.agentName)
575
+ const configured = this.config.relayUrl
576
+ const relay = configured && !isPlaceholderRelayUrl(configured)
577
+ ? configured
578
+ : (this.config.defaultRelayUrl || configured)
579
+ return enrollCommand(relay, this.config.agentName)
418
580
  }
419
581
 
420
582
  /** One relay connection: resolves when the socket closes. */
@@ -422,6 +584,7 @@ export class MobileLinkAgent extends EventEmitter {
422
584
  const identity = await resolveIdentity(this.config)
423
585
  this.identity = identity
424
586
  this.needsEnroll = false
587
+ this.#backfillMachine(identity)
425
588
  this.relay = normalizeRelayUrl(identity.relayUrl)
426
589
  const url = `${agentEndpoint(identity.relayUrl)}?agentId=${encodeURIComponent(identity.agentId)}`
427
590
  const socket = await this.connectImpl(url, {
@@ -686,6 +849,27 @@ export class MobileLinkAgent extends EventEmitter {
686
849
 
687
850
  // ── mux-event delegates ─────────────────────────────────────────────────
688
851
 
852
+ /// 老身份在登记时还没有 MAC 字段:连接成功后补一次。只填一次,失败下轮再试,
853
+ /// 旧中转 404 视为不支持而不是链路故障。
854
+ #backfillMachine(identity) {
855
+ if (this._machineReportedFor === identity.agentId || this._machineReporting) return
856
+ this._machineReporting = true
857
+ void reportMachineAddress({
858
+ relayUrl: identity.relayUrl,
859
+ agentSecret: identity.agentSecret,
860
+ fetchImpl: this.fetchImpl,
861
+ }).then((result) => {
862
+ if (result.ok || result.unsupported) this._machineReportedFor = identity.agentId
863
+ if (result.ok && result.updated) {
864
+ this.logger.info?.(`mobile-link: recorded machine MAC ${result.macAddress}`)
865
+ }
866
+ }).catch((error) => {
867
+ this.logger.debug?.(`mobile-link: machine MAC backfill deferred: ${messageOf(error)}`)
868
+ }).finally(() => {
869
+ this._machineReporting = false
870
+ })
871
+ }
872
+
689
873
  onStreamItem(muxId, value) {
690
874
  this.router.onStreamItem(muxId, value)
691
875
  }
package/lib/router.js CHANGED
@@ -658,9 +658,10 @@ export class DeviceRouter {
658
658
  const since = typeof args.since === 'string' ? args.since : null
659
659
  this.digestCursors ??= new Map()
660
660
  const previous = since ? this.digestCursors.get(deviceId) : null
661
- // 版本对得上才发增量;对不上(首次、连接器重启、设备被清理)发全量。
662
- const incremental = Boolean(previous && previous.version === since)
663
- const delta = digestDelta(incremental ? previous.fingerprints : null, items)
661
+ // 版本对得上才认那份指纹表;对不上(首次、连接器重启、设备被清理)就当没有历史。
662
+ // 能不能当增量发由 digestDelta 决定:它持有指纹,也只有它能判断身份是否可识别。
663
+ const usable = previous && previous.version === since ? previous : null
664
+ const delta = digestDelta(usable ? usable.fingerprints : null, items)
664
665
  this.digestCursors.set(deviceId, { version: delta.version, fingerprints: delta.fingerprints })
665
666
  if (this.digestCursors.size > 32) {
666
667
  const oldest = this.digestCursors.keys().next().value
@@ -668,10 +669,10 @@ export class DeviceRouter {
668
669
  }
669
670
  const value = {
670
671
  ...trimmed,
671
- items: incremental ? delta.changed : items,
672
- removed: incremental ? delta.removed : [],
672
+ items: delta.changed,
673
+ removed: delta.removed,
673
674
  version: delta.version,
674
- full: !incremental,
675
+ full: delta.full,
675
676
  }
676
677
  payload = { ok: true, value: encodeDigest(value, args.encoding) }
677
678
  }
package/lib/routes.js CHANGED
@@ -86,6 +86,31 @@ function ttlFrom(value, fallbackMs) {
86
86
  : fallbackMs
87
87
  }
88
88
 
89
+ /** Map an enrollment error code onto the HTTP status the panel sees. */
90
+ export function enrollStatus(code) {
91
+ switch (code) {
92
+ case 'enroll/already-registered':
93
+ case 'enroll/disabled':
94
+ return 409
95
+ case 'enroll/timeout':
96
+ return 504
97
+ case 'enroll/rate-limited':
98
+ return 429
99
+ case 'enroll/state-write':
100
+ case 'enroll/internal':
101
+ return 500
102
+ case 'enroll/bad-request':
103
+ case 'enroll/invalid-relay':
104
+ case 'enroll/placeholder-relay':
105
+ case 'enroll/unknown':
106
+ case 'enroll/used':
107
+ case 'enroll/expired':
108
+ return 400
109
+ default:
110
+ return 502
111
+ }
112
+ }
113
+
89
114
  /**
90
115
  * Register the routes.
91
116
  * @returns {Array<() => void>} disposers (already wrapped by the caller in ctx.effect).
@@ -93,6 +118,9 @@ function ttlFrom(value, fallbackMs) {
93
118
  export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, history }) {
94
119
  const guard = makeGuard(ctx, logger)
95
120
  const STATUS_ROUTE = '/mobile-link/status'
121
+ const ENROLL_ROUTE = '/mobile-link/enroll'
122
+ const REVOKE_ROUTE = '/mobile-link/devices/revoke'
123
+ const DEVICES_ROUTE = '/mobile-link/devices'
96
124
  const PAIR_ROUTE = '/mobile-link/pair-code'
97
125
  const REGENERATE_ROUTE = '/mobile-link/pair-code/regenerate'
98
126
  const disposers = []
@@ -116,6 +144,125 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, hist
116
144
  },
117
145
  }))
118
146
 
147
+ disposers.push(ctx.webServer.register({
148
+ kind: 'exact',
149
+ path: ENROLL_ROUTE,
150
+ handler: async (req, res) => {
151
+ if (guard(req, res, { requireFence: true })) return
152
+ if (req.method !== 'POST') {
153
+ res.statusCode = 405
154
+ res.setHeader('allow', 'POST')
155
+ res.end()
156
+ return
157
+ }
158
+ const body = await readJsonBody(req)
159
+ if (body === undefined) {
160
+ sendJson(res, 400, { ok: false, code: 'enroll/bad-request', message: 'invalid JSON body' })
161
+ return
162
+ }
163
+ const relayUrl = typeof body.relayUrl === 'string' ? body.relayUrl.trim() : ''
164
+ const inviteCode = typeof body.inviteCode === 'string' ? body.inviteCode.trim() : ''
165
+ const agentName = typeof body.agentName === 'string' ? body.agentName.trim() : ''
166
+ if (!relayUrl) {
167
+ sendJson(res, 400, {
168
+ ok: false,
169
+ code: 'enroll/bad-request',
170
+ message: 'relayUrl is required',
171
+ })
172
+ return
173
+ }
174
+ try {
175
+ // 开放登记:不需要邀请码;旧调用方仍可带(历史兼容)。
176
+ const settled = await agent.enroll({
177
+ inviteCode: inviteCode || undefined,
178
+ relayUrl,
179
+ agentName: agentName || undefined,
180
+ })
181
+ logger.info?.(`mobile-link: panel enrollment succeeded for ${settled.agentId}`)
182
+ // Never return `agentSecret`: the browser has no use for it, and one
183
+ // XSS in the panel would otherwise hand over the machine's credential.
184
+ sendJson(res, 200, {
185
+ ok: true,
186
+ agentId: settled.agentId,
187
+ agentName: settled.agentName,
188
+ relayUrl: settled.relayUrl,
189
+ })
190
+ } catch (error) {
191
+ // Read the code off the error rather than using `instanceof`: a hot
192
+ // reload can leave the route and the link on different module
193
+ // generations, and an identity check would then downgrade a precise
194
+ // "already registered" into a generic 500.
195
+ const code = typeof error?.code === 'string' && error.code.startsWith('enroll/')
196
+ ? error.code
197
+ : 'enroll/internal'
198
+ const status = enrollStatus(code)
199
+ if (code === 'enroll/internal') {
200
+ logger.warn?.(`mobile-link: panel enrollment failed: ${String(error?.message ?? error)}`)
201
+ }
202
+ sendJson(res, status, { ok: false, code, message: String(error?.message ?? error) })
203
+ }
204
+ },
205
+ }))
206
+
207
+ disposers.push(ctx.webServer.register({
208
+ kind: 'exact',
209
+ path: DEVICES_ROUTE,
210
+ handler: async (req, res) => {
211
+ if (guard(req, res, { requireFence: true })) return
212
+ if (req.method !== 'GET') {
213
+ res.statusCode = 405
214
+ res.setHeader('allow', 'GET')
215
+ res.end()
216
+ return
217
+ }
218
+ try {
219
+ const result = await agent.listPairedDevices()
220
+ sendJson(res, 200, { ok: true, ...result })
221
+ } catch (error) {
222
+ sendJson(res, 502, {
223
+ ok: false,
224
+ code: 'devices/list-failed',
225
+ message: String(error?.message ?? error),
226
+ })
227
+ }
228
+ },
229
+ }))
230
+
231
+ disposers.push(ctx.webServer.register({
232
+ kind: 'exact',
233
+ path: REVOKE_ROUTE,
234
+ handler: async (req, res) => {
235
+ if (guard(req, res, { requireFence: true })) return
236
+ if (req.method !== 'POST') {
237
+ res.statusCode = 405
238
+ res.setHeader('allow', 'POST')
239
+ res.end()
240
+ return
241
+ }
242
+ const body = await readJsonBody(req)
243
+ if (body === undefined) {
244
+ sendJson(res, 400, { ok: false, code: 'request/bad-request', message: 'invalid JSON body' })
245
+ return
246
+ }
247
+ const deviceId = typeof body.deviceId === 'string' ? body.deviceId.trim() : ''
248
+ if (!deviceId) {
249
+ sendJson(res, 400, { ok: false, code: 'request/device-id', message: 'deviceId is required' })
250
+ return
251
+ }
252
+ try {
253
+ const result = await agent.revokeDevice(deviceId)
254
+ logger.info?.(`mobile-link: revoked device ${result.deviceId}`)
255
+ sendJson(res, 200, { ok: true, deviceId: result.deviceId })
256
+ } catch (error) {
257
+ sendJson(res, 502, {
258
+ ok: false,
259
+ code: 'devices/revoke-failed',
260
+ message: String(error?.message ?? error),
261
+ })
262
+ }
263
+ },
264
+ }))
265
+
119
266
  disposers.push(ctx.webServer.register({
120
267
  kind: 'exact',
121
268
  path: PAIR_ROUTE,
@@ -133,11 +133,16 @@ export function digestFingerprint(item) {
133
133
  }
134
134
  }
135
135
 
136
- /** 一批会话的指纹表(id → 指纹)。 */
136
+ /**
137
+ * 一批会话的指纹表(sessionId → 指纹)。
138
+ *
139
+ * 键是宿主 `SessionSummary.sessionId`——会话身份在宿主只有这一个名字。
140
+ * 这里不认 `id`:多认一个名字就是第二份身份,正是它让指纹表恒为空(2026-10-27)。
141
+ */
137
142
  export function digestFingerprints(items) {
138
143
  const map = new Map()
139
144
  for (const item of items ?? []) {
140
- if (item && typeof item.id === 'string') map.set(item.id, digestFingerprint(item))
145
+ if (item && typeof item.sessionId === 'string') map.set(item.sessionId, digestFingerprint(item))
141
146
  }
142
147
  return map
143
148
  }
@@ -153,20 +158,29 @@ export function fingerprintsVersion(map) {
153
158
  /**
154
159
  * 相对上一次指纹表的增量。
155
160
  *
156
- * 入参 `previous` 为 Map 或 null;返回 changed(新增或内容变化的项)、
157
- * removed(消失的 id)与新版本。`previous` 为空时调用方应按全量处理。
161
+ * 入参 `previous` 为 Map 或 null。返回:
162
+ * - `full`:能不能把这份结果当增量发;
163
+ * - `changed`:新增或内容变化的项(`full` 时为整份 items);
164
+ * - `removed`:消失的 sessionId(`full` 时为空);
165
+ * - `version` / `fingerprints`:下一次比较用的版本与指纹表。
166
+ *
167
+ * **身份不可识别时退化为全量**:摘要是优化,不是事实来源。只要有一项没有 `sessionId`,
168
+ * 就没法证明其余项没变;此时宁可多发一份全量,也不能回空增量——后者不报错、不更新,
169
+ * 界面看起来正常却永远冻结(2026-10-27 的现场)。
158
170
  */
159
171
  export function digestDelta(previous, items) {
160
172
  const list = Array.isArray(items) ? items : []
173
+ const identifiable = list.every((item) => item && typeof item.sessionId === 'string')
161
174
  const next = digestFingerprints(list)
175
+ const version = fingerprintsVersion(next)
176
+ if (previous === null || !identifiable) {
177
+ return { full: true, changed: list, removed: [], version, fingerprints: next }
178
+ }
162
179
  const changed = []
163
180
  for (const item of list) {
164
- if (!item || typeof item.id !== 'string') continue
165
- if (!previous || previous.get(item.id) !== next.get(item.id)) changed.push(item)
181
+ if (previous.get(item.sessionId) !== next.get(item.sessionId)) changed.push(item)
166
182
  }
167
183
  const removed = []
168
- if (previous) {
169
- for (const id of previous.keys()) if (!next.has(id)) removed.push(id)
170
- }
171
- return { changed, removed, version: fingerprintsVersion(next), fingerprints: next }
184
+ for (const sessionId of previous.keys()) if (!next.has(sessionId)) removed.push(sessionId)
185
+ return { full: false, changed, removed, version, fingerprints: next }
172
186
  }
package/lib/state.js CHANGED
@@ -106,9 +106,9 @@ export async function resolveIdentity({ stateFile, agentId, agentSecret, relayUr
106
106
  }
107
107
  if (!resolved.agentId || !resolved.agentSecret) {
108
108
  throw new MissingIdentity(
109
- `mobile-link: this computer is not registered yet. Run "dsh-mobile-link enroll`
110
- + ` --invite <code> --relay <relay-url>" with the invite code you were given, or set`
111
- + ` ${INVITE_ENV} and restart DSH. (Provisioning by hand is still possible:`
109
+ `mobile-link: this computer is not registered yet. Open the "移动端连接" panel`
110
+ + ` and click 登记, or run "dsh-mobile-link enroll --relay <relay-url>".`
111
+ + ` (Provisioning by hand is still possible:`
112
112
  + ` "dsh-relay admin agent-register --account <acc> --name <name>`
113
113
  + ` --write-config ${path}".)`,
114
114
  { stateFile: path },
package/lib/status.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * 函数只读 agent 的字段,不持有状态,因此可以直接被单测调用。
7
7
  */
8
8
 
9
+ import { isPlaceholderRelayUrl } from './dlp.js'
9
10
  import { SERVER_CAPABILITIES, SERVER_VERSION, hostVersion } from './hello.js'
10
11
 
11
12
  const PROTOCOL_VERSION = 1
@@ -81,6 +82,26 @@ const STATUS_JOURNAL_ENTRIES = 20
81
82
  */
82
83
  export function statusSnapshot(agent) {
83
84
  const hostEntry = HOST_HINTS[agent.hostForm] ?? HOST_HINTS.unknown
85
+ // One resolved view of the relay address, shared by the form and the panel:
86
+ // a configured address outranks the identity, but the repository placeholder
87
+ // is "unset", never a value to prefill. `relayLocked` says the address came
88
+ // from config/env, so the form must be read-only — otherwise the panel would
89
+ // enroll with one address and the next boot would dial another.
90
+ const configuredRelay = typeof agent.config?.relayUrl === 'string' ? agent.config.relayUrl : ''
91
+ const realConfiguredRelay = configuredRelay && !isPlaceholderRelayUrl(configuredRelay)
92
+ ? configuredRelay
93
+ : null
94
+ // `defaultRelayUrl` is a *form default*, not a dialing override: it only
95
+ // prefills the input, so the user can replace it and the identity file still
96
+ // wins on the next boot. A real `relayUrl` is the operator pin and locks it.
97
+ const configuredDefault = typeof agent.config?.defaultRelayUrl === 'string'
98
+ ? agent.config.defaultRelayUrl
99
+ : ''
100
+ const defaultRelay = configuredDefault && !isPlaceholderRelayUrl(configuredDefault)
101
+ ? configuredDefault
102
+ : null
103
+ const effectiveRelay = agent.identity?.relayUrl ?? realConfiguredRelay ?? null
104
+ const relayPrefill = realConfiguredRelay ?? defaultRelay ?? agent.identity?.relayUrl ?? null
84
105
  return {
85
106
  ok: true,
86
107
  enabled: agent.enabled !== false,
@@ -102,11 +123,18 @@ export function statusSnapshot(agent) {
102
123
  // computer half is installed but not yet registered, and what to run.
103
124
  registered: !agent.needsEnroll,
104
125
  needsEnroll: agent.needsEnroll,
126
+ // `panel` is a host-UI capability, not a phone capability: an older
127
+ // connector omits it, and the panel falls back to the command. It is
128
+ // deliberately not in SERVER_CAPABILITIES (that list is what the app asks
129
+ // for over `_link/hello`; the phone has no use for a panel form).
130
+ panel: true,
105
131
  stateFile: agent.identity?.stateFile ?? agent.config.stateFile ?? null,
106
- relayUrl: agent.config.relayUrl ?? null,
132
+ relayUrl: effectiveRelay,
133
+ relayPrefill,
134
+ relayLocked: Boolean(realConfiguredRelay),
107
135
  command: agent.needsEnroll ? agent.enrollHint() : null,
108
136
  hint: agent.needsEnroll
109
- ? '这台电脑还没有登记到中转。请带上邀请码运行一次登记命令,然后重启 DSH。'
137
+ ? '这台电脑还没有登记到中转。在「移动端连接」面板里确认中转地址,然后点「登记」。'
110
138
  : null,
111
139
  },
112
140
  state: agent.state,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-link",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",