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/lib/index.js CHANGED
@@ -43,7 +43,6 @@ const DEFAULTS = {
43
43
  agentSecret: '',
44
44
  stateFile: '',
45
45
  dshUrl: '',
46
- endpointFile: '',
47
46
  inviteCode: '',
48
47
  heartbeatMs: 20000,
49
48
  pongTimeoutMs: 60000,
@@ -56,15 +55,34 @@ function asRecord(value) {
56
55
  return value !== null && typeof value === 'object' ? value : {}
57
56
  }
58
57
 
58
+ /**
59
+ * 当前宿主属于哪一种形态。**只看进程自己的证据,不看继承来的环境变量**:
60
+ * - 官方桌面版:入口脚本是私有的 `dsh-desktop-host`,可执行文件在应用包里
61
+ * (它用 Electron 当 Node 跑);
62
+ * - 其余把本插件加载进宿主进程的情况都是命令行 web 版。
63
+ *
64
+ * 为什么不能用 `ELECTRON_RUN_AS_NODE` 这类环境变量:从桌面版里启动的 `dsh web`
65
+ * (例如 agent 在桌面版宿主下敲命令)会**继承**它,于是把一个 web 宿主误判成桌面版。
66
+ * 这个错误由 2026-10-07 的慢闸实测抓到。
67
+ *
68
+ * 形状契约见 `specs/001-connector-host-compat/data-model.md` §1。
69
+ *
70
+ * @param {{argv?: string[], execPath?: string}} [context]
71
+ * @returns {'desktop' | 'web'}
72
+ */
73
+ export function detectHostForm({ argv = process.argv, execPath = process.execPath } = {}) {
74
+ if (/dsh-desktop-host/.test(argv[1] ?? '')) return 'desktop'
75
+ if (/DeepSeek Harness\.app|dsh-desktop-host/.test(execPath)) return 'desktop'
76
+ return 'web'
77
+ }
78
+
59
79
  /**
60
80
  * @param {import('@deepseek-ai/cordis').Context} ctx
61
81
  * @param {Record<string, unknown>} rawConfig
62
82
  */
63
- export function apply(ctx, rawConfig) {
64
- const config = { ...DEFAULTS, ...asRecord(rawConfig) }
65
- const logger = ctx.logger ?? console
66
-
67
- const agent = new MobileLinkAgent({
83
+ /** 构造 agent:把配置逐字段钳制成 agent 认的类型。 */
84
+ function buildAgent(config, logger) {
85
+ return new MobileLinkAgent({
68
86
  enabled: config.enabled !== false,
69
87
  relayUrl: typeof config.relayUrl === 'string' ? config.relayUrl : DEFAULTS.relayUrl,
70
88
  agentId: typeof config.agentId === 'string' ? config.agentId : '',
@@ -74,15 +92,18 @@ export function apply(ctx, rawConfig) {
74
92
  // otherwise the agent reads DSH_MOBILE_LINK_INVITE (see enroll.js).
75
93
  inviteCode: typeof config.inviteCode === 'string' && config.inviteCode ? config.inviteCode : undefined,
76
94
  dshUrl: typeof config.dshUrl === 'string' && config.dshUrl ? config.dshUrl : undefined,
77
- endpointFile: typeof config.endpointFile === 'string' && config.endpointFile ? config.endpointFile : undefined,
78
95
  heartbeatMs: Number(config.heartbeatMs) || DEFAULTS.heartbeatMs,
79
96
  pongTimeoutMs: Number(config.pongTimeoutMs) || DEFAULTS.pongTimeoutMs,
80
97
  maxBackoffMs: Number(config.maxBackoffMs) || DEFAULTS.maxBackoffMs,
81
98
  logger,
82
99
  })
100
+ }
83
101
 
102
+ /** 启动/停止 agent 的生命周期 effect。 */
103
+ function startAgent(ctx, agent, logger) {
84
104
  ctx.effect(() => {
85
105
  let disposed = false
106
+ agent.setHostForm(detectHostForm())
86
107
  agent.emit('status')
87
108
  agent.start().catch((error) => {
88
109
  if (!disposed) logger.warn?.(`mobile-link: start failed: ${error}`)
@@ -92,9 +113,10 @@ export function apply(ctx, rawConfig) {
92
113
  void agent.stop().catch(() => {})
93
114
  }
94
115
  }, 'mobile-link: DLP agent')
116
+ }
95
117
 
96
- // Routes ride on DSH's web server when that service exists. `ctx.inject`
97
- // scopes them without making the service a hard requirement for the agent.
118
+ /** 三条本机 HTTP 路由;只依赖 `webServer`,缺了也不影响链路。 */
119
+ function registerRoutes(ctx, agent, logger, config) {
98
120
  try {
99
121
  ctx.inject(['webServer'], (scope) => {
100
122
  const disposers = registerMobileLinkRoutes(scope, { agent, logger, config })
@@ -112,17 +134,49 @@ export function apply(ctx, rawConfig) {
112
134
  } catch (error) {
113
135
  logger.warn?.(`mobile-link: routes unavailable (the link itself keeps running): ${error}`)
114
136
  }
137
+ }
138
+
139
+ /**
140
+ * 进程内发现(本特性的核心):宿主把"当前带凭据的回环地址"交给我们。
141
+ *
142
+ * 必须声明注入(`ctx.inject(['webServer','connection'])`)——`ctx.get('connection')` 在插件
143
+ * 作用域里取不到,官方桌面版与 web 版都一样(实测)。与路由分开注入:`connection` 缺失时
144
+ * 路由照常注册,agent 也照常跑。
145
+ */
146
+ function wireEndpointResolver(ctx, agent, logger) {
147
+ try {
148
+ ctx.inject(['webServer', 'connection'], (scope) => {
149
+ agent.setHostService(() => scope.connection.authenticatedUrl(`http://127.0.0.1:${scope.webServer.port}`))
150
+ logger.info?.('mobile-link: in-process endpoint resolver ready')
151
+ })
152
+ } catch (error) {
153
+ logger.warn?.(`mobile-link: in-process endpoint resolver unavailable: ${error}`)
154
+ }
155
+ }
156
+
157
+ /**
158
+ * @param {import('@deepseek-ai/cordis').Context} ctx
159
+ * @param {Record<string, unknown>} rawConfig
160
+ */
161
+ export function apply(ctx, rawConfig) {
162
+ const config = { ...DEFAULTS, ...asRecord(rawConfig) }
163
+ const logger = ctx.logger ?? console
164
+ const agent = buildAgent(config, logger)
115
165
 
166
+ startAgent(ctx, agent, logger)
167
+ registerRoutes(ctx, agent, logger, config)
168
+ wireEndpointResolver(ctx, agent, logger)
116
169
  logger.info?.(`mobile-link: ready (relay ${config.relayUrl})`)
117
170
  }
118
171
 
119
172
  export { MobileLinkAgent } from './link.js'
120
- export { DshClient, discoverEndpoint, DshUnavailable } from './dsh-client.js'
173
+ export { DshClient, discoverEndpoint, discoverCandidates, DshUnavailable } from './dsh-client.js'
174
+ export { writeHandoff, handoffFromEndpoint } from './handoff.js'
121
175
  export {
122
176
  MAX_FRAME_BYTES, StreamTable, WaterfallDedupe, agentEndpoint, decodeFrame, encodeFrame,
123
177
  joinRelayPath, nextBackoff, normalizeRelayUrl, pairCodeEndpoint, qrPayload,
124
178
  } from './dlp.js'
125
- export { resolveIdentity, defaultStatePath, defaultEndpointFile, MissingIdentity } from './state.js'
179
+ export { resolveIdentity, defaultStatePath, defaultHandoffFile, MissingIdentity } from './state.js'
126
180
  export { enrollAgent, enrollPlan, enrollCommand, describeEnrollFailure } from './enroll.js'
127
181
  export { INVITE_ENV, DEFAULT_AGENT_NAME } from './state.js'
128
182
  export { qrMatrix, qrSvg, versionFor } from './qr.js'
package/lib/link.js CHANGED
@@ -9,9 +9,11 @@ import { EventEmitter } from 'node:events'
9
9
  import { DshClient } from './dsh-client.js'
10
10
  import { agentEndpoint, decodeFrame, encodeFrame, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
11
11
  import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
12
+ import { handoffFromEndpoint, writeHandoff } from './handoff.js'
13
+ import { statusSnapshot } from './status.js'
12
14
  import { mintPairCode } from './pairing.js'
13
15
  import {
14
- DEFAULT_EVENTS_BACKLOG, DEFAULT_EVENTS_GRACE_MS,
16
+ DEFAULT_EVENTS_BACKLOG,
15
17
  EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf,
16
18
  } from './router.js'
17
19
  import { SERVER_VERSION, SERVER_CAPABILITIES } from './hello.js'
@@ -20,6 +22,7 @@ import { connect as wsConnect } from './ws.js'
20
22
 
21
23
  const PROTOCOL_VERSION = 1
22
24
 
25
+
23
26
  function sleep(ms, signal) {
24
27
  return new Promise((resolve) => {
25
28
  const timer = setTimeout(resolve, ms)
@@ -40,18 +43,16 @@ function numberFromEnv(raw, fallback) {
40
43
 
41
44
  export class MobileLinkAgent extends EventEmitter {
42
45
  constructor({
43
- relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, endpointFile,
46
+ relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl,
44
47
  logger = console, heartbeatMs = 20000, pongTimeoutMs = 60000, maxBackoffMs = 30000,
45
48
  random = Math.random, now = Date.now, dshClient, connectImpl = wsConnect, enabled = true,
46
49
  inviteCode, fetchImpl = fetch, env = process.env,
47
- // 「手机不在时替它留着 $events 流」的宽限期与缓冲上限(见 router.js 的说明);
48
- // 环境变量给部署用,构造参数给测试用。
49
- eventsGraceMs = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_GRACE_MS, DEFAULT_EVENTS_GRACE_MS),
50
+ // 手机不在时替它攒下来的提问条数上限(见 router.js);环境变量给部署用,
51
+ // 构造参数给测试用。**没有"留多久"这个旋钮**:`$events` 一直留到设备被撤销。
50
52
  eventsBacklog = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_BACKLOG, DEFAULT_EVENTS_BACKLOG),
51
- setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout,
52
53
  } = {}) {
53
54
  super()
54
- this.config = { relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, endpointFile, inviteCode }
55
+ this.config = { relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, inviteCode }
55
56
  this.logger = logger
56
57
  this.heartbeatMs = heartbeatMs
57
58
  this.pongTimeoutMs = pongTimeoutMs
@@ -65,21 +66,25 @@ export class MobileLinkAgent extends EventEmitter {
65
66
  //: Set when this computer has no identity yet: a setup problem for a person
66
67
  //: to fix, not a transport problem to retry.
67
68
  this.needsEnroll = false
68
- this.dsh = dshClient ?? new DshClient({ endpointFile, explicitUrl: dshUrl, logger })
69
+ this.dsh = dshClient ?? new DshClient({ explicitUrl: dshUrl, logger })
69
70
  this.router = new DeviceRouter({
70
71
  dsh: this.dsh,
71
72
  logger,
72
73
  now,
73
74
  protocolVersion: PROTOCOL_VERSION,
74
- eventsGraceMs,
75
75
  eventsBacklog,
76
- setTimeoutFn,
77
- clearTimeoutFn,
78
76
  send: (deviceId, frame) => this.sendToDevice(deviceId, frame),
77
+ // 「跑完了 / 待你回应」到了就报一声(`{t:'notify'}`)。这是 agent 侧控制帧,
78
+ // **不带 deviceId**:连接器不知道、也不需要知道这台电脑配了哪些手机,
79
+ // 「该不该推给某台手机」由中转判(它手里才有"这台设备此刻在不在线")。
80
+ // `#rawSend` 自己处理"socket 不在就丢掉",这里不必另判。
81
+ notify: (payload) => this.#rawSend({ t: 'notify', ...payload }),
79
82
  onChange: () => this.emit('status'),
80
83
  })
81
84
 
82
85
  this.identity = undefined
86
+ /** Which host form we are running in (`desktop` / `web` / `unknown`); set by the plugin. */
87
+ this.hostForm = 'unknown'
83
88
  this.relay = undefined
84
89
  this.socket = undefined
85
90
  this.state = 'idle'
@@ -122,6 +127,70 @@ export class MobileLinkAgent extends EventEmitter {
122
127
  return this.lastError ?? this.router.lastError
123
128
  }
124
129
 
130
+ /**
131
+ * Wire the in-process resolver the plugin learned from `ctx.inject`.
132
+ *
133
+ * Only the plugin can obtain it (it needs `webServer` + `connection`), and it
134
+ * arrives asynchronously, so this is a setter rather than a constructor option.
135
+ *
136
+ * @param {(() => string | undefined) | undefined} resolver
137
+ */
138
+ setHostService(resolver) {
139
+ if (typeof this.dsh?.setHostService === 'function') this.dsh.setHostService(resolver)
140
+ // 解析器一到就试一次本机发现:交接文件服务的是"进程外工具",**与中转链路无关**,
141
+ // 因此不能只在连上中转之后才写(未登记的机器上那样永远写不出来)。
142
+ if (typeof resolver === 'function') void this.refreshLocal()
143
+ return this
144
+ }
145
+
146
+ /**
147
+ * 与中转无关的本机发现:拿到带凭据地址就刷新交接文件。
148
+ *
149
+ * 这是 `/mobile-link/status` 与"进程外工具能不能工作"的共同前提,所以它在
150
+ * 链路还没建立(甚至没登记)时也要跑。失败只记进 `dshError`,不影响链路。
151
+ */
152
+ async refreshLocal() {
153
+ try {
154
+ await this.dsh.refresh()
155
+ this.dshError = undefined
156
+ await this.#writeHandoff()
157
+ this.emit('status')
158
+ return this.dsh.endpoint
159
+ } catch (error) {
160
+ this.dshError = messageOf(error)
161
+ this.emit('status')
162
+ return undefined
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Record which host form we are in, so the status payload can tell the user
168
+ * which installation instructions apply to this machine.
169
+ *
170
+ * @param {'desktop' | 'web' | 'unknown'} form
171
+ */
172
+ setHostForm(form) {
173
+ this.hostForm = form === 'desktop' || form === 'web' ? form : 'unknown'
174
+ this.emit('status')
175
+ return this
176
+ }
177
+
178
+ /**
179
+ * Publish the current authenticated loopback address for out-of-process tools
180
+ * (our dev/test scripts and the iOS test helpers).
181
+ *
182
+ * Best effort: a failed write must never take the link down.
183
+ */
184
+ async #writeHandoff() {
185
+ const payload = handoffFromEndpoint(this.dsh?.endpoint)
186
+ if (!payload) return
187
+ try {
188
+ await writeHandoff(payload)
189
+ } catch (error) {
190
+ this.logger.debug?.(`mobile-link: handoff file not written: ${messageOf(error)}`)
191
+ }
192
+ }
193
+
125
194
  // ── lifecycle ───────────────────────────────────────────────────────────
126
195
 
127
196
  async start() {
@@ -135,6 +204,8 @@ export class MobileLinkAgent extends EventEmitter {
135
204
  this._abort = new AbortController()
136
205
  this.startedAt = this.now()
137
206
  this.state = 'connecting'
207
+ // 解析器可能在上一次会话里就已经装好;这里补一次本机发现(失败无妨)。
208
+ void this.refreshLocal()
138
209
  this._loop = this.#run().catch((error) => {
139
210
  this.lastError = messageOf(error)
140
211
  this.state = 'failed'
@@ -275,6 +346,9 @@ export class MobileLinkAgent extends EventEmitter {
275
346
  try {
276
347
  await this.dsh.refresh()
277
348
  this.dshError = undefined
349
+ // 进程外工具(本机脚本、iOS 集成测试辅助)靠这份交接文件拿带凭据地址;
350
+ // 写入失败不影响链路(`#writeHandoff` 自己吞异常)。
351
+ await this.#writeHandoff()
278
352
  } catch (error) {
279
353
  this.dshError = messageOf(error)
280
354
  this.lastError = this.dshError
@@ -345,6 +419,9 @@ export class MobileLinkAgent extends EventEmitter {
345
419
  }
346
420
 
347
421
  sendToDevice(deviceId, frame) {
422
+ // `deviceId` 为空=这是一条**不带寻址**的帧(`fsPutAck`/`fsPutDone`/`fsErr` 那一族):
423
+ // relay 靠 `bid` 把回复对回发起的那个 HTTP 请求,多一个 deviceId 反而会让它对不上。
424
+ if (deviceId === undefined || deviceId === null) return this.#rawSend(frame)
348
425
  return this.#rawSend({ ...frame, deviceId })
349
426
  }
350
427
 
@@ -407,51 +484,9 @@ export class MobileLinkAgent extends EventEmitter {
407
484
  return mintPairCode({ identity, ttlMs, fetchImpl })
408
485
  }
409
486
 
410
- /** Snapshot for `GET /mobile-link/status`. */
487
+ /** Snapshot for `GET /mobile-link/status`(载荷本身在 status.js,便于守住"只增不改")。 */
411
488
  status() {
412
- return {
413
- ok: true,
414
- enabled: this.enabled !== false,
415
- protocolVersion: PROTOCOL_VERSION,
416
- // Same facts as `_link/hello`, which is the channel the app actually
417
- // uses: this route only exists on the direct path.
418
- serverVersion: SERVER_VERSION,
419
- capabilities: SERVER_CAPABILITIES,
420
- enroll: {
421
- // A phone (or the user's browser) can read this to find out that the
422
- // computer half is installed but not yet registered, and what to run.
423
- registered: !this.needsEnroll,
424
- needsEnroll: this.needsEnroll,
425
- stateFile: this.identity?.stateFile ?? this.config.stateFile ?? null,
426
- relayUrl: this.config.relayUrl ?? null,
427
- command: this.needsEnroll ? this.enrollHint() : null,
428
- hint: this.needsEnroll
429
- ? '这台电脑还没有登记到中转。请带上邀请码运行一次登记命令,然后重启 DSH。'
430
- : null,
431
- },
432
- state: this.state,
433
- connected: this.state === 'connected',
434
- relayUrl: this.identity?.relayUrl ?? this.config.relayUrl ?? null,
435
- agentId: this.identity?.agentId ?? this.config.agentId ?? null,
436
- agentName: this.identity?.agentName ?? null,
437
- stateFile: this.identity?.stateFile ?? this.config.stateFile ?? null,
438
- dsh: {
439
- endpoint: this.dsh?.endpoint?.base ?? null,
440
- source: this.dsh?.endpoint?.source ?? null,
441
- port: this.dsh?.endpoint?.port ?? null,
442
- authenticated: Boolean(this.dsh?.cookie),
443
- muxUp: Boolean(this.dsh?.muxReady),
444
- error: this.dshError ?? null,
445
- },
446
- devices: this.router.snapshot(),
447
- deviceCount: this.devices.size,
448
- openStreams: this.streams.size,
449
- pendingWaterfalls: this.dedupe.size,
450
- lastError: this.failure ?? null,
451
- startedAt: this.startedAt ?? null,
452
- connectedAt: this.connectedAt ?? null,
453
- reconnectAttempts: this.reconnectAttempts,
454
- }
489
+ return statusSnapshot(this)
455
490
  }
456
491
  }
457
492
 
package/lib/pairing.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Pairing helper: ask the relay to mint a one-time code and build the QR
3
- * payload the desktop shows. Spec §2.1 plus relay/NOTES.md §2 (the endpoint the
3
+ * payload the desktop shows. Spec §2.1 plus docs/RELAY-NOTES.md §2 (the endpoint the
4
4
  * spec's table is missing).
5
5
  */
6
6