cicy-desktop 2.1.367 → 2.1.369

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.
@@ -551,7 +551,7 @@ export default function App() {
551
551
  // early return(termsOk / !token 等),把 hook 放到它们后面会让登录前后 hook 数量不一致,
552
552
  // React 直接抛 #310 "Rendered more hooks than during the previous render",整页白屏。
553
553
  // 2026-09-13 我就是这么把全车队首页打崩的。
554
- const dshFleet = useDshFleet();
554
+ // (2026-10-22 dsh 全车队下线:useDshFleet / DSH tab / DshCard 都已拿掉,别再加回来。)
555
555
  // CiCy Hub: email sign-in → every cicy-code instance of that account (no local
556
556
  // cicy-code needed). State + cards live in useHub / HubInstanceCard below.
557
557
  const hub = useHub();
@@ -1176,9 +1176,6 @@ export default function App() {
1176
1176
  const showCloud = false; // 首页只用 CiCy Hub 账号,云端团队不再展示
1177
1177
  const showHub = tab === "all" || tab === "hub";
1178
1178
  const hubCount = hub.instances ? hub.instances.length : 0;
1179
- // 「全部」只放真能打开的 dsh;DSH tab 里才把未就绪的一并列出来看状态。
1180
- const dshReady = (dshFleet.list || []).filter((m) => m.ready);
1181
- const showDshAll = tab === "all" || tab === "dsh";
1182
1179
  // 首次打开:本地或云端团队任一还没拉到(为 null)→ grid 显示 skeleton 占位卡,直到两边都
1183
1180
  // resolve(出错也 resolve 成 []),再显示真实内容 —— 避免一个先回来另一个还空的露馅。
1184
1181
  const firstLoading = localTeams === null || teams === null;
@@ -1196,11 +1193,10 @@ export default function App() {
1196
1193
  <div className="app__tabsrow">
1197
1194
  <div className="app__tabs">
1198
1195
  {[
1199
- { k: "all", label: tr("teamFilter.all", "全部"), n: localCount + customCount + hubCount + dshReady.length },
1196
+ { k: "all", label: tr("teamFilter.all", "全部"), n: localCount + customCount + hubCount },
1200
1197
  { k: "local", label: tr("teamFilter.local", "本地"), n: localCount },
1201
1198
  { k: "hub", label: tr("teamFilter.hub", "CiCy Hub"), n: hubCount },
1202
1199
  { k: "custom", label: tr("teamFilter.custom", "自定义"), n: customCount },
1203
- { k: "dsh", label: tr("teamFilter.dsh", "DSH"), n: dshReady.length },
1204
1200
  ].map(({ k, label, n }) => (
1205
1201
  <button
1206
1202
  key={k}
@@ -1213,15 +1209,6 @@ export default function App() {
1213
1209
  </button>
1214
1210
  ))}
1215
1211
  </div>
1216
- {/* DSH tab 的手动刷新:token 会随 dsh 重启而变,但不值得轮询 —— 要新的自己点。 */}
1217
- {tab === "dsh" && (
1218
- <button type="button" className="btn-ghost" data-id="DshFleetRefresh" disabled={dshFleet.busy}
1219
- title={tr("dshFleet.refresh", "刷新 dsh 中继状态")}
1220
- style={{ marginLeft: 8, width: 28, height: 28, padding: 0, display: "inline-flex", alignItems: "center", justifyContent: "center" }}
1221
- onClick={() => dshFleet.refresh()}>
1222
- {dshFleet.busy ? <Spinner /> : <RefreshIcon />}
1223
- </button>
1224
- )}
1225
1212
  {/* DesktopIdBadge 已移入用户头像下拉菜单(受信任站点上面)。此处仅留占位撑开右侧布局。 */}
1226
1213
  <span style={{ marginLeft: "auto" }} aria-hidden />
1227
1214
  {/* 行尾:新加团队 → 直接去云端团队中心添加(私有云)。自定义入口已删。 */}
@@ -1304,12 +1291,6 @@ export default function App() {
1304
1291
  {!firstLoading && showHub && hub.loggedIn && (hub.instances || []).filter((it) => it.reachable || it.online).map((it) => (
1305
1292
  <HubInstanceCard key={"hub:" + it.id} inst={it} onOpen={(next, title) => hub.open(it, next, title)} />
1306
1293
  ))}
1307
- {showDshAll && (tab === "dsh" ? (dshFleet.list || []) : dshReady).map((m) => <DshFleetCard key={"dsh:" + m.name} m={m} />)}
1308
- {tab === "dsh" && dshFleet.list !== null && dshFleet.list.length === 0 && !dshFleet.busy && (
1309
- <div className="empty" data-id="DshFleetEmpty" style={{ gridColumn: "1 / -1" }}>
1310
- {tr("dshFleet.empty", "还没有机器的 dsh 中继连上来。")}
1311
- </div>
1312
- )}
1313
1294
  {!firstLoading && showLocal && localList.map((t) => (
1314
1295
  <LocalTeamCard key={"local:" + t.id} team={t} cloudCode={cloudCodeFor(t.cloud_team_id)} onOpen={() => openLocalTeam(t.id)} onRename={renameLocalTeam} onRefresh={fetchLocalTeams} />
1315
1296
  ))}
@@ -1334,7 +1315,6 @@ export default function App() {
1334
1315
  />
1335
1316
  )}
1336
1317
  {/* 首页第二块:DeepSeek Harness(开源 Agent 框架),homepage 自装自启,和 cicy-code 卡并列 */}
1337
- {!firstLoading && showLocal && <DshCard />}
1338
1318
  {!firstLoading && showCustom && customList.map((t) => (
1339
1319
  <LocalTeamCard key={"custom:" + t.id} team={t} cloudCode={cloudCodeFor(t.cloud_team_id)} onOpen={() => openLocalTeam(t.id)} onRename={renameLocalTeam} onRefresh={fetchLocalTeams} />
1340
1320
  ))}
@@ -1511,7 +1491,11 @@ function useFleetSocket() {
1511
1491
  try { out.auto = await capped(window.cicy?.app?.getAutoUpdate?.(), null); } catch {}
1512
1492
  // Only cache a complete identity; a partial one (some step timed out) is
1513
1493
  // re-read on the next connect so the hello self-corrects once main is idle.
1514
- if (out.host && out.v) ident = out;
1494
+ // A token-less identity is never cached either: a fresh install hellos before the
1495
+ // user logs in to the hub, and caching that would keep every later hello token-less
1496
+ // → the hub never marks the machine authed → no dsh config / self-rpc for it
1497
+ // (seen on a new machine 2026-09-25). Re-read until the token shows up.
1498
+ if (out.host && out.v && out.tok) ident = out;
1515
1499
  return out;
1516
1500
  };
1517
1501
 
@@ -3143,8 +3127,8 @@ function DockerInstallDrawerHost() {
3143
3127
  const isOpen = st.kind === "open"; // 打开失败报告:无安装 stepper、标题/失败标签用「打开」
3144
3128
  const phaseIdx = DOCKER_PHASES.findIndex(([k]) => k === st.phase);
3145
3129
  const dlBars = isOpen ? [] : ["install-docker", "image"].filter((k) => st.bars?.[k]);
3146
- 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";
3130
+ const drawerTitle = st.title || (isOpen ? `打开${LOCAL_TEAM_NAME}` : `安装${LOCAL_TEAM_NAME}`);
3131
+ const drawerSub = st.sub || `127.0.0.1:${dockerAppPort}`;
3148
3132
  // Minimized → a floating restore chip (op keeps running in the background).
3149
3133
  if (st.minimized) {
3150
3134
  const pcts = dlBars.map((k) => st.bars[k]?.progress).filter(Number.isFinite);
@@ -3263,7 +3247,13 @@ function useDshFleet() {
3263
3247
  const refresh = useCallback(async () => {
3264
3248
  setBusy(true);
3265
3249
  try {
3266
- const r = await fetch(DSH_FLEET_API, { cache: "no-store" });
3250
+ // 车队清单是登录用户名下的资产:必须带本机 hubAuth token,hub 侧按 token 校验。
3251
+ // 没登录(全新安装)就没有 token → 什么都不列,别把车队里别的机器摆到陌生人首页上。
3252
+ let tok = "";
3253
+ try { tok = String(await mainEval(READ_TOKEN)).trim(); } catch {}
3254
+ if (!tok) { setList([]); return; }
3255
+ const r = await fetch(DSH_FLEET_API, { cache: "no-store", headers: { Authorization: "Bearer " + tok } });
3256
+ if (!r.ok) { setList([]); return; }
3267
3257
  const j = await r.json();
3268
3258
  setList(Array.isArray(j && j.machines) ? j.machines : []);
3269
3259
  } catch { setList([]); }
@@ -3446,6 +3436,42 @@ const dbDir = path.join(home, "cicy-ai", "db"), logDir = path.join(home, "cicy-a
3446
3436
  for (const d of [dbDir, logDir]) { try { fs.mkdirSync(d, { recursive: true }); } catch {} }
3447
3437
  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
3438
  const lockFile = path.join(dbDir, "dsh-install.lock"), okMarker = path.join(dbDir, "dsh-ok-" + VER + ".txt");
3439
+ // dsh@0.1.2-rc.1 是 developer preview:安装器只钉 dsh 版本,传递依赖(cordis 栈)浮动一升级就崩
3440
+ // ("user patch-layer watching requires the Cordis HMR service")。运行时 cordis 栈从 dsh 包自己的
3441
+ // nested node_modules 解析(~/.dsh/profiles 自举目录是空的),所以装完把这 8 个钉回已知良好版本。
3442
+ 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" };
3443
+ 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);
3444
+ const pinMarker = path.join(dbDir, "dsh-pins-" + VER + "-" + KG_HASH + ".txt");
3445
+ // 读版本要去 BOM:JSON.parse 遇 BOM 会抛
3446
+ 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; } }
3447
+ function depsOk(dir) { for (const n of Object.keys(KNOWN_GOOD)) if (nestedVer(dir, n) !== KNOWN_GOOD[n]) return false; return true; }
3448
+ // 钉法:不能在 dsh 包目录直接 npm i —— npm 会解析整棵 dsh 依赖树,里面的
3449
+ // @deepseek-ai/dsh-experimental-agent-team@^0.1.2-rc.1 在 registry 上不存在,直接 ETARGET 退出。
3450
+ // 改在临时 scratch 目录 npm i 这 8 个钉死版本,再把 8 个包目录整目录覆盖进 dsh/node_modules/@deepseek-ai
3451
+ // (旧目录改名 .bak,失败则还原)。必须 8 个一起(只钉 7 个、hmr 留新版会 hmr.registerConfig is not a function)。
3452
+ function pinDeps(dir, reg) {
3453
+ if (fs.existsSync(pinMarker) && depsOk(dir)) return { ok: true, skipped: true };
3454
+ if (depsOk(dir)) { try { fs.writeFileSync(pinMarker, String(Date.now())); } catch {} return { ok: true, skipped: true }; }
3455
+ const scratch = path.join(os.tmpdir(), "dsh-pin-scratch");
3456
+ 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) }; }
3457
+ const pins = Object.keys(KNOWN_GOOD).map((n) => n + "@" + KNOWN_GOOD[n]).join(" ");
3458
+ try { run(q(NPM) + " i --no-audit --no-fund --no-progress --loglevel=error --registry=" + reg + " " + pins, { cwd: scratch, timeout: 8 * 60 * 1000 }); }
3459
+ 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) }; }
3460
+ const dstScope = path.join(dir, "node_modules", "@deepseek-ai"), srcScope = path.join(scratch, "node_modules", "@deepseek-ai");
3461
+ const failed = [];
3462
+ for (const n of Object.keys(KNOWN_GOOD)) {
3463
+ const short = n.slice("@deepseek-ai/".length), src = path.join(srcScope, short), dst = path.join(dstScope, short);
3464
+ if (!fs.existsSync(src)) { failed.push(short + ":no-src"); continue; }
3465
+ 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 }); }
3466
+ 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 {} }
3467
+ }
3468
+ try { fs.rmSync(scratch, { recursive: true, force: true }); } catch {}
3469
+ if (failed.length) return { ok: false, error: "pin_copy_failed", tail: failed.join("; ") };
3470
+ 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) };
3471
+ 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 {} }
3472
+ try { fs.writeFileSync(pinMarker, String(Date.now())); } catch {}
3473
+ return { ok: true };
3474
+ }
3449
3475
  // 车队级启动环境(hub 下发,DshCard 写的 dsh-env.json):并进 dsh 子进程 env。启动环境在 dsh 里优先级最高且只读。
