cicy-desktop 2.1.135 → 2.1.137

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.135",
3
+ "version": "2.1.137",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -47,6 +47,45 @@ const LOGIN_TIMEOUT_MS = 120_000;
47
47
  let _server = null;
48
48
  let _state = null;
49
49
  let _timeoutHandle = null;
50
+ let _loginWindow = null;
51
+ let _onResult = null; // the caller's callback, for the user-closes-window case
52
+ let _resultFired = false; // guard: onResult is delivered exactly once
53
+
54
+ function closeLoginWindow() {
55
+ const w = _loginWindow;
56
+ _loginWindow = null;
57
+ if (w && !w.isDestroyed()) { try { w.removeAllListeners("closed"); w.close(); } catch {} }
58
+ }
59
+
60
+ // Open the login page in an IN-APP Electron window. This is the fix for "登录弹不出
61
+ //网页": shell.openExternal needs a working default browser, which a fresh Windows
62
+ // profile may not have. An in-app window has no such dependency, and the cloud's
63
+ // 302 to http://127.0.0.1:<port>/cb lands right back in THIS window — the loopback
64
+ // server answers it, firing onResult. Returns false if the window can't be made
65
+ // (then the caller falls back to the system browser).
66
+ function openLoginWindow(url) {
67
+ const { BrowserWindow } = require("electron");
68
+ try {
69
+ closeLoginWindow();
70
+ _loginWindow = new BrowserWindow({
71
+ width: 480, height: 760, title: "登录 CiCy", autoHideMenuBar: true, show: true,
72
+ webPreferences: { nodeIntegration: false, contextIsolation: true, partition: "persist:cicy-login" },
73
+ });
74
+ _loginWindow.loadURL(url);
75
+ // User closed the window before login completed → treat as cancel so the
76
+ // renderer's "登录中…" state is released instead of hanging until timeout.
77
+ _loginWindow.on("closed", () => {
78
+ _loginWindow = null;
79
+ if (_resultFired) return; // success already delivered; it shuts us down
80
+ try { _onResult && _onResult({ error: "login window closed" }); } catch {}
81
+ shutdown("login window closed by user");
82
+ });
83
+ return true;
84
+ } catch (e) {
85
+ log.warn(`[auth-loopback] in-app login window failed: ${e.message}`);
86
+ return false;
87
+ }
88
+ }
50
89
 
51
90
  function shutdown(reason) {
52
91
  if (_timeoutHandle) { clearTimeout(_timeoutHandle); _timeoutHandle = null; }
@@ -54,6 +93,7 @@ function shutdown(reason) {
54
93
  try { _server.close(); } catch {}
55
94
  _server = null;
56
95
  }
96
+ closeLoginWindow();
57
97
  _state = null;
58
98
  if (reason) log.info(`[auth-loopback] shutdown: ${reason}`);
59
99
  }
@@ -64,6 +104,14 @@ async function startLogin({ onResult } = {}) {
64
104
 
65
105
  _state = crypto.randomBytes(16).toString("hex");
66
106
  const expectedState = _state;
107
+ _resultFired = false;
108
+ // Deliver the result to the caller exactly once — the server callback, the
109
+ // user-closes-window handler, and the timeout all race to be first.
110
+ _onResult = (payload) => {
111
+ if (_resultFired) return;
112
+ _resultFired = true;
113
+ try { onResult && onResult(payload); } catch {}
114
+ };
67
115
 
68
116
  _server = http.createServer((req, res) => {
69
117
  let url;
@@ -98,7 +146,7 @@ async function startLogin({ onResult } = {}) {
98
146
  <h1 style="color:#c00">登录失败</h1>
99
147
  <p>state 校验未通过,请回到 CiCy Desktop 重新点击 Login。</p>
100
148
  </body>`);
101
- try { onResult && onResult({ error: "state mismatch" }); } catch {}
149
+ _onResult({ error: "state mismatch" });
102
150
  setTimeout(() => shutdown("state mismatch"), 500);
103
151
  return;
104
152
  }
@@ -110,14 +158,14 @@ async function startLogin({ onResult } = {}) {
110
158
  <h1 style="color:#c00">登录失败</h1>
111
159
  <p>未收到 token,请回到 CiCy Desktop 重试。</p>
112
160
  </body>`);
113
- try { onResult && onResult({ error: "no token" }); } catch {}
161
+ _onResult({ error: "no token" });
114
162
  setTimeout(() => shutdown("no token"), 500);
115
163
  return;
116
164
  }
117
165
 
118
166
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
119
167
  res.end(SUCCESS_HTML);
120
- try { onResult && onResult({ token, state, reused, accessToken, userId }); } catch {}
168
+ _onResult({ token, state, reused, accessToken, userId });
121
169
  setTimeout(() => shutdown("login complete"), 500);
122
170
  });
123
171
 
@@ -133,14 +181,18 @@ async function startLogin({ onResult } = {}) {
133
181
  });
