dsh-pocket 1.0.23 → 1.0.25

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/client.js CHANGED
@@ -55,7 +55,21 @@ function compareVersions(a, b) {
55
55
  if (!aPre && !bPre) return 0;
56
56
  if (!aPre) return 1;
57
57
  if (!bPre) return -1;
58
- return aPre < bPre ? -1 : aPre > bPre ? 1 : 0;
58
+ const aParts = aPre.slice(1).split(".");
59
+ const bParts = bPre.slice(1).split(".");
60
+ const len = Math.max(aParts.length, bParts.length);
61
+ for (let i = 0; i < len; i++) {
62
+ const ax = aParts[i] ?? "";
63
+ const bx = bParts[i] ?? "";
64
+ if (ax === bx) continue;
65
+ const aNum = /^\d+$/.test(ax);
66
+ const bNum = /^\d+$/.test(bx);
67
+ if (aNum && bNum) return Number(ax) - Number(bx);
68
+ if (aNum) return 1;
69
+ if (bNum) return -1;
70
+ return ax < bx ? -1 : 1;
71
+ }
72
+ return 0;
59
73
  }
60
74
  function redactStatus(s) {
61
75
  return {
@@ -1287,7 +1301,10 @@ function PocketSettingsTab({ rpcCall }) {
1287
1301
  const s = await call(POCKET_ENDPOINTS.status, {});
1288
1302
  setStatus(s);
1289
1303
  setTunnelState(s.tunnelState ?? null);
1290
- if (s.restartNotice) setRestartNotice(true);
1304
+ if (s.restartNotice) {
1305
+ setRestartNotice(true);
1306
+ setUpdateInfo(null);
1307
+ }
1291
1308
  } catch {
1292
1309
  }
1293
1310
  };
@@ -1319,10 +1336,14 @@ function PocketSettingsTab({ rpcCall }) {
1319
1336
  const restartPocket = async () => {
1320
1337
  setUpdateInfo((u) => ({ ...u, restarting: true }));
1321
1338
  try {
1322
- await call(POCKET_ENDPOINTS.restart, {});
1339
+ await Promise.race([
1340
+ call(POCKET_ENDPOINTS.restart, {}),
1341
+ new Promise((_, rej) => setTimeout(() => rej(new Error("restart requested (no reply within 3s)")), 3e3))
1342
+ ]);
1343
+ setUpdateInfo((u) => ({ ...u, restarting: true, result: "ok" }));
1323
1344
  } catch (err) {
1324
1345
  const msg = String(err?.message ?? "");
1325
- if (/connection|socket|fetch|network|abort|cancelled|ECONN|disconnect|closed/i.test(msg)) {
1346
+ if (/connection|socket|fetch|network|abort|cancelled|ECONN|disconnect|closed|timeout/i.test(msg)) {
1326
1347
  setUpdateInfo((u) => ({ ...u, restarting: true, result: "ok" }));
1327
1348
  return;
1328
1349
  }
@@ -1333,7 +1354,13 @@ function PocketSettingsTab({ rpcCall }) {
1333
1354
  setUpdateInfo((u) => ({ ...u, updating: true, result: null }));
1334
1355
  try {
1335
1356
  const r = await call(POCKET_ENDPOINTS.update, {});
1336
- setUpdateInfo((u) => ({ ...u, updating: false, result: r.ok ? "ok" : "fail", output: r.output ?? r.error }));
1357
+ setUpdateInfo((u) => ({
1358
+ ...u,
1359
+ updating: false,
1360
+ result: r.ok ? "ok" : "fail",
1361
+ autoRestart: r.autoRestart === true,
1362
+ output: r.output ?? r.error
1363
+ }));
1337
1364
  } catch (err) {
1338
1365
  setUpdateInfo((u) => ({ ...u, updating: false, result: "fail", output: err.message }));
1339
1366
  }
@@ -1390,9 +1417,9 @@ function PocketSettingsTab({ rpcCall }) {
1390
1417
  (0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 13 } }, "\u{1F504} \u5DF2\u91CD\u542F | Restarted"),
1391
1418
  (0, import_react2.createElement)("button", { style: styles.btn, onClick: () => setRestartNotice(false) }, "\u77E5\u9053\u4E86 | OK")
1392
1419
  ),
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")
1420
+ (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
1421
  ) : null,
1395
- // 更新提示——左侧黄色色条(提示有新版本)
1422
+ // 更新提示——左侧黄色色条(提示有新版本);单状态:有更新/更新中/已更新自动重启,不并存
1396
1423
  updateInfo ? (0, import_react2.createElement)(
1397
1424
  "div",
1398
1425
  { 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 +1429,14 @@ function PocketSettingsTab({ rpcCall }) {
1402
1429
  (0, import_react2.createElement)(
1403
1430
  "div",
1404
1431
  { 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`
1432
+ 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
1433
  ),
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")
1434
+ 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
1435
  ),
1409
1436
  (0, import_react2.createElement)(
1410
1437
  "div",
1411
1438
  { 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}`
1439
+ 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
1440
  )
1414
1441
  ) : null,
1415
1442
  // 局域网
@@ -1435,7 +1462,8 @@ function PocketSettingsTab({ rpcCall }) {
1435
1462
  null,
1436
1463
  (0, import_react2.createElement)("img", { src: status.tunnelQr, alt: "Tunnel QR", style: styles.qr }),
1437
1464
  (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"),
1465
+ (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"),
1466
+ (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
1467
  (0, import_react2.createElement)("button", { style: styles.btn, onClick: stopTunnel }, "\u5173\u95ED\u516C\u7F51 | Stop")
1440
1468
  ) : (0, import_react2.createElement)(
1441
1469
  "div",
@@ -1445,7 +1473,16 @@ function PocketSettingsTab({ rpcCall }) {
1445
1473
  "div",
1446
1474
  { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } },
1447
1475
  `\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")
1476
+ ) : tunnelPhase === "error" ? (0, import_react2.createElement)(
1477
+ "div",
1478
+ { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" } },
1479
+ `\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`
1480
+ ) : (0, import_react2.createElement)(
1481
+ "div",
1482
+ null,
1483
+ (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"),
1484
+ (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")
1485
+ )
1449
1486
  )
1450
1487
  ),
1451
1488
  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/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.23"
79
+ "version": "1.0.25"
80
80
  }