dsh-pocket 1.0.24 → 1.0.26

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
@@ -129,7 +129,7 @@ Such tools take over all traffic and often cut cloudflared's tunnel-edge connect
129
129
  | `lib/service.mjs` | Service: proxy lifecycle, public tunnel, status snapshot (with QR data URLs) |
130
130
  | `lib/proxy.mjs` | Header-rewriting reverse proxy: Host/Origin → loopback, HTTP + WebSocket passthrough + polyfill injection |
131
131
  | `lib/tunnel.mjs` | cloudflared quick tunnel: download/extract/start/parse public URL (HTTP/2) |
132
- | `lib/web-rpc.js` | Loopback RPC: `pocket.status` / `tunnel.start` / `tunnel.stop` |
132
+ | `lib/web-rpc.js` | Loopback RPC: `status` / `tunnel.start` / `tunnel.stop` / `version` / `update` / `restart` |
133
133
  | `client/` | "Phone access" settings tab + mobile adaptation (dsh-web-mobile port) |
134
134
  | `bin/dsh-pocket.mjs` | CLI: LAN/public modes, prints URL + QR |
135
135
 
package/README.md CHANGED
@@ -130,7 +130,7 @@ npx @deepseek-ai/dsh web
130
130
  | `lib/service.mjs` | 服务:代理生命周期、公网隧道、状态快照(含二维码 data URL) |
131
131
  | `lib/proxy.mjs` | 改头反向代理:Host/Origin → loopback,HTTP + WebSocket 全透传 + polyfill 注入 |
132
132
  | `lib/tunnel.mjs` | cloudflared 快速隧道:下载/解压/启动/解析公网 URL(HTTP/2) |
133
- | `lib/web-rpc.js` | loopback RPC:`pocket.status` / `tunnel.start` / `tunnel.stop` |
133
+ | `lib/web-rpc.js` | loopback RPC:`status` / `tunnel.start` / `tunnel.stop` / `version` / `update` / `restart` |
134
134
  | `client/` | 设置页「手机访问」+ 移动端适配(dsh-web-mobile 移植) |
135
135
  | `bin/dsh-pocket.mjs` | CLI:局域网/公网模式,打印 URL + 二维码 |
136
136
 
package/client/api.js CHANGED
@@ -19,13 +19,29 @@ export function compareVersions(a, b) {
19
19
  const y = parseInt(pb[i], 10) || 0;
20
20
  if (x !== y) return x - y;
21
21
  }
22
- // 数字段相等:无预发布后缀的更新;都有后缀时按后缀字典序(alpha < beta < rc…)
22
+ // 数字段相等:无预发布后缀的更新;都有后缀时按段比较(alpha < beta < rc…,
23
+ // 数字段按数值:rc.9 < rc.10)
23
24
  const aPre = String(a).replace(/^[vV]/, '').match(/-.*$/)?.[0] ?? '';
24
25
  const bPre = String(b).replace(/^[vV]/, '').match(/-.*$/)?.[0] ?? '';
25
26
  if (!aPre && !bPre) return 0;
26
27
  if (!aPre) return 1;
27
28
  if (!bPre) return -1;
28
- return aPre < bPre ? -1 : aPre > bPre ? 1 : 0;
29
+ // 逐段比较:数字段按数值、文本段按字典序
30
+ const aParts = aPre.slice(1).split('.');
31
+ const bParts = bPre.slice(1).split('.');
32
+ const len = Math.max(aParts.length, bParts.length);
33
+ for (let i = 0; i < len; i++) {
34
+ const ax = aParts[i] ?? '';
35
+ const bx = bParts[i] ?? '';
36
+ if (ax === bx) continue;
37
+ const aNum = /^\d+$/.test(ax);
38
+ const bNum = /^\d+$/.test(bx);
39
+ if (aNum && bNum) return Number(ax) - Number(bx); // 数值比较
40
+ if (aNum) return 1; // 数字段 > 文本段
41
+ if (bNum) return -1;
42
+ return ax < bx ? -1 : 1; // 字典序
43
+ }
44
+ return 0;
29
45
  }
30
46
 
31
47
  /** 浏览器可见的状态字段(无敏感信息;含二维码 data URL)。 */