134
182
  const url = `${LOGIN_BASE}?${params.toString()}`;
135
183
  log.info(`[auth-loopback] listening :${port}, opening ${url}`);
136
- // Best-effort auto-open. On some setups (fresh Windows user with no default
137
- // browser, locked-down shell) openExternal silently fails and the user is
138
- // left stuck "等回调" with no recourse so we ALSO return the url to the
139
- // renderer (below) to show a manual "open / copy this link" fallback.
140
- require("./open-external").openExternalRobust(url).catch((e) => log.warn(`[auth-loopback] open failed: ${e.message}`));
184
+ // Open the login page IN-APP (an Electron window) works even when the
185
+ // machine has no/locked default browser, which is the "登录弹不出网页" bug.
186
+ // The cloud's 302 to http://127.0.0.1:<port>/cb lands back in this same
187
+ // window and the loopback server answers it. Only if the window can't be
188
+ // created do we fall back to the system browser. We still return the url so
189
+ // the renderer can offer a manual "open / copy link" last resort.
190
+ if (!openLoginWindow(url)) {
191
+ require("./open-external").openExternalRobust(url).catch((e) => log.warn(`[auth-loopback] open failed: ${e.message}`));
192
+ }
141
193
 
142
194
  _timeoutHandle = setTimeout(() => {
143
- try { onResult && onResult({ error: "timeout" }); } catch {}
195
+ _onResult({ error: "timeout" });
144
196
  shutdown("timeout");
145
197
  }, LOGIN_TIMEOUT_MS);
146
198
 
@@ -16,6 +16,10 @@ const { ipcMain } = require("electron");
16
16
  const fs = require("fs");
17
17
  const os = require("os");
18
18
  const path = require("path");
19
+ const log = require("electron-log");
20
+ // Background-computed docker status lives here; the homepage READS this (never
21
+ // probes WSL live → never blocks the UI / strands it on 「重试检测」).
22
+ const DOCKER_STATUS_FILE = path.join(os.homedir(), "cicy-ai", "db", "docker-status.json");
19
23
  const sidecar = require("../sidecar/cicy-code");
20
24
  const docker = require("../sidecar/docker");
21
25
  const wslDocker = require("../sidecar/wsl-docker"); // Docker-版 via WSL2+Ubuntu (方案 A, win32)
