cicy-desktop 2.1.78 → 2.1.80

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,126 @@
1
+ // window-thumbnails.js
2
+ // Periodically writes a small JPEG thumbnail of every open BrowserWindow to a
3
+ // folder — like Chrome's tab thumbnails. On-disk so the homepage / agents can
4
+ // read a recent preview of a window without an RPC + capturePage round-trip
5
+ // each time.
6
+ //
7
+ // Reuses the same capture path as `GET /ui/snapshot` (capturePage → resize →
8
+ // toJPEG), just driven on a timer and scaled to a small rect.
9
+ //
10
+ // Layout (<dir> default ~/cicy-files/window-thumbs, override CICY_THUMB_DIR):
11
+ // <dir>/win-<id>.jpg one small JPEG per live window (overwritten each tick)
12
+ // <dir>/index.json manifest: [{ id, title, url, accountIdx, w, h, file, bytes, updatedAt }]
13
+ // Thumbnails for closed windows are pruned each tick.
14
+
15
+ const fs = require("fs");
16
+ const path = require("path");
17
+ const os = require("os");
18
+ const { BrowserWindow } = require("electron");
19
+
20
+ let timer = null;
21
+ let kickTimer = null;
22
+ let running = false; // a tick may outlast the interval — don't overlap captures
23
+
24
+ function thumbDir() {
25
+ const fromEnv = (process.env.CICY_THUMB_DIR || "").trim();
26
+ return fromEnv || path.join(os.homedir(), "cicy-files", "window-thumbs");
27
+ }
28
+
29
+ function accountIdxOf(win) {
30
+ // Windows tagged at creation expose cicyAccountIdx; otherwise the default
31
+ // session (homepage / system windows) maps to 0.
32
+ if (typeof win.cicyAccountIdx === "number") return win.cicyAccountIdx;
33
+ try {
34
+ const p = win.webContents.session.partition || "";
35
+ const m = /^persist:sandbox-(\d+)$/.exec(p);
36
+ if (m) return parseInt(m[1], 10);
37
+ } catch (_) {}
38
+ return 0;
39
+ }
40
+
41
+ async function captureOne(win, dir, { maxWidth, quality }) {
42
+ if (win.isDestroyed()) return null;
43
+ const wc = win.webContents;
44
+ if (!wc || wc.isDestroyed() || wc.isCrashed()) return null;
45
+ const image = await wc.capturePage();
46
+ if (image.isEmpty()) return null;
47
+ const { width, height } = image.getSize();
48
+ if (!width || !height) return null;
49
+ const scale = Math.min(1, maxWidth / width);
50
+ const tw = Math.max(1, Math.round(width * scale));
51
+ const th = Math.max(1, Math.round(height * scale));
52
+ const scaled = scale < 1 ? image.resize({ width: tw, height: th, quality: "good" }) : image;
53
+ const buf = scaled.toJPEG(quality);
54
+ const file = path.join(dir, `win-${win.id}.jpg`);
55
+ fs.writeFileSync(file, buf);
56
+ return {
57
+ id: win.id,
58
+ title: win.getTitle(),
59
+ url: wc.getURL(),
60
+ accountIdx: accountIdxOf(win),
61
+ w: tw,
62
+ h: th,
63
+ file,
64
+ bytes: buf.length,
65
+ updatedAt: new Date().toISOString(),
66
+ };
67
+ }
68
+
69
+ async function tick(opts) {
70
+ if (running) return;
71
+ running = true;
72
+ try {
73
+ const dir = thumbDir();
74
+ fs.mkdirSync(dir, { recursive: true });
75
+ const wins = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed());
76
+ const liveIds = new Set(wins.map((w) => w.id));
77
+
78
+ // Prune thumbnails whose window is gone.
79
+ try {
80
+ for (const f of fs.readdirSync(dir)) {
81
+ const m = /^win-(\d+)\.jpg$/.exec(f);
82
+ if (m && !liveIds.has(parseInt(m[1], 10))) {
83
+ try { fs.unlinkSync(path.join(dir, f)); } catch (_) {}
84
+ }
85
+ }
86
+ } catch (_) {}
87
+
88
+ const manifest = [];
89
+ for (const win of wins) {
90
+ try {
91
+ const entry = await captureOne(win, dir, opts);
92
+ if (entry) manifest.push(entry);
93
+ } catch (_) {
94
+ // one window failing must never stop the rest
95
+ }
96
+ }
97
+ try {
98
+ fs.writeFileSync(path.join(dir, "index.json"), JSON.stringify(manifest, null, 2));
99
+ } catch (_) {}
100
+ } finally {
101
+ running = false;
102
+ }
103
+ }
104
+
105
+ function startWindowThumbnails(options = {}) {
106
+ const opts = {
107
+ intervalMs: options.intervalMs || 4000,
108
+ maxWidth: options.maxWidth || 320, // small rect, chrome-ish
109
+ quality: options.quality || 60,
110
+ };
111
+ stopWindowThumbnails();
112
+ const kick = () => { tick(opts).catch(() => {}); };
113
+ timer = setInterval(kick, opts.intervalMs);
114
+ if (timer.unref) timer.unref();
115
+ // First pass shortly after launch (let windows paint at least one frame).
116
+ kickTimer = setTimeout(kick, 1500);
117
+ if (kickTimer.unref) kickTimer.unref();
118
+ return { dir: thumbDir(), ...opts };
119
+ }
120
+
121
+ function stopWindowThumbnails() {
122
+ if (timer) { clearInterval(timer); timer = null; }
123
+ if (kickTimer) { clearTimeout(kickTimer); kickTimer = null; }
124
+ }
125
+
126
+ module.exports = { startWindowThumbnails, stopWindowThumbnails, thumbDir };
@@ -1,4 +1,6 @@
1
1
  const { app, BrowserWindow, Menu, dialog, shell } = require("electron");
