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
package/src/main.js CHANGED
@@ -14,40 +14,13 @@ const { setupAppIcons } = require("./tray");
14
14
  const { brandHostElectron } = require("./utils/brand-host-electron");
15
15
  const appUpdater = require("./app-updater");
16
16
 
17
- // 🎯 添加右键上下文菜单
18
- contextMenu({
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
- showInspectElement: true,
29
- showServices: true,
30
- labels: {
31
- cut: "剪切",
32
- copy: "复制",
33
- paste: "粘贴",
34
- selectAll: "全选",
35
- reload: "重新加载",
36
- forceReload: "强制重新加载",
37
- toggleDevTools: "切换开发者工具",
38
- inspectElement: "检查元素",
39
- services: "服务",
40
- lookUpSelection: "查找选中内容",
41
- searchWithGoogle: "用 Google 搜索",
42
- copyImage: "复制图片",
43
- copyImageAddress: "复制图片地址",
44
- saveImage: "保存图片",
45
- copyVideoAddress: "复制视频地址",
46
- saveVideo: "保存视频",
47
- copyLink: "复制链接",
48
- saveLinkAs: "链接另存为...",
49
- },
50
- });
17
+ // 🎯 Right-click menu — attach the SAME menu to EVERY webContents (host window,
18
+ // tab, <webview>, popup) via 'web-contents-created'. ecm only auto-attaches to a
19
+ // BrowserWindow's MAIN webContents, so the tab-browser SHELL window ("BaseWindow")
20
+ // and guests fell back to the OS-native menu; this unifies them and adds 重新加载
21
+ // + 切换开发者工具 + 检查元素 everywhere (see utils/context-menu-options.js).
22
+ const { attachContextMenu } = require("./utils/context-menu-options");
23
+ electronApp.on("web-contents-created", (_e, wc) => attachContextMenu(wc));
51
24
 
52
25
  // Setup Electron flags IMMEDIATELY after require
53
26
  electronApp.commandLine.appendSwitch("ignore-certificate-errors");
@@ -89,7 +62,6 @@ const { loadToolCatalog } = require("./server/tool-catalog");
89
62
  const { executeTool } = require("./server/tool-executor");
90
63
  const { getWorkerIdentity } = require("./cluster/worker-identity");
91
64
  const { listLocalAgents } = require("./cluster/local-agent-registry");
92
- const { listArtifacts } = require("./cluster/artifact-registry");
93
65
  const { WorkerClient } = require("./cluster/worker-client");
94
66
  const { getChromeRuntimeRegistry } = require("./chrome/runtime-registry");
95
67
 
