cicy-desktop 2.1.329 → 2.1.331

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.329",
3
+ "version": "2.1.331",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -21,7 +21,7 @@ const os = require("os");
21
21
  const { readGlobalConfig, updateGlobalConfig } = require("./utils/global-json");
22
22
  const https = require("https");
23
23
  const log = require("electron-log");
24
- const { R2_RELEASES_BASE } = require("./sidecar/mirrors");
24
+ const { R2_RELEASES_BASE, buildUrlList } = require("./sidecar/mirrors");
25
25
 
26
26
  // ── 版本比较:>0 a 新于 b ─────────────────────────────────────────────────────
27
27
  function cmpVer(a, b) {
@@ -74,25 +74,76 @@ function headOk(url, redirects = 0) {
74
74
  }
75
75
 
76
76
  // ── 最新版本号 ────────────────────────────────────────────────────────────────
77
+ // R2 优先(win/mac/linux 各自指针,两条 CI 版本可能不同步,所以各读各的),GitHub 兜底。
78
+ //
79
+ // 原先这里是「一律读 OSS,彻底不碰 GitHub」,理由是仓库可能私有。代价是 R2 一旦不可达,
80
+ // 自更新就整条断掉 —— 而 fleet 里确实有一批 Windows 节点连 r2.deepfetch.de5.net 直接
81
+ // ECONNRESET(GitHub 反而 ~300ms 可达),它们因此永远停在旧版、只能人工推包。
82
+ // 现在 R2 仍是首选(仓库若再转私有,这条路不受影响),失败才回退 GitHub Release;
83
+ // 两边都不通才算真失败。
84
+ const GH_REPO = "cicy-ai/cicy-desktop";
85
+
86
+ function fetchLatestVersionFromGitHub() {
87
+ return new Promise((resolve, reject) => {
88
+ const req = https.get(
89
+ `https://api.github.com/repos/${GH_REPO}/releases/latest`,
90
+ { headers: { "User-Agent": "cicy-desktop-updater", Accept: "application/vnd.github+json" }, timeout: 10000 },
91
+ (res) => {
92
+ if (res.statusCode !== 200) { res.resume(); return reject(new Error(`GitHub HTTP ${res.statusCode}`)); }
93
+ let body = "";
94
+ res.setEncoding("utf8");
95
+ res.on("data", (d) => (body += d));
96
+ res.on("end", () => {
97
+ try {
98
+ const tag = String((JSON.parse(body) || {}).tag_name || "").replace(/^v/, "");
99
+ tag ? resolve(tag) : reject(new Error("no tag_name"));
100
+ } catch (e) { reject(e); }
101
+ });
102
+ },
103
+ );
104
+ req.on("error", reject);
105
+ req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
106
+ });
107
+ }
108
+
77
109
  async function fetchLatestVersion() {
78
- // 版本清单**一律读 OSS**(win/mac/linux 各自指针),与网络判定无关 —— 彻底不碰 GitHub,
79
- // 这样 cicy-desktop 仓库私有也不影响更新检查。win/mac 是两条独立 CI、版本可能不同步,读错
80
- // 平台会 404,所以各读各的指针文件。
81
110
  const file = process.platform === "win32" ? "win-latest-version.txt"
82
111
  : process.platform === "darwin" ? "mac-latest-version.txt"
83
112
  : "linux-latest-version.txt";
84
- return (await getText(`${R2_RELEASES_BASE}/${file}`)).trim().replace(/^v/, "");
113
+ try {
114
+ return (await getText(`${R2_RELEASES_BASE}/${file}`)).trim().replace(/^v/, "");
115
+ } catch (e) {
116
+ log.warn(`[app-updater] R2 版本指针不可达(${e.message}),回退 GitHub Release`);
117
+ return await fetchLatestVersionFromGitHub();
118
+ }
85
119
  }
86
120
 
87
- // ── 安装包 URL(分平台 + 分网络)+ 本地文件名 ────────────────────────────────
121
+ // ── 安装包来源(有序候选)+ 本地文件名 ──────────────────────────────────────
122
+ // 返回 { file, urls } —— urls 是按优先级排好的候选:R2 → GitHub 直连 → ghproxy 镜像。
123
+ // 调用方依次尝试,第一个能下的算数(见 check() 的 HEAD 探测和 downloadUpdate 的重试)。
124
+ //
125
+ // 注意 Windows 两边**文件名不同**:R2 上是 cicy-desktop-<ver>.exe(CI 传的版本化副本),
126
+ // GitHub Release 里是 electron-builder 产出的 CiCy-Desktop-Setup-<ver>.exe。
127
+ // mac 的 pkg 和 linux 的 AppImage 两边同名。`file` 只是本地落盘名,保持原样不变。
88
128
  function assetFor(version) {
89
129
  const plat = process.platform;
90
130
  const arch = process.arch === "arm64" ? "arm64" : "x64";
91
- // 三端安装包**全部从 OSS 拉**,与网络无关 —— 不再拼 GitHub 下载 URL,仓库私有也能更新。
92
131
  const file = plat === "win32" ? `cicy-desktop-${version}.exe`
93
132
  : plat === "darwin" ? `cicy-desktop-${version}-${arch}.pkg`
94
133
  : `CiCy-Desktop-${version}.AppImage`;
95
- return { url: `${R2_RELEASES_BASE}/${file}`, file };
134
+ const ghName = plat === "win32" ? `CiCy-Desktop-Setup-${version}.exe` : file;
135
+ const ghUrl = `https://github.com/${GH_REPO}/releases/download/v${version}/${ghName}`;
136
+ // buildUrlList("global") = [直连, ...镜像];R2 排在最前面。
137
+ const urls = [`${R2_RELEASES_BASE}/${file}`, ...buildUrlList(ghUrl, "global")];
138
+ return { file, urls, url: urls[0] };
139
+ }
140
+
141
+ // 第一个 HEAD 得到 200/206 的候选;都不行返回 null。
142
+ async function firstReachable(urls) {
143
+ for (const u of urls) {
144
+ if (await headOk(u)) return u;
145
+ }
146
+ return null;
96
147
  }
97
148
 
98
149
  // ── 下载(带进度,跟随重定向)──────────────────────────────────────────────────
@@ -179,9 +230,10 @@ async function check() {
179
230
  if (latest && cmpVer(latest, current) > 0) {
180
231
  // 「先出包再更新版本」客户端保险:版本号涨了不代表包传完了。先 HEAD 确认本平台安装包
181
232
  // 真能下(200),否则当成「还没就绪」继续显已是最新,避免用户点下载 404。
182
- const { url } = assetFor(latest);
183
- const ready = await headOk(url);
233
+ const { urls } = assetFor(latest);
234
+ const ready = await firstReachable(urls);
184
235
  if (ready) {
236
+ log.info(`[app-updater] ${latest} 安装包就绪:${ready}`);
185
237
  broadcast({ status: "available", version: latest, current, progress: null, filePath: null, autoUpdate: getAutoUpdate(), auto: false });
186
238
  if (getAutoUpdate()) {
187
239
  log.info(`[app-updater] auto-update on → downloading ${latest} and installing without asking`);
@@ -189,7 +241,7 @@ async function check() {
189
241
  await downloadUpdate();
190
242
  if (_state.status === "ready") installNow();
191
243
  }
192
- } else { log.info(`[app-updater] ${latest} 版本号已更新但安装包未就位(HEAD 非 200):${url} — 暂不提示更新`); broadcast({ status: "up-to-date", version: current, current }); }
244
+ } else { log.info(`[app-updater] ${latest} 版本号已更新但所有来源都拿不到安装包(HEAD 非 200):${urls.join(" , ")} — 暂不提示更新`); broadcast({ status: "up-to-date", version: current, current }); }
193
245
  } else {
194
246
  broadcast({ status: "up-to-date", version: latest || current, current });
195
247
  }
@@ -208,16 +260,26 @@ async function downloadUpdate() {
208
260
  if (!version) { broadcast({ status: "error", error: "no version" }); return _state; }
209
261
  _downloading = true;
210
262
  try {
211
- const { url, file } = assetFor(version);
263
+ const { urls, file } = assetFor(version);
212
264
  // 下到 ~/Downloads(用户能直接找到安装包);目录不存在则建。
213
265
  const dir = app.getPath("downloads");
214
266
  try { fs.mkdirSync(dir, { recursive: true }); } catch {}
215
267
  const dest = path.join(dir, file);
216
- log.info(`[app-updater] downloading ${url} → ${dest}`);
217
268
  broadcast({ status: "downloading", progress: { percent: 0, transferred: 0, total: 0 }, filePath: null });
218
- await download(url, dest, (p) => broadcast({ status: "downloading", progress: p }));
219
- broadcast({ status: "ready", filePath: dest, progress: { percent: 100 } });
220
- return _state;
269
+ // 逐个候选试(R2 GitHub 镜像):一个源断了就换下一个,全断才算失败。
270
+ let lastErr = null;
271
+ for (const url of urls) {
272
+ try {
273
+ log.info(`[app-updater] downloading ${url} → ${dest}`);
274
+ await download(url, dest, (p) => broadcast({ status: "downloading", progress: p }));
275
+ broadcast({ status: "ready", filePath: dest, progress: { percent: 100 } });
276
+ return _state;
277
+ } catch (e) {
278
+ lastErr = e;
279
+ log.warn(`[app-updater] 源失败(${url}):${e.message}`);
280
+ }
281
+ }
282
+ throw lastErr || new Error("no source available");
221
283
  } catch (e) {
222
284
  log.warn("[app-updater] download failed:", e.message);
223
285
  broadcast({ status: "error", error: e.message });
package/src/main.js CHANGED
@@ -949,22 +949,29 @@ function ensureAutoLaunch() {
949
949
  // (它先起 master 再起 worker,直接起 electron.exe 会缺 master)。登录项指向一个
950
950
  // wscript 隐藏启动的 VBS → `cmd /c node .\bin\cicy-desktop`,全程无控制台窗口。
951
951
  const fromSource = !electronApp.isPackaged;
952
- let opts;
952
+ let opts, runCmd;
953
953
  if (fromSource) {
954
954
  const vbs = writeSourceAutostartVbs();
955
955
  opts = { openAtLogin: want, path: "wscript.exe", args: ["//B", "//Nologo", vbs] };
956
+ runCmd = `wscript.exe //B //Nologo "${vbs}"`;
956
957
  } else {
957
958
  opts = { openAtLogin: want, args: ["--hidden"] };
959
+ runCmd = `"${process.execPath}" --hidden`;
958
960
  }
959
- const cur = electronApp.getLoginItemSettings(
960
- fromSource ? { path: opts.path, args: opts.args } : undefined
961
- );
962
- if (cur.openAtLogin !== want) {
961
+ const probe = () =>
962
+ electronApp.getLoginItemSettings(fromSource ? { path: opts.path, args: opts.args } : undefined);
963
+ if (probe().openAtLogin !== want) {
963
964
  electronApp.setLoginItemSettings(opts);
964
965
  log.info(
965
966
  `[autostart] openAtLogin → ${want}${fromSource ? ` (source: ${opts.path} ${opts.args.join(" ")})` : ""}`
966
967
  );
967
968
  }
969
+ // Read it back. setLoginItemSettings() returns without throwing on every
970
+ // Windows node of this fleet and still leaves openAtLogin false, with
971
+ // HKCU\...\Run untouched — so autostart silently never existed, and a node
972
+ // whose app went away (e.g. an installer closed it) never came back, not
973
+ // even after a reboot. When the API did not take, write the Run key.
974
+ if (probe().openAtLogin !== want) ensureWindowsRunKey(want, runCmd);
968
975
  } else if (process.platform === "linux") {
969
976
  if (!electronApp.isPackaged) return;
970
977
  ensureLinuxAutostart(want);
@@ -974,6 +981,27 @@ function ensureAutoLaunch() {
974
981
  }
975
982
  }
976
983
 
984
+ // Fallback for the silently-failing setLoginItemSettings (see ensureAutoLaunch):
985
+ // own the HKCU Run entry directly. Async so a slow reg.exe never delays startup;
986
+ // failures are logged, never thrown — autostart is best-effort.
987
+ const WIN_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
988
+ const WIN_RUN_NAME = "CiCy Desktop";
989
+ function ensureWindowsRunKey(want, command) {
990
+ try {
991
+ const { execFile } = require("child_process");
992
+ const args = want
993
+ ? ["add", WIN_RUN_KEY, "/v", WIN_RUN_NAME, "/t", "REG_SZ", "/d", command, "/f"]
994
+ : ["delete", WIN_RUN_KEY, "/v", WIN_RUN_NAME, "/f"];
995
+ execFile("reg", args, { windowsHide: true }, (err) => {
996
+ // deleting an absent value exits non-zero — that is the wanted end state.
997
+ if (err && want) log.warn(`[autostart] Run-key fallback failed: ${err.message}`);
998
+ else if (want) log.info(`[autostart] Run-key fallback wrote ${WIN_RUN_NAME} → ${command}`);
999
+ });
1000
+ } catch (e) {
1001
+ log.warn(`[autostart] Run-key fallback threw: ${e.message}`);
1002
+ }
1003
+ }
1004
+
977
1005
  // 源码模式 Windows 登录自启的隐藏启动器(VBS)。写到 %LOCALAPPDATA%\cicy-desktop,幂等覆盖。
978
1006
  // 继承当前的 CICY_DEBUG(用 start-cicy-desktop-win.bat 起的就带 =1),其余环境用登录时的用户环境。
979
1007
  function writeSourceAutostartVbs() {
@@ -233,14 +233,23 @@ function start({ force = false } = {}) {
233
233
 
234
234
  // Full enable: ensure binary → copy+adapt container config → (re)start.
235
235
  // containerYaml is fetched by the caller (sidecar-ipc) via appDocker.
236
+ // A missing container config is NOT fatal on its own: when the WSL/docker
237
+ // cicy-code isn't installed (or is down) we run STANDALONE on the host's own
238
+ // mihomo-host.yaml if one is already there, so the per-profile proxies keep
239
+ // working instead of dying with the container — that's what lets Windows run
240
+ // without WSL at all. Only "no container config AND no host config" leaves us
241
+ // nothing to start. Standalone has no authoritative selection source, so the
242
+ // selection sync is skipped rather than overriding whatever the host holds.
236
243
  async function enable({ containerYaml, selections = {}, emit } = {}) {
237
244
  await ensureBinary({ emit });
238
- if (!containerYaml) throw new Error(tt("noContainerConfig"));
239
- const changed = writeConfig(containerYaml);
245
+ let changed = false, standalone = false;
246
+ if (containerYaml) changed = writeConfig(containerYaml);
247
+ else if (fs.existsSync(HOST_CONFIG)) standalone = true;
248
+ else throw new Error(tt("noContainerConfig"));
240
249
  const res = start({ force: changed });
241
- const synced = await syncSelections(selections);
250
+ const synced = standalone ? { updated: [] } : await syncSelections(selections);
242
251
  emit && emit({ phase: "chrome-proxy", status: "running", message: tt("ready") });
243
- return { ok: true, ...res, ...synced };
252
+ return { ok: true, standalone, ...res, ...synced };
244
253
  }
245
254
 
246
255
  module.exports = {
@@ -0,0 +1,81 @@
1
+ // Copyright 2026 CiCy AI
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // The self-updater used to read BOTH the version pointer and the installer from
5
+ // R2 only ("彻底不碰 GitHub"). That made auto-update fail closed on every node
6
+ // that cannot reach r2.deepfetch.de5.net — a real slice of the fleet answers
7
+ // ECONNRESET there while GitHub responds in ~300ms, so those boxes sat on an old
8
+ // version until someone pushed a package by hand. R2 stays FIRST (a private repo
9
+ // must keep working); GitHub is the fallback.
10
+ //
11
+ // app-updater requires electron at load time, so this asserts on source shape —
12
+ // the same convention as app-updater-auto.test.js.
13
+ const test = require("node:test");
14
+ const assert = require("node:assert/strict");
15
+ const fs = require("node:fs");
16
+ const path = require("node:path");
17
+
18
+ const src = fs.readFileSync(path.join(__dirname, "..", "src", "app-updater.js"), "utf8");
19
+
20
+ test("version pointer falls back to the GitHub release when R2 is unreachable", () => {
21
+ assert.match(src, /function fetchLatestVersionFromGitHub\(\)/);
22
+ assert.match(src, /api\.github\.com\/repos\/\$\{GH_REPO\}\/releases\/latest/);
23
+ assert.match(src, /tag_name/);
24
+ // R2 first, GitHub only in the catch — not the other way round.
25
+ const fn = src.slice(
26
+ src.indexOf("async function fetchLatestVersion()"),
27
+ src.indexOf("// ── 安装包来源")
28
+ );
29
+ assert.match(fn, /R2_RELEASES_BASE\}\/\$\{file\}/);
30
+ assert.match(fn, /catch \(e\) \{[^]*fetchLatestVersionFromGitHub\(\)/);
31
+ assert.ok(
32
+ fn.indexOf("R2_RELEASES_BASE") < fn.indexOf("fetchLatestVersionFromGitHub"),
33
+ "R2 must be tried first"
34
+ );
35
+ });
36
+
37
+ test("assetFor returns ordered candidates: R2 → GitHub → mirrors", () => {
38
+ const fn = src.slice(
39
+ src.indexOf("function assetFor(version)"),
40
+ src.indexOf("async function firstReachable")
41
+ );
42
+ assert.match(
43
+ fn,
44
+ /const urls = \[`\$\{R2_RELEASES_BASE\}\/\$\{file\}`, \.\.\.buildUrlList\(ghUrl, "global"\)\]/
45
+ );
46
+ // Windows is the one platform whose asset is named differently on each source:
47
+ // R2 keeps the CI's versioned copy, GitHub has electron-builder's Setup exe.
48
+ assert.match(fn, /ghName = plat === "win32" \? `CiCy-Desktop-Setup-\$\{version\}\.exe` : file/);
49
+ assert.match(fn, /releases\/download\/v\$\{version\}\/\$\{ghName\}/);
50
+ assert.match(fn, /return \{ file, urls, url: urls\[0\] \}/);
51
+ });
52
+
53
+ test("check() probes every candidate before declaring the package missing", () => {
54
+ assert.match(src, /async function firstReachable\(urls\)/);
55
+ assert.match(src, /for \(const u of urls\) \{[^]*if \(await headOk\(u\)\) return u;/);
56
+ const chk = src.slice(
57
+ src.indexOf("async function check()"),
58
+ src.indexOf("async function downloadUpdate()")
59
+ );
60
+ assert.match(chk, /const \{ urls \} = assetFor\(latest\)/);
61
+ assert.match(chk, /const ready = await firstReachable\(urls\)/);
62
+ assert.doesNotMatch(chk, /await headOk\(url\)/); // the old single-source probe is gone
63
+ });
64
+
65
+ test("downloadUpdate() retries the next source instead of failing on the first", () => {
66
+ const dl = src.slice(
67
+ src.indexOf("async function downloadUpdate()"),
68
+ src.indexOf("function installNow()")
69
+ );
70
+ assert.match(dl, /const \{ urls, file \} = assetFor\(version\)/);
71
+ assert.match(dl, /for \(const url of urls\) \{/);
72
+ assert.match(dl, /catch \(e\) \{[^]*lastErr = e/);
73
+ assert.match(dl, /throw lastErr \|\| new Error\("no source available"\)/);
74
+ });
75
+
76
+ test("mirrors helper is imported so CN keeps its ghproxy fallback", () => {
77
+ assert.match(
78
+ src,
79
+ /const \{ R2_RELEASES_BASE, buildUrlList \} = require\("\.\/sidecar\/mirrors"\)/
80
+ );
81
+ });
@@ -0,0 +1,59 @@
1
+ // Copyright 2026 CiCy AI
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // Windows autostart had no test and never worked. Measured across the fleet:
5
+ // app.setLoginItemSettings({openAtLogin:true}) returns without throwing, yet
6
+ // getLoginItemSettings() still reports false and HKCU\...\Run stays untouched
7
+ // (the key exists and is writable — OneDrive lives there). So every Windows node
8
+ // ran with no autostart at all, which is why one whose app went away never came
9
+ // back, not even after a reboot. ensureAutoLaunch() now reads the setting back
10
+ // and owns the Run entry itself when the API did not take.
11
+ const test = require("node:test");
12
+ const assert = require("node:assert/strict");
13
+ const fs = require("node:fs");
14
+ const path = require("node:path");
15
+
16
+ const src = fs.readFileSync(path.join(__dirname, "..", "src", "main.js"), "utf8");
17
+ const fn = src.slice(
18
+ src.indexOf("function ensureAutoLaunch()"),
19
+ src.indexOf("function ensureWindowsRunKey")
20
+ );
21
+
22
+ test("windows autostart verifies the setting instead of trusting the API", () => {
23
+ // A single getLoginItemSettings() call before the write is not enough — the
24
+ // regression is entirely in what happens AFTER setLoginItemSettings().
25
+ assert.match(fn, /const probe = \(\) =>/);
26
+ assert.match(fn, /if \(probe\(\)\.openAtLogin !== want\) \{[^]*setLoginItemSettings\(opts\)/);
27
+ assert.match(fn, /if \(probe\(\)\.openAtLogin !== want\) ensureWindowsRunKey\(want, runCmd\)/);
28
+ });
29
+
30
+ test("the Run-key command differs for packaged vs source runs", () => {
31
+ // Packaged: the exe itself, hidden. Source: wscript running the VBS launcher,
32
+ // because starting electron.exe directly skips bin/cicy-desktop's master boot.
33
+ assert.match(fn, /runCmd = `"\$\{process\.execPath\}" --hidden`/);
34
+ assert.match(fn, /runCmd = `wscript\.exe \/\/B \/\/Nologo "\$\{vbs\}"`/);
35
+ });
36
+
37
+ test("ensureWindowsRunKey adds or removes the HKCU Run value, async and non-fatal", () => {
38
+ const helper = src.slice(
39
+ src.indexOf("function ensureWindowsRunKey"),
40
+ src.indexOf("// 源码模式 Windows 登录自启")
41
+ );
42
+ assert.match(
43
+ src,
44
+ /WIN_RUN_KEY = "HKCU\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run"/
45
+ );
46
+ assert.match(
47
+ helper,
48
+ /\["add", WIN_RUN_KEY, "\/v", WIN_RUN_NAME, "\/t", "REG_SZ", "\/d", command, "\/f"\]/
49
+ );
50
+ assert.match(helper, /\["delete", WIN_RUN_KEY, "\/v", WIN_RUN_NAME, "\/f"\]/);
51
+ assert.match(helper, /execFile\("reg", args, \{ windowsHide: true \}/); // no console flash, no startup stall
52
+ assert.match(helper, /catch \(e\) \{[^]*log\.warn/); // best-effort: never throws into startup
53
+ });
54
+
55
+ test("autostart still defaults to on and is honoured per-platform", () => {
56
+ assert.match(fn, /const want = prefs\.openAtLogin !== false/);
57
+ assert.match(fn, /if \(process\.platform === "darwin"\)/);
58
+ assert.match(fn, /else if \(process\.platform === "linux"\)/);
59
+ });