cicy-desktop 2.1.41 → 2.1.43

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.
package/CLAUDE.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
4
 
5
- `cicy-desktop` is an Electron app that exposes ~50 system tools (Chrome control, clipboard, screenshot, shell exec, system info, ...) over MCP and REST/RPC. The app runs in two roles — **worker** (the Electron process exposing tools) and **master** (a thin control plane that routes tool calls across workers) and ships a bundled `cicy-code` sidecar daemon so the desktop is a fully offline-capable backend.
5
+ `cicy-desktop` is an Electron app that exposes ~100+ system tools (Chrome control, CDP, clipboard, screenshot, shell/node/python exec, system info, file ops, ...) over MCP and REST/RPC. The app runs in two roles — **worker** (the Electron process exposing tools) and **master** (a thin control plane that routes tool calls across workers). It does **not** bundle a `cicy-code` binary — the sidecar daemon is acquired at runtime (`npx cicy-code` on mac/linux, Docker on Windows; see [Sidecar](#sidecar-cicy-code-daemon)).
6
+
7
+ **Distribution (2026-06):** end users run via npm, not a packaged installer — `npx cicy-desktop` (mac/linux) / `npm i -g cicy-desktop && cicy-desktop` (Windows). First launch drops a desktop shortcut and auto-acquires the sidecar. CN needs `ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/` so electron's binary postinstall doesn't hit GitHub. The electron-builder packaged build (dmg/NSIS) still exists but is secondary. To discover tools at runtime, call the `list_tools` meta-tool (`electronRPC("list_tools")` / `agent-desktop rpc list_tools`).
6
8
 
7
9
  ## Development workflow rules (read first)
8
10
 
@@ -208,21 +210,17 @@ Supporting modules:
208
210
  - `src/cluster/worker-client.js` — worker registration + heartbeat to the master
209
211
  - `src/cluster/worker-identity.js` — identity payload advertised to the master
210
212
 
211
- Worker RPC surface:
213
+ Worker tool surface (how the ~100 tools are reached):
212
214
 
213
- - `GET /rpc/tools` list registered tools
214
- - `POST /rpc/tools/call` `{ name, arguments }` style invocation
215
- - `POST /rpc/:toolName`direct REST entrypoint, what `cicy-rpc` uses after resolving the node
215
+ - **IPC** — `ipcMain.handle("rpc", (e, toolName, args) => executeTool(...))` (`src/main.js`). This is what `window.electronRPC(tool, args)` and the cicy-code `desktop_event` bridge ride. No HTTP, no port.
216
+ - **MCP**every registered tool is exposed via the MCP server (`src/server/mcp-server.js`, `/mcp` + `/messages`).
217
+ - **HTTP index**`GET /openapi.json` (dynamic, every tool + inputSchema) and the Swagger UI at `/docs`.
218
+ - **`list_tools`** — a meta-tool (`src/tools/list-tools.js`) that returns the live catalog over the IPC/RPC side, so callers that can't reach `/openapi.json` (renderer, `<webview>`, `agent-desktop rpc list_tools`) can still enumerate.
219
+ - **REST `POST /rpc/:toolName`** — served by the **master** (`src/master/master-routes.js`), which forwards to a worker. The worker process itself does not mount `/rpc/*` execution routes.
216
220
 
217
221
  ### Tool system
218
222
 
219
- Tool implementations live in `src/tools/*.js` and are loaded via `require("../tools")` from `src/server/tool-catalog.js`. Each module exports a function receiving `registerTool(name, description, schema, handler, options)`. The catalog is grouped by `tag` and reused for:
220
-
221
- - MCP tool registration
222
- - `GET /rpc/tools`
223
- - OpenAPI generation in `/openapi.json`
224
-
225
- A tool definition change affects all three surfaces at once.
223
+ Tool implementations live in `src/tools/*.js`, listed in `src/tools/index.js`, loaded via `require("../tools")` from `src/server/tool-catalog.js`. Each module exports a function receiving `registerTool(name, description, schema, handler, options)`. The catalog (grouped by `tag`) is reused for MCP registration, `list_tools`, and OpenAPI generation in `/openapi.json` — **a tool definition change affects all of them at once.**
226
224
 
227
225
  Tools use **CommonJS** and **Zod schemas**.
228
226
 
@@ -386,6 +384,18 @@ Only the **token** HMRs cleanly. If you also rebuilt the helper image with new A
386
384
 
387
385
  `HELPER_URL_BASE` (App.jsx) is the **other** half of the pairing. It currently points at `http://43.99.56.150:8011` (a long-running shared helper). If you want to swap to a locally rebuilt container, change it to `http://localhost:8011` and `ssh -fNR 8011:127.0.0.1:8011 mac` so the Mac can reach your dev box's helper. Same token-grab step still applies.
388
386
 
387
+ ### Deep links (`cicy://`) and local-team add/rename
388
+
389
+ Protocol `cicy://` is registered (`package.json` build `protocols` + `setAsDefaultProtocolClient("cicy")` in `src/main.js`). The handler is `src/main.js::handleDeepLink(url)`, fed by `app.on("open-url")` (mac), `second-instance`, and cold-start `argv` (win/linux).
390
+
391
+ `cicy://addTeam?title=<t>&url=<base_url>&token=<api_token>`:
392
+
393
+ - **handleDeepLink adds the team directly in the main process** via `local-teams.addTeam({base_url, api_token, name})` — it does NOT rely on the renderer. (An earlier version only broadcast `deeplink:addTeam` to the renderer, but App.jsx never wired `window.cicy.deeplink.onAddTeam`, so the team was silently dropped.) The homepage polls `localTeams:list` every few seconds, so the new team shows up on its own.
394
+ - **Upsert is keyed by `base_url`** (`local-teams.js::addTeam`): re-adding a known URL refreshes `api_token` + install meta but **keeps the existing (possibly user-renamed) name** — only a brand-new team takes the provided title. No-name → i18n default `localTeams.unnamed` (`Unnamed`/`未命名`/…).
395
+ - The URL value must be **single** percent-encoded; raw CJK in `title` makes macOS `open` re-encode the whole thing (double-encoding → `bad base_url`).
396
+
397
+ **Rename**: every local team is renamable. `LocalTeamCard` (App.jsx) has inline edit (double-click name / ✎) → `window.cicy.localTeams.update(id, {name})` → `local-teams.js::updateTeam` (whitelists `name`). Labels go through `window.cicyI18n.t` (preload-exposed i18n; keys in `src/i18n/locales/*.json` under `localTeams`).
398
+
389
399
  ### Backends launcher (legacy `src/backends/`)
390
400
 
391
401
  `src/backends/homepage.html` + `cicy.backends.{list, add, remove, …}` is the **pre-Vite** launcher. It still ships and still works (the bundled sidecar and Add-by-URL flow live here), but it is **not** what new users see — `pickHomepageURL` prefers the Vite/React entry. Touch this only when you're working on the old launcher path. Registry file: `<userData>/backends.json` (`src/backends/registry.js`). IPC handlers: `src/backends/ipc.js`.
@@ -424,7 +434,7 @@ Implication: anything that calls `window.electronRPC` from outside the cicy-code
424
434
 
425
435
  ### On-disk layout — `~/.local/bin/cicy-code` symlink → versioned binary
426
436
 
427
- Both the in-app installer (`src/sidecar/installer.js`) and the cloud Team Helper agent write the daemon into the user's `~/.local/bin/` with this shape:
437
+ The cloud Team Helper agent writes the daemon into the user's `~/.local/bin/` with this shape (the npx sidecar path uses the npm cache instead, but a Helper-installed `~/.local/bin/cicy-code` on `:8008` is still reused first via `probeExisting`, and `upgradeNative` manages it):
428
438
 
429
439
  ```
430
440
  ~/.local/bin/cicy-code-2.1.8 (actual binary, +x)
@@ -451,23 +461,16 @@ Upgrade flow inside `src/backends/local-teams.js::upgradeNative`:
451
461
 
452
462
  The cloud helper agent uses the **same layout** when it does the initial install (`AGENTS.md` step 1A.2/1A.3 in `cicy-cloud/workers/helper/`). It writes `cicy-code-<ver>` + `ln -sfn` + spawns via the symlink, then registers the team with `install_path=~/.local/bin/cicy-code`. This way the agent's install path and the desktop's upgrade path are identical — no special-case wiring needed.
453
463
 
454
- ### Sidecar cicy-code daemon — **NOT bundled** (2026-05-29 principle)
455
-
456
- `cicy-desktop` no longer ships a `cicy-code` binary in the `.app`. The daemon is acquired one of three ways:
457
-
458
- 1. **Already running on `:8008`** — left over from a previous session, started by the user, or installed by the cloud Team Helper. `src/sidecar/cicy-code.js::probeExisting` detects it and `start()` reuses without re-spawning.
459
- 2. **In-app installer** — `src/sidecar/installer.js` downloads the platform-matching binary from `cicy-ai/cicy-code` GitHub releases into `<userData>/cicy-code/<platform>-<arch>/cicy-code`. Triggered from the homepage when no daemon is running. `userBinary()` returns this path; `bundledBinaryPath()` only checks this single source — there is intentionally no `<App>/Contents/Resources/cicy-code` fallback.
460
- 3. **Cloud Team Helper** — the trial helper container walks the user through installing cicy-code on their own machine, then registers it via `window.cicy.localTeams.add({...})`. Today the helper writes to `~/Downloads/cicy-code`; the in-app installer location is preferred (`<userData>/cicy-code/...`) so `userBinary()` discovers it on the next desktop launch with no extra wiring.
464
+ ### Sidecar cicy-code daemon
461
465
 
462
- Removed in this principle change:
463
- - `package.json` no longer has `extraResources` entries for `vendor/cicy-code/*/cicy-code`
464
- - `package.json` no longer runs `prepare:sidecar` in `prebuild*`
465
- - `scripts/prepare-cicy-code-sidecar.js` is dormant (kept for reference; can be deleted)
466
- - `vendor/cicy-code/` directory is no longer touched by the build
466
+ `cicy-desktop` never bundles a `cicy-code` binary. `src/sidecar/cicy-code.js::start()` acquires the daemon at runtime:
467
467
 
468
- `sidecar/cicy-code.js::start()` therefore returns `null` whenever no `userBinary()` is present AND no daemon is on `:8008`. The homepage's Team Helper card is the surface that gets the user from "no daemon" to "daemon running" either by triggering the in-app installer (for the legacy single-click path) or by walking the cloud-helper onboarding flow.
468
+ 1. **Already running on `:8008`** left over from a previous session, started by the user, or installed by the cloud Team Helper. `probeExisting()` detects it and `start()` reuses without re-spawning. This wins on every platform.
469
+ 2. **mac / linux → `npx cicy-code`** — `start()` spawns `npx -y cicy-code` (default registry npmmirror for CN; override with `CICY_NPM_REGISTRY`, pin with `CICY_CODE_VERSION`). The launcher fetches the per-version binary from npm and does its own `:8008` port hygiene. No download/installer code in cicy-desktop.
470
+ 3. **Windows → Docker** — `start()` delegates to `src/sidecar/docker.js` (`docker run` of the cicy-code image whose entrypoint `npx`-installs cicy-code). The image is loaded from R2 when absent.
471
+ 4. **Cloud Team Helper** — the trial helper container walks the user through installing cicy-code on their machine, then registers the team via `window.cicy.localTeams.add({...})`.
469
472
 
470
- Windows path is unchanged `src/sidecar/wsl.js` runs the daemon inside WSL2 because cicy-code is POSIX-only.
473
+ **Retired (do not look for these):** the old in-app downloader `src/sidecar/installer.js` and the WSL path `src/sidecar/wsl.js` are **deleted**, along with their IPC handlers and the in-app install UI. There is no `bundledBinaryPath()`/`userBinary()`/`extraResources`/`vendor/cicy-code/` anymore. If no daemon is on `:8008` and the npx/Docker spawn can't run, `start()` returns `null` and the homepage's Team Helper card is the path from "no daemon" to "daemon running".
471
474
 
472
475
  #### What broke that motivated the principle (2026-05-29)
473
476
 
@@ -536,7 +539,10 @@ The master uses `CICY_MASTER_TOKEN` directly or falls back to `MasterTokenManage
536
539
  | backends launcher UI | `src/backends/homepage.html` |
537
540
  | backends preload bridge | `src/backends/homepage-preload.js` |
538
541
  | BrowserWindow trust + auto-inject | `src/utils/window-utils.js` |
539
- | sidecar packaging | `scripts/prepare-cicy-code-sidecar.js` |
542
+ | sidecar acquisition (npx / docker) | `src/sidecar/cicy-code.js`, `src/sidecar/docker.js` |
543
+ | desktop shortcut + deep links (`cicy://`) | `bin/cicy-desktop` (shortcut gen), `src/main.js` (handleDeepLink) |
544
+ | local teams (add/upsert/rename) + i18n | `src/backends/local-teams.js`, `src/i18n/locales/*.json` |
545
+ | tool list module | `src/tools/index.js`, `src/tools/list-tools.js` |
540
546
  | RPC test files | `tests/rpc/master-routes.test.js`, `tests/rpc/cicy-rpc.test.js` |
541
547
 
542
548
  ## Debugging via remote-debugging-port 9221
@@ -601,7 +607,7 @@ When touching any of these, expect ripple effects:
601
607
  - adding a tool → touches MCP server, REST/RPC, OpenAPI all at once via the catalog
602
608
  - changing trust criteria → changes `nodeIntegration` for whole classes of windows; verify with the cicy-code bridge still works
603
609
  - changing the homepage entry flow → check that cold-launch and "back to launcher" paths both behave (history.length / sessionStorage gates)
604
- - changing the sidecar packaging → verify `dist/mac/CiCy Desktop.app/Contents/Resources/cicy-code/cicy-code` is present and executable after build
610
+ - changing the sidecar acquisitionthere is no bundled binary; verify `npx cicy-code` (mac/linux) / Docker (win) actually spawns and binds `:8008`, and that `probeExisting` reuse still works
605
611
  - changing `chrome_*` tools → both master injection (`effectiveChromeProfile`) and worker fallback (`~/cicy-ai/db/chrome.json`) paths still need to work
606
612
  - changing **any preload file** → `⌘+Q` + reopen Electron is mandatory; HMR / `⌘+R` won't reload it. Confirm via CDP `Runtime.evaluate` on the target window
607
613
  - changing **`src/backends/local-teams.js`** → it's `require`d by main; full Electron restart needed. Don't forget to also expose any new methods through both `homepage-preload.js` (full surface) and `webview-preload.js` (relay)
package/README.md CHANGED
@@ -295,9 +295,8 @@ Current source of truth in code:
295
295
  - tool implementations: `src/tools/*`
296
296
 
297
297
  At a high level:
298
- - `src/main.js:242` exposes `POST /rpc/tools/call`
299
- - `src/main.js:267` exposes `GET /rpc/tools`
300
- - `src/main.js:294` exposes `POST /rpc/:toolName`
298
+ - the **worker** dispatches tools two ways: in-process via `ipcMain.handle("rpc", …)` (`src/main.js`, what `window.electronRPC(tool, args)` rides) and over HTTP. The full, live tool index is `GET /openapi.json` (browse at `/docs`); call `list_tools` to enumerate from the RPC/IPC side.
299
+ - REST tool invocation (`POST /rpc/:toolName`, used by `cicy-rpc`) is served by the **master** (`src/master/master-routes.js`), which forwards to the selected worker.
301
300
  - RPC routes are protected by auth and return `401 Unauthorized` when the token is wrong or missing
302
301
  - the desktop CLI starts a local master + worker cluster and provides status/log management
303
302
 
package/bin/cicy-desktop CHANGED
@@ -20,15 +20,35 @@ const ELECTRON_MIRROR = process.env.ELECTRON_MIRROR || "https://npmmirror.com/mi
20
20
  const NPM_REGISTRY = process.env.CICY_NPM_REGISTRY || process.env.npm_config_registry || "https://registry.npmmirror.com";
21
21
  const MASTER_ENTRY = path.join(PACKAGE_ROOT, "src", "master", "master-main.js");
22
22
 
23
- // Resolve the electron binary to spawn. Prefers the project-local copy
24
- // (node_modules/.bin/electron) when present; falls back to the global
25
- // `electron` on PATH (npm i -g electron), and finally to `npx electron`.
23
+ // Resolve a globally-installed electron (`npm i -g electron`), so the ~200MB
24
+ // binary is downloaded once and SHARED across cicy tools / desktop versions
25
+ // instead of one copy per package install. Returns null if not present.
26
+ let _globalElectronBin; // memoized (avoid re-spawning `npm prefix -g`)
27
+ function globalElectronBin() {
28
+ if (_globalElectronBin !== undefined) return _globalElectronBin;
29
+ _globalElectronBin = null;
30
+ try {
31
+ const prefix = execSync("npm prefix -g", { stdio: ["ignore", "pipe", "ignore"] })
32
+ .toString().trim();
33
+ if (prefix) {
34
+ const p = isWindows
35
+ ? path.join(prefix, "electron.cmd")
36
+ : path.join(prefix, "bin", "electron");
37
+ if (fs.existsSync(p)) _globalElectronBin = p;
38
+ }
39
+ } catch { /* keep null */ }
40
+ return _globalElectronBin;
41
+ }
42
+
43
+ // Resolve the electron binary to spawn. Order: (C) a shared GLOBAL electron,
44
+ // then the project-local copy, then `npx electron` as a last resort.
26
45
  // Windows uses `npx.cmd electron` to handle the shim correctly.
