cicy-desktop 2.1.60 → 2.1.62

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cicy-desktop",
3
- "version": "2.1.60",
3
+ "version": "2.1.62",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -45,10 +45,25 @@ function register({ sidecarLogPath } = {}) {
45
45
  ipcMain.handle("docker:bootstrap", async (e) => {
46
46
  if (process.platform !== "win32") return { ok: false, error: "docker bootstrap is Windows-only" };
47
47
  try {
48
- return await docker.bootstrap({
48
+ const result = await docker.bootstrap({
49
49
  port: PORT,
50
50
  onProgress: (ev) => { try { e.sender.send("docker:bootstrap-progress", ev); } catch {} },
51
51
  });
52
+ // Healthy local stack → make sure it shows up as a team ("本地团队就加
53
+ // 上去了"). addTeam dedups by host:port, so re-runs are no-ops. The
54
+ // api_token must be the CONTAINER's own (volume global.json) — the
55
+ // host's token is a different credential and fails verify.
56
+ if (result && result.ok) {
57
+ try {
58
+ const lt = require("./local-teams");
59
+ const tok = await docker.readContainerToken(PORT);
60
+ await lt.addTeam({
61
+ base_url: `http://127.0.0.1:${PORT}`, name: "本地团队",
62
+ ...(tok ? { api_token: tok } : {}),
63
+ });
64
+ } catch { /* best-effort — the stack itself is up */ }
65
+ }
66
+ return result;
52
67
  } catch (err) {
53
68
  return { ok: false, error: err.message };
54
69
  }
@@ -42,6 +42,28 @@ async function dockerOk() {
42
42
  catch { return false; }
43
43
  }
44
44
 
45
+ // Docker Desktop installed on disk? (daemon may still be stopped — dockerOk()
46
+ // only answers "is the daemon up"). Lets bootstrap start the app instead of
47
+ // re-downloading the 500MB installer when it's merely not running.
48
+ function dockerDesktopExe() {
49
+ const candidates = [
50
+ path.join(process.env["ProgramFiles"] || "C:\\Program Files", "Docker", "Docker", "Docker Desktop.exe"),
51
+ path.join(process.env["LOCALAPPDATA"] || "", "Docker", "Docker Desktop.exe"),
52
+ ];
53
+ for (const p of candidates) { try { if (p && fs.existsSync(p)) return p; } catch {} }
54
+ return null;
55
+ }
56
+
57
+ function startDockerDesktop() {
58
+ const exe = dockerDesktopExe();
59
+ if (!exe) return false;
60
+ try {
61
+ const child = spawn(exe, [], { detached: true, stdio: "ignore", windowsHide: false });
62
+ child.unref();
63
+ return true;
64
+ } catch { return false; }
65
+ }
66
+
45
67
  async function imagePresent() {
46
68
  try { await run(["image", "inspect", IMAGE], { timeout: 8000 }); return true; }
47
69
  catch { return false; }
@@ -143,12 +165,15 @@ async function ensureDownloaded(url, dest, mirror, { emit, phase, label } = {})
143
165
  return dest;
144
166
  }
145
167
  const sources = mirror ? [url, mirror] : [url];
168
+ let lastPct = -1; // throttle: chunks arrive dozens/s — only emit on whole-percent change
146
169
  return withRetry(async (attempt) => {
147
170
  const src = sources[Math.min(attempt - 1, sources.length - 1)];
148
171
  await download(src, dest, {
149
172
  resume: true,
150
173
  onProgress: ({ received, total }) => {
151
174
  const pct = total ? Math.round((received / total) * 100) : 0;
175
+ if (pct === lastPct) return;
176
+ lastPct = pct;
152
177
  emit && emit({ phase, status: "running", message: label, progress: pct, received, total });
153
178
  },
154
179
  });
@@ -164,6 +189,20 @@ async function ensureDownloaded(url, dest, mirror, { emit, phase, label } = {})
164
189
  });
165
190
  }
166
191
 
192
+ // The container's cicy-code mints its own api_token in its volume-persisted
193
+ // global.json — the HOST's global.json token is a different credential and
194
+ // won't verify. Read the real one out of whatever container publishes :port
195
+ // (works for adopted legacy-named containers too).
196
+ async function readContainerToken(port = 8008) {
197
+ try {
198
+ const { stdout } = await run(["ps", "--filter", `publish=${port}`, "--format", "{{.Names}}"]);
199
+ const name = stdout.trim().split("\n")[0];
200
+ if (!name) return "";
201
+ const r = await run(["exec", name, "cat", "/home/cicy/cicy-ai/global.json"], { timeout: 10000 });
202
+ return (JSON.parse(r.stdout).api_token || "");
203
+ } catch { return ""; }
204
+ }
205
+
167
206
  // HTTP /health probe of the container on :port.
168
207
  function probeHealth(port = 8008, timeoutMs = 2500) {
169
208
  return new Promise((resolve) => {
@@ -182,7 +221,15 @@ async function loadImage({ emit } = {}) {
182
221
  await ensureDownloaded(R2_TARBALL, tmp, null, { emit, phase: "image", label: "下载镜像" });
183
222
  emit && emit({ phase: "image", status: "running", message: "docker load…", progress: 100 });
184
223
  console.log(`[docker-sidecar] docker load…`);
185
- await run(["load", "-i", tmp], { timeout: 300000 });
224
+ const { stdout } = await run(["load", "-i", tmp], { timeout: 300000 });
225
+ // The tarball's embedded tag may be a pinned version (e.g. :2.1.6) while we
226
+ // run IMAGE (:latest). Re-tag whatever was loaded so imagePresent()/start()
227
+ // match — otherwise every start() re-downloads the tarball forever.
228
+ const m = String(stdout).match(/Loaded image:\s*(\S+)/i);
229
+ if (m && m[1] !== IMAGE) {
230
+ try { await run(["tag", m[1], IMAGE]); console.log(`[docker-sidecar] tagged ${m[1]} -> ${IMAGE}`); }
231
+ catch (e) { console.warn(`[docker-sidecar] re-tag failed: ${e.message}`); }
232
+ }
186
233
  // Only delete AFTER a successful load — a failed load keeps the tarball so the
187
234
  // next attempt skips the re-download. (imagePresent() gates re-entry anyway.)
188
235
  try { fs.unlinkSync(tmp); } catch {}
@@ -197,6 +244,13 @@ async function checkStatus() {
197
244
  // id } or null when Docker isn't ready (homepage guides the user to install
198
245
  // Docker Desktop).
199
246
  async function start({ port = 8008 } = {}) {
247
+ // Something already serves a healthy cicy-code on :port (a legacy-named
248
+ // container auto-revived by `--restart unless-stopped`, a manual run…).
249
+ // Adopt it — `docker run` would just lose the port-bind fight.
250
+ if (await probeHealth(port)) {
251
+ console.log(`[docker-sidecar] :${port} already healthy — adopting existing instance`);
252
+ return { docker: true, container: CONTAINER, adopted: true };
253
+ }
200
254
  if (!(await dockerOk())) {
201
255
  console.warn("[docker-sidecar] Docker not available — homepage will guide install");
202
256
  return null;
@@ -261,6 +315,17 @@ async function bootstrap({ onProgress, port = 8008 } = {}) {
261
315
  // 1) Docker present?
262
316
  if (await dockerOk()) {
263
317
  emit({ phase: "install-docker", status: "skip", message: "Docker 已安装,跳过" });
318
+ } else if (dockerDesktopExe()) {
319
+ // Installed but the daemon is down — just launch Docker Desktop, never
320
+ // re-download/re-run the installer ("步骤走过的不要再走").
321
+ emit({ phase: "install-docker", status: "running", message: "Docker 已安装,正在启动 Docker Desktop…" });
322
+ startDockerDesktop();
323
+ const up = await waitUntil(dockerOk, { totalMs: 300000, everyMs: 5000 });
324
+ if (!up) {
325
+ emit({ phase: "install-docker", status: "error", message: "Docker Desktop 启动超时——手动打开它等图标变绿,再点「重试」" });
326
+ return { ok: false, reason: "docker_not_ready" };
327
+ }
328
+ emit({ phase: "install-docker", status: "done", message: "Docker 就绪" });
264
329
  } else {
265
330
  await installDocker({ emit });
266
331
  emit({ phase: "install-docker", status: "running", message: "等待 Docker 启动(如需授权/重启,完成后会自动继续)…" });
@@ -285,12 +350,19 @@ async function bootstrap({ onProgress, port = 8008 } = {}) {
285
350
  }
286
351
  }
287
352
 
288
- // 3) Container — start() already reuses/replaces by name.
289
- emit({ phase: "container", status: "running", message: "启动 cicy-code 容器…" });
290
- const child = await start({ port });
291
- if (!child) {
292
- emit({ phase: "container", status: "error", message: "容器启动失败" });
293
- return { ok: false, reason: "container_start_failed" };
353
+ // 3) Container — skip when :port is already healthy (idempotent re-entry);
354
+ // start() additionally adopts an existing instance instead of port-fighting.
355
+ if (await probeHealth(port)) {
356
+ emit({ phase: "container", status: "skip", message: "本地服务已在运行,跳过" });
357
+ } else {
358
+ emit({ phase: "container", status: "running", message: "启动 cicy-code 容器…" });
359
+ let child = null;
360
+ try { child = await start({ port }); }
361
+ catch (e) { emit({ phase: "container", status: "error", message: `容器启动失败:${e.message}` }); return { ok: false, reason: "container_start_failed" }; }
362
+ if (!child) {
363
+ emit({ phase: "container", status: "error", message: "容器启动失败" });
364
+ return { ok: false, reason: "container_start_failed" };
365
+ }
294
366
  }
295
367
 
296
368
  // 4) Health
@@ -300,4 +372,4 @@ async function bootstrap({ onProgress, port = 8008 } = {}) {
300
372
  return { ok: healthy, container: CONTAINER };
301
373
  }
302
374
 
303
- module.exports = { start, stop, checkStatus, loadImage, imagePresent, dockerOk, installDocker, bootstrap, probeHealth };
375
+ module.exports = { start, stop, checkStatus, loadImage, imagePresent, dockerOk, installDocker, bootstrap, probeHealth, readContainerToken };