cicy-desktop 2.1.95 → 2.1.96

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,7 +6,7 @@
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-C7gQsfPP.js"></script>
9
+ <script type="module" crossorigin src="./assets/index-BtVG2Py6.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="./assets/index-CKpaMBKz.css">
11
11
  </head>
12
12
  <body>
@@ -13,6 +13,8 @@
13
13
  // along with src/sidecar/installer.js and src/sidecar/wsl.js.)
14
14
 
15
15
  const { ipcMain } = require("electron");
16
+ const fs = require("fs");
17
+ const os = require("os");
16
18
  const path = require("path");
17
19
  const sidecar = require("../sidecar/cicy-code");
18
20
  const docker = require("../sidecar/docker");
@@ -23,10 +25,23 @@ const PORT = Number(process.env.CICY_CODE_PORT || 8008);
23
25
  // :8009 (its own container + volume), alongside the native local daemon on
24
26
  // :8008. The homepage "Docker cicy-code" card owns its lifecycle; if Docker
25
27
  // Desktop is missing the card installs it first (installer downloads to the
26
- // user's Desktop).
28
+ // user's Desktop). The whole cicy home is persisted to a named volume so the
29
+ // entire container state survives recreation (主人: "把整个 docker 挂出来").
27
30
  const APP_PORT = Number(process.env.CICY_DOCKER_APP_PORT || 8009);
28
31
  const APP_CONTAINER = process.env.CICY_DOCKER_APP_CONTAINER || "cicy-code-docker";
29
- const APP_VOLUME = process.env.CICY_DOCKER_APP_VOLUME || "cicy-ai-docker-data";
32
+ const APP_VOLUME = process.env.CICY_DOCKER_APP_VOLUME || "cicy-team";
33
+ const APP_MOUNT = process.env.CICY_DOCKER_APP_MOUNT || "/home/cicy";
34
+ // The Docker-版 instance reaches the LLM through the cicy gateway, authenticated
35
+ // with the LOCAL 8008 team's api_token (主人: "key 用 local team 8008 的"). 8008
36
+ // is started by default on Windows and its token is already minted by the time
37
+ // the user opens the Docker card.
38
+ const GATEWAY_ENDPOINT = process.env.CICY_AI_GATEWAY_LLM_ENDPOINT || "https://gateway.cicy-ai.com";
39
+ function readLocalApiToken() {
40
+ try {
41
+ const p = path.join(os.homedir(), "cicy-ai", "global.json");
42
+ return String(JSON.parse(fs.readFileSync(p, "utf8")).api_token || "");
43
+ } catch { return ""; }
44
+ }
30
45
 
31
46
  let registered = false;
32
47
 
@@ -104,42 +119,86 @@ function register({ sidecarLogPath } = {}) {
104
119
  }
105
120
  });
106
121
 
122
+ // Common run options for the :8009 instance: its own container/volume, the
123
+ // whole-home mount, and the LLM gateway env keyed by the 8008 team's token.
124
+ const appOpts = () => {
125
+ const token = readLocalApiToken();
126
+ const env = { CICY_AI_GATEWAY_LLM_ENDPOINT: GATEWAY_ENDPOINT };
127
+ if (token) env.CICY_AI_GATEWAY_LLM_API_KEY = token;
128
+ return { port: APP_PORT, container: APP_CONTAINER, volume: APP_VOLUME, mountTarget: APP_MOUNT, env };
129
+ };
130
+ // Register the running :8009 instance as a (custom) team so the card's "打开"
131
+ // reuses the token-injected open/reload flow. addTeam dedups by host:port.
132
+ const registerAppTeam = async () => {
133
+ try {
134
+ const lt = require("./local-teams");
135
+ const tok = await docker.readContainerToken(APP_PORT);
136
+ await lt.addTeam({ base_url: `http://127.0.0.1:${APP_PORT}`, name: "Docker cicy-code", ...(tok ? { api_token: tok } : {}) });
137
+ } catch { /* best-effort — the container itself is up */ }
138
+ };
139
+
107
140
  // One-click bootstrap of the Docker-版 instance: install Docker Desktop if