3450
3476
  const envFile = path.join(dbDir, "dsh-env.json"), envApplied = path.join(dbDir, "dsh-env.applied");
3451
3477
  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 +3562,9 @@ function installing() { return pidAlive(readPidFile(lockFile)); }
3536
3562
  async function status() {
3537
3563
  const p = pkgInfo(); const pid = readPidFile(pidFile); const pr = await probe();
3538
3564
  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 };
3565
+ // 装好但传递依赖没钉/浮动了 → 报出来让卡片显示,别无声"启动中"(2026-09-25:rc.1 cordis 栈浮动崩)
3566
+ const depBad = (p && !p.broken && !fs.existsSync(pinMarker) && !depsOk(p.dir)) ? "cordis-stack-mismatch" : null;
3567
+ 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
3568
  }
3541
3569
  async function install() {
3542
3570
  // 安装锁:首页刷新/多开会再次触发安装,两个 npm -g 同时写全局目录会把包写坏。有锁就等它完。
@@ -3563,7 +3591,9 @@ async function install() {
3563
3591
  try { fs.writeFileSync(rootCache, root); } catch {}
3564
3592
  const p = pkgAt(root);
3565
3593
  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) };
3594
+ const pin = pinDeps(p.dir, savedRegistry() || used || "https://registry.npmjs.org");
3595
+ if (!pin.ok) return { ok: false, error: pin.error, tail: pin.tail };
3596
+ return { ok: true, version: p.version, registry: used, speed: regs.speed, secs: Math.round((Date.now() - t0) / 1000), pinned: !pin.skipped };
3567
3597
  } finally { try { fs.unlinkSync(lockFile); } catch {} }
