cicy-desktop 2.1.334 → 2.1.335

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cicy-desktop",
3
- "version": "2.1.334",
3
+ "version": "2.1.335",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -146,30 +146,45 @@ async function firstReachable(urls) {
146
146
  return null;
147
147
  }
148
148
 
149
- // ── 下载(带进度,跟随重定向)──────────────────────────────────────────────────
150
- function download(url, dest, onProgress, redirects = 0) {
149
+ // ── 下载(带进度、跟随重定向、断点续传)────────────────────────────────────
150
+ // A single https.get with a 30s socket timeout used to BE the download: one
151
+ // stall on a slow mirror and the update failed for good. That is not
152
+ // theoretical — a fleet node sat on an old build for several releases with
153
+ // "download failed: timeout" every 30 minutes, because its link to the CDN
154
+ // could serve ranges fine but never sustained the whole 125MB inside one
155
+ // socket. So: retry, and resume from what already landed instead of starting
156
+ // over. A slow link now takes several passes and still finishes.
157
+ function downloadOnce(url, dest, onProgress, startAt, redirects = 0) {
151
158
  return new Promise((resolve, reject) => {
152
159
  if (redirects > 6) return reject(new Error("too many redirects"));
153
- const f = fs.createWriteStream(dest);
154
- const req = https.get(url, { headers: { "User-Agent": "cicy-desktop" }, timeout: 30000 }, (res) => {
160
+ const headers = { "User-Agent": "cicy-desktop" };
161
+ if (startAt > 0) headers.Range = `bytes=${startAt}-`;
162
+ const req = https.get(url, { headers, timeout: 30000 }, (res) => {
155
163
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
156
- f.close(); try { fs.unlinkSync(dest); } catch {}
157
- return download(res.headers.location, dest, onProgress, redirects + 1).then(resolve, reject);
164
+ res.resume();
165
+ return downloadOnce(res.headers.location, dest, onProgress, startAt, redirects + 1).then(resolve, reject);
166
+ }
167
+ // 206 = the server honoured the range → append. 200 with startAt means it
168
+ // ignored it → start the file over rather than corrupt it by appending.
169
+ const resuming = res.statusCode === 206;
170
+ if (res.statusCode !== 200 && !resuming) {
171
+ res.resume();
172
+ return reject(new Error(`HTTP ${res.statusCode}`));
158
173
  }
159
- if (res.statusCode !== 200) { f.close(); try { fs.unlinkSync(dest); } catch {} return reject(new Error(`HTTP ${res.statusCode}`)); }
160
- const total = parseInt(res.headers["content-length"] || "0", 10) || 0;
161
- let transferred = 0, lastEmit = 0;
174
+ const base = resuming ? startAt : 0;
175
+ const f = fs.createWriteStream(dest, resuming ? { flags: "a" } : {});
176
+ const len = parseInt(res.headers["content-length"] || "0", 10) || 0;
177
+ const total = base + len;
178
+ let transferred = base, lastEmit = 0;
162
179
  const startT = Date.now();
163
- let winT = startT, winBytes = 0, bytesPerSec = 0;
180
+ let winT = startT, winBytes = transferred, bytesPerSec = 0;
164
181
  res.on("data", (chunk) => {
165
182
  transferred += chunk.length;
166
183
  const now = Date.now();
167
- // 速度按 ~500ms 滑窗算,平滑不抖。
168
184
  if (now - winT >= 500) {
169
185
  bytesPerSec = (transferred - winBytes) / ((now - winT) / 1000);
170
186
  winT = now; winBytes = transferred;
171
187
  }
172
- // 节流:每 ~120ms 或下载完才推一次(够顺滑又不刷爆 IPC)。
173
188
  if (now - lastEmit >= 120 || transferred === total) {
174
189
  lastEmit = now;
175
190
  const percent = total ? Math.min(100, Math.floor((transferred / total) * 100)) : 0;
@@ -177,15 +192,43 @@ function download(url, dest, onProgress, redirects = 0) {
177
192
  try { onProgress && onProgress({ percent, transferred, total, bytesPerSec, etaSec }); } catch {}
178
193
  }
179
194
  });
195
+ res.on("error", (e) => { f.close(); reject(e); });
180
196
  res.pipe(f);
181
- f.on("finish", () => f.close(() => resolve()));
182
- f.on("error", (e) => { try { fs.unlinkSync(dest); } catch {} reject(e); });
197
+ f.on("finish", () => f.close(() => resolve({ transferred, total })));
198
+ f.on("error", (e) => reject(e));
183
199
  });
184
- req.on("error", (e) => { try { fs.unlinkSync(dest); } catch {} reject(e); });
200
+ req.on("error", reject);
185
201
  req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
186
202
  });
187
203
  }
188
204
 
205
+ const DOWNLOAD_ATTEMPTS = 6;
206
+
207
+ async function download(url, dest, onProgress) {
208
+ let lastErr = null;
209
+ for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt++) {
210
+ let have = 0;
211
+ try { have = fs.statSync(dest).size; } catch {}
212
+ try {
213
+ const r = await downloadOnce(url, dest, onProgress, have);
214
+ // A truncated body that ends cleanly still resolves, so only stop when the
215
+ // file is actually as long as the server said.
216
+ if (!r.total || r.transferred >= r.total) return;
217
+ lastErr = new Error(`short read ${r.transferred}/${r.total}`);
218
+ } catch (e) {
219
+ lastErr = e;
220
+ }
221
+ let now = 0;
222
+ try { now = fs.statSync(dest).size; } catch {}
223
+ log.warn(`[app-updater] download attempt ${attempt}/${DOWNLOAD_ATTEMPTS} failed (${lastErr.message}); have ${now} bytes`);
224
+ if (now <= have && attempt > 1) {
225
+ // two passes in a row moved nothing — the source is not just slow.
226
+ try { fs.unlinkSync(dest); } catch {}
227
+ }
228
+ }
229
+ throw lastErr || new Error("download failed");
230
+ }
231
+
189
232
  // ── 状态机 + 广播 ─────────────────────────────────────────────────────────────
190
233
  let _win = null;
191
234
  let _state = { status: "idle", version: null, current: null, progress: null, filePath: null, error: null, autoUpdate: false, auto: false };
@@ -269,6 +312,12 @@ async function downloadUpdate() {
269
312
  // 逐个候选试(R2 → GitHub → 镜像):一个源断了就换下一个,全断才算失败。
270
313
  let lastErr = null;
271
314
  for (const url of urls) {
315
+ // Resume is per-SOURCE. The candidates are the same artifact today (CI
316
+ // uploads one exe under two names), but appending bytes from a second host
317
+ // onto a partial from the first would silently produce a corrupt installer
318
+ // the moment that stops being true — and nothing here verifies a hash
319
+ // before running it. Start each source from zero.
320
+ try { fs.unlinkSync(dest); } catch {}
272
321
  try {
273
322
  log.info(`[app-updater] downloading ${url} → ${dest}`);
274
323
  await download(url, dest, (p) => broadcast({ status: "downloading", progress: p }));
@@ -0,0 +1,64 @@
1
+ // Copyright 2026 CiCy AI
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // One https.get with a 30s socket timeout used to BE the download, so a single
5
+ // stall failed the update for good. Observed in production: a node logged
6
+ // "download failed: timeout" every 30 minutes across several releases while its
7
+ // link served byte ranges from the same CDN perfectly well — it just could not
8
+ // carry 125MB inside one socket. The download now retries and resumes.
9
+ const test = require("node:test");
10
+ const assert = require("node:assert/strict");
11
+ const fs = require("node:fs");
12
+ const path = require("node:path");
13
+
14
+ const src = fs.readFileSync(path.join(__dirname, "..", "src", "app-updater.js"), "utf8");
15
+ const once = src.slice(
16
+ src.indexOf("function downloadOnce"),
17
+ src.indexOf("const DOWNLOAD_ATTEMPTS")
18
+ );
19
+ const outer = src.slice(
20
+ src.indexOf("async function download(url, dest, onProgress)"),
21
+ src.indexOf("// ── 状态机")
22
+ );
23
+
24
+ test("a resumed request asks for the missing range and appends", () => {
25
+ assert.match(once, /if \(startAt > 0\) headers\.Range = `bytes=\$\{startAt\}-`/);
26
+ assert.match(once, /const resuming = res\.statusCode === 206/);
27
+ assert.match(once, /fs\.createWriteStream\(dest, resuming \? \{ flags: "a" \} : \{\}\)/);
28
+ });
29
+
30
+ test("a server that ignores Range restarts the file instead of corrupting it", () => {
31
+ // 200 + startAt means the body is the WHOLE file; appending it after a partial
32
+ // would produce a longer, broken installer that still "downloads fine".
33
+ assert.match(once, /const base = resuming \? startAt : 0/);
34
+ assert.match(once, /const total = base \+ len/); // progress counts what is already on disk
35
+ assert.match(once, /if \(res\.statusCode !== 200 && !resuming\)/);
36
+ });
37
+
38
+ test("progress and completion are judged against the real total", () => {
39
+ assert.match(once, /resolve\(\{ transferred, total \}\)/);
40
+ // A truncated body ends the stream cleanly, so finishing early must not count.
41
+ assert.match(outer, /if \(!r\.total \|\| r\.transferred >= r\.total\) return/);
42
+ assert.match(outer, /short read \$\{r\.transferred\}\/\$\{r\.total\}/);
43
+ });
44
+
45
+ test("retries resume from disk, and give up only when nothing moves", () => {
46
+ assert.match(outer, /for \(let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt\+\+\)/);
47
+ assert.match(outer, /have = fs\.statSync\(dest\)\.size/);
48
+ assert.match(outer, /downloadOnce\(url, dest, onProgress, have\)/);
49
+ assert.match(outer, /if \(now <= have && attempt > 1\)/); // no progress twice → drop the partial
50
+ assert.match(src, /DOWNLOAD_ATTEMPTS = 6/);
51
+ });
52
+
53
+ test("switching source discards the partial rather than appending across hosts", () => {
54
+ const dl = src.slice(
55
+ src.indexOf("async function downloadUpdate"),
56
+ src.indexOf("function installNow")
57
+ );
58
+ const loop = dl.slice(dl.indexOf("for (const url of urls)"));
59
+ assert.match(loop, /fs\.unlinkSync\(dest\)/);
60
+ assert.ok(
61
+ loop.indexOf("fs.unlinkSync(dest)") < loop.indexOf("await download(url, dest"),
62
+ "the partial must be cleared BEFORE the next source starts"
63
+ );
64
+ });