dsh-plugin-mobile-link 0.6.6 → 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/client.js CHANGED
@@ -10,6 +10,9 @@
10
10
  ;(() => {
11
11
  /** 路由用相对路径:宿主把插件路由挂在同一个源上。 */
12
12
  const STATUS_ROUTE = 'mobile-link/status'
13
+ const ENROLL_ROUTE = 'mobile-link/enroll'
14
+ const DEVICES_ROUTE = 'mobile-link/devices'
15
+ const REVOKE_ROUTE = 'mobile-link/devices/revoke'
13
16
  const PAIR_ROUTE = 'mobile-link/pair-code'
14
17
  const REGENERATE_ROUTE = 'mobile-link/pair-code/regenerate'
15
18
  const SLOT_ENTRY = 'sidebar.footer.action'
@@ -20,8 +23,13 @@ const STYLE_ID = 'dsh-mobile-link-style'
20
23
  const LABEL = '移动端连接'
21
24
  const TITLE = '移动端连接:二维码、已配对的手机与连接状态'
22
25
  const PRIMITIVES = '@deepseek-ai/dsh-client-ui-primitives'
26
+ /** GitHub 仓库与 iOS 公开测试链接;都只做展示,不参与准入。 */
27
+ const GITHUB_URL = 'https://github.com/jayantTang/DSH_Mobile'
28
+ const TESTFLIGHT_URL = 'https://testflight.apple.com/join/tHKQsbCk'
23
29
  /** 小窗打开时的状态轮询间隔;关闭时只为让侧栏状态点别停在旧值上。 */
24
30
  const OPEN_POLL_MS = 2500
31
+ /** 已配对设备全量列表的刷新间隔;在线状态仍由 /status 的 2.5 秒轮询负责。 */
32
+ const PAIRED_POLL_MS = 10000
25
33
  const IDLE_POLL_MS = 20000
26
34
  /** 剩余寿命低于这个值就换一张:只剩几秒的码,扫它失败的原因用户看不见。 */
27
35
  const CODE_FLOOR_MS = 60 * 1000
@@ -33,8 +41,12 @@ const COPIED_MS = 2000
33
41
  const CSS = [
34
42
  '.dsh-mobile-link-entry{display:flex;align-items:center;justify-content:flex-start;gap:8px;width:100%;font-size:13px}',
35
43
  '.dsh-mobile-link-entry-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
36
- '.dsh-mobile-link-dialog{width:min(420px,94vw)}',
37
- '.dsh-mobile-link-body{display:flex;flex-direction:column;gap:16px}',
44
+ '.dsh-mobile-link-dialog{width:min(420px,94vw);max-height:calc(100dvh - 2 * max(24px,var(--dsh-frame-overlay-top,24px)))}',
45
+ '.dsh-mobile-link-content{flex:1 1 auto;min-height:0}',
46
+ '.dsh-mobile-link-shell{position:relative;display:flex;flex-direction:column;flex:1 1 auto;min-height:0}',
47
+ // 官方 Modal 不限高;正文自己收进可视区。减去的 102px 是官方头部与内边距
48
+ // (22+24+12 的头部、20 的正文上边距、24 的底部内边距)。
49
+ '.dsh-mobile-link-body{position:relative;display:flex;flex-direction:column;gap:12px;max-height:calc(100dvh - 2 * max(24px,var(--dsh-frame-overlay-top,24px)) - 102px);overflow-y:auto;overscroll-behavior:contain}',
38
50
  '.dsh-mobile-link-status{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:500}',
39
51
  '.dsh-mobile-link-section-title{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary,inherit)}',
40
52
  '.dsh-mobile-link-kv{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px 16px;font-size:13px}',
@@ -42,20 +54,58 @@ const CSS = [
42
54
  '.dsh-mobile-link-v{min-width:0;text-align:right;color:var(--dsw-alias-label-primary,inherit);overflow-wrap:anywhere}',
43
55
  '.dsh-mobile-link-mono,.dsh-mobile-link-note,.dsh-mobile-link-device-meta{font-size:12px;color:var(--dsw-alias-label-tertiary,inherit)}',
44
56
  '.dsh-mobile-link-mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}',
45
- '.dsh-mobile-link-card{display:flex;flex-direction:column;align-items:center;gap:14px;padding:20px 18px;border-radius:12px;background:var(--dsw-alias-bg-module-platform,rgba(15,17,21,.04))}',
46
- '.dsh-mobile-link-qr{display:block;width:248px;max-width:100%;height:auto;box-sizing:border-box;padding:10px;border-radius:10px;background:#fff}',
57
+ '.dsh-mobile-link-card{display:flex;align-items:center;gap:12px;padding:12px;border-radius:12px;background:var(--dsw-alias-bg-module-platform,rgba(15,17,21,.04))}',
58
+ '.dsh-mobile-link-qr{display:block;flex:0 0 auto;width:160px;max-width:100%;height:auto;box-sizing:border-box;padding:10px;border-radius:10px;background:#fff}',
59
+ '.dsh-mobile-link-card-main{display:flex;flex-direction:column;align-items:flex-start;gap:10px;flex:1 1 auto;min-width:0}',
47
60
  '.dsh-mobile-link-code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:20px;font-weight:600;letter-spacing:.16em;line-height:1;color:var(--dsw-alias-label-primary,inherit)}',
48
- '.dsh-mobile-link-count{display:flex;flex-direction:column;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-tertiary,inherit)}',
49
- '.dsh-mobile-link-bar{width:180px;height:3px;border-radius:2px;overflow:hidden;background:var(--dsw-alias-border-secondary,rgba(15,17,21,.12))}',
61
+ '.dsh-mobile-link-count{display:flex;flex-direction:column;align-items:flex-start;gap:6px;width:100%;font-size:12px;color:var(--dsw-alias-label-tertiary,inherit)}',
62
+ '.dsh-mobile-link-count-tag{white-space:nowrap}',
63
+ '.dsh-mobile-link-bar{width:100%;height:3px;border-radius:2px;overflow:hidden;background:var(--dsw-alias-border-secondary,rgba(15,17,21,.12))}',
50
64
  '.dsh-mobile-link-bar i{display:block;height:100%;border-radius:2px;background:var(--dsw-alias-state-business-primary,#4176e6);transition:width .9s linear}',
51
- '.dsh-mobile-link-actions{display:flex;align-items:center;gap:8px}',
65
+ '.dsh-mobile-link-actions{display:flex;flex-direction:column;align-items:stretch;gap:8px;width:100%}',
52
66
  '.dsh-mobile-link-section{display:flex;flex-direction:column;gap:8px}',
53
67
  '.dsh-mobile-link-devices{display:flex;flex-direction:column;gap:6px;max-height:184px;overflow:auto}',
54
68
  '.dsh-mobile-link-device{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:10px;background:var(--dsw-alias-bg-module-platform,rgba(15,17,21,.04))}',
55
69
  '.dsh-mobile-link-device-name{font-size:13px;font-weight:500}',
56
- '.dsh-mobile-link-device .dsh-mobile-link-tag{margin-left:auto}',
70
+ '.dsh-mobile-link-device-line{display:flex;align-items:center;gap:6px;min-width:0}',
71
+ '.dsh-mobile-link-revoke{flex:0 0 auto;margin-left:auto;border:0;background:transparent;padding:4px 0 4px 8px;font:inherit;font-size:12px;color:var(--dsw-alias-label-tertiary,#8a919d);cursor:pointer}',
72
+ '.dsh-mobile-link-revoke:hover{color:var(--dsw-alias-label-secondary,#5f6672)}',
57
73
  '.dsh-mobile-link-error{color:var(--dsw-alias-state-error-primary,#d1242f)}',
58
74
  '.dsh-mobile-link-note{font-size:12px;line-height:1.65;color:var(--dsw-alias-label-tertiary,inherit)}',
75
+ '.dsh-mobile-link-form{display:flex;flex-direction:column;gap:12px}',
76
+ '.dsh-mobile-link-field{display:flex;flex-direction:column;gap:6px}',
77
+ '.dsh-mobile-link-field>span{width:100%}',
78
+ '.dsh-mobile-link-label{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,inherit)}',
79
+ '.dsh-mobile-link-input{width:100%;height:36px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-secondary,rgba(15,17,21,.16));border-radius:8px;padding:0 10px;font-size:13px;font-family:inherit;color:var(--dsw-alias-label-primary,inherit);background:var(--dsw-alias-bg-module-platform,#fff)}',
80
+ '.dsh-mobile-link-input:focus{outline:none;border-color:var(--dsw-alias-state-business-primary,#4176e6);box-shadow:0 0 0 3px rgba(65,118,230,.14)}',
81
+ '.dsh-mobile-link-input[readonly]{opacity:.78}',
82
+ '.dsh-mobile-link-input.dsh-mobile-link-code-input{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.12em}',
83
+ '.dsh-mobile-link-error-inline{padding:8px 10px;border-radius:8px;font-size:12px;line-height:1.6;color:var(--dsw-alias-state-error-primary,#d1242f);background:var(--dsw-alias-bg-error,rgba(209,36,47,.08))}',
84
+ '.dsh-mobile-link-link{color:var(--dsw-alias-state-business-primary,#4176e6);text-decoration:underline;text-underline-offset:2px}',
85
+ '.dsh-mobile-link-cmd{font-size:11px;line-height:1.65;color:var(--dsw-alias-label-tertiary,inherit)}',
86
+ '.dsh-mobile-link-cmd summary{cursor:pointer;user-select:none}',
87
+ '.dsh-mobile-link-cmd .dsh-mobile-link-mono{margin-top:6px;white-space:nowrap;overflow-x:auto}',
88
+ '.dsh-mobile-link-github{display:flex;flex-direction:column;gap:3px}',
89
+ '.dsh-mobile-link-github-line{display:flex;justify-content:space-between;gap:12px;font-size:13px;align-items:baseline}',
90
+ '.dsh-mobile-link-github-line .dsh-mobile-link-k{color:var(--dsw-alias-label-tertiary,inherit)}',
91
+ '.dsh-mobile-link-github-line a{flex:1 1 auto;min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--dsw-alias-state-business-primary,#4176e6);text-decoration:none;text-decoration:underline;text-underline-offset:2px;overflow-wrap:anywhere;word-break:break-all;text-align:right}',
92
+ '.dsh-mobile-link-welcome{font-size:11px;color:var(--dsw-alias-label-tertiary,inherit);text-align:right}',
93
+ '.dsh-mobile-link-ioslink{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border-radius:9px;background:var(--dsw-alias-bg-module-platform,rgba(15,17,21,.04));font-size:12px;color:var(--dsw-alias-label-secondary,inherit)}',
94
+ '.dsh-mobile-link-ioslink a{color:var(--dsw-alias-state-business-primary,#4176e6);text-decoration:none;font-weight:600;white-space:nowrap}',
95
+ '.dsh-mobile-link-overlay{position:absolute;inset:0;z-index:2;display:flex;align-items:center;justify-content:center;padding:18px;background:rgba(23,33,52,.30);border-radius:16px}',
96
+ '.dsh-mobile-link-alert{width:min(360px,100%);display:flex;flex-direction:column;gap:12px;padding:16px;border-radius:12px;background:var(--dsw-alias-bg-module-platform,#fff);border:1px solid var(--dsw-alias-border-secondary,#e2e6ec);box-shadow:0 18px 40px rgba(23,33,52,.20)}',
97
+ '.dsh-mobile-link-alert-title{font-size:14px;font-weight:700}',
98
+ '.dsh-mobile-link-alert-actions{display:flex;gap:10px}',
99
+ '.dsh-mobile-link-alert-actions .dsh-mobile-link-btn{flex:1}',
100
+ '.dsh-mobile-link-guide{width:min(420px,100%);max-height:calc(100% - 36px);overflow:auto;display:flex;flex-direction:column;gap:12px;padding:16px;border-radius:12px;background:var(--dsw-alias-bg-module-platform,#fff);border:1px solid var(--dsw-alias-border-secondary,#e2e6ec);box-shadow:0 18px 40px rgba(23,33,52,.20)}',
101
+ '.dsh-mobile-link-guide-title{font-size:15px;font-weight:700}',
102
+ '.dsh-mobile-link-step{display:flex;gap:9px;align-items:flex-start}',
103
+ '.dsh-mobile-link-step-n{flex:0 0 18px;height:18px;border-radius:50%;background:rgba(65,118,230,.12);color:var(--dsw-alias-state-business-primary,#4176e6);display:grid;place-items:center;font-size:10px;font-weight:700}',
104
+ '.dsh-mobile-link-step-t{font-size:12px;line-height:1.6}',
105
+ '.dsh-mobile-link-testflight{padding:9px;border-radius:8px;background:var(--dsw-alias-bg-module-platform,rgba(15,17,21,.04));font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;color:var(--dsw-alias-state-business-primary,#4176e6);overflow-wrap:anywhere}',
106
+ '.dsh-mobile-link-alert-actions>*{flex:1}',
107
+ '.dsh-mobile-link-danger{height:36px;border:0;border-radius:9px;background:var(--dsw-alias-state-error-primary,#d1242f);color:#fff;font:inherit;font-size:12px;font-weight:600;cursor:pointer}',
108
+ '.dsh-mobile-link-danger:disabled{opacity:.65;cursor:default}',
59
109
  ].join('')
60
110
 
61
111
  function report(ctx, message) {
@@ -138,7 +188,12 @@ async function requestJson(path, init) {
138
188
  throw new Error(`连接器应答无法解析(HTTP ${response.status})`)
139
189
  }
140
190
  if (!response.ok || body?.ok === false) {
141
- throw new Error(body?.message ?? `连接器应答 HTTP ${response.status}`)
191
+ const error = new Error(body?.message ?? `连接器应答 HTTP ${response.status}`)
192
+ // Keep the machine code: the panel maps it to Chinese copy and must not
193
+ // parse English messages to tell "used" from "expired".
194
+ error.code = body?.code
195
+ error.status = response.status
196
+ throw error
142
197
  }
143
198
  return body
144
199
  }
@@ -150,11 +205,11 @@ function needsEnroll(status) {
150
205
  /** 状态点与状态词的唯一来源:侧栏入口与标题行共用,不会出现"点上是绿的、文字说断开"。 */
151
206
  function connectionState(state) {
152
207
  if (state.statusError) return { dot: 'error', text: '状态未知' }
153
- if (!state.status) return { dot: 'ongoing', text: '正在读取连接状态' }
154
- if (needsEnroll(state.status)) return { dot: 'error', text: '这台电脑还没登记到中转' }
208
+ if (!state.status) return { dot: 'ongoing', text: '正在读取状态…' }
209
+ if (needsEnroll(state.status)) return { dot: 'error', text: '此电脑尚未登记' }
155
210
  if (state.status.enabled === false) return { dot: 'idle', text: '连接器已停用' }
156
- if (state.status.connected) return { dot: 'done', text: '已连接中转' }
157
- return { dot: 'warning', text: '未连接,正在重连' }
211
+ if (state.status.connected) return { dot: 'done', text: '已连接' }
212
+ return { dot: 'warning', text: '未连接,正在重新连接' }
158
213
  }
159
214
  /** 手机报上来的版本:`hello` 存的是 `{name,version,build,label}`,直接插值会渲染成 [object Object]。 */
160
215
  function clientLabel(client) {
@@ -165,9 +220,42 @@ function clientLabel(client) {
165
220
  /** 在线/总数分开说:`deviceCount` 把"连着但已离线"的记录也算进去了。 */
166
221
  function deviceSummary(devices) {
167
222
  const list = Array.isArray(devices) ? devices : []
168
- if (list.length === 0) return '还没有手机配对过'
223
+ if (list.length === 0) return '暂无已配对设备'
169
224
  const online = list.filter((device) => device.offlineSince === null || device.offlineSince === undefined).length
170
- return online === list.length ? `在线 ${online} 台` : `在线 ${online} 台 · 共 ${list.length} 台`
225
+ return online === list.length ? `${online} 台在线` : `${online} 台在线,共 ${list.length} 台`
226
+ }
227
+ /** 全量已配对设备 + /status 的在线标记;离线设备也要有行。 */
228
+ function pairedRows(state) {
229
+ // 全量列表是行集合的所有者;/status 只补在线标记与运行时元数据。
230
+ // 反过来把 status 独有项也拼进来,会把已撤销但本地 router 还留着的记录
231
+ // 重新显示出来(计数与行数再次不一致)。
232
+ const roster = Array.isArray(state.pairedDevices) ? state.pairedDevices : null
233
+ const base = roster ?? (Array.isArray(state.status?.devices) ? state.status.devices : [])
234
+ const rows = new Map()
235
+ for (const device of base) {
236
+ if (device?.deviceId) rows.set(device.deviceId, { ...device })
237
+ }
238
+ const statusDevices = Array.isArray(state.status?.devices) ? state.status.devices : []
239
+ for (const device of statusDevices) {
240
+ const existing = rows.get(device.deviceId)
241
+ if (!existing) continue
242
+ rows.set(device.deviceId, { ...existing, ...device })
243
+ }
244
+ const online = new Set(statusDevices
245
+ .filter((device) => device.offlineSince === null || device.offlineSince === undefined)
246
+ .map((device) => device.deviceId))
247
+ return [...rows.values()].map((device) => ({ ...device, online: online.has(device.deviceId) }))
248
+ }
249
+ function pairedTotal(state) {
250
+ if (typeof state.pairedCount === 'number') return state.pairedCount
251
+ if (Array.isArray(state.pairedDevices)) return state.pairedDevices.length
252
+ return Array.isArray(state.status?.devices) ? state.status.devices.length : 0
253
+ }
254
+ function deviceSummaryOf(state) {
255
+ const total = pairedTotal(state)
256
+ if (total === 0) return '暂无已配对设备'
257
+ const online = pairedRows(state).filter((device) => device.online).length
258
+ return online === total ? `${total} 台在线` : `${online} 台在线,共 ${total} 台已配对`
171
259
  }
172
260
  function clockText(expiresAt) {
173
261
  const date = new Date(Number(expiresAt))
@@ -191,6 +279,19 @@ function createPanel() {
191
279
  copied: false,
192
280
  showError: false,
193
281
  now: Date.now(),
282
+ // 登记表单只属于本次界面:草稿不落 localStorage,提交成功后清空。
283
+ enrolling: false,
284
+ enrollDraft: { relay: '', relayTouched: false },
285
+ enrollError: null,
286
+ pairedDevices: null,
287
+ pairedMax: null,
288
+ pairedCount: null,
289
+ pairedError: null,
290
+ pairedBusy: false,
291
+ revokeTarget: null,
292
+ revoking: false,
293
+ revokeError: null,
294
+ guideOpen: false,
194
295
  }),
195
296
  disposed: false,
196
297
  style: null,
@@ -203,13 +304,47 @@ function createPanel() {
203
304
  async function loadStatus(panel) {
204
305
  try {
205
306
  const status = await requestJson(STATUS_ROUTE, { method: 'GET' })
206
- if (panel.disposed) return
307
+ if (panel.disposed) return undefined
207
308
  panel.store.set({ status, statusError: null })
309
+ return status
208
310
  } catch (error) {
209
- if (panel.disposed) return
311
+ if (panel.disposed) return undefined
210
312
  panel.store.set({ statusError: describe(error) })
313
+ return undefined
314
+ }
315
+ }
316
+ /**
317
+ * 读中转上的全部已配对设备(含离线)。/status 里的 devices 只含当前接上的,
318
+ * 单看它会把离线设备藏起来,导致“已配对 2/8”却只列出一台。二者在这里合并:
319
+ * 全量列表决定行与配额,/status 决定在线标记。
320
+ */
321
+ async function loadPairedDevices(panel) {
322
+ if (panel.disposed) return
323
+ const status = panel.store.get().status
324
+ if (!status || needsEnroll(status)) {
325
+ panel.store.set({ pairedDevices: null, pairedCount: null, pairedMax: null, pairedError: null })
326
+ return
327
+ }
328
+ panel.store.set({ pairedBusy: true })
329
+ try {
330
+ const body = await requestJson(DEVICES_ROUTE, { method: 'GET' })
331
+ if (panel.disposed) return
332
+ panel.store.set({
333
+ pairedDevices: Array.isArray(body.devices) ? body.devices : [],
334
+ pairedCount: body.pairedDevices ?? null,
335
+ pairedMax: body.maxPairedDevices ?? null,
336
+ pairedError: null,
337
+ pairedBusy: false,
338
+ })
339
+ } catch (error) {
340
+ if (panel.disposed) return
341
+ panel.store.set({
342
+ pairedError: '无法读取全部已配对设备。',
343
+ pairedBusy: false,
344
+ })
211
345
  }
212
346
  }
347
+
213
348
  /**
214
349
  * 取一张码。
215
350
  *
@@ -226,6 +361,12 @@ async function refreshCode(panel, { regenerate = false } = {}) {
226
361
  panel.store.set({ code: null, codeError: null })
227
362
  return
228
363
  }
364
+ // 登记成功 ≠ 已连接:链路还没起来时去取码只会失败,界面应该显示
365
+ // "正在连接中转",而不是把一次连接等待误报成取码失败。
366
+ if (panel.store.get().status?.connected !== true) {
367
+ panel.store.set({ code: null, codeError: null })
368
+ return
369
+ }
229
370
  // 只有**持久码**才复用:限时码到期就得重新取一张(老行为),否则屏幕上会留一张死码。
230
371
  if (!regenerate && panel.store.get().code?.persistent) return
231
372
  panel.store.set({ busy: true, codeError: null })
@@ -272,12 +413,26 @@ function tick(panel) {
272
413
  panel.lastStatusAt = now
273
414
  void loadStatus(panel)
274
415
  }
416
+ if (now - (panel.lastPairedAt ?? 0) >= PAIRED_POLL_MS
417
+ && !panel.store.get().pairedBusy
418
+ && panel.store.get().status
419
+ && !needsEnroll(panel.store.get().status)) {
420
+ panel.lastPairedAt = now
421
+ void loadPairedDevices(panel)
422
+ }
275
423
  // 持久码不因时间换码(032):只有用户点「重新生成」才换。限时码(老中转/老调用方)
276
424
  // 仍然按原来的规则在快过期时换一张,免得把一张马上就要失效的码摆在屏幕上。
277
- const code = panel.store.get().code
425
+ const state = panel.store.get()
426
+ const code = state.code
278
427
  if (code && !code.persistent && code.expiresAt != null && code.expiresAt - now <= CODE_FLOOR_MS) {
279
428
  void refreshCode(panel)
280
429
  }
430
+ // 连接恢复/登记完成后自动补一张码;取码失败后不自动重试(会把中转打爆,
431
+ // 也让"重试"按钮失去意义),由用户点按钮重试。
432
+ if (state.status?.connected === true && !needsEnroll(state.status)
433
+ && !state.code && !state.codeError && !state.busy) {
434
+ void refreshCode(panel)
435
+ }
281
436
  }
282
437
  function openPanel(panel) {
283
438
  if (panel.disposed) return
@@ -285,8 +440,12 @@ function openPanel(panel) {
285
440
  if (!panel.style?.node?.isConnected) panel.style = ensureStyle()
286
441
  panel.store.set({ open: true, now: Date.now(), copied: false })
287
442
  panel.lastStatusAt = Date.now()
288
- void loadStatus(panel)
289
- void refreshCode(panel)
443
+ void loadStatus(panel).then((status) => {
444
+ if (!panel.disposed && status?.connected === true && !needsEnroll(status)) {
445
+ void refreshCode(panel)
446
+ void loadPairedDevices(panel)
447
+ }
448
+ })
290
449
  }
291
450
  function closePanel(panel) {
292
451
  if (panel.disposed) return
@@ -334,6 +493,152 @@ function copyCode(panel) {
334
493
  done()
335
494
  }
336
495
  }
496
+ /** 服务端的机器码 → 面板上的中文;原始 message 留在 details 里给排障。 */
497
+ const ENROLL_ERRORS = {
498
+ 'enroll/bad-request': '请输入中转地址。',
499
+ 'enroll/invalid-relay': '中转地址无效。',
500
+ 'enroll/placeholder-relay': '请输入有效的中转地址。',
501
+ 'enroll/rate-limited': '尝试次数过多,请稍后重试。',
502
+ 'enroll/unreachable': '无法连接中转服务器,请检查地址与网络。',
503
+ 'enroll/timeout': '中转服务器未响应,请稍后重试。',
504
+ 'enroll/bad-response': '中转服务器返回了无法识别的响应。',
505
+ 'enroll/already-registered': '此电脑已登记。',
506
+ 'enroll/disabled': '连接器已停用。',
507
+ 'enroll/unavailable': '连接器正在停止,请稍后重试。',
508
+ 'enroll/state-write': '登记信息未能保存到此电脑,请检查磁盘权限。',
509
+ 'enroll/relay-error': '中转服务器登记失败,请稍后重试。',
510
+ 'enroll/internal': '连接器内部错误,请稍后重试。',
511
+ }
512
+ function enrollMessage(error) {
513
+ return ENROLL_ERRORS[error?.code] ?? describe(error) ?? '登记失败,请重试。'
514
+ }
515
+ function revokeMessage(error) {
516
+ const code = error?.code
517
+ if (code === 'request/unknown-device') return '设备不存在或已被撤销。'
518
+ if (code === 'auth/agent-mismatch') return '这台设备不属于此电脑。'
519
+ return '撤销失败,请稍后重试。'
520
+ }
521
+ /** 表单字段只改草稿;不在这里写“是否已登记”之类的业务状态。 */
522
+ function setEnrollField(panel, field, value) {
523
+ if (panel.disposed) return
524
+ if (field !== 'relay') return
525
+ const draft = { ...panel.store.get().enrollDraft, [field]: String(value ?? '') }
526
+ draft.relayTouched = true
527
+ panel.store.set({ enrollDraft: draft, enrollError: null })
528
+ }
529
+ /**
530
+ * 提交登记。
531
+ *
532
+ * 开放登记:只提交中转地址;电脑名称由连接器自动获取。成功时先乐观地切到
533
+ * “已登记 / 正在连接”,随后用 `/status` 覆盖。
534
+ */
535
+ async function submitEnroll(panel) {
536
+ if (panel.disposed || panel.store.get().enrolling) return
537
+ const state = panel.store.get()
538
+ const status = state.status
539
+ const draft = state.enrollDraft
540
+ const locked = Boolean(status?.enroll?.relayLocked)
541
+ const relayUrl = (locked
542
+ ? status?.enroll?.relayPrefill
543
+ : (draft.relayTouched ? draft.relay : status?.enroll?.relayPrefill) || '').trim()
544
+ if (!relayUrl) {
545
+ panel.store.set({
546
+ enrollError: { code: 'enroll/bad-request', message: '请输入中转地址。' },
547
+ })
548
+ return
549
+ }
550
+
551
+ panel.store.set({ enrolling: true, enrollError: null })
552
+ try {
553
+ const body = await requestJson(ENROLL_ROUTE, {
554
+ method: 'POST',
555
+ headers: { 'content-type': 'application/json' },
556
+ body: JSON.stringify({ relayUrl }),
557
+ })
558
+ if (panel.disposed) return
559
+ const previous = panel.store.get().status ?? {}
560
+ const enroll = previous.enroll ?? {}
561
+ panel.store.set({
562
+ enrolling: false,
563
+ enrollDraft: { relay: '', relayTouched: false },
564
+ enrollError: null,
565
+ status: {
566
+ ...previous,
567
+ state: previous.connected ? previous.state : 'connecting',
568
+ connected: previous.connected === true,
569
+ relayUrl: body.relayUrl ?? previous.relayUrl,
570
+ agentId: body.agentId ?? previous.agentId,
571
+ agentName: body.agentName ?? previous.agentName,
572
+ enroll: {
573
+ ...enroll,
574
+ registered: true,
575
+ needsEnroll: false,
576
+ relayUrl: body.relayUrl ?? enroll.relayUrl ?? null,
577
+ hint: null,
578
+ command: null,
579
+ },
580
+ },
581
+ statusError: null,
582
+ })
583
+ const next = await loadStatus(panel)
584
+ if (!panel.disposed && next?.connected === true && !needsEnroll(next)) {
585
+ void refreshCode(panel)
586
+ void loadPairedDevices(panel)
587
+ }
588
+ } catch (error) {
589
+ if (panel.disposed) return
590
+ panel.store.set({
591
+ enrolling: false,
592
+ enrollError: {
593
+ code: error?.code ?? 'enroll/internal',
594
+ message: enrollMessage(error),
595
+ detail: describe(error),
596
+ },
597
+ })
598
+ }
599
+ }
600
+
601
+ /** 打开撤销确认:只记录目标,不发送请求。 */
602
+ function requestRevoke(panel, device) {
603
+ if (panel.disposed || !device?.deviceId) return
604
+ panel.store.set({ revokeTarget: device, revokeError: null, guideOpen: false })
605
+ }
606
+ function cancelRevoke(panel) {
607
+ if (panel.disposed || panel.store.get().revoking) return
608
+ panel.store.set({ revokeTarget: null, revokeError: null })
609
+ }
610
+ /** 确认后才调用宿主路由;成功后刷新状态,失败留在确认窗口里。 */
611
+ async function confirmRevoke(panel) {
612
+ const target = panel.store.get().revokeTarget
613
+ if (panel.disposed || !target || panel.store.get().revoking) return
614
+ panel.store.set({ revoking: true, revokeError: null })
615
+ try {
616
+ await requestJson(REVOKE_ROUTE, {
617
+ method: 'POST',
618
+ headers: { 'content-type': 'application/json' },
619
+ body: JSON.stringify({ deviceId: target.deviceId }),
620
+ })
621
+ if (panel.disposed) return
622
+ panel.store.set({ revoking: false, revokeTarget: null, revokeError: null })
623
+ await loadStatus(panel)
624
+ await loadPairedDevices(panel)
625
+ } catch (error) {
626
+ if (panel.disposed) return
627
+ panel.store.set({
628
+ revoking: false,
629
+ revokeError: { message: revokeMessage(error), detail: describe(error) },
630
+ })
631
+ }
632
+ }
633
+ function openGuide(panel) {
634
+ if (panel.disposed) return
635
+ panel.store.set({ guideOpen: true, revokeTarget: null, revokeError: null })
636
+ }
637
+ function closeGuide(panel) {
638
+ if (panel.disposed) return
639
+ panel.store.set({ guideOpen: false })
640
+ }
641
+
337
642
  /**
338
643
  * 控制器:视图只读它的 store、只调它的动作。
339
644
  *
@@ -350,6 +655,14 @@ function createController() {
350
655
  refreshCode: (options) => refreshCode(panel, options),
351
656
  copy: () => copyCode(panel),
352
657
  toggleError: () => panel.store.set({ showError: !panel.store.get().showError }),
658
+ enroll: () => submitEnroll(panel),
659
+ setEnrollField: (field, value) => setEnrollField(panel, field, value),
660
+ reloadStatus: () => { void loadStatus(panel).then(() => loadPairedDevices(panel)) },
661
+ requestRevoke: (device) => requestRevoke(panel, device),
662
+ cancelRevoke: () => cancelRevoke(panel),
663
+ confirmRevoke: () => { void confirmRevoke(panel) },
664
+ openGuide: () => openGuide(panel),
665
+ closeGuide: () => closeGuide(panel),
353
666
  start: () => startPanel(panel),
354
667
  }
355
668
  }
@@ -383,109 +696,277 @@ function statusSection(React, ui, state) {
383
696
  React.createElement(ui.StateDot, { state: conn.dot }),
384
697
  React.createElement('span', null, conn.text)),
385
698
  state.statusError ? React.createElement('div', { className: 'dsh-mobile-link-note dsh-mobile-link-error' },
386
- `${state.statusError}(连接器可能刚重启,或插件没装进当前宿主)`) : null,
387
- row(React, '中转', state.status?.relayUrl ?? '—', true),
388
- state.status?.agentName ? row(React, '电脑', state.status.agentName) : null,
389
- state.status ? row(React, '手机', deviceSummary(state.status.devices)) : null)
699
+ '无法读取连接状态。') : null,
700
+ state.status && !needsEnroll(state.status)
701
+ ? row(React, '中转服务器', state.status.relayUrl ?? '—', true)
702
+ : null,
703
+ state.status && !needsEnroll(state.status)
704
+ ? row(React, '设备', deviceSummaryOf(state))
705
+ : null)
706
+ }
707
+ /** 官方 Input 在旧宿主可能不存在;没有就用原生 input,表单不会因此消失。 */
708
+ function enrollInput(React, ui, props) {
709
+ const official = ui?.Input != null
710
+ const Input = official ? ui.Input : 'input'
711
+ const { className, ...rest } = props
712
+ return React.createElement(Input, official
713
+ ? rest
714
+ : { className: ['dsh-mobile-link-input', className].filter(Boolean).join(' '), ...rest })
390
715
  }
391
- /** 未登记:给命令,不给二维码(FR-008)。 */
392
- function enrollSection(React, status) {
716
+ function enrollField(React, id, labelText, children) {
717
+ return React.createElement('div', { className: 'dsh-mobile-link-field', key: id },
718
+ React.createElement('label', { className: 'dsh-mobile-link-label', htmlFor: id }, labelText),
719
+ children)
720
+ }
721
+ /** 旧宿主没有面板登记能力时,退回命令行提示。 */
722
+ function enrollCommandSection(React, status) {
393
723
  return React.createElement('div', { className: 'dsh-mobile-link-section' },
394
724
  React.createElement('div', { className: 'dsh-mobile-link-note' },
395
- '这台电脑还没有登记到中转。登记是一次性的,登记完重启 DSH 就能扫码配对:'),
725
+ '此电脑尚未登记。请使用命令行完成登记:'),
396
726
  React.createElement('div', { className: 'dsh-mobile-link-mono dsh-mobile-link-v', style: { textAlign: 'left' } },
397
- status?.enroll?.command ?? 'dsh-mobile-link enroll --invite <邀请码> --relay <中转地址>'))
727
+ status?.enroll?.command ?? 'dsh-mobile-link enroll --relay <中转地址>'))
398
728
  }
399
729
  /**
400
- * 二维码卡。
401
- *
402
- * 032 之前这里是"倒计时 + 换一张"(一次性码,10 分钟)。现在默认是**持久码**:
403
- * 所以卡片上写"长期有效,直到你点重新生成",并且不再有进度条——一个永远走不到头的
404
- * 倒计时只会让人以为码快过期了。限时码(老中转/显式请求)仍然显示倒计时与剩余时间。
730
+ * 未登记:开放登记。只提交中转地址;电脑名称由连接器自动获取。
731
+ * 旧宿主没有 `enroll.panel` 时继续显示命令,作为半升级回退。
732
+ */
733
+ function enrollSection(React, controller, ui, state) {
734
+ const status = state.status
735
+ if (status?.enroll?.panel !== true) return enrollCommandSection(React, status)
736
+ if (status.enabled === false) {
737
+ return React.createElement('div', { className: 'dsh-mobile-link-section' },
738
+ React.createElement('div', { className: 'dsh-mobile-link-note' },
739
+ '连接器已停用。请在插件配置中启用后重试。'))
740
+ }
741
+ const enroll = status.enroll
742
+ const locked = Boolean(enroll.relayLocked)
743
+ const prefill = typeof enroll.relayPrefill === 'string' ? enroll.relayPrefill : ''
744
+ const draft = state.enrollDraft ?? { relay: '', relayTouched: false }
745
+ const relayValue = locked ? prefill : (draft.relayTouched ? draft.relay : prefill)
746
+ const submitting = Boolean(state.enrolling)
747
+ const error = state.enrollError
748
+ const setField = (field) => (event) => controller.setEnrollField(field, event?.target?.value ?? '')
749
+ const submitOnEnter = (event) => {
750
+ if (event?.key !== 'Enter') return
751
+ event.preventDefault?.()
752
+ if (!submitting) controller.enroll()
753
+ }
754
+ const command = enroll.command ?? 'dsh-mobile-link enroll --relay <中转地址>'
755
+
756
+ return React.createElement('div', { className: 'dsh-mobile-link-form' },
757
+ React.createElement('div', { className: 'dsh-mobile-link-note', key: 'intro' },
758
+ '登记此电脑以连接中转服务器。'),
759
+ enrollField(React, 'mobile-link-relay', '中转地址',
760
+ enrollInput(React, ui, {
761
+ id: 'mobile-link-relay', type: 'text', inputMode: 'url', value: relayValue,
762
+ placeholder: 'wss://<中转地址>/dsh-link', readOnly: locked, disabled: submitting,
763
+ autoComplete: 'off', spellCheck: false,
764
+ onChange: setField('relay'), onKeyDown: submitOnEnter,
765
+ })),
766
+ React.createElement('div', { className: 'dsh-mobile-link-note', key: 'relay-hint' },
767
+ locked ? '由配置指定,不可修改。' : '已预填,可修改。'),
768
+ error ? React.createElement('div', {
769
+ className: 'dsh-mobile-link-error-inline', role: 'alert', key: 'error',
770
+ title: error.detail ?? undefined,
771
+ }, error.message) : null,
772
+ React.createElement(ui.Button, {
773
+ key: 'submit', variant: 'primary', type: 'button', disabled: submitting,
774
+ onClick: () => controller.enroll(),
775
+ }, submitting ? '正在登记…' : (error ? '重试' : '登记')),
776
+ React.createElement('div', { className: 'dsh-mobile-link-note', key: 'footnote' },
777
+ '登记后将自动连接。'),
778
+ React.createElement('details', { className: 'dsh-mobile-link-cmd', key: 'cli' },
779
+ React.createElement('summary', null, '命令行登记(适用于无界面环境)'),
780
+ React.createElement('div', { className: 'dsh-mobile-link-mono' }, command)))
781
+ }
782
+ function githubSection(React) {
783
+ return React.createElement('div', { className: 'dsh-mobile-link-github', key: 'github' },
784
+ React.createElement('div', { className: 'dsh-mobile-link-github-line' },
785
+ React.createElement('span', { className: 'dsh-mobile-link-k' }, 'GitHub 仓库'),
786
+ React.createElement('a', { href: GITHUB_URL, target: '_blank', rel: 'noreferrer' },
787
+ 'github.com/jayantTang/DSH_Mobile')),
788
+ React.createElement('div', { className: 'dsh-mobile-link-welcome' },
789
+ '欢迎在 GitHub 上为项目加星标。'))
790
+ }
791
+ function iosLinkSection(React, controller) {
792
+ return React.createElement('div', { className: 'dsh-mobile-link-ioslink', key: 'ios-link' },
793
+ React.createElement('span', null, '尚未安装 iOS App?'),
794
+ React.createElement('a', {
795
+ href: '#', onClick: (event) => { event?.preventDefault?.(); controller.openGuide() },
796
+ }, '查看安装方式'))
797
+ }
798
+ /**
799
+ * 二维码卡。默认是持久码,所以写“长期有效直到重新生成”,不再有倒计时;
800
+ * 限时码(老中转/显式请求)仍然显示剩余时间。
405
801
  */
406
802
  function codeSection(React, controller, ui, state) {
407
803
  const code = state.code
408
- const children = []
804
+ const connecting = !code && !state.codeError && state.status?.connected !== true
805
+ const main = []
409
806
  if (code) {
410
- children.push(React.createElement('div', { className: 'dsh-mobile-link-code', key: 'code' }, code.code))
411
- // 二维码:宿主给的 SVG 直接进 data URI,界面不碰图案本身。
412
- children.push(React.createElement('img', {
413
- key: 'qr', className: 'dsh-mobile-link-qr', alt: '配对二维码',
414
- src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(String(code.svg))}`,
415
- }))
807
+ main.push(React.createElement('div', { className: 'dsh-mobile-link-code', key: 'code' }, code.code))
416
808
  if (code.persistent || code.expiresAt == null) {
417
- children.push(React.createElement('div', { className: 'dsh-mobile-link-count', key: 'count' },
418
- React.createElement('span', null,
419
- `长期有效 · 直到你点「重新生成」${code.maxPairedDevices
420
- ? `(已配对 ${code.pairedDevices ?? 0}/${code.maxPairedDevices})` : ''}`)))
809
+ main.push(React.createElement('div', { className: 'dsh-mobile-link-count', key: 'count' },
810
+ React.createElement('span', null, '长期有效直到重新生成'),
811
+ code.maxPairedDevices
812
+ ? React.createElement('span', { className: 'dsh-mobile-link-count-tag' },
813
+ `(已配对 ${state.pairedCount ?? (Array.isArray(state.pairedDevices)
814
+ ? state.pairedDevices.length : (code.pairedDevices ?? 0))}/${code.maxPairedDevices})`)
815
+ : null))
421
816
  } else {
422
817
  const remaining = Math.max(0, code.expiresAt - state.now)
423
818
  const percent = Math.max(0, Math.min(100, (remaining / (code.ttlMs || CODE_TTL_MS)) * 100))
424
- children.push(React.createElement('div', { className: 'dsh-mobile-link-count', key: 'count' },
819
+ main.push(React.createElement('div', { className: 'dsh-mobile-link-count', key: 'count' },
425
820
  React.createElement('div', { className: 'dsh-mobile-link-bar' }, React.createElement('i', { style: { width: `${percent.toFixed(1)}%` } })),
426
- React.createElement('span', null, `有效期至 ${clockText(code.expiresAt)}(还剩 ${remainingText(remaining)})`)))
821
+ React.createElement('span', null, `有效期至 ${clockText(code.expiresAt)}(剩余 ${remainingText(remaining)})`)))
427
822
  }
428
823
  } else if (state.codeError) {
429
- children.push(React.createElement('div', { className: 'dsh-mobile-link-note dsh-mobile-link-error', key: 'err' }, state.codeError))
824
+ main.push(React.createElement('div', { className: 'dsh-mobile-link-note dsh-mobile-link-error', key: 'err' }, state.codeError))
825
+ } else if (connecting) {
826
+ main.push(React.createElement('div', { className: 'dsh-mobile-link-note', key: 'connecting' },
827
+ state.enrolling ? '正在登记…' : '正在连接…'))
430
828
  } else {
431
- children.push(React.createElement('div', { className: 'dsh-mobile-link-note', key: 'loading' },
432
- state.busy ? '正在向中转取一张新码…' : '还没有可用的配对码。'))
829
+ main.push(React.createElement('div', { className: 'dsh-mobile-link-note', key: 'loading' },
830
+ state.busy ? '正在获取…' : '暂无配对码。'))
433
831
  }
434
- const buttons = [React.createElement(ui.Button, {
832
+ const buttons = connecting ? [] : [React.createElement(ui.Button, {
435
833
  key: 'swap', variant: 'primary', type: 'button', disabled: Boolean(state.busy),
436
834
  onClick: () => void controller.refreshCode({ regenerate: Boolean(state.code) }),
437
- }, state.busy ? '取码中…'
438
- : (state.codeError ? '重试' : (state.code ? '重新生成' : '取一张码')))]
835
+ }, state.busy ? '正在获取…'
836
+ : (state.codeError ? '重试' : (state.code ? '重新生成' : '获取配对码')))]
439
837
  if (code) {
440
838
  buttons.push(React.createElement(ui.Button, {
441
839
  key: 'copy', variant: 'outline', type: 'button', onClick: () => controller.copy(),
442
840
  }, state.copied ? '已复制' : '复制配对码'))
443
841
  }
444
- children.push(React.createElement('div', { className: 'dsh-mobile-link-actions', key: 'actions' }, buttons))
842
+ if (buttons.length > 0) {
843
+ main.push(React.createElement('div', { className: 'dsh-mobile-link-actions', key: 'actions' }, buttons))
844
+ }
845
+ const children = []
846
+ if (code) {
847
+ children.push(React.createElement('img', {
848
+ key: 'qr', className: 'dsh-mobile-link-qr', alt: '配对二维码',
849
+ src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(String(code.svg))}`,
850
+ }))
851
+ }
852
+ children.push(React.createElement('div', { className: 'dsh-mobile-link-card-main', key: 'main' }, main))
445
853
  return React.createElement('div', { className: 'dsh-mobile-link-card' }, children)
446
854
  }
447
- function deviceRow(React, ui, device) {
448
- const online = device.offlineSince === null || device.offlineSince === undefined
855
+ function deviceRow(React, ui, device, online, controller) {
449
856
  const meta = [device.model, clientLabel(device.client)].filter(Boolean).join(' · ')
450
857
  return React.createElement('div', { className: 'dsh-mobile-link-device', key: device.deviceId ?? device.name },
451
858
  React.createElement(ui.StateDot, { state: online ? 'done' : 'idle' }),
452
859
  React.createElement('div', { style: { minWidth: 0 } },
453
- React.createElement('div', { className: 'dsh-mobile-link-device-name' }, device.name ?? '未命名设备'),
860
+ React.createElement('div', { className: 'dsh-mobile-link-device-line' },
861
+ React.createElement('div', { className: 'dsh-mobile-link-device-name' }, device.name ?? '未命名设备'),
862
+ React.createElement(ui.Tag, { tone: online ? 'success' : 'quiet', className: 'dsh-mobile-link-tag' },
863
+ online ? '在线' : '离线')),
454
864
  meta ? React.createElement('div', { className: 'dsh-mobile-link-device-meta' }, meta) : null),
455
- React.createElement(ui.Tag, { tone: online ? 'success' : 'quiet', className: 'dsh-mobile-link-tag' },
456
- online ? '在线' : '离线'))
865
+ React.createElement('button', {
866
+ type: 'button', className: 'dsh-mobile-link-revoke',
867
+ onClick: () => controller.requestRevoke(device),
868
+ }, '撤销'))
457
869
  }
458
- function devicesSection(React, ui, status) {
459
- const devices = Array.isArray(status?.devices) ? status.devices : []
870
+ function devicesSection(React, ui, state, controller) {
871
+ const devices = pairedRows(state)
460
872
  const rows = devices.length === 0
461
- ? [React.createElement('div', { className: 'dsh-mobile-link-note', key: 'none' }, '还没有手机配对过。')]
462
- : devices.map((device) => deviceRow(React, ui, device))
873
+ ? [React.createElement('div', { className: 'dsh-mobile-link-note', key: 'none' }, '暂无已配对设备。')]
874
+ : devices.map((device) => deviceRow(React, ui, device, device.online, controller))
463
875
  return React.createElement('div', { className: 'dsh-mobile-link-section' },
464
- React.createElement('div', { className: 'dsh-mobile-link-section-title' }, '已配对的手机'),
465
- React.createElement('div', { className: 'dsh-mobile-link-devices' }, rows))
876
+ React.createElement('div', { className: 'dsh-mobile-link-section-title' }, '已配对设备'),
877
+ React.createElement('div', { className: 'dsh-mobile-link-devices' }, rows),
878
+ state.pairedError
879
+ ? React.createElement('div', { className: 'dsh-mobile-link-note dsh-mobile-link-error' },
880
+ state.pairedError)
881
+ : null)
466
882
  }
467
- /** 最近一次失败:平时收起来,别让一行红字天天抢注意力。 */
883
+ /** 最近一次失败:平时收起来。 */
468
884
  function errorSection(React, controller, ui, status, open) {
469
885
  if (!status?.lastError) return null
470
886
  return React.createElement(ui.DisclosureRow, {
471
- title: '最近一次错误', open: Boolean(open), expandable: true,
887
+ title: '上次错误', open: Boolean(open), expandable: true,
472
888
  onToggle: () => controller.toggleError(),
473
889
  }, React.createElement('div', { className: 'dsh-mobile-link-mono dsh-mobile-link-error' }, status.lastError))
474
890
  }
891
+ /** 撤销确认:浮层叠加在扫码页上,不重复设备列表。 */
892
+ function revokeOverlay(React, ui, state, controller) {
893
+ const target = state.revokeTarget
894
+ if (!target) return null
895
+ const name = target.name ?? '这台设备'
896
+ return React.createElement('div', { className: 'dsh-mobile-link-overlay', key: 'revoke-overlay' },
897
+ React.createElement('div', {
898
+ className: 'dsh-mobile-link-alert', role: 'dialog', 'aria-modal': 'true',
899
+ 'aria-label': '撤销这台设备',
900
+ },
901
+ React.createElement('div', { className: 'dsh-mobile-link-alert-title' }, '撤销这台设备?'),
902
+ React.createElement('div', { className: 'dsh-mobile-link-note' },
903
+ `“${name}”将无法继续连接。需要重新扫码才能再次配对。`),
904
+ state.revokeError ? React.createElement('div', { className: 'dsh-mobile-link-error-inline', role: 'alert' },
905
+ state.revokeError.message) : null,
906
+ React.createElement('div', { className: 'dsh-mobile-link-alert-actions' },
907
+ React.createElement(ui.Button, {
908
+ variant: 'outline', type: 'button', disabled: Boolean(state.revoking),
909
+ onClick: () => controller.cancelRevoke(),
910
+ }, '取消'),
911
+ React.createElement('button', {
912
+ type: 'button', className: 'dsh-mobile-link-danger', disabled: Boolean(state.revoking),
913
+ onClick: () => controller.confirmRevoke(),
914
+ }, state.revoking ? '撤销中…' : '撤销设备'))))
915
+ }
916
+ /** iOS 安装指引:只给 TestFlight 公开链接。 */
917
+ function guideOverlay(React, ui, controller) {
918
+ return React.createElement('div', { className: 'dsh-mobile-link-overlay', key: 'guide-overlay' },
919
+ React.createElement('div', {
920
+ className: 'dsh-mobile-link-guide', role: 'dialog', 'aria-modal': 'true',
921
+ 'aria-label': '安装 DSH Mobile',
922
+ },
923
+ React.createElement('div', { className: 'dsh-mobile-link-guide-title' }, '安装 DSH Mobile'),
924
+ React.createElement('div', { className: 'dsh-mobile-link-note' }, '在 iPhone 或 iPad 上通过 TestFlight 安装。'),
925
+ React.createElement('div', { className: 'dsh-mobile-link-step' },
926
+ React.createElement('span', { className: 'dsh-mobile-link-step-n' }, '1'),
927
+ React.createElement('span', { className: 'dsh-mobile-link-step-t' }, '从 App Store 安装 TestFlight。')),
928
+ React.createElement('div', { className: 'dsh-mobile-link-step' },
929
+ React.createElement('span', { className: 'dsh-mobile-link-step-n' }, '2'),
930
+ React.createElement('span', { className: 'dsh-mobile-link-step-t' }, '打开 TestFlight 邀请链接,安装 DSH Mobile。')),
931
+ React.createElement('div', { className: 'dsh-mobile-link-step' },
932
+ React.createElement('span', { className: 'dsh-mobile-link-step-n' }, '3'),
933
+ React.createElement('span', { className: 'dsh-mobile-link-step-t' }, '打开 DSH Mobile,扫描电脑上的配对二维码。')),
934
+ React.createElement('a', {
935
+ className: 'dsh-mobile-link-testflight', href: TESTFLIGHT_URL, target: '_blank', rel: 'noreferrer',
936
+ }, TESTFLIGHT_URL),
937
+ React.createElement('div', { className: 'dsh-mobile-link-note' }, '需要 iOS 17、iPadOS 17 或更高版本。'),
938
+ React.createElement(ui.Button, {
939
+ variant: 'primary', type: 'button', onClick: () => controller.closeGuide(),
940
+ }, '完成')))
941
+ }
475
942
  function panelBody(React, controller, ui, state) {
476
943
  const children = [statusSection(React, ui, state)]
477
- if (needsEnroll(state.status)) children.push(enrollSection(React, state.status))
478
- else children.push(codeSection(React, controller, ui, state))
479
- children.push(devicesSection(React, ui, state.status))
944
+ if (!state.status) {
945
+ children.push(React.createElement('div', { className: 'dsh-mobile-link-note', key: 'loading' },
946
+ state.statusError ? '无法读取连接状态。' : '正在读取状态…'))
947
+ if (state.statusError) {
948
+ children.push(React.createElement(ui.Button, {
949
+ key: 'retry', variant: 'outline', type: 'button', onClick: () => controller.reloadStatus(),
950
+ }, '重试'))
951
+ }
952
+ return React.createElement('div', { className: 'dsh-mobile-link-body' }, children)
953
+ }
954
+ if (needsEnroll(state.status)) {
955
+ children.push(enrollSection(React, controller, ui, state))
956
+ return React.createElement('div', { className: 'dsh-mobile-link-body' }, children)
957
+ }
958
+ children.push(codeSection(React, controller, ui, state))
959
+ children.push(githubSection(React))
960
+ children.push(iosLinkSection(React, controller))
961
+ children.push(devicesSection(React, ui, state, controller))
480
962
  const error = errorSection(React, controller, ui, state.status, state.showError)
481
963
  if (error) children.push(error)
482
- children.push(React.createElement('div', { className: 'dsh-mobile-link-note', key: 'hint' },
483
- '用 DSH Mobile App 扫码配对:这串码长期有效,可以配多台设备(手机/浏览器)。'
484
- + '换掉它点「重新生成」——已经在用的设备不会掉线,只有拿旧码来配的人需要重新扫。'
485
- + '相机不方便时,也可以把上面那串配对码手动输进 App 连接页底部的手输入口。'))
486
964
  return React.createElement('div', { className: 'dsh-mobile-link-body' }, children)
487
965
  }
488
- /** 小窗:官方 Modal 负责遮罩、顶层 Esc 与焦点归位;内容只在打开时渲染。 */
966
+ /**
967
+ * 小窗:官方 Modal 负责遮罩、顶层 Esc 与焦点归位;内容只在打开时渲染。
968
+ * 正文超出窗口高度时在正文区滚动;确认与安装指引放在滚动区之外,不跟着内容走。
969
+ */
489
970
  function makePanel(React, controller, ui) {
490
971
  return function MobileLinkPanel() {
491
972
  const state = useStore(React, controller.store)
@@ -495,7 +976,11 @@ function makePanel(React, controller, ui) {
495
976
  title: LABEL,
496
977
  closeLabel: '关闭',
497
978
  className: 'dsh-mobile-link-dialog',
498
- }, panelBody(React, controller, ui, state))
979
+ contentClassName: 'dsh-mobile-link-content',
980
+ }, React.createElement('div', { className: 'dsh-mobile-link-shell' },
981
+ panelBody(React, controller, ui, state),
982
+ state.revokeTarget ? revokeOverlay(React, ui, state, controller) : null,
983
+ state.guideOpen ? guideOverlay(React, ui, controller) : null))
499
984
  }
500
985
  }
501
986