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.
@@ -7,6 +7,7 @@ import "./App.css";
7
7
  import { TERMS_VERSION, termsForDisplay } from "./termsText";
8
8
  import { mdToHtml } from "./mdLite";
9
9
  import { buildCustomTeamEditPatch } from "./custom-team-edit";
10
+ import DEB_CTL_SCRIPT from "../../../src/sidecar/qemu-vm.cjs?raw"; // 和 desktop 的 QEMU 版 Docker 共用同一份
10
11
 
11
12
  // i18n bridge exposed by homepage-preload (window.cicyI18n.t, locale from
12
13
  // app.getLocale()). Returns the localized string, or `fallback` when the key
@@ -1178,7 +1179,7 @@ export default function App() {
1178
1179
  const hubCount = hub.instances ? hub.instances.length : 0;
1179
1180
  // 「全部」只放真能打开的 dsh;DSH tab 里才把未就绪的一并列出来看状态。
1180
1181
  const dshReady = (dshFleet.list || []).filter((m) => m.ready);
1181
- const showDshAll = tab === "all" || tab === "dsh";
1182
+ const showDshAll = (tab === "all" || tab === "dsh") && !!hub.loggedIn; // 未登录不展示车队机器
1182
1183
  // 首次打开:本地或云端团队任一还没拉到(为 null)→ grid 显示 skeleton 占位卡,直到两边都
1183
1184
  // resolve(出错也 resolve 成 []),再显示真实内容 —— 避免一个先回来另一个还空的露馅。
1184
1185
  const firstLoading = localTeams === null || teams === null;
@@ -1335,6 +1336,7 @@ export default function App() {
1335
1336
  )}
1336
1337
  {/* 首页第二块:DeepSeek Harness(开源 Agent 框架),homepage 自装自启,和 cicy-code 卡并列 */}
1337
1338
  {!firstLoading && showLocal && <DshCard />}
1339
+ {!firstLoading && showLocal && <DebianCard />}
1338
1340
  {!firstLoading && showCustom && customList.map((t) => (
1339
1341
  <LocalTeamCard key={"custom:" + t.id} team={t} cloudCode={cloudCodeFor(t.cloud_team_id)} onOpen={() => openLocalTeam(t.id)} onRename={renameLocalTeam} onRefresh={fetchLocalTeams} />
1340
1342
  ))}
@@ -1511,7 +1513,11 @@ function useFleetSocket() {
1511
1513
  try { out.auto = await capped(window.cicy?.app?.getAutoUpdate?.(), null); } catch {}
1512
1514
  // Only cache a complete identity; a partial one (some step timed out) is
1513
1515
  // re-read on the next connect so the hello self-corrects once main is idle.
1514
- if (out.host && out.v) ident = out;
1516
+ // A token-less identity is never cached either: a fresh install hellos before the
1517
+ // user logs in to the hub, and caching that would keep every later hello token-less
1518
+ // → the hub never marks the machine authed → no dsh config / self-rpc for it
1519
+ // (seen on a new machine 2026-09-25). Re-read until the token shows up.
1520
+ if (out.host && out.v && out.tok) ident = out;
1515
1521
  return out;
1516
1522
  };
1517
1523
 
