cicy-desktop 2.1.65 → 2.1.67

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.
@@ -0,0 +1,256 @@
1
+ // Runtime Bundle v1 — versioned component store for everything cicy-desktop
2
+ // runs locally (cicy-code, mihomo, and on Windows the slim MSYS2 tree).
3
+ //
4
+ // Layout:
5
+ // ~/cicy-ai/runtime/
6
+ // versions.json { "<comp>": { "current": "<ver>" }, ... }
7
+ // cicy-code/<ver>/cicy-code(.exe)
8
+ // mihomo/<ver>/mihomo(.exe)
9
+ // msys2/<ver>/usr/bin/bash.exe (win32 only, directory component)
10
+ //
11
+ // Sourcing order:
12
+ // 1. first run: copy out of cicy-desktop's own node_modules — the platform
13
+ // subpackages are optionalDependencies, so `npm i -g cicy-desktop`
14
+ // already delivered the right binaries. First start = ZERO network,
15
+ // ZERO npx (主人指令).
16
+ // 2. upgrades: `npm pack <pkg>@<ver>` (npmmirror default) → extract into
17
+ // runtime/<comp>/<ver>/ → caller verifies health → switchCurrent().
18
+ // The previous version stays on disk for instant rollback.
19
+ //
20
+ // The `current` pointer lives in versions.json (NOT a symlink — Windows
21
+ // junction/symlink permissions are a minefield; a JSON pointer is identical
22
+ // on every platform).
23
+ const { execFile } = require("child_process");
24
+ const fs = require("fs");
25
+ const os = require("os");
26
+ const path = require("path");
27
+
28
+ const RUNTIME_DIR = path.join(os.homedir(), "cicy-ai", "runtime");
29
+ const VERSIONS_JSON = path.join(RUNTIME_DIR, "versions.json");
30
+ const REGISTRY = process.env.CICY_NPM_REGISTRY || "https://registry.npmmirror.com";
31
+ const IS_WIN = process.platform === "win32";
32
+
33
+ function plat() {
34
+ const osStr = IS_WIN ? "win32" : process.platform === "darwin" ? "darwin" : "linux";
35
+ const archStr = process.arch === "arm64" ? "arm64" : "x64";
36
+ return `${osStr}-${archStr}`;
37
+ }
38
+
39
+ // kind "bin": single executable at the package root.
40
+ // kind "dir": a directory tree (msys2) — `check` is the file proving it's intact.
41
+ const COMPONENTS = {
42
+ "cicy-code": {
43
+ kind: "bin",
44
+ pkg: () => `cicy-code-${plat()}`,
45
+ bin: () => (IS_WIN ? "cicy-code.exe" : "cicy-code"),
46
+ },
47
+ "mihomo": {
48
+ kind: "bin",
49
+ // npm spam filter 403s new names containing 'win32' → windows-* naming
50
+ pkg: () => `cicy-mihomo-${plat().replace("win32", "windows")}`,
51
+ bin: () => (IS_WIN ? "mihomo.exe" : "mihomo"),
52
+ },
53
+ "msys2": {
54
+ kind: "dir",
55
+ winOnly: true,
56
+ pkg: () => "cicy-msys2-windows-x64",
57
+ check: path.join("usr", "bin", "bash.exe"),
58
+ },
59
+ };
60
+
61
+ function readVersions() {
62
+ try { return JSON.parse(fs.readFileSync(VERSIONS_JSON, "utf8")); } catch { return {}; }
63
+ }
64
+
65
+ function writeVersions(updater) {
66
+ fs.mkdirSync(RUNTIME_DIR, { recursive: true });
67
+ const next = updater(readVersions()) || {};
68
+ const tmp = VERSIONS_JSON + ".tmp";
69
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2));
70
+ fs.renameSync(tmp, VERSIONS_JSON);
71
+ return next;
72
+ }
73
+
74
+ function currentVersion(comp) {
75
+ return readVersions()[comp]?.current || null;
76
+ }
77
+
78
+ function versionDir(comp, ver) {
79
+ return path.join(RUNTIME_DIR, comp, ver);
80
+ }
81
+
82
+ // Absolute path of the component's current payload — the executable for "bin"
83
+ // components, the root dir for "dir" components. null when not installed.
84
+ function binPath(comp) {
85
+ const c = COMPONENTS[comp];
86
+ const ver = currentVersion(comp);
87
+ if (!c || !ver) return null;
88
+ const p = c.kind === "dir" ? versionDir(comp, ver) : path.join(versionDir(comp, ver), c.bin());
89
+ const probe = c.kind === "dir" ? path.join(p, c.check) : p;
90
+ return fs.existsSync(probe) ? p : null;
91
+ }
92
+
93
+ function cpDirSync(src, dst) {
94
+ fs.cpSync(src, dst, { recursive: true });
95
+ }
96
+
97
+ // Install a payload directory as <comp>@<ver> and return the version dir.
98
+ // Payload = the npm package dir (or node_modules/<pkg>). Atomic-ish: extract
99
+ // to .staging then rename.
100
+ function installPayload(comp, ver, payloadDir) {
101
+ const c = COMPONENTS[comp];
102
+ const dst = versionDir(comp, ver);
103
+ if (fs.existsSync(dst)) return dst; // already installed — idempotent
104
+ const staging = dst + ".staging";
105
+ fs.rmSync(staging, { recursive: true, force: true });
106
+ fs.mkdirSync(staging, { recursive: true });
107
+ if (c.kind === "dir") {
108
+ cpDirSync(payloadDir, staging);
109
+ if (!fs.existsSync(path.join(staging, c.check))) {
110
+ // the tree may be nested one level (package/msys64/usr/...)
111
+ const sub = fs.readdirSync(staging).find((d) =>
112
+ fs.existsSync(path.join(staging, d, c.check)));
113
+ if (!sub) { fs.rmSync(staging, { recursive: true, force: true }); throw new Error(`${comp}: ${c.check} missing in package`); }
114
+ fs.renameSync(path.join(staging, sub), staging + ".inner");
115
+ fs.rmSync(staging, { recursive: true, force: true });
116
+ fs.renameSync(staging + ".inner", staging);
117
+ }
118
+ } else {
119
+ const src = path.join(payloadDir, c.bin());
120
+ if (!fs.existsSync(src)) { fs.rmSync(staging, { recursive: true, force: true }); throw new Error(`${comp}: ${c.bin()} missing in package`); }
121
+ fs.copyFileSync(src, path.join(staging, c.bin()));
122
+ if (!IS_WIN) fs.chmodSync(path.join(staging, c.bin()), 0o755);
123
+ }
124
+ fs.renameSync(staging, dst);
125
+ return dst;
126
+ }
127
+
128
+ function switchCurrent(comp, ver) {
129
+ writeVersions((v) => { v[comp] = { ...(v[comp] || {}), current: ver, switched_at: new Date().toISOString() }; return v; });
130
+ }
131
+
132
+ // Keep current + previous; GC everything older.
133
+ function gc(comp) {
134
+ const cur = currentVersion(comp);
135
+ const dir = path.join(RUNTIME_DIR, comp);
136
+ let entries = [];
137
+ try { entries = fs.readdirSync(dir).filter((d) => !d.endsWith(".staging")); } catch { return; }
138
+ const prev = readVersions()[comp]?.previous;
139
+ for (const e of entries) {
140
+ if (e !== cur && e !== prev) {
141
+ try { fs.rmSync(path.join(dir, e), { recursive: true, force: true }); } catch {}
142
+ }
143
+ }
144
+ }
145
+
146
+ // Locate the platform subpackage inside cicy-desktop's own install (it's an
147
+ // optionalDependency, delivered by the same `npm i -g cicy-desktop`).
148
+ function bundledPkgDir(comp) {
149
+ const c = COMPONENTS[comp];
150
+ const candidates = [
151
+ path.join(__dirname, "..", "..", "node_modules", c.pkg()), // npm install layout
152
+ path.join(process.resourcesPath || "", "runtime-pkgs", c.pkg()), // packaged (NSIS/dmg) layout
153
+ ];
154
+ for (const p of candidates) {
155
+ try {
156
+ if (fs.existsSync(path.join(p, "package.json"))) return p;
157
+ } catch {}
158
+ }
159
+ return null;
160
+ }
161
+
162
+ // First-run seeding: make sure SOME version of `comp` is installed + current,
163
+ // sourcing from the bundled subpackage. Never touches the network.
164
+ function ensureFromBundle(comp) {
165
+ const c = COMPONENTS[comp];
166
+ if (!c || (c.winOnly && !IS_WIN)) return null;
167
+ const existing = binPath(comp);
168
+ if (existing) return existing;
169
+ const pkgDir = bundledPkgDir(comp);
170
+ if (!pkgDir) return null; // not bundled (e.g. dev tree) — caller may npm-install
171
+ const ver = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8")).version;
172
+ installPayload(comp, ver, pkgDir);
173
+ if (!currentVersion(comp)) switchCurrent(comp, ver);
174
+ return binPath(comp);
175
+ }
176
+
177
+ function npmExec(args, { timeout = 600000 } = {}) {
178
+ return new Promise((resolve, reject) => {
179
+ execFile("npm", args, { windowsHide: true, timeout, shell: IS_WIN }, (err, stdout, stderr) =>
180
+ err ? reject(new Error(String(stderr || err.message).slice(0, 300))) : resolve(String(stdout)));
181
+ });
182
+ }
183
+
184
+ // Latest published version of the component's npm package.
185
+ async function checkUpdate(comp) {
186
+ const c = COMPONENTS[comp];
187
+ const out = await npmExec(["view", c.pkg(), "version", `--registry=${REGISTRY}`], { timeout: 30000 });
188
+ const latest = out.trim();
189
+ const current = currentVersion(comp);
190
+ return { current, latest, updateAvailable: !!latest && latest !== current };
191
+ }
192
+
193
+ // Download <pkg>@<ver> via `npm pack` (pacote verifies sha512 integrity),
194
+ // extract, install as a runtime version. Does NOT switch `current` — the
195
+ // caller health-checks first, then calls switchCurrent()/rollback.
196
+ async function fetchVersion(comp, ver, { emit } = {}) {
197
+ const c = COMPONENTS[comp];
198
+ const e = emit || (() => {});
199
+ if (fs.existsSync(versionDir(comp, ver))) {
200
+ e({ phase: "download", status: "skip", message: `${comp} ${ver} 已在本地,跳过下载` });
201
+ return versionDir(comp, ver);
202
+ }
203
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `cicy-rt-${comp}-`));
204
+ try {
205
+ e({ phase: "download", status: "running", message: `下载 ${comp} ${ver}…` });
206
+ const out = await npmExec(["pack", `${c.pkg()}@${ver}`, `--registry=${REGISTRY}`, "--pack-destination", tmp]);
207
+ const tgz = path.join(tmp, out.trim().split("\n").pop().trim());
208
+ await new Promise((resolve, reject) => {
209
+ execFile("tar", ["-xzf", tgz, "-C", tmp], { windowsHide: true, timeout: 120000 },
210
+ (err) => (err ? reject(err) : resolve()));
211
+ });
212
+ const dir = installPayload(comp, ver, path.join(tmp, "package"));
213
+ e({ phase: "download", status: "done", message: `${comp} ${ver} 就绪` });
214
+ return dir;
215
+ } finally {
216
+ fs.rmSync(tmp, { recursive: true, force: true });
217
+ }
218
+ }
219
+
220
+ // Full upgrade: fetch latest → caller's stop/start hooks around the pointer
221
+ // switch → health verify → rollback on failure. Returns { ok, from, to }.
222
+ async function upgrade(comp, { emit, stop, start, verify } = {}) {
223
+ const e = emit || (() => {});
224
+ const from = currentVersion(comp);
225
+ const { latest } = await checkUpdate(comp);
226
+ if (!latest) throw new Error(`registry has no ${COMPONENTS[comp].pkg()}`);
227
+ if (latest === from) {
228
+ e({ phase: "done", status: "done", message: `已是最新 ${latest}` });
229
+ return { ok: true, from, to: latest, noop: true };
230
+ }
231
+ await fetchVersion(comp, latest, { emit });
232
+ e({ phase: "swap", status: "running", message: "停止当前版本…" });
233
+ if (stop) await stop();
234
+ writeVersions((v) => { v[comp] = { ...(v[comp] || {}), previous: from }; return v; });
235
+ switchCurrent(comp, latest);
236
+ e({ phase: "swap", status: "running", message: `切换到 ${latest},启动…` });
237
+ try {
238
+ if (start) await start();
239
+ if (verify && !(await verify())) throw new Error("health check failed");
240
+ } catch (err) {
241
+ // rollback: pointer back, restart old version
242
+ e({ phase: "swap", status: "error", message: `新版本异常(${err.message}),回滚到 ${from}` });
243
+ switchCurrent(comp, from);
244
+ if (start) { try { await start(); } catch {} }
245
+ return { ok: false, from, to: latest, rolledBack: true, error: err.message };
246
+ }
247
+ gc(comp);
248
+ e({ phase: "done", status: "done", message: `已更新 ${from || "(无)"} → ${latest}` });
249
+ return { ok: true, from, to: latest };
250
+ }
251
+
252
+ module.exports = {
253
+ RUNTIME_DIR, COMPONENTS,
254
+ binPath, currentVersion, ensureFromBundle, fetchVersion, switchCurrent,
255
+ checkUpdate, upgrade, gc, readVersions,
256
+ };
@@ -733,3 +733,84 @@ 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
+ }
754
+
755
+ /* HTTPS 审计 CA 授权卡片 (合规 opt-in) */
756
+ .mitm-card {
757
+ border: 1px solid rgba(125, 135, 150, 0.22);
758
+ border-radius: 12px;
759
+ padding: 14px 16px;
760
+ margin-bottom: 14px;
761
+ background: rgba(30, 36, 46, 0.4);
762
+ }
763
+ .mitm-card--on { border-color: rgba(74, 222, 128, 0.35); background: rgba(22, 40, 30, 0.4); }
764
+ .mitm-card__head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
765
+ .mitm-card__dot { width: 8px; height: 8px; border-radius: 50%; background: #9da7b3; flex: 0 0 auto; }
766
+ .mitm-card__dot[data-state="on"] { background: #4ade80; box-shadow: 0 0 6px rgba(74, 222, 128, 0.6); }
767
+ .mitm-card__dot[data-state="warn"] { background: #fbbf24; }
768
+ .mitm-card__dot[data-state="off"] { background: #60a5fa; }
769
+ .mitm-card__title { font-size: 14px; font-weight: 600; color: var(--text, #e6edf3); }
770
+ .mitm-card__desc { font-size: 12.5px; line-height: 1.5; color: var(--text-dim, #9da7b3); margin: 0 0 10px; }
771
+ .mitm-card__note { color: #fbbf24; }
772
+ .mitm-card__error { font-size: 12px; color: #f87171; margin-bottom: 8px; }
773
+ .mitm-card__actions { display: flex; gap: 8px; }
774
+ .mitm-card__btn {
775
+ font-size: 13px; padding: 7px 16px; border-radius: 8px; border: none; cursor: pointer;
776
+ background: var(--accent, #3b82f6); color: #fff; font-weight: 500;
777
+ }
778
+ .mitm-card__btn:disabled { opacity: 0.6; cursor: default; }
779
+ .mitm-card__btn--ghost { background: transparent; border: 1px solid rgba(125, 135, 150, 0.35); color: var(--text-dim, #9da7b3); }
780
+ .mitm-card__sub { color: var(--text-dim, #9da7b3); opacity: 0.8; font-size: 11.5px; }
781
+
782
+ /* 首启门控条款页 (合规第一道整体同意) */
783
+ .terms-gate { display: flex; align-items: center; justify-content: center; padding: 24px; }
784
+ .terms-gate__panel {
785
+ position: relative; z-index: 1; width: min(680px, 94vw); max-height: 90vh;
786
+ display: flex; flex-direction: column;
787
+ background: rgba(20, 25, 33, 0.92); border: 1px solid rgba(125, 135, 150, 0.22);
788
+ border-radius: 16px; padding: 28px 30px; box-shadow: 0 24px 60px rgba(0,0,0,0.5);
789
+ }
790
+ .terms-gate__title { font-size: 20px; font-weight: 700; margin: 0 0 4px; color: var(--text, #e6edf3); }
791
+ .terms-gate__subtitle { font-size: 13px; color: var(--text-dim, #9da7b3); margin: 0 0 16px; }
792
+ .terms-gate__body {
793
+ overflow-y: auto; flex: 1 1 auto; min-height: 0; padding-right: 8px;
794
+ border-top: 1px solid rgba(125,135,150,0.15); border-bottom: 1px solid rgba(125,135,150,0.15);
795
+ padding-top: 14px; padding-bottom: 14px;
796
+ }
797
+ .terms-gate__h2 { font-size: 14px; font-weight: 600; margin: 0 0 10px; color: var(--text, #e6edf3); }
798
+ .terms-gate__summary { margin: 0 0 14px; padding-left: 20px; }
799
+ .terms-gate__summary li { font-size: 13px; line-height: 1.6; color: var(--text-dim, #c2cbd6); margin-bottom: 8px; }
800
+ .terms-gate__viewfull {
801
+ background: none; border: none; color: var(--accent, #3b82f6); cursor: pointer;
802
+ font-size: 13px; padding: 4px 0; text-decoration: underline;
803
+ }
804
+ .terms-gate__fulltext {
805
+ white-space: pre-wrap; word-break: break-word; font-size: 12px; line-height: 1.6;
806
+ color: var(--text-dim, #b3bcc8); background: rgba(0,0,0,0.2); border-radius: 8px;
807
+ padding: 14px; margin: 10px 0 0; font-family: inherit;
808
+ }
809
+ .terms-gate__scrollhint { font-size: 12px; color: #fbbf24; text-align: center; margin: 12px 0 0; }
810
+ .terms-gate__actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 18px; }
811
+ .terms-gate__btn {
812
+ font-size: 14px; padding: 10px 22px; border-radius: 9px; border: none; cursor: pointer;
813
+ background: var(--accent, #3b82f6); color: #fff; font-weight: 600;
814
+ }
815
+ .terms-gate__btn:disabled { opacity: 0.45; cursor: not-allowed; }
816
+ .terms-gate__btn--ghost { background: transparent; border: 1px solid rgba(125,135,150,0.35); color: var(--text-dim, #9da7b3); font-weight: 500; }
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState, useCallback, useMemo, useRef } from "react";
2
2
  import "./App.css";
3
+ import { TERMS_VERSION, TERMS_FULL } from "./termsText";
3
4
 
4
5
  // i18n bridge exposed by homepage-preload (window.cicyI18n.t, locale from
5
6
  // app.getLocale()). Returns the localized string, or `fallback` when the key
@@ -15,6 +16,17 @@ const USER_ID_KEY = "cicy_user_id";
15
16
  const CLOUD_BASE = "https://cicy-ai.com";
16
17
 
17
18
  export default function App() {
19
+ // First-run terms gate (合规第一道整体同意) — blocks the whole UI until
20
+ // accepted. undefined = checking, false = must show gate, true = past it.
21
+ // Distinct from the MITM CA opt-in; accepting terms never enables audit.
22
+ const [termsOk, setTermsOk] = useState(undefined);
23
+ useEffect(() => {
24
+ if (!window.cicy?.terms?.status) { setTermsOk(true); return; } // no bridge (dev) → don't block
25
+ window.cicy.terms.status(TERMS_VERSION)
26
+ .then((r) => setTermsOk(!!r?.accepted))
27
+ .catch(() => setTermsOk(true));
28
+ }, []);
29
+
18
30
  // sk-xxx (LLM API). Used by /v1/chat/completions etc.
19
31
  const [token, setToken] = useState(() => safeGet(TOKEN_KEY));
20
32
  // Console-API bearer. Used by /api/user/self, /api/teams, etc.
@@ -268,6 +280,20 @@ export default function App() {
268
280
  setProfileError("");
269
281
  }
270
282
 
283
+ // First-run terms gate takes precedence over everything (even login) —
284
+ // accepting the terms is a precondition to using the software at all.
285
+ if (termsOk === undefined) {
286
+ return (
287
+ <div className="shell" data-id="TermsCheckingSplash">
288
+ <div className="glow" aria-hidden />
289
+ <div className="card"><Brand /><div className="spinner-row"><Spinner /></div></div>
290
+ </div>
291
+ );
292
+ }
293
+ if (!termsOk) {
294
+ return <FirstRunTermsGate onAgree={() => setTermsOk(true)} />;
295
+ }
296
+
271
297
  // Still checking the durable store — show a minimal splash, not the login
272
298
  // card, so we never flash "please log in" before restore completes.
273
299
  if (!token && authRestoring) {
@@ -358,6 +384,7 @@ export default function App() {
358
384
  </div>
359
385
 
360
386
  {showLocal && <DockerSetup onReady={fetchLocalTeams} />}
387
+ {showLocal && localList.length > 0 && <MitmConsentCard team={localList[0]} />}
361
388
 
362
389
  {profileError && (
363
390
  <div className="error" style={{ marginBottom: 12 }}>
@@ -449,6 +476,175 @@ const DOCKER_STEPS = [
449
476
  { key: "health", label: "本地团队就绪" },
450
477
  ];
451
478
 
479
+ // 首启门控:整体条款的第一道同意。未同意不进主界面;读到底部才解锁"同意"。
480
+ // 与 MitmConsentCard(HTTPS 审计第二道同意)完全独立 —— 同意条款 ≠ 开启审计。
481
+ function FirstRunTermsGate({ onAgree }) {
482
+ const [scrolledEnd, setScrolledEnd] = useState(false);
483
+ const [showFull, setShowFull] = useState(false);
484
+ const [busy, setBusy] = useState(false);
485
+ const locale = (window.cicyI18n?.locale || "en").startsWith("zh") ? "zh-CN" : "en";
486
+ const t = (k, fb) => tr(`firstRunTerms.${k}`, fb);
487
+ const summaries = [1, 2, 3, 4, 5, 6].map((i) => t(`summary${i}`, ""));
488
+
489
+ const onScroll = (e) => {
490
+ const el = e.currentTarget;
491
+ if (el.scrollHeight - el.scrollTop - el.clientHeight < 24) setScrolledEnd(true);
492
+ };
493
+ // Short content that never scrolls → unlock immediately.
494
+ const bodyRef = useRef(null);
495
+ useEffect(() => {
496
+ const el = bodyRef.current;
497
+ if (el && el.scrollHeight <= el.clientHeight + 24) setScrolledEnd(true);
498
+ }, [showFull]);
499
+
500
+ const agree = async () => {
501
+ if (busy || !scrolledEnd) return;
502
+ setBusy(true);
503
+ try { await window.cicy?.terms?.agree?.(TERMS_VERSION); onAgree?.(); }
504
+ catch { onAgree?.(); } // never trap the user; main also persists
505
+ };
506
+ const decline = () => { try { window.cicy?.terms?.decline?.(); } catch {} };
507
+
508
+ return (
509
+ <div className="shell terms-gate" data-id="FirstRunTermsGate">
510
+ <div className="glow" aria-hidden />
511
+ <div className="terms-gate__panel">
512
+ <h1 className="terms-gate__title" data-id="FirstRunTermsGate-title">{t("title", "用户协议与授权说明")}</h1>
513
+ <p className="terms-gate__subtitle">{t("subtitle", "使用 CiCy Desktop 前,请阅读并同意以下条款")}</p>
514
+
515
+ <div className="terms-gate__body" ref={bodyRef} onScroll={onScroll} data-id="FirstRunTermsGate-body">
516
+ <h2 className="terms-gate__h2">{t("summaryTitle", "一眼看懂")}</h2>
517
+ <ol className="terms-gate__summary">
518
+ {summaries.filter(Boolean).map((s, i) => <li key={i}>{s}</li>)}
519
+ </ol>
520
+ {!showFull ? (
521
+ <button className="terms-gate__viewfull" data-id="FirstRunTermsGate-viewfull"
522
+ onClick={() => setShowFull(true)}>{t("viewFull", "查看完整条款")}</button>
523
+ ) : (
524
+ <pre className="terms-gate__fulltext" data-id="FirstRunTermsGate-fulltext">{TERMS_FULL[locale] || TERMS_FULL.en}</pre>
525
+ )}
526
+ </div>
527
+
528
+ {!scrolledEnd && <div className="terms-gate__scrollhint" data-id="FirstRunTermsGate-scrollhint">{t("scrollHint", "请阅读至底部以继续")}</div>}
529
+ <div className="terms-gate__actions">
530
+ <button data-id="FirstRunTermsGate-decline" className="terms-gate__btn terms-gate__btn--ghost" onClick={decline}>
531
+ {t("decline", "不同意并退出")}
532
+ </button>
533
+ <button data-id="FirstRunTermsGate-agree" className="terms-gate__btn" disabled={!scrolledEnd || busy}
534
+ title={!scrolledEnd ? t("mustAgree", "未同意则无法使用本软件。") : ""} onClick={agree}>
535
+ {t("agree", "同意并继续")}
536
+ </button>
537
+ </div>
538
+ </div>
539
+ </div>
540
+ );
541
+ }
542
+
543
+ // HTTPS 审计 CA 授权卡片 (合规 opt-in)。绝不首启静默装根证书 (Superfish 红线) —
544
+ // 用户在此显式同意后,才由 cicy-code 写入系统根信任库。三态:未授权 / 已授权(可撤销) /
545
+ // 处理中。同意走 POST /api/mitm/consent;need_elevation 回退 exec 自提权 install-ca。
546
+ function MitmConsentCard({ team }) {
547
+ const [status, setStatus] = useState(undefined); // undefined=loading, null=endpoint absent, {generated,trusted,consent}
548
+ const [busy, setBusy] = useState(""); // "" | enable | disable
549
+ const [error, setError] = useState("");
550
+
551
+ const base = (team?.base_url || "").replace(/\/$/, "");
552
+ const token = team?.api_token || "";
553
+
554
+ const caFetch = useCallback(async (path, opts = {}) => {
555
+ if (!window.cicy?.cloud?.fetch) throw new Error("bridge missing");
556
+ const r = await window.cicy.cloud.fetch(`${base}${path}`, {
557
+ ...opts,
558
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(opts.headers || {}) },
559
+ });
560
+ let json = null; try { json = JSON.parse(r.body); } catch {}
561
+ return { ok: r.ok, status: r.status, json };
562
+ }, [base, token]);
563
+
564
+ const refresh = useCallback(async () => {
565
+ try {
566
+ const r = await caFetch("/api/mitm/ca-status");
567
+ // 404 / not-ok → endpoint not present (older cicy-code) → hide card.
568
+ setStatus(r.ok && r.json ? r.json : null);
569
+ } catch { setStatus(null); }
570
+ }, [caFetch]);
571
+ useEffect(() => { if (base && token) refresh(); }, [base, token, refresh]);
572
+
573
+ // Hide until we know the CA exists. No CA generated (MITM off) → nothing to consent to.
574
+ if (status === undefined) return null; // still loading
575
+ if (!status || !status.generated) return null; // endpoint absent or no CA
576
+
577
+ const enable = async () => {
578
+ if (busy) return;
579
+ setBusy("enable"); setError("");
580
+ try {
581
+ const r = await caFetch("/api/mitm/consent", { method: "POST", body: JSON.stringify({ enable: true }) });
582
+ if (r.ok && r.json?.ok && r.json?.trusted) { await refresh(); }
583
+ else if (r.json?.error === "need_elevation" || (!r.ok && r.status === 403)) {
584
+ // fall back to the self-elevating CLI (OS prompt = the second consent)
585
+ const ex = await window.cicy?.mitm?.caExec?.("install");
586
+ if (ex?.ok) await refresh();
587
+ else setError(/cancel/i.test(ex?.stderr || "") ? tr("mitmConsent.errorAdminDenied", "未获得管理员授权,已取消。") : (ex?.stderr || tr("mitmConsent.errorTitle", "提权失败,请从管理员控制台运行")));
588
+ } else {
589
+ setError(r.json?.error || `失败 (HTTP ${r.status})`);
590
+ }
591
+ } catch (e) { setError(String(e?.message || e)); }
592
+ finally { setBusy(""); }
593
+ };
594
+
595
+ const disable = async () => {
596
+ if (busy) return;
597
+ setBusy("disable"); setError("");
598
+ try {
599
+ const r = await caFetch("/api/mitm/consent", { method: "POST", body: JSON.stringify({ enable: false }) });
600
+ if (r.ok && r.json?.ok) await refresh();
601
+ else {
602
+ const ex = await window.cicy?.mitm?.caExec?.("uninstall");
603
+ if (ex?.ok) await refresh(); else setError(ex?.stderr || r.json?.error || "撤销失败");
604
+ }
605
+ } catch (e) { setError(String(e?.message || e)); }
606
+ finally { setBusy(""); }
607
+ };
608
+
609
+ const granted = status.consent && status.trusted;
610
+ const partial = status.consent && !status.trusted; // consented but not (re)installed
611
+ const t = (k, fb) => tr(`mitmConsent.${k}`, fb);
612
+
613
+ return (
614
+ <div data-id="MitmConsentCard" className={`mitm-card${granted ? " mitm-card--on" : ""}`}>
615
+ <div className="mitm-card__head">
616
+ <span className="mitm-card__dot" data-state={granted ? "on" : partial ? "warn" : "off"} />
617
+ <span className="mitm-card__title" data-id="MitmConsentCard-title">
618
+ {busy ? t("stateProcessingTitle", "处理中…")
619
+ : granted ? t("stateGrantedTitle", "已启用")
620
+ : `${t("cardTitle", "HTTPS 流量本地审计")}${partial ? " — " + t("retry", "重试") : ""}`}
621
+ </span>
622
+ </div>
623
+ <p className="mitm-card__desc" data-id="MitmConsentCard-desc">
624
+ {granted ? t("grantedDesc", "HTTPS 审计已开启;可随时撤销并卸载证书。") : t("body", "启用后,本机到 AI 厂商(Claude / OpenAI / DeepSeek / Gemini)的 HTTPS 将被本地审计解密,数据留本地,可随时关闭。")}
625
+ {!granted && <>
626
+ <br /><span className="mitm-card__note">{t("adminNote", "需写入系统根证书信任库,需要管理员授权。")}</span>
627
+ <br /><span className="mitm-card__sub">{t("scopeNote", "仅解密上述 AI 厂商域名,其余一切流量不被解密、不被读取。")}</span>
628
+ </>}
629
+ </p>
630
+ {error && <div className="mitm-card__error" data-id="MitmConsentCard-error">{t("errorTitle", "操作失败")}: {error}</div>}
631
+ <div className="mitm-card__actions">
632
+ {granted ? (
633
+ <button data-id="MitmConsentCard-revoke" className="mitm-card__btn mitm-card__btn--ghost"
634
+ disabled={!!busy} onClick={() => { if (window.confirm(t("revokeConfirm", "撤销后将卸载证书、停止解密,并清除同意标记。确定?"))) disable(); }}>
635
+ {busy === "disable" ? t("processingRevoke", "正在卸载证书…") : t("revoke", "撤销")}
636
+ </button>
637
+ ) : (
638
+ <button data-id="MitmConsentCard-enable" className="mitm-card__btn"
639
+ disabled={!!busy} onClick={enable}>
640
+ {busy === "enable" ? t("processingEnable", "正在安装证书…") : partial ? t("retry", "重试") : t("enable", "同意并启用")}
641
+ </button>
642
+ )}
643
+ </div>
644
+ </div>
645
+ );
646
+ }
647
+
452
648
  function DockerSetup({ onReady }) {
453
649
  const [status, setStatus] = useState(null); // {platform, installed, imagePresent, running}
454
650
  const [phases, setPhases] = useState({}); // key -> { status, message, progress }
@@ -543,6 +739,7 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
543
739
  const running = team.status === "running";
544
740
  const [busy, setBusy] = useState(""); // "" | start | restart | update | stop
545
741
  const [opMsg, setOpMsg] = useState("");
742
+ const [opProg, setOpProg] = useState(null); // live {message, progress?, status} during 更新
546
743
  const [menuOpen, setMenuOpen] = useState(false);
547
744
  const [latest, setLatest] = useState(null); // newest cicy-code on the registry
548
745
  const [checking, setChecking] = useState(false);
@@ -610,7 +807,15 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
610
807
  const runOp = async (kind, fn, doneText) => {
611
808
  setMenuOpen(false);
612
809
  if (busy) return;
613
- setBusy(kind); setOpMsg("");
810
+ setBusy(kind); setOpMsg(""); setOpProg(null);
811
+ // 更新 streams real phase/percent events from the main process — surface
812
+ // them live on the card so the user SEES the download/swap happening.
813
+ let unsub = null;
814
+ if (kind === "update" && window.cicy?.sidecar?.onOpProgress) {
815
+ unsub = window.cicy.sidecar.onOpProgress((ev) => {
816
+ if (ev?.op === "update") setOpProg(ev);
817
+ });
818
+ }
614
819
  try {
615
820
  const r = await fn();
616
821
  setOpMsg(r?.ok
@@ -619,8 +824,10 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
619
824
  } catch (err) {
620
825
  setOpMsg(tr("sidecar.failed", "操作失败") + `: ${err?.message || err}`);
621
826
  } finally {
622
- setBusy("");
827
+ try { unsub && unsub(); } catch {}
828
+ setBusy(""); setOpProg(null);
623
829
  onRefresh?.(); // re-probe so the status dot/chip catches up
830
+ setTimeout(() => setOpMsg(""), 5000); // result line is transient
624
831
  }
625
832
  };
626
833
  const BUSY_LABEL = { start: "启动中…", restart: "重启中…", update: "更新中…", stop: "停止中…" };
@@ -771,6 +978,22 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
771
978
  <span className="bcard__ver" data-id="LocalTeamCard-version">v{team.version}</span>
772
979
  )}
773
980
  </div>
981
+ {busy && (
982
+ <div className="bcard__prog" data-id="LocalTeamCard-progress" data-status={opProg?.status || "running"}>
983
+ <span className="bcard__progmsg">
984
+ {opProg?.message || BUSY_LABEL[busy] || `${busy}…`}
985
+ {Number.isFinite(opProg?.progress) ? ` ${opProg.progress}%` : ""}
986
+ </span>
987
+ {Number.isFinite(opProg?.progress) && (
988
+ <span className="bcard__progbar"><span style={{ width: `${Math.min(100, opProg.progress)}%` }} /></span>
989
+ )}
990
+ </div>
991
+ )}
992
+ {!busy && opMsg && (
993
+ <div className="bcard__prog" data-id="LocalTeamCard-progress" data-status={/失败|error/i.test(opMsg) ? "error" : "done"}>
994
+ <span className="bcard__progmsg">{opMsg}</span>
995
+ </div>
996
+ )}
774
997
  </div>
775
998
  <button
776
999
  type="button"
@@ -779,8 +1002,8 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
779
1002
  disabled={!!busy || !team.base_url}
780
1003
  onClick={handleOpen}
781
1004
  >
782
- {busy === "start" ? <Spinner /> : <ArrowIcon />}
783
- <span>{openLabel}</span>
1005
+ {busy && busy !== "stop" ? <Spinner /> : <ArrowIcon />}
1006
+ <span>{busy ? (BUSY_LABEL[busy] || openLabel) : openLabel}</span>
784
1007
  </button>
785
1008
  </div>
786
1009
  );