cicy-desktop 2.1.78 → 2.1.79

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.
Files changed (54) hide show
  1. package/bin/cicy-desktop +7 -7
  2. package/package.json +6 -6
  3. package/src/backends/homepage-preload.js +22 -0
  4. package/src/backends/homepage-react/assets/index-CKpaMBKz.css +1 -0
  5. package/src/backends/homepage-react/assets/index-CSsNZgC5.js +365 -0
  6. package/src/backends/homepage-react/index.html +2 -2
  7. package/src/backends/homepage-window.js +52 -7
  8. package/src/backends/ipc.js +57 -0
  9. package/src/backends/local-teams.js +73 -26
  10. package/src/backends/sidecar-ipc.js +11 -0
  11. package/src/backends/webview-preload.js +5 -3
  12. package/src/backends/window-manager.js +13 -3
  13. package/src/chrome/chrome-launcher.js +5 -4
  14. package/src/chrome/debugger-port-resolver.js +1 -1
  15. package/src/cloud/cloud-client.js +237 -41
  16. package/src/cluster/types.js +0 -5
  17. package/src/extension/inject.js +1 -1
  18. package/src/main.js +282 -88
  19. package/src/master/chrome-config.js +2 -2
  20. package/src/preload-rpc.js +1 -1
  21. package/src/profiles/profile-store.js +321 -0
  22. package/src/profiles/trusted-origins-store.js +95 -0
  23. package/src/server/worker-observability-routes.js +0 -2
  24. package/src/sidecar/cicy-code.js +84 -23
  25. package/src/sidecar/localbin.js +20 -3
  26. package/src/sidecar/native.js +3 -3
  27. package/src/sidecar/version.js +45 -0
  28. package/src/tabbrowser/newtab-protocol.js +54 -0
  29. package/src/tabbrowser/tab-browser.html +151 -0
  30. package/src/tabbrowser/tab-shell-preload.js +28 -0
  31. package/src/tabbrowser/tab-shell.html +227 -0
  32. package/src/tools/account-tools.js +191 -25
  33. package/src/tools/chrome-tools.js +173 -37
  34. package/src/tools/device-tools.js +25 -0
  35. package/src/tools/index.js +2 -0
  36. package/src/tools/tab-browser-tools.js +453 -0
  37. package/src/tools/window-tools.js +64 -7
  38. package/src/utils/brand-host-electron.js +25 -0
  39. package/src/utils/context-menu-options.js +80 -0
  40. package/src/utils/cookie-logins.js +58 -0
  41. package/src/utils/ip-probe.js +50 -0
  42. package/src/utils/rpc-audit.js +53 -0
  43. package/src/utils/rpc-guard.js +189 -0
  44. package/src/utils/window-monitor.js +5 -15
  45. package/src/utils/window-registry.js +210 -0
  46. package/src/utils/window-thumbnails.js +126 -0
  47. package/src/utils/window-utils.js +146 -109
  48. package/workers/render/package-lock.json +6 -6
  49. package/workers/render/src/App.css +36 -2
  50. package/workers/render/src/App.jsx +587 -103
  51. package/src/backends/artifact-ipc.js +0 -142
  52. package/src/backends/homepage-react/assets/index-DE9m6JTn.css +0 -1
  53. package/src/backends/homepage-react/assets/index-DLYMzgf5.js +0 -365
  54. package/src/cluster/artifact-registry.js +0 -61
