cicy-desktop 2.1.95 → 2.1.97

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.
@@ -157,24 +157,38 @@ function headSize(url, hops = 5) {
157
157
  // Download `url`→`dest` but: SKIP if the file is already complete, RESUME if it's
158
158
  // a partial, retry with progress, fall back to `mirror`. This is the core of the
159
159
  // user's "下载了就不重复下载 / 步骤走过的不要再走".
160
- async function ensureDownloaded(url, dest, mirror, { emit, phase, label } = {}) {
160
+ async function ensureDownloaded(url, dest, mirror, { emit, phase, label, freshOnIncomplete = false } = {}) {
161
161
  const expected = (await headSize(url)) || (mirror ? await headSize(mirror) : 0);
162
162
  let have = 0; try { have = fs.statSync(dest).size; } catch {}
163
+ // Complete file already on disk → skip (主人: 完整的 exe/镜像包就别重下了).
163
164
  if (expected > 0 && have === expected) {
164
165
  emit && emit({ phase, status: "skip", message: `${label}:已下载,跳过`, progress: 100 });
165
166
  return dest;
166
167
  }
168
+ // A partial left by a PREVIOUS, interrupted/restarted session can be corrupt;
169
+ // when freshOnIncomplete, delete it and start clean rather than range-resuming
170
+ // onto a possibly-bad file (主人: 下载被重启打断的残包要删掉重下). Within THIS
171
+ // session, retries still resume the part we wrote ourselves.
172
+ if (freshOnIncomplete && have > 0 && expected > 0 && have !== expected) {
173
+ try { fs.unlinkSync(dest); } catch {}
174
+ have = 0;
175
+ emit && emit({ phase, status: "running", message: `${label}:删除不完整的旧包,重新下载`, progress: 0 });
176
+ }
167
177
  const sources = mirror ? [url, mirror] : [url];
168
178
  let lastPct = -1; // throttle: chunks arrive dozens/s — only emit on whole-percent change
169
179
  const attempted = withRetry(async (attempt) => {
170
180
  const src = sources[Math.min(attempt - 1, sources.length - 1)];
181
+ // 断点续传 (主人): resume the partial via a Range request instead of
182
+ // restarting from 0 — efficient on a flaky network. The post-download size
183
+ // check below + loadImage's load-failure cleanup guard against a bad partial.
171
184
  await download(src, dest, {
172
185
  resume: true,
173
186
  onProgress: ({ received, total }) => {
174
187
  const pct = total ? Math.round((received / total) * 100) : 0;
175
188
  if (pct === lastPct) return;
176
189
  lastPct = pct;
177
- emit && emit({ phase, status: "running", message: label, progress: pct, received, total });
190
+ // `url` lets the drawer show the actual source (incl. mirror fallback).
191
+ emit && emit({ phase, status: "running", message: label, progress: pct, received, total, url: src });
178
192
  },
179
193
  });
180
194
  if (expected > 0) {
@@ -226,14 +240,40 @@ function probeHealth(port = 8008, timeoutMs = 2500) {
226
240
  });
227
241
  }
228
242
 
229
- async function loadImage({ emit } = {}) {
230
- // STABLE temp name (no pid) so a re-run reuses an existing partial/complete
231
- // tarball instead of starting over.
232
- const tmp = path.join(os.tmpdir(), "cicy-code-latest.tar.gz");
233
- await ensureDownloaded(R2_TARBALL, tmp, null, { emit, phase: "image", label: "下载镜像" });
243
+ // The R2 image tarball downloads to ~/Downloads (主人: docker image 下到
244
+ // ~/Downloads visible, like the Docker installer on the Desktop). STABLE name
245
+ // (no pid) so a re-run reuses an existing partial/complete file (resume-friendly
246
+ // on a flaky network).
247
+ function imageTarballPath() {
248
+ const dir = path.join(process.env["USERPROFILE"] || os.homedir(), "Downloads");
249
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
250
+ return path.join(dir, "cicy-code-latest.tar.gz");
251
+ }
252
+
253
+ // Download the R2 base-env image tarball (no docker needed yet). Split out of
254
+ // loadImage so bootstrap can run this IN PARALLEL with the Docker Desktop
255
+ // install (主人: 装 Docker 的同时下载 R2 镜像). Returns the tarball path.
256
+ async function downloadImageTarball({ emit } = {}) {
257
+ const dest = imageTarballPath();
258
+ await ensureDownloaded(R2_TARBALL, dest, null, { emit, phase: "image", label: "下载镜像" });
259
+ return dest;
260
+ }
261
+
262
+ // `docker load` an already-downloaded tarball + re-tag to IMAGE. Needs the
263
+ // daemon up, so this runs AFTER Docker is ready (主人: 再导入 docker).
264
+ async function loadImageFromTarball(tmp, { emit } = {}) {
234
265
  emit && emit({ phase: "image", status: "running", message: "docker load…", progress: 100 });
235
266
  console.log(`[docker-sidecar] docker load…`);
236
- const { stdout } = await run(["load", "-i", tmp], { timeout: 300000 });
267
+ let stdout;
268
+ try {
269
+ ({ stdout } = await run(["load", "-i", tmp], { timeout: 300000 }));
270
+ } catch (e) {
271
+ // A resumed download can leave a byte-correct-size but corrupt tarball that
272
+ // `docker load` rejects. Delete it so the next attempt re-downloads fresh
273
+ // (断点续传 normally, fresh only when proven bad).
274
+ try { fs.unlinkSync(tmp); } catch {}
275
+ throw e;
276
+ }
237
277
  // The tarball's embedded tag may be a pinned version (e.g. :2.1.6) while we
238
278
  // run IMAGE (:latest). Re-tag whatever was loaded so imagePresent()/start()
239
279
  // match — otherwise every start() re-downloads the tarball forever.
@@ -242,9 +282,23 @@ async function loadImage({ emit } = {}) {
242
282
  try { await run(["tag", m[1], IMAGE]); console.log(`[docker-sidecar] tagged ${m[1]} -> ${IMAGE}`); }
243
283
  catch (e) { console.warn(`[docker-sidecar] re-tag failed: ${e.message}`); }
244
284
  }
245
- // Only delete AFTER a successful load a failed load keeps the tarball so the
246
- // next attempt skips the re-download. (imagePresent() gates re-entry anyway.)
247
- try { fs.unlinkSync(tmp); } catch {}
285
+ // Keep the tarball in ~/Downloads (主人: 下到 Downloads) it's a visible,
286
+ // resume-friendly cache; imagePresent() gates re-entry so we don't re-load it.
287
+ }
288
+
289
+ // Download-then-import in one shot (sequential). Used when Docker is already up.
290
+ async function loadImage({ emit } = {}) {
291
+ const tmp = await downloadImageTarball({ emit });
292
+ await loadImageFromTarball(tmp, { emit });
293
+ }
294
+
295
+ // `docker restart` / graceful `docker stop` for a given container — the Docker-版
296
+ // card's ⋯ menu (重启 / 停止), mirroring the 8008 local card's lifecycle menu.
297
+ async function restart({ container = CONTAINER } = {}) {
298
+ await run(["restart", container], { timeout: 60000 });
299
+ }
300
+ async function stopContainer({ container = CONTAINER } = {}) {
301
+ try { await run(["stop", container], { timeout: 30000 }); } catch {}
248
302
  }
249
303
 
250
304
  async function checkStatus() {
@@ -264,7 +318,7 @@ function desktopDir() {
264
318
  // Docker Desktop). `container`/`volume` are parameterized so a SECOND instance
265
319
  // (the Docker-版 cicy-code on :8009) can run alongside the native local one
266
320
  // without a name/volume collision.
267
- async function start({ port = 8008, container = CONTAINER, volume = VOLUME } = {}) {
321
+ async function start({ port = 8008, container = CONTAINER, volume = VOLUME, mountTarget = "/home/cicy/cicy-ai", env = {} } = {}) {
268
322
  // Something already serves a healthy cicy-code on :port (a legacy-named
269
323
  // container auto-revived by `--restart unless-stopped`, a manual run…).
270
324
  // Adopt it — `docker run` would just lose the port-bind fight.
@@ -283,14 +337,23 @@ async function start({ port = 8008, container = CONTAINER, volume = VOLUME } = {
283
337
  // Replace any stale container of the same name.
284
338
  try { await run(["rm", "-f", container]); } catch {}
285
339
 
340
+ // mountTarget defaults to /home/cicy/cicy-ai (legacy local-team layout); the
341
+ // Docker-版 instance passes /home/cicy to persist the WHOLE cicy home (主人:
342
+ // "把整个 docker 挂出来" — everything mutable lives under /home/cicy: global.json,
343
+ // db, agents, files, the npm-installed cicy-code itself).
286
344
  const args = [
287
345
  "run", "-d", "--name", container, "--restart", "unless-stopped",
288
346
  "-p", `${port}:8008`,
289
- "-v", `${volume}:/home/cicy/cicy-ai`,
347
+ "-v", `${volume}:${mountTarget}`,
290
348
  ];
291
349
  for (const k of PASS_ENV) {
292
350
  if (process.env[k]) args.push("-e", `${k}=${process.env[k]}`);
293
351
  }
352
+ // Caller-supplied env (e.g. the LLM gateway endpoint + key for the Docker-版
353
+ // instance, which bills through the 8008 local team's token).
354
+ for (const [k, v] of Object.entries(env || {})) {
355
+ if (v != null && v !== "") args.push("-e", `${k}=${v}`);
356
+ }
294
357
  args.push(IMAGE);
295
358
 
296
359
  const { stdout } = await run(args, { timeout: 60000 });
@@ -333,16 +396,22 @@ async function installDocker({ emit, dest } = {}) {
333
396
  // start the container → wait for :8008. Every step CHECKS first and SKIPS if
334
397
  // already done, emits coarse phase events + byte progress, and the downloads
335
398
  // resume. Safe to call again after a failure — it picks up where it left off.
336
- async function bootstrap({ onProgress, port = 8008, container = CONTAINER, volume = VOLUME, installDest } = {}) {
399
+ async function bootstrap({ onProgress, port = 8008, container = CONTAINER, volume = VOLUME, mountTarget, env, installDest } = {}) {
337
400
  const emit = (ev) => { try { onProgress && onProgress(ev); } catch {} };
338
401
 
402
+ // Decide up-front whether the base image needs fetching, so we can download
403
+ // the R2 tarball IN PARALLEL with the Docker Desktop install below.
404
+ const needImage = !(await imagePresent());
405
+ let imgDl = null; // Promise<tarballPath|null> when downloading in parallel
406
+
339
407
  // 1) Docker present?
340
408
  if (await dockerOk()) {
341
409
  emit({ phase: "install-docker", status: "skip", message: "Docker 已安装,跳过" });
342
410
  } else if (dockerDesktopExe()) {
343
411
  // Installed but the daemon is down — just launch Docker Desktop, never
344
- // re-download/re-run the installer ("步骤走过的不要再走").
412
+ // re-download/re-run the installer (主人: 装了就别再下 Docker Desktop 了).
345
413
  emit({ phase: "install-docker", status: "running", message: "Docker 已安装,正在启动 Docker Desktop…" });
414
+ if (needImage) imgDl = downloadImageTarball({ emit }).catch((e) => { emit({ phase: "image", status: "error", message: `镜像下载失败:${e.message}` }); return null; });
346
415
  startDockerDesktop();
347
416
  const up = await waitUntil(dockerOk, { totalMs: 300000, everyMs: 5000 });
348
417
  if (!up) {
@@ -351,6 +420,9 @@ async function bootstrap({ onProgress, port = 8008, container = CONTAINER, volum
351
420
  }
352
421
  emit({ phase: "install-docker", status: "done", message: "Docker 就绪" });
353
422
  } else {
423
+ // Docker missing → download the R2 image IN PARALLEL with the installer
424
+ // running + the daemon coming up (主人: 装 Docker 的同时下载 R2 镜像).
425
+ if (needImage) imgDl = downloadImageTarball({ emit }).catch((e) => { emit({ phase: "image", status: "error", message: `镜像下载失败:${e.message}` }); return null; });
354
426
  await installDocker({ emit, dest: installDest });
355
427
  emit({ phase: "install-docker", status: "running", message: "等待 Docker 启动(如需授权/重启,完成后会自动继续)…" });
356
428
  const up = await waitUntil(dockerOk, { totalMs: 900000, everyMs: 6000 });
@@ -361,12 +433,16 @@ async function bootstrap({ onProgress, port = 8008, container = CONTAINER, volum
361
433
  emit({ phase: "install-docker", status: "done", message: "Docker 就绪" });
362
434
  }
363
435
 
364
- // 2) Base image present?
365
- if (await imagePresent()) {
436
+ // 2) Base image — import it (docker load). If pre-downloaded in parallel, just
437
+ // load; otherwise (re)download now. Downloads resume/skip + delete bad partials.
438
+ if (!needImage) {
366
439
  emit({ phase: "image", status: "skip", message: "镜像已就绪,跳过" });
367
440
  } else {
368
441
  try {
369
- await loadImage({ emit });
442
+ let tmp = imgDl ? await imgDl : null;
443
+ if (!tmp) tmp = await downloadImageTarball({ emit }); // not pre-dl'd / parallel dl failed → fetch now
444
+ emit({ phase: "image", status: "running", message: "导入 Docker 镜像…", progress: 100 });
445
+ await loadImageFromTarball(tmp, { emit });
370
446
  emit({ phase: "image", status: "done", message: "镜像就绪" });
371
447
  } catch (e) {
372
448
  emit({ phase: "image", status: "error", message: `镜像加载失败:${e.message}(点重试,下载会续传)` });
@@ -381,7 +457,7 @@ async function bootstrap({ onProgress, port = 8008, container = CONTAINER, volum
381
457
  } else {
382
458
  emit({ phase: "container", status: "running", message: "启动 cicy-code 容器…" });
383
459
  let child = null;
384
- try { child = await start({ port, container, volume }); }
460
+ try { child = await start({ port, container, volume, mountTarget, env }); }
385
461
  catch (e) { emit({ phase: "container", status: "error", message: `容器启动失败:${e.message}` }); return { ok: false, reason: "container_start_failed" }; }
386
462
  if (!child) {
387
463
  emit({ phase: "container", status: "error", message: "容器启动失败" });
@@ -397,7 +473,8 @@ async function bootstrap({ onProgress, port = 8008, container = CONTAINER, volum
397
473
  }
398
474
 
399
475
  module.exports = {
400
- start, stop, checkStatus, loadImage, imagePresent, dockerOk, installDocker,
476
+ start, stop, stopContainer, restart, checkStatus, loadImage, loadImageFromTarball,
477
+ downloadImageTarball, imagePresent, dockerOk, installDocker,
401
478
  bootstrap, probeHealth, readContainerToken, dockerDesktopExe, desktopDir,
402
479
  // platform-agnostic download/retry primitives, reused by native.js
403
480
  ensureDownloaded, withRetry, waitUntil, run,
@@ -213,6 +213,13 @@ body {
213
213
  .user-chip__menu-item.is-danger { color: #f7a3a3; }
214
214
  .user-chip__menu-item.is-danger:hover { background: rgba(239,68,68,.16); color: #fff; }
215
215
  .user-chip__menu-sep { height: 1px; margin: 4px 2px; background: rgba(255,255,255,.08); }
216
+ /* cicy-desktop version — bottom of the avatar dropdown (主人) */
217
+ .user-chip__menu-version {
218
+ margin-top: 4px; padding: 8px 11px 4px;
219
+ font-size: 11px; color: #6b7280; text-align: center;
220
+ font-variant-numeric: tabular-nums; user-select: text;
221
+ border-top: 1px solid rgba(255,255,255,.06);
222
+ }
216
223
  /* HTTPS audit tip, rendered as a flat menu row with an on/off switch */
217
224
  .user-chip__mitm-row {
218
225
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
@@ -969,6 +976,34 @@ body {
969
976
  background: rgba(10, 12, 16, 0.7); border: 1px solid rgba(125, 135, 150, 0.12); border-radius: 10px;
970
977
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11.5px; line-height: 1.7;
971
978
  }
979
+ /* Custom thin scrollbar for the install log (主人: scroll 用自定义 style) */
980
+ .drawer__log--scroll { max-height: 168px; scrollbar-width: thin; scrollbar-color: rgba(125,135,150,.4) transparent; }
981
+ .drawer__log--scroll::-webkit-scrollbar { width: 7px; }
982
+ .drawer__log--scroll::-webkit-scrollbar-track { background: transparent; margin: 4px 0; }
983
+ .drawer__log--scroll::-webkit-scrollbar-thumb { background: rgba(125,135,150,.32); border-radius: 6px; border: 1.5px solid transparent; background-clip: padding-box; }
984
+ .drawer__log--scroll::-webkit-scrollbar-thumb:hover { background: rgba(125,135,150,.55); background-clip: padding-box; }
985
+
986
+ /* Fixed download progress bars (Docker Desktop / 基础镜像) — no scroll spam */
987
+ .drawer__dlbars { display: flex; flex-direction: column; gap: 10px; margin: 12px 14px 2px; }
988
+ .dlbar {
989
+ padding: 10px 12px; border-radius: 10px;
990
+ background: rgba(10, 12, 16, 0.55); border: 1px solid rgba(125, 135, 150, 0.14);
991
+ }
992
+ .dlbar__head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 7px; }
993
+ .dlbar__name { font-size: 12.5px; font-weight: 600; color: var(--text, #e6eaf0); }
994
+ .dlbar__pct { font-size: 11px; color: var(--text-dim, #9da7b3); font-variant-numeric: tabular-nums; }
995
+ .dlbar__track { height: 6px; border-radius: 4px; background: rgba(125, 135, 150, 0.16); overflow: hidden; }
996
+ .dlbar__fill {
997
+ height: 100%; border-radius: 4px;
998
+ background: linear-gradient(90deg, #5b8df7, #2496ed);
999
+ transition: width .2s ease;
1000
+ }
1001
+ .dlbar__fill.is-done { background: #4ade80; }
1002
+ .dlbar__url {
1003
+ margin-top: 7px; font-size: 10.5px; color: #6b7686;
1004
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1005
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
1006
+ }
972
1007
  .drawer__log-empty { color: var(--text-dim, #9da7b3); }
973
1008
  .drawer__line { display: flex; align-items: baseline; gap: 8px; padding: 1px 0; }
974
1009
  .drawer__t { color: #6b7686; flex: none; font-variant-numeric: tabular-nums; }
@@ -189,7 +189,6 @@ function UpdateDrawerHost() {
189
189
  {running ? (
190
190
  <>
191
191
  <span className="drawer__foot-status">更新进行中…</span>
192
- <button type="button" className="drawer__btn" data-id="UpdateDrawer-background" onClick={() => updateDrawer.close()}>在后台继续</button>
193
192
  </>
194
193
  ) : st.status === "error" ? (
195
194
  <>
@@ -1022,7 +1021,14 @@ function Header({ me, welcome, onLogout, mitmTeam }) {
1022
1021
  const [trustOpen, setTrustOpen] = useState(false);
1023
1022
  const [auditOpen, setAuditOpen] = useState(false);
1024
1023
  const [termsOpen, setTermsOpen] = useState(false);
1024
+ const [appVer, setAppVer] = useState("");
1025
1025
  const wrap = useRef(null);
1026
+ // cicy-desktop's own version, shown at the very bottom of this menu (主人).
1027
+ useEffect(() => {
1028
+ let alive = true;
1029
+ window.cicy?.app?.getVersion?.().then((v) => { if (alive) setAppVer(String(v || "")); }).catch(() => {});
1030
+ return () => { alive = false; };
1031
+ }, []);
1026
1032
  // Click-outside closes the dropdown (mirrors LocalTeamCard's ⋯ menu).
1027
1033
  useEffect(() => {
1028
1034
  if (!open) return;
@@ -1077,6 +1083,9 @@ function Header({ me, welcome, onLogout, mitmTeam }) {
1077
1083
  <button type="button" data-id="UserChip-logout" className="user-chip__menu-item is-danger" onClick={() => { setOpen(false); onLogout(); }}>
1078
1084
  退出
1079
1085
  </button>
1086
+ <div className="user-chip__menu-version" data-id="UserChip-version">
1087
+ CiCy Desktop {appVer ? `v${appVer}` : "…"}
1088
+ </div>
1080
1089
  </div>
1081
1090
  )}
1082
1091
  </div>
@@ -1435,14 +1444,30 @@ let dockerDrawerState = null; // null = closed
1435
1444
  function emitDockerDrawer() { dockerDrawerListeners.forEach((l) => l(dockerDrawerState)); }
1436
1445
  const dockerDrawer = {
1437
1446
  open({ onRetry } = {}) {
1438
- dockerDrawerState = { status: "running", phase: "install-docker", logs: [], onRetry: onRetry || null, lastAt: Date.now() };
1447
+ dockerDrawerState = { status: "running", phase: "install-docker", logs: [], bars: {}, onRetry: onRetry || null, lastAt: Date.now() };
1439
1448
  emitDockerDrawer();
1440
1449
  },
1441
1450
  push(ev = {}) {
1442
1451
  if (!dockerDrawerState) return;
1443
1452
  const phase = ev.phase === "health" ? "container" : (ev.phase || dockerDrawerState.phase);
1444
- const line = { id: ++dockerDrawerLogSeq, t: clockHHMMSS(), phase, status: ev.status || "running", message: ev.message || "", progress: ev.progress };
1445
- dockerDrawerState = { ...dockerDrawerState, phase, logs: [...dockerDrawerState.logs, line], lastAt: Date.now() };
1453
+ const next = { ...dockerDrawerState, phase, lastAt: Date.now() };
1454
+ const hasPct = Number.isFinite(ev.progress);
1455
+ // Per-byte download ticks (status running + a %) drive a PROGRESS BAR, not a
1456
+ // log line — so the log doesn't scroll-spam (主人: 下载不要输出滚动/日志太多).
1457
+ const isDownloadTick = ev.status === "running" && hasPct && (phase === "install-docker" || phase === "image");
1458
+ if (isDownloadTick) {
1459
+ const prev = dockerDrawerState.bars?.[phase] || {};
1460
+ next.bars = { ...dockerDrawerState.bars, [phase]: { progress: ev.progress, received: ev.received, total: ev.total, url: ev.url || prev.url, message: ev.message || prev.message } };
1461
+ } else {
1462
+ // Meaningful event → one log line (phase change / skip / done / error / retry).
1463
+ const line = { id: ++dockerDrawerLogSeq, t: clockHHMMSS(), phase, status: ev.status || "running", message: ev.message || "" };
1464
+ next.logs = [...dockerDrawerState.logs, line];
1465
+ if (ev.url) {
1466
+ const prev = dockerDrawerState.bars?.[phase] || {};
1467
+ next.bars = { ...dockerDrawerState.bars, [phase]: { ...prev, url: ev.url } };
1468
+ }
1469
+ }
1470
+ dockerDrawerState = next;
1446
1471
  emitDockerDrawer();
1447
1472
  },
1448
1473
  finish({ ok, message } = {}) {
@@ -1456,6 +1481,30 @@ const dockerDrawer = {
1456
1481
  };
1457
1482
  const DOCKER_PHASES = [["install-docker", "装 Docker"], ["image", "加载镜像"], ["container", "启动容器"], ["done", "完成"]];
1458
1483
  const DOCKER_BADGE = { "install-docker": "Docker", image: "镜像", container: "容器", health: "容器", done: "完成" };
1484
+ const DOCKER_DL_LABEL = { "install-docker": "Docker Desktop", image: "基础镜像" };
1485
+ function fmtBytes(n) {
1486
+ if (!Number.isFinite(n)) return "?";
1487
+ if (n < 1024) return n + " B";
1488
+ if (n < 1048576) return (n / 1024).toFixed(0) + " KB";
1489
+ if (n < 1073741824) return (n / 1048576).toFixed(1) + " MB";
1490
+ return (n / 1073741824).toFixed(2) + " GB";
1491
+ }
1492
+ // One fixed (non-scrolling) progress bar per download (Docker Desktop / image),
1493
+ // showing the source URL + % + bytes (主人: 下载做进度条、显示地址、不要滚动).
1494
+ function DownloadBar({ phaseKey, bar }) {
1495
+ const pct = Number.isFinite(bar?.progress) ? Math.max(0, Math.min(100, bar.progress)) : 0;
1496
+ const done = pct >= 100;
1497
+ return (
1498
+ <div className="dlbar" data-id={`DockerDrawer-dlbar-${phaseKey}`}>
1499
+ <div className="dlbar__head">
1500
+ <span className="dlbar__name">{DOCKER_DL_LABEL[phaseKey] || phaseKey}</span>
1501
+ <span className="dlbar__pct">{pct}%{bar?.total ? ` · ${fmtBytes(bar.received)} / ${fmtBytes(bar.total)}` : ""}</span>
1502
+ </div>
1503
+ <div className="dlbar__track"><div className={`dlbar__fill${done ? " is-done" : ""}`} style={{ width: `${pct}%` }} /></div>
1504
+ {bar?.url && <div className="dlbar__url" title={bar.url}>{bar.url}</div>}
1505
+ </div>
1506
+ );
1507
+ }
1459
1508
  function DockerInstallDrawerHost() {
1460
1509
  const [st, setSt] = useState(dockerDrawerState);
1461
1510
  useEffect(() => { dockerDrawerListeners.add(setSt); return () => { dockerDrawerListeners.delete(setSt); }; }, []);
@@ -1464,6 +1513,7 @@ function DockerInstallDrawerHost() {
1464
1513
  if (!st) return null;
1465
1514
  const running = st.status === "running";
1466
1515
  const phaseIdx = DOCKER_PHASES.findIndex(([k]) => k === st.phase);
1516
+ const dlBars = ["install-docker", "image"].filter((k) => st.bars?.[k]);
1467
1517
  return (
1468
1518
  <div className="drawer-scrim" data-id="DockerDrawer-scrim" onClick={() => { if (!running) dockerDrawer.close(); }}>
1469
1519
  <div className="drawer" data-id="DockerDrawer" data-status={st.status} onClick={(e) => e.stopPropagation()}>
@@ -1495,14 +1545,20 @@ function DockerInstallDrawerHost() {
1495
1545
  })}
1496
1546
  </div>
1497
1547
 
1498
- <div className="drawer__log" data-id="DockerDrawer-log" ref={logRef}>
1548
+ {dlBars.length > 0 && (
1549
+ <div className="drawer__dlbars" data-id="DockerDrawer-dlbars">
1550
+ {dlBars.map((k) => <DownloadBar key={k} phaseKey={k} bar={st.bars[k]} />)}
1551
+ </div>
1552
+ )}
1553
+
1554
+ <div className="drawer__log drawer__log--scroll" data-id="DockerDrawer-log" ref={logRef}>
1499
1555
  {st.logs.length === 0
1500
1556
  ? <div className="drawer__log-empty">{tr("docker.preparing", "准备中…")}</div>
1501
1557
  : st.logs.map((l) => (
1502
1558
  <div key={l.id} className="drawer__line" data-status={l.status}>
1503
1559
  <span className="drawer__t">{l.t}</span>
1504
1560
  <span className={`drawer__badge drawer__badge--${l.phase}`}>{DOCKER_BADGE[l.phase] || l.phase}</span>
1505
- <span className="drawer__linemsg">{l.message}{Number.isFinite(l.progress) ? ` ${l.progress}%` : ""}</span>
1561
+ <span className="drawer__linemsg">{l.message}</span>
1506
1562
  </div>
1507
1563
  ))}
1508
1564
  </div>
@@ -1511,7 +1567,6 @@ function DockerInstallDrawerHost() {
1511
1567
  {running ? (
1512
1568
  <>
1513
1569
  <span className="drawer__foot-status">{tr("docker.installing2", "安装进行中…")}</span>
1514
- <button type="button" className="drawer__btn" data-id="DockerDrawer-background" onClick={() => dockerDrawer.close()}>{tr("docker.background", "在后台继续")}</button>
1515
1570
  </>
1516
1571
  ) : st.status === "error" ? (
1517
1572
  <>
@@ -1537,7 +1592,12 @@ function DockerInstallDrawerHost() {
1537
1592
  // Desktop and runs it (主人指令), streaming progress through the drawer above.
1538
1593
  function DockerCard({ dockerTeam, onOpen, onRefresh }) {
1539
1594
  const [status, setStatus] = useState(null);
1540
- const [busy, setBusy] = useState(false);
1595
+ const [busy, setBusy] = useState(""); // "" | bootstrap | restart | stop | upgrade
1596
+ const [menuOpen, setMenuOpen] = useState(false);
1597
+ const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
1598
+ const kebabRef = useRef(null);
1599
+ const menuRef = useRef(null);
1600
+ const MENU_W = 184;
1541
1601
  const DOCKER_BLUE = "#2496ed";
1542
1602
 
1543
1603
  const checkStatus = useCallback(async () => {
@@ -1547,8 +1607,31 @@ function DockerCard({ dockerTeam, onOpen, onRefresh }) {
1547
1607
 
1548
1608
  useEffect(() => { checkStatus(); }, [checkStatus]);
1549
1609
 
1610
+ // Close the ⋯ menu on outside-click / Esc (mirrors LocalTeamCard).
1611
+ useEffect(() => {
1612
+ if (!menuOpen) return;
1613
+ const onDoc = (e) => {
1614
+ if (kebabRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
1615
+ setMenuOpen(false);
1616
+ };
1617
+ const onKey = (e) => { if (e.key === "Escape") setMenuOpen(false); };
1618
+ document.addEventListener("mousedown", onDoc);
1619
+ document.addEventListener("keydown", onKey);
1620
+ return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
1621
+ }, [menuOpen]);
1622
+
1623
+ const toggleMenu = () => {
1624
+ if (!menuOpen && kebabRef.current) {
1625
+ const r = kebabRef.current.getBoundingClientRect();
1626
+ const left = Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8));
1627
+ setMenuPos({ top: Math.round(r.bottom + 4), left: Math.round(left) });
1628
+ }
1629
+ setMenuOpen((v) => !v);
1630
+ };
1631
+
1632
+ // Install / start: streams through the drawer modal (logs + progress + retry).
1550
1633
  const runBootstrap = useCallback(async () => {
1551
- setBusy(true);
1634
+ setBusy("bootstrap");
1552
1635
  dockerDrawer.open({ onRetry: runBootstrap });
1553
1636
  const unsub = window.cicy?.docker?.onAppProgress?.((ev) => dockerDrawer.push(ev));
1554
1637
  try {
@@ -1559,11 +1642,41 @@ function DockerCard({ dockerTeam, onOpen, onRefresh }) {
1559
1642
  dockerDrawer.finish({ ok: false, message: e.message });
1560
1643
  } finally {
1561
1644
  try { unsub && unsub(); } catch {}
1562
- setBusy(false);
1563
- checkStatus();
1645
+ setBusy(""); checkStatus();
1564
1646
  }
1565
1647
  }, [checkStatus, onRefresh]);
1566
1648
 
1649
+ // Upgrade: re-pull the R2 image + recreate the container — also through the
1650
+ // drawer so the user sees the pull/import/restart log (主人: 升级要能看日志).
1651
+ const runUpgrade = useCallback(async () => {
1652
+ setMenuOpen(false); setBusy("upgrade");
1653
+ dockerDrawer.open({ onRetry: runUpgrade });
1654
+ const unsub = window.cicy?.docker?.onAppProgress?.((ev) => dockerDrawer.push(ev));
1655
+ try {
1656
+ const r = await window.cicy?.docker?.appUpgrade?.();
1657
+ dockerDrawer.finish({ ok: !!r?.ok, message: r?.ok ? tr("docker.upgraded", "已升级到最新") : (r?.error || tr("docker.upgradeFailed", "升级失败")) });
1658
+ if (r?.ok) onRefresh?.();
1659
+ } catch (e) {
1660
+ dockerDrawer.finish({ ok: false, message: e.message });
1661
+ } finally {
1662
+ try { unsub && unsub(); } catch {}
1663
+ setBusy(""); checkStatus();
1664
+ }
1665
+ }, [checkStatus, onRefresh]);
1666
+
1667
+ // Restart / stop: quick lifecycle ops with a toast (no full drawer needed).
1668
+ const runOp = useCallback(async (op, fn, okMsg) => {
1669
+ setMenuOpen(false); setBusy(op);
1670
+ toast.show({ id: "docker-op", message: tr(`docker.${op}ing`, op === "restart" ? "重启中…" : "停止中…"), status: "running" });
1671
+ try {
1672
+ const r = await fn();
1673
+ if (r?.ok) toast.show({ id: "docker-op", message: okMsg, status: "done", ttl: 2500 });
1674
+ else toast.show({ id: "docker-op", message: (r?.error || tr("docker.opFailed", "操作失败")), status: "error", ttl: 6000 });
1675
+ } catch (e) {
1676
+ toast.show({ id: "docker-op", message: e.message, status: "error", ttl: 6000 });
1677
+ } finally { setBusy(""); checkStatus(); }
1678
+ }, [checkStatus]);
1679
+
1567
1680
  // Render only on Windows. window.cicy.platform is sync, so we can decide
1568
1681
  // immediately without waiting on the async appStatus probe.
1569
1682
  const platform = window.cicy?.platform || status?.platform;
@@ -1572,13 +1685,14 @@ function DockerCard({ dockerTeam, onOpen, onRefresh }) {
1572
1685
  const running = !!status?.running || dockerTeam?.status === "running";
1573
1686
  const installed = !!status?.installed;
1574
1687
  const tone = running ? "ok" : installed ? "warn" : "off";
1688
+ const isBusy = !!busy;
1575
1689
  const stateText = running
1576
1690
  ? tr("docker.running", "运行中 · :8009")
1577
1691
  : installed
1578
1692
  ? tr("docker.notRunning", "未启动 · 点「启动」")
1579
1693
  : tr("docker.notInstalled", "Docker Desktop 未安装");
1580
1694
 
1581
- const ctaLabel = busy
1695
+ const ctaLabel = isBusy
1582
1696
  ? tr("docker.working", "处理中…")
1583
1697
  : running
1584
1698
  ? tr("localTeams.open", "打开")
@@ -1587,11 +1701,14 @@ function DockerCard({ dockerTeam, onOpen, onRefresh }) {
1587
1701
  : tr("docker.install", "下载安装");
1588
1702
 
1589
1703
  const onCta = () => {
1590
- if (busy) return;
1704
+ if (isBusy) return;
1591
1705
  if (running) { onOpen?.(dockerTeam?.id); return; }
1592
1706
  runBootstrap();
1593
1707
  };
1594
1708
 
1709
+ // The ⋯ menu (重启 / 停止 / 升级) is only meaningful once Docker is installed.
1710
+ const showMenu = installed;
1711
+
1595
1712
  return (
1596
1713
  <div data-id="DockerCard" className={`bcard bcard--docker${running ? " bcard--online" : ""}`}>
1597
1714
  <div className="bcard__accent" style={{ background: DOCKER_BLUE }} />
@@ -1602,6 +1719,44 @@ function DockerCard({ dockerTeam, onOpen, onRefresh }) {
1602
1719
  <path d="M21.81 10.25c-.06-.05-.67-.51-1.95-.51-.34 0-.68.03-1.01.09-.25-1.69-1.64-2.51-1.7-2.55l-.34-.2-.22.32a4.5 4.5 0 0 0-.59 1.4c-.23.94-.09 1.83.39 2.59-.58.32-1.51.4-1.7.41H2.62a.61.61 0 0 0-.61.61 9.32 9.32 0 0 0 .57 3.35 4.9 4.9 0 0 0 1.95 2.53c.92.52 2.42.82 4.12.82.77 0 1.54-.07 2.3-.21a9.6 9.6 0 0 0 3-1.09 8.3 8.3 0 0 0 2.05-1.68c.98-1.11 1.56-2.35 1.99-3.45h.17c1.36 0 2.2-.55 2.66-1l.13-.16zM4.7 11.33h1.78a.16.16 0 0 0 .16-.16V9.58a.16.16 0 0 0-.16-.16H4.7a.16.16 0 0 0-.16.16v1.59c0 .09.07.16.16.16m2.46 0h1.78a.16.16 0 0 0 .16-.16V9.58a.16.16 0 0 0-.16-.16H7.16a.16.16 0 0 0-.16.16v1.59c0 .09.07.16.16.16m2.5 0h1.78a.16.16 0 0 0 .16-.16V9.58a.16.16 0 0 0-.16-.16H9.66a.16.16 0 0 0-.16.16v1.59c0 .09.07.16.16.16m2.47 0h1.78a.16.16 0 0 0 .16-.16V9.58a.16.16 0 0 0-.16-.16h-1.78a.16.16 0 0 0-.16.16v1.59c0 .09.07.16.16.16M7.16 9.06h1.78a.16.16 0 0 0 .16-.16V7.31a.16.16 0 0 0-.16-.16H7.16a.16.16 0 0 0-.16.16v1.59c0 .09.07.16.16.16m2.5 0h1.78a.16.16 0 0 0 .16-.16V7.31a.16.16 0 0 0-.16-.16H9.66a.16.16 0 0 0-.16.16v1.59c0 .09.07.16.16.16m2.47 0h1.78a.16.16 0 0 0 .16-.16V7.31a.16.16 0 0 0-.16-.16h-1.78a.16.16 0 0 0-.16.16v1.59c0 .09.07.16.16.16" />
1603
1720
  </svg>
1604
1721
  </div>
1722
+ {showMenu && (
1723
+ <div className="bcard__menuwrap" onClick={(e) => e.stopPropagation()}>
1724
+ <button
1725
+ type="button"
1726
+ ref={kebabRef}
1727
+ data-id="DockerCard-menu-btn"
1728
+ className="bcard__kebab"
1729
+ title={tr("docker.manage", "管理 Docker cicy-code")}
1730
+ disabled={isBusy}
1731
+ onClick={toggleMenu}
1732
+ >
1733
+ {isBusy ? <Spinner /> : <KebabIcon />}
1734
+ </button>
1735
+ {menuOpen && createPortal(
1736
+ <div className="bcard__menu bcard__menu--portal" data-id="DockerCard-menu" role="menu"
1737
+ ref={menuRef}
1738
+ style={{ position: "fixed", top: menuPos.top, left: menuPos.left, width: MENU_W }}
1739
+ onClick={(e) => e.stopPropagation()}>
1740
+ {running && (
1741
+ <button type="button" data-id="DockerCard-restart" className="bcard__menu-item"
1742
+ onClick={() => runOp("restart", () => window.cicy.docker.appRestart(), tr("docker.restarted", "已重启"))}>
1743
+ {tr("docker.restart", "重启")}
1744
+ </button>
1745
+ )}
1746
+ <button type="button" data-id="DockerCard-upgrade" className="bcard__menu-item is-accent" onClick={runUpgrade}>
1747
+ {tr("docker.upgrade", "升级(拉取最新镜像)")}
1748
+ </button>
1749
+ {running && (
1750
+ <button type="button" data-id="DockerCard-stop" className="bcard__menu-item is-danger"
1751
+ onClick={() => runOp("stop", () => window.cicy.docker.appStop(), tr("docker.stopped", "已停止"))}>
1752
+ {tr("docker.stop", "停止")}
1753
+ </button>
1754
+ )}
1755
+ </div>,
1756
+ document.body
1757
+ )}
1758
+ </div>
1759
+ )}
1605
1760
  </div>
1606
1761
  <div className="bcard__body">
1607
1762
  <h3 className="bcard__name">{tr("docker.title", "Docker cicy-code")}</h3>
@@ -1612,11 +1767,11 @@ function DockerCard({ dockerTeam, onOpen, onRefresh }) {
1612
1767
  type="button"
1613
1768
  className="bcard__cta"
1614
1769
  data-id="DockerCard-cta"
1615
- disabled={busy}
1770
+ disabled={isBusy}
1616
1771
  onClick={onCta}
1617
1772
  style={!running ? { background: DOCKER_BLUE, color: "white" } : undefined}
1618
1773
  >
1619
- {busy ? <Spinner /> : <ArrowIcon />}
1774
+ {isBusy ? <Spinner /> : <ArrowIcon />}
1620
1775
  <span>{ctaLabel}</span>
1621
1776
  </button>
1622
1777
  </div>