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
|
@@ -1,12 +1,43 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
2
|
+
<html lang="en" data-cicy-stamp="dev">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
|
7
7
|
<link rel="icon" type="image/png" sizes="256x256" href="./favicon-256.png" />
|
|
8
8
|
<title>CiCy Desktop</title>
|
|
9
|
-
|
|
9
|
+
<!-- Deployment watchdog. Deliberately INLINE and outside the bundle:
|
|
10
|
+
the reload used to live in a React hook, so a bundle that failed to
|
|
11
|
+
render took the watchdog down with it and that machine could never learn
|
|
12
|
+
a fix had shipped — it stayed on the broken build forever, reachable only
|
|
13
|
+
by someone pressing reload. Here it runs even if nothing else does.
|
|
14
|
+
Plain ES5, no imports, everything in try/catch: it must never be the
|
|
15
|
+
thing that breaks. -->
|
|
16
|
+
<script>
|
|
17
|
+
(function () {
|
|
18
|
+
var STAMP = "20260903085137"; // filled in by the Worker on the way out
|
|
19
|
+
window.__CICY_BUILD__ = STAMP; // the bundle's socket reuses this, one source of truth
|
|
20
|
+
// The socket learns the deployed build on every connect, so this is only
|
|
21
|
+
// the floor for a bundle too broken to hold a socket open. Keep it rare.
|
|
22
|
+
var EVERY = 600000;
|
|
23
|
+
function check() {
|
|
24
|
+
try {
|
|
25
|
+
fetch("./api/version?t=" + Date.now(), { cache: "no-store" })
|
|
26
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
27
|
+
.then(function (j) {
|
|
28
|
+
// Numeric compare, and only ever forward: a stale page reloads,
|
|
29
|
+
// a page newer than the server (mid-deploy race) sits still.
|
|
30
|
+
var mine = parseInt(STAMP, 10), theirs = parseInt(j && j.build, 10);
|
|
31
|
+
if (mine && theirs && theirs > mine) { location.reload(true); }
|
|
32
|
+
})
|
|
33
|
+
.catch(function () {});
|
|
34
|
+
} catch (e) {}
|
|
35
|
+
}
|
|
36
|
+
setInterval(check, EVERY);
|
|
37
|
+
setTimeout(check, 5000);
|
|
38
|
+
})();
|
|
39
|
+
</script>
|
|
40
|
+
<script type="module" crossorigin src="./assets/index-B72KF7tw.js"></script>
|
|
10
41
|
<link rel="stylesheet" crossorigin href="./assets/index-C6Zrdgc0.css">
|
|
11
42
|
</head>
|
|
12
43
|
<body>
|
|
@@ -33,7 +33,14 @@ const FIXED_HEIGHT = 640;
|
|
|
33
33
|
const LOCAL_INDEX = path.join(__dirname, "homepage-react", "index.html");
|
|
34
34
|
const LOCAL_URL = `file://${LOCAL_INDEX}`;
|
|
35
35
|
const REMOTE_HOMEPAGE = "https://desktop.cicy-ai.com/";
|
|
36
|
-
|
|
36
|
+
// Boot is the slowest moment on a slow uplink (DNS, TLS, the app's own startup
|
|
37
|
+
// traffic all at once), and 15s was short enough to fire on a machine whose
|
|
38
|
+
// remote page loads in 10s a minute later. Give it real time.
|
|
39
|
+
const REMOTE_FALLBACK_MS = 60 * 1000;
|
|
40
|
+
// Once local, keep trying to get back. The snapshot is an old bundle with no
|
|
41
|
+
// control channel, so a machine left on it is unreachable — a fallback that is
|
|
42
|
+
// permanent turns one slow boot into a lost machine for the whole day.
|
|
43
|
+
const REMOTE_RETRY_MS = 60 * 1000;
|
|
37
44
|
|
|
38
45
|
function readHomepagePrefs() {
|
|
39
46
|
try {
|
|
@@ -73,13 +80,43 @@ function wireRemoteFallback(wc) {
|
|
|
73
80
|
try { url = wc.getURL(); } catch {}
|
|
74
81
|
if (url.startsWith("file://")) { settled = true; return; }
|
|
75
82
|
settled = true;
|
|
76
|
-
log.warn(`[homepage] remote homepage unusable (${reason}) → bundled snapshot`);
|
|
83
|
+
log.warn(`[homepage] remote homepage unusable (${reason}) → bundled snapshot; will keep retrying remote`);
|
|
77
84
|
try { wc.loadURL(LOCAL_URL); } catch (e) { log.error(`[homepage] fallback failed: ${e.message}`); }
|
|
85
|
+
scheduleRemoteRetry();
|
|
78
86
|
};
|
|
87
|
+
// Probe the remote with a plain fetch first, so a retry never yanks the user
|
|
88
|
+
// off the (working) local page onto another failed load. Stops on its own
|
|
89
|
+
// once the page is remote again or the webContents is gone.
|
|
90
|
+
let retryTimer = null;
|
|
91
|
+
const scheduleRemoteRetry = () => {
|
|
92
|
+
if (retryTimer || wc.isDestroyed()) return;
|
|
93
|
+
retryTimer = setTimeout(async () => {
|
|
94
|
+
retryTimer = null;
|
|
95
|
+
if (wc.isDestroyed()) return;
|
|
96
|
+
let url = "";
|
|
97
|
+
try { url = wc.getURL(); } catch {}
|
|
98
|
+
if (!url.startsWith("file://")) return; // already back on remote
|
|
99
|
+
let ok = false;
|
|
100
|
+
try {
|
|
101
|
+
const { net } = require("electron");
|
|
102
|
+
const res = await net.fetch(REMOTE_HOMEPAGE + "api/version", { cache: "no-store", signal: AbortSignal.timeout(15000) });
|
|
103
|
+
ok = res.ok;
|
|
104
|
+
} catch {}
|
|
105
|
+
if (ok) {
|
|
106
|
+
log.info("[homepage] remote reachable again → leaving bundled snapshot");
|
|
107
|
+
settled = false;
|
|
108
|
+
try { wc.loadURL(REMOTE_HOMEPAGE); } catch {}
|
|
109
|
+
setTimeout(() => toLocal("remote retry did not load"), REMOTE_FALLBACK_MS);
|
|
110
|
+
} else {
|
|
111
|
+
scheduleRemoteRetry();
|
|
112
|
+
}
|
|
113
|
+
}, REMOTE_RETRY_MS);
|
|
114
|
+
};
|
|
115
|
+
wc.once("destroyed", () => { if (retryTimer) clearTimeout(retryTimer); });
|
|
79
116
|
wc.on("did-fail-load", (_e, code, desc, _url, isMainFrame) => {
|
|
80
117
|
if (isMainFrame) toLocal(`${code} ${desc}`);
|
|
81
118
|
});
|
|
82
|
-
wc.
|
|
119
|
+
wc.on("did-finish-load", () => { settled = true; });
|
|
83
120
|
setTimeout(() => toLocal(`no load within ${REMOTE_FALLBACK_MS}ms`), REMOTE_FALLBACK_MS);
|
|
84
121
|
}
|
|
85
122
|
|
package/src/main.js
CHANGED
|
@@ -972,6 +972,10 @@ function ensureAutoLaunch() {
|
|
|
972
972
|
// whose app went away (e.g. an installer closed it) never came back, not
|
|
973
973
|
// even after a reboot. When the API did not take, write the Run key.
|
|
974
974
|
if (probe().openAtLogin !== want) ensureWindowsRunKey(want, runCmd);
|
|
975
|
+
// The Run key only fires at logon, so it cannot recover a session that is
|
|
976
|
+
// already logged in — which is exactly the case that hurt (see
|
|
977
|
+
// ensureWindowsWatchdog). Packaged only: source mode starts via bin/.
|
|
978
|
+
if (!fromSource) ensureWindowsWatchdog(want);
|
|
975
979
|
} else if (process.platform === "linux") {
|
|
976
980
|
if (!electronApp.isPackaged) return;
|
|
977
981
|
ensureLinuxAutostart(want);
|
|
@@ -1002,6 +1006,78 @@ function ensureWindowsRunKey(want, command) {
|
|
|
1002
1006
|
}
|
|
1003
1007
|
}
|
|
1004
1008
|
|
|
1009
|
+
// Bring the app back while the user is still logged in.
|
|
1010
|
+
//
|
|
1011
|
+
// Autostart alone cannot do this. HKCU\...\Run fires at LOGON, so a machine
|
|
1012
|
+
// whose app went away mid-session — an update that installed but never
|
|
1013
|
+
// relaunched, a crash, a stray taskkill — stays down until somebody signs in
|
|
1014
|
+
// again. That is precisely when the app is unreachable, so nothing inside it
|
|
1015
|
+
// can fix it: the watchdog has to live outside the process.
|
|
1016
|
+
//
|
|
1017
|
+
// A scheduled task every minute runs a VBS that asks WMI whether the exe is
|
|
1018
|
+
// running and starts it hidden if not. /it (interactive only) because it
|
|
1019
|
+
// launches a GUI app — a session-less run would start something invisible that
|
|
1020
|
+
// nobody can use. The battery defaults are cleared afterwards: a watchdog that
|
|
1021
|
+
// stands down when a laptop is unplugged is not a watchdog.
|
|
1022
|
+
const WIN_WATCHDOG_TASK = "CiCyDesktopWatchdog";
|
|
1023
|
+
|
|
1024
|
+
function ensureWindowsWatchdog(want) {
|
|
1025
|
+
try {
|
|
1026
|
+
const { execFile } = require("child_process");
|
|
1027
|
+
if (!want) {
|
|
1028
|
+
execFile("schtasks", ["/delete", "/tn", WIN_WATCHDOG_TASK, "/f"], { windowsHide: true }, () => {});
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
// LOCALAPPDATA has no spaces, so the /tr value needs no inner quoting —
|
|
1032
|
+
// which is where these commands usually break.
|
|
1033
|
+
const dir = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
1034
|
+
const vbs = path.join(dir, "cicy-desktop-watchdog.vbs");
|
|
1035
|
+
const exe = process.execPath;
|
|
1036
|
+
const lines = [
|
|
1037
|
+
'Set sh = CreateObject("WScript.Shell")',
|
|
1038
|
+
"On Error Resume Next",
|
|
1039
|
+
'Set wmi = GetObject("winmgmts:\\\\.\\root\\cimv2")',
|
|
1040
|
+
"If Err.Number <> 0 Then WScript.Quit",
|
|
1041
|
+
`Set procs = wmi.ExecQuery("SELECT ProcessId FROM Win32_Process WHERE Name = '${path.basename(exe)}'")`,
|
|
1042
|
+
"n = 0",
|
|
1043
|
+
"For Each p In procs",
|
|
1044
|
+
" n = n + 1",
|
|
1045
|
+
"Next",
|
|
1046
|
+
`If n = 0 Then sh.Run """${exe}"" --hidden", 0, False`,
|
|
1047
|
+
"",
|
|
1048
|
+
];
|
|
1049
|
+
fs.writeFileSync(vbs, lines.join("\r\n"), "latin1");
|
|
1050
|
+
|
|
1051
|
+
// Build the whole task through the Scheduler COM object in one shot.
|
|
1052
|
+
// `schtasks /mo 1` looks like it sets a one-minute cadence and does not —
|
|
1053
|
+
// the service normalises it back to five, so a killed app could sit dead
|
|
1054
|
+
// for up to five minutes. New-ScheduledTaskTrigger with an explicit
|
|
1055
|
+
// RepetitionInterval is the only thing that actually takes (verified live:
|
|
1056
|
+
// interval=PT1M). The battery flags, which schtasks cannot express at all,
|
|
1057
|
+
// are set in the same call.
|
|
1058
|
+
const q = (str) => "'" + String(str).replace(/'/g, "''") + "'";
|
|
1059
|
+
const ps =
|
|
1060
|
+
`$a=New-ScheduledTaskAction -Execute 'wscript.exe' -Argument ('//B //Nologo ' + ${q(vbs)});` +
|
|
1061
|
+
`$t=New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 1);` +
|
|
1062
|
+
`$p=New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive;` +
|
|
1063
|
+
`$s=New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew;` +
|
|
1064
|
+
`Register-ScheduledTask -TaskName ${WIN_WATCHDOG_TASK} -Action $a -Trigger $t -Principal $p -Settings $s -Force | Out-Null`;
|
|
1065
|
+
execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }, (err) => {
|
|
1066
|
+
if (err) {
|
|
1067
|
+
// Fall back to schtasks (five-minute cadence, but a live watchdog beats
|
|
1068
|
+
// none) so a PowerShell-less box is still covered.
|
|
1069
|
+
log.warn(`[watchdog] Register-ScheduledTask failed (${err.message}); falling back to schtasks`);
|
|
1070
|
+
execFile("schtasks", ["/create", "/f", "/tn", WIN_WATCHDOG_TASK, "/sc", "minute", "/mo", "1", "/it",
|
|
1071
|
+
"/tr", `wscript.exe //B //Nologo ${vbs}`], { windowsHide: true }, () => {});
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
log.info(`[watchdog] ${WIN_WATCHDOG_TASK} every 1 min (PT1M) → ${vbs}`);
|
|
1075
|
+
});
|
|
1076
|
+
} catch (e) {
|
|
1077
|
+
log.warn(`[watchdog] ensureWindowsWatchdog threw: ${e.message}`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1005
1081
|
// 源码模式 Windows 登录自启的隐藏启动器(VBS)。写到 %LOCALAPPDATA%\cicy-desktop,幂等覆盖。
|
|
1006
1082
|
// 继承当前的 CICY_DEBUG(用 start-cicy-desktop-win.bat 起的就带 =1),其余环境用登录时的用户环境。
|
|
1007
1083
|
function writeSourceAutostartVbs() {
|
|
@@ -336,9 +336,13 @@ class TabManager {
|
|
|
336
336
|
this.win.on("enter-full-screen", () => this.sendFullscreen(true));
|
|
337
337
|
this.win.on("leave-full-screen", () => this.sendFullscreen(false));
|
|
338
338
|
this.win.on("resize", () => this.layout());
|
|
339
|
+
// `closed` fires after the window's webContents is gone, so reading
|
|
340
|
+
// this.win.webContents there throws "Object has been destroyed" and the
|
|
341
|
+
// manager registries never get cleaned up. Capture the id up front.
|
|
342
|
+
const hostWcId = this.win.webContents.id;
|
|
339
343
|
this.win.on("closed", () => {
|
|
340
344
|
managers.delete(accountIdx);
|
|
341
|
-
managerByHost.delete(
|
|
345
|
+
managerByHost.delete(hostWcId);
|
|
342
346
|
});
|
|
343
347
|
}
|
|
344
348
|
|
|
@@ -46,18 +46,31 @@ test("the relaunch is part of the same detached chain, so it outlives the app",
|
|
|
46
46
|
assert.match(win, /windowsHide: true/);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
test("the chain
|
|
50
|
-
//
|
|
51
|
-
//
|
|
49
|
+
test("the chain is quit → install → settle → relaunch, in that order", () => {
|
|
50
|
+
// Three waits, each load-bearing:
|
|
51
|
+
// 8s — this process must be GONE before the installer starts. Racing it is
|
|
52
|
+
// what made installs fail, and a failed install fed the restart loop
|
|
53
|
+
// that took the fleet down.
|
|
54
|
+
// 20s — let Windows release the replaced files before the new exe runs.
|
|
55
|
+
// No `start` on the installer leg, so cmd blocks until the install finishes.
|
|
52
56
|
assert.doesNotMatch(win, /start "" "\$\{installer\}"/);
|
|
57
|
+
assert.match(win, /timeout \/t 8 \/nobreak >nul & "\$\{installer\}" \/S/);
|
|
53
58
|
assert.match(win, /timeout \/t 20 \/nobreak/);
|
|
54
|
-
const
|
|
55
|
-
|
|
59
|
+
const preWait = win.indexOf("timeout /t 8");
|
|
60
|
+
const install = win.indexOf('"${installer}" /S');
|
|
61
|
+
const settle = win.indexOf("timeout /t 20");
|
|
62
|
+
const relaunch = win.indexOf('start "" "${exe}"');
|
|
63
|
+
assert.ok(
|
|
64
|
+
preWait < install && install < settle && settle < relaunch,
|
|
65
|
+
"quit → install → settle → relaunch"
|
|
66
|
+
);
|
|
56
67
|
});
|
|
57
68
|
|
|
58
|
-
test("the app quits
|
|
69
|
+
test("the app quits promptly, well inside the chain's 8s head start", () => {
|
|
59
70
|
assert.match(win, /app\.quit\(\)/);
|
|
60
|
-
|
|
71
|
+
const m = win.match(/\}, (\d+)\);/);
|
|
72
|
+
assert.ok(m, "quit is scheduled");
|
|
73
|
+
assert.ok(Number(m[1]) < 8000, `quit delay ${m[1]}ms must be under the 8s head start`);
|
|
61
74
|
});
|
|
62
75
|
|
|
63
76
|
test("mac and linux keep their existing behaviour", () => {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Copyright 2026 CiCy AI
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// Auto-install kills the app by design — an installer cannot replace a running
|
|
5
|
+
// exe. So when an install does NOT land, the app comes back on the old version,
|
|
6
|
+
// the next check() tries the same thing, and the machine sits in a restart loop
|
|
7
|
+
// that closes the user's window every time. That is exactly what happened: the
|
|
8
|
+
// fleet flapped up/down until every node was gone. A destructive automatic
|
|
9
|
+
// action needs a stop condition, and it had none.
|
|
10
|
+
const test = require("node:test");
|
|
11
|
+
const assert = require("node:assert/strict");
|
|
12
|
+
const fs = require("node:fs");
|
|
13
|
+
const path = require("node:path");
|
|
14
|
+
|
|
15
|
+
const src = fs.readFileSync(path.join(__dirname, "..", "src", "app-updater.js"), "utf8");
|
|
16
|
+
|
|
17
|
+
test("the same version is not auto-installed forever", () => {
|
|
18
|
+
assert.match(src, /AUTO_TRY_MAX = 2/);
|
|
19
|
+
assert.match(src, /AUTO_TRY_COOLDOWN_MS = 6 \* 60 \* 60 \* 1000/);
|
|
20
|
+
const fn = src.slice(src.indexOf("function autoInstallAllowed"), src.indexOf("// ── 状态机"));
|
|
21
|
+
assert.match(fn, /if \(t\.version !== version\) return true/); // a new version always may
|
|
22
|
+
assert.match(fn, /if \(\(t\.count \|\| 0\) < AUTO_TRY_MAX\) return true/);
|
|
23
|
+
assert.match(fn, /Date\.now\(\) - \(t\.at \|\| 0\) > AUTO_TRY_COOLDOWN_MS/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("the attempt count survives the restart the install itself causes", () => {
|
|
27
|
+
// In-memory state would reset on every relaunch, which is precisely the thing
|
|
28
|
+
// the loop does — so the counter has to be on disk.
|
|
29
|
+
assert.match(src, /desktopAutoInstall: \{ version, count, at: Date\.now\(\) \}/);
|
|
30
|
+
assert.match(src, /readGlobalConfig\(GLOBAL_JSON\)\?\.desktopAutoInstall/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("check() gates the auto path and still offers the update", () => {
|
|
34
|
+
const chk = src.slice(
|
|
35
|
+
src.indexOf("async function check()"),
|
|
36
|
+
src.indexOf("async function downloadUpdate")
|
|
37
|
+
);
|
|
38
|
+
assert.match(chk, /if \(!autoInstallAllowed\(latest\)\) \{/);
|
|
39
|
+
assert.match(chk, /noteAutoTry\(latest\)/);
|
|
40
|
+
// blocked = do not install; the "available" broadcast above still happened, so
|
|
41
|
+
// the user can update by hand.
|
|
42
|
+
assert.ok(
|
|
43
|
+
chk.indexOf("autoInstallAllowed") < chk.indexOf("await downloadUpdate()"),
|
|
44
|
+
"the gate must come before the download"
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a landed install clears the record", () => {
|
|
49
|
+
const init = src.slice(
|
|
50
|
+
src.indexOf("function init(mainWin)"),
|
|
51
|
+
src.indexOf("async function check()")
|
|
52
|
+
);
|
|
53
|
+
assert.match(init, /readAutoTry\(\)\.version === _state\.current/);
|
|
54
|
+
assert.match(init, /clearAutoTry\(\)/);
|
|
55
|
+
});
|
package/tools/wsd
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""wsd — drive the desktop fleet over the homepage's realtime socket.
|
|
3
|
+
|
|
4
|
+
The primary channel. Unlike agent-desktop (needs a local cicy-code) and
|
|
5
|
+
hub-desktop (needs a hub login), this one has no precondition: every desktop
|
|
6
|
+
loads its homepage from the Worker and dials straight back, so it is reachable
|
|
7
|
+
whenever the app is running at all.
|
|
8
|
+
|
|
9
|
+
wsd ls | nodes who is connected right now
|
|
10
|
+
wsd exec <target> <shell command...> run a shell command there
|
|
11
|
+
wsd team <target> <name> declare which machine this is
|
|
12
|
+
wsd rpc <target> <tool> [json-args] call an Electron tool there
|
|
13
|
+
wsd js <target> <file|-|'code'> run async JS in the page
|
|
14
|
+
wsd main <target> <file|-|'code'> run JS in the Electron MAIN process
|
|
15
|
+
|
|
16
|
+
<target> is a team (desktop-xs-1001, or just xs-1001), a hostname, a cid, or `all`.
|
|
17
|
+
Add --json anywhere for machine-readable output.
|
|
18
|
+
"""
|
|
19
|
+
import json, os, sys, urllib.request, urllib.error
|
|
20
|
+
|
|
21
|
+
CFG = os.path.expanduser("~/cicy-ai/db/desktop-ctrl.json")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cfg():
|
|
25
|
+
try:
|
|
26
|
+
with open(CFG) as f:
|
|
27
|
+
c = json.load(f)
|
|
28
|
+
return c["base"].rstrip("/"), c["token"]
|
|
29
|
+
except Exception as e:
|
|
30
|
+
sys.exit(f"wsd: cannot read {CFG}: {e}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def call(path, payload=None, timeout=130):
|
|
34
|
+
base, tok = cfg()
|
|
35
|
+
data = json.dumps(payload).encode() if payload is not None else None
|
|
36
|
+
req = urllib.request.Request(
|
|
37
|
+
base + path, data=data,
|
|
38
|
+
# Cloudflare's bot check answers urllib's default UA with 1010, so
|
|
39
|
+
# present a normal one — this is our own origin, not a third party.
|
|
40
|
+
headers={"x-cicy-ctrl": tok, "content-type": "application/json",
|
|
41
|
+
"user-agent": "Mozilla/5.0 (X11; Linux x86_64) wsd/1"},
|
|
42
|
+
method="POST" if data else "GET")
|
|
43
|
+
try:
|
|
44
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
45
|
+
return json.loads(r.read().decode())
|
|
46
|
+
except urllib.error.HTTPError as e:
|
|
47
|
+
body = e.read().decode()[:400]
|
|
48
|
+
sys.exit(f"wsd: HTTP {e.code} {body}")
|
|
49
|
+
except Exception as e:
|
|
50
|
+
sys.exit(f"wsd: {e}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def body(a):
|
|
54
|
+
"""A literal, a file path, or - for stdin."""
|
|
55
|
+
if a == "-":
|
|
56
|
+
return sys.stdin.read()
|
|
57
|
+
return open(a).read() if os.path.exists(a) else a
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
JSON = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def show(res):
|
|
64
|
+
if JSON:
|
|
65
|
+
print(json.dumps(res, ensure_ascii=False, indent=2)); return
|
|
66
|
+
_show(res)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _show(res):
|
|
70
|
+
for r in res.get("results", []):
|
|
71
|
+
out = r.get("out")
|
|
72
|
+
if isinstance(out, str) and out and out[0] in "{[":
|
|
73
|
+
try:
|
|
74
|
+
out = json.dumps(json.loads(out), ensure_ascii=False)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
print(f"{'ok ' if r.get('ok') else 'ERR'} {r.get('host','?'):<18} {out}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main():
|
|
81
|
+
global JSON
|
|
82
|
+
argv = [x for x in sys.argv[1:] if x != "--json"]
|
|
83
|
+
JSON = "--json" in sys.argv
|
|
84
|
+
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
85
|
+
sys.exit(__doc__)
|
|
86
|
+
cmd, a = argv[0], argv[1:]
|
|
87
|
+
|
|
88
|
+
if cmd in ("ls", "nodes"):
|
|
89
|
+
d = call("/api/fleet")
|
|
90
|
+
peers = d.get("peers", [])
|
|
91
|
+
if JSON:
|
|
92
|
+
print(json.dumps(peers, ensure_ascii=False, indent=2)); return
|
|
93
|
+
print(f"build={d.get('build')} peers={len(peers)}")
|
|
94
|
+
for p in peers:
|
|
95
|
+
who = p.get("team") or (p.get("host", "?") + " (no team)")
|
|
96
|
+
print(f" {who:<26} v={p.get('v') or '?':<9} {p.get('plat','?'):<6} "
|
|
97
|
+
f"auto={p.get('auto')} up={p.get('upSec')}s idle={p.get('idleSec')}s "
|
|
98
|
+
f"ip={p.get('ip')} cid={p.get('cid')}")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
if cmd == "team":
|
|
102
|
+
# Label a machine that predates the login gate. Identity is declared, so
|
|
103
|
+
# this writes it where the page reads it and the socket reports it.
|
|
104
|
+
if len(a) < 2:
|
|
105
|
+
sys.exit("usage: wsd team <target> <name>")
|
|
106
|
+
# Same namespacing the homepage applies: typed bare, stored prefixed.
|
|
107
|
+
name = a[1] if a[1].startswith("desktop-") else "desktop-" + a[1]
|
|
108
|
+
code = ('(()=>{const r=process.mainModule.require.bind(process.mainModule);'
|
|
109
|
+
'const os=r("os"),fs=r("fs"),path=r("path");'
|
|
110
|
+
'const p=path.join(os.homedir(),"cicy-ai","global.json");'
|
|
111
|
+
'let c={};try{c=JSON.parse(fs.readFileSync(p,"utf8"))}catch(e){}'
|
|
112
|
+
'c.desktopTeam=' + json.dumps(name) + ';'
|
|
113
|
+
'fs.writeFileSync(p,JSON.stringify(c,null,2));'
|
|
114
|
+
'return JSON.stringify({host:os.hostname(),team:c.desktopTeam})})()')
|
|
115
|
+
wrapper = (
|
|
116
|
+
"const g = await window.electronRPC('get_windows', {});"
|
|
117
|
+
"const gt = ((g&&g.content)||[]).map(c=>c&&c.text).join('');"
|
|
118
|
+
"let wid = 1; try { const ws = JSON.parse(gt); if (ws && ws.length) wid = ws[0].id; } catch (e) {}"
|
|
119
|
+
"const r = await window.electronRPC('control_electron_BrowserWindow',"
|
|
120
|
+
"{win_id: wid, code: " + json.dumps(code) + "});"
|
|
121
|
+
"const out = ((r&&r.content)||[]).map(c=>c&&c.text).join('');"
|
|
122
|
+
# Re-announce immediately; otherwise the label only lands on the next reconnect.
|
|
123
|
+
"try { window.__cicyFleetRelabel && window.__cicyFleetRelabel(); } catch (e) {}"
|
|
124
|
+
"return out;")
|
|
125
|
+
return show(call("/api/rpc", {"target": a[0], "js": wrapper}))
|
|
126
|
+
|
|
127
|
+
if cmd == "exec":
|
|
128
|
+
if len(a) < 2:
|
|
129
|
+
sys.exit("usage: wsd exec <target> <shell command...>")
|
|
130
|
+
return show(call("/api/rpc", {"target": a[0], "tool": "exec_shell",
|
|
131
|
+
"args": {"command": " ".join(a[1:])}}))
|
|
132
|
+
|
|
133
|
+
if cmd == "rpc":
|
|
134
|
+
if len(a) < 2:
|
|
135
|
+
sys.exit("usage: wsd rpc <target> <tool> [json-args]")
|
|
136
|
+
args = json.loads(a[2]) if len(a) > 2 else {}
|
|
137
|
+
return show(call("/api/rpc", {"target": a[0], "tool": a[1], "args": args}))
|
|
138
|
+
|
|
139
|
+
if cmd == "js":
|
|
140
|
+
if len(a) < 2:
|
|
141
|
+
sys.exit("usage: wsd js <target> <file|-|code>")
|
|
142
|
+
return show(call("/api/rpc", {"target": a[0], "js": body(a[1])}))
|
|
143
|
+
|
|
144
|
+
if cmd == "main":
|
|
145
|
+
if len(a) < 2:
|
|
146
|
+
sys.exit("usage: wsd main <target> <file|-|code>")
|
|
147
|
+
# Reaches the Electron MAIN process. The homepage bridge is the
|
|
148
|
+
# unguarded one, so this is a plain call — no consent gate. Two things
|
|
149
|
+
# bite here: there is no bare `require` in the eval sandbox (use
|
|
150
|
+
# process.mainModule.require), and the window id is NOT 1 everywhere —
|
|
151
|
+
# it is per-process, so resolve it instead of assuming.
|
|
152
|
+
code = body(a[1])
|
|
153
|
+
wrapper = (
|
|
154
|
+
"const g = await window.electronRPC('get_windows', {});"
|
|
155
|
+
"const gt = ((g&&g.content)||[]).map(c=>c&&c.text).join('');"
|
|
156
|
+
"let wid = 1; try { const ws = JSON.parse(gt); if (ws && ws.length) wid = ws[0].id; } catch (e) {}"
|
|
157
|
+
"const r = await window.electronRPC('control_electron_BrowserWindow',"
|
|
158
|
+
"{win_id: wid, code: " + json.dumps(code) + "});"
|
|
159
|
+
"return ((r&&r.content)||[]).map(c=>c&&c.text).join('');")
|
|
160
|
+
return show(call("/api/rpc", {"target": a[0], "js": wrapper}))
|
|
161
|
+
|
|
162
|
+
sys.exit(f"wsd: unknown command {cmd!r}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
main()
|
|
@@ -1,11 +1,42 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
2
|
+
<html lang="en" data-cicy-stamp="__BUILD_STAMP__">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<link rel="icon" type="image/png" sizes="256x256" href="/favicon-256.png" />
|
|
8
8
|
<title>CiCy Desktop</title>
|
|
9
|
+
<!-- Deployment watchdog. Deliberately INLINE and outside the bundle:
|
|
10
|
+
the reload used to live in a React hook, so a bundle that failed to
|
|
11
|
+
render took the watchdog down with it and that machine could never learn
|
|
12
|
+
a fix had shipped — it stayed on the broken build forever, reachable only
|
|
13
|
+
by someone pressing reload. Here it runs even if nothing else does.
|
|
14
|
+
Plain ES5, no imports, everything in try/catch: it must never be the
|
|
15
|
+
thing that breaks. -->
|
|
16
|
+
<script>
|
|
17
|
+
(function () {
|
|
18
|
+
var STAMP = "20260903085137"; // filled in by the Worker on the way out
|
|
19
|
+
window.__CICY_BUILD__ = STAMP; // the bundle's socket reuses this, one source of truth
|
|
20
|
+
// The socket learns the deployed build on every connect, so this is only
|
|
21
|
+
// the floor for a bundle too broken to hold a socket open. Keep it rare.
|
|
22
|
+
var EVERY = 600000;
|
|
23
|
+
function check() {
|
|
24
|
+
try {
|
|
25
|
+
fetch("./api/version?t=" + Date.now(), { cache: "no-store" })
|
|
26
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
27
|
+
.then(function (j) {
|
|
28
|
+
// Numeric compare, and only ever forward: a stale page reloads,
|
|
29
|
+
// a page newer than the server (mid-deploy race) sits still.
|
|
30
|
+
var mine = parseInt(STAMP, 10), theirs = parseInt(j && j.build, 10);
|
|
31
|
+
if (mine && theirs && theirs > mine) { location.reload(true); }
|
|
32
|
+
})
|
|
33
|
+
.catch(function () {});
|
|
34
|
+
} catch (e) {}
|
|
35
|
+
}
|
|
36
|
+
setInterval(check, EVERY);
|
|
37
|
+
setTimeout(check, 5000);
|
|
38
|
+
})();
|
|
39
|
+
</script>
|
|
9
40
|
</head>
|
|
10
41
|
<body>
|
|
11
42
|
<div id="root"></div>
|