dsh-pocket 1.16.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/api.js CHANGED
@@ -14,6 +14,7 @@ export const POCKET_ENDPOINTS = Object.freeze({
14
14
  lanSetOverride: 'lan.setOverride',
15
15
  lanSetEnabled: 'lan.setEnabled',
16
16
  pinSetCustom: 'pin.setCustom',
17
+ pocketReset: 'pocket.reset',
17
18
  });
18
19
 
19
20
  /** 语义化版本比较:a > b 返回正数,相等 0,a < b 负数(数字段 + 预发布后缀)。 */
package/client/client.js CHANGED
@@ -53,7 +53,8 @@ var POCKET_ENDPOINTS = Object.freeze({
53
53
  lanAuthSetEnabled: "lanAuth.setEnabled",
54
54
  lanSetOverride: "lan.setOverride",
55
55
  lanSetEnabled: "lan.setEnabled",
56
- pinSetCustom: "pin.setCustom"
56
+ pinSetCustom: "pin.setCustom",
57
+ pocketReset: "pocket.reset"
57
58
  });
58
59
  function compareVersions(a, b) {
59
60
  const pa = String(a).replace(/^[vV]/, "").split(".");
@@ -1365,6 +1366,19 @@ var zh2 = {
1365
1366
  "updatedRestartDetail": "\u2705 \u5DF2\u66F4\u65B0\uFF0C\u91CD\u542F dsh web \u751F\u6548",
1366
1367
  "updateFailed": "\u274C \u5931\u8D25\uFF1A{err}\uFF08\u624B\u52A8\u66F4\u65B0\uFF1Adsh plugin --profile web update dsh-pocket --latest -w\uFF09",
1367
1368
  "versionRange": "\u5F53\u524D v{cur} \u2192 \u6700\u65B0 v{latest}",
1369
+ "wanAccess": "\u516C\u7F51\u8BBF\u95EE",
1370
+ "pinLabel": "\u8BBF\u95EE\u5BC6\u7801",
1371
+ "modeLabel": "\u5730\u5740\u6A21\u5F0F",
1372
+ "advAddress": "\u9AD8\u7EA7 \xB7 \u624B\u52A8\u9009\u5730\u5740",
1373
+ "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",
1374
+ "resetFactory": "\u{1F9F9} \u6062\u590D\u51FA\u5382\u8BBE\u7F6E",
1375
+ "resetGo": "\u6062\u590D",
1376
+ "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",
1377
+ "resetTitle": "\u26A0\uFE0F \u786E\u8BA4\u6062\u590D\u51FA\u5382\u8BBE\u7F6E\uFF1F",
1378
+ "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",
1379
+ "resetConfirm": "\u786E\u8BA4\u6062\u590D",
1380
+ "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",
1381
+ "resetFailed": "\u274C \u6062\u590D\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5",
1368
1382
  "lanTitle": "\u{1F4F6} \u5C40\u57DF\u7F51\uFF08\u540C\u4E00 WiFi\uFF09",
1369
1383
  "lanHint": "\u624B\u673A\u8FDE\u63A5\u540C\u4E00 WiFi \u540E\u626B\u7801\u5373\u53EF\u6253\u5F00",
1370
1384
  "lanAccess": "\u5C40\u57DF\u7F51\u8BBF\u95EE",
@@ -1447,6 +1461,19 @@ var en2 = {
1447
1461
  "updatedRestartDetail": "\u2705 Updated \u2014 restart dsh web to apply",
1448
1462
  "updateFailed": "\u274C Failed: {err} (manual update: dsh plugin --profile web update dsh-pocket --latest -w)",
1449
1463
  "versionRange": "Current v{cur} \u2192 latest v{latest}",
1464
+ "wanAccess": "Public access",
1465
+ "pinLabel": "Access PIN",
1466
+ "modeLabel": "Address mode",
1467
+ "advAddress": "Advanced \xB7 Pick address",
1468
+ "wanOffHint": "Reachable from any network once enabled (a disclaimer is confirmed on each enable)",
1469
+ "resetFactory": "\u{1F9F9} Factory reset",
1470
+ "resetGo": "Reset",
1471
+ "resetIntro": "Temporary fallback when settings break: clear local config and re-roll random PINs (DSH sessions, models and plugin config are untouched)",
1472
+ "resetTitle": "\u26A0\uFE0F Confirm factory reset?",
1473
+ "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.",
1474
+ "resetConfirm": "Reset",
1475
+ "resetDone": "\u2705 Factory reset done: settings cleared and PINs re-rolled (re-enter the PIN on your phone)",
1476
+ "resetFailed": "\u274C Reset failed \u2014 please retry",
1450
1477
  "lanTitle": "\u{1F4F6} LAN (same Wi-Fi)",
1451
1478
  "lanHint": "Scan to open once your phone is on the same Wi-Fi",
1452
1479
  "lanAccess": "LAN access",
@@ -1692,6 +1719,24 @@ function PocketSettingsTab({ rpcCall, t }) {
1692
1719
  setTunnelCfg((c) => ({ ...c, err: err.message }));
1693
1720
  }
1694
1721
  };
1722
+ const [resetOpen, setResetOpen] = (0, import_react2.useState)(false);
1723
+ const doFactoryReset = async () => {
1724
+ setResetOpen(false);
1725
+ setBusy(true);
1726
+ setError(null);
1727
+ try {
1728
+ setStatus(await call(POCKET_ENDPOINTS.pocketReset, { confirm: true }));
1729
+ setTunnelCfg(null);
1730
+ setCustomPin(null);
1731
+ setAdvOpen(false);
1732
+ showToast(t("resetDone"));
1733
+ } catch (err) {
1734
+ setError(err.message);
1735
+ showToast(t("resetFailed"));
1736
+ } finally {
1737
+ setBusy(false);
1738
+ }
1739
+ };
1695
1740
  const refreshLanPin = async () => {
1696
1741
  try {
1697
1742
  const r = await call(POCKET_ENDPOINTS.lanTokenRefresh, {});
@@ -1760,7 +1805,7 @@ function PocketSettingsTab({ rpcCall, t }) {
1760
1805
  }),
1761
1806
  (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12, marginLeft: 2 }, onClick: () => saveCustomPin(which) }, t("save")),
1762
1807
  (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: () => setCustomPin(null) }, t("cancel")),
1763
- customPin?.err ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", marginTop: 4 } }, customPin.err) : null
1808
+ customPin?.err ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", marginTop: 4 } }, errText(customPin.err)) : null
1764
1809
  );
1765
1810
  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"));
1766
1811
  const lanUrl = status?.lanUrl;
@@ -1772,6 +1817,20 @@ function PocketSettingsTab({ rpcCall, t }) {
1772
1817
  const tunnelModeView = status?.tunnelConfig ?? { mode: "quick", hostname: "", tokenSet: false };
1773
1818
  const namedMode = tunnelModeView.mode === "named";
1774
1819
  const namedActive = namedMode || tunnelCfg !== null;
1820
+ const errText = (msg) => {
1821
+ const s = String(msg ?? "");
1822
+ const i = s.indexOf(" | ");
1823
+ if (i < 0) return s;
1824
+ return (t("ok") === zh2.ok ? s.slice(0, i) : s.slice(i + 3)).trim();
1825
+ };
1826
+ const [toast, setToast] = (0, import_react2.useState)(null);
1827
+ const toastTimer = (0, import_react2.useRef)(null);
1828
+ const showToast = (text) => {
1829
+ setToast(text);
1830
+ clearTimeout(toastTimer.current);
1831
+ toastTimer.current = setTimeout(() => setToast(null), 2600);
1832
+ };
1833
+ (0, import_react2.useEffect)(() => () => clearTimeout(toastTimer.current), []);
1775
1834
  const modeBtnStyle = (active) => ({
1776
1835
  ...styles.btn,
1777
1836
  height: 28,
@@ -1781,6 +1840,31 @@ function PocketSettingsTab({ rpcCall, t }) {
1781
1840
  background: active ? "var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))" : "var(--dsw-alias-bg-layer-1,#fff)",
1782
1841
  color: active ? "var(--dsw-alias-label-primary-foreground, #fff)" : "var(--dsw-alias-label-primary,inherit)"
1783
1842
  });
1843
+ const Switch = (on, onClick) => (0, import_react2.createElement)("button", {
1844
+ role: "switch",
1845
+ "aria-checked": !!on,
1846
+ 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)" },
1847
+ onClick
1848
+ }, (0, import_react2.createElement)("span", { style: { position: "absolute", top: 2, left: on ? 20 : 2, width: 18, height: 18, borderRadius: "50%", background: "#fff" } }));
1849
+ const qrArea = (src, url, hint) => (0, import_react2.createElement)(
1850
+ "div",
1851
+ { style: { background: "var(--dsw-alias-bg-layer-2,#f3f4f6)", borderRadius: 10, padding: "10px 12px", textAlign: "center", margin: "10px 0" } },
1852
+ (0, import_react2.createElement)("img", { src, alt: "QR", style: styles.qr }),
1853
+ (0, import_react2.createElement)("div", { style: styles.code }, url),
1854
+ (0, import_react2.createElement)("div", { style: styles.muted }, hint)
1855
+ );
1856
+ const row = (label, control, extra) => (0, import_react2.createElement)(
1857
+ "div",
1858
+ { style: { borderTop: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", paddingTop: 9, marginTop: 9 } },
1859
+ (0, import_react2.createElement)(
1860
+ "div",
1861
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 } },
1862
+ (0, import_react2.createElement)("span", { style: { fontSize: 13 } }, label),
1863
+ control
1864
+ ),
1865
+ extra ?? null
1866
+ );
1867
+ const [advOpen, setAdvOpen] = (0, import_react2.useState)(false);
1784
1868
  return (0, import_react2.createElement)(
1785
1869
  "div",
1786
1870
  { style: styles.card },
@@ -1836,186 +1920,212 @@ function PocketSettingsTab({ rpcCall, t }) {
1836
1920
  (0, import_react2.createElement)(
1837
1921
  "div",
1838
1922
  { style: styles.muted, marginTop: 4 },
1839
- 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: updateInfo.output || t("unknownError") }) : fmt(t, "versionRange", { cur: updateInfo.current, latest: updateInfo.latest })
1923
+ 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 })
1840
1924
  )