108
- // missing (installer → user's Desktop), load the image, start the :8009
109
- // container (its own name/volume), wait for health. Streams phase/progress on
110
- // 'docker:app-progress' so the card's modal mirrors the cicy-code 升级 modal.
141
+ // missing (installer → Desktop) WHILE downloading the R2 image (→ ~/Downloads)
142
+ // in parallel, import it, start the :8009 container, wait for health. Streams
143
+ // phase/progress on 'docker:app-progress' so the card's modal mirrors the
144
+ // cicy-code 升级 modal. Idempotent + resumable → the modal's 重试 just re-runs.
111
145
  ipcMain.handle("docker:app-bootstrap", async (e) => {
112
146
  if (process.platform !== "win32") return { ok: false, error: "Docker cicy-code is Windows-only" };
113
147
  try {
114
148
  const installDest = path.join(docker.desktopDir(), "Docker Desktop Installer.exe");
115
149
  const result = await docker.bootstrap({
116
- port: APP_PORT, container: APP_CONTAINER, volume: APP_VOLUME, installDest,
150
+ ...appOpts(), installDest,
117
151
  onProgress: (ev) => { try { e.sender.send("docker:app-progress", ev); } catch {} },
118
152
  });
119
- // Healthy register :8009 as a (custom) team so the card's "打开" reuses
120
- // the token-injected open/reload flow. addTeam dedups by host:port.
121
- if (result && result.ok) {
122
- try {
123
- const lt = require("./local-teams");
124
- const tok = await docker.readContainerToken(APP_PORT);
125
- await lt.addTeam({
126
- base_url: `http://127.0.0.1:${APP_PORT}`, name: "Docker cicy-code",
127
- ...(tok ? { api_token: tok } : {}),
128
- });
129
- } catch { /* best-effort — the container itself is up */ }
130
- }
153
+ if (result && result.ok) await registerAppTeam();
131
154
  return result;
132
155
  } catch (err) {
133
156
  return { ok: false, error: err.message };
134
157
  }
135
158
  });
136
159
 
137
- // Stop + remove the :8009 Docker container (card's "停止").
160
+ // menu 重启: `docker restart` the :8009 container, wait for health.
161
+ ipcMain.handle("docker:app-restart", async () => {
162
+ try {
163
+ await docker.restart({ container: APP_CONTAINER });
164
+ const ok = await docker.waitUntil(() => docker.probeHealth(APP_PORT), { totalMs: 60000, everyMs: 2000 });
165
+ return { ok };
166
+ } catch (e) { return { ok: false, error: e.message }; }
167
+ });
168
+
169
+ // ⋯ menu → 停止: graceful `docker stop` (keeps the container; data persists in
170
+ // the named volume). The card's 启动 path re-creates/starts it.
138
171
  ipcMain.handle("docker:app-stop", async () => {
139
- try { await docker.stop({ container: APP_CONTAINER }); return { ok: true }; }
172
+ try { await docker.stopContainer({ container: APP_CONTAINER }); return { ok: true }; }
140
173
  catch (e) { return { ok: false, error: e.message }; }
141
174
  });
142
175
 
