cicy-desktop 2.1.337 → 2.1.339

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.337",
3
+ "version": "2.1.339",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -229,6 +229,41 @@ async function download(url, dest, onProgress) {
229
229
  throw lastErr || new Error("download failed");
230
230
  }
231
231
 
232
+ // ── 自动安装的循环闸 ─────────────────────────────────────────────────────────
233
+ // Auto-install kills the app by design (an installer cannot replace a running
234
+ // exe). So if an install does NOT land, the app comes back on the old version,
235
+ // the next check() tries the same thing, and the machine is stuck in a restart
236
+ // loop that closes the user's window every time. That is not hypothetical — it
237
+ // took the whole fleet down. The attempt is therefore remembered ACROSS
238
+ // restarts, and a version that has already failed twice is not auto-installed
239
+ // again until the cooldown passes; the update is still offered, just not forced.
240
+ const AUTO_TRY_MAX = 2;
241
+ const AUTO_TRY_COOLDOWN_MS = 6 * 60 * 60 * 1000;
242
+
243
+ function readAutoTry() {
244
+ try { return readGlobalConfig(GLOBAL_JSON)?.desktopAutoInstall || {}; } catch { return {}; }
245
+ }
246
+ function noteAutoTry(version) {
247
+ const cur = readAutoTry();
248
+ const count = cur.version === version ? (cur.count || 0) + 1 : 1;
249
+ try {
250
+ updateGlobalConfig(GLOBAL_JSON, (c) => ({
251
+ ...(c || {}),
252
+ desktopAutoInstall: { version, count, at: Date.now() },
253
+ }));
254
+ } catch {}
255
+ return count;
256
+ }
257
+ function clearAutoTry() {
258
+ try { updateGlobalConfig(GLOBAL_JSON, (c) => { const n = { ...(c || {}) }; delete n.desktopAutoInstall; return n; }); } catch {}
259
+ }
260
+ function autoInstallAllowed(version) {
261
+ const t = readAutoTry();
262
+ if (t.version !== version) return true;
263
+ if ((t.count || 0) < AUTO_TRY_MAX) return true;
264
+ return Date.now() - (t.at || 0) > AUTO_TRY_COOLDOWN_MS;
265
+ }
266
+
232
267
  // ── 状态机 + 广播 ─────────────────────────────────────────────────────────────
233
268
  let _win = null;
234
269
  let _state = { status: "idle", version: null, current: null, progress: null, filePath: null, error: null, autoUpdate: false, auto: false };
@@ -260,6 +295,9 @@ function init(mainWin) {
260
295
  _win = mainWin;
261
296
  _state.current = app.getVersion();
262
297
  _state.autoUpdate = getAutoUpdate();
298
+ // Running the version we last tried to install = that attempt worked; forget
299
+ // it so a future update to the same number is never wrongly blocked.
300
+ try { if (readAutoTry().version === _state.current) clearAutoTry(); } catch {}
263
301
  setTimeout(() => check().catch(() => {}), 15_000); // 启动后探一次
264
302
  setInterval(() => check().catch(() => {}), 30 * 60 * 1000); // 每 30 分钟
265
303
  }
@@ -279,10 +317,15 @@ async function check() {
279
317
  log.info(`[app-updater] ${latest} 安装包就绪:${ready}`);
280
318
  broadcast({ status: "available", version: latest, current, progress: null, filePath: null, autoUpdate: getAutoUpdate(), auto: false });
281
319
  if (getAutoUpdate()) {
282
- log.info(`[app-updater] auto-update on → downloading ${latest} and installing without asking`);
283
- broadcast({ auto: true });
284
- await downloadUpdate();
285
- if (_state.status === "ready") installNow();
320
+ if (!autoInstallAllowed(latest)) {
321
+ log.warn(`[app-updater] ${latest} 已自动安装失败 ${AUTO_TRY_MAX} 次,冷却中 — 只提示不再自动装(避免反复关闭 app)`);
322
+ } else {
323
+ const n = noteAutoTry(latest);
324
+ log.info(`[app-updater] auto-update on → downloading ${latest} and installing without asking (attempt ${n}/${AUTO_TRY_MAX})`);
325
+ broadcast({ auto: true });
326
+ await downloadUpdate();
327
+ if (_state.status === "ready") installNow();
328
+ }
286
329
  }
287
330
  } else { log.info(`[app-updater] ${latest} 版本号已更新但所有来源都拿不到安装包(HEAD 非 200):${urls.join(" , ")} — 暂不提示更新`); broadcast({ status: "up-to-date", version: current, current }); }