1841
1925
  ) : null,
1842
- // 局域网
1926
+ // 局域网:标题行自带总开关 → 二维码+地址 → 设置行(访问密码 / 高级·手动选地址)
1843
1927
  (0, import_react2.createElement)(
1844
1928
  "div",
1845
1929
  { style: styles.block },
1846
- (0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 13 } }, t("lanTitle")),
1847
- // 局域网访问总开关:关闭后扫码/链接直接失效(公网不受影响)
1848
1930
  (0, import_react2.createElement)(
1849
1931
  "div",
1850
- { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 8 } },
1851
- (0, import_react2.createElement)("span", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } }, t("lanAccess")),
1852
- (0, import_react2.createElement)("button", {
1853
- style: { ...styles.btn, height: 28, padding: "0 12px", fontSize: 12, fontWeight: status?.lanEnabled !== false ? 600 : 400, background: status?.lanEnabled !== false ? "var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))" : "var(--dsw-alias-bg-layer-1,#fff)", color: status?.lanEnabled !== false ? "var(--dsw-alias-label-primary-foreground, #fff)" : "var(--dsw-alias-label-primary,inherit)" },
1854
- onClick: () => requestLanToggle(true)
1855
- }, t("on")),
1856
- (0, import_react2.createElement)("button", {
1857
- style: { ...styles.btn, height: 28, padding: "0 12px", fontSize: 12, fontWeight: status?.lanEnabled === false ? 600 : 400, background: status?.lanEnabled === false ? "var(--dsw-alias-state-error-primary,#dc2626)" : "var(--dsw-alias-bg-layer-1,#fff)", color: status?.lanEnabled === false ? "#fff" : "var(--dsw-alias-label-primary,inherit)" },
1858
- onClick: () => requestLanToggle(false)
1859
- }, t("off"))
1932
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between" } },
1933
+ (0, import_react2.createElement)("span", { style: { fontWeight: 600, fontSize: 13 } }, t("lanAccess")),
1934
+ Switch(status?.lanEnabled !== false, () => requestLanToggle(status?.lanEnabled === false))
1860
1935
  ),
1861
1936
  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)(
1862
1937
  "div",
1863
1938
  null,
1864
- (0, import_react2.createElement)("img", { src: status.lanQr, alt: "LAN QR", style: styles.qr }),
1865
- (0, import_react2.createElement)("div", { style: styles.code }, lanUrl),
1866
- (0, import_react2.createElement)("div", { style: styles.muted }, t("lanHint")),
1867
- (0, import_react2.createElement)(
1868
- "label",
1869
- { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 10, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } },
1870
- t("lanAddress"),
1871
- (0, import_react2.createElement)(
1872
- "select",
1873
- {
1874
- value: status?.lanIpOverride || "",
1875
- onChange: (e) => setLanAddress(e.target.value),
1876
- 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)" }
1877
- },
1878
- (0, import_react2.createElement)("option", { value: "" }, t("lanAddressAuto")),
1879
- (status?.lanCandidates || []).map((ip) => (0, import_react2.createElement)("option", { key: ip, value: ip }, ip))
1939
+ qrArea(status.lanQr, lanUrl, t("lanHint")),
1940
+ // 访问密码行:开关 + 值(关闭时提示直连)
1941
+ row(
1942
+ t("lanPin"),
1943
+ Switch(status?.lanAuthEnabled !== false, () => setLanAuth(status?.lanAuthEnabled === false)),
1944
+ status?.lanAuthEnabled === false ? (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 6 } }, t("lanPinOff")) : customPin?.which === "lan" ? customPinRow("lan") : (0, import_react2.createElement)(
1945
+ "div",
1946
+ { style: { marginTop: 6, display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
1947
+ (0, import_react2.createElement)("span", { style: { fontFamily: "ui-monospace,Menlo,monospace", fontSize: 13, letterSpacing: 1 } }, status.lanToken),
1948
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: refreshLanPin }, t("refresh")),
1949
+ customBtn("lan"),
1950
+ status?.lanPinCustom ? (0, import_react2.createElement)("span", { style: { fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)" } }, t("pinCustomHint")) : null
1880
1951
  )
1881
1952
  ),
1882
- // 访问密码开关(issue #24):默认开启;关闭后扫码直连(仅同一局域网设备可访问)
1883
- (0, import_react2.createElement)(
1884
- "div",
1885
- { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 8 } },
1886
- (0, import_react2.createElement)("span", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } }, t("lanPin")),
1887
- (0, import_react2.createElement)("button", {
1888
- style: { ...styles.btn, height: 28, padding: "0 12px", fontSize: 12, fontWeight: status?.lanAuthEnabled !== false ? 600 : 400, background: status?.lanAuthEnabled !== false ? "var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))" : "var(--dsw-alias-bg-layer-1,#fff)", color: status?.lanAuthEnabled !== false ? "var(--dsw-alias-label-primary-foreground, #fff)" : "var(--dsw-alias-label-primary,inherit)" },
1889
- onClick: () => setLanAuth(true)
1890
- }, t("on")),
1891
- (0, import_react2.createElement)("button", {
1892
- style: { ...styles.btn, height: 28, padding: "0 12px", fontSize: 12, fontWeight: status?.lanAuthEnabled === false ? 600 : 400, background: status?.lanAuthEnabled === false ? "var(--dsw-alias-state-error-primary,#dc2626)" : "var(--dsw-alias-bg-layer-1,#fff)", color: status?.lanAuthEnabled === false ? "#fff" : "var(--dsw-alias-label-primary,inherit)" },
1893
- onClick: () => setLanAuth(false)
1894
- }, t("off"))
1895
- ),
1896
- status?.lanAuthEnabled !== false ? customPin?.which === "lan" ? customPinRow("lan") : (0, import_react2.createElement)(
1897
- "div",
1898
- { style: { marginTop: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.5 } },
1899
- fmt(t, status?.lanPinCustom ? "lanPinCustomValue" : "lanPinValue", { pin: status.lanToken }),
1900
- (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12, marginLeft: 8 }, onClick: refreshLanPin }, t("refresh")),
1901
- customBtn("lan")
1902
- ) : (0, import_react2.createElement)(
1903
- "div",
1904
- { style: { marginTop: 6, fontSize: 12, color: "var(--dsw-alias-state-warn-primary,#b45309)", lineHeight: 1.5 } },
1905
- t("lanPinOff")
1953
+ // 高级:手动选地址(默认收起)
1954
+ row(
1955
+ t("advAddress"),
1956
+ (0, import_react2.createElement)(
1957
+ "button",
1958
+ { style: { border: "none", background: "none", font: "inherit", cursor: "pointer", fontSize: 12, color: "var(--dsw-alias-label-tertiary,#8b93a1)", padding: 0 }, onClick: () => setAdvOpen((v) => !v) },
1959
+ (status?.lanIpOverride || t("lanAddressAuto")) + " \u203A"
1960
+ ),
1961
+ advOpen ? (0, import_react2.createElement)(
1962
+ "div",
1963
+ { style: { marginTop: 8 } },
1964
+ (0, import_react2.createElement)(
1965
+ "label",
1966
+ { style: { display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } },
1967
+ t("lanAddress"),
1968
+ (0, import_react2.createElement)(
1969
+ "select",
1970
+ {
1971
+ value: status?.lanIpOverride || "",
1972
+ onChange: (e) => setLanAddress(e.target.value),
1973
+ 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)" }
1974
+ },
1975
+ (0, import_react2.createElement)("option", { value: "" }, t("lanAddressAuto")),
1976
+ (status?.lanCandidates || []).map((ip) => (0, import_react2.createElement)("option", { key: ip, value: ip }, ip))
1977
+ )
1978
+ )
1979
+ ) : null
1906
1980
  )
1907
1981
  ) : (0, import_react2.createElement)("div", { style: styles.muted }, t("lanStarting"))
1908
1982
  ),
