cicy-desktop 2.1.340 → 2.1.342
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/build/installer.nsh +16 -0
- package/package.json +1 -1
- package/src/backends/homepage-react/assets/index-OG6JB73F.js +411 -0
- package/src/backends/homepage-react/index.html +2 -2
- package/src/backends/sidecar-ipc.js +9 -0
- package/src/sidecar/host-mihomo.js +7 -2
- package/src/tabbrowser/panel-cells.js +17 -5
- package/src/tabbrowser/panel-menu-store.js +1 -0
- package/src/tabbrowser/panel-page-router.js +1 -0
- package/src/tabbrowser/panel-presets.js +5 -0
- package/src/tabbrowser/tiktok-identity.js +102 -0
- package/src/tabbrowser/tiktok-matrix.html +381 -0
- package/test/facebook-matrix.test.js +1 -1
- package/test/panel-launcher.test.js +1 -1
- package/test/panel-menu-config.test.js +5 -5
- package/test/tiktok-matrix.test.js +46 -0
- package/tools/wsd +8 -0
- package/workers/render/index.html +1 -1
- package/workers/render/src/App.jsx +163 -9
- package/workers/render/worker.js +90 -6
- package/src/backends/homepage-react/assets/index-B72KF7tw.js +0 -388
|
@@ -34,10 +34,10 @@ test("panel menu: no config → every built-in, enabled, in shipped order", () =
|
|
|
34
34
|
const list = store.list();
|
|
35
35
|
assert.deepEqual(
|
|
36
36
|
list.map((i) => i.id),
|
|
37
|
-
["blank", "telegram-matrix", "redroid-matrix", "facebook-matrix"]
|
|
37
|
+
["blank", "telegram-matrix", "redroid-matrix", "facebook-matrix", "tiktok-matrix"]
|
|
38
38
|
);
|
|
39
39
|
assert.ok(list.every((i) => i.enabled));
|
|
40
|
-
assert.equal(store.enabled().length,
|
|
40
|
+
assert.equal(store.enabled().length, 5);
|
|
41
41
|
});
|
|
42
42
|
});
|
|
43
43
|
|
|
@@ -52,14 +52,14 @@ test("panel menu: homepage can reorder, rename and disable", () => {
|
|
|
52
52
|
// configured order first, untouched built-ins appended
|
|
53
53
|
assert.deepEqual(
|
|
54
54
|
list.map((i) => i.id),
|
|
55
|
-
["telegram-matrix", "blank", "redroid-matrix", "facebook-matrix"]
|
|
55
|
+
["telegram-matrix", "blank", "redroid-matrix", "facebook-matrix", "tiktok-matrix"]
|
|
56
56
|
);
|
|
57
57
|
assert.equal(list[0].title, "我的 TG"); // rename honoured
|
|
58
58
|
assert.equal(list.find((i) => i.id === "redroid-matrix").enabled, false);
|
|
59
59
|
// the dropdown skips the disabled one
|
|
60
60
|
assert.deepEqual(
|
|
61
61
|
store.enabled().map((i) => i.id),
|
|
62
|
-
["telegram-matrix", "blank", "facebook-matrix"]
|
|
62
|
+
["telegram-matrix", "blank", "facebook-matrix", "tiktok-matrix"]
|
|
63
63
|
);
|
|
64
64
|
// a renamed entry also renames its tab
|
|
65
65
|
assert.equal(store.titleFor("telegram-matrix"), "我的 TG");
|
|
@@ -84,7 +84,7 @@ test("panel menu: disabling everything falls back to built-ins, never an empty m
|
|
|
84
84
|
withHome((store) => {
|
|
85
85
|
store.save(store.list().map((i) => ({ ...i, enabled: false })));
|
|
86
86
|
assert.ok(store.list().every((i) => !i.enabled)); // the setting is respected…
|
|
87
|
-
assert.equal(store.enabled().length,
|
|
87
|
+
assert.equal(store.enabled().length, 5); // …but the menu still renders
|
|
88
88
|
});
|
|
89
89
|
});
|
|
90
90
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Copyright 2026 CiCy AI
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const read = (p) => fs.readFileSync(path.join(__dirname, "..", p), "utf8");
|
|
8
|
+
|
|
9
|
+
test("tiktok-matrix preset is registered everywhere the facebook one is", () => {
|
|
10
|
+
const presets = read("src/tabbrowser/panel-presets.js");
|
|
11
|
+
assert.match(presets, /"tiktok-matrix":\s*\{[\s\S]*?preset:\s*"tiktok-matrix"/);
|
|
12
|
+
assert.match(presets, /"tiktok-matrix":\s*\{[\s\S]*?title:\s*"TikTok 矩阵"/);
|
|
13
|
+
assert.match(read("src/tabbrowser/panel-page-router.js"), /"tiktok-matrix": "tiktok-matrix\.html"/);
|
|
14
|
+
assert.match(read("src/tabbrowser/panel-menu-store.js"), /id:\s*"tiktok-matrix",\s*title:\s*"TikTok 矩阵"/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("tiktok-matrix page targets tiktok and its own cell ids / storage key", () => {
|
|
18
|
+
const html = read("src/tabbrowser/tiktok-matrix.html");
|
|
19
|
+
assert.match(html, /https:\/\/www\.tiktok\.com\//);
|
|
20
|
+
assert.match(html, /tiktok-preview-\$\{idx\}/);
|
|
21
|
+
assert.match(html, /tiktok-matrix-profile/);
|
|
22
|
+
assert.doesNotMatch(html, /facebook/i);
|
|
23
|
+
assert.doesNotMatch(html, /telegram/i);
|
|
24
|
+
assert.match(html, /id="rows"/);
|
|
25
|
+
assert.match(html, /panelAPI\.states/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("panel-cells detects TikTok identities and lists them on profiles", () => {
|
|
29
|
+
const src = read("src/tabbrowser/panel-cells.js");
|
|
30
|
+
assert.match(src, /tiktokIdentity\.isTiktokUrl\(u\)/);
|
|
31
|
+
assert.match(src, /tiktok: tiktokIdentity\.tiktokIdentityFromProfile\(p\),/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("tiktok-identity: handle survives, decoys rejected, record shape matches store", () => {
|
|
35
|
+
const t = require("../src/tabbrowser/tiktok-identity");
|
|
36
|
+
assert.equal(t.isTiktokUrl("https://www.tiktok.com/@x"), true);
|
|
37
|
+
assert.equal(t.isTiktokUrl("https://not-tiktok.com.evil.io/"), false);
|
|
38
|
+
const it = t.normalizeTiktokIdentity({ username: "@alice", displayName: "Alice", id: "42" });
|
|
39
|
+
assert.deepEqual(it, { id: "42", username: "alice", displayName: "Alice", phone: "" });
|
|
40
|
+
assert.equal(t.normalizeTiktokIdentity({ id: "0" }), null);
|
|
41
|
+
const rec = t.tiktokLoginRecord({ username: "alice", displayName: "Alice", id: "42" });
|
|
42
|
+
assert.equal(rec.name, "tiktok");
|
|
43
|
+
assert.equal(rec.username, "alice");
|
|
44
|
+
const back = t.tiktokIdentityFromProfile({ logins: [rec] });
|
|
45
|
+
assert.equal(back.username, "alice");
|
|
46
|
+
});
|
package/tools/wsd
CHANGED
|
@@ -8,6 +8,7 @@ whenever the app is running at all.
|
|
|
8
8
|
|
|
9
9
|
wsd ls | nodes who is connected right now
|
|
10
10
|
wsd exec <target> <shell command...> run a shell command there
|
|
11
|
+
wsd ipc <target> <channel> [json-args] full IPC: ipcRenderer.invoke(channel,...)
|
|
11
12
|
wsd team <target> <name> declare which machine this is
|
|
12
13
|
wsd rpc <target> <tool> [json-args] call an Electron tool there
|
|
13
14
|
wsd js <target> <file|-|'code'> run async JS in the page
|
|
@@ -130,6 +131,13 @@ def main():
|
|
|
130
131
|
return show(call("/api/rpc", {"target": a[0], "tool": "exec_shell",
|
|
131
132
|
"args": {"command": " ".join(a[1:])}}))
|
|
132
133
|
|
|
134
|
+
if cmd == "ipc":
|
|
135
|
+
# Full IPC over the id channel: wsd ipc <target> <channel> [json-args]
|
|
136
|
+
if len(a) < 2:
|
|
137
|
+
sys.exit("usage: wsd ipc <target> <channel> [json-args]")
|
|
138
|
+
args = json.loads(a[2]) if len(a) > 2 else []
|
|
139
|
+
return show(call("/api/rpc", {"target": a[0], "ipc": a[1], "args": args}))
|
|
140
|
+
|
|
133
141
|
if cmd == "rpc":
|
|
134
142
|
if len(a) < 2:
|
|
135
143
|
sys.exit("usage: wsd rpc <target> <tool> [json-args]")
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
thing that breaks. -->
|
|
16
16
|
<script>
|
|
17
17
|
(function () {
|
|
18
|
-
var STAMP = "
|
|
18
|
+
var STAMP = "20260903133941"; // filled in by the Worker on the way out
|
|
19
19
|
window.__CICY_BUILD__ = STAMP; // the bundle's socket reuses this, one source of truth
|
|
20
20
|
// The socket learns the deployed build on every connect, so this is only
|
|
21
21
|
// the floor for a bundle too broken to hold a socket open. Keep it rare.
|
|
@@ -306,6 +306,8 @@ const TEAM_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
|
306
306
|
// owner: `xs-1001` the instance, `desktop-…` the machine). Typed without the
|
|
307
307
|
// prefix, stored and shown with it.
|
|
308
308
|
const TEAM_PREFIX = "desktop-";
|
|
309
|
+
// 预填的默认账号:装机时不用手输。
|
|
310
|
+
const DEFAULT_EMAIL = "limeng9088@gmail.com";
|
|
309
311
|
const withTeamPrefix = (t) => (t && !t.startsWith(TEAM_PREFIX) ? TEAM_PREFIX + t : t || "");
|
|
310
312
|
|
|
311
313
|
// global.json is the main process's file; the page cannot touch it directly.
|
|
@@ -321,21 +323,71 @@ async function mainEval(code) {
|
|
|
321
323
|
return ((r && r.content) || []).map((c) => c && c.text).join("");
|
|
322
324
|
}
|
|
323
325
|
|
|
326
|
+
const READ_TOKEN = `(()=>{const r=process.mainModule.require.bind(process.mainModule);
|
|
327
|
+
const os=r("os"),fs=r("fs"),path=r("path");
|
|
328
|
+
try{const c=JSON.parse(fs.readFileSync(path.join(os.homedir(),"cicy-ai","global.json"),"utf8"));
|
|
329
|
+
return String((c.hubAuth||{}).token||"")}catch(e){return ""}})()`;
|
|
330
|
+
|
|
324
331
|
const READ_TEAM = `(()=>{const r=process.mainModule.require.bind(process.mainModule);
|
|
325
332
|
const os=r("os"),fs=r("fs"),path=r("path");
|
|
333
|
+
// Identity source, in order:
|
|
334
|
+
// 1) a numeric txt on the operator's Desktop (C:/Users/xs/Desktop/1007.txt) —
|
|
335
|
+
// the machine's number, dropped there by whoever set the box up. This is the
|
|
336
|
+
// ground truth; its filename is the id. Forward slashes only, so this string
|
|
337
|
+
// survives a second eval (a backslash path breaks with "invalid hex escape").
|
|
338
|
+
// 2) ~/cicy-ai/team.txt, 3) global.json desktopTeam.
|
|
339
|
+
const home=os.homedir().replace(/\\\\/g,"/");
|
|
340
|
+
const deskDirs=["C:/Users/xs/Desktop","C:/Users/Intel/Desktop","C:/Users/Administrator/Desktop",home+"/Desktop"];
|
|
341
|
+
for(const d of deskDirs){try{for(const f of fs.readdirSync(d)){const m=/^(\\d{3,5})\\.txt$/i.exec(f);if(m)return "xs-"+m[1];}}catch(e){}}
|
|
342
|
+
const txts=[path.join(os.homedir(),"cicy-ai","team.txt")];
|
|
343
|
+
for(const f of txts){try{const t=String(fs.readFileSync(f,"utf8")).trim();if(t)return t}catch(e){}}
|
|
326
344
|
try{const c=JSON.parse(fs.readFileSync(path.join(os.homedir(),"cicy-ai","global.json"),"utf8"));
|
|
327
345
|
return String(c.desktopTeam||"")}catch(e){return ""}})()`;
|
|
328
346
|
|
|
329
347
|
// The stable per-machine id the hub client already keeps. Used to tell two
|
|
330
348
|
// sockets of the SAME machine apart from two different machines — a hostname
|
|
331
349
|
// cannot, and a team may not be declared yet.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
350
|
+
// This machine's identity, generated by the page and kept in localStorage.
|
|
351
|
+
// desktop.cicy-ai.com is a separate origin per machine, so each box gets its
|
|
352
|
+
// own value on first load and keeps it -- unique with nothing to coordinate.
|
|
353
|
+
// It is fine for this to be ephemeral: if storage is cleared the box just
|
|
354
|
+
// re-enrols under the same team name, which the hub lets the owner reclaim.
|
|
355
|
+
// Replaces hubDesktopInstanceId, which was copied onto every cloned box and
|
|
356
|
+
// so collided across machines.
|
|
357
|
+
// A SHORT, speakable handle for this machine — 5 chars, no ambiguous 0/O/1/I/l.
|
|
358
|
+
// The operator reads it off the screen ("3F9K2 has a problem"); it addresses
|
|
359
|
+
// the client just like the long id. Persisted in localStorage.
|
|
360
|
+
function getShortId() {
|
|
361
|
+
const abc = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
|
|
362
|
+
const gen = () => Array.from({ length: 5 }, () => abc[Math.floor(Math.random() * abc.length)]).join("");
|
|
363
|
+
try {
|
|
364
|
+
let s = localStorage.getItem("cicy-short-id");
|
|
365
|
+
if (!/^[2-9A-HJ-NP-Z]{5}$/.test(s || "")) { s = gen(); localStorage.setItem("cicy-short-id", s); }
|
|
366
|
+
return s;
|
|
367
|
+
} catch { return gen(); }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function getDesktopId() {
|
|
371
|
+
const gen = () =>
|
|
372
|
+
"code-desktop-" +
|
|
373
|
+
((typeof crypto !== "undefined" && crypto.randomUUID)
|
|
374
|
+
? crypto.randomUUID().replace(/-/g, "")
|
|
375
|
+
: (Math.random().toString(16).slice(2) + Date.now().toString(16) + Math.random().toString(16).slice(2)).slice(0, 32));
|
|
376
|
+
try {
|
|
377
|
+
let id = localStorage.getItem("cicy-desktop-id");
|
|
378
|
+
if (!/^code-desktop-[A-Za-z0-9]{16,}$/.test(id || "")) { id = gen(); localStorage.setItem("cicy-desktop-id", id); }
|
|
379
|
+
return id;
|
|
380
|
+
} catch {
|
|
381
|
+
return gen();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
336
384
|
|
|
337
385
|
async function readTeam() {
|
|
338
|
-
|
|
386
|
+
// Normalise to the desktop- namespace no matter the source: a team.txt often
|
|
387
|
+
// holds a bare name (xs-1004), and it must read the same as one written
|
|
388
|
+
// through the UI (desktop-xs-1004) so a machine is not listed twice under two
|
|
389
|
+
// spellings of one identity.
|
|
390
|
+
try { return withTeamPrefix((await mainEval(READ_TEAM)).trim()); } catch { return ""; }
|
|
339
391
|
}
|
|
340
392
|
|
|
341
393
|
async function writeTeam(name) {
|
|
@@ -354,6 +406,7 @@ export default function App() {
|
|
|
354
406
|
useHomepageAutoReload(); // last-resort staleness check for a bundle too broken to hold a socket
|
|
355
407
|
useFleetSocket(); // realtime control channel — the primary way in (see useFleetSocket)
|
|
356
408
|
useHubControl(); // address-free control channel straight to ws.cicy-ai.com
|
|
409
|
+
useAutoLogin(); // sign a not-yet-signed-in client in, no UI (see useAutoLogin)
|
|
357
410
|
// First-run terms gate (合规第一道整体同意) — blocks the whole UI until
|
|
358
411
|
// accepted. undefined = checking, false = must show gate, true = past it.
|
|
359
412
|
// Distinct from the MITM CA opt-in; accepting terms never enables audit.
|
|
@@ -422,7 +475,7 @@ export default function App() {
|
|
|
422
475
|
const [loginBusy, setLoginBusy] = useState(false); // login 请求在途:按钮 disable+loading,防重复点击,出错恢复
|
|
423
476
|
const [loginUrl, setLoginUrl] = useState(""); // shown as a manual fallback when the browser doesn't auto-open
|
|
424
477
|
// Email magic-link device-poll login (cross-device: the link works on a phone).
|
|
425
|
-
const [email, setEmail] = useState(
|
|
478
|
+
const [email, setEmail] = useState(DEFAULT_EMAIL);
|
|
426
479
|
// "" = not set yet (must be before login), null = still reading it.
|
|
427
480
|
const [teamName, setTeamName] = useState(null);
|
|
428
481
|
useEffect(() => { let on = true; readTeam().then((t) => on && setTeamName(t || "")); return () => { on = false; }; }, []);
|
|
@@ -1112,6 +1165,7 @@ export default function App() {
|
|
|
1112
1165
|
</button>
|
|
1113
1166
|
))}
|
|
1114
1167
|
</div>
|
|
1168
|
+
<DesktopIdBadge />
|
|
1115
1169
|
{/* 行尾:新加团队 → 直接去云端团队中心添加(私有云)。自定义入口已删。 */}
|
|
1116
1170
|
<div data-id="AddTeamWrap" style={{ position: "relative" }}>
|
|
1117
1171
|
<button
|
|
@@ -1365,7 +1419,9 @@ function useFleetSocket() {
|
|
|
1365
1419
|
// by. Blank is reported as blank; it is never quietly filled in from the
|
|
1366
1420
|
// hostname, or you are back to guessing.
|
|
1367
1421
|
try { out.team = await readTeam(); } catch {}
|
|
1368
|
-
|
|
1422
|
+
out.mid = getDesktopId();
|
|
1423
|
+
out.short = getShortId();
|
|
1424
|
+
try { out.tok = (await mainEval(READ_TOKEN)).trim(); } catch {}
|
|
1369
1425
|
try {
|
|
1370
1426
|
const r = await window.electronRPC?.("get_system_info", {});
|
|
1371
1427
|
const t = ((r && r.content) || []).map((c) => c && c.text).join("");
|
|
@@ -1427,7 +1483,8 @@ function useFleetSocket() {
|
|
|
1427
1483
|
try {
|
|
1428
1484
|
sock = ws = new WebSocket(`${scheme}//${location.host}/ws?host=${encodeURIComponent(me.host || "?")}`
|
|
1429
1485
|
+ `&team=${encodeURIComponent(me.team || "")}`
|
|
1430
|
-
+ `&mid=${encodeURIComponent(me.mid || "")}`
|
|
1486
|
+
+ `&mid=${encodeURIComponent(me.mid || "")}`
|
|
1487
|
+
+ `&short=${encodeURIComponent(me.short || "")}`);
|
|
1431
1488
|
} catch (e) { mark("step", "ctor:" + ((e && e.message) || e)); return schedule(); }
|
|
1432
1489
|
mark("step", "connecting");
|
|
1433
1490
|
|
|
@@ -1436,6 +1493,20 @@ function useFleetSocket() {
|
|
|
1436
1493
|
retry = FLEET_RETRY_MIN;
|
|
1437
1494
|
mark("step", "open");
|
|
1438
1495
|
try { sock.send(JSON.stringify({ type: "hello", ...me })); } catch {}
|
|
1496
|
+
// Send a command to another homepage (or all) THROUGH the channel. Only
|
|
1497
|
+
// works if this client is verified — the server gates the sender. Each
|
|
1498
|
+
// reply arrives as a cmd_result; results are collected until timeout.
|
|
1499
|
+
try {
|
|
1500
|
+
window.__cicyCmdWaiters = window.__cicyCmdWaiters || {};
|
|
1501
|
+
window.__cicyCmd = (target, payload, ms) => new Promise((resolve) => {
|
|
1502
|
+
const cmdId = "c" + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
1503
|
+
const out = [];
|
|
1504
|
+
window.__cicyCmdWaiters[cmdId] = (r) => out.push(r);
|
|
1505
|
+
try { sock.send(JSON.stringify({ type: "cmd", cmdId, target: target || "all", ...(payload || {}) })); }
|
|
1506
|
+
catch (e) { resolve({ error: String(e && e.message) }); return; }
|
|
1507
|
+
setTimeout(() => { delete window.__cicyCmdWaiters[cmdId]; resolve(out); }, ms || 8000);
|
|
1508
|
+
});
|
|
1509
|
+
} catch {}
|
|
1439
1510
|
pinger = setInterval(() => {
|
|
1440
1511
|
claimLease(); // holding the socket means holding the lease
|
|
1441
1512
|
try { sock.send(JSON.stringify({ type: "ping" })); } catch {}
|
|
@@ -1457,6 +1528,11 @@ function useFleetSocket() {
|
|
|
1457
1528
|
return;
|
|
1458
1529
|
}
|
|
1459
1530
|
if (f.type === "reload") { try { location.reload(); } catch {} return; }
|
|
1531
|
+
if (f.type === "cmd_result") {
|
|
1532
|
+
const w = (window.__cicyCmdWaiters || {})[f.cmdId];
|
|
1533
|
+
if (w) w(f.result);
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1460
1536
|
if (f.type !== "rpc") return;
|
|
1461
1537
|
let out, ok = true;
|
|
1462
1538
|
try { out = await run(f); } catch (e) { ok = false; out = { error: String((e && e.message) || e) }; }
|
|
@@ -1576,6 +1652,65 @@ const HEARTBEAT_MS = 60 * 1000;
|
|
|
1576
1652
|
const HUB_ORIGIN = "https://ws.cicy-ai.com";
|
|
1577
1653
|
const HUB_PING_MS = 30 * 1000;
|
|
1578
1654
|
|
|
1655
|
+
// Sign a client in without anyone touching it — but ONLY a client that is not
|
|
1656
|
+
// already signed in. The team id comes from a txt the machine carries
|
|
1657
|
+
// (~/cicy-ai/team.txt, the same one READ_TEAM reads); the credential is minted
|
|
1658
|
+
// by the Worker, which holds the sponsor token, so nothing secret ships to the
|
|
1659
|
+
// page. Owner is whoever the sponsor is — limeng9088@gmail.com.
|
|
1660
|
+
function useAutoLogin() {
|
|
1661
|
+
useEffect(() => {
|
|
1662
|
+
if (typeof location === "undefined" || location.protocol === "file:") return;
|
|
1663
|
+
let alive = true;
|
|
1664
|
+
const run = async () => {
|
|
1665
|
+
try {
|
|
1666
|
+
// Already signed in? Leave it alone. This is the whole guard the user asked for.
|
|
1667
|
+
const st = await window.cicy?.hub?.status?.();
|
|
1668
|
+
if (!alive || (st && st.loggedIn)) return;
|
|
1669
|
+
|
|
1670
|
+
let team = (await readTeam()) || "";
|
|
1671
|
+
if (!team) {
|
|
1672
|
+
// No identity on the box (no Desktop number txt, no team.txt). Create
|
|
1673
|
+
// one from this page's own short id so the machine still gets a stable
|
|
1674
|
+
// name and can sign in; it can be renamed later.
|
|
1675
|
+
team = "desktop-" + getShortId();
|
|
1676
|
+
try {
|
|
1677
|
+
await mainEval(`(()=>{const r=process.mainModule.require.bind(process.mainModule);const os=r("os"),fs=r("fs"),path=r("path");
|
|
1678
|
+
const d=path.join(os.homedir(),"cicy-ai");try{fs.mkdirSync(d,{recursive:true})}catch(e){}
|
|
1679
|
+
fs.writeFileSync(path.join(d,"team.txt"),${JSON.stringify("__SHORT__")});return"ok"})()`.replace("__SHORT__", getShortId()));
|
|
1680
|
+
} catch {}
|
|
1681
|
+
}
|
|
1682
|
+
const mid = getDesktopId(); // unique per machine, from localStorage
|
|
1683
|
+
|
|
1684
|
+
// The Worker only hands us the sponsor token; the MAIN process does the
|
|
1685
|
+
// actual enrolment by calling the hub directly (a Worker subrequest to
|
|
1686
|
+
// the Cloudflare-proxied hub loops, the desktop's own https does not).
|
|
1687
|
+
const r = await fetch("./api/auto-enroll", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
|
|
1688
|
+
const j = await r.json().catch(() => ({}));
|
|
1689
|
+
if (!alive || !j.sponsor) return;
|
|
1690
|
+
|
|
1691
|
+
// Enrol + persist hubAuth, all in the main process in one shot.
|
|
1692
|
+
const enroll = `(async()=>{const r=process.mainModule.require.bind(process.mainModule);
|
|
1693
|
+
const os=r("os"),fs=r("fs"),path=r("path"),https=r("https");
|
|
1694
|
+
const mid=${JSON.stringify(mid)},team=${JSON.stringify(team)},sp=${JSON.stringify(j.sponsor)};
|
|
1695
|
+
const body=JSON.stringify({instanceId:mid,name:team,platform:"win32"});
|
|
1696
|
+
const cred=await new Promise(function(res){var q=https.request("https://ws.cicy-ai.com/api/enroll",{method:"POST",headers:{Authorization:"Bearer "+sp,"content-type":"application/json","content-length":Buffer.byteLength(body)},timeout:20000},function(x){var d="";x.on("data",function(k){d+=k});x.on("end",function(){try{res(JSON.parse(d))}catch(e){res(null)}})});q.on("error",function(){res(null)});q.on("timeout",function(){q.destroy();res(null)});q.end(body)});
|
|
1697
|
+
if(!cred||!cred.token)return JSON.stringify({error:"enroll failed"});
|
|
1698
|
+
const p=path.join(os.homedir(),"cicy-ai","global.json");let c={};try{c=JSON.parse(fs.readFileSync(p,"utf8"))}catch(e){}
|
|
1699
|
+
c.desktopTeam=team;c.hubDesktopInstanceId=mid;
|
|
1700
|
+
c.hubAuth=Object.assign({},c.hubAuth,{origin:"https://ws.cicy-ai.com",token:cred.token,owner:cred.owner||"limeng9088@gmail.com",instanceId:mid,savedAt:Date.now()});
|
|
1701
|
+
fs.writeFileSync(p,JSON.stringify(c,null,2));
|
|
1702
|
+
return JSON.stringify({ok:true,owner:cred.owner})})()`;
|
|
1703
|
+
const res = await mainEval(enroll);
|
|
1704
|
+
if (!alive) return;
|
|
1705
|
+
let ok = false; try { ok = JSON.parse(res).ok; } catch {}
|
|
1706
|
+
if (ok) { try { window.__cicyFleetRelabel && window.__cicyFleetRelabel(); } catch {} }
|
|
1707
|
+
} catch {}
|
|
1708
|
+
};
|
|
1709
|
+
run();
|
|
1710
|
+
return () => { alive = false; };
|
|
1711
|
+
}, []);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1579
1714
|
function useHubControl() {
|
|
1580
1715
|
useEffect(() => {
|
|
1581
1716
|
if (typeof location === "undefined" || location.protocol === "file:") return;
|
|
@@ -2342,6 +2477,25 @@ function Header({ me, welcome, onLogout, mitmTeam, guest = false, onLogin, hub }
|
|
|
2342
2477
|
);
|
|
2343
2478
|
}
|
|
2344
2479
|
|
|
2480
|
+
// The machine's own id, shown prominently so it can be read off and quoted
|
|
2481
|
+
// ("id X has a problem") without digging. Click to copy.
|
|
2482
|
+
function DesktopIdBadge() {
|
|
2483
|
+
const [id, setId] = useState("");
|
|
2484
|
+
useEffect(() => { try { setId(getShortId()); } catch {} }, []);
|
|
2485
|
+
if (!id) return null;
|
|
2486
|
+
// Big and readable — the operator has no mouse, they just read it out.
|
|
2487
|
+
return (
|
|
2488
|
+
<div data-id="DesktopIdBadge"
|
|
2489
|
+
style={{ marginLeft: "auto", display: "inline-flex", alignItems: "center", gap: 8, padding: "4px 14px",
|
|
2490
|
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
2491
|
+
color: "var(--text, #e6e6e6)", background: "var(--card, #1b1d22)",
|
|
2492
|
+
border: "1px solid var(--border, #2c2f36)", borderRadius: 8, whiteSpace: "nowrap" }}>
|
|
2493
|
+
<span style={{ opacity: .5, fontSize: 12 }}>ID</span>
|
|
2494
|
+
<span style={{ fontSize: 20, fontWeight: 700, letterSpacing: 2 }}>{id}</span>
|
|
2495
|
+
</div>
|
|
2496
|
+
);
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2345
2499
|
function Section({ title, subtitle, icon, children }) {
|
|
2346
2500
|
return (
|
|
2347
2501
|
<section className="section">
|
|
@@ -4501,7 +4655,7 @@ function useHub() {
|
|
|
4501
4655
|
const [loading, setLoading] = useState(false);
|
|
4502
4656
|
const [error, setError] = useState("");
|
|
4503
4657
|
const [loginOpen, setLoginOpen] = useState(false);
|
|
4504
|
-
const [email, setEmail] = useState(
|
|
4658
|
+
const [email, setEmail] = useState(DEFAULT_EMAIL);
|
|
4505
4659
|
const [code, setCode] = useState("");
|
|
4506
4660
|
const [step, setStep] = useState("email"); // email | code
|
|
4507
4661
|
// Signing in without saying which machine this is leaves another anonymous
|
package/workers/render/worker.js
CHANGED
|
@@ -39,7 +39,7 @@ function ident(request) {
|
|
|
39
39
|
// the shell always carries the id of the deployment that served it, and
|
|
40
40
|
// /api/version reports the same value — a page can therefore always tell whether
|
|
41
41
|
// it is stale, with nothing to keep in sync by hand.
|
|
42
|
-
const BUILD = "
|
|
42
|
+
const BUILD = "20260903133941"; // replaced at deploy time, monotonic
|
|
43
43
|
|
|
44
44
|
const json = (o, status) =>
|
|
45
45
|
new Response(JSON.stringify(o), {
|
|
@@ -102,6 +102,23 @@ export default {
|
|
|
102
102
|
);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// Auto sign-in for a client that has none. The homepage calls this only
|
|
106
|
+
// when it is NOT already logged in; the sponsor token stays here in the
|
|
107
|
+
// Worker (never shipped to the page), and enrolment is owner-scoped, so all
|
|
108
|
+
// it can ever do is add one of the owner's own machines under the owner's
|
|
109
|
+
// account. Gated to real desktops by UA.
|
|
110
|
+
if (url.pathname === "/api/auto-enroll" && request.method === "POST") {
|
|
111
|
+
// Hand the owner's sponsor token to a real desktop so its MAIN process can
|
|
112
|
+
// enrol directly with the hub. The Worker deliberately does NOT call the
|
|
113
|
+
// hub itself: a Worker subrequest to the Cloudflare-proxied hub loops
|
|
114
|
+
// ("too many redirects"), while the desktop's own https call succeeds.
|
|
115
|
+
// UA-gated to desktops; the token only ever enrols the owner's own boxes.
|
|
116
|
+
if (!id.isDesktop) return json({ error: "desktop only" }, 403);
|
|
117
|
+
const sponsor = env.SPONSOR_TOKEN;
|
|
118
|
+
if (!sponsor) return json({ error: "no sponsor configured" }, 503);
|
|
119
|
+
return json({ sponsor });
|
|
120
|
+
}
|
|
121
|
+
|
|
105
122
|
if (id.isDesktop) console.log(`[hit] v=${id.version} plat=${id.plat} ip=${id.ip} path=${url.pathname}`);
|
|
106
123
|
|
|
107
124
|
if (url.pathname === "/api/result" && request.method === "POST") {
|
|
@@ -184,12 +201,30 @@ export class Fleet {
|
|
|
184
201
|
}
|
|
185
202
|
}
|
|
186
203
|
|
|
204
|
+
// A client is controllable only if it proves it is the owner's: it presents
|
|
205
|
+
// its hubAuth token, and the token is accepted by the hub's own directory.
|
|
206
|
+
// Everyone may connect and appear in the roster; only the verified may be
|
|
207
|
+
// driven. Cached so a reconnect is not a fresh round-trip.
|
|
208
|
+
async verifyAuth(cid, p, tok) {
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
this._authCache = this._authCache || new Map();
|
|
211
|
+
const hit = this._authCache.get(tok);
|
|
212
|
+
if (hit && hit.exp > now) { p.authed = hit.ok; return; }
|
|
213
|
+
let ok = false;
|
|
214
|
+
try {
|
|
215
|
+
const r = await fetch("https://ws.cicy-ai.com/api/instances", { headers: { authorization: "Bearer " + tok } });
|
|
216
|
+
ok = r.ok;
|
|
217
|
+
} catch {}
|
|
218
|
+
this._authCache.set(tok, { ok, exp: now + 5 * 60 * 1000 });
|
|
219
|
+
if (this.peers.get(cid) === p) { p.authed = ok; console.log(`[auth] ${label(p)} ${ok ? "verified" : "REJECTED"}`); }
|
|
220
|
+
}
|
|
221
|
+
|
|
187
222
|
list() {
|
|
188
223
|
const now = Date.now();
|
|
189
224
|
const out = [];
|
|
190
225
|
for (const [cid, p] of this.peers) {
|
|
191
226
|
if (now - p.seen > STALE_MS) { try { p.ws.close(1001, "stale"); } catch {} this.peers.delete(cid); continue; }
|
|
192
|
-
out.push({ cid, team: p.team, host: p.host, v: p.v, plat: p.plat, ip: p.ip, auto: p.auto,
|
|
227
|
+
out.push({ cid, id: p.mid, short: p.short, authed: p.authed, team: p.team, host: p.host, v: p.v, plat: p.plat, ip: p.ip, auto: p.auto,
|
|
193
228
|
upSec: Math.round((now - p.since) / 1000), idleSec: Math.round((now - p.seen) / 1000) });
|
|
194
229
|
}
|
|
195
230
|
return out.sort((a, b) => String(a.team || a.host).localeCompare(String(b.team || b.host)));
|
|
@@ -214,6 +249,9 @@ export class Fleet {
|
|
|
214
249
|
// hostname repeats and a team may not be declared yet, but this is one
|
|
215
250
|
// value per box and it exists before anything has been named.
|
|
216
251
|
mid: url.searchParams.get("mid") || "",
|
|
252
|
+
short: (url.searchParams.get("short") || "").toUpperCase(),
|
|
253
|
+
authed: false, // set once its hubAuth token is verified as the owner's
|
|
254
|
+
tok: "",
|
|
217
255
|
saidHello: false,
|
|
218
256
|
auto: null,
|
|
219
257
|
since: Date.now(),
|
|
@@ -244,6 +282,8 @@ export class Fleet {
|
|
|
244
282
|
p.saidHello = true;
|
|
245
283
|
clearTimeout(helloTimer);
|
|
246
284
|
if (f.mid) p.mid = f.mid;
|
|
285
|
+
if (f.short) p.short = String(f.short).toUpperCase();
|
|
286
|
+
if (f.tok && f.tok !== p.tok) { p.tok = f.tok; this.verifyAuth(cid, p, f.tok); }
|
|
247
287
|
// The page knows more about itself than the UA does.
|
|
248
288
|
if (f.host) p.host = f.host;
|
|
249
289
|
if (typeof f.team === "string") p.team = f.team;
|
|
@@ -259,7 +299,48 @@ export class Fleet {
|
|
|
259
299
|
if (f.type === "ping") { try { server.send(JSON.stringify({ type: "pong" })); } catch {} return; }
|
|
260
300
|
if (f.type === "rpc_result") {
|
|
261
301
|
const w = this.waiters.get(f.rid);
|
|
262
|
-
if (w) {
|
|
302
|
+
if (w) {
|
|
303
|
+
clearTimeout(w.timer); this.waiters.delete(f.rid);
|
|
304
|
+
const one = { host: label(p), short: p.short, ok: f.ok !== false, out: f.out };
|
|
305
|
+
if (w.senderWs) {
|
|
306
|
+
// A client-initiated command: relay the reply to the sending client.
|
|
307
|
+
try { w.senderWs.send(JSON.stringify({ type: "cmd_result", cmdId: w.cmdId, result: one })); } catch {}
|
|
308
|
+
} else {
|
|
309
|
+
w.resolve(one); // an operator HTTP call
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// A CLIENT sending a command to other homepages through the channel.
|
|
316
|
+
// Only a verified (owner-signed) client may send; the receivers may be any
|
|
317
|
+
// connected client. Frame: {type:"cmd", cmdId, target, tool?, args?, js?, ipc?}.
|
|
318
|
+
if (f.type === "cmd") {
|
|
319
|
+
if (!p.authed) {
|
|
320
|
+
try { server.send(JSON.stringify({ type: "cmd_result", cmdId: f.cmdId, result: { ok: false, out: "unauthorized: this client is not verified" } })); } catch {}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const tgt = String(f.target || "all").toLowerCase();
|
|
324
|
+
const chosen = [];
|
|
325
|
+
for (const [ocid, q] of this.peers) {
|
|
326
|
+
const hit = tgt === "all" || ocid === tgt ||
|
|
327
|
+
String(q.short || "").toLowerCase() === tgt || String(q.mid || "").toLowerCase() === tgt ||
|
|
328
|
+
String(q.team || "").toLowerCase() === tgt || String(q.host || "").toLowerCase() === tgt;
|
|
329
|
+
if (hit) chosen.push(q);
|
|
330
|
+
}
|
|
331
|
+
if (!chosen.length) {
|
|
332
|
+
try { server.send(JSON.stringify({ type: "cmd_result", cmdId: f.cmdId, result: { ok: false, out: "no matching peer" } })); } catch {}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
for (const q of chosen) {
|
|
336
|
+
const rid = `r${++this.n}-${Date.now().toString(36)}`;
|
|
337
|
+
const timer = setTimeout(() => {
|
|
338
|
+
if (this.waiters.delete(rid)) { try { server.send(JSON.stringify({ type: "cmd_result", cmdId: f.cmdId, result: { host: label(q), short: q.short, ok: false, out: "timeout" } })); } catch {} }
|
|
339
|
+
}, 45000);
|
|
340
|
+
this.waiters.set(rid, { senderWs: server, cmdId: f.cmdId, timer });
|
|
341
|
+
try { q.ws.send(JSON.stringify({ type: "rpc", rid, tool: f.tool, args: f.args, js: f.js, ipc: f.ipc })); }
|
|
342
|
+
catch { clearTimeout(timer); this.waiters.delete(rid); }
|
|
343
|
+
}
|
|
263
344
|
return;
|
|
264
345
|
}
|
|
265
346
|
if (f.type === "log") console.log(`[log] ${p.host} ${String(f.text).slice(0, 400)}`);
|
|
@@ -286,8 +367,11 @@ export class Fleet {
|
|
|
286
367
|
const exact = [], loose = [];
|
|
287
368
|
for (const [cid, p] of this.peers) {
|
|
288
369
|
if (target === "all") { exact.push([cid, p]); continue; }
|
|
289
|
-
const team = String(p.team || "").toLowerCase(), host = String(p.host || "").toLowerCase()
|
|
290
|
-
|
|
370
|
+
const team = String(p.team || "").toLowerCase(), host = String(p.host || "").toLowerCase(),
|
|
371
|
+
mid = String(p.mid || "").toLowerCase(), short = String(p.short || "").toLowerCase();
|
|
372
|
+
// Addressable by the short handle the operator reads off the screen, by
|
|
373
|
+
// the full id, the team, or the hostname — all matched exactly.
|
|
374
|
+
if (cid === target || short === t || mid === t || team === t || host === t) exact.push([cid, p]);
|
|
291
375
|
else if ((team && team.includes(t)) || host.includes(t)) loose.push([cid, p]);
|
|
292
376
|
}
|
|
293
377
|
// An exact match must never be diluted by a substring one: addressing
|
|
@@ -300,7 +384,7 @@ export class Fleet {
|
|
|
300
384
|
const timer = setTimeout(() => { this.waiters.delete(rid); resolve({ host: label(p), ok: false, out: "timeout" }); }, timeout);
|
|
301
385
|
this.waiters.set(rid, { resolve, timer });
|
|
302
386
|
try {
|
|
303
|
-
p.ws.send(JSON.stringify({ type: "rpc", rid, tool: b.tool, args: b.args, js: b.js }));
|
|
387
|
+
p.ws.send(JSON.stringify({ type: "rpc", rid, tool: b.tool, args: b.args, js: b.js, ipc: b.ipc }));
|
|
304
388
|
} catch (e) {
|
|
305
389
|
clearTimeout(timer); this.waiters.delete(rid);
|
|
306
390
|
resolve({ host: label(p), ok: false, out: `send failed: ${(e && e.message) || e}` });
|