cicy-desktop 2.1.77 → 2.1.78
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/README.md +21 -18
- package/build/icon.icns +0 -0
- package/build/icon.ico +0 -0
- package/build/icon.png +0 -0
- package/build/icon.svg +8 -18
- package/build/icons/icon-1024.png +0 -0
- package/build/icons/icon-128.png +0 -0
- package/build/icons/icon-16.png +0 -0
- package/build/icons/icon-24.png +0 -0
- package/build/icons/icon-256.png +0 -0
- package/build/icons/icon-32.png +0 -0
- package/build/icons/icon-48.png +0 -0
- package/build/icons/icon-512.png +0 -0
- package/build/icons/icon-64.png +0 -0
- package/build/icons/icon-96.png +0 -0
- package/build/icons/trayTemplate-16.png +0 -0
- package/build/icons/trayTemplate-16@2x.png +0 -0
- package/build/icons/trayTemplate-22.png +0 -0
- package/build/icons/trayTemplate-22@2x.png +0 -0
- package/build/icons/trayTemplate-32.png +0 -0
- package/build/icons/trayTemplate-32@2x.png +0 -0
- package/build/trayTemplate.png +0 -0
- package/build/trayTemplate.svg +11 -12
- package/build/trayTemplate@2x.png +0 -0
- package/package.json +6 -6
- package/src/backends/auth-loopback.js +17 -6
- package/src/backends/homepage-react/assets/index-DE9m6JTn.css +1 -0
- package/src/backends/homepage-react/assets/index-DLYMzgf5.js +365 -0
- package/src/backends/homepage-react/favicon-256.png +0 -0
- package/src/backends/homepage-react/favicon.svg +12 -0
- package/src/backends/homepage-react/index.html +4 -2
- package/src/backends/local-teams.js +53 -1
- package/src/backends/login-success.html +96 -0
- package/src/cloud/cloud-client.js +239 -0
- package/src/main.js +62 -1
- package/src/utils/brand-host-electron.js +134 -0
- package/src/utils/window-utils.js +62 -14
- package/workers/render/index.html +2 -0
- package/workers/render/public/favicon-256.png +0 -0
- package/workers/render/public/favicon.svg +12 -0
- package/workers/render/src/App.css +127 -31
- package/workers/render/src/App.jsx +170 -24
- package/src/backends/homepage-react/assets/index-CPH-S8uU.css +0 -1
- package/src/backends/homepage-react/assets/index-DuWX0iug.js +0 -365
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" fill="none">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="cicyMark" x1="16" y1="12" x2="80" y2="84" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop stop-color="#60A5FA"/>
|
|
5
|
+
<stop offset="0.55" stop-color="#2563EB"/>
|
|
6
|
+
<stop offset="1" stop-color="#1E3A8A"/>
|
|
7
|
+
</linearGradient>
|
|
8
|
+
</defs>
|
|
9
|
+
<path d="M48 11L39.5 33.3L16 29.5L31 48L16 66.5L39.5 62.7L48 85L56.5 62.7L80 66.5L65 48L80 29.5L56.5 33.3Z"
|
|
10
|
+
fill="url(#cicyMark)" stroke="url(#cicyMark)" stroke-width="8"
|
|
11
|
+
stroke-linejoin="round" stroke-linecap="round"/>
|
|
12
|
+
</svg>
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
|
7
|
+
<link rel="icon" type="image/png" sizes="256x256" href="./favicon-256.png" />
|
|
6
8
|
<title>CiCy Desktop</title>
|
|
7
|
-
<script type="module" crossorigin src="./assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
9
|
+
<script type="module" crossorigin src="./assets/index-DLYMzgf5.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="./assets/index-DE9m6JTn.css">
|
|
9
11
|
</head>
|
|
10
12
|
<body>
|
|
11
13
|
<div id="root"></div>
|
|
@@ -193,6 +193,10 @@ async function list({ refresh = false } = {}) {
|
|
|
193
193
|
name: node.name || slug,
|
|
194
194
|
base_url: baseUrl,
|
|
195
195
|
api_token: node.api_token || "",
|
|
196
|
+
// Cloud-issued teamId (from name-sync register). The renderer maps it to
|
|
197
|
+
// the team's sk-cicy- gateway apiKey (via /api/teams) for the 账单 link —
|
|
198
|
+
// the local api_token is an MCP token the cloud can't bill on.
|
|
199
|
+
cloud_team_id: node.cloud_team_id || null,
|
|
196
200
|
port,
|
|
197
201
|
install_source: node.install_source || null,
|
|
198
202
|
install_os: node.install_os || null,
|
|
@@ -392,6 +396,51 @@ function normaliseUrl(u) {
|
|
|
392
396
|
} catch { return ""; }
|
|
393
397
|
}
|
|
394
398
|
|
|
399
|
+
// Sync THIS device's local team title to the cloud (desktop→cloud, one-way;
|
|
400
|
+
// 主人 + w-10032 spec). Best-effort: a cloud failure NEVER blocks the local
|
|
401
|
+
// create/rename. Persists the cloud-assigned teamId so later renames UPDATE the
|
|
402
|
+
// same row (POST /api/team/register with teamId) instead of creating dupes.
|
|
403
|
+
// Only local-origin teams sync — a custom remote team isn't "this device's
|
|
404
|
+
// local team". No-op when logged out.
|
|
405
|
+
async function syncNameToCloud(id) {
|
|
406
|
+
let cc;
|
|
407
|
+
try { cc = require("../cloud/cloud-client"); } catch { return; }
|
|
408
|
+
try {
|
|
409
|
+
if (!cc.loginToken || !cc.loginToken()) return; // not logged in
|
|
410
|
+
const node = readNodes()[id];
|
|
411
|
+
if (!node || !isLocalOrigin(node.base_url || "")) return;
|
|
412
|
+
const reg = await cc.registerTeam({ teamId: node.cloud_team_id || null, title: node.name || "" });
|
|
413
|
+
if (reg && reg.ok && reg.teamId && reg.teamId !== node.cloud_team_id) {
|
|
414
|
+
await writeNodes((nodes) => {
|
|
415
|
+
if (nodes[id]) nodes[id].cloud_team_id = reg.teamId;
|
|
416
|
+
return nodes;
|
|
417
|
+
});
|
|
418
|
+
log.info(`[local-teams] cloud name-sync ${id} → teamId=${reg.teamId}`);
|
|
419
|
+
} else if (reg && reg.ok) {
|
|
420
|
+
log.info(`[local-teams] cloud name-sync ${id} title updated (teamId=${node.cloud_team_id})`);
|
|
421
|
+
}
|
|
422
|
+
} catch (e) { log.warn(`[local-teams] cloud name-sync ${id} failed: ${e.message}`); }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Sync EVERY existing local-origin team to cloud. Runs once at startup (after
|
|
426
|
+
// login) so teams that were created BEFORE the cloud-client existed — or that
|
|
427
|
+
// live on a freshly-deployed machine (e.g. a Windows box whose 本地团队 predates
|
|
428
|
+
// this code) — register to cloud without needing a manual rename to trigger it.
|
|
429
|
+
// create/rename still sync individually; this is the catch-up for the rest.
|
|
430
|
+
// Best-effort, fully non-blocking, no-op when logged out.
|
|
431
|
+
async function syncAllLocalTeams() {
|
|
432
|
+
try {
|
|
433
|
+
const cc = require("../cloud/cloud-client");
|
|
434
|
+
if (!cc.loginToken || !cc.loginToken()) return; // logged out → no-op
|
|
435
|
+
const nodes = readNodes();
|
|
436
|
+
const ids = Object.keys(nodes).filter((id) => isLocalOrigin(nodes[id]?.base_url || ""));
|
|
437
|
+
for (const id of ids) {
|
|
438
|
+
try { await syncNameToCloud(id); } catch {}
|
|
439
|
+
}
|
|
440
|
+
if (ids.length) log.info(`[local-teams] startup cloud-sync of ${ids.length} local team(s)`);
|
|
441
|
+
} catch (e) { log.warn(`[local-teams] startup cloud-sync failed: ${e.message}`); }
|
|
442
|
+
}
|
|
443
|
+
|
|
395
444
|
async function addTeam(spec) {
|
|
396
445
|
if (!spec || typeof spec !== "object") return { ok: false, error: "spec required" };
|
|
397
446
|
const baseUrlRaw = String(spec.base_url || "").trim();
|
|
@@ -474,6 +523,7 @@ async function addTeam(spec) {
|
|
|
474
523
|
});
|
|
475
524
|
log.info(`[local-teams] ${existingId ? "upsert" : "add"} ${id} → ${baseUrl} (source=${patch.install_source || "n/a"})`);
|
|
476
525
|
const next = readNodes()[id] || {};
|
|
526
|
+
syncNameToCloud(id).catch(() => {}); // best-effort title sync (desktop→cloud)
|
|
477
527
|
return { ok: true, id, upserted: !!existingId, team: { id, ...next, port } };
|
|
478
528
|
}
|
|
479
529
|
|
|
@@ -527,6 +577,8 @@ async function updateTeam(id, patch) {
|
|
|
527
577
|
});
|
|
528
578
|
if (!existed) return { ok: false, error: "team not found" };
|
|
529
579
|
log.info(`[local-teams] update ${id} → ${Object.keys(filtered).join(",")}`);
|
|
580
|
+
// Rename → push the new title to the cloud (best-effort, one-way).
|
|
581
|
+
if (filtered.name !== undefined) syncNameToCloud(id).catch(() => {});
|
|
530
582
|
const next = readNodes()[id] || {};
|
|
531
583
|
let port = null;
|
|
532
584
|
try { port = parseInt(new URL(next.base_url || "").port, 10) || null; } catch {}
|
|
@@ -795,4 +847,4 @@ async function upgradeTeam(id) {
|
|
|
795
847
|
return result;
|
|
796
848
|
}
|
|
797
849
|
|
|
798
|
-
module.exports = { list, openTeam, reloadTeam, closeLocalWindows, addTeam, removeTeam, updateTeam, upgradeTeam };
|
|
850
|
+
module.exports = { list, openTeam, reloadTeam, closeLocalWindows, addTeam, removeTeam, updateTeam, upgradeTeam, syncAllLocalTeams };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>登录成功 · CiCy</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root{
|
|
9
|
+
--bg-0:#0a0a0f; --bg-1:#13131b; --line:#23232e;
|
|
10
|
+
--txt:#e8e8f0; --dim:#9a9ab0;
|
|
11
|
+
--violet:#7c5cff; --cyan:#22d3ee;
|
|
12
|
+
--grad:linear-gradient(120deg,#7c5cff,#22d3ee);
|
|
13
|
+
}
|
|
14
|
+
*{box-sizing:border-box}
|
|
15
|
+
html,body{height:100%}
|
|
16
|
+
body{
|
|
17
|
+
margin:0; background:var(--bg-0); color:var(--txt);
|
|
18
|
+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
|
|
19
|
+
display:grid; place-items:center; overflow:hidden; position:relative;
|
|
20
|
+
}
|
|
21
|
+
/* ambient glow */
|
|
22
|
+
body::before,body::after{
|
|
23
|
+
content:""; position:absolute; border-radius:50%; filter:blur(90px); opacity:.5; pointer-events:none;
|
|
24
|
+
}
|
|
25
|
+
body::before{width:480px;height:480px;background:radial-gradient(circle,#7c5cff55,transparent 70%);top:-140px;left:-120px}
|
|
26
|
+
body::after{width:520px;height:520px;background:radial-gradient(circle,#22d3ee44,transparent 70%);bottom:-180px;right:-140px}
|
|
27
|
+
|
|
28
|
+
.card{
|
|
29
|
+
position:relative; z-index:1; width:min(92vw,440px);
|
|
30
|
+
background:rgba(19,19,27,.7); backdrop-filter:blur(12px);
|
|
31
|
+
border:1px solid var(--line); border-radius:22px;
|
|
32
|
+
padding:46px 38px 34px; text-align:center;
|
|
33
|
+
box-shadow:0 24px 80px -24px #000;
|
|
34
|
+
}
|
|
35
|
+
.brand{display:flex;align-items:center;justify-content:center;gap:9px;margin-bottom:30px}
|
|
36
|
+
.brand-mark{width:30px;height:30px;border-radius:9px;background:var(--grad);display:grid;place-items:center;box-shadow:0 6px 20px -4px #7c5cff88}
|
|
37
|
+
.brand-name{font-weight:700;font-size:17px;letter-spacing:.3px}
|
|
38
|
+
|
|
39
|
+
.check{
|
|
40
|
+
width:84px;height:84px;margin:0 auto 22px;border-radius:50%;
|
|
41
|
+
display:grid;place-items:center;
|
|
42
|
+
background:radial-gradient(circle at 50% 38%, #7c5cff33, transparent 70%);
|
|
43
|
+
position:relative;
|
|
44
|
+
}
|
|
45
|
+
.check svg{width:84px;height:84px;display:block}
|
|
46
|
+
.check .ring{stroke:url(#g);stroke-width:3;fill:none;stroke-linecap:round;
|
|
47
|
+
stroke-dasharray:251;stroke-dashoffset:251;animation:draw .7s ease forwards}
|
|
48
|
+
.check .tick{stroke:#fff;stroke-width:4.5;fill:none;stroke-linecap:round;stroke-linejoin:round;
|
|
49
|
+
stroke-dasharray:48;stroke-dashoffset:48;animation:draw .45s .55s ease forwards}
|
|
50
|
+
@keyframes draw{to{stroke-dashoffset:0}}
|
|
51
|
+
@keyframes pop{0%{transform:scale(.7);opacity:0}60%{transform:scale(1.05)}100%{transform:scale(1);opacity:1}}
|
|
52
|
+
.check{animation:pop .5s ease both}
|
|
53
|
+
|
|
54
|
+
h1{font-size:23px;margin:0 0 10px;font-weight:700}
|
|
55
|
+
.grad-txt{background:var(--grad);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
|
|
56
|
+
p.sub{color:var(--dim);font-size:14.5px;line-height:1.7;margin:0 0 26px}
|
|
57
|
+
p.sub b{color:var(--txt);font-weight:600}
|
|
58
|
+
|
|
59
|
+
.hint{
|
|
60
|
+
display:inline-flex;align-items:center;gap:7px;
|
|
61
|
+
font-size:12.5px;color:var(--dim);
|
|
62
|
+
border:1px solid var(--line);border-radius:999px;padding:7px 14px;
|
|
63
|
+
}
|
|
64
|
+
.dot{width:7px;height:7px;border-radius:50%;background:var(--grad);box-shadow:0 0 10px #22d3ee}
|
|
65
|
+
|
|
66
|
+
.foot{margin-top:26px;font-size:12px;color:#5b5b6e}
|
|
67
|
+
.foot a{color:#8a8aa0;text-decoration:none}
|
|
68
|
+
.foot a:hover{color:var(--txt)}
|
|
69
|
+
</style>
|
|
70
|
+
</head>
|
|
71
|
+
<body>
|
|
72
|
+
<div class="card">
|
|
73
|
+
<div class="brand">
|
|
74
|
+
<div class="brand-mark">
|
|
75
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#0a0a0f" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3.5-7.1"/><path d="M21 3v6h-6"/></svg>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="brand-name">CiCy</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div class="check">
|
|
81
|
+
<svg viewBox="0 0 90 90">
|
|
82
|
+
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7c5cff"/><stop offset="1" stop-color="#22d3ee"/></linearGradient></defs>
|
|
83
|
+
<circle class="ring" cx="45" cy="45" r="40"/>
|
|
84
|
+
<path class="tick" d="M28 46 L40 58 L62 33"/>
|
|
85
|
+
</svg>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<h1>登录<span class="grad-txt">成功</span></h1>
|
|
89
|
+
<p class="sub">请回到 <b>CiCy Desktop</b> 继续使用。<br>此页面可以安全关闭。</p>
|
|
90
|
+
|
|
91
|
+
<span class="hint"><span class="dot"></span> 已安全连接到你的 CiCy 账户</span>
|
|
92
|
+
|
|
93
|
+
<div class="foot">仍停留在此页?可手动切回 CiCy Desktop · <a href="/dash">打开网页版团队台</a></div>
|
|
94
|
+
</div>
|
|
95
|
+
</body>
|
|
96
|
+
</html>
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Cloud ⇄ cicy-desktop client (device / team / teams).
|
|
2
|
+
//
|
|
3
|
+
// Implements the contract agreed with the cloud side (w-10032), documented at
|
|
4
|
+
// cicy-ai/cloud-device-team-api.md:
|
|
5
|
+
// ① POST /api/device/register — on app launch, report this machine
|
|
6
|
+
// ② POST /api/team/register — before a local team starts, get its gateway key
|
|
7
|
+
// ③ GET /api/teams — list the user's cloud + local teams
|
|
8
|
+
//
|
|
9
|
+
// Data model (主人定): user → many devices (win/mac distinguished by a stable
|
|
10
|
+
// per-machine deviceId) → many local teams per device → one gateway key per team.
|
|
11
|
+
//
|
|
12
|
+
// Auth: every call carries `Authorization: Bearer <desktop login token>` — the
|
|
13
|
+
// magic-link token persisted in global.json's `desktopAuth.token`. The cloud
|
|
14
|
+
// resolves owner (= login email) from it. When the user is NOT logged in, every
|
|
15
|
+
// entry point here is a no-op that returns { ok:false, reason:"not_logged_in" }
|
|
16
|
+
// so callers can stay oblivious.
|
|
17
|
+
//
|
|
18
|
+
// All HTTP runs in the Electron MAIN process where global `fetch` has
|
|
19
|
+
// unrestricted network access (no CORS, unlike the file:// renderer).
|
|
20
|
+
|
|
21
|
+
const os = require("os");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
const crypto = require("crypto");
|
|
24
|
+
const log = require("electron-log");
|
|
25
|
+
const { readGlobalConfig, updateGlobalConfig } = require("../utils/global-json");
|
|
26
|
+
|
|
27
|
+
const CLOUD_BASE = process.env.CICY_CLOUD_BASE || "https://cicy-ai.com";
|
|
28
|
+
const GATEWAY_URL = process.env.CICY_GATEWAY_URL || "https://gateway.cicy-ai.com";
|
|
29
|
+
const GLOBAL_JSON = path.join(os.homedir(), "cicy-ai", "global.json");
|
|
30
|
+
|
|
31
|
+
// The two provider slots the gateway key must land in, per the contract.
|
|
32
|
+
const GATEWAY_PROVIDER_KEYS = {
|
|
33
|
+
defaultAnthropic: "anthropic",
|
|
34
|
+
defaultOpenAi: "openai",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ── token / identity ────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
// The desktop login (magic-link) bearer token, or "" when not logged in.
|
|
40
|
+
function loginToken() {
|
|
41
|
+
try {
|
|
42
|
+
const c = readGlobalConfig(GLOBAL_JSON);
|
|
43
|
+
return (c && c.desktopAuth && c.desktopAuth.token) || "";
|
|
44
|
+
} catch (e) {
|
|
45
|
+
log.warn(`[cloud] read login token failed: ${e.message}`);
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Stable per-machine UUID. Generated once and persisted in global.json so the
|
|
51
|
+
// SAME machine keeps one identity across restarts; a win box and a mac box each
|
|
52
|
+
// get their own (the `platform` field reported alongside makes that explicit).
|
|
53
|
+
function getDeviceId() {
|
|
54
|
+
const c = readGlobalConfig(GLOBAL_JSON);
|
|
55
|
+
if (c && typeof c.deviceId === "string" && c.deviceId) return c.deviceId;
|
|
56
|
+
const id = crypto.randomUUID();
|
|
57
|
+
updateGlobalConfig(GLOBAL_JSON, (cfg) => {
|
|
58
|
+
if (!cfg.deviceId) cfg.deviceId = id;
|
|
59
|
+
return cfg;
|
|
60
|
+
});
|
|
61
|
+
// Re-read in case a concurrent writer won the lock with a different id.
|
|
62
|
+
return readGlobalConfig(GLOBAL_JSON).deviceId || id;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Best-effort public IP. Optional in the contract (cloud falls back to the peer
|
|
66
|
+
// IP), so a failure here is non-fatal — we just send no publicIp.
|
|
67
|
+
async function getPublicIp({ timeoutMs = 4000 } = {}) {
|
|
68
|
+
const services = [
|
|
69
|
+
"https://api.ipify.org?format=json", // { ip }
|
|
70
|
+
"https://ipinfo.io/json", // { ip, ... }
|
|
71
|
+
];
|
|
72
|
+
for (const url of services) {
|
|
73
|
+
try {
|
|
74
|
+
const ctrl = new AbortController();
|
|
75
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
76
|
+
const r = await fetch(url, { signal: ctrl.signal, cache: "no-store" });
|
|
77
|
+
clearTimeout(t);
|
|
78
|
+
if (!r.ok) continue;
|
|
79
|
+
const j = await r.json();
|
|
80
|
+
if (j && typeof j.ip === "string" && j.ip) return j.ip;
|
|
81
|
+
} catch (_) {
|
|
82
|
+
/* try next */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── HTTP helper ─────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
async function cloudFetch(endpoint, { method = "GET", body = null } = {}) {
|
|
91
|
+
const token = loginToken();
|
|
92
|
+
if (!token) return { ok: false, status: 0, reason: "not_logged_in" };
|
|
93
|
+
const url = `${CLOUD_BASE}${endpoint}`;
|
|
94
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
95
|
+
if (body != null) headers["Content-Type"] = "application/json";
|
|
96
|
+
try {
|
|
97
|
+
const r = await fetch(url, {
|
|
98
|
+
method,
|
|
99
|
+
headers,
|
|
100
|
+
body: body != null ? JSON.stringify(body) : null,
|
|
101
|
+
cache: "no-store",
|
|
102
|
+
});
|
|
103
|
+
const text = await r.text();
|
|
104
|
+
let json = null;
|
|
105
|
+
try {
|
|
106
|
+
json = text ? JSON.parse(text) : null;
|
|
107
|
+
} catch (_) {
|
|
108
|
+
/* non-JSON body */
|
|
109
|
+
}
|
|
110
|
+
return { ok: r.ok, status: r.status, json, text };
|
|
111
|
+
} catch (e) {
|
|
112
|
+
log.warn(`[cloud] ${method} ${endpoint} failed: ${e.message}`);
|
|
113
|
+
return { ok: false, status: 0, reason: "network_error", error: e.message };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── ① device/register ───────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
async function registerDevice() {
|
|
120
|
+
const token = loginToken();
|
|
121
|
+
if (!token) return { ok: false, reason: "not_logged_in" };
|
|
122
|
+
const deviceId = getDeviceId();
|
|
123
|
+
const publicIp = await getPublicIp();
|
|
124
|
+
const body = {
|
|
125
|
+
deviceId,
|
|
126
|
+
platform: process.platform, // "win32" | "darwin" | "linux"
|
|
127
|
+
arch: process.arch, // "x64" | "arm64"
|
|
128
|
+
};
|
|
129
|
+
if (publicIp) body.publicIp = publicIp;
|
|
130
|
+
const res = await cloudFetch("/api/device/register", { method: "POST", body });
|
|
131
|
+
if (res.ok) {
|
|
132
|
+
log.info(`[cloud] device registered deviceId=${deviceId} platform=${body.platform}/${body.arch}`);
|
|
133
|
+
} else {
|
|
134
|
+
log.warn(`[cloud] device register failed status=${res.status} reason=${res.reason || ""}`);
|
|
135
|
+
}
|
|
136
|
+
return { ...res, deviceId };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── ② team/register ─────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
// Register (or idempotently re-fetch) a local team's gateway key.
|
|
142
|
+
// teamId omitted → cloud creates a new team + key.
|
|
143
|
+
// teamId given → cloud returns that team's existing key (no rotation).
|
|
144
|
+
// Returns { ok, teamId, apiKey, gatewayUrl } on success.
|
|
145
|
+
async function registerTeam({ teamId = null, title = "" } = {}) {
|
|
146
|
+
const token = loginToken();
|
|
147
|
+
if (!token) return { ok: false, reason: "not_logged_in" };
|
|
148
|
+
const deviceId = getDeviceId();
|
|
149
|
+
const body = { deviceId };
|
|
150
|
+
if (teamId != null) body.teamId = teamId;
|
|
151
|
+
if (title) body.title = title;
|
|
152
|
+
const res = await cloudFetch("/api/team/register", { method: "POST", body });
|
|
153
|
+
if (res.ok && res.json) {
|
|
154
|
+
return {
|
|
155
|
+
ok: true,
|
|
156
|
+
teamId: res.json.teamId,
|
|
157
|
+
apiKey: res.json.apiKey,
|
|
158
|
+
gatewayUrl: res.json.gatewayUrl || GATEWAY_URL,
|
|
159
|
+
protocols: res.json.protocols || ["anthropic", "openai"],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
log.warn(`[cloud] team register failed status=${res.status} reason=${res.reason || ""}`);
|
|
163
|
+
return { ok: false, status: res.status, reason: res.reason, json: res.json };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── ③ teams ─────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
async function listTeams({ deviceId = null, kind = null } = {}) {
|
|
169
|
+
const token = loginToken();
|
|
170
|
+
if (!token) return { ok: false, reason: "not_logged_in", teams: [] };
|
|
171
|
+
const qs = [];
|
|
172
|
+
if (deviceId) qs.push(`deviceId=${encodeURIComponent(deviceId)}`);
|
|
173
|
+
if (kind) qs.push(`kind=${encodeURIComponent(kind)}`);
|
|
174
|
+
const ep = `/api/teams${qs.length ? `?${qs.join("&")}` : ""}`;
|
|
175
|
+
const res = await cloudFetch(ep, { method: "GET" });
|
|
176
|
+
if (res.ok && res.json && Array.isArray(res.json.teams)) {
|
|
177
|
+
return { ok: true, teams: res.json.teams };
|
|
178
|
+
}
|
|
179
|
+
log.warn(`[cloud] teams list failed status=${res.status} reason=${res.reason || ""}`);
|
|
180
|
+
return { ok: false, status: res.status, reason: res.reason, teams: [] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── gateway-key injection ─────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
// Write the per-team gateway apiKey + url into the team's global.json
|
|
186
|
+
// providers.items entries keyed defaultAnthropic / defaultOpenAi. Existing
|
|
187
|
+
// entries are updated in place (preserving model lists etc.); missing ones are
|
|
188
|
+
// created minimally. `globalJsonPath` defaults to the user-global config, which
|
|
189
|
+
// is also the local team's config home on this machine.
|
|
190
|
+
function injectGatewayKey(apiKey, gatewayUrl = GATEWAY_URL, globalJsonPath = GLOBAL_JSON) {
|
|
191
|
+
if (!apiKey) throw new Error("injectGatewayKey: apiKey required");
|
|
192
|
+
return updateGlobalConfig(globalJsonPath, (cfg) => {
|
|
193
|
+
if (!cfg.providers || typeof cfg.providers !== "object") cfg.providers = {};
|
|
194
|
+
if (!Array.isArray(cfg.providers.items)) cfg.providers.items = [];
|
|
195
|
+
const items = cfg.providers.items;
|
|
196
|
+
for (const [key, protocol] of Object.entries(GATEWAY_PROVIDER_KEYS)) {
|
|
197
|
+
let item = items.find((it) => it && it.key === key);
|
|
198
|
+
if (!item) {
|
|
199
|
+
item = { key, protocol, name: "CiCyAi", url: gatewayUrl, apiKey };
|
|
200
|
+
items.push(item);
|
|
201
|
+
} else {
|
|
202
|
+
item.apiKey = apiKey;
|
|
203
|
+
item.url = gatewayUrl;
|
|
204
|
+
if (!item.protocol) item.protocol = protocol;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return cfg;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Convenience: register a team and immediately wire its key into the local
|
|
212
|
+
// team's global.json. `teamId` persists across calls so re-runs are idempotent
|
|
213
|
+
// (no key rotation). Caller supplies a getter/setter for where teamId lives.
|
|
214
|
+
async function registerTeamAndInjectKey({ teamId = null, title = "", globalJsonPath = GLOBAL_JSON } = {}) {
|
|
215
|
+
const reg = await registerTeam({ teamId, title });
|
|
216
|
+
if (!reg.ok) return reg;
|
|
217
|
+
try {
|
|
218
|
+
injectGatewayKey(reg.apiKey, reg.gatewayUrl, globalJsonPath);
|
|
219
|
+
log.info(`[cloud] gateway key injected into ${globalJsonPath} (teamId=${reg.teamId})`);
|
|
220
|
+
} catch (e) {
|
|
221
|
+
log.warn(`[cloud] key injection failed: ${e.message}`);
|
|
222
|
+
return { ...reg, injected: false, injectError: e.message };
|
|
223
|
+
}
|
|
224
|
+
return { ...reg, injected: true };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
CLOUD_BASE,
|
|
229
|
+
GATEWAY_URL,
|
|
230
|
+
GLOBAL_JSON,
|
|
231
|
+
loginToken,
|
|
232
|
+
getDeviceId,
|
|
233
|
+
getPublicIp,
|
|
234
|
+
registerDevice,
|
|
235
|
+
registerTeam,
|
|
236
|
+
listTeams,
|
|
237
|
+
injectGatewayKey,
|
|
238
|
+
registerTeamAndInjectKey,
|
|
239
|
+
};
|
package/src/main.js
CHANGED
|
@@ -11,6 +11,7 @@ const { openWindowForBackend } = require("./backends/window-manager");
|
|
|
11
11
|
const { Menu } = require("electron");
|
|
12
12
|
const { dialog } = require("electron");
|
|
13
13
|
const { setupAppIcons } = require("./tray");
|
|
14
|
+
const { brandHostElectron } = require("./utils/brand-host-electron");
|
|
14
15
|
const appUpdater = require("./app-updater");
|
|
15
16
|
|
|
16
17
|
// 🎯 添加右键上下文菜单
|
|
@@ -604,6 +605,19 @@ function ensureAutoLaunch() {
|
|
|
604
605
|
function ensureMacLoginItem(want) {
|
|
605
606
|
const name = "CiCy Desktop";
|
|
606
607
|
const appletPath = path.join(os.homedir(), "Desktop", "CiCy Desktop.app");
|
|
608
|
+
// Run the osascript ONLY when the desired state differs from what we last
|
|
609
|
+
// applied. Each `tell application "System Events"` triggers macOS's
|
|
610
|
+
// Automation-consent dialog, and because cicy-desktop is unpackaged/unsigned
|
|
611
|
+
// it has no stable TCC identity — so the grant can't be remembered and the
|
|
612
|
+
// dialog re-pops on EVERY launch. Persisting our own flag means we only touch
|
|
613
|
+
// System Events on first configuration (or an actual on↔off change), so the
|
|
614
|
+
// prompt appears at most once instead of every startup.
|
|
615
|
+
const prefs = readPrefs();
|
|
616
|
+
const state = want ? "on" : "off";
|
|
617
|
+
if (prefs.macLoginItem === state) {
|
|
618
|
+
log.info(`[autostart] mac login item already ${state} — skip (no re-prompt)`);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
607
621
|
try {
|
|
608
622
|
const { execFileSync } = require("child_process");
|
|
609
623
|
const osa = (script) => execFileSync("osascript", ["-e", script], { stdio: "ignore" });
|
|
@@ -614,6 +628,10 @@ function ensureMacLoginItem(want) {
|
|
|
614
628
|
} else {
|
|
615
629
|
log.info(`[autostart] mac login item ${want ? "skipped (applet missing)" : "removed"}`);
|
|
616
630
|
}
|
|
631
|
+
// Only persist the flag once the applet actually exists (so a first launch
|
|
632
|
+
// that races applet creation re-tries next time instead of latching "on"
|
|
633
|
+
// without ever registering).
|
|
634
|
+
if (!want || fs.existsSync(appletPath)) writePrefs({ ...prefs, macLoginItem: state });
|
|
617
635
|
} catch (e) {
|
|
618
636
|
log.warn(`[autostart] mac login item failed: ${e.message}`);
|
|
619
637
|
}
|
|
@@ -627,6 +645,15 @@ function readPrefs() {
|
|
|
627
645
|
return {};
|
|
628
646
|
}
|
|
629
647
|
|
|
648
|
+
function writePrefs(prefs) {
|
|
649
|
+
try {
|
|
650
|
+
const p = path.join(electronApp.getPath("userData"), "prefs.json");
|
|
651
|
+
fs.writeFileSync(p, JSON.stringify(prefs, null, 2), "utf8");
|
|
652
|
+
} catch (e) {
|
|
653
|
+
log.warn(`[prefs] write failed: ${e.message}`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
630
657
|
function ensureLinuxAutostart(want) {
|
|
631
658
|
const dir = path.join(os.homedir(), ".config", "autostart");
|
|
632
659
|
const file = path.join(dir, "cicy-desktop.desktop");
|
|
@@ -704,6 +731,10 @@ electronApp.whenReady().then(async () => {
|
|
|
704
731
|
log.info(`[i18n] locale = ${i18n.i18next.language} (raw: ${realLocale})`);
|
|
705
732
|
} catch (e) { log.warn(`[i18n] ready-time relocale failed: ${e.message}`); }
|
|
706
733
|
|
|
734
|
+
// Rebrand the host (unpackaged) Electron bundle → "CiCy Desktop" name + icon.
|
|
735
|
+
// May relaunch once on first run to apply the menu-bar name; everything after
|
|
736
|
+
// here is skipped on that one relaunch path, so keep it first.
|
|
737
|
+
brandHostElectron();
|
|
707
738
|
setupAppIcons();
|
|
708
739
|
ensureDesktopLauncher();
|
|
709
740
|
ensureAutoLaunch();
|
|
@@ -762,7 +793,21 @@ electronApp.whenReady().then(async () => {
|
|
|
762
793
|
try {
|
|
763
794
|
await auth.startLogin({
|
|
764
795
|
onResult: (payload) => {
|
|
765
|
-
if (payload && payload.token)
|
|
796
|
+
if (payload && payload.token) {
|
|
797
|
+
saveDesktopAuth(payload);
|
|
798
|
+
// Now that we have a real owner-bound login token, report this
|
|
799
|
+
// machine to the cloud (best-effort; safe to call repeatedly —
|
|
800
|
+
// cloud upserts by (owner, deviceId)).
|
|
801
|
+
try {
|
|
802
|
+
require("./cloud/cloud-client")
|
|
803
|
+
.registerDevice()
|
|
804
|
+
.catch((e) => log.warn(`[cloud] device register (on login) failed: ${e.message}`));
|
|
805
|
+
// Fresh login → also register this device's local team(s).
|
|
806
|
+
require("./backends/local-teams")
|
|
807
|
+
.syncAllLocalTeams()
|
|
808
|
+
.catch((e) => log.warn(`[cloud] local-team sync (on login) failed: ${e.message}`));
|
|
809
|
+
} catch (e) { log.warn(`[cloud] device register hook failed: ${e.message}`); }
|
|
810
|
+
}
|
|
766
811
|
const hw = require("./backends/homepage-window");
|
|
767
812
|
const w = hw.getHomepageWindow && hw.getHomepageWindow();
|
|
768
813
|
if (w && !w.isDestroyed()) {
|
|
@@ -829,6 +874,22 @@ electronApp.whenReady().then(async () => {
|
|
|
829
874
|
});
|
|
830
875
|
}
|
|
831
876
|
|
|
877
|
+
// Cloud device registration — if the user is already logged in, report this
|
|
878
|
+
// machine (stable deviceId + platform/arch/publicIp) to the cloud on launch.
|
|
879
|
+
// Best-effort and fully non-blocking; a no-op when not logged in (the login
|
|
880
|
+
// onResult hook above covers the log-in-later case).
|
|
881
|
+
try {
|
|
882
|
+
require("./cloud/cloud-client")
|
|
883
|
+
.registerDevice()
|
|
884
|
+
.catch((e) => log.warn(`[cloud] device register (on launch) failed: ${e.message}`));
|
|
885
|
+
// Catch-up sync of this device's local team(s) → cloud, so a box whose 本地
|
|
886
|
+
// team predates the cloud-client (e.g. a freshly-deployed Windows install)
|
|
887
|
+
// still shows up under 本地 without needing a rename. Non-blocking.
|
|
888
|
+
require("./backends/local-teams")
|
|
889
|
+
.syncAllLocalTeams()
|
|
890
|
+
.catch((e) => log.warn(`[cloud] startup local-team sync failed: ${e.message}`));
|
|
891
|
+
} catch (e) { log.warn(`[cloud] device register launch hook failed: ${e.message}`); }
|
|
892
|
+
|
|
832
893
|
// Local-team discovery — reads ~/cicy-ai/global.json's cicyDesktopNodes
|
|
833
894
|
// and probes each via /api/health. Pure local, never talks to the cloud
|
|
834
895
|
// and never runs docker shells.
|