176
+ // ⋯ menu → 升级: re-pull the latest R2 image (→ ~/Downloads, resume/skip + bad-
177
+ // partial delete), import it, re-create the :8009 container on the new image.
178
+ // Streams on 'docker:app-progress' so the same modal shows the upgrade.
179
+ ipcMain.handle("docker:app-upgrade", async (e) => {
180
+ if (process.platform !== "win32") return { ok: false, error: "Docker cicy-code is Windows-only" };
181
+ const emit = (ev) => { try { e.sender.send("docker:app-progress", ev); } catch {} };
182
+ try {
183
+ if (!(await docker.dockerOk())) { emit({ phase: "done", status: "error", message: "Docker 未运行" }); return { ok: false, error: "docker_not_running" }; }
184
+ const tmp = await docker.downloadImageTarball({ emit });
185
+ await docker.loadImageFromTarball(tmp, { emit });
186
+ emit({ phase: "image", status: "done", message: "镜像已更新" });
187
+ emit({ phase: "container", status: "running", message: "用新镜像重建容器…" });
188
+ await docker.stop({ container: APP_CONTAINER });
189
+ const child = await docker.start(appOpts());
190
+ if (!child) { emit({ phase: "done", status: "error", message: "容器启动失败" }); return { ok: false, error: "container_start_failed" }; }
191
+ emit({ phase: "health", status: "running", message: "等待就绪…" });
192
+ const ok = await docker.waitUntil(() => docker.probeHealth(APP_PORT), { totalMs: 120000, everyMs: 3000 });
193
+ emit({ phase: "done", status: ok ? "done" : "error", message: ok ? "升级完成 🎉" : "启动了但 :8009 还没响应" });
194
+ if (ok) await registerAppTeam();
195
+ return { ok };
196
+ } catch (err) {
197
+ emit({ phase: "done", status: "error", message: `升级失败:${err.message}` });
198
+ return { ok: false, error: err.message };
199
+ }
200
+ });
201
+
143
202
  // Start (or reuse) the cicy-code daemon. probeExisting inside start() reuses
144
203
  // a healthy :8008; otherwise it spawns `npx cicy-code` / the Docker container.
145
204
  ipcMain.handle("sidecar:start", async () => {
@@ -121,5 +121,37 @@
121
121
  "scrollHint": "Please read to the end to continue",
122
122
  "menu": "Terms of Use",
123
123
  "close": "Close"
124
+ },
125
+ "docker": {
126
+ "title": "Docker cicy-code",
127
+ "install": "Download & Install",
128
+ "start": "Start",
129
+ "running": "Running · :8009",
130
+ "notRunning": "Stopped · click Start",
131
+ "notInstalled": "Docker Desktop not installed",
132
+ "working": "Working…",
133
+ "ready": "Docker cicy-code is ready",
134
+ "failed": "Install failed",
135
+ "upgraded": "Upgraded to latest",
136
+ "upgradeFailed": "Upgrade failed",
137
+ "manage": "Manage Docker cicy-code",
138
+ "restart": "Restart",
139
+ "restarted": "Restarted",
140
+ "restarting": "Restarting…",
141
+ "stop": "Stop",
142
+ "stopped": "Stopped",
143
+ "stoping": "Stopping…",
144
+ "upgrade": "Upgrade (pull latest image)",
145
+ "opFailed": "Operation failed",
146
+ "setupTitle": "Install Docker cicy-code",
147
+ "busy": "In progress",
148
+ "preparing": "Preparing…",
149
+ "installing2": "Installing…",
150
+ "background": "Continue in background"
151
+ },
152
+ "common": {
153
+ "close": "Close",
154
+ "retry": "Retry",
155
+ "done": "Done"
124
156
  }
125
157
  }
@@ -120,5 +120,37 @@
120
120
  "scrollHint": "Veuillez lire jusqu'au bout pour continuer",
121
121
  "menu": "Conditions d'utilisation",
122
122
  "close": "Fermer"
