dsh-pocket-relay 1.0.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.
@@ -0,0 +1,979 @@
1
+ // dsh-pocket-relay 网页客户端:
2
+ // 1. 设置页签「手机访问」(局域网/公网二维码 + 更新/重启提示)
3
+ // 2. 移动端适配(移植自 MIT 项目 dsh-web-mobile,见 client/mobile/LICENSE.dsh-web-mobile)
4
+ //
5
+ // 手机扫码打开的就是电脑上的 dsh web,实时同步;窄屏自动变成抽屉布局。
6
+ //
7
+ // 注:Web Push 已移除——浏览器推送依赖 Google FCM(Chrome)等境外服务,
8
+ // 国内直连被墙,普通用户用不了。专注扫码同屏这一件事。
9
+
10
+ import { createElement as h, useEffect, useRef, useState } from 'react';
11
+
12
+ import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS, redactStatus, compareVersions } from './api.js';
13
+ import { mobileApply } from './mobile/mobile-apply.tsx';
14
+ import { NS as POCKET_NS, zh as POCKET_ZH, en as POCKET_EN } from './pocket-locales.js';
15
+
16
+ const name = 'dsh-pocket-relay';
17
+ const inject = ['slots', 'connection', 'layout', 'locale', 'sessionLogDownload'];
18
+
19
+ // 词典在 pocket-locales.js;这里只做「取 key → 替换 {占位符} → 字符串」。
20
+ // 不依赖 DSH t() 的插值能力,避免行为不一致。
21
+ function fmt(t, key, vars) {
22
+ let s = t(key);
23
+ if (vars) {
24
+ for (const [k, v] of Object.entries(vars)) {
25
+ s = String(s).split(`{${k}}`).join(String(v));
26
+ }
27
+ }
28
+ return s;
29
+ }
30
+
31
+ // 官方 DeepSeek Harness 设计系统(dsh-client-ui-theme design-platform.css):
32
+ // 按钮 md=36px 胶囊形 / sm=28px;品牌色 --dsw-alias-brand-primary;
33
+ // hover 走 --dsw-alias-button-*-hover;间距 4px 栅格;正文 13px。
34
+ const styles = {
35
+ card: { background: 'var(--dsw-alias-bg-layer-1,#fff)', border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', borderRadius: 12, padding: '16px 20px', maxWidth: 480 },
36
+ block: { borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', marginTop: 16, paddingTop: 16 },
37
+ muted: { color: 'var(--dsw-alias-label-tertiary,#8b93a1)', fontSize: 12, lineHeight: 1.5 },
38
+ code: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 12, wordBreak: 'break-all', margin: '6px 0 10px', color: 'var(--dsw-alias-label-primary,inherit)' },
39
+ // 主按钮:官方 md 胶囊形(36px)
40
+ primary: { font: 'inherit', cursor: 'pointer', border: 'none', background: 'var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))', color: 'var(--dsw-alias-label-primary-foreground, #fff)', height: 36, padding: '0 16px', borderRadius: 999, fontSize: 13, fontWeight: 500, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' },
41
+ // 次级按钮:官方 outline/ghost 胶囊形
42
+ btn: { font: 'inherit', cursor: 'pointer', border: '1px solid var(--dsw-alias-button-ghost-active-border, var(--dsw-alias-border-l2,#d1d5db))', background: 'var(--dsw-alias-bg-layer-1,#fff)', color: 'var(--dsw-alias-label-primary,inherit)', height: 36, padding: '0 16px', borderRadius: 999, fontSize: 13, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' },
43
+ qr: { width: 220, height: 220, borderRadius: 10, border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', margin: '8px 0' },
44
+ warn: { color: 'var(--dsw-alias-state-warn-primary,#b45309)', fontSize: 12, lineHeight: 1.5 },
45
+ // 表单控件(中继配置 / 设备命名):与既有下拉框同一套视觉
46
+ input: { font: 'inherit', height: 30, padding: '0 8px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', background: 'var(--dsw-alias-bg-layer-1,#fff)', color: 'var(--dsw-alias-label-primary,inherit)', fontSize: 12, boxSizing: 'border-box' },
47
+ field: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', margin: '6px 0' },
48
+ fieldLabel: { flexShrink: 0, width: 96, textAlign: 'right' },
49
+ // 状态点:靠颜色一眼看出通没通
50
+ dot: (bg) => ({ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: bg, marginRight: 6, flexShrink: 0 }),
51
+ deviceRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 0', borderTop: '1px solid var(--dsw-alias-border-l2,#f0f0f2)', fontSize: 12 },
52
+ };
53
+
54
+ function PocketSettingsTab({ rpcCall, t }) {
55
+ const [status, setStatus] = useState(null);
56
+ const [busy, setBusy] = useState(false);
57
+ const [error, setError] = useState(null);
58
+ const [tunnelState, setTunnelState] = useState(null); // 隧道进度 {phase, detail, startedAt}
59
+ const [restartNotice, setRestartNotice] = useState(false); // 重启后提示
60
+ const [updateInfo, setUpdateInfo] = useState(null); // { current, latest, updating, result, startedAt } | null
61
+ const [isDesktop, setIsDesktop] = useState(false); // DSH Desktop(Electron)环境:更新/重启由桌面版管理
62
+ const [now, setNow] = useState(Date.now()); // 每秒 tick,驱动倒计时
63
+
64
+ // 进行中操作的「已等待 X 秒」倒计时
65
+ useEffect(() => {
66
+ const t = setInterval(() => setNow(Date.now()), 1000);
67
+ return () => clearInterval(t);
68
+ }, []);
69
+ const elapsed = (startedAt) => (startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0);
70
+
71
+ const call = async (endpoint, payload) => {
72
+ const res = await rpcCall(endpoint, payload);
73
+ if (!res?.ok) throw new Error(res?.error?.message ?? 'RPC failed');
74
+ return res.value;
75
+ };
76
+
77
+ const load = async () => {
78
+ try {
79
+ const s = await call(POCKET_ENDPOINTS.status, {});
80
+ setStatus(s);
81
+ setTunnelState(s.tunnelState ?? null);
82
+ if (s.desktop) setIsDesktop(true);
83
+ if (s.restartNotice) {
84
+ // 新进程确认起来了:显示一次「已重启」,清掉旧的更新横幅(单状态,不并存),
85
+ // 然后自动刷新页面加载新代码——不用用户手动刷新
86
+ setRestartNotice(true);
87
+ setUpdateInfo(null);
88
+ if (!sessionStorage.getItem('dshp-auto-reloaded')) {
89
+ sessionStorage.setItem('dshp-auto-reloaded', '1');
90
+ setTimeout(() => { try { location.reload(); } catch { /* 忽略 */ } }, 2000);
91
+ }
92
+ }
93
+ } catch { /* 忽略瞬时失败 */ }
94
+ };
95
+
96
+ useEffect(() => {
97
+ load();
98
+ const t = setInterval(load, 3000);
99
+ return () => clearInterval(t);
100
+ }, []);
101
+
102
+ // 每次页面加载清掉自动刷新标记——这样下次重启(更新后)才能再次触发自动刷新
103
+ useEffect(() => {
104
+ try { sessionStorage.removeItem('dshp-auto-reloaded'); } catch { /* 忽略 */ }
105
+ }, []);
106
+
107
+ // 版本检测:host 当前版本 vs npm registry latest(registry 带 CORS *)
108
+ // 两种情况显示横幅:① 有新版可更新;② 磁盘已更新但进程还是旧代码(重启生效)
109
+ // cache: 'no-store' —— registry 响应带缓存头,浏览器会缓存旧版本号导致「小版本不提示」
110
+ // 周期重查(每 5 分钟):npm registry 的 /latest 走 CDN 边缘缓存,刚发布后打开页面
111
+ // 可能拿到旧版本号——周期性重查让更新提示在缓存刷新后自动出现,不用重开页面。
112
+ // 桌面端(isDesktop):更新/重启由 DSH Desktop 管理,这里不做版本检测、不显示更新横幅
113
+ useEffect(() => {
114
+ if (isDesktop) return;
115
+ let alive = true;
116
+ const check = async () => {
117
+ try {
118
+ const v = await call(POCKET_ENDPOINTS.version, {});
119
+ const meta = await (await fetch('https://registry.npmjs.org/dsh-pocket-relay/latest', { cache: 'no-store' })).json();
120
+ if (!alive) return;
121
+ const latest = typeof meta?.version === 'string' ? meta.version : null;
122
+ if (latest && v.current && compareVersions(latest, v.current) > 0) {
123
+ setUpdateInfo({ current: v.current, latest, updating: false, result: null });
124
+ } else if (v.current && v.loaded && compareVersions(v.current, v.loaded) > 0) {
125
+ // 已更新未重启:显示「已更新,重启生效」+ 重启按钮
126
+ setUpdateInfo({ current: v.current, latest: v.current, updating: false, result: 'ok', updated: true });
127
+ }
128
+ } catch { /* 网络失败静默 */ }
129
+ };
130
+ check();
131
+ const t = setInterval(check, 5 * 60 * 1000);
132
+ return () => { alive = false; clearInterval(t); };
133
+ }, [isDesktop]);
134
+
135
+ // 重启宿主(更新生效必需:刷新页面不会重载服务端代码)
136
+ const restartPocket = async () => {
137
+ setUpdateInfo((u) => ({ ...u, restarting: true, startedAt: Date.now() }));
138
+ try {
139
+ // 宿主 500ms 后自杀,RPC 响应可能来不及送达 → 3 秒超时兜底,别让按钮永远卡「重启中…」
140
+ await Promise.race([
141
+ call(POCKET_ENDPOINTS.restart, {}),
142
+ new Promise((_, rej) => setTimeout(() => rej(new Error('restart requested (no reply within 3s)')), 3000)),
143
+ ]);
144
+ setUpdateInfo((u) => ({ ...u, restarting: true, result: 'ok' }));
145
+ } catch (err) {
146
+ // 网络断连/超时同样视为「已请求重启」——旧进程即将退出,等新进程起来后刷新即可
147
+ const msg = String(err?.message ?? '');
148
+ if (/connection|socket|fetch|network|abort|cancelled|ECONN|disconnect|closed|timeout/i.test(msg)) {
149
+ setUpdateInfo((u) => ({ ...u, restarting: true, result: 'ok' }));
150
+ return;
151
+ }
152
+ setUpdateInfo((u) => ({ ...u, restarting: false, result: 'fail', output: err.message }));
153
+ }
154
+ };
155
+
156
+ // 一键更新:调宿主 dsh plugin update(成功后宿主自动重启生效,用户只点一次)
157
+ const runUpdate = async () => {
158
+ setUpdateInfo((u) => ({ ...u, updating: true, result: null, startedAt: Date.now() }));
159
+ try {
160
+ const r = await call(POCKET_ENDPOINTS.update, {});
161
+ setUpdateInfo((u) => ({
162
+ ...u,
163
+ updating: false,
164
+ result: r.ok ? 'ok' : 'fail',
165
+ autoRestart: r.autoRestart === true,
166
+ output: r.output ?? r.error,
167
+ }));
168
+ } catch (err) {
169
+ setUpdateInfo((u) => ({ ...u, updating: false, result: 'fail', output: err.message }));
170
+ }
171
+ };
172
+
173
+ // 安全免责声明(issue #31):每次开启公网都必须先弹框勾选「我已知情」。
174
+ // 服务端同样强制(tunnel.start 需 disclaimer: true),防绕过前端直接调 RPC。
175
+
176
+ const [disclaimerOpen, setDisclaimerOpen] = useState(false);
177
+ const [disclaimerChecked, setDisclaimerChecked] = useState(false);
178
+
179
+ const doStartTunnel = async () => {
180
+ // 命名隧道模式:Token/域名没配齐就不发起(服务端同样会拒绝)
181
+ const cfg = status?.tunnelConfig;
182
+ if (cfg?.mode === 'named' && (!cfg.hostname || !cfg.tokenSet)) {
183
+ setError(t('namedNeedCfg'));
184
+ return;
185
+ }
186
+ setBusy(true);
187
+ setError(null);
188
+ setTunnelState({ phase: 'starting', detail: '正在开启…', startedAt: Date.now() });
189
+ try {
190
+ setStatus(await call(POCKET_ENDPOINTS.tunnelStart, { disclaimer: true }));
191
+ } catch (err) {
192
+ setError(err.message);
193
+ } finally {
194
+ setBusy(false);
195
+ }
196
+ };
197
+ const startTunnel = () => {
198
+ // 每次开启都弹免责确认(勾选后才能继续)
199
+ setDisclaimerChecked(false);
200
+ setDisclaimerOpen(true);
201
+ };
202
+ const confirmDisclaimer = () => {
203
+ if (!disclaimerChecked) return; // 未勾选不允许
204
+ setDisclaimerOpen(false);
205
+ doStartTunnel();
206
+ };
207
+
208
+ const stopTunnel = async () => {
209
+ try { setStatus(await call(POCKET_ENDPOINTS.tunnelStop, {})); } catch { /* 忽略 */ }
210
+ };
211
+
212
+ // 公网模式(issue #66):随机域名(默认零配置)/ 固定域名(Cloudflare 命名隧道 + Tunnel Token)
213
+ // tunnelCfg:编辑态 { hostname, token, err } | null;token 输入留空 = 保持已存的 Token 不变
214
+ const [tunnelCfg, setTunnelCfg] = useState(null);
215
+ const switchToQuick = async () => {
216
+ try { setStatus(await call(POCKET_ENDPOINTS.tunnelSetConfig, { mode: 'quick' })); } catch (err) { setError(err.message); }
217
+ };
218
+ const saveNamedTunnel = async () => {
219
+ try {
220
+ setStatus(await call(POCKET_ENDPOINTS.tunnelSetConfig, {
221
+ mode: 'named',
222
+ hostname: tunnelCfg?.hostname ?? '',
223
+ token: tunnelCfg?.token || undefined, // 留空不覆盖已存 Token
224
+ }));
225
+ setTunnelCfg(null);
226
+ } catch (err) {
227
+ setTunnelCfg((c) => ({ ...c, err: err.message }));
228
+ }
229
+ };
230
+
231
+ // 恢复出厂设置:清本机设置 + 重设随机密码(弹窗确认;RPC 端也强制校验 confirm)
232
+ const [resetOpen, setResetOpen] = useState(false);
233
+ const doFactoryReset = async () => {
234
+ setResetOpen(false);
235
+ setBusy(true);
236
+ setError(null);
237
+ try {
238
+ setStatus(await call(POCKET_ENDPOINTS.pocketReset, { confirm: true }));
239
+ setTunnelCfg(null);
240
+ setCustomPin(null);
241
+ setAdvOpen(false);
242
+ showToast(t('resetDone'));
243
+ } catch (err) {
244
+ setError(err.message);
245
+ showToast(t('resetFailed'));
246
+ } finally {
247
+ setBusy(false);
248
+ }
249
+ };
250
+
251
+ // 刷新局域网访问密码(旧密码立即作废)
252
+ const refreshLanPin = async () => {
253
+ try {
254
+ const r = await call(POCKET_ENDPOINTS.lanTokenRefresh, {});
255
+ setStatus((s) => ({ ...s, lanToken: r.lanToken }));
256
+ } catch { /* 忽略 */ }
257
+ };
258
+
259
+ // 局域网访问密码开关(issue #24):默认开启;关闭后局域网扫码直连(公网不受影响)
260
+ const setLanAuth = async (on) => {
261
+ try {
262
+ const r = await call(POCKET_ENDPOINTS.lanAuthSetEnabled, { on });
263
+ setStatus((s) => ({ ...s, lanAuthEnabled: r.lanAuthEnabled }));
264
+ } catch { /* 忽略 */ }
265
+ };
266
+
267
+ // 局域网访问总开关:关闭后局域网扫码/链接直接失效(公网不受影响)。
268
+ // 切换前弹窗确认(弹窗提醒);服务端用 setLanEnabled 持久化,代理按 Host 实时拦截。
269
+ const [lanToggleOpen, setLanToggleOpen] = useState(null); // null | true | false(目标 on 状态)
270
+ const requestLanToggle = (on) => setLanToggleOpen(on);
271
+ const confirmLanToggle = async () => {
272
+ const on = lanToggleOpen;
273
+ setLanToggleOpen(null);
274
+ if (on === null) return;
275
+ try {
276
+ const r = await call(POCKET_ENDPOINTS.lanSetEnabled, { on });
277
+ setStatus((s) => ({ ...s, lanEnabled: r.lanEnabled }));
278
+ } catch (err) {
279
+ setError(err.message);
280
+ }
281
+ };
282
+
283
+ // 局域网地址手动覆盖(Tailscale/VPN 等远程访问场景):空值恢复自动选择
284
+ const setLanAddress = async (ip) => {
285
+ try {
286
+ setStatus(await call(POCKET_ENDPOINTS.lanSetOverride, { ip }));
287
+ } catch (err) {
288
+ setError(err.message);
289
+ }
290
+ };
291
+
292
+ // ---------- 中继(自建服务器) ----------
293
+ // relayForm:编辑态表单;null = 未编辑(显示当前配置摘要)。
294
+ // token 留空表示「保持不变」——界面不回显已存 token,用户没改就不该覆盖。
295
+ const [relayForm, setRelayForm] = useState(null);
296
+ const [consentOpen, setConsentOpen] = useState(false);
297
+ const [consentChecked, setConsentChecked] = useState(false);
298
+
299
+ const saveRelay = async (extra = {}) => {
300
+ const f = relayForm;
301
+ // 只在**真的在编辑表单**时才发这些字段。否则(比如用户只是点开关、被弹窗
302
+ // 要求先同意声明)发一组空值会把已存的配置抹掉。
303
+ const payload = f
304
+ ? {
305
+ host: f.host ?? '',
306
+ port: f.port ?? '',
307
+ publicUrl: f.publicUrl ?? '',
308
+ token: f.token || undefined, // 留空不覆盖
309
+ agentId: f.agentId ?? '', // 留空 = 回到「按主机名派生」
310
+ tls: f.tls === true,
311
+ allowInsecure: f.allowInsecure === true,
312
+ }
313
+ : {};
314
+ setBusy(true);
315
+ setError(null);
316
+ try {
317
+ setStatus(await call(POCKET_ENDPOINTS.relaySetConfig, { ...payload, ...extra }));
318
+ if (f) setRelayForm(null);
319
+ showToast(t('relaySaved'));
320
+ void loadDevices();
321
+ } catch (err) {
322
+ // 用户没勾知情同意就点保存启用:弹一次声明,而不是丢个红字
323
+ if (/安全声明/.test(String(err.message))) {
324
+ setConsentChecked(false);
325
+ setConsentOpen(true);
326
+ } else {
327
+ setError(err.message);
328
+ showToast(fmt(t, 'relaySaveFailed', { err: errText(err.message) }));
329
+ }
330
+ } finally {
331
+ setBusy(false);
332
+ }
333
+ };
334
+
335
+ // 知情同意只弹一次:勾选后连同「启用」一起提交(服务端要求两者同时成立)
336
+ const confirmConsent = () => {
337
+ if (!consentChecked) return;
338
+ setConsentOpen(false);
339
+ void saveRelay({ consent: true, enabled: true });
340
+ };
341
+
342
+ // 常开开关 / 暂停:暂停只断中继,局域网与公网通道不受影响
343
+ const toggleRelay = async (on) => {
344
+ setBusy(true);
345
+ try {
346
+ setStatus(await call(POCKET_ENDPOINTS.relaySetEnabled, { on }));
347
+ showToast(on ? t('relaySaved') : t('relayStateStopped'));
348
+ } catch (err) {
349
+ if (/安全声明/.test(String(err.message))) {
350
+ setConsentChecked(false);
351
+ setConsentOpen(true);
352
+ } else {
353
+ setError(err.message);
354
+ }
355
+ } finally {
356
+ setBusy(false);
357
+ }
358
+ };
359
+
360
+ // ---------- 设备管理 ----------
361
+ const [devices, setDevices] = useState({ devices: [], pending: [], status: null });
362
+ const [pairing, setPairing] = useState(null); // { code, pairUrl, expiresAt }
363
+ const [deviceName, setDeviceName] = useState('');
364
+
365
+ const loadDevices = async () => {
366
+ try {
367
+ const r = await call(POCKET_ENDPOINTS.deviceList, {});
368
+ setDevices({ devices: r.devices ?? [], pending: r.pending ?? [], status: r.status ?? null });
369
+ } catch { /* 未启用设备认证时静默 */ }
370
+ };
371
+
372
+ // 设备列表只在「中继已启用」时轮询:手机提交配对后要能自动出现在待批准里,
373
+ // 不轮询的话用户得手动刷新才看得到。
374
+ useEffect(() => {
375
+ if (status?.relay?.enabled !== true) return undefined;
376
+ void loadDevices();
377
+ const timer = setInterval(loadDevices, 3000);
378
+ return () => clearInterval(timer);
379
+ }, [status?.relay?.enabled]);
380
+
381
+ const createPairing = async () => {
382
+ setBusy(true);
383
+ setError(null);
384
+ try {
385
+ const r = await call(POCKET_ENDPOINTS.devicePairingCreate, { name: deviceName || undefined });
386
+ setPairing({ code: r.code, pairUrl: r.pairUrl, pairQr: r.pairQr, expiresAt: r.expiresAt });
387
+ setDevices({ devices: r.devices ?? [], pending: r.pending ?? [], status: r.status ?? null });
388
+ setDeviceName('');
389
+ } catch (err) {
390
+ setError(err.message);
391
+ } finally {
392
+ setBusy(false);
393
+ }
394
+ };
395
+
396
+ const cancelPairing = async () => {
397
+ try {
398
+ const r = await call(POCKET_ENDPOINTS.devicePairingCancel, {});
399
+ setDevices({ devices: r.devices ?? [], pending: r.pending ?? [], status: r.status ?? null });
400
+ } catch { /* 忽略 */ }
401
+ setPairing(null);
402
+ };
403
+
404
+ const deviceAction = async (endpoint, id, doneKey) => {
405
+ try {
406
+ const r = await call(endpoint, { id });
407
+ setDevices({ devices: r.devices ?? [], pending: r.pending ?? [], status: r.status ?? null });
408
+ showToast(t(doneKey));
409
+ // 批准之后配对码就没用了,收起二维码面板
410
+ if (endpoint === POCKET_ENDPOINTS.deviceApprove) setPairing(null);
411
+ } catch (err) {
412
+ setError(err.message);
413
+ }
414
+ };
415
+
416
+ const [revokeTarget, setRevokeTarget] = useState(null);
417
+
418
+ // 自定义访问密码(issue #33):公网/局域网各自设固定 8 位密码(英文字母大小写或数字);自定义后公网不再自动轮换。
419
+ // customPin: { which: 'public'|'lan', value, err } | null —— 正在输入自定义密码的区块
420
+ const [customPin, setCustomPin] = useState(null);
421
+ const saveCustomPin = async (which) => {
422
+ try {
423
+ const r = await call(POCKET_ENDPOINTS.pinSetCustom, { which, value: customPin?.value ?? '' });
424
+ setStatus((s) => ({
425
+ ...s,
426
+ accessToken: which === 'public' ? r.pin : s.accessToken,
427
+ lanToken: which === 'lan' ? r.pin : s.lanToken,
428
+ publicPinCustom: which === 'public' ? true : s.publicPinCustom,
429
+ lanPinCustom: which === 'lan' ? true : s.lanPinCustom,
430
+ }));
431
+ setCustomPin(null);
432
+ } catch (err) {
433
+ setCustomPin((c) => ({ ...c, err: err.message }));
434
+ }
435
+ };
436
+ // 渲染自定义输入行(共用):输入框 + 保存/取消
437
+ const customPinRow = (which) => h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.5 } },
438
+ t('customizing'),
439
+ h('input', {
440
+ style: { width: 130, margin: '0 6px', padding: '4px 8px', fontSize: 14, letterSpacing: 1, textAlign: 'center', border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', borderRadius: 6, outline: 'none' },
441
+ type: 'password',
442
+ maxLength: 8,
443
+ value: customPin?.value ?? '',
444
+ autoFocus: true,
445
+ onChange: (e) => setCustomPin((c) => ({ ...c, value: e.target.value.replace(/[^a-zA-Z0-9]/g, ''), err: null })),
446
+ onKeyDown: (e) => { if (e.key === 'Enter') saveCustomPin(which); if (e.key === 'Escape') setCustomPin(null); },
447
+ }),
448
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 2 }, onClick: () => saveCustomPin(which) }, t('save')),
449
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: () => setCustomPin(null) }, t('cancel')),
450
+ customPin?.err ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', marginTop: 4 } }, errText(customPin.err)) : null,
451
+ );
452
+ // 「自定义」按钮(非输入态显示在密码行末尾)
453
+ const customBtn = (which) => h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 8 }, onClick: () => setCustomPin({ which, value: '', err: null }) }, t('customize'));
454
+
455
+ const lanUrl = status?.lanUrl;
456
+ const tunnelUrl = status?.tunnelUrl;
457
+ const tunnelPhase = tunnelState?.phase ?? 'idle';
458
+ const tunnelStarting = ['downloading', 'starting', 'registering'].includes(tunnelPhase);
459
+ const tunnelStateDetail = tunnelState?.detail ?? '';
460
+ const tunnelStateStarted = tunnelState?.startedAt ?? null;
461
+ // 公网模式视图(issue #66):{ mode, hostname, tokenSet }
462
+ const tunnelModeView = status?.tunnelConfig ?? { mode: 'quick', hostname: '', tokenSet: false };
463
+ const namedMode = tunnelModeView.mode === 'named';
464
+ // 模式按钮选中态高亮:固定域名模式本身,或正在编辑固定域名配置,都视为「选中」
465
+ const namedActive = namedMode || tunnelCfg !== null;
466
+ // 后端错误消息统一为「中文 | English」混排;按当前界面语言只显示对应一半
467
+ const errText = (msg) => {
468
+ const s = String(msg ?? '');
469
+ const i = s.indexOf(' | ');
470
+ if (i < 0) return s;
471
+ return (t('ok') === POCKET_ZH.ok ? s.slice(0, i) : s.slice(i + 3)).trim();
472
+ };
473
+ // 轻量 Toast:操作成功/失败后短暂提示(自动消失,不打断操作)
474
+ const [toast, setToast] = useState(null);
475
+ const toastTimer = useRef(null);
476
+ const showToast = (text) => {
477
+ setToast(text);
478
+ clearTimeout(toastTimer.current);
479
+ toastTimer.current = setTimeout(() => setToast(null), 2600);
480
+ };
481
+ useEffect(() => () => clearTimeout(toastTimer.current), []);
482
+ const modeBtnStyle = (active) => ({
483
+ ...styles.btn, height: 28, padding: '0 12px', fontSize: 12,
484
+ fontWeight: active ? 600 : 400,
485
+ background: active ? 'var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))' : 'var(--dsw-alias-bg-layer-1,#fff)',
486
+ color: active ? 'var(--dsw-alias-label-primary-foreground, #fff)' : 'var(--dsw-alias-label-primary,inherit)',
487
+ });
488
+ // iOS 风格小开关(重排后统一用:局域网总开关 / 局域网密码开关)
489
+ const Switch = (on, onClick) => h('button', {
490
+ role: 'switch', 'aria-checked': !!on,
491
+ style: { flexShrink: 0, width: 40, height: 22, borderRadius: 11, border: 'none', padding: 0, position: 'relative', cursor: 'pointer', font: 'inherit', background: on ? 'var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))' : 'var(--dsw-alias-border-l2,#d1d5db)' },
492
+ onClick,
493
+ }, h('span', { style: { position: 'absolute', top: 2, left: on ? 20 : 2, width: 18, height: 18, borderRadius: '50%', background: '#fff' } }));
494
+ // 卡片内主内容:二维码 + 地址 + 提示
495
+ const qrArea = (src, url, hint) => h('div', { style: { background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', borderRadius: 10, padding: '10px 12px', textAlign: 'center', margin: '10px 0' } },
496
+ h('img', { src, alt: 'QR', style: styles.qr }),
497
+ h('div', { style: styles.code }, url),
498
+ h('div', { style: styles.muted }, hint));
499
+ // 设置行:上分隔线,内部第一行 = 左标签 + 右操作;extra 作为第二段渲染
500
+ const row = (label, control, extra) => h('div', { style: { borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', paddingTop: 9, marginTop: 9 } },
501
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
502
+ h('span', { style: { fontSize: 13 } }, label), control), extra ?? null);
503
+ // 高级(手动选地址)展开态
504
+ const [advOpen, setAdvOpen] = useState(false);
505
+
506
+ return h('div', { style: styles.card },
507
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
508
+ h('div', null,
509
+ h('strong', null, t('title')),
510
+ h('div', { style: styles.muted }, t('subtitle')),
511
+ ),
512
+ h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary,#8b93a1)', textAlign: 'right' } },
513
+ h('div', { style: { whiteSpace: 'nowrap' } }, t('developer')),
514
+ h('div', { style: { whiteSpace: 'nowrap' } }, t('starAsk')),
515
+ h('a', { href: 'https://github.com/kinderao/dsh-pocket-relay', target: '_blank', rel: 'noreferrer', style: { color: 'var(--dsw-alias-brand-primary,#4f6ef7)', fontSize: 12, lineHeight: 1.6, textDecoration: 'underline' } },
516
+ t('starCta')),
517
+ ),
518
+ ),
519
+
520
+ // 桌面端不显示更新/重启横幅(更新由 DSH Desktop 管理),也不需要额外提示
521
+
522
+ // 重启后提示(进程在后台运行,停止方法)——左侧蓝色色条(桌面端不会触发本插件的自重启)
523
+ !isDesktop && restartNotice ? h('div', { style: { ...styles.block, borderLeft: '4px solid var(--dsw-alias-brand-primary,#4f6ef7)', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', padding: '10px 12px' } },
524
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
525
+ h('div', { style: { fontWeight: 600, fontSize: 13 } }, t('restarted')),
526
+ h('button', { style: styles.btn, onClick: () => setRestartNotice(false) }, t('ok')),
527
+ ),
528
+ h('div', { style: styles.muted, marginTop: 4, wordBreak: 'break-all' }, fmt(t, 'bgHint', { cmd: status?.killHint ?? `lsof -ti :${status?.dshPort ?? 3080} | xargs kill -9` })),
529
+ ) : null,
530
+
531
+ // 更新提示——左侧黄色色条(提示有新版本);单状态:有更新/更新中/已更新自动重启,不并存
532
+ // 桌面端不渲染(更新由 DSH Desktop 管理)
533
+ !isDesktop && updateInfo ? h('div', { style: { ...styles.block, borderLeft: '4px solid var(--dsw-alias-state-warn-primary,#b45309)', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', padding: '10px 12px' } },
534
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
535
+ h('div', { style: { fontWeight: 600, fontSize: 13 } },
536
+ updateInfo.updated
537
+ ? fmt(t, 'updatedRestart', { ver: updateInfo.current })
538
+ : updateInfo.result === 'ok'
539
+ ? (updateInfo.autoRestart ? fmt(t, 'updateAutoRestarting', { ver: updateInfo.latest }) : fmt(t, 'updatedOk', { ver: updateInfo.latest }))
540
+ : fmt(t, 'updateAvailable', { ver: updateInfo.latest })),
541
+ updateInfo.result !== 'ok'
542
+ ? h('button', { style: styles.primary, onClick: runUpdate, disabled: updateInfo.updating }, updateInfo.updating ? t('updating') : fmt(t, 'updateTo', { ver: updateInfo.latest }))
543
+ : updateInfo.autoRestart
544
+ ? h('button', { style: styles.btn, disabled: true }, t('restartingNow'))
545
+ : h('button', { style: styles.primary, onClick: restartPocket, disabled: updateInfo.restarting }, updateInfo.restarting ? t('restarting') : t('restartNow')),
546
+ ),
547
+ h('div', { style: styles.muted, marginTop: 4 },
548
+ updateInfo.updating
549
+ ? fmt(t, 'updatingDetail', { s: elapsed(updateInfo.startedAt) })
550
+ : updateInfo.restarting
551
+ ? fmt(t, 'restartingDetail', { s: elapsed(updateInfo.startedAt) })
552
+ : updateInfo.result === 'ok'
553
+ ? (updateInfo.autoRestart ? t('updatedAutoDetail')
554
+ : t('updatedRestartDetail'))
555
+ : updateInfo.result === 'fail' ? fmt(t, 'updateFailed', { err: errText(updateInfo.output) || t('unknownError') })
556
+ : fmt(t, 'versionRange', { cur: updateInfo.current, latest: updateInfo.latest })),
557
+ ) : null,
558
+
559
+ // 局域网:标题行自带总开关 → 二维码+地址 → 设置行(访问密码 / 高级·手动选地址)
560
+ h('div', { style: styles.block },
561
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
562
+ h('span', { style: { fontWeight: 600, fontSize: 13 } }, t('lanAccess')),
563
+ Switch(status?.lanEnabled !== false, () => requestLanToggle(status?.lanEnabled === false)),
564
+ ),
565
+ status?.lanEnabled === false
566
+ ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-state-warn-primary,#b45309)', lineHeight: 1.5 } }, t('lanDisabledHint'))
567
+ : (lanUrl
568
+ ? h('div', null,
569
+ qrArea(status.lanQr, lanUrl, t('lanHint')),
570
+ // 访问密码行:开关 + 值(关闭时提示直连)
571
+ row(t('lanPin'), Switch(status?.lanAuthEnabled !== false, () => setLanAuth(status?.lanAuthEnabled === false)),
572
+ status?.lanAuthEnabled === false
573
+ ? h('div', { style: { ...styles.muted, marginTop: 6 } }, t('lanPinOff'))
574
+ : (customPin?.which === 'lan'
575
+ ? customPinRow('lan')
576
+ : h('div', { style: { marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
577
+ h('span', { style: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 13, letterSpacing: 1 } }, status.lanToken),
578
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: refreshLanPin }, t('refresh')),
579
+ customBtn('lan'),
580
+ status?.lanPinCustom ? h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)' } }, t('pinCustomHint')) : null,
581
+ ))),
582
+ // 高级:手动选地址(默认收起)
583
+ row(t('advAddress'),
584
+ h('button', { style: { border: 'none', background: 'none', font: 'inherit', cursor: 'pointer', fontSize: 12, color: 'var(--dsw-alias-label-tertiary,#8b93a1)', padding: 0 }, onClick: () => setAdvOpen((v) => !v) },
585
+ (status?.lanIpOverride || t('lanAddressAuto')) + ' ›'),
586
+ advOpen ? h('div', { style: { marginTop: 8 } },
587
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } },
588
+ t('lanAddress'),
589
+ h('select', {
590
+ value: status?.lanIpOverride || '',
591
+ onChange: (e) => setLanAddress(e.target.value),
592
+ style: { font: 'inherit', height: 30, padding: '0 8px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', background: 'var(--dsw-alias-bg-layer-1,#fff)', color: 'var(--dsw-alias-label-primary,inherit)' },
593
+ },
594
+ h('option', { value: '' }, t('lanAddressAuto')),
595
+ (status?.lanCandidates || []).map((ip) => h('option', { key: ip, value: ip }, ip)),
596
+ ),
597
+ ),
598
+ ) : null),
599
+ )
600
+ : h('div', { style: styles.muted }, t('lanStarting'))),
601
+ ),
602
+
603
+ // 公网:标题行自带 开启/关闭 → 开启后:二维码+地址、地址模式行、访问密码行
604
+ h('div', { style: styles.block },
605
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
606
+ h('span', { style: { fontWeight: 600, fontSize: 13 } }, t('wanAccess')),
607
+ tunnelUrl
608
+ ? h('button', { style: { ...styles.btn, height: 28, padding: '0 12px', fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' }, onClick: stopTunnel }, t('stopTunnel'))
609
+ : h('button', { style: { ...styles.primary, height: 28, padding: '0 14px', fontSize: 12 }, onClick: startTunnel, disabled: busy || tunnelStarting }, busy || tunnelStarting ? t('opening') : t('enable')),
610
+ ),
611
+ tunnelStarting
612
+ ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } },
613
+ tunnelPhase === 'downloading'
614
+ ? fmt(t, 'downloading', { s: elapsed(tunnelStateStarted) })
615
+ : fmt(t, 'connecting', { s: elapsed(tunnelStateStarted), suffix: elapsed(tunnelStateStarted) > 30 ? t('slowHint') : '' }))
616
+ : tunnelPhase === 'error'
617
+ ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } },
618
+ fmt(t, 'error', { detail: errText(tunnelStateDetail) || t('unknownError') }))
619
+ : (!tunnelUrl && !isDesktop ? h('div', { style: { ...styles.muted, marginTop: 8 } }, t('wanOffHint')) : null),
620
+ tunnelUrl
621
+ ? h('div', null,
622
+ qrArea(status.tunnelQr, tunnelUrl, namedMode ? t('namedRunningHint') : t('wanHint')),
623
+ // 防钓鱼 / 别收藏(issue #82):公网链接仅本次有效、勿收藏提示
624
+ h('div', { style: { marginTop: 8, fontSize: 12, lineHeight: 1.5, borderLeft: '4px solid var(--dsw-alias-state-warn-primary,#b45309)', background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', borderRadius: 8, padding: '8px 10px' } }, t('wanEphemeralWarn')),
625
+ // 地址模式行(随机/固定;固定域名选中或编辑时高亮)
626
+ row(t('modeLabel'),
627
+ h('span', { style: { display: 'inline-flex', gap: 6 } },
628
+ h('button', { style: modeBtnStyle(!namedActive), onClick: namedMode ? switchToQuick : (tunnelCfg ? () => setTunnelCfg(null) : undefined) }, t('modeQuick')),
629
+ h('button', { style: modeBtnStyle(namedActive), onClick: () => setTunnelCfg(tunnelCfg ? null : { hostname: tunnelModeView.hostname ?? '', token: '', err: null }) }, t('modeNamed')),
630
+ ),
631
+ h('div', { style: { marginTop: 6 } },
632
+ // 刚保存固定域名但当前连接仍是随机域名:需关闭后重新开启才生效
633
+ namedMode && /trycloudflare\.com/i.test(tunnelUrl ?? '') ? h('div', { style: { ...styles.warn } }, t('namedTakeEffect')) : null,
634
+ // 固定域名:已保存摘要 + 修改入口(非编辑态)
635
+ namedMode && !tunnelCfg ? h('div', { style: { ...styles.muted } },
636
+ fmt(t, 'namedSummary', { host: tunnelModeView.hostname || '—', token: tunnelModeView.tokenSet ? t('namedTokenSet') : t('namedTokenMissing') }),
637
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 8 }, onClick: () => setTunnelCfg({ hostname: tunnelModeView.hostname ?? '', token: '', err: null }) }, t('namedEdit')),
638
+ h('div', { style: { ...styles.muted, marginTop: 4 } }, t('namedHow')),
639
+ !tunnelModeView.tokenSet || !tunnelModeView.hostname ? h('div', { style: { marginTop: 2, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } }, t('namedNeedCfg')) : null,
640
+ ) : null,
641
+ // 固定域名:编辑表单(域名 + Tunnel Token,Token 留空保持不变)
642
+ tunnelCfg ? h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.6 } },
643
+ h('div', null,
644
+ t('namedHostnameLabel'),
645
+ h('input', {
646
+ style: { margin: '4px 0 0 6px', padding: '4px 8px', fontSize: 13, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', borderRadius: 6, outline: 'none', width: 200 },
647
+ placeholder: 'pocket.example.com',
648
+ value: tunnelCfg.hostname ?? '',
649
+ autoFocus: true,
650
+ onChange: (e) => setTunnelCfg((c) => ({ ...c, hostname: e.target.value.trim(), err: null })),
651
+ onKeyDown: (e) => { if (e.key === 'Enter') saveNamedTunnel(); if (e.key === 'Escape') setTunnelCfg(null); },
652
+ }),
653
+ ),
654
+ h('div', { style: { marginTop: 6 } },
655
+ t('namedTokenLabel'),
656
+ h('input', {
657
+ style: { margin: '4px 0 0 6px', padding: '4px 8px', fontSize: 13, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', borderRadius: 6, outline: 'none', width: 240, fontFamily: 'ui-monospace,Menlo,monospace' },
658
+ type: 'password',
659
+ value: tunnelCfg.token ?? '',
660
+ onChange: (e) => setTunnelCfg((c) => ({ ...c, token: e.target.value.trim(), err: null })),
661
+ onKeyDown: (e) => { if (e.key === 'Enter') saveNamedTunnel(); if (e.key === 'Escape') setTunnelCfg(null); },
662
+ }),
663
+ ),
664
+ h('div', { style: { marginTop: 6, display: 'flex', gap: 8 } },
665
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: saveNamedTunnel }, t('save')),
666
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: () => setTunnelCfg(null) }, t('cancel')),
667
+ ),
668
+ h('div', { style: { ...styles.muted, marginTop: 6 } }, t('namedHow')),
669
+ h('div', { style: { marginTop: 2, fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)', lineHeight: 1.5 } }, t('namedSecurity')),
670
+ tunnelCfg.err ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', marginTop: 4 } }, errText(tunnelCfg.err)) : null,
671
+ ) : null,
672
+ ),
673
+ ),
674
+ // 访问密码行:值 + 自定义(自定义输入态整体替换)
675
+ status.accessToken
676
+ ? row(t('pinLabel'),
677
+ customPin?.which === 'public'
678
+ ? null
679
+ : h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 8 } },
680
+ h('span', { style: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 13, letterSpacing: 1 } }, status.accessToken),
681
+ customBtn('public')),
682
+ h('div', { style: { marginTop: 6 } },
683
+ customPin?.which === 'public' ? customPinRow('public') : null,
684
+ status?.publicPinCustom ? h('div', { style: { ...styles.warn } }, t('pinCustomHint')) : null,
685
+ namedMode ? h('div', { style: { ...styles.warn } }, t('namedSecurity')) : null))
686
+ : null,
687
+ )
688
+ : null,
689
+ ),
690
+
691
+ error ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 12, marginTop: 8 } }, `❌ ${errText(error)}`) : null,
692
+
693
+ // ---------- 中继(自建服务器)+ 设备管理 ----------
694
+ (() => {
695
+ const relay = status?.relay ?? { enabled: false, consent: false, host: '', port: 0, tls: false, allowInsecure: false, tokenSet: false, publicUrl: '' };
696
+ const rs = status?.relayState ?? { phase: 'idle' };
697
+ const configured = Boolean(relay.host && relay.port && relay.tokenSet && relay.publicUrl);
698
+ const on = relay.enabled === true;
699
+ // 状态文案与颜色:一眼看出通没通(常开之后界面是唯一的信息来源)
700
+ const stateColor = !on ? 'var(--dsw-alias-label-tertiary,#8b93a1)'
701
+ : rs.phase === 'online' ? 'var(--dsw-alias-state-success-primary,#16a34a)'
702
+ : rs.phase === 'error' ? 'var(--dsw-alias-state-error-primary,#dc2626)'
703
+ : 'var(--dsw-alias-state-warn-primary,#b45309)';
704
+ const stateText = !on ? t('relayStateIdle')
705
+ : rs.phase === 'online' ? t('relayStateOnline')
706
+ : rs.phase === 'error' ? t('relayStateError')
707
+ : t('relayStateConnecting');
708
+ const f = relayForm;
709
+
710
+ const input = (key, props) => h('input', {
711
+ style: { ...styles.input, flex: 1, minWidth: 0 },
712
+ value: (f?.[key] ?? '') === null ? '' : String(f?.[key] ?? ''),
713
+ onChange: (e) => setRelayForm((c) => ({ ...(c ?? {}), [key]: e.target.value, err: null })),
714
+ ...props,
715
+ });
716
+
717
+ return h('div', { style: styles.block },
718
+ // 标题行:名称 + 状态点 + 常开开关
719
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
720
+ h('span', { style: { display: 'inline-flex', alignItems: 'center', fontWeight: 600, fontSize: 13 } },
721
+ h('span', { style: styles.dot(stateColor) }),
722
+ t('relayTitle')),
723
+ h('div', { style: { display: 'inline-flex', alignItems: 'center', gap: 8 } },
724
+ h('span', { style: { fontSize: 12, color: stateColor, whiteSpace: 'nowrap' } }, stateText),
725
+ configured ? Switch(on, () => toggleRelay(!on)) : null),
726
+ ),
727
+ h('div', { style: { ...styles.muted, marginTop: 6 } }, t('relayIntro')),
728
+
729
+ // 运行信息:重连次数 / 在途连接 / 本机在 relay 上的名字(多机排障要看的三个数)
730
+ on && rs.phase !== 'idle'
731
+ ? h('div', { style: { ...styles.muted, marginTop: 4 } },
732
+ `${fmt(t, 'relayReconnects', { n: rs.reconnects ?? 0 })} · ${fmt(t, 'relayStreams', { n: rs.streams ?? 0 })}`
733
+ + ` · ${fmt(t, 'relayThisPc', { id: rs.agentId || relay.agentId || '—' })}`
734
+ + (rs.phase === 'error' && rs.detail ? ` · ${errText(rs.detail)}` : ''))
735
+ : null,
736
+
737
+ // 配置摘要(非编辑态)
738
+ f === null
739
+ ? h('div', { style: { ...styles.muted, marginTop: 6 } },
740
+ configured
741
+ ? `${relay.host}:${relay.port} · ${relay.publicUrl} · Token ${relay.tokenSet ? t('relayTokenSet') : t('relayTokenMissing')}`
742
+ : t('relayNeedCfg'),
743
+ h('button', {
744
+ style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 8 },
745
+ onClick: () => setRelayForm({ ...relay, token: '', err: null }),
746
+ }, configured ? t('namedEdit') : t('relaySave')))
747
+ : null,
748
+
749
+ // 配置表单
750
+ f !== null
751
+ ? h('div', { style: { marginTop: 8 } },
752
+ h('div', { style: styles.field },
753
+ h('span', { style: styles.fieldLabel }, t('relayHostLabel')),
754
+ input('host', { placeholder: t('relayHostPh'), autoFocus: true })),
755
+ h('div', { style: styles.field },
756
+ h('span', { style: styles.fieldLabel }, t('relayPortLabel')),
757
+ input('port', { placeholder: '8444', inputMode: 'numeric' })),
758
+ h('div', { style: styles.field },
759
+ h('span', { style: styles.fieldLabel }, t('relayPublicUrlLabel')),
760
+ input('publicUrl', { placeholder: t('relayPublicUrlPh') })),
761
+ h('div', { style: { ...styles.muted, margin: '-2px 0 6px 104px' } }, t('relayPublicUrlHint')),
762
+ h('div', { style: styles.field },
763
+ h('span', { style: styles.fieldLabel }, t('relayTokenLabel')),
764
+ input('token', { type: 'password', placeholder: relay.tokenSet ? t('relayTokenSet') : '' })),
765
+ h('div', { style: styles.field },
766
+ h('span', { style: styles.fieldLabel }, t('relayAgentIdLabel')),
767
+ input('agentId', { placeholder: t('relayAgentIdPh') })),
768
+ h('div', { style: { ...styles.muted, margin: '-2px 0 6px 104px' } }, t('relayAgentIdHint')),
769
+ h('label', { style: { ...styles.field, cursor: 'pointer' } },
770
+ h('span', { style: styles.fieldLabel }, t('relayTlsLabel')),
771
+ h('input', { type: 'checkbox', checked: f.tls === true, onChange: (e) => setRelayForm((c) => ({ ...c, tls: e.target.checked })) })),
772
+ h('div', { style: { ...styles.muted, margin: '-2px 0 6px 104px' } }, t('relayTlsHint')),
773
+ f.tls === true
774
+ ? h('div', null,
775
+ h('label', { style: { ...styles.field, cursor: 'pointer' } },
776
+ h('span', { style: styles.fieldLabel }, t('relayInsecureLabel')),
777
+ h('input', { type: 'checkbox', checked: f.allowInsecure === true, onChange: (e) => setRelayForm((c) => ({ ...c, allowInsecure: e.target.checked })) })),
778
+ h('div', { style: { ...styles.muted, margin: '-2px 0 6px 104px' } }, t('relayInsecureHint')))
779
+ : null,
780
+ h('div', { style: { display: 'flex', gap: 8, marginTop: 8 } },
781
+ h('button', { style: styles.primary, disabled: busy, onClick: () => saveRelay() }, busy ? t('relaySaving') : t('relaySave')),
782
+ h('button', { style: styles.btn, onClick: () => setRelayForm(null) }, t('cancel'))))
783
+ : null,
784
+
785
+ // 对外地址二维码(配置完整时一直显示,手机扫了就能存下来)
786
+ configured && relay.publicUrl
787
+ ? h('div', { style: { marginTop: 10, background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', borderRadius: 10, padding: '10px 12px', textAlign: 'center' } },
788
+ status?.relayQr ? h('img', { src: status.relayQr, alt: 'QR', style: styles.qr }) : null,
789
+ h('div', { style: styles.code }, relay.publicUrl),
790
+ h('div', { style: styles.muted }, t('relayQrHint')))
791
+ : null,
792
+
793
+ // ----- 设备管理 -----
794
+ configured
795
+ ? h('div', { style: { marginTop: 12, borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', paddingTop: 10 } },
796
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
797
+ h('span', { style: { fontWeight: 600, fontSize: 13 } },
798
+ `${t('deviceTitle')} · ${fmt(t, 'deviceCount', { n: devices.devices.length })}`),
799
+ h('button', { style: styles.btn, disabled: busy || pairing !== null, onClick: createPairing },
800
+ busy ? t('deviceAdding') : t('deviceAdd'))),
801
+ h('div', { style: { ...styles.muted, marginTop: 4 } }, t('deviceIntro')),
802
+
803
+ // 配对二维码面板
804
+ pairing
805
+ ? h('div', { style: { marginTop: 8, background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', borderRadius: 10, padding: '10px 12px', textAlign: 'center' } },
806
+ h('div', { style: { fontWeight: 600, fontSize: 13 } }, t('devicePairTitle')),
807
+ pairing.pairQr ? h('img', { src: pairing.pairQr, alt: 'Pair QR', style: styles.qr }) : null,
808
+ h('div', { style: styles.code }, pairing.pairUrl),
809
+ h('div', { style: styles.muted }, t('devicePairHint')),
810
+ h('div', { style: { ...styles.muted, marginTop: 4 } },
811
+ fmt(t, 'devicePairExpire', { time: new Date(pairing.expiresAt).toLocaleTimeString() })),
812
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginTop: 6 }, onClick: cancelPairing }, t('devicePairCancel')))
813
+ : null,
814
+
815
+ // 待批准
816
+ devices.pending.length > 0
817
+ ? h('div', { style: { marginTop: 8 } },
818
+ h('div', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-state-warn-primary,#b45309)' } }, t('devicePendingTitle')),
819
+ devices.pending.map((d) => h('div', { key: d.id, style: styles.deviceRow },
820
+ h('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, d.name),
821
+ h('span', { style: { display: 'inline-flex', gap: 6, flexShrink: 0 } },
822
+ h('button', { style: { ...styles.primary, height: 24, padding: '0 10px', fontSize: 12 }, onClick: () => deviceAction(POCKET_ENDPOINTS.deviceApprove, d.id, 'deviceApprovedDone') }, t('deviceApprove')),
823
+ h('button', { style: { ...styles.btn, height: 24, padding: '0 10px', fontSize: 12 }, onClick: () => deviceAction(POCKET_ENDPOINTS.deviceReject, d.id, 'deviceRejectedDone') }, t('deviceReject'))))))
824
+ : null,
825
+
826
+ // 已批准
827
+ devices.devices.length === 0
828
+ ? h('div', { style: { ...styles.muted, marginTop: 6 } }, t('deviceNone'))
829
+ : devices.devices.map((d) => h('div', { key: d.id, style: styles.deviceRow },
830
+ h('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
831
+ d.name,
832
+ h('span', { style: { ...styles.muted, marginLeft: 6 } },
833
+ `${t('deviceLastLogin')}: ${d.lastLoginAt ? new Date(d.lastLoginAt).toLocaleString() : t('deviceNever')}`)),
834
+ h('button', {
835
+ style: { ...styles.btn, height: 24, padding: '0 10px', fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)', flexShrink: 0 },
836
+ onClick: () => setRevokeTarget(d),
837
+ }, t('deviceRevoke')))))
838
+ : null,
839
+ );
840
+ })(),
841
+
842
+ // 恢复出厂设置:设置出问题时的临时兜底(最底部,避免误触)
843
+ h('div', { style: styles.block },
844
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
845
+ h('span', { style: { fontWeight: 600, fontSize: 13 } }, t('resetFactory')),
846
+ h('button', { style: { ...styles.btn, height: 28, padding: '0 12px', fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' }, onClick: () => setResetOpen(true) }, t('resetGo')),
847
+ ),
848
+ h('div', { style: { ...styles.muted, marginTop: 6 } }, t('resetIntro')),
849
+ ),
850
+
851
+ // 恢复出厂设置确认弹框
852
+ resetOpen ? h('div', { style: { position: 'fixed', inset: 0, zIndex: 10000, background: 'rgba(0,0,0,.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 } },
853
+ h('div', { style: { background: 'var(--dsw-alias-bg-layer-1,#fff)', borderRadius: 12, maxWidth: 440, width: '100%', padding: '20px 22px', boxShadow: '0 8px 32px rgba(0,0,0,.18)' } },
854
+ h('div', { style: { fontWeight: 600, fontSize: 15, color: 'var(--dsw-alias-state-warn-primary,#b45309)', marginBottom: 10 } }, t('resetTitle')),
855
+ h('div', { style: { fontSize: 13, lineHeight: 1.7, color: 'var(--dsw-alias-label-primary,inherit)', whiteSpace: 'pre-line' } }, t('resetBody')),
856
+ h('div', { style: { display: 'flex', gap: 8, marginTop: 16 } },
857
+ h('button', { style: { ...styles.btn, flex: 1 }, onClick: () => setResetOpen(false) }, t('cancel')),
858
+ h('button', { style: { ...styles.primary, flex: 1, background: 'var(--dsw-alias-state-error-primary,#dc2626)' }, onClick: doFactoryReset }, t('resetConfirm')),
859
+ ),
860
+ ),
861
+ ) : null,
862
+
863
+ // Toast:重置等操作的即时反馈(固定屏幕正中央,2.6s 自动消失)
864
+ toast ? h('div', {
865
+ style: { position: 'fixed', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', zIndex: 10001, width: 'auto', maxWidth: 280, background: 'rgba(17,24,39,.92)', color: '#fff', border: 'none', borderRadius: 10, padding: '10px 16px', fontSize: 13, lineHeight: 1.5, textAlign: 'center', boxShadow: '0 8px 24px rgba(0,0,0,.22)' },
866
+ }, toast) : null,
867
+
868
+ // 局域网访问开关确认弹框(关闭/打开时弹窗提醒)
869
+ lanToggleOpen !== null ? h('div', { style: { position: 'fixed', inset: 0, zIndex: 10000, background: 'rgba(0,0,0,.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 } },
870
+ h('div', { style: { background: 'var(--dsw-alias-bg-layer-1,#fff)', borderRadius: 12, maxWidth: 420, width: '100%', padding: '20px 22px', boxShadow: '0 8px 32px rgba(0,0,0,.18)' } },
871
+ h('div', { style: { fontWeight: 600, fontSize: 15, color: lanToggleOpen ? 'var(--dsw-alias-brand-primary,#4f6ef7)' : 'var(--dsw-alias-state-warn-primary,#b45309)', marginBottom: 10 } }, t(lanToggleOpen ? 'lanToggleTitleOn' : 'lanToggleTitleOff')),
872
+ h('div', { style: { fontSize: 13, lineHeight: 1.7, color: 'var(--dsw-alias-label-primary,inherit)' } }, t(lanToggleOpen ? 'lanToggleBodyOn' : 'lanToggleBodyOff')),
873
+ h('div', { style: { display: 'flex', gap: 8, marginTop: 16 } },
874
+ h('button', { style: { ...styles.btn, flex: 1 }, onClick: () => setLanToggleOpen(null) }, t('cancel')),
875
+ h('button', { style: { ...styles.primary, flex: 1 }, onClick: confirmLanToggle }, t('confirm')),
876
+ ),
877
+ ),
878
+ ) : null,
879
+
880
+ // 安全免责声明弹框(issue #31):每次开启公网访问前确认
881
+ disclaimerOpen ? h('div', { style: { position: 'fixed', inset: 0, zIndex: 10000, background: 'rgba(0,0,0,.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 } },
882
+ h('div', { style: { background: 'var(--dsw-alias-bg-layer-1,#fff)', borderRadius: 12, maxWidth: 420, width: '100%', padding: '20px 22px', boxShadow: '0 8px 32px rgba(0,0,0,.18)' } },
883
+ h('div', { style: { fontWeight: 600, fontSize: 15, color: 'var(--dsw-alias-state-warn-primary,#b45309)', marginBottom: 10 } }, t('disclaimerTitle')),
884
+ h('div', { style: { fontSize: 13, lineHeight: 1.7, color: 'var(--dsw-alias-label-primary,inherit)' } }, t('disclaimerBody')),
885
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 14, fontSize: 13, cursor: 'pointer' } },
886
+ h('input', { type: 'checkbox', checked: disclaimerChecked, onChange: (e) => setDisclaimerChecked(e.target.checked), style: { width: 16, height: 16 } }),
887
+ t('disclaimerAgree'),
888
+ ),
889
+ h('div', { style: { display: 'flex', gap: 8, marginTop: 16 } },
890
+ h('button', { style: { ...styles.btn, flex: 1 }, onClick: () => setDisclaimerOpen(false) }, t('cancel')),
891
+ h('button', {
892
+ style: { ...styles.primary, flex: 1, opacity: disclaimerChecked ? 1 : .5 },
893
+ disabled: !disclaimerChecked,
894
+ onClick: confirmDisclaimer,
895
+ }, t('disclaimerAgree')),
896
+ ),
897
+ !disclaimerChecked ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } }, t('disclaimerHint')) : null,
898
+ ),
899
+ ) : null,
900
+
901
+ // 常开知情同意(一次性):相对 cloudflared「每次开启都弹」,这里刻意的放宽——
902
+ // 常开的意义就是不用每次点,所以同意改为配置阶段勾一次并由服务端强制校验。
903
+ consentOpen ? h('div', { style: { position: 'fixed', inset: 0, zIndex: 10000, background: 'rgba(0,0,0,.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 } },
904
+ h('div', { style: { background: 'var(--dsw-alias-bg-layer-1,#fff)', borderRadius: 12, maxWidth: 440, width: '100%', padding: '20px 22px', boxShadow: '0 8px 32px rgba(0,0,0,.18)', maxHeight: '85vh', overflowY: 'auto' } },
905
+ h('div', { style: { fontWeight: 600, fontSize: 15, color: 'var(--dsw-alias-state-warn-primary,#b45309)', marginBottom: 10 } }, t('relayConsentTitle')),
906
+ h('div', { style: { fontSize: 13, lineHeight: 1.75, color: 'var(--dsw-alias-label-primary,inherit)', whiteSpace: 'pre-line' } }, t('relayConsentBody')),
907
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 14, fontSize: 13, cursor: 'pointer' } },
908
+ h('input', { type: 'checkbox', checked: consentChecked, onChange: (e) => setConsentChecked(e.target.checked), style: { width: 16, height: 16 } }),
909
+ t('relayConsentAgree')),
910
+ h('div', { style: { display: 'flex', gap: 8, marginTop: 16 } },
911
+ h('button', { style: { ...styles.btn, flex: 1 }, onClick: () => setConsentOpen(false) }, t('cancel')),
912
+ h('button', {
913
+ style: { ...styles.primary, flex: 1, opacity: consentChecked ? 1 : .5 },
914
+ disabled: !consentChecked,
915
+ onClick: confirmConsent,
916
+ }, t('relayConsentAgree'))),
917
+ !consentChecked ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } }, t('relayConsentHint')) : null),
918
+ ) : null,
919
+
920
+ // 撤销设备确认(撤销 = 那台手机要重新配对,不可轻点)
921
+ revokeTarget ? h('div', { style: { position: 'fixed', inset: 0, zIndex: 10000, background: 'rgba(0,0,0,.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 } },
922
+ h('div', { style: { background: 'var(--dsw-alias-bg-layer-1,#fff)', borderRadius: 12, maxWidth: 400, width: '100%', padding: '20px 22px', boxShadow: '0 8px 32px rgba(0,0,0,.18)' } },
923
+ h('div', { style: { fontWeight: 600, fontSize: 15, color: 'var(--dsw-alias-state-error-primary,#dc2626)', marginBottom: 10 } }, `${t('deviceRevoke')} · ${revokeTarget.name}`),
924
+ h('div', { style: { fontSize: 13, lineHeight: 1.7, color: 'var(--dsw-alias-label-primary,inherit)' } }, t('deviceRevokeConfirm')),
925
+ h('div', { style: { display: 'flex', gap: 8, marginTop: 16 } },
926
+ h('button', { style: { ...styles.btn, flex: 1 }, onClick: () => setRevokeTarget(null) }, t('cancel')),
927
+ h('button', {
928
+ style: { ...styles.primary, flex: 1, background: 'var(--dsw-alias-state-error-primary,#dc2626)' },
929
+ onClick: () => { const d = revokeTarget; setRevokeTarget(null); void deviceAction(POCKET_ENDPOINTS.deviceRevoke, d.id, 'deviceRevokeDone'); },
930
+ }, t('deviceRevoke')))),
931
+ ) : null,
932
+
933
+ // 页面最底部:反馈入口
934
+ h('div', { style: { ...styles.block, textAlign: 'center' } },
935
+ h('a', { href: 'https://github.com/kinderao/dsh-pocket-relay/issues', target: '_blank', rel: 'noreferrer', style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', textDecoration: 'none' } },
936
+ t('feedback')),
937
+ ),
938
+ );
939
+ }
940
+
941
+ export function apply(ctx) {
942
+ // 兜底:确保 connection.isLoopback 为 true(issue #58)。
943
+ // 注:代理注入的 loopback 补丁(proxy.mjs LOOPBACK_ENV_PATCH)已在 #105 移除——
944
+ // 它与 DSH Desktop 2.0.4+ 客户端运行时不兼容,会令 BootHandoff 阶段白屏。
945
+ // #58「远程浏览器开设置页」需上游提供官方信任来源机制才能正经解决;此处仅保留兜底。
946
+ if (ctx?.connection) {
947
+ try {
948
+ Object.defineProperty(ctx.connection, 'isLoopback', { value: true, writable: true, configurable: true });
949
+ } catch {
950
+ try { ctx.connection.isLoopback = true; } catch { /* 忽略 */ }
951
+ }
952
+ }
953
+
954
+ // 移动端适配(dsh-web-mobile 移植):抽屉布局/触控/安全区,仅窄屏生效
955
+ mobileApply(ctx);
956
+
957
+ const rpcCall = (endpoint, payload, signal) =>
958
+ ctx.connection.rpc.call(POCKET_RPC_CHANNEL, endpoint, payload, signal);
959
+
960
+ // 设置页签接入 DSH 本地化:注册 pocket 词典(zh/en),并绑定一个随当前 locale 切换的 t()。
961
+ const translate = ctx.locale.bind(POCKET_NS);
962
+ ctx.effect(() => ctx.locale.register(POCKET_NS, { zh: POCKET_ZH, en: POCKET_EN }), 'dsh-pocket: pocket locale dictionaries');
963
+
964
+ // 设置一级入口(与 通用设置/模型/插件 同级,order 1 = 通用之后、最外层)
965
+ ctx.slots.inject('settings.section', () =>
966
+ ctx.slots.register(
967
+ {
968
+ name: 'settings.section',
969
+ id: 'pocket',
970
+ order: 1,
971
+ label: () => translate('section'),
972
+ inject: () => ({ rpcCall, t: translate }),
973
+ },
974
+ PocketSettingsTab,
975
+ ),
976
+ );
977
+ }
978
+
979
+ export { name, inject, redactStatus };