1909
- // 公网
1983
+ // 公网:标题行自带 开启/关闭 → 开启后:二维码+地址、地址模式行、访问密码行
1910
1984
  (0, import_react2.createElement)(
1911
1985
  "div",
1912
1986
  { style: styles.block },
1913
- (0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 13 } }, t("wanTitle")),
1987
+ (0, import_react2.createElement)(
1988
+ "div",
1989
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between" } },
1990
+ (0, import_react2.createElement)("span", { style: { fontWeight: 600, fontSize: 13 } }, t("wanAccess")),
1991
+ 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"))
1992
+ ),
1993
+ tunnelStarting ? (0, import_react2.createElement)(
1994
+ "div",
1995
+ { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } },
1996
+ tunnelPhase === "downloading" ? fmt(t, "downloading", { s: elapsed(tunnelStateStarted) }) : fmt(t, "connecting", { s: elapsed(tunnelStateStarted), suffix: elapsed(tunnelStateStarted) > 30 ? t("slowHint") : "" })
1997
+ ) : tunnelPhase === "error" ? (0, import_react2.createElement)(
1998
+ "div",
1999
+ { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" } },
2000
+ fmt(t, "error", { detail: errText(tunnelStateDetail) || t("unknownError") })
2001
+ ) : !tunnelUrl && !isDesktop ? (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 8 } }, t("wanOffHint")) : null,
1914
2002
  tunnelUrl ? (0, import_react2.createElement)(
1915
2003
  "div",
1916
2004
  null,
1917
- (0, import_react2.createElement)("img", { src: status.tunnelQr, alt: "Tunnel QR", style: styles.qr }),
1918
- (0, import_react2.createElement)("div", { style: styles.code }, tunnelUrl),
1919
- (0, import_react2.createElement)("div", { style: styles.muted }, namedMode ? t("namedRunningHint") : t("wanHint")),
1920
- status.accessToken ? customPin?.which === "public" ? customPinRow("public") : (0, import_react2.createElement)(
1921
- "div",
1922
- { style: { marginTop: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.5 } },
1923
- fmt(t, status?.publicPinCustom ? "wanPinCustom" : "wanPin", { pin: status.accessToken }),
1924
- customBtn("public"),
1925
- status?.publicPinCustom ? (0, import_react2.createElement)("div", { style: { marginTop: 2, fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)" } }, t("pinCustomHint")) : null,
1926
- namedMode ? (0, import_react2.createElement)("div", { style: { marginTop: 2, fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)" } }, t("namedSecurity")) : null
1927
- ) : null,
1928
- (0, import_react2.createElement)("button", { style: styles.btn, onClick: stopTunnel }, t("stopTunnel")),
1929
- // 公网模式(issue #66):仅在公网开启后才显示/可选
1930
- (0, import_react2.createElement)(
1931
- "div",
1932
- { style: { marginTop: 10 } },
2005
+ qrArea(status.tunnelQr, tunnelUrl, namedMode ? t("namedRunningHint") : t("wanHint")),
2006
+ // 地址模式行(随机/固定;固定域名选中或编辑时高亮)
2007
+ row(
2008
+ t("modeLabel"),
1933
2009
  (0, import_react2.createElement)(
1934
- "div",
1935
- { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
1936
- (0, import_react2.createElement)("span", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } }, t("tunnelMode")),
1937
- (0, import_react2.createElement)("button", {
1938
- style: modeBtnStyle(!namedActive),
1939
- onClick: namedMode ? switchToQuick : tunnelCfg ? () => setTunnelCfg(null) : void 0
1940
- }, t("modeQuick")),
1941
- (0, import_react2.createElement)("button", {
1942
- style: modeBtnStyle(namedActive),
1943
- onClick: () => setTunnelCfg(tunnelCfg ? null : { hostname: tunnelModeView.hostname ?? "", token: "", err: null })
1944
- }, t("modeNamed"))
2010
+ "span",
2011
+ { style: { display: "inline-flex", gap: 6 } },
2012
+ (0, import_react2.createElement)("button", { style: modeBtnStyle(!namedActive), onClick: namedMode ? switchToQuick : tunnelCfg ? () => setTunnelCfg(null) : void 0 }, t("modeQuick")),
2013
+ (0, import_react2.createElement)("button", { style: modeBtnStyle(namedActive), onClick: () => setTunnelCfg(tunnelCfg ? null : { hostname: tunnelModeView.hostname ?? "", token: "", err: null }) }, t("modeNamed"))
1945
2014
  ),
1946
- // 刚保存固定域名但当前连接仍是随机域名:需关闭后重新开启才生效
1947
- namedMode && /trycloudflare\.com/i.test(tunnelUrl ?? "") ? (0, import_react2.createElement)("div", { style: { marginTop: 4, fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)" } }, t("namedTakeEffect")) : null,
1948
- // 固定域名:已保存的摘要 + 「修改」入口(非编辑态)
1949
- namedMode && !tunnelCfg ? (0, import_react2.createElement)(
1950
- "div",
1951
- { style: { marginTop: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.5 } },
1952
- fmt(t, "namedSummary", { host: tunnelModeView.hostname || "\u2014", token: tunnelModeView.tokenSet ? t("namedTokenSet") : t("namedTokenMissing") }),
1953
- (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")),
1954
- (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 4 } }, t("namedHow")),
1955
- !tunnelModeView.tokenSet || !tunnelModeView.hostname ? (0, import_react2.createElement)("div", { style: { marginTop: 2, fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" } }, t("namedNeedCfg")) : null
1956
- ) : null,
1957
- // 固定域名:编辑表单(域名 + Tunnel Token,Token 留空保持不变)
1958
- tunnelCfg ? (0, import_react2.createElement)(
2015
+ (0, import_react2.createElement)(
1959
2016
  "div",
1960
- { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.6 } },
1961
- (0, import_react2.createElement)(
1962
- "div",
1963
- null,
1964
- t("namedHostnameLabel"),
1965
- (0, import_react2.createElement)("input", {
1966
- 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 },
1967
- placeholder: "pocket.example.com",
1968
- value: tunnelCfg.hostname ?? "",
1969
- autoFocus: true,
1970
- onChange: (e) => setTunnelCfg((c) => ({ ...c, hostname: e.target.value.trim(), err: null })),
1971
- onKeyDown: (e) => {
1972
- if (e.key === "Enter") saveNamedTunnel();
1973
- if (e.key === "Escape") setTunnelCfg(null);
1974
- }
1975
- })
1976
- ),
1977
- (0, import_react2.createElement)(
2017
+ { style: { marginTop: 6 } },
2018
+ // 刚保存固定域名但当前连接仍是随机域名:需关闭后重新开启才生效
2019
+ namedMode && /trycloudflare\.com/i.test(tunnelUrl ?? "") ? (0, import_react2.createElement)("div", { style: { ...styles.warn } }, t("namedTakeEffect")) : null,
2020
+ // 固定域名:已保存摘要 + 修改入口(非编辑态)
2021
+ namedMode && !tunnelCfg ? (0, import_react2.createElement)(
1978
2022
  "div",
1979
- { style: { marginTop: 6 } },
1980
- t("namedTokenLabel"),
1981
- (0, import_react2.createElement)("input", {
1982
- 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" },
1983
- type: "password",
1984
- value: tunnelCfg.token ?? "",
1985
- onChange: (e) => setTunnelCfg((c) => ({ ...c, token: e.target.value.trim(), err: null })),
1986
- onKeyDown: (e) => {
1987
- if (e.key === "Enter") saveNamedTunnel();
1988
- if (e.key === "Escape") setTunnelCfg(null);
1989
- }
1990
- })
1991
- ),
1992
- (0, import_react2.createElement)(
2023
+ { style: { ...styles.muted } },
2024
+ fmt(t, "namedSummary", { host: tunnelModeView.hostname || "\u2014", token: tunnelModeView.tokenSet ? t("namedTokenSet") : t("namedTokenMissing") }),
2025
+ (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")),
2026
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 4 } }, t("namedHow")),
2027
+ !tunnelModeView.tokenSet || !tunnelModeView.hostname ? (0, import_react2.createElement)("div", { style: { marginTop: 2, color: "var(--dsw-alias-state-error-primary,#dc2626)" } }, t("namedNeedCfg")) : null
2028
+ ) : null,
2029
+ // 固定域名:编辑表单(域名 + Tunnel Token,Token 留空保持不变)
2030
+ tunnelCfg ? (0, import_react2.createElement)(
1993
2031
  "div",
1994
- { style: { marginTop: 6, display: "flex", gap: 8 } },
1995
- (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: saveNamedTunnel }, t("save")),
1996
- (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: () => setTunnelCfg(null) }, t("cancel"))
1997
- ),
1998
- (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 6 } }, t("namedHow")),
1999
- (0, import_react2.createElement)("div", { style: { marginTop: 2, fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)", lineHeight: 1.5 } }, t("namedSecurity")),
2000
- tunnelCfg.err ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", marginTop: 4 } }, tunnelCfg.err) : null
2001
- ) : null
2002
- )
2003
- ) : (0, import_react2.createElement)(
2032
+ { style: { marginTop: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.6 } },
2033
+ (0, import_react2.createElement)(
2034
+ "div",
2035
+ null,
2036
+ t("namedHostnameLabel"),
2037
+ (0, import_react2.createElement)("input", {
2038
+ 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 },
2039
+ placeholder: "pocket.example.com",
2040
+ value: tunnelCfg.hostname ?? "",
2041
+ autoFocus: true,
2042
+ onChange: (e) => setTunnelCfg((c) => ({ ...c, hostname: e.target.value.trim(), err: null })),
2043
+ onKeyDown: (e) => {
2044
+ if (e.key === "Enter") saveNamedTunnel();
2045
+ if (e.key === "Escape") setTunnelCfg(null);
2046
+ }
2047
+ })
2048
+ ),
2049
+ (0, import_react2.createElement)(
2050
+ "div",
2051
+ { style: { marginTop: 6 } },
2052
+ t("namedTokenLabel"),
2053
+ (0, import_react2.createElement)("input", {
2054
+ 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" },
2055
+ type: "password",
2056
+ value: tunnelCfg.token ?? "",
2057
+ onChange: (e) => setTunnelCfg((c) => ({ ...c, token: e.target.value.trim(), err: null })),
2058
+ onKeyDown: (e) => {
2059
+ if (e.key === "Enter") saveNamedTunnel();
2060
+ if (e.key === "Escape") setTunnelCfg(null);
2061
+ }
2062
+ })
2063
+ ),
2064
+ (0, import_react2.createElement)(
2065
+ "div",
2066
+ { style: { marginTop: 6, display: "flex", gap: 8 } },
2067
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: saveNamedTunnel }, t("save")),
2068
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: () => setTunnelCfg(null) }, t("cancel"))
2069
+ ),
2070
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 6 } }, t("namedHow")),
2071
+ (0, import_react2.createElement)("div", { style: { marginTop: 2, fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)", lineHeight: 1.5 } }, t("namedSecurity")),
2072
+ tunnelCfg.err ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", marginTop: 4 } }, errText(tunnelCfg.err)) : null
2073
+ ) : null
2074
+ )
2075
+ ),
2076
+ // 访问密码行:值 + 自定义(自定义输入态整体替换)
2077
+ status.accessToken ? row(
2078
+ t("pinLabel"),
2079
+ customPin?.which === "public" ? null : (0, import_react2.createElement)(
2080
+ "span",
2081
+ { style: { display: "inline-flex", alignItems: "center", gap: 8 } },
2082
+ (0, import_react2.createElement)("span", { style: { fontFamily: "ui-monospace,Menlo,monospace", fontSize: 13, letterSpacing: 1 } }, status.accessToken),
2083
+ customBtn("public")
2084
+ ),
2085
+ (0, import_react2.createElement)(
2086
+ "div",
2087
+ { style: { marginTop: 6 } },
2088
+ customPin?.which === "public" ? customPinRow("public") : null,
2089
+ status?.publicPinCustom ? (0, import_react2.createElement)("div", { style: { ...styles.warn } }, t("pinCustomHint")) : null,
2090
+ namedMode ? (0, import_react2.createElement)("div", { style: { ...styles.warn } }, t("namedSecurity")) : null
2091
+ )
2092
+ ) : null
2093
+ ) : null
2094
+ ),
2095
+ error ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", fontSize: 12, marginTop: 8 } }, `\u274C ${errText(error)}`) : null,
2096
+ // 恢复出厂设置:设置出问题时的临时兜底(最底部,避免误触)
2097
+ (0, import_react2.createElement)(
2098
+ "div",
2099
+ { style: styles.block },
2100
+ (0, import_react2.createElement)(
2004
2101
  "div",
2005
- null,
2006
- (0, import_react2.createElement)("button", { style: { ...styles.primary, margin: "8px 0" }, onClick: startTunnel, disabled: busy || tunnelStarting }, busy ? t("opening") : t("enable")),
2007
- tunnelStarting ? (0, import_react2.createElement)(
2008
- "div",
2009
- { style: { marginTop: 4, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } },
2010
- tunnelPhase === "downloading" ? fmt(t, "downloading", { s: elapsed(tunnelStateStarted) }) : fmt(t, "connecting", { s: elapsed(tunnelStateStarted), suffix: elapsed(tunnelStateStarted) > 30 ? t("slowHint") : "" })
2011
- ) : tunnelPhase === "error" ? (0, import_react2.createElement)(
2102
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 } },
2103
+ (0, import_react2.createElement)("span", { style: { fontWeight: 600, fontSize: 13 } }, t("resetFactory")),
2104
+ (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"))
2105
+ ),
2106
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 6 } }, t("resetIntro"))
2107
+ ),
2108
+ // 恢复出厂设置确认弹框
2109
+ resetOpen ? (0, import_react2.createElement)(
2110
+ "div",
2111
+ { style: { position: "fixed", inset: 0, zIndex: 1e4, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 } },
2112
+ (0, import_react2.createElement)(
2113
+ "div",
2114
+ { 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)" } },
2115
+ (0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 15, color: "var(--dsw-alias-state-warn-primary,#b45309)", marginBottom: 10 } }, t("resetTitle")),
2116
+ (0, import_react2.createElement)("div", { style: { fontSize: 13, lineHeight: 1.7, color: "var(--dsw-alias-label-primary,inherit)", whiteSpace: "pre-line" } }, t("resetBody")),
2117
+ (0, import_react2.createElement)(
2012
2118
  "div",
2013
- { style: { marginTop: 4, fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" } },
2014
- fmt(t, "error", { detail: tunnelStateDetail || t("unknownError") })
2015
- ) : null
2119
+ { style: { display: "flex", gap: 8, marginTop: 16 } },
2120
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, flex: 1 }, onClick: () => setResetOpen(false) }, t("cancel")),
2121
+ (0, import_react2.createElement)("button", { style: { ...styles.primary, flex: 1, background: "var(--dsw-alias-state-error-primary,#dc2626)" }, onClick: doFactoryReset }, t("resetConfirm"))
2122
+ )
2016
2123
  )