123
+ },
124
+ "docker": {
125
+ "title": "Docker cicy-code",
126
+ "install": "Télécharger et installer",
127
+ "start": "Démarrer",
128
+ "running": "En cours · :8009",
129
+ "notRunning": "Arrêté · cliquez sur Démarrer",
130
+ "notInstalled": "Docker Desktop non installé",
131
+ "working": "En cours…",
132
+ "ready": "Docker cicy-code est prêt",
133
+ "failed": "Échec de l'installation",
134
+ "upgraded": "Mis à jour",
135
+ "upgradeFailed": "Échec de la mise à niveau",
136
+ "manage": "Gérer Docker cicy-code",
137
+ "restart": "Redémarrer",
138
+ "restarted": "Redémarré",
139
+ "restarting": "Redémarrage…",
140
+ "stop": "Arrêter",
141
+ "stopped": "Arrêté",
142
+ "stoping": "Arrêt…",
143
+ "upgrade": "Mettre à niveau (dernière image)",
144
+ "opFailed": "Échec de l'opération",
145
+ "setupTitle": "Installer Docker cicy-code",
146
+ "busy": "En cours",
147
+ "preparing": "Préparation…",
148
+ "installing2": "Installation…",
149
+ "background": "Continuer en arrière-plan"
150
+ },
151
+ "common": {
152
+ "close": "Fermer",
153
+ "retry": "Réessayer",
154
+ "done": "Terminé"
123
155
  }
124
156
  }
@@ -120,5 +120,37 @@
120
120
  "scrollHint": "続行するには最後までお読みください",
121
121
  "menu": "利用規約",
122
122
  "close": "閉じる"
123
+ },
124
+ "docker": {
125
+ "title": "Docker cicy-code",
126
+ "install": "ダウンロードしてインストール",
127
+ "start": "起動",
128
+ "running": "実行中 · :8009",
129
+ "notRunning": "停止中 · 「起動」をクリック",
130
+ "notInstalled": "Docker Desktop 未インストール",
131
+ "working": "処理中…",
132
+ "ready": "Docker cicy-code の準備完了",
133
+ "failed": "インストール失敗",
134
+ "upgraded": "最新へ更新しました",
135
+ "upgradeFailed": "アップグレード失敗",
136
+ "manage": "Docker cicy-code を管理",
137
+ "restart": "再起動",
138
+ "restarted": "再起動しました",
139
+ "restarting": "再起動中…",
140
+ "stop": "停止",
141
+ "stopped": "停止しました",
142
+ "stoping": "停止中…",
143
+ "upgrade": "アップグレード(最新イメージ取得)",
144
+ "opFailed": "操作に失敗しました",
145
+ "setupTitle": "Docker cicy-code をインストール",
146
+ "busy": "進行中",
147
+ "preparing": "準備中…",
148
+ "installing2": "インストール中…",
149
+ "background": "バックグラウンドで継続"
150
+ },
151
+ "common": {
152
+ "close": "閉じる",
153
+ "retry": "再試行",
154
+ "done": "完了"
123
155
  }
124
156
  }
@@ -121,5 +121,37 @@
121
121
  "scrollHint": "请阅读至底部以继续",
122
122
  "menu": "用户协议",
123
123
  "close": "关闭"
124
+ },
125
+ "docker": {
126
+ "title": "Docker cicy-code",
127
+ "install": "下载安装",
128
+ "start": "启动",
129
+ "running": "运行中 · :8009",
130
+ "notRunning": "未启动 · 点「启动」",
131
+ "notInstalled": "Docker Desktop 未安装",
132
+ "working": "处理中…",
133
+ "ready": "Docker cicy-code 已就绪",
134
+ "failed": "安装失败",
135
+ "upgraded": "已升级到最新",
136
+ "upgradeFailed": "升级失败",
137
+ "manage": "管理 Docker cicy-code",
138
+ "restart": "重启",
139
+ "restarted": "已重启",
140
+ "restarting": "重启中…",
141
+ "stop": "停止",
142
+ "stopped": "已停止",
143
+ "stoping": "停止中…",
144
+ "upgrade": "升级(拉取最新镜像)",
145
+ "opFailed": "操作失败",
146
+ "setupTitle": "安装 Docker cicy-code",
147
+ "busy": "进行中",
148
+ "preparing": "准备中…",
149
+ "installing2": "安装进行中…",
150
+ "background": "在后台继续"
151
+ },
152
+ "common": {
153
+ "close": "关闭",
154
+ "retry": "重试",
155
+ "done": "完成"
124
156
  }
125
157
  }