@@ -0,0 +1,58 @@
1
+ // Shared "which sites is this session logged into?" summarizer for BOTH backends.
2
+ //
3
+ // Input: an array of cookies, each with at least { name, domain }. Both
4
+ // CDP Storage.getCookies (Chrome) and Electron session.cookies.get() return
5
+ // that shape, so the same rollup serves chrome + electron tools identically.
6
+ //
7
+ // Output: per-registrable-domain counts, flagging domains that carry an
8
+ // auth/session-looking cookie. That flag is a STRONG SIGNAL, not proof — a
9
+ // session cookie can be present while the account is signed out / expired
10
+ // (e.g. github keeping `logged_out` cookies). Confirm by loading the site.
11
+
12
+ // Cookie names that typically indicate an authenticated/session state.
13
+ const AUTH_RE = /sid|session|auth|token|login|sapisid|__secure|remember|passport|csrf/i;
14
+
15
+ // Cheap registrable-domain: last two labels. Good enough for grouping/display;
16
+ // not a full Public Suffix List (e.g. foo.co.uk collapses to co.uk).
17
+ function registrableDomain(host) {
18
+ const h = String(host || "").replace(/^\./, "").toLowerCase();
19
+ if (!h) return "";
20
+ const parts = h.split(".");
21
+ return parts.length <= 2 ? h : parts.slice(-2).join(".");
22
+ }
23
+
24
+ function summarizeCookieLogins(cookies) {
25
+ const list = Array.isArray(cookies) ? cookies : [];
26
+ const byDomain = new Map();
27
+ for (const c of list) {
28
+ const d = registrableDomain(c && c.domain);
29
+ if (!d) continue;
30
+ let e = byDomain.get(d);
31
+ if (!e) {
32
+ e = { domain: d, cookies: 0, authCookies: [] };
33
+ byDomain.set(d, e);
34
+ }
35
+ e.cookies += 1;
36
+ if (c && c.name && AUTH_RE.test(c.name) && !e.authCookies.includes(c.name)) {
37
+ e.authCookies.push(c.name);
38
+ }
39
+ }
40
+ const sites = [...byDomain.values()]
41
+ .map((e) => ({
42
+ domain: e.domain,
43
+ cookies: e.cookies,
44
+ loggedIn: e.authCookies.length > 0,
45
+ authCookies: e.authCookies,
46
+ }))
47
+ .sort((a, b) => b.cookies - a.cookies);
48
+
49
+ return {
50
+ totalCookies: list.length,
51
+ domains: sites.length,
52
+ loggedIn: sites.filter((s) => s.loggedIn).map((s) => s.domain),
53
+ sites,
54
+ note: "loggedIn = 该域名带会话 cookie,强信号但非确证(cookie 在≠一定登录),可打开站点确认",
55
+ };
56
+ }
57
+
58
+ module.exports = { summarizeCookieLogins, AUTH_RE, registrableDomain };
@@ -0,0 +1,50 @@
1
+ // Per-profile egress IP + geo probe. Fetches an IP-info API THROUGH a given
2
+ // Electron session (so the result reflects that profile's proxy egress), in the
3
+ // main process via net.request (no browser launch needed). Returns
4
+ // { ip, area } — area is "country · region · city" (blank parts dropped).
5
+ //
6
+ // NOTE: the opposite of cloud-client's detectIpGeo (which deliberately goes
7
+ // DIRECT for the *machine's* egress). Here we WANT the proxy path, per profile.
8
+ const { net } = require("electron");
9
+
10
+ function fetchJsonViaSession(url, ses, timeoutMs = 7000) {
11
+ return new Promise((resolve) => {
12
+ let done = false;
13
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
14
+ try {
15
+ const req = net.request({ url, session: ses, useSessionCookies: false });
16
+ const to = setTimeout(() => { try { req.abort(); } catch (_) {} finish(null); }, timeoutMs);
17
+ let body = "";
18
+ req.on("response", (res) => {
19
+ res.on("data", (d) => { body += d; });
20
+ res.on("end", () => { clearTimeout(to); try { finish(JSON.parse(body)); } catch (_) { finish(null); } });
21
+ res.on("error", () => { clearTimeout(to); finish(null); });
22
+ });
23
+ req.on("error", () => { clearTimeout(to); finish(null); });
24
+ req.end();
25
+ } catch (_) { finish(null); }
26
+ });
27
+ }
28
+
29
+ const joinArea = (...parts) => parts.filter((p) => typeof p === "string" && p.trim()).join(" · ");
30
+
31
+ // Probe egress IP + geo via the given session. Primary: api.myip.com
32
+ // ({ ip, country, cc }) — same endpoint as `agent-chrome ip`. Falls back to
33
+ // ip-api.com (adds region/city) then ipify (ip only). Never throws.
34
+ async function probeIpViaSession(ses) {
35
+ let j = await fetchJsonViaSession("https://api.myip.com", ses);
36
+ if (j && typeof j.ip === "string" && j.ip) {
37
+ return { ip: j.ip, area: joinArea(j.cc, j.country) };
38
+ }
39
+ j = await fetchJsonViaSession("http://ip-api.com/json", ses);
40
+ if (j && typeof j.query === "string" && j.query) {
41
+ return { ip: j.query, area: joinArea(j.country, j.regionName, j.city) };
42
+ }
43
+ j = await fetchJsonViaSession("https://api.ipify.org?format=json", ses);
44
+ if (j && typeof j.ip === "string" && j.ip) {
45
+ return { ip: j.ip, area: "" };
46
+ }
47
+ return { ip: "", area: "" };
48
+ }
49
+
50
+ module.exports = { probeIpViaSession, fetchJsonViaSession };
@@ -0,0 +1,53 @@
1
+ // RPC audit log — an append-only record of (a) every electronRPC tool call and
2
+ // (b) every authorization decision (including the TEMPORARY ones — "本次允许" /
3
+ // "允许一次" / "本页面内允许" — which otherwise live only in memory and leave no
4
+ // trace), plus trusted-origin allowlist add/remove. Written as JSONL to
5
+ // ~/cicy-ai/db/rpc-audit.log (mode 0600), rotated at 5 MB.
6
+ //
7
+ // Security intent: the RPC bridge can run host code / read-write files, so who
8
+ // authorized what, when, and which calls actually ran must be reviewable after
9
+ // the fact — not just gated by an in-the-moment modal.
10
+ const fs = require("fs");
11
+ const os = require("os");
12
+ const path = require("path");
13
+
14
+ const LOG = path.join(os.homedir(), "cicy-ai", "db", "rpc-audit.log");
15
+ const MAX_BYTES = 5 * 1024 * 1024; // rotate to .1 past this
16
+
17
+ function rotateIfNeeded() {
18
+ try {
19
+ const st = fs.statSync(LOG);
20
+ if (st.size > MAX_BYTES) {
21
+ try { fs.renameSync(LOG, LOG + ".1"); } catch {}
22
+ }
23
+ } catch {} // missing file → nothing to rotate
24
+ }
25
+
26
+ // Append one record as a JSON line. NEVER throws — auditing must not break RPC.
27
+ function audit(entry) {
28
+ try {
29
+ fs.mkdirSync(path.dirname(LOG), { recursive: true });
30
+ rotateIfNeeded();
31
+ const rec = { ts: new Date().toISOString(), ...entry };
32
+ fs.appendFileSync(LOG, JSON.stringify(rec) + "\n", { mode: 0o600 });
33
+ try { fs.chmodSync(LOG, 0o600); } catch {}
34
+ } catch {}
35
+ }
36
+
37
+ // Short, secret-light preview of args for exec_*/file_* (same fields the consent
38
+ // dialog surfaces). Other tools log no args.
39
+ function argsPreview(tool, args) {
40
+ try {
41
+ if (/^exec_/.test(tool)) {
42
+ const c = args && (args.command || args.code || args.script || args.cmd);
43
+ if (c) return String(c).slice(0, 240);
44
+ }
45
+ if (/^file_/.test(tool)) {
46
+ const p = args && (args.path || args.filename || args.file);
47
+ if (p) return String(p).slice(0, 240);
48
+ }
49
+ } catch {}
50
+ return "";
51
+ }
52
+
53
+ module.exports = { LOG, audit, argsPreview };
@@ -0,0 +1,189 @@
1
+ // RPC capability gate (security hole #3 — "trusted source XSS ≠ RCE").
2
+ //
3
+ // The renderer electronRPC bridge can invoke ANY registered tool, including host
4
+ // code/file execution (exec_*, file_*). The homepage is first-party system UI and
5
+ // keeps the unguarded "rpc" channel. EVERY OTHER renderer surface (team-helper
6
+ // <webview>, trusted remote pages, dom-ready injected scripts) is wired to the
7
+ // "rpc:guarded" channel: a *dangerous* tool there requires an explicit, page-
8
+ // scoped user grant. So a trusted origin that gets XSS'd cannot silently run a
9
+ // command — the user sees a consent dialog naming the origin + operation. Normal
10
+ // (non-dangerous) tools pass straight through.
11
+ //
12
+ // NOTE: this gate is only *enforceable* for renderers that have NO direct Node
13
+ // access (contextIsolation:true, no nodeIntegration) — e.g. the team-helper
14
+ // webview and the sandboxed tab-browser trusted tabs. A renderer created with
15
+ // nodeIntegration:true (the legacy createWindow trusted path) can `require()`
16
+ // child_process directly and bypass any IPC gate; closing that requires dropping
17
+ // its nodeIntegration, tracked separately.
18
+ const { dialog, BrowserWindow } = require("electron");
19
+ const { audit } = require("./rpc-audit");
20
+
21
+ // Host code-exec + host filesystem tools = the RCE surface.
22
+ const DANGEROUS_TOOLS = new Set([
23
+ "exec_shell", "exec_python", "exec_node",
24
+ "exec_shell_file", "exec_python_file", "exec_node_file",
25
+ "file_read", "file_write", "file_upload", "file_download",
26
+ ]);
27
+ function isDangerousTool(t) { return DANGEROUS_TOOLS.has(t); }
28
+
29
+ // webContents.id -> granted origin (page-scoped; cleared on cross-doc nav / destroy).
30
+ const _grants = new Map();
31
+
32
+ function originOf(wc) {
33
+ try { return new URL(wc.getURL()).origin; } catch { return wc.getURL() || "(unknown)"; }
34
+ }
35
+
36
+ function previewArgs(tool, args) {
37
+ try {
38
+ if (/^exec_/.test(tool)) {
39
+ const c = args && (args.command || args.code || args.script || args.cmd);
40
+ if (c) return `命令: ${String(c).slice(0, 240)}`;
41
+ }
42
+ if (/^file_/.test(tool)) {
43
+ const p = args && (args.path || args.filename || args.file);
44
+ if (p) return `路径: ${String(p).slice(0, 240)}`;
45
+ }
46
+ } catch {}
47
+ return "";
48
+ }
49
+
50
+ // Returns true if the dangerous call is allowed (granted now or earlier for this
51
+ // page), false if the user denied it.
52
+ async function ensureRpcGrant(event, toolName, args) {
53
+ const wc = event && event.sender;
54
+ if (!wc || wc.isDestroyed()) return false;
55
+ const origin = originOf(wc);
56
+ if (_grants.get(wc.id) === origin) return true; // already allowed for this page
57
+
58
+ const win = BrowserWindow.fromWebContents(wc) || BrowserWindow.getFocusedWindow() || null;
59
+ const detail = [`来源: ${origin}`, `操作: ${toolName}`];
60
+ const pv = previewArgs(toolName, args);
61
+ if (pv) detail.push(pv);
62
+ detail.push("", "该站点请求在你的电脑上执行命令 / 读写文件。只有你完全信任它时才允许。");
63
+
64
+ let choice;
65
+ try {
66
+ choice = await dialog.showMessageBox(win, {
67
+ type: "warning",
68
+ noLink: true,
69
+ buttons: ["拒绝", "允许一次", "本页面内允许"],
70
+ defaultId: 0,
71
+ cancelId: 0,
72
+ title: "敏感操作请求",
73
+ message: "站点要在本机执行敏感操作",
74
+ detail: detail.join("\n"),
75
+ });
76
+ } catch { return false; }
77
+
78
+ const response = choice && choice.response;
79
+ if (response === 2) { // remember for this page
80
+ _grants.set(wc.id, origin);
81
+ if (!wc.__rpcGuardWired) {
82
+ wc.__rpcGuardWired = true;
83
+ const clear = () => _grants.delete(wc.id);
84
+ wc.on("did-start-navigation", (_e, _url, isInPlace, isMainFrame) => { if (isMainFrame && !isInPlace) clear(); });
85
+ wc.once("destroyed", clear);
86
+ }
87
+ audit({ kind: "auth", gate: "dangerous-tool", origin, tool: toolName, decision: "page-allow", temporary: true, args: pv });
88
+ return true;
89
+ }
90
+ audit({ kind: "auth", gate: "dangerous-tool", origin, tool: toolName, decision: response === 1 ? "allow-once" : "deny", temporary: true, args: pv });
91
+ return response === 1; // allow once
92
+ }
93
+
94
+ // ── Origin allowlist gate (domain-level trust-on-demand) ──────────────────────
95
+ // Distinct from the per-tool DANGEROUS gate above: this decides whether a page's
96
+ // ORIGIN may use the electronRPC bridge AT ALL. The bridge is now injected into
97
+ // every profile-0 tab, but it stays inert until the origin is authorized: a
98
+ // non-allowlisted origin's first rpc:guarded call pops a consent modal where the
99
+ // user can deny, allow for this run only, or add the domain to the persistent
100
+ // trusted-origins allowlist (so it's never asked again). This replaces the old
101
+ // "no bridge unless pre-trusted" behaviour with explicit, on-demand consent.
102
+ const _sessionOrigins = new Set(); // origin -> "本次允许" for this process lifetime
103
+ const _deniedOrigins = new Set(); // origin -> "拒绝" — sticky so a page can't spam modals
104
+ const _pendingByOrigin = new Map(); // origin -> in-flight modal promise (dedup races)
105
+
106
+ // Synchronous verdict for an origin WITHOUT prompting: "allow" | "deny" | "unknown".
107
+ // Lets a non-blocking caller (agent/skill, see main.js) decide instantly whether to
108
+ // proceed, refuse, or return a PENDING sentinel while the modal runs in the bg.
109
+ function originDecision(event) {
110
+ const wc = event && event.sender;
111
+ if (!wc || wc.isDestroyed()) return "deny";
112
+ const url = wc.getURL();
113
+ let isTrustedUrl;
114
+ try { ({ isTrustedUrl } = require("./window-utils")); } catch {}
115
+ if (isTrustedUrl && isTrustedUrl(url)) return "allow"; // on the allowlist
116
+ const origin = originOf(wc);
117
+ if (_sessionOrigins.has(origin)) return "allow"; // "本次允许" earlier
118
+ if (_deniedOrigins.has(origin)) return "deny"; // blocked — settings = escape hatch
119
+ return "unknown";
120
+ }
121
+
122
+ // Ensure the consent modal is running for this origin (deduped per origin). Returns
123
+ // the in-flight promise (resolves true/false). Safe to fire-and-forget: it records
124
+ // the outcome in _sessionOrigins/_deniedOrigins/the allowlist so a later
125
+ // originDecision() resolves it — that's how the agent's poll eventually succeeds.
126
+ function startOriginModal(event) {
127
+ const wc = event && event.sender;
128
+ if (!wc || wc.isDestroyed()) return Promise.resolve(false);
129
+ const origin = originOf(wc);
130
+ if (_pendingByOrigin.has(origin)) return _pendingByOrigin.get(origin); // dedup
131
+ let host = "";
132
+ try { host = new URL(wc.getURL()).hostname; } catch {}
133
+ let refreshTrustedOrigins;
134
+ try { ({ refreshTrustedOrigins } = require("./window-utils")); } catch {}
135
+
136
+ const p = (async () => {
137
+ const win = BrowserWindow.fromWebContents(wc) || BrowserWindow.getFocusedWindow() || null;
138
+ let choice;
139
+ try {
140
+ choice = await dialog.showMessageBox(win, {
141
+ type: "warning",
142
+ noLink: true,
143
+ buttons: ["拒绝", "本次允许", "信任此站点(加入白名单)"],
144
+ defaultId: 0,
145
+ cancelId: 0,
146
+ title: "站点请求桌面 RPC 授权",
147
+ message: "是否授权该站点使用桌面 RPC?",
148
+ detail: [
149
+ `来源: ${origin}`,
150
+ "",
151
+ "授权后,该站点可通过 electronRPC 调用本机工具(执行命令 / 读写文件等)。",
152
+ "只有你完全信任它时才授权。",
153
+ "「信任此站点」会把该域名加入白名单,以后不再询问。",
154
+ ].join("\n"),
155
+ });
156
+ } catch { return false; }
157
+ const r = choice && choice.response;
158
+ if (r === 1) { // 本次允许 — temporary (process lifetime), record it so it isn't trace-less
159
+ _sessionOrigins.add(origin);
160
+ audit({ kind: "auth", gate: "origin", origin, decision: "session-allow", temporary: true });
161
+ return true;
162
+ }
163
+ if (r === 2 && host) { // 加入白名单(持久)— trusted-origins-store.add() logs the allowlist change
164
+ try {
165
+ const res = require("../profiles/trusted-origins-store").add(host);
166
+ if (!res || res.ok === false) return false;
167
+ if (refreshTrustedOrigins) refreshTrustedOrigins();
168
+ _sessionOrigins.add(origin);
169
+ return true;
170
+ } catch { return false; }
171
+ }
172
+ _deniedOrigins.add(origin); // 拒绝 / 关闭 — sticky for the session
173
+ audit({ kind: "auth", gate: "origin", origin, decision: "deny", temporary: true });
174
+ return false;
175
+ })();
176
+ _pendingByOrigin.set(origin, p);
177
+ p.finally(() => _pendingByOrigin.delete(origin));
178
+ return p;
179
+ }
180
+
181
+ // Blocking gate (in-page callers): resolve the verdict, prompting if undecided.
182
+ async function ensureOriginAuthorized(event) {
183
+ const d = originDecision(event);
184
+ if (d === "allow") return true;
185
+ if (d === "deny") return false;
186
+ return await startOriginModal(event);
187
+ }
188
+
189
+ module.exports = { DANGEROUS_TOOLS, isDangerousTool, ensureRpcGrant, ensureOriginAuthorized, originDecision, startOriginModal };
@@ -3,7 +3,6 @@ const path = require("path");
3
3
  const os = require("os");