2017
- ),
2018
- error ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", fontSize: 12, marginTop: 8 } }, `\u274C ${error}`) : null,
2124
+ ) : null,
2125
+ // Toast:重置等操作的即时反馈(固定屏幕正中央,2.6s 自动消失)
2126
+ toast ? (0, import_react2.createElement)("div", {
2127
+ 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)" }
2128
+ }, toast) : null,
2019
2129
  // 局域网访问开关确认弹框(关闭/打开时弹窗提醒)
2020
2130
  lanToggleOpen !== null ? (0, import_react2.createElement)(
2021
2131
  "div",
package/client/index.jsx CHANGED
@@ -7,7 +7,7 @@
7
7
  // 注:Web Push 已移除——浏览器推送依赖 Google FCM(Chrome)等境外服务,
8
8
  // 国内直连被墙,普通用户用不了。专注扫码同屏这一件事。
9
9
 
10
- import { createElement as h, useEffect, useState } from 'react';
10
+ import { createElement as h, useEffect, useRef, useState } from 'react';
11
11
 
12
12
  import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS, redactStatus, compareVersions } from './api.js';
13
13
  import { mobileApply } from './mobile/mobile-apply.tsx';
@@ -220,6 +220,26 @@ function PocketSettingsTab({ rpcCall, t }) {
220
220
  }
221
221
  };
222
222
 
223
+ // 恢复出厂设置:清本机设置 + 重设随机密码(弹窗确认;RPC 端也强制校验 confirm)
224
+ const [resetOpen, setResetOpen] = useState(false);
225
+ const doFactoryReset = async () => {
226
+ setResetOpen(false);
227
+ setBusy(true);
228
+ setError(null);
229
+ try {
230
+ setStatus(await call(POCKET_ENDPOINTS.pocketReset, { confirm: true }));
231
+ setTunnelCfg(null);
232
+ setCustomPin(null);
233
+ setAdvOpen(false);
234
+ showToast(t('resetDone'));
235
+ } catch (err) {
236
+ setError(err.message);
237
+ showToast(t('resetFailed'));
238
+ } finally {
239
+ setBusy(false);
240
+ }
241
+ };
242
+
223
243
  // 刷新局域网访问密码(旧密码立即作废)
224
244
  const refreshLanPin = async () => {
225
245
  try {
@@ -293,7 +313,7 @@ function PocketSettingsTab({ rpcCall, t }) {
293
313
  }),
294
314
  h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 2 }, onClick: () => saveCustomPin(which) }, t('save')),
295
315
  h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: () => setCustomPin(null) }, t('cancel')),
296
- customPin?.err ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', marginTop: 4 } }, customPin.err) : null,
316
+ customPin?.err ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', marginTop: 4 } }, errText(customPin.err)) : null,
297
317
  );
298
318
  // 「自定义」按钮(非输入态显示在密码行末尾)
299
319
  const customBtn = (which) => h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 8 }, onClick: () => setCustomPin({ which, value: '', err: null }) }, t('customize'));