@@ -157,13 +157,23 @@ 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) => {
@@ -226,11 +236,28 @@ function probeHealth(port = 8008, timeoutMs = 2500) {
226
236
  });
227
237
  }
228
238
 
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: "下载镜像" });
239
+ // The R2 image tarball downloads to ~/Downloads (主人: docker image 下到
240
+ // ~/Downloads visible, like the Docker installer on the Desktop). STABLE name
241
+ // (no pid) so a re-run reuses an existing partial/complete file (resume-friendly
242
+ // on a flaky network).
243
+ function imageTarballPath() {
244
+ const dir = path.join(process.env["USERPROFILE"] || os.homedir(), "Downloads");
245
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
246
+ return path.join(dir, "cicy-code-latest.tar.gz");
247
+ }
248
+
249
+ // Download the R2 base-env image tarball (no docker needed yet). Split out of
250
+ // loadImage so bootstrap can run this IN PARALLEL with the Docker Desktop
251
+ // install (主人: 装 Docker 的同时下载 R2 镜像). Returns the tarball path.
252
+ async function downloadImageTarball({ emit } = {}) {
253
+ const dest = imageTarballPath();
254
+ await ensureDownloaded(R2_TARBALL, dest, null, { emit, phase: "image", label: "下载镜像", freshOnIncomplete: true });
255
+ return dest;
256
+ }
257
+
258
+ // `docker load` an already-downloaded tarball + re-tag to IMAGE. Needs the
259
+ // daemon up, so this runs AFTER Docker is ready (主人: 再导入 docker).
260
+ async function loadImageFromTarball(tmp, { emit } = {}) {
234
261
  emit && emit({ phase: "image", status: "running", message: "docker load…", progress: 100 });
235
262
  console.log(`[docker-sidecar] docker load…`);
236
263
  const { stdout } = await run(["load", "-i", tmp], { timeout: 300000 });
@@ -242,9 +269,23 @@ async function loadImage({ emit } = {}) {
242
269
  try { await run(["tag", m[1], IMAGE]); console.log(`[docker-sidecar] tagged ${m[1]} -> ${IMAGE}`); }
243
270
  catch (e) { console.warn(`[docker-sidecar] re-tag failed: ${e.message}`); }
244
271
  }
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 {}
272
+ // Keep the tarball in ~/Downloads (主人: 下到 Downloads) it's a visible,
273
+ // resume-friendly cache; imagePresent() gates re-entry so we don't re-load it.
274
+ }
275
+
276
+ // Download-then-import in one shot (sequential). Used when Docker is already up.
277
+ async function loadImage({ emit } = {}) {
278
+ const tmp = await downloadImageTarball({ emit });
279
+ await loadImageFromTarball(tmp, { emit });
280
+ }
281
+
282
+ // `docker restart` / graceful `docker stop` for a given container — the Docker-版
283
+ // card's ⋯ menu (重启 / 停止), mirroring the 8008 local card's lifecycle menu.
284
+ async function restart({ container = CONTAINER } = {}) {
285
+ await run(["restart", container], { timeout: 60000 });
286
+ }
287
+ async function stopContainer({ container = CONTAINER } = {}) {
288
+ try { await run(["stop", container], { timeout: 30000 }); } catch {}
248
289
  }
249
290
 
