cicy-desktop 2.1.64 → 2.1.65

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.64",
3
+ "version": "2.1.65",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -180,7 +180,7 @@ async function list({ refresh = false } = {}) {
180
180
  // dom-ready electronRPC injection — bare `new BrowserWindow` strips
181
181
  // the SPA of every desktop tool, which was the regression in the
182
182
  // previous implementation.
183
- function openTeam(id) {
183
+ async function openTeam(id) {
184
184
  const node = readNodes()[id];
185
185
  if (!node) return { ok: false, error: "team not found" };
186
186
  const baseUrl = (node.base_url || "").replace(/\/$/, "");
@@ -201,6 +201,21 @@ function openTeam(id) {
201
201
  try { if (existing.isMinimized()) existing.restore(); } catch {}
202
202
  try { existing.show(); } catch {}
203
203
  try { existing.focus(); } catch {}
204
+ // A reused window can be STUCK at the login screen (its original load
205
+ // had a stale/absent token, e.g. after a token rotation) — focusing it
206
+ // would loop the user at login forever. If the page holds no token,
207
+ // re-navigate with the current ?token= so the SPA can consume it. An
208
+ // authenticated workspace (token present) is left untouched.
209
+ if (token) {
210
+ try {
211
+ const hasTok = await existing.webContents.executeJavaScript(
212
+ "!!localStorage.getItem('api_token')", true);
213
+ if (!hasTok) {
214
+ log.info(`[local-teams] open ${id} → reused win.id=${existing.id} had no token, re-navigating`);
215
+ existing.loadURL(url);
216
+ }
217
+ } catch { /* page not ready / JS blocked — leave as-is */ }
218
+ }
204
219
  log.info(`[local-teams] open ${id} → reused win.id=${existing.id}`);
205
220
  return { ok: true, windowId: existing.id, reused: true };
206
221
  }
@@ -46,9 +46,26 @@ async function start({ logPath, port = DEFAULT_PORT, force = false, version = nu
46
46
  }
47
47
 
48
48
  if (process.platform === "win32") {
49
- // Windows runs cicy-code in Docker Desktop (the container's entrypoint
50
- // npx-installs cicy-code). The docker module owns image-load-from-R2 +
51
- // container run; here we just delegate. (Replaced the old WSL path.)
49
+ // NATIVE route (2026-06 方向): cicy-code.exe + bundled slim MSYS2, no
50
+ // Docker/WSL. Gated behind CICY_WIN_NATIVE=1 while in 联调; the Docker
51
+ // container route below remains the transitional default until native
52
+ // ships.
53
+ if (process.env.CICY_WIN_NATIVE === "1") {
54
+ try {
55
+ const native = require("./native");
56
+ const r = await native.start({ port, logPath });
57
+ if (!r) { console.warn("[cicy-code-sidecar] native start failed"); return null; }
58
+ child = r; // { native:true, pid|adopted, port }
59
+ console.log(`[cicy-code-sidecar] started native exe (${r.adopted ? "adopted" : `pid=${r.pid}`}) on :${port}`);
60
+ return child;
61
+ } catch (e) {
62
+ console.warn(`[cicy-code-sidecar] native start failed: ${e.message}`);
63
+ return null;
64
+ }
65
+ }
66
+ // Transitional: Windows runs cicy-code in Docker Desktop (the container's
67
+ // entrypoint npx-installs cicy-code). The docker module owns
68
+ // image-load-from-R2 + container run; here we just delegate.
52
69
  try {
53
70
  const docker = require("./docker");
54
71
  const r = await docker.start({ port });
@@ -138,7 +155,8 @@ async function killPortListeners(port = DEFAULT_PORT, timeoutMs = 5000) {
138
155
  }
139
156
 
140
157
  async function stop({ timeoutMs = 5000, port = DEFAULT_PORT } = {}) {
141
- // 1) The child we spawned this session (npx) or the Docker container.
158
+ // 1) The child we spawned this session (npx), the Docker container, or the
159
+ // native exe.
142
160
  if (child) {
143
161
  const p = child;
144
162
  child = null;
@@ -146,6 +164,10 @@ async function stop({ timeoutMs = 5000, port = DEFAULT_PORT } = {}) {
146
164
  try { await require("./docker").stop(); } catch {}
147
165
  return;
148
166
  }
167
+ if (p.native) {
168
+ try { await require("./native").stop({ port }); } catch {}
169
+ return;
170
+ }
149
171
  try { p.kill("SIGTERM"); } catch {}
150
172
  const t0 = Date.now();
151
173
  while (p.exitCode === null && Date.now() - t0 < timeoutMs) {
@@ -166,7 +166,7 @@ async function ensureDownloaded(url, dest, mirror, { emit, phase, label } = {})
166
166
  }
167
167
  const sources = mirror ? [url, mirror] : [url];
168
168
  let lastPct = -1; // throttle: chunks arrive dozens/s — only emit on whole-percent change
169
- return withRetry(async (attempt) => {
169
+ const attempted = withRetry(async (attempt) => {
170
170
  const src = sources[Math.min(attempt - 1, sources.length - 1)];
171
171
  await download(src, dest, {
172
172
  resume: true,
@@ -187,6 +187,18 @@ async function ensureDownloaded(url, dest, mirror, { emit, phase, label } = {})
187
187
  onAttempt: ({ attempt, tries, error }) =>
188
188
  emit && emit({ phase, status: "retry", message: `${label}:重试 (${attempt}/${tries})`, error }),
189
189
  });
190
+ return attempted.catch((e) => {
191
+ // Offline fallback: the network (and HEAD) may be dead while a complete
192
+ // file from an earlier run sits on disk — use it instead of dying. Only
193
+ // when we CAN'T prove it incomplete (no expected size, or sizes match).
194
+ let have = 0; try { have = fs.statSync(dest).size; } catch {}
195
+ if (have > 0 && (expected === 0 || have === expected)) {
196
+ console.warn(`[docker-sidecar] download failed (${e.message}) — using existing ${dest} (${have}B, unverified)`);
197
+ emit && emit({ phase, status: "skip", message: `${label}:网络不可达,使用本地已有文件`, progress: 100 });
198
+ return dest;
199
+ }
200
+ throw e;
201
+ });
190
202
  }
191
203
 
192
204
  // The container's cicy-code mints its own api_token in its volume-persisted
@@ -372,4 +384,9 @@ async function bootstrap({ onProgress, port = 8008 } = {}) {
372
384
  return { ok: healthy, container: CONTAINER };
373
385
  }
374
386
 
375
- module.exports = { start, stop, checkStatus, loadImage, imagePresent, dockerOk, installDocker, bootstrap, probeHealth, readContainerToken };
387
+ module.exports = {
388
+ start, stop, checkStatus, loadImage, imagePresent, dockerOk, installDocker,
389
+ bootstrap, probeHealth, readContainerToken,
390
+ // platform-agnostic download/retry primitives, reused by native.js
391
+ ensureDownloaded, withRetry, waitUntil, run,
392
+ };
@@ -0,0 +1,186 @@
1
+ // Windows NATIVE sidecar backend: run cicy-code.exe directly — no Docker, no
2
+ // WSL. (2026-06 方向变更: the Docker route in docker.js is transitional and
3
+ // being retired; this module replaces it once stable.)
4
+ //
5
+ // The exe is a native Go build (w-10084's line). It shells out to a slim
6
+ // bundled MSYS2 (bash/tmux/coreutils…) which it locates itself via
7
+ // CICY_MSYS_ROOT probing — nothing to do here beyond optionally passing the
8
+ // env through. Known exe-side behaviors we rely on:
9
+ // - reads PORT / CICY_CODE_PORT for the listen port
10
+ // - missing optional deps degrade to warnings (never os.Exit)
11
+ // - cold tmux-server start may need ConPTY (w-10084's ensureTmuxServer, WIP)
12
+ //
13
+ // Gate: cicy-code.js picks this module over docker.js when
14
+ // CICY_WIN_NATIVE === "1" (dev flag until the native route ships by default).
15
+ const { spawn, execFile } = require("child_process");
16
+ const fs = require("fs");
17
+ const os = require("os");
18
+ const path = require("path");
19
+
20
+ const docker = require("./docker"); // ensureDownloaded/withRetry/waitUntil/probeHealth/run
21
+
22
+ // Exe acquisition (主人指令 2026-06-07): the PRODUCT path is npm — the
23
+ // cicy-code-win32-x64 subpackage (optionalDependency of cicy-code, os/cpu
24
+ // pinned) carries cicy-code.exe, installed via npmmirror with npm's own
25
+ // caching/resume/version management. NO R2 download in product. The R2
26
+ // direct-pull below survives ONLY for w-10084↔w-10026 联调, gated on an
27
+ // explicitly set CICY_CODE_EXE_URL.
28
+ // dev/联调 only — read LAZILY (not at require time) so tests/tools can set the
29
+ // env var after loading the module.
30
+ const devExeUrl = () => process.env.CICY_CODE_EXE_URL || "";
31
+ const EXE_PKG = process.env.CICY_CODE_EXE_PKG || "cicy-code-win32-x64";
32
+ const REGISTRY = process.env.CICY_NPM_REGISTRY || "https://registry.npmmirror.com";
33
+ const BIN_DIR = path.join(os.homedir(), "cicy-ai", "bin");
34
+ // npm prefix dir owned by the sidecar (isolated from the user's global npm).
35
+ const NPM_PREFIX = path.join(os.homedir(), "cicy-ai", "sidecar-npm");
36
+ const DEV_EXE_PATH = process.env.CICY_CODE_EXE_PATH || path.join(BIN_DIR, "cicy-code.exe");
37
+ const PID_FILE = path.join(BIN_DIR, "cicy-code.pid");
38
+
39
+ function npmExePath() {
40
+ return path.join(NPM_PREFIX, "node_modules", EXE_PKG, "cicy-code.exe");
41
+ }
42
+
43
+ const probeHealth = docker.probeHealth;
44
+
45
+ // Acquire (or reuse) the exe.
46
+ // PRODUCT: npm-install the win32 subpackage into the sidecar's own prefix
47
+ // and resolve the exe inside it — npm brings caching/integrity/versioning,
48
+ // npmmirror keeps CN viable. `version` ("latest" from update()) re-resolves.
49
+ // DEV (联调 only): CICY_CODE_EXE_URL set → resumable R2 download.
50
+ async function ensureExe({ emit, version = null } = {}) {
51
+ if (devExeUrl()) {
52
+ fs.mkdirSync(BIN_DIR, { recursive: true });
53
+ await docker.ensureDownloaded(devExeUrl(), DEV_EXE_PATH, null, {
54
+ emit, phase: "exe", label: "下载 cicy-code.exe (dev)",
55
+ });
56
+ return DEV_EXE_PATH;
57
+ }
58
+ const exe = npmExePath();
59
+ if (!version && fs.existsSync(exe)) return exe;
60
+ const e = emit || (() => {});
61
+ e({ phase: "exe", status: "running", message: "npm 安装 cicy-code (win32)…" });
62
+ fs.mkdirSync(NPM_PREFIX, { recursive: true });
63
+ const spec = `${EXE_PKG}@${version || "latest"}`;
64
+ // npm on Windows is npm.cmd — Node ≥18 (CVE-2024-27980) refuses to spawn
65
+ // .cmd/.bat without shell:true (spawn EINVAL). shell:true concatenates args
66
+ // un-escaped, so quote the one arg that can contain spaces (user dir).
67
+ const quotedPrefix = /\s/.test(NPM_PREFIX) ? `"${NPM_PREFIX}"` : NPM_PREFIX;
68
+ await new Promise((resolve, reject) => {
69
+ execFile("npm", ["i", spec, "--prefix", quotedPrefix, `--registry=${REGISTRY}`, "--no-audit", "--no-fund", "--loglevel=error"],
70
+ { windowsHide: true, timeout: 600000, shell: true },
71
+ (err, _o, stderr) => err ? reject(new Error(`npm i ${spec}: ${String(stderr).slice(0, 200)}`)) : resolve());
72
+ });
73
+ if (!fs.existsSync(exe)) throw new Error(`installed ${spec} but ${exe} missing`);
74
+ e({ phase: "exe", status: "done", message: "cicy-code.exe 就绪" });
75
+ return exe;
76
+ }
77
+
78
+ // One-time migration off the Docker route: the legacy containers (`cicy` from
79
+ // the old flow, `cicy-code` from docker.js) hold :8008 and `--restart
80
+ // unless-stopped` revives them on every daemon start — rm -f BOTH or the port
81
+ // is never free. Best-effort: no Docker installed → nothing to clear.
82
+ async function clearDockerLegacy() {
83
+ for (const name of ["cicy", "cicy-code"]) {
84
+ try { await docker.run(["rm", "-f", name], { timeout: 20000 }); console.log(`[native-sidecar] removed legacy container ${name}`); }
85
+ catch { /* absent or no docker — fine */ }
86
+ }
87
+ }
88
+
89
+ function readPid() {
90
+ try { return Number(fs.readFileSync(PID_FILE, "utf8").trim()) || 0; } catch { return 0; }
91
+ }
92
+
93
+ function pidAlive(pid) {
94
+ if (!pid) return false;
95
+ try { process.kill(pid, 0); return true; } catch { return false; }
96
+ }
97
+
98
+ // PIDs listening on :port via netstat (Windows has no lsof).
99
+ function listPortPids(port) {
100
+ return new Promise((resolve) => {
101
+ execFile("netstat", ["-ano", "-p", "TCP"], { windowsHide: true, timeout: 15000 }, (err, stdout) => {
102
+ if (err) return resolve([]);
103
+ const pids = new Set();
104
+ for (const line of String(stdout).split(/\r?\n/)) {
105
+ if (line.includes(`:${port} `) && /LISTENING/i.test(line)) {
106
+ const pid = Number(line.trim().split(/\s+/).pop());
107
+ if (pid) pids.add(pid);
108
+ }
109
+ }
110
+ resolve([...pids]);
111
+ });
112
+ });
113
+ }
114
+
115
+ function taskkill(pid) {
116
+ return new Promise((resolve) => {
117
+ execFile("taskkill", ["/f", "/t", "/pid", String(pid)], { windowsHide: true, timeout: 15000 }, () => resolve());
118
+ });
119
+ }
120
+
121
+ // Start cicy-code.exe on :port. Adopts an already-healthy instance. When
122
+ // taking the canonical :8008, clears the legacy Docker containers first so
123
+ // they can't fight for the bind.
124
+ async function start({ port = 8008, logPath = null, emit, version = null } = {}) {
125
+ if (await probeHealth(port)) {
126
+ console.log(`[native-sidecar] :${port} already healthy — adopting`);
127
+ return { native: true, adopted: true, port };
128
+ }
129
+ const exe = await ensureExe({ emit, version });
130
+ if (port === 8008) await clearDockerLegacy();
131
+
132
+ let stdio = ["ignore", "ignore", "ignore"];
133
+ if (logPath) {
134
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
135
+ const fd = fs.openSync(logPath, "a");
136
+ stdio = ["ignore", fd, fd];
137
+ }
138
+ const env = {
139
+ ...process.env,
140
+ PORT: String(port),
141
+ CICY_CODE_PORT: String(port),
142
+ };
143
+ const child = spawn(exe, [], { stdio, detached: true, windowsHide: true, env });
144
+ child.unref();
145
+ try { fs.writeFileSync(PID_FILE, String(child.pid)); } catch {}
146
+ console.log(`[native-sidecar] spawned ${exe} pid=${child.pid} port=${port} log=${logPath || "(none)"}`);
147
+
148
+ const up = await docker.waitUntil(() => probeHealth(port), { totalMs: 60000, everyMs: 2000 });
149
+ if (!up) {
150
+ console.warn(`[native-sidecar] :${port} not healthy after 60s (exe may still be warming up)`);
151
+ return null;
152
+ }
153
+ return { native: true, pid: child.pid, port };
154
+ }
155
+
156
+ // Stop whatever serves :port — pidfile first, then netstat by port.
157
+ async function stop({ port = 8008 } = {}) {
158
+ const pid = readPid();
159
+ if (pidAlive(pid)) await taskkill(pid);
160
+ for (const p of await listPortPids(port)) await taskkill(p);
161
+ try { fs.unlinkSync(PID_FILE); } catch {}
162
+ }
163
+
164
+ async function restart({ port = 8008, logPath = null } = {}) {
165
+ await stop({ port });
166
+ await new Promise((r) => setTimeout(r, 1000));
167
+ return start({ port, logPath });
168
+ }
169
+
170
+ // Update: npm route re-resolves @latest; dev route unlinks the cached exe to
171
+ // defeat ensureDownloaded's size-match skip. Then restart on the new build.
172
+ async function update({ port = 8008, logPath = null, emit } = {}) {
173
+ await stop({ port });
174
+ if (devExeUrl()) { try { fs.unlinkSync(DEV_EXE_PATH); } catch {} }
175
+ return start({ port, logPath, emit, version: devExeUrl() ? null : "latest" });
176
+ }
177
+
178
+ async function checkStatus({ port = 8008 } = {}) {
179
+ return {
180
+ exePresent: fs.existsSync(devExeUrl() ? DEV_EXE_PATH : npmExePath()),
181
+ running: await probeHealth(port),
182
+ pid: readPid() || null,
183
+ };
184
+ }
185
+
186
+ module.exports = { start, stop, restart, update, checkStatus, ensureExe, clearDockerLegacy, probeHealth, npmExePath };