4
4
  const beautify = require("js-beautify");
5
5
  const log = require("electron-log");
6
- const { maybeRegisterArtifact } = require("../cluster/artifact-registry");
7
6
 
8
7
  // 存储每个窗口的日志和请求
9
8
  const windowLogs = new Map();
@@ -197,20 +196,11 @@ function saveDataToFile(winId, url, content, type, contentType, isBinary, dataSi
197
196
  fs.writeFileSync(filepath, formattedContent);
198
197
  }
199
198
 
200
- return maybeRegisterArtifact(
201
- {
202
- __file: filepath,
203
- __size: dataSize,
204
- __binary: isBinary,
205
- },
206
- {
207
- kind: type,
208
- url,
209
- winId,
210
- contentType,
211
- timestamp: ts,
212
- }
213
- );
199
+ return {
200
+ __file: filepath,
201
+ __size: dataSize,
202
+ __binary: isBinary,
203
+ };
214
204
  } catch (error) {
215
205
  // 文件保存失败,返回错误信息
216
206
  log.error(`[Window ${winId}] Failed to save file for ${url}:`, error.message);
@@ -0,0 +1,210 @@
1
+ // Persistent window registry.
2
+ //
3
+ // Background: every window list in the app (get_windows tool, /ui/windows,
4
+ // local-agent-registry) is a LIVE enumeration of BrowserWindow.getAllWindows()
5
+ // with NO persistence, and close_window/destroy() drops the window from that
6
+ // only source. So a closed window leaves no trace and nothing survives a
7
+ // restart.
8
+ //
9
+ // This module adds a durable structure on disk (~/cicy-ai/db/windows.json):
10
+ // - open → upsert an entry (status:"open"), deduped by accountIdx + url
11
+ // - close → KEEP the entry, just flip status:"closed" (never delete)
12
+ // - restart → the list reloads; windows that were still open when the app
13
+ // QUIT come back (auto-reopen), windows the user closed stay "closed".
14
+ //
15
+ // Identity: each entry has an immutable windowKey (uuid). The live
16
+ // BrowserWindow.id is ephemeral (reassigned every session) so we keep a
17
+ // runtime-only liveId↔windowKey map and never trust the persisted liveId
18
+ // across restarts (load() clears it).
19
+
20
+ const fs = require("fs");
21
+ const path = require("path");
22
+ const os = require("os");
23
+ const crypto = require("crypto");
24
+ const log = require("electron-log");
25
+
26
+ const DB_DIR = path.join(os.homedir(), "cicy-ai", "db");
27
+ const REGISTRY_PATH = path.join(DB_DIR, "windows.json");
28
+
29
+ // { windows: { [windowKey]: entry } }
30
+ let _cache = null;
31
+ // runtime-only: live BrowserWindow.id -> windowKey (rebuilt every process)
32
+ const liveIdToKey = new Map();
33
+
34
+ function ensureDir() {
35
+ try {
36
+ if (!fs.existsSync(DB_DIR)) fs.mkdirSync(DB_DIR, { recursive: true });
37
+ } catch {}
38
+ }
39
+
40
+ function nowIso() {
41
+ return new Date().toISOString();
42
+ }
43
+
44
+ // Normalize for dedup: drop hash, strip trailing slash, lowercase.
45
+ function normalizeUrl(url) {
46
+ if (!url) return "";
47
+ try {
48
+ const u = new URL(url);
49
+ u.hash = "";
50
+ let s = u.toString();
51
+ if (s.endsWith("/")) s = s.slice(0, -1);
52
+ return s.toLowerCase();
53
+ } catch {
54
+ return String(url).trim().replace(/\/+$/, "").toLowerCase();
55
+ }
56
+ }
57
+
58
+ function load() {
59
+ if (_cache) return _cache;
60
+ try {
61
+ if (fs.existsSync(REGISTRY_PATH)) {
62
+ const raw = JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf8"));
63
+ _cache = raw && typeof raw === "object" && raw.windows ? raw : { windows: {} };
64
+ } else {
65
+ _cache = { windows: {} };
66
+ }
67
+ } catch (e) {
68
+ log.error("[WindowRegistry] load failed:", e.message);
69
+ _cache = { windows: {} };
70
+ }
71
+ // Process restarted → no window is live yet. The persisted liveId is stale;
72
+ // clear it so registerOpen() reuses the existing slot instead of orphaning it.
73
+ for (const k of Object.keys(_cache.windows)) _cache.windows[k].liveId = null;
74
+ return _cache;
75
+ }
76
+
77
+ function persist() {
78
+ ensureDir();
79
+ try {
80
+ const tmp = REGISTRY_PATH + ".tmp";
81
+ fs.writeFileSync(tmp, JSON.stringify(_cache, null, 2), "utf8");
82
+ fs.renameSync(tmp, REGISTRY_PATH);
83
+ } catch (e) {
84
+ log.error("[WindowRegistry] persist failed:", e.message);
85
+ }
86
+ }
87
+
88
+ function findEntry(accountIdx, url) {
89
+ const reg = load();
90
+ const norm = normalizeUrl(url);
91
+ for (const key of Object.keys(reg.windows)) {
92
+ const e = reg.windows[key];
93
+ if (e.accountIdx === accountIdx && normalizeUrl(e.url) === norm) return e;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ // A real BrowserWindow was created/opened. Upsert its entry (dedup by
99
+ // accountIdx+url) and bind it to the live id. Returns the entry.
100
+ function registerOpen({ accountIdx = 0, url = "", title = "", bounds = null, liveId }) {
101
+ const reg = load();
102
+ let entry = findEntry(accountIdx, url);
103
+ // Don't collapse two distinct live windows into one slot: if the matching
104
+ // entry is already bound to a DIFFERENT live window, start a fresh entry.
105
+ if (entry && entry.liveId != null && entry.liveId !== liveId) entry = null;
106
+
107
+ if (!entry) {
108
+ const windowKey = crypto.randomUUID();
109
+ entry = {
110
+ windowKey,
111
+ accountIdx,
112
+ url: url || "",
113
+ title: title || "",
114
+ bounds: bounds || null,
115
+ status: "open",
116
+ createdAt: nowIso(),
117
+ updatedAt: nowIso(),
118
+ openedAt: nowIso(),
119
+ closedAt: null,
120
+ liveId: liveId ?? null,
121
+ };
122
+ reg.windows[windowKey] = entry;
123
+ } else {
124
+ entry.status = "open";
125
+ if (url) entry.url = url;
126
+ if (title) entry.title = title;
127
+ if (bounds) entry.bounds = bounds;
128
+ entry.openedAt = nowIso();
129
+ entry.updatedAt = nowIso();
130
+ entry.closedAt = null;
131
+ entry.liveId = liveId ?? entry.liveId;
132
+ }
133
+ if (liveId != null) liveIdToKey.set(liveId, entry.windowKey);
134
+ persist();
135
+ return entry;
136
+ }
137
+
138
+ // Keep a live window's record fresh (url/title/bounds change).
139
+ function touch({ liveId, url, title, bounds }) {
140
+ const key = liveIdToKey.get(liveId);
141
+ if (!key) return;
142
+ const reg = load();
143
+ const e = reg.windows[key];
144
+ if (!e) return;
145
+ let changed = false;
146
+ if (url && e.url !== url) {
147
+ e.url = url;
148
+ changed = true;
149
+ }
150
+ if (title && e.title !== title) {
151
+ e.title = title;
152
+ changed = true;
153
+ }
154
+ if (bounds) {
155
+ e.bounds = bounds;
156
+ changed = true;
157
+ }
158
+ if (changed) {
159
+ e.updatedAt = nowIso();
160
+ persist();
161
+ }
162
+ }
163
+
164
+ // User/agent closed a window → keep the record, flip to "closed".
165
+ function markClosed(liveId) {
166
+ const key = liveIdToKey.get(liveId);
167
+ if (!key) return;
168
+ const reg = load();
169
+ const e = reg.windows[key];
170
+ if (e) {
171
+ e.status = "closed";
172
+ e.closedAt = nowIso();
173
+ e.updatedAt = nowIso();
174
+ e.liveId = null;
175
+ persist();
176
+ }
177
+ liveIdToKey.delete(liveId);
178
+ }
179
+
180
+ function list() {
181
+ return Object.values(load().windows);
182
+ }
183
+
184
+ function keyForLiveId(liveId) {
185
+ return liveIdToKey.get(liveId) || null;
186
+ }
187
+
188
+ function getByKey(windowKey) {
189
+ return load().windows[windowKey] || null;
190
+ }
191
+
192
+ // Entries that should auto-reopen on startup: status "open" with no live
193
+ // binding (i.e. they were open when the app last quit). liveId is cleared by
194
+ // load() at process start, so at startup these are exactly the survivors.
195
+ function staleOpenEntries() {
196
+ return list().filter((e) => e.status === "open" && e.liveId == null);
197
+ }
198
+
199
+ module.exports = {
200
+ REGISTRY_PATH,
201
+ normalizeUrl,
202
+ registerOpen,
203
+ touch,
204
+ markClosed,
205
+ list,
206
+ keyForLiveId,
207
+ getByKey,
208
+ staleOpenEntries,
209
+ load,
210
+ };