cicy-desktop 2.1.250 → 2.1.252

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.
@@ -36,6 +36,7 @@ jobs:
36
36
  default = root
37
37
  [automount]
38
38
  enabled = true
39
+ options = "metadata"
39
40
  EOS
40
41
  cat > ctx/Dockerfile <<'EOF'
41
42
  FROM ubuntu:22.04
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cicy-desktop",
3
- "version": "2.1.250",
3
+ "version": "2.1.252",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -139,10 +139,10 @@
139
139
  "//optionalDependencies": "(2026-06 回调): mac/linux 改回 native cicy-code(:8008,colima 在 16G mac 上压垮内存)→ 重新内置 cicy-code-<plat> / cicy-mihomo-<plat>,localbin.fromBundle 零网络 seed(npm 仅作更新通道,带 npmmirror→npmjs 回退)。这些由 scripts/sync-runtime-deps.cjs 在 tag-push 时同步到最新版。npm 的 os/cpu 字段保证每个平台只装自己那份。Windows 仍走 docker(WSL :8009),它那份 cicy-code-windows 用不到但 bundle 着无害。",
140
140
  "optionalDependencies": {
141
141
  "electron": "41.0.3",
142
- "cicy-code-darwin-x64": "2.3.205",
143
- "cicy-code-darwin-arm64": "2.3.205",
144
- "cicy-code-linux-x64": "2.3.205",
145
- "cicy-code-linux-arm64": "2.3.205",
142
+ "cicy-code-darwin-x64": "2.3.266",
143
+ "cicy-code-darwin-arm64": "2.3.266",
144
+ "cicy-code-linux-x64": "2.3.266",
145
+ "cicy-code-linux-arm64": "2.3.266",
146
146
  "cicy-code-windows-x64": "2.3.193",
147
147
  "cicy-mihomo-darwin-x64": "1.10.4",
148
148
  "cicy-mihomo-darwin-arm64": "1.10.4",
@@ -1153,6 +1153,28 @@ function avatarForUrl(url) {
1153
1153
  return "";
1154
1154
  }
1155
1155
 
1156
+ // Resolve the canonical team identity for a URL so every surface (team card,
1157
+ // normal open, restored tab) uses the same title/avatar/color seed.
1158
+ function teamIdentityForUrl(url) {
1159
+ const key = stripVolatile(url);
1160
+ try {
1161
+ const avatars = readAvatars();
1162
+ for (const [id, node] of Object.entries(readNodes())) {
1163
+ const base = node && node.base_url;
1164
+ if (!base) continue;
1165
+ const teamKey = stripVolatile(base);
1166
+ if (teamKey === key || key.startsWith(teamKey)) {
1167
+ return {
1168
+ id,
1169
+ title: localizedTeamName(node.name) || id,
1170
+ avatar: avatars[id] || node.avatar || "",
1171
+ };
1172
+ }
1173
+ }
1174
+ } catch (e) {}
1175
+ return null;
1176
+ }
1177
+
1156
1178
  // Remove teams tied to a cloud ACCOUNT (remote base_url — pulled from the cloud
1157
1179
  // via pullCustomTeams, or user-added remote nodes) so switching/leaving an account
1158
1180
  // doesn't leak them onto the next user. LOCAL machine teams on THIS device
@@ -1166,4 +1188,4 @@ function invalidateForAccountChange() {
1166
1188
  log.info("[local-teams] account changed → team-list cache invalidated (no delete; filter by user_id)");
1167
1189
  }
1168
1190
 
1169
- module.exports = { list, openTeam, reloadTeam, closeLocalWindows, addTeam, removeTeam, updateTeam, upgradeTeam, syncAllLocalTeams, invalidateForAccountChange, setAvatar, getAvatars, avatarForUrl };
1191
+ module.exports = { list, openTeam, reloadTeam, closeLocalWindows, addTeam, removeTeam, updateTeam, upgradeTeam, syncAllLocalTeams, invalidateForAccountChange, setAvatar, getAvatars, avatarForUrl, teamIdentityForUrl };
@@ -255,7 +255,7 @@ function closeChromeProcess(pid) {
255
255
 
256
256
  // macOS / Windows focus-stealing prevention keeps the spawning Electron
257
257
  // app on top after we launch Chrome, so the user's keystrokes go into
258
- // Electron. Nudge the OS to bring Chrome forward.
258
+ // Electron. Nudge the OS to bring Chrome forward without opening it again.
259
259
  function bringChromeAppToForeground(binaryPath) {
260
260
  try {
261
261
  if (process.platform === "darwin") {
@@ -265,7 +265,15 @@ function bringChromeAppToForeground(binaryPath) {
265
265
  }
266
266
  const match = resolved && resolved.match(/^(.+\.app)\//);
267
267
  if (!match) return;
268
- spawn("open", ["-a", match[1]], { stdio: "ignore", detached: true }).unref();
268
+ // `open -a <bundle>` sends an OPEN event. With a managed Chrome already
269
+ // running under --user-data-dir, LaunchServices may handle that event by
270
+ // opening the default, argument-less Chrome too — one click then produces
271
+ // two Chrome windows. AppleScript `activate` only raises the running app.
272
+ const appName = path.basename(match[1], ".app").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
273
+ spawn("/usr/bin/osascript", ["-e", `tell application "${appName}" to activate`], {
274
+ stdio: "ignore",
275
+ detached: true,
276
+ }).unref();
269
277
  return;
270
278
  }
271
279
  if (process.platform === "win32") {
@@ -577,7 +577,8 @@
577
577
  },
578
578
  "tabShell": {
579
579
  "myTeam": "My Team",
580
- "newTabBtn": "New tab"
580
+ "newTabBtn": "New tab",
581
+ "newPanel": "New panel"
581
582
  },
582
583
  "dood": {
583
584
  "menu": "Use Docker in container",
@@ -577,7 +577,8 @@
577
577
  },
578
578
  "tabShell": {
579
579
  "myTeam": "Mon équipe",
580
- "newTabBtn": "Nouvel onglet"
580
+ "newTabBtn": "Nouvel onglet",
581
+ "newPanel": "Nouveau panneau"
581
582
  },
582
583
  "dood": {
583
584
  "menu": "Utiliser Docker dans le conteneur",
@@ -577,7 +577,8 @@
577
577
  },
578
578
  "tabShell": {
579
579
  "myTeam": "マイチーム",
580
- "newTabBtn": "新しいタブ"
580
+ "newTabBtn": "新しいタブ",
581
+ "newPanel": "新しいパネル"
581
582
  },
582
583
  "dood": {
583
584
  "menu": "コンテナ内で Docker を使う",
@@ -577,7 +577,8 @@
577
577
  },
578
578
  "tabShell": {
579
579
  "myTeam": "我的团队",
580
- "newTabBtn": "新建标签"
580
+ "newTabBtn": "新建标签",
581
+ "newPanel": "新建面板"
581
582
  },
582
583
  "dood": {
583
584
  "menu": "容器内使用 Docker",
package/src/main.js CHANGED
@@ -1307,6 +1307,49 @@ electronApp.whenReady().then(async () => {
1307
1307
  }
1308
1308
  }
1309
1309
 
1310
+ const profileStore = require("./profiles/profile-store");
1311
+ const electronProfiles = (() => {
1312
+ let rows = [];
1313
+ try { rows = profileStore.listProfiles("electron"); } catch {}
1314
+ // Profile 0 is the resident CiCy system window and must not be exposed in
1315
+ // the macOS title/top-bar launcher. Profile 9 is Chrome's source template.
1316
+ rows = rows.filter((p) => ![0, 9].includes(Number(p.accountIdx)));
1317
+ return rows;
1318
+ })();
1319
+ const chromeProfiles = (() => {
1320
+ try { return profileStore.listProfiles("chrome"); } catch { return []; }
1321
+ })();
1322
+ const showProfileError = (kind, e) => {
1323
+ dialog.showMessageBox({
1324
+ type: "error",
1325
+ message: `${kind} profile 打开失败`,
1326
+ detail: String((e && e.message) || e),
1327
+ buttons: ["OK"],
1328
+ });
1329
+ };
1330
+ const openElectronProfile = async (accountIdx) => {
1331
+ try {
1332
+ const tabs = require("./tools/tab-browser-tools");
1333
+ await tabs.openTab(accountIdx, undefined, { activate: true });
1334
+ const m = tabs.ensureManager(accountIdx);
1335
+ if (m.win.isMinimized()) m.win.restore();
1336
+ m.win.show();
1337
+ m.win.focus();
1338
+ } catch (e) { showProfileError("Electron", e); }
1339
+ };
1340
+ const openChromeProfile = async (accountIdx) => {
1341
+ try {
1342
+ await require("./tools/chrome-tools").launchOrActivateProfile({
1343
+ accountIdx,
1344
+ activateIfRunning: true,
1345
+ });
1346
+ } catch (e) { showProfileError("Chrome", e); }
1347
+ };
1348
+ const openNativeChrome = () => {
1349
+ try { require("./tools/chrome-tools").launchNativeChrome(); }
1350
+ catch (e) { showProfileError("Chrome", e); }
1351
+ };
1352
+
1310
1353
  const menuTemplate = [
1311
1354
  ...(process.platform === "darwin" ? [{ role: "appMenu" }] : []),
1312
1355
  {
@@ -1344,6 +1387,24 @@ electronApp.whenReady().then(async () => {
1344
1387
  { label: i18n.t("menu.toggleFullscreen"), role: "togglefullscreen" },
1345
1388
  ],
1346
1389
  },
1390
+ {
1391
+ label: "Electron",
1392
+ submenu: electronProfiles.map((p) => ({
1393
+ label: `Profile ${p.accountIdx}${p.name ? ` · ${p.name}` : ""}`,
1394
+ click: () => openElectronProfile(Number(p.accountIdx)),
1395
+ })),
1396
+ },
1397
+ {
1398
+ label: "Chrome",
1399
+ submenu: [
1400
+ { label: "原生 Chrome", click: openNativeChrome },
1401
+ { type: "separator" },
1402
+ ...chromeProfiles.map((p) => ({
1403
+ label: `Profile ${p.accountIdx}${p.gmail ? ` · ${p.gmail}` : p.note ? ` · ${p.note}` : ""}`,
1404
+ click: () => openChromeProfile(Number(p.accountIdx)),
1405
+ })),
1406
+ ],
1407
+ },
1347
1408
  {
1348
1409
  label: i18n.t("menu.window"),
1349
1410
  submenu: [
@@ -12,8 +12,16 @@
12
12
  // If cicy-code is momentarily unreachable we fall back to a minimal inline page so
13
13
  // a fresh tab is never blank.
14
14
  const { protocol, session } = require("electron");
15
+ const fs = require("fs");
16
+ const path = require("path");
15
17
  const _handled = new WeakSet(); // sessions that already have the cicyui handler
16
18
 
19
+ // cicyui://panel/<id> — the split-webview panel page (opened by the tab strip's
20
+ // top-right "+"). <id> keeps each panel tab's URL unique so addTab's
21
+ // origin+pathname reuse never collapses two panels into one; the page keys its
22
+ // persisted layout off the same path.
23
+ const PANEL_HTML = path.join(__dirname, "split-panel.html");
24
+
17
25
  // NOTE: scheme is "cicyui", NOT "cicy" — "cicy" is already an OS deep-link
18
26
  // protocol client (setAsDefaultProtocolClient), so navigating a webContents to
19
27
  // cicy://… gets dispatched externally (open-url) and the page never renders.
@@ -59,6 +67,14 @@ function handlerFor(ses, partition) {
59
67
  ses.protocol.handle("cicyui", async (request) => {
60
68
  let host = "";
61
69
  try { host = new URL(request.url).hostname; } catch (e) {}
70
+ if (host === "panel") {
71
+ // read per request (not cached) so dev edits to split-panel.html land on
72
+ // a simple tab reload, no Electron restart.
73
+ try {
74
+ const html = await fs.promises.readFile(PANEL_HTML, "utf8");
75
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
76
+ } catch (e) { return new Response("panel page missing", { status: 500 }); }
77
+ }
62
78
  if (host !== "newtab") return new Response("not found", { status: 404 });
63
79
  // Fetch the single-source page from cicy-code; fall back to the inline page.
64
80
  try {
@@ -83,4 +99,5 @@ function ensureForPartition(partition) {
83
99
  try { handlerFor(session.fromPartition(partition), partition); } catch (e) {}
84
100
  }
85
101
 
86
- module.exports = { NEWTAB_URL, registerScheme, installHandler, ensureForPartition, startPageHtml };
102
+ const PANEL_URL_BASE = "cicyui://panel/";
103
+ module.exports = { NEWTAB_URL, PANEL_URL_BASE, registerScheme, installHandler, ensureForPartition, startPageHtml };
@@ -0,0 +1,292 @@
1
+ // Copyright 2026 CiCy AI
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // panel-cells.js — BrowserView-backed cells for the split panel tab.
5
+ //
6
+ // Why not <webview>: windows opened from a <webview> guest NEVER get
7
+ // window.opener in Electron (long-standing architectural limit), which kills
8
+ // every OAuth popup flow (Google GSI posts the credential back via
9
+ // postMessage(window.opener) — no opener, no login). Cells are therefore real
10
+ // BrowserViews owned by main, layered ABOVE the panel tab's own BrowserView;
11
+ // the panel page (split-panel.html) stays the chrome: it computes each cell's
12
+ // body rect and syncs [{id,url,rect}] here over IPC. Bonus: each cell has a
13
+ // webContentsId, so the electron_tab_* tools (eval/screenshot/navigate) work
14
+ // on panel cells exactly like tabs.
15
+ const { BrowserView, ipcMain, webContents, session } = require("electron");
16
+ const fs = require("fs");
17
+ const os = require("os");
18
+ const path = require("path");
19
+
20
+ // Panel tabs only exist in profile 0, whose URL toolbar is hidden. Their page
21
+ // starts immediately below the 40px tab strip, so native cell BrowserViews do too.
22
+ const CHROME_H = 40;
23
+
24
+ // Each cell picks its PROFILE (accountIdx → persist:sandbox-N), default 1.
25
+ // Profile 0 is hard-forced DIRECT (no proxy — the gotty terminal ws must never
26
+ // route into mihomo), so external sites (x.com / accounts.google.com) belong in
27
+ // profile ≥1: per-profile proxy from account-N.json (fallback config.proxy)
28
+ // with localhost ALWAYS bypassed — team pages (127.0.0.1:8008) stay direct in
29
+ // every profile. Team auth is token-in-URL, so cookie isolation costs nothing.
30
+ const DEFAULT_PROFILE = 1;
31
+ const partitionFor = (idx) => `persist:sandbox-${idx}`;
32
+ // URL → profile routing (用户定的规则): localhost/127.0.0.1 → profile 0(直连、
33
+ // 与团队 tab 同会话);其余一律 profile 1(走代理)。按格子的目标 URL 定,变更时重建视图。
34
+ function isLocalCicyCode(url) {
35
+ try {
36
+ const u = new URL(url);
37
+ const local = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "[::1]" || u.hostname === "::1";
38
+ return local && (u.port || "80") === "8008";
39
+ } catch (e) {}
40
+ return false;
41
+ }
42
+ function profileForCell(url, requested) {
43
+ if (isLocalCicyCode(url)) return 0;
44
+ const idx = Number(requested);
45
+ return Number.isInteger(idx) && idx > 0 ? idx : DEFAULT_PROFILE;
46
+ }
47
+ const appliedProxy = new Set(); // partitions whose proxy is already configured
48
+ function ensureCellSessionProxy(idx) {
49
+ const part = partitionFor(idx);
50
+ if (appliedProxy.has(part)) return;
51
+ appliedProxy.add(part);
52
+ if (idx === 0) return; // profile 0 stays direct — managed by window-utils, don't touch
53
+ try {
54
+ const profileStore = require("../profiles/profile-store");
55
+ let rules = "";
56
+ try { const p = profileStore.getProfile("electron", idx); rules = profileStore.proxyRules(p && p.proxy) || ""; } catch (e) {}
57
+ if (!rules) { try { rules = require("../config").config.proxy || ""; } catch (e) {} }
58
+ if (!rules) return;
59
+ session.fromPartition(part)
60
+ .setProxy({ proxyRules: rules, proxyBypassRules: "127.0.0.1,localhost,[::1]" })
61
+ .catch(() => {});
62
+ } catch (e) {}
63
+ }
64
+
65
+ // tab webContents.id -> PanelCells
66
+ const registry = new Map();
67
+
68
+ // strip CiCyDesktop/Electron UA tokens — Google OAuth rejects Electron UAs
69
+ // ("this browser may not be secure").
70
+ function scrubUA(wc) {
71
+ try { wc.setUserAgent(wc.getUserAgent().replace(/\s(CiCyDesktop|Electron)\/\S+/g, "")); } catch (e) {}
72
+ }
73
+
74
+ class PanelCells {
75
+ constructor(manager, tab) {
76
+ this.m = manager; // owning TabManager
77
+ this.tabId = tab.id; // panel tab's webContents.id
78
+ this.views = new Map(); // cellId(string) -> { view, url }
79
+ this.visible = manager.activeId === tab.id;
80
+ try {
81
+ tab.view.webContents.once("destroyed", () => this.destroyAll());
82
+ } catch (e) {}
83
+ }
84
+
85
+ tabWc() { try { return webContents.fromId(this.tabId); } catch (e) { return null; } }
86
+ sendState(payload) { const wc = this.tabWc(); if (wc && !wc.isDestroyed()) { try { wc.send("panelcells:state", payload); } catch (e) {} } }
87
+
88
+ create(cellId, profileIdx) {
89
+ ensureCellSessionProxy(profileIdx);
90
+ const view = new BrowserView({
91
+ webPreferences: {
92
+ // the cell's chosen profile session: proxied external web (profile ≥1),
93
+ // localhost bypassed. Plain sandboxed web content: no preload, no Node.
94
+ partition: partitionFor(profileIdx),
95
+ contextIsolation: true,
96
+ nodeIntegration: false,
97
+ sandbox: true,
98
+ },
99
+ });
100
+ const wc = view.webContents;
101
+ try { view.setBackgroundColor("#0d0d0f"); } catch (e) {}
102
+ try { wc.cicyAccountIdx = profileIdx; } catch (e) {}
103
+ scrubUA(wc);
104
+ try { require("../utils/context-menu-options").attachContextMenu(wc); } catch (e) {}
105
+ try { require("../utils/window-monitor").attachTabConsole(wc); } catch (e) {}
106
+
107
+ // Popups stay IN-APP as real child windows: `action:allow` keeps
108
+ // window.opener (OAuth needs it to postMessage the credential back).
109
+ // Named reuse: GSI opens the same frameName twice expecting to land in the
110
+ // SAME window (Chrome behavior); Electron creates a second one — so reuse
111
+ // by frameName manually, else Google login shows two windows.
112
+ const named = new Map(); // frameName -> BrowserWindow
113
+ try {
114
+ wc.setWindowOpenHandler(({ url, frameName }) => {
115
+ const ex = frameName && named.get(frameName);
116
+ if (ex && !ex.isDestroyed()) {
117
+ try { ex.loadURL(url); ex.focus(); } catch (e) {}
118
+ return { action: "deny" };
119
+ }
120
+ return {
121
+ action: "allow",
122
+ overrideBrowserWindowOptions: {
123
+ autoHideMenuBar: true,
124
+ webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
125
+ },
126
+ };
127
+ });
128
+ wc.on("did-create-window", (win, details) => {
129
+ scrubUA(win.webContents);
130
+ const fn = details && details.frameName;
131
+ if (fn) { named.set(fn, win); win.on("closed", () => { if (named.get(fn) === win) named.delete(fn); }); }
132
+ });
133
+ } catch (e) {}
134
+
135
+ // push nav state back to the panel chrome (url input / spinner / persistence)
136
+ const push = () => {
137
+ let url = "", title = "";
138
+ try { url = wc.getURL(); title = wc.getTitle(); } catch (e) {}
139
+ this.sendState({ id: cellId, url, title, loading: false, wcId: wc.id });
140
+ };
141
+ wc.on("did-start-loading", () => this.sendState({ id: cellId, loading: true, wcId: wc.id }));
142
+ wc.on("did-stop-loading", push);
143
+ wc.on("did-navigate", push);
144
+ wc.on("did-navigate-in-page", push);
145
+ wc.on("page-title-updated", push);
146
+
147
+ const rec = { view, url: "", profile: profileIdx };
148
+ this.views.set(String(cellId), rec);
149
+ return rec;
150
+ }
151
+
152
+ place(rec, rect) {
153
+ if (!rect) return;
154
+ const b = {
155
+ x: Math.max(0, Math.round(rect.x)),
156
+ y: Math.max(0, Math.round(rect.y)) + CHROME_H,
157
+ width: Math.max(0, Math.round(rect.w)),
158
+ height: Math.max(0, Math.round(rect.h)),
159
+ };
160
+ try { rec.view.setBounds(b); } catch (e) {}
161
+ }
162
+
163
+ // cells: [{id, url, rect:{x,y,w,h}}] in panel-page CSS coords
164
+ sync(cells) {
165
+ if (!Array.isArray(cells)) return;
166
+ const seen = new Set();
167
+ for (const c of cells.slice(0, 24)) {
168
+ if (c == null || c.id == null) continue;
169
+ const key = String(c.id);
170
+ seen.add(key);
171
+ const url = (c.url && String(c.url)) || "";
172
+ const prof = profileForCell(url, c.profile);
173
+ let rec = this.views.get(key);
174
+ // locality change (localhost ↔ external) → partition must change → rebuild
175
+ if (rec && rec.profile !== prof) { this.destroyCell(key); rec = null; }
176
+ if (!rec) rec = this.create(c.id, prof);
177
+ if (url && rec.url !== url) {
178
+ rec.url = url;
179
+ try { rec.view.webContents.loadURL(url); } catch (e) {}
180
+ }
181
+ this.place(rec, c.rect);
182
+ }
183
+ for (const key of [...this.views.keys()]) if (!seen.has(key)) this.destroyCell(key);
184
+ this.updateAttach();
185
+ }
186
+
187
+ reload(cellId) {
188
+ const rec = this.views.get(String(cellId));
189
+ if (rec) { try { rec.view.webContents.reload(); } catch (e) {} }
190
+ }
191
+
192
+ async snapshots() {
193
+ const out = [];
194
+ for (const [id, rec] of this.views) {
195
+ try {
196
+ const image = await rec.view.webContents.capturePage();
197
+ if (!image.isEmpty()) out.push({ id, dataUrl: image.toDataURL() });
198
+ } catch (e) {}
199
+ }
200
+ return out;
201
+ }
202
+
203
+ updateAttach() {
204
+ const win = this.m.win;
205
+ if (!win || win.isDestroyed()) return;
206
+ for (const rec of this.views.values()) {
207
+ try {
208
+ if (this.visible) win.addBrowserView(rec.view); // no-op if already attached
209
+ else win.removeBrowserView(rec.view);
210
+ } catch (e) {}
211
+ }
212
+ }
213
+ show() { this.visible = true; this.updateAttach(); }
214
+ hide() { this.visible = false; this.updateAttach(); }
215
+
216
+ destroyCell(key) {
217
+ const rec = this.views.get(key);
218
+ if (!rec) return;
219
+ try { this.m.win.removeBrowserView(rec.view); } catch (e) {}
220
+ try { rec.view.webContents.close(); } catch (e) { try { rec.view.webContents.destroy(); } catch (_) {} }
221
+ this.views.delete(key);
222
+ }
223
+ destroyAll() {
224
+ for (const key of [...this.views.keys()]) this.destroyCell(key);
225
+ registry.delete(this.tabId);
226
+ }
227
+ }
228
+
229
+ function cellsForTab(manager, tab) {
230
+ let pc = registry.get(tab.id);
231
+ if (!pc) { pc = new PanelCells(manager, tab); registry.set(tab.id, pc); }
232
+ return pc;
233
+ }
234
+
235
+ // TabManager.activate hooks — hide the outgoing tab's cells, show the incoming's.
236
+ function onTabShown(manager, tabId) { const pc = registry.get(tabId); if (pc) pc.show(); }
237
+ function onTabHidden(manager, tabId) { const pc = registry.get(tabId); if (pc) pc.hide(); }
238
+
239
+ // ── IPC (installed once) ─────────────────────────────────────────────────────
240
+ let installed = false;
241
+ function installIpc(findTab) {
242
+ if (installed) return;
243
+ installed = true;
244
+ // e.sender is the PANEL PAGE's webContents; findTab maps it to (manager, tab).
245
+ const ctx = (e) => {
246
+ const hit = findTab(e.sender.id);
247
+ return hit ? cellsForTab(hit.manager, hit.tab) : null;
248
+ };
249
+ ipcMain.on("panelcells:sync", (e, { cells }) => { const pc = ctx(e); if (pc) pc.sync(cells); });
250
+ ipcMain.on("panelcells:reload", (e, { id }) => { const pc = ctx(e); if (pc) pc.reload(id); });
251
+ ipcMain.handle("panelcells:profiles", (e) => {
252
+ if (!ctx(e)) return [];
253
+ try {
254
+ return require("../profiles/profile-store").listProfiles("electron")
255
+ .filter((p) => Number.isInteger(Number(p.accountIdx)) && Number(p.accountIdx) > 0 && Number(p.accountIdx) !== 9)
256
+ .map((p) => ({ accountIdx: Number(p.accountIdx), name: String(p.name || "") }));
257
+ } catch (err) { return []; }
258
+ });
259
+ ipcMain.handle("panelcells:snapshots", async (e) => {
260
+ const pc = ctx(e);
261
+ return pc ? pc.snapshots() : [];
262
+ });
263
+ ipcMain.handle("panelcells:agents", async (e) => {
264
+ if (!ctx(e)) return { ok: false, agents: [], error: "invalid panel" };
265
+ try {
266
+ const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), "cicy-ai", "global.json"), "utf8"));
267
+ const token = String(cfg.api_token || "");
268
+ const r = await fetch("http://127.0.0.1:8008/api/tmux/panes", {
269
+ headers: { Authorization: `Bearer ${token}` },
270
+ signal: AbortSignal.timeout(5000),
271
+ });
272
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
273
+ const data = await r.json();
274
+ const panes = data?.panes || data?.data?.panes || [];
275
+ return {
276
+ ok: true,
277
+ agents: panes.map((p) => ({
278
+ id: String(p.pane_id || "").replace(/:.*$/, ""),
279
+ title: String(p.title || p.pane_id || ""),
280
+ type: String(p.agent_type || ""),
281
+ })).filter((p) => p.id),
282
+ };
283
+ } catch (err) {
284
+ return { ok: false, agents: [], error: err.message || String(err) };
285
+ }
286
+ });
287
+ // divider drag in the panel page: views would swallow pointer events — detach
288
+ // during the drag (page shows frame-only preview), reattach + re-place on up.
289
+ ipcMain.on("panelcells:drag", (e, { on }) => { const pc = ctx(e); if (pc) { if (on) pc.hide(); else pc.show(); } });
290
+ }
291
+
292
+ module.exports = { installIpc, onTabShown, onTabHidden };
@@ -0,0 +1,24 @@
1
+ // Copyright 2026 CiCy AI
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // Preload for the split panel page (cicyui://panel/<id>). Exposes window.panelAPI
5
+ // so the page (pure chrome: headers/dividers/layout) drives main-process
6
+ // BrowserView cells (panel-cells.js). Sandbox-safe: only contextBridge + ipcRenderer.
7
+ const { contextBridge, ipcRenderer } = require("electron");
8
+
9
+ contextBridge.exposeInMainWorld("panelAPI", {
10
+ // full desired state, main reconciles: [{id, url, rect:{x,y,w,h}}]
11
+ sync: (cells) => ipcRenderer.send("panelcells:sync", { cells }),
12
+ reload: (id) => ipcRenderer.send("panelcells:reload", { id }),
13
+ agents: () => ipcRenderer.invoke("panelcells:agents"),
14
+ profiles: () => ipcRenderer.invoke("panelcells:profiles"),
15
+ snapshots: () => ipcRenderer.invoke("panelcells:snapshots"),
16
+ // divider drag: BrowserViews sit ABOVE the page and would swallow pointermove —
17
+ // detach them for the duration of the drag (frame-only preview), reattach on up.
18
+ dragging: (on) => ipcRenderer.send("panelcells:drag", { on: !!on }),
19
+ onCellState: (cb) => {
20
+ const h = (_e, s) => { try { cb(s); } catch (e) {} };
21
+ ipcRenderer.on("panelcells:state", h);
22
+ return () => ipcRenderer.removeListener("panelcells:state", h);
23
+ },
24
+ });