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.
@@ -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
- <script type="module" crossorigin src="./assets/index-CppodgBn.js"></script>
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
- const REMOTE_FALLBACK_MS = 15000;
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.once("did-finish-load", () => { settled = true; });
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(this.win.webContents.id);
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 waits for the installer before relaunching", () => {
50
- // No `start` on the installer leg → cmd blocks on it; then a pause so Windows
51
- // releases the replaced files before the new exe runs.
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 order = [win.indexOf("/S"), win.indexOf("timeout /t"), win.indexOf('start "" "${exe}"')];
55
- assert.ok(order[0] < order[1] && order[1] < order[2], "install → wait → relaunch, in that order");
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 so the installer can replace its files", () => {
69
+ test("the app quits promptly, well inside the chain's 8s head start", () => {
59
70
  assert.match(win, /app\.quit\(\)/);
60
- assert.match(win, /setTimeout\(\(\) => \{ try \{ app\.quit\(\); \} catch \{\} \}, 1500\)/);
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>