dsh-plugin-mobile-link 0.8.0 → 0.8.1

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
@@ -5,9 +5,10 @@
5
5
  */
6
6
 
7
7
  import { EventEmitter } from 'node:events'
8
+ import { dirname, join } from 'node:path'
8
9
 
9
10
  import { DshClient } from './dsh-client.js'
10
- import { agentEndpoint, decodeFrame, deviceListEndpoint, deviceRevokeEndpoint, encodeFrameChecked, isPlaceholderRelayUrl, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
11
+ import { agentEndpoint, decodeFrame, deviceListEndpoint, deviceRevokeEndpoint, encodeFrameChecked, isPlaceholderRelayUrl, nextBackoff, normalizeRelayUrl, pongFor, qrPayload } from './dlp.js'
11
12
 
12
13
  /**
13
14
  * 进程级登记表:**同一个进程里只允许有一条到中转的 agent 连接**。
@@ -30,11 +31,17 @@ import {
30
31
  } from './router.js'
31
32
  import { SERVER_VERSION, SERVER_CAPABILITIES, hostVersion } from './hello.js'
32
33
  import { createJobsRoster } from './jobs-roster.js'
33
- import { MissingIdentity, defaultStatePath, readState, resolveIdentity } from './state.js'
34
+ import { MissingIdentity, defaultStatePath, readState, resolveIdentity, writeState } from './state.js'
34
35
  import { connect as wsConnect } from './ws.js'
35
36
 
36
37
  const PROTOCOL_VERSION = 1
37
38
 
39
+ // WebSocket 关闭码:中转据此区分"计划内重连"和"需要处理的异常"(SR-08.08)。
40
+ // 4002 已用于中转协议错误;这里补上两类自愈失败的码,不要再把 1000 复用给它们。
41
+ const CLOSE_NORMAL = 1000
42
+ const CLOSE_WATCHDOG = 4003
43
+ const CLOSE_HEARTBEAT = 4004
44
+
38
45
 
39
46
  function sleep(ms, signal) {
40
47
  return new Promise((resolve) => {
@@ -54,6 +61,55 @@ function numberFromEnv(raw, fallback) {
54
61
  return Number.isFinite(value) && value >= 0 ? value : fallback
55
62
  }
56
63
 
64
+ // ── 持久码缓存 ──────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * 上次铸出来的持久码放在身份文件旁边(0600)。
68
+ *
69
+ * 中转只存码的哈希,铸码接口不会把同一张码再给一次,所以「小窗复用同一张持久码」
70
+ * (docs/PAIRING.md「二维码」一行)只能由连接器自己记住:面板每次打开都会请求一次码,
71
+ * 没有这份缓存就变成每开一次、每重载一次都多一张永久有效的码。
72
+ */
73
+ function pairCodeCachePath(config) {
74
+ if (config?.pairCodePath) return config.pairCodePath
75
+ return join(dirname(config?.stateFile || defaultStatePath()), 'pair-code.json')
76
+ }
77
+
78
+ async function rememberPairCode(config, minted) {
79
+ if (!minted?.code) return
80
+ try {
81
+ await writeState(pairCodeCachePath(config), {
82
+ code: minted.code,
83
+ agentId: minted.agentId,
84
+ relayUrl: minted.relayUrl,
85
+ maxPairedDevices: minted.maxPairedDevices ?? null,
86
+ pairedDevices: Number(minted.pairedDevices ?? 0),
87
+ savedAt: Date.now(),
88
+ })
89
+ } catch (error) {
90
+ // 缓存写不进去只影响"下次还会不会换码",不该让这次取码失败。
91
+ config?.logger?.warn?.(`mobile-link: could not remember the pairing code: ${messageOf(error)}`)
92
+ }
93
+ }
94
+
95
+ async function readRememberedPairCode(config, identity) {
96
+ const file = await readState(pairCodeCachePath(config))
97
+ if (!file || typeof file.code !== 'string' || !file.code) return null
98
+ // 身份变了(重新登记、换了中转)就不是同一台电脑的码,不能拿来复用。
99
+ if (file.agentId !== identity.agentId || file.relayUrl !== identity.relayUrl) return null
100
+ return {
101
+ code: file.code,
102
+ expiresAt: null,
103
+ ttlMs: null,
104
+ persistent: true,
105
+ maxPairedDevices: Number(file.maxPairedDevices ?? 0) || null,
106
+ pairedDevices: Number(file.pairedDevices ?? 0),
107
+ relayUrl: identity.relayUrl,
108
+ agentId: identity.agentId,
109
+ qrPayload: qrPayload({ relay: identity.relayUrl, code: file.code }),
110
+ }
111
+ }
112
+
57
113
  export class MobileLinkAgent extends EventEmitter {
58
114
  constructor({
59
115
  relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl,
@@ -138,6 +194,9 @@ export class MobileLinkAgent extends EventEmitter {
138
194
  /// 历史读取(spec 007):宿主 `sessionQuery` 就绪后由插件挂上;没挂上时
139
195
  /// 路由回 `link/unsupported`,手机继续走 `session/page`。
140
196
  this.setHistoryService = (service) => { this.router.history = service }
197
+ /// 图片两变体(预览 + 可选原图):宿主 `attachments` / `fileUploads` 就绪后由插件挂上;
198
+ /// 没挂上时三个 `_link/image*` 方法回 `link/unsupported`(手机显示"原图不可用")。
199
+ this.setImageVariants = (service) => { this.router.bindImageVariants(service) }
141
200
  /// 活动名单与 `link/jobs` 推帧(spec 018):帧由 router 广播给在线设备,
142
201
  /// 订阅与去抖都在 roster 里。宿主服务由 `setJobsServices` 挂上。
143
202
  this.jobsRoster = createJobsRoster({
@@ -147,6 +206,9 @@ export class MobileLinkAgent extends EventEmitter {
147
206
  })
148
207
  this.router.bindJobsRoster(this.jobsRoster)
149
208
  this.setJobsServices = (services) => this.jobsRoster.bindServices(services)
209
+ /// 提醒口径(SR-02.08):只报正式会话。宿主 `ctx.sessions` / `ctx.workspaceRegistry`
210
+ /// 由插件在 `wireSessionScope` 里挂上;缺一个就维持旧行为(全报)。
211
+ this.setSessionScopeServices = (services) => this.router.bindSessionScope(services)
150
212
  this.relay = undefined
151
213
  this.socket = undefined
152
214
  this.state = 'idle'
@@ -681,7 +743,7 @@ export class MobileLinkAgent extends EventEmitter {
681
743
  this.lastError = `relay 超过 ${Math.round(idle / 1000)}s 没有动静(看门狗拆连接)`
682
744
  this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
683
745
  this.logger.warn?.(`mobile-link: ${this.lastError}`)
684
- this.#teardownSocket(this.lastError)
746
+ this.#teardownSocket(this.lastError, CLOSE_HEARTBEAT, 'heartbeat timeout')
685
747
  this._lastProgressAt = this.now()
686
748
  return
687
749
  }
@@ -689,7 +751,7 @@ export class MobileLinkAgent extends EventEmitter {
689
751
  this.lastError = `链路 ${Math.round(idle / 1000)}s 没有进展(看门狗重建连接)`
690
752
  this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
691
753
  this.logger.warn?.(`mobile-link: ${this.lastError}`)
692
- this.#teardownSocket(this.lastError)
754
+ this.#teardownSocket(this.lastError, CLOSE_WATCHDOG, 'watchdog timeout')
693
755
  this._lastProgressAt = this.now()
694
756
  }
695
757
  }, every)
@@ -707,7 +769,7 @@ export class MobileLinkAgent extends EventEmitter {
707
769
  if (this.now() - this._lastPong > this.pongTimeoutMs) {
708
770
  this.lastError = `relay stopped answering pings (>${this.pongTimeoutMs}ms)`
709
771
  this.logger.warn?.(`mobile-link: ${this.lastError}`)
710
- this.#teardownSocket(this.lastError)
772
+ this.#teardownSocket(this.lastError, CLOSE_HEARTBEAT, 'heartbeat timeout')
711
773
  return
712
774
  }
713
775
  this.#rawSend({ t: 'ping', ts: this.now() })
@@ -720,14 +782,14 @@ export class MobileLinkAgent extends EventEmitter {
720
782
  this._heartbeat = undefined
721
783
  }
722
784
 
723
- #teardownSocket(reason) {
785
+ #teardownSocket(reason, closeCode = CLOSE_NORMAL, closeReason = 'reconnecting') {
724
786
  this.#stopHeartbeat()
725
787
  const socket = this.socket
726
788
  const from = this.state
727
789
  this.socket = undefined
728
790
  if (socket) {
729
791
  try {
730
- socket.close(1000, 'reconnecting')
792
+ socket.close(closeCode, closeReason)
731
793
  } catch {
732
794
  /* already gone */
733
795
  }
@@ -744,6 +806,7 @@ export class MobileLinkAgent extends EventEmitter {
744
806
  // connectedForMs 用来区分"刚连上就掉"和"挂了很久才掉"。
745
807
  this.journal.record('link.close', {
746
808
  reason,
809
+ closeCode,
747
810
  from,
748
811
  connectedForMs: this.connectedAt === undefined ? undefined : this.now() - this.connectedAt,
749
812
  // 有系统错误才带这个字段(正常关闭时不该凭空多一个 null)。
@@ -886,7 +949,7 @@ export class MobileLinkAgent extends EventEmitter {
886
949
  this.router.onMuxDown(reason)
887
950
  }
888
951
 
889
- // ── relay-side helpers ──────────────────────────────────────────────────
952
+ // ── relay-side helpers ──────────────────────────────────────────────────
890
953
 
891
954
  /**
892
955
  * Ask the relay to mint a pairing code for this agent (relay NOTES.md §2).
@@ -895,13 +958,23 @@ export class MobileLinkAgent extends EventEmitter {
895
958
  */
896
959
  async mintPairCode({ ttlMs = null, fetchImpl = fetch } = {}) {
897
960
  const identity = this.identity ?? await resolveIdentity(this.config)
898
- return mintPairCode({ identity, ttlMs, fetchImpl })
961
+ // 持久码:手上已经有一张就直接给它。中转那边只有哈希,重铸只会多出一张有效码。
962
+ if (ttlMs == null) {
963
+ const remembered = await readRememberedPairCode(this.config, identity)
964
+ if (remembered) return remembered
965
+ }
966
+ const minted = await mintPairCode({ identity, ttlMs, fetchImpl })
967
+ if (minted.persistent) await rememberPairCode(this.config, minted)
968
+ return minted
899
969
  }
900
970
 
901
971
  /** 用户点「重新生成」:作废旧码、铸新的,**不碰已配对设备**(032)。 */
902
972
  async regeneratePairCode({ fetchImpl = fetch } = {}) {
903
973
  const identity = this.identity ?? await resolveIdentity(this.config)
904
- return regeneratePairCode({ identity, fetchImpl })
974
+ const minted = await regeneratePairCode({ identity, fetchImpl })
975
+ // 中转已经把旧码全部作废,这里换成新的那张。
976
+ await rememberPairCode(this.config, minted)
977
+ return minted
905
978
  }
906
979
 
907
980
  /** Snapshot for `GET /mobile-link/status`(载荷本身在 status.js,便于守住"只增不改")。 */
package/lib/router.js CHANGED
@@ -16,9 +16,11 @@ import { FileInbox, FILE_BEGIN, FILE_CHUNK, FILE_END, isFileMethod } from './fil
16
16
  import { FileFetcher, FSGET_CANCEL } from './files-out.js'
17
17
  import { GitBridge, isGitMethod } from './git.js'
18
18
  import { isHelloMethod, helloPayload, parseClientInfo } from './hello.js'
19
+ import { isImageVariantMethod } from './image-variants.js'
19
20
  import { isHistoryMethod } from './history.js'
20
21
  import { trimRecordsForWire } from './history/records.js'
21
22
  import { isJobsMethod } from './jobs-roster.js'
23
+ import { createSessionScope } from './session-scope.js'
22
24
 
23
25
  /**
24
26
  * 后台大文件上传的桥接帧(relay → 连接器),名字见 `docs/relay-contract.json`
@@ -176,6 +178,12 @@ export class DeviceRouter {
176
178
  // 「跑完了 / 待你回应」这类事件到了就报一声(见 `notifyFor`)。默认空函数,
177
179
  // 测试与不关心提醒的调用方可以不管它。帧由 link.js 发(router 不发帧)。
178
180
  this.notify = notify
181
+ /**
182
+ * 提醒口径(SR-02.08):只报正式会话。`undefined` = 宿主服务还没绑上,按旧行为
183
+ * 全报;绑上之后由 `session-scope.js` 判定。列表顶部的待答计数在 App 侧另算,
184
+ * 走同一份「正式会话」定义。
185
+ */
186
+ this.sessionScope = undefined
179
187
  // 生命周期事件(设备 attach/detach 等)的去处,由 link.js 接到流水(journal.js)。
180
188
  // 默认空函数,所以 router 单独用时(单测、本地脚本)不需要日志设施。
181
189
  this.onEvent = onEvent
@@ -204,6 +212,15 @@ export class DeviceRouter {
204
212
  // 活动名单与 `link/jobs` 推帧(spec 018)。订阅与去抖都在 roster 里,router 只负责
205
213
  // 转交设备生命周期与把帧广播出去。
206
214
  this.jobsRoster = undefined
215
+ // 图片的两变体能力(预览 + 可选原图):由 index.js 注入宿主 `attachments` /
216
+ // `fileUploads` 后挂上。没挂上时三个保留方法回 `link/unsupported`,
217
+ // 手机显示"原图不可用",预览照旧。
218
+ this.imageVariants = undefined
219
+ }
220
+
221
+ /** 挂上图片变体能力;`undefined` 表示宿主服务缺失(如实报不支持)。 */
222
+ bindImageVariants(service) {
223
+ this.imageVariants = service
207
224
  }
208
225
 
209
226
  /**
@@ -218,6 +235,21 @@ export class DeviceRouter {
218
235
  for (const deviceId of this.devices.keys()) roster.addDevice(deviceId)
219
236
  }
220
237
 
238
+ /**
239
+ * 挂上提醒口径所需的宿主账本(`ctx.sessions` / `ctx.workspaceRegistry`)。
240
+ *
241
+ * 传 `undefined` 摘掉:宿主换代或插件卸载后不能拿着失效服务对象去判定。
242
+ */
243
+ bindSessionScope(services) {
244
+ this.sessionScope = services === undefined ? undefined : createSessionScope(services)
245
+ }
246
+
247
+ /** 这条会话是否值得报一声(只看正式会话;服务未绑上时保持旧行为)。 */
248
+ #shouldNotify(sessionId) {
249
+ if (typeof sessionId !== 'string' || sessionId === '') return false
250
+ return this.sessionScope === undefined || this.sessionScope.isFormal(sessionId)
251
+ }
252
+
221
253
  #changed() {
222
254
  this.onChange()
223
255
  }
@@ -755,6 +787,30 @@ export class DeviceRouter {
755
787
  return
756
788
  }
757
789
 
790
+ // 图片变体(预览 + 原图):手机先经 `_link/file*` 把字节落进收件箱,再来这里
791
+ // 让连接器把它们交给 Host 附件服务并登记关联。自己回答而不是转发:Host
792
+ // 没有对应的 RPC,而这正是保留方法存在的理由。
793
+ if (isImageVariantMethod(frame.method)) {
794
+ let result
795
+ try {
796
+ if (this.imageVariants === undefined) {
797
+ result = {
798
+ ok: false,
799
+ error: errorObject('link/unsupported', 'image variants are unavailable on this connector'),
800
+ }
801
+ } else {
802
+ result = { ok: true, value: await this.imageVariants.handle(frame.method, frame.args ?? {}) }
803
+ }
804
+ } catch (error) {
805
+ result = {
806
+ ok: false,
807
+ error: errorObject(error?.code ?? 'link/failed', messageOf(error), error?.details ?? {}),
808
+ }
809
+ }
810
+ this.send(deviceId, resultFrame(frame.id, result, deviceId))
811
+ return
812
+ }
813
+
758
814
  // Git also runs here, on the machine that holds the work tree: the Host has
759
815
  // no endpoint that runs a command, and the review a person wants — what
760
816
  // changed, what the diff says, what the last commits were — is all local.
@@ -1018,6 +1074,9 @@ export class DeviceRouter {
1018
1074
  * `api-session/error`(`:3150`)**有意不推**:它不一定是"跑完了"(也可能是一轮中途
1019
1075
  * 报错后继续),拿它当"跑完了"会发出一堆噪音提醒。这是决定,不是漏做。
1020
1076
  *
1077
+ * **只报正式会话**(SR-02.08):未分组、子会话与已归档的运行结束和待确认不报。
1078
+ * 这就是 `#shouldNotify` 的唯一判据来源;主机服务绑上之前维持旧行为。
1079
+ *
1021
1080
  * 不做去抖、不做队列、不做重试:一轮才一条,发失败(中转断了)就丢。`turnEnd` 丢一条
1022
1081
  * 不值得为它做重试;`attention` 丢不了,它同时被记账,手机回来会补发。
1023
1082
  */
@@ -1025,11 +1084,13 @@ export class DeviceRouter {
1025
1084
  if (!value || typeof value !== 'object') return
1026
1085
  if (value.type === 'emit' && value.event === 'api-session/status' && value.args?.[1] === false) {
1027
1086
  const sid = String(value.args[0] ?? '')
1028
- if (sid) this.notify({ kind: 'turnEnd', sid })
1087
+ if (this.#shouldNotify(sid)) this.notify({ kind: 'turnEnd', sid })
1029
1088
  return
1030
1089
  }
1031
1090
  if (value.type === 'waterfall') {
1032
- this.notify({ kind: 'attention', sid: value.agentId, eid: value.eventId })
1091
+ if (this.#shouldNotify(value.agentId)) {
1092
+ this.notify({ kind: 'attention', sid: value.agentId, eid: value.eventId })
1093
+ }
1033
1094
  }
1034
1095
  }
1035
1096
 
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 哪些会话值得提醒手机(SR-02.08 / SR-02.15)。
3
+ *
4
+ * 只有**正式会话**算数:属于某个工作区、未归档、且不是子会话。未分组、子会话与
5
+ * 已归档会在列表里以行、桶头或转写出现,但它们不该让手机弹窗,也不该进顶部的
6
+ * 「等你回应」计数——一个子智能体跑完或停下来问问题,不是用户要处理的事。
7
+ *
8
+ * 判据全部来自宿主自己的账本,不做目录推断:
9
+ * - 子会话:`Session.header.origin === 'subagent'`(历史 fork 也可能有
10
+ * `parentSession`,所以只认 `origin`,与 `@deepseek-ai/dsh-session` 的分类口径一致);
11
+ * - 归档:`workspaceRegistry.archivedSessionIds`;
12
+ * - 属于工作区:某个 `Workspace.sessionIds` 里含这个 id。
13
+ *
14
+ * 服务没绑上时返回 `true`(保持旧行为):拿不到宿主账本就断言"非正式"会把正式会话的
15
+ * 提醒一起吞掉。两种失败模式里,漏一条提醒比整类提醒消失更容易被发现。
16
+ */
17
+
18
+ function sessionFrom(sessions, sessionId) {
19
+ if (typeof sessions?.get === 'function') {
20
+ try {
21
+ const found = sessions.get(sessionId)
22
+ if (found !== undefined && found !== null) return found
23
+ } catch {
24
+ // 会话在枚举与读取之间消失:交给下面的 list 兜底。
25
+ }
26
+ }
27
+ if (typeof sessions?.list !== 'function') return undefined
28
+ try {
29
+ const listed = sessions.list()
30
+ if (!Array.isArray(listed)) return undefined
31
+ return listed.find((entry) => (entry?.id ?? entry?.sessionId) === sessionId)
32
+ } catch {
33
+ return undefined
34
+ }
35
+ }
36
+
37
+ function archivedIds(workspaces) {
38
+ try {
39
+ const ids = workspaces?.archivedSessionIds
40
+ return Array.isArray(ids) ? ids : []
41
+ } catch {
42
+ // 账本还没装好:按"没有归档"处理,不让一次读失败吞掉全部提醒。
43
+ return []
44
+ }
45
+ }
46
+
47
+ function workspaceMembers(workspaces) {
48
+ try {
49
+ const listed = workspaces?.list?.()
50
+ return Array.isArray(listed) ? listed : undefined
51
+ } catch {
52
+ return undefined
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @param {{
58
+ * sessions?: { get?: (id: string) => object | undefined, list?: () => object[] },
59
+ * workspaces?: {
60
+ * archivedSessionIds?: readonly string[],
61
+ * list?: () => Array<{ sessionIds?: readonly string[] }>,
62
+ * },
63
+ * }} [services]
64
+ * @returns {{ isFormal: (sessionId: string) => boolean }}
65
+ */
66
+ export function createSessionScope({ sessions, workspaces } = {}) {
67
+ const canReadSessions = typeof sessions?.get === 'function' || typeof sessions?.list === 'function'
68
+ const canReadWorkspaces = typeof workspaces?.list === 'function'
69
+ const available = canReadSessions && canReadWorkspaces
70
+
71
+ function isFormal(sessionId) {
72
+ if (typeof sessionId !== 'string' || sessionId === '') return false
73
+ // 宿主服务还没绑上:保持旧行为,见文件头。
74
+ if (!available) return true
75
+
76
+ const session = sessionFrom(sessions, sessionId)
77
+ if (session === undefined) return false
78
+ if ((session?.header?.origin ?? session?.origin) === 'subagent') return false
79
+ if (archivedIds(workspaces).includes(sessionId)) return false
80
+ const members = workspaceMembers(workspaces)
81
+ // 工作区账本读不出来:无法判定,维持旧行为,别把整类提醒一起吞掉。
82
+ if (members === undefined) return true
83
+ return members.some((workspace) => (
84
+ Array.isArray(workspace?.sessionIds) && workspace.sessionIds.includes(sessionId)
85
+ ))
86
+ }
87
+
88
+ return { isFormal }
89
+ }
package/lib/status.js CHANGED
@@ -8,6 +8,9 @@
8
8
 
9
9
  import { isPlaceholderRelayUrl } from './dlp.js'
10
10
  import { SERVER_CAPABILITIES, SERVER_VERSION, hostVersion } from './hello.js'
11
+ import { T, setLanguage } from './i18n.js'
12
+
13
+ if (process.env.DSH_LANG) setLanguage(process.env.DSH_LANG)
11
14
 
12
15
  const PROTOCOL_VERSION = 1
13
16
 
@@ -17,10 +20,10 @@ const PROTOCOL_VERSION = 1
17
20
  * 锚点标题与 `docs/ONBOARDING.md` 里的小节标题**逐字一致**,这样"锚点存在"可以机械核对。
18
21
  */
19
22
  export const DSH_HINTS = {
20
- config: { hint: '你手工配置的本机地址不对或已失效', docAnchor: '手工指定本机地址' },
21
- 'host-service': { hint: '当前宿主没有把本机访问方式交给插件(多为插件没装进正在运行的宿主)', docAnchor: '装在哪里:档案与插件入口' },
22
- DSH_WEB_URL: { hint: '环境变量里的本机地址已过期', docAnchor: '手工指定本机地址' },
23
- unreachable: { hint: '本机 DSH 不可达;请确认宿主正在运行', docAnchor: '排查:连不上时看这里' },
23
+ config: { hint: '你手工配置的本机地址不对或已失效', i18n: 'status.config', docAnchor: '手工指定本机地址' },
24
+ 'host-service': { hint: '当前宿主没有把本机访问方式交给插件(多为插件没装进正在运行的宿主)', i18n: 'status.hostService', docAnchor: '装在哪里:档案与插件入口' },
25
+ DSH_WEB_URL: { hint: '环境变量里的本机地址已过期', i18n: 'status.dshWebUrl', docAnchor: '手工指定本机地址' },
26
+ unreachable: { hint: '本机 DSH 不可达;请确认宿主正在运行', i18n: 'status.unreachable', docAnchor: '排查:连不上时看这里' },
24
27
  }
25
28
 
26
29
  /**
@@ -29,9 +32,9 @@ export const DSH_HINTS = {
29
32
  * 锚点标题与 `docs/ONBOARDING.md` 的小节标题逐字一致,`test/status.test.js` 会核对。
30
33
  */
31
34
  export const HOST_HINTS = {
32
- desktop: { hint: '你正在用官方桌面版(连接器已装进它的档案)', docAnchor: '安装:官方桌面版' },
33
- web: { hint: '你正在用命令行 web 版', docAnchor: '安装:命令行 web 版' },
34
- unknown: { hint: '无法识别当前宿主;若连不上,请按"排查"一节处理', docAnchor: '排查:连不上时看这里' },
35
+ desktop: { hint: '你正在用官方桌面版(连接器已装进它的档案)', i18n: 'status.desktop', docAnchor: '安装:官方桌面版' },
36
+ web: { hint: '你正在用命令行 web 版', i18n: 'status.web', docAnchor: '安装:命令行 web 版' },
37
+ unknown: { hint: '无法识别当前宿主;若连不上,请按"排查"一节处理', i18n: 'status.unknown', docAnchor: '排查:连不上时看这里' },
35
38
  }
36
39
 
37
40
  /** 本机 DSH 侧的诊断块(老字段一个不少,新字段只增)。 */function dshBlock(agent) {
@@ -46,7 +49,7 @@ export const HOST_HINTS = {
46
49
  error: agent.dshError ?? null,
47
50
  // 新增(只增不改):失败分类 + 用户可读的一句话 + 文档锚点。
48
51
  errorKind,
49
- hint: entry?.hint ?? null,
52
+ hint: entry?.i18n ? T(entry.i18n) : entry?.hint ?? null,
50
53
  docAnchor: entry?.docAnchor ?? null,
51
54
  }
52
55
  }
@@ -108,7 +111,7 @@ export function statusSnapshot(agent) {
108
111
  // 新增(只增不改):宿主形态 + 它对应的一句话与文档锚点,
109
112
  // 决定用户该按哪条说明安装/排障(FR-012)。
110
113
  host: agent.hostForm,
111
- hostHint: hostEntry.hint,
114
+ hostHint: hostEntry.i18n ? T(hostEntry.i18n) : hostEntry.hint,
112
115
  hostDocAnchor: hostEntry.docAnchor,
113
116
  protocolVersion: PROTOCOL_VERSION,
114
117
  // Same facts as `_link/hello`, which is the channel the app actually uses:
@@ -134,7 +137,7 @@ export function statusSnapshot(agent) {
134
137
  relayLocked: Boolean(realConfiguredRelay),
135
138
  command: agent.needsEnroll ? agent.enrollHint() : null,
136
139
  hint: agent.needsEnroll
137
- ? '这台电脑还没有登记到中转。在「移动端连接」面板里确认中转地址,然后点「登记」。'
140
+ ? T('status.unregistered')
138
141
  : null,
139
142
  },
140
143
  state: agent.state,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-link",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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",