@@ -309,12 +329,45 @@ function PocketSettingsTab({ rpcCall, t }) {
309
329
  const namedMode = tunnelModeView.mode === 'named';
310
330
  // 模式按钮选中态高亮:固定域名模式本身,或正在编辑固定域名配置,都视为「选中」
311
331
  const namedActive = namedMode || tunnelCfg !== null;
332
+ // 后端错误消息统一为「中文 | English」混排;按当前界面语言只显示对应一半
333
+ const errText = (msg) => {
334
+ const s = String(msg ?? '');
335
+ const i = s.indexOf(' | ');
336
+ if (i < 0) return s;
337
+ return (t('ok') === POCKET_ZH.ok ? s.slice(0, i) : s.slice(i + 3)).trim();
338
+ };
339
+ // 轻量 Toast:操作成功/失败后短暂提示(自动消失,不打断操作)
340
+ const [toast, setToast] = useState(null);
341
+ const toastTimer = useRef(null);
342
+ const showToast = (text) => {
343
+ setToast(text);
344
+ clearTimeout(toastTimer.current);
345
+ toastTimer.current = setTimeout(() => setToast(null), 2600);
346
+ };
347
+ useEffect(() => () => clearTimeout(toastTimer.current), []);
312
348
  const modeBtnStyle = (active) => ({
313
349
  ...styles.btn, height: 28, padding: '0 12px', fontSize: 12,
314
350
  fontWeight: active ? 600 : 400,
315
351
  background: active ? 'var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))' : 'var(--dsw-alias-bg-layer-1,#fff)',
316
352
  color: active ? 'var(--dsw-alias-label-primary-foreground, #fff)' : 'var(--dsw-alias-label-primary,inherit)',
317
353
  });
354
+ // iOS 风格小开关(重排后统一用:局域网总开关 / 局域网密码开关)
355
+ const Switch = (on, onClick) => h('button', {
356
+ role: 'switch', 'aria-checked': !!on,
357
+ 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)' },
358
+ onClick,
359
+ }, h('span', { style: { position: 'absolute', top: 2, left: on ? 20 : 2, width: 18, height: 18, borderRadius: '50%', background: '#fff' } }));
360
+ // 卡片内主内容:二维码 + 地址 + 提示
361
+ const qrArea = (src, url, hint) => h('div', { style: { background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', borderRadius: 10, padding: '10px 12px', textAlign: 'center', margin: '10px 0' } },
362
+ h('img', { src, alt: 'QR', style: styles.qr }),
363
+ h('div', { style: styles.code }, url),
364
+ h('div', { style: styles.muted }, hint));
365
+ // 设置行:上分隔线,内部第一行 = 左标签 + 右操作;extra 作为第二段渲染
366
+ const row = (label, control, extra) => h('div', { style: { borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', paddingTop: 9, marginTop: 9 } },
367
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
368
+ h('span', { style: { fontSize: 13 } }, label), control), extra ?? null);
369
+ // 高级(手动选地址)展开态
370
+ const [advOpen, setAdvOpen] = useState(false);
318
371
 
319
372
  return h('div', { style: styles.card },
320
373
  h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
@@ -365,158 +418,167 @@ function PocketSettingsTab({ rpcCall, t }) {
365
418
  : updateInfo.result === 'ok'
366
419
  ? (updateInfo.autoRestart ? t('updatedAutoDetail')
367
420
  : t('updatedRestartDetail'))
368
- : updateInfo.result === 'fail' ? fmt(t, 'updateFailed', { err: updateInfo.output || t('unknownError') })
421
+ : updateInfo.result === 'fail' ? fmt(t, 'updateFailed', { err: errText(updateInfo.output) || t('unknownError') })
369
422
  : fmt(t, 'versionRange', { cur: updateInfo.current, latest: updateInfo.latest })),
370
423
  ) : null,
371
424
 
372
- // 局域网
425
+ // 局域网:标题行自带总开关 → 二维码+地址 → 设置行(访问密码 / 高级·手动选地址)
373
426
  h('div', { style: styles.block },
374
- h('div', { style: { fontWeight: 600, fontSize: 13 } }, t('lanTitle')),
375
- // 局域网访问总开关:关闭后扫码/链接直接失效(公网不受影响)
376
- h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 } },
377
- h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } }, t('lanAccess')),
378
- h('button', {
379
- style: { ...styles.btn, height: 28, padding: '0 12px', fontSize: 12, fontWeight: status?.lanEnabled !== false ? 600 : 400, background: status?.lanEnabled !== false ? 'var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))' : 'var(--dsw-alias-bg-layer-1,#fff)', color: status?.lanEnabled !== false ? 'var(--dsw-alias-label-primary-foreground, #fff)' : 'var(--dsw-alias-label-primary,inherit)' },
380
- onClick: () => requestLanToggle(true),
381
- }, t('on')),
382
- h('button', {
383
- style: { ...styles.btn, height: 28, padding: '0 12px', fontSize: 12, fontWeight: status?.lanEnabled === false ? 600 : 400, background: status?.lanEnabled === false ? 'var(--dsw-alias-state-error-primary,#dc2626)' : 'var(--dsw-alias-bg-layer-1,#fff)', color: status?.lanEnabled === false ? '#fff' : 'var(--dsw-alias-label-primary,inherit)' },
384
- onClick: () => requestLanToggle(false),
385
- }, t('off')),
427
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
428
+ h('span', { style: { fontWeight: 600, fontSize: 13 } }, t('lanAccess')),
429
+ Switch(status?.lanEnabled !== false, () => requestLanToggle(status?.lanEnabled === false)),
386
430
  ),
387
431
  status?.lanEnabled === false
388
432
  ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-state-warn-primary,#b45309)', lineHeight: 1.5 } }, t('lanDisabledHint'))
389
433
  : (lanUrl
390
434
  ? h('div', null,
391
- h('img', { src: status.lanQr, alt: 'LAN QR', style: styles.qr }),
392
- h('div', { style: styles.code }, lanUrl),
393
- h('div', { style: styles.muted }, t('lanHint')),
394
- h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } },
395
- t('lanAddress'),
396
- h('select', {
397
- value: status?.lanIpOverride || '',
398
- onChange: (e) => setLanAddress(e.target.value),
399
- 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)' },
400
- },
401
- h('option', { value: '' }, t('lanAddressAuto')),
402
- (status?.lanCandidates || []).map((ip) => h('option', { key: ip, value: ip }, ip)),
403
- ),
404
- ),
405
- // 访问密码开关(issue #24):默认开启;关闭后扫码直连(仅同一局域网设备可访问)
406
- h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 } },
407
- h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } }, t('lanPin')),
408
- h('button', {
409
- style: { ...styles.btn, height: 28, padding: '0 12px', fontSize: 12, fontWeight: status?.lanAuthEnabled !== false ? 600 : 400, background: status?.lanAuthEnabled !== false ? 'var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))' : 'var(--dsw-alias-bg-layer-1,#fff)', color: status?.lanAuthEnabled !== false ? 'var(--dsw-alias-label-primary-foreground, #fff)' : 'var(--dsw-alias-label-primary,inherit)' },
410
- onClick: () => setLanAuth(true),
411
- }, t('on')),
412
- h('button', {
413
- style: { ...styles.btn, height: 28, padding: '0 12px', fontSize: 12, fontWeight: status?.lanAuthEnabled === false ? 600 : 400, background: status?.lanAuthEnabled === false ? 'var(--dsw-alias-state-error-primary,#dc2626)' : 'var(--dsw-alias-bg-layer-1,#fff)', color: status?.lanAuthEnabled === false ? '#fff' : 'var(--dsw-alias-label-primary,inherit)' },
414
- onClick: () => setLanAuth(false),
415
- }, t('off')),
416
- ),
417
- status?.lanAuthEnabled !== false
418
- ? (customPin?.which === 'lan'
419
- ? customPinRow('lan')
420
- : h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.5 } },
421
- fmt(t, status?.lanPinCustom ? 'lanPinCustomValue' : 'lanPinValue', { pin: status.lanToken }),
422
- h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 8 }, onClick: refreshLanPin }, t('refresh')),
423
- customBtn('lan'),
424
- ))
425
- : h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-state-warn-primary,#b45309)', lineHeight: 1.5 } },
426
- t('lanPinOff')),
435
+ qrArea(status.lanQr, lanUrl, t('lanHint')),
436
+ // 访问密码行:开关 + 值(关闭时提示直连)
437
+ row(t('lanPin'), Switch(status?.lanAuthEnabled !== false, () => setLanAuth(status?.lanAuthEnabled === false)),
438
+ status?.lanAuthEnabled === false
439
+ ? h('div', { style: { ...styles.muted, marginTop: 6 } }, t('lanPinOff'))
440
+ : (customPin?.which === 'lan'
441
+ ? customPinRow('lan')
442
+ : h('div', { style: { marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
443
+ h('span', { style: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 13, letterSpacing: 1 } }, status.lanToken),
444
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: refreshLanPin }, t('refresh')),
445
+ customBtn('lan'),
446
+ status?.lanPinCustom ? h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)' } }, t('pinCustomHint')) : null,
447
+ ))),
448
+ // 高级:手动选地址(默认收起)
449
+ row(t('advAddress'),
450
+ h('button', { style: { border: 'none', background: 'none', font: 'inherit', cursor: 'pointer', fontSize: 12, color: 'var(--dsw-alias-label-tertiary,#8b93a1)', padding: 0 }, onClick: () => setAdvOpen((v) => !v) },
451
+ (status?.lanIpOverride || t('lanAddressAuto')) + ' '),
452
+ advOpen ? h('div', { style: { marginTop: 8 } },
453
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } },
454
+ t('lanAddress'),
455
+ h('select', {
456
+ value: status?.lanIpOverride || '',
457
+ onChange: (e) => setLanAddress(e.target.value),
458
+ 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)' },
459
+ },
460
+ h('option', { value: '' }, t('lanAddressAuto')),
461
+ (status?.lanCandidates || []).map((ip) => h('option', { key: ip, value: ip }, ip)),
462
+ ),
463
+ ),
464
+ ) : null),
427
465
  )