288
331
  } else {
@@ -340,32 +383,47 @@ async function downloadUpdate() {
340
383
 
341
384
  // 用户点「安装」:拉起原生安装器(win NSIS / mac pkg)并退出 app;linux AppImage 在
342
385
  // 文件管理器里定位(AppImage 非安装器,用户自行替换运行)。
343
- // Windows install + relaunch, as ONE detached chain.
386
+ // Windows install + relaunch, as one hidden chain.
344
387
  //
345
388
  // shell.openPath() ran the NSIS installer INTERACTIVELY: on a machine with
346
389
  // nobody at the keyboard that is a wizard waiting forever for a click, and even
347
390
  // when it did install, nothing started the app again — NSIS runAfterFinish does
348
391
  // 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.
392
+ // an update never came back; it had to be started by hand, and a headless box
393
+ // has no hand.
351
394
  //
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.
395
+ // The chain must also be INVISIBLE. Doing it with spawn(detached:true) put a
396
+ // black console on screen counting down for 20 seconds during every update:
397
+ // on Windows `detached` means CREATE_NEW_CONSOLE, which windowsHide cannot
398
+ // suppress. So it goes through wscript + a one-line VBS with window style 0 —
399
+ // the same hidden-launch trick writeSourceAutostartVbs() already uses — which
400
+ // is genuinely windowless and detached from this process either way.
356
401
  function installWindows(installer) {
357
402
  const { spawn } = require("child_process");
358
403
  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], {
404
+ const dir = path.join(process.env.LOCALAPPDATA || os.homedir(), "cicy-desktop");
405
+ fs.mkdirSync(dir, { recursive: true });
406
+ const vbs = path.join(dir, "update-install.vbs");
407
+ // VBS escapes a double quote by doubling it. cmd waits for the installer (no
408
+ // `start` on that leg), then the pause lets Windows release the replaced
409
+ // files before the new exe runs hidden.
410
+ // Wait for THIS process to exit before the installer starts: it cannot replace
411
+ // a running exe, and previously the chain and app.quit() raced — the installer
412
+ // could begin while the app was still up, fail, and leave the version
413
+ // unchanged, which is what fed the restart loop.
414
+ const cmd =
415
+ `cmd /c "timeout /t 8 /nobreak >nul & "${installer}" /S & timeout /t 20 /nobreak >nul & start "" "${exe}" --hidden"`;
416
+ const line = `sh.Run "${cmd.replace(/"/g, '""')}", 0, False`;
417
+ fs.writeFileSync(vbs, ['Set sh = CreateObject("WScript.Shell")', line, ""].join("\r\n"));
418
+ const ch = spawn("wscript.exe", ["//B", "//Nologo", vbs], {
361
419
  detached: true,
362
420
  stdio: "ignore",
363
421
  windowsHide: true,
364
422
  });
365
423
  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);
424
+ log.info(`[app-updater] hidden install + relaunch chain started (pid ${ch.pid})`);
425
+ // Quit promptly the chain waits 8s for us before touching anything.
426
+ setTimeout(() => { try { app.quit(); } catch {} }, 500);
369
427
  }
370
428
 
371
429
  function installNow() {
@@ -26,6 +26,17 @@ test("windows installs silently instead of opening a wizard nobody can click", (
26
26
  );
27
27
  });
28
28
 
29
+ test("the chain is windowless — no console flashes during an update", () => {
30
+ // Regression: doing this with spawn(detached:true) put a black console on
31
+ // screen counting down for 20s on every update. On Windows `detached` means
32
+ // CREATE_NEW_CONSOLE, which windowsHide cannot suppress, so the runner has to
33
+ // be wscript + a window-style-0 VBS (same trick as writeSourceAutostartVbs).
34
+ assert.match(win, /spawn\("wscript\.exe", \["\/\/B", "\/\/Nologo", vbs\]/);
35
+ assert.match(win, /sh\.Run "\$\{cmd\.replace\(\/"\/g, '""'\)\}", 0, False/);
36
+ assert.match(win, /CreateObject\("WScript\.Shell"\)/);
37
+ assert.doesNotMatch(win, /spawn\(process\.env\.COMSPEC/); // the visible-console version
38
+ });
39
+
29
40
  test("the relaunch is part of the same detached chain, so it outlives the app", () => {
30
41
  // The installer kills this process; a child in this process group would die
31
42
  // with it and the machine would stay down.
@@ -35,18 +46,31 @@ test("the relaunch is part of the same detached chain, so it outlives the app",
35
46
  assert.match(win, /windowsHide: true/);
36
47
  });
37
48
 
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.
49
+ test("the chain is quit install settle → relaunch, in that order", () => {
50
+ // Three waits, each load-bearing:
51
+ // 8s — this process must be GONE before the installer starts. Racing it is
52
+ // what made installs fail, and a failed install fed the restart loop
53
+ // that took the fleet down.
54
+ // 20s — let Windows release the replaced files before the new exe runs.
55
+ // No `start` on the installer leg, so cmd blocks until the install finishes.
41
56
  assert.doesNotMatch(win, /start "" "\$\{installer\}"/);
57
+ assert.match(win, /timeout \/t 8 \/nobreak >nul & "\$\{installer\}" \/S/);
42
58
  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");
59
+ const preWait = win.indexOf("timeout /t 8");
60
+ const install = win.indexOf('"${installer}" /S');
61
+ const settle = win.indexOf("timeout /t 20");
62
+ const relaunch = win.indexOf('start "" "${exe}"');
63
+ assert.ok(
64
+ preWait < install && install < settle && settle < relaunch,
65
+ "quit → install → settle → relaunch"
66
+ );
45
67
  });
46
68
 
47
- test("the app quits so the installer can replace its files", () => {
69
+ test("the app quits promptly, well inside the chain's 8s head start", () => {
48
70
  assert.match(win, /app\.quit\(\)/);
49
- assert.match(win, /setTimeout\(\(\) => \{ try \{ app\.quit\(\); \} catch \{\} \}, 1500\)/);
71
+ const m = win.match(/\}, (\d+)\);/);
72
+ assert.ok(m, "quit is scheduled");
73
+ assert.ok(Number(m[1]) < 8000, `quit delay ${m[1]}ms must be under the 8s head start`);
50
74
  });
51
75
 
52
76
  test("mac and linux keep their existing behaviour", () => {
@@ -0,0 +1,55 @@
1
+ // Copyright 2026 CiCy AI
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // Auto-install kills the app by design — an installer cannot replace a running
5
+ // exe. So when an install does NOT land, the app comes back on the old version,
6
+ // the next check() tries the same thing, and the machine sits in a restart loop
7
+ // that closes the user's window every time. That is exactly what happened: the
8
+ // fleet flapped up/down until every node was gone. A destructive automatic
9
+ // action needs a stop condition, and it had none.
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
+
17
+ test("the same version is not auto-installed forever", () => {
18
+ assert.match(src, /AUTO_TRY_MAX = 2/);
19
+ assert.match(src, /AUTO_TRY_COOLDOWN_MS = 6 \* 60 \* 60 \* 1000/);
20
+ const fn = src.slice(src.indexOf("function autoInstallAllowed"), src.indexOf("// ── 状态机"));
21
+ assert.match(fn, /if \(t\.version !== version\) return true/); // a new version always may
22
+ assert.match(fn, /if \(\(t\.count \|\| 0\) < AUTO_TRY_MAX\) return true/);
23
+ assert.match(fn, /Date\.now\(\) - \(t\.at \|\| 0\) > AUTO_TRY_COOLDOWN_MS/);
24
+ });
25
+
26
+ test("the attempt count survives the restart the install itself causes", () => {
27
+ // In-memory state would reset on every relaunch, which is precisely the thing
28
+ // the loop does — so the counter has to be on disk.
29
+ assert.match(src, /desktopAutoInstall: \{ version, count, at: Date\.now\(\) \}/);
30
+ assert.match(src, /readGlobalConfig\(GLOBAL_JSON\)\?\.desktopAutoInstall/);
31
+ });
32
+
33
+ test("check() gates the auto path and still offers the update", () => {
34
+ const chk = src.slice(
35
+ src.indexOf("async function check()"),
36
+ src.indexOf("async function downloadUpdate")
37
+ );
38
+ assert.match(chk, /if \(!autoInstallAllowed\(latest\)\) \{/);
39
+ assert.match(chk, /noteAutoTry\(latest\)/);
40
+ // blocked = do not install; the "available" broadcast above still happened, so
41
+ // the user can update by hand.
42
+ assert.ok(
43
+ chk.indexOf("autoInstallAllowed") < chk.indexOf("await downloadUpdate()"),
44
+ "the gate must come before the download"
45
+ );
46
+ });
47
+
48
+ test("a landed install clears the record", () => {
49
+ const init = src.slice(
50
+ src.indexOf("function init(mainWin)"),
51
+ src.indexOf("async function check()")
52
+ );
53
+ assert.match(init, /readAutoTry\(\)\.version === _state\.current/);
54
+ assert.match(init, /clearAutoTry\(\)/);
55
+ });