cicy-desktop 2.1.237 → 2.1.239

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.237",
3
+ "version": "2.1.239",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -9,10 +9,8 @@
9
9
  "scripts": {
10
10
  "start": "node ./bin/cicy-desktop",
11
11
  "start:master": "node src/master/master-main.js",
12
- "format": "prettier --write \"src/**/*.js\" \"tests/**/*.js\"",
13
- "format:check": "prettier --check \"src/**/*.js\" \"tests/**/*.js\"",
14
- "test": "jest",
15
- "test:ls": "node tests/test-ls.js",
12
+ "format": "prettier --write \"src/**/*.js\"",
13
+ "format:check": "prettier --check \"src/**/*.js\"",
16
14
  "build": "electron-builder --publish never",
17
15
  "build:homepage": "node scripts/build-homepage.cjs",
18
16
  "prebuild:win": "node scripts/build-homepage.cjs",
@@ -141,11 +139,11 @@
141
139
  "//optionalDependencies": "(2026-06 回调): mac/linux 改回 native cicy-code(:8008,colima 在 16G mac 上压垮内存)→ 重新内置 cicy-code-<plat> / cicy-mihomo-<plat>,localbin.fromBundle 零网络 seed(npm 仅作更新通道,带 npmmirror→npmjs 回退)。这些由 scripts/sync-runtime-deps.cjs 在 tag-push 时同步到最新版。npm 的 os/cpu 字段保证每个平台只装自己那份。Windows 仍走 docker(WSL :8009),它那份 cicy-code-windows 用不到但 bundle 着无害。",
142
140
  "optionalDependencies": {
143
141
  "electron": "41.0.3",
144
- "cicy-code-darwin-x64": "2.3.192",
145
- "cicy-code-darwin-arm64": "2.3.192",
146
- "cicy-code-linux-x64": "2.3.192",
147
- "cicy-code-linux-arm64": "2.3.192",
148
- "cicy-code-windows-x64": "2.3.192",
142
+ "cicy-code-darwin-x64": "2.3.194",
143
+ "cicy-code-darwin-arm64": "2.3.194",
144
+ "cicy-code-linux-x64": "2.3.194",
145
+ "cicy-code-linux-arm64": "2.3.194",
146
+ "cicy-code-windows-x64": "2.3.193",
149
147
  "cicy-mihomo-darwin-x64": "1.10.4",
150
148
  "cicy-mihomo-darwin-arm64": "1.10.4",
151
149
  "cicy-mihomo-linux-x64": "1.10.4",
@@ -154,13 +152,8 @@
154
152
  "cicy-mihomo-windows-arm64": "1.10.4"
155
153
  },
156
154
  "devDependencies": {
157
- "@babel/core": "^7.29.0",
158
- "@babel/preset-env": "^7.29.0",
159
- "axios": "^1.13.5",
160
155
  "electron": "41.0.3",
161
156
  "electron-builder": "^26.7.0",
162
- "jest": "^29.7.0",
163
- "prettier": "^3.8.1",
164
- "supertest": "^6.3.3"
157
+ "prettier": "^3.8.1"
165
158
  }
166
159
  }
@@ -28,11 +28,13 @@ const { t } = require("../i18n"); // 安装/升级日志走 i18n(main 进程按
28
28
  // ── Node runtime bootstrap ───────────────────────────────────────────────────
29
29
  // (2026-06): native cicy-code 走 `npx cicy-code`,需要一个**可用**的 Node。但用户
30
30
  // 机器可能没 node,或 node 太老(实测 josephs 是 node v13/npx6,老 npx 跑不动)。所以:
31
- // 系统有 node≥20 就用;没有/太老 → 下载 Node 24 到 ~/cicy-ai/runtime/node(免 sudo,
32
- // 用户自己拥有);下载也失败 → 提示用户去 nodejs.org 自己装。
31
+ // 系统有 node≥20 就用;没有/太老 → 下载 Node 24 到 ~/.local/node(免 sudo,用户自己
32
+ // 拥有);下载也失败 → 提示用户去 nodejs.org 自己装。老装在 ~/cicy-ai/runtime/node 的
33
+ // 树:ensureNode 首次同盘 rename 迁进 ~/.local/node,迁不动也能被 legacy 搜索路径命中。
33
34
  const NODE_VER = process.env.CICY_NODE_VERSION || "v24.18.0";