428
466
  : h('div', { style: styles.muted }, t('lanStarting'))),
429
467
  ),
430
468
 
431
- // 公网
469
+ // 公网:标题行自带 开启/关闭 → 开启后:二维码+地址、地址模式行、访问密码行
432
470
  h('div', { style: styles.block },
433
- h('div', { style: { fontWeight: 600, fontSize: 13 } }, t('wanTitle')),
471
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
472
+ h('span', { style: { fontWeight: 600, fontSize: 13 } }, t('wanAccess')),
473
+ tunnelUrl
474
+ ? h('button', { style: { ...styles.btn, height: 28, padding: '0 12px', fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' }, onClick: stopTunnel }, t('stopTunnel'))
475
+ : h('button', { style: { ...styles.primary, height: 28, padding: '0 14px', fontSize: 12 }, onClick: startTunnel, disabled: busy || tunnelStarting }, busy || tunnelStarting ? t('opening') : t('enable')),
476
+ ),
477
+ tunnelStarting
478
+ ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } },
479
+ tunnelPhase === 'downloading'
480
+ ? fmt(t, 'downloading', { s: elapsed(tunnelStateStarted) })
481
+ : fmt(t, 'connecting', { s: elapsed(tunnelStateStarted), suffix: elapsed(tunnelStateStarted) > 30 ? t('slowHint') : '' }))
482
+ : tunnelPhase === 'error'
483
+ ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } },
484
+ fmt(t, 'error', { detail: errText(tunnelStateDetail) || t('unknownError') }))
485
+ : (!tunnelUrl && !isDesktop ? h('div', { style: { ...styles.muted, marginTop: 8 } }, t('wanOffHint')) : null),
434
486
  tunnelUrl
435
487
  ? h('div', null,
436
- h('img', { src: status.tunnelQr, alt: 'Tunnel QR', style: styles.qr }),
437
- h('div', { style: styles.code }, tunnelUrl),
438
- h('div', { style: styles.muted }, namedMode ? t('namedRunningHint') : t('wanHint')),
439
- status.accessToken
440
- ? (customPin?.which === 'public'
441
- ? customPinRow('public')
442
- : h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.5 } },
443
- fmt(t, status?.publicPinCustom ? 'wanPinCustom' : 'wanPin', { pin: status.accessToken }),
444
- customBtn('public'),
445
- status?.publicPinCustom ? h('div', { style: { marginTop: 2, fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)' } }, t('pinCustomHint')) : null,
446
- namedMode ? h('div', { style: { marginTop: 2, fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)' } }, t('namedSecurity')) : null,
447
- ))
448
- : null,
449
- h('button', { style: styles.btn, onClick: stopTunnel }, t('stopTunnel')),
450
- // 公网模式(issue #66):仅在公网开启后才显示/可选
451
- h('div', { style: { marginTop: 10 } },
452
- h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
453
- h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } }, t('tunnelMode')),
454
- h('button', {
455
- style: modeBtnStyle(!namedActive),
456
- onClick: namedMode ? switchToQuick : (tunnelCfg ? () => setTunnelCfg(null) : undefined),
457
- }, t('modeQuick')),
458
- h('button', {
459
- style: modeBtnStyle(namedActive),
460
- onClick: () => setTunnelCfg(tunnelCfg ? null : { hostname: tunnelModeView.hostname ?? '', token: '', err: null }),
461
- }, t('modeNamed')),
488
+ qrArea(status.tunnelQr, tunnelUrl, namedMode ? t('namedRunningHint') : t('wanHint')),
489
+ // 地址模式行(随机/固定;固定域名选中或编辑时高亮)
490
+ row(t('modeLabel'),
491
+ h('span', { style: { display: 'inline-flex', gap: 6 } },
492
+ h('button', { style: modeBtnStyle(!namedActive), onClick: namedMode ? switchToQuick : (tunnelCfg ? () => setTunnelCfg(null) : undefined) }, t('modeQuick')),
493
+ h('button', { style: modeBtnStyle(namedActive), onClick: () => setTunnelCfg(tunnelCfg ? null : { hostname: tunnelModeView.hostname ?? '', token: '', err: null }) }, t('modeNamed')),
494
+ ),
495
+ h('div', { style: { marginTop: 6 } },
496
+ // 刚保存固定域名但当前连接仍是随机域名:需关闭后重新开启才生效
497
+ namedMode && /trycloudflare\.com/i.test(tunnelUrl ?? '') ? h('div', { style: { ...styles.warn } }, t('namedTakeEffect')) : null,
498
+ // 固定域名:已保存摘要 + 修改入口(非编辑态)
499
+ namedMode && !tunnelCfg ? h('div', { style: { ...styles.muted } },
500
+ fmt(t, 'namedSummary', { host: tunnelModeView.hostname || '—', token: tunnelModeView.tokenSet ? t('namedTokenSet') : t('namedTokenMissing') }),
501
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 8 }, onClick: () => setTunnelCfg({ hostname: tunnelModeView.hostname ?? '', token: '', err: null }) }, t('namedEdit')),
502
+ h('div', { style: { ...styles.muted, marginTop: 4 } }, t('namedHow')),
503
+ !tunnelModeView.tokenSet || !tunnelModeView.hostname ? h('div', { style: { marginTop: 2, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } }, t('namedNeedCfg')) : null,
504
+ ) : null,
505
+ // 固定域名:编辑表单(域名 + Tunnel Token,Token 留空保持不变)
506
+ tunnelCfg ? h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.6 } },
507
+ h('div', null,
508
+ t('namedHostnameLabel'),
509
+ h('input', {
510
+ 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 },
511
+ placeholder: 'pocket.example.com',
512
+ value: tunnelCfg.hostname ?? '',
513
+ autoFocus: true,
514
+ onChange: (e) => setTunnelCfg((c) => ({ ...c, hostname: e.target.value.trim(), err: null })),
515
+ onKeyDown: (e) => { if (e.key === 'Enter') saveNamedTunnel(); if (e.key === 'Escape') setTunnelCfg(null); },
516
+ }),
517
+ ),
518
+ h('div', { style: { marginTop: 6 } },
519
+ t('namedTokenLabel'),
520
+ h('input', {
521
+ 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' },
522
+ type: 'password',
523
+ value: tunnelCfg.token ?? '',
524
+ onChange: (e) => setTunnelCfg((c) => ({ ...c, token: e.target.value.trim(), err: null })),
525
+ onKeyDown: (e) => { if (e.key === 'Enter') saveNamedTunnel(); if (e.key === 'Escape') setTunnelCfg(null); },
526
+ }),
527
+ ),
528
+ h('div', { style: { marginTop: 6, display: 'flex', gap: 8 } },
529
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: saveNamedTunnel }, t('save')),
530
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: () => setTunnelCfg(null) }, t('cancel')),
531
+ ),
532
+ h('div', { style: { ...styles.muted, marginTop: 6 } }, t('namedHow')),
533
+ h('div', { style: { marginTop: 2, fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)', lineHeight: 1.5 } }, t('namedSecurity')),
534
+ tunnelCfg.err ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', marginTop: 4 } }, errText(tunnelCfg.err)) : null,
535
+ ) : null,
462
536
  ),
