@guowenzhang/dsh-pocket 0.0.0-stage → 2.12.2

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,581 @@
1
+ // dsh-pocket 网页客户端:
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, MOBILE_RIGHTBAR_ATTRIBUTE, MOBILE_RIGHTBAR_EVENT, redactStatus } 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';
17
+ const inject = ['slots', 'connection', 'layout', 'locale'];
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
+
47
+ function applyMobileRightbarSetting(enabled) {
48
+ const on = enabled !== false;
49
+ document.body?.setAttribute(MOBILE_RIGHTBAR_ATTRIBUTE, on ? 'on' : 'off');
50
+ window.dispatchEvent(new CustomEvent(MOBILE_RIGHTBAR_EVENT, { detail: { enabled: on } }));
51
+ }
52
+
53
+ function PocketSettingsTab({ rpcCall, t }) {
54
+ const [status, setStatus] = useState(null);
55
+ const [busy, setBusy] = useState(false);
56
+ const [error, setError] = useState(null);
57
+ const [tunnelState, setTunnelState] = useState(null); // 隧道进度 {phase, detail, startedAt}
58
+ const [restartNotice, setRestartNotice] = useState(false); // 重启后提示
59
+ const [isDesktop, setIsDesktop] = useState(false); // DSH Desktop(Electron)环境
60
+ const [now, setNow] = useState(Date.now()); // 每秒 tick,驱动倒计时
61
+
62
+ // 进行中操作的「已等待 X 秒」倒计时
63
+ useEffect(() => {
64
+ const t = setInterval(() => setNow(Date.now()), 1000);
65
+ return () => clearInterval(t);
66
+ }, []);
67
+ const elapsed = (startedAt) => (startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0);
68
+
69
+ const call = async (endpoint, payload) => {
70
+ const res = await rpcCall(endpoint, payload);
71
+ if (!res?.ok) throw new Error(res?.error?.message ?? 'RPC failed');
72
+ return res.value;
73
+ };
74
+
75
+ const load = async () => {
76
+ try {
77
+ const s = await call(POCKET_ENDPOINTS.status, {});
78
+ setStatus(s);
79
+ applyMobileRightbarSetting(s.mobileRightbarEnabled);
80
+ setTunnelState(s.tunnelState ?? null);
81
+ if (s.desktop) setIsDesktop(true);
82
+ if (s.restartNotice) {
83
+ // 新进程确认起来了:显示一次「已重启」,然后自动刷新页面加载新代码——不用用户手动刷新
84
+ setRestartNotice(true);
85
+ if (!sessionStorage.getItem('dshp-auto-reloaded')) {
86
+ sessionStorage.setItem('dshp-auto-reloaded', '1');
87
+ setTimeout(() => { try { location.reload(); } catch { /* 忽略 */ } }, 2000);
88
+ }
89
+ }
90
+ } catch { /* 忽略瞬时失败 */ }
91
+ };
92
+
93
+ useEffect(() => {
94
+ load();
95
+ const t = setInterval(load, 3000);
96
+ return () => clearInterval(t);
97
+ }, []);
98
+
99
+ // 每次页面加载清掉自动刷新标记——这样下次重启(更新后)才能再次触发自动刷新
100
+ useEffect(() => {
101
+ try { sessionStorage.removeItem('dshp-auto-reloaded'); } catch { /* 忽略 */ }
102
+ }, []);
103
+
104
+ // 安全免责声明(issue #31):每次开启公网都必须先弹框勾选「我已知情」。
105
+ // 服务端同样强制(tunnel.start 需 disclaimer: true),防绕过前端直接调 RPC。
106
+
107
+ const [disclaimerOpen, setDisclaimerOpen] = useState(false);
108
+ const [disclaimerChecked, setDisclaimerChecked] = useState(false);
109
+
110
+ const doStartTunnel = async () => {
111
+ // 命名隧道模式:Token/域名没配齐就不发起(服务端同样会拒绝)
112
+ const cfg = status?.tunnelConfig;
113
+ if (cfg?.mode === 'named' && (!cfg.hostname || !cfg.tokenSet)) {
114
+ setError(t('namedNeedCfg'));
115
+ return;
116
+ }
117
+ setBusy(true);
118
+ setError(null);
119
+ setTunnelState({ phase: 'starting', detail: '正在开启…', startedAt: Date.now() });
120
+ try {
121
+ setStatus(await call(POCKET_ENDPOINTS.tunnelStart, { disclaimer: true }));
122
+ } catch (err) {
123
+ setError(err.message);
124
+ } finally {
125
+ setBusy(false);
126
+ }
127
+ };
128
+ const startTunnel = () => {
129
+ // 每次开启都弹免责确认(勾选后才能继续)
130
+ setDisclaimerChecked(false);
131
+ setDisclaimerOpen(true);
132
+ };
133
+ const confirmDisclaimer = () => {
134
+ if (!disclaimerChecked) return; // 未勾选不允许
135
+ setDisclaimerOpen(false);
136
+ doStartTunnel();
137
+ };
138
+
139
+ const stopTunnel = async () => {
140
+ try { setStatus(await call(POCKET_ENDPOINTS.tunnelStop, {})); } catch { /* 忽略 */ }
141
+ };
142
+
143
+ // 公网模式(issue #66):随机域名(默认零配置)/ 固定域名(Cloudflare 命名隧道 + Tunnel Token)
144
+ // tunnelCfg:编辑态 { hostname, token, err } | null;token 输入留空 = 保持已存的 Token 不变
145
+ const [tunnelCfg, setTunnelCfg] = useState(null);
146
+ const switchToQuick = async () => {
147
+ try { setStatus(await call(POCKET_ENDPOINTS.tunnelSetConfig, { mode: 'quick' })); } catch (err) { setError(err.message); }
148
+ };
149
+ const saveNamedTunnel = async () => {
150
+ try {
151
+ setStatus(await call(POCKET_ENDPOINTS.tunnelSetConfig, {
152
+ mode: 'named',
153
+ hostname: tunnelCfg?.hostname ?? '',
154
+ token: tunnelCfg?.token || undefined, // 留空不覆盖已存 Token
155
+ }));
156
+ setTunnelCfg(null);
157
+ } catch (err) {
158
+ setTunnelCfg((c) => ({ ...c, err: err.message }));
159
+ }
160
+ };
161
+
162
+ // 恢复出厂设置:清本机设置 + 重设随机密码(弹窗确认;RPC 端也强制校验 confirm)
163
+ const [resetOpen, setResetOpen] = useState(false);
164
+ const doFactoryReset = async () => {
165
+ setResetOpen(false);
166
+ setBusy(true);
167
+ setError(null);
168
+ try {
169
+ const next = await call(POCKET_ENDPOINTS.pocketReset, { confirm: true });
170
+ setStatus(next);
171
+ applyMobileRightbarSetting(next.mobileRightbarEnabled);
172
+ setTunnelCfg(null);
173
+ setCustomPin(null);
174
+ setAdvOpen(false);
175
+ showToast(t('resetDone'));
176
+ } catch (err) {
177
+ setError(err.message);
178
+ showToast(t('resetFailed'));
179
+ } finally {
180
+ setBusy(false);
181
+ }
182
+ };
183
+
184
+ // 刷新局域网访问密码(旧密码立即作废)
185
+ const refreshLanPin = async () => {
186
+ try {
187
+ const r = await call(POCKET_ENDPOINTS.lanTokenRefresh, {});
188
+ setStatus((s) => ({ ...s, lanToken: r.lanToken }));
189
+ } catch { /* 忽略 */ }
190
+ };
191
+
192
+ // 局域网访问密码开关(issue #24):默认开启;关闭后局域网扫码直连(公网不受影响)
193
+ const setLanAuth = async (on) => {
194
+ try {
195
+ const r = await call(POCKET_ENDPOINTS.lanAuthSetEnabled, { on });
196
+ setStatus((s) => ({ ...s, lanAuthEnabled: r.lanAuthEnabled }));
197
+ } catch { /* 忽略 */ }
198
+ };
199
+
200
+ const setMobileRightbar = async (on) => {
201
+ try {
202
+ const r = await call(POCKET_ENDPOINTS.mobileRightbarSetEnabled, { on });
203
+ const enabled = r.mobileRightbarEnabled === true;
204
+ setStatus((s) => ({ ...s, mobileRightbarEnabled: enabled }));
205
+ applyMobileRightbarSetting(enabled);
206
+ } catch (err) {
207
+ setError(err.message);
208
+ }
209
+ };
210
+
211
+ // 局域网访问总开关:关闭后局域网扫码/链接直接失效(公网不受影响)。
212
+ // 切换前弹窗确认(弹窗提醒);服务端用 setLanEnabled 持久化,代理按 Host 实时拦截。
213
+ const [lanToggleOpen, setLanToggleOpen] = useState(null); // null | true | false(目标 on 状态)
214
+ const requestLanToggle = (on) => setLanToggleOpen(on);
215
+ const confirmLanToggle = async () => {
216
+ const on = lanToggleOpen;
217
+ setLanToggleOpen(null);
218
+ if (on === null) return;
219
+ try {
220
+ const r = await call(POCKET_ENDPOINTS.lanSetEnabled, { on });
221
+ setStatus((s) => ({ ...s, lanEnabled: r.lanEnabled }));
222
+ } catch (err) {
223
+ setError(err.message);
224
+ }
225
+ };
226
+
227
+ // 局域网地址手动覆盖(Tailscale/VPN 等远程访问场景):空值恢复自动选择
228
+ const setLanAddress = async (ip) => {
229
+ try {
230
+ setStatus(await call(POCKET_ENDPOINTS.lanSetOverride, { ip }));
231
+ } catch (err) {
232
+ setError(err.message);
233
+ }
234
+ };
235
+
236
+ // 自定义访问密码(issue #33):公网/局域网各自设固定 8–64 位密码(英文字母大小写或数字);自定义后公网不再自动轮换。
237
+ // customPin: { which: 'public'|'lan', value, err } | null —— 正在输入自定义密码的区块
238
+ const [customPin, setCustomPin] = useState(null);
239
+ const saveCustomPin = async (which) => {
240
+ try {
241
+ const r = await call(POCKET_ENDPOINTS.pinSetCustom, { which, value: customPin?.value ?? '' });
242
+ setStatus((s) => ({
243
+ ...s,
244
+ accessToken: which === 'public' ? r.pin : s.accessToken,
245
+ lanToken: which === 'lan' ? r.pin : s.lanToken,
246
+ publicPinCustom: which === 'public' ? true : s.publicPinCustom,
247
+ lanPinCustom: which === 'lan' ? true : s.lanPinCustom,
248
+ }));
249
+ setCustomPin(null);
250
+ } catch (err) {
251
+ setCustomPin((c) => ({ ...c, err: err.message }));
252
+ }
253
+ };
254
+ // 渲染自定义输入行(共用):输入框 + 保存/取消
255
+ const customPinRow = (which) => h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.5 } },
256
+ t('customizing'),
257
+ h('input', {
258
+ 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' },
259
+ type: 'password',
260
+ minLength: 8,
261
+ maxLength: 64,
262
+ value: customPin?.value ?? '',
263
+ autoFocus: true,
264
+ onChange: (e) => setCustomPin((c) => ({ ...c, value: e.target.value.replace(/[^a-zA-Z0-9]/g, ''), err: null })),
265
+ onKeyDown: (e) => { if (e.key === 'Enter') saveCustomPin(which); if (e.key === 'Escape') setCustomPin(null); },
266
+ }),
267
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 2 }, onClick: () => saveCustomPin(which) }, t('save')),
268
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: () => setCustomPin(null) }, t('cancel')),
269
+ customPin?.err ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', marginTop: 4 } }, errText(customPin.err)) : null,
270
+ );
271
+ // 「自定义」按钮(非输入态显示在密码行末尾)
272
+ 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'));
273
+
274
+ const lanUrl = status?.lanUrl;
275
+ const tunnelUrl = status?.tunnelUrl;
276
+ const tunnelPhase = tunnelState?.phase ?? 'idle';
277
+ const tunnelStarting = ['downloading', 'starting', 'registering'].includes(tunnelPhase);
278
+ const tunnelStateDetail = tunnelState?.detail ?? '';
279
+ const tunnelStateStarted = tunnelState?.startedAt ?? null;
280
+ // 公网模式视图(issue #66):{ mode, hostname, tokenSet }
281
+ const tunnelModeView = status?.tunnelConfig ?? { mode: 'quick', hostname: '', tokenSet: false };
282
+ const namedMode = tunnelModeView.mode === 'named';
283
+ // 模式按钮选中态高亮:固定域名模式本身,或正在编辑固定域名配置,都视为「选中」
284
+ const namedActive = namedMode || tunnelCfg !== null;
285
+ // 后端错误消息统一为「中文 | English」混排;按当前界面语言只显示对应一半
286
+ const errText = (msg) => {
287
+ const s = String(msg ?? '');
288
+ const i = s.indexOf(' | ');
289
+ if (i < 0) return s;
290
+ return (t('ok') === POCKET_ZH.ok ? s.slice(0, i) : s.slice(i + 3)).trim();
291
+ };
292
+ // 轻量 Toast:操作成功/失败后短暂提示(自动消失,不打断操作)
293
+ const [toast, setToast] = useState(null);
294
+ const toastTimer = useRef(null);
295
+ const showToast = (text) => {
296
+ setToast(text);
297
+ clearTimeout(toastTimer.current);
298
+ toastTimer.current = setTimeout(() => setToast(null), 2600);
299
+ };
300
+ useEffect(() => () => clearTimeout(toastTimer.current), []);
301
+ const modeBtnStyle = (active) => ({
302
+ ...styles.btn, height: 28, padding: '0 12px', fontSize: 12,
303
+ fontWeight: active ? 600 : 400,
304
+ background: active ? 'var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))' : 'var(--dsw-alias-bg-layer-1,#fff)',
305
+ color: active ? 'var(--dsw-alias-label-primary-foreground, #fff)' : 'var(--dsw-alias-label-primary,inherit)',
306
+ });
307
+ // iOS 风格小开关(重排后统一用:局域网总开关 / 局域网密码开关)
308
+ const Switch = (on, onClick) => h('button', {
309
+ role: 'switch', 'aria-checked': !!on,
310
+ style: { boxSizing: 'border-box', flexShrink: 0, width: 36, height: 20, padding: 2, border: 0, borderRadius: 999, cornerShape: 'round', position: 'relative', cursor: 'pointer', font: 'inherit', background: on ? 'var(--dsw-alias-brand-primary, #e6e8eb)' : 'var(--dsw-alias-border-l3, #3a3f46)', transition: 'background 120ms ease' },
311
+ onClick,
312
+ }, h('span', { style: { display: 'block', width: 16, height: 16, borderRadius: '50%', cornerShape: 'round', background: on ? 'var(--dsw-alias-label-primary-foreground, #0f1115)' : 'var(--dsw-alias-switch-thumb, #8b9198)', transition: 'transform 120ms ease', transform: on ? 'translateX(16px)' : 'translateX(0)' } }));
313
+ // 卡片内主内容:二维码 + 地址 + 提示
314
+ 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' } },
315
+ h('img', { src, alt: 'QR', style: styles.qr }),
316
+ h('div', { style: styles.code }, url),
317
+ h('div', { style: styles.muted }, hint));
318
+ // 设置行:上分隔线,内部第一行 = 左标签 + 右操作;extra 作为第二段渲染
319
+ const row = (label, control, extra) => h('div', { style: { borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', paddingTop: 9, marginTop: 9 } },
320
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
321
+ h('span', { style: { fontSize: 13 } }, label), control), extra ?? null);
322
+ // 高级(手动选地址)展开态
323
+ const [advOpen, setAdvOpen] = useState(false);
324
+
325
+ return h('div', { style: styles.card },
326
+ h('div', null,
327
+ h('strong', null, t('title')),
328
+ h('div', { style: styles.muted }, t('subtitle')),
329
+ ),
330
+
331
+ // 重启后提示(进程在后台运行,停止方法)——左侧蓝色色条(桌面端不会触发本插件的自重启)
332
+ !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' } },
333
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
334
+ h('div', { style: { fontWeight: 600, fontSize: 13 } }, t('restarted')),
335
+ h('button', { style: styles.btn, onClick: () => setRestartNotice(false) }, t('ok')),
336
+ ),
337
+ h('div', { style: styles.muted, marginTop: 4, wordBreak: 'break-all' }, fmt(t, 'bgHint', { cmd: status?.killHint ?? `lsof -ti :${status?.dshPort ?? 3080} | xargs kill -9` })),
338
+ ) : null,
339
+
340
+ // 局域网:标题行自带总开关 → 二维码+地址 → 设置行(访问密码 / 高级·手动选地址)
341
+ h('div', { style: styles.block },
342
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
343
+ h('span', { style: { fontWeight: 600, fontSize: 13 } }, t('lanAccess')),
344
+ Switch(status?.lanEnabled !== false, () => requestLanToggle(status?.lanEnabled === false)),
345
+ ),
346
+ status?.lanEnabled === false
347
+ ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-state-warn-primary,#b45309)', lineHeight: 1.5 } }, t('lanDisabledHint'))
348
+ : (lanUrl
349
+ ? h('div', null,
350
+ qrArea(status.lanQr, lanUrl, t('lanHint')),
351
+ // 访问密码行:开关 + 值(关闭时提示直连)
352
+ row(t('lanPin'), Switch(status?.lanAuthEnabled !== false, () => setLanAuth(status?.lanAuthEnabled === false)),
353
+ status?.lanAuthEnabled === false
354
+ ? h('div', { style: { ...styles.muted, marginTop: 6 } }, t('lanPinOff'))
355
+ : (customPin?.which === 'lan'
356
+ ? customPinRow('lan')
357
+ : h('div', { style: { marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
358
+ h('span', { style: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 13, letterSpacing: 1 } }, status.lanToken),
359
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: refreshLanPin }, t('refresh')),
360
+ customBtn('lan'),
361
+ status?.lanPinCustom ? h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)' } }, t('pinCustomHint')) : null,
362
+ ))),
363
+ // 高级:手动选地址(默认收起)
364
+ row(t('advAddress'),
365
+ 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) },
366
+ (status?.lanIpOverride || t('lanAddressAuto')) + ' ›'),
367
+ advOpen ? h('div', { style: { marginTop: 8 } },
368
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } },
369
+ t('lanAddress'),
370
+ h('select', {
371
+ value: status?.lanIpOverride || '',
372
+ onChange: (e) => setLanAddress(e.target.value),
373
+ 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)' },
374
+ },
375
+ h('option', { value: '' }, t('lanAddressAuto')),
376
+ (status?.lanCandidates || []).map((ip) => h('option', { key: ip, value: ip }, ip)),
377
+ ),
378
+ ),
379
+ ) : null),
380
+ )
381
+ : h('div', { style: styles.muted }, t('lanStarting'))),
382
+ ),
383
+
384
+ // 公网:标题行自带 开启/关闭 → 开启后:二维码+地址、地址模式行、访问密码行
385
+ h('div', { style: styles.block },
386
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
387
+ h('span', { style: { fontWeight: 600, fontSize: 13 } }, t('wanAccess')),
388
+ tunnelUrl
389
+ ? h('button', { style: { ...styles.btn, height: 28, padding: '0 12px', fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' }, onClick: stopTunnel }, t('stopTunnel'))
390
+ : h('button', { style: { ...styles.primary, height: 28, padding: '0 14px', fontSize: 12 }, onClick: startTunnel, disabled: busy || tunnelStarting }, busy || tunnelStarting ? t('opening') : t('enable')),
391
+ ),
392
+ tunnelStarting
393
+ ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } },
394
+ tunnelPhase === 'downloading'
395
+ ? fmt(t, 'downloading', { s: elapsed(tunnelStateStarted) })
396
+ : fmt(t, 'connecting', { s: elapsed(tunnelStateStarted), suffix: elapsed(tunnelStateStarted) > 30 ? t('slowHint') : '' }))
397
+ : tunnelPhase === 'error'
398
+ ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } },
399
+ fmt(t, 'error', { detail: errText(tunnelStateDetail) || t('unknownError') }))
400
+ : (!tunnelUrl && !isDesktop ? h('div', { style: { ...styles.muted, marginTop: 8 } }, t('wanOffHint')) : null),
401
+ tunnelUrl
402
+ ? h('div', null,
403
+ qrArea(status.tunnelQr, tunnelUrl, namedMode ? t('namedRunningHint') : t('wanHint')),
404
+ // 防钓鱼 / 别收藏(issue #82):公网链接仅本次有效、勿收藏提示
405
+ 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')),
406
+ // 地址模式行(随机/固定;固定域名选中或编辑时高亮)
407
+ row(t('modeLabel'),
408
+ h('span', { style: { display: 'inline-flex', gap: 6 } },
409
+ h('button', { style: modeBtnStyle(!namedActive), onClick: namedMode ? switchToQuick : (tunnelCfg ? () => setTunnelCfg(null) : undefined) }, t('modeQuick')),
410
+ h('button', { style: modeBtnStyle(namedActive), onClick: () => setTunnelCfg(tunnelCfg ? null : { hostname: tunnelModeView.hostname ?? '', token: '', err: null }) }, t('modeNamed')),
411
+ ),
412
+ h('div', { style: { marginTop: 6 } },
413
+ // 刚保存固定域名但当前连接仍是随机域名:需关闭后重新开启才生效
414
+ namedMode && /trycloudflare\.com/i.test(tunnelUrl ?? '') ? h('div', { style: { ...styles.warn } }, t('namedTakeEffect')) : null,
415
+ // 固定域名:已保存摘要 + 修改入口(非编辑态)
416
+ namedMode && !tunnelCfg ? h('div', { style: { ...styles.muted } },
417
+ fmt(t, 'namedSummary', { host: tunnelModeView.hostname || '—', token: tunnelModeView.tokenSet ? t('namedTokenSet') : t('namedTokenMissing') }),
418
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 8 }, onClick: () => setTunnelCfg({ hostname: tunnelModeView.hostname ?? '', token: '', err: null }) }, t('namedEdit')),
419
+ h('div', { style: { ...styles.muted, marginTop: 4 } }, t('namedHow')),
420
+ !tunnelModeView.tokenSet || !tunnelModeView.hostname ? h('div', { style: { marginTop: 2, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } }, t('namedNeedCfg')) : null,
421
+ ) : null,
422
+ // 固定域名:编辑表单(域名 + Tunnel Token,Token 留空保持不变)
423
+ tunnelCfg ? h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.6 } },
424
+ h('div', null,
425
+ t('namedHostnameLabel'),
426
+ h('input', {
427
+ 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 },
428
+ placeholder: 'pocket.example.com',
429
+ value: tunnelCfg.hostname ?? '',
430
+ autoFocus: true,
431
+ onChange: (e) => setTunnelCfg((c) => ({ ...c, hostname: e.target.value.trim(), err: null })),
432
+ onKeyDown: (e) => { if (e.key === 'Enter') saveNamedTunnel(); if (e.key === 'Escape') setTunnelCfg(null); },
433
+ }),
434
+ ),
435
+ h('div', { style: { marginTop: 6 } },
436
+ t('namedTokenLabel'),
437
+ h('input', {
438
+ 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' },
439
+ type: 'password',
440
+ value: tunnelCfg.token ?? '',
441
+ onChange: (e) => setTunnelCfg((c) => ({ ...c, token: e.target.value.trim(), err: null })),
442
+ onKeyDown: (e) => { if (e.key === 'Enter') saveNamedTunnel(); if (e.key === 'Escape') setTunnelCfg(null); },
443
+ }),
444
+ ),
445
+ h('div', { style: { marginTop: 6, display: 'flex', gap: 8 } },
446
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: saveNamedTunnel }, t('save')),
447
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: () => setTunnelCfg(null) }, t('cancel')),
448
+ ),
449
+ h('div', { style: { ...styles.muted, marginTop: 6 } }, t('namedHow')),
450
+ h('div', { style: { marginTop: 2, fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)', lineHeight: 1.5 } }, t('namedSecurity')),
451
+ tunnelCfg.err ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', marginTop: 4 } }, errText(tunnelCfg.err)) : null,
452
+ ) : null,
453
+ ),
454
+ ),
455
+ // 访问密码行:值 + 自定义(自定义输入态整体替换)
456
+ status.accessToken
457
+ ? row(t('pinLabel'),
458
+ customPin?.which === 'public'
459
+ ? null
460
+ : h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 8 } },
461
+ h('span', { style: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 13, letterSpacing: 1 } }, status.accessToken),
462
+ customBtn('public')),
463
+ h('div', { style: { marginTop: 6 } },
464
+ customPin?.which === 'public' ? customPinRow('public') : null,
465
+ status?.publicPinCustom ? h('div', { style: { ...styles.warn } }, t('pinCustomHint')) : null,
466
+ namedMode ? h('div', { style: { ...styles.warn } }, t('namedSecurity')) : null))
467
+ : null,
468
+ )
469
+ : null,
470
+ ),
471
+
472
+ h('div', { style: styles.block },
473
+ row(
474
+ t('mobileRightbar'),
475
+ Switch(status?.mobileRightbarEnabled !== false, () => setMobileRightbar(status?.mobileRightbarEnabled === false)),
476
+ h('div', { style: { ...styles.muted, marginTop: 6 } }, t('mobileRightbarHint')),
477
+ ),
478
+ ),
479
+
480
+ error ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 12, marginTop: 8 } }, `❌ ${errText(error)}`) : null,
481
+
482
+ // 恢复出厂设置:设置出问题时的临时兜底(最底部,避免误触)
483
+ h('div', { style: styles.block },
484
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
485
+ h('span', { style: { fontWeight: 600, fontSize: 13 } }, t('resetFactory')),
486
+ 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')),
487
+ ),
488
+ h('div', { style: { ...styles.muted, marginTop: 6 } }, t('resetIntro')),
489
+ ),
490
+
491
+ // 恢复出厂设置确认弹框
492
+ resetOpen ? h('div', { style: { position: 'fixed', inset: 0, zIndex: 10000, background: 'rgba(0,0,0,.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 } },
493
+ 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)' } },
494
+ h('div', { style: { fontWeight: 600, fontSize: 15, color: 'var(--dsw-alias-state-warn-primary,#b45309)', marginBottom: 10 } }, t('resetTitle')),
495
+ h('div', { style: { fontSize: 13, lineHeight: 1.7, color: 'var(--dsw-alias-label-primary,inherit)', whiteSpace: 'pre-line' } }, t('resetBody')),
496
+ h('div', { style: { display: 'flex', gap: 8, marginTop: 16 } },
497
+ h('button', { style: { ...styles.btn, flex: 1 }, onClick: () => setResetOpen(false) }, t('cancel')),
498
+ h('button', { style: { ...styles.primary, flex: 1, background: 'var(--dsw-alias-state-error-primary,#dc2626)' }, onClick: doFactoryReset }, t('resetConfirm')),
499
+ ),
500
+ ),
501
+ ) : null,
502
+
503
+ // Toast:重置等操作的即时反馈(固定屏幕正中央,2.6s 自动消失)
504
+ toast ? h('div', {
505
+ 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)' },
506
+ }, toast) : null,
507
+
508
+ // 局域网访问开关确认弹框(关闭/打开时弹窗提醒)
509
+ 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 } },
510
+ 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)' } },
511
+ 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')),
512
+ h('div', { style: { fontSize: 13, lineHeight: 1.7, color: 'var(--dsw-alias-label-primary,inherit)' } }, t(lanToggleOpen ? 'lanToggleBodyOn' : 'lanToggleBodyOff')),
513
+ h('div', { style: { display: 'flex', gap: 8, marginTop: 16 } },
514
+ h('button', { style: { ...styles.btn, flex: 1 }, onClick: () => setLanToggleOpen(null) }, t('cancel')),
515
+ h('button', { style: { ...styles.primary, flex: 1 }, onClick: confirmLanToggle }, t('confirm')),
516
+ ),
517
+ ),
518
+ ) : null,
519
+
520
+ // 安全免责声明弹框(issue #31):每次开启公网访问前确认
521
+ disclaimerOpen ? h('div', { style: { position: 'fixed', inset: 0, zIndex: 10000, background: 'rgba(0,0,0,.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 } },
522
+ 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)' } },
523
+ h('div', { style: { fontWeight: 600, fontSize: 15, color: 'var(--dsw-alias-state-warn-primary,#b45309)', marginBottom: 10 } }, t('disclaimerTitle')),
524
+ h('div', { style: { fontSize: 13, lineHeight: 1.7, color: 'var(--dsw-alias-label-primary,inherit)' } }, t('disclaimerBody')),
525
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 14, fontSize: 13, cursor: 'pointer' } },
526
+ h('input', { type: 'checkbox', checked: disclaimerChecked, onChange: (e) => setDisclaimerChecked(e.target.checked), style: { width: 16, height: 16 } }),
527
+ t('disclaimerAgree'),
528
+ ),
529
+ h('div', { style: { display: 'flex', gap: 8, marginTop: 16 } },
530
+ h('button', { style: { ...styles.btn, flex: 1 }, onClick: () => setDisclaimerOpen(false) }, t('cancel')),
531
+ h('button', {
532
+ style: { ...styles.primary, flex: 1, opacity: disclaimerChecked ? 1 : .5 },
533
+ disabled: !disclaimerChecked,
534
+ onClick: confirmDisclaimer,
535
+ }, t('disclaimerAgree')),
536
+ ),
537
+ !disclaimerChecked ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } }, t('disclaimerHint')) : null,
538
+ ),
539
+ ) : null,
540
+ );
541
+ }
542
+
543
+ export function apply(ctx) {
544
+ // 兜底:确保 connection.isLoopback 为 true(issue #58)。
545
+ // 注:代理注入的 loopback 补丁(proxy.mjs LOOPBACK_ENV_PATCH)已在 #105 移除——
546
+ // 它与 DSH Desktop 2.0.4+ 客户端运行时不兼容,会令 BootHandoff 阶段白屏。
547
+ // #58「远程浏览器开设置页」需上游提供官方信任来源机制才能正经解决;此处仅保留兜底。
548
+ if (ctx?.connection) {
549
+ try {
550
+ Object.defineProperty(ctx.connection, 'isLoopback', { value: true, writable: true, configurable: true });
551
+ } catch {
552
+ try { ctx.connection.isLoopback = true; } catch { /* 忽略 */ }
553
+ }
554
+ }
555
+
556
+ // 移动端适配(dsh-web-mobile 移植):抽屉布局/触控/安全区,仅窄屏生效
557
+ mobileApply(ctx);
558
+
559
+ const rpcCall = (endpoint, payload, signal) =>
560
+ ctx.connection.rpc.call(POCKET_RPC_CHANNEL, endpoint, payload, signal);
561
+
562
+ // 设置页签接入 DSH 本地化:注册 pocket 词典(zh/en),并绑定一个随当前 locale 切换的 t()。
563
+ const translate = ctx.locale.bind(POCKET_NS);
564
+ ctx.effect(() => ctx.locale.register(POCKET_NS, { zh: POCKET_ZH, en: POCKET_EN }), 'dsh-pocket: pocket locale dictionaries');
565
+
566
+ // 设置一级入口(与 通用设置/模型/插件 同级,order 1 = 通用之后、最外层)
567
+ ctx.slots.inject('settings.section', () =>
568
+ ctx.slots.register(
569
+ {
570
+ name: 'settings.section',
571
+ id: 'pocket',
572
+ order: 1,
573
+ label: () => translate('section'),
574
+ inject: () => ({ rpcCall, t: translate }),
575
+ },
576
+ PocketSettingsTab,
577
+ ),
578
+ );
579
+ }
580
+
581
+ export { name, inject, redactStatus };
@@ -0,0 +1,4 @@
1
+ Mobile adaptation source adapted from dsh-web-mobile
2
+ https://github.com/mexiaosqwq/dsh-web-mobile
3
+ Copyright (c) mexiaosqwq
4
+ MIT License — see https://github.com/mexiaosqwq/dsh-web-mobile/blob/main/LICENSE
@@ -0,0 +1,69 @@
1
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
2
+ import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-store'
3
+ import type { SessionId } from '@deepseek-ai/dsh-session/types'
4
+ import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
5
+ import { NS } from './locales.ts'
6
+ import { MobileIcon } from './MobileIcon.tsx'
7
+ import { resolveMobileIcons } from './icon-compat.mjs'
8
+
9
+ /** 宿主图标在加载时解析一次:不同 DSH 版本的导出名不同(见 icon-compat.mjs)。 */
10
+ const icons = resolveMobileIcons(primitives)
11
+
12
+ /** Optional host download action; absent while Session log export is disabled. */
13
+ export interface SessionLogDownloadState {
14
+ download: ((sessionId: SessionId) => void) | undefined
15
+ }
16
+
17
+ /** Full props for the sidebar footer action entry. */
18
+ export interface MobileDrawerFooterProps extends PropsRuntime<'sidebar.footer.action'>, PropsLocale<typeof NS> {
19
+ /** Observes the host download service's availability. */
20
+ useSessionLogDownload: SnapshotSelectorHook<SessionLogDownloadState>
21
+ /** Bound ctx.layout.toggleSidebar(): the Files sheet closes the drawer. */
22
+ toggleSidebar: () => void
23
+ }
24
+
25
+ /**
26
+ * Mobile-only drawer footer actions, relocated from the session header to the
27
+ * drawer footer (beside Settings):
28
+ * - Files: opens the dsh-web-ui aionui explorer as a floating bottom sheet
29
+ * (the explorer column is hidden on mobile until this marker is set, so
30
+ * the suite's own persisted-expanded state can never cover the UI on load).
31
+ * - Session log: the official session-log-export controller, so the
32
+ * progress/result dialog is shared with the desktop flow.
33
+ * Hidden entirely on wide screens (CSS media query).
34
+ */
35
+ export function MobileDrawerFooter({ useSessions, useSessionLogDownload, toggleSidebar, t }: MobileDrawerFooterProps) {
36
+ const sessionId = useSessions((state) => state.current)
37
+ const downloadSessionLog = useSessionLogDownload((state) => state.download)
38
+ const openExplorer = (): void => {
39
+ document.querySelector('[data-mobile-nav="frame"]')?.setAttribute('data-aionui-explorer-open', '')
40
+ toggleSidebar()
41
+ }
42
+ return (
43
+ <div data-mobile-nav="drawer-actions">
44
+ <button
45
+ type="button"
46
+ data-mobile-nav="explorer"
47
+ aria-label={t('files')}
48
+ title={t('files')}
49
+ onClick={openExplorer}
50
+ >
51
+ <MobileIcon icon={icons.panelLeft} name="panelLeft" size={14} />
52
+ <span>{t('files')}</span>
53
+ </button>
54
+ {downloadSessionLog !== undefined && <button
55
+ type="button"
56
+ data-mobile-nav="session-log"
57
+ aria-label={t('sessionLog')}
58
+ title={t('sessionLog')}
59
+ disabled={sessionId === undefined}
60
+ onClick={() => {
61
+ if (sessionId !== undefined) downloadSessionLog(sessionId)
62
+ }}
63
+ >
64
+ <MobileIcon icon={icons.download} name="download" size={14} />
65
+ <span>{t('sessionLog')}</span>
66
+ </button>}
67
+ </div>
68
+ )
69
+ }