34
- const NODE_HOME = path.join(os.homedir(), "cicy-ai", "runtime", "node");
35
- const NODE_SEARCH = ["/usr/local/bin", "/opt/homebrew/bin", path.join(os.homedir(), ".local", "bin"), path.join(NODE_HOME, "bin")];
35
+ const NODE_HOME = path.join(os.homedir(), ".local", "node");
36
+ const LEGACY_NODE_HOME = path.join(os.homedir(), "cicy-ai", "runtime", "node"); // read-only compat
37
+ const NODE_SEARCH = ["/usr/local/bin", "/opt/homebrew/bin", path.join(os.homedir(), ".local", "bin"), path.join(NODE_HOME, "bin"), path.join(LEGACY_NODE_HOME, "bin")];
36
38
  function nodeMajor(bin) {
37
39
  try { const m = String(execFileSync(bin, ["-v"], { encoding: "utf8", timeout: 5000 })).match(/v(\d+)\./); return m ? Number(m[1]) : 0; } catch { return 0; }
38
40
  }
@@ -60,6 +62,15 @@ async function ensureNode({ emit } = {}) {
60
62
  }
61
63
  return dir;
62
64
  };
65
+ // Migrate a legacy ~/cicy-ai/runtime/node tree into ~/.local/node — same-fs
66
+ // rename is instant. Cross-fs (EXDEV) → leave it; the legacy NODE_SEARCH dir
67
+ // still finds it. New downloads (below) land in ~/.local/node directly.
68
+ try {
69
+ if (!fs.existsSync(path.join(NODE_HOME, "bin", "node")) && fs.existsSync(path.join(LEGACY_NODE_HOME, "bin", "node"))) {
70
+ fs.mkdirSync(path.dirname(NODE_HOME), { recursive: true });
71
+ fs.renameSync(LEGACY_NODE_HOME, NODE_HOME);
72
+ }
73
+ } catch {}
63
74
  let dir = findUsableNode();
64
75
  if (dir) return adopt(dir);
65
76
  const arch = process.arch === "arm64" ? "arm64" : "x64";