@@ -3144,7 +3150,7 @@ function DockerInstallDrawerHost() {
3144
3150
  const phaseIdx = DOCKER_PHASES.findIndex(([k]) => k === st.phase);
3145
3151
  const dlBars = isOpen ? [] : ["install-docker", "image"].filter((k) => st.bars?.[k]);
3146
3152
  const drawerTitle = st.title || (isOpen ? tr("docker.openTitle", "打开 Docker 团队") : tr("docker.setupTitle", "安装 Docker cicy-code"));
3147
- const drawerSub = st.sub || "127.0.0.1:8008";
3153
+ const drawerSub = st.sub || `127.0.0.1:${dockerAppPort}`;
3148
3154
  // Minimized → a floating restore chip (op keeps running in the background).
3149
3155
  if (st.minimized) {
3150
3156
  const pcts = dlBars.map((k) => st.bars[k]?.progress).filter(Number.isFinite);
@@ -3263,7 +3269,13 @@ function useDshFleet() {
3263
3269
  const refresh = useCallback(async () => {
3264
3270
  setBusy(true);
3265
3271
  try {
3266
- const r = await fetch(DSH_FLEET_API, { cache: "no-store" });
3272
+ // 车队清单是登录用户名下的资产:必须带本机 hubAuth token,hub 侧按 token 校验。
3273
+ // 没登录(全新安装)就没有 token → 什么都不列,别把车队里别的机器摆到陌生人首页上。
3274
+ let tok = "";
3275
+ try { tok = String(await mainEval(READ_TOKEN)).trim(); } catch {}
3276
+ if (!tok) { setList([]); return; }
3277
+ const r = await fetch(DSH_FLEET_API, { cache: "no-store", headers: { Authorization: "Bearer " + tok } });
3278
+ if (!r.ok) { setList([]); return; }
3267
3279
  const j = await r.json();
3268
3280
  setList(Array.isArray(j && j.machines) ? j.machines : []);
3269
3281
  } catch { setList([]); }
@@ -3446,6 +3458,42 @@ const dbDir = path.join(home, "cicy-ai", "db"), logDir = path.join(home, "cicy-a
3446
3458
  for (const d of [dbDir, logDir]) { try { fs.mkdirSync(d, { recursive: true }); } catch {} }
3447
3459
  const logFile = path.join(logDir, "dsh.log"), errFile = path.join(logDir, "dsh.err.log"), pidFile = path.join(dbDir, "dsh.pid"), rootCache = path.join(dbDir, "dsh-npm-root.txt");
3448
3460
  const lockFile = path.join(dbDir, "dsh-install.lock"), okMarker = path.join(dbDir, "dsh-ok-" + VER + ".txt");
3461
+ // dsh@0.1.2-rc.1 是 developer preview:安装器只钉 dsh 版本,传递依赖(cordis 栈)浮动一升级就崩
3462
+ // ("user patch-layer watching requires the Cordis HMR service")。运行时 cordis 栈从 dsh 包自己的
3463
+ // nested node_modules 解析(~/.dsh/profiles 自举目录是空的),所以装完把这 8 个钉回已知良好版本。
3464
+ const KNOWN_GOOD = { "@deepseek-ai/cordis": "4.0.2", "@deepseek-ai/cordis-plugin-hmr": "1.0.17", "@deepseek-ai/cordis-plugin-loader": "1.0.3", "@deepseek-ai/cordis-plugin-include": "1.0.7", "@deepseek-ai/cordis-plugin-timer": "1.1.4", "@deepseek-ai/cordis-plugin-group": "1.0.2", "@deepseek-ai/cosmokit": "1.8.3", "@deepseek-ai/schemastery": "3.18.2" };
3465
+ const KG_HASH = require("crypto").createHash("sha1").update(JSON.stringify(Object.keys(KNOWN_GOOD).sort().map((k) => [k, KNOWN_GOOD[k]]))).digest("hex").slice(0, 8);
3466
+ const pinMarker = path.join(dbDir, "dsh-pins-" + VER + "-" + KG_HASH + ".txt");
3467
+ // 读版本要去 BOM:JSON.parse 遇 BOM 会抛
3468
+ function nestedVer(dir, name) { try { return JSON.parse(fs.readFileSync(path.join(dir, "node_modules", ...name.split("/"), "package.json"), "utf8").replace(/^\uFEFF/, "")).version; } catch { return null; } }
3469
+ function depsOk(dir) { for (const n of Object.keys(KNOWN_GOOD)) if (nestedVer(dir, n) !== KNOWN_GOOD[n]) return false; return true; }
3470
+ // 钉法:不能在 dsh 包目录直接 npm i —— npm 会解析整棵 dsh 依赖树,里面的
3471
+ // @deepseek-ai/dsh-experimental-agent-team@^0.1.2-rc.1 在 registry 上不存在,直接 ETARGET 退出。
3472
+ // 改在临时 scratch 目录 npm i 这 8 个钉死版本,再把 8 个包目录整目录覆盖进 dsh/node_modules/@deepseek-ai
3473
+ // (旧目录改名 .bak,失败则还原)。必须 8 个一起(只钉 7 个、hmr 留新版会 hmr.registerConfig is not a function)。
3474
+ function pinDeps(dir, reg) {
3475
+ if (fs.existsSync(pinMarker) && depsOk(dir)) return { ok: true, skipped: true };
3476
+ if (depsOk(dir)) { try { fs.writeFileSync(pinMarker, String(Date.now())); } catch {} return { ok: true, skipped: true }; }
3477
+ const scratch = path.join(os.tmpdir(), "dsh-pin-scratch");
3478
+ try { fs.rmSync(scratch, { recursive: true, force: true }); fs.mkdirSync(scratch, { recursive: true }); fs.writeFileSync(path.join(scratch, "package.json"), JSON.stringify({ name: "dsh-pin-scratch", version: "0.0.0", private: true })); } catch (e) { return { ok: false, error: "pin_scratch_failed", tail: String(e.message).slice(-200) }; }
3479
+ const pins = Object.keys(KNOWN_GOOD).map((n) => n + "@" + KNOWN_GOOD[n]).join(" ");
3480
+ try { run(q(NPM) + " i --no-audit --no-fund --no-progress --loglevel=error --registry=" + reg + " " + pins, { cwd: scratch, timeout: 8 * 60 * 1000 }); }
3481
+ catch (e) { try { fs.rmSync(scratch, { recursive: true, force: true }); } catch {} return { ok: false, error: "pin_install_failed", tail: String((e.stderr || "") + (e.stdout || "") + (e.message || "")).trim().slice(-300) }; }
3482
+ const dstScope = path.join(dir, "node_modules", "@deepseek-ai"), srcScope = path.join(scratch, "node_modules", "@deepseek-ai");
3483
+ const failed = [];
3484
+ for (const n of Object.keys(KNOWN_GOOD)) {
3485
+ const short = n.slice("@deepseek-ai/".length), src = path.join(srcScope, short), dst = path.join(dstScope, short);
3486
+ if (!fs.existsSync(src)) { failed.push(short + ":no-src"); continue; }
3487
+ try { if (fs.existsSync(dst)) { try { fs.rmSync(dst + ".bak", { recursive: true, force: true }); } catch {} fs.renameSync(dst, dst + ".bak"); } fs.cpSync(src, dst, { recursive: true }); }
3488
+ catch (e) { failed.push(short + ":" + String(e.message).slice(0, 40)); try { if (!fs.existsSync(dst) && fs.existsSync(dst + ".bak")) fs.renameSync(dst + ".bak", dst); } catch {} }
3489
+ }
3490
+ try { fs.rmSync(scratch, { recursive: true, force: true }); } catch {}
3491
+ if (failed.length) return { ok: false, error: "pin_copy_failed", tail: failed.join("; ") };
3492
+ if (!depsOk(dir)) return { ok: false, error: "pin_mismatch", tail: JSON.stringify(Object.fromEntries(Object.keys(KNOWN_GOOD).map((n) => [n, nestedVer(dir, n)]))).slice(0, 300) };
3493
+ for (const n of Object.keys(KNOWN_GOOD)) { try { fs.rmSync(path.join(dstScope, n.slice("@deepseek-ai/".length) + ".bak"), { recursive: true, force: true }); } catch {} }
3494
+ try { fs.writeFileSync(pinMarker, String(Date.now())); } catch {}
3495
+ return { ok: true };
3496
+ }
3449
3497
  // 车队级启动环境(hub 下发,DshCard 写的 dsh-env.json):并进 dsh 子进程 env。启动环境在 dsh 里优先级最高且只读。
3450
3498
  const envFile = path.join(dbDir, "dsh-env.json"), envApplied = path.join(dbDir, "dsh-env.applied");
3451
3499
  function fleetEnv() { try { const j = JSON.parse(fs.readFileSync(envFile, "utf8")); const src = (j && j.env) || {}; const out = {}; for (const [k, v] of Object.entries(src)) { if (/^[A-Z][A-Z0-9_]*$/.test(k) && typeof v === "string" && v) out[k] = v; } return out; } catch { return {}; } }
@@ -3536,7 +3584,9 @@ function installing() { return pidAlive(readPidFile(lockFile)); }
3536
3584
  async function status() {
3537
3585
  const p = pkgInfo(); const pid = readPidFile(pidFile); const pr = await probe();
3538
3586
  const eh = envHash(), ea = appliedHash(), wm = wsMissing();
3539
- return { ok: true, installed: !!p && !p.broken, broken: !!(p && p.broken), version: p ? p.version : null, installing: installing(), running: pr.dsh, occupied: pr.up && !pr.dsh, pid, pidAlive: pidAlive(pid), hasToken: !!lastToken(), node: process.version, logFile, envKeys: Object.keys(fleetEnv()), envHash: eh, envStale: pr.dsh && ea !== eh, wsMissing: wm, wsStale: pr.dsh && wm.length > 0 };
3587
+ // 装好但传递依赖没钉/浮动了 → 报出来让卡片显示,别无声"启动中"(2026-09-25:rc.1 cordis 栈浮动崩)
3588
+ const depBad = (p && !p.broken && !fs.existsSync(pinMarker) && !depsOk(p.dir)) ? "cordis-stack-mismatch" : null;
3589
+ return { ok: true, installed: !!p && !p.broken, broken: !!(p && p.broken), depError: depBad, version: p ? p.version : null, installing: installing(), running: pr.dsh, occupied: pr.up && !pr.dsh, pid, pidAlive: pidAlive(pid), hasToken: !!lastToken(), node: process.version, logFile, envKeys: Object.keys(fleetEnv()), envHash: eh, envStale: pr.dsh && ea !== eh, wsMissing: wm, wsStale: pr.dsh && wm.length > 0 };
3540
3590
  }
3541
3591
  async function install() {
3542
3592
  // 安装锁:首页刷新/多开会再次触发安装,两个 npm -g 同时写全局目录会把包写坏。有锁就等它完。
@@ -3563,7 +3613,9 @@ async function install() {
3563
3613
  try { fs.writeFileSync(rootCache, root); } catch {}
3564
3614
  const p = pkgAt(root);
3565
3615
  if (!p || p.broken) return { ok: false, error: "install_incomplete", tail: (outp || "").slice(-300) };
3566
- return { ok: true, version: p.version, registry: used, speed: regs.speed, secs: Math.round((Date.now() - t0) / 1000) };
3616
+ const pin = pinDeps(p.dir, savedRegistry() || used || "https://registry.npmjs.org");
3617
+ if (!pin.ok) return { ok: false, error: pin.error, tail: pin.tail };
3618
+ return { ok: true, version: p.version, registry: used, speed: regs.speed, secs: Math.round((Date.now() - t0) / 1000), pinned: !pin.skipped };
3567
3619
  } finally { try { fs.unlinkSync(lockFile); } catch {} }
3568
3620
  }
3569
3621
  async function start() {
@@ -3585,11 +3637,17 @@ async function start() {
3585
3637
  const cmd = "$p = Start-Process -FilePath " + q(process.execPath)
3586
3638
  + " -ArgumentList @(" + [p.bin, "web", "--no-open", "--port", String(PORT)].map(q).join(",") + ")"
3587
3639
  + " -WindowStyle Hidden -RedirectStandardOutput " + q(logFile) + " -RedirectStandardError " + q(errFile)
3588
- + " -WorkingDirectory " + q(home) + " -PassThru; $p.Id";
3640
+ + " -WorkingDirectory " + q(home) + " -PassThru";
3641
+ // PID / 报错走文件,powershell 的 stdio 全给 ignore:Start-Process 带重定向时用继承句柄的
3642
+ // CreateProcess,dsh 会顺手继承 powershell 的 stdout —— 若那是管道,spawnSync 要等 dsh
3643
+ // 退出才 EOF,于是每次「启动」都卡满 60s 超时才返回(实测假进程:管道 25.3s vs 文件 0.25s)。
3644
+ const spawnPid = path.join(dbDir, "dsh-spawn.pid"), spawnErr = path.join(dbDir, "dsh-spawn.err");
3645
+ for (const f of [spawnPid, spawnErr]) { try { fs.unlinkSync(f); } catch {} }
3646
+ const ps = "try { " + cmd + "; Set-Content -Path " + q(spawnPid) + " -Value $p.Id } catch { Set-Content -Path " + q(spawnErr) + " -Value ($_ | Out-String) }";
3589
3647
  try { fs.writeFileSync(logFile, ""); fs.writeFileSync(errFile, ""); } catch {}
3590
- const r = cp.spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", cmd], { encoding: "utf8", timeout: 60000, windowsHide: true, env });
3591
- pid = Number(String((r && r.stdout) || "").match(/\d+/) || 0);
3592
- if (!pid) return { ok: false, error: "start_failed", tail: String((r && r.stderr) || "").slice(-800) };
3648
+ cp.spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", ps], { stdio: "ignore", timeout: 60000, windowsHide: true, env });
3649
+ pid = readPidFile(spawnPid);
3650
+ if (!pid) { let err = ""; try { err = fs.readFileSync(spawnErr, "utf8"); } catch {} return { ok: false, error: "start_failed", tail: err.slice(-800) }; }
3593
3651
  } else {
3594
3652
  const fd = fs.openSync(logFile, "w");
3595
3653
  const child = cp.spawn(process.execPath, [p.bin, "web", "--no-open", "--port", String(PORT)], { detached: true, windowsHide: true, stdio: ["ignore", fd, fd], cwd: home, env });
@@ -3907,6 +3965,7 @@ function DshCard() {
3907
3965
  const stateText = !st ? ""
3908
3966
  : st.error ? tr("dsh.stateNoNode", "未就绪:{{why}}", { why: st.error === "no_node" ? tr("dsh.noNodeShort", "没有 Node.js(点「安装」自动装便携版)") : st.error })
3909
3967
  : st.installing ? tr("dsh.stateInstalling", "另一个安装进程进行中…")
3968
+ : st.depError ? tr("dsh.stateDepBad", "依赖损坏(cordis 栈),点「安装」修复")
3910
3969
  : running ? tr("dsh.stateRunning", "运行中")
3911
3970
  : st.broken ? tr("dsh.stateBroken", "安装不完整(点「安装」修复)")
3912
3971
  : installed ? tr("dsh.stateStopped", "已安装,未运行")
@@ -3975,6 +4034,290 @@ function DshCard() {
3975
4034
  );
3976
4035
  }
3977
4036
 
4037
+ // ---------------------------------------------------------------------------
4038
+ // Debian card —— 不靠 WSL 的开箱 Linux:QEMU 便携版 + Debian 13 官方云镜像,网页终端(ttyd)开成 tab。
4039
+ // 和 DshCard 同构:全部只靠 homepage(exec_shell 跑 debian-ctl.js,脚本见 ./debian-ctl.cjs),
4040
+ // 不改 desktop。有 WHPX 就硬件加速,没有退回软件模拟(慢但免管理员);「开启硬件加速」弹 UAC + 重启。
4041
+ // 不自动安装(首次要下 ~390MB);装过之后开机自动启动(可在菜单关)。
4042
+ const DEB_RED = "#d70a53";
4043
+ const DEB_AUTO_KEY = "debian.auto"; // localStorage:"off" = 关闭开机自启
4044
+ const DEB_PHASE = { qemu: "install-docker", extract: "install-docker", image: "image", verify: "image", disk: "image" };
4045
+ const DEB_PHASE_MSG = {
4046
+ qemu: "下载 QEMU 便携版(约 47MB)…", extract: "解压 QEMU…",
4047
+ image: "下载 Debian 13 云镜像(约 343MB)…", verify: "校验镜像 SHA512…", disk: "创建虚拟磁盘(40G,按需增长)…",
4048
+ };
4049
+ const DEB_ERR = {
4050
+ qemu_download: "QEMU 下载失败(网络)", qemu_checksum: "QEMU 包校验失败", qemu_extract: "QEMU 解压失败",
4051
+ image_download: "Debian 镜像下载失败(网络)", image_checksum: "Debian 镜像校验失败", disk_create: "创建虚拟磁盘失败",
4052
+ port_in_use: "端口被占用", not_installed: "还没安装", qemu_not_started: "QEMU 没能启动", spawn_failed: "后台进程起不来",
4053
+ };
4054
+ const debErr = (r) => [DEB_ERR[r?.error] || r?.error || "failed", r?.detail].filter(Boolean).join(":");
4055
+ const debSleep = (ms) => new Promise((r) => setTimeout(r, ms));
4056
+ let _debWritten = false;
4057
+ async function debCtl(op, opts = {}) {
4058
+ const env = await dshEnv(opts);
4059
+ if (!env.node) return { ok: false, error: "no_node", detail: env.error || "" };
4060
+ const ctl = `${env.home}\\cicy-ai\\db\\debian-ctl.js`;
4061
+ if (!_debWritten) {
4062
+ const w = parseRpcText(await window.electronRPC?.("file_write", { path: ctl, content: DEB_CTL_SCRIPT }));
4063
+ if (!w.ok) return { ok: false, error: "ctl_write_failed", detail: w.stderr || w.stdout };
4064
+ _debWritten = true;
4065
+ }
4066
+ const r = await dshShell(`"${env.node}" "${ctl}" ${op}`);
4067
+ const m = String(r.stdout || "").trim().match(/\{[\s\S]*\}$/);
4068
+ if (m) { try { return JSON.parse(m[0]); } catch {} }
4069
+ return { ok: false, error: "ctl_no_output", detail: (r.stderr || r.stdout || "").trim().slice(-400) };
4070
+ }
4071
+ function DebianIcon() {
4072
+ return (
4073
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
4074
+ <path d="M14.5 19.5c-4.4 0-8-3.4-8-7.6 0-4 3.2-7.4 7.3-7.4 3.6 0 6.2 2.6 6.2 5.8 0 2.7-2 4.7-4.5 4.7-2 0-3.5-1.4-3.5-3.2 0-1.5 1.1-2.6 2.5-2.6" />
4075
+ </svg>
4076
+ );
4077
+ }
4078
+
4079
+ function DebianCard() {
4080
+ const [st, setSt] = useState(null);
4081
+ const [busy, setBusy] = useState(""); // "" | install | start | stop | open | accel
4082
+ const [menuOpen, setMenuOpen] = useState(false);
4083
+ const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
4084
+ const [auto, setAuto] = useState(() => { try { return localStorage.getItem(DEB_AUTO_KEY) !== "off"; } catch { return true; } });
4085
+ const kebabRef = useRef(null);
4086
+ const menuRef = useRef(null);
4087
+ const autoRan = useRef(false);
4088
+ const busyRef = useRef("");
4089
+ useEffect(() => { busyRef.current = busy; }, [busy]);
4090
+ const MENU_W = 196;
4091
+
4092
+ const refresh = useCallback(async () => {
4093
+ try { const s = await debCtl("status"); setSt(s); return s; }
4094
+ catch (e) { console.warn("[DebianCard] status", e); return null; }
4095
+ }, []);
4096
+ // 开机中(running 但终端还没起来)轮询快一点,好让「启动中」及时变「打开终端」
4097
+ const booting = !!(st?.running && !st?.ready);
4098
+ useEffect(() => {
4099
+ if (!dshIsWin()) return;
4100
+ refresh();
4101
+ const id = setInterval(() => { if (!busyRef.current) refresh(); }, booting ? 5000 : 20000);
4102
+ return () => clearInterval(id);
4103
+ }, [refresh, booting]);
4104
+
4105
+ // 等虚拟机里的网页终端起来:首次开机 cloud-init 要建用户/装 ttyd;软件模拟下要几分钟。
4106
+ const waitReady = async (push) => {
4107
+ let lastBoot = "";
4108
+ for (let i = 0; i < 180; i++) { // 180 × 5s = 15 分钟
4109
+ const s = await debCtl("status");
4110
+ setSt(s);
4111
+ if (s.ready) return s;
4112
+ if (!s.running) return { ...s, error: "vm_exited" };
4113
+ if (push && s.boot && s.boot !== lastBoot) { lastBoot = s.boot; push("container", s.boot); }
4114
+ await debSleep(5000);
4115
+ }
4116
+ return { error: "boot_timeout" };
4117
+ };
4118
+
4119
+ const openTerm = async () => {
4120
+ const r = await debCtl("open");
4121
+ if (!r.ok) { toast.show({ id: "deb-open", status: "error", ttl: 6000, message: tr("debian.notReady", "Debian 还没启动好") }); return false; }
4122
+ await window.cicy?.tabs?.open?.(r.url, "Debian", "", true);
4123
+ return true;
4124
+ };
4125
+
4126
+ // 安装 + 启动 + 打开终端,全程走 Docker 同款抽屉。
4127
+ const runSetup = useCallback(async () => {
4128
+ if (busyRef.current) return;
4129
+ setBusy("install");
4130
+ dockerDrawer.open({ kind: "debian", title: tr("debian.setupTitle", "安装 Debian 13"), sub: tr("debian.setupSub", "QEMU 虚拟机 · 不需要 WSL"), onRetry: () => runSetup() });
4131
+ const push = (phase, message, status) => dockerDrawer.push({ phase, message, status: status || "running" });
4132
+ try {
4133
+ push("install-docker", tr("debian.checkNode", "检查 Node.js…"));
4134
+ let env = await dshEnv();
4135
+ if (!env.node) {
4136
+ push("install-docker", tr("dsh.installingNode", "这台机器没有 Node.js,下载便携版 {{v}}(约 37MB,免管理员)…", { v: DSH_NODE_VERSION }));
4137
+ env = await dshEnv({ installNode: true });
4138
+ if (!env.node) { dockerDrawer.finish({ ok: false, message: tr("dsh.nodeFailed", "Node.js 便携版安装失败:{{why}}", { why: env.error || "download" }) }); return; }
4139
+ }
4140
+ let s = await debCtl("status");
4141
+ if (!s.ok) { dockerDrawer.finish({ ok: false, message: tr("dsh.ctlFailed", "控制脚本无法运行:{{why}}", { why: debErr(s) }) }); return; }
4142
+ if (!s.installed) {
4143
+ const r = await debCtl("install");
4144
+ if (!r.ok) { dockerDrawer.finish({ ok: false, message: debErr(r) }); return; }
4145
+ let lastPhase = "";
4146
+ for (;;) {
4147
+ await debSleep(2000);
4148
+ s = await debCtl("status");
4149
+ if (s.installed && !s.installing) break;
4150
+ if (!s.installing) { dockerDrawer.finish({ ok: false, message: tr("debian.installFailed", "安装失败:{{why}}", { why: s.installError || "stopped" }) }); return; }
4151
+ const ph = DEB_PHASE[s.phase] || "install-docker";
4152
+ if (s.phase !== lastPhase) { lastPhase = s.phase; push(ph, tr(`debian.phase.${s.phase}`, DEB_PHASE_MSG[s.phase] || s.phase)); }
4153
+ if ((s.phase === "qemu" || s.phase === "image") && s.total) {
4154
+ dockerDrawer.push({ phase: ph, 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" });
4155
+ }
4156
+ }
4157
+ push("image", tr("debian.installed", "QEMU + Debian 13 就绪"), "done");
4158
+ }
4159
+ push("container", s.whpxFeature ? tr("debian.startingWhpx", "启动虚拟机(尝试 WHPX 硬件加速)…") : tr("debian.startingTcg", "启动虚拟机(软件模拟,首次开机要几分钟)…"));
4160
+ const r2 = await debCtl("start");
4161
+ if (!r2.ok) { dockerDrawer.finish({ ok: false, message: tr("debian.startFailed", "启动失败:{{why}}", { why: debErr(r2) + (r2.tail ? " · " + String(r2.tail).trim().split(/\r?\n/).slice(-2).join(" · ") : "") }) }); return; }
4162
+ const s2 = await waitReady(push);
4163
+ if (!s2.ready) { dockerDrawer.finish({ ok: false, message: s2.error === "boot_timeout" ? tr("debian.bootTimeout", "15 分钟内终端没起来,看 ~/cicy-ai/logs/debian-console.log") : tr("debian.vmExited", "虚拟机退出了,看 ~/cicy-ai/logs/debian-qemu.log") }); return; }
4164
+ push("container", s2.accel === "whpx" ? tr("debian.accelWhpx", "硬件加速:WHPX") : tr("debian.accelTcg", "软件模拟(较慢)—— 菜单里可「开启硬件加速」"), "done");
4165
+ dockerDrawer.finish({ ok: true, message: tr("debian.ready", "Debian 已就绪,正在打开终端") });
4166
+ await openTerm();
4167
+ } catch (e) { dockerDrawer.finish({ ok: false, message: e.message }); }
4168
+ finally { setBusy(""); refresh(); }
4169
+ }, [refresh]);
4170
+
4171
+ const runOp = async (kind, fn, okMsg) => {
4172
+ if (busyRef.current) return;
4173
+ setMenuOpen(false); setBusy(kind);
4174
+ try {
4175
+ const r = await fn();
4176
+ if (r && r.ok === false) toast.show({ id: "deb-op", status: "error", ttl: 6000, message: debErr(r) });
4177
+ else if (okMsg) toast.show({ id: "deb-op", status: "ok", ttl: 3000, message: okMsg });
4178
+ } catch (e) { toast.show({ id: "deb-op", status: "error", ttl: 6000, message: e.message }); }
4179
+ finally { setBusy(""); refresh(); }
4180
+ };
4181
+ const startSilent = () => runOp("start", () => debCtl("start"));
4182
+ const openUi = () => runOp("open", async () => { await openTerm(); });
4183
+ // 新机器(QEMU 后端)上 Docker 版 cicy-code 也跑在这台虚拟机里:关机/重启会把它一起停,先问一句。
4184
+ const confirmVmDown = async () => {
4185
+ let s = null; try { s = await window.cicy?.docker?.appStatus?.(); } catch {}
4186
+ if (!(s && s.running && Number(s.port) === 8009)) return true;
4187
+ return window.confirm(tr("debian.vmDownConfirm", "Docker 版 cicy-code(:8009)也跑在这台 Debian 虚拟机里,关机/重启会让它一起停(重启后自动恢复)。继续?"));
4188
+ };
4189
+ const enableAccel = () => {
4190
+ if (!window.confirm(tr("debian.accelConfirm", "开启「Windows 虚拟机监控程序平台」(WHPX)需要管理员权限,开启后要重启电脑才生效。继续?"))) return;
4191
+ runOp("accel", async () => {
4192
+ const r = await debCtl("accel");
4193
+ if (r.ok) toast.show({ id: "deb-op", status: "ok", ttl: 10000, message: tr("debian.accelDone", "已开启,重启电脑后 Debian 自动用硬件加速") });
4194
+ return r.ok ? null : r;
4195
+ });
4196
+ };
4197
+
4198
+ // 开机自启:装过的才自启;首页每次加载只跑一次。
4199
+ useEffect(() => {
4200
+ if (autoRan.current || !st) return;
4201
+ autoRan.current = true;
4202
+ if (auto && st.ok && st.installed && !st.running && !st.installing) startSilent();
4203
+ }, [st, auto]); // eslint-disable-line react-hooks/exhaustive-deps
4204
+
4205
+ const toggleAuto = () => {
4206
+ const next = !auto; setAuto(next); setMenuOpen(false);
4207
+ try { if (next) localStorage.removeItem(DEB_AUTO_KEY); else localStorage.setItem(DEB_AUTO_KEY, "off"); } catch {}
4208
+ };
4209
+ const toggleMenu = (e) => {
4210
+ e.stopPropagation();
4211
+ if (!menuOpen && kebabRef.current) {
4212
+ const r = kebabRef.current.getBoundingClientRect();
4213
+ const left = Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8));
4214
+ setMenuPos({ top: Math.round(r.bottom + 4), left: Math.round(left) });
4215
+ }
4216
+ setMenuOpen((v) => !v);
4217
+ };
4218
+ useEffect(() => {
4219
+ if (!menuOpen) return;
4220
+ const onDoc = (e) => { if (kebabRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return; setMenuOpen(false); };
4221
+ const onKey = (e) => { if (e.key === "Escape") setMenuOpen(false); };
4222
+ document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onKey);
4223
+ return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
4224
+ }, [menuOpen]);
4225
+
4226
+ if (typeof window !== "undefined" && !window.electronRPC) return null;
4227
+ if (!dshIsWin()) return null; // QEMU 便携包目前只打了 Windows x64
4228
+
4229
+ const running = !!st?.running, ready = !!st?.ready, installed = !!st?.installed;
4230
+ const isBusy = !!busy;
4231
+ const tone = ready ? "ok" : installed || st?.installing ? "warn" : "off";
4232
+ const pct = st?.installing && st?.total ? Math.min(99, Math.round(st.bytes * 100 / st.total)) : null;
4233
+ const ctaLabel = busy === "open" ? tr("docker.opening", "打开中…")
4234
+ : busy === "install" || st?.installing ? tr("dsh.installingShort", "安装中…")
4235
+ : busy === "start" || booting ? tr("dsh.startingShort", "启动中…")
4236
+ : isBusy ? tr("docker.working", "处理中…")
4237
+ : !st ? tr("docker.probing", "检测中…")
4238
+ : ready ? tr("debian.openTerm", "打开终端")
4239
+ : installed ? tr("docker.start", "启动")
4240
+ : tr("dsh.install", "安装");
4241
+ const onCta = () => {
4242
+ if (isBusy || !st || booting) return;
4243
+ if (ready) return openUi();
4244
+ return runSetup(); // 未装 → 装;已装未跑 → 抽屉里启动 + 等终端
4245
+ };
4246
+ const accelText = st?.accel === "whpx" ? tr("debian.accelWhpxShort", "硬件加速") : st?.accel === "tcg" ? tr("debian.accelTcgShort", "软件模拟") : "";
4247
+ const stateText = !st ? ""
4248
+ : st.error === "no_node" ? tr("debian.stateNoNode", "未安装 · 约 390MB")
4249
+ : st.error ? tr("dsh.stateNoNode", "未就绪:{{why}}", { why: debErr(st) })
4250
+ : st.installing ? tr("debian.stateInstalling", "安装中 {{p}}", { p: pct != null ? pct + "%" : "…" })
4251
+ : ready ? tr("debian.stateReady", "运行中 · {{a}} · {{m}}G 内存", { a: accelText, m: Math.round(st.mem / 1024) })
4252
+ : running ? (st.boot || tr("debian.stateBooting", "开机中…"))
4253
+ : st.installError && !installed ? tr("debian.stateFailed", "上次安装失败:{{why}}", { why: st.installError })
4254
+ : installed ? tr("dsh.stateStopped", "已安装,未运行")
4255
+ : tr("debian.stateMissing", "未安装 · 约 390MB");
4256
+
4257
+ return (
4258
+ <div data-id="DebianCard" className={`bcard bcard--debian${ready ? " bcard--online" : ""}`}>
4259
+ <div className="bcard__accent" />
4260
+ <div className="bcard__top">
4261
+ <div className="bcard__pill" style={{ color: DEB_RED }}>
4262
+ <span className="bcard__dot" data-tone={tone} />
4263
+ <DebianIcon />
4264
+ </div>
4265
+ {installed && (
4266
+ <div className="bcard__menuwrap" onClick={(e) => e.stopPropagation()}>
4267
+ <button type="button" ref={kebabRef} data-id="DebianCard-menu-btn" className="bcard__kebab" title={tr("debian.manage", "管理 Debian")} disabled={isBusy} onClick={toggleMenu}>
4268
+ {isBusy ? <Spinner /> : <KebabIcon />}
4269
+ </button>
4270
+ {menuOpen && createPortal(
4271
+ <div className="bcard__menu bcard__menu--portal" data-id="DebianCard-menu" role="menu" ref={menuRef}
4272
+ style={{ position: "fixed", top: menuPos.top, left: menuPos.left, width: MENU_W }}
4273
+ onClick={(e) => e.stopPropagation()}>
4274
+ {running && (
4275
+ <button type="button" data-id="DebianCard-restart" className="bcard__menu-item is-accent"
4276
+ onClick={async () => { if (await confirmVmDown()) runOp("start", async () => { await debCtl("stop"); return debCtl("start"); }, tr("debian.restarted", "已重启,稍等终端起来")); }}>
4277
+ {tr("debian.restart", "重启虚拟机")}
4278
+ </button>
4279
+ )}
4280
+ {running && (
4281
+ <button type="button" data-id="DebianCard-stop" className="bcard__menu-item is-danger"
4282
+ onClick={async () => { if (await confirmVmDown()) runOp("stop", () => debCtl("stop"), tr("debian.stopped", "已关机")); }}>
4283
+ {tr("debian.stop", "关机")}
4284
+ </button>
4285
+ )}
4286
+ {(st?.accel === "tcg" || !st?.whpxFeature) && (
4287
+ <button type="button" data-id="DebianCard-accel" className="bcard__menu-item"
4288
+ title={tr("debian.accelHint", "开启 Windows 虚拟机监控程序平台(要管理员 + 重启)")} onClick={enableAccel}>
4289
+ {tr("debian.accel", "开启硬件加速")}
4290
+ </button>
4291
+ )}
4292
+ <div className="bcard__menu-sep" role="separator" aria-hidden />
4293
+ <button type="button" data-id="DebianCard-auto" className="bcard__menu-item" onClick={toggleAuto}>
4294
+ {auto ? tr("dsh.autoOn", "✓ 开机自动启动") : tr("dsh.autoOff", "开机自动启动:关")}
4295
+ </button>
4296
+ </div>,
4297
+ document.body
4298
+ )}
4299
+ </div>
4300
+ )}
4301
+ </div>
4302
+ <div className="bcard__body">
4303
+ <div style={{ height: 28, display: "flex", alignItems: "center", gap: 8 }}>
4304
+ <h3 className="bcard__name" title="Debian 13" style={{ margin: 0, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>Debian 13</h3>
4305
+ </div>
4306
+ <div className="bcard__meta">
4307
+ <span className="bcard__chip">Linux</span>
4308
+ <span className="bcard__ver" style={{ marginLeft: 8, fontSize: 11, opacity: 0.6 }}>QEMU</span>
4309
+ </div>
4310
+ {stateText && <div data-id="DebianCard-state" title={stateText} style={{ marginTop: 6, fontSize: 12, color: "#9aa4b2", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{stateText}</div>}
4311
+ </div>
4312
+ <button type="button" className="bcard__cta" data-id="DebianCard-cta" disabled={isBusy || !st || booting || !!st?.installing} onClick={onCta}
4313
+ style={!ready ? { background: DEB_RED, color: "white" } : undefined}>
4314
+ {isBusy || booting ? <Spinner /> : <ArrowIcon />}
4315
+ <span>{ctaLabel}</span>
4316
+ </button>
4317
+ </div>
4318
+ );
4319
+ }
4320
+
3978
4321
  // Docker-版 cicy-code card (Windows only): a SECOND cicy-code instance running
3979
4322
  // in Docker on :8008, alongside the native local daemon (:8008). If Docker
3980
4323
  // Desktop is missing, the install flow downloads its installer to the user's
@@ -4011,6 +4354,7 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
4011
4354
  const checkStatus = useCallback(async () => {
4012
4355
  try {
4013
4356
  const s = await window.cicy?.docker?.appStatus?.();
4357
+ if (s && Number(s.port)) dockerAppPort = Number(s.port);
4014
4358
  setStatus(s);
4015
4359
  // 自愈卡死的设置抽屉:容器已健康(running)说明 setup 实际已完成,但抽屉可能还停在
4016
4360
  // 「进行中」—— 网络失败重试后,follower 跟随的 bootstrap promise 迟迟不 resolve、又收
@@ -4052,7 +4396,7 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
4052
4396
  for (const raw of portList) {
4053
4397
  const s = String(raw).trim(); if (!s) continue;
4054
4398
  const p = Number(s);
4055
- if (!Number.isInteger(p) || p < 1 || p > 65535 || p === 8008 || seen.has(p)) { setPortsErr(tr("docker.ports.invalid", "有端口无效(1-65535,不能是 8008,不能重复)")); return; }
4399
+ if (!Number.isInteger(p) || p < 1 || p > 65535 || p === 8008 || p === dockerAppPort || seen.has(p)) { setPortsErr(tr("docker.ports.invalid", "有端口无效(1-65535,不能是 {{p}},不能重复)", { p: dockerAppPort })); return; }
4056
4400
  seen.add(p); out.push(p);
4057
4401
  }
4058
4402
  setPortsErr(""); setPortsBusy(true); setPortsOpen(false);
@@ -4176,7 +4520,7 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
4176
4520
  // update() 返回时 :8008 已健康(waitUntil probeHealth)。立刻:① reload 已打开的
4177
4521
  // docker tab(reloadIgnoringCache,拿新版 SPA)② 强制重探,卡片版本马上更新(不等
4178
4522
  // 60s reconcile / 缓存)。
4179
- try { window.cicy?.tabs?.reloadIfOpen?.("http://127.0.0.1:8008", tr("docker.teamTab", "Docker 团队")); } catch {}
4523
+ try { window.cicy?.tabs?.reloadIfOpen?.(dockerAppUrl(), tr("docker.teamTab", "Docker 团队")); } catch {}
4180
4524
  try { const s = await window.cicy?.docker?.appRedetect?.(); if (s) setStatus(s); } catch {}
4181
4525
  }
4182
4526
  } catch (e) {
@@ -4286,7 +4630,7 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
4286
4630
  // 打开很慢 → 先探这个 :8008 tab 开过没。开过(openedWc 里有它的
4287
4631
  // webContentsId)就**直接 active 秒切**,不再拿 token / 注册 team(那是慢的根)。
4288
4632
  try {
4289
- const r = await window.cicy?.tabs?.activateIfOpen?.("http://127.0.0.1:8008");
4633
+ const r = await window.cicy?.tabs?.activateIfOpen?.(dockerAppUrl());
4290
4634
  if (r?.active) return;
4291
4635
  } catch {}
4292
4636
  // 没开过 → 走慢路径(读容器 token 最多 ~50s)。进行中只显示 CTA 的「打开中…」spinner
@@ -4365,7 +4709,7 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
4365
4709
  </button>
4366
4710
  {/* 常用 */}
4367
4711
  <button type="button" data-id="DockerCard-reload" className="bcard__menu-item"
4368
- onClick={(e) => { e.stopPropagation(); setMenuOpen(false); window.cicy?.tabs?.reloadIfOpen?.("http://127.0.0.1:8008", "Docker 团队"); }}>
4712
+ onClick={(e) => { e.stopPropagation(); setMenuOpen(false); window.cicy?.tabs?.reloadIfOpen?.(dockerAppUrl(), "Docker 团队"); }}>
4369
4713
  {tr("docker.reloadWindow", "刷新窗口")}
4370
4714
  </button>
4371
4715
  <button type="button" data-id="DockerCard-open-dir" className="bcard__menu-item"
@@ -4488,7 +4832,7 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
4488
4832
  <h3 style={{ margin: "0 0 6px", fontSize: 16, color: "#e6edf3" }}>{tr("docker.ports.title", "端口设置")}</h3>
4489
4833
  <p style={{ margin: "0 0 14px", fontSize: 12.5, lineHeight: 1.6, color: "#9aa4b2" }}>{tr("docker.ports.sub", "除 :8008 外,额外发布、可从 Windows 直达容器内服务的端口")}</p>
4490
4834
  <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", marginBottom: 10, borderRadius: 8, background: "#0d1117", border: "1px solid #21262d", color: "#7d8590", fontSize: 13 }}>
4491
- 🔒 {tr("docker.ports.mainFixed", ":8008 · cicy-code(固定)")}
4835
+ 🔒 {tr("docker.ports.mainFixed", ":{{p}} · cicy-code(固定)", { p: dockerAppPort })}
4492
4836
  </div>
4493
4837
  <div style={{ display: "flex", flexDirection: "column", gap: 8, maxHeight: 220, overflowY: "auto" }}>
4494
4838
  {portList.length === 0 && (
@@ -5036,12 +5380,16 @@ function isLocalSidecar(baseUrl) {
5036
5380
 
5037
5381
  // The Docker-版 cicy-code instance — localhost:8008, **只在 Windows**。Owned by
5038
5382
  // <DockerCard>, so it's filtered out of the generic node lists.
5383
+ // Docker-版 cicy-code 的本机端口:老机器(WSL)8008,新机器(QEMU,不靠 WSL)8009。一台机器只会有其一;
5384
+ // 真实值以 desktop 报上来的 docker:app-status.port 为准(DockerCard 拿到后写进 dockerAppPort)。
5385
+ let dockerAppPort = 8008;
5386
+ const dockerAppUrl = () => `http://127.0.0.1:${dockerAppPort}`;
5039
5387
  function isDockerApp(baseUrl) {
5040
5388
  try {
5041
5389
  if (window.cicy?.platform !== "win32") return false; // docker-版只在 Windows
5042
5390
  const p = new URL(baseUrl);
5043
5391
  const local = p.hostname === "127.0.0.1" || p.hostname === "localhost" || p.hostname === "::1";
5044
- return local && p.port === "8008";
5392
+ return local && (p.port === "8008" || p.port === "8009");
5045
5393
  } catch { return false; }
5046
5394
  }
5047
5395