250
291
  async function checkStatus() {
@@ -264,7 +305,7 @@ function desktopDir() {
264
305
  // Docker Desktop). `container`/`volume` are parameterized so a SECOND instance
265
306
  // (the Docker-版 cicy-code on :8009) can run alongside the native local one
266
307
  // without a name/volume collision.
267
- async function start({ port = 8008, container = CONTAINER, volume = VOLUME } = {}) {
308
+ async function start({ port = 8008, container = CONTAINER, volume = VOLUME, mountTarget = "/home/cicy/cicy-ai", env = {} } = {}) {
268
309
  // Something already serves a healthy cicy-code on :port (a legacy-named
269
310
  // container auto-revived by `--restart unless-stopped`, a manual run…).
270
311
  // Adopt it — `docker run` would just lose the port-bind fight.
@@ -283,14 +324,23 @@ async function start({ port = 8008, container = CONTAINER, volume = VOLUME } = {
283
324
  // Replace any stale container of the same name.
284
325
  try { await run(["rm", "-f", container]); } catch {}
285
326
 
327
+ // mountTarget defaults to /home/cicy/cicy-ai (legacy local-team layout); the
328
+ // Docker-版 instance passes /home/cicy to persist the WHOLE cicy home (主人:
329
+ // "把整个 docker 挂出来" — everything mutable lives under /home/cicy: global.json,
330
+ // db, agents, files, the npm-installed cicy-code itself).
286
331
  const args = [
287
332
  "run", "-d", "--name", container, "--restart", "unless-stopped",
288
333
  "-p", `${port}:8008`,
289
- "-v", `${volume}:/home/cicy/cicy-ai`,
334
+ "-v", `${volume}:${mountTarget}`,
290
335
  ];
291
336
  for (const k of PASS_ENV) {
292
337
  if (process.env[k]) args.push("-e", `${k}=${process.env[k]}`);
293
338
  }
339
+ // Caller-supplied env (e.g. the LLM gateway endpoint + key for the Docker-版
340
+ // instance, which bills through the 8008 local team's token).
341
+ for (const [k, v] of Object.entries(env || {})) {
342
+ if (v != null && v !== "") args.push("-e", `${k}=${v}`);
343
+ }
294
344
  args.push(IMAGE);
295
345
 
296
346
  const { stdout } = await run(args, { timeout: 60000 });
@@ -314,7 +364,7 @@ async function installDocker({ emit, dest } = {}) {
314
364
  try { fs.mkdirSync(path.dirname(target), { recursive: true }); } catch {}
315
365
  e({ phase: "install-docker", status: "running", message: "下载 Docker Desktop 安装包…", progress: 0 });
316
366
  await ensureDownloaded(DOCKER_DESKTOP_URL, target, DOCKER_DESKTOP_MIRROR, {
317
- emit, phase: "install-docker", label: "下载 Docker Desktop",
367
+ emit, phase: "install-docker", label: "下载 Docker Desktop", freshOnIncomplete: true,
318
368
  });
319
369
  e({ phase: "install-docker", status: "running", message: "安装 Docker Desktop(请在弹出的授权框点「是」,装完可能需重启)…" });
320
370
  await new Promise((resolve) => {
@@ -333,16 +383,22 @@ async function installDocker({ emit, dest } = {}) {
333
383
  // start the container → wait for :8008. Every step CHECKS first and SKIPS if
334
384
  // already done, emits coarse phase events + byte progress, and the downloads
335
385
  // 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 } = {}) {
386
+ async function bootstrap({ onProgress, port = 8008, container = CONTAINER, volume = VOLUME, mountTarget, env, installDest } = {}) {
337
387
  const emit = (ev) => { try { onProgress && onProgress(ev); } catch {} };
338
388
 
389
+ // Decide up-front whether the base image needs fetching, so we can download
390
+ // the R2 tarball IN PARALLEL with the Docker Desktop install below.
391
+ const needImage = !(await imagePresent());
392
+ let imgDl = null; // Promise<tarballPath|null> when downloading in parallel
393
+
339
394
  // 1) Docker present?
340
395
  if (await dockerOk()) {
341
396
  emit({ phase: "install-docker", status: "skip", message: "Docker 已安装,跳过" });
342
397
  } else if (dockerDesktopExe()) {
343
398
  // Installed but the daemon is down — just launch Docker Desktop, never
344
- // re-download/re-run the installer ("步骤走过的不要再走").
399
+ // re-download/re-run the installer (主人: 装了就别再下 Docker Desktop 了).
345
400
  emit({ phase: "install-docker", status: "running", message: "Docker 已安装,正在启动 Docker Desktop…" });
401
+ if (needImage) imgDl = downloadImageTarball({ emit }).catch((e) => { emit({ phase: "image", status: "error", message: `镜像下载失败:${e.message}` }); return null; });
346
402
  startDockerDesktop();
347
403
  const up = await waitUntil(dockerOk, { totalMs: 300000, everyMs: 5000 });
348
404
  if (!up) {
@@ -351,6 +407,9 @@ async function bootstrap({ onProgress, port = 8008, container = CONTAINER, volum
351
407
  }
352
408
  emit({ phase: "install-docker", status: "done", message: "Docker 就绪" });
353
409
  } else {
410
+ // Docker missing → download the R2 image IN PARALLEL with the installer
411
+ // running + the daemon coming up (主人: 装 Docker 的同时下载 R2 镜像).
412
+ if (needImage) imgDl = downloadImageTarball({ emit }).catch((e) => { emit({ phase: "image", status: "error", message: `镜像下载失败:${e.message}` }); return null; });
354
413
  await installDocker({ emit, dest: installDest });
355
414
  emit({ phase: "install-docker", status: "running", message: "等待 Docker 启动(如需授权/重启,完成后会自动继续)…" });
356
415
  const up = await waitUntil(dockerOk, { totalMs: 900000, everyMs: 6000 });
@@ -361,12 +420,16 @@ async function bootstrap({ onProgress, port = 8008, container = CONTAINER, volum
361
420
  emit({ phase: "install-docker", status: "done", message: "Docker 就绪" });
362
421
  }
363
422
 
364
- // 2) Base image present?
365
- if (await imagePresent()) {
423
+ // 2) Base image — import it (docker load). If pre-downloaded in parallel, just
424
+ // load; otherwise (re)download now. Downloads resume/skip + delete bad partials.
425
+ if (!needImage) {
366
426
  emit({ phase: "image", status: "skip", message: "镜像已就绪,跳过" });
367
427
  } else {
368
428
  try {
369
- await loadImage({ emit });
429
+ let tmp = imgDl ? await imgDl : null;
430
+ if (!tmp) tmp = await downloadImageTarball({ emit }); // not pre-dl'd / parallel dl failed → fetch now
431
+ emit({ phase: "image", status: "running", message: "导入 Docker 镜像…", progress: 100 });
432
+ await loadImageFromTarball(tmp, { emit });
370
433
  emit({ phase: "image", status: "done", message: "镜像就绪" });
371
434
  } catch (e) {
372
435
  emit({ phase: "image", status: "error", message: `镜像加载失败:${e.message}(点重试,下载会续传)` });
@@ -381,7 +444,7 @@ async function bootstrap({ onProgress, port = 8008, container = CONTAINER, volum
381
444
  } else {
382
445
  emit({ phase: "container", status: "running", message: "启动 cicy-code 容器…" });
383
446
  let child = null;
384
- try { child = await start({ port, container, volume }); }
447
+ try { child = await start({ port, container, volume, mountTarget, env }); }
385
448
  catch (e) { emit({ phase: "container", status: "error", message: `容器启动失败:${e.message}` }); return { ok: false, reason: "container_start_failed" }; }
386
449
  if (!child) {
387
450
  emit({ phase: "container", status: "error", message: "容器启动失败" });
@@ -397,7 +460,8 @@ async function bootstrap({ onProgress, port = 8008, container = CONTAINER, volum
397
460
  }
398
461
 
399
462
  module.exports = {
400
- start, stop, checkStatus, loadImage, imagePresent, dockerOk, installDocker,
463
+ start, stop, stopContainer, restart, checkStatus, loadImage, loadImageFromTarball,
464
+ downloadImageTarball, imagePresent, dockerOk, installDocker,
401
465
  bootstrap, probeHealth, readContainerToken, dockerDesktopExe, desktopDir,
402
466
  // platform-agnostic download/retry primitives, reused by native.js
403
467
  ensureDownloaded, withRetry, waitUntil, run,