cicy-desktop 2.1.78 → 2.1.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cicy-desktop +7 -7
- package/package.json +6 -6
- package/src/backends/homepage-preload.js +22 -0
- package/src/backends/homepage-react/assets/index-CKpaMBKz.css +1 -0
- package/src/backends/homepage-react/assets/index-CSsNZgC5.js +365 -0
- package/src/backends/homepage-react/index.html +2 -2
- package/src/backends/homepage-window.js +52 -7
- package/src/backends/ipc.js +57 -0
- package/src/backends/local-teams.js +73 -26
- package/src/backends/sidecar-ipc.js +11 -0
- package/src/backends/webview-preload.js +5 -3
- package/src/backends/window-manager.js +13 -3
- package/src/chrome/chrome-launcher.js +5 -4
- package/src/chrome/debugger-port-resolver.js +1 -1
- package/src/cloud/cloud-client.js +237 -41
- package/src/cluster/types.js +0 -5
- package/src/extension/inject.js +1 -1
- package/src/main.js +282 -88
- package/src/master/chrome-config.js +2 -2
- package/src/preload-rpc.js +1 -1
- package/src/profiles/profile-store.js +321 -0
- package/src/profiles/trusted-origins-store.js +95 -0
- package/src/server/worker-observability-routes.js +0 -2
- package/src/sidecar/cicy-code.js +84 -23
- package/src/sidecar/localbin.js +20 -3
- package/src/sidecar/native.js +3 -3
- package/src/sidecar/version.js +45 -0
- package/src/tabbrowser/newtab-protocol.js +54 -0
- package/src/tabbrowser/tab-browser.html +151 -0
- package/src/tabbrowser/tab-shell-preload.js +28 -0
- package/src/tabbrowser/tab-shell.html +227 -0
- package/src/tools/account-tools.js +191 -25
- package/src/tools/chrome-tools.js +173 -37
- package/src/tools/device-tools.js +25 -0
- package/src/tools/index.js +2 -0
- package/src/tools/tab-browser-tools.js +453 -0
- package/src/tools/window-tools.js +64 -7
- package/src/utils/brand-host-electron.js +25 -0
- package/src/utils/context-menu-options.js +80 -0
- package/src/utils/cookie-logins.js +58 -0
- package/src/utils/ip-probe.js +50 -0
- package/src/utils/rpc-audit.js +53 -0
- package/src/utils/rpc-guard.js +189 -0
- package/src/utils/window-monitor.js +5 -15
- package/src/utils/window-registry.js +210 -0
- package/src/utils/window-thumbnails.js +126 -0
- package/src/utils/window-utils.js +146 -109
- package/workers/render/package-lock.json +6 -6
- package/workers/render/src/App.css +36 -2
- package/workers/render/src/App.jsx +587 -103
- package/src/backends/artifact-ipc.js +0 -142
- package/src/backends/homepage-react/assets/index-DE9m6JTn.css +0 -1
- package/src/backends/homepage-react/assets/index-DLYMzgf5.js +0 -365
- package/src/cluster/artifact-registry.js +0 -61
|
@@ -255,6 +255,9 @@ export default function App() {
|
|
|
255
255
|
const [teams, setTeams] = useState(null);
|
|
256
256
|
const [profileLoading, setProfileLoading] = useState(false);
|
|
257
257
|
const [profileError, setProfileError] = useState("");
|
|
258
|
+
// Guards the auto re-login so a dead session (/api/teams 401) triggers the
|
|
259
|
+
// magic-link flow ONCE instead of looping. Reset when a fresh login lands.
|
|
260
|
+
const reauthing = useRef(false);
|
|
258
261
|
// Local teams discovered from ~/cicy-ai/global.json (main-process probe).
|
|
259
262
|
const [localTeams, setLocalTeams] = useState(null);
|
|
260
263
|
const [localTeamsLoading, setLocalTeamsLoading] = useState(false);
|
|
@@ -287,6 +290,18 @@ export default function App() {
|
|
|
287
290
|
window.cicy.cloud.fetch(`${CLOUD_BASE}/api/user/self`, { headers }),
|
|
288
291
|
window.cicy.cloud.fetch(`${CLOUD_BASE}/api/teams`, { headers }),
|
|
289
292
|
]);
|
|
293
|
+
// Session DEAD (cloud invalidated the sk-sess token) → /api/teams 401. The
|
|
294
|
+
// "永久登录" red line forbids re-prompting on mere restart/expiry, but a
|
|
295
|
+
// GENUINE 401 means the session is gone — the only recovery is a fresh
|
|
296
|
+
// login. Trigger the magic-link ONCE (guarded) instead of retrying forever.
|
|
297
|
+
if (teamsRes?.status === 401) {
|
|
298
|
+
if (!reauthing.current && window.cicy?.auth?.loginStart) {
|
|
299
|
+
reauthing.current = true;
|
|
300
|
+
setProfileError("会话已过期,正在重新登录…");
|
|
301
|
+
try { await window.cicy.auth.loginStart(); } catch {}
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
290
305
|
// /api/teams drives the team grid — it is the ONLY critical call here.
|
|
291
306
|
if (!teamsRes?.ok) throw new Error(`/api/teams ${teamsRes?.status || "?"} ${teamsRes?.error || ""}`);
|
|
292
307
|
// /api/teams is bare: { teams: [...] }
|
|
@@ -311,6 +326,20 @@ export default function App() {
|
|
|
311
326
|
}
|
|
312
327
|
}, []);
|
|
313
328
|
|
|
329
|
+
// 私有云/云端团队信息持续同步:/api/teams 原本只在登录/挂载拉一次,所以 dash 上改了
|
|
330
|
+
// 私有云的 host_url / 名字 / 状态,桌面看不到。用 ref 持有当前 bearer,挂到对账循环里
|
|
331
|
+
// 按窗口可见性周期重拉(轻量:只 setTeams,不动 loading/self,不在 401 时清空列表)。
|
|
332
|
+
const bearerRef = useRef("");
|
|
333
|
+
useEffect(() => { bearerRef.current = token || accessToken || ""; }, [token, accessToken]);
|
|
334
|
+
const refreshCloudTeams = useCallback(async () => {
|
|
335
|
+
const at = bearerRef.current;
|
|
336
|
+
if (!at || !window.cicy?.cloud?.fetch) return;
|
|
337
|
+
try {
|
|
338
|
+
const r = await window.cicy.cloud.fetch(`${CLOUD_BASE}/api/teams`, { headers: { Authorization: `Bearer ${at}` } });
|
|
339
|
+
if (r?.ok) { const b = JSON.parse(r.body || "{}"); if (Array.isArray(b?.teams)) setTeams(b.teams); }
|
|
340
|
+
} catch {}
|
|
341
|
+
}, []);
|
|
342
|
+
|
|
314
343
|
// First profile fetch on mount. The cloud console endpoints (/api/user/self,
|
|
315
344
|
// /api/teams) authenticate the owner-bound LOGIN token (the sk-xxx from the
|
|
316
345
|
// /cb callback) — NOT the console access_token (the cloud never mints one;
|
|
@@ -341,32 +370,53 @@ export default function App() {
|
|
|
341
370
|
// Rename a local team: persist via localTeams.update then refresh the list.
|
|
342
371
|
// Empty name falls back to 未命名 (mirrors local-teams.addTeam default).
|
|
343
372
|
const renameLocalTeam = useCallback(async (id, name) => {
|
|
344
|
-
if (!window.cicy?.localTeams?.update) return;
|
|
373
|
+
if (!window.cicy?.localTeams?.update) return { ok: false, error: "no_bridge" };
|
|
374
|
+
let r;
|
|
345
375
|
try {
|
|
346
|
-
await window.cicy.localTeams.update(id, { name: String(name || "").trim() || tr("localTeams.unnamed", "未命名") });
|
|
347
|
-
} catch {}
|
|
348
|
-
await fetchLocalTeams();
|
|
376
|
+
r = await window.cicy.localTeams.update(id, { name: String(name || "").trim() || tr("localTeams.unnamed", "未命名") });
|
|
377
|
+
} catch (e) { r = { ok: false, error: e?.message || String(e) }; }
|
|
378
|
+
await fetchLocalTeams(); // 对账:props 追上后清乐观名
|
|
379
|
+
return r || { ok: false, error: "no_result" };
|
|
349
380
|
}, [fetchLocalTeams]);
|
|
381
|
+
// 自适应对账:窗口可见时 ~3s 拉一次云端 title(远端/dash 改名秒级可见),隐藏时
|
|
382
|
+
// 退避到 30s 只刷新本地(云端对账交给 main 进程 30s 兜底);切回可见/聚焦立即对账。
|
|
350
383
|
useEffect(() => {
|
|
351
|
-
let
|
|
352
|
-
let
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
await
|
|
360
|
-
|
|
361
|
-
if (elapsed < FAST_WINDOW_MS) {
|
|
362
|
-
fastTimer = setTimeout(tick, FAST_MS);
|
|
363
|
-
} else {
|
|
364
|
-
slowTimer = setInterval(fetchLocalTeams, SLOW_MS);
|
|
365
|
-
}
|
|
384
|
+
let timer;
|
|
385
|
+
let stopped = false;
|
|
386
|
+
const VISIBLE_MS = 3_000;
|
|
387
|
+
const HIDDEN_MS = 30_000;
|
|
388
|
+
|
|
389
|
+
// 一发对账:本地 title 拉进 teams.json + 刷新本地列表 + 重拉云端团队(私有云
|
|
390
|
+
// host_url/名字/状态的同步)。三件事并行。
|
|
391
|
+
const reconcile = async () => {
|
|
392
|
+
try { await window.cicy?.localTeams?.syncCloud?.(); } catch {}
|
|
393
|
+
await Promise.all([fetchLocalTeams(), refreshCloudTeams()]);
|
|
366
394
|
};
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
395
|
+
|
|
396
|
+
const schedule = () => {
|
|
397
|
+
if (stopped) return;
|
|
398
|
+
const visible = document.visibilityState === "visible";
|
|
399
|
+
timer = setTimeout(async () => {
|
|
400
|
+
if (document.visibilityState === "visible") await reconcile();
|
|
401
|
+
else await fetchLocalTeams(); // 隐藏:只刷新本地,不打云端
|
|
402
|
+
schedule();
|
|
403
|
+
}, visible ? VISIBLE_MS : HIDDEN_MS);
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
reconcile(); // 挂载即来一发
|
|
407
|
+
schedule();
|
|
408
|
+
|
|
409
|
+
// 切回可见/聚焦 → 立即对账(dash 改完名点回桌面秒同步)
|
|
410
|
+
const onWake = () => { if (document.visibilityState === "visible") reconcile(); };
|
|
411
|
+
document.addEventListener("visibilitychange", onWake);
|
|
412
|
+
window.addEventListener("focus", onWake);
|
|
413
|
+
|
|
414
|
+
return () => {
|
|
415
|
+
stopped = true; clearTimeout(timer);
|
|
416
|
+
document.removeEventListener("visibilitychange", onWake);
|
|
417
|
+
window.removeEventListener("focus", onWake);
|
|
418
|
+
};
|
|
419
|
+
}, [fetchLocalTeams, refreshCloudTeams]);
|
|
370
420
|
|
|
371
421
|
// Webview relay — the Team Helper <webview> calls window.cicy.localTeams.add(...)
|
|
372
422
|
// inside the webview, that hops main → here. We run the actual IPC, refresh
|
|
@@ -456,6 +506,10 @@ export default function App() {
|
|
|
456
506
|
return;
|
|
457
507
|
}
|
|
458
508
|
if (payload?.token) {
|
|
509
|
+
// Fresh session landed — clear the dead-session re-auth guard + message
|
|
510
|
+
// so a future 401 can re-trigger recovery.
|
|
511
|
+
reauthing.current = false;
|
|
512
|
+
setProfileError("");
|
|
459
513
|
try { localStorage.setItem(TOKEN_KEY, payload.token); } catch {}
|
|
460
514
|
setToken(payload.token);
|
|
461
515
|
if (payload.accessToken) {
|
|
@@ -595,23 +649,36 @@ export default function App() {
|
|
|
595
649
|
<Header me={me} welcome={welcome} onLogout={handleLogout}
|
|
596
650
|
mitmTeam={localList.length > 0 ? localList[0] : null} />
|
|
597
651
|
<main className="main">
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
{
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
652
|
+
{/* 整行:左边 tab 药丸,右边「新加团队」顶到行尾 */}
|
|
653
|
+
<div className="app__tabsrow">
|
|
654
|
+
<div className="app__tabs">
|
|
655
|
+
{[
|
|
656
|
+
{ k: "all", label: "全部", n: localCount + customCount + cloudCount },
|
|
657
|
+
{ k: "local", label: "本地", n: localCount },
|
|
658
|
+
{ k: "cloud", label: "私有云", n: cloudCount },
|
|
659
|
+
{ k: "custom", label: "自定义", n: customCount },
|
|
660
|
+
].map(({ k, label, n }) => (
|
|
661
|
+
<button
|
|
662
|
+
key={k}
|
|
663
|
+
type="button"
|
|
664
|
+
className={`app__tab ${tab === k ? "is-active" : ""}`}
|
|
665
|
+
onClick={() => setTab(k)}
|
|
666
|
+
>
|
|
667
|
+
{label}
|
|
668
|
+
<span className="app__tab-count">{n}</span>
|
|
669
|
+
</button>
|
|
670
|
+
))}
|
|
671
|
+
</div>
|
|
672
|
+
{/* 行尾:新加团队 → 跳浏览器到云端 dash 私有云页 */}
|
|
673
|
+
<button
|
|
674
|
+
type="button"
|
|
675
|
+
data-id="AddTeamButton"
|
|
676
|
+
className="app__add-team"
|
|
677
|
+
title={tr("teams.addHint", "在云端新建私有云团队")}
|
|
678
|
+
onClick={() => openCloudPage("?tab=private")}
|
|
679
|
+
>
|
|
680
|
+
+ {tr("teams.add", "新加团队")}
|
|
681
|
+
</button>
|
|
615
682
|
</div>
|
|
616
683
|
|
|
617
684
|
{/* Docker 安装卡已下线 (主人令): Windows 走原生 cicy-code.exe --helper,不再用 Docker。 */}
|
|
@@ -630,6 +697,30 @@ export default function App() {
|
|
|
630
697
|
{showLocal && localList.map((t) => (
|
|
631
698
|
<LocalTeamCard key={"local:" + t.id} team={t} onOpen={() => openLocalTeam(t.id)} onRename={renameLocalTeam} onRefresh={fetchLocalTeams} />
|
|
632
699
|
))}
|
|
700
|
+
{/* 占位卡 (主人: "本地团队没有占位"): a fresh install starts the sidecar
|
|
701
|
+
and main auto-registers 本地团队 once :8008 answers — until that
|
|
702
|
+
lands, hold its spot so the 本地 tab is never blank. The slow
|
|
703
|
+
localTeams poll swaps this for the real card automatically. */}
|
|
704
|
+
{showLocal && localList.length === 0 && (
|
|
705
|
+
<div data-id="LocalTeamPlaceholder" className="bcard bcard--local">
|
|
706
|
+
<div className="bcard__accent" />
|
|
707
|
+
<div className="bcard__top">
|
|
708
|
+
<div className="bcard__pill">
|
|
709
|
+
<span className="bcard__dot" data-tone="warn" />
|
|
710
|
+
<LaptopIcon />
|
|
711
|
+
</div>
|
|
712
|
+
</div>
|
|
713
|
+
<div className="bcard__body">
|
|
714
|
+
<h3 className="bcard__name">本地团队</h3>
|
|
715
|
+
<div className="bcard__host">http://127.0.0.1:8008</div>
|
|
716
|
+
<div className="bcard__meta" />
|
|
717
|
+
</div>
|
|
718
|
+
<button type="button" className="bcard__cta" disabled>
|
|
719
|
+
<Spinner />
|
|
720
|
+
<span>{localTeamsFetched ? "正在启动,就绪后自动加入…" : "检测中…"}</span>
|
|
721
|
+
</button>
|
|
722
|
+
</div>
|
|
723
|
+
)}
|
|
633
724
|
{showCustom && customList.map((t) => (
|
|
634
725
|
<LocalTeamCard key={"custom:" + t.id} team={t} onOpen={() => openLocalTeam(t.id)} onRename={renameLocalTeam} onRefresh={fetchLocalTeams} />
|
|
635
726
|
))}
|
|
@@ -638,8 +729,11 @@ export default function App() {
|
|
|
638
729
|
key={"cloud:" + t.id}
|
|
639
730
|
team={t}
|
|
640
731
|
onOpen={() => {
|
|
641
|
-
|
|
642
|
-
|
|
732
|
+
// private:开 host_url(自托管地址);历史 cloud:开 workspace_url。
|
|
733
|
+
// Open as a TAB in the current profile (like the local card), NOT
|
|
734
|
+
// the system browser.
|
|
735
|
+
const url = t.kind === "private" ? t.host_url : (t.workspace_url || t.workspace_direct_url);
|
|
736
|
+
if (url) window.cicy?.tabs?.open?.(url, t.name || t.title || "");
|
|
643
737
|
}}
|
|
644
738
|
/>
|
|
645
739
|
))}
|
|
@@ -666,10 +760,262 @@ export default function App() {
|
|
|
666
760
|
);
|
|
667
761
|
}
|
|
668
762
|
|
|
763
|
+
// Chrome-style "site settings" for the trusted-origins allowlist: which sites may
|
|
764
|
+
// receive the electronRPC bridge in profile 0 (= run commands on this machine).
|
|
765
|
+
// Backed by window.cicy.trustedOrigins.{list,add,remove}; built-ins (localhost)
|
|
766
|
+
// are greyed + non-removable; the default list is just the built-ins. Inline
|
|
767
|
+
// styles keep it self-contained (no dependency on App.css classes).
|
|
768
|
+
function TrustedSitesModal({ onClose }) {
|
|
769
|
+
const [rows, setRows] = useState(null); // [{host, builtin}] | null(loading)
|
|
770
|
+
const [input, setInput] = useState("");
|
|
771
|
+
const [busy, setBusy] = useState(false);
|
|
772
|
+
const [err, setErr] = useState("");
|
|
773
|
+
const api = (typeof window !== "undefined" && window.cicy && window.cicy.trustedOrigins) || null;
|
|
774
|
+
|
|
775
|
+
const load = useCallback(async () => {
|
|
776
|
+
try { setRows((api && (await api.list())) || []); } catch { setRows([]); }
|
|
777
|
+
}, [api]);
|
|
778
|
+
useEffect(() => { load(); }, [load]);
|
|
779
|
+
|
|
780
|
+
const doAdd = async () => {
|
|
781
|
+
const v = input.trim();
|
|
782
|
+
if (!v || busy || !api) return;
|
|
783
|
+
setBusy(true); setErr("");
|
|
784
|
+
try {
|
|
785
|
+
const r = await api.add(v);
|
|
786
|
+
if (r && r.ok === false) setErr(r.error || tr("trustedSites.addFailed", "添加失败"));
|
|
787
|
+
else { setInput(""); setRows((r && r.origins) || (await api.list())); }
|
|
788
|
+
} catch (e) { setErr(String((e && e.message) || e)); }
|
|
789
|
+
finally { setBusy(false); }
|
|
790
|
+
};
|
|
791
|
+
const doRemove = async (host) => {
|
|
792
|
+
if (busy || !api) return;
|
|
793
|
+
setBusy(true); setErr("");
|
|
794
|
+
try {
|
|
795
|
+
const r = await api.remove(host);
|
|
796
|
+
if (r && r.ok === false) setErr(r.error || tr("trustedSites.removeFailed", "删除失败"));
|
|
797
|
+
else setRows((r && r.origins) || (await api.list()));
|
|
798
|
+
} catch (e) { setErr(String((e && e.message) || e)); }
|
|
799
|
+
finally { setBusy(false); }
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
const S = {
|
|
803
|
+
overlay: { position: "fixed", inset: 0, zIndex: 2000, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.62)", backdropFilter: "blur(3px)" },
|
|
804
|
+
card: { width: 560, maxWidth: "94vw", maxHeight: "82vh", display: "flex", flexDirection: "column", background: "#101012", border: "1px solid rgba(255,255,255,.09)", borderRadius: 16, boxShadow: "0 24px 64px rgba(0,0,0,.55)", overflow: "hidden", color: "#e4e4e7" },
|
|
805
|
+
head: { display: "flex", alignItems: "center", gap: 8, padding: "14px 16px", borderBottom: "1px solid rgba(255,255,255,.06)" },
|
|
806
|
+
title: { margin: 0, fontSize: 15, fontWeight: 600, flex: 1 },
|
|
807
|
+
x: { background: "transparent", border: "none", color: "#a1a1aa", fontSize: 16, cursor: "pointer", lineHeight: 1, padding: 4 },
|
|
808
|
+
warn: { margin: "14px 16px 0", padding: "10px 12px", fontSize: 12.5, lineHeight: 1.55, color: "#fca5a5", background: "rgba(239,68,68,.08)", border: "1px solid rgba(239,68,68,.25)", borderRadius: 10 },
|
|
809
|
+
addRow: { display: "flex", gap: 8, padding: "12px 16px 4px" },
|
|
810
|
+
input: { flex: 1, minWidth: 0, background: "#161618", border: "1px solid rgba(255,255,255,.1)", borderRadius: 9, padding: "9px 11px", color: "#e4e4e7", fontSize: 13, outline: "none" },
|
|
811
|
+
addBtn: { background: "rgba(255,255,255,.1)", border: "none", borderRadius: 9, padding: "0 16px", color: "#fff", fontSize: 13, fontWeight: 500, cursor: "pointer" },
|
|
812
|
+
err: { margin: "6px 16px 0", fontSize: 12, color: "#fca5a5" },
|
|
813
|
+
listWrap: { margin: "10px 16px 16px", border: "1px solid rgba(255,255,255,.07)", borderRadius: 10, overflow: "auto", flex: 1, minHeight: 80 },
|
|
814
|
+
row: { display: "flex", alignItems: "center", gap: 10, padding: "9px 12px", borderTop: "1px solid rgba(255,255,255,.05)" },
|
|
815
|
+
host: (b) => ({ flex: 1, fontFamily: "ui-monospace,SFMono-Regular,Menlo,monospace", fontSize: 13, color: b ? "#71717a" : "#e4e4e7", wordBreak: "break-all" }),
|
|
816
|
+
tag: { fontSize: 11, color: "#71717a", background: "rgba(255,255,255,.05)", borderRadius: 6, padding: "2px 7px" },
|
|
817
|
+
rm: { background: "transparent", border: "none", color: "#a1a1aa", fontSize: 12, cursor: "pointer", padding: "3px 6px", borderRadius: 6 },
|
|
818
|
+
muted: { padding: "16px", textAlign: "center", color: "#71717a", fontSize: 12.5 },
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
return createPortal(
|
|
822
|
+
<div style={S.overlay} onClick={onClose} data-id="TrustedSitesModal">
|
|
823
|
+
<div style={S.card} onClick={(e) => e.stopPropagation()}>
|
|
824
|
+
<div style={S.head}>
|
|
825
|
+
<h2 style={S.title}>{tr("trustedSites.title", "受信任站点")}</h2>
|
|
826
|
+
<button type="button" style={S.x} onClick={onClose} aria-label="close">✕</button>
|
|
827
|
+
</div>
|
|
828
|
+
<div style={S.warn}>
|
|
829
|
+
{tr("trustedSites.warn", "⚠ 列表中的站点可以在你的电脑上执行命令(exec)。只添加你完全信任的地址。")}
|
|
830
|
+
</div>
|
|
831
|
+
<div style={S.addRow}>
|
|
832
|
+
<input
|
|
833
|
+
data-id="trusted-sites-input"
|
|
834
|
+
style={S.input}
|
|
835
|
+
value={input}
|
|
836
|
+
onChange={(e) => setInput(e.target.value)}
|
|
837
|
+
onKeyDown={(e) => { if (e.key === "Enter") doAdd(); }}
|
|
838
|
+
placeholder={tr("trustedSites.placeholder", "添加站点,如 app.cicy-ai.com 或 my-cloud.example.org")}
|
|
839
|
+
/>
|
|
840
|
+
<button type="button" data-id="trusted-sites-add" style={{ ...S.addBtn, opacity: busy || !input.trim() ? 0.5 : 1 }} onClick={doAdd} disabled={busy || !input.trim()}>
|
|
841
|
+
{tr("trustedSites.add", "添加")}
|
|
842
|
+
</button>
|
|
843
|
+
</div>
|
|
844
|
+
{err && <div style={S.err}>{err}</div>}
|
|
845
|
+
<div style={S.listWrap}>
|
|
846
|
+
{rows === null ? (
|
|
847
|
+
<div style={S.muted}>{tr("trustedSites.loading", "加载中…")}</div>
|
|
848
|
+
) : rows.length === 0 ? (
|
|
849
|
+
<div style={S.muted}>{tr("trustedSites.empty", "暂无")}</div>
|
|
850
|
+
) : (
|
|
851
|
+
rows.map((r) => (
|
|
852
|
+
<div key={r.host} style={S.row} data-id="trusted-sites-row">
|
|
853
|
+
<span style={S.host(r.builtin)}>{r.host}</span>
|
|
854
|
+
{r.builtin ? (
|
|
855
|
+
<span style={S.tag}>{tr("trustedSites.builtin", "系统")}</span>
|
|
856
|
+
) : (
|
|
857
|
+
<button type="button" style={S.rm} onClick={() => doRemove(r.host)} disabled={busy}>
|
|
858
|
+
{tr("trustedSites.remove", "删除")}
|
|
859
|
+
</button>
|
|
860
|
+
)}
|
|
861
|
+
</div>
|
|
862
|
+
))
|
|
863
|
+
)}
|
|
864
|
+
</div>
|
|
865
|
+
</div>
|
|
866
|
+
</div>,
|
|
867
|
+
document.body,
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Read-only viewer for the RPC audit log (~/cicy-ai/db/rpc-audit.log): every
|
|
872
|
+
// electronRPC call + every authorization decision (incl. temporary 本次允许 / 允许
|
|
873
|
+
// 一次) + allowlist edit. Backed by window.cicy.rpcAudit.tail(); newest-first,
|
|
874
|
+
// refreshable. Review-only — there is no mutation path.
|
|
875
|
+
function AuditLogModal({ onClose }) {
|
|
876
|
+
const [entries, setEntries] = useState(null); // [] | null(loading)
|
|
877
|
+
const [err, setErr] = useState("");
|
|
878
|
+
const [logPath, setLogPath] = useState("");
|
|
879
|
+
const [busy, setBusy] = useState(false);
|
|
880
|
+
const api = (typeof window !== "undefined" && window.cicy && window.cicy.rpcAudit) || null;
|
|
881
|
+
|
|
882
|
+
const load = useCallback(async () => {
|
|
883
|
+
setBusy(true); setErr("");
|
|
884
|
+
try {
|
|
885
|
+
const r = api && (await api.tail(400));
|
|
886
|
+
if (!r || r.ok === false) { setErr((r && r.error) || tr("audit.loadFailed", "读取失败")); setEntries([]); }
|
|
887
|
+
else { setEntries(r.entries || []); setLogPath(r.path || ""); }
|
|
888
|
+
} catch (e) { setErr(String((e && e.message) || e)); setEntries([]); }
|
|
889
|
+
finally { setBusy(false); }
|
|
890
|
+
}, [api]);
|
|
891
|
+
useEffect(() => { load(); }, [load]);
|
|
892
|
+
|
|
893
|
+
const [filter, setFilter] = useState("all"); // all | rpc | auth
|
|
894
|
+
const [query, setQuery] = useState("");
|
|
895
|
+
|
|
896
|
+
const fmtTime = (ts) => { try { return new Date(ts).toLocaleString(); } catch { return ts || ""; } };
|
|
897
|
+
const badge = (e) => {
|
|
898
|
+
if (e.kind === "auth") {
|
|
899
|
+
const deny = /deny/.test(e.decision || "");
|
|
900
|
+
return { text: e.decision || "auth", color: deny ? "#fca5a5" : "#86efac", bg: deny ? "rgba(239,68,68,.14)" : "rgba(34,197,94,.14)" };
|
|
901
|
+
}
|
|
902
|
+
if (e.kind === "rpc") {
|
|
903
|
+
const ok = e.ok !== false && !e.error;
|
|
904
|
+
return { text: ok ? "ok" : "err", color: ok ? "#86efac" : "#fca5a5", bg: ok ? "rgba(34,197,94,.14)" : "rgba(239,68,68,.14)" };
|
|
905
|
+
}
|
|
906
|
+
return { text: e.kind || "log", color: "#a1a1aa", bg: "rgba(255,255,255,.06)" };
|
|
907
|
+
};
|
|
908
|
+
const opText = (e) => {
|
|
909
|
+
if (e.kind === "auth") return `${e.gate || ""}${e.decision ? " · " + e.decision : ""}`;
|
|
910
|
+
if (e.kind === "rpc") return `${e.tool || ""}${e.dangerous ? " ⚠" : ""}`;
|
|
911
|
+
return e.kind || "";
|
|
912
|
+
};
|
|
913
|
+
const detailText = (e) => e.error || e.args || (e.kind === "rpc" ? e.channel : "") || "";
|
|
914
|
+
|
|
915
|
+
const all = entries || [];
|
|
916
|
+
const q = query.trim().toLowerCase();
|
|
917
|
+
const view = all.filter((e) => {
|
|
918
|
+
if (filter !== "all" && e.kind !== filter) return false;
|
|
919
|
+
if (!q) return true;
|
|
920
|
+
return [e.origin, e.host, e.tool, e.gate, e.decision, e.channel, e.args, e.error, e.kind]
|
|
921
|
+
.filter(Boolean).join(" ").toLowerCase().includes(q);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
const COLS = "186px 104px minmax(160px,1.1fr) minmax(150px,1.1fr) minmax(220px,1.8fr)";
|
|
925
|
+
const S = {
|
|
926
|
+
overlay: { position: "fixed", inset: 0, zIndex: 2000, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.66)", backdropFilter: "blur(4px)" },
|
|
927
|
+
card: { width: "96vw", height: "92vh", maxWidth: 1480, display: "flex", flexDirection: "column", background: "#0d0d0f", border: "1px solid rgba(255,255,255,.09)", borderRadius: 18, boxShadow: "0 32px 80px rgba(0,0,0,.6)", overflow: "hidden", color: "#e4e4e7" },
|
|
928
|
+
head: { display: "flex", alignItems: "center", gap: 14, padding: "20px 24px", borderBottom: "1px solid rgba(255,255,255,.07)" },
|
|
929
|
+
titleWrap: { flex: 1, minWidth: 0 },
|
|
930
|
+
title: { margin: 0, fontSize: 21, fontWeight: 650, letterSpacing: .2 },
|
|
931
|
+
subtitle: { margin: "3px 0 0", fontSize: 12.5, color: "#71717a", fontFamily: "ui-monospace,SFMono-Regular,Menlo,monospace", wordBreak: "break-all" },
|
|
932
|
+
count: { fontSize: 12.5, color: "#a1a1aa", whiteSpace: "nowrap" },
|
|
933
|
+
refresh: { background: "rgba(255,255,255,.1)", border: "none", borderRadius: 9, padding: "9px 16px", color: "#fff", fontSize: 13, fontWeight: 500, cursor: "pointer" },
|
|
934
|
+
x: { background: "transparent", border: "none", color: "#a1a1aa", fontSize: 20, cursor: "pointer", lineHeight: 1, padding: 6 },
|
|
935
|
+
toolbar: { display: "flex", alignItems: "center", gap: 10, padding: "14px 24px", borderBottom: "1px solid rgba(255,255,255,.05)" },
|
|
936
|
+
chips: { display: "flex", gap: 6 },
|
|
937
|
+
chip: (on) => ({ background: on ? "rgba(255,255,255,.14)" : "transparent", border: "1px solid rgba(255,255,255,.12)", borderRadius: 999, padding: "6px 16px", color: on ? "#fff" : "#a1a1aa", fontSize: 13, cursor: "pointer" }),
|
|
938
|
+
search: { flex: 1, minWidth: 0, background: "#161618", border: "1px solid rgba(255,255,255,.1)", borderRadius: 10, padding: "10px 14px", color: "#e4e4e7", fontSize: 13.5, outline: "none" },
|
|
939
|
+
err: { margin: "10px 24px 0", fontSize: 12.5, color: "#fca5a5" },
|
|
940
|
+
tableWrap: { flex: 1, overflow: "auto", margin: "0" },
|
|
941
|
+
theadRow: { position: "sticky", top: 0, zIndex: 1, display: "grid", gridTemplateColumns: COLS, gap: 16, padding: "12px 24px", background: "#141417", borderBottom: "1px solid rgba(255,255,255,.08)", fontSize: 11.5, letterSpacing: .6, textTransform: "uppercase", color: "#71717a", fontWeight: 600 },
|
|
942
|
+
row: { display: "grid", gridTemplateColumns: COLS, gap: 16, padding: "13px 24px", borderBottom: "1px solid rgba(255,255,255,.045)", alignItems: "center" },
|
|
943
|
+
time: { fontSize: 12.5, color: "#a1a1aa", fontFamily: "ui-monospace,SFMono-Regular,Menlo,monospace", whiteSpace: "nowrap" },
|
|
944
|
+
badge: (b) => ({ justifySelf: "start", fontSize: 11.5, color: b.color, background: b.bg, borderRadius: 7, padding: "3px 10px", whiteSpace: "nowrap", fontWeight: 500 }),
|
|
945
|
+
cell: { fontSize: 13, color: "#d4d4d8", fontFamily: "ui-monospace,SFMono-Regular,Menlo,monospace", wordBreak: "break-all" },
|
|
946
|
+
detail: { fontSize: 12.5, color: "#8b8b93", wordBreak: "break-all" },
|
|
947
|
+
muted: { padding: "60px 24px", textAlign: "center", color: "#71717a", fontSize: 14 },
|
|
948
|
+
};
|
|
949
|
+
const Th = (t) => <div>{t}</div>;
|
|
950
|
+
|
|
951
|
+
return createPortal(
|
|
952
|
+
<div style={S.overlay} onClick={onClose} data-id="AuditLogModal">
|
|
953
|
+
<div style={S.card} onClick={(e) => e.stopPropagation()}>
|
|
954
|
+
<div style={S.head}>
|
|
955
|
+
<div style={S.titleWrap}>
|
|
956
|
+
<h2 style={S.title}>{tr("audit.title", "审计日志")}</h2>
|
|
957
|
+
{logPath && <p style={S.subtitle}>{logPath}</p>}
|
|
958
|
+
</div>
|
|
959
|
+
<span style={S.count}>{tr("audit.count", "共")} {view.length}{filter !== "all" || q ? ` / ${all.length}` : ""}</span>
|
|
960
|
+
<button type="button" data-id="audit-refresh" style={{ ...S.refresh, opacity: busy ? 0.5 : 1 }} onClick={load} disabled={busy}>
|
|
961
|
+
{tr("audit.refresh", "刷新")}
|
|
962
|
+
</button>
|
|
963
|
+
<button type="button" style={S.x} onClick={onClose} aria-label="close">✕</button>
|
|
964
|
+
</div>
|
|
965
|
+
<div style={S.toolbar}>
|
|
966
|
+
<div style={S.chips}>
|
|
967
|
+
<button type="button" style={S.chip(filter === "all")} onClick={() => setFilter("all")}>{tr("audit.all", "全部")}</button>
|
|
968
|
+
<button type="button" style={S.chip(filter === "rpc")} onClick={() => setFilter("rpc")}>{tr("audit.rpc", "RPC 调用")}</button>
|
|
969
|
+
<button type="button" style={S.chip(filter === "auth")} onClick={() => setFilter("auth")}>{tr("audit.auth", "授权决定")}</button>
|
|
970
|
+
</div>
|
|
971
|
+
<input
|
|
972
|
+
data-id="audit-search"
|
|
973
|
+
style={S.search}
|
|
974
|
+
value={query}
|
|
975
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
976
|
+
placeholder={tr("audit.search", "搜索来源 / 工具 / 命令…")}
|
|
977
|
+
/>
|
|
978
|
+
</div>
|
|
979
|
+
{err && <div style={S.err}>{err}</div>}
|
|
980
|
+
<div style={S.tableWrap}>
|
|
981
|
+
<div style={S.theadRow}>
|
|
982
|
+
{Th(tr("audit.colTime", "时间"))}
|
|
983
|
+
{Th(tr("audit.colType", "类型"))}
|
|
984
|
+
{Th(tr("audit.colSource", "来源"))}
|
|
985
|
+
{Th(tr("audit.colOp", "操作"))}
|
|
986
|
+
{Th(tr("audit.colDetail", "详情"))}
|
|
987
|
+
</div>
|
|
988
|
+
{entries === null ? (
|
|
989
|
+
<div style={S.muted}>{tr("audit.loading", "加载中…")}</div>
|
|
990
|
+
) : view.length === 0 ? (
|
|
991
|
+
<div style={S.muted}>{all.length === 0 ? tr("audit.empty", "暂无审计记录") : tr("audit.noMatch", "无匹配记录")}</div>
|
|
992
|
+
) : (
|
|
993
|
+
view.map((e, i) => {
|
|
994
|
+
const b = badge(e);
|
|
995
|
+
return (
|
|
996
|
+
<div key={i} style={S.row} data-id="audit-row">
|
|
997
|
+
<span style={S.time}>{fmtTime(e.ts)}</span>
|
|
998
|
+
<span style={S.badge(b)}>{b.text}</span>
|
|
999
|
+
<span style={S.cell}>{e.origin || e.host || "—"}</span>
|
|
1000
|
+
<span style={S.cell}>{opText(e) || "—"}</span>
|
|
1001
|
+
<span style={S.detail}>{detailText(e) || "—"}</span>
|
|
1002
|
+
</div>
|
|
1003
|
+
);
|
|
1004
|
+
})
|
|
1005
|
+
)}
|
|
1006
|
+
</div>
|
|
1007
|
+
</div>
|
|
1008
|
+
</div>,
|
|
1009
|
+
document.body,
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
669
1013
|
function Header({ me, welcome, onLogout, mitmTeam }) {
|
|
670
1014
|
const name = me?.display_name || me?.username || "…";
|
|
671
1015
|
const initials = (name || "?").slice(0, 1).toUpperCase();
|
|
672
1016
|
const [open, setOpen] = useState(false);
|
|
1017
|
+
const [trustOpen, setTrustOpen] = useState(false);
|
|
1018
|
+
const [auditOpen, setAuditOpen] = useState(false);
|
|
673
1019
|
const wrap = useRef(null);
|
|
674
1020
|
// Click-outside closes the dropdown (mirrors LocalTeamCard's ⋯ menu).
|
|
675
1021
|
useEffect(() => {
|
|
@@ -707,6 +1053,12 @@ function Header({ me, welcome, onLogout, mitmTeam }) {
|
|
|
707
1053
|
<button type="button" data-id="UserChip-billing" className="user-chip__menu-item" onClick={() => goDash("?view=usage")}>
|
|
708
1054
|
我的账单
|
|
709
1055
|
</button>
|
|
1056
|
+
<button type="button" data-id="UserChip-trusted-sites" className="user-chip__menu-item" onClick={() => { setOpen(false); setTrustOpen(true); }}>
|
|
1057
|
+
{tr("trustedSites.menu", "受信任站点")}
|
|
1058
|
+
</button>
|
|
1059
|
+
<button type="button" data-id="UserChip-audit-log" className="user-chip__menu-item" onClick={() => { setOpen(false); setAuditOpen(true); }}>
|
|
1060
|
+
{tr("audit.menu", "审计日志")}
|
|
1061
|
+
</button>
|
|
710
1062
|
{mitmTeam && (
|
|
711
1063
|
<div className="user-chip__menu-mitm" data-id="UserChip-mitm" onClick={(e) => e.stopPropagation()}>
|
|
712
1064
|
<MitmConsentCard team={mitmTeam} variant="menu" />
|
|
@@ -719,6 +1071,8 @@ function Header({ me, welcome, onLogout, mitmTeam }) {
|
|
|
719
1071
|
</div>
|
|
720
1072
|
)}
|
|
721
1073
|
</div>
|
|
1074
|
+
{trustOpen && <TrustedSitesModal onClose={() => setTrustOpen(false)} />}
|
|
1075
|
+
{auditOpen && <AuditLogModal onClose={() => setAuditOpen(false)} />}
|
|
722
1076
|
</header>
|
|
723
1077
|
);
|
|
724
1078
|
}
|
|
@@ -1048,15 +1402,34 @@ function DockerSetup({ onReady }) {
|
|
|
1048
1402
|
function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
|
|
1049
1403
|
const statusInfo = LOCAL_STATUS[team.status] || LOCAL_STATUS.error;
|
|
1050
1404
|
const tone = statusInfo.tone;
|
|
1051
|
-
// Inline rename
|
|
1052
|
-
//
|
|
1405
|
+
// Inline rename — 产品级:点名字即编辑、乐观更新、行内"保存中/已保存"、失败回滚。
|
|
1406
|
+
// 显示名 = pendingName(乐观,保存中暂显)?? team.name(props,后台对账后更新)。
|
|
1053
1407
|
const [editing, setEditing] = useState(false);
|
|
1054
1408
|
const [draft, setDraft] = useState(team.name || "");
|
|
1055
|
-
const
|
|
1409
|
+
const [pendingName, setPendingName] = useState(null); // 乐观名;props 追上后清
|
|
1410
|
+
const [saveState, setSaveState] = useState(""); // "" | saving | saved | error
|
|
1411
|
+
const displayName = pendingName != null ? pendingName : team.name;
|
|
1412
|
+
// props 追上乐观名 → 清 pending(两者相等,无闪烁)
|
|
1413
|
+
useEffect(() => { if (pendingName != null && team.name === pendingName) setPendingName(null); }, [team.name, pendingName]);
|
|
1414
|
+
const startEdit = (e) => { e?.stopPropagation?.(); setDraft(displayName || ""); setEditing(true); };
|
|
1056
1415
|
const commit = async () => {
|
|
1057
1416
|
setEditing(false);
|
|
1058
1417
|
const next = String(draft || "").trim();
|
|
1059
|
-
if (onRename
|
|
1418
|
+
if (!onRename || !next || next === displayName) return;
|
|
1419
|
+
setPendingName(next); // 乐观:立即显示新名
|
|
1420
|
+
setSaveState("saving");
|
|
1421
|
+
let r;
|
|
1422
|
+
try { r = await onRename(team.id, next); } catch (e) { r = { ok: false, error: e?.message }; }
|
|
1423
|
+
// 落定:让权威值(team.name,onRename 已刷新)接管显示。服务端权威下,本端改名若与
|
|
1424
|
+
// 云端并发冲突会被判负,后续 ~3s 对账会把名字换成云端版本——清掉乐观名才不会卡住。
|
|
1425
|
+
setPendingName(null);
|
|
1426
|
+
if (r && r.ok) {
|
|
1427
|
+
setSaveState("saved");
|
|
1428
|
+
setTimeout(() => setSaveState((s) => (s === "saved" ? "" : s)), 1500);
|
|
1429
|
+
} else {
|
|
1430
|
+
setSaveState("");
|
|
1431
|
+
toast.show({ message: tr("localTeams.renameFailed", "改名没保存,已恢复"), status: "error", ttl: 4000 });
|
|
1432
|
+
}
|
|
1060
1433
|
};
|
|
1061
1434
|
|
|
1062
1435
|
// Lifecycle (启动 / 重启 / 更新 / 停止) acts on the daemon the desktop OWNS —
|
|
@@ -1069,46 +1442,63 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
|
|
|
1069
1442
|
const running = team.status === "running";
|
|
1070
1443
|
const [busy, setBusy] = useState(""); // "" | start | restart | update | stop
|
|
1071
1444
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
1072
|
-
|
|
1445
|
+
// cicy-code 版本统一从 sidecar.versions() 一处拿(主人令:"拿版本就一个方法")。
|
|
1446
|
+
// running===undefined = 还没查到(用于区分"加载中" vs "停了/拿不到");区别于
|
|
1447
|
+
// running===null(查过了但 daemon 没报版本)。latest/installed 同源。
|
|
1448
|
+
const [versions, setVersions] = useState({ running: undefined, latest: null, installed: null });
|
|
1449
|
+
const latest = versions.latest;
|
|
1450
|
+
const runningVer = versions.running;
|
|
1073
1451
|
const [checking, setChecking] = useState(false);
|
|
1074
|
-
const [upToDateMsg, setUpToDateMsg] = useState(""); // transient "已是最新 vX"
|
|
1075
1452
|
const menuWrap = useRef(null);
|
|
1453
|
+
const kebabRef = useRef(null); // ⋯ button — anchor for the portaled menu
|
|
1454
|
+
const menuRef = useRef(null); // portaled menu (lives on document.body)
|
|
1455
|
+
const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
|
|
1456
|
+
const MENU_W = 184;
|
|
1457
|
+
// The ⋯ menu is rendered in a PORTAL on document.body (not inside .bcard, whose
|
|
1458
|
+
// overflow:hidden — needed for the rounded card + glow — would otherwise CLIP
|
|
1459
|
+
// the dropdown). Anchor it under the kebab, right-aligned, clamped on-screen.
|
|
1460
|
+
const toggleMenu = () => {
|
|
1461
|
+
if (!menuOpen && kebabRef.current) {
|
|
1462
|
+
const r = kebabRef.current.getBoundingClientRect();
|
|
1463
|
+
const left = Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8));
|
|
1464
|
+
setMenuPos({ top: Math.round(r.bottom + 4), left: Math.round(left) });
|
|
1465
|
+
}
|
|
1466
|
+
setMenuOpen((v) => !v);
|
|
1467
|
+
};
|
|
1076
1468
|
|
|
1077
|
-
//
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1469
|
+
// 版本统一从 sidecar.versions() 一处拿(running=活着的 /api/health 版本,
|
|
1470
|
+
// latest=npm 最新,同源)。Auto-runs once on mount + 每次 daemon 起来(status→
|
|
1471
|
+
// running)时重查,这样 cicy-code 刚启动那一拍拿不到版本、之后能自动补上。
|
|
1472
|
+
// ⋯ 菜单"检查更新"用 manual=true 给 toast。
|
|
1081
1473
|
const checkUpdate = useCallback(async (manual = false) => {
|
|
1082
|
-
if (!local || !window.cicy?.
|
|
1083
|
-
if (manual)
|
|
1474
|
+
if (!local || !window.cicy?.sidecar?.versions) return;
|
|
1475
|
+
if (manual) setChecking(true);
|
|
1084
1476
|
try {
|
|
1085
|
-
const
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
setUpToDateMsg(`${tr("sidecar.latestVersionIs", "最新版本")} v${v}·${tr("sidecar.startToCompare", "启动后对比当前版本")}`);
|
|
1096
|
-
setTimeout(() => setUpToDateMsg(""), 3500);
|
|
1097
|
-
} else if (cmpVer(v, team.version) > 0) {
|
|
1098
|
-
// Behind — the 更新 badge/button drives the upgrade; no toast here.
|
|
1099
|
-
} else {
|
|
1100
|
-
setUpToDateMsg(`${tr("sidecar.upToDate", "已是最新")} v${team.version}`);
|
|
1101
|
-
setTimeout(() => setUpToDateMsg(""), 2500);
|
|
1102
|
-
}
|
|
1477
|
+
const v = await window.cicy.sidecar.versions(); // { running, latest, installed }
|
|
1478
|
+
setVersions({ running: v?.running ?? null, latest: v?.latest ?? null, installed: v?.installed ?? null });
|
|
1479
|
+
if (manual) {
|
|
1480
|
+
if (v?.running && v?.latest && cmpVer(v.latest, v.running) > 0) {
|
|
1481
|
+
toast.show({ message: `${tr("sidecar.found", "发现新版本")} v${v.latest}`, status: "done", ttl: 2500 });
|
|
1482
|
+
} else if (v?.running) {
|
|
1483
|
+
toast.show({ message: `${tr("sidecar.upToDate", "已是最新")} v${v.running}`, status: "done", ttl: 2500 });
|
|
1484
|
+
} else {
|
|
1485
|
+
// daemon 没在跑 / 没报版本 — 别撒谎说最新,也别误报有更新
|
|
1486
|
+
toast.show({ message: tr("sidecar.notRunning", "cicy-code 未运行"), status: "error", ttl: 4000 });
|
|
1103
1487
|
}
|
|
1104
1488
|
}
|
|
1105
|
-
} catch {
|
|
1489
|
+
} catch { if (manual) toast.show({ message: tr("sidecar.checkFailed", "检查更新失败"), status: "error", ttl: 5000 }); }
|
|
1106
1490
|
finally { if (manual) setChecking(false); }
|
|
1107
|
-
}, [local
|
|
1491
|
+
}, [local]);
|
|
1108
1492
|
|
|
1109
1493
|
useEffect(() => { checkUpdate(false); }, [checkUpdate]);
|
|
1110
|
-
|
|
1111
|
-
|
|
1494
|
+
// daemon 起来后(或重启/更新后 status 翻 running)自动重查一次版本,补上启动早期的空值。
|
|
1495
|
+
useEffect(() => { if (running) checkUpdate(false); }, [running, checkUpdate]);
|
|
1496
|
+
|
|
1497
|
+
// 只有在【确知运行版本 runningVer 且确实落后 latest】时才提示更新。runningVer 未知
|
|
1498
|
+
// (undefined/null:停了或刚启动还没读到)一律不提示——修掉"版本暂时读不到就误报更新 +
|
|
1499
|
+
// 卡片一直转/显示更新"的 bug。NOTE: 这跟"版本落后却不让更新"不冲突:落后是 runningVer
|
|
1500
|
+
// 已知且 < latest,照样提示。
|
|
1501
|
+
const updateAvailable = !!(local && latest && runningVer && cmpVer(latest, runningVer) > 0);
|
|
1112
1502
|
// Custom (deeplink-added, non-local) nodes can be removed from the desktop —
|
|
1113
1503
|
// it just drops them from cicyDesktopNodes; re-addable via deeplink. The
|
|
1114
1504
|
// local sidecar isn't deletable here. So the ⋯ menu shows for a local card
|
|
@@ -1123,7 +1513,13 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
|
|
|
1123
1513
|
|
|
1124
1514
|
useEffect(() => {
|
|
1125
1515
|
if (!menuOpen) return;
|
|
1126
|
-
|
|
1516
|
+
// The menu is portaled to document.body, so menuWrap no longer contains it —
|
|
1517
|
+
// check BOTH the kebab anchor and the portaled menu before closing.
|
|
1518
|
+
const onDoc = (e) => {
|
|
1519
|
+
if (kebabRef.current?.contains(e.target)) return;
|
|
1520
|
+
if (menuRef.current?.contains(e.target)) return;
|
|
1521
|
+
setMenuOpen(false);
|
|
1522
|
+
};
|
|
1127
1523
|
document.addEventListener("mousedown", onDoc);
|
|
1128
1524
|
return () => document.removeEventListener("mousedown", onDoc);
|
|
1129
1525
|
}, [menuOpen]);
|
|
@@ -1152,7 +1548,7 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
|
|
|
1152
1548
|
const isUpdate = kind === "update";
|
|
1153
1549
|
let unsub = null;
|
|
1154
1550
|
if (isUpdate) {
|
|
1155
|
-
updateDrawer.open({ teamId: team.id, fromVer:
|
|
1551
|
+
updateDrawer.open({ teamId: team.id, fromVer: runningVer, toVer: latest, onRetry: () => runOp("update", fn, doneText) });
|
|
1156
1552
|
if (window.cicy?.sidecar?.onOpProgress) {
|
|
1157
1553
|
unsub = window.cicy.sidecar.onOpProgress((ev) => { if (ev?.op === "update") updateDrawer.push(ev); });
|
|
1158
1554
|
}
|
|
@@ -1177,6 +1573,9 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
|
|
|
1177
1573
|
try { unsub && unsub(); } catch {}
|
|
1178
1574
|
setBusy("");
|
|
1179
1575
|
onRefresh?.(); // re-probe so the status dot/chip catches up
|
|
1576
|
+
// 重启/更新/启动后,daemon 版本可能变了(且 status 可能仍是 running、不会触发
|
|
1577
|
+
// 上面那个 effect),所以这里强制重查一次版本,卡片立刻反映真实版本。
|
|
1578
|
+
if (kind === "update" || kind === "restart" || kind === "start") checkUpdate(false);
|
|
1180
1579
|
}
|
|
1181
1580
|
};
|
|
1182
1581
|
const BUSY_LABEL = { start: "启动中…", restart: "重启中…", update: "更新中…", stop: "停止中…" };
|
|
@@ -1220,16 +1619,20 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
|
|
|
1220
1619
|
<div className="bcard__menuwrap" ref={menuWrap} onClick={(e) => e.stopPropagation()}>
|
|
1221
1620
|
<button
|
|
1222
1621
|
type="button"
|
|
1622
|
+
ref={kebabRef}
|
|
1223
1623
|
data-id="LocalTeamCard-menu-btn"
|
|
1224
1624
|
className={`bcard__kebab${updateAvailable ? " has-dot" : ""}`}
|
|
1225
1625
|
title={local ? tr("localTeams.manage", "管理本地 cicy-code") : tr("localTeams.more", "更多")}
|
|
1226
1626
|
disabled={!!busy}
|
|
1227
|
-
onClick={
|
|
1627
|
+
onClick={toggleMenu}
|
|
1228
1628
|
>
|
|
1229
1629
|
{busy ? <Spinner /> : <KebabIcon />}
|
|
1230
1630
|
</button>
|
|
1231
|
-
{menuOpen && (
|
|
1232
|
-
<div className="bcard__menu" data-id="LocalTeamCard-menu" role="menu"
|
|
1631
|
+
{menuOpen && createPortal(
|
|
1632
|
+
<div className="bcard__menu bcard__menu--portal" data-id="LocalTeamCard-menu" role="menu"
|
|
1633
|
+
ref={menuRef}
|
|
1634
|
+
style={{ position: "fixed", top: menuPos.top, left: menuPos.left, width: MENU_W }}
|
|
1635
|
+
onClick={(e) => e.stopPropagation()}>
|
|
1233
1636
|
{updateAvailable && (
|
|
1234
1637
|
<button
|
|
1235
1638
|
type="button"
|
|
@@ -1280,9 +1683,7 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
|
|
|
1280
1683
|
disabled={checking}
|
|
1281
1684
|
onClick={(e) => { e.stopPropagation(); checkUpdate(true); }}
|
|
1282
1685
|
>
|
|
1283
|
-
{checking
|
|
1284
|
-
? tr("sidecar.checking2", "检查中…")
|
|
1285
|
-
: (upToDateMsg || tr("sidecar.checkUpdate", "检查更新"))}
|
|
1686
|
+
{checking ? tr("sidecar.checking2", "检查中…") : tr("sidecar.checkUpdate", "检查更新")}
|
|
1286
1687
|
</button>
|
|
1287
1688
|
)}
|
|
1288
1689
|
{team.cloud_team_id && (
|
|
@@ -1312,7 +1713,8 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
|
|
|
1312
1713
|
{confirmDel ? tr("localTeams.removeConfirm", "确认删除?") : tr("localTeams.remove", "删除")}
|
|
1313
1714
|
</button>
|
|
1314
1715
|
)}
|
|
1315
|
-
</div
|
|
1716
|
+
</div>,
|
|
1717
|
+
document.body
|
|
1316
1718
|
)}
|
|
1317
1719
|
</div>
|
|
1318
1720
|
)}
|
|
@@ -1324,29 +1726,43 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
|
|
|
1324
1726
|
autoFocus
|
|
1325
1727
|
value={draft}
|
|
1326
1728
|
onChange={(e) => setDraft(e.target.value)}
|
|
1729
|
+
onFocus={(e) => e.target.select()}
|
|
1327
1730
|
onBlur={commit}
|
|
1328
1731
|
onClick={(e) => e.stopPropagation()}
|
|
1329
1732
|
onKeyDown={(e) => { if (e.key === "Enter") commit(); else if (e.key === "Escape") setEditing(false); }}
|
|
1330
1733
|
style={{ width: "100%", font: "inherit", fontWeight: 600, padding: "2px 6px", border: "1px solid #3b82f6", borderRadius: 6, background: "#0d1117", color: "#e6edf3", boxSizing: "border-box" }}
|
|
1331
1734
|
/>
|
|
1332
1735
|
) : (
|
|
1333
|
-
<h3 className="bcard__name" title={tr("localTeams.renameHint", "
|
|
1334
|
-
<span
|
|
1335
|
-
|
|
1336
|
-
type="button"
|
|
1337
|
-
data-id="LocalTeamCard-rename-btn"
|
|
1338
|
-
title={tr("localTeams.rename", "重命名")}
|
|
1736
|
+
<h3 className="bcard__name" title={tr("localTeams.renameHint", "点名字或 ✎ 改名")} style={{ display: "flex", alignItems: "center", gap: 6 }} onDoubleClick={startEdit}>
|
|
1737
|
+
<span
|
|
1738
|
+
data-id="LocalTeamCard-name-text"
|
|
1339
1739
|
onClick={startEdit}
|
|
1340
|
-
style={{
|
|
1341
|
-
|
|
1740
|
+
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", cursor: "text" }}
|
|
1741
|
+
>{displayName}</span>
|
|
1742
|
+
{/* 行内保存状态:保存中 spinner / 已保存 ✓ */}
|
|
1743
|
+
{saveState === "saving" && (
|
|
1744
|
+
<span data-id="LocalTeamCard-save-state" title={tr("localTeams.saving", "保存中…")} style={{ flex: "none", display: "inline-flex" }}><Spinner /></span>
|
|
1745
|
+
)}
|
|
1746
|
+
{saveState === "saved" && (
|
|
1747
|
+
<span data-id="LocalTeamCard-save-state" title={tr("localTeams.saved", "已保存")} style={{ flex: "none", color: "#3fb950", fontSize: 13, lineHeight: 1 }}>✓</span>
|
|
1748
|
+
)}
|
|
1749
|
+
{!saveState && (
|
|
1750
|
+
<button
|
|
1751
|
+
type="button"
|
|
1752
|
+
data-id="LocalTeamCard-rename-btn"
|
|
1753
|
+
title={tr("localTeams.rename", "重命名")}
|
|
1754
|
+
onClick={startEdit}
|
|
1755
|
+
style={{ flex: "none", cursor: "pointer", border: "none", background: "transparent", color: "#8b949e", fontSize: 13, padding: 0, lineHeight: 1 }}
|
|
1756
|
+
>✎</button>
|
|
1757
|
+
)}
|
|
1342
1758
|
</h3>
|
|
1343
1759
|
)}
|
|
1344
1760
|
<div className="bcard__host">
|
|
1345
1761
|
{team.base_url || "—"}
|
|
1346
1762
|
</div>
|
|
1347
1763
|
<div className="bcard__meta">
|
|
1348
|
-
{team.version && (
|
|
1349
|
-
<span className="bcard__ver" data-id="LocalTeamCard-version">v{team.version}</span>
|
|
1764
|
+
{(runningVer || team.version) && (
|
|
1765
|
+
<span className="bcard__ver" data-id="LocalTeamCard-version">v{runningVer || team.version}</span>
|
|
1350
1766
|
)}
|
|
1351
1767
|
</div>
|
|
1352
1768
|
</div>
|
|
@@ -1392,13 +1808,55 @@ const LOCAL_STATUS = {
|
|
|
1392
1808
|
error: { tone: "err", label: "error", cta: "异常" },
|
|
1393
1809
|
};
|
|
1394
1810
|
|
|
1811
|
+
// 私有云 / (历史)云端团队卡片。产品方向变更(w-10032):公有云不做了,主打 private
|
|
1812
|
+
// (用户自托管,数据不出企业)。private 字段:{name,kind:"private",status,apiKey,
|
|
1813
|
+
// gatewayUrl,host_url,titleVersion,deviceId:""}。卡片展示名字+host_url,点开可看/复制 apiKey。
|
|
1395
1814
|
function TeamCard({ team, onOpen }) {
|
|
1396
|
-
const
|
|
1815
|
+
const isPrivate = team.kind === "private";
|
|
1397
1816
|
const statusOk = team.status === "active";
|
|
1398
|
-
const
|
|
1399
|
-
const
|
|
1817
|
+
const name = team.name || team.title || "—";
|
|
1818
|
+
const hostUrl = team.host_url || "";
|
|
1819
|
+
const billTeamId = team.teamId || team.id; // /dash?team=<teamId>(URL 不带 key)
|
|
1820
|
+
const kindLabel = isPrivate ? "私有云" : (team.team_kind === "personal" ? "个人" : "共享");
|
|
1821
|
+
const openUrl = isPrivate ? hostUrl : (team.workspace_url || team.workspace_direct_url);
|
|
1822
|
+
const hasUrl = !!openUrl;
|
|
1823
|
+
|
|
1824
|
+
// ⋯ menu (reload) — mirrors LocalTeamCard so EVERY card has a 刷新窗口. Reload
|
|
1825
|
+
// re-loads the cloud team's tab in profile 0 (opens it if not yet open).
|
|
1826
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
1827
|
+
const [busy, setBusy] = useState(false);
|
|
1828
|
+
const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
|
|
1829
|
+
const menuWrap = useRef(null);
|
|
1830
|
+
const kebabRef = useRef(null);
|
|
1831
|
+
const menuRef = useRef(null);
|
|
1832
|
+
const MENU_W = 184;
|
|
1833
|
+
const toggleMenu = () => {
|
|
1834
|
+
if (!menuOpen && kebabRef.current) {
|
|
1835
|
+
const r = kebabRef.current.getBoundingClientRect();
|
|
1836
|
+
const left = Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8));
|
|
1837
|
+
setMenuPos({ top: Math.round(r.bottom + 4), left: Math.round(left) });
|
|
1838
|
+
}
|
|
1839
|
+
setMenuOpen((v) => !v);
|
|
1840
|
+
};
|
|
1841
|
+
useEffect(() => {
|
|
1842
|
+
if (!menuOpen) return;
|
|
1843
|
+
const onDoc = (e) => {
|
|
1844
|
+
if (kebabRef.current?.contains(e.target)) return;
|
|
1845
|
+
if (menuRef.current?.contains(e.target)) return;
|
|
1846
|
+
setMenuOpen(false);
|
|
1847
|
+
};
|
|
1848
|
+
document.addEventListener("mousedown", onDoc);
|
|
1849
|
+
return () => document.removeEventListener("mousedown", onDoc);
|
|
1850
|
+
}, [menuOpen]);
|
|
1851
|
+
const doReload = async () => {
|
|
1852
|
+
if (!hasUrl || busy) return;
|
|
1853
|
+
setBusy(true); setMenuOpen(false);
|
|
1854
|
+
try { await window.cicy?.tabs?.reload?.(openUrl, name); } catch {}
|
|
1855
|
+
finally { setBusy(false); }
|
|
1856
|
+
};
|
|
1857
|
+
// 主人令:私有云卡片不展示 api key(安全)。key 只在云端 dash / 注入 global.json 用。
|
|
1400
1858
|
return (
|
|
1401
|
-
<div className={`bcard bcard--cloud${statusOk ? " bcard--online" : ""}`}>
|
|
1859
|
+
<div data-id="TeamCard" className={`bcard bcard--cloud${statusOk ? " bcard--online" : ""}`}>
|
|
1402
1860
|
<div className="bcard__accent" />
|
|
1403
1861
|
<div className="bcard__top">
|
|
1404
1862
|
<div className="bcard__pill">
|
|
@@ -1418,16 +1876,42 @@ function TeamCard({ team, onOpen }) {
|
|
|
1418
1876
|
{tr("localTeams.billing", "账单")}
|
|
1419
1877
|
</button>
|
|
1420
1878
|
)}
|
|
1879
|
+
{hasUrl && (
|
|
1880
|
+
<div className="bcard__menuwrap" ref={menuWrap} onClick={(e) => e.stopPropagation()}>
|
|
1881
|
+
<button
|
|
1882
|
+
type="button"
|
|
1883
|
+
ref={kebabRef}
|
|
1884
|
+
data-id="TeamCard-menu-btn"
|
|
1885
|
+
className="bcard__kebab"
|
|
1886
|
+
title={tr("localTeams.more", "更多")}
|
|
1887
|
+
disabled={busy}
|
|
1888
|
+
onClick={toggleMenu}
|
|
1889
|
+
>
|
|
1890
|
+
{busy ? <Spinner /> : <KebabIcon />}
|
|
1891
|
+
</button>
|
|
1892
|
+
{menuOpen && createPortal(
|
|
1893
|
+
<div className="bcard__menu bcard__menu--portal" data-id="TeamCard-menu" role="menu"
|
|
1894
|
+
ref={menuRef}
|
|
1895
|
+
style={{ position: "fixed", top: menuPos.top, left: menuPos.left, width: MENU_W }}
|
|
1896
|
+
onClick={(e) => e.stopPropagation()}>
|
|
1897
|
+
<button type="button" data-id="TeamCard-reload" className="bcard__menu-item" onClick={doReload}>
|
|
1898
|
+
{tr("localTeams.reloadWindow", "刷新窗口")}
|
|
1899
|
+
</button>
|
|
1900
|
+
</div>,
|
|
1901
|
+
document.body,
|
|
1902
|
+
)}
|
|
1903
|
+
</div>
|
|
1904
|
+
)}
|
|
1421
1905
|
</div>
|
|
1422
1906
|
</div>
|
|
1423
1907
|
<div className="bcard__body">
|
|
1424
|
-
<h3 className="bcard__name" title={
|
|
1425
|
-
<div className="bcard__host">
|
|
1426
|
-
{team.runtime_region || team.region || "—"}
|
|
1908
|
+
<h3 className="bcard__name" title={name}>{name}</h3>
|
|
1909
|
+
<div className="bcard__host" title={isPrivate ? (hostUrl || "") : ""}>
|
|
1910
|
+
{isPrivate ? (hostUrl || tr("teamCard.noHost", "未填访问地址")) : (team.runtime_region || team.region || "—")}
|
|
1427
1911
|
</div>
|
|
1428
1912
|
<div className="bcard__meta">
|
|
1429
1913
|
<span className="bcard__chip">{kindLabel}</span>
|
|
1430
|
-
{team.membership_status && team.membership_status !== "active" && (
|
|
1914
|
+
{!isPrivate && team.membership_status && team.membership_status !== "active" && (
|
|
1431
1915
|
<span className="bcard__chip">{team.membership_status}</span>
|
|
1432
1916
|
)}
|
|
1433
1917
|
</div>
|
|
@@ -1439,7 +1923,7 @@ function TeamCard({ team, onOpen }) {
|
|
|
1439
1923
|
disabled={!hasUrl}
|
|
1440
1924
|
>
|
|
1441
1925
|
<ArrowIcon />
|
|
1442
|
-
<span>{hasUrl ? "打开" : "无 URL"}</span>
|
|
1926
|
+
<span>{hasUrl ? tr("localTeams.open", "打开") : (isPrivate ? tr("teamCard.noHost", "未填访问地址") : tr("teamCard.noUrl", "无 URL"))}</span>
|
|
1443
1927
|
</button>
|
|
1444
1928
|
</div>
|
|
1445
1929
|
);
|