27
46
  function resolveElectronSpawn() {
47
+ const g = globalElectronBin();
48
+ if (g) return { cmd: g, prefixArgs: [] };
28
49
  if (isWindows) return { cmd: "npx.cmd", prefixArgs: ["electron"] };
29
50
  const local = path.join(PACKAGE_ROOT, "node_modules", ".bin", "electron");
30
51
  if (fs.existsSync(local)) return { cmd: local, prefixArgs: [] };
31
- // Fall back to a globally-installed electron resolved via PATH.
32
52
  return { cmd: "npx", prefixArgs: ["electron"] };
33
53
  }
34
54
  const PROJECT_COMMAND_FILE = path.join(PACKAGE_ROOT, "cicy-dektop.command");
@@ -358,6 +378,17 @@ function nodeBinDir() {
358
378
  try { return path.dirname(process.execPath); } catch { return ""; }
359
379
  }
360
380
 
381
+ // npm's global bin dir (<npm prefix -g>[/bin]), where `npm i -g cicy-desktop`
382
+ // installs the launcher. The GUI shortcut runs that global bin directly so
383
+ // startup doesn't pay an npx registry round-trip on every launch.
384
+ function npmGlobalBinDir() {
385
+ try {
386
+ const prefix = execSync("npm prefix -g", { stdio: ["ignore", "pipe", "ignore"] })
387
+ .toString().trim();
388
+ return prefix ? (isWindows ? prefix : path.join(prefix, "bin")) : "";
389
+ } catch { return ""; }
390
+ }
391
+
361
392
  function ensureDesktopCommandFile() {
362
393
  try {
363
394
  if (process.platform === "darwin") return ensureMacDesktopApp();
@@ -373,8 +404,8 @@ function ensureMacDesktopApp() {
373
404
  // executable is a shell script triggers "CiCy Desktop is not responding"
374
405
  // (LaunchServices waits for the script to register as a Cocoa app and times
375
406
  // out). An osacompile applet's executable IS the AppleScript runtime — a
376
- // proper Cocoa app — so it launches cleanly, backgrounds `npx cicy-desktop`
377
- // via `do shell script`, then quits. Icon = the applet's applet.icns.
407
+ // proper Cocoa app — so it launches cleanly, backgrounds the global
408
+ // `cicy-desktop` bin via `do shell script`, then quits. Icon = applet.icns.
378
409
  const { execFileSync } = require("child_process");
379
410
  const appDir = path.join(DESKTOP_DIR, "CiCy Desktop.app");
380
411
  // Idempotent — create only when missing. When the app is launched FROM this
@@ -384,16 +415,21 @@ function ensureMacDesktopApp() {
384
415
  // ever appears ("打不开"). Skip if it already exists.
385
416
  if (fs.existsSync(appDir)) return;
386
417
  const nodeDir = nodeBinDir();
387
- // do shell script needs node on PATH (GUI launch has a minimal PATH); the
388
- // trailing & + nohup + </dev/null lets the shell return so the applet quits.
389
- // ELECTRON_MIRROR + npmmirror: a fresh CN machine has no cached electron
390
- // binary, so npx's electron postinstall would try GitHub releases and fail
391
- // (ECOMPROMISED / lock-compromised). Point it at npmmirror's electron mirror.
392
- // Harmless elsewhere — only consulted when electron actually (re)downloads.
418
+ const gbin = npmGlobalBinDir();
419
+ // Launch the globally-installed `cicy-desktop` bin DIRECTLY (npm i -g) no
420
+ // per-launch `npx` registry round-trip. Use the absolute path when resolvable
421
+ // (GUI launch has a minimal PATH); fall back to the bare name otherwise.
422
+ const cicyBin = gbin && fs.existsSync(path.join(gbin, "cicy-desktop"))
423
+ ? path.join(gbin, "cicy-desktop")
424
+ : "cicy-desktop";
425
+ // do shell script needs node + the global bin on PATH (GUI launch has a
426
+ // minimal PATH); trailing & + nohup + </dev/null lets the shell return so the
427
+ // applet quits. ELECTRON_MIRROR + npmmirror only matter if electron ever
428
+ // (re)downloads — harmless otherwise.
393
429
  const ascript =
394
- `do shell script "export PATH=\\"${nodeDir}:/usr/local/bin:/opt/homebrew/bin:$PATH\\" ; ` +
430
+ `do shell script "export PATH=\\"${nodeDir}:${gbin}:/usr/local/bin:/opt/homebrew/bin:$PATH\\" ; ` +
395
431
  `export ELECTRON_MIRROR=\\"${ELECTRON_MIRROR}\\" ; export npm_config_registry=\\"${NPM_REGISTRY}\\" ; ` +
396
- `nohup npx -y cicy-desktop > /tmp/cicy-desktop.log 2>&1 < /dev/null &"`;
432
+ `nohup '${cicyBin}' > /tmp/cicy-desktop.log 2>&1 < /dev/null &"`;
397
433
  const tmp = path.join(os.tmpdir(), `cicy-launch-${process.pid}.applescript`);
398
434
  fs.writeFileSync(tmp, ascript);
399
435
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cicy-desktop",
3
- "version": "2.1.41",
3
+ "version": "2.1.43",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -0,0 +1,142 @@
1
+ // artifact-ipc.js — main-process side of the window.cicy.artifact bridge.
2
+ //
3
+ // The cicy-code app (loaded in a trusted team BrowserWindow) hosts a
4
+ // <webview id="cicy-artifact-webview"> for the 产物 (artifact) tab. Its
5
+ // renderer bridge (cicy-code app/src/lib/artifactBridge.ts) drives that
6
+ // webview's *guest* webContents through `window.cicy.artifact.{invoke,cdp}` —
7
+ // injected in window-utils.js — which round-trips to the handlers here.
8
+ //
9
+ // We resolve the guest via webContents.fromId(guestId) (the renderer passes the
10
+ // element's getWebContentsId()), call webContents methods / drive its debugger,
11
+ // and forward the guest's console / navigation / CDP-message events back to the
12
+ // host renderer as 'artifact:event' (re-dispatched there as the
13
+ // CustomEvent('cicy-artifact-event') the bridge buffers).
14
+
15
+ const { ipcMain, webContents } = require("electron");
16
+ const log = require("electron-log");
17
+
18
+ // guest webContents.id → host renderer webContents that should receive events.
19
+ // Updated on every invoke/attach so events follow host reloads.
20
+ const guestHost = new Map();
21
+ // guest webContents.id we've already wired passive (console/nav) listeners on.
22
+ const wiredPassive = new Set();
23
+ // guest webContents.id we've already wired the debugger 'message'/'detach' on.
24
+ const wiredDebugger = new Set();
25
+
26
+ function getGuest(guestId) {
27
+ const wc = typeof guestId === "number" ? webContents.fromId(guestId) : null;
28
+ if (!wc || wc.isDestroyed()) {
29
+ throw new Error(`artifact guest webContents ${guestId} not found (open the 产物 tab once)`);
30
+ }
31
+ return wc;
32
+ }
33
+
34
+ function emit(wc, detail) {
35
+ try {
36
+ const host = guestHost.get(wc.id);
37
+ if (host && !host.isDestroyed()) host.send("artifact:event", detail);
38
+ } catch {}
39
+ }
40
+
41
+ function rememberHost(wc, hostSender) {
42
+ guestHost.set(wc.id, hostSender);
43
+ if (!wc.__artifactCleanup) {
44
+ wc.__artifactCleanup = true;
45
+ wc.once("destroyed", () => {
46
+ guestHost.delete(wc.id);
47
+ wiredPassive.delete(wc.id);
48
+ wiredDebugger.delete(wc.id);
49
+ });
50
+ }
51
+ }
52
+
53
+ // console-message + navigation → host renderer. Wired once per guest.
54
+ function wirePassive(wc) {
55
+ if (wiredPassive.has(wc.id)) return;
56
+ wiredPassive.add(wc.id);
57
+ wc.on("console-message", (_e, level, message, line, sourceId) => {
58
+ emit(wc, { source: "console", level, message, line, sourceId });
59
+ });
60
+ wc.on("did-navigate", (_e, url, httpResponseCode) => {
61
+ emit(wc, { source: "navigation", kind: "did-navigate", url, httpResponseCode });
62
+ });
63
+ wc.on("did-navigate-in-page", (_e, url, isMainFrame) => {
64
+ emit(wc, { source: "navigation", kind: "did-navigate-in-page", url, isMainFrame });
65
+ });
66
+ }
67
+
68
+ // debugger 'message' (the CDP event stream) + 'detach' → host renderer. Wired
69
+ // once per guest; survives attach/detach cycles (only fires while attached).
70
+ function wireDebugger(wc) {
71
+ if (wiredDebugger.has(wc.id)) return;
72
+ wiredDebugger.add(wc.id);
73
+ wc.debugger.on("message", (_e, method, params) => {
74
+ emit(wc, { source: "cdp", method, params });
75
+ });
76
+ wc.debugger.on("detach", (_e, reason) => {
77
+ emit(wc, { source: "cdp", method: "__detached", params: { reason } });
78
+ });
79
+ }
80
+
81
+ function register() {
82
+ // Call any webContents method on the artifact guest. capturePage/printToPDF
83
+ // are normalized to dataURL / base64 so the result survives JSON/WS.
84
+ ipcMain.handle("artifact:invoke", async (e, payload) => {
85
+ const { guestId, method, args = [] } = payload || {};
86
+ const wc = getGuest(guestId);
87
+ rememberHost(wc, e.sender);
88
+ wirePassive(wc);
89
+ if (typeof wc[method] !== "function") {
90
+ throw new Error(`artifact.invoke: webContents has no method '${method}'`);
91
+ }
92
+ if (method === "capturePage") {
93
+ const img = await wc.capturePage(...(args || []));
94
+ return img && typeof img.toDataURL === "function" ? img.toDataURL() : null;
95
+ }
96
+ if (method === "printToPDF") {
97
+ const buf = await wc.printToPDF((args && args[0]) || {});
98
+ return Buffer.from(buf).toString("base64");
99
+ }
100
+ return await wc[method](...(args || []));
101
+ });
102
+
103
+ ipcMain.handle("artifact:cdp-attach", async (e, payload) => {
104
+ const { guestId, protocolVersion } = payload || {};
105
+ const wc = getGuest(guestId);
106
+ rememberHost(wc, e.sender);
107
+ // DevTools and the debugger are mutually exclusive on one webContents —
108
+ // close a manually-opened inspector first, else attach() throws.
109
+ try { if (wc.isDevToolsOpened()) wc.closeDevTools(); } catch {}
110
+ if (!wc.debugger.isAttached()) {
111
+ wc.debugger.attach(protocolVersion || "1.3");
112
+ }
113
+ wireDebugger(wc);
114
+ wirePassive(wc);
115
+ return { ok: true, attached: true };
116
+ });
117
+
118
+ ipcMain.handle("artifact:cdp-detach", async (_e, payload) => {
119
+ const { guestId } = payload || {};
120
+ const wc = getGuest(guestId);
121
+ if (wc.debugger.isAttached()) wc.debugger.detach();
122
+ return { ok: true, attached: false };
123
+ });
124
+
125
+ ipcMain.handle("artifact:cdp-is-attached", async (_e, payload) => {
126
+ const { guestId } = payload || {};
127
+ try { return getGuest(guestId).debugger.isAttached(); } catch { return false; }
128
+ });
129
+
130
+ ipcMain.handle("artifact:cdp-send", async (_e, payload) => {
131
+ const { guestId, method, params } = payload || {};
132
+ const wc = getGuest(guestId);
133
+ if (!wc.debugger.isAttached()) {
134
+ throw new Error("artifact.cdp: debugger not attached (call cdp.attach first)");
135
+ }
136
+ return await wc.debugger.sendCommand(method, params || {});
137
+ });
138
+
139
+ log.info("[artifact] window.cicy.artifact IPC handlers registered");
140
+ }
141
+
142
+ module.exports = { register };
@@ -150,6 +150,11 @@ contextBridge.exposeInMainWorld("cicy", {
150
150
  auth: {
151
151
  loginStart: () => logInvoke("auth:login-start"),
152
152
  loginCancel: () => logInvoke("auth:login-cancel"),
153
+ // Durable, origin-independent login persisted in main (global.json).
154
+ // getSaved() restores it when this origin's localStorage is empty;
155
+ // logout() clears it (the only thing that should).
156
+ getSaved: () => logInvoke("auth:get-saved"),
157
+ logout: () => logInvoke("auth:logout"),
153
158
  onComplete: (cb) => {
154
159
  const handler = (_e, payload) => { try { cb(payload); } catch {} };
155
160
  ipcRenderer.on("auth:complete", handler);