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.
- package/package.json +1 -1
- package/src/app-updater.js +54 -7
- package/src/backends/homepage-react/assets/index-B72KF7tw.js +388 -0
- package/src/backends/homepage-react/index.html +33 -2
- package/src/backends/homepage-window.js +40 -3
- package/src/main.js +76 -0
- package/src/tools/tab-browser-tools.js +5 -1
- package/test/app-updater-install-relaunch.test.js +20 -7
- package/test/app-updater-loop-guard.test.js +55 -0
- package/tools/wsd +166 -0
- package/workers/render/index.html +32 -1
- package/workers/render/src/App.jsx +620 -3
- package/workers/render/vite.config.js +13 -0
- package/workers/render/worker.js +311 -0
- package/workers/render/wrangler.toml +12 -0
- package/src/backends/homepage-react/assets/index-CppodgBn.js +0 -377
|
@@ -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", "
|
|
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"
|
|
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
|
</>
|