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,3238 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-pocket",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ // The DSH client module system provides react as a module, never as a
7
+ // global. esbuild keeps react external (see the build config above) and
8
+ // its classic JSX transform emits bare React.createElement calls for the
9
+ // mobile components (which import only named hooks, not React itself), so
10
+ // the bundle must bind React itself - otherwise every mobile component
11
+ // crashes at render time with "ReferenceError: React is not defined".
12
+ var React = require("react");
13
+ var __defProp = Object.defineProperty;
14
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
15
+ var __getOwnPropNames = Object.getOwnPropertyNames;
16
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
17
+ var __export = (target, all) => {
18
+ for (var name2 in all)
19
+ __defProp(target, name2, { get: all[name2], enumerable: true });
20
+ };
21
+ var __copyProps = (to, from, except, desc) => {
22
+ if (from && typeof from === "object" || typeof from === "function") {
23
+ for (let key of __getOwnPropNames(from))
24
+ if (!__hasOwnProp.call(to, key) && key !== except)
25
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
26
+ }
27
+ return to;
28
+ };
29
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
30
+
31
+ // client/index.jsx
32
+ var index_exports = {};
33
+ __export(index_exports, {
34
+ apply: () => apply,
35
+ inject: () => inject,
36
+ name: () => name,
37
+ redactStatus: () => redactStatus
38
+ });
39
+ module.exports = __toCommonJS(index_exports);
40
+ var import_react2 = require("react");
41
+
42
+ // client/api.js
43
+ var POCKET_RPC_CHANNEL = "/dsh-pocket";
44
+ var POCKET_ENDPOINTS = Object.freeze({
45
+ status: "pocket.status",
46
+ tunnelStart: "tunnel.start",
47
+ tunnelStop: "tunnel.stop",
48
+ tunnelSetConfig: "tunnel.setConfig",
49
+ version: "pocket.version",
50
+ update: "pocket.update",
51
+ restart: "pocket.restart",
52
+ lanTokenRefresh: "token.lanRefresh",
53
+ lanAuthSetEnabled: "lanAuth.setEnabled",
54
+ lanSetOverride: "lan.setOverride",
55
+ lanSetEnabled: "lan.setEnabled",
56
+ pinSetCustom: "pin.setCustom",
57
+ pocketReset: "pocket.reset",
58
+ // 自建中继通道(替代 cloudflared 的传输层)。配置好即常开,无需手动开启。
59
+ relaySetConfig: "relay.setConfig",
60
+ relaySetEnabled: "relay.setEnabled",
61
+ // 设备管理(设备认证):生成配对码、批准/拒绝/撤销设备。
62
+ // 这些端点只对本机 loopback 开放——代理层会拒绝非本机来源调 /dsh-pocket(白名单除外),
63
+ // 所以「已通过设备认证的手机」也拿不到它们,见 lib/proxy.mjs。
64
+ deviceList: "device.list",
65
+ devicePairingCreate: "device.pairing.create",
66
+ devicePairingCancel: "device.pairing.cancel",
67
+ deviceApprove: "device.approve",
68
+ deviceReject: "device.reject",
69
+ deviceRevoke: "device.revoke",
70
+ // 移动端「复制文件内容」(issue #17):手机经此 RPC 让主机读取文件正文,
71
+ // 再写入剪贴板——因为手机无法直接打开电脑上的文件。
72
+ fileRead: "pocket.fileRead"
73
+ });
74
+ function compareVersions(a, b) {
75
+ const pa = String(a).replace(/^[vV]/, "").split(".");
76
+ const pb = String(b).replace(/^[vV]/, "").split(".");
77
+ for (let i = 0; i < 3; i++) {
78
+ const x = parseInt(pa[i], 10) || 0;
79
+ const y = parseInt(pb[i], 10) || 0;
80
+ if (x !== y) return x - y;
81
+ }
82
+ const aPre = String(a).replace(/^[vV]/, "").match(/-.*$/)?.[0] ?? "";
83
+ const bPre = String(b).replace(/^[vV]/, "").match(/-.*$/)?.[0] ?? "";
84
+ if (!aPre && !bPre) return 0;
85
+ if (!aPre) return 1;
86
+ if (!bPre) return -1;
87
+ const aParts = aPre.slice(1).split(".");
88
+ const bParts = bPre.slice(1).split(".");
89
+ const len = Math.max(aParts.length, bParts.length);
90
+ for (let i = 0; i < len; i++) {
91
+ const ax = aParts[i] ?? "";
92
+ const bx = bParts[i] ?? "";
93
+ if (ax === bx) continue;
94
+ const aNum = /^\d+$/.test(ax);
95
+ const bNum = /^\d+$/.test(bx);
96
+ if (aNum && bNum) return Number(ax) - Number(bx);
97
+ if (aNum) return 1;
98
+ if (bNum) return -1;
99
+ return ax < bx ? -1 : 1;
100
+ }
101
+ return 0;
102
+ }
103
+ function redactStatus(s) {
104
+ return {
105
+ proxyRunning: s?.proxyRunning === true,
106
+ proxyPort: s?.proxyPort ?? null,
107
+ lanUrl: s?.lanUrl ?? null,
108
+ lanQr: s?.lanQr ?? null,
109
+ lanCandidates: Array.isArray(s?.lanCandidates) ? s.lanCandidates : [],
110
+ lanIpOverride: s?.lanIpOverride ?? "",
111
+ tunnelRunning: s?.tunnelRunning === true,
112
+ tunnelUrl: s?.tunnelUrl ?? null,
113
+ tunnelQr: s?.tunnelQr ?? null,
114
+ tunnelState: s?.tunnelState ?? { phase: "idle" },
115
+ tunnelConfig: s?.tunnelConfig ?? { mode: "quick", hostname: "", tokenSet: false },
116
+ dshPort: s?.dshPort ?? null,
117
+ relayRunning: s?.relayRunning === true,
118
+ relayState: s?.relayState ?? { phase: "idle" },
119
+ relayQr: s?.relayQr ?? null
120
+ };
121
+ }
122
+
123
+ // client/mobile/MobileNavToggle.tsx
124
+ var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
125
+ function MobileNavToggle({ toggleSidebar, t }) {
126
+ const toggleExplorer = () => {
127
+ const frame = document.querySelector('[data-mobile-nav="frame"]');
128
+ if (frame === null) return;
129
+ if (frame.hasAttribute("data-aionui-explorer-open")) {
130
+ frame.removeAttribute("data-aionui-explorer-open");
131
+ } else {
132
+ frame.setAttribute("data-aionui-explorer-open", "");
133
+ }
134
+ };
135
+ return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(
136
+ "button",
137
+ {
138
+ type: "button",
139
+ "data-mobile-nav": "toggle",
140
+ "aria-label": t("open"),
141
+ title: t("open"),
142
+ onClick: () => toggleSidebar()
143
+ },
144
+ /* @__PURE__ */ React.createElement(import_dsh_client_ui_primitives.IconPanelLeftOutline16, { size: 16 })
145
+ ), /* @__PURE__ */ React.createElement(
146
+ "button",
147
+ {
148
+ type: "button",
149
+ "data-mobile-nav": "files",
150
+ "aria-label": t("files"),
151
+ title: t("files"),
152
+ onClick: toggleExplorer
153
+ },
154
+ /* @__PURE__ */ React.createElement(import_dsh_client_ui_primitives.IconFolderOpenOutline16, { size: 16 })
155
+ ));
156
+ }
157
+
158
+ // client/mobile/MobileNavOverlay.tsx
159
+ var import_react = require("react");
160
+ var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives");
161
+
162
+ // client/mobile/nav-targets.mjs
163
+ var DRAWER_SELECTOR = '[data-mobile-nav="frame"] > :first-child';
164
+ var TOGGLE_SELECTOR = '[data-mobile-nav="toggle"]';
165
+ var NAV_TARGETS = [
166
+ "button[data-dsh-taskboard-entry]",
167
+ "button[data-dsh-ssh-entry]",
168
+ // 抽屉底部的 "文件" 入口打开的是 dsh-web-ui 的 explorer 面板,它的 z-index
169
+ // (55) 低于展开的抽屉 (600),且在抽屉 DOM 之外:抽屉不关就会盖住面板,点
170
+ // 面板里的行又会被"点抽屉外就关"吃掉。所以按导航处理,一起关掉。
171
+ '[data-mobile-nav="files"]',
172
+ '[class*="sessionRow"]',
173
+ '[class*="newSession"]',
174
+ '[class*="searchResultWorkspace"]',
175
+ '[class*="searchResultRow"]'
176
+ ].join(", ");
177
+ var NAV_EXCLUDE = '[class*="sessionRow"] button';
178
+ var OVERLAY_SELECTOR = [
179
+ '[role="menu"]',
180
+ '[role="listbox"]',
181
+ '[role="dialog"]',
182
+ '[role="tooltip"]',
183
+ "[data-radix-popper-content-wrapper]"
184
+ ].join(", ");
185
+ function navTargetFor(target) {
186
+ if (target == null || typeof target.closest !== "function") return null;
187
+ if (target.closest(NAV_EXCLUDE) !== null) return null;
188
+ return target.closest(NAV_TARGETS);
189
+ }
190
+ function isOverlayTap(target) {
191
+ if (target == null || typeof target.closest !== "function") return false;
192
+ return target.closest(OVERLAY_SELECTOR) !== null;
193
+ }
194
+
195
+ // client/mobile/MobileNavOverlay.tsx
196
+ var MOBILE_QUERY = "(max-width: 1023px)";
197
+ function useMobile() {
198
+ const [mobile, setMobile] = (0, import_react.useState)(() => window.matchMedia(MOBILE_QUERY).matches);
199
+ (0, import_react.useEffect)(() => {
200
+ const query = window.matchMedia(MOBILE_QUERY);
201
+ const onChange = (event) => setMobile(event.matches);
202
+ query.addEventListener("change", onChange);
203
+ return () => query.removeEventListener("change", onChange);
204
+ }, []);
205
+ return mobile;
206
+ }
207
+ function findFrame() {
208
+ return document.querySelector("[data-shell-overlay]")?.parentElement ?? null;
209
+ }
210
+ function MobileNavOverlay({ toggleSidebar, t }) {
211
+ const mobile = useMobile();
212
+ const [open, setOpen] = (0, import_react.useState)(false);
213
+ const [fabVisible, setFabVisible] = (0, import_react.useState)(false);
214
+ (0, import_react.useLayoutEffect)(() => {
215
+ if (!mobile) {
216
+ setOpen(false);
217
+ return;
218
+ }
219
+ const frame = findFrame();
220
+ if (frame === null) return;
221
+ frame.setAttribute("data-mobile-nav", "frame");
222
+ const sync = () => setOpen(!frame.hasAttribute("data-sidebar-collapsed"));
223
+ sync();
224
+ const observer = new MutationObserver(sync);
225
+ observer.observe(frame, { attributes: true, attributeFilter: ["data-sidebar-collapsed"] });
226
+ return () => {
227
+ observer.disconnect();
228
+ frame.removeAttribute("data-mobile-nav");
229
+ };
230
+ }, [mobile]);
231
+ (0, import_react.useEffect)(() => {
232
+ if (!mobile) {
233
+ setFabVisible(false);
234
+ return;
235
+ }
236
+ const sync = () => setFabVisible(document.querySelector('[data-phase="active"]') === null);
237
+ sync();
238
+ const observer = new MutationObserver(sync);
239
+ observer.observe(document.documentElement, {
240
+ subtree: true,
241
+ childList: true,
242
+ attributes: true,
243
+ attributeFilter: ["data-phase"]
244
+ });
245
+ return () => observer.disconnect();
246
+ }, [mobile]);
247
+ (0, import_react.useEffect)(() => {
248
+ if (!mobile || !open) return;
249
+ const onKeyDown = (event) => {
250
+ if (event.key === "Escape" && document.querySelector('[aria-modal="true"]') === null) toggleSidebar();
251
+ };
252
+ document.addEventListener("keydown", onKeyDown, true);
253
+ return () => document.removeEventListener("keydown", onKeyDown, true);
254
+ }, [mobile, open, toggleSidebar]);
255
+ (0, import_react.useEffect)(() => {
256
+ if (!mobile || !open) return;
257
+ let lastTouchNavAt = 0;
258
+ let lastTouchX = 0;
259
+ let lastTouchY = 0;
260
+ let suppressTouchClickUntil = 0;
261
+ let pendingTouchRow = null;
262
+ let selectedRowAtArm = null;
263
+ let navClickArrived = false;
264
+ let navObserver = null;
265
+ let navTimer = null;
266
+ const drawerRoot = () => document.querySelector(DRAWER_SELECTOR);
267
+ const disarmNav = () => {
268
+ navObserver?.disconnect();
269
+ navObserver = null;
270
+ if (navTimer !== null) window.clearTimeout(navTimer);
271
+ navTimer = null;
272
+ pendingTouchRow = null;
273
+ selectedRowAtArm = null;
274
+ navClickArrived = false;
275
+ };
276
+ const armNav = (row) => {
277
+ disarmNav();
278
+ pendingTouchRow = row;
279
+ const drawer = drawerRoot();
280
+ selectedRowAtArm = drawer?.querySelector('[role="treeitem"][aria-selected="true"]') ?? null;
281
+ if (drawer === null) return;
282
+ navObserver = new MutationObserver(() => {
283
+ const frame = document.querySelector('[data-mobile-nav="frame"]');
284
+ if (frame === null || frame.hasAttribute("data-sidebar-collapsed")) {
285
+ disarmNav();
286
+ return;
287
+ }
288
+ const selectedRow = drawerRoot()?.querySelector('[role="treeitem"][aria-selected="true"]') ?? null;
289
+ if (navClickArrived && selectedRow !== null && selectedRow !== selectedRowAtArm) {
290
+ disarmNav();
291
+ toggleSidebar();
292
+ }
293
+ });
294
+ navObserver.observe(drawer, {
295
+ childList: true,
296
+ subtree: true,
297
+ attributes: true,
298
+ attributeFilter: ["aria-selected"]
299
+ });
300
+ navTimer = window.setTimeout(disarmNav, 2e3);
301
+ };
302
+ const navigationTarget = (target) => {
303
+ if (document.querySelector('[aria-modal="true"]') !== null) return null;
304
+ const frame = document.querySelector('[data-mobile-nav="frame"]');
305
+ if (frame === null || frame.hasAttribute("data-sidebar-collapsed")) return null;
306
+ if (!(target instanceof Element)) return null;
307
+ const drawer = drawerRoot();
308
+ if (drawer === null || !drawer.contains(target)) return null;
309
+ return navTargetFor(target);
310
+ };
311
+ const isPendingTouchClick = (event) => {
312
+ const capabilities = event.sourceCapabilities;
313
+ if (capabilities?.firesTouchEvents === true) return true;
314
+ return Math.hypot(event.clientX - lastTouchX, event.clientY - lastTouchY) <= 24;
315
+ };
316
+ const onDrawerClick = (event) => {
317
+ if (performance.now() < suppressTouchClickUntil) return;
318
+ if (pendingTouchRow !== null && performance.now() - lastTouchNavAt < 500 && isPendingTouchClick(event)) {
319
+ const target = navigationTarget(event.target);
320
+ const row = target?.closest('[role="treeitem"]');
321
+ if (row !== null && row !== void 0) {
322
+ pendingTouchRow = row;
323
+ navClickArrived = true;
324
+ return;
325
+ }
326
+ }
327
+ if (navigationTarget(event.target) !== null) toggleSidebar();
328
+ };
329
+ const onDrawerPointerUp = (event) => {
330
+ if (event.pointerType !== "touch" && event.pointerType !== "pen") return;
331
+ const target = navigationTarget(event.target);
332
+ if (target === null) return;
333
+ const row = target.closest('[role="treeitem"]');
334
+ if (row !== null) {
335
+ if (row.getAttribute("aria-selected") === "true") {
336
+ suppressTouchClickUntil = performance.now() + 500;
337
+ toggleSidebar();
338
+ } else {
339
+ lastTouchNavAt = performance.now();
340
+ lastTouchX = event.clientX;
341
+ lastTouchY = event.clientY;
342
+ armNav(row);
343
+ }
344
+ return;
345
+ }
346
+ };
347
+ document.addEventListener("click", onDrawerClick, true);
348
+ document.addEventListener("pointerup", onDrawerPointerUp, true);
349
+ return () => {
350
+ disarmNav();
351
+ document.removeEventListener("click", onDrawerClick, true);
352
+ document.removeEventListener("pointerup", onDrawerPointerUp, true);
353
+ };
354
+ }, [mobile, open, toggleSidebar]);
355
+ (0, import_react.useEffect)(() => {
356
+ if (!mobile || !open) return;
357
+ const onOutsideClick = (event) => {
358
+ if (document.querySelector('[aria-modal="true"]') !== null) return;
359
+ const target = event.target;
360
+ if (target === null) return;
361
+ if (target.closest(TOGGLE_SELECTOR) !== null) return;
362
+ if (isOverlayTap(target)) return;
363
+ const frame = document.querySelector('[data-mobile-nav="frame"]');
364
+ if (frame === null || frame.hasAttribute("data-sidebar-collapsed")) return;
365
+ const drawer = document.querySelector(DRAWER_SELECTOR);
366
+ if (drawer !== null && drawer.contains(target)) return;
367
+ toggleSidebar();
368
+ };
369
+ document.addEventListener("click", onOutsideClick, true);
370
+ return () => document.removeEventListener("click", onOutsideClick, true);
371
+ }, [mobile, open, toggleSidebar]);
372
+ if (!mobile) return null;
373
+ return /* @__PURE__ */ React.createElement(React.Fragment, null, open && /* @__PURE__ */ React.createElement("div", { "data-mobile-nav": "backdrop" }), fabVisible && !open && /* @__PURE__ */ React.createElement(
374
+ "button",
375
+ {
376
+ type: "button",
377
+ "data-mobile-nav": "fab",
378
+ "aria-label": t("open"),
379
+ title: t("open"),
380
+ onClick: () => toggleSidebar()
381
+ },
382
+ /* @__PURE__ */ React.createElement(import_dsh_client_ui_primitives2.IconPanelLeftOutline16, { size: 18 })
383
+ ));
384
+ }
385
+
386
+ // client/mobile/MobileDrawerFooter.tsx
387
+ var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives");
388
+ function MobileDrawerFooter({ useSessions, downloadSessionLog, toggleSidebar, t }) {
389
+ const sessionId = useSessions((state) => state.current);
390
+ const openExplorer = () => {
391
+ document.querySelector('[data-mobile-nav="frame"]')?.setAttribute("data-aionui-explorer-open", "");
392
+ toggleSidebar();
393
+ };
394
+ return /* @__PURE__ */ React.createElement("div", { "data-mobile-nav": "drawer-actions" }, /* @__PURE__ */ React.createElement(
395
+ "button",
396
+ {
397
+ type: "button",
398
+ "data-mobile-nav": "explorer",
399
+ "aria-label": t("files"),
400
+ title: t("files"),
401
+ onClick: openExplorer
402
+ },
403
+ /* @__PURE__ */ React.createElement(import_dsh_client_ui_primitives3.IconPanelLeftOutline16, { size: 14 }),
404
+ /* @__PURE__ */ React.createElement("span", null, t("files"))
405
+ ), /* @__PURE__ */ React.createElement(
406
+ "button",
407
+ {
408
+ type: "button",
409
+ "data-mobile-nav": "session-log",
410
+ "aria-label": t("sessionLog"),
411
+ title: t("sessionLog"),
412
+ disabled: sessionId === void 0,
413
+ onClick: () => {
414
+ if (sessionId !== void 0) downloadSessionLog(sessionId);
415
+ }
416
+ },
417
+ /* @__PURE__ */ React.createElement(import_dsh_client_ui_primitives3.IconDownloadOutline16, { size: 14 }),
418
+ /* @__PURE__ */ React.createElement("span", null, t("sessionLog"))
419
+ ));
420
+ }
421
+
422
+ // client/mobile/fileGuard.ts
423
+ var GUARD_MSG = "\u624B\u673A\u4E0A\u65E0\u6CD5\u76F4\u63A5\u6253\u5F00\u7535\u8111\u4E0A\u7684\u6587\u4EF6";
424
+ var WS_LABELS = ["\u6DFB\u52A0\u5DE5\u4F5C\u533A", "\u6DFB\u52A0\u5DE5\u4F5C\u533A\u2026", "Add workspace", "Add workspace\u2026"];
425
+ var COPY_LABEL = "\u590D\u5236";
426
+ function looksLikeFilePath(text) {
427
+ const t = (text ?? "").trim();
428
+ if (t.length < 3 || t.length > 320) return false;
429
+ if (/^(\/|~\/|\.\.?\/|[A-Za-z]:\\)/.test(t)) return true;
430
+ if (/\/[\w.\-]+\.\w{1,12}$/.test(t)) return true;
431
+ if (/[\w.\-]+\/[\w.\-]+\.\w{1,12}/.test(t)) return true;
432
+ return false;
433
+ }
434
+ async function copyText(text) {
435
+ try {
436
+ if (navigator.clipboard?.writeText) {
437
+ await navigator.clipboard.writeText(text);
438
+ return true;
439
+ }
440
+ } catch {
441
+ }
442
+ try {
443
+ const ta = document.createElement("textarea");
444
+ ta.value = text;
445
+ ta.style.position = "fixed";
446
+ ta.style.top = "-9999px";
447
+ ta.style.opacity = "0";
448
+ document.body.appendChild(ta);
449
+ ta.focus();
450
+ ta.select();
451
+ const okCopy = document.execCommand("copy");
452
+ ta.remove();
453
+ return okCopy;
454
+ } catch {
455
+ return false;
456
+ }
457
+ }
458
+ function startFileGuard(readFile) {
459
+ let toastEl = null;
460
+ let toastTimer = null;
461
+ const showToast = (text) => {
462
+ if (toastEl === null) {
463
+ toastEl = document.createElement("div");
464
+ toastEl.setAttribute("data-mobile-nav", "file-guard-toast");
465
+ Object.assign(toastEl.style, {
466
+ position: "fixed",
467
+ left: "50%",
468
+ bottom: "64px",
469
+ transform: "translateX(-50%)",
470
+ maxWidth: "84vw",
471
+ zIndex: "9999",
472
+ padding: "10px 14px",
473
+ borderRadius: "10px",
474
+ background: "rgba(20,22,28,.92)",
475
+ color: "#fff",
476
+ fontSize: "13px",
477
+ lineHeight: "1.4",
478
+ textAlign: "center",
479
+ fontFamily: "inherit",
480
+ boxShadow: "0 4px 16px rgba(0,0,0,.28)",
481
+ pointerEvents: "none",
482
+ opacity: "0",
483
+ transition: "opacity .18s ease"
484
+ });
485
+ document.body.appendChild(toastEl);
486
+ }
487
+ toastEl.textContent = text;
488
+ requestAnimationFrame(() => {
489
+ if (toastEl !== null) toastEl.style.opacity = "1";
490
+ });
491
+ if (toastTimer !== null) window.clearTimeout(toastTimer);
492
+ toastTimer = window.setTimeout(() => {
493
+ if (toastEl !== null) toastEl.style.opacity = "0";
494
+ }, 2600);
495
+ };
496
+ const onClick = (event) => {
497
+ const target = event.target;
498
+ if (target === null) return;
499
+ const el = target.closest("button, a");
500
+ if (el === null) return;
501
+ if (!looksLikeFilePath(el.textContent)) return;
502
+ event.preventDefault();
503
+ event.stopImmediatePropagation();
504
+ showToast(GUARD_MSG);
505
+ };
506
+ document.addEventListener("click", onClick, true);
507
+ const injectCopyButtons = () => {
508
+ const links = document.querySelectorAll("button, a");
509
+ links.forEach((el) => {
510
+ if (el.getAttribute("data-mobile-nav-copy") === "1") return;
511
+ const txt = (el.textContent ?? "").trim();
512
+ if (!looksLikeFilePath(txt)) return;
513
+ el.setAttribute("data-mobile-nav-copy", "1");
514
+ const btn = document.createElement("button");
515
+ btn.type = "button";
516
+ btn.setAttribute("data-mobile-nav", "copy-file");
517
+ btn.textContent = COPY_LABEL;
518
+ btn.addEventListener("click", async (e) => {
519
+ e.preventDefault();
520
+ e.stopPropagation();
521
+ const filePath = (el.textContent ?? "").trim();
522
+ btn.disabled = true;
523
+ btn.textContent = "\u2026";
524
+ try {
525
+ const res = await readFile(filePath);
526
+ if (!res?.ok) {
527
+ showToast(res?.error?.message ?? "\u590D\u5236\u5931\u8D25");
528
+ return;
529
+ }
530
+ const content = res.value?.content ?? "";
531
+ const copied = await copyText(content);
532
+ if (copied) {
533
+ const kb = Math.max(1, Math.round((res.value?.size ?? content.length) / 1024));
534
+ showToast(`\u5DF2\u590D\u5236\u6587\u4EF6\u5185\u5BB9\uFF08${kb} KB\uFF09`);
535
+ } else {
536
+ showToast("\u590D\u5236\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8\u9009\u62E9");
537
+ }
538
+ } catch (err) {
539
+ showToast(err instanceof Error ? err.message : "\u590D\u5236\u5931\u8D25");
540
+ } finally {
541
+ btn.disabled = false;
542
+ btn.textContent = COPY_LABEL;
543
+ }
544
+ });
545
+ el.parentElement?.insertBefore(btn, el.nextSibling);
546
+ });
547
+ };
548
+ injectCopyButtons();
549
+ const copyObserver = new MutationObserver(() => injectCopyButtons());
550
+ copyObserver.observe(document.body, { childList: true, subtree: true });
551
+ const hideWsEntries = () => {
552
+ const checkOne = (node) => {
553
+ if (node.nodeType !== 1) return;
554
+ const el = node;
555
+ const txt = (el.getAttribute("aria-label") ?? el.textContent ?? "").trim();
556
+ if (WS_LABELS.includes(txt)) {
557
+ el.style.display = "none";
558
+ el.setAttribute("data-mobile-nav-hide", "add-workspace");
559
+ }
560
+ };
561
+ const sel = '[role="menuitem"],[role="option"],li,button,a';
562
+ document.querySelectorAll(sel).forEach(checkOne);
563
+ const observer = new MutationObserver((mutations) => {
564
+ for (const m of mutations) {
565
+ m.addedNodes.forEach((n) => {
566
+ if (n.nodeType !== 1) return;
567
+ checkOne(n);
568
+ n.querySelectorAll?.(sel).forEach(checkOne);
569
+ });
570
+ }
571
+ });
572
+ observer.observe(document.body, { childList: true, subtree: true });
573
+ return () => observer.disconnect();
574
+ };
575
+ const disconnectWs = hideWsEntries();
576
+ return () => {
577
+ document.removeEventListener("click", onClick, true);
578
+ copyObserver.disconnect();
579
+ disconnectWs();
580
+ if (toastTimer !== null) window.clearTimeout(toastTimer);
581
+ toastEl?.remove();
582
+ };
583
+ }
584
+
585
+ // client/mobile/mobile.css.ts
586
+ var MOBILE_CSS = `
587
+ /* ---------- base control styles (rendered at any width, hidden where unused) ---------- */
588
+
589
+ [data-mobile-nav="toggle"],
590
+ [data-mobile-nav="files"] {
591
+ display: inline-flex;
592
+ align-items: center;
593
+ justify-content: center;
594
+ width: 28px;
595
+ height: 28px;
596
+ flex: none;
597
+ padding: 0;
598
+ border: none;
599
+ border-radius: 50%;
600
+ background: transparent;
601
+ color: var(--dsw-alias-label-secondary, inherit);
602
+ cursor: pointer;
603
+ -webkit-tap-highlight-color: transparent;
604
+ }
605
+ [data-mobile-nav="toggle"]:hover,
606
+ [data-mobile-nav="files"]:hover {
607
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
608
+ }
609
+ [data-mobile-nav="toggle"]:focus-visible,
610
+ [data-mobile-nav="files"]:focus-visible {
611
+ outline: 2px solid var(--dsw-alias-state-business-primary, #4f6ef7);
612
+ outline-offset: 1px;
613
+ }
614
+
615
+ /* Drawer footer actions: the relocated Session log download plus the Files
616
+ action that opens the dsh-web-ui explorer sheet. */
617
+ [data-mobile-nav="drawer-actions"] {
618
+ display: inline-flex;
619
+ align-items: center;
620
+ gap: 8px;
621
+ }
622
+ /* \u5BBF\u4E3B\u6CA1\u6709 aionui explorer \u5217\uFF08\u5B98\u65B9 DSH \u65E0 dsh-web-ui\uFF0Cissue #48\uFF09\u65F6\u9690\u85CF
623
+ \u79FB\u52A8\u7AEF\u300C\u6587\u4EF6\u6D4F\u89C8\u300D\u5165\u53E3\uFF08header \u56FE\u6807 + drawer footer \u9879\uFF09\u2014\u2014\u4E0D\u7136\u70B9\u4E86\u6CA1\u53CD\u5E94\u3002 */
624
+ [data-mobile-nav-explorer="0"] [data-mobile-nav="files"],
625
+ [data-mobile-nav-explorer="0"] [data-mobile-nav="explorer"] {
626
+ display: none !important;
627
+ }
628
+ [data-mobile-nav="session-log"],
629
+ [data-mobile-nav="explorer"] {
630
+ display: inline-flex;
631
+ align-items: center;
632
+ justify-content: center;
633
+ gap: 6px;
634
+ height: 34px;
635
+ padding: 0 12px;
636
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12));
637
+ border-radius: 12px;
638
+ background: transparent;
639
+ color: var(--dsw-alias-label-primary, inherit);
640
+ font-family: inherit;
641
+ font-size: 13px;
642
+ line-height: 20px;
643
+ cursor: pointer;
644
+ -webkit-tap-highlight-color: transparent;
645
+ }
646
+ [data-mobile-nav="session-log"]:hover:not(:disabled),
647
+ [data-mobile-nav="explorer"]:hover {
648
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
649
+ }
650
+ [data-mobile-nav="session-log"]:disabled {
651
+ color: var(--dsw-alias-label-dimmed, rgba(0, 0, 0, .35));
652
+ cursor: default;
653
+ }
654
+
655
+ /* Floating fallback button (hero / blank phases without a session header).
656
+ The top clears the camera band below the status bar; when the client has
657
+ set viewport-fit=cover the safe-area inset moves it below the notch too. */
658
+ [data-mobile-nav="fab"] {
659
+ position: absolute;
660
+ top: calc(env(safe-area-inset-top, 0px) + 72px);
661
+ left: 10px;
662
+ z-index: 21;
663
+ display: inline-flex;
664
+ align-items: center;
665
+ justify-content: center;
666
+ width: 38px;
667
+ height: 38px;
668
+ padding: 0;
669
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12));
670
+ border-radius: 50%;
671
+ background: var(--dsw-alias-button-floating-fill, #ffffff);
672
+ color: var(--dsw-alias-label-primary, inherit);
673
+ cursor: pointer;
674
+ box-shadow: 0 2px 12px rgba(0, 0, 0, .18);
675
+ -webkit-tap-highlight-color: transparent;
676
+ }
677
+ [data-mobile-nav="fab"]:hover {
678
+ background: var(--dsw-alias-button-floating-hover, rgba(0, 0, 0, .08));
679
+ }
680
+ [data-mobile-nav="fab"]:focus-visible {
681
+ outline: 2px solid var(--dsw-alias-state-business-primary, #4f6ef7);
682
+ outline-offset: 2px;
683
+ }
684
+
685
+ /* Dimmed backdrop under the open drawer; above every column, below the drawer.
686
+ pointer-events: none \u2014\u2014 \u70B9\u51FB\u7A7F\u900F\uFF08issue #38\uFF09\uFF1Abackdrop \u53EA\u8D1F\u8D23\u89C6\u89C9\u538B\u6697\uFF0C
687
+ \u4E0D\u62A2\u70B9\u51FB\u3002\u5173\u95ED\u62BD\u5C49\u6539\u7531 MobileNavOverlay \u7684 document \u7EA7\u300C\u62BD\u5C49\u5916\u70B9\u51FB\u300D\u76D1\u542C\u5904\u7406
688
+ \uFF08\u7B49\u4EF7\u4E8E\u539F\u6765\u7684\u70B9\u51FB\u906E\u7F69\u5173\u95ED\uFF0C\u4E14\u62BD\u5C49\u5185\u70B9\u51FB\u4E0D\u518D\u88AB backdrop \u5403\u6389\uFF09\u3002 */
689
+ [data-mobile-nav="backdrop"] {
690
+ position: absolute;
691
+ inset: 0;
692
+ z-index: 30;
693
+ background: rgba(0, 0, 0, .45);
694
+ pointer-events: none;
695
+ animation: dsh-mobile-nav-fade .2s var(--ds-ease-in-out, ease-in-out);
696
+ -webkit-tap-highlight-color: transparent;
697
+ }
698
+ @keyframes dsh-mobile-nav-fade {
699
+ from { opacity: 0; }
700
+ to { opacity: 1; }
701
+ }
702
+ /* Settings sheet entrance: the official dialog mounts with no animation at
703
+ all, so it snaps in. Fade + slight rise/scale reads as a proper sheet. */
704
+ @keyframes dsh-mobile-nav-sheet-in {
705
+ from {
706
+ opacity: 0;
707
+ transform: translateY(14px) scale(.98);
708
+ }
709
+ to {
710
+ opacity: 1;
711
+ transform: none;
712
+ }
713
+ }
714
+ /* Preview sheet rise: the aionui preview column opens as a bottom sheet. */
715
+ @keyframes dsh-mobile-nav-sheet-up {
716
+ from {
717
+ opacity: 0;
718
+ transform: translateY(28px);
719
+ }
720
+ to {
721
+ opacity: 1;
722
+ transform: none;
723
+ }
724
+ }
725
+
726
+ /* ---------- mobile-only layout ---------- */
727
+
728
+ @media (max-width: 1023px) {
729
+ /* --- Phone chrome ---
730
+ The system status bar stays visible (no fullscreen). Two adjustments
731
+ make it behave:
732
+ - touch-action: manipulation kills double-tap-to-zoom (and the 300ms
733
+ tap delay) while keeping pan and pinch zoom; the client also
734
+ suppresses legacy-iOS gesturestart as a fallback.
735
+ - With the client's viewport-fit=cover, env(safe-area-inset-top) is the
736
+ status bar / notch height; the rules below push the app content below
737
+ it so the status bar never covers anything. Off notched phones (or in
738
+ a normal browser tab where the layout viewport already sits below the
739
+ status bar) the inset is 0 and nothing shifts. */
740
+ html,
741
+ body {
742
+ touch-action: manipulation !important;
743
+ }
744
+
745
+ /* AppFrame: the drawer takes the sidebar column out of grid flow, so the
746
+ remaining in-flow items (center, details) land in tracks 1..2: give the
747
+ center every pixel and keep the details track at zero. The top padding
748
+ clears the status bar / notch for every in-flow surface (session header,
749
+ messages, composer); the absolutely-positioned drawer is unaffected (its
750
+ containing block is the frame's padding box, i.e. still the frame top). */
751
+ [data-mobile-nav="frame"] {
752
+ position: relative !important;
753
+ grid-template-columns: minmax(0, 1fr) 0 0 !important;
754
+ padding-top: env(safe-area-inset-top, 0px) !important;
755
+ }
756
+
757
+ /* \u4E3B\u5185\u5BB9\u5217\uFF08\u7B2C 2 \u4E2A\u7F51\u683C\u5B50\u5143\u7D20\uFF09\u5728\u5B98\u65B9\u6837\u5F0F\u91CC\u6709\u663E\u5F0F grid-column: 2\u2014\u2014
758
+ \u7F51\u683C\u88AB\u538B\u7F29\u6210 [1fr, 0, 0] \u540E\u5B83\u4F1A\u843D\u5728 0px \u7684\u7B2C 2 \u8F68\uFF0C\u6574\u4E2A\u4E3B\u754C\u9762\u88AB\u6324\u51FA
759
+ \u89C6\u53E3\uFF08\u53EA\u5269\u80CC\u666F\u56FE\uFF09\u3002\u5FC5\u987B\u663E\u5F0F\u628A\u5B83\u62C9\u56DE\u7B2C 1 \u8F68\uFF08issue #5\uFF09\u3002
760
+ \u7B2C 3 \u5217\uFF08details\uFF09\u4FDD\u6301 0 \u8F68\u5373\u53EF\uFF0C\u65E0\u9700\u5904\u7406\u3002 */
761
+ [data-mobile-nav="frame"] > :nth-child(2) {
762
+ grid-column: 1 !important;
763
+ grid-row: 1 !important;
764
+ min-width: 0 !important;
765
+ }
766
+
767
+ /* The sidebar column (first grid child) becomes a left drawer. The drawer
768
+ hugs the sidebar content exactly (the wide sidebar carries an inline
769
+ width, ~280px): a fixed 92vw box would leave a white strip where the
770
+ container background shows beside the content.
771
+ Closed state: translateX(-110%) \u2014 more than -100% of the max-content
772
+ width \u2014 guarantees the whole drawer (and its shadow, had it one) leaves
773
+ the viewport. A mere -100% leaves a sliver on screen; -105% (as used
774
+ before) left 14px of the drawer plus a long 32px-blur shadow gradient
775
+ visible along the left edge of the main UI. No box-shadow at all: the
776
+ dimmed backdrop already separates drawer from content.
777
+ Z-index note: the backdrop renders inside the shell's overlay layer
778
+ ([data-shell-overlay]), which forms its own stacking context. Third-party
779
+ plugins can force that layer up with !important (dsh-update-checker sets
780
+ it to 500), and when the layer outranks the drawer, the backdrop paints
781
+ ABOVE the drawer and swallows every tap \u2014 the drawer opens but no row
782
+ can be pressed (every tap just closes it). The drawer must therefore
783
+ outrank any such raise.
784
+ 1200 (was 600) clears the mobile layers shipped by
785
+ @linxin666/dsh-web-ui-all \u2014 its sidebar pane is z-index 1100, its
786
+ details pane 1000 and its full-screen frame ::after mask 1050 (issue
787
+ #67: that mask sat on top of the 600 drawer and ate every tap). Still
788
+ far under the fixed-position banners/toasts (z 9999) that float at the
789
+ viewport level. */
790
+ [data-mobile-nav="frame"] > :first-child {
791
+ position: absolute !important;
792
+ inset: 0 auto 0 0 !important;
793
+ width: max-content !important;
794
+ max-width: 92vw !important;
795
+ z-index: 1200 !important;
796
+ transform: translateX(-110%);
797
+ transition: transform .28s var(--ds-ease-in-out, ease-in-out);
798
+ background: var(--dsw-alias-bg-base, #ffffff);
799
+ /* Keep the drawer's own content below the status bar / notch: the drawer
800
+ spans the full frame height (its absolute containing block is the
801
+ frame's padding box, so the frame's own safe-area padding does NOT
802
+ reach it). The drawer background paints the status-bar strip, which
803
+ the client's theme-color meta matches, so the strip reads seamless. */
804
+ padding-top: env(safe-area-inset-top, 0px) !important;
805
+ /* Kill the official sidebarCol right border: with the backdrop the edge
806
+ reads cleanly, and the settings dialog (width:100% of this box) stays
807
+ pixel-flush with the drawer. */
808
+ border-right: none !important;
809
+ }
810
+
811
+ /* Expanded state (frame without data-sidebar-collapsed) slides the drawer in.
812
+ The open state must be transform:none \u2014 NOT translateX(0): an identity
813
+ transform still makes the drawer the containing block for fixed-position
814
+ descendants (the settings dialog's .VOzbGW_overlay is portaled into the
815
+ sidebar DOM). With the identity transform the wide settings sheet
816
+ (100vw-16) overflows the 280px drawer, the dialog's focus scrolls the
817
+ overflow:hidden drawer to scrollLeft=102, and every static child (plus the
818
+ fixed overlay) shifts 102px off-screen. With transform:none the overlay is
819
+ viewport-anchored: it dims the full screen and the sheet sits at left:8. */
820
+ [data-mobile-nav="frame"]:not([data-sidebar-collapsed]) > :first-child {
821
+ transform: none !important;
822
+ }
823
+
824
+ /* Kill a competing full-screen mask (issue #67).
825
+ @linxin666/dsh-web-ui-all ships its own mobile drawer, and part of it is
826
+
827
+ [data-dsh-frame]:not([data-sidebar-collapsed])::after {
828
+ content: ""; position: fixed; inset: 0; z-index: 1050;
829
+ background: rgb(0 0 0 / 24%);
830
+ }
831
+
832
+ The pseudo-element belongs to the frame we already mark, and the frame
833
+ carries only "position: relative" with z-index auto \u2014 no stacking context
834
+ \u2014 so this mask competes with the drawer in the parent stacking context
835
+ and, at 1050, paints over it. It covers the whole viewport, so every tap
836
+ on a session row lands on the mask instead: the drawer opens but nothing
837
+ inside it can be pressed, and the page behind cannot be scrolled.
838
+ Removing it is safe: the mobile stylesheet already renders its own
839
+ backdrop, and tapping outside the drawer is handled in JS.
840
+
841
+ The attribute selector is repeated on purpose. Their rule has the same
842
+ specificity (0,2,1) once ours is written the obvious way, and plugin
843
+ stylesheets are injected in load order, so a tie would be decided by
844
+ whichever plugin happened to load last. Doubling the attribute makes it
845
+ (0,3,1) and deterministic. */
846
+ [data-mobile-nav="frame"][data-mobile-nav="frame"]:not([data-sidebar-collapsed])::after {
847
+ content: none !important;
848
+ }
849
+
850
+ /* Drag handles are useless on touch and would float over the drawer. */
851
+ [data-side="sidebar"],
852
+ [data-side="details"] {
853
+ display: none !important;
854
+ }
855
+
856
+ /* --- Conversation text on mobile ---
857
+ The official message flow keeps desktop's 32px side gutters and 16px
858
+ type. On a phone: shrink the type a notch and widen the lines by
859
+ trimming the gutters (the sidebar drawer list keeps its size). The
860
+ flow's scroll container is the only _scroll element holding markdown
861
+ <p> paragraphs \u2014 the composer's own scroll (textarea) is excluded
862
+ via :has(p). */
863
+ /* The official main scroll body reserves scrollbar-gutter for desktop
864
+ scrollbars (8px), which shoves every column off-center on a phone.
865
+ Classic desktop scrollbars (Edge/Chrome) also occupy ~8-17px in a
866
+ phone-sized viewport, shifting the column further. Mobile scrolling
867
+ is touch/wheel, so remove the scrollbar entirely on phones: the
868
+ column is then exactly centered in every browser. */
869
+ [data-phase] [class$="_scrollBody"] {
870
+ scrollbar-gutter: auto !important;
871
+ scrollbar-width: none !important;
872
+ }
873
+ [data-phase] [class$="_scrollBody"]::-webkit-scrollbar {
874
+ display: none !important;
875
+ width: 0 !important;
876
+ height: 0 !important;
877
+ }
878
+ /* Message action rows (copy / run-time badges) can overflow the right
879
+ edge on narrow screens \u2014 keep them inside the message width. */
880
+ [data-phase] [class$="_actions"] {
881
+ overflow: hidden !important;
882
+ }
883
+ [data-phase] [class$="_actions"] [class$="_timeEnd"] {
884
+ flex: 0 1 auto !important;
885
+ min-width: 0 !important;
886
+ overflow: hidden !important;
887
+ text-overflow: ellipsis !important;
888
+ white-space: nowrap !important;
889
+ }
890
+
891
+ [data-phase] [class$="_scroll"]:has(p) {
892
+ padding-left: 20px !important;
893
+ padding-right: 20px !important;
894
+ font-size: 15px !important;
895
+ }
896
+ /* The official markdown styles set an explicit 16px on paragraphs and
897
+ list items, so the container's inherited 15px is not enough. User
898
+ messages render their text in a div whose class carries _text_
899
+ (16px too) \u2014 cover it as well. */
900
+ [data-phase] [class$="_scroll"]:has(p) p,
901
+ [data-phase] [class$="_scroll"]:has(p) li,
902
+ [data-phase] [class$="_scroll"]:has(p) [class*="_text_"] {
903
+ font-size: 15px !important;
904
+ }
905
+
906
+ /* Keep DSH's own process disclosures and their expand/collapse behaviour,
907
+ but remove desktop-sized vertical breathing room between consecutive
908
+ context, Skill, and system-prompt entries. */
909
+ [data-phase] [data-turn-process] {
910
+ height: 28px !important;
911
+ padding-bottom: 4px !important;
912
+ margin-bottom: 4px !important;
913
+ }
914
+ [data-phase] [data-turn-process][data-open] {
915
+ margin-bottom: 4px !important;
916
+ }
917
+ [data-phase] [data-disclosure-row] {
918
+ min-height: 24px !important;
919
+ }
920
+ [data-phase] :is([data-context-injection-body], [data-system-prompt-body]) {
921
+ margin-top: 2px !important;
922
+ }
923
+
924
+ /* --- Composer bottom row on mobile ---
925
+ Keep add, permission, model, reasoning and send controls on one line at
926
+ the Honor 50's 360px CSS viewport. DSH's stable data-composer-card hook
927
+ survives the editor's textarea -> contenteditable migration. */
928
+ [data-phase] [data-composer-card="true"] > [class$="_row"] {
929
+ flex-wrap: nowrap !important;
930
+ gap: 6px !important;
931
+ }
932
+ [data-phase] [data-composer-card="true"] > [class$="_row"] > :first-child {
933
+ gap: 8px !important;
934
+ min-width: 0 !important;
935
+ }
936
+ [data-phase] [data-composer-card="true"] > [class$="_row"] > :first-child > :nth-child(2) {
937
+ flex: 0 1 auto !important;
938
+ min-width: 0 !important;
939
+ }
940
+ [data-phase] [data-composer-card="true"] > [class$="_row"] > :last-child {
941
+ flex: 1 1 0 !important;
942
+ min-width: 0 !important;
943
+ gap: 6px !important;
944
+ }
945
+
946
+ /* --- Composer popups as bottom sheets on mobile ---
947
+ Two composer-anchored popups break on phones (field: bottom + side
948
+ cut, only part of the popup visible):
949
+ 1. the model pill menu ([role=menu], max 360px, opens upward from the
950
+ pill inside the composer card);
951
+ 2. the "/" command palette (max 320px card with the search box \u2014 it
952
+ hosts the /model popupSelect list: search + provider-grouped rows).
953
+ Both are position:absolute INSIDE the conversation scrollBody
954
+ (overflow:hidden) and the shell center column (overflow:hidden), so
955
+ the scroll containers clip them mid-list. Forensics showed neither
956
+ layer creates a containing block (no transform/contain/will-change),
957
+ so on mobile we snap whichever popup is open to a viewport-anchored
958
+ sheet: fixed positioning escapes the scroll clip entirely, width is
959
+ deterministic, safe-area keeps it off the gesture bar. A transient
960
+ picker covering the composer is standard mobile UX; selection or an
961
+ outside tap still dismisses it. */
962
+ [data-phase] [class$="_root"]:has(> [aria-haspopup="menu"]) > [role="menu"],
963
+ [data-phase] [class$="_card"]:has(> [class$="_search"]) {
964
+ position: fixed !important;
965
+ left: 12px !important;
966
+ right: 12px !important;
967
+ top: auto !important;
968
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 12px) !important;
969
+ width: auto !important;
970
+ min-width: 0 !important;
971
+ max-width: none !important;
972
+ max-height: min(65vh, 480px) !important;
973
+ max-height: min(65dvh, 480px) !important;
974
+ z-index: 130 !important;
975
+ border-radius: 14px !important;
976
+ box-shadow: 0 -4px 28px rgba(0, 0, 0, .18) !important;
977
+ }
978
+
979
+ /* --- Session header on mobile ---
980
+ Layout goal: [toggle] [session title] [mode badge] in a row, with the
981
+ Session log capsule removed from the header (relocated to the drawer
982
+ footer). Stable structural hooks only:
983
+ [data-phase] header the session header element
984
+ header > :first-child titleRow (titleCluster + utilities)
985
+ header > :first-child > :last-child headerUtilities (Session log seat) */
986
+ [data-phase] header {
987
+ padding: 8px 12px 0 !important;
988
+ }
989
+ /* The directory and Files controls are absolutely positioned, so reserve
990
+ their lanes and let the title use the remaining width without squeezing. */
991
+ [data-phase] header > :first-child {
992
+ min-height: 36px !important;
993
+ padding: 0 32px !important;
994
+ }
995
+ [data-phase] header [class$="_titleCluster"],
996
+ [data-phase] header [class$="_crumbs"] {
997
+ min-width: 0 !important;
998
+ }
999
+ [data-phase] header button[class*="_crumb"] {
1000
+ max-width: calc(100vw - 104px) !important;
1001
+ padding-left: 0 !important;
1002
+ padding-right: 0 !important;
1003
+ }
1004
+ /* The directory toggle sits at the far left of the header (the header
1005
+ is position:relative; the data-slot wrappers are display:contents). */
1006
+ [data-mobile-nav="toggle"] {
1007
+ position: absolute !important;
1008
+ left: 8px !important;
1009
+ top: 12px !important;
1010
+ z-index: 2 !important;
1011
+ }
1012
+ /* The Files action sits at the FAR RIGHT of the header so it reads as a
1013
+ distinct control from the directory toggle on the left (which opens
1014
+ the history sidebar). */
1015
+ [data-mobile-nav="files"] {
1016
+ position: absolute !important;
1017
+ left: auto !important;
1018
+ right: 8px !important;
1019
+ top: 12px !important;
1020
+ z-index: 2 !important;
1021
+ }
1022
+ /* Session log download: gone from the header row on mobile (the utilities
1023
+ seat holds only the session-log-export capsule). */
1024
+ [data-phase] header > :first-child > :last-child {
1025
+ display: none !important;
1026
+ }
1027
+
1028
+ /* --- Settings dialog on mobile ---
1029
+ Desktop: 800px two-column flex (188px nav + content). Mobile: a
1030
+ near-full-width sheet \u2014 nav tabs wrap into rows on top, option rows
1031
+ stay horizontal (title+description left, control right). Structural
1032
+ selectors are scoped to the unique aria-modal dialog; every
1033
+ settings-specific rule is gated with
1034
+ :has(> :first-child > :last-child > button) \u2014 the settings nav tab
1035
+ list holds <button> tabs, so the transient export dialog (the same
1036
+ primitives Modal, header(title+close)+description+body) keeps its
1037
+ official centered card layout. Requires :has() support
1038
+ (Chromium 105+, 2022). */
1039
+ [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])) {
1040
+ position: absolute !important;
1041
+ left: 8px !important;
1042
+ /* Fixed top (no translateY): a transform on the panel combined with the
1043
+ panel overflowing the max-content drawer shifts the fixed overlay's
1044
+ coordinate frame, dragging the whole sidebar content off-screen. The
1045
+ safe-area inset keeps the sheet below the status bar / notch. */
1046
+ top: calc(env(safe-area-inset-top, 0px) + 12px) !important;
1047
+ width: calc(100vw - 16px) !important;
1048
+ max-width: calc(100vw - 16px) !important;
1049
+ /* Height follows the content (no dead space under a short page); it
1050
+ caps at 100dvh-24 (less the safe-area top) and the options area
1051
+ scrolls only then. */
1052
+ height: auto !important;
1053
+ max-height: min(800px, calc(100vh - 24px - env(safe-area-inset-top, 0px))) !important;
1054
+ max-height: min(800px, calc(100dvh - 24px - env(safe-area-inset-top, 0px))) !important;
1055
+ flex-direction: column !important;
1056
+ border-radius: 14px !important;
1057
+ animation: dsh-mobile-nav-sheet-in .22s var(--ds-ease-out, ease-in-out);
1058
+ }
1059
+ /* The settings sheet's dimmed mask fades in with the panel (the mask is
1060
+ the first child of the overlay that directly contains the sheet). */
1061
+ :has(> [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"]))) > :first-child {
1062
+ animation: dsh-mobile-nav-fade .18s var(--ds-ease-out, ease-in-out);
1063
+ }
1064
+ @media (prefers-reduced-motion: reduce) {
1065
+ [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])),
1066
+ :has(> [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"]))) > :first-child {
1067
+ animation: none !important;
1068
+ }
1069
+ }
1070
+ /* The export dialog (not the settings sheet) must never overflow the
1071
+ viewport: the official centered card can be wider than 390px. */
1072
+ [aria-modal="true"]:not(:has(> :first-child > :last-child > button)) {
1073
+ max-width: calc(100vw - 32px) !important;
1074
+ }
1075
+ /* Nav bar: hide the "Settings" caption (redundant on a full-width sheet)
1076
+ and wrap the tab list so every tab is visible \u2014 a horizontal scroll cut
1077
+ the last tab ("Plugins") off with no affordance to scroll. */
1078
+ [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])) > :first-child {
1079
+ width: 100% !important;
1080
+ flex-direction: row !important;
1081
+ align-items: center !important;
1082
+ gap: 6px !important;
1083
+ padding: 10px 12px 8px !important;
1084
+ }
1085
+ [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])) > :first-child > :first-child {
1086
+ display: none !important;
1087
+ }
1088
+ [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])) > :first-child > :last-child {
1089
+ flex-direction: row !important;
1090
+ flex-wrap: wrap !important;
1091
+ width: 100% !important;
1092
+ gap: 6px !important;
1093
+ overflow: visible !important;
1094
+ }
1095
+ /* Content toolbar (Open configuration file + close): spread to the edges
1096
+ instead of clustering right with a dead zone on the left. The toolbar
1097
+ children carry official auto-margins that would defeat space-between,
1098
+ so neutralize them. The close button gets a round tappable base so it
1099
+ reads as its own control, not part of the outline button. */
1100
+ [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])) > :last-child > :first-child {
1101
+ justify-content: space-between !important;
1102
+ align-items: center !important;
1103
+ padding: 0 12px !important;
1104
+ min-height: 40px !important;
1105
+ }
1106
+ [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])) > :last-child > :first-child > * {
1107
+ margin-left: 0 !important;
1108
+ margin-right: 0 !important;
1109
+ }
1110
+ [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])) > :last-child > :first-child > :last-child {
1111
+ width: 32px !important;
1112
+ height: 32px !important;
1113
+ border-radius: 50% !important;
1114
+ display: inline-flex !important;
1115
+ align-items: center !important;
1116
+ justify-content: center !important;
1117
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06)) !important;
1118
+ }
1119
+ /* Appearance mode cards: the official cube row renders three tall
1120
+ vertical cards (~268px) that eat half the sheet. Turn them into a
1121
+ compact horizontal trio (icon + label inline, equal widths).
1122
+ Relies on the official cube-row class name of this version. */
1123
+ [aria-modal="true"] [class$="_cubeRow"] {
1124
+ gap: 6px !important;
1125
+ }
1126
+ [aria-modal="true"] [class$="_cubeRow"] > * {
1127
+ flex: 1 1 0 !important;
1128
+ flex-direction: row !important;
1129
+ align-items: center !important;
1130
+ justify-content: center !important;
1131
+ gap: 6px !important;
1132
+ padding: 10px 8px !important;
1133
+ min-height: 0 !important;
1134
+ }
1135
+ /* Content: the options scroll area gets bottom breathing room so the last
1136
+ row never sits flush against the sheet's rounded corner. */
1137
+ [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])) > :last-child {
1138
+ flex: 1 1 auto !important;
1139
+ min-height: 0 !important;
1140
+ }
1141
+ [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])) > :last-child > :last-child {
1142
+ padding: 0 12px 24px !important;
1143
+ }
1144
+
1145
+ /* ---------- dsh-web-ui family compatibility ----------
1146
+ The linxin666 plugin suite extends the shell frame directly:
1147
+ - aionui-panel appends two trailing grid columns (explorer / preview)
1148
+ plus absolute drag handles to [data-dsh-frame]; its 5-track inline
1149
+ grid is already overridden above, but the handles and columns would
1150
+ still float over the main UI. On mobile the columns leave the grid
1151
+ as floating bottom sheets and keep their own visibility state \u2014
1152
+ the suite's collapse chevron / preview tabs still work, so no
1153
+ feature is lost. The task-board / ssh plugins inject sidebar
1154
+ entries and center-column takeover panels; the entries need
1155
+ spacing and the kanban needs scrollable columns. */
1156
+
1157
+ /* Touch devices: the drag handles are useless \u2014 the floating expand
1158
+ button is the opener. */
1159
+ .aionui-explorer-handle,
1160
+ .aionui-preview-handle {
1161
+ display: none !important;
1162
+ }
1163
+
1164
+ /* Shared base: both columns leave the grid as floating panels. The
1165
+ explorer is gated shut by default (its own persisted expanded state
1166
+ must never cover the mobile UI on load); the header Files action opens
1167
+ it via the frame marker below, and the sheet's own collapse chevron
1168
+ clears it. Preview stays owned by the suite (hidden while no tab is
1169
+ open). The per-column rules below override the geometry. */
1170
+ [data-aionui-explorer-col],
1171
+ [data-aionui-preview-col] {
1172
+ position: fixed !important;
1173
+ z-index: 55 !important;
1174
+ background: var(--aion-bg-base, #ffffff) !important;
1175
+ border-left: none !important;
1176
+ }
1177
+ /* Explorer (file tree) bottom sheet: bottom edge aligned exactly with
1178
+ the composer card's bottom line \u2014 the card sits 36px above the
1179
+ viewport bottom (8px composer padding + the 28px stats strip below
1180
+ the card), so the sheet uses the same 36px bottom offset. */
1181
+ [data-aionui-explorer-col] {
1182
+ visibility: hidden !important;
1183
+ left: 8px !important;
1184
+ right: 8px !important;
1185
+ top: auto !important;
1186
+ bottom: 36px !important;
1187
+ width: auto !important;
1188
+ height: min(55dvh, 460px) !important;
1189
+ max-height: calc(100dvh - 44px) !important;
1190
+ border-radius: 14px !important;
1191
+ overflow: hidden !important;
1192
+ box-shadow: 0 -4px 28px rgba(0, 0, 0, .18) !important;
1193
+ animation: dsh-mobile-nav-sheet-up .24s var(--ds-ease-out, ease-in-out) !important;
1194
+ }
1195
+ /* Preview (file content) bottom sheet. Gated shut by default: the suite
1196
+ persists open preview tabs in localStorage and restores them on load,
1197
+ which would pop the sheet over the fresh UI. The client only sets the
1198
+ frame marker after the user taps a file row in the explorer; the
1199
+ suite's own collapse chevron clears it via the visibility watcher. */
1200
+ [data-aionui-preview-col] {
1201
+ visibility: hidden !important;
1202
+ position: fixed !important;
1203
+ left: 8px !important;
1204
+ right: 8px !important;
1205
+ top: auto !important;
1206
+ bottom: 40px !important;
1207
+ width: auto !important;
1208
+ height: min(50dvh, 420px) !important;
1209
+ max-height: calc(100dvh - 48px) !important;
1210
+ border-radius: 14px !important;
1211
+ overflow: hidden !important;
1212
+ box-shadow: 0 -4px 28px rgba(0, 0, 0, .18) !important;
1213
+ z-index: 56 !important;
1214
+ animation: dsh-mobile-nav-sheet-up .24s var(--ds-ease-out, ease-in-out) !important;
1215
+ }
1216
+ /* User-opened preview sheet (frame marker, set on file-row tap). */
1217
+ [data-mobile-nav="frame"][data-aionui-preview-open] [data-aionui-preview-col] {
1218
+ visibility: visible !important;
1219
+ }
1220
+ /* The Files action opens the explorer sheet (frame marker). */
1221
+ [data-mobile-nav="frame"][data-aionui-explorer-open] [data-aionui-explorer-col] {
1222
+ visibility: visible !important;
1223
+ }
1224
+ /* The open drawer must never sit under a sheet: while the frame is in the
1225
+ narrow-expanded state both sheets yield (later in the file than the
1226
+ open marker rule, so it wins at equal specificity). */
1227
+ [data-mobile-nav="frame"]:not([data-sidebar-collapsed]) [data-aionui-explorer-col],
1228
+ [data-mobile-nav="frame"]:not([data-sidebar-collapsed]) [data-aionui-preview-col] {
1229
+ visibility: hidden !important;
1230
+ }
1231
+ /* The suite's own expand button reads the store state we bypass on
1232
+ mobile \u2014 hide it; the header Files action is the opener. */
1233
+ .aionui-floating-expand {
1234
+ display: none !important;
1235
+ }
1236
+
1237
+ /* dsh-web-ui sidebar entries (task board / ssh) sit flush against each
1238
+ other \u2014 give the injected rows breathing room. */
1239
+ button[data-dsh-taskboard-entry],
1240
+ button[data-dsh-ssh-entry] {
1241
+ margin-bottom: 8px !important;
1242
+ }
1243
+
1244
+ /* Task board: five kanban columns at minmax(0,1fr) crush into ~78px phone
1245
+ strips. Give every column a usable minimum and let the row scroll. */
1246
+ [data-dsh-taskboard-board] > [class$="_columns"] {
1247
+ grid-template-columns: repeat(5, minmax(240px, 1fr)) !important;
1248
+ overflow-x: auto !important;
1249
+ }
1250
+ /* The floating button must not float over a takeover panel (task board /
1251
+ ssh own the center column while active). */
1252
+ html[data-dsh-taskboard-active] [data-mobile-nav="fab"],
1253
+ html[data-dsh-ssh-active] [data-mobile-nav="fab"],
1254
+ html[data-dsh-taskboard-active] [data-mobile-nav="backdrop"],
1255
+ html[data-dsh-ssh-active] [data-mobile-nav="backdrop"] {
1256
+ display: none !important;
1257
+ }
1258
+ /* Board header: let the search field take the slack instead of squeezing
1259
+ the action buttons. */
1260
+ [data-dsh-taskboard-board] > [class$="_boardHeader"] [class$="_search"] {
1261
+ flex: 1 1 auto !important;
1262
+ min-width: 80px !important;
1263
+ }
1264
+
1265
+ /* ---------- dsh-web-ui polish: plugin market search ----------
1266
+ The market tab row (Discover / Themes / Installed + the plugin search
1267
+ box) is a no-wrap flex: at 390px the tabs plus the ~218px search box
1268
+ (~475px total) overflow the ~334px sheet and the search box runs off
1269
+ the right edge of the screen (it also forces a horizontal scrollbar on
1270
+ the sheet's options area). Let the row wrap: the tabs keep the first
1271
+ line and the search box gets its own full-width second line. */
1272
+
1273
+ [aria-modal="true"] [class$="_tabs"] {
1274
+ flex-wrap: wrap !important;
1275
+ row-gap: 8px !important;
1276
+ }
1277
+ [aria-modal="true"] [class$="_searchInline"] {
1278
+ flex: 1 1 100% !important;
1279
+ width: 100% !important;
1280
+ max-width: 100% !important;
1281
+ }
1282
+
1283
+ /* ---------- dsh-usage-stats polish: usage & balance panel ----------
1284
+ The panel's stats row shows three token counters side by side
1285
+ (today / month / total). The counters use tabular nowrap figures whose
1286
+ min-content width overflows the ~336px panel body on a phone: figures
1287
+ clip at the row's edges and the panel grows a horizontal scrollbar.
1288
+ Stack the three counters vertically \u2014 full-width rows, so the figures
1289
+ always fit. */
1290
+
1291
+ [class*="usg_"][class$="_statsRow"] {
1292
+ flex-direction: column !important;
1293
+ }
1294
+ [class*="usg_"][class$="_stat"] {
1295
+ flex: 0 0 auto !important;
1296
+ width: 100% !important;
1297
+ min-width: 0 !important;
1298
+ }
1299
+
1300
+ /* ---------- dsh-web-ui polish: settings sheet ----------
1301
+ The official dialog is a desktop two-column form; on a phone the
1302
+ label/control split leaves a huge dead gap and long descriptions wrap
1303
+ into tall stacks. Stack each row (text above, control full-width) and
1304
+ compact the nav tabs into an even wrap. */
1305
+
1306
+ /* Nav tabs: a stable 3-per-row grid (two clean rows instead of a ragged
1307
+ wrap) with tighter cells. */
1308
+ [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])) > :first-child > :last-child {
1309
+ display: grid !important;
1310
+ grid-template-columns: repeat(3, 1fr) !important;
1311
+ gap: 6px !important;
1312
+ }
1313
+ [aria-modal="true"] [class$="_navCell"] {
1314
+ padding: 6px 8px !important;
1315
+ gap: 6px !important;
1316
+ font-size: 13px !important;
1317
+ justify-content: flex-start !important;
1318
+ }
1319
+ [aria-modal="true"] [class$="_navCell"] svg {
1320
+ width: 14px !important;
1321
+ height: 14px !important;
1322
+ flex: none !important;
1323
+ }
1324
+ /* Setting rows: text on top, control below at full width. */
1325
+ [aria-modal="true"] [class$="_section"] [class$="_row"] {
1326
+ flex-direction: column !important;
1327
+ align-items: stretch !important;
1328
+ gap: 8px !important;
1329
+ }
1330
+ [aria-modal="true"] [class$="_section"] [class$="_row"] > :first-child {
1331
+ width: 100% !important;
1332
+ max-width: none !important;
1333
+ }
1334
+ [aria-modal="true"] [class$="_section"] [class$="_row"] > :last-child {
1335
+ width: 100% !important;
1336
+ max-width: none !important;
1337
+ }
1338
+ /* Appearance mode group: give the cube row a consistent bordered
1339
+ segmented look (the official borders differ per state). */
1340
+ [aria-modal="true"] [class$="_cubeRow"] > * {
1341
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12)) !important;
1342
+ }
1343
+
1344
+ /* ---------- dsh-web-ui polish: explorer sheet ----------
1345
+ The aionui explorer was designed for a desktop side column: compact the
1346
+ header, search box and tree rows so a phone shows more entries, and pad
1347
+ the scroll bottom so the last row never sits flush on the edge. */
1348
+
1349
+ [data-aionui-explorer-col] [class$="_tabBar"] {
1350
+ height: 36px !important;
1351
+ }
1352
+ [data-aionui-explorer-col] [class$="_tabBtn"],
1353
+ [data-aionui-explorer-col] [class$="_tabBtnActive"] {
1354
+ padding: 0 12px !important;
1355
+ font-size: 13px !important;
1356
+ }
1357
+ [data-aionui-explorer-col] [class$="_searchBox"] {
1358
+ height: 32px !important;
1359
+ font-size: 13px !important;
1360
+ }
1361
+ [data-aionui-explorer-col] [class$="_treeRow"] {
1362
+ height: 30px !important;
1363
+ font-size: 13px !important;
1364
+ }
1365
+ [data-aionui-explorer-col] [class$="_treeRow"] svg {
1366
+ width: 14px !important;
1367
+ height: 14px !important;
1368
+ }
1369
+ [data-aionui-explorer-col] [class$="_scrollArea"] {
1370
+ padding-bottom: 28px !important;
1371
+ }
1372
+
1373
+ /* ---------- dsh-web-ui polish: drawer footer ----------
1374
+ The injected footer actions (Files + Session log) become two equal pill
1375
+ buttons instead of text-width capsules. */
1376
+
1377
+ /* The official footerActions row also hosts the remote-web-ui entry
1378
+ row (two icon buttons); without wrapping the two groups squeeze each
1379
+ other on one line. Wrap so each group gets its own full-width row. */
1380
+ [data-mobile-nav="frame"] [class$="_footerActions"] {
1381
+ flex-wrap: wrap !important;
1382
+ gap: 6px !important;
1383
+ }
1384
+ [data-mobile-nav="drawer-actions"] {
1385
+ width: 100% !important;
1386
+ }
1387
+ [data-mobile-nav="drawer-actions"] > button {
1388
+ flex: 1 1 0 !important;
1389
+ padding: 0 8px !important;
1390
+ white-space: nowrap !important;
1391
+ }
1392
+
1393
+ /* ---------- dsh-web-ui polish: floating pet ----------
1394
+ The whale-girl pet (dsh-pet) floats at the viewport corner with a
1395
+ persisted, draggable position. On phones the pet is scaled down so
1396
+ it does not dominate the screen; the plugin's own drag + persist
1397
+ still work (the position itself is left alone \u2014 the mobile default
1398
+ position is seeded via the pet API to just above the composer). */
1399
+
1400
+ body > [class$="_float"]:has([class$="_sprite"][role="button"]) {
1401
+ transform: scale(.66);
1402
+ transform-origin: bottom right;
1403
+ }
1404
+ /* While a modal dialog (settings sheet / export) owns the screen the pet
1405
+ floats ABOVE it and covers the dialog content; modal semantics say the
1406
+ background is inert, so hide the pet for the modal's lifetime. */
1407
+ body:has([aria-modal="true"]) > [class$="_float"]:has([class$="_sprite"][role="button"]) {
1408
+ display: none !important;
1409
+ }
1410
+
1411
+ /* ---------- dsh-web-ui polish: conversation stats line ----------
1412
+ The official session-status row (turns / steps / LLM time / TTFT /
1413
+ cache) is long. The client marks the exact row with
1414
+ [data-mobile-nav="stats"] (text-anchored, hashed classes can't be
1415
+ targeted). Layout: ONE fixed-height (28px) flex strip that scrolls
1416
+ horizontally \u2014 the full metrics stream stays reachable by swiping,
1417
+ the row never grows vertically, no ellipsis or fade, 12px gaps
1418
+ between metric groups, a 2px scrollbar as the swipe affordance. */
1419
+
1420
+ [data-mobile-nav="stats"] {
1421
+ display: flex !important;
1422
+ flex-flow: row nowrap !important;
1423
+ align-items: center !important;
1424
+ width: 100% !important;
1425
+ max-width: 100% !important;
1426
+ min-width: 0 !important;
1427
+ height: 28px !important;
1428
+ min-height: 28px !important;
1429
+ max-height: 28px !important;
1430
+ box-sizing: border-box !important;
1431
+ white-space: nowrap !important;
1432
+ overflow-x: auto !important;
1433
+ overflow-y: hidden !important;
1434
+ -webkit-overflow-scrolling: touch;
1435
+ overscroll-behavior-x: contain;
1436
+ scrollbar-width: thin !important;
1437
+ scrollbar-color: var(--dsw-alias-border-l1, rgba(0, 0, 0, .28)) transparent !important;
1438
+ padding: 0 0 4px !important;
1439
+ line-height: 20px !important;
1440
+ font-size: 12px !important;
1441
+ }
1442
+ [data-mobile-nav="stats"]::-webkit-scrollbar {
1443
+ height: 2px !important;
1444
+ }
1445
+ [data-mobile-nav="stats"]::-webkit-scrollbar-thumb {
1446
+ background: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, .3)) !important;
1447
+ border-radius: 2px !important;
1448
+ }
1449
+ [data-mobile-nav="stats"]::-webkit-scrollbar-track {
1450
+ background: transparent !important;
1451
+ }
1452
+ [data-mobile-nav="stats"] > * {
1453
+ display: flex !important;
1454
+ flex: 0 0 auto !important;
1455
+ flex-flow: row nowrap !important;
1456
+ align-items: center !important;
1457
+ width: max-content !important;
1458
+ min-width: max-content !important;
1459
+ max-width: none !important;
1460
+ white-space: nowrap !important;
1461
+ margin-right: 12px !important;
1462
+ padding: 0 !important;
1463
+ }
1464
+ [data-mobile-nav="stats"] > *:last-child {
1465
+ margin-right: 0 !important;
1466
+ }
1467
+ [data-mobile-nav="stats"] * {
1468
+ white-space: nowrap !important;
1469
+ }
1470
+
1471
+ /* ---------- hero composer on mobile ----------
1472
+ The official hero card carries a 2-line textarea plus a tall tool row,
1473
+ which reads oversized on a phone. Tighten the empty-state rhythm: keep
1474
+ the official centered hero, shrink the textarea line box, slim the card
1475
+ padding and the tool row, and close the gap under the headline. */
1476
+
1477
+ [data-phase="hero"] [class$="_card"]:has(textarea) {
1478
+ padding-top: 6px !important;
1479
+ gap: 8px !important;
1480
+ }
1481
+ /* The official composer autosizes the textarea and writes an inline
1482
+ height (2 lines on the hero empty state) on the textarea's scroll/grow
1483
+ wrappers. :placeholder-shown lets us collapse the EMPTY state to one
1484
+ line with !important; as soon as the user types, the pseudo-class no
1485
+ longer matches and the autosizer's inline height takes over again \u2014 so
1486
+ multi-line growth keeps working. */
1487
+ [data-phase="hero"] textarea:placeholder-shown {
1488
+ height: 28px !important;
1489
+ }
1490
+ [data-phase="hero"] [class$="_card"]:has(textarea:placeholder-shown) > [class$="_scroll"],
1491
+ [data-phase="hero"] [class$="_card"]:has(textarea:placeholder-shown) [class$="_grow"] {
1492
+ height: 28px !important;
1493
+ }
1494
+ [data-phase="hero"] [class$="_card"]:has(textarea) > [class$="_row"] {
1495
+ padding-top: 2px !important;
1496
+ }
1497
+ [data-phase="hero"] [class$="_headline"] {
1498
+ line-height: 1.15 !important;
1499
+ margin-bottom: 0 !important;
1500
+ }
1501
+ [data-phase="hero"] [class$="_stack"] {
1502
+ gap: 0 !important;
1503
+ }
1504
+
1505
+ /* ---------- \u9690\u85CF\u300C\u6DFB\u52A0\u5DE5\u4F5C\u533A\u300D\u5165\u53E3\uFF08\u624B\u673A\u4E0A\u914D\u5DE5\u4F5C\u533A\u65E0\u610F\u4E49\uFF0Cissue #17 \u4FEE\u6B63\uFF09 ----------
1506
+ \u56FE\u6807\u6309\u94AE\u7684 aria-label \u968F\u8BED\u8A00\u53D8\u5316\uFF08zh\u300C\u6DFB\u52A0\u5DE5\u4F5C\u533A\u300D/ en\u300CAdd workspace\u300D\uFF09\uFF0C
1507
+ \u4E24\u79CD\u90FD\u8986\u76D6\uFF1B\u4E0B\u62C9\u83DC\u5355\u91CC\u7684\u300C\u6DFB\u52A0\u5DE5\u4F5C\u533A\u2026\u300D\u9879\u7531 fileGuard.ts \u7684 MutationObserver
1508
+ \u6309\u6587\u6848\u515C\u5E95\u9690\u85CF\uFF08CSS \u9009\u4E0D\u5230\u7EAF\u6587\u672C\u8282\u70B9\uFF09\u3002\u53EA\u5728\u7A84\u5C4F\u751F\u6548\u2014\u2014\u684C\u9762\u7AEF\u7167\u5E38\u4FDD\u7559\u3002 */
1509
+ button[aria-label="\u6DFB\u52A0\u5DE5\u4F5C\u533A"],
1510
+ button[aria-label="\u6DFB\u52A0\u5DE5\u4F5C\u533A\u2026"],
1511
+ button[aria-label="Add workspace"],
1512
+ button[aria-label="Add workspace\u2026"] {
1513
+ display: none !important;
1514
+ }
1515
+
1516
+ /* ---------- \u6587\u4EF6\u94FE\u63A5\u65C1\u7684\u300C\u590D\u5236\u300D\u6309\u94AE\uFF08issue #17\uFF1A\u590D\u5236\u6587\u4EF6\u5185\u5BB9\uFF09 ----------
1517
+ \u6302\u5728\u5BF9\u8BDD\u91CC\u7684\u6587\u4EF6\u94FE\u63A5\uFF08<button>/<a>\uFF0C\u6587\u6848\u5373\u8DEF\u5F84\uFF09\u7D27\u90BB\u4F4D\u7F6E\uFF0C\u7531 fileGuard.ts
1518
+ \u6CE8\u5165\u3002\u53EA\u5C4F\u5185\u53EF\u89C1\uFF1A\u684C\u9762\u7AEF\u4E0D\u6CE8\u5165\u3001\u4E0D\u663E\u793A\uFF1B\u8FD9\u91CC\u518D\u515C\u5E95\u4E00\u5C42\uFF0C\u907F\u514D\u4EFB\u4F55\u9057\u6F0F\u3002
1519
+ \u6587\u4EF6\u94FE\u63A5\u591A\u4E3A inline\uFF0C\u6309\u94AE\u7528 inline-flex \u7D27\u8DDF\u5176\u540E\u5373\u53EF\u3002 */
1520
+ [data-mobile-nav="copy-file"] {
1521
+ display: inline-flex !important;
1522
+ align-items: center;
1523
+ justify-content: center;
1524
+ margin-left: 6px !important;
1525
+ vertical-align: baseline !important;
1526
+ height: 22px !important;
1527
+ padding: 0 8px !important;
1528
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .14)) !important;
1529
+ border-radius: 6px !important;
1530
+ background: var(--dsw-alias-bg-layer-1, #fff) !important;
1531
+ color: var(--dsw-alias-label-primary, inherit) !important;
1532
+ font-family: inherit !important;
1533
+ font-size: 11px !important;
1534
+ line-height: 1 !important;
1535
+ cursor: pointer !important;
1536
+ -webkit-tap-highlight-color: transparent !important;
1537
+ box-shadow: 0 1px 3px rgba(0, 0, 0, .12) !important;
1538
+ }
1539
+ [data-mobile-nav="copy-file"]:active {
1540
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06)) !important;
1541
+ }
1542
+ [data-mobile-nav="copy-file"][disabled] {
1543
+ opacity: .55 !important;
1544
+ cursor: default !important;
1545
+ }
1546
+ }
1547
+
1548
+ /* ---------- mobile: stop iOS Safari forced zoom on input focus ----------
1549
+ * Inputs are rendered with inline fontSize 13-14px, below the 16px threshold
1550
+ * that makes iOS Safari zoom the whole page on focus (and never recover).
1551
+ * Force the safe 16px minimum on narrow viewports only, so desktop keeps its
1552
+ * tighter metrics. !important is required to beat the inline styles. */
1553
+ @media (max-width: 1024px) {
1554
+ input,
1555
+ textarea,
1556
+ [contenteditable="true"] {
1557
+ font-size: 16px !important;
1558
+ }
1559
+ }
1560
+
1561
+ /* ---------- desktop: the mobile controls must never appear ---------- */
1562
+
1563
+ @media (min-width: 1024px) {
1564
+ [data-mobile-nav="toggle"],
1565
+ [data-mobile-nav="files"],
1566
+ [data-mobile-nav="fab"],
1567
+ [data-mobile-nav="backdrop"],
1568
+ [data-mobile-nav="session-log"],
1569
+ [data-mobile-nav="explorer"],
1570
+ [data-mobile-nav="drawer-actions"] {
1571
+ display: none !important;
1572
+ }
1573
+ }
1574
+
1575
+ `;
1576
+
1577
+ // client/mobile/locales.ts
1578
+ var NS = "mobileNav";
1579
+ var zh = {
1580
+ "open": "\u6253\u5F00\u76EE\u5F55",
1581
+ "close": "\u6536\u8D77\u76EE\u5F55",
1582
+ "backdrop": "\u70B9\u51FB\u5173\u95ED\u76EE\u5F55",
1583
+ "sessionLog": "\u5BFC\u51FA\u4F1A\u8BDD\u65E5\u5FD7",
1584
+ "files": "\u6587\u4EF6\u6D4F\u89C8"
1585
+ };
1586
+ var en = {
1587
+ "open": "Open directory",
1588
+ "close": "Close directory",
1589
+ "backdrop": "Click to close directory",
1590
+ "sessionLog": "Session log",
1591
+ "files": "Files"
1592
+ };
1593
+
1594
+ // client/mobile/layout-mode.mjs
1595
+ function resolveLayout({ urlValue, stored, narrowMatch }) {
1596
+ const url = String(urlValue ?? "").trim();
1597
+ if (url === "desktop") return "desktop";
1598
+ if (url === "mobile") return "mobile";
1599
+ if (stored === "desktop" || stored === "mobile") return stored;
1600
+ return narrowMatch ? "mobile" : "desktop";
1601
+ }
1602
+ function persistLayoutFromUrl(urlValue) {
1603
+ if (typeof localStorage === "undefined") return "";
1604
+ const v = String(urlValue ?? "").trim();
1605
+ try {
1606
+ if (v === "desktop" || v === "mobile") localStorage.setItem("dsh-pocket.layout", v);
1607
+ else if (v === "auto" || v === "") localStorage.removeItem("dsh-pocket.layout");
1608
+ } catch {
1609
+ }
1610
+ try {
1611
+ const s = localStorage.getItem("dsh-pocket.layout");
1612
+ return s === "desktop" || s === "mobile" ? s : "";
1613
+ } catch {
1614
+ return "";
1615
+ }
1616
+ }
1617
+
1618
+ // client/mobile/mobile-apply.tsx
1619
+ function mobileApply(ctx) {
1620
+ const urlValue = new URL(window.location.href).searchParams.get("dsh-layout") ?? "";
1621
+ const narrowMQ = window.matchMedia("(max-width: 1023px)");
1622
+ const stored = persistLayoutFromUrl(urlValue);
1623
+ const layout = resolveLayout({ urlValue, stored, narrowMatch: narrowMQ.matches });
1624
+ document.body?.setAttribute("data-dsh-pocket-layout", layout);
1625
+ if (layout === "desktop") return;
1626
+ let narrow = narrowMQ;
1627
+ if (layout === "mobile") {
1628
+ narrow = { matches: true, addEventListener: () => {
1629
+ }, removeEventListener: () => {
1630
+ } };
1631
+ }
1632
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-mobile-nav: dictionaries");
1633
+ ctx.effect(() => {
1634
+ const tag = document.createElement("style");
1635
+ tag.dataset.plugin = "@dsh-external/dsh-mobile-nav";
1636
+ tag.dataset.pluginCss = "@dsh-external/dsh-mobile-nav/mobile.css";
1637
+ tag.textContent = MOBILE_CSS;
1638
+ document.head.appendChild(tag);
1639
+ return () => {
1640
+ tag.remove();
1641
+ };
1642
+ }, "dsh-mobile-nav: styles");
1643
+ ctx.effect(() => {
1644
+ const viewport = document.querySelector('meta[name="viewport"]');
1645
+ const originalViewport = viewport?.content ?? "";
1646
+ const themeMeta = document.createElement("meta");
1647
+ themeMeta.name = "theme-color";
1648
+ const bodyBg = () => getComputedStyle(document.body).backgroundColor;
1649
+ const sync = () => {
1650
+ if (viewport !== null) viewport.content = "width=device-width, initial-scale=1, viewport-fit=cover";
1651
+ themeMeta.content = bodyBg();
1652
+ if (themeMeta.parentElement === null) document.head.appendChild(themeMeta);
1653
+ };
1654
+ const restore = () => {
1655
+ if (viewport !== null) viewport.content = originalViewport;
1656
+ themeMeta.remove();
1657
+ };
1658
+ const onGestureStart = (event) => event.preventDefault();
1659
+ if (narrow.matches) sync();
1660
+ const onChange = (event) => event.matches ? sync() : restore();
1661
+ narrow.addEventListener("change", onChange);
1662
+ const observer = new MutationObserver(() => {
1663
+ if (narrow.matches) themeMeta.content = bodyBg();
1664
+ });
1665
+ observer.observe(document.body, { attributes: true, attributeFilter: ["data-ds-dark-theme"] });
1666
+ document.addEventListener("gesturestart", onGestureStart);
1667
+ return () => {
1668
+ narrow.removeEventListener("change", onChange);
1669
+ observer.disconnect();
1670
+ document.removeEventListener("gesturestart", onGestureStart);
1671
+ restore();
1672
+ };
1673
+ }, "dsh-mobile-nav: status bar theme + viewport + zoom guard");
1674
+ ctx.effect(() => {
1675
+ if (!narrow.matches) return () => {
1676
+ };
1677
+ const onChevronClick = (event) => {
1678
+ const target = event.target;
1679
+ if (target === null || !target.closest(".aionui-collapse-chevron")) return;
1680
+ document.querySelector('[data-mobile-nav="frame"]')?.removeAttribute("data-aionui-explorer-open");
1681
+ };
1682
+ document.addEventListener("click", onChevronClick, true);
1683
+ return () => document.removeEventListener("click", onChevronClick, true);
1684
+ }, "dsh-mobile-nav: aionui explorer close marker");
1685
+ ctx.effect(() => {
1686
+ if (!narrow.matches) return () => {
1687
+ };
1688
+ const frame = () => document.querySelector('[data-mobile-nav="frame"]');
1689
+ const check = () => {
1690
+ const has = document.querySelector("[data-aionui-explorer-col]") !== null;
1691
+ frame()?.setAttribute("data-mobile-nav-explorer", has ? "1" : "0");
1692
+ };
1693
+ check();
1694
+ const timer = window.setTimeout(check, 1500);
1695
+ const observer = new MutationObserver(check);
1696
+ observer.observe(document.body, { childList: true, subtree: true });
1697
+ return () => {
1698
+ window.clearTimeout(timer);
1699
+ observer.disconnect();
1700
+ };
1701
+ }, "dsh-mobile-nav: explorer availability (issue #48)");
1702
+ ctx.effect(() => {
1703
+ if (!narrow.matches) return () => {
1704
+ };
1705
+ const frame = () => document.querySelector('[data-mobile-nav="frame"]');
1706
+ const onTap = (event) => {
1707
+ const target = event.target;
1708
+ if (target === null) return;
1709
+ if (target.closest('[data-aionui-explorer-col] [class$="_treeRow"]') === null) return;
1710
+ frame()?.setAttribute("data-aionui-preview-open", "");
1711
+ };
1712
+ const sync = () => {
1713
+ const pv = document.querySelector("[data-aionui-preview-col]");
1714
+ if (pv === null) return;
1715
+ if (getComputedStyle(pv).visibility === "hidden") frame()?.removeAttribute("data-aionui-preview-open");
1716
+ };
1717
+ document.addEventListener("click", onTap, true);
1718
+ const observer = new MutationObserver(sync);
1719
+ observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ["style"] });
1720
+ sync();
1721
+ return () => {
1722
+ document.removeEventListener("click", onTap, true);
1723
+ observer.disconnect();
1724
+ };
1725
+ }, "dsh-mobile-nav: preview sheet open marker");
1726
+ ctx.effect(() => {
1727
+ if (!narrow.matches) return () => {
1728
+ };
1729
+ const moveTps = (stats) => {
1730
+ if ([...stats.children].some((c) => /^TPS\s+\d/.test((c.textContent ?? "").trim()))) return;
1731
+ const stack = stats.closest('[class$="_composerStack"]');
1732
+ if (stack === null) return;
1733
+ for (const el of stack.querySelectorAll("div")) {
1734
+ const text = (el.textContent ?? "").trim();
1735
+ if (!/^TPS\s+\d/.test(text)) continue;
1736
+ if (el.children.length > 0) continue;
1737
+ stats.appendChild(el);
1738
+ return;
1739
+ }
1740
+ };
1741
+ const mark = () => {
1742
+ const selector = '[data-phase] [data-slot="conversation.composer.dock"] [class$="_root"]';
1743
+ for (const root of document.querySelectorAll(selector)) {
1744
+ const text = root.textContent ?? "";
1745
+ if (!/(turns|steps|\bLLM\b|轮|步)/.test(text)) continue;
1746
+ root.setAttribute("data-mobile-nav", "stats");
1747
+ moveTps(root);
1748
+ return;
1749
+ }
1750
+ };
1751
+ const observer = new MutationObserver(mark);
1752
+ observer.observe(document.body, { childList: true, subtree: true });
1753
+ mark();
1754
+ return () => {
1755
+ observer.disconnect();
1756
+ };
1757
+ }, "dsh-mobile-nav: stats line marker");
1758
+ ctx.effect(() => {
1759
+ if (!narrow.matches) return () => {
1760
+ };
1761
+ const cols = ["[data-aionui-explorer-col]", "[data-aionui-preview-col]"];
1762
+ const seen = /* @__PURE__ */ new Map();
1763
+ const play = (el) => {
1764
+ el.animate(
1765
+ [
1766
+ { opacity: 0, transform: "translateY(28px)" },
1767
+ { opacity: 1, transform: "none" }
1768
+ ],
1769
+ { duration: 280, easing: "cubic-bezier(.16, 1, .3, 1)", fill: "backwards" }
1770
+ );
1771
+ };
1772
+ const check = () => {
1773
+ for (const sel of cols) {
1774
+ const el = document.querySelector(sel);
1775
+ if (el === null) continue;
1776
+ const visible = getComputedStyle(el).visibility === "visible";
1777
+ const prev = seen.get(sel) ?? false;
1778
+ if (visible && !prev) play(el);
1779
+ seen.set(sel, visible);
1780
+ }
1781
+ };
1782
+ const observer = new MutationObserver(check);
1783
+ observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ["style", "class", "data-aionui-explorer-open"] });
1784
+ check();
1785
+ return () => {
1786
+ observer.disconnect();
1787
+ };
1788
+ }, "dsh-mobile-nav: sheet rise animation replay");
1789
+ ctx.effect(() => {
1790
+ if (!narrow.matches) return () => {
1791
+ };
1792
+ const getWorkspaceCwd = () => {
1793
+ try {
1794
+ const ws = ctx.get?.("workspaces") ?? ctx.workspaces;
1795
+ const list = ws?.list;
1796
+ const arr = Array.isArray(list) ? list : list && typeof list === "object" && "value" in list ? list.value : null;
1797
+ if (Array.isArray(arr)) {
1798
+ for (const w of arr) {
1799
+ const c = w?.cwd ?? w?.root;
1800
+ if (typeof c === "string" && c) return c;
1801
+ }
1802
+ }
1803
+ } catch {
1804
+ }
1805
+ return "";
1806
+ };
1807
+ const readFile = (filePath) => ctx.connection.rpc.call(
1808
+ POCKET_RPC_CHANNEL,
1809
+ POCKET_ENDPOINTS.fileRead,
1810
+ { path: filePath, cwd: getWorkspaceCwd() }
1811
+ );
1812
+ return startFileGuard(readFile);
1813
+ }, "dsh-mobile-nav: file open guard + copy button + hide add-workspace (issue #17)");
1814
+ ctx.effect(() => {
1815
+ if (!narrow.matches) return () => {
1816
+ };
1817
+ const PHRASES = ["\u52A0\u8F7D\u63D0\u4F9B\u65B9\u76EE\u5F55\u5931\u8D25", "Settings are unavailable in this browser"];
1818
+ const NOTICE = "\u624B\u673A\u4E0A\u4E0D\u652F\u6301\u6A21\u578B\u8BBE\u7F6E\uFF0C\u8BF7\u53BB\u7535\u8111\u7AEF\u4FEE\u6539\u8BBE\u7F6E";
1819
+ const findDeepest = (el) => {
1820
+ let deepest = el;
1821
+ for (const child of el.querySelectorAll("*")) {
1822
+ if (PHRASES.some((p) => (child.textContent ?? "").includes(p))) deepest = child;
1823
+ }
1824
+ return deepest;
1825
+ };
1826
+ const patch = () => {
1827
+ for (const el of document.querySelectorAll("body *")) {
1828
+ const t = el.textContent ?? "";
1829
+ if (!PHRASES.some((p) => t.includes(p))) continue;
1830
+ if (el.dataset?.dshpModelNotice === "1") continue;
1831
+ const target = findDeepest(el);
1832
+ target.textContent = NOTICE;
1833
+ target.dataset.dshpModelNotice = "1";
1834
+ }
1835
+ };
1836
+ const observer = new MutationObserver(patch);
1837
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
1838
+ patch();
1839
+ return () => observer.disconnect();
1840
+ }, "dsh-mobile-nav: replace model-settings load error with mobile hint");
1841
+ ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
1842
+ name: "conversation.session.header.actions",
1843
+ id: "mobile-nav-toggle",
1844
+ order: 10,
1845
+ locale: NS,
1846
+ inject: () => ({
1847
+ toggleSidebar: () => ctx.layout.toggleSidebar()
1848
+ })
1849
+ }, MobileNavToggle));
1850
+ ctx.slots.inject("shell.overlay", () => ctx.slots.register({
1851
+ name: "shell.overlay",
1852
+ id: "mobile-nav-overlay",
1853
+ order: 10,
1854
+ locale: NS,
1855
+ inject: () => ({
1856
+ toggleSidebar: () => ctx.layout.toggleSidebar()
1857
+ })
1858
+ }, MobileNavOverlay));
1859
+ ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
1860
+ name: "sidebar.footer.action",
1861
+ id: "mobile-nav-session-log",
1862
+ order: 10,
1863
+ locale: NS,
1864
+ inject: () => ({
1865
+ downloadSessionLog: (sessionId) => ctx.sessionLogDownload.download(sessionId),
1866
+ toggleSidebar: () => ctx.layout.toggleSidebar()
1867
+ })
1868
+ }, MobileDrawerFooter));
1869
+ }
1870
+
1871
+ // client/pocket-locales.js
1872
+ var NS2 = "pocket";
1873
+ var zh2 = {
1874
+ "section": "\u624B\u673A\u8BBF\u95EE",
1875
+ "title": "\u{1F4F1} \u624B\u673A\u8BBF\u95EE",
1876
+ "subtitle": "\u624B\u673A\u626B\u7801\u6253\u5F00\u7684\u5C31\u662F\u7535\u8111\u4E0A\u7684\u8FD9\u4E2A\u754C\u9762\uFF0C\u5B9E\u65F6\u540C\u6B65",
1877
+ "developer": "\u57FA\u4E8E dsh-pocket \u4E8C\u6B21\u5F00\u53D1\uFF08GPL-2.0\uFF09",
1878
+ "starAsk": "\u2B50 \u987A\u624B\u7559\u9897 Star\uFF0C\u4F5C\u8005\u80FD\u9AD8\u5174\u4E00\u6574\u5929",
1879
+ "starCta": "\u884C\uFF0C\u7ED9\u4F60\u4E00\u9897 Star",
1880
+ "restarted": "\u{1F504} \u5DF2\u91CD\u542F",
1881
+ "ok": "\u77E5\u9053\u4E86",
1882
+ "bgHint": "\u8FDB\u7A0B\u5728\u540E\u53F0\u8FD0\u884C\uFF08\u4E0D\u6302\u7EC8\u7AEF\uFF09\u3002\u5982\u9700\u505C\u6B62\uFF1A{cmd}",
1883
+ "updatedRestart": "\u2705 \u5DF2\u66F4\u65B0 v{ver}\uFF0C\u91CD\u542F\u751F\u6548",
1884
+ "updateAutoRestarting": "\u2705 \u5DF2\u66F4\u65B0 v{ver}\uFF0C\u6B63\u5728\u81EA\u52A8\u91CD\u542F\u2026",
1885
+ "updatedOk": "\u2705 \u5DF2\u66F4\u65B0 v{ver}",
1886
+ "updateAvailable": "\u{1F4E6} \u65B0\u7248\u672C v{ver}",
1887
+ "updating": "\u66F4\u65B0\u4E2D\u2026",
1888
+ "updateTo": "\u66F4\u65B0\u5230 v{ver}",
1889
+ "restartingNow": "\u6B63\u5728\u91CD\u542F\u751F\u6548\u2026",
1890
+ "restarting": "\u91CD\u542F\u4E2D\u2026",
1891
+ "restartNow": "\u{1F504} \u91CD\u542F dsh web \u751F\u6548",
1892
+ "updatingDetail": "\u23F3 \u66F4\u65B0\u4E2D\uFF08\u901A\u5E38 1-2 \u5206\u949F\uFF09\xB7 \u5DF2\u7B49\u5F85 {s} \u79D2",
1893
+ "restartingDetail": "\u23F3 \u6B63\u5728\u91CD\u542F\u751F\u6548\uFF08\u901A\u5E38 10-30 \u79D2\uFF09\xB7 \u5DF2\u7B49\u5F85 {s} \u79D2",
1894
+ "updatedAutoDetail": "\u2705 \u5DF2\u66F4\u65B0\uFF0C\u6B63\u5728\u81EA\u52A8\u91CD\u542F\u751F\u6548\uFF0C\u8BF7\u7A0D\u5019\u5237\u65B0",
1895
+ "updatedRestartDetail": "\u2705 \u5DF2\u66F4\u65B0\uFF0C\u91CD\u542F dsh web \u751F\u6548",
1896
+ "updateFailed": "\u274C \u5931\u8D25\uFF1A{err}\uFF08\u624B\u52A8\u66F4\u65B0\uFF1Adsh plugin --profile web update dsh-pocket-relay --latest -w\uFF09",
1897
+ "versionRange": "\u5F53\u524D v{cur} \u2192 \u6700\u65B0 v{latest}",
1898
+ "wanAccess": "\u516C\u7F51\u8BBF\u95EE",
1899
+ "pinLabel": "\u8BBF\u95EE\u5BC6\u7801",
1900
+ "modeLabel": "\u5730\u5740\u6A21\u5F0F",
1901
+ "advAddress": "\u9AD8\u7EA7 \xB7 \u624B\u52A8\u9009\u5730\u5740",
1902
+ "wanOffHint": "\u5F00\u542F\u540E\u53EF\u4ECE\u4EFB\u4F55\u7F51\u7EDC\u8BBF\u95EE\uFF08\u6BCF\u6B21\u5F00\u542F\u9700\u786E\u8BA4\u514D\u8D23\u58F0\u660E\uFF09",
1903
+ "resetFactory": "\u{1F9F9} \u6062\u590D\u51FA\u5382\u8BBE\u7F6E",
1904
+ "resetGo": "\u6062\u590D",
1905
+ "resetIntro": "\u8BBE\u7F6E\u641E\u51FA\u95EE\u9898\u65F6\u7684\u4E34\u65F6\u515C\u5E95\uFF1A\u6E05\u7A7A\u672C\u673A\u914D\u7F6E\u5E76\u91CD\u8BBE\u968F\u673A\u5BC6\u7801\uFF08DSH \u7684\u4F1A\u8BDD\u3001\u6A21\u578B\u3001\u63D2\u4EF6\u914D\u7F6E\u4E0D\u53D7\u5F71\u54CD\uFF09",
1906
+ "resetTitle": "\u26A0\uFE0F \u786E\u8BA4\u6062\u590D\u51FA\u5382\u8BBE\u7F6E\uFF1F",
1907
+ "resetBody": "\u5C06\u6E05\u7A7A\u5E76\u6062\u590D\u9ED8\u8BA4\uFF1A\n\u2460 \u5F00\u5173\uFF1A\u5C40\u57DF\u7F51\u8BBF\u95EE=\u5F00\u3001\u8BBF\u95EE\u5BC6\u7801=\u5F00\u3001\u5C40\u57DF\u7F51\u5730\u5740=\u81EA\u52A8\n\u2461 \u516C\u7F51\uFF1A\u6A21\u5F0F\u56DE\u5230\u968F\u673A\u57DF\u540D\uFF0C\u6E05\u7A7A Tunnel Token \u4E0E\u56FA\u5B9A\u57DF\u540D\uFF0C\u5E76\u5173\u95ED\u6B63\u5728\u8FD0\u884C\u7684\u516C\u7F51\n\u2462 \u5BC6\u7801\uFF1A\u516C\u7F51\u548C\u5C40\u57DF\u7F51\u90FD\u6362\u6210\u65B0\u7684\u968F\u673A 8 \u4F4D\u5BC6\u7801\uFF08\u65E7\u5BC6\u7801\u7ACB\u5373\u4F5C\u5E9F\uFF0C\u624B\u673A\u9700\u91CD\u65B0\u8F93\u5165\uFF09\n\nDSH \u81EA\u8EAB\u7684\u4F1A\u8BDD\u3001\u6A21\u578B\u3001\u63D2\u4EF6\u914D\u7F6E\u4E0D\u53D7\u5F71\u54CD\uFF1B\u6B64\u64CD\u4F5C\u4E0D\u53EF\u64A4\u9500\u3002",
1908
+ "resetConfirm": "\u786E\u8BA4\u6062\u590D",
1909
+ "resetDone": "\u2705 \u5DF2\u6062\u590D\u51FA\u5382\u8BBE\u7F6E\uFF1A\u8BBE\u7F6E\u5DF2\u6E05\u7A7A\uFF0C\u5BC6\u7801\u5DF2\u6362\u65B0\uFF08\u624B\u673A\u9700\u91CD\u65B0\u8F93\u5165\uFF09",
1910
+ "resetFailed": "\u274C \u6062\u590D\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5",
1911
+ "lanTitle": "\u{1F4F6} \u5C40\u57DF\u7F51\uFF08\u540C\u4E00 WiFi\uFF09",
1912
+ "lanHint": "\u624B\u673A\u8FDE\u63A5\u540C\u4E00 WiFi \u540E\u626B\u7801\u5373\u53EF\u6253\u5F00",
1913
+ "lanAccess": "\u5C40\u57DF\u7F51\u8BBF\u95EE",
1914
+ "lanDisabledHint": "\u{1F512} \u5C40\u57DF\u7F51\u8BBF\u95EE\u5DF2\u5173\u95ED\uFF1A\u624B\u673A\u626B\u7801/\u94FE\u63A5\u5747\u4E0D\u53EF\u7528\uFF08\u516C\u7F51\u4E0D\u53D7\u5F71\u54CD\uFF09\u3002\u70B9\u300C\u5F00\u300D\u6062\u590D\u3002",
1915
+ "lanToggleTitleOff": "\u5173\u95ED\u5C40\u57DF\u7F51\u8BBF\u95EE",
1916
+ "lanToggleBodyOff": "\u5173\u95ED\u540E\uFF0C\u540C\u4E00 WiFi \u4E0B\u7684\u624B\u673A\u5C06\u65E0\u6CD5\u626B\u7801\u8BBF\u95EE\uFF08\u5C40\u57DF\u7F51\u4E8C\u7EF4\u7801/\u94FE\u63A5\u7ACB\u5373\u5931\u6548\uFF09\u3002\u516C\u7F51\u8BBF\u95EE\u4E0D\u53D7\u5F71\u54CD\u3002\u786E\u5B9A\u5173\u95ED\uFF1F",
1917
+ "lanToggleTitleOn": "\u5F00\u542F\u5C40\u57DF\u7F51\u8BBF\u95EE",
1918
+ "lanToggleBodyOn": "\u5F00\u542F\u540E\uFF0C\u540C\u4E00 WiFi \u7684\u624B\u673A\u626B\u7801\u5373\u53EF\u8BBF\u95EE\uFF08\u9ED8\u8BA4\u9700\u8F93\u5165\u5C40\u57DF\u7F51\u5BC6\u7801\uFF09\u3002\u786E\u5B9A\u5F00\u542F\uFF1F",
1919
+ "confirm": "\u786E\u5B9A",
1920
+ "lanAddress": "\u5C40\u57DF\u7F51\u5730\u5740",
1921
+ "lanAddressAuto": "\u81EA\u52A8\uFF08\u63A8\u8350\uFF09",
1922
+ "lanPin": "\u5C40\u57DF\u7F51\u8BBF\u95EE\u5BC6\u7801",
1923
+ "on": "\u5F00",
1924
+ "off": "\u5173",
1925
+ "lanPinValue": "\u{1F510} \u8BBF\u95EE\u5BC6\u7801\uFF1A{pin}\uFF08\u624B\u673A\u6253\u5F00\u9700\u8F93\u5165\uFF1B\u4E0E\u516C\u7F51\u5BC6\u7801\u5206\u5F00\uFF09",
1926
+ "lanPinCustomValue": "\u{1F510} \u8BBF\u95EE\u5BC6\u7801\uFF1A{pin}\uFF08\u81EA\u5B9A\u4E49\uFF1B\u624B\u673A\u6253\u5F00\u9700\u8F93\u5165\uFF09",
1927
+ "refresh": "\u5237\u65B0",
1928
+ "customize": "\u81EA\u5B9A\u4E49",
1929
+ "customizing": "\u65B0\u5BC6\u7801\uFF088 \u4F4D\uFF0C\u82F1\u6587\u5B57\u6BCD\u6216\u6570\u5B57\uFF09\uFF1A",
1930
+ "save": "\u4FDD\u5B58",
1931
+ "cancel": "\u53D6\u6D88",
1932
+ "pinInvalid": "\u5BC6\u7801\u5FC5\u987B\u662F 8 \u4F4D\u82F1\u6587\u5B57\u6BCD\u6216\u6570\u5B57",
1933
+ "pinCustomHint": "\u81EA\u5B9A\u4E49\u540E\u5F00\u542F\u516C\u7F51\u4E0D\u518D\u81EA\u52A8\u6362\u65B0",
1934
+ "lanPinOff": "\u{1F513} \u5BC6\u7801\u5DF2\u5173\u95ED\uFF1A\u626B\u7801\u76F4\u8FDE\uFF0C\u65E0\u9700\u5BC6\u7801\uFF08\u4EC5\u540C\u4E00\u5C40\u57DF\u7F51\u8BBE\u5907\u53EF\u8BBF\u95EE\uFF1B\u516C\u7F51\u4ECD\u8981\u5BC6\u7801\uFF09",
1935
+ "lanStarting": "\u4EE3\u7406\u672A\u5C31\u7EEA\u2026",
1936
+ "wanTitle": "\u{1F310} \u516C\u7F51\uFF08\u4EBA\u5728\u5916\u9762\uFF09",
1937
+ "wanHint": "\u4EFB\u4F55\u7F51\u7EDC\u626B\u7801\u5373\u7528\uFF08URL \u6BCF\u6B21\u91CD\u542F\u81EA\u52A8\u6362\u65B0\uFF09",
1938
+ "wanPin": "\u{1F510} \u8BBF\u95EE\u5BC6\u7801\uFF1A{pin}\uFF08\u6BCF\u6B21\u5F00\u542F\u516C\u7F51\u53D8\u65B0\uFF1B\u624B\u673A\u6253\u5F00\u94FE\u63A5\u9700\u8F93\u5165\u6B64\u5BC6\u7801\uFF09",
1939
+ "wanPinCustom": "\u{1F510} \u8BBF\u95EE\u5BC6\u7801\uFF1A{pin}\uFF08\u81EA\u5B9A\u4E49\uFF0C\u5F00\u542F\u516C\u7F51\u4E0D\u518D\u81EA\u52A8\u6362\u65B0\uFF09",
1940
+ "wanEphemeralWarn": "\u26A0\uFE0F \u516C\u7F51\u94FE\u63A5\u4EC5\u5728\u672C\u6B21\u5F00\u542F\u671F\u95F4\u6709\u6548\uFF1A\u5173\u95ED\u6216\u91CD\u542F\u540E\u5931\u6548\uFF0C\u5E76\u53EF\u80FD\u88AB\u4ED6\u4EBA\u590D\u7528\u4E3A\u964C\u751F\u7F51\u7AD9\u3002\u8BF7\u52FF\u6536\u85CF\uFF0C\u6BCF\u6B21\u4ECE\u672C\u9875\u626B\u300C\u5F53\u524D\u300D\u4E8C\u7EF4\u7801\u3002\u9700\u8981\u56FA\u5B9A\u4E0D\u53D8\u7684\u5730\u5740\u8BF7\u7528\u4E0B\u65B9\u300C\u56FA\u5B9A\u57DF\u540D\u300D\u3002",
1941
+ "stopTunnel": "\u5173\u95ED\u516C\u7F51",
1942
+ "enable": "\u5F00\u542F\u516C\u7F51\u8BBF\u95EE",
1943
+ "opening": "\u5F00\u542F\u4E2D\u2026",
1944
+ "tunnelMode": "\u516C\u7F51\u6A21\u5F0F\uFF1A",
1945
+ "modeQuick": "\u968F\u673A\u57DF\u540D\uFF08\u9ED8\u8BA4\uFF09",
1946
+ "modeNamed": "\u56FA\u5B9A\u57DF\u540D",
1947
+ "namedSummary": "\u56FA\u5B9A\u57DF\u540D\uFF1A{host} \xB7 Token {token}",
1948
+ "namedTokenSet": "\u5DF2\u914D\u7F6E",
1949
+ "namedTokenMissing": "\u672A\u914D\u7F6E",
1950
+ "namedEdit": "\u4FEE\u6539",
1951
+ "namedHostnameLabel": "\u56FA\u5B9A\u57DF\u540D\uFF1A",
1952
+ "namedTokenLabel": "Tunnel Token\uFF08\u7559\u7A7A = \u4FDD\u6301\u4E0D\u53D8\uFF09\uFF1A",
1953
+ "namedHow": "\u5728 Cloudflare Zero Trust \u2192 Networks \u2192 Tunnels \u521B\u5EFA\u96A7\u9053\u5E76\u590D\u5236 Token\uFF1B\u628A\u57DF\u540D\u7684 Service \u6307\u5411 http://127.0.0.1:3081\uFF08\u4EE3\u7406\u7AEF\u53E3\uFF09\u3002\u5730\u5740\u56FA\u5B9A\uFF0C\u91CD\u542F\u4E0D\u518D\u53D8\u5316\u3002",
1954
+ "namedSecurity": "\u56FA\u5B9A\u57DF\u540D\u957F\u671F\u66B4\u9732\u5728\u516C\u7F51\u3001\u66F4\u6613\u88AB\u626B\u63CF\uFF0C\u5EFA\u8BAE\u540C\u65F6\u8BBE\u7F6E\u81EA\u5B9A\u4E49\u5F3A\u5BC6\u7801\uFF08\u672C\u6A21\u5F0F\u516C\u7F51\u5BC6\u7801\u9ED8\u8BA4\u4E0D\u968F\u91CD\u542F\u8F6E\u6362\uFF09\u3002",
1955
+ "namedNeedCfg": "\u8BF7\u5148\u586B\u5199\u56FA\u5B9A\u57DF\u540D\u4E0E Tunnel Token",
1956
+ "namedRunningHint": "\u56FA\u5B9A\u57DF\u540D\uFF08Cloudflare \u547D\u540D\u96A7\u9053\uFF09\u2014\u2014\u5730\u5740\u4E0D\u968F\u91CD\u542F\u53D8\u5316",
1957
+ "namedTakeEffect": "\u5DF2\u4FDD\u5B58\u56FA\u5B9A\u57DF\u540D\u914D\u7F6E\u2014\u2014\u9700\u5173\u95ED\u5E76\u91CD\u65B0\u5F00\u542F\u516C\u7F51\u8BBF\u95EE\u540E\u751F\u6548",
1958
+ "disclaimerTitle": "\u26A0\uFE0F \u5B89\u5168\u514D\u8D23\u58F0\u660E",
1959
+ "disclaimerBody": "\u5F00\u542F\u516C\u7F51 = \u628A\u672C\u673A DSH\uFF08\u80FD\u6267\u884C\u4EE3\u7801\uFF09\u66B4\u9732\u5230\u4E92\u8054\u7F51\u3002\u4EFB\u4F55\u4EBA\u62FF\u5230\u516C\u7F51\u94FE\u63A5\u548C\u5BC6\u7801\uFF0C\u90FD\u80FD\u8BBF\u95EE\u751A\u81F3\u64CD\u4F5C\u4F60\u7684\u7535\u8111\u3002\u8BF7\u786E\u8BA4\uFF1A\u2460 \u4F7F\u7528\u81EA\u5B9A\u4E49\u5F3A\u5BC6\u7801\u6216\u59A5\u5584\u4FDD\u7BA1\u81EA\u52A8\u5BC6\u7801\uFF1B\u2461 \u7528\u5B8C\u7ACB\u5373\u300C\u5173\u95ED\u516C\u7F51\u300D\uFF1B\u2462 \u516C\u53F8/\u6D89\u5BC6\u7F51\u7EDC\u8BF7\u5148\u786E\u8BA4\u5408\u89C4\u3002",
1960
+ "disclaimerAgree": "\u6211\u5DF2\u77E5\u60C5\uFF0C\u540C\u610F\u5F00\u542F",
1961
+ "disclaimerHint": "\u8BF7\u52FE\u9009\u300C\u6211\u5DF2\u77E5\u60C5\u300D\u540E\u518D\u5F00\u542F\u516C\u7F51",
1962
+ "downloading": "\u23F3 \u4E0B\u8F7D cloudflared\uFF08\u9996\u6B21\u7EA6 20-50MB\uFF0C\u901A\u5E38 1-2 \u5206\u949F\uFF1B\u4E4B\u540E\u79D2\u5F00\uFF09\xB7 \u5DF2\u7B49\u5F85 {s} \u79D2",
1963
+ "connecting": "\u23F3 \u8FDE\u63A5 Cloudflare \u8FB9\u7F18\uFF08\u901A\u5E38 5-30 \u79D2\uFF09\xB7 \u5DF2\u7B49\u5F85 {s} \u79D2{suffix}",
1964
+ "slowHint": " \u2014 \u6709\u70B9\u4E45\uFF1F\u68C0\u67E5\u662F\u5426\u5F00\u7740\u4EE3\u7406/VPN\uFF08Clash TUN \u7B49\uFF09",
1965
+ "error": "\u274C \u5F00\u542F\u5931\u8D25\uFF1A{detail}\uFF08\u53EF\u91CD\u8BD5\uFF1B\u82E5\u662F\u4EE3\u7406/VPN \u95EE\u9898\u89C1 README \u6392\u969C\uFF09",
1966
+ "unknownError": "\u672A\u77E5\u9519\u8BEF",
1967
+ "feedback": "\u6709\u95EE\u9898\uFF1F\u6B22\u8FCE\u5230 GitHub Issues \u53CD\u9988 \u{1F64F}",
1968
+ // ---------- 中继(自建服务器) ----------
1969
+ "relayTitle": "\u{1F6F0} \u4E2D\u7EE7\uFF08\u81EA\u5EFA\u670D\u52A1\u5668\uFF09",
1970
+ "relayIntro": "\u7528\u81EA\u5DF1\u7684\u670D\u52A1\u5668\u4E2D\u8F6C\uFF0C\u5730\u5740\u56FA\u5B9A\u3001\u4E0D\u4F9D\u8D56\u7B2C\u4E09\u65B9\u3002\u914D\u7F6E\u597D\u5373\u5E38\u5F00\uFF1Adsh \u4E00\u8D77\u6765\u5C31\u5728\u7EBF\uFF0C\u4E0D\u7528\u6BCF\u6B21\u624B\u52A8\u5F00\u3002",
1971
+ "relayStateOnline": "\u{1F7E2} \u5DF2\u8FDE\u63A5",
1972
+ "relayStateConnecting": "\u{1F7E0} \u8FDE\u63A5\u4E2D\u2026",
1973
+ "relayStateError": "\u{1F534} \u672A\u8FDE\u63A5",
1974
+ "relayStateStopped": "\u26AA \u5DF2\u6682\u505C",
1975
+ "relayStateIdle": "\u26AA \u672A\u542F\u7528",
1976
+ "relayHostLabel": "\u670D\u52A1\u5668\u5730\u5740",
1977
+ "relayHostPh": "relay.example.com",
1978
+ "relayPortLabel": "agent \u7AEF\u53E3",
1979
+ "relayPublicUrlLabel": "\u5BF9\u5916\u8BBF\u95EE\u5730\u5740",
1980
+ "relayPublicUrlPh": "https://relay.example.com:8443",
1981
+ "relayPublicUrlHint": "\u624B\u673A\u626B\u7801\u548C\u6536\u85CF\u7528\u8FD9\u4E2A\u5730\u5740\u3002relay \u81EA\u5DF1\u7EC8\u7ED3 TLS\uFF0C\u6240\u4EE5\u586B https:// \u52A0\u7AEF\u53E3\u3002",
1982
+ "relayTokenLabel": "Token\uFF08\u7559\u7A7A = \u4FDD\u6301\u4E0D\u53D8\uFF09",
1983
+ "relayTokenSet": "\u5DF2\u914D\u7F6E",
1984
+ "relayTokenMissing": "\u672A\u914D\u7F6E",
1985
+ "relayAgentIdLabel": "\u672C\u673A\u540D\u79F0",
1986
+ "relayAgentIdPh": "\u7559\u7A7A = \u7528\u7535\u8111\u4E3B\u673A\u540D",
1987
+ "relayAgentIdHint": "\u540C\u4E00\u53F0\u4E2D\u7EE7\u4E0A\u6302\u591A\u53F0\u7535\u8111\u65F6\u9760\u5B83\u533A\u5206\uFF08\u53EA\u5141\u8BB8\u5B57\u6BCD\u3001\u6570\u5B57\u3001- _ .\uFF09\u3002\u7559\u7A7A\u5C31\u7528\u4E3B\u673A\u540D\u2014\u2014\u540C\u4E00\u53F0\u4E2D\u7EE7\u4E0A\u591A\u53F0\u7535\u8111\u4E0D\u80FD\u540C\u540D\uFF0C\u5426\u5219\u4F1A\u4E92\u76F8\u9876\u4E0B\u7EBF\u3002",
1988
+ "relayThisPc": "\u672C\u673A\u540D\u79F0\uFF1A{id}",
1989
+ "relayTlsLabel": "agent \u901A\u9053\u4F7F\u7528 TLS",
1990
+ "relayTlsHint": "agent \u901A\u9053\u627F\u8F7D\u4F60\u548C\u7535\u8111\u4E4B\u95F4\u7684\u5168\u90E8\u660E\u6587\u6D41\u91CF\uFF0C\u9664\u975E\u8D70 Tailscale/WireGuard \u5185\u7F51\uFF0C\u5426\u5219\u5EFA\u8BAE\u5F00\u542F\u3002",
1991
+ "relayInsecureLabel": "\u5141\u8BB8\u81EA\u7B7E\u540D\u8BC1\u4E66",
1992
+ "relayInsecureHint": "\u26A0\uFE0F \u7B49\u4E8E\u653E\u5F03\u4E2D\u95F4\u4EBA\u9632\u62A4\uFF0C\u4EC5\u5728\u5B8C\u5168\u53EF\u4FE1\u7684\u7F51\u7EDC\u91CC\u7528\u3002",
1993
+ "relaySave": "\u4FDD\u5B58\u5E76\u8FDE\u63A5",
1994
+ "relaySaving": "\u4FDD\u5B58\u4E2D\u2026",
1995
+ "relaySaved": "\u2705 \u4E2D\u7EE7\u914D\u7F6E\u5DF2\u4FDD\u5B58",
1996
+ "relaySaveFailed": "\u274C \u4FDD\u5B58\u5931\u8D25\uFF1A{err}",
1997
+ "relayNeedCfg": "\u8BF7\u5148\u586B\u5199\u670D\u52A1\u5668\u5730\u5740\u3001agent \u7AEF\u53E3\u3001\u5BF9\u5916\u8BBF\u95EE\u5730\u5740\u548C Token",
1998
+ "relayPause": "\u6682\u505C\u8FDC\u7A0B",
1999
+ "relayResume": "\u6062\u590D\u8FDC\u7A0B",
2000
+ "relayReconnects": "\u5DF2\u91CD\u8FDE {n} \u6B21",
2001
+ "relayStreams": "\u8FDB\u884C\u4E2D\u7684\u8FDE\u63A5\uFF1A{n}",
2002
+ "relayConsentTitle": "\u26A0\uFE0F \u5E38\u5F00\u8FDC\u7A0B\u8BBF\u95EE\u7684\u5B89\u5168\u58F0\u660E",
2003
+ "relayConsentBody": "\u5F00\u542F\u540E\uFF0C\u53EA\u8981 dsh \u5728\u8FD0\u884C\uFF0C\u8FD9\u53F0\u7535\u8111\u5C31\u4F1A**\u957F\u671F**\u6302\u5728\u4E2D\u7EE7\u670D\u52A1\u5668\u4E0A\uFF0C\u4EFB\u4F55\u4EBA\u5728\u516C\u7F51\u4E0A\u90FD\u80FD\u5C1D\u8BD5\u8FDE\u63A5\u3002\n\n\u2460 \u8BF7\u786E\u8BA4\u5DF2\u8BBE\u7F6E\u8BBF\u95EE\u5BC6\u7801\u6216\u5B8C\u6210\u8BBE\u5907\u914D\u5BF9\uFF1B\n\u2461 \u4E2D\u7EE7\u901A\u9053\u4F7F\u7528\u8BBE\u5907\u8BA4\u8BC1\uFF1A\u6BCF\u53F0\u8BBE\u5907\u4E00\u4E2A\u72EC\u7ACB\u5BC6\u7801\uFF0C\u53EF\u5355\u72EC\u64A4\u9500\uFF1B\n\u2462 \u624B\u673A\u4E22\u5931\u8BF7\u5728\u7535\u8111\u4E0A\u300C\u64A4\u9500\u300D\u90A3\u53F0\u8BBE\u5907\uFF08\u4E0D\u662F\u6539\u5BC6\u7801\uFF09\uFF1B\n\u2463 \u516C\u53F8/\u6D89\u5BC6\u7F51\u7EDC\u8BF7\u5148\u786E\u8BA4\u5408\u89C4\u3002",
2004
+ "relayConsentAgree": "\u6211\u5DF2\u77E5\u60C5\uFF0C\u540C\u610F\u5E38\u5F00",
2005
+ "relayConsentHint": "\u8BF7\u5148\u52FE\u9009\u300C\u6211\u5DF2\u77E5\u60C5\u300D",
2006
+ "relayQrHint": "\u624B\u673A\u7528\u8FD9\u4E2A\u5730\u5740\u8BBF\u95EE\uFF08\u9700\u5148\u5B8C\u6210\u914D\u5BF9\uFF09",
2007
+ // ---------- 设备管理 ----------
2008
+ "deviceTitle": "\u5DF2\u914D\u5BF9\u8BBE\u5907",
2009
+ "deviceIntro": "\u4E2D\u7EE7\u901A\u9053\u7528\u8BBE\u5907\u8BA4\u8BC1\uFF1A\u6BCF\u53F0\u624B\u673A\u4E00\u4E2A\u72EC\u7ACB\u5BC6\u7801\uFF0C\u4E22\u4E86\u4E00\u53F0\u53EA\u64A4\u4E00\u53F0\u3002",
2010
+ "deviceAdd": "\u6DFB\u52A0\u8BBE\u5907",
2011
+ "deviceAdding": "\u751F\u6210\u4E2D\u2026",
2012
+ "deviceNone": "\u8FD8\u6CA1\u6709\u5DF2\u914D\u5BF9\u7684\u8BBE\u5907",
2013
+ "devicePendingTitle": "\u5F85\u6279\u51C6",
2014
+ "devicePendingNone": "\u6CA1\u6709\u5F85\u6279\u51C6\u7684\u8BBE\u5907",
2015
+ "deviceApprove": "\u6279\u51C6",
2016
+ "deviceReject": "\u62D2\u7EDD",
2017
+ "deviceRevoke": "\u64A4\u9500",
2018
+ "deviceName": "\u540D\u79F0",
2019
+ "deviceCreatedAt": "\u6DFB\u52A0\u65F6\u95F4",
2020
+ "deviceLastLogin": "\u6700\u540E\u767B\u5F55",
2021
+ "deviceNever": "\u4ECE\u672A\u767B\u5F55",
2022
+ "devicePairTitle": "\u{1F4F1} \u914D\u5BF9\u4E8C\u7EF4\u7801",
2023
+ "devicePairHint": "\u624B\u673A\u626B\u7801\u540E\u8BBE\u7F6E\u81EA\u5DF1\u7684\u8BBE\u5907\u5BC6\u7801\uFF0C\u518D\u56DE\u6765\u70B9\u300C\u6279\u51C6\u300D\u3002\u4E8C\u7EF4\u7801 5 \u5206\u949F\u5185\u6709\u6548\uFF0C\u4E14\u53EA\u80FD\u7528\u4E00\u6B21\u3002",
2024
+ "devicePairExpire": "\u8FC7\u671F\u65F6\u95F4\uFF1A{time}",
2025
+ "devicePairCancel": "\u53D6\u6D88\u914D\u5BF9",
2026
+ "deviceRevokeConfirm": "\u64A4\u9500\u540E\u8BE5\u8BBE\u5907\u9700\u91CD\u65B0\u914D\u5BF9\u624D\u80FD\u8BBF\u95EE\uFF0C\u786E\u5B9A\u7EE7\u7EED\uFF1F",
2027
+ "deviceRevokeDone": "\u2705 \u5DF2\u64A4\u9500\u8BE5\u8BBE\u5907",
2028
+ "deviceApprovedDone": "\u2705 \u5DF2\u6279\u51C6\u8BE5\u8BBE\u5907",
2029
+ "deviceRejectedDone": "\u2705 \u5DF2\u62D2\u7EDD\u8BE5\u8BBE\u5907",
2030
+ "deviceNamePh": "\u5982\uFF1A\u6211\u7684 iPhone",
2031
+ "deviceCount": "\u5DF2\u914D\u5BF9 {n} \u53F0"
2032
+ };
2033
+ var en2 = {
2034
+ "section": "Phone access",
2035
+ "title": "\u{1F4F1} Phone access",
2036
+ "subtitle": "The phone shows this exact screen, live",
2037
+ "developer": "Built on dsh-pocket (GPL-2.0)",
2038
+ "starAsk": "\u2B50 Drop a Star if it helped \u2014 it makes the author\u2019s day",
2039
+ "starCta": "\u2605 Give a Star",
2040
+ "restarted": "\u{1F504} Restarted",
2041
+ "ok": "Got it",
2042
+ "bgHint": "Running in the background (not attached to a terminal). To stop: {cmd}",
2043
+ "updatedRestart": "\u2705 Updated to v{ver} \u2014 restart to apply",
2044
+ "updateAutoRestarting": "\u2705 Updated to v{ver} \u2014 auto-restarting\u2026",
2045
+ "updatedOk": "\u2705 Updated to v{ver}",
2046
+ "updateAvailable": "\u{1F4E6} Update available: v{ver}",
2047
+ "updating": "Updating\u2026",
2048
+ "updateTo": "Update to v{ver}",
2049
+ "restartingNow": "Restarting to apply\u2026",
2050
+ "restarting": "Restarting\u2026",
2051
+ "restartNow": "\u{1F504} Restart dsh web now",
2052
+ "updatingDetail": "\u23F3 Updating (usually 1-2 min) \xB7 {s}s elapsed",
2053
+ "restartingDetail": "\u23F3 Restarting to apply (usually 10-30s) \xB7 {s}s elapsed",
2054
+ "updatedAutoDetail": "\u2705 Updated \u2014 auto-restarting in progress, refresh shortly",
2055
+ "updatedRestartDetail": "\u2705 Updated \u2014 restart dsh web to apply",
2056
+ "updateFailed": "\u274C Failed: {err} (manual update: dsh plugin --profile web update dsh-pocket-relay --latest -w)",
2057
+ "versionRange": "Current v{cur} \u2192 latest v{latest}",
2058
+ "wanAccess": "Public access",
2059
+ "pinLabel": "Access PIN",
2060
+ "modeLabel": "Address mode",
2061
+ "advAddress": "Advanced \xB7 Pick address",
2062
+ "wanOffHint": "Reachable from any network once enabled (a disclaimer is confirmed on each enable)",
2063
+ "resetFactory": "\u{1F9F9} Factory reset",
2064
+ "resetGo": "Reset",
2065
+ "resetIntro": "Temporary fallback when settings break: clear local config and re-roll random PINs (DSH sessions, models and plugin config are untouched)",
2066
+ "resetTitle": "\u26A0\uFE0F Confirm factory reset?",
2067
+ "resetBody": "This clears and restores defaults:\n\u2460 Switches: LAN access on, access PIN on, LAN address auto\n\u2461 Public: mode back to random URL, Tunnel Token and fixed domain cleared, and any running tunnel is stopped\n\u2462 PINs: both public and LAN become new random 8-character PINs (old ones stop working; the phone must re-enter)\n\nYour DSH sessions, models and plugin config are untouched. This cannot be undone.",
2068
+ "resetConfirm": "Reset",
2069
+ "resetDone": "\u2705 Factory reset done: settings cleared and PINs re-rolled (re-enter the PIN on your phone)",
2070
+ "resetFailed": "\u274C Reset failed \u2014 please retry",
2071
+ "lanTitle": "\u{1F4F6} LAN (same Wi-Fi)",
2072
+ "lanHint": "Scan to open once your phone is on the same Wi-Fi",
2073
+ "lanAccess": "LAN access",
2074
+ "lanDisabledHint": '\u{1F512} LAN access is off \u2014 the QR code and link are unavailable (public access is unaffected). Tap "On" to restore.',
2075
+ "lanToggleTitleOff": "Turn off LAN access",
2076
+ "lanToggleBodyOff": "Once off, phones on the same Wi-Fi can no longer scan to connect (the LAN QR code and link stop working immediately). Public access is unaffected. Turn it off?",
2077
+ "lanToggleTitleOn": "Turn on LAN access",
2078
+ "lanToggleBodyOn": "Once on, phones on the same Wi-Fi can scan to connect (a LAN PIN is required by default). Turn it on?",
2079
+ "confirm": "Confirm",
2080
+ "lanAddress": "LAN address",
2081
+ "lanAddressAuto": "Auto (recommended)",
2082
+ "lanPin": "LAN access PIN",
2083
+ "on": "On",
2084
+ "off": "Off",
2085
+ "lanPinValue": "\u{1F510} PIN: {pin} (required on the phone; separate from the public PIN)",
2086
+ "lanPinCustomValue": "\u{1F510} PIN: {pin} (custom; required on the phone)",
2087
+ "refresh": "Refresh",
2088
+ "customize": "Customize",
2089
+ "customizing": "New PIN (8 chars, letters/digits): ",
2090
+ "save": "Save",
2091
+ "cancel": "Cancel",
2092
+ "pinInvalid": "PIN must be exactly 8 characters (letters and digits only)",
2093
+ "pinCustomHint": "custom PINs are not rotated on tunnel start",
2094
+ "lanPinOff": "\u{1F513} PIN off \u2014 scan & go, no PIN (LAN devices only; public still requires PIN)",
2095
+ "lanStarting": "Proxy starting\u2026",
2096
+ "wanTitle": "\u{1F310} Anywhere (public)",
2097
+ "wanHint": "Scan from any network (the URL changes on every restart)",
2098
+ "wanPin": "\u{1F510} PIN: {pin} (changes each time the tunnel is enabled; required on the phone)",
2099
+ "wanPinCustom": "\u{1F510} PIN: {pin} (custom \u2014 not rotated on tunnel start)",
2100
+ "wanEphemeralWarn": '\u26A0\uFE0F The public link is valid only for this session: it stops working after you close or restart, and may be reused by someone else for an unrelated site. Do not bookmark it \u2014 scan the CURRENT QR code from this page each time. For a permanent address use "Fixed domain" below.',
2101
+ "stopTunnel": "Stop",
2102
+ "enable": "Enable anywhere",
2103
+ "opening": "Enabling\u2026",
2104
+ "tunnelMode": "Mode:",
2105
+ "modeQuick": "Random URL (default)",
2106
+ "modeNamed": "Fixed domain",
2107
+ "namedSummary": "Fixed domain: {host} \xB7 Token {token}",
2108
+ "namedTokenSet": "configured",
2109
+ "namedTokenMissing": "not set",
2110
+ "namedEdit": "Edit",
2111
+ "namedHostnameLabel": "Fixed domain:",
2112
+ "namedTokenLabel": "Tunnel Token (blank = keep current):",
2113
+ "namedHow": "Create a tunnel in Cloudflare Zero Trust \u2192 Networks \u2192 Tunnels and copy the token; point the hostname's Service at http://127.0.0.1:3081 (the proxy port). The URL stays fixed across restarts.",
2114
+ "namedSecurity": "A fixed domain is long-lived and easier to scan \u2014 set a strong custom PIN too (the public PIN is not rotated on restart in this mode).",
2115
+ "namedNeedCfg": "Set the fixed domain and Tunnel Token first",
2116
+ "namedRunningHint": "Fixed domain (Cloudflare named tunnel) \u2014 the URL no longer changes on restart",
2117
+ "namedTakeEffect": "Fixed-domain config saved \u2014 turn public access off and on again to take effect",
2118
+ "disclaimerTitle": "\u26A0\uFE0F Security disclaimer",
2119
+ "disclaimerBody": "Enabling public access exposes this computer\u2019s DSH (which can execute code) to the internet. Anyone with the public link and PIN can reach \u2014 and operate \u2014 your computer. Please confirm: \u2460 use a strong custom PIN or keep the auto-generated one safe; \u2461 turn public access OFF as soon as you\u2019re done; \u2462 on a corporate/classified network, confirm compliance first.",
2120
+ "disclaimerAgree": "I understand and agree",
2121
+ "disclaimerHint": 'Check "I understand" before enabling public access',
2122
+ "downloading": "\u23F3 Downloading cloudflared (first run ~20-50MB, usually 1-2 min; instant afterward) \xB7 {s}s elapsed",
2123
+ "connecting": "\u23F3 Connecting to Cloudflare edge (usually 5-30s) \xB7 {s}s elapsed{suffix}",
2124
+ "slowHint": " \u2014 taking long? Check for a proxy/VPN (e.g., Clash TUN)",
2125
+ "error": "\u274C Failed to enable: {detail} (you can retry; for proxy/VPN issues see the README)",
2126
+ "unknownError": "unknown error",
2127
+ "feedback": "\u{1F64F} Questions? Open an issue on GitHub",
2128
+ // ---------- Relay (self-hosted server) ----------
2129
+ "relayTitle": "\u{1F6F0} Relay (self-hosted)",
2130
+ "relayIntro": "Route through your own server: fixed address, no third party. Once configured it stays on \u2014 as long as dsh runs, you are reachable.",
2131
+ "relayStateOnline": "\u{1F7E2} Connected",
2132
+ "relayStateConnecting": "\u{1F7E0} Connecting\u2026",
2133
+ "relayStateError": "\u{1F534} Not connected",
2134
+ "relayStateStopped": "\u26AA Paused",
2135
+ "relayStateIdle": "\u26AA Not enabled",
2136
+ "relayHostLabel": "Server address",
2137
+ "relayHostPh": "relay.example.com",
2138
+ "relayPortLabel": "Agent port",
2139
+ "relayPublicUrlLabel": "Public URL",
2140
+ "relayPublicUrlPh": "https://relay.example.com:8443",
2141
+ "relayPublicUrlHint": "The phone scans/bookmarks this. The relay terminates TLS itself, so use https:// plus the port.",
2142
+ "relayTokenLabel": "Token (blank = keep current)",
2143
+ "relayTokenSet": "configured",
2144
+ "relayTokenMissing": "not set",
2145
+ "relayAgentIdLabel": "This PC name",
2146
+ "relayAgentIdPh": "blank = computer hostname",
2147
+ "relayAgentIdHint": "Distinguishes multiple PCs behind one relay (letters, digits, - _ . only). Blank uses the hostname \u2014 two PCs on one relay must not share a name, or they will keep kicking each other off.",
2148
+ "relayThisPc": "This PC: {id}",
2149
+ "relayTlsLabel": "Use TLS for the agent channel",
2150
+ "relayTlsHint": "The agent channel carries all plaintext traffic between you and the PC. Keep it on unless you are on a Tailscale/WireGuard network.",
2151
+ "relayInsecureLabel": "Allow self-signed certificate",
2152
+ "relayInsecureHint": "\u26A0\uFE0F Gives up man-in-the-middle protection. Only on a fully trusted network.",
2153
+ "relaySave": "Save & connect",
2154
+ "relaySaving": "Saving\u2026",
2155
+ "relaySaved": "\u2705 Relay settings saved",
2156
+ "relaySaveFailed": "\u274C Save failed: {err}",
2157
+ "relayNeedCfg": "Fill in the server address, agent port, public URL and token first",
2158
+ "relayPause": "Pause remote",
2159
+ "relayResume": "Resume remote",
2160
+ "relayReconnects": "Reconnects: {n}",
2161
+ "relayStreams": "Active connections: {n}",
2162
+ "relayConsentTitle": "\u26A0\uFE0F Always-on remote access",
2163
+ "relayConsentBody": "Once enabled, this computer stays reachable through the relay server for as long as dsh is running, and anyone on the internet can attempt to connect.\n\n\u2460 Make sure an access PIN is set or device pairing is complete;\n\u2461 The relay channel uses device auth: each device has its own password and can be revoked individually;\n\u2462 If a phone is lost, revoke THAT device on the computer (do not just change a password);\n\u2463 On a corporate/classified network, confirm compliance first.",
2164
+ "relayConsentAgree": "I understand, enable always-on",
2165
+ "relayConsentHint": 'Check "I understand" first',
2166
+ "relayQrHint": "Open this address on your phone (pairing required first)",
2167
+ // ---------- Device management ----------
2168
+ "deviceTitle": "Paired devices",
2169
+ "deviceIntro": "The relay channel uses device auth: each phone has its own password, so losing one only affects that one.",
2170
+ "deviceAdd": "Add device",
2171
+ "deviceAdding": "Generating\u2026",
2172
+ "deviceNone": "No paired devices yet",
2173
+ "devicePendingTitle": "Pending approval",
2174
+ "devicePendingNone": "Nothing waiting for approval",
2175
+ "deviceApprove": "Approve",
2176
+ "deviceReject": "Reject",
2177
+ "deviceRevoke": "Revoke",
2178
+ "deviceName": "Name",
2179
+ "deviceCreatedAt": "Added",
2180
+ "deviceLastLogin": "Last login",
2181
+ "deviceNever": "never",
2182
+ "devicePairTitle": "\u{1F4F1} Pairing QR code",
2183
+ "devicePairHint": "Scan on the phone, set its own device password, then come back and tap Approve. The code is valid for 5 minutes and single-use.",
2184
+ "devicePairExpire": "Expires: {time}",
2185
+ "devicePairCancel": "Cancel pairing",
2186
+ "deviceRevokeConfirm": "The device will need to pair again to regain access. Continue?",
2187
+ "deviceRevokeDone": "\u2705 Device revoked",
2188
+ "deviceApprovedDone": "\u2705 Device approved",
2189
+ "deviceRejectedDone": "\u2705 Device rejected",
2190
+ "deviceNamePh": "e.g. My iPhone",
2191
+ "deviceCount": "{n} paired"
2192
+ };
2193
+
2194
+ // client/index.jsx
2195
+ var name = "dsh-pocket-relay";
2196
+ var inject = ["slots", "connection", "layout", "locale", "sessionLogDownload"];
2197
+ function fmt(t, key, vars) {
2198
+ let s = t(key);
2199
+ if (vars) {
2200
+ for (const [k, v] of Object.entries(vars)) {
2201
+ s = String(s).split(`{${k}}`).join(String(v));
2202
+ }
2203
+ }
2204
+ return s;
2205
+ }
2206
+ var styles = {
2207
+ 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 },
2208
+ block: { borderTop: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", marginTop: 16, paddingTop: 16 },
2209
+ muted: { color: "var(--dsw-alias-label-tertiary,#8b93a1)", fontSize: 12, lineHeight: 1.5 },
2210
+ code: { fontFamily: "ui-monospace,Menlo,monospace", fontSize: 12, wordBreak: "break-all", margin: "6px 0 10px", color: "var(--dsw-alias-label-primary,inherit)" },
2211
+ // 主按钮:官方 md 胶囊形(36px)
2212
+ 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" },
2213
+ // 次级按钮:官方 outline/ghost 胶囊形
2214
+ 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" },
2215
+ qr: { width: 220, height: 220, borderRadius: 10, border: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", margin: "8px 0" },
2216
+ warn: { color: "var(--dsw-alias-state-warn-primary,#b45309)", fontSize: 12, lineHeight: 1.5 },
2217
+ // 表单控件(中继配置 / 设备命名):与既有下拉框同一套视觉
2218
+ 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" },
2219
+ field: { display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", margin: "6px 0" },
2220
+ fieldLabel: { flexShrink: 0, width: 96, textAlign: "right" },
2221
+ // 状态点:靠颜色一眼看出通没通
2222
+ dot: (bg) => ({ display: "inline-block", width: 8, height: 8, borderRadius: "50%", background: bg, marginRight: 6, flexShrink: 0 }),
2223
+ deviceRow: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, padding: "7px 0", borderTop: "1px solid var(--dsw-alias-border-l2,#f0f0f2)", fontSize: 12 }
2224
+ };
2225
+ function PocketSettingsTab({ rpcCall, t }) {
2226
+ const [status, setStatus] = (0, import_react2.useState)(null);
2227
+ const [busy, setBusy] = (0, import_react2.useState)(false);
2228
+ const [error, setError] = (0, import_react2.useState)(null);
2229
+ const [tunnelState, setTunnelState] = (0, import_react2.useState)(null);
2230
+ const [restartNotice, setRestartNotice] = (0, import_react2.useState)(false);
2231
+ const [updateInfo, setUpdateInfo] = (0, import_react2.useState)(null);
2232
+ const [isDesktop, setIsDesktop] = (0, import_react2.useState)(false);
2233
+ const [now, setNow] = (0, import_react2.useState)(Date.now());
2234
+ (0, import_react2.useEffect)(() => {
2235
+ const t2 = setInterval(() => setNow(Date.now()), 1e3);
2236
+ return () => clearInterval(t2);
2237
+ }, []);
2238
+ const elapsed = (startedAt) => startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1e3)) : 0;
2239
+ const call = async (endpoint, payload) => {
2240
+ const res = await rpcCall(endpoint, payload);
2241
+ if (!res?.ok) throw new Error(res?.error?.message ?? "RPC failed");
2242
+ return res.value;
2243
+ };
2244
+ const load = async () => {
2245
+ try {
2246
+ const s = await call(POCKET_ENDPOINTS.status, {});
2247
+ setStatus(s);
2248
+ setTunnelState(s.tunnelState ?? null);
2249
+ if (s.desktop) setIsDesktop(true);
2250
+ if (s.restartNotice) {
2251
+ setRestartNotice(true);
2252
+ setUpdateInfo(null);
2253
+ if (!sessionStorage.getItem("dshp-auto-reloaded")) {
2254
+ sessionStorage.setItem("dshp-auto-reloaded", "1");
2255
+ setTimeout(() => {
2256
+ try {
2257
+ location.reload();
2258
+ } catch {
2259
+ }
2260
+ }, 2e3);
2261
+ }
2262
+ }
2263
+ } catch {
2264
+ }
2265
+ };
2266
+ (0, import_react2.useEffect)(() => {
2267
+ load();
2268
+ const t2 = setInterval(load, 3e3);
2269
+ return () => clearInterval(t2);
2270
+ }, []);
2271
+ (0, import_react2.useEffect)(() => {
2272
+ try {
2273
+ sessionStorage.removeItem("dshp-auto-reloaded");
2274
+ } catch {
2275
+ }
2276
+ }, []);
2277
+ (0, import_react2.useEffect)(() => {
2278
+ if (isDesktop) return;
2279
+ let alive = true;
2280
+ const check = async () => {
2281
+ try {
2282
+ const v = await call(POCKET_ENDPOINTS.version, {});
2283
+ const meta = await (await fetch("https://registry.npmjs.org/dsh-pocket-relay/latest", { cache: "no-store" })).json();
2284
+ if (!alive) return;
2285
+ const latest = typeof meta?.version === "string" ? meta.version : null;
2286
+ if (latest && v.current && compareVersions(latest, v.current) > 0) {
2287
+ setUpdateInfo({ current: v.current, latest, updating: false, result: null });
2288
+ } else if (v.current && v.loaded && compareVersions(v.current, v.loaded) > 0) {
2289
+ setUpdateInfo({ current: v.current, latest: v.current, updating: false, result: "ok", updated: true });
2290
+ }
2291
+ } catch {
2292
+ }
2293
+ };
2294
+ check();
2295
+ const t2 = setInterval(check, 5 * 60 * 1e3);
2296
+ return () => {
2297
+ alive = false;
2298
+ clearInterval(t2);
2299
+ };
2300
+ }, [isDesktop]);
2301
+ const restartPocket = async () => {
2302
+ setUpdateInfo((u) => ({ ...u, restarting: true, startedAt: Date.now() }));
2303
+ try {
2304
+ await Promise.race([
2305
+ call(POCKET_ENDPOINTS.restart, {}),
2306
+ new Promise((_, rej) => setTimeout(() => rej(new Error("restart requested (no reply within 3s)")), 3e3))
2307
+ ]);
2308
+ setUpdateInfo((u) => ({ ...u, restarting: true, result: "ok" }));
2309
+ } catch (err) {
2310
+ const msg = String(err?.message ?? "");
2311
+ if (/connection|socket|fetch|network|abort|cancelled|ECONN|disconnect|closed|timeout/i.test(msg)) {
2312
+ setUpdateInfo((u) => ({ ...u, restarting: true, result: "ok" }));
2313
+ return;
2314
+ }
2315
+ setUpdateInfo((u) => ({ ...u, restarting: false, result: "fail", output: err.message }));
2316
+ }
2317
+ };
2318
+ const runUpdate = async () => {
2319
+ setUpdateInfo((u) => ({ ...u, updating: true, result: null, startedAt: Date.now() }));
2320
+ try {
2321
+ const r = await call(POCKET_ENDPOINTS.update, {});
2322
+ setUpdateInfo((u) => ({
2323
+ ...u,
2324
+ updating: false,
2325
+ result: r.ok ? "ok" : "fail",
2326
+ autoRestart: r.autoRestart === true,
2327
+ output: r.output ?? r.error
2328
+ }));
2329
+ } catch (err) {
2330
+ setUpdateInfo((u) => ({ ...u, updating: false, result: "fail", output: err.message }));
2331
+ }
2332
+ };
2333
+ const [disclaimerOpen, setDisclaimerOpen] = (0, import_react2.useState)(false);
2334
+ const [disclaimerChecked, setDisclaimerChecked] = (0, import_react2.useState)(false);
2335
+ const doStartTunnel = async () => {
2336
+ const cfg = status?.tunnelConfig;
2337
+ if (cfg?.mode === "named" && (!cfg.hostname || !cfg.tokenSet)) {
2338
+ setError(t("namedNeedCfg"));
2339
+ return;
2340
+ }
2341
+ setBusy(true);
2342
+ setError(null);
2343
+ setTunnelState({ phase: "starting", detail: "\u6B63\u5728\u5F00\u542F\u2026", startedAt: Date.now() });
2344
+ try {
2345
+ setStatus(await call(POCKET_ENDPOINTS.tunnelStart, { disclaimer: true }));
2346
+ } catch (err) {
2347
+ setError(err.message);
2348
+ } finally {
2349
+ setBusy(false);
2350
+ }
2351
+ };
2352
+ const startTunnel = () => {
2353
+ setDisclaimerChecked(false);
2354
+ setDisclaimerOpen(true);
2355
+ };
2356
+ const confirmDisclaimer = () => {
2357
+ if (!disclaimerChecked) return;
2358
+ setDisclaimerOpen(false);
2359
+ doStartTunnel();
2360
+ };
2361
+ const stopTunnel = async () => {
2362
+ try {
2363
+ setStatus(await call(POCKET_ENDPOINTS.tunnelStop, {}));
2364
+ } catch {
2365
+ }
2366
+ };
2367
+ const [tunnelCfg, setTunnelCfg] = (0, import_react2.useState)(null);
2368
+ const switchToQuick = async () => {
2369
+ try {
2370
+ setStatus(await call(POCKET_ENDPOINTS.tunnelSetConfig, { mode: "quick" }));
2371
+ } catch (err) {
2372
+ setError(err.message);
2373
+ }
2374
+ };
2375
+ const saveNamedTunnel = async () => {
2376
+ try {
2377
+ setStatus(await call(POCKET_ENDPOINTS.tunnelSetConfig, {
2378
+ mode: "named",
2379
+ hostname: tunnelCfg?.hostname ?? "",
2380
+ token: tunnelCfg?.token || void 0
2381
+ // 留空不覆盖已存 Token
2382
+ }));
2383
+ setTunnelCfg(null);
2384
+ } catch (err) {
2385
+ setTunnelCfg((c) => ({ ...c, err: err.message }));
2386
+ }
2387
+ };
2388
+ const [resetOpen, setResetOpen] = (0, import_react2.useState)(false);
2389
+ const doFactoryReset = async () => {
2390
+ setResetOpen(false);
2391
+ setBusy(true);
2392
+ setError(null);
2393
+ try {
2394
+ setStatus(await call(POCKET_ENDPOINTS.pocketReset, { confirm: true }));
2395
+ setTunnelCfg(null);
2396
+ setCustomPin(null);
2397
+ setAdvOpen(false);
2398
+ showToast(t("resetDone"));
2399
+ } catch (err) {
2400
+ setError(err.message);
2401
+ showToast(t("resetFailed"));
2402
+ } finally {
2403
+ setBusy(false);
2404
+ }
2405
+ };
2406
+ const refreshLanPin = async () => {
2407
+ try {
2408
+ const r = await call(POCKET_ENDPOINTS.lanTokenRefresh, {});
2409
+ setStatus((s) => ({ ...s, lanToken: r.lanToken }));
2410
+ } catch {
2411
+ }
2412
+ };
2413
+ const setLanAuth = async (on) => {
2414
+ try {
2415
+ const r = await call(POCKET_ENDPOINTS.lanAuthSetEnabled, { on });
2416
+ setStatus((s) => ({ ...s, lanAuthEnabled: r.lanAuthEnabled }));
2417
+ } catch {
2418
+ }
2419
+ };
2420
+ const [lanToggleOpen, setLanToggleOpen] = (0, import_react2.useState)(null);
2421
+ const requestLanToggle = (on) => setLanToggleOpen(on);
2422
+ const confirmLanToggle = async () => {
2423
+ const on = lanToggleOpen;
2424
+ setLanToggleOpen(null);
2425
+ if (on === null) return;
2426
+ try {
2427
+ const r = await call(POCKET_ENDPOINTS.lanSetEnabled, { on });
2428
+ setStatus((s) => ({ ...s, lanEnabled: r.lanEnabled }));
2429
+ } catch (err) {
2430
+ setError(err.message);
2431
+ }
2432
+ };
2433
+ const setLanAddress = async (ip) => {
2434
+ try {
2435
+ setStatus(await call(POCKET_ENDPOINTS.lanSetOverride, { ip }));
2436
+ } catch (err) {
2437
+ setError(err.message);
2438
+ }
2439
+ };
2440
+ const [relayForm, setRelayForm] = (0, import_react2.useState)(null);
2441
+ const [consentOpen, setConsentOpen] = (0, import_react2.useState)(false);
2442
+ const [consentChecked, setConsentChecked] = (0, import_react2.useState)(false);
2443
+ const saveRelay = async (extra = {}) => {
2444
+ const f = relayForm;
2445
+ const payload = f ? {
2446
+ host: f.host ?? "",
2447
+ port: f.port ?? "",
2448
+ publicUrl: f.publicUrl ?? "",
2449
+ token: f.token || void 0,
2450
+ // 留空不覆盖
2451
+ agentId: f.agentId ?? "",
2452
+ // 留空 = 回到「按主机名派生」
2453
+ tls: f.tls === true,
2454
+ allowInsecure: f.allowInsecure === true
2455
+ } : {};
2456
+ setBusy(true);
2457
+ setError(null);
2458
+ try {
2459
+ setStatus(await call(POCKET_ENDPOINTS.relaySetConfig, { ...payload, ...extra }));
2460
+ if (f) setRelayForm(null);
2461
+ showToast(t("relaySaved"));
2462
+ void loadDevices();
2463
+ } catch (err) {
2464
+ if (/安全声明/.test(String(err.message))) {
2465
+ setConsentChecked(false);
2466
+ setConsentOpen(true);
2467
+ } else {
2468
+ setError(err.message);
2469
+ showToast(fmt(t, "relaySaveFailed", { err: errText(err.message) }));
2470
+ }
2471
+ } finally {
2472
+ setBusy(false);
2473
+ }
2474
+ };
2475
+ const confirmConsent = () => {
2476
+ if (!consentChecked) return;
2477
+ setConsentOpen(false);
2478
+ void saveRelay({ consent: true, enabled: true });
2479
+ };
2480
+ const toggleRelay = async (on) => {
2481
+ setBusy(true);
2482
+ try {
2483
+ setStatus(await call(POCKET_ENDPOINTS.relaySetEnabled, { on }));
2484
+ showToast(on ? t("relaySaved") : t("relayStateStopped"));
2485
+ } catch (err) {
2486
+ if (/安全声明/.test(String(err.message))) {
2487
+ setConsentChecked(false);
2488
+ setConsentOpen(true);
2489
+ } else {
2490
+ setError(err.message);
2491
+ }
2492
+ } finally {
2493
+ setBusy(false);
2494
+ }
2495
+ };
2496
+ const [devices, setDevices] = (0, import_react2.useState)({ devices: [], pending: [], status: null });
2497
+ const [pairing, setPairing] = (0, import_react2.useState)(null);
2498
+ const [deviceName, setDeviceName] = (0, import_react2.useState)("");
2499
+ const loadDevices = async () => {
2500
+ try {
2501
+ const r = await call(POCKET_ENDPOINTS.deviceList, {});
2502
+ setDevices({ devices: r.devices ?? [], pending: r.pending ?? [], status: r.status ?? null });
2503
+ } catch {
2504
+ }
2505
+ };
2506
+ (0, import_react2.useEffect)(() => {
2507
+ if (status?.relay?.enabled !== true) return void 0;
2508
+ void loadDevices();
2509
+ const timer = setInterval(loadDevices, 3e3);
2510
+ return () => clearInterval(timer);
2511
+ }, [status?.relay?.enabled]);
2512
+ const createPairing = async () => {
2513
+ setBusy(true);
2514
+ setError(null);
2515
+ try {
2516
+ const r = await call(POCKET_ENDPOINTS.devicePairingCreate, { name: deviceName || void 0 });
2517
+ setPairing({ code: r.code, pairUrl: r.pairUrl, pairQr: r.pairQr, expiresAt: r.expiresAt });
2518
+ setDevices({ devices: r.devices ?? [], pending: r.pending ?? [], status: r.status ?? null });
2519
+ setDeviceName("");
2520
+ } catch (err) {
2521
+ setError(err.message);
2522
+ } finally {
2523
+ setBusy(false);
2524
+ }
2525
+ };
2526
+ const cancelPairing = async () => {
2527
+ try {
2528
+ const r = await call(POCKET_ENDPOINTS.devicePairingCancel, {});
2529
+ setDevices({ devices: r.devices ?? [], pending: r.pending ?? [], status: r.status ?? null });
2530
+ } catch {
2531
+ }
2532
+ setPairing(null);
2533
+ };
2534
+ const deviceAction = async (endpoint, id, doneKey) => {
2535
+ try {
2536
+ const r = await call(endpoint, { id });
2537
+ setDevices({ devices: r.devices ?? [], pending: r.pending ?? [], status: r.status ?? null });
2538
+ showToast(t(doneKey));
2539
+ if (endpoint === POCKET_ENDPOINTS.deviceApprove) setPairing(null);
2540
+ } catch (err) {
2541
+ setError(err.message);
2542
+ }
2543
+ };
2544
+ const [revokeTarget, setRevokeTarget] = (0, import_react2.useState)(null);
2545
+ const [customPin, setCustomPin] = (0, import_react2.useState)(null);
2546
+ const saveCustomPin = async (which) => {
2547
+ try {
2548
+ const r = await call(POCKET_ENDPOINTS.pinSetCustom, { which, value: customPin?.value ?? "" });
2549
+ setStatus((s) => ({
2550
+ ...s,
2551
+ accessToken: which === "public" ? r.pin : s.accessToken,
2552
+ lanToken: which === "lan" ? r.pin : s.lanToken,
2553
+ publicPinCustom: which === "public" ? true : s.publicPinCustom,
2554
+ lanPinCustom: which === "lan" ? true : s.lanPinCustom
2555
+ }));
2556
+ setCustomPin(null);
2557
+ } catch (err) {
2558
+ setCustomPin((c) => ({ ...c, err: err.message }));
2559
+ }
2560
+ };
2561
+ const customPinRow = (which) => (0, import_react2.createElement)(
2562
+ "div",
2563
+ { style: { marginTop: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.5 } },
2564
+ t("customizing"),
2565
+ (0, import_react2.createElement)("input", {
2566
+ 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" },
2567
+ type: "password",
2568
+ maxLength: 8,
2569
+ value: customPin?.value ?? "",
2570
+ autoFocus: true,
2571
+ onChange: (e) => setCustomPin((c) => ({ ...c, value: e.target.value.replace(/[^a-zA-Z0-9]/g, ""), err: null })),
2572
+ onKeyDown: (e) => {
2573
+ if (e.key === "Enter") saveCustomPin(which);
2574
+ if (e.key === "Escape") setCustomPin(null);
2575
+ }
2576
+ }),
2577
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12, marginLeft: 2 }, onClick: () => saveCustomPin(which) }, t("save")),
2578
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: () => setCustomPin(null) }, t("cancel")),
2579
+ customPin?.err ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", marginTop: 4 } }, errText(customPin.err)) : null
2580
+ );
2581
+ const customBtn = (which) => (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12, marginLeft: 8 }, onClick: () => setCustomPin({ which, value: "", err: null }) }, t("customize"));
2582
+ const lanUrl = status?.lanUrl;
2583
+ const tunnelUrl = status?.tunnelUrl;
2584
+ const tunnelPhase = tunnelState?.phase ?? "idle";
2585
+ const tunnelStarting = ["downloading", "starting", "registering"].includes(tunnelPhase);
2586
+ const tunnelStateDetail = tunnelState?.detail ?? "";
2587
+ const tunnelStateStarted = tunnelState?.startedAt ?? null;
2588
+ const tunnelModeView = status?.tunnelConfig ?? { mode: "quick", hostname: "", tokenSet: false };
2589
+ const namedMode = tunnelModeView.mode === "named";
2590
+ const namedActive = namedMode || tunnelCfg !== null;
2591
+ const errText = (msg) => {
2592
+ const s = String(msg ?? "");
2593
+ const i = s.indexOf(" | ");
2594
+ if (i < 0) return s;
2595
+ return (t("ok") === zh2.ok ? s.slice(0, i) : s.slice(i + 3)).trim();
2596
+ };
2597
+ const [toast, setToast] = (0, import_react2.useState)(null);
2598
+ const toastTimer = (0, import_react2.useRef)(null);
2599
+ const showToast = (text) => {
2600
+ setToast(text);
2601
+ clearTimeout(toastTimer.current);
2602
+ toastTimer.current = setTimeout(() => setToast(null), 2600);
2603
+ };
2604
+ (0, import_react2.useEffect)(() => () => clearTimeout(toastTimer.current), []);
2605
+ const modeBtnStyle = (active) => ({
2606
+ ...styles.btn,
2607
+ height: 28,
2608
+ padding: "0 12px",
2609
+ fontSize: 12,
2610
+ fontWeight: active ? 600 : 400,
2611
+ background: active ? "var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))" : "var(--dsw-alias-bg-layer-1,#fff)",
2612
+ color: active ? "var(--dsw-alias-label-primary-foreground, #fff)" : "var(--dsw-alias-label-primary,inherit)"
2613
+ });
2614
+ const Switch = (on, onClick) => (0, import_react2.createElement)("button", {
2615
+ role: "switch",
2616
+ "aria-checked": !!on,
2617
+ 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)" },
2618
+ onClick
2619
+ }, (0, import_react2.createElement)("span", { style: { position: "absolute", top: 2, left: on ? 20 : 2, width: 18, height: 18, borderRadius: "50%", background: "#fff" } }));
2620
+ const qrArea = (src, url, hint) => (0, import_react2.createElement)(
2621
+ "div",
2622
+ { style: { background: "var(--dsw-alias-bg-layer-2,#f3f4f6)", borderRadius: 10, padding: "10px 12px", textAlign: "center", margin: "10px 0" } },
2623
+ (0, import_react2.createElement)("img", { src, alt: "QR", style: styles.qr }),
2624
+ (0, import_react2.createElement)("div", { style: styles.code }, url),
2625
+ (0, import_react2.createElement)("div", { style: styles.muted }, hint)
2626
+ );
2627
+ const row = (label, control, extra) => (0, import_react2.createElement)(
2628
+ "div",
2629
+ { style: { borderTop: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", paddingTop: 9, marginTop: 9 } },
2630
+ (0, import_react2.createElement)(
2631
+ "div",
2632
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 } },
2633
+ (0, import_react2.createElement)("span", { style: { fontSize: 13 } }, label),
2634
+ control
2635
+ ),
2636
+ extra ?? null
2637
+ );
2638
+ const [advOpen, setAdvOpen] = (0, import_react2.useState)(false);
2639
+ return (0, import_react2.createElement)(
2640
+ "div",
2641
+ { style: styles.card },
2642
+ (0, import_react2.createElement)(
2643
+ "div",
2644
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 } },
2645
+ (0, import_react2.createElement)(
2646
+ "div",
2647
+ null,
2648
+ (0, import_react2.createElement)("strong", null, t("title")),
2649
+ (0, import_react2.createElement)("div", { style: styles.muted }, t("subtitle"))
2650
+ ),
2651
+ (0, import_react2.createElement)(
2652
+ "div",
2653
+ { style: { fontSize: 12, color: "var(--dsw-alias-label-tertiary,#8b93a1)", textAlign: "right" } },
2654
+ (0, import_react2.createElement)("div", { style: { whiteSpace: "nowrap" } }, t("developer")),
2655
+ (0, import_react2.createElement)("div", { style: { whiteSpace: "nowrap" } }, t("starAsk")),
2656
+ (0, import_react2.createElement)(
2657
+ "a",
2658
+ { 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" } },
2659
+ t("starCta")
2660
+ )
2661
+ )
2662
+ ),
2663
+ // 桌面端不显示更新/重启横幅(更新由 DSH Desktop 管理),也不需要额外提示
2664
+ // 重启后提示(进程在后台运行,停止方法)——左侧蓝色色条(桌面端不会触发本插件的自重启)
2665
+ !isDesktop && restartNotice ? (0, import_react2.createElement)(
2666
+ "div",
2667
+ { 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" } },
2668
+ (0, import_react2.createElement)(
2669
+ "div",
2670
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 } },
2671
+ (0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 13 } }, t("restarted")),
2672
+ (0, import_react2.createElement)("button", { style: styles.btn, onClick: () => setRestartNotice(false) }, t("ok"))
2673
+ ),
2674
+ (0, import_react2.createElement)("div", { style: styles.muted, marginTop: 4, wordBreak: "break-all" }, fmt(t, "bgHint", { cmd: status?.killHint ?? `lsof -ti :${status?.dshPort ?? 3080} | xargs kill -9` }))
2675
+ ) : null,
2676
+ // 更新提示——左侧黄色色条(提示有新版本);单状态:有更新/更新中/已更新自动重启,不并存
2677
+ // 桌面端不渲染(更新由 DSH Desktop 管理)
2678
+ !isDesktop && updateInfo ? (0, import_react2.createElement)(
2679
+ "div",
2680
+ { 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" } },
2681
+ (0, import_react2.createElement)(
2682
+ "div",
2683
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 } },
2684
+ (0, import_react2.createElement)(
2685
+ "div",
2686
+ { style: { fontWeight: 600, fontSize: 13 } },
2687
+ updateInfo.updated ? fmt(t, "updatedRestart", { ver: updateInfo.current }) : updateInfo.result === "ok" ? updateInfo.autoRestart ? fmt(t, "updateAutoRestarting", { ver: updateInfo.latest }) : fmt(t, "updatedOk", { ver: updateInfo.latest }) : fmt(t, "updateAvailable", { ver: updateInfo.latest })
2688
+ ),
2689
+ updateInfo.result !== "ok" ? (0, import_react2.createElement)("button", { style: styles.primary, onClick: runUpdate, disabled: updateInfo.updating }, updateInfo.updating ? t("updating") : fmt(t, "updateTo", { ver: updateInfo.latest })) : updateInfo.autoRestart ? (0, import_react2.createElement)("button", { style: styles.btn, disabled: true }, t("restartingNow")) : (0, import_react2.createElement)("button", { style: styles.primary, onClick: restartPocket, disabled: updateInfo.restarting }, updateInfo.restarting ? t("restarting") : t("restartNow"))
2690
+ ),
2691
+ (0, import_react2.createElement)(
2692
+ "div",
2693
+ { style: styles.muted, marginTop: 4 },
2694
+ updateInfo.updating ? fmt(t, "updatingDetail", { s: elapsed(updateInfo.startedAt) }) : updateInfo.restarting ? fmt(t, "restartingDetail", { s: elapsed(updateInfo.startedAt) }) : updateInfo.result === "ok" ? updateInfo.autoRestart ? t("updatedAutoDetail") : t("updatedRestartDetail") : updateInfo.result === "fail" ? fmt(t, "updateFailed", { err: errText(updateInfo.output) || t("unknownError") }) : fmt(t, "versionRange", { cur: updateInfo.current, latest: updateInfo.latest })
2695
+ )
2696
+ ) : null,
2697
+ // 局域网:标题行自带总开关 → 二维码+地址 → 设置行(访问密码 / 高级·手动选地址)
2698
+ (0, import_react2.createElement)(
2699
+ "div",
2700
+ { style: styles.block },
2701
+ (0, import_react2.createElement)(
2702
+ "div",
2703
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between" } },
2704
+ (0, import_react2.createElement)("span", { style: { fontWeight: 600, fontSize: 13 } }, t("lanAccess")),
2705
+ Switch(status?.lanEnabled !== false, () => requestLanToggle(status?.lanEnabled === false))
2706
+ ),
2707
+ status?.lanEnabled === false ? (0, import_react2.createElement)("div", { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-state-warn-primary,#b45309)", lineHeight: 1.5 } }, t("lanDisabledHint")) : lanUrl ? (0, import_react2.createElement)(
2708
+ "div",
2709
+ null,
2710
+ qrArea(status.lanQr, lanUrl, t("lanHint")),
2711
+ // 访问密码行:开关 + 值(关闭时提示直连)
2712
+ row(
2713
+ t("lanPin"),
2714
+ Switch(status?.lanAuthEnabled !== false, () => setLanAuth(status?.lanAuthEnabled === false)),
2715
+ status?.lanAuthEnabled === false ? (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 6 } }, t("lanPinOff")) : customPin?.which === "lan" ? customPinRow("lan") : (0, import_react2.createElement)(
2716
+ "div",
2717
+ { style: { marginTop: 6, display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
2718
+ (0, import_react2.createElement)("span", { style: { fontFamily: "ui-monospace,Menlo,monospace", fontSize: 13, letterSpacing: 1 } }, status.lanToken),
2719
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: refreshLanPin }, t("refresh")),
2720
+ customBtn("lan"),
2721
+ status?.lanPinCustom ? (0, import_react2.createElement)("span", { style: { fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)" } }, t("pinCustomHint")) : null
2722
+ )
2723
+ ),
2724
+ // 高级:手动选地址(默认收起)
2725
+ row(
2726
+ t("advAddress"),
2727
+ (0, import_react2.createElement)(
2728
+ "button",
2729
+ { style: { border: "none", background: "none", font: "inherit", cursor: "pointer", fontSize: 12, color: "var(--dsw-alias-label-tertiary,#8b93a1)", padding: 0 }, onClick: () => setAdvOpen((v) => !v) },
2730
+ (status?.lanIpOverride || t("lanAddressAuto")) + " \u203A"
2731
+ ),
2732
+ advOpen ? (0, import_react2.createElement)(
2733
+ "div",
2734
+ { style: { marginTop: 8 } },
2735
+ (0, import_react2.createElement)(
2736
+ "label",
2737
+ { style: { display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } },
2738
+ t("lanAddress"),
2739
+ (0, import_react2.createElement)(
2740
+ "select",
2741
+ {
2742
+ value: status?.lanIpOverride || "",
2743
+ onChange: (e) => setLanAddress(e.target.value),
2744
+ 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)" }
2745
+ },
2746
+ (0, import_react2.createElement)("option", { value: "" }, t("lanAddressAuto")),
2747
+ (status?.lanCandidates || []).map((ip) => (0, import_react2.createElement)("option", { key: ip, value: ip }, ip))
2748
+ )
2749
+ )
2750
+ ) : null
2751
+ )
2752
+ ) : (0, import_react2.createElement)("div", { style: styles.muted }, t("lanStarting"))
2753
+ ),
2754
+ // 公网:标题行自带 开启/关闭 → 开启后:二维码+地址、地址模式行、访问密码行
2755
+ (0, import_react2.createElement)(
2756
+ "div",
2757
+ { style: styles.block },
2758
+ (0, import_react2.createElement)(
2759
+ "div",
2760
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between" } },
2761
+ (0, import_react2.createElement)("span", { style: { fontWeight: 600, fontSize: 13 } }, t("wanAccess")),
2762
+ tunnelUrl ? (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 28, padding: "0 12px", fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" }, onClick: stopTunnel }, t("stopTunnel")) : (0, import_react2.createElement)("button", { style: { ...styles.primary, height: 28, padding: "0 14px", fontSize: 12 }, onClick: startTunnel, disabled: busy || tunnelStarting }, busy || tunnelStarting ? t("opening") : t("enable"))
2763
+ ),
2764
+ tunnelStarting ? (0, import_react2.createElement)(
2765
+ "div",
2766
+ { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } },
2767
+ tunnelPhase === "downloading" ? fmt(t, "downloading", { s: elapsed(tunnelStateStarted) }) : fmt(t, "connecting", { s: elapsed(tunnelStateStarted), suffix: elapsed(tunnelStateStarted) > 30 ? t("slowHint") : "" })
2768
+ ) : tunnelPhase === "error" ? (0, import_react2.createElement)(
2769
+ "div",
2770
+ { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" } },
2771
+ fmt(t, "error", { detail: errText(tunnelStateDetail) || t("unknownError") })
2772
+ ) : !tunnelUrl && !isDesktop ? (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 8 } }, t("wanOffHint")) : null,
2773
+ tunnelUrl ? (0, import_react2.createElement)(
2774
+ "div",
2775
+ null,
2776
+ qrArea(status.tunnelQr, tunnelUrl, namedMode ? t("namedRunningHint") : t("wanHint")),
2777
+ // 防钓鱼 / 别收藏(issue #82):公网链接仅本次有效、勿收藏提示
2778
+ (0, import_react2.createElement)("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")),
2779
+ // 地址模式行(随机/固定;固定域名选中或编辑时高亮)
2780
+ row(
2781
+ t("modeLabel"),
2782
+ (0, import_react2.createElement)(
2783
+ "span",
2784
+ { style: { display: "inline-flex", gap: 6 } },
2785
+ (0, import_react2.createElement)("button", { style: modeBtnStyle(!namedActive), onClick: namedMode ? switchToQuick : tunnelCfg ? () => setTunnelCfg(null) : void 0 }, t("modeQuick")),
2786
+ (0, import_react2.createElement)("button", { style: modeBtnStyle(namedActive), onClick: () => setTunnelCfg(tunnelCfg ? null : { hostname: tunnelModeView.hostname ?? "", token: "", err: null }) }, t("modeNamed"))
2787
+ ),
2788
+ (0, import_react2.createElement)(
2789
+ "div",
2790
+ { style: { marginTop: 6 } },
2791
+ // 刚保存固定域名但当前连接仍是随机域名:需关闭后重新开启才生效
2792
+ namedMode && /trycloudflare\.com/i.test(tunnelUrl ?? "") ? (0, import_react2.createElement)("div", { style: { ...styles.warn } }, t("namedTakeEffect")) : null,
2793
+ // 固定域名:已保存摘要 + 修改入口(非编辑态)
2794
+ namedMode && !tunnelCfg ? (0, import_react2.createElement)(
2795
+ "div",
2796
+ { style: { ...styles.muted } },
2797
+ fmt(t, "namedSummary", { host: tunnelModeView.hostname || "\u2014", token: tunnelModeView.tokenSet ? t("namedTokenSet") : t("namedTokenMissing") }),
2798
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12, marginLeft: 8 }, onClick: () => setTunnelCfg({ hostname: tunnelModeView.hostname ?? "", token: "", err: null }) }, t("namedEdit")),
2799
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 4 } }, t("namedHow")),
2800
+ !tunnelModeView.tokenSet || !tunnelModeView.hostname ? (0, import_react2.createElement)("div", { style: { marginTop: 2, color: "var(--dsw-alias-state-error-primary,#dc2626)" } }, t("namedNeedCfg")) : null
2801
+ ) : null,
2802
+ // 固定域名:编辑表单(域名 + Tunnel Token,Token 留空保持不变)
2803
+ tunnelCfg ? (0, import_react2.createElement)(
2804
+ "div",
2805
+ { style: { marginTop: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.6 } },
2806
+ (0, import_react2.createElement)(
2807
+ "div",
2808
+ null,
2809
+ t("namedHostnameLabel"),
2810
+ (0, import_react2.createElement)("input", {
2811
+ 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 },
2812
+ placeholder: "pocket.example.com",
2813
+ value: tunnelCfg.hostname ?? "",
2814
+ autoFocus: true,
2815
+ onChange: (e) => setTunnelCfg((c) => ({ ...c, hostname: e.target.value.trim(), err: null })),
2816
+ onKeyDown: (e) => {
2817
+ if (e.key === "Enter") saveNamedTunnel();
2818
+ if (e.key === "Escape") setTunnelCfg(null);
2819
+ }
2820
+ })
2821
+ ),
2822
+ (0, import_react2.createElement)(
2823
+ "div",
2824
+ { style: { marginTop: 6 } },
2825
+ t("namedTokenLabel"),
2826
+ (0, import_react2.createElement)("input", {
2827
+ 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" },
2828
+ type: "password",
2829
+ value: tunnelCfg.token ?? "",
2830
+ onChange: (e) => setTunnelCfg((c) => ({ ...c, token: e.target.value.trim(), err: null })),
2831
+ onKeyDown: (e) => {
2832
+ if (e.key === "Enter") saveNamedTunnel();
2833
+ if (e.key === "Escape") setTunnelCfg(null);
2834
+ }
2835
+ })
2836
+ ),
2837
+ (0, import_react2.createElement)(
2838
+ "div",
2839
+ { style: { marginTop: 6, display: "flex", gap: 8 } },
2840
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: saveNamedTunnel }, t("save")),
2841
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: () => setTunnelCfg(null) }, t("cancel"))
2842
+ ),
2843
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 6 } }, t("namedHow")),
2844
+ (0, import_react2.createElement)("div", { style: { marginTop: 2, fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)", lineHeight: 1.5 } }, t("namedSecurity")),
2845
+ tunnelCfg.err ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", marginTop: 4 } }, errText(tunnelCfg.err)) : null
2846
+ ) : null
2847
+ )
2848
+ ),
2849
+ // 访问密码行:值 + 自定义(自定义输入态整体替换)
2850
+ status.accessToken ? row(
2851
+ t("pinLabel"),
2852
+ customPin?.which === "public" ? null : (0, import_react2.createElement)(
2853
+ "span",
2854
+ { style: { display: "inline-flex", alignItems: "center", gap: 8 } },
2855
+ (0, import_react2.createElement)("span", { style: { fontFamily: "ui-monospace,Menlo,monospace", fontSize: 13, letterSpacing: 1 } }, status.accessToken),
2856
+ customBtn("public")
2857
+ ),
2858
+ (0, import_react2.createElement)(
2859
+ "div",
2860
+ { style: { marginTop: 6 } },
2861
+ customPin?.which === "public" ? customPinRow("public") : null,
2862
+ status?.publicPinCustom ? (0, import_react2.createElement)("div", { style: { ...styles.warn } }, t("pinCustomHint")) : null,
2863
+ namedMode ? (0, import_react2.createElement)("div", { style: { ...styles.warn } }, t("namedSecurity")) : null
2864
+ )
2865
+ ) : null
2866
+ ) : null
2867
+ ),
2868
+ error ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", fontSize: 12, marginTop: 8 } }, `\u274C ${errText(error)}`) : null,
2869
+ // ---------- 中继(自建服务器)+ 设备管理 ----------
2870
+ (() => {
2871
+ const relay = status?.relay ?? { enabled: false, consent: false, host: "", port: 0, tls: false, allowInsecure: false, tokenSet: false, publicUrl: "" };
2872
+ const rs = status?.relayState ?? { phase: "idle" };
2873
+ const configured = Boolean(relay.host && relay.port && relay.tokenSet && relay.publicUrl);
2874
+ const on = relay.enabled === true;
2875
+ const stateColor = !on ? "var(--dsw-alias-label-tertiary,#8b93a1)" : rs.phase === "online" ? "var(--dsw-alias-state-success-primary,#16a34a)" : rs.phase === "error" ? "var(--dsw-alias-state-error-primary,#dc2626)" : "var(--dsw-alias-state-warn-primary,#b45309)";
2876
+ const stateText = !on ? t("relayStateIdle") : rs.phase === "online" ? t("relayStateOnline") : rs.phase === "error" ? t("relayStateError") : t("relayStateConnecting");
2877
+ const f = relayForm;
2878
+ const input = (key, props) => (0, import_react2.createElement)("input", {
2879
+ style: { ...styles.input, flex: 1, minWidth: 0 },
2880
+ value: (f?.[key] ?? "") === null ? "" : String(f?.[key] ?? ""),
2881
+ onChange: (e) => setRelayForm((c) => ({ ...c ?? {}, [key]: e.target.value, err: null })),
2882
+ ...props
2883
+ });
2884
+ return (0, import_react2.createElement)(
2885
+ "div",
2886
+ { style: styles.block },
2887
+ // 标题行:名称 + 状态点 + 常开开关
2888
+ (0, import_react2.createElement)(
2889
+ "div",
2890
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 } },
2891
+ (0, import_react2.createElement)(
2892
+ "span",
2893
+ { style: { display: "inline-flex", alignItems: "center", fontWeight: 600, fontSize: 13 } },
2894
+ (0, import_react2.createElement)("span", { style: styles.dot(stateColor) }),
2895
+ t("relayTitle")
2896
+ ),
2897
+ (0, import_react2.createElement)(
2898
+ "div",
2899
+ { style: { display: "inline-flex", alignItems: "center", gap: 8 } },
2900
+ (0, import_react2.createElement)("span", { style: { fontSize: 12, color: stateColor, whiteSpace: "nowrap" } }, stateText),
2901
+ configured ? Switch(on, () => toggleRelay(!on)) : null
2902
+ )
2903
+ ),
2904
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 6 } }, t("relayIntro")),
2905
+ // 运行信息:重连次数 / 在途连接 / 本机在 relay 上的名字(多机排障要看的三个数)
2906
+ on && rs.phase !== "idle" ? (0, import_react2.createElement)(
2907
+ "div",
2908
+ { style: { ...styles.muted, marginTop: 4 } },
2909
+ `${fmt(t, "relayReconnects", { n: rs.reconnects ?? 0 })} \xB7 ${fmt(t, "relayStreams", { n: rs.streams ?? 0 })} \xB7 ${fmt(t, "relayThisPc", { id: rs.agentId || relay.agentId || "\u2014" })}` + (rs.phase === "error" && rs.detail ? ` \xB7 ${errText(rs.detail)}` : "")
2910
+ ) : null,
2911
+ // 配置摘要(非编辑态)
2912
+ f === null ? (0, import_react2.createElement)(
2913
+ "div",
2914
+ { style: { ...styles.muted, marginTop: 6 } },
2915
+ configured ? `${relay.host}:${relay.port} \xB7 ${relay.publicUrl} \xB7 Token ${relay.tokenSet ? t("relayTokenSet") : t("relayTokenMissing")}` : t("relayNeedCfg"),
2916
+ (0, import_react2.createElement)("button", {
2917
+ style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12, marginLeft: 8 },
2918
+ onClick: () => setRelayForm({ ...relay, token: "", err: null })
2919
+ }, configured ? t("namedEdit") : t("relaySave"))
2920
+ ) : null,
2921
+ // 配置表单
2922
+ f !== null ? (0, import_react2.createElement)(
2923
+ "div",
2924
+ { style: { marginTop: 8 } },
2925
+ (0, import_react2.createElement)(
2926
+ "div",
2927
+ { style: styles.field },
2928
+ (0, import_react2.createElement)("span", { style: styles.fieldLabel }, t("relayHostLabel")),
2929
+ input("host", { placeholder: t("relayHostPh"), autoFocus: true })
2930
+ ),
2931
+ (0, import_react2.createElement)(
2932
+ "div",
2933
+ { style: styles.field },
2934
+ (0, import_react2.createElement)("span", { style: styles.fieldLabel }, t("relayPortLabel")),
2935
+ input("port", { placeholder: "8444", inputMode: "numeric" })
2936
+ ),
2937
+ (0, import_react2.createElement)(
2938
+ "div",
2939
+ { style: styles.field },
2940
+ (0, import_react2.createElement)("span", { style: styles.fieldLabel }, t("relayPublicUrlLabel")),
2941
+ input("publicUrl", { placeholder: t("relayPublicUrlPh") })
2942
+ ),
2943
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, margin: "-2px 0 6px 104px" } }, t("relayPublicUrlHint")),
2944
+ (0, import_react2.createElement)(
2945
+ "div",
2946
+ { style: styles.field },
2947
+ (0, import_react2.createElement)("span", { style: styles.fieldLabel }, t("relayTokenLabel")),
2948
+ input("token", { type: "password", placeholder: relay.tokenSet ? t("relayTokenSet") : "" })
2949
+ ),
2950
+ (0, import_react2.createElement)(
2951
+ "div",
2952
+ { style: styles.field },
2953
+ (0, import_react2.createElement)("span", { style: styles.fieldLabel }, t("relayAgentIdLabel")),
2954
+ input("agentId", { placeholder: t("relayAgentIdPh") })
2955
+ ),
2956
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, margin: "-2px 0 6px 104px" } }, t("relayAgentIdHint")),
2957
+ (0, import_react2.createElement)(
2958
+ "label",
2959
+ { style: { ...styles.field, cursor: "pointer" } },
2960
+ (0, import_react2.createElement)("span", { style: styles.fieldLabel }, t("relayTlsLabel")),
2961
+ (0, import_react2.createElement)("input", { type: "checkbox", checked: f.tls === true, onChange: (e) => setRelayForm((c) => ({ ...c, tls: e.target.checked })) })
2962
+ ),
2963
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, margin: "-2px 0 6px 104px" } }, t("relayTlsHint")),
2964
+ f.tls === true ? (0, import_react2.createElement)(
2965
+ "div",
2966
+ null,
2967
+ (0, import_react2.createElement)(
2968
+ "label",
2969
+ { style: { ...styles.field, cursor: "pointer" } },
2970
+ (0, import_react2.createElement)("span", { style: styles.fieldLabel }, t("relayInsecureLabel")),
2971
+ (0, import_react2.createElement)("input", { type: "checkbox", checked: f.allowInsecure === true, onChange: (e) => setRelayForm((c) => ({ ...c, allowInsecure: e.target.checked })) })
2972
+ ),
2973
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, margin: "-2px 0 6px 104px" } }, t("relayInsecureHint"))
2974
+ ) : null,
2975
+ (0, import_react2.createElement)(
2976
+ "div",
2977
+ { style: { display: "flex", gap: 8, marginTop: 8 } },
2978
+ (0, import_react2.createElement)("button", { style: styles.primary, disabled: busy, onClick: () => saveRelay() }, busy ? t("relaySaving") : t("relaySave")),
2979
+ (0, import_react2.createElement)("button", { style: styles.btn, onClick: () => setRelayForm(null) }, t("cancel"))
2980
+ )
2981
+ ) : null,
2982
+ // 对外地址二维码(配置完整时一直显示,手机扫了就能存下来)
2983
+ configured && relay.publicUrl ? (0, import_react2.createElement)(
2984
+ "div",
2985
+ { style: { marginTop: 10, background: "var(--dsw-alias-bg-layer-2,#f3f4f6)", borderRadius: 10, padding: "10px 12px", textAlign: "center" } },
2986
+ status?.relayQr ? (0, import_react2.createElement)("img", { src: status.relayQr, alt: "QR", style: styles.qr }) : null,
2987
+ (0, import_react2.createElement)("div", { style: styles.code }, relay.publicUrl),
2988
+ (0, import_react2.createElement)("div", { style: styles.muted }, t("relayQrHint"))
2989
+ ) : null,
2990
+ // ----- 设备管理 -----
2991
+ configured ? (0, import_react2.createElement)(
2992
+ "div",
2993
+ { style: { marginTop: 12, borderTop: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", paddingTop: 10 } },
2994
+ (0, import_react2.createElement)(
2995
+ "div",
2996
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 } },
2997
+ (0, import_react2.createElement)(
2998
+ "span",
2999
+ { style: { fontWeight: 600, fontSize: 13 } },
3000
+ `${t("deviceTitle")} \xB7 ${fmt(t, "deviceCount", { n: devices.devices.length })}`
3001
+ ),
3002
+ (0, import_react2.createElement)(
3003
+ "button",
3004
+ { style: styles.btn, disabled: busy || pairing !== null, onClick: createPairing },
3005
+ busy ? t("deviceAdding") : t("deviceAdd")
3006
+ )
3007
+ ),
3008
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 4 } }, t("deviceIntro")),
3009
+ // 配对二维码面板
3010
+ pairing ? (0, import_react2.createElement)(
3011
+ "div",
3012
+ { style: { marginTop: 8, background: "var(--dsw-alias-bg-layer-2,#f3f4f6)", borderRadius: 10, padding: "10px 12px", textAlign: "center" } },
3013
+ (0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 13 } }, t("devicePairTitle")),
3014
+ pairing.pairQr ? (0, import_react2.createElement)("img", { src: pairing.pairQr, alt: "Pair QR", style: styles.qr }) : null,
3015
+ (0, import_react2.createElement)("div", { style: styles.code }, pairing.pairUrl),
3016
+ (0, import_react2.createElement)("div", { style: styles.muted }, t("devicePairHint")),
3017
+ (0, import_react2.createElement)(
3018
+ "div",
3019
+ { style: { ...styles.muted, marginTop: 4 } },
3020
+ fmt(t, "devicePairExpire", { time: new Date(pairing.expiresAt).toLocaleTimeString() })
3021
+ ),
3022
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12, marginTop: 6 }, onClick: cancelPairing }, t("devicePairCancel"))
3023
+ ) : null,
3024
+ // 待批准
3025
+ devices.pending.length > 0 ? (0, import_react2.createElement)(
3026
+ "div",
3027
+ { style: { marginTop: 8 } },
3028
+ (0, import_react2.createElement)("div", { style: { fontSize: 12, fontWeight: 600, color: "var(--dsw-alias-state-warn-primary,#b45309)" } }, t("devicePendingTitle")),
3029
+ devices.pending.map((d) => (0, import_react2.createElement)(
3030
+ "div",
3031
+ { key: d.id, style: styles.deviceRow },
3032
+ (0, import_react2.createElement)("span", { style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, d.name),
3033
+ (0, import_react2.createElement)(
3034
+ "span",
3035
+ { style: { display: "inline-flex", gap: 6, flexShrink: 0 } },
3036
+ (0, import_react2.createElement)("button", { style: { ...styles.primary, height: 24, padding: "0 10px", fontSize: 12 }, onClick: () => deviceAction(POCKET_ENDPOINTS.deviceApprove, d.id, "deviceApprovedDone") }, t("deviceApprove")),
3037
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 24, padding: "0 10px", fontSize: 12 }, onClick: () => deviceAction(POCKET_ENDPOINTS.deviceReject, d.id, "deviceRejectedDone") }, t("deviceReject"))
3038
+ )
3039
+ ))
3040
+ ) : null,
3041
+ // 已批准
3042
+ devices.devices.length === 0 ? (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 6 } }, t("deviceNone")) : devices.devices.map((d) => (0, import_react2.createElement)(
3043
+ "div",
3044
+ { key: d.id, style: styles.deviceRow },
3045
+ (0, import_react2.createElement)(
3046
+ "span",
3047
+ { style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
3048
+ d.name,
3049
+ (0, import_react2.createElement)(
3050
+ "span",
3051
+ { style: { ...styles.muted, marginLeft: 6 } },
3052
+ `${t("deviceLastLogin")}: ${d.lastLoginAt ? new Date(d.lastLoginAt).toLocaleString() : t("deviceNever")}`
3053
+ )
3054
+ ),
3055
+ (0, import_react2.createElement)("button", {
3056
+ style: { ...styles.btn, height: 24, padding: "0 10px", fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)", flexShrink: 0 },
3057
+ onClick: () => setRevokeTarget(d)
3058
+ }, t("deviceRevoke"))
3059
+ ))
3060
+ ) : null
3061
+ );
3062
+ })(),
3063
+ // 恢复出厂设置:设置出问题时的临时兜底(最底部,避免误触)
3064
+ (0, import_react2.createElement)(
3065
+ "div",
3066
+ { style: styles.block },
3067
+ (0, import_react2.createElement)(
3068
+ "div",
3069
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 } },
3070
+ (0, import_react2.createElement)("span", { style: { fontWeight: 600, fontSize: 13 } }, t("resetFactory")),
3071
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 28, padding: "0 12px", fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" }, onClick: () => setResetOpen(true) }, t("resetGo"))
3072
+ ),
3073
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 6 } }, t("resetIntro"))
3074
+ ),
3075
+ // 恢复出厂设置确认弹框
3076
+ resetOpen ? (0, import_react2.createElement)(
3077
+ "div",
3078
+ { style: { position: "fixed", inset: 0, zIndex: 1e4, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 } },
3079
+ (0, import_react2.createElement)(
3080
+ "div",
3081
+ { 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)" } },
3082
+ (0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 15, color: "var(--dsw-alias-state-warn-primary,#b45309)", marginBottom: 10 } }, t("resetTitle")),
3083
+ (0, import_react2.createElement)("div", { style: { fontSize: 13, lineHeight: 1.7, color: "var(--dsw-alias-label-primary,inherit)", whiteSpace: "pre-line" } }, t("resetBody")),
3084
+ (0, import_react2.createElement)(
3085
+ "div",
3086
+ { style: { display: "flex", gap: 8, marginTop: 16 } },
3087
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, flex: 1 }, onClick: () => setResetOpen(false) }, t("cancel")),
3088
+ (0, import_react2.createElement)("button", { style: { ...styles.primary, flex: 1, background: "var(--dsw-alias-state-error-primary,#dc2626)" }, onClick: doFactoryReset }, t("resetConfirm"))
3089
+ )
3090
+ )
3091
+ ) : null,
3092
+ // Toast:重置等操作的即时反馈(固定屏幕正中央,2.6s 自动消失)
3093
+ toast ? (0, import_react2.createElement)("div", {
3094
+ 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)" }
3095
+ }, toast) : null,
3096
+ // 局域网访问开关确认弹框(关闭/打开时弹窗提醒)
3097
+ lanToggleOpen !== null ? (0, import_react2.createElement)(
3098
+ "div",
3099
+ { style: { position: "fixed", inset: 0, zIndex: 1e4, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 } },
3100
+ (0, import_react2.createElement)(
3101
+ "div",
3102
+ { 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)" } },
3103
+ (0, import_react2.createElement)("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")),
3104
+ (0, import_react2.createElement)("div", { style: { fontSize: 13, lineHeight: 1.7, color: "var(--dsw-alias-label-primary,inherit)" } }, t(lanToggleOpen ? "lanToggleBodyOn" : "lanToggleBodyOff")),
3105
+ (0, import_react2.createElement)(
3106
+ "div",
3107
+ { style: { display: "flex", gap: 8, marginTop: 16 } },
3108
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, flex: 1 }, onClick: () => setLanToggleOpen(null) }, t("cancel")),
3109
+ (0, import_react2.createElement)("button", { style: { ...styles.primary, flex: 1 }, onClick: confirmLanToggle }, t("confirm"))
3110
+ )
3111
+ )
3112
+ ) : null,
3113
+ // 安全免责声明弹框(issue #31):每次开启公网访问前确认
3114
+ disclaimerOpen ? (0, import_react2.createElement)(
3115
+ "div",
3116
+ { style: { position: "fixed", inset: 0, zIndex: 1e4, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 } },
3117
+ (0, import_react2.createElement)(
3118
+ "div",
3119
+ { 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)" } },
3120
+ (0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 15, color: "var(--dsw-alias-state-warn-primary,#b45309)", marginBottom: 10 } }, t("disclaimerTitle")),
3121
+ (0, import_react2.createElement)("div", { style: { fontSize: 13, lineHeight: 1.7, color: "var(--dsw-alias-label-primary,inherit)" } }, t("disclaimerBody")),
3122
+ (0, import_react2.createElement)(
3123
+ "label",
3124
+ { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 14, fontSize: 13, cursor: "pointer" } },
3125
+ (0, import_react2.createElement)("input", { type: "checkbox", checked: disclaimerChecked, onChange: (e) => setDisclaimerChecked(e.target.checked), style: { width: 16, height: 16 } }),
3126
+ t("disclaimerAgree")
3127
+ ),
3128
+ (0, import_react2.createElement)(
3129
+ "div",
3130
+ { style: { display: "flex", gap: 8, marginTop: 16 } },
3131
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, flex: 1 }, onClick: () => setDisclaimerOpen(false) }, t("cancel")),
3132
+ (0, import_react2.createElement)("button", {
3133
+ style: { ...styles.primary, flex: 1, opacity: disclaimerChecked ? 1 : 0.5 },
3134
+ disabled: !disclaimerChecked,
3135
+ onClick: confirmDisclaimer
3136
+ }, t("disclaimerAgree"))
3137
+ ),
3138
+ !disclaimerChecked ? (0, import_react2.createElement)("div", { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" } }, t("disclaimerHint")) : null
3139
+ )
3140
+ ) : null,
3141
+ // 常开知情同意(一次性):相对 cloudflared「每次开启都弹」,这里刻意的放宽——
3142
+ // 常开的意义就是不用每次点,所以同意改为配置阶段勾一次并由服务端强制校验。
3143
+ consentOpen ? (0, import_react2.createElement)(
3144
+ "div",
3145
+ { style: { position: "fixed", inset: 0, zIndex: 1e4, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 } },
3146
+ (0, import_react2.createElement)(
3147
+ "div",
3148
+ { 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" } },
3149
+ (0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 15, color: "var(--dsw-alias-state-warn-primary,#b45309)", marginBottom: 10 } }, t("relayConsentTitle")),
3150
+ (0, import_react2.createElement)("div", { style: { fontSize: 13, lineHeight: 1.75, color: "var(--dsw-alias-label-primary,inherit)", whiteSpace: "pre-line" } }, t("relayConsentBody")),
3151
+ (0, import_react2.createElement)(
3152
+ "label",
3153
+ { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 14, fontSize: 13, cursor: "pointer" } },
3154
+ (0, import_react2.createElement)("input", { type: "checkbox", checked: consentChecked, onChange: (e) => setConsentChecked(e.target.checked), style: { width: 16, height: 16 } }),
3155
+ t("relayConsentAgree")
3156
+ ),
3157
+ (0, import_react2.createElement)(
3158
+ "div",
3159
+ { style: { display: "flex", gap: 8, marginTop: 16 } },
3160
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, flex: 1 }, onClick: () => setConsentOpen(false) }, t("cancel")),
3161
+ (0, import_react2.createElement)("button", {
3162
+ style: { ...styles.primary, flex: 1, opacity: consentChecked ? 1 : 0.5 },
3163
+ disabled: !consentChecked,
3164
+ onClick: confirmConsent
3165
+ }, t("relayConsentAgree"))
3166
+ ),
3167
+ !consentChecked ? (0, import_react2.createElement)("div", { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" } }, t("relayConsentHint")) : null
3168
+ )
3169
+ ) : null,
3170
+ // 撤销设备确认(撤销 = 那台手机要重新配对,不可轻点)
3171
+ revokeTarget ? (0, import_react2.createElement)(
3172
+ "div",
3173
+ { style: { position: "fixed", inset: 0, zIndex: 1e4, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 } },
3174
+ (0, import_react2.createElement)(
3175
+ "div",
3176
+ { 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)" } },
3177
+ (0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 15, color: "var(--dsw-alias-state-error-primary,#dc2626)", marginBottom: 10 } }, `${t("deviceRevoke")} \xB7 ${revokeTarget.name}`),
3178
+ (0, import_react2.createElement)("div", { style: { fontSize: 13, lineHeight: 1.7, color: "var(--dsw-alias-label-primary,inherit)" } }, t("deviceRevokeConfirm")),
3179
+ (0, import_react2.createElement)(
3180
+ "div",
3181
+ { style: { display: "flex", gap: 8, marginTop: 16 } },
3182
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, flex: 1 }, onClick: () => setRevokeTarget(null) }, t("cancel")),
3183
+ (0, import_react2.createElement)("button", {
3184
+ style: { ...styles.primary, flex: 1, background: "var(--dsw-alias-state-error-primary,#dc2626)" },
3185
+ onClick: () => {
3186
+ const d = revokeTarget;
3187
+ setRevokeTarget(null);
3188
+ void deviceAction(POCKET_ENDPOINTS.deviceRevoke, d.id, "deviceRevokeDone");
3189
+ }
3190
+ }, t("deviceRevoke"))
3191
+ )
3192
+ )
3193
+ ) : null,
3194
+ // 页面最底部:反馈入口
3195
+ (0, import_react2.createElement)(
3196
+ "div",
3197
+ { style: { ...styles.block, textAlign: "center" } },
3198
+ (0, import_react2.createElement)(
3199
+ "a",
3200
+ { 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" } },
3201
+ t("feedback")
3202
+ )
3203
+ )
3204
+ );
3205
+ }
3206
+ function apply(ctx) {
3207
+ if (ctx?.connection) {
3208
+ try {
3209
+ Object.defineProperty(ctx.connection, "isLoopback", { value: true, writable: true, configurable: true });
3210
+ } catch {
3211
+ try {
3212
+ ctx.connection.isLoopback = true;
3213
+ } catch {
3214
+ }
3215
+ }
3216
+ }
3217
+ mobileApply(ctx);
3218
+ const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(POCKET_RPC_CHANNEL, endpoint, payload, signal);
3219
+ const translate = ctx.locale.bind(NS2);
3220
+ ctx.effect(() => ctx.locale.register(NS2, { zh: zh2, en: en2 }), "dsh-pocket: pocket locale dictionaries");
3221
+ ctx.slots.inject(
3222
+ "settings.section",
3223
+ () => ctx.slots.register(
3224
+ {
3225
+ name: "settings.section",
3226
+ id: "pocket",
3227
+ order: 1,
3228
+ label: () => translate("section"),
3229
+ inject: () => ({ rpcCall, t: translate })
3230
+ },
3231
+ PocketSettingsTab
3232
+ )
3233
+ );
3234
+ }
3235
+
3236
+ return module.exports;
3237
+ }
3238
+ });