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.
- package/package.json +6 -6
- package/src/backends/homepage-preload.js +2 -0
- package/src/backends/homepage-react/assets/index-B04YSZUc.js +365 -0
- package/src/backends/homepage-react/assets/index-Bs9ihcPL.css +1 -0
- package/src/backends/homepage-react/index.html +2 -2
- package/src/backends/sidecar-ipc.js +79 -20
- package/src/chrome/chrome-launcher.js +67 -7
- package/src/i18n/locales/en.json +32 -0
- package/src/i18n/locales/fr.json +32 -0
- package/src/i18n/locales/ja.json +32 -0
- package/src/i18n/locales/zh-CN.json +32 -0
- package/src/sidecar/docker.js +97 -20
- package/workers/render/src/App.css +35 -0
- package/workers/render/src/App.jsx +170 -15
- package/src/backends/homepage-react/assets/index-C7gQsfPP.js +0 -365
- package/src/backends/homepage-react/assets/index-CKpaMBKz.css +0 -1
package/src/sidecar/docker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
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}
|
|
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
|
|
365
|
-
|
|
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
|
|
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,
|
|
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
|
|
1445
|
-
|
|
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
|
-
|
|
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}
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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 (
|
|
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={
|
|
1770
|
+
disabled={isBusy}
|
|
1616
1771
|
onClick={onCta}
|
|
1617
1772
|
style={!running ? { background: DOCKER_BLUE, color: "white" } : undefined}
|
|
1618
1773
|
>
|
|
1619
|
-
{
|
|
1774
|
+
{isBusy ? <Spinner /> : <ArrowIcon />}
|
|
1620
1775
|
<span>{ctaLabel}</span>
|
|
1621
1776
|
</button>
|
|
1622
1777
|
</div>
|