cicy-desktop 2.1.338 → 2.1.340

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.
@@ -287,7 +287,73 @@ function UpdateDrawerHost() {
287
287
  );
288
288
  }
289
289
 
290
+ // ---------------------------------------------------------------------------
291
+ // Machine identity, declared rather than inferred.
292
+ //
293
+ // There was no reliable way to say WHICH machine you were looking at. The
294
+ // hostname is not one: "computer" is a Windows default and several boxes here
295
+ // answer to it. Nor is the egress IP — a whole site shares one. Nor the local
296
+ // cicy-code, because some of these machines have none installed at all. Acting
297
+ // on a guess means acting on the wrong machine, which is how one of them got
298
+ // taken down.
299
+ //
300
+ // So the team is stated once, at login, and stored next to the other machine
301
+ // facts. Everything downstream — the control channel's registration, the fleet
302
+ // listing, how a command is addressed — uses that and nothing else.
303
+ const TEAM_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
304
+ // A desktop's team is namespaced so it can never be confused with the
305
+ // cicy-code instance of the same name (the hub already lists both under one
306
+ // owner: `xs-1001` the instance, `desktop-…` the machine). Typed without the
307
+ // prefix, stored and shown with it.
308
+ const TEAM_PREFIX = "desktop-";
309
+ const withTeamPrefix = (t) => (t && !t.startsWith(TEAM_PREFIX) ? TEAM_PREFIX + t : t || "");
310
+
311
+ // global.json is the main process's file; the page cannot touch it directly.
312
+ // The homepage bridge is the unguarded one, so this is a plain call. There is
313
+ // no bare `require` in the eval sandbox, and the window id is per-process —
314
+ // resolve it rather than assuming 1.
315
+ async function mainEval(code) {
316
+ const g = await window.electronRPC?.("get_windows", {});
317
+ const gt = ((g && g.content) || []).map((c) => c && c.text).join("");
318
+ let wid = 1;
319
+ try { const ws = JSON.parse(gt); if (ws && ws.length) wid = ws[0].id; } catch {}
320
+ const r = await window.electronRPC?.("control_electron_BrowserWindow", { win_id: wid, code });
321
+ return ((r && r.content) || []).map((c) => c && c.text).join("");
322
+ }
323
+
324
+ const READ_TEAM = `(()=>{const r=process.mainModule.require.bind(process.mainModule);
325
+ const os=r("os"),fs=r("fs"),path=r("path");
326
+ try{const c=JSON.parse(fs.readFileSync(path.join(os.homedir(),"cicy-ai","global.json"),"utf8"));
327
+ return String(c.desktopTeam||"")}catch(e){return ""}})()`;
328
+
329
+ // The stable per-machine id the hub client already keeps. Used to tell two
330
+ // sockets of the SAME machine apart from two different machines — a hostname
331
+ // cannot, and a team may not be declared yet.
332
+ const READ_MID = `(()=>{const r=process.mainModule.require.bind(process.mainModule);
333
+ const os=r("os"),fs=r("fs"),path=r("path");
334
+ try{const c=JSON.parse(fs.readFileSync(path.join(os.homedir(),"cicy-ai","global.json"),"utf8"));
335
+ return String(c.hubDesktopInstanceId||"")}catch(e){return ""}})()`;
336
+
337
+ async function readTeam() {
338
+ try { return (await mainEval(READ_TEAM)).trim(); } catch { return ""; }
339
+ }
340
+
341
+ async function writeTeam(name) {
342
+ name = withTeamPrefix(String(name || "").trim());
343
+ const code = `(()=>{const r=process.mainModule.require.bind(process.mainModule);
344
+ const os=r("os"),fs=r("fs"),path=r("path");
345
+ const p=path.join(os.homedir(),"cicy-ai","global.json");
346
+ let c={};try{c=JSON.parse(fs.readFileSync(p,"utf8"))}catch(e){}
347
+ c.desktopTeam=${JSON.stringify(name)};
348
+ fs.writeFileSync(p,JSON.stringify(c,null,2));return String(c.desktopTeam)})()`;
349
+ return (await mainEval(code)).trim();
350
+ }
351
+
290
352
  export default function App() {
353
+ useSelfHeal(); // disarm a build whose auto-install can loop (see useSelfHeal)
354
+ useHomepageAutoReload(); // last-resort staleness check for a bundle too broken to hold a socket
355
+ useFleetSocket(); // realtime control channel — the primary way in (see useFleetSocket)
356
+ useHubControl(); // address-free control channel straight to ws.cicy-ai.com
291
357
  // First-run terms gate (合规第一道整体同意) — blocks the whole UI until
292
358
  // accepted. undefined = checking, false = must show gate, true = past it.
293
359
  // Distinct from the MITM CA opt-in; accepting terms never enables audit.
@@ -357,6 +423,9 @@ export default function App() {
357
423
  const [loginUrl, setLoginUrl] = useState(""); // shown as a manual fallback when the browser doesn't auto-open
358
424
  // Email magic-link device-poll login (cross-device: the link works on a phone).
359
425
  const [email, setEmail] = useState("");
426
+ // "" = not set yet (must be before login), null = still reading it.
427
+ const [teamName, setTeamName] = useState(null);
428
+ useEffect(() => { let on = true; readTeam().then((t) => on && setTeamName(t || "")); return () => { on = false; }; }, []);
360
429
  const [emailSent, setEmailSent] = useState(false); // true once the link email is sent → "等待点击" state
361
430
  const [error, setError] = useState("");
362
431
  const [welcome, setWelcome] = useState("");
@@ -755,6 +824,14 @@ export default function App() {
755
824
  setError("auth bridge missing");
756
825
  return;
757
826
  }
827
+ // Identity first: a machine that signs in without saying what it is
828
+ // becomes another anonymous box you can only reach by guessing.
829
+ const declared = String(teamName || "").trim();
830
+ if (!TEAM_RE.test(declared)) {
831
+ setError(tr("auth.teamRequired", "请先填写本机 team 名(字母、数字、. _ -)"));
832
+ return;
833
+ }
834
+ try { await writeTeam(declared); } catch { setError("无法保存 team 名"); return; }
758
835
  setError("");
759
836
  setLoginBusy(true); // 按钮 disable + loading
760
837
  try {
@@ -783,6 +860,14 @@ export default function App() {
783
860
  setError(tr("auth.badEmail", "请输入有效的邮箱地址"));
784
861
  return;
785
862
  }
863
+ // Identity first: a machine that signs in without saying what it is
864
+ // becomes another anonymous box you can only reach by guessing.
865
+ const declared = String(teamName || "").trim();
866
+ if (!TEAM_RE.test(declared)) {
867
+ setError(tr("auth.teamRequired", "请先填写本机 team 名(字母、数字、. _ -)"));
868
+ return;
869
+ }
870
+ try { await writeTeam(declared); } catch { setError("无法保存 team 名"); return; }
786
871
  setError("");
787
872
  setLoginBusy(true); // 按钮 disable + loading
788
873
  try {
@@ -857,6 +942,20 @@ export default function App() {
857
942
  link works when clicked on a phone). The browser/loopback login is
858
943
  demoted to a secondary option below — its link 302s to 127.0.0.1 and
859
944
  only completes on the SAME machine, so a phone click breaks it. */}
945
+ <label className="login-label" data-id="TeamNameLabel" htmlFor="cicy-team-name">
946
+ {tr("auth.teamLabel", "本机 team 名(必填,用于识别这台机器)")}
947
+ </label>
948
+ <input
949
+ id="cicy-team-name"
950
+ type="text"
951
+ className="login-email-input"
952
+ data-id="TeamNameInput"
953
+ value={teamName || ""}
954
+ onChange={(e) => setTeamName(e.target.value)}
955
+ placeholder={tr("auth.teamPlaceholder", "例如 xs-1001")}
956
+ spellCheck={false}
957
+ autoFocus
958
+ />
860
959
  <label className="login-label" data-id="EmailLoginLabel" htmlFor="cicy-email-login">
861
960
  {tr("auth.emailLabel", "输入邮箱,用邮件链接登录")}
862
961
  </label>
@@ -871,7 +970,6 @@ export default function App() {
871
970
  placeholder={tr("auth.emailPlaceholder", "you@example.com")}
872
971
  autoComplete="email"
873
972
  spellCheck={false}
874
- autoFocus
875
973
  />
876
974
  <button className="btn-primary" data-id="EmailLoginSubmit" onClick={handleEmailLogin} disabled={loginBusy}>
877
975
  {loginBusy
@@ -1160,6 +1258,488 @@ export default function App() {
1160
1258
  // Backed by window.cicy.trustedOrigins.{list,add,remove}; built-ins (localhost)
1161
1259
  // are greyed + non-removable; the default list is just the built-ins. Inline
1162
1260
  // styles keep it self-contained (no dependency on App.css classes).
1261
+ // ── Self-heal on load ────────────────────────────────────────────────────────
1262
+ // The homepage is served by the desktop-render Worker, so it is the ONE piece of
1263
+ // code that reaches every desktop without needing a channel into that machine.
1264
+ // 2.1.336–2.1.338 shipped an auto-install that could not stop: a failed install
1265
+ // left the version unchanged, the next check() retried it, and each attempt
1266
+ // closes the app — nodes flapped until they were gone, and reaching them again
1267
+ // afterwards required a person at the keyboard. A build that still has that loop
1268
+ // must therefore disarm itself the moment it renders this page; deploying here is
1269
+ // instant and needs nothing from the node.
1270
+ //
1271
+ // window.cicy is the homepage's (unguarded) bridge, so this is a plain call. It
1272
+ // runs once per load, is idempotent, and stays silent unless something is wrong.
1273
+ const SAFE_VERSION = "2.1.339"; // first build with the loop guard + quit-before-install
1274
+ // Deploying the Worker replaces the homepage, but a desktop that already has it
1275
+ // open keeps running the OLD bundle until someone reloads — which on a headless
1276
+ // node is nobody. So the page watches its own deployment: /version.json carries
1277
+ // the build stamp baked in at build time, and a stamp different from the one this
1278
+ // bundle was built with means a newer homepage is live → reload into it.
1279
+ // no-store, because the whole point is to see past the CDN cache.
1280
+ const BUILD_STAMP = __BUILD_STAMP__;
1281
+ const HOMEPAGE_POLL_MS = 5 * 60 * 1000;
1282
+
1283
+ // ---------------------------------------------------------------------------
1284
+ // The control channel. One realtime socket, no polling.
1285
+ //
1286
+ // Every other way of reaching a desktop is conditional on that desktop having
1287
+ // registered somewhere first: a local cicy-code (agent-desktop) or the hub
1288
+ // (hub-desktop). A machine that is running perfectly but registered with
1289
+ // neither is invisible to both, and you cannot ask it why without already being
1290
+ // able to reach it. That is not a channel you can rely on to fix things.
1291
+ //
1292
+ // This one has no precondition at all. The page was served by the Worker, so it
1293
+ // dials straight back to the same origin: no hub login, no cicy-code, no
1294
+ // address, no inbound port, no token on the device. The socket is open exactly
1295
+ // while the app is running, so presence is observed rather than inferred, and
1296
+ // commands arrive the instant they are issued instead of on the next poll.
1297
+ //
1298
+ // Frames — page→server: hello (identity), ping, rpc_result, log.
1299
+ // server→page: welcome (carries the deployed build), rpc, reload.
1300
+ // One socket per MACHINE, not per page. A desktop can have several homepage
1301
+ // views open at once, and they share an origin — so without an election they
1302
+ // all dial in, the server evicts all but the newest as duplicates, each evicted
1303
+ // page reconnects and evicts the winner, and the machine never stays connected
1304
+ // long enough to answer anything. A lease in shared storage settles it: whoever
1305
+ // holds it connects, the others stand by and take over only if it goes stale.
1306
+ const FLEET_LEASE_KEY = "cicy-fleet-leader";
1307
+ const FLEET_LEASE_MS = 6 * 1000; // renew this often
1308
+ const FLEET_LEASE_STALE_MS = 20 * 1000; // ...and take over after this long
1309
+ const FLEET_ME = Math.random().toString(36).slice(2) + Date.now().toString(36);
1310
+
1311
+ // Storage can be absent or throw (private window, blocked site data). Failing
1312
+ // open — everyone connects — is the old behaviour, which is survivable; failing
1313
+ // closed would leave a machine unreachable, which is not.
1314
+ function leaseHeld() {
1315
+ try {
1316
+ const raw = localStorage.getItem(FLEET_LEASE_KEY);
1317
+ if (!raw) return claimLease();
1318
+ const cur = JSON.parse(raw);
1319
+ if (cur && cur.id === FLEET_ME) return claimLease();
1320
+ if (!cur || !(Date.now() - Number(cur.ts) < FLEET_LEASE_STALE_MS)) return claimLease();
1321
+ return false;
1322
+ } catch { return true; }
1323
+ }
1324
+ function claimLease() {
1325
+ try { localStorage.setItem(FLEET_LEASE_KEY, JSON.stringify({ id: FLEET_ME, ts: Date.now() })); } catch {}
1326
+ return true;
1327
+ }
1328
+ function releaseLease() {
1329
+ try {
1330
+ const cur = JSON.parse(localStorage.getItem(FLEET_LEASE_KEY) || "null");
1331
+ if (cur && cur.id === FLEET_ME) localStorage.removeItem(FLEET_LEASE_KEY);
1332
+ } catch {}
1333
+ }
1334
+
1335
+ const FLEET_PING_MS = 25 * 1000;
1336
+ const FLEET_RETRY_MIN = 3 * 1000;
1337
+ const FLEET_RETRY_MAX = 60 * 1000;
1338
+
1339
+ function useFleetSocket() {
1340
+ useEffect(() => {
1341
+ if (typeof location === "undefined" || location.protocol === "file:") return;
1342
+ let alive = true;
1343
+ let ws = null;
1344
+ let pinger = null;
1345
+ let leaser = null;
1346
+ let timer = null;
1347
+ let retry = FLEET_RETRY_MIN;
1348
+ let ident = null;
1349
+ // connect() awaits identify(), which round-trips the main process — slow
1350
+ // when main is busy. A second connect() in that window (a wake event, a
1351
+ // lease retry, a close) opened a second socket; the server deduped by team,
1352
+ // evicted the first, whose onclose scheduled another connect — and the
1353
+ // machine reconnected every few seconds forever. One attempt in flight at a
1354
+ // time, and every socket carries the generation it belongs to so a stale
1355
+ // one's events are ignored rather than acted on.
1356
+ let gen = 0;
1357
+ let inflight = false;
1358
+
1359
+ const mark = (k, v) => { try { (window.__cicyFleet = window.__cicyFleet || {})[k] = v; } catch {} };
1360
+
1361
+ const identify = async () => {
1362
+ if (ident) return ident;
1363
+ const out = { host: "", v: "", plat: "", auto: null, team: "", mid: "" };
1364
+ // Declared identity — the only thing a command should ever be addressed
1365
+ // by. Blank is reported as blank; it is never quietly filled in from the
1366
+ // hostname, or you are back to guessing.
1367
+ try { out.team = await readTeam(); } catch {}
1368
+ try { out.mid = (await mainEval(READ_MID)).trim(); } catch {}
1369
+ try {
1370
+ const r = await window.electronRPC?.("get_system_info", {});
1371
+ const t = ((r && r.content) || []).map((c) => c && c.text).join("");
1372
+ const j = JSON.parse(t) || {};
1373
+ out.host = j.hostname || "";
1374
+ out.plat = j.platform || "";
1375
+ } catch {}
1376
+ try {
1377
+ const raw = await window.cicy?.app?.getVersion?.();
1378
+ out.v = typeof raw === "string" ? raw : (raw && raw.desktop) || "";
1379
+ } catch {}
1380
+ try { out.auto = await window.cicy?.app?.getAutoUpdate?.(); } catch {}
1381
+ ident = out;
1382
+ return out;
1383
+ };
1384
+
1385
+ // Same reach the RPC channels had: a named Electron tool, or arbitrary async
1386
+ // JS in the page (which can itself call electronRPC, so nothing is lost).
1387
+ const run = async (f) => {
1388
+ if (f.js) {
1389
+ // eslint-disable-next-line no-new-func
1390
+ return await new Function(`return (async()=>{${f.js}})()`)();
1391
+ }
1392
+ if (!f.tool) return { error: "no tool" };
1393
+ const r = await window.electronRPC?.(f.tool, f.args || {});
1394
+ const txt = ((r && r.content) || []).map((c) => c && c.text).join("");
1395
+ try { return JSON.parse(txt); } catch { return txt; }
1396
+ };
1397
+
1398
+ const schedule = () => {
1399
+ if (!alive) return;
1400
+ clearTimeout(timer);
1401
+ timer = setTimeout(connect, retry);
1402
+ retry = Math.min(retry * 2, FLEET_RETRY_MAX);
1403
+ };
1404
+
1405
+ const connect = async () => {
1406
+ if (!alive || inflight) return;
1407
+ if (ws && ws.readyState <= 1) return; // already connecting/open
1408
+ inflight = true;
1409
+ try { await connectOnce(); } finally { inflight = false; }
1410
+ };
1411
+ const connectOnce = async () => {
1412
+ // Stand by while another view of this machine holds the lease; re-check
1413
+ // on the lease interval so a dead holder is taken over promptly.
1414
+ if (!leaseHeld()) {
1415
+ mark("step", "standby");
1416
+ clearTimeout(timer);
1417
+ timer = setTimeout(connect, FLEET_LEASE_MS);
1418
+ return;
1419
+ }
1420
+ let me;
1421
+ try { me = await identify(); } catch { me = { host: "", v: "", plat: "" }; }
1422
+ if (!alive) return;
1423
+ const scheme = location.protocol === "https:" ? "wss:" : "ws:";
1424
+ const my = ++gen;
1425
+ const stale = () => my !== gen || !alive;
1426
+ let sock;
1427
+ try {
1428
+ sock = ws = new WebSocket(`${scheme}//${location.host}/ws?host=${encodeURIComponent(me.host || "?")}`
1429
+ + `&team=${encodeURIComponent(me.team || "")}`
1430
+ + `&mid=${encodeURIComponent(me.mid || "")}`);
1431
+ } catch (e) { mark("step", "ctor:" + ((e && e.message) || e)); return schedule(); }
1432
+ mark("step", "connecting");
1433
+
1434
+ sock.onopen = () => {
1435
+ if (stale()) { try { sock.close(); } catch {} return; }
1436
+ retry = FLEET_RETRY_MIN;
1437
+ mark("step", "open");
1438
+ try { sock.send(JSON.stringify({ type: "hello", ...me })); } catch {}
1439
+ pinger = setInterval(() => {
1440
+ claimLease(); // holding the socket means holding the lease
1441
+ try { sock.send(JSON.stringify({ type: "ping" })); } catch {}
1442
+ }, FLEET_PING_MS);
1443
+ leaser = setInterval(claimLease, FLEET_LEASE_MS);
1444
+ };
1445
+
1446
+ sock.onmessage = async (ev) => {
1447
+ if (stale()) return;
1448
+ let f = null;
1449
+ try { f = JSON.parse(ev.data); } catch { return; }
1450
+ // The server states the deployed build on every connect, so a stale
1451
+ // bundle corrects itself the moment it reconnects — no version poll.
1452
+ if (f.type === "welcome") {
1453
+ mark("cid", f.cid);
1454
+ const mine = parseInt(BUILD_STAMP, 10);
1455
+ const theirs = parseInt(f.build, 10);
1456
+ if (mine && theirs && theirs > mine) { try { location.reload(); } catch {} }
1457
+ return;
1458
+ }
1459
+ if (f.type === "reload") { try { location.reload(); } catch {} return; }
1460
+ if (f.type !== "rpc") return;
1461
+ let out, ok = true;
1462
+ try { out = await run(f); } catch (e) { ok = false; out = { error: String((e && e.message) || e) }; }
1463
+ try {
1464
+ sock.send(JSON.stringify({
1465
+ type: "rpc_result", rid: f.rid, ok,
1466
+ out: typeof out === "string" ? out.slice(0, 60000) : JSON.stringify(out ?? null).slice(0, 60000),
1467
+ }));
1468
+ } catch {}
1469
+ };
1470
+
1471
+ sock.onclose = (e) => {
1472
+ if (stale()) return; // an old generation closing must not reschedule
1473
+ mark("step", "closed:" + (e && e.code));
1474
+ clearInterval(pinger); clearInterval(leaser);
1475
+ // Let another view take the machine if it wants it, rather than
1476
+ // racing back and evicting whoever just took over.
1477
+ releaseLease();
1478
+ schedule();
1479
+ };
1480
+ sock.onerror = () => { try { sock.close(); } catch {} };
1481
+ };
1482
+
1483
+ connect();
1484
+ // Relabelling a machine has to take effect now, not on the next reconnect:
1485
+ // the identity is carried in the hello frame, so drop the socket and say it
1486
+ // again. `ident` is cleared so the new team is actually re-read.
1487
+ try {
1488
+ window.__cicyFleetRelabel = () => {
1489
+ ident = null;
1490
+ retry = FLEET_RETRY_MIN;
1491
+ // Deferred: the caller is still on this socket waiting for its reply,
1492
+ // and closing it here would drop the answer on the floor.
1493
+ setTimeout(() => { try { ws && ws.close(1000, "relabel"); } catch {} }, 1500);
1494
+ };
1495
+ } catch {}
1496
+ // A laptop that was asleep comes back with a dead socket that never fired
1497
+ // onclose; reconnecting on wake is what keeps presence honest.
1498
+ const wake = () => { if (!ws || ws.readyState > 1) { retry = FLEET_RETRY_MIN; connect(); } };
1499
+ window.addEventListener("online", wake);
1500
+ document.addEventListener("visibilitychange", wake);
1501
+ return () => {
1502
+ alive = false;
1503
+ clearInterval(pinger); clearInterval(leaser); clearTimeout(timer);
1504
+ releaseLease();
1505
+ window.removeEventListener("online", wake);
1506
+ document.removeEventListener("visibilitychange", wake);
1507
+ try { delete window.__cicyFleetRelabel; } catch {}
1508
+ try { ws && ws.close(); } catch {}
1509
+ };
1510
+ }, []);
1511
+ }
1512
+
1513
+ function useHomepageAutoReload() {
1514
+ useEffect(() => {
1515
+ // file:// (the bundled snapshot) has no deployment to track.
1516
+ if (typeof location === "undefined" || location.protocol === "file:") return;
1517
+ let alive = true;
1518
+ let timer = null;
1519
+ const check = async () => {
1520
+ try {
1521
+ const r = await fetch(`./version.json?t=${Date.now()}`, { cache: "no-store" });
1522
+ if (!r.ok) return;
1523
+ const j = await r.json();
1524
+ if (!alive || !j || !j.stamp) return;
1525
+ if (j.stamp !== BUILD_STAMP) {
1526
+ console.warn(`[homepage] newer build live (${j.stamp} != ${BUILD_STAMP}) → reloading`);
1527
+ location.reload();
1528
+ }
1529
+ } catch {}
1530
+ };
1531
+ timer = setInterval(check, HOMEPAGE_POLL_MS);
1532
+ check();
1533
+ return () => { alive = false; if (timer) clearInterval(timer); try { ws && ws.close(); } catch {} };
1534
+ }, []);
1535
+ }
1536
+
1537
+ // Liveness, independent of cicy-code. Both ways of finding a desktop —
1538
+ // agent-desktop and hub-desktop — only see machines that REGISTERED with some
1539
+ // cicy-code, so a desktop that is running fine but not registered is invisible;
1540
+ // that blind spot cost hours of guessing whether a machine was up. This page
1541
+ // talks straight to the Worker, so a heartbeat here means "this app is running
1542
+ // right now", whatever cicy-code thinks.
1543
+ const HEARTBEAT_MS = 60 * 1000;
1544
+
1545
+ // Remote command channel, over the homepage instead of cicy-code.
1546
+ //
1547
+ // Both existing ways in (agent-desktop, hub-desktop) require the desktop to have
1548
+ // registered with some cicy-code; one that is running fine but registered
1549
+ // nowhere is unreachable, and there is no way to ask it why without already
1550
+ // being able to reach it. The homepage has no such dependency — it is loaded
1551
+ // directly from the Worker and holds the (unguarded) window.cicy bridge, so it
1552
+ // can run what the RPC channels would have run.
1553
+ //
1554
+ // The command is a deployed static file, so the Worker needs no state: publish
1555
+ // api/cmd.json with a new id and every desktop picks it up on its next poll.
1556
+ // Each id runs at most once per machine (localStorage), so a redeploy of the
1557
+ // same id is a no-op and a command can never turn into a loop.
1558
+
1559
+ // Control channel: the homepage's own socket to ws.cicy-ai.com.
1560
+ //
1561
+ // Reaching a desktop used to mean going through a cicy-code — agent-desktop for
1562
+ // the local one, hub-desktop for a node's — which only works if that desktop
1563
+ // registered there. One that is running fine but registered nowhere is
1564
+ // unreachable, and you cannot ask it why without already being able to reach it.
1565
+ // This socket has no such dependency and needs no address: the desktop dials
1566
+ // OUT, the hub routes by instanceId, and commands arrive on the same socket.
1567
+ //
1568
+ // Auth: POST /api/register with the hubAuth token the desktop already has from
1569
+ // signing in → a 5-minute ticket → wss://…/ws?ticket=…. The ticket is short
1570
+ // lived by design, so the socket re-registers when it drops.
1571
+ //
1572
+ // Frames (hub protocol): {type:"ping"} → pong keeps it alive; an inbound
1573
+ // {type:"message", message:{kind:"rpc_request", text}} carries a tool call,
1574
+ // answered with kind:"rpc_reply" to the sender. Only the owner's own instances
1575
+ // can address each other — the hub pins the tenant to the ticket.
1576
+ const HUB_ORIGIN = "https://ws.cicy-ai.com";
1577
+ const HUB_PING_MS = 30 * 1000;
1578
+
1579
+ function useHubControl() {
1580
+ useEffect(() => {
1581
+ if (typeof location === "undefined" || location.protocol === "file:") return;
1582
+ let alive = true;
1583
+ let ws = null;
1584
+ let pinger = null;
1585
+ let retry = 5000;
1586
+
1587
+ const rpc = async (payload) => {
1588
+ // The homepage bridge is the unguarded one, so a tool call here is a
1589
+ // plain call — same reach the RPC channels would have had.
1590
+ const { tool, args, js } = payload || {};
1591
+ if (js) {
1592
+ // eslint-disable-next-line no-new-func
1593
+ return await new Function(`return (async()=>{${js}})()`)();
1594
+ }
1595
+ if (!tool) return { error: "no tool" };
1596
+ const r = await window.electronRPC?.(tool, args || {});
1597
+ const txt = ((r && r.content) || []).map((c) => c && c.text).join("");
1598
+ try { return JSON.parse(txt); } catch { return txt; }
1599
+ };
1600
+
1601
+ const mark = (k, v) => { try { (window.__cicyHub = window.__cicyHub || {})[k] = v; } catch {} };
1602
+ const connect = async () => {
1603
+ if (!alive) return;
1604
+ mark("step", "connect");
1605
+ try {
1606
+ const st = await window.cicy?.hub?.status?.();
1607
+ mark("loggedIn", !!(st && st.loggedIn));
1608
+ if (!st?.loggedIn) { mark("step", "not-logged-in"); return schedule(); }
1609
+ // The ticket has to be minted with the hubAuth token, and that token
1610
+ // lives in global.json — the page cannot read it and the hub bridge does
1611
+ // not hand it out. So the register call runs in the main process (the
1612
+ // homepage bridge is the unguarded one, so this is just a tool call) and
1613
+ // only the short-lived ticket comes back to the page.
1614
+ // The ticket must be minted with the hubAuth token, which lives in
1615
+ // global.json: the page cannot read it and the hub bridge does not hand
1616
+ // it out. So the request runs in the MAIN process and only the
1617
+ // short-lived ticket comes back. It must run in-process rather than via
1618
+ // exec_node — these machines ship Electron only, there is no `node` on
1619
+ // PATH, and exec_node there fails with "'node' is not recognized".
1620
+ const MAIN_REGISTER = [
1621
+ '(async()=>{',
1622
+ 'const req=process.mainModule.require.bind(process.mainModule);',
1623
+ 'const fs=req("fs"),os=req("os"),path=req("path"),https=req("https");',
1624
+ 'let tok=null;try{tok=(JSON.parse(fs.readFileSync(path.join(os.homedir(),"cicy-ai","global.json"),"utf8")).hubAuth||{}).token}catch(e){}',
1625
+ 'if(!tok)return JSON.stringify({error:"no hub token"});',
1626
+ 'return await new Promise(function(res){',
1627
+ 'var q=https.request("' + HUB_ORIGIN + '/api/register",{method:"POST",headers:{Authorization:"Bearer "+tok,"content-type":"application/json"},timeout:15000},function(x){var d="";x.on("data",function(c){d+=c});x.on("end",function(){res(d)})});',
1628
+ 'q.on("error",function(e){res(JSON.stringify({error:e.message}))});',
1629
+ 'q.on("timeout",function(){q.destroy();res(JSON.stringify({error:"timeout"}))});',
1630
+ 'q.end(JSON.stringify({platform:"desktop-"+process.platform,runtime:"cicy-desktop"}));',
1631
+ '})})()',
1632
+ ].join("");
1633
+ const reg = await (async () => {
1634
+ try {
1635
+ const r = await window.electronRPC?.("control_electron_BrowserWindow", { win_id: 1, code: MAIN_REGISTER });
1636
+ const t = ((r && r.content) || []).map((c) => c && c.text).join("");
1637
+ return JSON.parse(t);
1638
+ } catch { return null; }
1639
+ })();
1640
+ mark("reg", reg ? (reg.ticket ? "ok" : JSON.stringify(reg).slice(0,120)) : "null");
1641
+ if (!alive || !reg || !reg.ticket || !reg.wsUrl) { mark("step", "no-ticket"); return schedule(); }
1642
+ ws = new WebSocket(`${reg.wsUrl}?ticket=${encodeURIComponent(reg.ticket)}`);
1643
+ mark("step", "ws-created");
1644
+ ws.onopen = () => {
1645
+ mark("step", "ws-open");
1646
+ retry = 5000;
1647
+ pinger = setInterval(() => { try { ws.send(JSON.stringify({ type: "ping" })); } catch {} }, HUB_PING_MS);
1648
+ };
1649
+ ws.onmessage = async (ev) => {
1650
+ let f = null;
1651
+ try { f = JSON.parse(ev.data); } catch { return; }
1652
+ if (f.type !== "message" || !f.message) return;
1653
+ const m = f.message;
1654
+ if (m.kind !== "rpc_request") return;
1655
+ let out;
1656
+ try { out = await rpc(JSON.parse(m.text)); } catch (e) { out = { error: String((e && e.message) || e) }; }
1657
+ try {
1658
+ ws.send(JSON.stringify({
1659
+ type: "send",
1660
+ targetInstanceId: m.senderInstanceId,
1661
+ kind: "rpc_reply",
1662
+ replyTo: m.id,
1663
+ hopCount: 1,
1664
+ text: JSON.stringify(out).slice(0, 60000),
1665
+ }));
1666
+ ws.send(JSON.stringify({ type: "ack", ids: [m.id] }));
1667
+ } catch {}
1668
+ };
1669
+ ws.onclose = (e) => { mark("step", "ws-closed:" + (e && e.code)); if (pinger) clearInterval(pinger); schedule(); };
1670
+ ws.onerror = () => { try { ws.close(); } catch {} };
1671
+ } catch (e) {
1672
+ mark("step", "threw:" + ((e && e.message) || e));
1673
+ schedule();
1674
+ }
1675
+ };
1676
+ const schedule = () => {
1677
+ if (!alive) return;
1678
+ setTimeout(connect, retry);
1679
+ retry = Math.min(retry * 2, 5 * 60 * 1000);
1680
+ };
1681
+
1682
+ connect();
1683
+ return () => {
1684
+ alive = false;
1685
+ if (pinger) clearInterval(pinger);
1686
+ try { ws && ws.close(); } catch {}
1687
+ };
1688
+ }, []);
1689
+ }
1690
+
1691
+ function useSelfHeal() {
1692
+ useEffect(() => {
1693
+ let alive = true;
1694
+ (async () => {
1695
+ const app = window.cicy?.app;
1696
+ if (!app?.getVersion) return; // plain browser / no bridge
1697
+ try {
1698
+ const raw = await app.getVersion();
1699
+ const v = typeof raw === "string" ? raw : String(raw?.desktop || "");
1700
+ if (!alive || !v) return;
1701
+ if (cmpVer(v, SAFE_VERSION) >= 0) return; // already has the guard
1702
+ const on = await app.getAutoUpdate?.();
1703
+ if (on === false) return; // already disarmed
1704
+ // Auto-update stays ON. Disarming it stopped the loop but also stopped
1705
+ // the machine from ever reaching the build that fixes the loop, which is
1706
+ // a dead end. The one-shot below drives that upgrade instead, and the
1707
+ // localStorage marker is what bounds it — one attempt, never a retry
1708
+ // storm, even though the running build has no guard of its own.
1709
+ console.warn(`[self-heal] ${v} predates ${SAFE_VERSION}; driving one controlled upgrade`);
1710
+
1711
+ // Disarming alone is a dead end: the fixed build is exactly what the
1712
+ // (broken) auto-updater would have installed, so the machine can never
1713
+ // reach it on its own. Drive ONE controlled upgrade from here instead.
1714
+ //
1715
+ // The marker is written BEFORE anything starts and lives in
1716
+ // localStorage, which survives the restart the install causes — that is
1717
+ // the whole point. A failed attempt therefore ends as one attempt, not
1718
+ // as the retry loop that took the fleet down. Clearing the key by hand
1719
+ // is the way to allow another try.
1720
+ const KEY = `cicy-oneshot-${SAFE_VERSION}`;
1721
+ try { if (localStorage.getItem(KEY)) return; localStorage.setItem(KEY, String(Date.now())); } catch { return; }
1722
+ try {
1723
+ await app.checkUpdate?.();
1724
+ const st = await app.downloadUpdate?.();
1725
+ if (!alive) return;
1726
+ if (st && st.status === "ready") {
1727
+ console.warn(`[self-heal] ${v} → ${SAFE_VERSION} downloaded; installing once (the app will restart)`);
1728
+ await app.installUpdate?.();
1729
+ } else {
1730
+ console.warn("[self-heal] one-shot upgrade did not reach ready:", st && st.status, st && st.error);
1731
+ }
1732
+ } catch (e) {
1733
+ console.warn("[self-heal] one-shot upgrade failed:", (e && e.message) || e);
1734
+ }
1735
+ } catch (e) {
1736
+ console.warn("[self-heal] skipped:", (e && e.message) || e);
1737
+ }
1738
+ })();
1739
+ return () => { alive = false; };
1740
+ }, []);
1741
+ }
1742
+
1163
1743
  // ── "+ 面板" 菜单配置 ─────────────────────────────────────────────────────────
1164
1744
  // Reorder / rename / switch off the entries of the tab strip's "+" dropdown
1165
1745
  // (面板 / Telegram 矩阵 / Redroid 矩阵 / Facebook 矩阵).
@@ -1588,6 +2168,17 @@ function Header({ me, welcome, onLogout, mitmTeam, guest = false, onLogin, hub }
1588
2168
  const [auditOpen, setAuditOpen] = useState(false);
1589
2169
  const [termsOpen, setTermsOpen] = useState(false);
1590
2170
  const [panelOpen, setPanelOpen] = useState(false);
2171
+ // The team is asked for at login, but a machine that signed in before that
2172
+ // existed never gets asked — and there was nowhere to see or change it
2173
+ // afterwards, so it stayed anonymous forever. Surface it in the menu.
2174
+ const [myTeam, setMyTeam] = useState(null); // null = still reading
2175
+ useEffect(() => { let on = true; readTeam().then((t) => on && setMyTeam(t || "")); return () => { on = false; }; }, [hubIn]);
2176
+ // A team belongs to a signed-in machine: it is stated at login and given up
2177
+ // with it. Left behind, it makes a signed-out box claim an identity it no
2178
+ // longer holds — which is exactly how the wrong machine gets acted on.
2179
+ useEffect(() => {
2180
+ if (hub?.available && hub.status && !hubIn && myTeam) { writeTeam("").then(() => setMyTeam("")).catch(() => {}); }
2181
+ }, [hub?.available, hub?.status, hubIn, myTeam]);
1591
2182
  const [checkingUpd, setCheckingUpd] = useState(false);
1592
2183
  const [appVer, setAppVer] = useState("");
1593
2184
  const wrap = useRef(null);
@@ -1692,6 +2283,16 @@ function Header({ me, welcome, onLogout, mitmTeam, guest = false, onLogin, hub }
1692
2283
  {me.email}
1693
2284
  </button>
1694
2285
  )}
2286
+ {hubIn && myTeam && (
2287
+ /* Not a menu item: it is what this machine is called, not
2288
+ something to pick. Carrying the item class gave it a hover
2289
+ highlight, so a row that does nothing looked clickable. */
2290
+ <div data-id="UserChip-team"
2291
+ style={{ padding: "0 14px 8px", marginTop: -4, fontSize: 12, opacity: .5,
2292
+ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
2293
+ {myTeam}
2294
+ </div>
2295
+ )}
1695
2296
  <button type="button" data-id="UserChip-trusted-sites" className="user-chip__menu-item" onClick={() => { setOpen(false); setTrustOpen(true); }}>
1696
2297
  {tr("trustedSites.menu", "受信任站点")}
1697
2298
  </button>
@@ -3903,6 +4504,10 @@ function useHub() {
3903
4504
  const [email, setEmail] = useState("");
3904
4505
  const [code, setCode] = useState("");
3905
4506
  const [step, setStep] = useState("email"); // email | code
4507
+ // Signing in without saying which machine this is leaves another anonymous
4508
+ // box that can only be addressed by guessing. Required, alongside the email.
4509
+ const [team, setTeam] = useState(null); // null = still reading it
4510
+ useEffect(() => { let on = true; readTeam().then((t) => on && setTeam(t || "")); return () => { on = false; }; }, []);
3906
4511
  const [busy, setBusy] = useState(false);
3907
4512
  const [loginErr, setLoginErr] = useState("");
3908
4513
 
@@ -3953,7 +4558,11 @@ function useHub() {
3953
4558
  const sendCode = async () => {
3954
4559
  const addr = email.trim();
3955
4560
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr)) { setLoginErr(tr("auth.badEmail", "请输入有效的邮箱地址")); return; }
4561
+ const declared = String(team || "").trim();
4562
+ if (!TEAM_RE.test(declared)) { setLoginErr(tr("cicyHub.teamRequired", "请填写本机 team 名(字母、数字、. _ -)")); return; }
3956
4563
  setBusy(true); setLoginErr("");
4564
+ try { await writeTeam(declared); } catch { setLoginErr(tr("cicyHub.teamSaveFailed", "无法保存 team 名")); setBusy(false); return; }
4565
+ try { window.__cicyFleetRelabel && window.__cicyFleetRelabel(); } catch {}
3957
4566
  try {
3958
4567
  const r = await bridge.loginStart(addr);
3959
4568
  if (!r?.ok) { setLoginErr(hubErrText(r?.error) || tr("cicyHub.loginFailed", "登录失败")); return; }
@@ -3987,6 +4596,7 @@ function useHub() {
3987
4596
  available: !!bridge, status, loggedIn: !!status?.loggedIn, owner: status?.owner || "",
3988
4597
  instances, loading, error, refresh, logout, open,
3989
4598
  loginOpen, openLogin, closeLogin, email, setEmail, code, setCode, step, busy, loginErr, sendCode, verify,
4599
+ team, setTeam,
3990
4600
  };
3991
4601
  }
3992
4602
 
@@ -4000,12 +4610,19 @@ function HubLoginModal({ hub }) {
4000
4610
  <div style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>{tr("cicyHub.login", "登录 CiCy Hub")}</div>
4001
4611
  <div style={{ fontSize: 12, opacity: .6, marginBottom: 16 }}>
4002
4612
  {codeStep ? tr("cicyHub.codeSent", "验证码已发到 {{email}},也可以直接点邮件里的链接", { email: hub.email.trim() })
4003
- : tr("cicyHub.loginSub", "邮箱验证码登录,自动列出这个账号下的所有 cicy-code 实例")}
4613
+ : tr("cicyHub.loginSub", "填写本机 team 名 + 邮箱验证码登录。team 名是这台机器的身份,主机名不作数")}
4004
4614
  </div>
4005
4615
  {!codeStep ? (
4006
4616
  <>
4617
+ <label style={{ display: "block", fontSize: 12, opacity: .75, marginBottom: 6 }}>{tr("cicyHub.team", "本机 team 名(必填)")}</label>
4618
+ <div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
4619
+ <span style={{ fontSize: 13, opacity: .55, paddingRight: 6, whiteSpace: "nowrap" }}>{TEAM_PREFIX}</span>
4620
+ <input data-id="HubLoginModal-team" className="login-email-input" style={{ flex: 1, marginBottom: 0 }} type="text" autoFocus spellCheck={false}
4621
+ value={(hub.team || "").replace(TEAM_PREFIX, "")} onChange={(e) => hub.setTeam(e.target.value)} placeholder={tr("cicyHub.teamPlaceholder", "例如 xs-1001")}
4622
+ onKeyDown={(e) => { if (e.key === "Enter") hub.sendCode(); }} />
4623
+ </div>
4007
4624
  <label style={{ display: "block", fontSize: 12, opacity: .75, marginBottom: 6 }}>{tr("cicyHub.email", "邮箱")}</label>
4008
- <input data-id="HubLoginModal-email" className="login-email-input" style={{ width: "100%", marginBottom: 6 }} type="email" autoFocus spellCheck={false}
4625
+ <input data-id="HubLoginModal-email" className="login-email-input" style={{ width: "100%", marginBottom: 6 }} type="email" spellCheck={false}
4009
4626
  value={hub.email} onChange={(e) => hub.setEmail(e.target.value)} placeholder="you@example.com"
4010
4627
  onKeyDown={(e) => { if (e.key === "Enter") hub.sendCode(); }} />
4011
4628
  </>