package/client/build.mjs CHANGED
@@ -29,6 +29,13 @@ const wrapped = `window.__ModuleLoader__.load({
29
29
  factory: (require) => {
30
30
  var module = { exports: {} };
31
31
  var exports = module.exports;
32
+ // The DSH client module system provides react as a module, never as a
33
+ // global. esbuild keeps react external (see the build config above) and
34
+ // its classic JSX transform emits bare React.createElement calls for the
35
+ // mobile components (which import only named hooks, not React itself), so
36
+ // the bundle must bind React itself - otherwise every mobile component
37
+ // crashes at render time with "ReferenceError: React is not defined".
38
+ var React = require("react");
32
39
  ${bundled}
33
40
  return module.exports;
34
41
  }
package/client/client.js CHANGED
@@ -3,6 +3,13 @@ window.__ModuleLoader__.load({
3
3
  factory: (require) => {
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
6
+ // The DSH client module system provides react as a module, never as a
7
+ // global. esbuild keeps react external (see the build config above) and
8
+ // its classic JSX transform emits bare React.createElement calls for the
9
+ // mobile components (which import only named hooks, not React itself), so
10
+ // the bundle must bind React itself - otherwise every mobile component
11
+ // crashes at render time with "ReferenceError: React is not defined".
12
+ var React = require("react");
6
13
  var __defProp = Object.defineProperty;
7
14
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
8
15
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -55,7 +62,21 @@ function compareVersions(a, b) {
55
62
  if (!aPre && !bPre) return 0;
56
63
  if (!aPre) return 1;
57
64
  if (!bPre) return -1;
58
- return aPre < bPre ? -1 : aPre > bPre ? 1 : 0;
65
+ const aParts = aPre.slice(1).split(".");
66
+ const bParts = bPre.slice(1).split(".");
67
+ const len = Math.max(aParts.length, bParts.length);
68
+ for (let i = 0; i < len; i++) {
69
+ const ax = aParts[i] ?? "";
70
+ const bx = bParts[i] ?? "";
71
+ if (ax === bx) continue;
72
+ const aNum = /^\d+$/.test(ax);
73
+ const bNum = /^\d+$/.test(bx);
74
+ if (aNum && bNum) return Number(ax) - Number(bx);
75
+ if (aNum) return 1;
76
+ if (bNum) return -1;
77
+ return ax < bx ? -1 : 1;
78
+ }
79
+ return 0;
59
80
  }
60
81
  function redactStatus(s) {
61
82
  return {
@@ -1287,7 +1308,10 @@ function PocketSettingsTab({ rpcCall }) {
1287
1308
  const s = await call(POCKET_ENDPOINTS.status, {});
1288
1309
  setStatus(s);
1289
1310
  setTunnelState(s.tunnelState ?? null);
1290
- if (s.restartNotice) setRestartNotice(true);
1311
+ if (s.restartNotice) {
1312
+ setRestartNotice(true);
1313
+ setUpdateInfo(null);
1314
+ }
1291
1315
  } catch {
1292
1316
  }
1293
1317
  };
@@ -1319,10 +1343,14 @@ function PocketSettingsTab({ rpcCall }) {
1319
1343
  const restartPocket = async () => {
1320
1344
  setUpdateInfo((u) => ({ ...u, restarting: true }));
1321
1345
  try {
1322
- await call(POCKET_ENDPOINTS.restart, {});
1346
+ await Promise.race([
1347
+ call(POCKET_ENDPOINTS.restart, {}),
1348
+ new Promise((_, rej) => setTimeout(() => rej(new Error("restart requested (no reply within 3s)")), 3e3))
1349
+ ]);
1350
+ setUpdateInfo((u) => ({ ...u, restarting: true, result: "ok" }));
1323
1351
  } catch (err) {
1324
1352
  const msg = String(err?.message ?? "");
1325
- if (/connection|socket|fetch|network|abort|cancelled|ECONN|disconnect|closed/i.test(msg)) {
1353
+ if (/connection|socket|fetch|network|abort|cancelled|ECONN|disconnect|closed|timeout/i.test(msg)) {
1326
1354
  setUpdateInfo((u) => ({ ...u, restarting: true, result: "ok" }));
1327
1355
  return;
1328
1356
  }
@@ -1333,7 +1361,13 @@ function PocketSettingsTab({ rpcCall }) {
1333
1361
  setUpdateInfo((u) => ({ ...u, updating: true, result: null }));
1334
1362
  try {
1335
1363
  const r = await call(POCKET_ENDPOINTS.update, {});
1336
- setUpdateInfo((u) => ({ ...u, updating: false, result: r.ok ? "ok" : "fail", output: r.output ?? r.error }));
1364
+ setUpdateInfo((u) => ({
1365
+ ...u,
1366
+ updating: false,
1367
+ result: r.ok ? "ok" : "fail",
1368
+ autoRestart: r.autoRestart === true,
1369
+ output: r.output ?? r.error
1370
+ }));
1337
1371
  } catch (err) {
1338
1372
  setUpdateInfo((u) => ({ ...u, updating: false, result: "fail", output: err.message }));
1339
1373
  }
@@ -1390,9 +1424,9 @@ function PocketSettingsTab({ rpcCall }) {
1390
1424
  (0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 13 } }, "\u{1F504} \u5DF2\u91CD\u542F | Restarted"),
1391
1425
  (0, import_react2.createElement)("button", { style: styles.btn, onClick: () => setRestartNotice(false) }, "\u77E5\u9053\u4E86 | OK")
1392
1426
  ),
1393
- (0, import_react2.createElement)("div", { style: styles.muted, marginTop: 4, wordBreak: "break-all" }, "\u8FDB\u7A0B\u5728\u540E\u53F0\u8FD0\u884C\uFF08\u4E0D\u6302\u7EC8\u7AEF\uFF09\u3002\u5982\u9700\u505C\u6B62\uFF1Alsof -ti :3080 | xargs kill -9")
1427
+ (0, import_react2.createElement)("div", { style: styles.muted, marginTop: 4, wordBreak: "break-all" }, `\u8FDB\u7A0B\u5728\u540E\u53F0\u8FD0\u884C\uFF08\u4E0D\u6302\u7EC8\u7AEF\uFF09\u3002\u5982\u9700\u505C\u6B62\uFF1Alsof -ti :${status?.dshPort ?? 3080} | xargs kill -9`)
1394
1428
  ) : null,
1395
- // 更新提示——左侧黄色色条(提示有新版本)
1429
+ // 更新提示——左侧黄色色条(提示有新版本);单状态:有更新/更新中/已更新自动重启,不并存
1396
1430
  updateInfo ? (0, import_react2.createElement)(
1397
1431
  "div",
1398
1432
  { style: { ...styles.block, borderLeft: "4px solid var(--dsw-alias-state-warn-primary,#b45309)", borderRadius: 8, background: "var(--dsw-alias-bg-layer-2,#f3f4f6)", padding: "10px 12px" } },
@@ -1402,14 +1436,14 @@ function PocketSettingsTab({ rpcCall }) {
1402
1436
  (0, import_react2.createElement)(
1403
1437
  "div",
1404
1438
  { style: { fontWeight: 600, fontSize: 13 } },
1405
- updateInfo.updated ? `\u2705 \u5DF2\u66F4\u65B0 v${updateInfo.current}\uFF0C\u91CD\u542F\u751F\u6548 | Updated \u2014 restart to apply` : `\u{1F4E6} \u65B0\u7248\u672C v${updateInfo.latest} | Update available`
1439
+ updateInfo.updated ? `\u2705 \u5DF2\u66F4\u65B0 v${updateInfo.current}\uFF0C\u91CD\u542F\u751F\u6548 | Updated \u2014 restart to apply` : updateInfo.result === "ok" ? updateInfo.autoRestart ? `\u2705 \u5DF2\u66F4\u65B0 v${updateInfo.latest}\uFF0C\u6B63\u5728\u81EA\u52A8\u91CD\u542F\u2026 | updated \u2014 restarting\u2026` : `\u2705 \u5DF2\u66F4\u65B0 v${updateInfo.latest} | Updated` : `\u{1F4E6} \u65B0\u7248\u672C v${updateInfo.latest} | Update available`
1406
1440
  ),
1407
- updateInfo.result !== "ok" ? (0, import_react2.createElement)("button", { style: styles.primary, onClick: runUpdate, disabled: updateInfo.updating }, updateInfo.updating ? "\u66F4\u65B0\u4E2D\u2026" : `\u66F4\u65B0\u5230 v${updateInfo.latest} | Update`) : (0, import_react2.createElement)("button", { style: styles.primary, onClick: restartPocket, disabled: updateInfo.restarting }, updateInfo.restarting ? "\u91CD\u542F\u4E2D\u2026" : "\u{1F504} \u91CD\u542F dsh web \u751F\u6548 | Restart now")
1441
+ updateInfo.result !== "ok" ? (0, import_react2.createElement)("button", { style: styles.primary, onClick: runUpdate, disabled: updateInfo.updating }, updateInfo.updating ? "\u66F4\u65B0\u4E2D\u2026" : `\u66F4\u65B0\u5230 v${updateInfo.latest} | Update`) : updateInfo.autoRestart ? (0, import_react2.createElement)("button", { style: styles.btn, disabled: true }, "\u6B63\u5728\u91CD\u542F\u751F\u6548\u2026 | restarting\u2026") : (0, import_react2.createElement)("button", { style: styles.primary, onClick: restartPocket, disabled: updateInfo.restarting }, updateInfo.restarting ? "\u91CD\u542F\u4E2D\u2026" : "\u{1F504} \u91CD\u542F dsh web \u751F\u6548 | Restart now")
1408
1442
  ),
1409
1443
  (0, import_react2.createElement)(
1410
1444
  "div",
1411
1445
  { style: styles.muted, marginTop: 4 },
1412
- updateInfo.result === "ok" ? "\u2705 \u5DF2\u66F4\u65B0\uFF0C\u91CD\u542F dsh web \u751F\u6548 | updated \u2014 restart dsh web" : updateInfo.result === "fail" ? `\u274C \u5931\u8D25\uFF1A${updateInfo.output || "\u672A\u77E5"}\uFF08\u624B\u52A8\u66F4\u65B0\uFF1Adsh plugin --profile web update dsh-pocket --latest -w\uFF09` : `\u5F53\u524D v${updateInfo.current} \u2192 \u6700\u65B0 v${updateInfo.latest}`
1446
+ updateInfo.result === "ok" ? updateInfo.autoRestart ? "\u2705 \u5DF2\u66F4\u65B0\uFF0C\u6B63\u5728\u81EA\u52A8\u91CD\u542F\u751F\u6548\uFF0C\u8BF7\u7A0D\u5019\u5237\u65B0 | updated \u2014 restarting automatically, refresh shortly" : "\u2705 \u5DF2\u66F4\u65B0\uFF0C\u91CD\u542F dsh web \u751F\u6548 | updated \u2014 restart dsh web" : updateInfo.result === "fail" ? `\u274C \u5931\u8D25\uFF1A${updateInfo.output || "\u672A\u77E5"}\uFF08\u624B\u52A8\u66F4\u65B0\uFF1Adsh plugin --profile web update dsh-pocket --latest -w\uFF09` : `\u5F53\u524D v${updateInfo.current} \u2192 \u6700\u65B0 v${updateInfo.latest}`
1413
1447
  )
1414
1448
  ) : null,
1415
1449
  // 局域网
@@ -1435,7 +1469,8 @@ function PocketSettingsTab({ rpcCall }) {
1435
1469
  null,
1436
1470
  (0, import_react2.createElement)("img", { src: status.tunnelQr, alt: "Tunnel QR", style: styles.qr }),
1437
1471
  (0, import_react2.createElement)("div", { style: styles.code }, tunnelUrl),
1438
- (0, import_react2.createElement)("div", { style: styles.muted }, "\u4EFB\u4F55\u7F51\u7EDC\u626B\u7801\u5373\u7528\uFF08URL \u6BCF\u6B21\u91CD\u542F\u4F1A\u53D8\uFF09"),
1472
+ (0, import_react2.createElement)("div", { style: styles.muted }, "\u4EFB\u4F55\u7F51\u7EDC\u626B\u7801\u5373\u7528\uFF08URL \u6BCF\u6B21\u91CD\u542F\u81EA\u52A8\u6362\u65B0\uFF09"),
1473
+ (0, import_react2.createElement)("div", { style: styles.warn, marginTop: 4 }, "\u{1F511} \u94FE\u63A5\u5DF2\u6CC4\u9732\uFF1F\u91CD\u542F dsh web\u2014\u2014URL \u7ACB\u5373\u6362\u65B0\uFF0C\u65E7\u94FE\u63A5\u4F5C\u5E9F\uFF0C\u65E0\u5B89\u5168\u98CE\u9669 | URL leaked? Restart dsh web \u2014 the URL rotates and the old one dies"),
1439
1474
  (0, import_react2.createElement)("button", { style: styles.btn, onClick: stopTunnel }, "\u5173\u95ED\u516C\u7F51 | Stop")
1440
1475
  ) : (0, import_react2.createElement)(
1441
1476
  "div",
@@ -1445,7 +1480,16 @@ function PocketSettingsTab({ rpcCall }) {
1445
1480
  "div",
1446
1481
  { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } },
1447
1482
  `\u23F3 ${tunnelStateDetail}\uFF08\u5DF2\u7B49\u5F85 ${Math.floor((Date.now() - (tunnelStateStarted || Date.now())) / 1e3)} \u79D2\uFF09\u2026`
1448
- ) : (0, import_react2.createElement)("div", { style: styles.warn, marginTop: 8 }, "\u26A0\uFE0F DSH \u80FD\u6267\u884C\u7535\u8111\u4EE3\u7801\uFF1A\u4E8C\u7EF4\u7801/URL \u5C31\u662F\u94A5\u5319\uFF0C\u8BF7\u52FF\u53D1\u7ED9\u522B\u4EBA")
1483
+ ) : tunnelPhase === "error" ? (0, import_react2.createElement)(
1484
+ "div",
1485
+ { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" } },
1486
+ `\u274C \u5F00\u542F\u5931\u8D25\uFF1A${tunnelStateDetail || "\u672A\u77E5\u9519\u8BEF | failed"}\uFF08\u53EF\u91CD\u8BD5\uFF1B\u82E5\u662F\u4EE3\u7406/VPN \u95EE\u9898\u89C1 README \u6392\u969C\uFF09`
1487
+ ) : (0, import_react2.createElement)(
1488
+ "div",
1489
+ null,
1490
+ (0, import_react2.createElement)("div", { style: styles.warn, marginTop: 8 }, "\u26A0\uFE0F DSH \u80FD\u6267\u884C\u7535\u8111\u4EE3\u7801\uFF1A\u4E8C\u7EF4\u7801/URL \u5C31\u662F\u94A5\u5319\uFF0C\u8BF7\u52FF\u53D1\u7ED9\u522B\u4EBA | the QR/URL is the key \u2014 never share it"),
1491
+ (0, import_react2.createElement)("div", { style: styles.muted, marginTop: 4 }, "\u4E0D\u614E\u6CC4\u9732\u4E86\uFF1F\u91CD\u542F dsh web\uFF0CURL \u81EA\u52A8\u6362\u65B0\u3001\u65E7\u94FE\u63A5\u7ACB\u5373\u5931\u6548 | Leaked it? Restart dsh web \u2014 the URL rotates and the old one dies instantly")
1492
+ )
1449
1493
  )
1450
1494
  ),
1451
1495
  error ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", fontSize: 12, marginTop: 8 } }, `\u274C ${error}`) : null,
package/client/index.jsx CHANGED
@@ -45,7 +45,11 @@ function PocketSettingsTab({ rpcCall }) {
45
45
  const s = await call(POCKET_ENDPOINTS.status, {});
46
46
  setStatus(s);
47
47
  setTunnelState(s.tunnelState ?? null);
48
- if (s.restartNotice) setRestartNotice(true);
48
+ if (s.restartNotice) {
49
+ // 新进程确认起来了:显示一次「已重启」,并清掉旧的更新横幅(单状态,不并存)
50
+ setRestartNotice(true);
51
+ setUpdateInfo(null);
52
+ }
49
53
  } catch { /* 忽略瞬时失败 */ }
50
54
  };
51
55
 
@@ -80,11 +84,16 @@ function PocketSettingsTab({ rpcCall }) {
80
84
  const restartPocket = async () => {
81
85
  setUpdateInfo((u) => ({ ...u, restarting: true }));
82
86
  try {
83
- await call(POCKET_ENDPOINTS.restart, {});
87
+ // 宿主 500ms 后自杀,RPC 响应可能来不及送达 → 3 秒超时兜底,别让按钮永远卡「重启中…」
88
+ await Promise.race([
89
+ call(POCKET_ENDPOINTS.restart, {}),
90
+ new Promise((_, rej) => setTimeout(() => rej(new Error('restart requested (no reply within 3s)')), 3000)),
91
+ ]);
92
+ setUpdateInfo((u) => ({ ...u, restarting: true, result: 'ok' }));
84
93
  } catch (err) {
85
- // 宿主 500ms 后自杀,RPC 响应可能来不及送达——网络断连视为「已请求重启」
94
+ // 网络断连/超时同样视为「已请求重启」——旧进程即将退出,等新进程起来后刷新即可
86
95
  const msg = String(err?.message ?? '');
87
- if (/connection|socket|fetch|network|abort|cancelled|ECONN|disconnect|closed/i.test(msg)) {
96
+ if (/connection|socket|fetch|network|abort|cancelled|ECONN|disconnect|closed|timeout/i.test(msg)) {
88
97
  setUpdateInfo((u) => ({ ...u, restarting: true, result: 'ok' }));
89
98
  return;
90
99
  }
@@ -92,12 +101,18 @@ function PocketSettingsTab({ rpcCall }) {
92
101
  }
93
102
  };
94
103
 
95
- // 一键更新:调宿主 dsh plugin update
104
+ // 一键更新:调宿主 dsh plugin update(成功后宿主自动重启生效,用户只点一次)
96
105
  const runUpdate = async () => {
97
106
  setUpdateInfo((u) => ({ ...u, updating: true, result: null }));
98
107
  try {
99
108
  const r = await call(POCKET_ENDPOINTS.update, {});
100
- setUpdateInfo((u) => ({ ...u, updating: false, result: r.ok ? 'ok' : 'fail', output: r.output ?? r.error }));
109
+ setUpdateInfo((u) => ({
110
+ ...u,
111
+ updating: false,
112
+ result: r.ok ? 'ok' : 'fail',
113
+ autoRestart: r.autoRestart === true,
114
+ output: r.output ?? r.error,
115
+ }));
101
116
  } catch (err) {
102
117
  setUpdateInfo((u) => ({ ...u, updating: false, result: 'fail', output: err.message }));
103
118
  }
@@ -143,22 +158,28 @@ function PocketSettingsTab({ rpcCall }) {
143
158
  h('div', { style: { fontWeight: 600, fontSize: 13 } }, '🔄 已重启 | Restarted'),
144
159
  h('button', { style: styles.btn, onClick: () => setRestartNotice(false) }, '知道了 | OK'),
145
160
  ),
146
- h('div', { style: styles.muted, marginTop: 4, wordBreak: 'break-all' }, '进程在后台运行(不挂终端)。如需停止:lsof -ti :3080 | xargs kill -9'),
161
+ h('div', { style: styles.muted, marginTop: 4, wordBreak: 'break-all' }, `进程在后台运行(不挂终端)。如需停止:lsof -ti :${status?.dshPort ?? 3080} | xargs kill -9`),
147
162
  ) : null,
148
163
 
149
- // 更新提示——左侧黄色色条(提示有新版本)
164
+ // 更新提示——左侧黄色色条(提示有新版本);单状态:有更新/更新中/已更新自动重启,不并存
150
165
  updateInfo ? h('div', { style: { ...styles.block, borderLeft: '4px solid var(--dsw-alias-state-warn-primary,#b45309)', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', padding: '10px 12px' } },
151
166
  h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
152
167
  h('div', { style: { fontWeight: 600, fontSize: 13 } },
153
168
  updateInfo.updated
154
169
  ? `✅ 已更新 v${updateInfo.current},重启生效 | Updated — restart to apply`
155
- : `📦 新版本 v${updateInfo.latest} | Update available`),
170
+ : updateInfo.result === 'ok'
171
+ ? (updateInfo.autoRestart ? `✅ 已更新 v${updateInfo.latest},正在自动重启… | updated — restarting…` : `✅ 已更新 v${updateInfo.latest} | Updated`)
172
+ : `📦 新版本 v${updateInfo.latest} | Update available`),
156
173
  updateInfo.result !== 'ok'
157
174
  ? h('button', { style: styles.primary, onClick: runUpdate, disabled: updateInfo.updating }, updateInfo.updating ? '更新中…' : `更新到 v${updateInfo.latest} | Update`)
158
- : h('button', { style: styles.primary, onClick: restartPocket, disabled: updateInfo.restarting }, updateInfo.restarting ? '重启中…' : '🔄 重启 dsh web 生效 | Restart now'),
175
+ : updateInfo.autoRestart
176
+ ? h('button', { style: styles.btn, disabled: true }, '正在重启生效… | restarting…')
177
+ : h('button', { style: styles.primary, onClick: restartPocket, disabled: updateInfo.restarting }, updateInfo.restarting ? '重启中…' : '🔄 重启 dsh web 生效 | Restart now'),
159
178
  ),
160
179
  h('div', { style: styles.muted, marginTop: 4 },
161
- updateInfo.result === 'ok' ? '✅ 已更新,重启 dsh web 生效 | updated — restart dsh web'
180
+ updateInfo.result === 'ok'
181
+ ? (updateInfo.autoRestart ? '✅ 已更新,正在自动重启生效,请稍候刷新 | updated — restarting automatically, refresh shortly'
182
+ : '✅ 已更新,重启 dsh web 生效 | updated — restart dsh web')
162
183
  : updateInfo.result === 'fail' ? `❌ 失败:${updateInfo.output || '未知'}(手动更新:dsh plugin --profile web update dsh-pocket --latest -w)`
163
184
  : `当前 v${updateInfo.current} → 最新 v${updateInfo.latest}`),
164
185
  ) : null,
@@ -182,7 +203,8 @@ function PocketSettingsTab({ rpcCall }) {
182
203
  ? h('div', null,
183
204
  h('img', { src: status.tunnelQr, alt: 'Tunnel QR', style: styles.qr }),
184
205
  h('div', { style: styles.code }, tunnelUrl),
185
- h('div', { style: styles.muted }, '任何网络扫码即用(URL 每次重启会变)'),
206
+ h('div', { style: styles.muted }, '任何网络扫码即用(URL 每次重启自动换新)'),
207
+ h('div', { style: styles.warn, marginTop: 4 }, '🔑 链接已泄露?重启 dsh web——URL 立即换新,旧链接作废,无安全风险 | URL leaked? Restart dsh web — the URL rotates and the old one dies'),
186
208
  h('button', { style: styles.btn, onClick: stopTunnel }, '关闭公网 | Stop'),
187
209
  )
188
210
  : h('div', null,
@@ -190,7 +212,13 @@ function PocketSettingsTab({ rpcCall }) {
190
212
  tunnelStarting
191
213
  ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } },
192
214
  `⏳ ${tunnelStateDetail}(已等待 ${Math.floor((Date.now() - (tunnelStateStarted || Date.now())) / 1000)} 秒)…`)
193
- : h('div', { style: styles.warn, marginTop: 8 }, '⚠️ DSH 能执行电脑代码:二维码/URL 就是钥匙,请勿发给别人'),
215
+ : tunnelPhase === 'error'
216
+ ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } },
217
+ `❌ 开启失败:${tunnelStateDetail || '未知错误 | failed'}(可重试;若是代理/VPN 问题见 README 排障)`)
218
+ : h('div', null,
219
+ h('div', { style: styles.warn, marginTop: 8 }, '⚠️ DSH 能执行电脑代码:二维码/URL 就是钥匙,请勿发给别人 | the QR/URL is the key — never share it'),
220
+ h('div', { style: styles.muted, marginTop: 4 }, '不慎泄露了?重启 dsh web,URL 自动换新、旧链接立即失效 | Leaked it? Restart dsh web — the URL rotates and the old one dies instantly'),
221
+ ),
194
222
  ),
195
223
  ),
196
224
 
package/lib/index.js CHANGED
@@ -11,7 +11,6 @@
11
11
 
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { spawn } from 'node:child_process';
14
- import { createRequire } from 'node:module';
15
14
  import { readFileSync } from 'node:fs';
16
15
  import { writeFile, readFile, mkdir, rm } from 'node:fs/promises';
17
16
  import { join, dirname } from 'node:path';
@@ -23,7 +22,6 @@ import { restartHost } from './restart.js';
23
22
 
24
23
  const name = 'dsh-pocket';
25
24
  const inject = ['connection', 'webServer'];
26
- const require = createRequire(import.meta.url);
27
25
 
28
26
  const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url));
29
27
 
@@ -69,10 +67,18 @@ async function consumeRestartNotice() {
69
67
  }
70
68
  return notice;
71
69
  }
72
- /** 自重启(先落 notice,让重启后的页面提示停止方法)。 */
73
- function pocketRestart() {
70
+ /**
71
+ * 自重启。
72
+ * 顺序很重要:先拉起 helper(失败就如实返回,不写标记、不停隧道)→ 停公网隧道
73
+ * (否则孤儿 cloudflared 让旧公网 URL 永活,与「重启即换 URL 作废」的宣传矛盾)→
74
+ * 写重启标记(新进程据此显示一次「已重启」横幅)。
75
+ */
76
+ function pocketRestart(service) {
77
+ const result = restartHost();
78
+ if (!result || result.helperPid == null) return result; // helper 都没 spawn 出来 → 失败
79
+ try { service?.stopTunnel(); } catch { /* 忽略 */ }
74
80
  writeRestartNotice().catch(() => {});
75
- return restartHost(); // 不传 internals:模块级函数里没有该变量(曾导致 ReferenceError)
81
+ return result;
76
82
  }
77
83
 
78
84
  /** 执行更新:dsh plugin --profile <p> update dsh-pocket --latest -w(超时保护)。 */
@@ -116,7 +122,7 @@ export function apply(ctx, config = {}, internals = {}) {
116
122
  const disposeRpc = installPocketRpc(ctx, {
117
123
  service,
118
124
  runUpdate: internals.runUpdate ?? { currentVersion, perform: performUpdate, loadedVersion: () => loadedVersion },
119
- restart: internals.restart ?? pocketRestart,
125
+ restart: internals.restart ?? (() => pocketRestart(service)),
120
126
  restartNotice: internals.restartNotice ?? consumeRestartNotice,
121
127
  log: logger,
122
128
  });
package/lib/proxy.mjs CHANGED
@@ -84,8 +84,11 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
84
84
  }
85
85
  res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
86
86
  proxyRes.pipe(res);
87
- // 客户端断开时销毁上游响应(SSE 流手机断连后不留僵尸连接)
87
+ // 任一端断开都要清理另一端:客户端断连销毁上游流(不留僵尸),
88
+ // 上游流中途断开也要掐断客户端(否则响应头已发、体没发完 → 悬挂)
88
89
  res.on('close', () => proxyRes.destroy());
90
+ proxyRes.on('error', () => res.destroy());
91
+ proxyRes.on('close', () => { if (!res.writableEnded) res.destroy(); });
89
92
  },
90
93
  );
91
94
  proxyReq.on('error', (err) => {
@@ -125,10 +128,10 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
125
128
  for (const [k, v] of Object.entries(proxyRes.headers)) {
126
129
  raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
127
130
  }
131
+ // end 会 flush 响应头再 FIN——不要紧跟 destroy(),否则排队的头会被丢弃
128
132
  socket.end(raw.join('\r\n') + '\r\n\r\n');
129
133
  proxyRes.resume(); // 消费掉上游响应体,释放连接
130
- } catch { /* socket 已关 */ }
131
- socket.destroy();
134
+ } catch { socket.destroy(); }
132
135
  });
133
136
  proxyReq.on('error', () => socket.destroy());
134
137
  // 关键:浏览器在握手请求后可能立即发出首帧(如 mux 流的初始 RPC),
@@ -140,6 +143,15 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
140
143
  socket.on('error', () => socket.destroy());
141
144
  });
142
145
 
146
+ // 跟踪所有 TCP 连接(含 WebSocket upgrade 后的 socket——Node 的
147
+ // closeAllConnections 不包含它们,不手动销毁 close() 会永远等)
148
+ const clientSockets = new Set();
149
+ server.on('connection', (sock) => {
150
+ clientSockets.add(sock);
151
+ sock.on('close', () => clientSockets.delete(sock));
152
+ sock.on('error', () => {}); // 防未处理 error 崩进程
153
+ });
154
+
143
155
  return new Promise((resolve, reject) => {
144
156
  server.once('error', reject);
145
157
  server.listen(port, host, () => {
@@ -147,7 +159,10 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
147
159
  resolve({
148
160
  server,
149
161
  port: actualPort,
150
- close: () => new Promise((r) => server.close(() => r())),
162
+ close: () => new Promise((r) => {
163
+ for (const s of clientSockets) { try { s.destroy(); } catch { /* 忽略 */ } }
164
+ server.close(() => r());
165
+ }),
151
166
  });
152
167
  });
153
168
  });
package/lib/restart.js CHANGED
@@ -15,12 +15,16 @@ import { spawn } from 'node:child_process';
15
15
  import { tmpdir } from 'node:os';
16
16
  import { join } from 'node:path';
17
17
 
18
- /** 从启动参数里解析 dsh web 端口(--port/-p),默认 3080。 */
18
+ /** 从启动参数里解析 dsh web 端口(--port/-p,含 --port=3080 形式),默认 3080。 */
19
19
  export function dshPortFromArgs(args) {
20
20
  for (let i = 0; i < args.length; i++) {
21
- if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) {
21
+ const a = args[i];
22
+ if (a === '--port' || a === '-p') {
22
23
  const n = Number(args[i + 1]);
23
24
  if (Number.isInteger(n) && n > 0 && n < 65536) return n;
25
+ } else if (a.startsWith('--port=')) {
26
+ const n = Number(a.slice('--port='.length));
27
+ if (Number.isInteger(n) && n > 0 && n < 65536) return n;
24
28
  }
25
29
  }
26
30
  return 3080;
@@ -57,7 +61,7 @@ function helperCode(launch, logOut, logErr, port) {
57
61
  ' else setTimeout(() => waitPort(p, tries - 1, cb), 200)',
58
62
  ' })',
59
63
  '}',
60
- `waitPort(${port}, 100, (free) => {`, // 最多 100×200ms = 20s
64
+ `waitPort(${port}, 100, (free) => {`, // 最多 100×200ms = 20s;超时也照常拉起(宁可试拉、日志留痕,也不让 dsh 起不来)
61
65
  ' setTimeout(() => {',
62
66
  ' try {',
63
67
  ' const out = fs.openSync(logOut, "a")',
package/lib/service.mjs CHANGED
@@ -59,13 +59,18 @@ export function createPocketService({
59
59
  async function qrCached(text) {
60
60
  if (!text) return null;
61
61
  if (!qrCache.has(text)) {
62
- if (qrCache.size > 8) qrCache.clear(); // 隧道 URL 每次重启换新,防止无限增长
62
+ if (qrCache.size >= 8) {
63
+ // 只淘汰最旧一条(隧道 URL 每次重启换新),别殃及稳定的 LAN 二维码
64
+ const oldest = qrCache.keys().next().value;
65
+ qrCache.delete(oldest);
66
+ }
63
67
  qrCache.set(text, encodeQr(text).catch(() => null));
64
68
  }
65
69
  return qrCache.get(text);
66
70
  }
67
71
 
68
72
  return {
73
+ dshPort,
69
74
  /** 启动局域网代理(幂等)。 */
70
75
  async startProxy() {
71
76
  if (proxy) return proxy;
@@ -98,6 +103,12 @@ export function createPocketService({
98
103
  // 归一化:startTunnel 契约返回 {url, kill}(字符串也兼容)
99
104
  tunnel = typeof result === 'string' ? { url: result, kill: () => {} } : result;
100
105
  tunnelState.phase = 'ready';
106
+ // M1:隧道进程运行中死亡(崩溃/被杀)→ 状态打回,别让 UI 永远显示"可用"
107
+ tunnel.onExit?.((code) => {
108
+ if (controller.signal.aborted) return; // 主动停止(stopTunnel)不算故障
109
+ tunnelState.phase = 'error';
110
+ tunnelState.detail = `隧道进程退出(code=${code})| tunnel process exited`;
111
+ });
101
112
  return tunnel.url;
102
113
  } catch (err) {
103
114
  // stopTunnel 触发的 abort 不算错误:保持 idle,别把状态刷成 error
@@ -108,9 +119,12 @@ export function createPocketService({
108
119
  tunnelState.startedAt = null; // 失败后清掉计时,避免 UI 误显"启动中"
109
120
  throw err;
110
121
  } finally {
111
- tunnelPromise = null;
122
+ // 只清自己的引用:stopTunnel 后立即 startTunnel 可能已建了新的 in-flight
123
+ // (tunnelPromise=B),A 的 finally 不能把 B 清掉,否则第三次调用会并发 spawn
124
+ if (tunnelPromise === p) tunnelPromise = null;
112
125
  }
113
126
  })();
127
+ const p = tunnelPromise;
114
128
  return tunnelPromise;
115
129
  },
116
130
 
package/lib/tunnel.mjs CHANGED
@@ -20,12 +20,16 @@ function platformBinary() {
20
20
  return { os, a, ext: os === 'windows' ? '.exe' : '' };
21
21
  }
22
22
 
23
- async function downloadCloudflared(binPath) {
23
+ async function downloadCloudflared(binPath, signal) {
24
24
  const { os, a, ext } = platformBinary();
25
25
  // cloudflared 新版发布资产是 .tgz 压缩包(内含 cloudflared 二进制)
26
26
  const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-${os}-${a}.tgz`;
27
27
  console.log(`⬇️ 正在下载 cloudflared(首次运行,之后跳过)…`);
28
- const res = await fetch(url, { signal: AbortSignal.timeout(180_000) });
28
+ // 同时响应外部 abort(用户点停止)与 180s 兜底超时
29
+ const fetchSignal = signal
30
+ ? AbortSignal.any([signal, AbortSignal.timeout(180_000)])
31
+ : AbortSignal.timeout(180_000);
32
+ const res = await fetch(url, { signal: fetchSignal });
29
33
  if (!res.ok) throw new Error(`cloudflared 下载失败(HTTP ${res.status})`);
30
34
  const dir = dirname(binPath);
31
35
  const tgz = join(dir, `cloudflared.tgz`);
@@ -61,7 +65,7 @@ let downloading = null;
61
65
  * 优先:PATH 已有 → 直接用;否则用持久缓存($DSH_HOME/dsh-pocket/cloudflared),
62
66
  * 只有缓存缺失才下载——避免每次开启公网都重新下 20MB。
63
67
  */
64
- export async function resolveCloudflared({ home, onPhase = () => {} } = {}) {
68
+ export async function resolveCloudflared({ home, onPhase = () => {}, signal } = {}) {
65
69
  if (cloudflaredOnPath()) return 'cloudflared';
66
70
  const dshHome = home ?? process.env.DSH_HOME ?? join(homedir(), '.dsh');
67
71
  const cacheDir = join(dshHome, 'dsh-pocket', 'bin');
@@ -73,7 +77,7 @@ export async function resolveCloudflared({ home, onPhase = () => {} } = {}) {
73
77
  onPhase('downloading');
74
78
  await mkdir(cacheDir, { recursive: true });
75
79
  if (!downloading) {
76
- downloading = downloadCloudflared(bin).finally(() => { downloading = null; });
80
+ downloading = downloadCloudflared(bin, signal).finally(() => { downloading = null; });
77
81
  }
78
82
  return downloading;
79
83
  }
@@ -88,7 +92,7 @@ export async function resolveCloudflared({ home, onPhase = () => {} } = {}) {
88
92
  * @returns {Promise<{url:string, kill:()=>void}>}
89
93
  */
90
94
  export async function startQuickTunnel({ port, home, signal, onPhase = () => {} }) {
91
- const bin = await resolveCloudflared({ home, onPhase });
95
+ const bin = await resolveCloudflared({ home, onPhase, signal });
92
96
  onPhase('starting');
93
97
  // 强制 HTTP/2(TCP 443)而不是默认的 QUIC(UDP 7844):
94
98
  // 国内网络/部分企业网常屏蔽 UDP 7844,导致 tunnel 报 error 1033(Tunnel error);
@@ -96,8 +100,16 @@ export async function startQuickTunnel({ port, home, signal, onPhase = () => {}
96
100
  const child = spawn(bin, ['tunnel', '--url', `http://127.0.0.1:${port}`, '--protocol', 'http2', '--no-autoupdate'], {
97
101
  stdio: ['ignore', 'pipe', 'pipe'],
98
102
  });
103
+ // H1:spawn 失败(缓存二进制损坏等)必须接住,否则 uncaughtException 崩宿主
104
+ child.on('error', (err) => {
105
+ cleanup?.();
106
+ onPhase?.('error');
107
+ rejectErr?.(new Error(`cloudflared 启动失败:${err?.message ?? err}(可删除 $DSH_HOME/dsh-pocket/bin 缓存后重试)`));
108
+ });
99
109
  onPhase('registering');
100
110
 
111
+ let cleanup = null;
112
+ let rejectErr = null;
101
113
  const url = await new Promise((resolve, reject) => {
102
114
  let buf = '';
103
115
  const onData = (chunk) => {
@@ -113,12 +125,15 @@ export async function startQuickTunnel({ port, home, signal, onPhase = () => {}
113
125
  cleanup();
114
126
  reject(new Error(`cloudflared 退出(code=${code})`));
115
127
  };
116
- const cleanup = () => {
128
+ cleanup = () => {
117
129
  child.stdout.off('data', onData);
118
130
  child.stderr.off('data', onData);
119
131
  child.off('exit', onExit);
120
132
  clearTimeout(timer);
121
133
  signal?.removeEventListener('abort', onAbort);
134
+ // M4:摘掉监听后管道不再消费 → 64KB 缓冲填满会阻塞 cloudflared → 继续吞掉输出
135
+ child.stdout.resume();
136
+ child.stderr.resume();
122
137
  };
123
138
  const onAbort = () => {
124
139
  cleanup();
@@ -138,6 +153,13 @@ export async function startQuickTunnel({ port, home, signal, onPhase = () => {}
138
153
  child.stderr.on('data', onData);
139
154
  child.once('exit', onExit);
140
155
  signal?.addEventListener('abort', onAbort, { once: true });
156
+ rejectErr = reject;
157
+ });
158
+
159
+ // M1:隧道进程运行中死亡(崩溃/被杀)→ 通知监听方(service 据此把状态从 ready 打回)
160
+ const exitListeners = new Set();
161
+ child.on('exit', (code) => {
162
+ for (const cb of exitListeners) cb(code);
141
163
  });
142
164
 
143
165
  return {
@@ -145,5 +167,10 @@ export async function startQuickTunnel({ port, home, signal, onPhase = () => {}
145
167
  kill: () => {
146
168
  try { child.kill(); } catch { /* 忽略 */ }
147
169
  },
170
+ /** 注册「进程已退出」回调,返回取消函数。 */
171
+ onExit: (cb) => {
172
+ exitListeners.add(cb);
173
+ return () => exitListeners.delete(cb);
174
+ },
148
175
  };
149
176
  }
package/lib/web-rpc.js CHANGED
@@ -50,12 +50,22 @@ export function installPocketRpc(ctx, { service, log = console, runUpdate = null
50
50
  if (endpoint === POCKET_ENDPOINTS.update) {
51
51
  if (!runUpdate) return fail('bad-request', '更新不可用 | update unavailable');
52
52
  const result = await runUpdate.perform(payload?.profile ?? 'web');
53
+ // 更新成功 → 自动重启生效(用户只点一次;helper 拉起失败则保持现状,可手动重启)
54
+ if (result?.ok && restart) {
55
+ const rr = restart();
56
+ result.autoRestart = rr?.helperPid != null;
57
+ }
53
58
  return ok(result);
54
59
  }
55
60
  if (endpoint === POCKET_ENDPOINTS.restart) {
56
61
  if (!restart) return fail('bad-request', '重启不可用 | restart unavailable');
57
62
  const result = restart();
58
- return ok({ ...result, hint: '重启后进程在后台运行;如需停止:lsof -ti :3080 | xargs kill -9' });
63
+ // 重启拉起失败(helper 都没 spawn 出来)→ 如实报错,别让 UI 误报成功
64
+ if (!result || result.helperPid == null) {
65
+ return fail('bad-request', `重启失败:${result?.error ?? '未知'} | restart failed`);
66
+ }
67
+ const dshPort = service.dshPort ?? 3080;
68
+ return ok({ ...result, hint: `重启后进程在后台运行;如需停止:lsof -ti :${dshPort} | xargs kill -9` });
59
69
  }
60
70
  return fail('bad-request', `Unknown endpoint: ${endpoint}`);
61
71
  } catch (err) {
package/package.json CHANGED
@@ -76,5 +76,5 @@
76
76
  "access": "public",
77
77
  "registry": "https://registry.npmjs.org/"
78
78
  },
79
- "version": "1.0.24"
79
+ "version": "1.0.26"
80
80
  }