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,453 @@
1
+ // tab-browser-tools.js — Chrome-like tabbed browser, one window per profile,
2
+ // built on BrowserView tabs (NOT <webview>), so each tab is a full webContents
3
+ // that can itself host <webview> (the cicy-code team app's artifact frame, the
4
+ // homepage's team-assistant drawer) — nested webviews work.
5
+ //
6
+ // Model (additive — the existing win_id BrowserWindow tools are untouched):
7
+ // • one profile (accountIdx → persist:sandbox-N) == one tabbed BrowserWindow
8
+ // whose chrome (tab strip + toolbar) is tab-shell.html; each tab is a
9
+ // BrowserView positioned below the 80px chrome.
10
+ // • every "open" goes in as a TAB, never a new window (homepage / teams /
11
+ // window.open all become tabs).
12
+ // • each tab is addressed by its webContents.id (like a Chrome target).
13
+ // • per-tab preload: home → homepage-preload (window.cicy bridges),
14
+ // trusted (team) → webview-preload (electronRPC), plain site → none.
15
+ const path = require("path");
16
+ const { z } = require("zod");
17
+ const { app, BrowserWindow, BrowserView, webContents, ipcMain } = require("electron");
18
+ const { attachContextMenu } = require("../utils/context-menu-options");
19
+
20
+ const SHELL_HTML = path.join(__dirname, "..", "tabbrowser", "tab-shell.html");
21
+ const SHELL_PRELOAD = path.join(__dirname, "..", "tabbrowser", "tab-shell-preload.js");
22
+ const HOMEPAGE_PRELOAD = path.join(__dirname, "..", "backends", "homepage-preload.js");
23
+ const WEBVIEW_PRELOAD = path.join(__dirname, "..", "backends", "webview-preload.js");
24
+ const CHROME_H = 80; // tab strip (40) + toolbar (40) — must match tab-shell.html
25
+ const STRIP_H = 40; // tab strip only; the homepage tab hides the toolbar (no address bar)
26
+
27
+ const managers = new Map(); // accountIdx -> TabManager
28
+ const managerByHost = new Map(); // shell webContents.id -> TabManager
29
+
30
+ function stripVol(u) { try { const x = new URL(u); return x.origin + x.pathname; } catch (e) { return u || ""; } }
31
+
32
+ const { NEWTAB_URL, ensureForPartition } = require("../tabbrowser/newtab-protocol");
33
+
34
+ // ── Per-profile privilege gate ────────────────────────────────────────────────
35
+ // Security model: accountIdx 0 is the SYSTEM profile (homepage + team apps) and
36
+ // is the ONLY profile allowed to run privileged tabs — Node-capable preloads
37
+ // (homepage-preload / webview-preload → window.electronRPC), <webview>, and
38
+ // insecure content. Profile-0 tabs carry the electronRPC bridge, but it is gated
39
+ // at CALL TIME: a non-allowlisted origin must pass the rpc:guarded consent modal
40
+ // (ensureOriginAuthorized) before any tool runs. Every other profile (accountIdx
41
+ // ≥ 1) is a HARD-SANDBOXED web browser: contextIsolation on, nodeIntegration off,
42
+ // OS sandbox on, NO preload, NO webviewTag — so a sandbox profile can never reach
43
+ // the electronRPC bridge, Node, or nest another webview, whatever flags a caller
44
+ // passes.
45
+ function buildTabWebPreferences(accountIdx, partition, target, opts = {}) {
46
+ const wp = { partition, contextIsolation: true, nodeIntegration: false, sandbox: true };
47
+ if (accountIdx !== 0) return wp; // sandbox profiles: locked baseline, no exceptions
48
+ // homepage-preload does Node require()s (../i18n, path) → must run unsandboxed.
49
+ // home is system-driven (openHomeWindow), never caller-URL-reachable, so it
50
+ // keeps its privileges without a URL check.
51
+ if (opts.home) { wp.preload = HOMEPAGE_PRELOAD; wp.webviewTag = true; wp.allowRunningInsecureContent = true; wp.sandbox = false; }
52
+ // Every other profile-0 tab carries the electronRPC bridge (WEBVIEW_PRELOAD),
53
+ // but the bridge is INERT until the page's origin is authorized: the first
54
+ // rpc:guarded call from a non-allowlisted origin pops a consent modal
55
+ // (ensureOriginAuthorized) that can deny, allow once, or add the domain to the
56
+ // trusted-origins allowlist. Trust moved from inject-time to call-time so the
57
+ // user can grant it on demand instead of hitting a silent "electronRPC not
58
+ // available". The page itself still can't reach Node (contextIsolation on,
59
+ // nodeIntegration off) — only the preload runs privileged, and it exposes
60
+ // nothing but the gated bridge.
61
+ else { wp.preload = WEBVIEW_PRELOAD; wp.webviewTag = true; wp.sandbox = false; }
62
+ return wp;
63
+ }
64
+ function startPageUrl(_accountIdx) {
65
+ // clean cicy://newtab instead of a giant data: URL (served by newtab-protocol).
66
+ return NEWTAB_URL;
67
+ }
68
+
69
+ class TabManager {
70
+ constructor(accountIdx) {
71
+ this.accountIdx = accountIdx;
72
+ this.partition = `persist:sandbox-${accountIdx}`;
73
+ ensureForPartition(this.partition); // register cicyui://newtab on this session
74
+ this.tabs = []; // [{ id(=webContents.id), view, title, url }]
75
+ this.activeId = null;
76
+ // profile 0 is the primary window (its first tab is the resident homepage),
77
+ // so give it the app title/icon; other profiles are sandbox browsers.
78
+ const STRIP_BG = "#1c1c20"; // matches tab-shell.html --strip so the titlebar merges in
79
+ const winOpts = {
80
+ width: 1180,
81
+ height: 820,
82
+ backgroundColor: STRIP_BG,
83
+ title: accountIdx === 0 ? "CiCy Desktop" : `CiCy Browser · sandbox-${accountIdx}`,
84
+ // Drop the native title-bar row — the tab strip becomes the top of the window
85
+ // (Chrome-style). mac: keep traffic lights, inset into the strip. win: keep the
86
+ // min/max/close as an overlay tinted to the strip color so it merges. (Linux:
87
+ // leave the default frame.)
88
+ ...(process.platform === "darwin"
89
+ ? { titleBarStyle: "hidden", trafficLightPosition: { x: 12, y: 13 } }
90
+ : {}),
91
+ ...(process.platform === "win32"
92
+ ? { titleBarStyle: "hidden", titleBarOverlay: { color: STRIP_BG, symbolColor: "#e8eaed", height: 40 } }
93
+ : {}),
94
+ // win/linux: hide the native application menu bar too (mac keeps it in the
95
+ // global bar). Without this, Windows draws the File/Edit/View row where the
96
+ // titlebar was, so the strip never reaches the top edge. Alt still reveals it.
97
+ autoHideMenuBar: true,
98
+ webPreferences: { preload: SHELL_PRELOAD, contextIsolation: true, nodeIntegration: false },
99
+ };
100
+ try { winOpts.icon = require("../utils/app-icon").appIconPath(); } catch (e) {}
101
+ this.win = new BrowserWindow(winOpts);
102
+ // profile 0 is the system tab window (teams only) — no manual "+" new tab.
103
+ this.win.loadFile(SHELL_HTML, { query: { p: String(accountIdx), noNew: accountIdx === 0 ? "1" : "0", plat: process.platform } });
104
+ managerByHost.set(this.win.webContents.id, this);
105
+ // Fullscreen hides the OS window controls (mac traffic lights / win caption
106
+ // buttons) → tell the shell so it reclaims the reserved gutter (CSS
107
+ // body.is-fullscreen). Fires on both mac and Windows.
108
+ this.win.on("enter-full-screen", () => { try { this.win.webContents.send("window:fullscreen", true); } catch (e) {} });
109
+ this.win.on("leave-full-screen", () => { try { this.win.webContents.send("window:fullscreen", false); } catch (e) {} });
110
+ this.win.on("resize", () => this.layout());
111
+ this.win.on("closed", () => {
112
+ managers.delete(accountIdx);
113
+ managerByHost.delete(this.win.webContents.id);
114
+ });
115
+ }
116
+
117
+ pushState() {
118
+ const active = this.tabs.find((t) => t.id === this.activeId);
119
+ let wc = null;
120
+ try { wc = active ? active.view.webContents : null; } catch (e) {}
121
+ const s = {
122
+ tabs: this.tabs.map((t) => ({
123
+ id: t.id,
124
+ // fixedTitle (team title / homepage) wins over the page's own document.title
125
+ title: t.fixedTitle || t.title || "新标签页",
126
+ url: t.url || "",
127
+ active: t.id === this.activeId,
128
+ loading: !!t.loading,
129
+ favicon: t.favicon || "",
130
+ home: !!t.home,
131
+ })),
132
+ nav: {
133
+ canBack: wc ? wc.canGoBack() : false,
134
+ canFwd: wc ? wc.canGoForward() : false,
135
+ loading: active ? !!active.loading : false,
136
+ url: active ? active.url || "" : "",
137
+ },
138
+ };
139
+ try { this.win.webContents.send("tabwin:state", s); } catch (e) {}
140
+ }
141
+
142
+ layout() {
143
+ const t = this.tabs.find((x) => x.id === this.activeId);
144
+ if (!t) return;
145
+ const [w, h] = this.win.getContentSize();
146
+ // Hide the toolbar (address bar) ONLY on the homepage tab — its full-page UI
147
+ // covers y=40 down. Every other tab, INCLUDING profile 0's team tabs, keeps
148
+ // the address bar (主人令: profile 0 的地址栏不再隐藏 / 不限制).
149
+ const top = t.home ? STRIP_H : CHROME_H;
150
+ try { t.view.setBounds({ x: 0, y: top, width: w, height: Math.max(0, h - top) }); } catch (e) {}
151
+ }
152
+
153
+ addTab(url, opts = {}) {
154
+ const target = (url && String(url).trim()) || startPageUrl(this.accountIdx);
155
+ // reuse an existing tab with the same origin+pathname
156
+ const key = url ? stripVol(target) : null;
157
+ if (key) {
158
+ const ex = this.tabs.find((t) => stripVol(t.url) === key);
159
+ if (ex) { this.activate(ex.id); return ex.id; }
160
+ }
161
+ // Privilege gate: only the system profile (accountIdx 0) may get a Node-capable
162
+ // preload / <webview>; all other profiles are forced to the sandbox baseline.
163
+ const wp = buildTabWebPreferences(this.accountIdx, this.partition, target, opts);
164
+ const view = new BrowserView({ webPreferences: wp });
165
+ const wc = view.webContents;
166
+ const id = wc.id;
167
+ // BrowserView tabs aren't auto-covered by the global contextMenu() (it only
168
+ // attaches to BrowserWindows + webviews), so give each tab exactly one
169
+ // right-click menu (copy/paste/inspect) — without this, right-click did nothing.
170
+ try { attachContextMenu(wc); } catch (e) {}
171
+ // home = the resident homepage tab (pinned, first, user-icon, no close).
172
+ // fixedTitle = a caller-supplied tab name (e.g. the team title) that the
173
+ // page's own document.title must NOT override.
174
+ const tab = { id, view, title: "", url: target, home: !!opts.home, fixedTitle: opts.title || "" };
175
+ if (opts.home) this.tabs.unshift(tab); else this.tabs.push(tab);
176
+ wc.on("page-title-updated", (_e, title) => { if (!tab.fixedTitle) { tab.title = title; this.pushState(); } });
177
+ wc.on("page-favicon-updated", (_e, favs) => { tab.favicon = (favs && favs[0]) || ""; this.pushState(); });
178
+ wc.on("did-start-loading", () => { tab.loading = true; this.pushState(); });
179
+ wc.on("did-stop-loading", () => { tab.loading = false; this.pushState(); });
180
+ wc.on("did-navigate", (_e, u) => { tab.url = u; tab.favicon = ""; this.pushState(); });
181
+ wc.on("did-navigate-in-page", (_e, u) => { tab.url = u; this.pushState(); });
182
+ // popups / window.open → open as a new tab. In profile 0 the new tab carries
183
+ // the (inert) electronRPC bridge like any other profile-0 tab; its origin is
184
+ // still gated by the rpc:guarded consent modal before any tool runs. Sandbox
185
+ // profiles (accountIdx ≥ 1) never get the bridge, popup or not.
186
+ try { wc.setWindowOpenHandler(({ url: u }) => {
187
+ try { this.addTab(u); } catch (e) {}
188
+ return { action: "deny" };
189
+ }); } catch (e) {}
190
+ wc.loadURL(target);
191
+ this.activate(id);
192
+ return id;
193
+ }
194
+
195
+ activate(id) {
196
+ const t = this.tabs.find((x) => x.id === id);
197
+ if (!t) return false;
198
+ if (this.activeId != null && this.activeId !== id) {
199
+ const cur = this.tabs.find((x) => x.id === this.activeId);
200
+ if (cur) { try { this.win.removeBrowserView(cur.view); } catch (e) {} }
201
+ }
202
+ this.activeId = id;
203
+ try { this.win.addBrowserView(t.view); } catch (e) {}
204
+ this.layout();
205
+ this.pushState();
206
+ return true;
207
+ }
208
+
209
+ close(id) {
210
+ const i = this.tabs.findIndex((x) => x.id === id);
211
+ if (i < 0) return false;
212
+ const t = this.tabs[i];
213
+ if (t.home) return false; // the resident homepage tab can't be closed
214
+ try { this.win.removeBrowserView(t.view); } catch (e) {}
215
+ try { t.view.webContents.close(); } catch (e) { try { t.view.webContents.destroy(); } catch (_) {} }
216
+ this.tabs.splice(i, 1);
217
+ if (this.activeId === id) {
218
+ this.activeId = null;
219
+ const n = this.tabs[Math.min(i, this.tabs.length - 1)];
220
+ if (n) this.activate(n.id);
221
+ else if (this.accountIdx !== 0) this.addTab(); // non-system: keep a start page
222
+ else this.pushState(); // profile 0: leave empty (tabs only come from homepage)
223
+ } else {
224
+ this.pushState();
225
+ }
226
+ return true;
227
+ }
228
+
229
+ list() { return this.tabs.map((t) => ({ webContentsId: t.id, title: t.title, url: t.url, active: t.id === this.activeId })); }
230
+
231
+ activeWc() { const t = this.tabs.find((x) => x.id === this.activeId); return t ? t.view.webContents : null; }
232
+ }
233
+
234
+ function ensureManager(accountIdx) {
235
+ let m = managers.get(accountIdx);
236
+ if (m && !m.win.isDestroyed()) return m;
237
+ m = new TabManager(accountIdx);
238
+ managers.set(accountIdx, m);
239
+ return m;
240
+ }
241
+
242
+ function findManagerByTab(webContentsId) {
243
+ for (const m of managers.values()) {
244
+ if (m.tabs.some((t) => t.id === webContentsId)) return m;
245
+ }
246
+ return null;
247
+ }
248
+
249
+ // ── programmatic API (team-open reroute / homepage) ──────────────────────────
250
+ // profile 0 is the system tab window: tabs may ONLY be added by the homepage
251
+ // (team open passes systemOpen:true). The "+" button, the electron_tab_open
252
+ // tool and the panel can't add tabs to it.
253
+ async function openTab(accountIdx, url, opts = {}) {
254
+ if (accountIdx === 0 && !opts.systemOpen) {
255
+ throw new Error("profile 0 的标签只能从首页点开 team");
256
+ }
257
+ const m = ensureManager(accountIdx);
258
+ const id = m.addTab(url, { trusted: !!opts.trusted, home: !!opts.home, title: opts.title || "" });
259
+ try { m.win.show(); m.win.focus(); } catch (e) {}
260
+ return { winId: m.win.id, accountIdx, tabId: id };
261
+ }
262
+ // Open (or focus) the resident homepage tab of a profile's tab window. Returns
263
+ // { win, wc } so the homepage module can track the tab's webContents for the
264
+ // main→renderer pushes (auth:complete, update-state) that used to target the
265
+ // standalone homepage window.
266
+ function openHomeWindow(accountIdx, homeUrl) {
267
+ const m = ensureManager(accountIdx);
268
+ let tab = m.tabs.find((t) => t.home);
269
+ if (tab) { m.activate(tab.id); }
270
+ else { const id = m.addTab(homeUrl, { home: true }); tab = m.tabs.find((t) => t.id === id); }
271
+ try { m.win.show(); m.win.focus(); } catch (e) {}
272
+ let wc = null; try { wc = tab ? tab.view.webContents : null; } catch (e) {}
273
+ return { win: m.win, wc };
274
+ }
275
+
276
+ // ── shell IPC (registered once) ──────────────────────────────────────────────
277
+ let ipcInstalled = false;
278
+ function installIpc() {
279
+ if (ipcInstalled) return;
280
+ ipcInstalled = true;
281
+ const mgr = (e) => managerByHost.get(e.sender.id);
282
+ ipcMain.on("tabwin:ready", (e) => { const m = mgr(e); if (m) m.pushState(); });
283
+ ipcMain.on("tabwin:new", (e, { url }) => { const m = mgr(e); if (m && m.accountIdx !== 0) m.addTab(url || ""); });
284
+ ipcMain.on("tabwin:activate", (e, { id }) => { const m = mgr(e); if (m) m.activate(id); });
285
+ ipcMain.on("tabwin:close", (e, { id }) => { const m = mgr(e); if (m) m.close(id); });
286
+ ipcMain.on("tabwin:navigate", (e, { url }) => { const m = mgr(e); const wc = m && m.activeWc(); if (wc && url) wc.loadURL(String(url)); });
287
+ ipcMain.on("tabwin:back", (e) => { const m = mgr(e); const wc = m && m.activeWc(); if (wc && wc.canGoBack()) wc.goBack(); });
288
+ ipcMain.on("tabwin:fwd", (e) => { const m = mgr(e); const wc = m && m.activeWc(); if (wc && wc.canGoForward()) wc.goForward(); });
289
+ ipcMain.on("tabwin:reload", (e) => { const m = mgr(e); const wc = m && m.activeWc(); if (wc) wc.reload(); });
290
+ }
291
+ installIpc();
292
+
293
+ // CDP captureScreenshot — works for background tabs (capturePage blanks when the
294
+ // BrowserView isn't the attached/visible one).
295
+ async function tabScreenshot(webContentsId, format) {
296
+ const wc = webContents.fromId(webContentsId);
297
+ if (!wc) throw new Error(`tab ${webContentsId} not found`);
298
+ const fmt = format === "png" ? "png" : "jpeg";
299
+ let attached = false;
300
+ try {
301
+ try { wc.debugger.attach("1.3"); attached = true; } catch (e) {}
302
+ const res = await wc.debugger.sendCommand("Page.captureScreenshot", { format: fmt, quality: 70 });
303
+ return `data:image/${fmt};base64,${res.data}`;
304
+ } finally {
305
+ if (attached) { try { wc.debugger.detach(); } catch (e) {} }
306
+ }
307
+ }
308
+
309
+ function registerTabBrowserTools(registerTool) {
310
+ const ok = (obj, isErr = false) => ({
311
+ content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
312
+ ...(isErr ? { isError: true } : {}),
313
+ });
314
+
315
+ registerTool(
316
+ "electron_tabwin_open",
317
+ "打开/前置某 profile 的标签浏览器窗口(一个 profile 一个窗口,accountIdx → persist:sandbox-N,BrowserView tab)。",
318
+ z.object({ accountIdx: z.number().describe("账户索引(profile)") }),
319
+ async ({ accountIdx }) => {
320
+ try {
321
+ const m = ensureManager(accountIdx);
322
+ if (m.tabs.length === 0 && accountIdx !== 0) m.addTab();
323
+ try { m.win.show(); m.win.focus(); } catch (e) {}
324
+ return ok({ success: true, accountIdx, winId: m.win.id });
325
+ } catch (e) { return ok({ error: e.message }, true); }
326
+ },
327
+ { tag: "TabBrowser" }
328
+ );
329
+
330
+ registerTool(
331
+ "electron_tab_open",
332
+ "在某 profile 的标签窗口新开一个标签(窗口不在则先建)。打开=开 tab,不弹新窗口。trusted=true 给 cicy-code 等受信任页注入 electronRPC 桥。",
333
+ z.object({
334
+ accountIdx: z.number().describe("账户索引(profile)"),
335
+ url: z.string().optional().describe("网址;省略则起始页"),
336
+ trusted: z.boolean().optional().describe("受信任页面才注入桥;默认 false"),
337
+ }),
338
+ async ({ accountIdx, url, trusted }) => {
339
+ try {
340
+ const r = await openTab(accountIdx, url, { trusted: !!trusted });
341
+ return ok({ success: true, accountIdx, winId: r.winId, tabId: r.tabId, tabs: ensureManager(accountIdx).list() });
342
+ } catch (e) { return ok({ error: e.message }, true); }
343
+ },
344
+ { tag: "TabBrowser" }
345
+ );
346
+
347
+ registerTool(
348
+ "electron_tabs",
349
+ "列出某 profile 标签窗口的所有标签(每个 = 一个 webContentsId,可单独控制,像 Chrome target)。",
350
+ z.object({ accountIdx: z.number().describe("账户索引(profile)") }),
351
+ async ({ accountIdx }) => {
352
+ try {
353
+ const m = managers.get(accountIdx);
354
+ return ok({ accountIdx, tabs: m && !m.win.isDestroyed() ? m.list() : [] });
355
+ } catch (e) { return ok({ error: e.message }, true); }
356
+ },
357
+ { tag: "TabBrowser" }
358
+ );
359
+
360
+ registerTool(
361
+ "electron_tab_navigate",
362
+ "让某个标签(按 webContentsId)导航到一个网址。",
363
+ z.object({ webContentsId: z.number(), url: z.string() }),
364
+ async ({ webContentsId, url }) => {
365
+ try {
366
+ const wc = webContents.fromId(webContentsId);
367
+ if (!wc) throw new Error(`tab ${webContentsId} not found`);
368
+ await wc.loadURL(String(url));
369
+ return ok({ success: true, webContentsId, url });
370
+ } catch (e) { return ok({ error: e.message }, true); }
371
+ },
372
+ { tag: "TabBrowser" }
373
+ );
374
+
375
+ registerTool(
376
+ "electron_tab_eval",
377
+ "在某个标签(按 webContentsId)的页面执行 JS 并返回结果。",
378
+ z.object({ webContentsId: z.number(), code: z.string() }),
379
+ async ({ webContentsId, code }) => {
380
+ try {
381
+ const wc = webContents.fromId(webContentsId);
382
+ if (!wc) throw new Error(`tab ${webContentsId} not found`);
383
+ const result = await wc.executeJavaScript(String(code), true);
384
+ return ok({ success: true, webContentsId, result });
385
+ } catch (e) { return ok({ error: e.message }, true); }
386
+ },
387
+ { tag: "TabBrowser" }
388
+ );
389
+
390
+ registerTool(
391
+ "electron_tab_screenshot",
392
+ "截取某个标签(按 webContentsId)。走 CDP,后台标签也能截。",
393
+ z.object({ webContentsId: z.number(), format: z.enum(["png", "jpeg"]).optional().default("jpeg") }),
394
+ async ({ webContentsId, format }) => {
395
+ try {
396
+ const dataUrl = await tabScreenshot(webContentsId, format);
397
+ return ok({ success: true, webContentsId, format, base64: dataUrl });
398
+ } catch (e) { return ok({ error: e.message }, true); }
399
+ },
400
+ { tag: "TabBrowser" }
401
+ );
402
+
403
+ registerTool(
404
+ "electron_tab_activate",
405
+ "把某个标签(按 webContentsId)切到前台。",
406
+ z.object({ webContentsId: z.number() }),
407
+ async ({ webContentsId }) => {
408
+ try {
409
+ const m = findManagerByTab(webContentsId);
410
+ if (!m) throw new Error(`tab ${webContentsId} not found`);
411
+ return ok({ success: m.activate(webContentsId), webContentsId });
412
+ } catch (e) { return ok({ error: e.message }, true); }
413
+ },
414
+ { tag: "TabBrowser" }
415
+ );
416
+
417
+ registerTool(
418
+ "electron_tab_close",
419
+ "关闭某个标签(按 webContentsId)。",
420
+ z.object({ webContentsId: z.number() }),
421
+ async ({ webContentsId }) => {
422
+ try {
423
+ const m = findManagerByTab(webContentsId);
424
+ if (!m) throw new Error(`tab ${webContentsId} not found`);
425
+ return ok({ success: m.close(webContentsId), webContentsId });
426
+ } catch (e) { return ok({ error: e.message }, true); }
427
+ },
428
+ { tag: "TabBrowser" }
429
+ );
430
+ }
431
+
432
+ // Reload the profile-N tab whose URL matches (origin+pathname); if none is open,
433
+ // open it. Used by the homepage cloud-team card's ⋯ "刷新窗口".
434
+ async function reloadTabByUrl(accountIdx, url, opts = {}) {
435
+ const m = managers.get(accountIdx);
436
+ if (m && !m.win.isDestroyed()) {
437
+ const key = stripVol(url);
438
+ const tab = m.tabs.find((t) => stripVol(t.url) === key);
439
+ if (tab) {
440
+ try { tab.view.webContents.reload(); } catch (e) {}
441
+ try { m.activate(tab.id); m.win.show(); m.win.focus(); } catch (e) {}
442
+ return { ok: true, winId: m.win.id, reloaded: true };
443
+ }
444
+ }
445
+ const r = await openTab(accountIdx, url, { systemOpen: true, trusted: !!opts.trusted, title: opts.title || "" });
446
+ return { ok: true, winId: r.winId, opened: true };
447
+ }
448
+
449
+ registerTabBrowserTools.openTab = openTab;
450
+ registerTabBrowserTools.reloadTabByUrl = reloadTabByUrl;
451
+ registerTabBrowserTools.openHomeWindow = openHomeWindow;
452
+ registerTabBrowserTools.ensureManager = ensureManager;
453
+ module.exports = registerTabBrowserTools;
@@ -39,10 +39,15 @@ function resolveWindowId(input = {}, context = {}) {
39
39
  function registerTools(registerTool) {
40
40
  registerTool(
41
41
  "get_windows",
42
- `获取当前所有 Electron 窗口的实时状态列表。返回每个窗口的详细信息,是窗口管理和自动化操作的基础工具。
43
-
42
+ `获取窗口列表(活动窗口 + 已关闭但持久化保留的窗口)。是窗口管理和自动化操作的基础工具。
43
+
44
+ - status:"open" 的是当前活动窗口;status:"closed" 的是关闭后仍保留在持久化注册表里的记录(可用 reopen_window + windowKey 原样重开)。
45
+ - windowKey: 跨重启稳定的持久标识(reopen_window 用);id 仅活动窗口有,重启后会变。
46
+
44
47
  返回信息包括:
45
- - id: 窗口的唯一标识符(调用其他 invoke_window 工具时必需)
48
+ - id: 活动窗口的运行时标识符(已关闭窗口为 null;调用其他 invoke_window 工具时必需)
49
+ - windowKey: 持久窗口标识(重开/跨重启用)
50
+ - status: "open"(活动)| "closed"(已关闭,记录保留)
46
51
  - title/url: 窗口当前的标题和网址
47
52
  - debuggerIsAttached: 调试器是否已附加
48
53
  - isActive/isVisible: 窗口焦点和可见性状态
@@ -58,9 +63,31 @@ function registerTools(registerTool) {
58
63
  z.object({}),
59
64
  async () => {
60
65
  try {
61
- const windows = BrowserWindow.getAllWindows()
66
+ const registry = require("../utils/window-registry");
67
+ // Live windows (status "open"), annotated with their persistent windowKey.
68
+ const live = BrowserWindow.getAllWindows()
62
69
  .map(getWindowInfo)
63
- .filter((w) => w !== null);
70
+ .filter((w) => w !== null)
71
+ .map((w) => ({ ...w, status: "open", windowKey: registry.keyForLiveId(w.id) }));
72
+ // Closed windows from the persistent registry — kept (not deleted) so
73
+ // the list survives close + restart and can be re-opened (reopen_window).
74
+ const liveKeys = new Set(live.map((w) => w.windowKey).filter(Boolean));
75
+ const closed = registry
76
+ .list()
77
+ .filter((e) => e.status === "closed" && !liveKeys.has(e.windowKey))
78
+ .map((e) => ({
79
+ id: null,
80
+ windowKey: e.windowKey,
81
+ status: "closed",
82
+ title: e.title,
83
+ url: e.url,
84
+ accountIdx: e.accountIdx,
85
+ bounds: e.bounds,
86
+ isVisible: false,
87
+ isDestroyed: true,
88
+ closedAt: e.closedAt,
89
+ }));
90
+ const windows = [...live, ...closed];
64
91
  return { content: [{ type: "text", text: JSON.stringify(windows, null, 2) }] };
65
92
  } catch (error) {
66
93
  return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
@@ -94,8 +121,11 @@ function registerTools(registerTool) {
94
121
  accountIdx: z
95
122
  .number()
96
123
  .optional()
97
- .default(0)
98
- .describe("窗口所在帐户,帐户可以是0,1,2,3...每个相同帐户下面的所有窗口共享缓存cookie等"),
124
+ .default(1)
125
+ .describe(
126
+ "窗口所在帐户。账户 0 是系统级保留(平台 homepage / 系统窗口),agent 开窗请用 >0;不指定时默认 1。" +
127
+ "帐户可以是 1,2,3...每个相同帐户下面的所有窗口共享缓存/cookie/session/proxy。"
128
+ ),
99
129
  reuseWindow: z
100
130
  .boolean()
101
131
  .optional()
@@ -159,6 +189,33 @@ function registerTools(registerTool) {
159
189
  { tag: "Window" }
160
190
  );
161
191
 
192
+ registerTool(
193
+ "reopen_window",
194
+ "重新打开一个已关闭(status:closed)的窗口。窗口关闭后记录仍保留在持久化注册表里,用 get_windows 返回的 windowKey 即可原样重开(沿用原 url / 帐户 / 位置大小)。",
195
+ z.object({ window_key: z.string().describe("窗口的 windowKey(来自 get_windows)") }),
196
+ async ({ window_key }) => {
197
+ try {
198
+ const registry = require("../utils/window-registry");
199
+ const entry = registry.getByKey(window_key);
200
+ if (!entry) throw new Error(`windowKey ${window_key} not found in registry`);
201
+ const opts = { url: entry.url };
202
+ if (entry.bounds && typeof entry.bounds === "object") Object.assign(opts, entry.bounds);
203
+ const win = createWindow(opts, entry.accountIdx || 0, true);
204
+ return {
205
+ content: [
206
+ {
207
+ type: "text",
208
+ text: `Reopened ${entry.url} as window ${win.id} (windowKey ${window_key})`,
209
+ },
210
+ ],
211
+ };
212
+ } catch (error) {
213
+ return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
214
+ }
215
+ },
216
+ { tag: "Window" }
217
+ );
218
+
162
219
  registerTool(
163
220
  "load_url",
164
221
  "加载URL",
@@ -60,6 +60,26 @@ function ourIcns() {
60
60
  return fs.existsSync(p) ? p : null;
61
61
  }
62
62
 
63
+ // Declare the desktop's URL schemes (cicy-desktop://, cicy://) in the borrowed
64
+ // Electron.app Info.plist so LaunchServices routes deeplinks (cicy-desktop://
65
+ // addTeam?…) to this app. An UNPACKAGED app CANNOT register a scheme at runtime
66
+ // on macOS — `setAsDefaultProtocolClient()` is a no-op unless the bundle DECLARES
67
+ // the scheme here. Idempotent: skips when our schemes are already declared.
68
+ function ensureURLSchemes(plist) {
69
+ try {
70
+ if (plistGet(plist, "CFBundleURLTypes:0:CFBundleURLSchemes:0") === "cicy-desktop") return false;
71
+ const pb = (cmd) => { try { cp.execFileSync("/usr/libexec/PlistBuddy", ["-c", cmd, plist]); } catch {} };
72
+ pb("Delete :CFBundleURLTypes"); // clear any partial/stale block
73
+ pb("Add :CFBundleURLTypes array");
74
+ pb("Add :CFBundleURLTypes:0 dict");
75
+ pb("Add :CFBundleURLTypes:0:CFBundleURLName string com.cicy.desktop");
76
+ pb("Add :CFBundleURLTypes:0:CFBundleURLSchemes array");
77
+ pb("Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string cicy-desktop");
78
+ pb("Add :CFBundleURLTypes:0:CFBundleURLSchemes:1 string cicy");
79
+ return true;
80
+ } catch { return false; }
81
+ }
82
+
63
83
  // Patch name + icon. Returns true if the bundle NAME changed (caller relaunches).
64
84
  function brandMac() {
65
85
  const contents = macBundleContents();
@@ -97,6 +117,11 @@ function brandMac() {
97
117
  }
98
118
  }
99
119
 
120
+ // 3) Declare cicy-desktop:// / cicy:// so deeplinks route to this app (the
121
+ // lsregister below refreshes LaunchServices to pick it up; no relaunch).
122
+ const urlChanged = ensureURLSchemes(plist);
123
+ if (urlChanged) log.info("[Brand] declared URL schemes cicy-desktop:// / cicy:// in Info.plist");
124
+
100
125
  // Nudge LaunchServices / Finder / Dock to drop the cached icon + name.
101
126
  try {
102
127
  const bundle = path.dirname(contents); // .../Electron.app
@@ -0,0 +1,80 @@
1
+ // Shared electron-context-menu (ecm) config + a universal attach helper so the
2
+ // SAME i18n'd right-click menu — 重新加载 / 复制 / 粘贴 / 检查元素(DevTools)— applies to
3
+ // EVERY surface: the tab-browser SHELL window (host/"BaseWindow"), BrowserView
4
+ // tabs, <webview> guests, popups, the homepage window. ecm only auto-attaches to
5
+ // a BrowserWindow's MAIN webContents, so the shell window and guests otherwise
6
+ // fall back to the OS-native menu; attachContextMenu() (wired on app
7
+ // 'web-contents-created' in main.js) closes that gap, guarded so nothing
8
+ // double-pops.
9
+ const { default: contextMenu } = require("electron-context-menu");
10
+
11
+ // ecm hands prepend/append the same `win` it was attached with — a BrowserWindow
12
+ // in auto-attach mode, or the raw webContents when we pass `{ window: wc }`.
13
+ // Resolve to the webContents either way.
14
+ function wcOf(win) {
15
+ try { return win && win.webContents ? win.webContents : win; } catch (e) { return win; }
16
+ }
17
+
18
+ const OPTIONS = {
19
+ showLookUpSelection: true,
20
+ showSearchWithGoogle: true,
21
+ showCopyImage: true,
22
+ showCopyImageAddress: true,
23
+ showSaveImageAs: true,
24
+ showCopyVideoAddress: true,
25
+ showSaveVideoAs: true,
26
+ showCopyLink: true,
27
+ showSaveLinkAs: true,
28
+ // 检查元素 → webContents.inspectElement(x, y): opens DevTools focused on the
29
+ // node under the cursor. Works on host windows AND <webview> guests.
30
+ showInspectElement: true,
31
+ showServices: true,
32
+ // ecm has no built-in Reload item — add ONLY 重新加载 at the top. NO 切换开发者工具
33
+ // anywhere (removed per master). <webview> guests keep their own custom menu —
34
+ // see attachContextMenu.
35
+ prepend: (_defaultActions, _params, win) => {
36
+ const wc = wcOf(win);
37
+ return [
38
+ { label: "重新加载", click: () => { try { if (wc) wc.reload(); } catch (e) {} } },
39
+ { type: "separator" },
40
+ ];
41
+ },
42
+ labels: {
43
+ cut: "剪切",
44
+ copy: "复制",
45
+ paste: "粘贴",
46
+ selectAll: "全选",
47
+ inspectElement: "检查元素",
48
+ services: "服务",
49
+ lookUpSelection: "查找选中内容",
50
+ searchWithGoogle: "用 Google 搜索",
51
+ copyImage: "复制图片",
52
+ copyImageAddress: "复制图片地址",
53
+ saveImage: "保存图片",
54
+ copyVideoAddress: "复制视频地址",
55
+ saveVideo: "保存视频",
56
+ copyLink: "复制链接",
57
+ saveLinkAs: "链接另存为...",
58
+ },
59
+ };
60
+
61
+ // Attach the menu to the host window + BrowserView tabs only. <webview> guests
62
+ // are EXCLUDED on purpose: they carry their OWN custom context menu (e.g.
63
+ // cicy-code's WebFrame / the gotty terminal), and an ecm native menu would cover
64
+ // it. Idempotent via __cicyCtxMenu so the web-contents-created hook + the
65
+ // tab-browser's per-tab call never double-pop.
66
+ function attachContextMenu(wc) {
67
+ try {
68
+ if (!wc || (wc.isDestroyed && wc.isDestroyed())) return;
69
+ if (wc.__cicyCtxMenu) return;
70
+ const t = wc.getType && wc.getType();
71
+ if (t !== "window" && t !== "browserView") return; // skip <webview> + others
72
+ wc.__cicyCtxMenu = true;
73
+ contextMenu({ ...OPTIONS, window: wc });
74
+ } catch (e) {}
75
+ }
76
+
77
+ module.exports = OPTIONS;
78
+ // Non-enumerable so spreading the options object (`{ ...CTX_MENU_OPTS }`) doesn't
79
+ // leak a function into ecm's option set.
80
+ Object.defineProperty(module.exports, "attachContextMenu", { value: attachContextMenu, enumerable: false });