dsh-pocket 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -1282,14 +1282,16 @@ function mobileApply(ctx) {
1282
1282
  var name = "dsh-pocket";
1283
1283
  var inject = ["slots", "connection", "layout", "locale", "sessionLogDownload"];
1284
1284
  var styles = {
1285
- card: { background: "var(--dsw-alias-bg-layer-1,#fff)", border: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", borderRadius: 12, padding: "14px 16px", maxWidth: 480 },
1286
- block: { borderTop: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", marginTop: 12, paddingTop: 12 },
1287
- muted: { color: "var(--dsw-alias-label-tertiary,#8b93a1)", fontSize: 12 },
1288
- code: { fontFamily: "ui-monospace,Menlo,monospace", fontSize: 12, wordBreak: "break-all", margin: "4px 0 8px" },
1289
- primary: { font: "inherit", cursor: "pointer", border: "none", background: "var(--dsw-alias-brand-primary,#4f6ef7)", color: "#fff", borderRadius: 8, padding: "6px 14px", fontSize: 13 },
1290
- btn: { font: "inherit", cursor: "pointer", border: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", background: "var(--dsw-alias-bg-layer-1,#fff)", borderRadius: 8, padding: "6px 14px", fontSize: 13 },
1291
- qr: { width: 220, height: 220, borderRadius: 8, border: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", margin: "6px 0" },
1292
- warn: { color: "var(--dsw-alias-state-warn-primary,#b45309)", fontSize: 12 }
1285
+ card: { background: "var(--dsw-alias-bg-layer-1,#fff)", border: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", borderRadius: 12, padding: "16px 20px", maxWidth: 480 },
1286
+ block: { borderTop: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", marginTop: 16, paddingTop: 16 },
1287
+ muted: { color: "var(--dsw-alias-label-tertiary,#8b93a1)", fontSize: 12, lineHeight: 1.5 },
1288
+ code: { fontFamily: "ui-monospace,Menlo,monospace", fontSize: 12, wordBreak: "break-all", margin: "6px 0 10px", color: "var(--dsw-alias-label-primary,inherit)" },
1289
+ // 主按钮:官方 md 胶囊形(36px)
1290
+ primary: { font: "inherit", cursor: "pointer", border: "none", background: "var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))", color: "#fff", height: 36, padding: "0 16px", borderRadius: 999, fontSize: 13, fontWeight: 500, display: "inline-flex", alignItems: "center", justifyContent: "center" },
1291
+ // 次级按钮:官方 outline/ghost 胶囊形
1292
+ btn: { font: "inherit", cursor: "pointer", border: "1px solid var(--dsw-alias-button-ghost-active-border, var(--dsw-alias-border-l2,#d1d5db))", background: "var(--dsw-alias-bg-layer-1,#fff)", color: "var(--dsw-alias-label-primary,inherit)", height: 36, padding: "0 16px", borderRadius: 999, fontSize: 13, display: "inline-flex", alignItems: "center", justifyContent: "center" },
1293
+ qr: { width: 220, height: 220, borderRadius: 10, border: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", margin: "8px 0" },
1294
+ warn: { color: "var(--dsw-alias-state-warn-primary,#b45309)", fontSize: 12, lineHeight: 1.5 }
1293
1295
  };
1294
1296
  function PocketSettingsTab({ rpcCall }) {
1295
1297
  const [status, setStatus] = (0, import_react2.useState)(null);
@@ -1298,6 +1300,12 @@ function PocketSettingsTab({ rpcCall }) {
1298
1300
  const [tunnelState, setTunnelState] = (0, import_react2.useState)(null);
1299
1301
  const [restartNotice, setRestartNotice] = (0, import_react2.useState)(false);
1300
1302
  const [updateInfo, setUpdateInfo] = (0, import_react2.useState)(null);
1303
+ const [now, setNow] = (0, import_react2.useState)(Date.now());
1304
+ (0, import_react2.useEffect)(() => {
1305
+ const t = setInterval(() => setNow(Date.now()), 1e3);
1306
+ return () => clearInterval(t);
1307
+ }, []);
1308
+ const elapsed = (startedAt) => startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1e3)) : 0;
1301
1309
  const call = async (endpoint, payload) => {
1302
1310
  const res = await rpcCall(endpoint, payload);
1303
1311
  if (!res?.ok) throw new Error(res?.error?.message ?? "RPC failed");
@@ -1356,7 +1364,7 @@ function PocketSettingsTab({ rpcCall }) {
1356
1364
  };
1357
1365
  }, []);
1358
1366
  const restartPocket = async () => {
1359
- setUpdateInfo((u) => ({ ...u, restarting: true }));
1367
+ setUpdateInfo((u) => ({ ...u, restarting: true, startedAt: Date.now() }));
1360
1368
  try {
1361
1369
  await Promise.race([
1362
1370
  call(POCKET_ENDPOINTS.restart, {}),
@@ -1373,7 +1381,7 @@ function PocketSettingsTab({ rpcCall }) {
1373
1381
  }
1374
1382
  };
1375
1383
  const runUpdate = async () => {
1376
- setUpdateInfo((u) => ({ ...u, updating: true, result: null }));
1384
+ setUpdateInfo((u) => ({ ...u, updating: true, result: null, startedAt: Date.now() }));
1377
1385
  try {
1378
1386
  const r = await call(POCKET_ENDPOINTS.update, {});
1379
1387
  setUpdateInfo((u) => ({
@@ -1458,7 +1466,7 @@ function PocketSettingsTab({ rpcCall }) {
1458
1466
  (0, import_react2.createElement)(
1459
1467
  "div",
1460
1468
  { style: styles.muted, marginTop: 4 },
1461
- 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}`
1469
+ updateInfo.updating ? `\u23F3 \u66F4\u65B0\u4E2D\uFF08\u901A\u5E38 1-2 \u5206\u949F\uFF09\xB7 \u5DF2\u7B49\u5F85 ${elapsed(updateInfo.startedAt)} \u79D2 | updating (usually 1-2 min) \xB7 ${elapsed(updateInfo.startedAt)}s` : updateInfo.restarting ? `\u23F3 \u6B63\u5728\u91CD\u542F\u751F\u6548\uFF08\u901A\u5E38 10-30 \u79D2\uFF09\xB7 \u5DF2\u7B49\u5F85 ${elapsed(updateInfo.startedAt)} \u79D2 | restarting (usually 10-30s) \xB7 ${elapsed(updateInfo.startedAt)}s` : 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}`
1462
1470
  )
1463
1471
  ) : null,
1464
1472
  // 局域网
@@ -1494,7 +1502,7 @@ function PocketSettingsTab({ rpcCall }) {
1494
1502
  tunnelStarting ? (0, import_react2.createElement)(
1495
1503
  "div",
1496
1504
  { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } },
1497
- `\u23F3 ${tunnelStateDetail}\uFF08\u5DF2\u7B49\u5F85 ${Math.floor((Date.now() - (tunnelStateStarted || Date.now())) / 1e3)} \u79D2\uFF09\u2026`
1505
+ tunnelPhase === "downloading" ? `\u23F3 \u4E0B\u8F7D cloudflared\uFF08\u9996\u6B21\u7EA6 20-50MB\uFF0C\u901A\u5E38 1-2 \u5206\u949F\uFF1B\u4E4B\u540E\u79D2\u5F00\uFF09\xB7 \u5DF2\u7B49\u5F85 ${elapsed(tunnelStateStarted)} \u79D2` : `\u23F3 \u8FDE\u63A5 Cloudflare \u8FB9\u7F18\uFF08\u901A\u5E38 5-30 \u79D2\uFF09\xB7 \u5DF2\u7B49\u5F85 ${elapsed(tunnelStateStarted)} \u79D2${elapsed(tunnelStateStarted) > 30 ? " \u2014 \u6709\u70B9\u4E45\uFF1F\u68C0\u67E5\u662F\u5426\u5F00\u7740\u4EE3\u7406/VPN\uFF08Clash TUN \u7B49\uFF09" : ""}`
1498
1506
  ) : tunnelPhase === "error" ? (0, import_react2.createElement)(
1499
1507
  "div",
1500
1508
  { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" } },
package/client/index.jsx CHANGED
@@ -15,15 +15,20 @@ import { mobileApply } from './mobile/mobile-apply.tsx';
15
15
  const name = 'dsh-pocket';
16
16
  const inject = ['slots', 'connection', 'layout', 'locale', 'sessionLogDownload'];
17
17
 
18
+ // 官方 DeepSeek Harness 设计系统(dsh-client-ui-theme design-platform.css):
19
+ // 按钮 md=36px 胶囊形 / sm=28px;品牌色 --dsw-alias-brand-primary;
20
+ // hover 走 --dsw-alias-button-*-hover;间距 4px 栅格;正文 13px。
18
21
  const styles = {
19
- card: { background: 'var(--dsw-alias-bg-layer-1,#fff)', border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', borderRadius: 12, padding: '14px 16px', maxWidth: 480 },
20
- block: { borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', marginTop: 12, paddingTop: 12 },
21
- muted: { color: 'var(--dsw-alias-label-tertiary,#8b93a1)', fontSize: 12 },
22
- code: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 12, wordBreak: 'break-all', margin: '4px 0 8px' },
23
- primary: { font: 'inherit', cursor: 'pointer', border: 'none', background: 'var(--dsw-alias-brand-primary,#4f6ef7)', color: '#fff', borderRadius: 8, padding: '6px 14px', fontSize: 13 },
24
- btn: { font: 'inherit', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', background: 'var(--dsw-alias-bg-layer-1,#fff)', borderRadius: 8, padding: '6px 14px', fontSize: 13 },
25
- qr: { width: 220, height: 220, borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', margin: '6px 0' },
26
- warn: { color: 'var(--dsw-alias-state-warn-primary,#b45309)', fontSize: 12 },
22
+ card: { background: 'var(--dsw-alias-bg-layer-1,#fff)', border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', borderRadius: 12, padding: '16px 20px', maxWidth: 480 },
23
+ block: { borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', marginTop: 16, paddingTop: 16 },
24
+ muted: { color: 'var(--dsw-alias-label-tertiary,#8b93a1)', fontSize: 12, lineHeight: 1.5 },
25
+ code: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 12, wordBreak: 'break-all', margin: '6px 0 10px', color: 'var(--dsw-alias-label-primary,inherit)' },
26
+ // 主按钮:官方 md 胶囊形(36px)
27
+ primary: { font: 'inherit', cursor: 'pointer', border: 'none', background: 'var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))', color: '#fff', height: 36, padding: '0 16px', borderRadius: 999, fontSize: 13, fontWeight: 500, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' },
28
+ // 次级按钮:官方 outline/ghost 胶囊形
29
+ btn: { font: 'inherit', cursor: 'pointer', border: '1px solid var(--dsw-alias-button-ghost-active-border, var(--dsw-alias-border-l2,#d1d5db))', background: 'var(--dsw-alias-bg-layer-1,#fff)', color: 'var(--dsw-alias-label-primary,inherit)', height: 36, padding: '0 16px', borderRadius: 999, fontSize: 13, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' },
30
+ qr: { width: 220, height: 220, borderRadius: 10, border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', margin: '8px 0' },
31
+ warn: { color: 'var(--dsw-alias-state-warn-primary,#b45309)', fontSize: 12, lineHeight: 1.5 },
27
32
  };
28
33
 
29
34
  function PocketSettingsTab({ rpcCall }) {
@@ -32,7 +37,15 @@ function PocketSettingsTab({ rpcCall }) {
32
37
  const [error, setError] = useState(null);
33
38
  const [tunnelState, setTunnelState] = useState(null); // 隧道进度 {phase, detail, startedAt}
34
39
  const [restartNotice, setRestartNotice] = useState(false); // 重启后提示
35
- const [updateInfo, setUpdateInfo] = useState(null); // { current, latest, updating, result } | null
40
+ const [updateInfo, setUpdateInfo] = useState(null); // { current, latest, updating, result, startedAt } | null
41
+ const [now, setNow] = useState(Date.now()); // 每秒 tick,驱动倒计时
42
+
43
+ // 进行中操作的「已等待 X 秒」倒计时
44
+ useEffect(() => {
45
+ const t = setInterval(() => setNow(Date.now()), 1000);
46
+ return () => clearInterval(t);
47
+ }, []);
48
+ const elapsed = (startedAt) => (startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0);
36
49
 
37
50
  const call = async (endpoint, payload) => {
38
51
  const res = await rpcCall(endpoint, payload);
@@ -92,7 +105,7 @@ function PocketSettingsTab({ rpcCall }) {
92
105
 
93
106
  // 重启宿主(更新生效必需:刷新页面不会重载服务端代码)
94
107
  const restartPocket = async () => {
95
- setUpdateInfo((u) => ({ ...u, restarting: true }));
108
+ setUpdateInfo((u) => ({ ...u, restarting: true, startedAt: Date.now() }));
96
109
  try {
97
110
  // 宿主 500ms 后自杀,RPC 响应可能来不及送达 → 3 秒超时兜底,别让按钮永远卡「重启中…」
98
111
  await Promise.race([
@@ -113,7 +126,7 @@ function PocketSettingsTab({ rpcCall }) {
113
126
 
114
127
  // 一键更新:调宿主 dsh plugin update(成功后宿主自动重启生效,用户只点一次)
115
128
  const runUpdate = async () => {
116
- setUpdateInfo((u) => ({ ...u, updating: true, result: null }));
129
+ setUpdateInfo((u) => ({ ...u, updating: true, result: null, startedAt: Date.now() }));
117
130
  try {
118
131
  const r = await call(POCKET_ENDPOINTS.update, {});
119
132
  setUpdateInfo((u) => ({
@@ -187,7 +200,11 @@ function PocketSettingsTab({ rpcCall }) {
187
200
  : h('button', { style: styles.primary, onClick: restartPocket, disabled: updateInfo.restarting }, updateInfo.restarting ? '重启中…' : '🔄 重启 dsh web 生效 | Restart now'),
188
201
  ),
189
202
  h('div', { style: styles.muted, marginTop: 4 },
190
- updateInfo.result === 'ok'
203
+ updateInfo.updating
204
+ ? `⏳ 更新中(通常 1-2 分钟)· 已等待 ${elapsed(updateInfo.startedAt)} 秒 | updating (usually 1-2 min) · ${elapsed(updateInfo.startedAt)}s`
205
+ : updateInfo.restarting
206
+ ? `⏳ 正在重启生效(通常 10-30 秒)· 已等待 ${elapsed(updateInfo.startedAt)} 秒 | restarting (usually 10-30s) · ${elapsed(updateInfo.startedAt)}s`
207
+ : updateInfo.result === 'ok'
191
208
  ? (updateInfo.autoRestart ? '✅ 已更新,正在自动重启生效,请稍候刷新 | updated — restarting automatically, refresh shortly'
192
209
  : '✅ 已更新,重启 dsh web 生效 | updated — restart dsh web')
193
210
  : updateInfo.result === 'fail' ? `❌ 失败:${updateInfo.output || '未知'}(手动更新:dsh plugin --profile web update dsh-pocket --latest -w)`
@@ -221,7 +238,9 @@ function PocketSettingsTab({ rpcCall }) {
221
238
  h('button', { style: styles.primary, onClick: startTunnel, disabled: busy || tunnelStarting }, busy ? '开启中…' : '开启公网访问 | Enable anywhere'),
222
239
  tunnelStarting
223
240
  ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } },
224
- `⏳ ${tunnelStateDetail}(已等待 ${Math.floor((Date.now() - (tunnelStateStarted || Date.now())) / 1000)} 秒)…`)
241
+ tunnelPhase === 'downloading'
242
+ ? `⏳ 下载 cloudflared(首次约 20-50MB,通常 1-2 分钟;之后秒开)· 已等待 ${elapsed(tunnelStateStarted)} 秒`
243
+ : `⏳ 连接 Cloudflare 边缘(通常 5-30 秒)· 已等待 ${elapsed(tunnelStateStarted)} 秒${elapsed(tunnelStateStarted) > 30 ? ' — 有点久?检查是否开着代理/VPN(Clash TUN 等)' : ''}`)
225
244
  : tunnelPhase === 'error'
226
245
  ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } },
227
246
  `❌ 开启失败:${tunnelStateDetail || '未知错误 | failed'}(可重试;若是代理/VPN 问题见 README 排障)`)
package/lib/tunnel.mjs CHANGED
@@ -36,10 +36,114 @@ const CLOUDFLARED_MIRRORS = [
36
36
 
37
37
  const TUNA_BOTTLES = 'https://mirrors.tuna.tsinghua.edu.cn/homebrew-bottles/';
38
38
 
39
+ /** 多线程分块下载的并发段数(Windows 官方源单线程 ~200KB/s,8 并发 ≈ 1.6MB/s)。 */
40
+ const PARALLEL_SEGMENTS = 8;
41
+ /** 小于该字节数的文件不值得分块(直接用单线程)。 */
42
+ const MIN_PARALLEL_SIZE = 8 * 1024 * 1024;
43
+ /** 探针大小:单线程先下这么多测速。 */
44
+ const PROBE_SIZE = 2 * 1024 * 1024;
45
+ /** 探针测速阈值(bytes/ms):低于它认为慢网络,切多线程。300KB/s = 0.3。 */
46
+ const SLOW_SPEED_THRESHOLD = 0.3;
47
+
39
48
  function hostOf(url) {
40
49
  try { return new URL(url).host; } catch { return url; }
41
50
  }
42
51
 
52
+ /** 合并多个分段文件为一个目标文件(顺序拼接后统一结束)。 */
53
+ async function mergeParts(partFiles, dest) {
54
+ const { createReadStream } = await import('node:fs');
55
+ const out = createWriteStream(dest);
56
+ try {
57
+ for (const f of partFiles) {
58
+ await new Promise((resolve, reject) => {
59
+ const rs = createReadStream(f);
60
+ rs.on('error', reject);
61
+ rs.pipe(out, { end: false });
62
+ rs.on('end', resolve);
63
+ });
64
+ }
65
+ } finally {
66
+ await new Promise((r) => out.end(r));
67
+ }
68
+ }
69
+
70
+ /**
71
+ * 下载文件到 dest(自适应):
72
+ * 1. 服务器不支持 Range 或文件小 → 单线程;
73
+ * 2. 单线程下载探针(PROBE_SIZE)测速——速度够快 → 继续单线程(多线程在部分网络/
74
+ * 服务器上反而更慢,如 GitHub CDN 并发限速);
75
+ * 3. 探针速度低于阈值(典型慢网络,如 Windows 用户官方源 ~200KB/s)→ 丢弃探针,
76
+ * 改 8 段并发分块(可把 200KB/s 拉到 1.6MB/s)。
77
+ * 返回实际下载字节数。
78
+ */
79
+ export async function downloadFile(url, dest, { signal, segments = PARALLEL_SEGMENTS } = {}) {
80
+ // HEAD 探测:Content-Length + Accept-Ranges
81
+ let head = null;
82
+ try { head = await fetch(url, { method: 'HEAD', signal }); } catch { head = null; }
83
+ const len = head ? Number(head.headers.get('content-length') || 0) : 0;
84
+ const acceptsRanges = head ? String(head.headers.get('accept-ranges') || '').toLowerCase() === 'bytes' : false;
85
+
86
+ if (!head || !acceptsRanges || len < MIN_PARALLEL_SIZE) {
87
+ // 单线程
88
+ const res = await fetch(url, { signal });
89
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
90
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
91
+ return len || 0;
92
+ }
93
+
94
+ // 探针测速:单线程下载前 PROBE_SIZE,计时
95
+ const probeBytes = Math.min(PROBE_SIZE, len);
96
+ const probeStart = Date.now();
97
+ try {
98
+ const probeRes = await fetch(url, { signal, headers: { Range: `bytes=0-${probeBytes - 1}` } });
99
+ if (!probeRes.ok) throw new Error(`HTTP ${probeRes.status} (probe)`);
100
+ const probeBody = await probeRes.arrayBuffer();
101
+ const probeMs = Date.now() - probeStart;
102
+ const probeSpeed = probeMs > 0 ? probeBytes / probeMs : Infinity; // bytes/ms
103
+ if (probeMs < 500 || probeSpeed >= SLOW_SPEED_THRESHOLD) {
104
+ // 够快 → 单线程下完剩余部分(探针字节已拿到,写入 dest)
105
+ const { createWriteStream, createReadStream } = await import('node:fs');
106
+ const w = createWriteStream(dest);
107
+ await new Promise((resolve, reject) => {
108
+ w.on('error', reject);
109
+ w.write(Buffer.from(probeBody));
110
+ w.end(resolve);
111
+ });
112
+ const restRes = await fetch(url, { signal, headers: { Range: `bytes=${probeBytes}-${len - 1}` } });
113
+ if (!restRes.ok) throw new Error(`HTTP ${restRes.status} (rest)`);
114
+ await pipeline(Readable.fromWeb(restRes.body), createWriteStream(dest, { flags: 'a' }));
115
+ return len;
116
+ }
117
+ // 慢 → 丢弃探针,转分块并发(从 0 开始全量分块)
118
+ await rm(dest, { force: true }).catch(() => {});
119
+ } catch (err) {
120
+ await rm(dest, { force: true }).catch(() => {});
121
+ if (!/HTTP|fetch/i.test(String(err?.message ?? ''))) throw err; // 探针网络异常 → 抛给上层换源
122
+ // 探针 HTTP 错误(部分服务器 HEAD 与 GET 行为不一致)→ 直接分块
123
+ }
124
+
125
+ // 分块并发
126
+ const parts = [];
127
+ const chunk = Math.ceil(len / segments);
128
+ for (let i = 0; i < segments; i++) {
129
+ const start = i * chunk;
130
+ const end = i === segments - 1 ? len - 1 : Math.min(start + chunk - 1, len - 1);
131
+ if (start > end) break;
132
+ parts.push({ start, end, file: `${dest}.part${i}` });
133
+ }
134
+ try {
135
+ await Promise.all(parts.map(async (p) => {
136
+ const res = await fetch(url, { signal, headers: { Range: `bytes=${p.start}-${p.end}` } });
137
+ if (!res.ok) throw new Error(`HTTP ${res.status} (range ${p.start}-${p.end})`);
138
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(p.file));
139
+ }));
140
+ await mergeParts(parts.map((p) => p.file), dest);
141
+ } finally {
142
+ await Promise.all(parts.map((p) => rm(p.file, { force: true }).catch(() => {})));
143
+ }
144
+ return len;
145
+ }
146
+
43
147
  /**
44
148
  * 清华 TUNA 镜像的 cloudflared Homebrew bottle URL(国内 CDN,实测 ~3MB/s)。
45
149
  * 只覆盖 macOS/Linux(Windows 无 Homebrew bottle,返回 null)。
@@ -93,9 +197,9 @@ async function downloadCloudflared(binPath, signal) {
93
197
  const { url, host } = sources[i];
94
198
  console.log(`⬇️ 下载 cloudflared(${i + 1}/${sources.length}:${host})…`);
95
199
  try {
96
- const res = await fetch(url, { signal: fetchSignal });
97
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
98
- await pipeline(Readable.fromWeb(res.body), createWriteStream(tmpFile));
200
+ // 多线程分块(官方 GitHub 支持 Range,Windows 50MB 从几分钟降到几十秒);
201
+ // 不支持 Range 的源自动回退单线程
202
+ await downloadFile(url, tmpFile, { signal: fetchSignal });
99
203
  // 简单校验:空文件/极小文件视为下载失败(可能是镜像返回了错误页)
100
204
  const st = await stat(tmpFile);
101
205
  if (st.size < 1024 * 1024) throw new Error(`文件异常小(${st.size} 字节),疑似镜像错误页`);
package/package.json CHANGED
@@ -76,5 +76,5 @@
76
76
  "access": "public",
77
77
  "registry": "https://registry.npmjs.org/"
78
78
  },
79
- "version": "1.1.0"
79
+ "version": "1.3.0"
80
80
  }