cicy-desktop 2.1.96 → 2.1.98

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.
@@ -6,8 +6,8 @@
6
6
  <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
7
7
  <link rel="icon" type="image/png" sizes="256x256" href="./favicon-256.png" />
8
8
  <title>CiCy Desktop</title>
9
- <script type="module" crossorigin src="./assets/index-BtVG2Py6.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-CKpaMBKz.css">
9
+ <script type="module" crossorigin src="./assets/index-Moep5uf-.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-Ka9HcyRP.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
@@ -145,7 +145,7 @@ function register({ sidecarLogPath } = {}) {
145
145
  ipcMain.handle("docker:app-bootstrap", async (e) => {
146
146
  if (process.platform !== "win32") return { ok: false, error: "Docker cicy-code is Windows-only" };
147
147
  try {
148
- const installDest = path.join(docker.desktopDir(), "Docker Desktop Installer.exe");
148
+ const installDest = path.join(docker.downloadsDir(), "Docker Desktop Installer.exe");
149
149
  const result = await docker.bootstrap({
150
150
  ...appOpts(), installDest,
151
151
  onProgress: (ev) => { try { e.sender.send("docker:app-progress", ev); } catch {} },
@@ -1,7 +1,7 @@
1
1
  const fs = require("fs");
2
2
  const os = require("os");
3
3
  const path = require("path");
4
- const { spawn } = require("child_process");
4
+ const { spawn, execFileSync } = require("child_process");
5
5
  const { isPortOpen } = require("../utils/process-utils");
6
6
  const { waitForDebugger, getVersion } = require("./chrome-cdp-client");
7
7
 
@@ -73,19 +73,79 @@ function isDirectPath(binaryPath) {
73
73
  return binaryPath.includes(path.sep) || (process.platform === "win32" && /^[a-zA-Z]:\\/.test(binaryPath));
74
74
  }
75
75
 
76
+ // Windows: Chrome registers its exact install path under "App Paths" on install,
77
+ // independent of where it landed (per-user vs per-machine, custom drive). This is
78
+ // far more reliable than the %ProgramFiles% guesses — those miss per-user installs
79
+ // and break when the launching process runs with a stripped env (e.g. the
80
+ // StartElectron scheduled task), which is why an installed Chrome can still read
81
+ // as "not found".
82
+ function queryWindowsChromeFromRegistry() {
83
+ if (process.platform !== "win32") return null;
84
+ const keys = [
85
+ "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe",
86
+ "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe",
87
+ "HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe",
88
+ ];
89
+ for (const key of keys) {
90
+ try {
91
+ const out = execFileSync("reg", ["query", key, "/ve"], {
92
+ encoding: "utf8",
93
+ windowsHide: true,
94
+ timeout: 5000,
95
+ });
96
+ const m = out.match(/REG_SZ\s+(.+?\.exe)\s*$/im);
97
+ if (m && fs.existsSync(m[1].trim())) return m[1].trim();
98
+ } catch (_) {}
99
+ }
100
+ return null;
101
+ }
102
+
103
+ // PATH lookup for a bare command — `where` on Windows, `which` on posix. Returns
104
+ // the first existing match, else null.
105
+ function whichBinary(cmd) {
106
+ try {
107
+ const tool = process.platform === "win32" ? "where" : "which";
108
+ const out = execFileSync(tool, [cmd], { encoding: "utf8", windowsHide: true, timeout: 5000 });
109
+ const first = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
110
+ if (first && fs.existsSync(first)) return first;
111
+ } catch (_) {}
112
+ return null;
113
+ }
114
+
76
115
  function resolveChromeBinary(binaryPath) {
77
- const candidates = [binaryPath, ...getBinaryCandidates()].filter(Boolean);
116
+ // 1) Explicit override (config.chromeBinary / --chrome-binary) that exists.
117
+ if (binaryPath && isDirectPath(binaryPath) && fs.existsSync(binaryPath)) return binaryPath;
118
+
119
+ // 2) Windows registry App Paths — authoritative, install-location independent.
120
+ const regPath = queryWindowsChromeFromRegistry();
121
+ if (regPath) return regPath;
78
122
 
123
+ // 3) Platform candidates: concrete paths checked for existence; bare commands
124
+ // resolved via PATH (where/which) so we only accept a Chrome that's actually
125
+ // present.
126
+ const candidates = [binaryPath, ...getBinaryCandidates()].filter(Boolean);
127
+ const bareCommands = [];
79
128
  for (const candidate of candidates) {
80
129
  if (isDirectPath(candidate)) {
81
- if (fs.existsSync(candidate)) {
82
- return candidate;
83
- }
84
- continue;
130
+ if (fs.existsSync(candidate)) return candidate;
131
+ } else {
132
+ bareCommands.push(candidate);
133
+ const resolved = whichBinary(candidate);
134
+ if (resolved) return resolved;
85
135
  }
86
- return candidate;
87
136
  }
88
137
 
138
+ // 4) Name-based last resort.
139
+ const byName =
140
+ whichBinary(process.platform === "win32" ? "chrome" : "google-chrome") ||
141
+ whichBinary(process.platform === "win32" ? "chrome.exe" : "chromium");
142
+ if (byName) return byName;
143
+
144
+ // 5) Nothing concrete found. On posix, let spawn try the first bare command
145
+ // (covers exotic PATH setups where `which` itself isn't available); on
146
+ // Windows everything is a concrete path, so fail with a clear message.
147
+ if (bareCommands.length) return bareCommands[0];
148
+
89
149
  throw new Error(
90
150
  "Chrome/Chromium binary not found. Please configure chromeBinary or --chrome-binary."
91
151
  );
@@ -152,6 +152,7 @@
152
152
  "common": {
153
153
  "close": "Close",
154
154
  "retry": "Retry",
155
- "done": "Done"
155
+ "done": "Done",
156
+ "minimize": "Minimize"
156
157
  }
157
158
  }
@@ -151,6 +151,7 @@
151
151
  "common": {
152
152
  "close": "Fermer",
153
153
  "retry": "Réessayer",
154
- "done": "Terminé"
154
+ "done": "Terminé",
155
+ "minimize": "Réduire"
155
156
  }
156
157
  }
@@ -151,6 +151,7 @@
151
151
  "common": {
152
152
  "close": "閉じる",
153
153
  "retry": "再試行",
154
- "done": "完了"
154
+ "done": "完了",
155
+ "minimize": "最小化"
155
156
  }
156
157
  }
@@ -152,6 +152,7 @@
152
152
  "common": {
153
153
  "close": "关闭",
154
154
  "retry": "重试",
155
- "done": "完成"
155
+ "done": "完成",
156
+ "minimize": "最小化"
156
157
  }
157
158
  }
@@ -160,9 +160,10 @@ function headSize(url, hops = 5) {
160
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
+ // Complete file already on disk → skip (主人: 完整的 exe/镜像包就别重下了;用户
164
+ // 自己下到 ~/Downloads 同名文件也走这条直接复用).
164
165
  if (expected > 0 && have === expected) {
165
- emit && emit({ phase, status: "skip", message: `${label}:已下载,跳过`, progress: 100 });
166
+ emit && emit({ phase, status: "skip", message: `${label}:已下载,跳过`, progress: 100, received: have, total: expected, url, dest });
166
167
  return dest;
167
168
  }
168
169
  // A partial left by a PREVIOUS, interrupted/restarted session can be corrupt;
@@ -178,13 +179,18 @@ async function ensureDownloaded(url, dest, mirror, { emit, phase, label, freshOn
178
179
  let lastPct = -1; // throttle: chunks arrive dozens/s — only emit on whole-percent change
179
180
  const attempted = withRetry(async (attempt) => {
180
181
  const src = sources[Math.min(attempt - 1, sources.length - 1)];
182
+ // 断点续传 (主人): resume the partial via a Range request instead of
183
+ // restarting from 0 — efficient on a flaky network. The post-download size
184
+ // check below + loadImage's load-failure cleanup guard against a bad partial.
181
185
  await download(src, dest, {
182
186
  resume: true,
183
187
  onProgress: ({ received, total }) => {
184
188
  const pct = total ? Math.round((received / total) * 100) : 0;
185
189
  if (pct === lastPct) return;
186
190
  lastPct = pct;
187
- emit && emit({ phase, status: "running", message: label, progress: pct, received, total });
191
+ // `url` = source, `dest` = local target path (主人: UI 显示下载目录; lets the
192
+ // user drop a manual download at the same path and have it reused).
193
+ emit && emit({ phase, status: "running", message: label, progress: pct, received, total, url: src, dest });
188
194
  },
189
195
  });
190
196
  if (expected > 0) {
@@ -240,10 +246,16 @@ function probeHealth(port = 8008, timeoutMs = 2500) {
240
246
  // ~/Downloads — visible, like the Docker installer on the Desktop). STABLE name
241
247
  // (no pid) so a re-run reuses an existing partial/complete file (resume-friendly
242
248
  // on a flaky network).
243
- function imageTarballPath() {
249
+ // Both the Docker installer AND the image tarball download here (主人: 都下到
250
+ // ~/Downloads). If the user manually downloads either file to this folder with
251
+ // the SAME name, ensureDownloaded sees a complete file and skips the download.
252
+ function downloadsDir() {
244
253
  const dir = path.join(process.env["USERPROFILE"] || os.homedir(), "Downloads");
245
254
  try { fs.mkdirSync(dir, { recursive: true }); } catch {}
246
- return path.join(dir, "cicy-code-latest.tar.gz");
255
+ return dir;
256
+ }
257
+ function imageTarballPath() {
258
+ return path.join(downloadsDir(), "cicy-code-latest.tar.gz");
247
259
  }
248
260
 
249
261
  // Download the R2 base-env image tarball (no docker needed yet). Split out of
@@ -251,7 +263,7 @@ function imageTarballPath() {
251
263
  // install (主人: 装 Docker 的同时下载 R2 镜像). Returns the tarball path.
252
264
  async function downloadImageTarball({ emit } = {}) {
253
265
  const dest = imageTarballPath();
254
- await ensureDownloaded(R2_TARBALL, dest, null, { emit, phase: "image", label: "下载镜像", freshOnIncomplete: true });
266
+ await ensureDownloaded(R2_TARBALL, dest, null, { emit, phase: "image", label: "下载镜像" });
255
267
  return dest;
256
268
  }
257
269
 
@@ -260,7 +272,16 @@ async function downloadImageTarball({ emit } = {}) {
260
272
  async function loadImageFromTarball(tmp, { emit } = {}) {
261
273
  emit && emit({ phase: "image", status: "running", message: "docker load…", progress: 100 });
262
274
  console.log(`[docker-sidecar] docker load…`);
263
- const { stdout } = await run(["load", "-i", tmp], { timeout: 300000 });
275
+ let stdout;
276
+ try {
277
+ ({ stdout } = await run(["load", "-i", tmp], { timeout: 300000 }));
278
+ } catch (e) {
279
+ // A resumed download can leave a byte-correct-size but corrupt tarball that
280
+ // `docker load` rejects. Delete it so the next attempt re-downloads fresh
281
+ // (断点续传 normally, fresh only when proven bad).
282
+ try { fs.unlinkSync(tmp); } catch {}
283
+ throw e;
284
+ }
264
285
  // The tarball's embedded tag may be a pinned version (e.g. :2.1.6) while we
265
286
  // run IMAGE (:latest). Re-tag whatever was loaded so imagePresent()/start()
266
287
  // match — otherwise every start() re-downloads the tarball forever.
@@ -364,7 +385,7 @@ async function installDocker({ emit, dest } = {}) {
364
385
  try { fs.mkdirSync(path.dirname(target), { recursive: true }); } catch {}
365
386
  e({ phase: "install-docker", status: "running", message: "下载 Docker Desktop 安装包…", progress: 0 });
366
387
  await ensureDownloaded(DOCKER_DESKTOP_URL, target, DOCKER_DESKTOP_MIRROR, {
367
- emit, phase: "install-docker", label: "下载 Docker Desktop", freshOnIncomplete: true,
388
+ emit, phase: "install-docker", label: "下载 Docker Desktop",
368
389
  });
369
390
  e({ phase: "install-docker", status: "running", message: "安装 Docker Desktop(请在弹出的授权框点「是」,装完可能需重启)…" });
370
391
  await new Promise((resolve) => {
@@ -411,7 +432,11 @@ async function bootstrap({ onProgress, port = 8008, container = CONTAINER, volum
411
432
  // running + the daemon coming up (主人: 装 Docker 的同时下载 R2 镜像).
412
433
  if (needImage) imgDl = downloadImageTarball({ emit }).catch((e) => { emit({ phase: "image", status: "error", message: `镜像下载失败:${e.message}` }); return null; });
413
434
  await installDocker({ emit, dest: installDest });
414
- emit({ phase: "install-docker", status: "running", message: "等待 Docker 启动(如需授权/重启,完成后会自动继续)…" });
435
+ // A silent install doesn't auto-launch the daemon explicitly start Docker
436
+ // Desktop once its exe lands so the user doesn't have to (主人: 安装启动有问题).
437
+ emit({ phase: "install-docker", status: "running", message: "启动 Docker Desktop…" });
438
+ const launched = await waitUntil(() => { if (dockerDesktopExe()) { startDockerDesktop(); return true; } return false; }, { totalMs: 120000, everyMs: 5000 });
439
+ emit({ phase: "install-docker", status: "running", message: launched ? "等待 Docker 引擎就绪(首次启动较慢,如弹授权/重启请确认)…" : "等待 Docker 安装完成…" });
415
440
  const up = await waitUntil(dockerOk, { totalMs: 900000, everyMs: 6000 });
416
441
  if (!up) {
417
442
  emit({ phase: "install-docker", status: "error", message: "Docker 还没就绪——装好后启动 Docker Desktop,再点「重试」即可(已完成的步骤不会重来)" });
@@ -462,7 +487,7 @@ async function bootstrap({ onProgress, port = 8008, container = CONTAINER, volum
462
487
  module.exports = {
463
488
  start, stop, stopContainer, restart, checkStatus, loadImage, loadImageFromTarball,
464
489
  downloadImageTarball, imagePresent, dockerOk, installDocker,
465
- bootstrap, probeHealth, readContainerToken, dockerDesktopExe, desktopDir,
490
+ bootstrap, probeHealth, readContainerToken, dockerDesktopExe, desktopDir, downloadsDir, imageTarballPath,
466
491
  // platform-agnostic download/retry primitives, reused by native.js
467
492
  ensureDownloaded, withRetry, waitUntil, run,
468
493
  };
@@ -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,59 @@ 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: 6px; 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
+ }
1007
+ .dlbar__urlk {
1008
+ display: inline-block; min-width: 16px; text-align: center;
1009
+ margin-right: 5px; padding: 0 4px; border-radius: 4px;
1010
+ font-family: inherit; font-size: 9.5px; color: #8fb0f5; background: rgba(91,141,247,.16);
1011
+ }
1012
+
1013
+ /* Drawer header buttons (minimize ‒ / close ×) */
1014
+ .drawer__headbtns { display: flex; align-items: center; gap: 2px; }
1015
+
1016
+ /* Minimized drawer → floating restore chip (op keeps running) */
1017
+ .drawer-min {
1018
+ position: fixed; right: 20px; bottom: 20px; z-index: 2001;
1019
+ display: inline-flex; align-items: center; gap: 9px;
1020
+ padding: 10px 15px; border-radius: 999px; cursor: pointer;
1021
+ background: #16181d; border: 1px solid rgba(125,135,150,.22);
1022
+ box-shadow: 0 10px 30px rgba(0,0,0,.5);
1023
+ color: var(--text, #e6eaf0); font-size: 12.5px; font-weight: 550;
1024
+ animation: drawer-min-in .18s cubic-bezier(.2,.8,.2,1);
1025
+ }
1026
+ .drawer-min:hover { border-color: rgba(125,135,150,.4); background: #1b1e24; }
1027
+ @keyframes drawer-min-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
1028
+ .drawer-min__spark { display: inline-flex; width: 16px; height: 16px; align-items: center; justify-content: center; font-weight: 700; }
1029
+ .drawer-min--done .drawer-min__spark { color: #4ade80; }
1030
+ .drawer-min--error .drawer-min__spark { color: #f87171; }
1031
+ .drawer-min__label { font-variant-numeric: tabular-nums; }
972
1032
  .drawer__log-empty { color: var(--text-dim, #9da7b3); }
973
1033
  .drawer__line { display: flex; align-items: baseline; gap: 8px; padding: 1px 0; }
974
1034
  .drawer__t { color: #6b7686; flex: none; font-variant-numeric: tabular-nums; }
@@ -109,11 +109,13 @@ const updateDrawer = {
109
109
  };
110
110
  emitDrawer();
111
111
  },
112
+ minimize() { if (drawerState) { drawerState = { ...drawerState, minimized: true }; emitDrawer(); } },
113
+ restore() { if (drawerState) { drawerState = { ...drawerState, minimized: false }; emitDrawer(); } },
112
114
  finish({ ok, message } = {}) {
113
115
  if (!drawerState) return;
114
116
  const status = ok ? "done" : "error";
115
117
  const line = { id: ++drawerLogSeq, t: clockHHMMSS(), phase: "done", status, message: message || (ok ? "更新完成" : "更新失败") };
116
- drawerState = { ...drawerState, status, phase: "done", logs: [...drawerState.logs, line], lastAt: Date.now() };
118
+ drawerState = { ...drawerState, status, phase: "done", minimized: false, logs: [...drawerState.logs, line], lastAt: Date.now() };
117
119
  emitDrawer();
118
120
  },
119
121
  close() { drawerState = null; emitDrawer(); },
@@ -136,8 +138,16 @@ function UpdateDrawerHost() {
136
138
  if (!st) return null;
137
139
  const running = st.status === "running";
138
140
  const phaseIdx = DRAWER_PHASES.findIndex(([k]) => k === st.phase);
141
+ if (st.minimized) {
142
+ return (
143
+ <button type="button" className={`drawer-min drawer-min--${st.status}`} data-id="UpdateDrawer-restore" onClick={() => updateDrawer.restore()}>
144
+ <span className="drawer-min__spark">{running ? <Spinner /> : st.status === "done" ? "✓" : "!"}</span>
145
+ <span className="drawer-min__label">更新 cicy-code{st.toVer ? ` · v${st.toVer}` : ""}</span>
146
+ </button>
147
+ );
148
+ }
139
149
  return (
140
- <div className="drawer-scrim" data-id="UpdateDrawer-scrim" onClick={() => { if (!running) updateDrawer.close(); }}>
150
+ <div className="drawer-scrim" data-id="UpdateDrawer-scrim" onClick={() => running ? updateDrawer.minimize() : updateDrawer.close()}>
141
151
  <div className="drawer" data-id="UpdateDrawer" data-status={st.status} onClick={(e) => e.stopPropagation()}>
142
152
  <div className="drawer__head">
143
153
  <div className="drawer__title">
@@ -149,7 +159,10 @@ function UpdateDrawerHost() {
149
159
  <div className="drawer__sub">{st.fromVer ? `v${st.fromVer}` : "当前"} → {st.toVer ? `v${st.toVer}` : "最新版"}</div>
150
160
  </div>
151
161
  </div>
152
- <button type="button" className="drawer__x" data-id="UpdateDrawer-close" disabled={running} title={running ? "更新进行中" : "关闭"} onClick={() => updateDrawer.close()} aria-label="close">×</button>
162
+ <div className="drawer__headbtns">
163
+ <button type="button" className="drawer__x" data-id="UpdateDrawer-min" title="最小化" onClick={() => updateDrawer.minimize()} aria-label="minimize">‒</button>
164
+ <button type="button" className="drawer__x" data-id="UpdateDrawer-close" title="关闭" onClick={() => running ? updateDrawer.minimize() : updateDrawer.close()} aria-label="close">×</button>
165
+ </div>
153
166
  </div>
154
167
 
155
168
  <div className="drawer__steps" data-id="UpdateDrawer-steps">
@@ -189,7 +202,6 @@ function UpdateDrawerHost() {
189
202
  {running ? (
190
203
  <>
191
204
  <span className="drawer__foot-status">更新进行中…</span>
192
- <button type="button" className="drawer__btn" data-id="UpdateDrawer-background" onClick={() => updateDrawer.close()}>在后台继续</button>
193
205
  </>
194
206
  ) : st.status === "error" ? (
195
207
  <>
@@ -1022,7 +1034,19 @@ function Header({ me, welcome, onLogout, mitmTeam }) {
1022
1034
  const [trustOpen, setTrustOpen] = useState(false);
1023
1035
  const [auditOpen, setAuditOpen] = useState(false);
1024
1036
  const [termsOpen, setTermsOpen] = useState(false);
1037
+ const [appVer, setAppVer] = useState("");
1025
1038
  const wrap = useRef(null);
1039
+ // cicy-desktop's own version, shown at the very bottom of this menu (主人).
1040
+ // app.getVersion() returns { desktop, cicyCodeRef, electron, node } — pick the
1041
+ // desktop version string (was rendering as [object Object]).
1042
+ useEffect(() => {
1043
+ let alive = true;
1044
+ window.cicy?.app?.getVersion?.().then((v) => {
1045
+ if (!alive) return;
1046
+ setAppVer(typeof v === "string" ? v : String(v?.desktop || ""));
1047
+ }).catch(() => {});
1048
+ return () => { alive = false; };
1049
+ }, []);
1026
1050
  // Click-outside closes the dropdown (mirrors LocalTeamCard's ⋯ menu).
1027
1051
  useEffect(() => {
1028
1052
  if (!open) return;
@@ -1077,6 +1101,9 @@ function Header({ me, welcome, onLogout, mitmTeam }) {
1077
1101
  <button type="button" data-id="UserChip-logout" className="user-chip__menu-item is-danger" onClick={() => { setOpen(false); onLogout(); }}>
1078
1102
  退出
1079
1103
  </button>
1104
+ <div className="user-chip__menu-version" data-id="UserChip-version">
1105
+ CiCy Desktop {appVer ? `v${appVer}` : "…"}
1106
+ </div>
1080
1107
  </div>
1081
1108
  )}
1082
1109
  </div>
@@ -1435,27 +1462,71 @@ let dockerDrawerState = null; // null = closed
1435
1462
  function emitDockerDrawer() { dockerDrawerListeners.forEach((l) => l(dockerDrawerState)); }
1436
1463
  const dockerDrawer = {
1437
1464
  open({ onRetry } = {}) {
1438
- dockerDrawerState = { status: "running", phase: "install-docker", logs: [], onRetry: onRetry || null, lastAt: Date.now() };
1465
+ dockerDrawerState = { status: "running", phase: "install-docker", logs: [], bars: {}, minimized: false, onRetry: onRetry || null, lastAt: Date.now() };
1439
1466
  emitDockerDrawer();
1440
1467
  },
1468
+ minimize() { if (dockerDrawerState) { dockerDrawerState = { ...dockerDrawerState, minimized: true }; emitDockerDrawer(); } },
1469
+ restore() { if (dockerDrawerState) { dockerDrawerState = { ...dockerDrawerState, minimized: false }; emitDockerDrawer(); } },
1441
1470
  push(ev = {}) {
1442
1471
  if (!dockerDrawerState) return;
1443
1472
  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() };
1473
+ const next = { ...dockerDrawerState, phase, lastAt: Date.now() };
1474
+ const hasPct = Number.isFinite(ev.progress);
1475
+ const isDl = phase === "install-docker" || phase === "image";
1476
+ // Any download-related event (running %, skip, done — they carry url/dest)
1477
+ // drives a per-phase PROGRESS BAR, not a log line, so the log doesn't
1478
+ // scroll-spam (主人: 下载不要输出滚动/日志太多).
1479
+ if (isDl && (hasPct || ev.dest || ev.url)) {
1480
+ const prev = dockerDrawerState.bars?.[phase] || {};
1481
+ const progress = hasPct ? ev.progress : (ev.status === "skip" || ev.status === "done") ? 100 : prev.progress;
1482
+ next.bars = { ...dockerDrawerState.bars, [phase]: { progress, received: ev.received ?? prev.received, total: ev.total ?? prev.total, url: ev.url || prev.url, dest: ev.dest || prev.dest } };
1483
+ }
1484
+ // Log only milestone events — never the per-% running download ticks.
1485
+ const isRunningTick = ev.status === "running" && hasPct && isDl;
1486
+ if (!isRunningTick) {
1487
+ const line = { id: ++dockerDrawerLogSeq, t: clockHHMMSS(), phase, status: ev.status || "running", message: ev.message || "" };
1488
+ next.logs = [...dockerDrawerState.logs, line];
1489
+ }
1490
+ dockerDrawerState = next;
1446
1491
  emitDockerDrawer();
1447
1492
  },
1448
1493
  finish({ ok, message } = {}) {
1449
1494
  if (!dockerDrawerState) return;
1450
1495
  const status = ok ? "done" : "error";
1451
1496
  const line = { id: ++dockerDrawerLogSeq, t: clockHHMMSS(), phase: "done", status, message: message || (ok ? "完成" : "失败") };
1452
- dockerDrawerState = { ...dockerDrawerState, status, phase: "done", logs: [...dockerDrawerState.logs, line], lastAt: Date.now() };
1497
+ // Pop back open on finish so the user sees the result even if minimized.
1498
+ dockerDrawerState = { ...dockerDrawerState, status, phase: "done", minimized: false, logs: [...dockerDrawerState.logs, line], lastAt: Date.now() };
1453
1499
  emitDockerDrawer();
1454
1500
  },
1455
1501
  close() { dockerDrawerState = null; emitDockerDrawer(); },
1456
1502
  };
1457
1503
  const DOCKER_PHASES = [["install-docker", "装 Docker"], ["image", "加载镜像"], ["container", "启动容器"], ["done", "完成"]];
1458
1504
  const DOCKER_BADGE = { "install-docker": "Docker", image: "镜像", container: "容器", health: "容器", done: "完成" };
1505
+ const DOCKER_DL_LABEL = { "install-docker": "Docker Desktop", image: "基础镜像" };
1506
+ function fmtBytes(n) {
1507
+ if (!Number.isFinite(n)) return "?";
1508
+ if (n < 1024) return n + " B";
1509
+ if (n < 1048576) return (n / 1024).toFixed(0) + " KB";
1510
+ if (n < 1073741824) return (n / 1048576).toFixed(1) + " MB";
1511
+ return (n / 1073741824).toFixed(2) + " GB";
1512
+ }
1513
+ // One fixed (non-scrolling) progress bar per download (Docker Desktop / image),
1514
+ // showing the source URL + % + bytes (主人: 下载做进度条、显示地址、不要滚动).
1515
+ function DownloadBar({ phaseKey, bar }) {
1516
+ const pct = Number.isFinite(bar?.progress) ? Math.max(0, Math.min(100, bar.progress)) : 0;
1517
+ const done = pct >= 100;
1518
+ return (
1519
+ <div className="dlbar" data-id={`DockerDrawer-dlbar-${phaseKey}`}>
1520
+ <div className="dlbar__head">
1521
+ <span className="dlbar__name">{DOCKER_DL_LABEL[phaseKey] || phaseKey}</span>
1522
+ <span className="dlbar__pct">{pct}%{bar?.total ? ` · ${fmtBytes(bar.received)} / ${fmtBytes(bar.total)}` : ""}</span>
1523
+ </div>
1524
+ <div className="dlbar__track"><div className={`dlbar__fill${done ? " is-done" : ""}`} style={{ width: `${pct}%` }} /></div>
1525
+ {bar?.url && <div className="dlbar__url" title={bar.url}><span className="dlbar__urlk">源</span> {bar.url}</div>}
1526
+ {bar?.dest && <div className="dlbar__url" title={bar.dest}><span className="dlbar__urlk">存</span> {bar.dest}</div>}
1527
+ </div>
1528
+ );
1529
+ }
1459
1530
  function DockerInstallDrawerHost() {
1460
1531
  const [st, setSt] = useState(dockerDrawerState);
1461
1532
  useEffect(() => { dockerDrawerListeners.add(setSt); return () => { dockerDrawerListeners.delete(setSt); }; }, []);
@@ -1464,8 +1535,20 @@ function DockerInstallDrawerHost() {
1464
1535
  if (!st) return null;
1465
1536
  const running = st.status === "running";
1466
1537
  const phaseIdx = DOCKER_PHASES.findIndex(([k]) => k === st.phase);
1538
+ const dlBars = ["install-docker", "image"].filter((k) => st.bars?.[k]);
1539
+ // Minimized → a floating restore chip (op keeps running in the background).
1540
+ if (st.minimized) {
1541
+ const pcts = dlBars.map((k) => st.bars[k]?.progress).filter(Number.isFinite);
1542
+ const overall = pcts.length ? Math.round(pcts.reduce((a, b) => a + b, 0) / pcts.length) : null;
1543
+ return (
1544
+ <button type="button" className={`drawer-min drawer-min--${st.status}`} data-id="DockerDrawer-restore" onClick={() => dockerDrawer.restore()}>
1545
+ <span className="drawer-min__spark">{running ? <Spinner /> : st.status === "done" ? "✓" : "!"}</span>
1546
+ <span className="drawer-min__label">{tr("docker.setupTitle", "安装 Docker cicy-code")}{overall != null ? ` · ${overall}%` : ""}</span>
1547
+ </button>
1548
+ );
1549
+ }
1467
1550
  return (
1468
- <div className="drawer-scrim" data-id="DockerDrawer-scrim" onClick={() => { if (!running) dockerDrawer.close(); }}>
1551
+ <div className="drawer-scrim" data-id="DockerDrawer-scrim" onClick={() => running ? dockerDrawer.minimize() : dockerDrawer.close()}>
1469
1552
  <div className="drawer" data-id="DockerDrawer" data-status={st.status} onClick={(e) => e.stopPropagation()}>
1470
1553
  <div className="drawer__head">
1471
1554
  <div className="drawer__title">
@@ -1477,7 +1560,10 @@ function DockerInstallDrawerHost() {
1477
1560
  <div className="drawer__sub">127.0.0.1:8009</div>
1478
1561
  </div>
1479
1562
  </div>
1480
- <button type="button" className="drawer__x" data-id="DockerDrawer-close" disabled={running} title={running ? tr("docker.busy", "进行中") : tr("common.close", "关闭")} onClick={() => dockerDrawer.close()} aria-label="close">×</button>
1563
+ <div className="drawer__headbtns">
1564
+ <button type="button" className="drawer__x" data-id="DockerDrawer-min" title={tr("common.minimize", "最小化")} onClick={() => dockerDrawer.minimize()} aria-label="minimize">‒</button>
1565
+ <button type="button" className="drawer__x" data-id="DockerDrawer-close" title={tr("common.close", "关闭")} onClick={() => running ? dockerDrawer.minimize() : dockerDrawer.close()} aria-label="close">×</button>
1566
+ </div>
1481
1567
  </div>
1482
1568
 
1483
1569
  <div className="drawer__steps" data-id="DockerDrawer-steps">
@@ -1495,14 +1581,20 @@ function DockerInstallDrawerHost() {
1495
1581
  })}
1496
1582
  </div>
1497
1583
 
1498
- <div className="drawer__log" data-id="DockerDrawer-log" ref={logRef}>
1584
+ {dlBars.length > 0 && (
1585
+ <div className="drawer__dlbars" data-id="DockerDrawer-dlbars">
1586
+ {dlBars.map((k) => <DownloadBar key={k} phaseKey={k} bar={st.bars[k]} />)}
1587
+ </div>
1588
+ )}
1589
+
1590
+ <div className="drawer__log drawer__log--scroll" data-id="DockerDrawer-log" ref={logRef}>
1499
1591
  {st.logs.length === 0
1500
1592
  ? <div className="drawer__log-empty">{tr("docker.preparing", "准备中…")}</div>
1501
1593
  : st.logs.map((l) => (
1502
1594
  <div key={l.id} className="drawer__line" data-status={l.status}>
1503
1595
  <span className="drawer__t">{l.t}</span>
1504
1596
  <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>
1597
+ <span className="drawer__linemsg">{l.message}</span>
1506
1598
  </div>
1507
1599
  ))}
1508
1600
  </div>
@@ -1511,7 +1603,6 @@ function DockerInstallDrawerHost() {
1511
1603
  {running ? (
1512
1604
  <>
1513
1605
  <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
1606
  </>
1516
1607
  ) : st.status === "error" ? (
1517
1608
  <>