cicy-desktop 2.1.314 → 2.1.316

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.314",
3
+ "version": "2.1.316",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -277,6 +277,14 @@ function register({ sidecarLogPath } = {}) {
277
277
  return;
278
278
  }
279
279
  if (_relayMisses < 2) { log.warn(`[docker-daemon] :${APP_PORT} unreachable from Windows but healthy inside (${_relayMisses}/2)`); return; }
280
+ // 已重置过一次仍不通:bootstrap 对此无能为力(而且以前它会跑「修复=更新」把 cicy-code
281
+ // 重启掉)。只把原因和修复提示写进卡片,每 10 分钟记一次日志,不再反复 bootstrap。
282
+ if (_relayResetDone) {
283
+ const hint = (appDocker.RELAY_UNREACHABLE_HINT && appDocker.RELAY_UNREACHABLE_HINT(APP_PORT)) || `container healthy inside but 127.0.0.1:${APP_PORT} unreachable from Windows`;
284
+ if (!_lastBootstrapError || _lastBootstrapError.reason !== "relay_unreachable") { _lastBootstrapError = { reason: "relay_unreachable", message: hint, ts: Date.now() }; log.warn(`[docker-daemon] ${hint}`); }
285
+ await refreshDockerStatus();
286
+ return;
287
+ }
280
288
  } else { _relayMisses = 0; }
281
289
  } else if (s.running) { _relayMisses = 0; }
