Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 47 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ repo works.
| ---------------- | ------------------------------------------------------------------------ |
| `core/` | Rust control plane. JSON-RPC over HTTP + SSE. Owns projects, sessions, agent loop, assets, MCP, skills. |
| `client/` | Vite + React + TypeScript editor. Three.js viewport, agent panel, workspace tabs. |
| `client/src-tauri/` | macOS desktop shell. Bundles the core release binary as a sidecar. |
| `client/electron/` | Desktop shell (Electron). Spawns core, hosts the browser panel as a real view. |
| `shared/schemas/` | `project.schema.json`, `cali-asset.schema.json` — the contracts both sides honour. |
| `scripts/` | `dev.sh` (run both halves), `desktop.sh` (package the app), live agent clients. |
| `docs/` | `runbook.md` (operations), `verification.md` (what proves each feature works), plans, templates. |
Expand All @@ -36,6 +36,22 @@ pnpm desktop:build # from client/ — packages CaliCode.app (+ .dmg)
pnpm desktop:dev # native shell against a live core
```

**One shell, and it is Electron.** A Tauri shell shipped first and was removed
(`docs/plans/electron-shell.md`): its webview is a different engine per platform
— WKWebView, WebView2, WebKitGTK — and none of them can host a second browser, so
the BROWSER tab could only ever be a video stream of a Chrome running elsewhere.
Electron bundles Chromium, so the panel is a `WebContentsView` the window
composites directly and core drives over CDP.

The macOS menu bar reads `CFBundleName`, so an unpackaged `pnpm desktop:electron`
says "Electron" no matter what `app.setName` is; only a packaged build says
CaliCode. Not a bug to chase.

`CALI_PORT` moves the shell off `:8765` so a second instance can run beside a
live app — attaching two clients to one core is worse than a port collision,
because `editor_attachment` is one owner per session and the newcomer silently
steals tool routing.

## Verify before you claim done

Run these from `client/` unless noted, in this order. Everything must be green.
Expand Down Expand Up @@ -146,18 +162,37 @@ the *client webview* tools (`editor_*`) "browser tools" — unrelated.
Linux (chrome, then chromium, then edge, then a playwright cache);
`CALI_CHROME` overrides the path, `CALI_BROWSER_HEADED=1` shows the window
for debugging, `CALI_BROWSER_PROFILE` moves the profile off `~/.cali/browser`.
- **Why a separate Chrome rather than an embedded webview.** Tauri's webview is
a different engine per platform — WKWebView, WebView2, WebKitGTK — and only
the Windows one speaks CDP. Embedding would mean three rendering behaviours
and an agent that can only drive the browser on one of the three. One found
Chrome behaves identically everywhere. The cost is that the tab is a
screencast, so it cannot select text or open devtools; the toolbar's
open-in-browser button is the way out.
- **A found Chrome is the fallback, not the panel.** When the shell hands core
its `WebContentsView` there is one browser and the user watches the agent work
in it. Core still knows how to launch its own Chrome, because the handshake can
fail and a headless agent has no panel at all; that path is degraded — the tab
shows a screencast of it — but it keeps working rather than failing.
- The model reads pages as ref-tagged element lists (`browser_snapshot`), not
HTML or pixels, and clicks refs rather than coordinates. Coordinates exist
for `<canvas>`, which is the only way to reach a running game.
- `browser_search` deliberately skips Google, which serves this browser an
interstitial instead of results.
- **The Electron shell hands core its own panel** (`browser_attach`), so core
drives the `WebContentsView` the user is looking at instead of launching a
Chrome of its own. The target id is passed, never discovered: guessing by url
or title would eventually pick the editor's own window, and core would start
driving the app instead of the page inside it. `browser::tests::live_attach`
is the regression check: it asserts core drives a browser it did not launch and
leaves it running.
- **Playing a game is a different verb from using a page.** `browser_key` takes
`holdMs` because movement is a held W, not a tapped one; `browser_mouse_move`
takes a *delta* because a camera turns by motion, not by destination. It
splits that motion into steps and primes the origin first — Blink measures
`movementX` against the previous pointer position, so the first event of a
sequence reports 0 and an unprimed turn arrives one step short. `browser_play`
exists because the other two are strictly sequential: strafing (w+a) or aiming
while advancing needs keys held *and* the mouse moving at once. Click the
canvas once before looking if the game wants a pointer lock.
- Files downloaded in the panel land in `~/.cali/downloads`, and
`browser_downloads` lists them — a download is working material the agent is
about to pull into a project, not a personal download. A navigation that turns
into one reports `aborted: true` rather than failing: chrome cancels the page
load by design, and the file is already on disk.
- The BROWSER tab renders the same page over a CDP screencast, so the user and
the agent share one browser rather than each having their own. The tab strip
shows that page's own favicon and title; its accessible name stays `browser`.
Expand All @@ -167,8 +202,10 @@ the *client webview* tools (`editor_*`) "browser tools" — unrelated.
- A chrome that outlives core keeps the profile's `SingletonLock`. Core clears
a lock whose pid is gone and diverts to a unique scratch profile when one is
genuinely held, so a leaked browser cannot wedge the next launch.
- Live coverage is one `#[ignore]`d test: `cargo test browser::tests::live --
--ignored`. Run it after touching that module; CI cannot.
- Live coverage is four `#[ignore]`d tests: `cargo test browser::tests::live --
--ignored`. Run them after touching that module; CI cannot. `live_attach` is
the one to keep honest — it asserts core drives a browser it did not launch
*and* leaves it running, which is the shell's entire contract.

## Extending without touching source

Expand Down
3 changes: 3 additions & 0 deletions client/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist-electron/
.shell-compare/
release-electron/
21 changes: 21 additions & 0 deletions client/build-electron/entitlements.mac.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The shell spawns `cali-core`, a separate unsigned-by-us executable. Under
the hardened runtime a child that is not part of the signed bundle is
refused without this. -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Core listens on 127.0.0.1 and the browser panel loads the open web. -->
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- Workspaces are the user's own folders, chosen through the native panel. -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>
File renamed without changes.
File renamed without changes.
File renamed without changes
45 changes: 45 additions & 0 deletions client/electron-builder.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Packaging for the Electron shell.
#
# Mirrors what `scripts/desktop.sh` does for Tauri: build the client, build the
# core release binary, stage both, produce a .app. The identifier and product
# name match `src-tauri/tauri.conf.json` deliberately — the two shells are the
# same application, and a user upgrading from one to the other should not end up
# with two apps and two `~/.cali` states.
appId: com.calicode.desktop
productName: CaliCode
directories:
output: release-electron
buildResources: build-electron

# `dist-electron` is the compiled shell; `dist` is the built client, which core
# serves rather than the window loading it from disk.
files:
- dist-electron/**/*
- package.json

# Staged beside the app rather than inside the asar: `cali-core` must be a real
# executable on disk to spawn, and an asar-packed binary is not. `main.ts`
# probes `process.resourcesPath` for both of these.
extraResources:
- from: ../core/target/release/cali-core
to: cali-core
- from: dist
to: dist

mac:
category: public.app-category.developer-tools
target:
- dmg
- dir
# Core is spawned as a child and talks to localhost; the hardened runtime
# blocks that without these. Kept minimal on purpose — every entitlement is a
# capability the notarised app carries forever.
hardenedRuntime: true
entitlements: build-electron/entitlements.mac.plist
entitlementsInherit: build-electron/entitlements.mac.plist

# Signing is deliberately not configured here. The repo has
# `scripts/dev-signing-identity.sh` for local identities, and a real identity
# belongs in CI secrets rather than a checked-in file. Unsigned builds are fine
# for testing the packaging itself; `CSC_IDENTITY_AUTO_DISCOVERY=false` skips
# the search entirely.
195 changes: 195 additions & 0 deletions client/electron/browserPanel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { homedir } from "node:os";
import path from "node:path";

import { WebContentsView, session, type BrowserWindow, type Rectangle } from "electron";
import type { PanelBounds } from "./ipc";

/**
* Persistent so logins survive a restart, as they do in the agent's Chrome
* profile today — but its own partition, because this view loads arbitrary
* pages inside our app and their cookies must never reach the session the
* editor and core RPC run on.
*/
const PARTITION = "persist:cali-browser";

/** A target only exists once there is a page, and core attaches at startup. */
const BLANK = "about:blank";

export interface BrowserPanel {
setBounds(b: PanelBounds): void;
targetId(): Promise<string | null>;
destroy(): void;
}

/**
* The BROWSER tab's page, as a real Chromium view rather than a video of one.
*
* The view is created here and driven by core over CDP — the same `browser.rs`
* that drives Chrome today — so the user and the agent are never looking at two
* different pages. Nothing about the protocol lives in this file; the main
* process owns only the view's lifetime and its geometry.
*/
/**
* Where a file downloaded in the panel lands.
*
* Under `~/.cali` beside core's own state rather than the OS Downloads folder,
* because these are the agent's working material: the panel exists so a model
* can go and find a `.glb` or a texture, and the next step is always to pull it
* into a project. Somewhere core already knows how to look is worth more than
* somewhere the user would expect to find a personal download.
*/
const DOWNLOAD_DIR = path.join(homedir(), ".cali", "downloads");

/**
* Accept downloads instead of ignoring them.
*
* This capability is only possible now. A streamed panel had no file transfer
* channel at all — clicking a download link did nothing, which for a browser
* whose whole purpose is finding assets was a hole. A real view has a session,
* and a session has downloads.
*
* Saved without prompting: a modal save dialog would be a native window over an
* app that is already juggling a native view, and the destination is not the
* user's choice here — it is a staging area the agent reads from.
*/
function captureDownloads(panelSession: Electron.Session): void {
panelSession.on("will-download", (_event, item) => {
const target = path.join(DOWNLOAD_DIR, path.basename(item.getFilename()));
item.setSavePath(target);
item.once("done", (_doneEvent, state) => {
if (state === "completed") {
console.log(`browser panel downloaded ${target}`);
} else {
console.error(`browser panel download failed (${state}): ${item.getFilename()}`);
}
});
});
}

export function createBrowserPanel(win: BrowserWindow): BrowserPanel {
const view = new WebContentsView({
webPreferences: {
session: session.fromPartition(PARTITION),
// Untrusted pages. No preload and no Node reach, so nothing on the page
// can address the main process even if it finds a bug in a renderer API.
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webviewTag: false,
},
});

captureDownloads(view.webContents.session);

let destroyed = false;
let applied: Rectangle | null = null;
let cachedTargetId: string | null = null;
let inFlight: Promise<string | null> | null = null;

// A popup would be a second CDP target, and core is attached to exactly one.
// Keeping navigation in this view is what makes "one browser" true.
view.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:/i.test(url)) void view.webContents.loadURL(url);
return { action: "deny" };
});

win.contentView.addChildView(view);
// Starts hidden: until the renderer reports a rect, any bounds we invent
// would paint over the editor.
view.setVisible(false);
void view.webContents.loadURL(BLANK).catch(() => {
// Nothing to recover — core's first navigate replaces this anyway.
});

/**
* Geometry arrives from the renderer in CSS pixels, which are the same
* device-independent pixels Electron's view bounds use — do not scale by
* devicePixelRatio. The rect is relative to the window's content area, and
* that only matches the renderer's client coordinates while this is the sole
* child view laid out over a full-bleed renderer.
*/
const setBounds = (b: PanelBounds): void => {
if (destroyed) return;

// Hidden, never destroyed: the agent keeps browsing with the tab closed,
// and a hidden view still captures and still takes trusted input. The
// renderer also hides it while a Radix dropdown or dialog is open — a
// native view composites above the DOM, so a portalled overlay would
// otherwise be drawn behind it.
if (!b.visible) {
view.setVisible(false);
return;
}

// Electron rejects fractional bounds; getBoundingClientRect returns them.
const next: Rectangle = {
x: Math.round(b.x),
y: Math.round(b.y),
width: Math.max(0, Math.round(b.width)),
height: Math.max(0, Math.round(b.height)),
};
if (!applied || !sameRect(applied, next)) {
// The panel's rounded corners and border are CSS and this view will not
// clip to them; `view.setBorderRadius` is the fix if square corners
// inside the frame ever read as wrong.
view.setBounds(next);
applied = next;
}
view.setVisible(true);
};

/**
* Core attaches to this exact view instead of guessing from url or title.
*
* Only meaningful when the app was launched with `--remote-debugging-port`;
* the id is the same one core sees in that port's target list. Stable for
* the view's lifetime, so it is resolved once.
*/
const targetId = async (): Promise<string | null> => {
if (destroyed || view.webContents.isDestroyed()) return null;
if (cachedTargetId) return cachedTargetId;
if (inFlight) return inFlight;

inFlight = (async () => {
const wc = view.webContents;
// `attach` throws when someone else holds the session — DevTools open on
// this view, most likely. Borrow it only if it is free, and hand it back,
// since holding it blocks DevTools for the rest of the run.
const borrowed = !wc.debugger.isAttached();
try {
if (borrowed) wc.debugger.attach("1.3");
const reply: unknown = await wc.debugger.sendCommand("Target.getTargetInfo");
cachedTargetId = readTargetId(reply);
return cachedTargetId;
} catch {
return null;
} finally {
if (borrowed && !wc.isDestroyed() && wc.debugger.isAttached()) wc.debugger.detach();
inFlight = null;
}
})();
return inFlight;
};

const destroy = (): void => {
if (destroyed) return;
destroyed = true;
if (!win.isDestroyed()) win.contentView.removeChildView(view);
// `close` defaults to skipping beforeunload, so a page cannot refuse to go.
if (!view.webContents.isDestroyed()) view.webContents.close();
};

return { setBounds, targetId, destroy };
}

function sameRect(a: Rectangle, b: Rectangle): boolean {
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
}

function readTargetId(reply: unknown): string | null {
if (typeof reply !== "object" || reply === null) return null;
const info = (reply as { targetInfo?: unknown }).targetInfo;
if (typeof info !== "object" || info === null) return null;
const id = (info as { targetId?: unknown }).targetId;
return typeof id === "string" && id.length > 0 ? id : null;
}
Loading
Loading