@@ -147,15 +119,30 @@ for (const s of DEEPLINK_SCHEMES) {
147
119
  // Linux when the URL is in argv). Queue them and flush whenever a window
148
120
  // finishes loading. The renderer subscribes via window.cicy.deeplink.onAddTeam.
149
121
  const __pendingDeepLinks = [];
150
- function broadcastDeepLink(channel, payload) {
122
+ // Deep-link delivery targets = every BrowserWindow's webContents PLUS the
123
+ // homepage tab's webContents. The homepage is now a BrowserView tab (not a
124
+ // BrowserWindow), so getAllWindows() alone would miss it and cicy://addTeam
125
+ // would only refresh on the next poll instead of instantly.
126
+ function deepLinkTargets() {
151
127
  const { BrowserWindow } = require("electron");
152
- const wins = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed());
153
- if (wins.length === 0) {
128
+ const targets = BrowserWindow.getAllWindows()
129
+ .filter((w) => !w.isDestroyed())
130
+ .map((w) => w.webContents);
131
+ try {
132
+ const hw = require("./backends/homepage-window").getHomepageWindow();
133
+ const wc = hw && hw.webContents;
134
+ if (wc && !wc.isDestroyed() && !targets.includes(wc)) targets.push(wc);
135
+ } catch {}
136
+ return targets;
137
+ }
138
+ function broadcastDeepLink(channel, payload) {
139
+ const targets = deepLinkTargets();
140
+ if (targets.length === 0) {
154
141
  __pendingDeepLinks.push({ channel, payload });
155
142
  return;
156
143
  }
157
- for (const w of wins) {
158
- try { w.webContents.send(channel, payload); } catch {}
144
+ for (const wc of targets) {
145
+ try { wc.send(channel, payload); } catch {}
159
146
  }
160
147
  }
161
148
 
@@ -164,13 +151,12 @@ function broadcastDeepLink(channel, payload) {
164
151
  // even when it was the URL that started the app in the first place.
165
152
  function flushPendingDeepLinks() {
166
153
  if (__pendingDeepLinks.length === 0) return;
167
- const { BrowserWindow } = require("electron");
168
- const wins = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed());
169
- if (wins.length === 0) return;
154
+ const targets = deepLinkTargets();
155
+ if (targets.length === 0) return;
170
156
  const drained = __pendingDeepLinks.splice(0, __pendingDeepLinks.length);
171
157
  for (const { channel, payload } of drained) {
172
- for (const w of wins) {
173
- try { w.webContents.send(channel, payload); } catch {}
158
+ for (const wc of targets) {
159
+ try { wc.send(channel, payload); } catch {}
174
160
  }
175
161
  }
176
162
  }
@@ -270,6 +256,17 @@ const {
270
256
  chromeUserDataRoot,
271
257
  chromeDebuggerBasePort,
272
258
  } = parseArgs();
259
+
260
+ // The two remote-control surfaces are the HTTP/MCP tool server (PORT, default
261
+ // 8101) and the Chrome DevTools Protocol debug port (9221). CDP has NO
262
+ // authentication — anyone who can reach 9221 can drive every Electron window
263
+ // (run arbitrary JS, read the DOM, navigate). So gate BOTH behind the same
264
+ // opt-in: a default desktop install (no --mcp, no CICY_DESKTOP_HTTP, no
265
+ // CICY_MASTER_URL) opens neither, removing the unauthenticated CDP surface
266
+ // entirely unless automation is actually wanted.
267
+ const automationEnabled =
268
+ process.env.CICY_DESKTOP_HTTP === "1" || enableMcp || !!process.env.CICY_MASTER_URL;
269
+
273
270
  config.port = PORT;
274
271
  if (chromeBinary) {
275
272
  config.chromeBinary = chromeBinary;
@@ -399,7 +396,6 @@ function getWorkerSnapshot(authManager) {
399
396
  .map((tool) => tool.name),
400
397
  agents: listLocalAgents(),
401
398
  chromeProfiles: chromeRuntimeRegistry.list(),
402
- artifacts: listArtifacts(),
403
399
  resources: {
404
400
  pid: process.pid,
405
401
  memory: process.memoryUsage(),
@@ -446,10 +442,6 @@ app.get("/api/agents", authMiddleware, (req, res) => {
446
442
  res.json({ agents: listLocalAgents() });
447
443
  });
448
444
 
449
- app.get("/api/artifacts", authMiddleware, (req, res) => {
450
- res.json({ artifacts: listArtifacts() });
451
- });
452
-
453
445
  app.use(
454
446
  "/api/chrome",
455
447
  createChromeManagementRoutes({
@@ -464,34 +456,143 @@ app.use(
464
456
  // Start server
465
457
  const server = http.createServer(app);
466
458
 
467
- // 必须在 whenReady 之前设置调试端口
468
- electronApp.commandLine.appendSwitch("remote-debugging-port", "9221");
469
- log.info("[MCP] Remote debugging enabled on port 9221");
459
+ // 必须在 whenReady 之前设置调试端口。CDP 无鉴权,仅在自动化启用时才开,并显式
460
+ // 绑回环地址(默认已是 127.0.0.1,显式设置防止意外暴露到 0.0.0.0)
461
+ if (automationEnabled) {
462
+ electronApp.commandLine.appendSwitch("remote-debugging-port", "9221");
463
+ electronApp.commandLine.appendSwitch("remote-debugging-address", "127.0.0.1");
464
+ log.info("[MCP] Remote debugging enabled on 127.0.0.1:9221 (automation enabled)");
465
+ } else {
466
+ log.info("[MCP] Remote debugging port NOT opened (automation disabled — set --mcp / CICY_DESKTOP_HTTP=1 / CICY_MASTER_URL to enable)");
467
+ }
468
+
469
+ // Register the cicy:// scheme (tab-browser start page) — must run before ready.
470
+ require("./tabbrowser/newtab-protocol").registerScheme();
470
471
 
471
472
  // IPC Bridge: expose all RPC tools to renderer via ipcMain.handle
472
473
  const { ipcMain } = require("electron");
474
+ const { isDangerousTool, ensureRpcGrant, ensureOriginAuthorized, originDecision, startOriginModal } = require("./utils/rpc-guard");
475
+ // Sentinels returned (as normal tool results, so useDesktopEvents forwards their
476
+ // text verbatim) to a NON-BLOCKING caller while origin consent is undecided/denied.
477
+ // The agent/skill CLI polls on __CICY_AUTH_PENDING__ and stops on __CICY_AUTH_DENIED__.
478
+ const AUTH_PENDING_RESULT = { content: [{ type: "text", text: "__CICY_AUTH_PENDING__" }], isError: false };
479
+ const AUTH_DENIED_RESULT = { content: [{ type: "text", text: "__CICY_AUTH_DENIED__" }], isError: false };
480
+ const { audit, argsPreview } = require("./utils/rpc-audit");
481
+ function rpcOrigin(event) {
482
+ try { return new URL(event.sender.getURL()).origin; } catch { return (event && event.sender && event.sender.getURL && event.sender.getURL()) || "(unknown)"; }
483
+ }
484
+ async function dispatchRpc(event, toolName, args) {
485
+ const result = await executeTool(toolName, args || {}, {
486
+ transport: "ipc",
487
+ toolName,
488
+ controlSessionId: args?.controlSessionId || null,
489
+ agentId: args?.agentId || null,
490
+ runtimeSessionId: args?.runtimeSessionId || null,
491
+ windowRef: args?.windowRef || null,
492
+ accountIdx: args?.accountIdx,
493
+ worker: getWorkerIdentity(),
494
+ webContentsId: event.sender.id,
495
+ });
496
+ return result;
497
+ }
498
+ // "rpc" — unguarded full bridge for the first-party homepage system UI only.
473
499
  ipcMain.handle("rpc", async (event, toolName, args) => {
474
500
  console.log("[IPC Bridge] called:", toolName, JSON.stringify(args));
501
+ const origin = rpcOrigin(event);
502
+ const danger = isDangerousTool(toolName);
475
503
  try {
476
- const result = await executeTool(toolName, args || {}, {
477
- transport: "ipc",
478
- toolName,
479
- controlSessionId: args?.controlSessionId || null,
480
- agentId: args?.agentId || null,
481
- runtimeSessionId: args?.runtimeSessionId || null,
482
- windowRef: args?.windowRef || null,
483
- accountIdx: args?.accountIdx,
484
- worker: getWorkerIdentity(),
485
- webContentsId: event.sender.id,
486
- });
504
+ const result = await dispatchRpc(event, toolName, args);
487
505
  console.log("[IPC Bridge] success:", toolName);
506
+ audit({ kind: "rpc", channel: "rpc", origin, tool: toolName, dangerous: danger, ok: true, args: argsPreview(toolName, args) });
488
507
  return result;
489
508
  } catch (e) {
490
509
  console.error("[IPC Bridge] error:", toolName, e.message);
510
+ audit({ kind: "rpc", channel: "rpc", origin, tool: toolName, dangerous: danger, ok: false, error: e.message, args: argsPreview(toolName, args) });
511
+ throw e;
512
+ }
513
+ });
514
+ // FIFO concurrency limiter for the guarded path. While the consent modal is open
515
+ // a page (or the cloud rpc_call bridge) can pile up many RPC calls all awaiting
516
+ // the same authorization promise; the moment the user clicks 允许 they would ALL
517
+ // resolve and dispatch in one microtask flush, pegging the main thread for a beat
518
+ // ("点完之后卡一阵"). The limiter spreads that burst over a few in-flight slots —
519
+ // a single call still runs immediately (slot is free), only true bursts queue.
520
+ function makeLimiter(max) {
521
+ let active = 0;
522
+ const q = [];
523
+ const pump = () => {
524
+ while (active < max && q.length) {
525
+ active++;
526
+ const { fn, resolve, reject } = q.shift();
527
+ Promise.resolve().then(fn).then(
528
+ (v) => { active--; resolve(v); pump(); },
529
+ (e) => { active--; reject(e); pump(); },
530
+ );
531
+ }
532
+ };
533
+ return (fn) => new Promise((resolve, reject) => { q.push({ fn, resolve, reject }); pump(); });
534
+ }
535
+ const guardedDispatchLimit = makeLimiter(6);
536
+
537
+ // "rpc:guarded" — for every non-homepage renderer (team-helper webview, trusted
538
+ // remote pages, injected scripts). Dangerous tools (exec_*/file_*) require an
539
+ // explicit per-page user grant so a trusted-origin XSS can't silently run code.
540
+ ipcMain.handle("rpc:guarded", async (event, toolName, args) => {
541
+ console.log("[IPC Bridge] guarded call:", toolName);
542
+ // Domain-allowlist gate: a non-allowlisted origin must be authorized via a
543
+ // consent modal (deny / allow once / add to allowlist) before it can use the
544
+ // bridge at all. Allowlisted origins pass straight through.
545
+ const origin = rpcOrigin(event);
546
+ const danger = isDangerousTool(toolName);
547
+ // Agent/skill calls tag args with __cicyAuthNonBlocking: their transport is one
548
+ // fixed-timeout HTTP request, so they can't sit on the blocking consent modal.
549
+ // For an undecided origin we pop the modal in the BACKGROUND and hand back a
550
+ // PENDING sentinel the caller polls on; a denied origin gets a DENIED sentinel.
551
+ // In-page callers (no tag) keep the original blocking behaviour.
552
+ let nonBlockingAuth = false;
553
+ if (args && typeof args === "object" && args.__cicyAuthNonBlocking) {
554
+ nonBlockingAuth = true;
555
+ args = { ...args };
556
+ delete args.__cicyAuthNonBlocking;
557
+ }
558
+ if (nonBlockingAuth) {
559
+ const decision = originDecision(event); // "allow" | "deny" | "unknown" (no prompt)
560
+ if (decision === "deny") {
561
+ audit({ kind: "rpc", channel: "rpc:guarded", origin, tool: toolName, dangerous: danger, ok: false, error: "origin-denied", args: argsPreview(toolName, args) });
562
+ return AUTH_DENIED_RESULT;
563
+ }
564
+ if (decision === "unknown") {
565
+ startOriginModal(event); // background consent, deduped per origin
566
+ audit({ kind: "rpc", channel: "rpc:guarded", origin, tool: toolName, dangerous: danger, ok: false, error: "origin-pending", args: argsPreview(toolName, args) });
567
+ return AUTH_PENDING_RESULT;
568
+ }
569
+ // "allow" → fall through to the normal path
570
+ } else {
571
+ const originOk = await ensureOriginAuthorized(event);
572
+ if (!originOk) {
573
+ audit({ kind: "rpc", channel: "rpc:guarded", origin, tool: toolName, dangerous: danger, ok: false, error: "origin-unauthorized", args: argsPreview(toolName, args) });
574
+ throw new Error(`未授权站点访问桌面 RPC(rpc:guarded:域名未加入白名单)`);
575
+ }
576
+ }
577
+ if (danger) {
578
+ const ok = await ensureRpcGrant(event, toolName, args);
579
+ if (!ok) {
580
+ audit({ kind: "rpc", channel: "rpc:guarded", origin, tool: toolName, dangerous: true, ok: false, error: "grant-denied", args: argsPreview(toolName, args) });
581
+ throw new Error(`已拒绝敏感操作 ${toolName}(rpc:guarded:来源未获授权)`);
582
+ }
583
+ }
584
+ try {
585
+ // Throttled so a post-allow backlog drains a few at a time, not all at once.
586
+ const result = await guardedDispatchLimit(() => dispatchRpc(event, toolName, args));
587
+ audit({ kind: "rpc", channel: "rpc:guarded", origin, tool: toolName, dangerous: danger, ok: true, args: argsPreview(toolName, args) });
588
+ return result;
589
+ } catch (e) {
590
+ console.error("[IPC Bridge] guarded error:", toolName, e.message);
591
+ audit({ kind: "rpc", channel: "rpc:guarded", origin, tool: toolName, dangerous: danger, ok: false, error: e.message, args: argsPreview(toolName, args) });
491
592
  throw e;
492
593
  }
493
594
  });
494
- console.log("[IPC Bridge] All RPC tools available via ipcRenderer.invoke('rpc', toolName, args)");
595
+ console.log("[IPC Bridge] RPC tools available via ipcRenderer.invoke('rpc'|'rpc:guarded', toolName, args)");
495
596
 
496
597
  const workerClient = maybeCreateWorkerClient(authManager);
497
598
 
@@ -691,6 +792,11 @@ function startSidecarWatchdog({ intervalMs = 30_000 } = {}) {
691
792
 
692
793
  const tick = async () => {
693
794
  try {
795
+ // An update() in progress intentionally stops cicy-code (to swap the binary)
796
+ // and starts the new one itself — DON'T let the watchdog respawn the OLD
797
+ // binary into that gap (it would race the swap and the update would "finish"
798
+ // still on the old version). Pause until the update releases the flag.
799
+ if (cicyCodeSidecar.isUpdating && cicyCodeSidecar.isUpdating()) { consecutiveFailures = 0; return; }
694
800
  const ok = await cicyCodeSidecar.probeExisting();
695
801
  if (ok) { consecutiveFailures = 0; return; }
696
802
  consecutiveFailures++;
@@ -721,6 +827,9 @@ function startSidecarWatchdog({ intervalMs = 30_000 } = {}) {
721
827
  }
722
828
 
723
829
  electronApp.whenReady().then(async () => {
830
+ // Serve cicy://newtab (tab-browser start page) — must be after ready.
831
+ require("./tabbrowser/newtab-protocol").installHandler();
832
+
724
833
  // Re-init i18n now that app is ready — getLocale() returns reliable values
725
834
  // only after the ready event. The module-load init may have picked English
726
835
  // on platforms (e.g. Windows) where LANG env is unset.
@@ -746,15 +855,34 @@ electronApp.whenReady().then(async () => {
746
855
  .catch((e) => log.warn(`[Sidecar] cicy-code start failed: ${e.message}`));
747
856
  startSidecarWatchdog();
748
857
 
858
+ // Auto-register the local sidecar as 本地团队 once :8008 answers (主人:
859
+ // "本地团队没有占位" — a fresh install must show its local team without any
860
+ // manual step). addTeam upserts by host:port + auto-fills api_token from
861
+ // global.json, so re-runs are no-ops; addTeam itself then triggers the
862
+ // cloud team register + gateway-key injection when logged in. A fresh boot
863
+ // may npm-seed the runtime first, so probe for up to ~90s before giving up.
864
+ (async () => {
865
+ const sidecarPort = Number(process.env.CICY_CODE_PORT || 8008);
866
+ const lt = require("./backends/local-teams");
867
+ for (let i = 0; i < 30; i++) {
868
+ try {
869
+ if (await cicyCodeSidecar.probeExisting(sidecarPort)) {
870
+ const r = await lt.addTeam({ base_url: `http://127.0.0.1:${sidecarPort}`, name: "本地团队" });
871
+ if (r && r.ok) log.info(`[Sidecar] local team ${r.upserted ? "refreshed" : "registered"} (${r.id})`);
872
+ else log.warn(`[Sidecar] local team auto-register failed: ${r && r.error}`);
873
+ return;
874
+ }
875
+ } catch (e) { log.warn(`[Sidecar] local team auto-register error: ${e.message}`); }
876
+ await new Promise((res) => setTimeout(res, 3000));
877
+ }
878
+ log.warn(`[Sidecar] local team auto-register gave up — :${sidecarPort} never came up`);
879
+ })();
880
+
749
881
  // Backend launcher: app menu + IPC handlers. Menu adds a Backends top-level
750
882
  // entry; IPC powers the launcher window (src/backends/launcher.html).
751
883
  backendsIPC.register({ sidecarLogPath: path.join(os.homedir(), "logs", "cicy-code-sidecar.log") });
752
884
  require("./backends/sidecar-ipc").register({ sidecarLogPath: path.join(os.homedir(), "logs", "cicy-code-sidecar.log") });
753
885
 
754
- // window.cicy.artifact bridge — CDP/webContents control of the cicy-code
755
- // 产物 (artifact) <webview> guest. Injected renderer-side in window-utils.js.
756
- require("./backends/artifact-ipc").register();
757
-
758
886
  // Browser-login loopback listener. Renderer calls auth:login-start when
759
887
  // the user clicks Login; main opens a 127.0.0.1 server + the browser,
760
888
  // and broadcasts auth:complete back to the homepage window once the
@@ -799,13 +927,15 @@ electronApp.whenReady().then(async () => {
799
927
  // machine to the cloud (best-effort; safe to call repeatedly —
800
928
  // cloud upserts by (owner, deviceId)).
801
929
  try {
930
+ // Order matters: register the DEVICE FIRST, THEN the local team(s).
931
+ // POST /api/team/register 404s when the device isn't registered yet,
932
+ // so firing both concurrently (the old bug) let team-sync race ahead
933
+ // of device-register → 404 → gateway key never injected → apiKey 空.
934
+ // Chain them so syncAllLocalTeams only runs after the device exists.
802
935
  require("./cloud/cloud-client")
803
936
  .registerDevice()
804
- .catch((e) => log.warn(`[cloud] device register (on login) failed: ${e.message}`));
805
- // Fresh login also register this device's local team(s).
806
- require("./backends/local-teams")
807
- .syncAllLocalTeams()
808
- .catch((e) => log.warn(`[cloud] local-team sync (on login) failed: ${e.message}`));
937
+ .then(() => require("./backends/local-teams").syncAllLocalTeams())
938
+ .catch((e) => log.warn(`[cloud] device/team register (on login) failed: ${e.message}`));
809
939
  } catch (e) { log.warn(`[cloud] device register hook failed: ${e.message}`); }
810
940
  }
811
941
  const hw = require("./backends/homepage-window");
@@ -879,16 +1009,44 @@ electronApp.whenReady().then(async () => {
879
1009
  // Best-effort and fully non-blocking; a no-op when not logged in (the login
880
1010
  // onResult hook above covers the log-in-later case).
881
1011
  try {
882
- require("./cloud/cloud-client")
883
- .registerDevice()
884
- .catch((e) => log.warn(`[cloud] device register (on launch) failed: ${e.message}`));
885
- // Catch-up sync of this device's local team(s) → cloud, so a box whose 本地
886
- // team predates the cloud-client (e.g. a freshly-deployed Windows install)
887
- // still shows up under 本地 without needing a rename. Non-blocking.
888
- require("./backends/local-teams")
889
- .syncAllLocalTeams()
890
- .catch((e) => log.warn(`[cloud] startup local-team sync failed: ${e.message}`));
891
- } catch (e) { log.warn(`[cloud] device register launch hook failed: ${e.message}`); }
1012
+ // 1) Detect egress IP + IP region + system language and persist to
1013
+ // global.json (deviceInfo) — the single source the get_device_info RPC,
1014
+ // the chat-WS register, and the cloud report all read. The detection goes
1015
+ // DIRECT (no proxy), each request times out, and the whole thing is
1016
+ // fire-and-forget (never awaited does not block startup). Runs even when
1017
+ // not logged in so local config is always populated.
1018
+ // 2) THEN register the device with the cloud (no-op if not logged in) — it
1019
+ // reads the just-persisted ip/region/syslang.
1020
+ // 3) THEN sync local teams (device must register first or /api/team/register 404s).
1021
+ const cc = require("./cloud/cloud-client");
1022
+ let sysLang = "";
1023
+ try { sysLang = (electronApp.getLocale && electronApp.getLocale()) || ""; } catch (_) {}
1024
+ cc.detectAndPersistDeviceInfo({ systemLanguage: sysLang })
1025
+ .then(() => cc.registerDevice())
1026
+ .then(() => require("./backends/local-teams").syncAllLocalTeams())
1027
+ .catch((e) => log.warn(`[cloud] device-info/register (on launch) failed: ${e.message}`));
1028
+ } catch (e) { log.warn(`[cloud] device-info launch hook failed: ${e.message}`); }
1029
+
1030
+ // Cloud↔desktop title reconcile. The homepage drives the FAST cadence by window
1031
+ // visibility (聚焦 ~3s / 切回立即,见 App.jsx + localTeams:syncCloud IPC). This
1032
+ // 30s timer is just the SLOW fallback for when no homepage window is open / it's
1033
+ // hidden / logged-in-but-idle. Best-effort, no-op when logged out.
1034
+ if (!global.__cicyTitleSyncTimer) {
1035
+ global.__cicyTitleSyncTimer = setInterval(() => {
1036
+ try { require("./backends/local-teams").syncAllLocalTeams().catch(() => {}); } catch {}
1037
+ }, 30_000);
1038
+ if (global.__cicyTitleSyncTimer.unref) global.__cicyTitleSyncTimer.unref();
1039
+ }
1040
+
1041
+ // Periodic per-window thumbnails → ~/cicy-files/window-thumbs (chrome-style
1042
+ // small previews on disk; override dir via CICY_THUMB_DIR). Best-effort.
1043
+ if (!global.__cicyThumbStarted) {
1044
+ global.__cicyThumbStarted = true;
1045
+ try {
1046
+ const info = require("./utils/window-thumbnails").startWindowThumbnails();
1047
+ log.info(`[thumbs] window thumbnails → ${info.dir} (every ${info.intervalMs}ms, maxW ${info.maxWidth})`);
1048
+ } catch (e) { log.warn(`[thumbs] start failed: ${e.message}`); }
1049
+ }
892
1050
 
893
1051
  // Local-team discovery — reads ~/cicy-ai/global.json's cicyDesktopNodes
894
1052
  // and probes each via /api/health. Pure local, never talks to the cloud
@@ -903,6 +1061,9 @@ electronApp.whenReady().then(async () => {
903
1061
  __ipcLT.handle("localTeams:remove", (_e, id) => lt.removeTeam(id));
904
1062
  __ipcLT.handle("localTeams:update", (_e, payload) => lt.updateTeam(payload?.id, payload?.patch || {}));
905
1063
  __ipcLT.handle("localTeams:upgrade", (_e, id) => lt.upgradeTeam(id));
1064
+ // Pull cloud title NOW (homepage calls this on window focus so a dash rename
1065
+ // reflects immediately instead of waiting for the 15s background tick).
1066
+ __ipcLT.handle("localTeams:syncCloud", async () => { try { await lt.syncAllLocalTeams(); return { ok: true }; } catch (e) { return { ok: false, error: e.message }; } });
906
1067
 
907
1068
  // Webview → host-renderer relay. The Team Helper <webview> can't
908
1069
  // directly mutate localTeams: instead its preload (webview-preload.js)
@@ -1074,9 +1235,8 @@ electronApp.whenReady().then(async () => {
1074
1235
  // are required for the homepage UI itself, which talks to the main
1075
1236
  // process via Electron IPC. So skip the listen by default; opt in with
1076
1237
  // CICY_DESKTOP_HTTP=1 (or CICY_DESKTOP_HTTP_PORT set explicitly).
1077
- const httpEnabled = process.env.CICY_DESKTOP_HTTP === "1"
1078
- || enableMcp
1079
- || !!process.env.CICY_MASTER_URL;
1238
+ // Same opt-in as the CDP debug port above (see `automationEnabled`).
1239
+ const httpEnabled = automationEnabled;
1080
1240
 
1081
1241
  // Code that used to live inside server.listen(...) — startup work that
1082
1242
  // needs to happen after whenReady. Pulled out so we can run it whether
@@ -1085,6 +1245,40 @@ electronApp.whenReady().then(async () => {
1085
1245
  if (START_URL) {
1086
1246
  createWindow({ url: START_URL }, ACCOUNT);
1087
1247
  }
1248
+
1249
+ // Persistent window registry: re-open windows that were still open when the
1250
+ // app last quit. Windows the user/agent closed stay "closed" and are not
1251
+ // reopened. Skip any url already live this session (homepage / START_URL).
1252
+ try {
1253
+ const { BrowserWindow } = require("electron");
1254
+ const registry = require("./utils/window-registry");
1255
+ const liveSet = new Set(
1256
+ BrowserWindow.getAllWindows()
1257
+ .map((w) => {
1258
+ try {
1259
+ const part = w.webContents.session.partition || "";
1260
+ const acc = part.startsWith("persist:sandbox-")
1261
+ ? parseInt(part.replace("persist:sandbox-", ""), 10)
1262
+ : 0;
1263
+ return `${acc}::${registry.normalizeUrl(w.webContents.getURL())}`;
1264
+ } catch {
1265
+ return null;
1266
+ }
1267
+ })
1268
+ .filter(Boolean)
1269
+ );
1270
+ for (const e of registry.staleOpenEntries()) {
1271
+ if (!e.url) continue;
1272
+ if (liveSet.has(`${e.accountIdx || 0}::${registry.normalizeUrl(e.url)}`)) continue;
1273
+ log.info(`[WindowRegistry] Reopening ${e.url} (account ${e.accountIdx || 0})`);
1274
+ const opts = { url: e.url };
1275
+ if (e.bounds && typeof e.bounds === "object") Object.assign(opts, e.bounds);
1276
+ createWindow(opts, e.accountIdx || 0, true);
1277
+ }
1278
+ } catch (err) {
1279
+ log.error(`[WindowRegistry] reopen failed: ${err.message}`);
1280
+ }
1281
+
1088
1282
  if (workerClient) {
1089
1283
  try {
1090
1284
  await workerClient.start();
@@ -1097,7 +1291,7 @@ electronApp.whenReady().then(async () => {
1097
1291
 
1098
1292
  if (!httpEnabled) {
1099
1293
  log.info(`[MCP] HTTP server skipped (set CICY_DESKTOP_HTTP=1 or pass --mcp to enable)`);
1100
- log.info(`[MCP] Remote debugger: http://localhost:9221`);
1294
+ log.info(`[MCP] Remote debugger NOT running (automation disabled)`);
1101
1295
  onAppStarted();
1102
1296
  } else {
1103
1297
  server.listen(PORT, async () => {
@@ -48,7 +48,7 @@ function getMasterChromeAccountEntry(accountIdx) {
48
48
  }
49
49
 
50
50
  const data = readMasterChromeConfig();
51
- const key = `account_${accountIdx}`;
51
+ const key = `profile_${accountIdx}`;
52
52
  const entry = data?.[key] || null;
53
53
  if (!entry) {
54
54
  throw new ChromeProfileResolutionError(`Missing chrome.json entry on master: ${key}`);
@@ -66,7 +66,7 @@ function normalizeEffectiveChromeProfile({ accountIdx, entry }) {
66
66
  const rpaDirRaw =
67
67
  typeof safeEntry.rpaDir === "string" && safeEntry.rpaDir.length
68
68
  ? safeEntry.rpaDir
69
- : `~/chrome/account_${accountIdx}`;
69
+ : `~/chrome/profile_${accountIdx}`;
70
70
 
71
71
  const orgPathRaw = typeof safeEntry.orgPath === "string" && safeEntry.orgPath.length ? safeEntry.orgPath : null;
72
72
 
@@ -1,4 +1,4 @@
1
1
  const { contextBridge, ipcRenderer } = require("electron");
2
2
  contextBridge.exposeInMainWorld("electronRPC", {
3
- invoke: (toolName, args) => ipcRenderer.invoke("rpc", toolName, args),
3
+ invoke: (toolName, args) => ipcRenderer.invoke("rpc:guarded", toolName, args),
4
4
  });