282
290
  if (!s.running && !s.unknown && _autoBootstrapPaused && Date.now() < _autoBootstrapRetryAt) {
@@ -0,0 +1,82 @@
1
+ // In-app cicy-code update over the container's OWN API — cicy-code's native
2
+ // upgrade path (POST /api/cicy-update). The updater runs INSIDE the container
3
+ // (setsid'd, survives the supervisor restart), so the desktop needs neither
4
+ // `docker exec` nor a script push into /usr/local/bin — the two steps that kept
5
+ // failing ("cannot execute: required file not found", EACCES, wrong container).
6
+ //
7
+ // The host still resolves the version (fast, host network) and PINS it via
8
+ // `target`, so the container never runs its own slow `npm view`.
9
+ const http = require("node:http");
10
+
11
+ function httpJson(method, port, urlPath, { token = "", body = null, timeoutMs = 15000 } = {}) {
12
+ return new Promise((resolve, reject) => {
13
+ const payload = body == null ? null : Buffer.from(JSON.stringify(body));
14
+ const req = http.request({
15
+ host: "127.0.0.1", port, path: urlPath, method, timeout: timeoutMs,
16
+ headers: {
17
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
18
+ ...(payload ? { "Content-Type": "application/json", "Content-Length": payload.length } : {}),
19
+ },
20
+ }, (res) => {
21
+ const chunks = [];
22
+ res.on("data", (c) => chunks.push(c));
23
+ res.on("end", () => {
24
+ const text = Buffer.concat(chunks).toString("utf8");
25
+ let json = null;
26
+ try { json = text ? JSON.parse(text) : null; } catch {}
27
+ resolve({ status: res.statusCode || 0, json, text });
28
+ });
29
+ });
30
+ req.on("error", reject);
31
+ req.on("timeout", () => { req.destroy(new Error("timeout")); });
32
+ if (payload) req.write(payload);
33
+ req.end();
34
+ });
35
+ }
36
+
37
+ // GET /api/health → running version (or null while the server is restarting).
38
+ async function healthVersion(port) {
39
+ try {
40
+ const r = await httpJson("GET", port, "/api/health", { timeoutMs: 3000 });
41
+ return r.status === 200 && r.json && r.json.version ? String(r.json.version) : null;
42
+ } catch { return null; }
43
+ }
44
+
45
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
46
+
47
+ // Returns { started, ok, reason, version }.
48
+ // started=false → caller should fall back to its legacy path (API unreachable,
49
+ // rejected token, updater missing in this image, …).
50
+ // started=true → the container is updating; ok tells whether the new version
51
+ // came back healthy within waitMs.
52
+ async function inAppUpdate({ port = 8008, token, target, registry = "", emit = () => {}, waitMs = 300000, pollMs = 3000, sleep = defaultSleep, now = Date.now } = {}) {
53
+ if (!token) return { started: false, reason: "no_token" };
54
+ let res;
55
+ try {
56
+ res = await httpJson("POST", port, "/api/cicy-update", { token, body: { target: target || "", registry }, timeoutMs: 20000 });
57
+ } catch (e) {
58
+ return { started: false, reason: `api_unreachable: ${e.message}` };
59
+ }
60
+ if (res.status === 401 || res.status === 403) return { started: false, reason: `unauthorized (${res.status})` };
61
+ if (res.status !== 200 || !res.json) return { started: false, reason: `http ${res.status}` };
62
+ if (res.json.started !== true) {
63
+ const err = String(res.json.error || "");
64
+ if (/already up to date/i.test(err)) return { started: true, ok: true, alreadyLatest: true, version: String(res.json.current || target || "") };
65
+ return { started: false, reason: err || "not started" };
66
+ }
67
+ const want = String(res.json.target || target || "");
68
+ emit({ phase: "image", status: "running", message: `cicy-code → v${want} (in-app)` });
69
+ const deadline = now() + waitMs;
70
+ let sawDown = false;
71
+ while (now() < deadline) {
72
+ const v = await healthVersion(port);
73
+ if (v === null) sawDown = true;
74
+ else if (v === want) return { started: true, ok: true, version: v };
75
+ // Same old version still answering: the updater is installing (npm) — keep
76
+ // waiting; once it repoints + restarts, health drops and comes back new.
77
+ await sleep(pollMs);
78
+ }
79
+ return { started: true, ok: false, reason: sawDown ? "restarted but not healthy in time" : "updater did not switch version in time", version: want };
80
+ }
81
+
82
+ module.exports = { inAppUpdate, healthVersion, httpJson };
@@ -23,6 +23,7 @@ const log = require("electron-log"); // persisted main.log — bootstrap timing/
23
23
  const { gatewayKeyPresentInEnv } = require("./gateway-key-health");
24
24
  const { t } = require("../i18n"); // 打开/读 token 的可见日志走 i18n
25
25
  const { shouldSkipCicyUpdate } = require("./cicy-runtime-health");
26
+ const { inAppUpdate } = require("./cicy-inapp-update");
26
27
  const protect = require("./docker-protect"); // 容器保护:自动流程禁止 rm/shutdown
27
28
 
28
29
  // Dedicated distro name — NEVER reuse/clobber a user's own "Ubuntu" distro.
@@ -655,6 +656,13 @@ async function ensureFreshImage({ emit } = {}) {
655
656
  // 容器内部视角的健康:docker exec 进容器用 node 探 127.0.0.1:8008。用来区分「容器真挂了」和
656
657
  // 「容器好好的、只是 WSL 的 localhost 转发坏了」(后者 Windows 侧 :8008 打不通,实测
657
658
  // `wsl --shutdown` 后 75 秒恢复)。
659
+ // 容器内健康、Windows 却连不上 :port 时给用户/日志的统一说明(bootstrap 与守护循环共用)。
660
+ function RELAY_UNREACHABLE_HINT(port = 8008) {
661
+ return `容器内 cicy-code 正常,但 Windows 连不上 127.0.0.1:${port}(WSL localhost 转发或 Windows TCP 端口异常;` +
662
+ `若桌面端日志里出现 connect EADDRINUSE,多半是 Windows 动态端口被占满/被 Hyper-V 保留段吞掉)。` +
663
+ `已自动重置过一次 WSL 仍不通时,请在管理员 PowerShell 执行:` +
664
+ ` netsh int ipv4 show excludedportrange protocol=tcp; netsh int ipv4 set dynamicport tcp start=49152 num=16384; 然后重启 Windows。`;
665
+ }
658
666
  async function insideHealthy(container = "cicy-code-docker-8008", port = 8008) {
659
667
  try {
660
668
  await wslRun(`docker exec ${container} node -e "fetch('http://127.0.0.1:${port}/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"`, { timeout: 20000 });
@@ -1373,7 +1381,17 @@ async function _bootstrap({ onProgress, port = 8008, container = "cicy-code-dock
1373
1381
  if (!healthy && !protect.guard(log, "auto-update after health miss")) {
1374
1382
  healthy = await docker.waitUntil(() => probeHealth(port), { totalMs: 120000, everyMs: 3000 }); // 保护开启:只等,不自动改容器
1375
1383
  }
1384
+ // Windows 侧探不到 ≠ 容器坏了。先从容器内部探一次:内部健康 = WSL localhost 转发 / Windows 端口
1385
+ // 出了问题(实测 connect EADDRINUSE 连外网也报),这时绝不能再跑 update()「修复」——它会
1386
+ // SIGTERM 重启 cicy-code,把正在跑的 agent 全部打断,而且对转发问题毫无帮助。
1387
+ let relayBroken = false;
1376
1388
  if (!healthy) {
1389
+ try { relayBroken = await insideHealthy(container, port); } catch {}
1390
+ }
1391
+ if (!healthy && relayBroken) {
1392
+ log.warn(`[bootstrap] container healthy inside but 127.0.0.1:${port} unreachable from Windows → not touching the container`);
1393
+ emit({ phase: "container", status: "running", message: RELAY_UNREACHABLE_HINT(port) });
1394
+ } else if (!healthy) {
1377
1395
  emit({ phase: "container", status: "running", message: "服务尚未响应,正在检查并修复运行时完整性…" });
1378
1396
  try {
1379
1397
  const repaired = await update({ onProgress: emit, container, port });
@@ -1382,11 +1400,13 @@ async function _bootstrap({ onProgress, port = 8008, container = "cicy-code-dock
1382
1400
  log.warn(`[bootstrap] runtime auto-repair failed: ${e.message}`);
1383
1401
  }
1384
1402
  }
1385
- if (!healthy) healthy = await docker.waitUntil(() => probeHealth(port), { totalMs: 105000, everyMs: 3000 });
1403
+ if (!healthy) healthy = await docker.waitUntil(() => probeHealth(port), { totalMs: relayBroken ? 30000 : 105000, everyMs: 3000 });
1386
1404
  if (healthy) { done(); await ensureAutostart(); await ensureDesktopShortcut(volume, port); } // survive reboot + desktop shortcut
1387
- else fail("health_timeout");
1405
+ else fail(relayBroken ? "relay_unreachable" : "health_timeout", relayBroken ? RELAY_UNREACHABLE_HINT(port) : "");
1388
1406
  let healthMessage = "Docker cicy-code 已就绪 🎉";
1389
- if (!healthy) {
1407
+ if (!healthy && relayBroken) {
1408
+ healthMessage = RELAY_UNREACHABLE_HINT(port);
1409
+ } else if (!healthy) {
1390
1410
  let tail = "";
1391
1411
  try {
1392
1412
  const r = await wslRun(`docker logs --tail 60 ${container}`, { timeout: 15000 });
@@ -1472,7 +1492,10 @@ async function cicyRuntimePlatformReady(container) {
1472
1492
  const check = `target=$(readlink -f "$HOME/.local/bin/cicy-code" 2>/dev/null || true); ` +
1473
1493
  `dest=$(dirname "$(dirname "$target")" 2>/dev/null); ` +
1474
1494
  `case "$(uname -m)" in x86_64) p=cicy-code-linux-x64;; aarch64|arm64) p=cicy-code-linux-arm64;; *) exit 1;; esac; ` +
1475
- `[ -x "$target" ] && { [ -f "$dest/lib/node_modules/$p/package.json" ] || [ -f "$dest/lib/node_modules/cicy-code/node_modules/$p/package.json" ]; }`;
1495
+ // readlink -f 会一路解析到 <prefix>/lib/node_modules/cicy-code/bin/cicy-code.js,此时 $dest 就是
1496
+ // 包目录本身,平台包在 $dest/node_modules/<p>(实际布局)。之前只查 $dest/lib/... 两种 → 永远 false
1497
+ // → 每轮 bootstrap 都把「更新 cicy-code」当修复跑一遍 → 更新脚本 SIGTERM 重启 cicy-code,打断所有 agent。
1498
+ `[ -x "$target" ] && { [ -f "$dest/node_modules/$p/package.json" ] || [ -f "$dest/lib/node_modules/$p/package.json" ] || [ -f "$dest/lib/node_modules/cicy-code/node_modules/$p/package.json" ]; }`;
1476
1499
  try { await wslRun(`docker exec ${container} bash -lc '${check}'`, { timeout: 15000 }); return true; }
1477
1500
  catch { return false; }
1478
1501
  }
@@ -1513,6 +1536,28 @@ async function update({ onProgress, container = "cicy-code-docker", port = 8008
1513
1536
  }
1514
1537
  // 宿主机没解析出版本 → 给个可见提示(诊断:让用户/我们知道是 host 网络问题,而非容器)。
1515
1538
  if (!latest) emit({ phase: "image", status: "running", message: t("docker.updating.hostResolveFail") });
1539
+ // 3) 首选:cicy-code 自己的升级方式 —— 容器内 API `POST /api/cicy-update`,把宿主机解析好
1540
+ // 的版本 pin 进去。更新脚本在容器里自己跑(setsid,扛得住 supervisor 重启),desktop
1541
+ // 不再 docker exec、不再往 /usr/local/bin 推脚本(这两步就是"required file not found"
1542
+ // / EACCES / 打错容器 的来源)。API 打不通或被拒才回落到下面的老路径。
1543
+ const apiRegistry = net === "global" ? "https://registry.npmjs.org" : net === "cn" ? "https://registry.npmmirror.com" : "";
1544
+ try {
1545
+ const token = await readContainerToken(port, container, `cicy-team-${port}`, { onLog: (ev) => emit({ phase: "image", status: ev.status === "error" ? "running" : ev.status, message: ev.message }) });
1546
+ const r = await inAppUpdate({ port, token, target: latest || "", registry: apiRegistry, emit });
1547
+ log.info(`[wsl-docker] update via API: ${JSON.stringify(r)}`);
1548
+ if (r.started) {
1549
+ if (r.alreadyLatest) {
1550
+ emit({ phase: "done", status: "done", message: t("docker.updating.alreadyLatest", { v: r.version || current }) });
1551
+ return { ok: true, alreadyLatest: true, version: r.version || current };
1552
+ }
1553
+ const doneMsg = r.ok ? t("docker.updating.doneVersion", { v: r.version }) : t("docker.updating.notReady");
1554
+ emit({ phase: "done", status: r.ok ? "done" : "error", message: doneMsg });
1555
+ return { ok: r.ok, version: r.version || latest || null, inApp: true };
1556
+ }
1557
+ emit({ phase: "image", status: "running", message: `in-app update unavailable (${r.reason}) → docker exec` });
1558
+ } catch (e) {
1559
+ emit({ phase: "image", status: "running", message: `in-app update failed (${e.message}) → docker exec` });
1560
+ }
1516
1561
  // 3) 真要装:cp desktop 自带的脚本进容器(随 desktop 发版下发,不依赖镜像),把**已解析
1517
1562
  // 的具体版本**作参数传进去 → 脚本跳过自己的 npm view,容器里不再有版本查询的卡顿。
1518
1563
  emit({ phase: "image", status: "running", message: latest ? t("docker.updating.toVersion", { v: latest }) : t("docker.updating.pulling") });
@@ -1662,7 +1707,7 @@ async function readMihomoSelections(container = "cicy-code-docker-8008") {
1662
1707
  }
1663
1708
 
1664
1709
  module.exports = {
1665
- insideHealthy, wslShutdown,
1710
+ insideHealthy, wslShutdown, RELAY_UNREACHABLE_HINT,
1666
1711
  bootstrap, status, restart, stop, dockerRestart, recreate, update, upgrade, runContainer, readContainerToken,
1667
1712
  distroInstalled, dockerInstalled, dockerEngineUp, imagePresent, probeHealth, wslRun, hasGatewayKey,
1668
1713
  readMihomoConfig, readMihomoSelections, parseMihomoSelections,
@@ -0,0 +1,66 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const http = require("node:http");
4
+ const { inAppUpdate } = require("../src/sidecar/cicy-inapp-update");
5
+
6
+ function serve(handler) {
7
+ return new Promise((resolve) => {
8
+ const srv = http.createServer(handler);
9
+ srv.listen(0, "127.0.0.1", () => resolve({ srv, port: srv.address().port }));
10
+ });
11
+ }
12
+
13
+ test("inAppUpdate pins the host-resolved target, then waits for the new version to come back", async () => {
14
+ const seen = [];
15
+ let version = "2.3.571";
16
+ const { srv, port } = await serve((req, res) => {
17
+ let body = "";
18
+ req.on("data", (c) => { body += c; });
19
+ req.on("end", () => {
20
+ if (req.method === "POST" && req.url === "/api/cicy-update") {
21
+ seen.push({ auth: req.headers.authorization, body: JSON.parse(body) });
22
+ res.setHeader("content-type", "application/json");
23
+ res.end(JSON.stringify({ started: true, current: version, target: "2.3.573" }));
24
+ setTimeout(() => { version = "2.3.573"; }, 30);
25
+ return;
26
+ }
27
+ if (req.url === "/api/health") { res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ status: "ok", version })); return; }
28
+ res.statusCode = 404; res.end();
29
+ });
30
+ });
31
+ try {
32
+ const r = await inAppUpdate({ port, token: "cicy_tok", target: "2.3.573", registry: "https://registry.npmmirror.com", pollMs: 10, waitMs: 5000 });
33
+ assert.equal(r.started, true);
34
+ assert.equal(r.ok, true);
35
+ assert.equal(r.version, "2.3.573");
36
+ assert.equal(seen[0].auth, "Bearer cicy_tok");
37
+ assert.deepEqual(seen[0].body, { target: "2.3.573", registry: "https://registry.npmmirror.com" });
38
+ } finally { srv.close(); }
39
+ });
40
+
41
+ test("inAppUpdate reports not-started so the caller can fall back", async () => {
42
+ const { srv, port } = await serve((req, res) => {
43
+ res.setHeader("content-type", "application/json");
44
+ res.end(JSON.stringify({ started: false, error: "updater not found: /usr/local/bin/cicy-code-update.sh" }));
45
+ });
46
+ try {
47
+ const r = await inAppUpdate({ port, token: "t", target: "2.3.573" });
48
+ assert.equal(r.started, false);
49
+ assert.match(r.reason, /updater not found/);
50
+ } finally { srv.close(); }
51
+ const unreachable = await inAppUpdate({ port: 1, token: "t", target: "2.3.573" });
52
+ assert.equal(unreachable.started, false);
53
+ assert.match(unreachable.reason, /api_unreachable/);
54
+ assert.equal((await inAppUpdate({ port, token: "", target: "x" })).reason, "no_token");
55
+ });
56
+
57
+ test("inAppUpdate treats 'already up to date' as success", async () => {
58
+ const { srv, port } = await serve((req, res) => {
59
+ res.setHeader("content-type", "application/json");
60
+ res.end(JSON.stringify({ started: false, current: "2.3.573", latest: "2.3.573", error: "already up to date" }));
61
+ });
62
+ try {
63
+ const r = await inAppUpdate({ port, token: "t", target: "2.3.573" });
64
+ assert.equal(r.started, true); assert.equal(r.ok, true); assert.equal(r.alreadyLatest, true);
65
+ } finally { srv.close(); }
66
+ });
@@ -10,3 +10,26 @@ test("does not skip an update when the recorded version matches but the platform
10
10
  test("skips an update only when the recorded version matches and the runtime is complete", () => {
11
11
  assert.equal(shouldSkipCicyUpdate({ latest: "2.3.563", current: "2.3.563", platformReady: true }), true);
12
12
  });
13
+
14
+ const fs = require("node:fs");
15
+ const path = require("node:path");
16
+ const readSrc = (p) => fs.readFileSync(path.join(__dirname, "..", p), "utf8");
17
+
18
+ test("platform-ready check covers the real npm layout (<pkg>/node_modules/cicy-code-linux-x64)", () => {
19
+ const src = readSrc("src/sidecar/wsl-docker.js");
20
+ assert.match(src, /\[ -f "\$dest\/node_modules\/\$p\/package\.json" \]/);
21
+ });
22
+
23
+ test("bootstrap never runs the updater when the container is healthy inside (relay problem)", () => {
24
+ const src = readSrc("src/sidecar/wsl-docker.js");
25
+ const i = src.indexOf('begin("wait-health")');
26
+ const block = src.slice(i, i + 3500);
27
+ assert.match(block, /relayBroken = await insideHealthy\(container, port\)/);
28
+ assert.match(block, /else if \(!healthy\) \{[\s\S]*?await update\(/);
29
+ assert.match(block, /fail\(relayBroken \? "relay_unreachable" : "health_timeout"/);
30
+ });
31
+
32
+ test("daemon loop stops re-bootstrapping after the one-shot relay reset", () => {
33
+ const src = readSrc("src/backends/sidecar-ipc.js");
34
+ assert.match(src, /if \(_relayResetDone\) \{[\s\S]*?reason: "relay_unreachable"[\s\S]*?return;/);
35
+ });