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/README.md +28 -13
- package/cordis.patch.yml +11 -0
- package/lib/cli.js +3 -4
- package/lib/client.js +560 -75
- package/lib/dir-listing.js +167 -0
- package/lib/dlp.js +33 -1
- package/lib/enroll.js +249 -35
- package/lib/files.js +118 -12
- package/lib/git.js +20 -5
- package/lib/hello.js +23 -1
- package/lib/history/compress.js +40 -0
- package/lib/history/coverage.js +21 -0
- package/lib/history/fold-summary.js +472 -0
- package/lib/history/fold.js +184 -0
- package/lib/history/session-snapshot.js +75 -24
- package/lib/history/summary-window.js +168 -0
- package/lib/history.js +171 -9
- package/lib/index.js +38 -2
- package/lib/jobs-roster.js +377 -0
- package/lib/link.js +238 -11
- package/lib/logs.js +242 -0
- package/lib/pairing.js +1 -1
- package/lib/router.js +171 -5
- package/lib/routes.js +147 -0
- package/lib/session-digest.js +73 -4
- package/lib/state.js +6 -6
- package/lib/status.js +30 -2
- package/lib/unicode.js +116 -0
- package/package.json +1 -1
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-
|
|
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;
|
|
46
|
-
'.dsh-mobile-link-qr{display:block;width:
|
|
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:
|
|
49
|
-
'.dsh-mobile-link-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
'
|
|
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 --
|
|
727
|
+
status?.enroll?.command ?? 'dsh-mobile-link enroll --relay <中转地址>'))
|
|
398
728
|
}
|
|
399
729
|
/**
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
|
|
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
|
|
804
|
+
const connecting = !code && !state.codeError && state.status?.connected !== true
|
|
805
|
+
const main = []
|
|
409
806
|
if (code) {
|
|
410
|
-
|
|
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
|
-
|
|
418
|
-
React.createElement('span', null,
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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)}
|
|
821
|
+
React.createElement('span', null, `有效期至 ${clockText(code.expiresAt)}(剩余 ${remainingText(remaining)})`)))
|
|
427
822
|
}
|
|
428
823
|
} else if (state.codeError) {
|
|
429
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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(
|
|
456
|
-
|
|
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,
|
|
459
|
-
const 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: '
|
|
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 (
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|