3568
3598
  }
3569
3599
  async function start() {
@@ -3585,11 +3615,17 @@ async function start() {
3585
3615
  const cmd = "$p = Start-Process -FilePath " + q(process.execPath)
3586
3616
  + " -ArgumentList @(" + [p.bin, "web", "--no-open", "--port", String(PORT)].map(q).join(",") + ")"
3587
3617
  + " -WindowStyle Hidden -RedirectStandardOutput " + q(logFile) + " -RedirectStandardError " + q(errFile)
3588
- + " -WorkingDirectory " + q(home) + " -PassThru; $p.Id";
3618
+ + " -WorkingDirectory " + q(home) + " -PassThru";
3619
+ // PID / 报错走文件,powershell 的 stdio 全给 ignore:Start-Process 带重定向时用继承句柄的
3620
+ // CreateProcess,dsh 会顺手继承 powershell 的 stdout —— 若那是管道,spawnSync 要等 dsh
3621
+ // 退出才 EOF,于是每次「启动」都卡满 60s 超时才返回(实测假进程:管道 25.3s vs 文件 0.25s)。
3622
+ const spawnPid = path.join(dbDir, "dsh-spawn.pid"), spawnErr = path.join(dbDir, "dsh-spawn.err");
3623
+ for (const f of [spawnPid, spawnErr]) { try { fs.unlinkSync(f); } catch {} }
3624
+ const ps = "try { " + cmd + "; Set-Content -Path " + q(spawnPid) + " -Value $p.Id } catch { Set-Content -Path " + q(spawnErr) + " -Value ($_ | Out-String) }";
3589
3625
  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) };
