cicy-desktop 2.1.30 → 2.1.32
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/bin/cicy-desktop +84 -6
- package/package.json +1 -1
- package/src/backends/homepage-preload.js +2 -10
- package/src/backends/homepage-react/assets/index-CzuQdFw8.js +49 -0
- package/src/backends/homepage-react/index.html +1 -1
- package/src/backends/ipc.js +2 -3
- package/src/backends/sidecar-ipc.js +18 -158
- package/src/backends/webview-preload.js +2 -9
- package/src/server/tool-registry.js +12 -1
- package/src/sidecar/cicy-code.js +44 -68
- package/src/sidecar/docker.js +113 -0
- package/workers/render/src/App.jsx +2 -8
- package/src/backends/homepage-react/assets/index-B8FrtpTX.js +0 -49
- package/src/sidecar/installer.js +0 -672
- package/src/sidecar/wsl.js +0 -585
package/src/sidecar/installer.js
DELETED
|
@@ -1,672 +0,0 @@
|
|
|
1
|
-
// cicy-code installer — downloads the sidecar binary into ~/.local/bin/,
|
|
2
|
-
// with CN-network awareness and double-source retry.
|
|
3
|
-
//
|
|
4
|
-
// Public surface:
|
|
5
|
-
// getStatus() → { userInstalled, userVersion, binaryPath, installing }
|
|
6
|
-
// checkLatest() → { ok, latest, network, releaseUrl }
|
|
7
|
-
// install({ onProgress, signal }) → final state event
|
|
8
|
-
// cancel() → cancels the in-flight install
|
|
9
|
-
//
|
|
10
|
-
// Layout on disk (2026-05-29 — versioned + symlink):
|
|
11
|
-
// ~/.local/bin/cicy-code-<version> actual binary, +x
|
|
12
|
-
// ~/.local/bin/cicy-code symlink → cicy-code-<version>
|
|
13
|
-
//
|
|
14
|
-
// The current version is read by `fs.readlink(~/.local/bin/cicy-code)` and
|
|
15
|
-
// parsing the basename — no separate version file. Upgrading is "drop
|
|
16
|
-
// cicy-code-<newver> + atomic-rename a new symlink over the old one".
|
|
17
|
-
// Old versions stay on disk for rollback; future GC may trim them.
|
|
18
|
-
//
|
|
19
|
-
// Windows path is unchanged — the daemon lives inside WSL2 at
|
|
20
|
-
// `$HOME/.local/bin/cicy-code` from the distro's POV, and a separate
|
|
21
|
-
// `<userData>/cicy-code/wsl-version` cache mirrors the version for fast
|
|
22
|
-
// Windows-side reads.
|
|
23
|
-
//
|
|
24
|
-
// Progress event shape:
|
|
25
|
-
// { phase, message, progress?, version?, network?, error? }
|
|
26
|
-
// phase ∈ "detecting" | "checking" | "downloading" | "installing" | "done" | "error" | "cancelled"
|
|
27
|
-
|
|
28
|
-
const fs = require("fs");
|
|
29
|
-
const path = require("path");
|
|
30
|
-
const https = require("https");
|
|
31
|
-
const http = require("http");
|
|
32
|
-
const { app } = require("electron");
|
|
33
|
-
const log = require("electron-log");
|
|
34
|
-
const netDetect = require("./net-detect");
|
|
35
|
-
const { buildUrlList } = require("./mirrors");
|
|
36
|
-
|
|
37
|
-
const REPO = "cicy-ai/cicy-code";
|
|
38
|
-
|
|
39
|
-
// Arch aliases — release artifacts often use Go's "amd64" while Node's
|
|
40
|
-
// process.arch reports "x64".
|
|
41
|
-
const ARCH_ALIASES = {
|
|
42
|
-
x64: ["x64", "amd64", "x86_64"],
|
|
43
|
-
arm64: ["arm64", "aarch64"],
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
// ---- platform helpers ----
|
|
47
|
-
// On Windows we run the linux-amd64 binary inside WSL2, so platformDir()
|
|
48
|
-
// reports "linux" for win32 — the manifest lookup, asset URL construction,
|
|
49
|
-
// and arch alias logic all just use the Linux entry. wsl.js owns actually
|
|
50
|
-
// putting the binary onto disk inside the distro.
|
|
51
|
-
function platformDir() {
|
|
52
|
-
return process.platform === "darwin" ? "darwin"
|
|
53
|
-
: process.platform === "linux" ? "linux"
|
|
54
|
-
: process.platform === "win32" ? "linux"
|
|
55
|
-
: null;
|
|
56
|
-
}
|
|
57
|
-
function archDir() {
|
|
58
|
-
// On Windows we always pull linux-amd64 (WSL distros are typically x64).
|
|
59
|
-
if (process.platform === "win32") return "x64";
|
|
60
|
-
return process.arch === "x64" ? "x64"
|
|
61
|
-
: process.arch === "arm64" ? "arm64"
|
|
62
|
-
: null;
|
|
63
|
-
}
|
|
64
|
-
function userDir() {
|
|
65
|
-
if (process.platform === "win32") return null; // WSL-managed
|
|
66
|
-
return path.join(require("os").homedir(), ".local", "bin");
|
|
67
|
-
}
|
|
68
|
-
function userBinary() {
|
|
69
|
-
// Windows: cicy-code lives inside WSL at $HOME/.local/bin/cicy-code, which
|
|
70
|
-
// is not directly addressable from the Windows side. Return a virtual
|
|
71
|
-
// marker path so callers can still test "is something installed?" — actual
|
|
72
|
-
// existence is checked via wsl.userInstalled() in cicy-code.js.
|
|
73
|
-
if (process.platform === "win32") return "wsl:cicy-code";
|
|
74
|
-
|
|
75
|
-
const dir = userDir();
|
|
76
|
-
if (!dir) return null;
|
|
77
|
-
// ~/.local/bin/cicy-code — symlink to the active version on disk.
|
|
78
|
-
return path.join(dir, "cicy-code");
|
|
79
|
-
}
|
|
80
|
-
// Resolve the versioned binary path for a given semver string.
|
|
81
|
-
function versionedBinaryPath(version) {
|
|
82
|
-
const dir = userDir();
|
|
83
|
-
if (!dir || !version) return null;
|
|
84
|
-
return path.join(dir, `cicy-code-${version}`);
|
|
85
|
-
}
|
|
86
|
-
function userVersion() {
|
|
87
|
-
if (process.platform === "win32") {
|
|
88
|
-
// sync read of version file from inside WSL is awkward; we rely on the
|
|
89
|
-
// installer step to mirror the version into a Windows-side cache.
|
|
90
|
-
try {
|
|
91
|
-
const cache = path.join(app.getPath("userData"), "cicy-code", "wsl-version");
|
|
92
|
-
return fs.readFileSync(cache, "utf8").trim() || null;
|
|
93
|
-
} catch { return null; }
|
|
94
|
-
}
|
|
95
|
-
const bin = userBinary();
|
|
96
|
-
if (!bin) return null;
|
|
97
|
-
// Preferred: read the symlink target and parse `cicy-code-<version>`.
|
|
98
|
-
try {
|
|
99
|
-
const target = fs.readlinkSync(bin);
|
|
100
|
-
const m = path.basename(target).match(/^cicy-code-(\d+\.\d+\.\d+)$/);
|
|
101
|
-
if (m) return m[1];
|
|
102
|
-
} catch {}
|
|
103
|
-
// Fallback: if some legacy install wrote a `version` file next to the
|
|
104
|
-
// binary, honour it. Lets older installs upgrade cleanly.
|
|
105
|
-
try {
|
|
106
|
-
const legacy = path.join(path.dirname(bin), "version");
|
|
107
|
-
const v = fs.readFileSync(legacy, "utf8").trim();
|
|
108
|
-
if (/^\d+\.\d+\.\d+$/.test(v)) return v;
|
|
109
|
-
} catch {}
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// ---- state ----
|
|
114
|
-
let inflight = null; // { abort: () => void }
|
|
115
|
-
let lastProgress = null; // last emitted event (for late subscribers)
|
|
116
|
-
|
|
117
|
-
function getStatus() {
|
|
118
|
-
// win32: binaryPath is a virtual marker; actual existence is inside WSL.
|
|
119
|
-
// We can't call wsl.userInstalled() synchronously here, so we rely on the
|
|
120
|
-
// cached wsl-version file: if it exists the user already completed an
|
|
121
|
-
// install. The sidecar:status IPC also has a dedicated sidecar:wsl-status
|
|
122
|
-
// channel for deeper probing.
|
|
123
|
-
const bin = userBinary();
|
|
124
|
-
const ver = userVersion();
|
|
125
|
-
const installed = process.platform === "win32"
|
|
126
|
-
? !!ver // win32: treat "has version cache" as installed
|
|
127
|
-
: !!(bin && fs.existsSync(bin));
|
|
128
|
-
return {
|
|
129
|
-
userInstalled: installed,
|
|
130
|
-
userVersion: ver,
|
|
131
|
-
binaryPath: bin,
|
|
132
|
-
installing: !!inflight,
|
|
133
|
-
lastProgress,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Quick "is the daemon listening on :8008?" probe used by the homepage to
|
|
138
|
-
// decide between showing a "Start" or an "Open" button on the local card.
|
|
139
|
-
// Returns true on any 2xx/3xx/4xx response — we just want to know that
|
|
140
|
-
// something is binding the port and answering HTTP.
|
|
141
|
-
function isRunning(port = 8008) {
|
|
142
|
-
return new Promise((resolve) => {
|
|
143
|
-
const req = http.request(
|
|
144
|
-
{ host: "127.0.0.1", port, path: "/", method: "HEAD", timeout: 1500 },
|
|
145
|
-
(res) => { res.resume(); resolve(true); }
|
|
146
|
-
);
|
|
147
|
-
req.on("error", () => resolve(false));
|
|
148
|
-
req.on("timeout", () => { req.destroy(); resolve(false); });
|
|
149
|
-
req.end();
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ---- HTTP helpers ----
|
|
154
|
-
function headCheck(url, { timeoutMs = 6000 } = {}) {
|
|
155
|
-
return new Promise((resolve) => {
|
|
156
|
-
const lib = url.startsWith("https:") ? https : http;
|
|
157
|
-
const tryUrl = (u, hops = 0) => {
|
|
158
|
-
if (hops > 5) return resolve(false);
|
|
159
|
-
const req = lib.request(u, { method: "HEAD", timeout: timeoutMs }, (res) => {
|
|
160
|
-
if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
161
|
-
res.resume();
|
|
162
|
-
return tryUrl(res.headers.location, hops + 1);
|
|
163
|
-
}
|
|
164
|
-
res.resume();
|
|
165
|
-
// 200 = exists, 403/404 = not found / blocked. Some mirrors return 200 for HEAD even when GET would 404 — best effort.
|
|
166
|
-
resolve(res.statusCode === 200);
|
|
167
|
-
});
|
|
168
|
-
req.on("error", () => resolve(false));
|
|
169
|
-
req.on("timeout", () => { req.destroy(); resolve(false); });
|
|
170
|
-
req.end();
|
|
171
|
-
};
|
|
172
|
-
tryUrl(url);
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function getJson(url, { timeoutMs = 10000, headers = {} } = {}) {
|
|
177
|
-
return new Promise((resolve, reject) => {
|
|
178
|
-
const lib = url.startsWith("https:") ? https : http;
|
|
179
|
-
const req = lib.get(url, { timeout: timeoutMs, headers: { "User-Agent": "cicy-desktop", ...headers } }, (res) => {
|
|
180
|
-
if ([301, 302, 307, 308].includes(res.statusCode)) {
|
|
181
|
-
res.resume();
|
|
182
|
-
return getJson(res.headers.location, { timeoutMs, headers }).then(resolve, reject);
|
|
183
|
-
}
|
|
184
|
-
if (res.statusCode !== 200) {
|
|
185
|
-
res.resume();
|
|
186
|
-
return reject(new Error(`HTTP ${res.statusCode} ${url}`));
|
|
187
|
-
}
|
|
188
|
-
const chunks = [];
|
|
189
|
-
res.on("data", (c) => chunks.push(c));
|
|
190
|
-
res.on("end", () => {
|
|
191
|
-
try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
|
|
192
|
-
catch (e) { reject(e); }
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
req.on("error", reject);
|
|
196
|
-
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function downloadFile(url, destPath, { signal, onProgress, timeoutMs = 30000 } = {}) {
|
|
201
|
-
return new Promise((resolve, reject) => {
|
|
202
|
-
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
203
|
-
const tmp = destPath + ".part";
|
|
204
|
-
let out;
|
|
205
|
-
let aborted = false;
|
|
206
|
-
let req;
|
|
207
|
-
|
|
208
|
-
const cleanup = () => {
|
|
209
|
-
if (out) try { out.close(); } catch {}
|
|
210
|
-
try { fs.unlinkSync(tmp); } catch {}
|
|
211
|
-
};
|
|
212
|
-
const onAbort = () => {
|
|
213
|
-
aborted = true;
|
|
214
|
-
if (req) try { req.destroy(); } catch {}
|
|
215
|
-
cleanup();
|
|
216
|
-
reject(new Error("cancelled"));
|
|
217
|
-
};
|
|
218
|
-
if (signal) {
|
|
219
|
-
if (signal.aborted) return onAbort();
|
|
220
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const start = (currentUrl) => {
|
|
224
|
-
const lib = currentUrl.startsWith("https:") ? https : http;
|
|
225
|
-
req = lib.get(currentUrl, { timeout: timeoutMs }, (res) => {
|
|
226
|
-
if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
227
|
-
res.resume();
|
|
228
|
-
return start(res.headers.location);
|
|
229
|
-
}
|
|
230
|
-
if (res.statusCode !== 200) {
|
|
231
|
-
res.resume();
|
|
232
|
-
cleanup();
|
|
233
|
-
return reject(new Error(`HTTP ${res.statusCode} ${currentUrl}`));
|
|
234
|
-
}
|
|
235
|
-
const total = Number(res.headers["content-length"]) || 0;
|
|
236
|
-
let received = 0;
|
|
237
|
-
out = fs.createWriteStream(tmp);
|
|
238
|
-
res.on("data", (chunk) => {
|
|
239
|
-
if (aborted) return;
|
|
240
|
-
received += chunk.length;
|
|
241
|
-
if (onProgress) {
|
|
242
|
-
try { onProgress({ received, total }); } catch {}
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
res.pipe(out);
|
|
246
|
-
out.on("finish", () => {
|
|
247
|
-
if (aborted) return;
|
|
248
|
-
out.close((closeErr) => {
|
|
249
|
-
if (closeErr) return reject(closeErr);
|
|
250
|
-
try {
|
|
251
|
-
// On macOS/Linux the old binary may still be running.
|
|
252
|
-
// rename() over a running binary replaces the dir entry but
|
|
253
|
-
// the running process keeps the old inode — safe. However
|
|
254
|
-
// some systems raise ETXTBSY. Unlinking the old file first
|
|
255
|
-
// ensures we always get a fresh inode.
|
|
256
|
-
try { fs.unlinkSync(destPath); } catch {}
|
|
257
|
-
try {
|
|
258
|
-
fs.renameSync(tmp, destPath);
|
|
259
|
-
} catch {
|
|
260
|
-
// Last resort: copy bytes (works even if rename fails across
|
|
261
|
-
// filesystems or when the old file is still locked).
|
|
262
|
-
fs.copyFileSync(tmp, destPath);
|
|
263
|
-
try { fs.unlinkSync(tmp); } catch {}
|
|
264
|
-
}
|
|
265
|
-
resolve({ destPath, total });
|
|
266
|
-
} catch (e) { reject(e); }
|
|
267
|
-
});
|
|
268
|
-
});
|
|
269
|
-
out.on("error", (e) => { cleanup(); reject(e); });
|
|
270
|
-
});
|
|
271
|
-
req.on("error", (e) => { cleanup(); reject(e); });
|
|
272
|
-
req.on("timeout", () => { req.destroy(); cleanup(); reject(new Error("timeout")); });
|
|
273
|
-
};
|
|
274
|
-
start(url);
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// ---- release lookup ----
|
|
279
|
-
// `manifest.json` is published as a release asset by .github/workflows/release.yml.
|
|
280
|
-
// GitHub's `releases/latest/download/<asset>` redirect always resolves to the
|
|
281
|
-
// most recent release, so we get a stable URL for "the latest manifest" without
|
|
282
|
-
// hitting api.github.com (which is blocked in CN).
|
|
283
|
-
//
|
|
284
|
-
// Each request is parallel-raced across direct + CN_MIRRORS so whichever path
|
|
285
|
-
// is fastest wins. Short timeout so we don't hang the UI when a mirror dies.
|
|
286
|
-
const MANIFEST_PATH = `${REPO}/releases/latest/download/manifest.json`;
|
|
287
|
-
const MANIFEST_DIRECT = `https://github.com/${MANIFEST_PATH}`;
|
|
288
|
-
|
|
289
|
-
async function fetchManifest() {
|
|
290
|
-
const network = await netDetect.detect();
|
|
291
|
-
const urls = buildUrlList(MANIFEST_DIRECT, network);
|
|
292
|
-
return new Promise((resolve, reject) => {
|
|
293
|
-
let done = 0;
|
|
294
|
-
let lastErr;
|
|
295
|
-
let resolved = false;
|
|
296
|
-
urls.forEach(u => {
|
|
297
|
-
getJson(u, { timeoutMs: 4000 }).then(j => {
|
|
298
|
-
if (resolved) return;
|
|
299
|
-
if (j && j.version && j.assets) {
|
|
300
|
-
resolved = true;
|
|
301
|
-
log.info(`[installer] manifest from ${u.startsWith("https://github.com/") ? "direct" : "mirror"} → v${j.version}`);
|
|
302
|
-
resolve(j);
|
|
303
|
-
} else if (++done === urls.length && !resolved) {
|
|
304
|
-
reject(lastErr || new Error("manifest had no version field"));
|
|
305
|
-
}
|
|
306
|
-
}).catch(e => {
|
|
307
|
-
lastErr = e;
|
|
308
|
-
if (++done === urls.length && !resolved) reject(lastErr);
|
|
309
|
-
});
|
|
310
|
-
});
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
async function fetchLatestReleaseSmart() {
|
|
315
|
-
const archAlias = pickArchAlias();
|
|
316
|
-
if (!archAlias) throw new Error(`unsupported arch ${process.arch}`);
|
|
317
|
-
const plat = platformDir();
|
|
318
|
-
const key = `${plat}-${archAlias}`;
|
|
319
|
-
try {
|
|
320
|
-
const m = await fetchManifest();
|
|
321
|
-
const assetUrl = m.assets && m.assets[key];
|
|
322
|
-
if (!assetUrl) throw new Error(`no asset for ${key} in manifest`);
|
|
323
|
-
|
|
324
|
-
// The manifest can be uploaded before the binary asset on GitHub
|
|
325
|
-
// Releases (Actions uploads files sequentially). Verify the binary
|
|
326
|
-
// is actually reachable before declaring this version "available" —
|
|
327
|
-
// otherwise an "update available" indicator points at a 404.
|
|
328
|
-
const network = await netDetect.detect();
|
|
329
|
-
const probeUrls = buildUrlList(assetUrl, network);
|
|
330
|
-
const reachable = await new Promise((resolve) => {
|
|
331
|
-
let pending = probeUrls.length;
|
|
332
|
-
let done = false;
|
|
333
|
-
probeUrls.forEach((u) =>
|
|
334
|
-
headCheck(u, { timeoutMs: 5000 }).then((ok) => {
|
|
335
|
-
if (ok && !done) { done = true; resolve(true); }
|
|
336
|
-
if (--pending === 0 && !done) resolve(false);
|
|
337
|
-
})
|
|
338
|
-
);
|
|
339
|
-
});
|
|
340
|
-
if (!reachable) {
|
|
341
|
-
log.warn(`[installer] manifest v${m.version} present but binary asset not reachable yet`);
|
|
342
|
-
throw new Error(`RELEASE_NOT_READY:${m.version}`);
|
|
343
|
-
}
|
|
344
|
-
return {
|
|
345
|
-
version: m.version,
|
|
346
|
-
htmlUrl: `https://github.com/${REPO}/releases/tag/v${m.version}`,
|
|
347
|
-
archAlias,
|
|
348
|
-
assetUrl,
|
|
349
|
-
sizeBytes: (m.sizes && m.sizes[key]) || null,
|
|
350
|
-
};
|
|
351
|
-
} catch (manifestErr) {
|
|
352
|
-
// RELEASE_NOT_READY is a real signal — propagate to caller so the UI
|
|
353
|
-
// can show "wait a few minutes" instead of fallback-probing.
|
|
354
|
-
if (/^RELEASE_NOT_READY:/.test(manifestErr.message)) throw manifestErr;
|
|
355
|
-
// Fallback path: older releases (before manifest.json existed) still use
|
|
356
|
-
// jsdelivr tag list + HEAD probe. Will be removed once all live releases
|
|
357
|
-
// ship a manifest.json.
|
|
358
|
-
log.warn(`[installer] manifest fetch failed (${manifestErr.message}), falling back to jsdelivr probe`);
|
|
359
|
-
return fetchLatestReleaseLegacy();
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const JSDELIVR_DATA_URL = `https://data.jsdelivr.com/v1/package/gh/${REPO}`;
|
|
364
|
-
async function fetchLatestReleaseLegacy() {
|
|
365
|
-
const meta = await getJson(JSDELIVR_DATA_URL, { timeoutMs: 5000 });
|
|
366
|
-
const versions = meta.versions || [];
|
|
367
|
-
if (versions.length === 0) throw new Error("no versions in jsdelivr response");
|
|
368
|
-
const network = await netDetect.detect();
|
|
369
|
-
const archAlias = pickArchAlias();
|
|
370
|
-
const plat = platformDir();
|
|
371
|
-
for (const version of versions.slice(0, 6)) {
|
|
372
|
-
const directUrl = `https://github.com/${REPO}/releases/download/v${version}/cicy-code-${plat}-${archAlias}`;
|
|
373
|
-
const probeOrder = buildUrlList(directUrl, network);
|
|
374
|
-
log.info(`[installer] probing v${version} (legacy, network=${network})`);
|
|
375
|
-
const found = await new Promise(resolve => {
|
|
376
|
-
let done = 0;
|
|
377
|
-
probeOrder.forEach(u => headCheck(u, { timeoutMs: 3000 }).then(ok => {
|
|
378
|
-
if (ok && !done) resolve(u);
|
|
379
|
-
if (++done === probeOrder.length) resolve(null);
|
|
380
|
-
}));
|
|
381
|
-
});
|
|
382
|
-
if (found) return {
|
|
383
|
-
version,
|
|
384
|
-
htmlUrl: `https://github.com/${REPO}/releases/tag/v${version}`,
|
|
385
|
-
archAlias,
|
|
386
|
-
assetUrl: directUrl,
|
|
387
|
-
sizeBytes: null,
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
throw new Error(`no release binary found for ${plat}-${archAlias} in recent versions`);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function pickArchAlias() {
|
|
394
|
-
const arch = archDir();
|
|
395
|
-
if (!arch) return null;
|
|
396
|
-
const aliases = ARCH_ALIASES[arch] || [arch];
|
|
397
|
-
// First alias is the canonical one for our naming convention.
|
|
398
|
-
// Prefer Go-style amd64 over x64 since release names use it.
|
|
399
|
-
const preferred = arch === "x64" ? "amd64" : aliases[0];
|
|
400
|
-
return preferred;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
async function checkLatest() {
|
|
404
|
-
try {
|
|
405
|
-
const release = await fetchLatestReleaseSmart();
|
|
406
|
-
if (!release.version) return { ok: false, error: "no version found" };
|
|
407
|
-
const network = await netDetect.detect();
|
|
408
|
-
return {
|
|
409
|
-
ok: true,
|
|
410
|
-
latest: release.version,
|
|
411
|
-
assetName: `cicy-code-${platformDir()}-${release.archAlias}`,
|
|
412
|
-
assetUrl: release.assetUrl,
|
|
413
|
-
sizeBytes: release.sizeBytes, // populated when manifest.json available
|
|
414
|
-
network,
|
|
415
|
-
releaseUrl: release.htmlUrl,
|
|
416
|
-
installedVersion: userVersion(),
|
|
417
|
-
};
|
|
418
|
-
} catch (e) {
|
|
419
|
-
return { ok: false, error: e.message };
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// ---- parallel race download ----
|
|
424
|
-
// Race a list of URLs in parallel — whichever responds first wins. Each
|
|
425
|
-
// candidate writes to its own .partN temp file; on success, the winner is
|
|
426
|
-
// atomically moved (or copied) to dest and losers are cancelled and cleaned up.
|
|
427
|
-
//
|
|
428
|
-
// emit: coarse phase events (per-attempt "downloading" announcement)
|
|
429
|
-
// emitProgress: throttled byte-level progress updates
|
|
430
|
-
// outerSignal: external cancel — checked after the race resolves
|
|
431
|
-
async function raceDownload({ urls, dest, version, network, emit, emitProgress, outerSignal }) {
|
|
432
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
433
|
-
let lastErr;
|
|
434
|
-
|
|
435
|
-
await new Promise((resolve, reject) => {
|
|
436
|
-
let settled = false;
|
|
437
|
-
let pending = urls.length;
|
|
438
|
-
|
|
439
|
-
const settle = (winner) => {
|
|
440
|
-
if (settled) return;
|
|
441
|
-
settled = true;
|
|
442
|
-
resolve(winner);
|
|
443
|
-
controllers.forEach(c => { try { c.abort(); } catch {} });
|
|
444
|
-
};
|
|
445
|
-
const fail = (err) => {
|
|
446
|
-
lastErr = err;
|
|
447
|
-
if (--pending === 0 && !settled) reject(lastErr);
|
|
448
|
-
};
|
|
449
|
-
|
|
450
|
-
const controllers = urls.map((url, i) => {
|
|
451
|
-
const ctl = new AbortController();
|
|
452
|
-
const tmpPath = dest + `.part${i}`;
|
|
453
|
-
const { MIRRORS } = require("./mirrors");
|
|
454
|
-
const isMirror = MIRRORS.some(m => url.startsWith(m.url));
|
|
455
|
-
emit({ phase: "downloading", message: "并行下载中…", version, network, progress: 0 });
|
|
456
|
-
downloadFile(url, tmpPath, {
|
|
457
|
-
signal: ctl.signal,
|
|
458
|
-
timeoutMs: 60000,
|
|
459
|
-
onProgress: ({ received, total }) => {
|
|
460
|
-
if (settled) return;
|
|
461
|
-
const pct = total ? received / total : null;
|
|
462
|
-
emitProgress({ phase: "downloading", message: `下载中 (${isMirror ? "镜像" : "直连"})`, progress: pct, version, network, received, total });
|
|
463
|
-
},
|
|
464
|
-
}).then(({ destPath }) => {
|
|
465
|
-
if (settled) { try { fs.unlinkSync(destPath); } catch {} return; }
|
|
466
|
-
try { fs.unlinkSync(dest); } catch {}
|
|
467
|
-
try {
|
|
468
|
-
fs.renameSync(destPath, dest);
|
|
469
|
-
} catch {
|
|
470
|
-
fs.copyFileSync(destPath, dest);
|
|
471
|
-
try { fs.unlinkSync(destPath); } catch {}
|
|
472
|
-
}
|
|
473
|
-
settle(url);
|
|
474
|
-
}).catch(err => {
|
|
475
|
-
if (err.message !== "cancelled") fail(err);
|
|
476
|
-
try { fs.unlinkSync(tmpPath); } catch {}
|
|
477
|
-
});
|
|
478
|
-
return ctl;
|
|
479
|
-
});
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
if (outerSignal && outerSignal.aborted) throw new Error("cancelled");
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// ---- install ----
|
|
486
|
-
async function install({ onProgress } = {}) {
|
|
487
|
-
if (inflight) throw new Error("install already in progress");
|
|
488
|
-
|
|
489
|
-
const ac = new AbortController();
|
|
490
|
-
inflight = { abort: () => ac.abort() };
|
|
491
|
-
|
|
492
|
-
const emit = (event) => {
|
|
493
|
-
lastProgress = event;
|
|
494
|
-
if (onProgress) {
|
|
495
|
-
try { onProgress(event); } catch {}
|
|
496
|
-
}
|
|
497
|
-
};
|
|
498
|
-
|
|
499
|
-
// Throttled progress emitter — max 1 update per 80ms to prevent
|
|
500
|
-
// rapid parallel-download events from causing UI flicker.
|
|
501
|
-
let pendingProg = null;
|
|
502
|
-
let progTimer = null;
|
|
503
|
-
const emitProgress = (event) => {
|
|
504
|
-
pendingProg = event;
|
|
505
|
-
if (!progTimer) {
|
|
506
|
-
progTimer = setTimeout(() => {
|
|
507
|
-
progTimer = null;
|
|
508
|
-
if (pendingProg) {
|
|
509
|
-
emit(pendingProg);
|
|
510
|
-
pendingProg = null;
|
|
511
|
-
}
|
|
512
|
-
}, 80);
|
|
513
|
-
}
|
|
514
|
-
};
|
|
515
|
-
|
|
516
|
-
try {
|
|
517
|
-
emit({ phase: "detecting", message: "检测网络…" });
|
|
518
|
-
const network = await netDetect.detect();
|
|
519
|
-
|
|
520
|
-
emit({ phase: "checking", message: "检查最新版本…", network });
|
|
521
|
-
const check = await checkLatest();
|
|
522
|
-
if (!check.ok) throw new Error(check.error);
|
|
523
|
-
|
|
524
|
-
if (check.installedVersion === check.latest) {
|
|
525
|
-
const bin = userBinary();
|
|
526
|
-
const binaryExists = bin && bin !== "wsl:cicy-code" && fs.existsSync(bin);
|
|
527
|
-
if (binaryExists) {
|
|
528
|
-
const ev = { phase: "done", message: `已是最新版本 v${check.latest}`, version: check.latest, network, alreadyUpToDate: true };
|
|
529
|
-
emit(ev);
|
|
530
|
-
return ev;
|
|
531
|
-
}
|
|
532
|
-
// version file says up-to-date but binary is missing — re-download
|
|
533
|
-
log.info(`[installer] version=${check.latest} but binary missing, re-downloading`);
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// ── Windows: download on host (parallel race + progress + verify),
|
|
537
|
-
// then hand the file to WSL. setupAll() also handles WSL/Ubuntu install
|
|
538
|
-
// if needed, fully automatic with CN-aware mirrors. Network detection is
|
|
539
|
-
// already done; we pass it through so wsl.installWsl picks --web-download
|
|
540
|
-
// first when in CN (avoids slow Microsoft Store).
|
|
541
|
-
if (process.platform === "win32") {
|
|
542
|
-
const wsl = require("./wsl");
|
|
543
|
-
|
|
544
|
-
// Stage to userData/cicy-code/wsl-stage. Path is accessible from WSL
|
|
545
|
-
// via /mnt/c/... (translated by wslpath in installFromHostFile).
|
|
546
|
-
const stageDir = path.join(app.getPath("userData"), "cicy-code", "wsl-stage");
|
|
547
|
-
const stagePath = path.join(stageDir, "cicy-code-staged");
|
|
548
|
-
|
|
549
|
-
emit({ phase: "downloading", message: `下载 cicy-code v${check.latest}…`, version: check.latest, network, progress: 0 });
|
|
550
|
-
const order = buildUrlList(check.assetUrl, network);
|
|
551
|
-
await raceDownload({
|
|
552
|
-
urls: order,
|
|
553
|
-
dest: stagePath,
|
|
554
|
-
version: check.latest,
|
|
555
|
-
network,
|
|
556
|
-
emit,
|
|
557
|
-
emitProgress,
|
|
558
|
-
outerSignal: ac.signal,
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
// setupAll handles the rest: install WSL+Ubuntu if needed, then
|
|
562
|
-
// copy the staged binary into the distro. Each step streams progress.
|
|
563
|
-
const result = await wsl.setupAll({
|
|
564
|
-
network,
|
|
565
|
-
hostStagePath: stagePath,
|
|
566
|
-
version: check.latest,
|
|
567
|
-
onProgress: emit,
|
|
568
|
-
});
|
|
569
|
-
const installedVersion = result.version || check.latest;
|
|
570
|
-
if (installedVersion !== check.latest) {
|
|
571
|
-
log.warn(`[installer] WSL binary reports v${installedVersion}, expected v${check.latest} — mirror likely cached stale content`);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
// Cache version on Windows side so userVersion() doesn't need WSL.
|
|
575
|
-
try {
|
|
576
|
-
const cacheDir = path.join(app.getPath("userData"), "cicy-code");
|
|
577
|
-
fs.mkdirSync(cacheDir, { recursive: true });
|
|
578
|
-
fs.writeFileSync(path.join(cacheDir, "wsl-version"), installedVersion, "utf8");
|
|
579
|
-
} catch {}
|
|
580
|
-
|
|
581
|
-
try { fs.unlinkSync(stagePath); } catch {}
|
|
582
|
-
|
|
583
|
-
const final = { phase: "done", message: `已安装 v${installedVersion}`, version: installedVersion, network };
|
|
584
|
-
emit(final);
|
|
585
|
-
return final;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
emit({ phase: "downloading", message: `下载 cicy-code v${check.latest}…`, version: check.latest, network, progress: 0 });
|
|
589
|
-
|
|
590
|
-
const order = buildUrlList(check.assetUrl, network);
|
|
591
|
-
// Download straight to ~/.local/bin/cicy-code-<assumed version>. We re-
|
|
592
|
-
// verify the version after by execing --version; if the mirror served a
|
|
593
|
-
// stale binary the file may end up renamed.
|
|
594
|
-
let dest = versionedBinaryPath(check.latest);
|
|
595
|
-
if (!dest) throw new Error(`unsupported platform ${process.platform}-${process.arch}`);
|
|
596
|
-
|
|
597
|
-
await raceDownload({
|
|
598
|
-
urls: order,
|
|
599
|
-
dest,
|
|
600
|
-
version: check.latest,
|
|
601
|
-
network,
|
|
602
|
-
emit,
|
|
603
|
-
emitProgress,
|
|
604
|
-
outerSignal: ac.signal,
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
emit({ phase: "installing", message: "正在安装…", version: check.latest, network, progress: 1 });
|
|
608
|
-
|
|
609
|
-
try { fs.chmodSync(dest, 0o755); } catch (e) { log.warn(`[installer] chmod failed: ${e.message}`); }
|
|
610
|
-
|
|
611
|
-
// Verify the downloaded binary reports the expected version. Mirrors
|
|
612
|
-
// sometimes serve stale cached content. If `--version` disagrees, rename
|
|
613
|
-
// the file onto the real version so the symlink we point at is honest.
|
|
614
|
-
let installedVersion = check.latest;
|
|
615
|
-
try {
|
|
616
|
-
const { execFileSync } = require("child_process");
|
|
617
|
-
const out = execFileSync(dest, ["--version"], { timeout: 5000, encoding: "utf8" }).trim();
|
|
618
|
-
const m = out.match(/(\d+\.\d+\.\d+)/);
|
|
619
|
-
if (m && m[1] !== check.latest) {
|
|
620
|
-
log.warn(`[installer] downloaded binary reports v${m[1]}, expected v${check.latest} — mirror likely cached stale content`);
|
|
621
|
-
const realDest = versionedBinaryPath(m[1]);
|
|
622
|
-
if (realDest && realDest !== dest) {
|
|
623
|
-
try { fs.unlinkSync(realDest); } catch {}
|
|
624
|
-
fs.renameSync(dest, realDest);
|
|
625
|
-
dest = realDest;
|
|
626
|
-
}
|
|
627
|
-
installedVersion = m[1];
|
|
628
|
-
}
|
|
629
|
-
} catch (e) {
|
|
630
|
-
log.warn(`[installer] version verify failed: ${e.message}`);
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
// Atomic-replace the `cicy-code` symlink to point at the new versioned
|
|
634
|
-
// binary. Write the new link at a tmp name + rename = atomic on POSIX,
|
|
635
|
-
// so a concurrent reader either sees the old version or the new one,
|
|
636
|
-
// never a missing file.
|
|
637
|
-
const link = userBinary(); // ~/.local/bin/cicy-code
|
|
638
|
-
const linkDir = path.dirname(link);
|
|
639
|
-
fs.mkdirSync(linkDir, { recursive: true });
|
|
640
|
-
const tmpLink = `${link}.new-${process.pid}-${Date.now()}`;
|
|
641
|
-
try { fs.unlinkSync(tmpLink); } catch {}
|
|
642
|
-
fs.symlinkSync(path.basename(dest), tmpLink); // relative → cicy-code-<ver>
|
|
643
|
-
fs.renameSync(tmpLink, link);
|
|
644
|
-
|
|
645
|
-
log.info(`[installer] linked ~/.local/bin/cicy-code → cicy-code-${installedVersion}`);
|
|
646
|
-
const final = { phase: "done", message: `已安装 v${installedVersion}`, version: installedVersion, network };
|
|
647
|
-
emit(final);
|
|
648
|
-
return final;
|
|
649
|
-
} catch (e) {
|
|
650
|
-
const ev = e.message === "cancelled"
|
|
651
|
-
? { phase: "cancelled", message: "已取消" }
|
|
652
|
-
: { phase: "error", message: `失败: ${e.message}`, error: e.message };
|
|
653
|
-
emit(ev);
|
|
654
|
-
throw e;
|
|
655
|
-
} finally {
|
|
656
|
-
inflight = null;
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
function cancel() {
|
|
661
|
-
if (inflight) inflight.abort();
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
module.exports = {
|
|
665
|
-
getStatus,
|
|
666
|
-
isRunning,
|
|
667
|
-
checkLatest,
|
|
668
|
-
install,
|
|
669
|
-
cancel,
|
|
670
|
-
userBinary,
|
|
671
|
-
userVersion,
|
|
672
|
-
};
|