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.
- package/package.json +15 -1
- package/src/backends/homepage-preload.js +17 -0
- package/src/backends/homepage-react/assets/index-B8gGhz8B.js +365 -0
- package/src/backends/homepage-react/assets/{index-BV0z4aaf.css → index-BniEbx_j.css} +1 -1
- package/src/backends/homepage-react/index.html +2 -2
- package/src/backends/sidecar-ipc.js +27 -4
- package/src/i18n/locales/en.json +36 -1
- package/src/i18n/locales/fr.json +38 -1
- package/src/i18n/locales/ja.json +38 -1
- package/src/i18n/locales/zh-CN.json +36 -1
- package/src/main.js +27 -0
- package/src/sidecar/cicy-code.js +87 -4
- package/src/sidecar/native.js +26 -5
- package/src/sidecar/runtime.js +256 -0
- package/workers/render/src/App.css +81 -0
- package/workers/render/src/App.jsx +227 -4
- package/workers/render/src/termsText.js +9 -0
- package/src/backends/homepage-react/assets/index-C0vFqx44.js +0 -49
|
@@ -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
|
-
|
|
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
|
|
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
|
);
|