3626
+ cp.spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", ps], { stdio: "ignore", timeout: 60000, windowsHide: true, env });
3627
+ pid = readPidFile(spawnPid);
3628
+ if (!pid) { let err = ""; try { err = fs.readFileSync(spawnErr, "utf8"); } catch {} return { ok: false, error: "start_failed", tail: err.slice(-800) }; }
3593
3629
  } else {
3594
3630
  const fd = fs.openSync(logFile, "w");
3595
3631
  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 +3943,7 @@ function DshCard() {
3907
3943
  const stateText = !st ? ""
3908
3944
  : st.error ? tr("dsh.stateNoNode", "未就绪:{{why}}", { why: st.error === "no_node" ? tr("dsh.noNodeShort", "没有 Node.js(点「安装」自动装便携版)") : st.error })
3909
3945
  : st.installing ? tr("dsh.stateInstalling", "另一个安装进程进行中…")
3946
+ : st.depError ? tr("dsh.stateDepBad", "依赖损坏(cordis 栈),点「安装」修复")
3910
3947
  : running ? tr("dsh.stateRunning", "运行中")
3911
3948
  : st.broken ? tr("dsh.stateBroken", "安装不完整(点「安装」修复)")
3912
3949
  : installed ? tr("dsh.stateStopped", "已安装,未运行")
@@ -3979,6 +4016,21 @@ function DshCard() {
3979
4016
  // in Docker on :8008, alongside the native local daemon (:8008). If Docker
3980
4017
  // Desktop is missing, the install flow downloads its installer to the user's
3981
4018
  // Desktop and runs it, streaming progress through the drawer above.
4019
+ // 本机 cicy-code(WSL 或 QEMU 里的 Docker 容器)的团队名:2026-10-21 起统一叫「本机团队」。
4020
+ const LOCAL_TEAM_NAME = "本机团队";
4021
+ const isDefaultLocalTeamTitle = (t) => !t || /^\s*docker\s*(团队|team)?\s*(:\s*\d+)?\s*$/i.test(String(t));
4022
+
4023
+ // QEMU 机器(本机团队在 :8009,跑在 Debian 虚拟机里)的虚拟机操作:跑 desktop(2.1.368+)写好的
4024
+ // ~/cicy-ai/db/qemu-vm-ctl.js(源码 src/sidecar/qemu-vm.cjs),node 用 dsh 同款找法。只打一行 JSON。
4025
+ async function vmCtl(op) {
4026
+ const env = await dshEnv();
4027
+ if (!env.node || !env.home) return { ok: false, error: "no_node" };
4028
+ const r = await dshShell(`"${env.node}" "${env.home}\\cicy-ai\\db\\qemu-vm-ctl.js" ${op}`);
4029
+ const m = String(r.stdout || "").trim().match(/\{[\s\S]*\}$/);
4030
+ if (m) { try { return JSON.parse(m[0]); } catch {} }
4031
+ return { ok: false, error: "ctl_no_output", detail: (r.stderr || r.stdout || "").trim().slice(-300) };
4032
+ }
4033
+
3982
4034
  function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRefresh }) {
3983
4035
  const [status, setStatus] = useState(null);
3984
4036
  const [busy, setBusy] = useState(""); // "" | bootstrap | restart | stop | upgrade | probe
@@ -3993,8 +4045,21 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
3993
4045
  const [editing, setEditing] = useState(false);
3994
4046
  const [draft, setDraft] = useState("");
3995
4047
  // 标题 = 云端 team 的 title(和其它团队同一套;refreshCloudTeams 周期刷新 → 自动跟随
3996
- // 云端改名)。还没建好云端 team 时回退 "Docker 团队"。
3997
- const displayName = cloudTitle || "Docker 团队";
4048
+ // 云端改名)。没建好云端 team、或标题还是系统默认名(老的「Docker 团队」/ 云端缺省「Docker :<port>」)
4049
+ // 时显示「本机团队」;用户自己改过的名字原样显示。
4050
+ const displayName = isDefaultLocalTeamTitle(cloudTitle) ? LOCAL_TEAM_NAME : cloudTitle;
4051
+ // 本地团队节点(teams.json)里的名字还是默认名 → 一次性改成「本机团队」。tab 标题取的是这个名字;
4052
+ // localTeams.update 会同时刷新已打开的 tab 标题并把名字同步到云端。addTeam 再次注册时保留已有
4053
+ // 名字,所以改一次就稳定。用户自己起的名字不碰。
4054
+ const renamedRef = useRef(false);
4055
+ useEffect(() => {
4056
+ if (renamedRef.current || !dockerTeam?.id || !isDefaultLocalTeamTitle(dockerTeam.name)) return;
4057
+ if (!isDefaultLocalTeamTitle(cloudTitle)) return; // 云端已有用户起的名字 → 以它为准,不覆盖
4058
+ renamedRef.current = true;
4059
+ Promise.resolve(window.cicy?.localTeams?.update?.(dockerTeam.id, { name: LOCAL_TEAM_NAME }))
4060
+ .then((r) => { if (r?.ok) onRefresh?.(); })
4061
+ .catch((e) => console.warn("[DockerCard] rename to 本机团队", e));
4062
+ }, [dockerTeam?.id, dockerTeam?.name, cloudTitle, onRefresh]);
3998
4063
  const startEdit = (e) => { e?.stopPropagation?.(); setDraft(displayName); setEditing(true); };
3999
4064
  const commitName = async () => {
4000
4065
  setEditing(false);
@@ -4011,6 +4076,7 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
4011
4076
  const checkStatus = useCallback(async () => {
4012
4077
  try {
4013
4078
  const s = await window.cicy?.docker?.appStatus?.();
4079
+ if (s && Number(s.port)) dockerAppPort = Number(s.port);
4014
4080
  setStatus(s);
4015
4081
  // 自愈卡死的设置抽屉:容器已健康(running)说明 setup 实际已完成,但抽屉可能还停在
4016
4082
  // 「进行中」—— 网络失败重试后,follower 跟随的 bootstrap promise 迟迟不 resolve、又收
@@ -4052,7 +4118,7 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
4052
4118
  for (const raw of portList) {
4053
4119
  const s = String(raw).trim(); if (!s) continue;
4054
4120
  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; }
4121
+ 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
4122
  seen.add(p); out.push(p);
4057
4123
  }
4058
4124
  setPortsErr(""); setPortsBusy(true); setPortsOpen(false);
@@ -4176,7 +4242,7 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
4176
4242
  // update() 返回时 :8008 已健康(waitUntil probeHealth)。立刻:① reload 已打开的
4177
4243
  // docker tab(reloadIgnoringCache,拿新版 SPA)② 强制重探,卡片版本马上更新(不等
4178
4244
  // 60s reconcile / 缓存)。
4179
- try { window.cicy?.tabs?.reloadIfOpen?.("http://127.0.0.1:8008", tr("docker.teamTab", "Docker 团队")); } catch {}
4245
+ try { window.cicy?.tabs?.reloadIfOpen?.(dockerAppUrl(), LOCAL_TEAM_NAME); } catch {}
4180
4246
  try { const s = await window.cicy?.docker?.appRedetect?.(); if (s) setStatus(s); } catch {}
4181
4247
  }
4182
4248
  } catch (e) {
@@ -4188,6 +4254,27 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
4188
4254
  }, [checkStatus, onRefresh]);
4189
4255
 
4190
4256
  // Restart / stop: quick lifecycle ops with a toast (no full drawer needed).
4257
+ // QEMU 机器:菜单打开时顺手查一下虚拟机(加速方式),决定要不要显示「开启硬件加速」。
4258
+ const isQemu = Number(status?.port) === 8009;
4259
+ const [vmSt, setVmSt] = useState(null);
4260
+ useEffect(() => { if (menuOpen && isQemu) vmCtl("status").then(setVmSt).catch(() => {}); }, [menuOpen, isQemu]);
4261
+ const openVmTerminal = async () => {
4262
+ setMenuOpen(false);
4263
+ const r = await vmCtl("open");
4264
+ if (!r.ok) { toast.show({ id: "docker-op", status: "error", ttl: 6000, message: tr("docker.vmTermNotReady", "虚拟机终端还没就绪,稍等再试") }); return; }
4265
+ await window.cicy?.tabs?.open?.(r.url, tr("docker.vmTermTab", "Debian 终端"), "", true);
4266
+ };
4267
+ const enableAccel = async () => {
4268
+ setMenuOpen(false);
4269
+ if (!window.confirm(tr("docker.accelConfirm", "开启「Windows 虚拟机监控程序平台」(WHPX)需要管理员权限,开启后要重启电脑才生效。继续?"))) return;
4270
+ setBusy("accel");
4271
+ try {
4272
+ const r = await vmCtl("accel");
4273
+ toast.show({ id: "docker-op", status: r.ok ? "done" : "error", ttl: r.ok ? 10000 : 6000,
4274
+ message: r.ok ? tr("docker.accelDone", "已开启,重启电脑后虚拟机自动用硬件加速") : (r.error || tr("docker.opFailed", "操作失败")) });
4275
+ } finally { setBusy(""); }
4276
+ };
4277
+
4191
4278
  const runOp = useCallback(async (op, fn, okMsg) => {
4192
4279
  setMenuOpen(false); setBusy(op);
4193
4280
  toast.show({ id: "docker-op", message: tr(`docker.${op}ing`, op === "restart" ? "重启中…" : op === "reload" ? "刷新中…" : "停止中…"), status: "running" });
@@ -4286,7 +4373,7 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
4286
4373
  // 打开很慢 → 先探这个 :8008 tab 开过没。开过(openedWc 里有它的
4287
4374
  // webContentsId)就**直接 active 秒切**,不再拿 token / 注册 team(那是慢的根)。
4288
4375
  try {
4289
- const r = await window.cicy?.tabs?.activateIfOpen?.("http://127.0.0.1:8008");
4376
+ const r = await window.cicy?.tabs?.activateIfOpen?.(dockerAppUrl());
4290
4377
  if (r?.active) return;
4291
4378
  } catch {}
4292
4379
  // 没开过 → 走慢路径(读容器 token 最多 ~50s)。进行中只显示 CTA 的「打开中…」spinner
@@ -4365,17 +4452,33 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
4365
4452
  </button>
4366
4453
  {/* 常用 */}
4367
4454
  <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 团队"); }}>
4455
+ onClick={(e) => { e.stopPropagation(); setMenuOpen(false); window.cicy?.tabs?.reloadIfOpen?.(dockerAppUrl(), LOCAL_TEAM_NAME); }}>
4369
4456
  {tr("docker.reloadWindow", "刷新窗口")}
4370
4457
  </button>
4371
- <button type="button" data-id="DockerCard-open-dir" className="bcard__menu-item"
4372
- onClick={(e) => { e.stopPropagation(); setMenuOpen(false); window.cicy?.docker?.openDir?.(); }}>
4373
- {tr("docker.openWslDir", "打开 WSL 目录")}
4374
- </button>
4375
- <button type="button" data-id="DockerCard-open-projects" className="bcard__menu-item"
4376
- onClick={(e) => { e.stopPropagation(); setMenuOpen(false); window.cicy?.docker?.openDir?.("projects"); }}>
4377
- {tr("docker.openProjectsDir", "打开项目目录")}
4378
- </button>
4458
+ {/* QEMU 机器:没有宿主共享目录(WSL/项目目录打不开),换成虚拟机的终端 + 硬件加速 */}
4459
+ {isQemu ? (<>
4460
+ <button type="button" data-id="DockerCard-vm-terminal" className="bcard__menu-item"
4461
+ title={tr("docker.vmTermHint", "本机团队所在的 Debian 虚拟机的命令行(用户 cicy,sudo 免密;sudo docker ps 可看到容器)")}
4462
+ onClick={(e) => { e.stopPropagation(); openVmTerminal(); }}>
4463
+ {tr("docker.vmTerm", "打开终端")}
4464
+ </button>
4465
+ {!(vmSt && vmSt.accel === "whpx") && (
4466
+ <button type="button" data-id="DockerCard-vm-accel" className="bcard__menu-item"
4467
+ title={tr("docker.accelHint", "开启 Windows 虚拟机监控程序平台(要管理员 + 重启)")}
4468
+ onClick={(e) => { e.stopPropagation(); enableAccel(); }}>
4469
+ {tr("docker.accel", "开启硬件加速")}
4470
+ </button>
4471
+ )}
4472
+ </>) : (<>
4473
+ <button type="button" data-id="DockerCard-open-dir" className="bcard__menu-item"
4474
+ onClick={(e) => { e.stopPropagation(); setMenuOpen(false); window.cicy?.docker?.openDir?.(); }}>
4475
+ {tr("docker.openWslDir", "打开 WSL 目录")}
4476
+ </button>
4477
+ <button type="button" data-id="DockerCard-open-projects" className="bcard__menu-item"
4478
+ onClick={(e) => { e.stopPropagation(); setMenuOpen(false); window.cicy?.docker?.openDir?.("projects"); }}>
4479
+ {tr("docker.openProjectsDir", "打开项目目录")}
4480
+ </button>
4481
+ </>)}
4379
4482
  <button type="button" data-id="DockerCard-dood" className="bcard__menu-item"
4380
4483
  title={tr("dood.hint", "把宿主 Docker + docker 客户端挂进容器,容器内 agent 能直接跑 docker(切换会重建容器,秒生效、无需下载)")}
4381
4484
  onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setDoodOpen(true); }}>
@@ -4488,7 +4591,7 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
4488
4591
  <h3 style={{ margin: "0 0 6px", fontSize: 16, color: "#e6edf3" }}>{tr("docker.ports.title", "端口设置")}</h3>
4489
4592
  <p style={{ margin: "0 0 14px", fontSize: 12.5, lineHeight: 1.6, color: "#9aa4b2" }}>{tr("docker.ports.sub", "除 :8008 外,额外发布、可从 Windows 直达容器内服务的端口")}</p>
4490
4593
  <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(固定)")}
4594
+ 🔒 {tr("docker.ports.mainFixed", ":{{p}} · cicy-code(固定)", { p: dockerAppPort })}
4492
4595
  </div>
4493
4596
  <div style={{ display: "flex", flexDirection: "column", gap: 8, maxHeight: 220, overflowY: "auto" }}>
4494
4597
  {portList.length === 0 && (
@@ -5036,12 +5139,16 @@ function isLocalSidecar(baseUrl) {
5036
5139
 
5037
5140
  // The Docker-版 cicy-code instance — localhost:8008, **只在 Windows**。Owned by
5038
5141
  // <DockerCard>, so it's filtered out of the generic node lists.
5142
+ // Docker-版 cicy-code 的本机端口:老机器(WSL)8008,新机器(QEMU,不靠 WSL)8009。一台机器只会有其一;
5143
+ // 真实值以 desktop 报上来的 docker:app-status.port 为准(DockerCard 拿到后写进 dockerAppPort)。
5144
+ let dockerAppPort = 8008;
5145
+ const dockerAppUrl = () => `http://127.0.0.1:${dockerAppPort}`;
5039
5146
  function isDockerApp(baseUrl) {
5040
5147
  try {
5041
5148
  if (window.cicy?.platform !== "win32") return false; // docker-版只在 Windows
5042
5149
  const p = new URL(baseUrl);
5043
5150
  const local = p.hostname === "127.0.0.1" || p.hostname === "localhost" || p.hostname === "::1";
5044
- return local && p.port === "8008";
5151
+ return local && (p.port === "8008" || p.port === "8009");
5045
5152
  } catch { return false; }
5046
5153
  }
5047
5154