dsh-pocket 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -132,6 +132,8 @@ Note: in named-tunnel mode the public PIN is **not auto-rotated** (the address i
132
132
  | `ERR_PNPM_ADDING_TO_ROOT` | pnpm 9 workspace-root restriction: append `-w` (`--workspace-root`) to install/update commands |
133
133
  | Nothing changed after install/update | **You must restart `dsh web`**; the running process still loads the old code |
134
134
  | `listen EADDRINUSE ... :3081` | A stale dsh-pocket process holds the port: macOS/Linux `lsof -ti :3081 \| xargs kill -9`; Windows `netstat -ano \| findstr :3081` (find the LISTENING PID) → `taskkill /PID <PID> /F`, then retry |
135
+ | Want a different port (issue #70) | Plugin mode: write `"proxyPort": 3082` into `$DSH_HOME/dsh-pocket/settings.json` and restart `dsh web`. CLI mode: `dsh-pocket --port 3082`. If the port is taken you'll get `EADDRINUSE` — kill the old process or pick another one |
136
+ | Issue a temporary PIN to a guest (issue #69) | Settings → "Temporary access PINs" → pick public/LAN + duration (1h/24h/7d) + note → Create. Share the 8-char PIN + the access URL with the guest; it auto-expires and can be revoked manually. Temp PINs share the main PIN rate limit |
135
137
  | Version stuck below 1.x | `^0.x` ranges never jump to 1.x: update with `--latest` (`dsh plugin --profile web update dsh-pocket --latest -w`) |
136
138
  | Public `error 1033` | See "Public tunnel troubleshooting" below — usually a local proxy/VPN (Clash etc. TUN mode) killing the tunnel |
137
139
  | After "Restart dsh web", the page says the process is running in the background | The new process from in-page self-restart is a detached background process (not attached to your terminal) — that's the standard way to apply updates in-page; stop it: macOS/Linux `lsof -ti :3080 \| xargs kill -9`; Windows `netstat -ano \| findstr :3080` → `taskkill /PID <PID> /F` (logs under `$DSH_HOME` as `dsh-pocket-restart-*.log`) |
package/README.md CHANGED
@@ -132,6 +132,8 @@ npx @deepseek-ai/dsh web
132
132
  | `ERR_PNPM_ADDING_TO_ROOT` | pnpm 9 对 workspace 根的限制:安装/更新命令**末尾加 `-w`**(`--workspace-root`) |
133
133
  | 装完/更新了但界面没变化 | **必须重启 `dsh web`** 才生效;运行中的进程仍加载旧代码 |
134
134
  | `listen EADDRINUSE ... :3081` | 旧 dsh-pocket 进程还占着端口:macOS/Linux `lsof -ti :3081 \| xargs kill -9`;Windows `netstat -ano \| findstr :3081`(找 LISTENING 的 PID)→ `taskkill /PID <PID> /F`,后重试 |
135
+ | 想换端口(issue #70) | 插件模式:在 `$DSH_HOME/dsh-pocket/settings.json` 写 `"proxyPort": 3082` 后重启 `dsh web`。CLI 模式:`dsh-pocket --port 3082`。端口被占会报 `EADDRINUSE`,杀掉旧进程或换一个端口 |
136
+ | 给访客一个临时 PIN(issue #69) | 设置页「临时访问 PIN」区块 → 选公网/局域网 + 时长(1h/24h/7d) + 备注 → 生成。把 8 位 PIN + 入口 URL 一起发给对方;过期自动作废,也能手动撤销。临时 PIN 与主 PIN 共用速率限制 |
135
137
  | 版本停在 0.x 升不上去 | `^0.x` 范围不允许升到 1.x:更新用 `--latest`(`dsh plugin --profile web update dsh-pocket --latest -w`) |
136
138
  | 公网 `error 1033` | 见下方「公网隧道常见问题」——多半是本机代理/VPN(Clash 等 TUN 模式)掐断了隧道 |
137
139
  | 点「重启 dsh web」后页面提示进程在后台运行 | 自重启的新进程是 detached 后台进程(不挂终端),是页内更新的标准做法;停止它:macOS/Linux `lsof -ti :3080 \| xargs kill -9`;Windows `netstat -ano \| findstr :3080` → `taskkill /PID <PID> /F`(日志在 `$DSH_HOME` 下 `dsh-pocket-restart-*.log`) |
package/client/api.js CHANGED
@@ -15,6 +15,9 @@ export const POCKET_ENDPOINTS = Object.freeze({
15
15
  lanSetEnabled: 'lan.setEnabled',
16
16
  pinSetCustom: 'pin.setCustom',
17
17
  pocketReset: 'pocket.reset',
18
+ tempPinCreate: 'tempPin.create',
19
+ tempPinList: 'tempPin.list',
20
+ tempPinRevoke: 'tempPin.revoke',
18
21
  });
19
22
 
20
23
  /** 语义化版本比较:a > b 返回正数,相等 0,a < b 负数(数字段 + 预发布后缀)。 */
package/client/client.js CHANGED
@@ -54,7 +54,10 @@ var POCKET_ENDPOINTS = Object.freeze({
54
54
  lanSetOverride: "lan.setOverride",
55
55
  lanSetEnabled: "lan.setEnabled",
56
56
  pinSetCustom: "pin.setCustom",
57
- pocketReset: "pocket.reset"
57
+ pocketReset: "pocket.reset",
58
+ tempPinCreate: "tempPin.create",
59
+ tempPinList: "tempPin.list",
60
+ tempPinRevoke: "tempPin.revoke"
58
61
  });
59
62
  function compareVersions(a, b) {
60
63
  const pa = String(a).replace(/^[vV]/, "").split(".");
@@ -1552,7 +1555,26 @@ var zh2 = {
1552
1555
  "slowHint": " \u2014 \u6709\u70B9\u4E45\uFF1F\u68C0\u67E5\u662F\u5426\u5F00\u7740\u4EE3\u7406/VPN\uFF08Clash TUN \u7B49\uFF09",
1553
1556
  "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",
1554
1557
  "unknownError": "\u672A\u77E5\u9519\u8BEF",
1555
- "feedback": "\u6709\u95EE\u9898\uFF1F\u6B22\u8FCE\u5230 GitHub Issues \u53CD\u9988 \u{1F64F}"
1558
+ "feedback": "\u6709\u95EE\u9898\uFF1F\u6B22\u8FCE\u5230 GitHub Issues \u53CD\u9988 \u{1F64F}",
1559
+ // 临时 PIN(issue #69)
1560
+ "tempPinTitle": "\u4E34\u65F6\u8BBF\u95EE PIN\uFF08\u5E26\u8D85\u65F6\uFF09",
1561
+ "tempPinSubtitle": "\u7ED9\u8BBF\u5BA2\u5F00\u4E00\u4E2A\u6709\u5BFF\u547D\u7684 8 \u4F4D PIN\uFF0C\u8FC7\u671F\u81EA\u52A8\u4F5C\u5E9F\uFF1B\u4E0E\u4E3B PIN \u5171\u7528\u901F\u7387\u9650\u5236\u3002",
1562
+ "tempPinKindPublic": "\u516C\u7F51",
1563
+ "tempPinKindLan": "\u5C40\u57DF\u7F51",
1564
+ "tempPinDuration": "\u6709\u6548\u65F6\u957F",
1565
+ "tempPinDuration1h": "1 \u5C0F\u65F6",
1566
+ "tempPinDuration24h": "24 \u5C0F\u65F6",
1567
+ "tempPinDuration7d": "7 \u5929",
1568
+ "tempPinLabel": "\u5907\u6CE8\uFF08\u53EF\u9009\uFF0C\u7ED9\u8C01\u7528\uFF09",
1569
+ "tempPinLabelPh": "\u670B\u53CB\u5C0F\u738B",
1570
+ "tempPinCreate": "\u751F\u6210",
1571
+ "tempPinCreated": "\u5DF2\u751F\u6210\uFF08\u4EC5\u663E\u793A\u4E00\u6B21\uFF09",
1572
+ "tempPinCopied": "\u5DF2\u590D\u5236",
1573
+ "tempPinCopy": "\u590D\u5236",
1574
+ "tempPinEmpty": "\u6682\u65E0\u4E34\u65F6 PIN",
1575
+ "tempPinExpiresIn": "\u8FC7\u671F",
1576
+ "tempPinRevoke": "\u64A4\u9500",
1577
+ "tempPinRevoked": "\u5DF2\u64A4\u9500"
1556
1578
  };
1557
1579
  var en2 = {
1558
1580
  "section": "Phone access",
@@ -1647,7 +1669,26 @@ var en2 = {
1647
1669
  "slowHint": " \u2014 taking long? Check for a proxy/VPN (e.g., Clash TUN)",
1648
1670
  "error": "\u274C Failed to enable: {detail} (you can retry; for proxy/VPN issues see the README)",
1649
1671
  "unknownError": "unknown error",
1650
- "feedback": "\u{1F64F} Questions? Open an issue on GitHub"
1672
+ "feedback": "\u{1F64F} Questions? Open an issue on GitHub",
1673
+ // Temp PIN (issue #69)
1674
+ "tempPinTitle": "Temporary access PINs (auto-expire)",
1675
+ "tempPinSubtitle": "Issue a time-limited 8-char PIN to a guest; expires automatically. Shares the main PIN rate limit.",
1676
+ "tempPinKindPublic": "Public",
1677
+ "tempPinKindLan": "LAN",
1678
+ "tempPinDuration": "Valid for",
1679
+ "tempPinDuration1h": "1 hour",
1680
+ "tempPinDuration24h": "24 hours",
1681
+ "tempPinDuration7d": "7 days",
1682
+ "tempPinLabel": "Note (optional, who is this for)",
1683
+ "tempPinLabelPh": "Friend Alice",
1684
+ "tempPinCreate": "Create",
1685
+ "tempPinCreated": "Created (shown once)",
1686
+ "tempPinCopied": "Copied",
1687
+ "tempPinCopy": "Copy",
1688
+ "tempPinEmpty": "No temporary PINs yet",
1689
+ "tempPinExpiresIn": "expires",
1690
+ "tempPinRevoke": "Revoke",
1691
+ "tempPinRevoked": "Revoked"
1651
1692
  };
1652
1693
 
1653
1694
  // client/index.jsx
@@ -1782,6 +1823,73 @@ function PocketSettingsTab({ rpcCall, t }) {
1782
1823
  setUpdateInfo((u) => ({ ...u, updating: false, result: "fail", output: err.message }));
1783
1824
  }
1784
1825
  };
1826
+ const [tempPinForm, setTempPinForm] = (0, import_react2.useState)({ kind: "lan", expiresInSec: 86400, label: "" });
1827
+ const [tempPinsList, setTempPinsList] = (0, import_react2.useState)([]);
1828
+ const [tempPinLast, setTempPinLast] = (0, import_react2.useState)(null);
1829
+ const [tempPinBusy, setTempPinBusy] = (0, import_react2.useState)(false);
1830
+ const [tempPinError, setTempPinError] = (0, import_react2.useState)(null);
1831
+ const loadTempPins = async () => {
1832
+ try {
1833
+ const r = await call(POCKET_ENDPOINTS.tempPinList, {});
1834
+ setTempPinsList(Array.isArray(r?.tempPins) ? r.tempPins : []);
1835
+ } catch {
1836
+ }
1837
+ };
1838
+ (0, import_react2.useEffect)(() => {
1839
+ loadTempPins();
1840
+ }, []);
1841
+ (0, import_react2.useEffect)(() => {
1842
+ if (tempPinsList.length === 0 && !tempPinLast) return;
1843
+ const t2 = setInterval(() => {
1844
+ loadTempPins();
1845
+ }, 3e4);
1846
+ return () => clearInterval(t2);
1847
+ }, [tempPinsList.length, tempPinLast]);
1848
+ const createTempPinNow = async () => {
1849
+ setTempPinBusy(true);
1850
+ setTempPinError(null);
1851
+ try {
1852
+ const r = await call(POCKET_ENDPOINTS.tempPinCreate, { kind: tempPinForm.kind, expiresInSec: tempPinForm.expiresInSec, label: tempPinForm.label });
1853
+ setTempPinLast(r);
1854
+ setTempPinForm((f) => ({ ...f, label: "" }));
1855
+ await loadTempPins();
1856
+ } catch (err) {
1857
+ setTempPinError(err.message);
1858
+ } finally {
1859
+ setTempPinBusy(false);
1860
+ }
1861
+ };
1862
+ const revokeTempPinNow = async (p) => {
1863
+ try {
1864
+ await call(POCKET_ENDPOINTS.tempPinRevoke, { value: p.value, kind: p.kind });
1865
+ showToast(t("tempPinRevoked"));
1866
+ if (tempPinLast?.value === p.value) setTempPinLast(null);
1867
+ await loadTempPins();
1868
+ } catch (err) {
1869
+ setError(err.message);
1870
+ }
1871
+ };
1872
+ const copyTempPin = async (value) => {
1873
+ try {
1874
+ if (navigator.clipboard?.writeText) {
1875
+ await navigator.clipboard.writeText(value);
1876
+ showToast(t("tempPinCopied"));
1877
+ }
1878
+ } catch {
1879
+ }
1880
+ };
1881
+ const formatRemaining = (expiresAt) => {
1882
+ const ms = expiresAt - Date.now();
1883
+ if (ms <= 0) return "0s";
1884
+ const s = Math.floor(ms / 1e3);
1885
+ if (s < 60) return `${s}s`;
1886
+ const m = Math.floor(s / 60);
1887
+ if (m < 60) return `${m}m`;
1888
+ const h2 = Math.floor(m / 60);
1889
+ if (h2 < 24) return `${h2}h`;
1890
+ const d = Math.floor(h2 / 24);
1891
+ return `${d}d`;
1892
+ };
1785
1893
  const [disclaimerOpen, setDisclaimerOpen] = (0, import_react2.useState)(false);
1786
1894
  const [disclaimerChecked, setDisclaimerChecked] = (0, import_react2.useState)(false);
1787
1895
  const doStartTunnel = async () => {
@@ -2211,6 +2319,77 @@ function PocketSettingsTab({ rpcCall, t }) {
2211
2319
  ) : null
2212
2320
  ),
2213
2321
  error ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", fontSize: 12, marginTop: 8 } }, `\u274C ${errText(error)}`) : null,
2322
+ // 临时 PIN(issue #69):带过期的访问密码,给访客用
2323
+ (0, import_react2.createElement)(
2324
+ "div",
2325
+ { style: styles.block },
2326
+ (0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 13, marginBottom: 4 } }, t("tempPinTitle")),
2327
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, marginBottom: 10 } }, t("tempPinSubtitle")),
2328
+ // 生成表单:分类 + 时长 + 备注 + 生成
2329
+ (0, import_react2.createElement)(
2330
+ "div",
2331
+ { style: { display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" } },
2332
+ (0, import_react2.createElement)(
2333
+ "select",
2334
+ {
2335
+ value: tempPinForm.kind,
2336
+ onChange: (e) => setTempPinForm((f) => ({ ...f, kind: e.target.value })),
2337
+ 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)" }
2338
+ },
2339
+ (0, import_react2.createElement)("option", { value: "public" }, t("tempPinKindPublic")),
2340
+ (0, import_react2.createElement)("option", { value: "lan" }, t("tempPinKindLan"))
2341
+ ),
2342
+ (0, import_react2.createElement)(
2343
+ "select",
2344
+ {
2345
+ value: String(tempPinForm.expiresInSec),
2346
+ onChange: (e) => setTempPinForm((f) => ({ ...f, expiresInSec: Number(e.target.value) })),
2347
+ 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)" }
2348
+ },
2349
+ (0, import_react2.createElement)("option", { value: "3600" }, t("tempPinDuration1h")),
2350
+ (0, import_react2.createElement)("option", { value: "86400" }, t("tempPinDuration24h")),
2351
+ (0, import_react2.createElement)("option", { value: "604800" }, t("tempPinDuration7d"))
2352
+ ),
2353
+ (0, import_react2.createElement)("input", {
2354
+ style: { flex: 1, minWidth: 120, height: 30, padding: "0 10px", borderRadius: 8, border: "1px solid var(--dsw-alias-border-l2,#d1d5db)", fontSize: 13, outline: "none" },
2355
+ placeholder: t("tempPinLabelPh"),
2356
+ value: tempPinForm.label,
2357
+ onChange: (e) => setTempPinForm((f) => ({ ...f, label: e.target.value })),
2358
+ onKeyDown: (e) => {
2359
+ if (e.key === "Enter") createTempPinNow();
2360
+ }
2361
+ }),
2362
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 30 }, onClick: createTempPinNow, disabled: tempPinBusy }, t("tempPinCreate"))
2363
+ ),
2364
+ // 错误展示
2365
+ tempPinError ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", fontSize: 12, marginTop: 6 } }, errText(tempPinError)) : null,
2366
+ // 刚生成的那一个(高亮,下一秒会消失提示用户立刻复制)
2367
+ tempPinLast ? (0, import_react2.createElement)(
2368
+ "div",
2369
+ { style: { marginTop: 10, padding: "8px 10px", borderRadius: 8, background: "var(--dsw-alias-bg-layer-2,#f3f4f6)", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, flexWrap: "wrap" } },
2370
+ (0, import_react2.createElement)(
2371
+ "div",
2372
+ null,
2373
+ (0, import_react2.createElement)("div", { style: { fontSize: 11, color: "var(--dsw-alias-state-success-primary,#16a34a)", fontWeight: 600, marginBottom: 2 } }, `${t("tempPinCreated")} \xB7 ${tempPinLast.kind === "public" ? t("tempPinKindPublic") : t("tempPinKindLan")} \xB7 ${formatRemaining(tempPinLast.expiresAt)}`),
2374
+ (0, import_react2.createElement)("span", { style: { fontFamily: "ui-monospace,Menlo,monospace", fontSize: 16, letterSpacing: 2, fontWeight: 600 } }, tempPinLast.value)
2375
+ ),
2376
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 28, padding: "0 12px", fontSize: 12 }, onClick: () => copyTempPin(tempPinLast.value) }, t("tempPinCopy"))
2377
+ ) : null,
2378
+ // 列表
2379
+ (0, import_react2.createElement)(
2380
+ "div",
2381
+ { style: { marginTop: 12, borderTop: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", paddingTop: 8 } },
2382
+ tempPinsList.length === 0 ? (0, import_react2.createElement)("div", { style: { ...styles.muted, padding: "6px 0" } }, t("tempPinEmpty")) : tempPinsList.map((p) => (0, import_react2.createElement)(
2383
+ "div",
2384
+ { key: p.value, style: { display: "flex", alignItems: "center", gap: 8, padding: "6px 0", borderBottom: "1px solid var(--dsw-alias-border-l2,#e5e7eb)" } },
2385
+ (0, import_react2.createElement)("span", { style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary,#8b93a1)", minWidth: 50 } }, p.kind === "public" ? t("tempPinKindPublic") : t("tempPinKindLan")),
2386
+ (0, import_react2.createElement)("span", { style: { fontFamily: "ui-monospace,Menlo,monospace", fontSize: 13, letterSpacing: 1, flexShrink: 0 } }, p.value),
2387
+ (0, import_react2.createElement)("span", { style: { ...styles.muted, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, p.label || "\u2014"),
2388
+ (0, import_react2.createElement)("span", { style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary,#8b93a1)" } }, `${t("tempPinExpiresIn")} ${formatRemaining(p.expiresAt)}`),
2389
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 24, padding: "0 10px", fontSize: 11 }, onClick: () => revokeTempPinNow(p) }, t("tempPinRevoke"))
2390
+ ))
2391
+ )
2392
+ ),
2214
2393
  // 恢复出厂设置:设置出问题时的临时兜底(最底部,避免误触)
2215
2394
  (0, import_react2.createElement)(
2216
2395
  "div",
package/client/index.jsx CHANGED
@@ -165,6 +165,76 @@ function PocketSettingsTab({ rpcCall, t }) {
165
165
 
166
166
  // 安全免责声明(issue #31):每次开启公网都必须先弹框勾选「我已知情」。
167
167
  // 服务端同样强制(tunnel.start 需 disclaimer: true),防绕过前端直接调 RPC。
168
+ // 临时 PIN(issue #69):表单、刚生成项、列表
169
+ const [tempPinForm, setTempPinForm] = useState({ kind: 'lan', expiresInSec: 86400, label: '' });
170
+ const [tempPinsList, setTempPinsList] = useState([]);
171
+ const [tempPinLast, setTempPinLast] = useState(null); // { value, kind, expiresAt, label }
172
+ const [tempPinBusy, setTempPinBusy] = useState(false);
173
+ const [tempPinError, setTempPinError] = useState(null);
174
+
175
+ const loadTempPins = async () => {
176
+ try {
177
+ const r = await call(POCKET_ENDPOINTS.tempPinList, {});
178
+ setTempPinsList(Array.isArray(r?.tempPins) ? r.tempPins : []);
179
+ } catch { /* 忽略 */ }
180
+ };
181
+ useEffect(() => { loadTempPins(); }, []);
182
+
183
+ // 每 30 秒刷新一次(让剩余时间实时倒计时显示)
184
+ useEffect(() => {
185
+ if (tempPinsList.length === 0 && !tempPinLast) return;
186
+ const t = setInterval(() => {
187
+ // 触发 setNow 已有的 tick 重新渲染即可;这里只重新拉一次(处理过期自动消失)
188
+ loadTempPins();
189
+ }, 30_000);
190
+ return () => clearInterval(t);
191
+ }, [tempPinsList.length, tempPinLast]);
192
+
193
+ const createTempPinNow = async () => {
194
+ setTempPinBusy(true);
195
+ setTempPinError(null);
196
+ try {
197
+ const r = await call(POCKET_ENDPOINTS.tempPinCreate, { kind: tempPinForm.kind, expiresInSec: tempPinForm.expiresInSec, label: tempPinForm.label });
198
+ setTempPinLast(r);
199
+ setTempPinForm((f) => ({ ...f, label: '' }));
200
+ await loadTempPins();
201
+ } catch (err) {
202
+ setTempPinError(err.message);
203
+ } finally {
204
+ setTempPinBusy(false);
205
+ }
206
+ };
207
+ const revokeTempPinNow = async (p) => {
208
+ try {
209
+ await call(POCKET_ENDPOINTS.tempPinRevoke, { value: p.value, kind: p.kind });
210
+ showToast(t('tempPinRevoked'));
211
+ if (tempPinLast?.value === p.value) setTempPinLast(null);
212
+ await loadTempPins();
213
+ } catch (err) {
214
+ setError(err.message);
215
+ }
216
+ };
217
+ const copyTempPin = async (value) => {
218
+ try {
219
+ if (navigator.clipboard?.writeText) {
220
+ await navigator.clipboard.writeText(value);
221
+ showToast(t('tempPinCopied'));
222
+ }
223
+ } catch { /* 忽略 */ }
224
+ };
225
+ const formatRemaining = (expiresAt) => {
226
+ const ms = expiresAt - Date.now();
227
+ if (ms <= 0) return '0s';
228
+ const s = Math.floor(ms / 1000);
229
+ if (s < 60) return `${s}s`;
230
+ const m = Math.floor(s / 60);
231
+ if (m < 60) return `${m}m`;
232
+ const h = Math.floor(m / 60);
233
+ if (h < 24) return `${h}h`;
234
+ const d = Math.floor(h / 24);
235
+ return `${d}d`;
236
+ };
237
+
168
238
  const [disclaimerOpen, setDisclaimerOpen] = useState(false);
169
239
  const [disclaimerChecked, setDisclaimerChecked] = useState(false);
170
240
 
@@ -554,6 +624,62 @@ function PocketSettingsTab({ rpcCall, t }) {
554
624
 
555
625
  error ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 12, marginTop: 8 } }, `❌ ${errText(error)}`) : null,
556
626
 
627
+ // 临时 PIN(issue #69):带过期的访问密码,给访客用
628
+ h('div', { style: styles.block },
629
+ h('div', { style: { fontWeight: 600, fontSize: 13, marginBottom: 4 } }, t('tempPinTitle')),
630
+ h('div', { style: { ...styles.muted, marginBottom: 10 } }, t('tempPinSubtitle')),
631
+ // 生成表单:分类 + 时长 + 备注 + 生成
632
+ h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' } },
633
+ h('select', {
634
+ value: tempPinForm.kind,
635
+ onChange: (e) => setTempPinForm((f) => ({ ...f, kind: e.target.value })),
636
+ 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)' },
637
+ },
638
+ h('option', { value: 'public' }, t('tempPinKindPublic')),
639
+ h('option', { value: 'lan' }, t('tempPinKindLan')),
640
+ ),
641
+ h('select', {
642
+ value: String(tempPinForm.expiresInSec),
643
+ onChange: (e) => setTempPinForm((f) => ({ ...f, expiresInSec: Number(e.target.value) })),
644
+ 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)' },
645
+ },
646
+ h('option', { value: '3600' }, t('tempPinDuration1h')),
647
+ h('option', { value: '86400' }, t('tempPinDuration24h')),
648
+ h('option', { value: '604800' }, t('tempPinDuration7d')),
649
+ ),
650
+ h('input', {
651
+ style: { flex: 1, minWidth: 120, height: 30, padding: '0 10px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', fontSize: 13, outline: 'none' },
652
+ placeholder: t('tempPinLabelPh'),
653
+ value: tempPinForm.label,
654
+ onChange: (e) => setTempPinForm((f) => ({ ...f, label: e.target.value })),
655
+ onKeyDown: (e) => { if (e.key === 'Enter') createTempPinNow(); },
656
+ }),
657
+ h('button', { style: { ...styles.btn, height: 30 }, onClick: createTempPinNow, disabled: tempPinBusy }, t('tempPinCreate')),
658
+ ),
659
+ // 错误展示
660
+ tempPinError ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 12, marginTop: 6 } }, errText(tempPinError)) : null,
661
+ // 刚生成的那一个(高亮,下一秒会消失提示用户立刻复制)
662
+ tempPinLast ? h('div', { style: { marginTop: 10, padding: '8px 10px', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' } },
663
+ h('div', null,
664
+ h('div', { style: { fontSize: 11, color: 'var(--dsw-alias-state-success-primary,#16a34a)', fontWeight: 600, marginBottom: 2 } }, `${t('tempPinCreated')} · ${tempPinLast.kind === 'public' ? t('tempPinKindPublic') : t('tempPinKindLan')} · ${formatRemaining(tempPinLast.expiresAt)}`),
665
+ h('span', { style: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 16, letterSpacing: 2, fontWeight: 600 } }, tempPinLast.value),
666
+ ),
667
+ h('button', { style: { ...styles.btn, height: 28, padding: '0 12px', fontSize: 12 }, onClick: () => copyTempPin(tempPinLast.value) }, t('tempPinCopy')),
668
+ ) : null,
669
+ // 列表
670
+ h('div', { style: { marginTop: 12, borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', paddingTop: 8 } },
671
+ tempPinsList.length === 0
672
+ ? h('div', { style: { ...styles.muted, padding: '6px 0' } }, t('tempPinEmpty'))
673
+ : tempPinsList.map((p) => h('div', { key: p.value, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', borderBottom: '1px solid var(--dsw-alias-border-l2,#e5e7eb)' } },
674
+ h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary,#8b93a1)', minWidth: 50 } }, p.kind === 'public' ? t('tempPinKindPublic') : t('tempPinKindLan')),
675
+ h('span', { style: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 13, letterSpacing: 1, flexShrink: 0 } }, p.value),
676
+ h('span', { style: { ...styles.muted, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, p.label || '—'),
677
+ h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary,#8b93a1)' } }, `${t('tempPinExpiresIn')} ${formatRemaining(p.expiresAt)}`),
678
+ h('button', { style: { ...styles.btn, height: 24, padding: '0 10px', fontSize: 11 }, onClick: () => revokeTempPinNow(p) }, t('tempPinRevoke')),
679
+ )),
680
+ ),
681
+ ),
682
+
557
683
  // 恢复出厂设置:设置出问题时的临时兜底(最底部,避免误触)
558
684
  h('div', { style: styles.block },
559
685
  h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
@@ -97,6 +97,25 @@ export const zh = {
97
97
  'error': '❌ 开启失败:{detail}(可重试;若是代理/VPN 问题见 README 排障)',
98
98
  'unknownError': '未知错误',
99
99
  'feedback': '有问题?欢迎到 GitHub Issues 反馈 🙏',
100
+ // 临时 PIN(issue #69)
101
+ 'tempPinTitle': '临时访问 PIN(带超时)',
102
+ 'tempPinSubtitle': '给访客开一个有寿命的 8 位 PIN,过期自动作废;与主 PIN 共用速率限制。',
103
+ 'tempPinKindPublic': '公网',
104
+ 'tempPinKindLan': '局域网',
105
+ 'tempPinDuration': '有效时长',
106
+ 'tempPinDuration1h': '1 小时',
107
+ 'tempPinDuration24h': '24 小时',
108
+ 'tempPinDuration7d': '7 天',
109
+ 'tempPinLabel': '备注(可选,给谁用)',
110
+ 'tempPinLabelPh': '朋友小王',
111
+ 'tempPinCreate': '生成',
112
+ 'tempPinCreated': '已生成(仅显示一次)',
113
+ 'tempPinCopied': '已复制',
114
+ 'tempPinCopy': '复制',
115
+ 'tempPinEmpty': '暂无临时 PIN',
116
+ 'tempPinExpiresIn': '过期',
117
+ 'tempPinRevoke': '撤销',
118
+ 'tempPinRevoked': '已撤销',
100
119
  }
101
120
 
102
121
  /** English dictionary, key-identical to the Chinese source of truth. */
@@ -194,4 +213,23 @@ export const en = {
194
213
  'error': '❌ Failed to enable: {detail} (you can retry; for proxy/VPN issues see the README)',
195
214
  'unknownError': 'unknown error',
196
215
  'feedback': '🙏 Questions? Open an issue on GitHub',
216
+ // Temp PIN (issue #69)
217
+ 'tempPinTitle': 'Temporary access PINs (auto-expire)',
218
+ 'tempPinSubtitle': 'Issue a time-limited 8-char PIN to a guest; expires automatically. Shares the main PIN rate limit.',
219
+ 'tempPinKindPublic': 'Public',
220
+ 'tempPinKindLan': 'LAN',
221
+ 'tempPinDuration': 'Valid for',
222
+ 'tempPinDuration1h': '1 hour',
223
+ 'tempPinDuration24h': '24 hours',
224
+ 'tempPinDuration7d': '7 days',
225
+ 'tempPinLabel': 'Note (optional, who is this for)',
226
+ 'tempPinLabelPh': 'Friend Alice',
227
+ 'tempPinCreate': 'Create',
228
+ 'tempPinCreated': 'Created (shown once)',
229
+ 'tempPinCopied': 'Copied',
230
+ 'tempPinCopy': 'Copy',
231
+ 'tempPinEmpty': 'No temporary PINs yet',
232
+ 'tempPinExpiresIn': 'expires',
233
+ 'tempPinRevoke': 'Revoke',
234
+ 'tempPinRevoked': 'Revoked',
197
235
  }
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 { advancedNoticeScript, DEFAULT_INJECT, classifyHost } from './proxy.mjs';
24
- import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname, resetSettings } from './settings.mjs';
24
+ import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname, resetSettings, proxyPort, tempPinValuesFor, tempPins as readTempPins, createTempPin, revokeTempPin } from './settings.mjs';
25
25
 
26
26
  const name = 'dsh-pocket';
27
27
  const inject = ['connection', 'webServer'];
@@ -246,7 +246,9 @@ export function apply(ctx, config = {}, internals = {}) {
246
246
 
247
247
  const service = internals.service ?? createPocketService({
248
248
  dshPort,
249
- port: internals.port ?? config.port ?? 3081,
249
+ // 端口(issue #70):插件模式优先级 settings.proxyPort > cordis patch config.port > 3081
250
+ // 3081 被占时 createPocketService 内部 EADDRINUSE 抛错,按 DSH 插件启动失败处理(用户改端口重启即可)。
251
+ port: internals.port ?? config.port ?? (proxyPort() || 3081),
250
252
  home: internals.home,
251
253
  internals,
252
254
  getLanIpOverride: () => lanIpOverride(),
@@ -274,6 +276,8 @@ export function apply(ctx, config = {}, internals = {}) {
274
276
  sessionKey: randomBytes(16).toString('hex'),
275
277
  getToken: (host) => tokenForHost(host),
276
278
  isProtected: (host) => (classifyHost(host) === 'public' ? true : lanAuthEnabled()),
279
+ // 临时 PIN(issue #69):与主 PIN 同 host 分类,仅在受保护入口校验
280
+ getAltTokens: (host) => tempPinValuesFor(classifyHost(host) === 'public' ? 'public' : 'lan'),
277
281
  },
278
282
  // dsh web 浏览器会话启动 token(issue #77):新版 dsh(>= 0.1.2-alpha.1)要求根路径
279
283
  // 带一次 `?token=` 换 cookie,否则 /api 与 WebSocket 全 401。token 每次进程启动都变,
@@ -330,6 +334,11 @@ export function apply(ctx, config = {}, internals = {}) {
330
334
  if (token !== undefined) setTunnelToken(token);
331
335
  return { mode: tunnelMode(), hostname: tunnelHostname(), tokenSet: tunnelToken().length > 0 };
332
336
  },
337
+ // 临时 PIN(issue #69):创建/列出/撤销。前端只看到 value 一次(创建时),
338
+ // 列出时也回显 value(用户需要复制给别人),文件 0o600 + 仅本机 loopback RPC 兜底。
339
+ listTempPins: () => readTempPins(),
340
+ createTempPin: ({ kind, expiresInSec, label }) => createTempPin({ kind, expiresInSec, label }),
341
+ revokeTempPin: (value, kind) => revokeTempPin(value, kind),
333
342
  runUpdate: internals.runUpdate ?? { currentVersion, perform: performUpdate, loadedVersion: () => loadedVersion },
334
343
  restart: internals.restart ?? (() => pocketRestart(service)),
335
344
  restartNotice: internals.restartNotice ?? consumeRestartNotice,
package/lib/proxy.mjs CHANGED
@@ -268,14 +268,24 @@ function isHtmlRequest(req) {
268
268
  return accept.includes('text/html') || req.url === '/' || /\.html?$/i.test(String(req.url));
269
269
  }
270
270
 
271
- /** 校验请求是否已认证;返回 true 放行。 */
272
- function authCheck(req, token, sessionKey) {
273
- if (!token) return true; // token(未配置密码)→ 放行
274
- const expected = cookieFor(token, sessionKey);
275
- const cookieTok = parseCookies(req.headers.cookie)[TOKEN_COOKIE];
276
- if (cookieTok === expected) return true;
271
+ /** 校验请求是否已认证;返回 true 放行。`tokens` 允许传多个(主 PIN + 临时 PIN)。 */
272
+ function authCheck(req, tokens, sessionKey) {
273
+ const list = (Array.isArray(tokens) ? tokens : tokens ? [tokens] : []).filter(Boolean);
274
+ if (list.length === 0) return true; // 无 token(未配置密码)→ 放行
275
+ const cookies = parseCookies(req.headers.cookie);
276
+ const cookieTok = cookies[TOKEN_COOKIE];
277
+ if (cookieTok) {
278
+ for (const token of list) {
279
+ if (cookieTok === cookieFor(token, sessionKey)) return true;
280
+ }
281
+ }
277
282
  const qTok = new URL(req.url ?? '/', 'http://x').searchParams.get('token');
278
- return qTok === expected;
283
+ if (qTok) {
284
+ for (const token of list) {
285
+ if (qTok === cookieFor(token, sessionKey)) return true;
286
+ }
287
+ }
288
+ return false;
279
289
  }
280
290
 
281
291
  /** 把浏览器可见的权威改写成 loopback 权威(Host 和 Origin 都改)。 */
@@ -372,7 +382,9 @@ function attachWebSocketHeartbeat(socket, { intervalMs = 30_000, missLimit = 2 }
372
382
  * @param {string} [opts.host] 监听地址(默认 0.0.0.0:LAN 与隧道都能到)
373
383
  * @param {{host:string,port:number}} [opts.upstream] 上游 dsh web(默认 127.0.0.1:3080)
374
384
  * @param {string} [opts.injectHtml] 注入 HTML 的内容(默认 polyfill + 移动端适配;传 '' 关闭)
375
- * @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, isProtected, sessionKey }
385
+ * @param {object} [opts.auth] 可选访问令牌认证(issue #13 + #69):{ getToken, getAltTokens?, isProtected, sessionKey }
386
+ * - getToken(host) → 主 PIN(公网/局域网各一个,按 host 分类)
387
+ * - getAltTokens?(host) → 临时 PIN 列表(issue #69),校验时与主 PIN 任一命中即放行
376
388
  * @param {object|false} [opts.rateLimit] 登录速率限制参数覆盖(issue #40;测试用短窗口)
377
389
  * @param {object|false} [opts.heartbeat] WebSocket 心跳注入(PR #41):{ intervalMs, missLimit };false 关闭(默认开:30s/容忍 2 个静默周期)
378
390
  * @param {() => boolean} [opts.lanAccessEnabled] 局域网访问是否开启(默认开启)。关闭时拦截经局域网 Host 的请求(公网/loopback 不受影响)。
@@ -396,11 +408,14 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
396
408
  }
397
409
  return;
398
410
  }
399
- // 访问令牌认证(issue #13 + #18 + #33 + #40):局域网与公网按开关/来源要求密码
411
+ // 访问令牌认证(issue #13 + #18 + #33 + #40 + #69):局域网与公网按开关/来源要求密码
400
412
  if (auth) {
401
413
  const protectedHost = isProtectedHost(host, auth.isProtected);
402
414
  const token = protectedHost ? (auth.getToken?.(host) ?? null) : null;
415
+ // 临时 PIN(issue #69):按 host 同源分发(公网临时 PIN 只在公网入口放行,局域网同理)
416
+ const altTokens = protectedHost && token && typeof auth.getAltTokens === 'function' ? (auth.getAltTokens(host) ?? []) : [];
403
417
  const sessionKey = auth.sessionKey ?? null;
418
+ const acceptedTokens = token ? [token, ...altTokens] : [];
404
419
  if (protectedHost && token) {
405
420
  const ip = clientIp(req);
406
421
  // 登录提交:速率限制(issue #40)→ 校验密码 → 种持久 HttpOnly cookie(30 天,绑定进程会话密钥)→ 回首页
@@ -438,7 +453,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
438
453
  });
439
454
  return;
440
455
  }
441
- if (!authCheck(req, token, sessionKey)) {
456
+ if (!authCheck(req, acceptedTokens, sessionKey)) {
442
457
  if (isHtmlRequest(req)) {
443
458
  // 锁定期间打开登录页也给提示(HTTP 200 + 锁定文案;429 语义留给 POST 拒绝)
444
459
  const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
@@ -543,10 +558,12 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
543
558
  socket.destroy();
544
559
  return;
545
560
  }
546
- // WebSocket 同样校验(防止绕过 HTTP 认证从 WS 进入)
561
+ // WebSocket 同样校验(防止绕过 HTTP 认证从 WS 进入;含临时 PIN,issue #69)
547
562
  if (auth) {
548
563
  const token = isProtectedHost(host, auth.isProtected) ? (auth.getToken?.(host) ?? null) : null;
549
- if (token && !authCheck(req, token, auth.sessionKey ?? null)) {
564
+ const altTokens = token && typeof auth.getAltTokens === 'function' ? (auth.getAltTokens(host) ?? []) : [];
565
+ const acceptedTokens = token ? [token, ...altTokens] : [];
566
+ if (token && !authCheck(req, acceptedTokens, auth.sessionKey ?? null)) {
550
567
  socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
551
568
  socket.destroy();
552
569
  return;
package/lib/settings.mjs CHANGED
@@ -14,6 +14,7 @@
14
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
+ import { randomBytes } from 'node:crypto';
17
18
  import { isValidIpv4 } from './ip.mjs';
18
19
 
19
20
  const settingsRel = join('dsh-pocket', 'settings.json');
@@ -192,3 +193,98 @@ export function setTunnelHostname(value) {
192
193
  writeSettings(s);
193
194
  return v;
194
195
  }
196
+
197
+ // ---------- 代理端口(issue #70) ----------
198
+ // 局域网代理的监听端口(默认 3081)。插件模式下唯一改法就是写 settings.json 的
199
+ // proxyPort 字段(CLI 模式可用 dsh-pocket --port)。允许范围 1-65535;
200
+ // 端口已被占用时 dsh web 启动会直接抛 EADDRINUSE(保持原行为),不必在 setter 校验。
201
+ /** 当前代理端口(0 = 用默认 3081)。 */
202
+ export function proxyPort() {
203
+ const v = Number(readSettings().proxyPort);
204
+ return Number.isInteger(v) && v >= 1 && v <= 65535 ? v : 0;
205
+ }
206
+
207
+ /** 设置代理端口(持久化)。空/0/非法值清除,回退默认 3081。 */
208
+ export function setProxyPort(value) {
209
+ const n = Number(value);
210
+ const s = readSettings();
211
+ if (Number.isInteger(n) && n >= 1 && n <= 65535) s.proxyPort = n;
212
+ else delete s.proxyPort;
213
+ writeSettings(s);
214
+ return proxyPort();
215
+ }
216
+
217
+ // ---------- 临时 PIN(issue #69:带超时的临时访问) ----------
218
+ // 让用户能给访客(朋友/同事/临时设备)开一个**有寿命**的 PIN,过期自动作废——
219
+ //
220
+ // 设计要点:
221
+ // - 临时 PIN 与主 PIN **共用速率限制**(按 IP):攻击者拿到临时 PIN 也不能暴力破解别的;
222
+ // - 临时 PIN **绑 host 分类**:公网临时 PIN 只在公网入口校验,局域网临时 PIN 只在局域网入口校验
223
+ // (与主 PIN 的 host 分发一致;防止公网临时 PIN 被局域网内的人滥用,反之亦然);
224
+ // - 过期**懒清理**:访问时过滤掉过期的,避免每次都写磁盘(频繁写影响响应时间);
225
+ // 设置页列表操作或重启 dsh web 之后才落盘(writeSettings 在 setTempPin / revokeTempPin 时调用);
226
+ // - 不复用主 PIN 的「自定义标记」:临时 PIN 是一次性生成,标记无意义;
227
+ // - 存本机 0o600 settings.json(与 Token 一致)。
228
+ //
229
+ // 临时 PIN 默认长度同主 PIN(8 位字母数字),但由系统生成,用户不设置。
230
+ // 一次性签名不重要:临时 PIN 本身足够长(8 位)且绑超时,被截获也只活到过期。
231
+ //
232
+ // 数据结构:settings.tempPins = [{ value, kind: 'public'|'lan', expiresAt: <epoch_ms>, label: <string> }]
233
+ const TEMP_PIN_KINDS = new Set(['public', 'lan']);
234
+ const TEMP_PIN_RE = /^[a-zA-Z0-9]{8}$/;
235
+ /** 最小允许有效期 5 分钟(防误点立即过期让用户迷惑),最大 30 天。 */
236
+ const TEMP_PIN_MIN_SEC = 5 * 60;
237
+ const TEMP_PIN_MAX_SEC = 30 * 24 * 60 * 60;
238
+ /** 临时 PIN 生成(issue #69):与主 PIN 同长度(8 位字母数字),用密码学随机。 */
239
+ function newTempPin() {
240
+ const bytes = randomBytes(8);
241
+ // 把 8 字节转 base32-ish 字母数字串,再截 8 位;碰撞概率忽略(8 字节熵 ≈ 2^64)
242
+ const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
243
+ let out = '';
244
+ for (let i = 0; i < 8; i++) out += alphabet[bytes[i] % alphabet.length];
245
+ return out;
246
+ }
247
+
248
+ /** 读临时 PIN 列表(自动过滤过期项;不写磁盘)。 */
249
+ export function tempPins() {
250
+ const arr = readSettings().tempPins;
251
+ if (!Array.isArray(arr)) return [];
252
+ const now = Date.now();
253
+ return arr
254
+ .filter((p) => p && typeof p === 'object' && typeof p.value === 'string' && typeof p.expiresAt === 'number' && p.expiresAt > now)
255
+ .map((p) => ({ value: p.value, kind: p.kind, expiresAt: p.expiresAt, label: typeof p.label === 'string' ? p.label : '' }));
256
+ }
257
+
258
+ /** 创建一个临时 PIN(issue #69)。返回 { value, expiresAt, kind, label }。 */
259
+ export function createTempPin({ kind, expiresInSec, label = '' } = {}) {
260
+ if (!TEMP_PIN_KINDS.has(kind)) throw new Error('临时 PIN 类型必须是 public 或 lan | temp pin kind must be public or lan');
261
+ const sec = Number(expiresInSec);
262
+ if (!Number.isInteger(sec) || sec < TEMP_PIN_MIN_SEC || sec > TEMP_PIN_MAX_SEC) {
263
+ throw new Error(`临时 PIN 有效期需在 ${TEMP_PIN_MIN_SEC}-${TEMP_PIN_MAX_SEC} 秒之间 | expiresInSec out of range`);
264
+ }
265
+ const value = newTempPin();
266
+ const expiresAt = Date.now() + sec * 1000;
267
+ const s = readSettings();
268
+ s.tempPins = Array.isArray(s.tempPins) ? s.tempPins.filter((p) => p && p.expiresAt > Date.now()) : [];
269
+ s.tempPins.push({ value, kind, expiresAt, label: String(label).slice(0, 32) });
270
+ writeSettings(s);
271
+ return { value, kind, expiresAt, label: String(label).slice(0, 32) };
272
+ }
273
+
274
+ /** 撤销一个临时 PIN(按 value 匹配,kind 校验防止误删)。不存在也返回 false。 */
275
+ export function revokeTempPin(value, kind) {
276
+ const v = String(value ?? '');
277
+ if (!TEMP_PIN_RE.test(v)) return false;
278
+ const s = readSettings();
279
+ if (!Array.isArray(s.tempPins)) return false;
280
+ const before = s.tempPins.length;
281
+ s.tempPins = s.tempPins.filter((p) => !(p?.value === v && (kind === undefined || p.kind === kind)));
282
+ if (s.tempPins.length === before) return false;
283
+ writeSettings(s);
284
+ return true;
285
+ }
286
+
287
+ /** 给定 kind 返回所有未过期的临时 PIN 集合(value 数组),用于 proxy 校验。 */
288
+ export function tempPinValuesFor(kind) {
289
+ return tempPins().filter((p) => p.kind === kind).map((p) => p.value);
290
+ }
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, resetPocket = 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, listTempPins = null, createTempPin = null, revokeTempPin = 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 () => {};
@@ -161,6 +161,25 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
161
161
  const dshPort = service.dshPort ?? 3080;
162
162
  return ok({ ...result, hint: `重启后进程在后台运行;如需停止:${killHint(dshPort)}` });
163
163
  }
164
+ // 临时 PIN(issue #69):生成 / 列出 / 撤销。临时 PIN 是带过期的主 PIN 同位替代品,
165
+ // 用于把访问权限临时分给别人(朋友/同事/临时设备),过期自动作废。
166
+ if (endpoint === POCKET_ENDPOINTS.tempPinCreate) {
167
+ if (!createTempPin) return fail('bad-request', '临时 PIN 生成不可用 | temp pin create unavailable');
168
+ try {
169
+ const pin = createTempPin({ kind: payload?.kind, expiresInSec: payload?.expiresInSec, label: payload?.label });
170
+ return ok(pin);
171
+ } catch (err) {
172
+ return fail('bad-request', err?.message ?? String(err));
173
+ }
174
+ }
175
+ if (endpoint === POCKET_ENDPOINTS.tempPinList) {
176
+ return ok({ tempPins: listTempPins?.() ?? [] });
177
+ }
178
+ if (endpoint === POCKET_ENDPOINTS.tempPinRevoke) {
179
+ if (!revokeTempPin) return fail('bad-request', '临时 PIN 撤销不可用 | temp pin revoke unavailable');
180
+ const ok = revokeTempPin(payload?.value, payload?.kind);
181
+ return ok({ revoked: ok });
182
+ }
164
183
  return fail('bad-request', `Unknown endpoint: ${endpoint}`);
165
184
  } catch (err) {
166
185
  log.error?.('dsh-pocket: rpc %s failed | RPC 失败: %s', endpoint, err?.message ?? err);
package/package.json CHANGED
@@ -80,5 +80,5 @@
80
80
  "access": "public",
81
81
  "registry": "https://registry.npmjs.org/"
82
82
  },
83
- "version": "2.2.0"
83
+ "version": "2.4.0"
84
84
  }