cicy-desktop 2.1.334 → 2.1.336
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
package/src/app-updater.js
CHANGED
|
@@ -146,30 +146,45 @@ async function firstReachable(urls) {
|
|
|
146
146
|
return null;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
// ── 下载(
|
|
150
|
-
|
|
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
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
return
|
|
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
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
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 =
|
|
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) =>
|
|
197
|
+
f.on("finish", () => f.close(() => resolve({ transferred, total })));
|
|
198
|
+
f.on("error", (e) => reject(e));
|
|
183
199
|
});
|
|
184
|
-
req.on("error",
|
|
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 }));
|
|
@@ -291,11 +340,40 @@ async function downloadUpdate() {
|
|
|
291
340
|
|
|
292
341
|
// 用户点「安装」:拉起原生安装器(win NSIS / mac pkg)并退出 app;linux AppImage 在
|
|
293
342
|
// 文件管理器里定位(AppImage 非安装器,用户自行替换运行)。
|
|
343
|
+
// Windows install + relaunch, as ONE detached chain.
|
|
344
|
+
//
|
|
345
|
+
// shell.openPath() ran the NSIS installer INTERACTIVELY: on a machine with
|
|
346
|
+
// nobody at the keyboard that is a wizard waiting forever for a click, and even
|
|
347
|
+
// when it did install, nothing started the app again — NSIS runAfterFinish does
|
|
348
|
+
// not fire on a /S install. Both together are why an unattended node that took
|
|
349
|
+
// an update simply never came back; it had to be started by hand, and a headless
|
|
350
|
+
// box has no hand. Observed on most of the fleet.
|
|
351
|
+
//
|
|
352
|
+
// So: /S for the install, and we own the relaunch. The chain is detached and in
|
|
353
|
+
// its own process group, so it outlives the app the installer is about to kill;
|
|
354
|
+
// cmd waits for the installer (no `start` on that leg), then gives Windows a
|
|
355
|
+
// moment to release the files before launching the new exe hidden.
|
|
356
|
+
function installWindows(installer) {
|
|
357
|
+
const { spawn } = require("child_process");
|
|
358
|
+
const exe = process.execPath;
|
|
359
|
+
const chain = `"${installer}" /S & timeout /t 20 /nobreak >nul & start "" "${exe}" --hidden`;
|
|
360
|
+
const ch = spawn(process.env.COMSPEC || "cmd.exe", ["/c", chain], {
|
|
361
|
+
detached: true,
|
|
362
|
+
stdio: "ignore",
|
|
363
|
+
windowsHide: true,
|
|
364
|
+
});
|
|
365
|
+
ch.unref();
|
|
366
|
+
log.info(`[app-updater] silent install + relaunch chain started (pid ${ch.pid})`);
|
|
367
|
+
// Quit so the installer can replace the files it is about to overwrite.
|
|
368
|
+
setTimeout(() => { try { app.quit(); } catch {} }, 1500);
|
|
369
|
+
}
|
|
370
|
+
|
|
294
371
|
function installNow() {
|
|
295
372
|
const f = _state.filePath;
|
|
296
373
|
if (!f) return;
|
|
297
374
|
try {
|
|
298
375
|
if (process.platform === "linux") { shell.showItemInFolder(f); return; }
|
|
376
|
+
if (process.platform === "win32") { installWindows(f); return; }
|
|
299
377
|
shell.openPath(f).then((err) => {
|
|
300
378
|
if (err) log.warn("[app-updater] openPath failed:", err);
|
|
301
379
|
else setTimeout(() => { try { app.quit(); } catch {} }, 800);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Copyright 2026 CiCy AI
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// installNow() used shell.openPath() on the NSIS installer. On a machine with
|
|
5
|
+
// nobody at the keyboard that is an interactive wizard waiting forever for a
|
|
6
|
+
// click; and even when it did install, nothing started the app again, because
|
|
7
|
+
// NSIS runAfterFinish does not fire on a /S install. Both together are why an
|
|
8
|
+
// unattended node that took an update never came back — observed across most of
|
|
9
|
+
// the fleet, each box needing a manual start.
|
|
10
|
+
const test = require("node:test");
|
|
11
|
+
const assert = require("node:assert/strict");
|
|
12
|
+
const fs = require("node:fs");
|
|
13
|
+
const path = require("node:path");
|
|
14
|
+
|
|
15
|
+
const src = fs.readFileSync(path.join(__dirname, "..", "src", "app-updater.js"), "utf8");
|
|
16
|
+
const win = src.slice(src.indexOf("function installWindows"), src.indexOf("function installNow"));
|
|
17
|
+
|
|
18
|
+
test("windows installs silently instead of opening a wizard nobody can click", () => {
|
|
19
|
+
assert.match(win, /"\$\{installer\}" \/S/);
|
|
20
|
+
const now = src.slice(src.indexOf("function installNow"), src.indexOf("module.exports"));
|
|
21
|
+
assert.match(now, /if \(process\.platform === "win32"\) \{ installWindows\(f\); return; \}/);
|
|
22
|
+
// the interactive path must no longer be what Windows takes
|
|
23
|
+
assert.ok(
|
|
24
|
+
now.indexOf('process.platform === "win32"') < now.indexOf("shell.openPath"),
|
|
25
|
+
"win32 must be handled before the openPath fallback"
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("the relaunch is part of the same detached chain, so it outlives the app", () => {
|
|
30
|
+
// The installer kills this process; a child in this process group would die
|
|
31
|
+
// with it and the machine would stay down.
|
|
32
|
+
assert.match(win, /detached: true/);
|
|
33
|
+
assert.match(win, /ch\.unref\(\)/);
|
|
34
|
+
assert.match(win, /start "" "\$\{exe\}" --hidden/);
|
|
35
|
+
assert.match(win, /windowsHide: true/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the chain waits for the installer before relaunching", () => {
|
|
39
|
+
// No `start` on the installer leg → cmd blocks on it; then a pause so Windows
|
|
40
|
+
// releases the replaced files before the new exe runs.
|
|
41
|
+
assert.doesNotMatch(win, /start "" "\$\{installer\}"/);
|
|
42
|
+
assert.match(win, /timeout \/t 20 \/nobreak/);
|
|
43
|
+
const order = [win.indexOf("/S"), win.indexOf("timeout /t"), win.indexOf('start "" "${exe}"')];
|
|
44
|
+
assert.ok(order[0] < order[1] && order[1] < order[2], "install → wait → relaunch, in that order");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("the app quits so the installer can replace its files", () => {
|
|
48
|
+
assert.match(win, /app\.quit\(\)/);
|
|
49
|
+
assert.match(win, /setTimeout\(\(\) => \{ try \{ app\.quit\(\); \} catch \{\} \}, 1500\)/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("mac and linux keep their existing behaviour", () => {
|
|
53
|
+
const now = src.slice(src.indexOf("function installNow"), src.indexOf("module.exports"));
|
|
54
|
+
assert.match(
|
|
55
|
+
now,
|
|
56
|
+
/if \(process\.platform === "linux"\) \{ shell\.showItemInFolder\(f\); return; \}/
|
|
57
|
+
);
|
|
58
|
+
assert.match(now, /shell\.openPath\(f\)/); // mac pkg still opens the GUI installer
|
|
59
|
+
});
|
|
@@ -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
|
+
});
|