cicy-desktop 2.1.366 → 2.1.368

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.
@@ -0,0 +1,521 @@
1
+ // Copyright 2026 CiCy AI
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // Docker-版 cicy-code on Windows via QEMU (不靠 WSL) —— 新机器的后端(老 WSL 机器不动,见 linux-backend.js)。
5
+ //
6
+ // 跑在首页「Debian」卡片的同一台 QEMU 虚拟机里(只开一台,不各占一份内存):
7
+ // • 虚拟机的生命周期(下载 QEMU/Debian 镜像、开机、关机、端口转发)全交给 qemu-vm.cjs ——
8
+ // 首页和这里共用同一份源码、同一套磁盘约定(~/cicy-ai/vm/debian),谁先开机谁当 runner;
9
+ // • 虚拟机里的事(装 Docker、docker load/run/exec、读 token)走 SSH(ssh2,密钥由 qemu-vm.cjs
10
+ // 生成并经 cloud-init 注入),每条命令 `sudo -n sh -c`,等价于 wsl-docker.js 的 wslRun;
11
+ // • 端口:宿主 127.0.0.1:<port> 经 QMP hostfwd_add 转发到虚拟机 :<port>,容器 -p <port>:8008。
12
+ // 虚拟机网卡只有 slirp,外面只够得着宿主回环,所以虚拟机内 0.0.0.0 发布是安全的。
13
+ //
14
+ // 与 wsl-docker.js / colima-docker.js 同接口(bootstrap/status/restart/stop/dockerRestart/
15
+ // recreate/update/upgrade/runContainer/readContainerToken/…),sidecar-ipc 经 app-docker.js 选用。
16
+ // WSL 专属的自愈(repairWsl / wslShutdown / elevatedTcpRepair)这里没有 —— sidecar-ipc 对它们
17
+ // 都是 `appDocker.x &&` 判空调用,缺了就自然跳过。
18
+ //
19
+ // 已知差异:没有宿主目录共享(Windows 上的 QEMU 不支持 virtio-9p/virtiofs),所以不挂 C:\projects
20
+ // 和各盘;容器数据在虚拟机磁盘的 docker 卷里。软件模拟(无 WHPX)能跑但很慢,会在进度里提示。
21
+
22
+ const { spawn, execFile } = require("child_process");
23
+ const path = require("path");
24
+ const os = require("os");
25
+ const fs = require("fs");
26
+ const https = require("https");
27
+ const log = require("electron-log");
28
+ const docker = require("./docker"); // 共享:downloadImageTarball / remoteImageEtag / waitUntil / probeHealth
29
+ const { gatewayKeyPresentInEnv } = require("./gateway-key-health");
30
+ const { t } = require("../i18n");
31
+ const { shouldSkipCicyUpdate } = require("./cicy-runtime-health");
32
+ const { inAppUpdate } = require("./cicy-inapp-update");
33
+ const protect = require("./docker-protect");
34
+
35
+ const IMAGE = process.env.CICY_DOCKER_IMAGE || "cicybot/cicy-code:latest";
36
+ const VM_DIR = path.join(os.homedir(), "cicy-ai", "vm", "debian");
37
+ const CTL_SRC = path.join(__dirname, "qemu-vm.cjs");
38
+ const CTL_DST = path.join(os.homedir(), "cicy-ai", "db", "qemu-vm-ctl.js");
39
+ const SSH_PORT = 17622;
40
+ const CICY_NODE = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "cicy-node", "node.exe");
41
+
42
+ // ---- qemu-vm.cjs(虚拟机控制)--------------------------------------------
43
+
44
+ // 用哪个 node 跑控制脚本:优先 dsh 同款便携 node(%LOCALAPPDATA%\cicy-node);没有就用 Electron
45
+ // 自己当 node(ELECTRON_RUN_AS_NODE)。前者更好:后台 runner 进程不叫「CiCy Desktop.exe」,
46
+ // desktop 退出/升级杀进程时不会把虚拟机一起带走。
47
+ function ctlNode() {
48
+ if (fs.existsSync(CICY_NODE)) return { bin: CICY_NODE, env: { ...process.env } };
49
+ return { bin: process.execPath, env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } };
50
+ }
51
+ function writeCtl() {
52
+ const src = fs.readFileSync(CTL_SRC, "utf8");
53
+ let cur = ""; try { cur = fs.readFileSync(CTL_DST, "utf8"); } catch {}
54
+ if (cur !== src) { fs.mkdirSync(path.dirname(CTL_DST), { recursive: true }); fs.writeFileSync(CTL_DST, src); }
55
+ }
56
+ function ctl(op, args = [], timeout = 90000) {
57
+ return new Promise((resolve) => {
58
+ try { writeCtl(); } catch (e) { return resolve({ ok: false, error: "ctl_write_failed", detail: e.message }); }
59
+ const { bin, env } = ctlNode();
60
+ execFile(bin, [CTL_DST, op, ...args.map(String)], { windowsHide: true, timeout, env, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
61
+ const m = String(stdout || "").trim().match(/\{[\s\S]*\}$/);
62
+ if (m) { try { return resolve(JSON.parse(m[0])); } catch {} }
63
+ resolve({ ok: false, error: "ctl_no_output", detail: String(stderr || (err && err.message) || "").trim().slice(-400) });
64
+ });
65
+ });
66
+ }
67
+ const vmStatus = () => ctl("status", [], 30000);
68
+
69
+ // ---- SSH(进虚拟机跑命令)-------------------------------------------------
70
+
71
+ function shQuote(s) { return `'${String(s).replace(/'/g, "'\\''")}'`; }
72
+ let _conn = null, _connP = null;
73
+ function sshConnect() {
74
+ if (_conn) return Promise.resolve(_conn);
75
+ if (_connP) return _connP;
76
+ _connP = new Promise((resolve, reject) => {
77
+ let key;
78
+ try { key = fs.readFileSync(path.join(VM_DIR, "id_rsa")); } catch (e) { _connP = null; return reject(Object.assign(new Error("no_ssh_key"), { code: "no_ssh_key" })); }
79
+ const { Client } = require("ssh2");
80
+ const c = new Client();
81
+ const done = (err) => { _connP = null; if (err) { try { c.end(); } catch {} reject(err); } else { _conn = c; resolve(c); } };
82
+ c.on("ready", () => done());
83
+ c.on("error", (e) => { if (_conn === c) _conn = null; done(e); });
84
+ c.on("close", () => { if (_conn === c) _conn = null; });
85
+ c.connect({ host: "127.0.0.1", port: SSH_PORT, username: "cicy", privateKey: key, readyTimeout: 15000, keepaliveInterval: 15000 });
86
+ });
87
+ return _connP;
88
+ }
89
+ // 等价于 wslRun:成功 resolve {stdout, stderr};非零退出 reject(err.stdout/err.stderr/err.code)。
90
+ // opts.stdin:可读流,灌进命令的 stdin(docker load 用)。opts.onLine:逐行回调(流式进度)。
91
+ async function vmRun(cmd, { timeout = 60000, stdin, onLine } = {}) {
92
+ const c = await sshConnect();
93
+ return new Promise((resolve, reject) => {
94
+ c.exec(`sudo -n sh -c ${shQuote(cmd)}`, (err, stream) => {
95
+ if (err) { _conn = null; try { c.end(); } catch {} return reject(err); }
96
+ let out = "", errOut = "", buf = "";
97
+ const timer = setTimeout(() => { try { stream.close(); } catch {} reject(Object.assign(new Error(`vmRun timeout after ${timeout}ms: ${cmd.slice(0, 120)}`), { stdout: out, stderr: errOut })); }, timeout);
98
+ const line = (chunk) => {
99
+ if (!onLine) return;
100
+ buf += chunk;
101
+ let i; while ((i = buf.indexOf("\n")) >= 0) { const l = buf.slice(0, i).replace(/\r$/, "").trim(); buf = buf.slice(i + 1); if (l) { try { onLine(l); } catch {} } }
102
+ };
103
+ stream.on("data", (d) => { const s = d.toString("utf8"); out += s; line(s); });
104
+ stream.stderr.on("data", (d) => { const s = d.toString("utf8"); errOut += s; line(s); });
105
+ stream.on("close", (code) => {
106
+ clearTimeout(timer);
107
+ if (code === 0) resolve({ stdout: out, stderr: errOut });
108
+ else reject(Object.assign(new Error(`exit ${code}: ${(errOut || out).trim().slice(-300)}`), { code, stdout: out, stderr: errOut }));
109
+ });
110
+ if (stdin) { stdin.on("error", (e) => { try { stream.close(); } catch {} reject(e); }); stdin.pipe(stream); }
111
+ else stream.end();
112
+ });
113
+ });
114
+ }
115
+ function vmRunStream(cmd, { emit, phase = "install-docker", timeout = 900000 } = {}) {
116
+ let last = 0;
117
+ return vmRun(cmd, { timeout, onLine: (l) => { const now = Date.now(); if (emit && now - last > 350) { last = now; emit({ phase, status: "running", message: l.slice(0, 200) }); } } });
118
+ }
119
+ async function sshOk() { try { await vmRun("true", { timeout: 15000 }); return true; } catch (e) { _conn = null; return false; } }
120
+
121
+ // ---- 虚拟机就绪 ----------------------------------------------------------
122
+
123
+ const vmInstalled = (s) => !!(s && s.installed);
124
+
125
+ // 装(如需)→ 开机(如需)→ 等 SSH 可用。已装好的旧虚拟机(首页先装的,没注入 desktop 公钥)
126
+ // 需要按新 instance-id 重开机一次,让 cloud-init 把公钥写进去。
127
+ async function ensureVm({ emit } = {}) {
128
+ const e = (ev) => { try { emit && emit(ev); } catch {} };
129
+ let s = await vmStatus();
130
+ if (!s.ok) return { ok: false, reason: "vm_ctl_failed", message: [s.error, s.detail].filter(Boolean).join(": ") };
131
+ if (!vmInstalled(s)) {
132
+ e({ phase: "install-docker", status: "running", message: "下载 QEMU 便携版 + Debian 13 云镜像(约 390MB,不需要 WSL)…" });
133
+ const r = await ctl("install");
134
+ if (!r.ok) return { ok: false, reason: "vm_install_failed", message: r.error };
135
+ let lastPhase = "";
136
+ for (;;) {
137
+ await new Promise((res) => setTimeout(res, 2000));
138
+ s = await vmStatus();
139
+ if (s.installed && !s.installing) break;
140
+ if (!s.installing) return { ok: false, reason: "vm_install_failed", message: s.installError || "stopped" };
141
+ if (s.phase !== lastPhase) { lastPhase = s.phase; e({ phase: "install-docker", status: "running", message: `虚拟机安装:${s.phase}` }); }
142
+ if (s.total && (s.phase === "qemu" || s.phase === "image")) {
143
+ e({ phase: s.phase === "qemu" ? "install-docker" : "image", status: "running", progress: Math.min(99, Math.round(s.bytes * 100 / s.total)), received: s.bytes, total: s.total, url: s.src, label: s.phase === "qemu" ? "QEMU" : "Debian 13" });
144
+ }
145
+ }
146
+ e({ phase: "install-docker", status: "done", message: "QEMU + Debian 13 就绪" });
147
+ }
148
+ let restarted = false;
149
+ for (let round = 0; round < 2; round++) {
150
+ s = await vmStatus();
151
+ if (!s.running) {
152
+ e({ phase: "install-docker", status: "running", message: "启动 Debian 虚拟机…" });
153
+ const r = await ctl("start");
154
+ if (!r.ok) return { ok: false, reason: "vm_start_failed", message: [r.error, r.detail, r.tail].filter(Boolean).join(" · ").slice(0, 400) };
155
+ }
156
+ const t0 = Date.now();
157
+ const up = await docker.waitUntil(sshOk, { totalMs: 15 * 60 * 1000, everyMs: 5000, onTick: () => e({ phase: "install-docker", status: "running", message: `等虚拟机开机…(${Math.round((Date.now() - t0) / 1000)}s)` }) });
158
+ if (up) {
159
+ s = await vmStatus();
160
+ if (s.accel === "tcg") e({ phase: "install-docker", status: "running", message: "⚠️ 这台机器没开 WHPX,虚拟机在软件模拟下运行,Docker 版 cicy-code 会很慢。首页 Debian 卡片菜单里可「开启硬件加速」(需管理员 + 重启)。" });
161
+ return { ok: true, accel: s.accel };
162
+ }
163
+ // SSH 一直不通:多半是这台虚拟机是首页早先开的(老 provision,没 desktop 公钥)→ 重开机一次。
164
+ if (restarted) break;
165
+ restarted = true;
166
+ log.warn(`[qemu-docker] ssh not reachable (provision=${s.provision}) → reboot VM once to re-run cloud-init`);
167
+ e({ phase: "install-docker", status: "running", message: "虚拟机需要按新配置重开机一次(注入 desktop 的 SSH 密钥)…" });
168
+ await ctl("stop", [], 120000);
169
+ }
170
+ return { ok: false, reason: "vm_ssh_unreachable", message: "虚拟机开机了但 SSH 一直连不上(看 ~/cicy-ai/logs/debian-console.log)" };
171
+ }
172
+
173
+ // 宿主 127.0.0.1:<p> → 虚拟机 :<p>。转发开机才有效、关机即丢,所以每次用之前都补一遍(重复添加
174
+ // 无害)。runContainer 用过的端口记在 forwards.json,之后只传主端口也能把额外端口一并补回来。
175
+ const FWD_FILE = path.join(VM_DIR, "forwards.json");
176
+ function savedForwards() { try { return (JSON.parse(fs.readFileSync(FWD_FILE, "utf8")).ports || []).map(Number); } catch { return []; } }
177
+ async function ensureForwards(port, extraPorts = [], { save = false } = {}) {
178
+ const want = [port, ...(Array.isArray(extraPorts) ? extraPorts : [])].map(Number).filter((p) => Number.isInteger(p) && p > 0 && p < 65536);
179
+ if (save) { try { fs.writeFileSync(FWD_FILE, JSON.stringify({ ports: [...new Set(want)] })); } catch {} }
180
+ const ports = [...new Set([...want, ...savedForwards()])];
181
+ const r = await ctl("fwd", ports, 20000);
182
+ if (!r.ok) log.warn(`[qemu-docker] hostfwd ${ports.join(",")} failed: ${r.error}`);
183
+ return r;
184
+ }
185
+
186
+ // ---- Docker 引擎 ---------------------------------------------------------
187
+
188
+ async function dockerInstalled() { try { await vmRun("command -v docker >/dev/null", { timeout: 15000 }); return true; } catch { return false; } }
189
+ async function dockerEngineUp() { try { await vmRun("docker info >/dev/null 2>&1", { timeout: 20000 }); return true; } catch { return false; } }
190
+
191
+ // apt 装 docker.io(Debian 自带包)。deb.debian.org 在 CN 很慢 → 5 秒探不通就换 TUNA。
192
+ async function installDockerEngine({ emit } = {}) {
193
+ emit && emit({ phase: "install-docker", status: "running", message: "在虚拟机里安装 Docker 引擎(apt install docker.io)…" });
194
+ const mirror = "if ! curl -fsS -m 5 -o /dev/null https://deb.debian.org/debian/dists/trixie/Release; then " +
195
+ "sed -i 's#http[s]*://deb.debian.org#https://mirrors.tuna.tsinghua.edu.cn#g' /etc/apt/sources.list.d/debian.sources 2>/dev/null; fi";
196
+ await vmRunStream(`${mirror}; export DEBIAN_FRONTEND=noninteractive; apt-get update -q && apt-get install -y -q docker.io && systemctl enable --now docker && usermod -aG docker cicy`, { emit, phase: "install-docker", timeout: 1800000 });
197
+ }
198
+
199
+ // ---- 镜像 ----------------------------------------------------------------
200
+
201
+ async function imagePresent() { try { await vmRun(`docker image inspect ${IMAGE} >/dev/null 2>&1`, { timeout: 15000 }); return true; } catch { return false; } }
202
+ // 宿主下好的 R2 tarball 经 SSH stdin 先落成虚拟机里的临时文件,再 `docker load -i`(不需要共享目录;
203
+ // 落盘后 load 失败也能和传输失败分开)。
204
+ // ⚠️ 读流挂了统计进度的 'data' 监听就会立刻开始流动,而 SSH 通道是异步建起来的 —— 不先 pause(),
205
+ // 管道接上之前的那段数据就被白白读掉,虚拟机收到的包缺头 → docker load 报 invalid tar header(实测)。
206
+ async function loadImage(tarball, { emit } = {}) {
207
+ const total = (() => { try { return fs.statSync(tarball).size; } catch { return 0; } })();
208
+ let sent = 0, lastPct = -1;
209
+ const rs = fs.createReadStream(tarball);
210
+ rs.on("data", (chunk) => {
211
+ sent += chunk.length;
212
+ const pct = total ? Math.floor((sent / total) * 100) : -1;
213
+ if (pct !== lastPct && pct % 5 === 0) { lastPct = pct; emit && emit({ phase: "image", status: "loading", message: `导入镜像到虚拟机 Docker… ${pct}%`, progress: pct }); }
214
+ });
215
+ rs.pause(); // 等 vmRun 里 pipe() 接上再流(见上)
216
+ const tmp = "/var/tmp/cicy-image.tar.gz";
217
+ await vmRun(`cat > ${tmp}`, { stdin: rs, timeout: 1800000 });
218
+ emit && emit({ phase: "image", status: "loading", message: "镜像已传进虚拟机,docker load 中(约 1 分钟)…" });
219
+ let stdout = "";
220
+ try { ({ stdout } = await vmRun(`docker load -i ${tmp}`, { timeout: 1800000 })); }
221
+ finally { try { await vmRun(`rm -f ${tmp}`, { timeout: 30000 }); } catch {} }
222
+ const m = String(stdout).match(/Loaded image:\s*(\S+)/i);
223
+ if (m && m[1] !== IMAGE) { try { await vmRun(`docker tag ${m[1]} ${IMAGE}`, { timeout: 15000 }); } catch {} }
224
+ }
225
+ // 同 wsl/colima:缺镜像、或 R2 tarball 的 ETag 跟上次 load 的不一致 → 重下重载(非破坏性)。
226
+ async function ensureFreshImage({ emit } = {}) {
227
+ const e = (ev) => { try { emit && emit(ev); } catch {} };
228
+ const present = await imagePresent();
229
+ const remote = await docker.remoteImageEtag().catch(() => "");
230
+ const loaded = docker.readLoadedImageEtag();
231
+ if (present && remote && loaded && remote === loaded) return false;
232
+ if (present && !remote) return false;
233
+ if (present && remote !== loaded) { e({ phase: "image", status: "running", message: "检测到更新的镜像,正在拉取最新版…" }); docker.clearImageTarball(); }
234
+ let tarball;
235
+ try { tarball = await docker.downloadImageTarball({ emit }); }
236
+ catch (err) { if (present) { e({ phase: "image", status: "running", message: `最新镜像下载失败(${err.message}),沿用现有镜像` }); return false; } throw err; }
237
+ await loadImage(tarball, { emit });
238
+ if (remote) docker.writeLoadedImageEtag(remote);
239
+ return true;
240
+ }
241
+
242
+ // ---- 容器 ----------------------------------------------------------------
243
+
244
+ const probeHealth = docker.probeHealth;
245
+
246
+ async function containerState(container) {
247
+ try { const { stdout } = await vmRun(`docker inspect --format '{{.State.Status}}' ${container} 2>/dev/null`, { timeout: 15000 }); return String(stdout || "").trim() || null; }
248
+ catch { return null; }
249
+ }
250
+ function publishArgs(port, extraPorts = []) {
251
+ const seen = new Set([Number(port), 8008]);
252
+ let args = `-p ${port}:8008`;
253
+ for (const raw of Array.isArray(extraPorts) ? extraPorts : []) {
254
+ const p = Number(raw);
255
+ if (!Number.isInteger(p) || p < 1 || p > 65535 || seen.has(p)) continue;
256
+ seen.add(p); args += ` -p ${p}:${p}`;
257
+ }
258
+ return args;
259
+ }
260
+ // DooD:把虚拟机里 dockerd 的 socket 和 docker 客户端挂进容器(兄弟容器,同 wsl 版)。
261
+ async function dockerSockMountArg() {
262
+ const SOCK = "/var/run/docker.sock";
263
+ try { await vmRun(`test -S ${SOCK}`, { timeout: 10000 }); } catch { return ""; }
264
+ let groupAdd = "", cliMount = "";
265
+ try { const gid = String((await vmRun(`stat -c '%g' ${SOCK}`, { timeout: 10000 })).stdout || "").trim(); if (/^\d+$/.test(gid)) groupAdd = ` --group-add ${gid}`; } catch {}
266
+ try { const p = String((await vmRun("command -v docker", { timeout: 10000 })).stdout || "").trim(); if (p) cliMount = ` -v ${p}:/usr/local/bin/docker:ro`; } catch {}
267
+ return `-v ${SOCK}:${SOCK}${cliMount}${groupAdd}`;
268
+ }
269
+
270
+ async function startExisting({ container, port, emit } = {}) {
271
+ if (await probeHealth(port)) return { ok: true, adopted: true };
272
+ const v = await ensureVm({ emit });
273
+ if (!v.ok) return { ok: false, error: v.message };
274
+ await ensureForwards(port);
275
+ const st = await containerState(container);
276
+ if (!st) return { ok: false, missing: true };
277
+ if (st !== "running") { try { await vmRun(`docker start ${container}`, { timeout: 60000 }); } catch (e) { return { ok: false, error: e.message }; } }
278
+ const ok = await docker.waitUntil(() => probeHealth(port), { totalMs: 120000, everyMs: 3000 });
279
+ return { ok, container };
280
+ }
281
+
282
+ async function runContainer({ port, container, volume, env = {}, extraPorts = [], dockerSock = false, emit, force = false } = {}) {
283
+ await ensureForwards(port, extraPorts, { save: true });
284
+ if (await probeHealth(port)) return { adopted: true };
285
+ const existing = await containerState(container);
286
+ if (existing && !protect.guard(log, `docker rm -f ${container} (state=${existing})`, { force })) {
287
+ emit && emit({ phase: "container", status: "running", message: `容器已存在(${existing}),受保护 → 仅 docker start` });
288
+ try { await vmRun(`docker start ${container}`, { timeout: 60000 }); } catch (e) { log.warn(`[qemu-docker] docker start failed: ${e.message}`); }
289
+ return { started: true, reused: true };
290
+ }
291
+ try { await vmRun(`docker rm -f ${container}`, { timeout: 30000 }); } catch {}
292
+ const inherited = {};
293
+ for (const key of ["CICY_CLOUD_EMAIL", "CICY_CLOUD_TEAM_ID", "CICY_CFT"]) if (process.env[key]) inherited[key] = process.env[key];
294
+ const envArgs = Object.entries({ ...inherited, ...(env || {}) })
295
+ .filter(([, v]) => v != null && v !== "")
296
+ .map(([k, v]) => `-e ${k}=${shQuote(v)}`).join(" ");
297
+ let doodOn = dockerSock;
298
+ if (!doodOn) { try { doodOn = require("./cicy-code").isDood(); } catch {} }
299
+ const sockArg = doodOn ? await dockerSockMountArg() : "";
300
+ const cmd = `docker run -d --name ${container} --restart unless-stopped --dns 223.5.5.5 --dns 8.8.8.8 ${publishArgs(port, extraPorts)} -e CICY_PUBLIC=1 -v ${volume}:/home/cicy ${sockArg} ${envArgs} ${IMAGE}`;
301
+ emit && emit({ phase: "container", status: "running", message: `$ ${cmd.length > 220 ? cmd.slice(0, 220) + " …" : cmd}` });
302
+ await vmRun(cmd, { timeout: 120000 });
303
+ return { started: true };
304
+ }
305
+
306
+ // 容器自己的 api_token(卷里的 global.json)—— :port 唯一正确的凭证。失败 60s 内不再重试(同 wsl 版)。
307
+ let _tokenFailAt = 0;
308
+ async function readContainerToken(port, container, volume, { onLog } = {}) {
309
+ const say = (status, message) => { try { (status === "error" ? log.warn : log.info)(`[qemu-docker:token] ${message}`); } catch {} try { onLog && onLog({ status, message }); } catch {} };
310
+ if (Date.now() - _tokenFailAt < 60000) return "";
311
+ port = Number(port || process.env.CICY_DOCKER_APP_PORT || 8009);
312
+ const vol = volume || `cicy-team-${port}`;
313
+ container = container || `cicy-code-docker-${port}`;
314
+ for (let attempt = 1; attempt <= 5; attempt++) {
315
+ try {
316
+ const { stdout } = await vmRun(`cat /var/lib/docker/volumes/${vol}/_data/cicy-ai/global.json 2>/dev/null`, { timeout: 10000 });
317
+ const m = String(stdout).match(/"api_token"\s*:\s*"(cicy_[A-Za-z0-9]+)"/);
318
+ if (m) { say("done", t("dockerOpen.tokVolHit", { n: attempt })); return m[1]; }
319
+ say("running", t("dockerOpen.tokVolMiss"));
320
+ } catch (e) { say("running", t("dockerOpen.tokVolErr", { err: e.message })); }
321
+ if (container) {
322
+ try {
323
+ const { stdout } = await vmRun(`docker exec ${container} cat /home/cicy/cicy-ai/global.json`, { timeout: 12000 });
324
+ const tok = JSON.parse(stdout).api_token || "";
325
+ if (tok) { say("done", t("dockerOpen.tokExecHit", { n: attempt })); return tok; }
326
+ } catch (e) { say("running", t("dockerOpen.tokExecErr", { err: e.message })); }
327
+ }
328
+ if (attempt < 5) await new Promise((r) => setTimeout(r, 2000));
329
+ }
330
+ say("error", t("dockerOpen.tokFail"));
331
+ _tokenFailAt = Date.now();
332
+ return "";
333
+ }
334
+
335
+ async function insideHealthy(container, port) {
336
+ try { await vmRun(`docker exec ${container} node -e "fetch('http://127.0.0.1:8008/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"`, { timeout: 20000 }); return true; }
337
+ catch { return false; }
338
+ }
339
+
340
+ // ---- 状态 / bootstrap ----------------------------------------------------
341
+
342
+ // 与 wsl status 同形:wsl → 虚拟机已装(QEMU+镜像在),distro → 同,engineUp → 虚拟机里 dockerd 在。
343
+ // 不碰 SSH 就能答的先答:没装 / 没开机直接返回,不阻塞。
344
+ async function status(port) {
345
+ const healthy = await probeHealth(port);
346
+ const s = await vmStatus();
347
+ if (!s.ok) return { wsl: false, distro: false, engineUp: false, running: healthy, healthy, unknown: !healthy };
348
+ const installed = vmInstalled(s);
349
+ const engineUp = !!(s.running && installed && (await dockerEngineUp()));
350
+ return { wsl: installed, distro: installed, engineUp, running: healthy, healthy, accel: s.accel || "", vmRunning: !!s.running };
351
+ }
352
+
353
+ let _bootstrapInFlight = null;
354
+ async function bootstrap(opts = {}) {
355
+ if (_bootstrapInFlight) {
356
+ try { opts.onProgress && opts.onProgress({ phase: "install-docker", status: "running", message: "安装已在进行中,正在跟随同一进度…" }); } catch {}
357
+ return _bootstrapInFlight;
358
+ }
359
+ _bootstrapInFlight = _bootstrap(opts).finally(() => { _bootstrapInFlight = null; });
360
+ return _bootstrapInFlight;
361
+ }
362
+
363
+ async function _bootstrap({ onProgress, port, container, volume, env = {}, extraPorts = [], dockerSock = false } = {}) {
364
+ const emit = (ev) => { try { onProgress && onProgress(ev); } catch {} };
365
+ const T0 = Date.now();
366
+ const fail = (reason, message) => {
367
+ log.error(`[qemu-docker] bootstrap ✗ ${reason} ${((Date.now() - T0) / 1000).toFixed(1)}s ${message || ""}`);
368
+ emit({ phase: reason.startsWith("image") ? "image" : reason.startsWith("vm") || reason.startsWith("docker_install") ? "install-docker" : "container", status: "error", message: `${message || reason}(点重试)` });
369
+ return { ok: false, reason, message };
370
+ };
371
+ log.info(`[qemu-docker] bootstrap START port=${port} container=${container} volume=${volume}`);
372
+ if (await probeHealth(port)) { emit({ phase: "done", status: "done", message: "Docker cicy-code 已就绪 🎉" }); return { ok: true, container }; }
373
+
374
+ const v = await ensureVm({ emit });
375
+ if (!v.ok) return fail(v.reason, v.message);
376
+
377
+ if (!(await dockerInstalled())) {
378
+ try { await installDockerEngine({ emit }); } catch (e) { return fail("docker_install_failed", `Docker 安装失败:${e.message}`); }
379
+ }
380
+ if (!(await dockerEngineUp())) {
381
+ try { await vmRun("systemctl start docker", { timeout: 60000 }); } catch {}
382
+ if (!(await docker.waitUntil(dockerEngineUp, { totalMs: 90000, everyMs: 3000 }))) return fail("dockerd_not_up", "Docker 引擎没起来");
383
+ }
384
+
385
+ try { await ensureFreshImage({ emit }); }
386
+ catch (e) {
387
+ if (!(await imagePresent())) return fail("image_download_failed", `镜像下载失败:${e.message}`);
388
+ emit({ phase: "image", status: "running", message: `镜像刷新失败(${e.message}),沿用现有镜像` });
389
+ }
390
+
391
+ emit({ phase: "container", status: "running", message: "启动 cicy-code 服务…" });
392
+ try { await runContainer({ port, container, volume, env, extraPorts, dockerSock, emit }); }
393
+ catch (e) { return fail("container_start_failed", `服务启动失败:${e.message}`); }
394
+
395
+ emit({ phase: "container", status: "running", message: "等待 cicy-code 就绪…" });
396
+ // 软件模拟下 cicy-code 冷启动可能要好几分钟
397
+ const healthy = await docker.waitUntil(() => probeHealth(port), { totalMs: (v.accel === "tcg" ? 600 : 180) * 1000, everyMs: 3000 });
398
+ if (!healthy) return fail("health_timeout", `服务起来了但 127.0.0.1:${port} 还没响应——稍等或点「重试」`);
399
+ log.info(`[qemu-docker] bootstrap DONE ${((Date.now() - T0) / 1000).toFixed(1)}s accel=${v.accel}`);
400
+ emit({ phase: "done", status: "done", message: "Docker cicy-code 已就绪 🎉" });
401
+ return { ok: true, container };
402
+ }
403
+
404
+ // 守护循环在 :port 健康后调一次:确保端口转发还在(虚拟机重开机后转发会丢)。
405
+ // 虚拟机开机自启由首页 Debian 卡片和这里的 reconcile/bootstrap 负责,不另建计划任务。
406
+ async function ensureAutostart({ port, extraPorts } = {}) {
407
+ const p = Number(port || process.env.CICY_DOCKER_APP_PORT || 8009);
408
+ await ensureForwards(p, extraPorts || []);
409
+ }
410
+
411
+ // ---- 生命周期(卡片 ⋯ 菜单)--------------------------------------------
412
+
413
+ async function restart({ container, port } = {}) {
414
+ await ensureVm({}); await ensureForwards(port);
415
+ try { await vmRun(`docker exec ${container} supervisorctl -c /etc/supervisor/supervisord.conf restart cicy-code`, { timeout: 30000 }); }
416
+ catch { try { await vmRun(`docker restart ${container}`, { timeout: 60000 }); } catch {} }
417
+ return docker.waitUntil(() => probeHealth(port), { totalMs: 90000, everyMs: 2000 });
418
+ }
419
+ async function stop({ container } = {}) { try { await vmRun(`docker stop ${container}`, { timeout: 45000 }); } catch {} }
420
+ async function dockerRestart({ container } = {}) { await vmRun(`docker restart ${container}`, { timeout: 60000 }); return true; }
421
+
422
+ function getJson(url, timeout) {
423
+ return new Promise((resolve, reject) => {
424
+ const req = https.get(url, { timeout, headers: { "user-agent": "cicy-desktop" } }, (res) => {
425
+ let b = ""; res.on("data", (d) => { b += d; }); res.on("end", () => { try { resolve(JSON.parse(b)); } catch (e) { reject(e); } });
426
+ });
427
+ req.on("timeout", () => req.destroy(new Error("timeout"))); req.on("error", reject);
428
+ });
429
+ }
430
+ async function resolveLatestCicy(net) {
431
+ const order = net === "global" ? ["https://registry.npmjs.org", "https://registry.npmmirror.com"] : ["https://registry.npmmirror.com", "https://registry.npmjs.org"];
432
+ for (const reg of order) { try { const j = await getJson(`${reg}/cicy-code/latest`, 12000); if (j && j.version) return String(j.version); } catch {} }
433
+ return null;
434
+ }
435
+ async function installedCicyVersion(container) {
436
+ try { const j = JSON.parse((await vmRun(`docker exec ${container} bash -lc "cat ~/cicy-ai/runtime/versions.json 2>/dev/null"`, { timeout: 15000 })).stdout); return j && j["cicy-code"] && j["cicy-code"].current ? String(j["cicy-code"].current) : null; }
437
+ catch { return null; }
438
+ }
439
+
440
+ // 原地更新 cicy-code:先走 cicy-code 自己的 API(POST /api/cicy-update),不行再推 desktop 自带的
441
+ // cicy-code-update.sh 进容器跑(同 wsl 版,去掉了 WSL 专属的引擎自愈)。
442
+ async function update({ onProgress, container, port } = {}) {
443
+ const emit = (ev) => { try { onProgress && onProgress(ev); } catch {} };
444
+ await ensureVm({ emit }); await ensureForwards(port);
445
+ emit({ phase: "image", status: "running", message: t("docker.updating.checking") });
446
+ let net = "unknown"; try { net = await require("./net-detect").detect(); } catch {}
447
+ const [latest, current] = await Promise.all([resolveLatestCicy(net), installedCicyVersion(container)]);
448
+ if (shouldSkipCicyUpdate({ latest, current, platformReady: true })) {
449
+ emit({ phase: "done", status: "done", message: t("docker.updating.alreadyLatest", { v: current }) });
450
+ return { ok: true, alreadyLatest: true, version: current };
451
+ }
452
+ const registry = net === "global" ? "https://registry.npmjs.org" : net === "cn" ? "https://registry.npmmirror.com" : "";
453
+ try {
454
+ const token = await readContainerToken(port, container, `cicy-team-${port}`);
455
+ const r = await inAppUpdate({ port, token, target: latest || "", registry, emit });
456
+ if (r.started) {
457
+ emit({ phase: "done", status: r.ok ? "done" : "error", message: r.ok ? t("docker.updating.doneVersion", { v: r.version }) : t("docker.updating.notReady") });
458
+ return { ok: r.ok, version: r.version || latest || null, inApp: true };
459
+ }
460
+ } catch (e) { emit({ phase: "image", status: "running", message: `in-app update failed (${e.message}) → docker exec` }); }
461
+ try {
462
+ const b64 = Buffer.from(fs.readFileSync(path.join(__dirname, "container-scripts", "cicy-code-update.sh"), "utf8").replace(/\r\n?/g, "\n"), "utf8").toString("base64");
463
+ await vmRun(`echo ${b64} | base64 -d | docker exec -u root -i ${container} bash -c 'cat > /usr/local/bin/cicy-code-update.sh && chmod 0755 /usr/local/bin/cicy-code-update.sh'`, { timeout: 30000 });
464
+ } catch (e) { log.warn(`[qemu-docker] push cicy-code-update.sh failed: ${e.message}`); }
465
+ const regEnv = registry ? `-e NPM_REGISTRY=${registry} ` : "";
466
+ try { await vmRunStream(`docker exec ${regEnv}${container} bash -lc "/usr/local/bin/cicy-code-update.sh${latest ? " " + latest : ""}"`, { emit, phase: "image", timeout: 300000 }); }
467
+ catch (e) { emit({ phase: "done", status: "error", message: t("docker.updating.failed", { msg: e.message }) }); return { ok: false, reason: "update_failed" }; }
468
+ const healthy = await docker.waitUntil(() => probeHealth(port), { totalMs: 120000, everyMs: 3000 });
469
+ emit({ phase: "done", status: healthy ? "done" : "error", message: healthy ? t("docker.updating.doneLatest") : t("docker.updating.notReady") });
470
+ return { ok: healthy, version: latest || null };
471
+ }
472
+
473
+ // 重建:最新镜像 + 新 env 重新 docker run(卷保留,数据/token 不丢)。保护开启且非用户显式 → 只确保在跑。
474
+ async function recreate({ onProgress, port, container, volume, env = {}, extraPorts = [], dockerSock = false, force = false } = {}) {
475
+ const emit = (ev) => { try { onProgress && onProgress(ev); } catch {} };
476
+ const v = await ensureVm({ emit });
477
+ if (!v.ok) { emit({ phase: "done", status: "error", message: v.message }); return { ok: false, reason: v.reason }; }
478
+ if (!protect.guard(log, `recreate ${container}`, { force })) {
479
+ emit({ phase: "container", status: "running", message: "容器保护已开启:跳过重建,仅确保容器在运行" });
480
+ const r = await startExisting({ container, port, emit });
481
+ if (r.missing) { await runContainer({ port, container, volume, env, extraPorts, dockerSock, emit }); }
482
+ const healthy = await docker.waitUntil(() => probeHealth(port), { totalMs: 120000, everyMs: 3000 });
483
+ emit({ phase: "done", status: healthy ? "done" : "error", message: healthy ? t("docker.ready") : t("docker.updating.notReady") });
484
+ return { ok: healthy, blocked: true };
485
+ }
486
+ try { await ensureFreshImage({ emit }); } catch (e) { emit({ phase: "image", status: "running", message: `镜像刷新跳过(${e.message}),用现有镜像重建` }); }
487
+ try { await vmRun(`docker ps -aq --filter publish=${port} | xargs -r docker rm -f; docker rm -f ${container}; true`, { timeout: 45000 }); } catch {}
488
+ const r = await runContainer({ port, container, volume, env, extraPorts, dockerSock, emit, force: true });
489
+ emit({ phase: "container", status: "running", message: t("docker.updating.starting") });
490
+ const healthy = await docker.waitUntil(() => probeHealth(port), { totalMs: 180000, everyMs: 3000 });
491
+ emit({ phase: "done", status: healthy ? "done" : "error", message: healthy ? t("docker.ready") : t("docker.updating.notReady") });
492
+ return { ...r, ok: healthy };
493
+ }
494
+ // 「升级」:WSL 版是重装发行版;这里虚拟机和首页 Debian 共用,不能删 —— 等同于用最新镜像强制重建。
495
+ async function upgrade(opts = {}) { return recreate({ ...opts, force: true }); }
496
+
497
+ async function hasGatewayKey(container) {
498
+ try { const { stdout } = await vmRun(`docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' ${container}`, { timeout: 12000 }); return gatewayKeyPresentInEnv(stdout); }
499
+ catch (e) { log.warn(`[qemu-docker] hasGatewayKey check failed: ${e.message}`); return null; }
500
+ }
501
+ async function readMihomoConfig(container) {
502
+ return String((await vmRun(`docker exec ${container} cat /home/cicy/cicy-ai/db/mihomo.yaml`, { timeout: 15000 })).stdout || "");
503
+ }
504
+ function parseMihomoSelections(payload) {
505
+ let parsed; try { parsed = JSON.parse(String(payload || "")); } catch { throw new Error("invalid mihomo controller response"); }
506
+ if (!parsed || !parsed.proxies || typeof parsed.proxies !== "object") throw new Error("invalid mihomo controller response: proxies missing");
507
+ const out = {};
508
+ for (const [name, p] of Object.entries(parsed.proxies)) if (p && p.type === "Selector" && typeof p.now === "string" && p.now) out[name] = p.now;
509
+ return out;
510
+ }
511
+ async function readMihomoSelections(container) {
512
+ return parseMihomoSelections((await vmRun(`docker exec ${container} curl -fsS http://127.0.0.1:19001/proxies`, { timeout: 15000 })).stdout);
513
+ }
514
+
515
+ module.exports = {
516
+ backend: "qemu",
517
+ bootstrap, status, restart, stop, dockerRestart, recreate, update, upgrade, runContainer, readContainerToken,
518
+ insideHealthy, probeHealth, ensureAutostart, startExisting, containerState, hasGatewayKey,
519
+ readMihomoConfig, readMihomoSelections, parseMihomoSelections,
520
+ vmRun, ensureVm, ensureForwards, vmStatus, dockerEngineUp, imagePresent, loadImage,
521
+ };