cicy-desktop 2.1.64 → 2.1.66
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 +1 -1
- package/src/backends/homepage-preload.js +7 -0
- package/src/backends/homepage-react/assets/{index-BV0z4aaf.css → index-ByYzSssW.css} +1 -1
- package/src/backends/homepage-react/assets/index-DHvg4jSd.js +49 -0
- package/src/backends/homepage-react/index.html +2 -2
- package/src/backends/local-teams.js +16 -1
- package/src/backends/sidecar-ipc.js +8 -4
- package/src/sidecar/cicy-code.js +41 -8
- package/src/sidecar/docker.js +19 -2
- package/src/sidecar/native.js +207 -0
- package/workers/render/src/App.css +18 -0
- package/workers/render/src/App.jsx +31 -4
- package/src/backends/homepage-react/assets/index-C0vFqx44.js +0 -49
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>CiCy Desktop</title>
|
|
7
|
-
<script type="module" crossorigin src="./assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
7
|
+
<script type="module" crossorigin src="./assets/index-DHvg4jSd.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./assets/index-ByYzSssW.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
|
@@ -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
|
}
|
|
@@ -116,16 +116,20 @@ function register({ sidecarLogPath } = {}) {
|
|
|
116
116
|
// Update: stop + spawn cicy-code@latest (or reload the Docker image on
|
|
117
117
|
// win32). The npx re-resolve / image pull can take a while on a cold cache,
|
|
118
118
|
// so allow a longer window for :8008 to come back.
|
|
119
|
-
ipcMain.handle("sidecar:update", async () => {
|
|
119
|
+
ipcMain.handle("sidecar:update", async (e) => {
|
|
120
|
+
// Stream phase/progress events to the homepage so the user SEES the
|
|
121
|
+
// update working (download %, swap, restart) instead of a frozen label.
|
|
122
|
+
const emit = (ev) => { try { e.sender.send("sidecar:op-progress", { op: "update", ...ev }); } catch {} };
|
|
120
123
|
try {
|
|
121
|
-
const child = await sidecar.update({ logPath: sidecarLogPath });
|
|
124
|
+
const child = await sidecar.update({ logPath: sidecarLogPath, port: PORT, emit });
|
|
122
125
|
for (let i = 0; i < 240; i++) {
|
|
123
126
|
if (await sidecar.probeExisting(PORT)) return { ok: true, pid: child?.pid || null };
|
|
124
127
|
await new Promise((r) => setTimeout(r, 250));
|
|
125
128
|
}
|
|
126
129
|
return { ok: true, pid: child?.pid || null, warning: "updated but did not bind :8008 within 60s" };
|
|
127
|
-
} catch (
|
|
128
|
-
|
|
130
|
+
} catch (err) {
|
|
131
|
+
emit({ phase: "done", status: "error", message: `更新失败:${err.message}` });
|
|
132
|
+
return { ok: false, error: err.message };
|
|
129
133
|
}
|
|
130
134
|
});
|
|
131
135
|
}
|
package/src/sidecar/cicy-code.js
CHANGED
|
@@ -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
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// container
|
|
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)
|
|
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) {
|
|
@@ -197,17 +219,28 @@ async function restart({ logPath, port = DEFAULT_PORT } = {}) {
|
|
|
197
219
|
// win32 → reload the Docker image (from R2) and re-run the container.
|
|
198
220
|
// else → clear the npx cache + spawn `cicy-code@latest` so npx re-resolves
|
|
199
221
|
// against the registry (npmmirror for CN) and pulls a newer build.
|
|
200
|
-
async function update({ logPath, port = DEFAULT_PORT } = {}) {
|
|
201
|
-
|
|
222
|
+
async function update({ logPath, port = DEFAULT_PORT, emit } = {}) {
|
|
223
|
+
const e = emit || (() => {});
|
|
202
224
|
if (process.platform === "win32") {
|
|
203
|
-
|
|
204
|
-
|
|
225
|
+
// NATIVE route: safe-swap update with real download progress.
|
|
226
|
+
if (process.env.CICY_WIN_NATIVE === "1") {
|
|
227
|
+
return require("./native").update({ port, logPath, emit });
|
|
228
|
+
}
|
|
229
|
+
// Transitional Docker route. loadImage streams 下载镜像 % via emit.
|
|
230
|
+
await stop({ port });
|
|
231
|
+
try { await require("./docker").loadImage({ emit }); } catch (err) {
|
|
232
|
+
console.warn(`[cicy-code-sidecar] docker image reload failed: ${err.message}`);
|
|
233
|
+
e({ phase: "download", status: "error", message: `镜像更新失败:${err.message}` });
|
|
205
234
|
}
|
|
206
235
|
await new Promise(r => setTimeout(r, 300));
|
|
236
|
+
e({ phase: "swap", status: "running", message: "重启容器…" });
|
|
207
237
|
return start({ logPath, port, force: true });
|
|
208
238
|
}
|
|
239
|
+
e({ phase: "download", status: "running", message: "获取最新版 cicy-code…" });
|
|
240
|
+
await stop({ port });
|
|
209
241
|
clearNpxCache();
|
|
210
242
|
await new Promise(r => setTimeout(r, 300));
|
|
243
|
+
e({ phase: "swap", status: "running", message: "启动新版本…" });
|
|
211
244
|
return start({ logPath, port, force: true, version: "latest" });
|
|
212
245
|
}
|
|
213
246
|
|
package/src/sidecar/docker.js
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
|
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,207 @@
|
|
|
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 — SAFE swap order: acquire the new build FIRST (progress streamed),
|
|
171
|
+
// only then stop the running instance and switch. A dead network therefore
|
|
172
|
+
// fails the update loudly but never kills a working install. Dev route
|
|
173
|
+
// downloads to .new and renames; npm route re-resolves @latest in place
|
|
174
|
+
// (npm's own staging is already atomic).
|
|
175
|
+
async function update({ port = 8008, logPath = null, emit } = {}) {
|
|
176
|
+
const e = emit || (() => {});
|
|
177
|
+
if (devExeUrl()) {
|
|
178
|
+
const tmp = DEV_EXE_PATH + ".new";
|
|
179
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
180
|
+
e({ phase: "download", status: "running", message: "下载新版 cicy-code.exe…", progress: 0 });
|
|
181
|
+
await docker.ensureDownloaded(devExeUrl(), tmp, null, { emit, phase: "download", label: "下载新版" });
|
|
182
|
+
if (!fs.existsSync(tmp)) throw new Error("下载未完成,保留当前版本");
|
|
183
|
+
e({ phase: "swap", status: "running", message: "停止旧版本…" });
|
|
184
|
+
await stop({ port });
|
|
185
|
+
try { fs.unlinkSync(DEV_EXE_PATH + ".bak"); } catch {}
|
|
186
|
+
try { fs.renameSync(DEV_EXE_PATH, DEV_EXE_PATH + ".bak"); } catch {}
|
|
187
|
+
fs.renameSync(tmp, DEV_EXE_PATH);
|
|
188
|
+
} else {
|
|
189
|
+
e({ phase: "download", status: "running", message: "npm 获取最新版…" });
|
|
190
|
+
await ensureExe({ emit, version: "latest" }); // service untouched until this succeeds
|
|
191
|
+
e({ phase: "swap", status: "running", message: "重启服务…" });
|
|
192
|
+
await stop({ port });
|
|
193
|
+
}
|
|
194
|
+
const r = await start({ port, logPath, emit });
|
|
195
|
+
e({ phase: "done", status: r ? "done" : "error", message: r ? "已更新并重启" : "更新后启动失败" });
|
|
196
|
+
return r;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function checkStatus({ port = 8008 } = {}) {
|
|
200
|
+
return {
|
|
201
|
+
exePresent: fs.existsSync(devExeUrl() ? DEV_EXE_PATH : npmExePath()),
|
|
202
|
+
running: await probeHealth(port),
|
|
203
|
+
pid: readPid() || null,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { start, stop, restart, update, checkStatus, ensureExe, clearDockerLegacy, probeHealth, npmExePath };
|
|
@@ -733,3 +733,21 @@ body {
|
|
|
733
733
|
font-size: 11.5px; color: #9ca3af;
|
|
734
734
|
word-break: break-word;
|
|
735
735
|
}
|
|
736
|
+
|
|
737
|
+
/* live op progress on the local team card (更新 download %/phase stream) */
|
|
738
|
+
.bcard__prog {
|
|
739
|
+
display: flex; flex-direction: column; gap: 4px;
|
|
740
|
+
margin-top: 6px; font-size: 12px; line-height: 1.35;
|
|
741
|
+
color: var(--text-dim, #9da7b3);
|
|
742
|
+
}
|
|
743
|
+
.bcard__prog[data-status="error"] .bcard__progmsg { color: #f87171; }
|
|
744
|
+
.bcard__prog[data-status="done"] .bcard__progmsg { color: #4ade80; }
|
|
745
|
+
.bcard__progbar {
|
|
746
|
+
display: block; height: 4px; border-radius: 2px;
|
|
747
|
+
background: rgba(125, 135, 150, 0.25); overflow: hidden;
|
|
748
|
+
}
|
|
749
|
+
.bcard__progbar > span {
|
|
750
|
+
display: block; height: 100%; border-radius: 2px;
|
|
751
|
+
background: var(--accent, #3b82f6);
|
|
752
|
+
transition: width 0.25s ease;
|
|
753
|
+
}
|
|
@@ -543,6 +543,7 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
|
|
|
543
543
|
const running = team.status === "running";
|
|
544
544
|
const [busy, setBusy] = useState(""); // "" | start | restart | update | stop
|
|
545
545
|
const [opMsg, setOpMsg] = useState("");
|
|
546
|
+
const [opProg, setOpProg] = useState(null); // live {message, progress?, status} during 更新
|
|
546
547
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
547
548
|
const [latest, setLatest] = useState(null); // newest cicy-code on the registry
|
|
548
549
|
const [checking, setChecking] = useState(false);
|
|
@@ -610,7 +611,15 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
|
|
|
610
611
|
const runOp = async (kind, fn, doneText) => {
|
|
611
612
|
setMenuOpen(false);
|
|
612
613
|
if (busy) return;
|
|
613
|
-
setBusy(kind); setOpMsg("");
|
|
614
|
+
setBusy(kind); setOpMsg(""); setOpProg(null);
|
|
615
|
+
// 更新 streams real phase/percent events from the main process — surface
|
|
616
|
+
// them live on the card so the user SEES the download/swap happening.
|
|
617
|
+
let unsub = null;
|
|
618
|
+
if (kind === "update" && window.cicy?.sidecar?.onOpProgress) {
|
|
619
|
+
unsub = window.cicy.sidecar.onOpProgress((ev) => {
|
|
620
|
+
if (ev?.op === "update") setOpProg(ev);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
614
623
|
try {
|
|
615
624
|
const r = await fn();
|
|
616
625
|
setOpMsg(r?.ok
|
|
@@ -619,8 +628,10 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
|
|
|
619
628
|
} catch (err) {
|
|
620
629
|
setOpMsg(tr("sidecar.failed", "操作失败") + `: ${err?.message || err}`);
|
|
621
630
|
} finally {
|
|
622
|
-
|
|
631
|
+
try { unsub && unsub(); } catch {}
|
|
632
|
+
setBusy(""); setOpProg(null);
|
|
623
633
|
onRefresh?.(); // re-probe so the status dot/chip catches up
|
|
634
|
+
setTimeout(() => setOpMsg(""), 5000); // result line is transient
|
|
624
635
|
}
|
|
625
636
|
};
|
|
626
637
|
const BUSY_LABEL = { start: "启动中…", restart: "重启中…", update: "更新中…", stop: "停止中…" };
|
|
@@ -771,6 +782,22 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
|
|
|
771
782
|
<span className="bcard__ver" data-id="LocalTeamCard-version">v{team.version}</span>
|
|
772
783
|
)}
|
|
773
784
|
</div>
|
|
785
|
+
{busy && (
|
|
786
|
+
<div className="bcard__prog" data-id="LocalTeamCard-progress" data-status={opProg?.status || "running"}>
|
|
787
|
+
<span className="bcard__progmsg">
|
|
788
|
+
{opProg?.message || BUSY_LABEL[busy] || `${busy}…`}
|
|
789
|
+
{Number.isFinite(opProg?.progress) ? ` ${opProg.progress}%` : ""}
|
|
790
|
+
</span>
|
|
791
|
+
{Number.isFinite(opProg?.progress) && (
|
|
792
|
+
<span className="bcard__progbar"><span style={{ width: `${Math.min(100, opProg.progress)}%` }} /></span>
|
|
793
|
+
)}
|
|
794
|
+
</div>
|
|
795
|
+
)}
|
|
796
|
+
{!busy && opMsg && (
|
|
797
|
+
<div className="bcard__prog" data-id="LocalTeamCard-progress" data-status={/失败|error/i.test(opMsg) ? "error" : "done"}>
|
|
798
|
+
<span className="bcard__progmsg">{opMsg}</span>
|
|
799
|
+
</div>
|
|
800
|
+
)}
|
|
774
801
|
</div>
|
|
775
802
|
<button
|
|
776
803
|
type="button"
|
|
@@ -779,8 +806,8 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
|
|
|
779
806
|
disabled={!!busy || !team.base_url}
|
|
780
807
|
onClick={handleOpen}
|
|
781
808
|
>
|
|
782
|
-
{busy
|
|
783
|
-
<span>{openLabel}</span>
|
|
809
|
+
{busy && busy !== "stop" ? <Spinner /> : <ArrowIcon />}
|
|
810
|
+
<span>{busy ? (BUSY_LABEL[busy] || openLabel) : openLabel}</span>
|
|
784
811
|
</button>
|
|
785
812
|
</div>
|
|
786
813
|
);
|