463
- // 刚保存固定域名但当前连接仍是随机域名:需关闭后重新开启才生效
464
- namedMode && /trycloudflare\.com/i.test(tunnelUrl ?? '') ? h('div', { style: { marginTop: 4, fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)' } }, t('namedTakeEffect')) : null,
465
- // 固定域名:已保存的摘要 + 「修改」入口(非编辑态)
466
- namedMode && !tunnelCfg ? h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.5 } },
467
- fmt(t, 'namedSummary', { host: tunnelModeView.hostname || '—', token: tunnelModeView.tokenSet ? t('namedTokenSet') : t('namedTokenMissing') }),
468
- h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 8 }, onClick: () => setTunnelCfg({ hostname: tunnelModeView.hostname ?? '', token: '', err: null }) }, t('namedEdit')),
469
- h('div', { style: { ...styles.muted, marginTop: 4 } }, t('namedHow')),
470
- !tunnelModeView.tokenSet || !tunnelModeView.hostname ? h('div', { style: { marginTop: 2, fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } }, t('namedNeedCfg')) : null,
471
- ) : null,
472
- // 固定域名:编辑表单(域名 + Tunnel Token,Token 留空保持不变)
473
- tunnelCfg ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.6 } },
474
- h('div', null,
475
- t('namedHostnameLabel'),
476
- h('input', {
477
- 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 },
478
- placeholder: 'pocket.example.com',
479
- value: tunnelCfg.hostname ?? '',
480
- autoFocus: true,
481
- onChange: (e) => setTunnelCfg((c) => ({ ...c, hostname: e.target.value.trim(), err: null })),
482
- onKeyDown: (e) => { if (e.key === 'Enter') saveNamedTunnel(); if (e.key === 'Escape') setTunnelCfg(null); },
483
- }),
484
- ),
485
- h('div', { style: { marginTop: 6 } },
486
- t('namedTokenLabel'),
487
- h('input', {
488
- 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' },
489
- type: 'password',
490
- value: tunnelCfg.token ?? '',
491
- onChange: (e) => setTunnelCfg((c) => ({ ...c, token: e.target.value.trim(), err: null })),
492
- onKeyDown: (e) => { if (e.key === 'Enter') saveNamedTunnel(); if (e.key === 'Escape') setTunnelCfg(null); },
493
- }),
494
- ),
495
- h('div', { style: { marginTop: 6, display: 'flex', gap: 8 } },
496
- h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: saveNamedTunnel }, t('save')),
497
- h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: () => setTunnelCfg(null) }, t('cancel')),
498
- ),
499
- h('div', { style: { ...styles.muted, marginTop: 6 } }, t('namedHow')),
500
- h('div', { style: { marginTop: 2, fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)', lineHeight: 1.5 } }, t('namedSecurity')),
501
- tunnelCfg.err ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', marginTop: 4 } }, tunnelCfg.err) : null,
502
- ) : null,
503
537
  ),
538
+ // 访问密码行:值 + 自定义(自定义输入态整体替换)
539
+ status.accessToken
540
+ ? row(t('pinLabel'),
541
+ customPin?.which === 'public'
542
+ ? null
543
+ : h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 8 } },
544
+ h('span', { style: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 13, letterSpacing: 1 } }, status.accessToken),
545
+ customBtn('public')),
546
+ h('div', { style: { marginTop: 6 } },
547
+ customPin?.which === 'public' ? customPinRow('public') : null,
548
+ status?.publicPinCustom ? h('div', { style: { ...styles.warn } }, t('pinCustomHint')) : null,
549
+ namedMode ? h('div', { style: { ...styles.warn } }, t('namedSecurity')) : null))
550
+ : null,
504
551
  )
505
- : h('div', null,
506
- h('button', { style: { ...styles.primary, margin: '8px 0' }, onClick: startTunnel, disabled: busy || tunnelStarting }, busy ? t('opening') : t('enable')),
507
- tunnelStarting
508
- ? h('div', { style: { marginTop: 4, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } },
509
- tunnelPhase === 'downloading'
510
- ? fmt(t, 'downloading', { s: elapsed(tunnelStateStarted) })
511
- : fmt(t, 'connecting', { s: elapsed(tunnelStateStarted), suffix: elapsed(tunnelStateStarted) > 30 ? t('slowHint') : '' }))
512
- : tunnelPhase === 'error'
513
- ? h('div', { style: { marginTop: 4, fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } },
514
- fmt(t, 'error', { detail: tunnelStateDetail || t('unknownError') }))
515
- : null,
516
- ),
552
+ : null,
517
553
  ),
518
554
 