@@ -110,6 +114,46 @@ function register({ sidecarLogPath } = {}) {
110
114
  if (registered) return;
111
115
  registered = true;
112
116
 
117
+ // ---- Docker status: computed by a dedicated NON-BLOCKING background daemon ----
118
+ // The homepage's 「重试检测」 / 「未响应」 came from probing WSL live on the UI's
119
+ // status call: a cold/busy WSL2 VM takes 10-20s to answer, timed out, and got
120
+ // mis-read as unknown. Instead the daemon below detects status off the UI thread,
121
+ // caches it (memory + file), AND auto-starts whatever is installed-but-down. The
122
+ // docker:app-status handler just returns the cache — instant, never blocks.
123
+ let _dockerStatusCache = null;
124
+ let _dockerDaemonBusy = false;
125
+ async function refreshDockerStatus() {
126
+ try {
127
+ const s = await appDocker.status(APP_PORT); // { wsl, distro, engineUp, running, unknown }
128
+ _dockerStatusCache = { installed: !!s.distro, dockerRunning: !!s.engineUp, running: !!s.running, unknown: !!s.unknown, port: APP_PORT, platform: process.platform, ts: Date.now() };
129
+ } catch (e) {
130
+ _dockerStatusCache = { installed: false, dockerRunning: false, running: false, unknown: true, port: APP_PORT, platform: process.platform, error: e.message, ts: Date.now() };
131
+ }
132
+ try { fs.mkdirSync(path.dirname(DOCKER_STATUS_FILE), { recursive: true }); fs.writeFileSync(DOCKER_STATUS_FILE, JSON.stringify(_dockerStatusCache)); } catch {}
133
+ return _dockerStatusCache;
134
+ }
135
+ async function reconcileDocker() {
136
+ if (_dockerDaemonBusy) return _dockerStatusCache;
137
+ _dockerDaemonBusy = true;
138
+ try {
139
+ const s = await refreshDockerStatus();
140
+ // 没启动的给我启动: distro installed but :8009 not healthy (and WSL not unknown)
141
+ // → bring it up. bootstrap is idempotent: it skips done steps and just runs
142
+ // startEngine + the container. Skip when not installed (would silently pull
143
+ // the 444MB rootfs) or unknown (WSL not answering — let the next tick retry).
144
+ if (s.installed && !s.running && !s.unknown) {
145
+ log.info("[docker-daemon] installed but :8009 down → auto-starting (bootstrap idempotent)");
146
+ try { await appDocker.bootstrap(appOpts()); } catch (e) { log.warn(`[docker-daemon] auto-start failed: ${e.message}`); }
147
+ await refreshDockerStatus();
148
+ }
149
+ } finally { _dockerDaemonBusy = false; }
150
+ return _dockerStatusCache;
151
+ }
152
+ function startDockerStatusDaemon() {
153
+ setTimeout(() => { reconcileDocker().catch(() => {}); }, 2000); // shortly after startup
154
+ setInterval(() => { reconcileDocker().catch(() => {}); }, 60000); // keep fresh + self-heal
155
+ }
156
+
113
157
  ipcMain.handle("sidecar:status", async () => {
114
158
  const running = await sidecar.probeExisting(PORT);
115
159
  return { running };
@@ -170,13 +214,14 @@ function register({ sidecarLogPath } = {}) {
170
214
  // ---- Docker-版 cicy-code on :8009 — WSL2 + Ubuntu + Docker Engine (方案 A) ----
171
215
  // Card states (主人: 状态分清楚): running(:8009 healthy)→打开 / dockerRunning
172
216
  // (engine up)→启动 / installed(Ubuntu present)→启动 Docker / else→下载安装.
173
- ipcMain.handle("docker:app-status", async () => {
174
- try {
175
- const s = await appDocker.status(APP_PORT); // { wsl, distro, engineUp, running, unknown }
176
- return { installed: !!s.distro, dockerRunning: !!s.engineUp, running: !!s.running, unknown: !!s.unknown, port: APP_PORT, platform: process.platform };
177
- } catch (e) {
178
- return { installed: false, dockerRunning: false, running: false, port: APP_PORT, platform: process.platform, error: e.message };
179
- }
217
+ ipcMain.handle("docker:app-status", () => {
218
+ // NON-BLOCKING: return what the background daemon already computed (memory →
219
+ // file). NEVER probe WSL live here that's what froze the UI / stranded the
220
+ // card on 「重试检测」. Kick a refresh if we have nothing cached yet.
221
+ if (_dockerStatusCache) return _dockerStatusCache;
222
+ try { return JSON.parse(fs.readFileSync(DOCKER_STATUS_FILE, "utf8")); } catch {}
223
+ reconcileDocker().catch(() => {});
224
+ return { installed: false, dockerRunning: false, running: false, unknown: true, port: APP_PORT, platform: process.platform };
180
225
  });
181
226
 
182
227
  // Common run options for the :8009 instance: its own container/volume + the
@@ -422,6 +467,10 @@ function register({ sidecarLogPath } = {}) {
422
467
  });
423
468
  });
424
469
  });
470
+
471
+ // Kick the non-blocking docker status daemon: detect on startup + every 60s,
472
+ // cache to file, and auto-start what's installed-but-down. The UI just reads it.
473
+ startDockerStatusDaemon();
425
474
  }
426
475
 
427
476
  module.exports = { register };
@@ -567,7 +567,7 @@ async function wslMissing() {
567
567
  // (wsl didn't answer / timed out). null lets status() report `unknown` instead
568
568
  // of falsely concluding "not installed" when WSL is merely stuck.
569
569
  return await new Promise((resolve) => {
570
- execFile("wsl", ["--status"], { timeout: 8000, windowsHide: true, encoding: "utf16le" }, (err, stdout, stderr) => {
570
+ execFile("wsl", ["--status"], { timeout: 25000, windowsHide: true, encoding: "utf16le" }, (err, stdout, stderr) => {
571
571
  const s = String((stdout || "") + (stderr || "") + (err && err.message ? err.message : ""));
572
572
  if (/未安装|not installed|--install/i.test(s)) return resolve(true); // definitely missing
573
573
  if (err && (err.killed || err.signal || err.code === "ETIMEDOUT")) return resolve(null); // timed out → unknown
@@ -98,7 +98,11 @@ async function distroInstalled(distro = DISTRO) {
98
98
  if (process.platform !== "win32") return false;
99
99
  // ASYNC execFile (sync froze the main process on a cold/stuck WSL → "未响应").
100
100
  return await new Promise((resolve) => {
101
- execFile("wsl", ["-l", "-q"], { timeout: 8000, windowsHide: true, encoding: "utf16le" }, (err, stdout) => {
101
+ // 25s, NOT 8s: a COLD WSL2 VM takes 10-20s just to boot before it answers the
102
+ // first `wsl -l -q`, so an 8s timeout falsely returns null(unknown) → the
103
+ // homepage gets stuck on 「重试检测」 instead of offering 「安装」. A warm WSL
104
+ // answers in <1s, so the longer ceiling never bites in the normal case.
105
+ execFile("wsl", ["-l", "-q"], { timeout: 25000, windowsHide: true, encoding: "utf16le" }, (err, stdout) => {
102
106
  // Our timeout killed it / it was signalled → WSL didn't answer → UNKNOWN.
103
107
  if (err && (err.killed || err.signal || err.code === "ETIMEDOUT")) return resolve(null);
104
108
  // Other errors (wsl missing / non-zero exit) → definitively not our distro.