2
+ const { default: contextMenu } = require("electron-context-menu");
3
+ const contextMenuOptions = require("./context-menu-options");
2
4
  const path = require("path");
3
5
  const fs = require("fs");
4
6
  const os = require("os");
@@ -31,8 +33,11 @@ function setupWindowHandlers(win) {
31
33
  icon: require("./app-icon").appIconPath(),
32
34
  webPreferences: {
33
35
  webviewTag: true, // embedded <webview> (ttyd/artifact) must render
34
- nodeIntegration: isTrustedUrl(url),
35
- contextIsolation: !isTrustedUrl(url),
36
+ // hole #4: trusted pages no longer get raw Node. Their electronRPC
37
+ // bridge comes from webview-preload via contextBridge, so a trusted-origin
38
+ // XSS can't `require('child_process')` to bypass the rpc:guarded gate.
39
+ nodeIntegration: false,
40
+ contextIsolation: true,
36
41
  preload: path.join(__dirname, "../backends/webview-preload.js"),
37
42
  webSecurity: false,
38
43
  enableClipboard: true,
@@ -55,8 +60,22 @@ function setupWindowHandlers(win) {
55
60
  // homepage is a persistent window; everything created here is disposable and
56
61
  // re-openable from the homepage. (Previously these preventDefault()+hide()'d,
57
62
  // so "closed" windows lingered hidden forever.)
63
+ const _registryId = win.id;
58
64
  win.on("close", () => {
59
- log.info(`[Window ${win.id}] Close → destroy: ${win.getTitle()}`);
65
+ log.info(`[Window ${_registryId}] Close → destroy: ${win.getTitle()}`);
66
+ });
67
+ // Persistent window registry: a USER/agent close keeps the record (status
68
+ // "closed", re-openable). A close during app QUIT leaves it "open" so it
69
+ // auto-reopens next launch. No-op for unregistered windows (e.g. homepage).
70
+ // "closed" fires for both win.close() and win.destroy().
71
+ win.on("closed", () => {
72
+ try {
73
+ if (!app || !app.isQuitting) {
74
+ require("./window-registry").markClosed(_registryId);
75
+ }
76
+ } catch (e) {
77
+ log.error("[WindowRegistry] markClosed failed:", e.message);
78
+ }
60
79
  });
61
80
 
62
81
  // 🔥 全局下载处理 - 自动保存到 ~/Downloads/electron/
@@ -78,70 +97,10 @@ function setupWindowHandlers(win) {
78
97
  }
79
98
 
80
99
  win.webContents.on("dom-ready", async () => {
81
- // Auto-inject electronRPC for trusted URLs. Trust comes from
82
- // isTrustedUrl(), which includes built-in dev hosts AND any backend the
83
- // user explicitly added to the registry (they opt in by adding the URL,
84
- // and the auth token is required to reach the cicy-code there anyway).
85
- const pageUrl = win.webContents.getURL();
86
- try {
87
- if (isTrustedUrl(pageUrl)) {
88
- const rpcCode = `
89
- if (!window.electronRPC) {
90
- try {
91
- const { ipcRenderer } = require('electron');
92
- window.electronRPC = (tool, args) => ipcRenderer.invoke('rpc', tool, args || {});
93
- console.log('[RPC] electronRPC ready');
94
- } catch(e) {}
95
- }
96
- // window.cicy.artifact — remote control of the 产物 (artifact) <webview>
97
- // guest webContents for cicy-code's artifactBridge.ts. Targets the
98
- // element id 'cicy-artifact-webview'; round-trips to artifact-ipc.js.
99
- (function(){
100
- try {
101
- if (window.cicy && window.cicy.artifact) return;
102
- const { ipcRenderer } = require('electron');
103
- window.cicy = window.cicy || {};
104
- const guestId = () => {
105
- const el = document.getElementById('cicy-artifact-webview');
106
- if (!el || typeof el.getWebContentsId !== 'function')
107
- throw new Error('artifact webview not mounted (open the 产物 tab once)');
108
- return el.getWebContentsId();
109
- };
110
- let _attached = false;
111
- window.cicy.artifact = {
112
- invoke: (method, args) =>
113
- ipcRenderer.invoke('artifact:invoke', { guestId: guestId(), method, args: args || [] }),
114
- cdp: {
115
- attach: (protocolVersion) =>
116
- ipcRenderer.invoke('artifact:cdp-attach', { guestId: guestId(), protocolVersion })
117
- .then((r) => { _attached = true; return r; }),
118
- detach: () =>
119
- ipcRenderer.invoke('artifact:cdp-detach', { guestId: guestId() })
120
- .then((r) => { _attached = false; return r; }),
121
- isAttached: () => _attached,
122
- send: (method, params) =>
123
- ipcRenderer.invoke('artifact:cdp-send', { guestId: guestId(), method, params: params || {} }),
124
- },
125
- };
126
- try { ipcRenderer.removeAllListeners('artifact:event'); } catch(e) {}
127
- ipcRenderer.on('artifact:event', (_e, detail) => {
128
- if (detail && detail.source === 'cdp' && detail.method === '__detached') _attached = false;
129
- try { window.dispatchEvent(new CustomEvent('cicy-artifact-event', { detail: detail })); } catch(e) {}
130
- });
131
- console.log('[artifact] window.cicy.artifact ready');
132
- } catch(e) { console.error('[artifact] bridge inject failed', e && e.message); }
133
- })();
134
- `;
135
- if (win.webContents.debugger.isAttached()) {
136
- await win.webContents.debugger.sendCommand("Runtime.evaluate", { expression: rpcCode });
137
- } else {
138
- await win.webContents.executeJavaScript(rpcCode);
139
- }
140
- }
141
- } catch (e) {
142
- log.error("[RPC inject]", e.message);
143
- }
144
-
100
+ // (Removed: the 产物/artifact bridge injection. electronRPC for trusted pages
101
+ // is provided by webview-preload.js via contextBridge; the artifact webview
102
+ // remote-control feature was deleted superseded by the electron tab + chrome
103
+ // profile browsers.)
145
104
  try {
146
105
  // 1. 获取当前页面的根域名
147
106
  const currentURL = win.webContents.getURL();
@@ -203,23 +162,17 @@ function setupWindowHandlers(win) {
203
162
  // registry.remove call refreshTrustedOrigins() — wired in registry.js).
204
163
  let _trustedOriginsCache = null;
205
164
  function loadTrustedOrigins() {
206
- // Built-in always-trusted hosts (dev + reserved internal domain).
207
- const set = new Set(["localhost", "127.0.0.1"]);
165
+ // The trusted set = the user-managed allowlist in
166
+ // ~/cicy-ai/db/trusted-origins.json (built-ins localhost/127.0.0.1 included by
167
+ // the store). Backends / teams are NO LONGER auto-trusted: "add a server" must
168
+ // never implicitly grant a remote origin the right to run commands locally.
169
+ // Users (incl. self-hosted) add their own domain explicitly in settings.
208
170
  try {
209
- // Lazy require to avoid a cycle (registry.js requires electron.app which
210
- // isn't ready when this module is first loaded by main.js).
211
- const registry = require("../backends/registry");
212
- for (const b of registry.list()) {
213
- if (!b || !b.url) continue;
214
- try {
215
- const h = new URL(b.url).hostname;
216
- if (h) set.add(h);
217
- } catch {}
218
- }
171
+ const store = require("../profiles/trusted-origins-store");
172
+ return new Set(store.listAll());
219
173
  } catch (e) {
220
- // Registry not ready yet — fall back to built-ins.
174
+ return new Set(["localhost", "127.0.0.1"]);
221
175
  }
222
- return set;
223
176
  }
224
177
  function trustedOrigins() {
225
178
  if (!_trustedOriginsCache) _trustedOriginsCache = loadTrustedOrigins();
@@ -233,14 +186,20 @@ function isTrustedUrl(url) {
233
186
  if (!url) return false;
234
187
  try {
235
188
  const u = new URL(url);
236
- if (u.hostname.endsWith(".de5.net")) return true;
189
+ // Exact-hostname match against the user allowlist ONLY. No domain-suffix
190
+ // wildcard — a public-upload host under a trusted suffix (e.g.
191
+ // r2.deepfetch.de5.net, which can serve attacker HTML) must never count as a
192
+ // trusted RPC origin.
237
193
  return trustedOrigins().has(u.hostname);
238
194
  } catch {
239
195
  return false;
240
196
  }
241
197
  }
242
198
 
243
- function createWindow(options = {}, accountIdx = 0, forceNew = false) {
199
+ // accountIdx default = 1 (user profile space). Account 0 is reserved for the
200
+ // platform's own/system windows; callers that want the system slot pass 0
201
+ // explicitly (e.g. local-teams). Agents opening windows should use >0.
202
+ function createWindow(options = {}, accountIdx = 1, forceNew = false) {
244
203
  const { width = 1200, height = 800, url, webPreferences = {}, x, y } = options;
245
204
  console.log("[createWindow] url:", url, "isTrusted:", isTrustedUrl(url));
246
205
 
@@ -310,13 +269,16 @@ function createWindow(options = {}, accountIdx = 0, forceNew = false) {
310
269
  autoHideMenuBar: true,
311
270
  webPreferences: {
312
271
  offscreen: false, // 确保不是离屏渲染
313
- nodeIntegration: isTrustedUrl(url),
314
- contextIsolation: !isTrustedUrl(url),
315
- // electronRPC + window.cicy for EVERY window open_window creates — without
316
- // this, untrusted (contextIsolation:true) pages had no electronRPC and the
272
+ // hole #4: trusted pages run with NO raw Node (was nodeIntegration:
273
+ // isTrustedUrl). electronRPC + window.cicy come from webview-preload via
274
+ // contextBridge, so a trusted-origin XSS can't `require('child_process')`
275
+ // past the rpc:guarded gate.
276
+ nodeIntegration: false,
277
+ contextIsolation: true,
278
+ // webview-preload exposes electronRPC + window.cicy for EVERY window
279
+ // open_window creates (contextBridge under isolation). Without it the
317
280
  // agent-desktop/agent-electron skills' `desktop_event rpc_call` failed with
318
- // 'electronRPC not available'. webview-preload self-adapts to the isolation
319
- // mode (contextBridge when isolated, direct window assign when not).
281
+ // 'electronRPC not available'.
320
282
  preload: path.join(__dirname, "../backends/webview-preload.js"),
321
283
  partition: `persist:sandbox-${accountIdx}`,
322
284
  // 启用剪贴板权限
@@ -337,16 +299,29 @@ function createWindow(options = {}, accountIdx = 0, forceNew = false) {
337
299
  // ✅ 核心修正:获取当前窗口真正使用的那个 session
338
300
  const ses = win.webContents.session;
339
301
 
340
- // 设置代理(如果全局配置了)
341
- if (config.proxy) {
342
- const proxyConfig = {
343
- proxyRules: config.proxy,
344
- // proxyBypassRules removed
345
- };
302
+ // 设置代理:优先用该 profile 持久化的 proxy(account-N.json),否则回退全局 config.proxy。
303
+ // 这样新开窗口会自动套用账号自己保存的代理,无需手动 set_account_proxy。
304
+ let proxyRules = "";
305
+ let proxySource = "";
306
+ try {
307
+ const profileStore = require("../profiles/profile-store");
308
+ const persisted = profileStore.proxyRules(profileStore.getProfile("electron", accountIdx)?.proxy);
309
+ if (persisted) {
310
+ proxyRules = persisted;
311
+ proxySource = "profile";
312
+ }
313
+ } catch (err) {
314
+ log.error(`[Proxy] Account ${accountIdx} 读取持久化代理失败:`, err);
315
+ }
316
+ if (!proxyRules && config.proxy) {
317
+ proxyRules = config.proxy;
318
+ proxySource = "global";
319
+ }
320
+ if (proxyRules) {
346
321
  ses
347
- .setProxy(proxyConfig)
322
+ .setProxy({ proxyRules })
348
323
  .then(() => {
349
- log.info(`[Proxy] Account ${accountIdx} 已设置代理: ${config.proxy}`);
324
+ log.info(`[Proxy] Account ${accountIdx} 已设置代理 (${proxySource}): ${proxyRules}`);
350
325
  })
351
326
  .catch((err) => {
352
327
  log.error(`[Proxy] Account ${accountIdx} 设置代理失败:`, err);
@@ -385,6 +360,43 @@ function createWindow(options = {}, accountIdx = 0, forceNew = false) {
385
360
 
386
361
  setupWindowHandlers(win);
387
362
 
363
+ // Persistent window registry: record this window (dedup by accountIdx+url),
364
+ // then keep its url/title/bounds fresh so a restart can restore it. The
365
+ // "closed" handler in setupWindowHandlers flips status when it's closed.
366
+ try {
367
+ const registry = require("./window-registry");
368
+ registry.registerOpen({
369
+ accountIdx,
370
+ url: url || "",
371
+ title: win.getTitle(),
372
+ bounds: win.getBounds(),
373
+ liveId: win.id,
374
+ });
375
+ let boundsTimer = null;
376
+ const touchBounds = () => {
377
+ if (boundsTimer) clearTimeout(boundsTimer);
378
+ boundsTimer = setTimeout(() => {
379
+ if (!win.isDestroyed()) registry.touch({ liveId: win.id, bounds: win.getBounds() });
380
+ }, 500);
381
+ };
382
+ win.on("resize", touchBounds);
383
+ win.on("move", touchBounds);
384
+ win.webContents.on("page-title-updated", () => {
385
+ if (!win.isDestroyed())
386
+ registry.touch({
387
+ liveId: win.id,
388
+ title: win.getTitle(),
389
+ url: win.webContents.getURL(),
390
+ });
391
+ });
392
+ win.webContents.on("did-navigate", () => {
393
+ if (!win.isDestroyed())
394
+ registry.touch({ liveId: win.id, url: win.webContents.getURL() });
395
+ });
396
+ } catch (e) {
397
+ log.error("[WindowRegistry] register failed:", e.message);
398
+ }
399
+
388
400
  if (url) {
389
401
  win.loadURL(url);
390
402
  }
@@ -397,6 +409,9 @@ function getWindowInfo(win) {
397
409
  const wc = win.webContents;
398
410
  if (!wc || !wc.session) return null;
399
411
  const partition = wc.session.partition || "";
412
+ // persist:sandbox-N → account N (N>=1 are user profiles). Anything on the
413
+ // default session (homepage / platform system windows, partition "") maps
414
+ // to account 0 — the reserved system slot.
400
415
  const accountIdx = partition.startsWith("persist:sandbox-")
401
416
  ? parseInt(partition.replace("persist:sandbox-", ""), 10)
402
417
  : 0;
@@ -442,19 +457,40 @@ if (app) {
442
457
  app.on("web-contents-created", (_e, contents) => {
443
458
  try {
444
459
  if (contents.getType && contents.getType() === "webview") {
445
- contents.setWindowOpenHandler(({ url }) => ({
446
- action: "allow",
447
- overrideBrowserWindowOptions: {
448
- autoHideMenuBar: true,
449
- webPreferences: {
450
- webviewTag: true,
451
- contextIsolation: !isTrustedUrl(url),
452
- nodeIntegration: isTrustedUrl(url),
453
- webSecurity: false,
454
- enableClipboard: true,
460
+ // NOTE: do NOT attach contextMenu here — the global contextMenu() in
461
+ // main.js now also auto-attaches to <webview> guests, so an explicit
462
+ // attach made them get TWO right-click menus (双重弹窗). Global covers it.
463
+ contents.setWindowOpenHandler(({ url }) => {
464
+ // External (cross-origin http/https) links opened from a <webview> guest
465
+ // — e.g. the gotty terminal's "打开链接" confirm button — go to the user's
466
+ // SYSTEM browser instead of a new in-app window. Same-origin popups (a
467
+ // team app opening its own sub-page) keep the in-app window so embedded
468
+ // app flows aren't disturbed. (master: gotty open link 用系统 browser 打开)
469
+ try {
470
+ if (/^https?:\/\//i.test(url)) {
471
+ let guestOrigin = "";
472
+ try { guestOrigin = new URL(contents.getURL()).origin; } catch (_e) {}
473
+ if (new URL(url).origin !== guestOrigin) {
474
+ shell.openExternal(url);
475
+ return { action: "deny" };
476
+ }
477
+ }
478
+ } catch (_e) {}
479
+ return {
480
+ action: "allow",
481
+ overrideBrowserWindowOptions: {
482
+ autoHideMenuBar: true,
483
+ webPreferences: {
484
+ webviewTag: true,
485
+ // hole #4: no raw Node for trusted pages (see createWindow above).
486
+ contextIsolation: true,
487
+ nodeIntegration: false,
488
+ webSecurity: false,
489
+ enableClipboard: true,
490
+ },
455
491
  },
456
- },
457
- }));
492
+ };
493
+ });
458
494
  }
459
495
  } catch (e) {
460
496
  log.warn(`[web-contents-created] guest open-handler failed: ${e.message}`);
@@ -491,4 +527,5 @@ module.exports = {
491
527
  setupWindowHandlers,
492
528
  getWindowInfo,
493
529
  refreshTrustedOrigins,
530
+ isTrustedUrl,
494
531
  };
@@ -353,9 +353,9 @@
353
353
  }
354
354
  },
355
355
  "node_modules/@emnapi/runtime": {
356
- "version": "1.11.0",
357
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
358
- "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
356
+ "version": "1.11.1",
357
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
358
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
359
359
  "dev": true,
360
360
  "license": "MIT",
361
361
  "optional": true,
@@ -730,9 +730,9 @@
730
730
  }
731
731
  },
732
732
  "node_modules/browserslist/node_modules/caniuse-lite": {
733
- "version": "1.0.30001797",
734
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
735
- "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==",
733
+ "version": "1.0.30001799",
734
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
735
+ "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
736
736
  "dev": true,
737
737
  "funding": [
738
738
  {
@@ -16,6 +16,9 @@
16
16
  }
17
17
  * { box-sizing: border-box; }
18
18
  html, body, #root { margin: 0; height: 100%; }
19
+ /* Fixed-size window (930*640): the page itself never scrolls — only .main
20
+ scrolls (overflow-y auto, custom scrollbar). 主人令. */
21
+ html, body { overflow: hidden; }
19
22
  body {
20
23
  font-family: -apple-system, BlinkMacSystemFont, "PingFang SC",
21
24
  "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
@@ -41,7 +44,7 @@ body {
41
44
  flex: 1 1 auto;
42
45
  min-width: 0; /* allow shrink, prevent grid blow-out */
43
46
  display: flex; flex-direction: column;
44
- overflow: auto;
47
+ overflow: hidden; /* topbar fixed; only .main scrolls */
45
48
  }
46
49
  .helper-aside {
47
50
  flex: 0 0 auto;
@@ -141,7 +144,7 @@ body {
141
144
  /* ── App shell (logged in) ── */
142
145
  .topbar {
143
146
  -webkit-app-region: drag;
144
- position: sticky; top: 0; z-index: 10;
147
+ position: relative; flex: 0 0 auto; z-index: 10; /* pinned: .main scrolls under it */
145
148
  display: flex; align-items: center; justify-content: space-between;
146
149
  padding: 14px 24px 12px;
147
150
  background: rgba(8,9,14,.6);
@@ -267,7 +270,19 @@ body {
267
270
  padding: 22px 32px 48px;
268
271
  width: 100%;
269
272
  display: flex; flex-direction: column; gap: 16px;
273
+ flex: 1 1 auto; min-height: 0;
274
+ overflow-y: auto; /* the ONLY scroll container of the page */
275
+ }
276
+ /* Custom scrollbar for .main */
277
+ .main::-webkit-scrollbar { width: 8px; }
278
+ .main::-webkit-scrollbar-track { background: transparent; }
279
+ .main::-webkit-scrollbar-thumb {
280
+ background: rgba(125,135,150,.30);
281
+ border-radius: 999px;
282
+ border: 2px solid transparent;
283
+ background-clip: padding-box;
270
284
  }
285
+ .main::-webkit-scrollbar-thumb:hover { background-color: rgba(125,135,150,.55); }
271
286
 
272
287
  /* ── Tabs row ── */
273
288
  .app__tabs {
@@ -312,6 +327,20 @@ body {
312
327
  text-align: center;
313
328
  }
314
329
  .app__tab.is-active .app__tab-count { background: rgba(91,141,247,.3); color: #fff; }
330
+ /* 整行:tab 药丸在左,「新加团队」顶到行尾 */
331
+ .app__tabsrow { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; }
332
+ /* 行尾"新加团队"动作按钮:独立强调色按钮,跟 tab 药丸区分开 */
333
+ .app__add-team {
334
+ -webkit-app-region: no-drag;
335
+ appearance: none; cursor: pointer;
336
+ flex: none;
337
+ color: #5b8df7; font-weight: 600; font-size: 12.5px;
338
+ background: rgba(91,141,247,.10);
339
+ border: 1px solid rgba(91,141,247,.35);
340
+ border-radius: 9px; padding: 7px 14px;
341
+ transition: color 120ms ease, background 120ms ease, border-color 120ms ease;
342
+ }
343
+ .app__add-team:hover { color: #fff; background: rgba(91,141,247,.22); border-color: rgba(91,141,247,.65); }
315
344
 
316
345
  /* ── Card grid ── */
317
346
  .app__grid {
@@ -776,6 +805,11 @@ body {
776
805
  .bcard__menu-item.is-accent:hover { background: var(--accent-soft); color: #c7dbff; }
777
806
  .bcard__menu-item.is-danger { color: #f7a3a3; }
778
807
  .bcard__menu-item.is-danger:hover { background: rgba(239,68,68,.16); color: #fff; }
808
+ /* Portaled to document.body to escape .bcard's overflow:hidden (which clipped the
809
+ dropdown). Inline top/left position it under the kebab; here just a high
810
+ stacking order (below toast/drawer) + word-wrapping items so nothing overflows. */
811
+ .bcard__menu--portal { z-index: 9990; }
812
+ .bcard__menu--portal .bcard__menu-item { white-space: normal; word-break: break-word; }
779
813
  /* ---- Windows Docker 一键安装/启动卡 (DockerSetup) ---- */
780
814
  .docker-setup {
781
815
  margin-bottom: 14px;