519
- error ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 12, marginTop: 8 } }, `❌ ${error}`) : null,
555
+ error ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 12, marginTop: 8 } }, `❌ ${errText(error)}`) : null,
556
+
557
+ // 恢复出厂设置:设置出问题时的临时兜底(最底部,避免误触)
558
+ h('div', { style: styles.block },
559
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
560
+ h('span', { style: { fontWeight: 600, fontSize: 13 } }, t('resetFactory')),
561
+ h('button', { style: { ...styles.btn, height: 28, padding: '0 12px', fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' }, onClick: () => setResetOpen(true) }, t('resetGo')),
562
+ ),
563
+ h('div', { style: { ...styles.muted, marginTop: 6 } }, t('resetIntro')),
564
+ ),
565
+
566
+ // 恢复出厂设置确认弹框
567
+ resetOpen ? h('div', { style: { position: 'fixed', inset: 0, zIndex: 10000, background: 'rgba(0,0,0,.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 } },
568
+ h('div', { style: { background: 'var(--dsw-alias-bg-layer-1,#fff)', borderRadius: 12, maxWidth: 440, width: '100%', padding: '20px 22px', boxShadow: '0 8px 32px rgba(0,0,0,.18)' } },
569
+ h('div', { style: { fontWeight: 600, fontSize: 15, color: 'var(--dsw-alias-state-warn-primary,#b45309)', marginBottom: 10 } }, t('resetTitle')),
570
+ h('div', { style: { fontSize: 13, lineHeight: 1.7, color: 'var(--dsw-alias-label-primary,inherit)', whiteSpace: 'pre-line' } }, t('resetBody')),
571
+ h('div', { style: { display: 'flex', gap: 8, marginTop: 16 } },
572
+ h('button', { style: { ...styles.btn, flex: 1 }, onClick: () => setResetOpen(false) }, t('cancel')),
573
+ h('button', { style: { ...styles.primary, flex: 1, background: 'var(--dsw-alias-state-error-primary,#dc2626)' }, onClick: doFactoryReset }, t('resetConfirm')),
574
+ ),
575
+ ),
576
+ ) : null,
577
+
578
+ // Toast:重置等操作的即时反馈(固定屏幕正中央,2.6s 自动消失)
579
+ toast ? h('div', {
580
+ 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)' },
581
+ }, toast) : null,
520
582
 
521
583
  // 局域网访问开关确认弹框(关闭/打开时弹窗提醒)
522
584
  lanToggleOpen !== null ? h('div', { style: { position: 'fixed', inset: 0, zIndex: 10000, background: 'rgba(0,0,0,.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 } },
@@ -28,6 +28,19 @@ export const zh = {
28
28
  'updatedRestartDetail': '✅ 已更新,重启 dsh web 生效',
29
29
  'updateFailed': '❌ 失败:{err}(手动更新:dsh plugin --profile web update dsh-pocket --latest -w)',
30
30
  'versionRange': '当前 v{cur} → 最新 v{latest}',
31
+ 'wanAccess': '公网访问',
32
+ 'pinLabel': '访问密码',
33
+ 'modeLabel': '地址模式',
34
+ 'advAddress': '高级 · 手动选地址',
35
+ 'wanOffHint': '开启后可从任何网络访问(每次开启需确认免责声明)',
36
+ 'resetFactory': '🧹 恢复出厂设置',
37
+ 'resetGo': '恢复',
38
+ 'resetIntro': '设置搞出问题时的临时兜底:清空本机配置并重设随机密码(DSH 的会话、模型、插件配置不受影响)',
39
+ 'resetTitle': '⚠️ 确认恢复出厂设置?',
40
+ 'resetBody': '将清空并恢复默认:\n① 开关:局域网访问=开、访问密码=开、局域网地址=自动\n② 公网:模式回到随机域名,清空 Tunnel Token 与固定域名,并关闭正在运行的公网\n③ 密码:公网和局域网都换成新的随机 8 位密码(旧密码立即作废,手机需重新输入)\n\nDSH 自身的会话、模型、插件配置不受影响;此操作不可撤销。',
41
+ 'resetConfirm': '确认恢复',
42
+ 'resetDone': '✅ 已恢复出厂设置:设置已清空,密码已换新(手机需重新输入)',
43
+ 'resetFailed': '❌ 恢复失败,请重试',
31
44
  'lanTitle': '📶 局域网(同一 WiFi)',
32
45
  'lanHint': '手机连接同一 WiFi 后扫码即可打开',
33
46
  'lanAccess': '局域网访问',
@@ -112,6 +125,19 @@ export const en = {
112
125
  'updatedRestartDetail': '✅ Updated — restart dsh web to apply',
113
126
  'updateFailed': '❌ Failed: {err} (manual update: dsh plugin --profile web update dsh-pocket --latest -w)',
114
127
  'versionRange': 'Current v{cur} → latest v{latest}',
128
+ 'wanAccess': 'Public access',
129
+ 'pinLabel': 'Access PIN',
130
+ 'modeLabel': 'Address mode',
131
+ 'advAddress': 'Advanced · Pick address',
132
+ 'wanOffHint': 'Reachable from any network once enabled (a disclaimer is confirmed on each enable)',
133
+ 'resetFactory': '🧹 Factory reset',
134
+ 'resetGo': 'Reset',
135
+ 'resetIntro': 'Temporary fallback when settings break: clear local config and re-roll random PINs (DSH sessions, models and plugin config are untouched)',
136
+ 'resetTitle': '⚠️ Confirm factory reset?',
137
+ 'resetBody': 'This clears and restores defaults:\n① Switches: LAN access on, access PIN on, LAN address auto\n② Public: mode back to random URL, Tunnel Token and fixed domain cleared, and any running tunnel is stopped\n③ 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.',
138
+ 'resetConfirm': 'Reset',
139
+ 'resetDone': '✅ Factory reset done: settings cleared and PINs re-rolled (re-enter the PIN on your phone)',
140
+ 'resetFailed': '❌ Reset failed — please retry',
115
141
  'lanTitle': '📶 LAN (same Wi-Fi)',
116
142
  'lanHint': 'Scan to open once your phone is on the same Wi-Fi',
117
143
  'lanAccess': 'LAN access',
package/lib/index.js CHANGED
@@ -21,7 +21,7 @@ import { createPocketService } from './service.mjs';
21
21
  import { installPocketRpc } from './web-rpc.js';
22
22
  import { restartHost } from './restart.js';
23
23
  import { desktopEnvPatchScript, advancedNoticeScript, DEFAULT_INJECT, classifyHost } from './proxy.mjs';
24
- import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname } from './settings.mjs';
24
+ import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname, resetSettings } from './settings.mjs';
25
25
 
26
26
  const name = 'dsh-pocket';
27
27
  const inject = ['connection', 'webServer'];
@@ -128,6 +128,24 @@ export function setCustomPin(which, value) {
128
128
  throw new Error('未知密码类型 | unknown PIN kind');
129
129
  }
130
130
 
131
+ /**
132
+ * 恢复出厂设置(设置页底部按钮):清空本机设置 + 重设随机访问密码。
133
+ *
134
+ * 删除 settings.json → 所有开关回到默认(局域网访问开、访问密码开、局域网地址自动、
135
+ * 公网模式随机、固定域名/Tunnel Token 清空);公网与局域网密码都换新随机 8 位值
136
+ * (旧密码立即作废,手机需重新输入)。只动 $DSH_HOME/dsh-pocket/ 下的文件,
137
+ * DSH 自身的会话、模型、插件配置不受影响。
138
+ *
139
+ * 注意:调用方应先停掉正在运行的公网隧道(RPC 层已处理),避免重置后仍按旧模式连接。
140
+ * @returns {{accessToken:string, lanToken:string}} 重置后的新密码
141
+ */
142
+ export function resetPocketState() {
143
+ resetSettings();
144
+ writePinToFile(tokenPath(), newPin());
145
+ writePinToFile(lanTokenPath(), newPin());
146
+ return { accessToken: getAccessToken(), lanToken: getLanToken() };
147
+ }
148
+
131
149
  const restartNoticeRel = join('dsh-pocket', 'restarted.json');
132
150
  function restartNoticePath() {
133
151
  return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), restartNoticeRel);
@@ -283,6 +301,8 @@ export function apply(ctx, config = {}, internals = {}) {
283
301
  setCustomPin: (which, value) => setCustomPin(which, value),
284
302
  // 命名隧道配置(issue #66):RPC 只回显脱敏视图(token 只写不读)
285
303
  getTunnelConfig: () => ({ mode: tunnelMode(), hostname: tunnelHostname(), tokenSet: tunnelToken().length > 0 }),
304
+ // 恢复出厂设置:清空设置文件 + 重设随机密码(RPC 层已先停掉公网隧道)
305
+ resetPocket: () => resetPocketState(),
286
306
  setTunnelConfig: ({ mode, hostname, token } = {}) => {
287
307
  const m = mode === undefined ? tunnelMode() : mode;
288
308
  if (m !== 'quick' && m !== 'named') throw new Error('未知隧道模式 | unknown tunnel mode');
package/lib/service.mjs CHANGED
@@ -309,7 +309,7 @@ export function createPocketService({
309
309
  tunnel.onExit?.((code) => {
310
310
  if (controller.signal.aborted) return; // 主动停止(stopTunnel)不算故障
311
311
  tunnelState.phase = 'error';
312
- tunnelState.detail = `隧道进程退出(code=${code})| tunnel process exited`;
312
+ tunnelState.detail = `隧道进程退出(code=${code} | tunnel process exited (code=${code})`;
313
313
  });
314
314
  // 记录「隧道开启中」,供重启后自动恢复(issue #11)
315
315
  void persistAutoTunnel();
package/lib/settings.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  // 默认**开启**(安全优先):局域网扫码也要输 8 位密码;
12
12
  // 用户可关闭——关闭后局域网扫码直连(仅同一网络内的设备能访问),公网不受影响(永远要密码)。
13
13
 
14
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
14
+ import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
15
15
  import { join, dirname } from 'node:path';
16
16
  import { homedir } from 'node:os';
17
17
  import { isValidIpv4 } from './ip.mjs';
@@ -103,6 +103,21 @@ export function setPinCustom(which, on) {
103
103
  return !!on;
104
104
  }
105
105
 
106
+ // ---------- 恢复出厂设置 ----------
107
+ // 设置出问题时的临时兜底:删掉 settings.json 即回到出厂默认(文件缺失 = 默认开启,
108
+ // 于是局域网访问开、访问密码开、局域网地址自动、公网模式随机)。DSH 自身的会话、
109
+ // 模型、插件配置都在 $DSH_HOME 的其他目录,不受影响。
110
+
111
+ /** 删除本机设置文件(不存在也算成功);返回 true 表示已清空。 */
112
+ export function resetSettings() {
113
+ try {
114
+ rmSync(settingsPath(), { force: true });
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
106
121
  // ---------- 命名隧道配置(issue #66:固定公网域名) ----------
107
122
  // 用户在 Cloudflare Zero Trust(Networks → Tunnels)创建命名隧道并复制 Tunnel Token,
108
123
  // 把自己域名的 ingress Service 指向 http://127.0.0.1:<代理端口>;填到这里后,
package/lib/tunnel.mjs CHANGED
@@ -400,8 +400,8 @@ export async function startNamedTunnel({ token, home, signal, onPhase = () => {}
400
400
  cleanup();
401
401
  const tail = buf.trim().split(/\r?\n/).slice(-4).join('\n').trim();
402
402
  reject(new Error(
403
- `cloudflared 退出(code=${code})${tail ? ':' + tail.slice(0, 500) : ''}`
404
- + '——请检查 Tunnel Token 是否有效、域名 Service 是否指向本机代理端口 | tunnel exited — check the token and the ingress hostname',
403
+ `cloudflared 退出(code=${code})${tail ? ':' + tail.slice(0, 500) : ''}——请检查 Tunnel Token 是否有效、域名 Service 是否指向本机代理端口 | `
404
+ + `tunnel exited (code=${code})${tail ? ': ' + tail.slice(0, 500) : ''} — check the Tunnel Token and the ingress hostname`,
405
405
  ));
406
406
  };
407
407
  cleanup = () => {
package/lib/web-rpc.js CHANGED
@@ -25,7 +25,7 @@ export function killHint(port) {
25
25
  }
26
26
 
27
27
  /** 注册 /dsh-pocket 逻辑通道(仅本机 loopback 可调)。 */
28
- export function installPocketRpc(ctx, { service, log = console, desktop = false, runUpdate = null, restart = null, restartNotice = null, getToken = null, getLanToken = null, refreshLanToken = null, getLanAuthEnabled = null, setLanAuthEnabled = null, getLanEnabled = null, setLanEnabled = null, getLanIpOverride = null, setLanIpOverride = null, getPinCustom = null, setCustomPin = null, getTunnelConfig = null, setTunnelConfig = null }) {
28
+ export function installPocketRpc(ctx, { service, log = console, desktop = false, runUpdate = null, restart = null, restartNotice = null, getToken = null, getLanToken = null, refreshLanToken = null, getLanAuthEnabled = null, setLanAuthEnabled = null, getLanEnabled = null, setLanEnabled = null, getLanIpOverride = null, setLanIpOverride = null, getPinCustom = null, setCustomPin = null, getTunnelConfig = null, setTunnelConfig = null, resetPocket = null }) {
29
29
  if (!ctx?.connection?.rpc?.handle) {
30
30
  log.warn?.('dsh-pocket: DSH Host Connection RPC unavailable — settings tab disabled | 无 Connection RPC,设置页不可用');
31
31
  return () => {};
@@ -106,6 +106,21 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
106
106
  return fail('bad-request', err?.message ?? String(err));
107
107
  }
108
108
  }
109
+ if (endpoint === POCKET_ENDPOINTS.pocketReset) {
110
+ // 恢复出厂设置:必须显式确认(payload.confirm === true),先停隧道再清空设置与密码,
111
+ // 返回完整 status 供前端直接替换(旧密码立即作废,手机需重新输入)。
112
+ if (payload?.confirm !== true) {
113
+ return fail('bad-request', '恢复出厂设置需要确认 | factory reset requires confirmation');
114
+ }
115
+ if (!resetPocket) return fail('bad-request', '恢复出厂设置不可用 | factory reset unavailable');
116
+ try {
117
+ service.stopTunnel();
118
+ resetPocket();
119
+ return await statusPayload();
120
+ } catch (err) {
121
+ return fail('bad-request', err?.message ?? String(err));
122
+ }
123
+ }
109
124
  if (endpoint === POCKET_ENDPOINTS.tunnelStart) {
110
125
  // 安全免责声明(issue #31):每次开启公网都必须先确认(前端弹框勾选)。
111
126
  // 服务端强制校验,防止绕过前端直接调 RPC。
package/package.json CHANGED
@@ -80,5 +80,5 @@
80
80
  "access": "public",
81
81
  "registry": "https://registry.npmjs.org/"
82
82
  },
83
- "version": "1.16.1"
83
+ "version": "2.1.0"
84
84
  }