@@ -188,7 +199,10 @@ async function ensureEnv({ emit } = {}) {
188
199
  let mihomoBin = null;
189
200
  try {
190
201
  const runtime = require("./runtime");
191
- mihomoBin = runtime.binPath("mihomo");
202
+ // ensureFromBundle migrates a legacy ~/cicy-ai/runtime mihomo into ~/.local/bin
203
+ // (no network) and fixes the symlink; falls through to the npm-pack path below
204
+ // when nothing is bundled/installed yet.
205
+ mihomoBin = runtime.ensureFromBundle("mihomo") || runtime.binPath("mihomo");
192
206
  if (!mihomoBin) {
193
207
  const prevPath = process.env.PATH;
194
208
  process.env.PATH = `${nodeBinDir}:${BREW_DIRS.join(":")}:${process.env.PATH || ""}`; // 让 runtime 的 execFile("npm") 命中 node24
@@ -203,7 +217,9 @@ async function ensureEnv({ emit } = {}) {
203
217
  } finally { process.env.PATH = prevPath; }
204
218
  }
205
219
  if (mihomoBin) {
206
- linkBinsToDefaultPath(path.dirname(mihomoBin), ["mihomo"], e); // ~/.local/bin/mihomo → runtime
220
+ // runtime.js already owns ~/.local/bin/mihomo (symlink mihomo-<ver>); no
221
+ // extra linking here (mihomoBin is itself under ~/.local/bin now, so the old
222
+ // linkBinsToDefaultPath call would have symlinked it to itself).
207
223
  e({ phase: "deps", status: "done", message: t("sidecar.mihomoReady", { bin: mihomoBin }) });
208
224
  }
209
225
  } catch (err) {
@@ -235,10 +251,17 @@ function probeExisting(port = DEFAULT_PORT, timeoutMs = 500) {
235
251
 
236
252
  let child = null;
237
253
 
238
- // 「局域网访问」开关持久化: 存 ~/cicy-ai/runtime/desktop-flags.json 的 public 字段。
254
+ // 「局域网访问」开关持久化: 存 ~/cicy-ai/db/desktop-flags.json 的 public 字段。
239
255
  // start() 读它决定 npx cicy-code 加不加 --public。renderer 通过 sidecar:getPublic/setPublic 读写。
240
- const FLAGS_FILE = path.join(os.homedir(), "cicy-ai", "runtime", "desktop-flags.json");
241
- function readFlags() { try { return JSON.parse(fs.readFileSync(FLAGS_FILE, "utf8")) || {}; } catch { return {}; } }
256
+ // 不再放 ~/cicy-ai/runtime;老位置只读兜底,首次写入即迁移(public/cft token 不丢)
257
+ const FLAGS_FILE = path.join(os.homedir(), "cicy-ai", "db", "desktop-flags.json");
258
+ const LEGACY_FLAGS_FILE = path.join(os.homedir(), "cicy-ai", "runtime", "desktop-flags.json");
259
+ function readFlags() {
260
+ for (const p of [FLAGS_FILE, LEGACY_FLAGS_FILE]) {
261
+ try { const v = JSON.parse(fs.readFileSync(p, "utf8")); if (v && typeof v === "object") return v; } catch {}
262
+ }
263
+ return {};
264
+ }
242
265
  function isPublic() { return !!readFlags().public; }
243
266
  function setPublicFlag(on) {
244
267
  const f = readFlags(); f.public = !!on;
@@ -30,7 +30,12 @@ pick_registry() {
30
30
  fi
31
31
  }
32
32
  REG="$(pick_registry)"
33
- RT="$HOME_DIR/cicy-ai/runtime/cicy-code"
33
+ # cicy-code's binary tree now lives under ~/.local/cicy-code/<ver>/, matching
34
+ # cicy-code v2.3.193+ (api/cicy-code-update.sh). CICY_CODE_STORE overrides it.
35
+ # versions.json deliberately STAYS under ~/cicy-ai/runtime — it's the SHARED
36
+ # pointer the container's mihomo store & the Go server also read; only the
37
+ # cicy-code binary tree moved off runtime.
38
+ RT="${CICY_CODE_STORE:-$HOME_DIR/.local/cicy-code}"
34
39
  LINK="$HOME_DIR/.local/bin/cicy-code"
35
40
  VERSIONS="$HOME_DIR/cicy-ai/runtime/versions.json"
36
41
  SVCTL="supervisorctl -c /etc/supervisor/supervisord.conf"
@@ -37,8 +37,27 @@ function archStr() { return process.arch === "arm64" ? "arm64" : "amd64"; }
37
37
  // OSS mirror of cicy-ai/cicy-mihomo releases (see mihomo/<ver>/mihomo-<os>-<arch>).
38
38
  function assetUrl() { return process.env.CICY_MIHOMO_RELEASE_URL || `${OSS_BASE}/mihomo/${VER}/mihomo-${osStr()}-${archStr()}${EXT}`; }
39
39
 
40
- const RT_DIR = path.join(os.homedir(), "cicy-ai", "runtime", "mihomo", VER.replace(/^v/, ""));
41
- function binPath() { return path.join(RT_DIR, "mihomo" + EXT); }
40
+ // Store under ~/.local/bin like cicy-code / cicy-mihomo never ~/cicy-ai/runtime
41
+ // anymore. Old runtime-store copies still RESOLVE (read-only compat) so an
42
+ // existing install isn't re-downloaded; ensureBinary migrates them in.
43
+ const VERD = VER.replace(/^v/, "");
44
+ const LOCAL_BIN = path.join(os.homedir(), ".local", "bin");
45
+ const NEW_BIN = path.join(LOCAL_BIN, `mihomo-${VERD}${EXT}`);
46
+ const LINK = path.join(LOCAL_BIN, `mihomo${EXT}`);
47
+ const LEGACY_BIN = path.join(os.homedir(), "cicy-ai", "runtime", "mihomo", VERD, "mihomo" + EXT);
48
+ function valid(p) { try { return fs.statSync(p).size > 1_000_000; } catch { return false; } }
49
+ // resolve: ~/.local/bin versioned file first, legacy runtime store as fallback.
50
+ function binPath() { return valid(NEW_BIN) ? NEW_BIN : valid(LEGACY_BIN) ? LEGACY_BIN : NEW_BIN; }
51
+ // ~/.local/bin/mihomo → versioned file. We exec via the absolute versioned path,
52
+ // so this symlink is only a PATH convenience — create it only when absent, never
53
+ // clobber one that runtime.js / cicy-mihomo already points at their version.
54
+ function ensureLink() {
55
+ try {
56
+ if (!valid(NEW_BIN) || fs.existsSync(LINK)) return;
57
+ if (IS_WIN) { fs.copyFileSync(NEW_BIN, LINK); return; }
58
+ fs.symlinkSync(NEW_BIN, LINK);
59
+ } catch {}
60
+ }
42
61
  const HOST_CONFIG = path.join(os.homedir(), "cicy-ai", "db", "mihomo-host.yaml");
43
62
  // Log lives under ~/logs, NOT db/ — db/ holds data + config only (config below
44
63
  // stays in db/ as mihomo-host.yaml; this is just the runtime log).
@@ -50,7 +69,7 @@ const PID_FILE = path.join(os.homedir(), "cicy-ai", "db", "mihomo-host.pid");
50
69
  const HOST_MIXED = Number(process.env.CICY_HOST_MIHOMO_MIXED || 9011);
51
70
  const HOST_CTRL = Number(process.env.CICY_HOST_MIHOMO_CTRL || 19011);
52
71
 
53
- function binPresent() { try { return fs.statSync(binPath()).size > 1_000_000; } catch { return false; } }
72
+ function binPresent() { return valid(NEW_BIN) || valid(LEGACY_BIN); }
54
73
 
55
74
  function download(url, dest, redirects = 0) {
56
75
  return new Promise((resolve, reject) => {
@@ -70,15 +89,26 @@ function download(url, dest, redirects = 0) {
70
89
  });
71
90
  }
72
91
 
73
- // Pull the mihomo binary from OSS into the runtime store (idempotent).
92
+ // Ensure the mihomo binary is in ~/.local/bin/mihomo-<ver> (idempotent).
93
+ // Already there → done. Only a legacy runtime copy → migrate it (no network).
94
+ // Nothing → pull from OSS.
74
95
  async function ensureBinary({ emit } = {}) {
75
- if (binPresent()) return binPath();
76
- fs.mkdirSync(RT_DIR, { recursive: true });
96
+ if (valid(NEW_BIN)) { ensureLink(); return NEW_BIN; }
97
+ fs.mkdirSync(LOCAL_BIN, { recursive: true });
98
+ if (valid(LEGACY_BIN)) {
99
+ // atomic: stage then rename, so an interrupted copy never leaves a partial
100
+ // NEW_BIN that valid() would later mistake for a good binary.
101
+ const stg = NEW_BIN + ".mig";
102
+ try { fs.copyFileSync(LEGACY_BIN, stg); if (!IS_WIN) fs.chmodSync(stg, 0o755); fs.renameSync(stg, NEW_BIN); }
103
+ catch { try { fs.rmSync(stg, { force: true }); } catch {} }
104
+ if (valid(NEW_BIN)) { ensureLink(); return NEW_BIN; }
105
+ }
77
106
  emit && emit({ phase: "chrome-proxy", status: "running", message: tt("downloading") });
78
- await download(assetUrl(), binPath());
79
- if (!IS_WIN) { try { fs.chmodSync(binPath(), 0o755); } catch {} }
80
- if (!binPresent()) throw new Error(tt("verifyFailed"));
81
- return binPath();
107
+ await download(assetUrl(), NEW_BIN);
108
+ if (!IS_WIN) { try { fs.chmodSync(NEW_BIN, 0o755); } catch {} }
109
+ if (!valid(NEW_BIN)) throw new Error(tt("verifyFailed"));
110
+ ensureLink();
111
+ return NEW_BIN;
82
112
  }
83
113
 
84
114
  // Build the HOST config from the container's mihomo.yaml: keep only what Chrome
@@ -1,36 +1,45 @@
1
1
  // Copyright 2026 CiCy AI
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- // Runtime Bundle v1 — versioned component store for everything cicy-desktop
5
- // runs locally (cicy-code, mihomo).
4
+ // Runtime Bundle v2 — versioned component store for everything cicy-desktop
5
+ // runs locally (cicy-code, mihomo). Everything installs under ~/.local/bin,
6
+ // matching cicy-code / cicy-mihomo: the store is on PATH, never in a private
7
+ // ~/cicy-ai/runtime tree anymore.
6
8
  //
7
- // Layout:
8
- // ~/cicy-ai/runtime/
9
- // versions.json { "<comp>": { "current": "<ver>" }, ... }
10
- // cicy-code/<ver>/cicy-code(.exe)
11
- // mihomo/<ver>/mihomo(.exe)
9
+ // Layout (writes ONLY here):
10
+ // ~/.local/bin/<comp>-<ver> versioned binary (e.g. mihomo-1.10.4)
11
+ // ~/.local/bin/<comp> -> <comp>-<ver> symlink we swap on switch (atomic
12
+ // relink; win32 copies instead —
13
+ // symlink needs admin there)
14
+ // ~/cicy-ai/db/local-bin-versions.json { "<comp>": { "current", "previous" } }
12
15
  //
13
- // Sourcing order:
16
+ // Resolution order (READS, with backward compat):
17
+ // ~/.local/bin/<comp>-<current> → ~/.local/bin/<comp> symlink →
18
+ // legacy ~/cicy-ai/runtime/<comp>/<ver>/<bin> (old installs still resolve
19
+ // until their next upgrade migrates them into ~/.local/bin).
20
+ //
21
+ // Sourcing order for the binary itself:
14
22
  // 1. first run: copy out of cicy-desktop's own node_modules — the platform
15
23
  // subpackages are optionalDependencies, so `npm i -g cicy-desktop`
16
- // already delivered the right binaries. First start = ZERO network,
17
- // ZERO npx.
18
- // 2. upgrades: `npm pack <pkg>@<ver>` (npmmirror default) extract into
19
- // runtime/<comp>/<ver>/ caller verifies healthswitchCurrent().
20
- // The previous version stays on disk for instant rollback.
21
- //
22
- // The `current` pointer lives in versions.json (NOT a symlink — Windows
23
- // junction/symlink permissions are a minefield; a JSON pointer is identical
24
- // on every platform).
24
+ // already delivered the right binaries. First start = ZERO network.
25
+ // 2. migrate: if only a legacy ~/cicy-ai/runtime binary exists, copy it into
26
+ // ~/.local/bin (no network) so the new layout takes over.
27
+ // 3. upgrades: `npm pack <pkg>@<ver>` (npmmirror default) extractinstall
28
+ // into ~/.local/bin caller verifies health switchCurrent(). The
29
+ // previous versioned file stays on disk for instant rollback.
25
30
  const { execFile } = require("child_process");
26
31
  const fs = require("fs");
27
32
  const os = require("os");
28
33
  const path = require("path");
29
34
 
30
- const RUNTIME_DIR = path.join(os.homedir(), "cicy-ai", "runtime");
31
- const VERSIONS_JSON = path.join(RUNTIME_DIR, "versions.json");
35
+ const LOCAL_BIN = path.join(os.homedir(), ".local", "bin");
36
+ const VERSIONS_JSON = path.join(os.homedir(), "cicy-ai", "db", "local-bin-versions.json");
37
+ // read-only backward-compat fallbacks (never written anymore)
38
+ const LEGACY_DIR = path.join(os.homedir(), "cicy-ai", "runtime");
39
+ const LEGACY_VERSIONS = path.join(LEGACY_DIR, "versions.json");
32
40
  const REGISTRY = process.env.CICY_NPM_REGISTRY || "https://registry.npmmirror.com";
33
41
  const IS_WIN = process.platform === "win32";
42
+ const EXT = IS_WIN ? ".exe" : "";
34
43
 
35
44
  // npm subpackage platform suffix. NOTE: Windows is "windows" not "win32" —
36
45
  // npm's spam filter 403s NEW package names containing 'win32', so EVERY cicy
@@ -48,21 +57,41 @@ const COMPONENTS = {
48
57
  "cicy-code": {
49
58
  kind: "bin",
50
59
  pkg: () => `cicy-code-${plat()}`,
51
- bin: () => (IS_WIN ? "cicy-code.exe" : "cicy-code"),
60
+ bin: () => `cicy-code${EXT}`,
52
61
  },
53
62
  "mihomo": {
54
63
  kind: "bin",
55
64
  pkg: () => `cicy-mihomo-${plat()}`,
56
- bin: () => (IS_WIN ? "mihomo.exe" : "mihomo"),
65
+ bin: () => `mihomo${EXT}`,
57
66
  },
58
67
  };
59
68
 
69
+ // ~/.local/bin/<comp>-<ver>[.exe] — the versioned binary we own.
70
+ function binFile(comp, ver) {
71
+ return path.join(LOCAL_BIN, `${comp}-${ver}${EXT}`);
72
+ }
73
+ // ~/.local/bin/<comp>[.exe] — the PATH entry (symlink, or copy on win32).
74
+ function linkPath(comp) {
75
+ return path.join(LOCAL_BIN, `${comp}${EXT}`);
76
+ }
77
+ // legacy ~/cicy-ai/runtime/<comp>/<ver>/<bin> — read-only compat.
78
+ function legacyBinPath(comp, ver) {
79
+ const c = COMPONENTS[comp];
80
+ return c ? path.join(LEGACY_DIR, comp, ver, c.bin()) : null;
81
+ }
82
+
60
83
  function readVersions() {
61
- try { return JSON.parse(fs.readFileSync(VERSIONS_JSON, "utf8")); } catch { return {}; }
84
+ for (const p of [VERSIONS_JSON, LEGACY_VERSIONS]) {
85
+ try {
86
+ const v = JSON.parse(fs.readFileSync(p, "utf8"));
87
+ if (v && Object.keys(v).length) return v;
88
+ } catch {}
89
+ }
90
+ return {};
62
91
  }
63
92
 
64
93
  function writeVersions(updater) {
65
- fs.mkdirSync(RUNTIME_DIR, { recursive: true });
94
+ fs.mkdirSync(path.dirname(VERSIONS_JSON), { recursive: true });
66
95
  const next = updater(readVersions()) || {};
67
96
  const tmp = VERSIONS_JSON + ".tmp";
68
97
  fs.writeFileSync(tmp, JSON.stringify(next, null, 2));
@@ -74,51 +103,87 @@ function currentVersion(comp) {
74
103
  return readVersions()[comp]?.current || null;
75
104
  }
76
105
 
77
- function versionDir(comp, ver) {
78
- return path.join(RUNTIME_DIR, comp, ver);
79
- }
80
-
81
106
  // Absolute path of the component's current executable. null when not installed.
107
+ // ~/.local/bin versioned file → symlink → legacy runtime store (compat).
82
108
  function binPath(comp) {
83
109
  const c = COMPONENTS[comp];
110
+ if (!c) return null;
84
111
  const ver = currentVersion(comp);
85
- if (!c || !ver) return null;
86
- const p = path.join(versionDir(comp, ver), c.bin());
87
- return fs.existsSync(p) ? p : null;
112
+ if (ver) {
113
+ const vf = binFile(comp, ver);
114
+ if (fs.existsSync(vf)) return vf;
115
+ }
116
+ const lp = linkPath(comp);
117
+ if (fs.existsSync(lp)) return lp;
118
+ if (ver) {
119
+ const legacy = legacyBinPath(comp, ver);
120
+ if (legacy && fs.existsSync(legacy)) return legacy; // old install, pre-migration
121
+ }
122
+ return null;
88
123
  }
89
124
 
90
- // Install a payload directory as <comp>@<ver> and return the version dir.
91
- // Payload = the npm package dir (or node_modules/<pkg>). Atomic-ish: extract
92
- // to .staging then rename.
93
- function installPayload(comp, ver, payloadDir) {
94
- const c = COMPONENTS[comp];
95
- const dst = versionDir(comp, ver);
96
- if (fs.existsSync(dst)) return dst; // already installed — idempotent
125
+ // Copy a single executable file into ~/.local/bin/<comp>-<ver>. Atomic-ish
126
+ // (stage under a temp name, then rename). Idempotent.
127
+ function installFromFile(comp, ver, srcFile) {
128
+ const dst = binFile(comp, ver);
129
+ if (fs.existsSync(dst)) return dst;
130
+ if (!fs.existsSync(srcFile)) throw new Error(`${comp}: source binary missing (${srcFile})`);
131
+ fs.mkdirSync(LOCAL_BIN, { recursive: true });
97
132
  const staging = dst + ".staging";
98
- fs.rmSync(staging, { recursive: true, force: true });
99
- fs.mkdirSync(staging, { recursive: true });
100
- const src = path.join(payloadDir, c.bin());
101
- if (!fs.existsSync(src)) { fs.rmSync(staging, { recursive: true, force: true }); throw new Error(`${comp}: ${c.bin()} missing in package`); }
102
- fs.copyFileSync(src, path.join(staging, c.bin()));
103
- if (!IS_WIN) fs.chmodSync(path.join(staging, c.bin()), 0o755);
133
+ try { fs.rmSync(staging, { force: true }); } catch {}
134
+ fs.copyFileSync(srcFile, staging);
135
+ if (!IS_WIN) fs.chmodSync(staging, 0o755);
104
136
  fs.renameSync(staging, dst);
105
137
  return dst;
106
138
  }
107
139
 
140
+ // Install a payload directory (the npm package dir) as <comp>@<ver>.
141
+ function installPayload(comp, ver, payloadDir) {
142
+ const c = COMPONENTS[comp];
143
+ return installFromFile(comp, ver, path.join(payloadDir, c.bin()));
144
+ }
145
+
146
+ // Point ~/.local/bin/<comp> at <comp>-<ver>. Symlink on posix (atomic
147
+ // tmp+rename), copy on win32 (symlink there needs admin — same call the
148
+ // cicy-mihomo/cicy-code installers make).
149
+ function relink(comp, ver) {
150
+ const target = binFile(comp, ver);
151
+ if (!fs.existsSync(target)) return;
152
+ const link = linkPath(comp);
153
+ fs.mkdirSync(LOCAL_BIN, { recursive: true });
154
+ if (IS_WIN) {
155
+ try { fs.rmSync(link, { force: true }); } catch {}
156
+ try { fs.copyFileSync(target, link); } catch {}
157
+ return;
158
+ }
159
+ const tmp = `${link}.tmp-${process.pid}`;
160
+ try {
161
+ try { fs.rmSync(tmp, { force: true }); } catch {}
162
+ fs.symlinkSync(target, tmp);
163
+ fs.renameSync(tmp, link); // atomically replaces the old symlink
164
+ } catch {
165
+ try { fs.rmSync(tmp, { force: true }); } catch {}
166
+ }
167
+ }
168
+
108
169
  function switchCurrent(comp, ver) {
109
170
  writeVersions((v) => { v[comp] = { ...(v[comp] || {}), current: ver, switched_at: new Date().toISOString() }; return v; });
171
+ relink(comp, ver);
110
172
  }
111
173
 
112
- // Keep current + previous; GC everything older.
174
+ // Keep current + previous versioned files; GC everything older in ~/.local/bin.
113
175
  function gc(comp) {
114
176
  const cur = currentVersion(comp);
115
- const dir = path.join(RUNTIME_DIR, comp);
116
- let entries = [];
117
- try { entries = fs.readdirSync(dir).filter((d) => !d.endsWith(".staging")); } catch { return; }
118
177
  const prev = readVersions()[comp]?.previous;
178
+ const prefix = `${comp}-`;
179
+ let entries = [];
180
+ try { entries = fs.readdirSync(LOCAL_BIN); } catch { return; }
119
181
  for (const e of entries) {
120
- if (e !== cur && e !== prev) {
121
- try { fs.rmSync(path.join(dir, e), { recursive: true, force: true }); } catch {}
182
+ if (!e.startsWith(prefix)) continue; // skips the bare "<comp>" symlink too
183
+ if (e.endsWith(".staging") || e.includes(".tmp-")) { try { fs.rmSync(path.join(LOCAL_BIN, e), { force: true }); } catch {} continue; }
184
+ const ver = e.slice(prefix.length).replace(/\.exe$/, "");
185
+ if (ver !== cur && ver !== prev) {
186
+ try { fs.rmSync(path.join(LOCAL_BIN, e), { force: true }); } catch {}
122
187
  }
123
188
  }
124
189
  }
@@ -139,18 +204,33 @@ function bundledPkgDir(comp) {
139
204
  return null;
140
205
  }
141
206
 
142
- // First-run seeding: make sure SOME version of `comp` is installed + current,
143
- // sourcing from the bundled subpackage. Never touches the network.
207
+ // First-run seeding / migration: make sure the current `comp` binary lives in
208
+ // ~/.local/bin and its symlink is current. Never touches the network.
209
+ // • already in ~/.local/bin → just fix the symlink.
210
+ // • only a legacy ~/cicy-ai/runtime copy → migrate it into ~/.local/bin.
211
+ // • nothing → copy from the bundled subpackage.
144
212
  function ensureFromBundle(comp) {
145
213
  const c = COMPONENTS[comp];
146
214
  if (!c) return null;
147
- const existing = binPath(comp);
148
- if (existing) return existing;
215
+ const cur = currentVersion(comp);
216
+
217
+ if (cur && fs.existsSync(binFile(comp, cur))) { relink(comp, cur); return binFile(comp, cur); }
218
+
219
+ // migrate a legacy runtime-store install into ~/.local/bin (no network)
220
+ if (cur) {
221
+ const legacy = legacyBinPath(comp, cur);
222
+ if (legacy && fs.existsSync(legacy)) {
223
+ try { installFromFile(comp, cur, legacy); switchCurrent(comp, cur); return binPath(comp); } catch {}
224
+ }
225
+ }
226
+
227
+ // seed from the bundled optionalDependency subpackage
149
228
  const pkgDir = bundledPkgDir(comp);
150
- if (!pkgDir) return null; // not bundled (e.g. dev tree) caller may npm-install
229
+ if (!pkgDir) return binPath(comp); // dev tree / not bundled may still resolve via legacy or symlink
151
230
  const ver = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8")).version;
152
231
  installPayload(comp, ver, pkgDir);
153
- if (!currentVersion(comp)) switchCurrent(comp, ver);
232
+ if (!currentVersion(comp) || !fs.existsSync(binFile(comp, currentVersion(comp)))) switchCurrent(comp, ver);
233
+ else relink(comp, currentVersion(comp));
154
234
  return binPath(comp);
155
235
  }
156
236
 
@@ -171,14 +251,14 @@ async function checkUpdate(comp) {
171
251
  }
172
252
 
173
253
  // Download <pkg>@<ver> via `npm pack` (pacote verifies sha512 integrity),
174
- // extract, install as a runtime version. Does NOT switch `current` — the
175
- // caller health-checks first, then calls switchCurrent()/rollback.
254
+ // extract, install into ~/.local/bin. Does NOT switch `current` — the caller
255
+ // health-checks first, then calls switchCurrent()/rollback.
176
256
  async function fetchVersion(comp, ver, { emit } = {}) {
177
257
  const c = COMPONENTS[comp];
178
258
  const e = emit || (() => {});
179
- if (fs.existsSync(versionDir(comp, ver))) {
259
+ if (fs.existsSync(binFile(comp, ver))) {
180
260
  e({ phase: "download", status: "skip", message: `${comp} ${ver} 已在本地,跳过下载` });
181
- return versionDir(comp, ver);
261
+ return binFile(comp, ver);
182
262
  }
183
263
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `cicy-rt-${comp}-`));
184
264
  try {
@@ -189,9 +269,9 @@ async function fetchVersion(comp, ver, { emit } = {}) {
189
269
  execFile("tar", ["-xzf", tgz, "-C", tmp], { windowsHide: true, timeout: 120000 },
190
270
  (err) => (err ? reject(err) : resolve()));
191
271
  });
192
- const dir = installPayload(comp, ver, path.join(tmp, "package"));
272
+ const file = installPayload(comp, ver, path.join(tmp, "package"));
193
273
  e({ phase: "download", status: "done", message: `${comp} ${ver} 就绪` });
194
- return dir;
274
+ return file;
195
275
  } finally {
196
276
  fs.rmSync(tmp, { recursive: true, force: true });
197
277
  }
@@ -220,7 +300,7 @@ async function upgrade(comp, { emit, stop, start, verify } = {}) {
220
300
  } catch (err) {
221
301
  // rollback: pointer back, restart old version
222
302
  e({ phase: "swap", status: "error", message: `新版本异常(${err.message}),回滚到 ${from}` });
223
- switchCurrent(comp, from);
303
+ if (from) switchCurrent(comp, from);
224
304
  if (start) { try { await start(); } catch {} }
225
305
  return { ok: false, from, to: latest, rolledBack: true, error: err.message };
226
306
  }
@@ -230,7 +310,7 @@ async function upgrade(comp, { emit, stop, start, verify } = {}) {
230
310
  }
231
311
 
232
312
  module.exports = {
233
- RUNTIME_DIR, COMPONENTS,
234
- binPath, currentVersion, ensureFromBundle, fetchVersion, switchCurrent,
313
+ LOCAL_BIN, COMPONENTS,
314
+ binPath, binFile, linkPath, currentVersion, ensureFromBundle, fetchVersion, switchCurrent,
235
315
  checkUpdate, upgrade, gc, readVersions,
236
316
  };
@@ -244,7 +244,10 @@
244
244
  const d = document.createElement('div');
245
245
  d.className = 'tab' + (t.home ? ' home' : '') + (t.active ? ' active' : '');
246
246
  d.title = t.home ? T('tabShell.myTeam', '我的团队') : (t.url || '');
247
- d.onclick = () => window.tabAPI.activate(t.id);
247
+ // 按下即切(pointerdown),不用 click:render() 在切换/加载/标题更新时会 innerHTML=''
248
+ // 整条重建,若发生在用户 mousedown 与 mouseup 之间,click 就丢了(要点好几下才切)。
249
+ // pointerdown 在按下瞬间就激活,之后的重建不影响这次激活。
250
+ d.onpointerdown = () => window.tabAPI.activate(t.id);
248
251
  // resident start-page tab: pinned first, user icon only, no title, no close
249
252
  if (t.home) {
250
253
  const ic = document.createElement('span');
@@ -266,6 +269,7 @@
266
269
  const cls = document.createElement('span');
267
270
  cls.className = 'cls';
268
271
  cls.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><line x1="5" y1="5" x2="19" y2="19"/><line x1="19" y1="5" x2="5" y2="19"/></svg>';
272
+ cls.onpointerdown = (e) => e.stopPropagation(); // 别让「关闭」的按下冒泡成 tab 激活
269
273
  cls.onclick = (e) => { e.stopPropagation(); window.tabAPI.close(t.id); };
270
274
  d.appendChild(cls);
271
275
  }