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
10 changes: 0 additions & 10 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,6 @@
"port": 5177,
"autoPort": false
},
{
"name": "electron-renderer-worktree (vite 5178)",
"runtimeExecutable": "/bin/sh",
"runtimeArgs": [
"-c",
"VITE_PORT=5178 ELECTRON_CDP_PORT=9225 exec pnpm --filter xnet-desktop dev:electron"
],
"port": 5178,
"autoPort": false
},
{
"name": "expo (mobile)",
"runtimeExecutable": "pnpm",
Expand Down
67 changes: 50 additions & 17 deletions .claude/skills/electron-prototype/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,18 @@ window chrome, real platform, real fonts.
pnpm --filter xnet-desktop dev
```

The main process opens CDP on `127.0.0.1:9223` in development
(`src/main/index.ts`). Attach with the **`playwright-electron`** MCP server —
registered in `.mcp.json`, already pointed at that endpoint. Then:
**Ports are derived from the worktree (0413), not fixed.** Wait for the
machine-readable ready line, which tells you where to attach:

```text
[xnet-dev] ready {"profile":"wt-…","renderer":23400,"cdp":23401,…,"branch":"…"}
```

The main checkout keeps the legacy `9223`; every linked worktree gets its own
block. `pnpm --filter xnet-desktop dev:scope` prints this tree's ports without
starting anything. Attach with the **`playwright-electron`** MCP server —
registered in `.mcp.json`, pointed at `9223` — and pass `--cdp-endpoint` for a
worktree's port. Then:

```javascript
localStorage.setItem('xnet:test:bypass', 'true')
Expand All @@ -52,9 +61,34 @@ your first page`). Now `browser_snapshot`, `browser_click` and
`browser_console_messages` drive the **real** desktop app: real preload, real
IPC, real `better-sqlite3`.

**Confirm which instance you attached to** before trusting anything. A stale
Electron from a crashed run, or a second profile, will happily answer on 9223.
The profile name in the title bar is the cheapest signal.
## Confirm which instance you attached to

Do this before trusting anything, and do it by asking — not by looking:

```js
window.__xnetDev // { worktree, branch, commit, profile, ports, pid, startedAt, logs() }
```

This used to be "check the profile name in the title bar," which could not
work: the title was plain `xNet` for the `default` profile, so a stale instance
and your own were indistinguishable — the exact case worth catching. The title
now names the profile and commit in development, and `__xnetDev` is the
authoritative answer.

`pnpm dev` also refuses to report ready if its CDP port is serving another
tree's renderer, and losing the single-instance lock is now a named error with
**exit 1** rather than a silent exit 0.

## Logs from all three processes

CDP shows you the renderer. `main` and `data-process` records now reach the
renderer console too, tagged `[main]` / `[data]`, so
`browser_console_messages` covers the whole app. For failures from **before**
the window existed — the ones that matter most — read the ring buffer:

```js
await window.__xnetDev.logs()
```

## Never test Electron in a browser

Expand All @@ -66,21 +100,20 @@ already the browser-native surface.

## Ports

| Port | What |
| ---- | --------------------------- |
| 5177 | renderer (Vite) |
| 9223 | CDP, dev only |
| 9224 | CDP for the `user2` profile |
| 4444 | hub |

Two instances for sync testing:
Derived per worktree — `blockBase = 20000 + hash(worktree) % 500 * 10`, then
`+0` renderer, `+1` CDP, `+2` hub, `+3` local API. The main checkout keeps
5177 / 9223 / 4444 / 31415 and the `default` profile.

```bash
pnpm --filter xnet-desktop dev:both
pnpm --filter xnet-desktop dev:scope # this tree's profile and ports
pnpm --filter xnet-desktop dev:clean # prune profiles whose worktree is gone
pnpm --filter xnet-desktop dev:both # two instances for sync testing
```

`dev:user2` sets `ELECTRON_CDP_PORT=9224`, so the two instances no longer
collide on 9223.
Because each worktree also gets its own `userData`, it gets its own Chromium
single-instance lock — which is what lets two trees run at once. Override one
service with `VITE_PORT` / `ELECTRON_CDP_PORT` / `XNET_HUB_PORT` /
`XNET_LOCAL_API_PORT`, or the profile with `XNET_PROFILE`.

## The native rebuild

Expand Down
80 changes: 61 additions & 19 deletions apps/electron/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,40 +9,82 @@ or Expo.

Pick by what the change touches, not by convenience.

| Rung | Loop | Use when the change is… |
| ------------------------ | -------------------------------------------- | ------------------------------------------------------ |
| 1 — Storybook | `pnpm dev:stories` → `:6006` | pure UI: a component, a layout, a state |
| 2 — CDP attach | `pnpm dev` → `:9223` → `playwright-electron` | anything touching `window.xnet*`, SQLite, native APIs |
| 3 — `_electron.launch()` | `tests/e2e/src/electron-smoke.spec.ts` | restart durability, deep links, packaging, crash paths |
| Rung | Loop | Use when the change is… |
| ------------------------ | --------------------------------------------- | ------------------------------------------------------ |
| 1 — Storybook | `pnpm dev:stories` → `:6006` | pure UI: a component, a layout, a state |
| 2 — CDP attach | `pnpm dev` → this tree's CDP port (see below) | anything touching `window.xnet*`, SQLite, native APIs |
| 3 — `_electron.launch()` | `tests/e2e/src/electron-smoke.spec.ts` | restart durability, deep links, packaging, crash paths |

Never reach for rung 3 to check a layout.

**Never test Electron in a plain browser.** The renderer depends on its preload
and cannot boot in a tab — see below. Drive it with `playwright-electron` over
CDP.

## Ports
## Ports are derived, not fixed (0413)

| Port | What |
| ---- | ------------------------------------------------------------------ |
| 5177 | renderer (Vite) — `electron.vite.config.ts` |
| 9223 | CDP, dev only — `src/main/index.ts` sets `--remote-debugging-port` |
| 9224 | CDP for the `user2` profile |
| 4444 | hub |
**Do not hard-code a port. Ask the app which one it is on.**

```bash
pnpm dev # hub + app
pnpm dev:both # two instances for sync testing
`pnpm dev` resolves the profile and a block of ten ports from the **worktree**,
once, in `scripts/dev-launch.mjs`:

```text
blockBase = 20000 + hash(worktree path) % 500 * 10
+0 renderer +1 CDP +2 hub +3 local API +4…+9 reserved
```

`dev:both` does not set `ELECTRON_CDP_PORT`, so both instances request 9223.
For a two-instance session, set the ports explicitly:
The **main checkout keeps the legacy fixed ports** — 5177 / 9223 / 4444 /
31415, profile `default` — so nothing there moved. Every linked worktree gets
its own block, its own `userData` (`xnet-desktop-wt-<slug>`), and therefore its
own single-instance lock. Two worktrees can now run at once.

```bash
ELECTRON_CDP_PORT=9223 pnpm --filter xnet-desktop run dev:electron
XNET_PROFILE=user2 VITE_PORT=5174 ELECTRON_CDP_PORT=9224 pnpm --filter xnet-desktop run dev:user2
pnpm --filter xnet-desktop dev:scope # print this tree's profile and ports
pnpm dev # prints them too, then `[xnet-dev] ready {…}`
pnpm --filter xnet-desktop dev:clean # prune profiles whose worktree is gone
```

Wait for the machine-readable ready line before attaching — it means the CDP
port was checked and is serving **this** tree's renderer:

```text
[xnet-dev] ready {"profile":"wt-…","renderer":23400,"cdp":23401,…,"branch":"…"}
```

Override any single service with `VITE_PORT`, `ELECTRON_CDP_PORT`,
`XNET_HUB_PORT` or `XNET_LOCAL_API_PORT`; `XNET_PROFILE` overrides the profile.
`dev:both` still runs two instances for sync testing.

> The renderer now uses `strictPort`. A taken port **fails** instead of quietly
> becoming the next one — that silent drift is why the port tables here used to
> lie.

## Confirm which instance you are driving

Never assume. One `browser_evaluate` answers it:

```js
window.__xnetDev // { worktree, branch, commit, profile, ports, pid, startedAt, logs() }
```

Dev-only, and `cdp-dev-only.test.ts` asserts it never ships. The window title
also names the profile and commit in development, for every profile including
`default` — it used to be plain `xNet`, which made it useless in exactly the
case worth catching.

Losing the lock is now a named error and **exit 1**. It used to be `app.quit()`
and exit 0, which an agent read as success before attaching to someone else's
app.

## Logs from all three processes

`window.__xnetDev.logs()` returns a bounded ring buffer of `main` and `data`
records, including everything logged **before the window existed** — boot
failures a CDP-attached agent could not otherwise see. Live records also land
in the renderer console tagged `[main]` / `[data]`, so
`browser_console_messages` covers the whole app rather than one process of
three. Development only; production logging is 0315's.

## Preload

The preload exposes **10** `contextBridge.exposeInMainWorld` namespaces
Expand Down
7 changes: 6 additions & 1 deletion apps/electron/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,12 @@ export default defineConfig({
renderer: {
root: resolve(__dirname, 'src/renderer'),
server: {
port: rendererPort
port: rendererPort,
// Fail rather than relocate (0413). Vite's default is to increment, so a
// taken 5177 silently became 5178 while AGENTS.md, the prototyping skill
// and .claude/launch.json all still said 5177 — the app worked and every
// document about it was wrong. A taken port is a collision worth seeing.
strictPort: true
},
build: {
rollupOptions: {
Expand Down
9 changes: 6 additions & 3 deletions apps/electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
"main": "./out/main/index.js",
"type": "module",
"scripts": {
"dev": "node scripts/rebuild-if-stale.mjs && concurrently -k -n hub,electron -c blue,green \"pnpm run dev:hub\" \"pnpm run dev:electron\"",
"dev:electron": "node scripts/rebuild-if-stale.mjs && electron-vite dev",
"dev": "node scripts/rebuild-if-stale.mjs && node scripts/dev-launch.mjs concurrently -k -n hub,electron -c blue,green \"pnpm run dev:hub\" \"pnpm run dev:electron:raw\"",
"dev:electron": "node scripts/rebuild-if-stale.mjs && node scripts/dev-launch.mjs electron-vite dev",
"dev:electron:raw": "electron-vite dev",
"dev:hub": "HUB_AUTH=false HUB_STORAGE=memory node scripts/run-dev-hub.mjs",
"dev:user2": "cross-env XNET_PROFILE=user2 VITE_PORT=5174 ELECTRON_CDP_PORT=9224 electron-vite dev",
"dev:clean": "node scripts/dev-clean.mjs",
"dev:scope": "node scripts/dev-scope.mjs",
"dev:user2": "cross-env XNET_PROFILE=user2 VITE_PORT=5174 ELECTRON_CDP_PORT=9224 node scripts/dev-launch.mjs electron-vite dev",
"dev:both": "node scripts/rebuild-if-stale.mjs && concurrently -k -n hub,user1,user2 -c blue,green,yellow \"pnpm run dev:hub\" \"pnpm run dev:electron\" \"sleep 3 && pnpm run dev:user2\"",
"build": "electron-vite build",
"deps:electron": "pnpm dlx @electron/rebuild -f -w better-sqlite3,usearch,sharp",
Expand Down
139 changes: 139 additions & 0 deletions apps/electron/scripts/dev-clean.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env node
/**
* Prune desktop profiles whose reason to exist is gone (0413).
*
* Two kinds accumulate and nothing collects them:
*
* - `xnet-desktop-wt-<slug>` — a worktree profile whose worktree has since
* been deleted. `git worktree list` is the authority.
* - `xnet-desktop-e2e-*` with a millisecond timestamp — created per test run
* by `tests/e2e`, never removed.
*
* At the time this was written there were 18 of them, including five from
* Codex sessions that ended months earlier. Small in bytes, large in confusion:
* it makes "which profile is real?" a research task.
*
* Refuses to touch `xnet-desktop` (the default profile), any live worktree's
* profile, and any directory currently holding a `SingletonLock` — a running
* app is never garbage, whatever the naming says.
*
* Run: `pnpm --filter xnet-desktop dev:clean [--dry-run]`
*/
import { execFileSync } from 'node:child_process'
import { existsSync, lstatSync, readdirSync, readlinkSync, rmSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { basename, dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const APP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const DRY_RUN = process.argv.includes('--dry-run')
const E2E_AGE_MS = 24 * 60 * 60 * 1000

/** Where Electron puts userData for this platform. */
function userDataRoot() {
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support')
if (process.platform === 'win32') {
return process.env.APPDATA || join(homedir(), 'AppData', 'Roaming')
}
return process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
}

/** Basenames of every live worktree, as `wt-<slug>` profile names. */
function liveWorktreeProfiles() {
try {
const out = execFileSync('git', ['worktree', 'list', '--porcelain'], {
cwd: APP_DIR,
encoding: 'utf8'
})
return new Set(
out
.split('\n')
.filter((line) => line.startsWith('worktree '))
.map((line) => `wt-${basename(line.slice('worktree '.length).trim())}`)
)
} catch (err) {
// Without the authority we cannot tell orphan from live. Deleting on a
// guess is the one outcome worse than leaving litter.
console.error(`[dev-clean] cannot list worktrees (${err.message}) — refusing to guess.`)
process.exit(1)
}
}

function isRunning(dir) {
const lock = join(dir, 'SingletonLock')
try {
lstatSync(lock)
return true
} catch {
return false
}
}

function classify(name, live) {
if (name === 'xnet-desktop') return null // the default profile
const profile = name.slice('xnet-desktop-'.length)

if (profile.startsWith('wt-')) {
return live.has(profile) ? null : 'worktree deleted'
}

// `…-e2e-<13-digit epoch>` and friends: per-run, and only once they are old.
const stamp = profile.match(/(\d{13})$/)
if (stamp && Date.now() - Number(stamp[1]) > E2E_AGE_MS) return 'stale e2e run'

return null
}

const root = userDataRoot()
if (!existsSync(root)) {
console.error(`[dev-clean] no userData root at ${root}`)
process.exit(1)
}

const live = liveWorktreeProfiles()
const candidates = readdirSync(root)
.filter((name) => name.startsWith('xnet-desktop-'))
.map((name) => ({ name, dir: join(root, name) }))
.filter(({ dir }) => {
try {
return statSync(dir).isDirectory()
} catch {
return false
}
})

let removed = 0
let kept = 0

for (const { name, dir } of candidates) {
const reason = classify(name, live)
if (!reason) {
kept += 1
continue
}
if (isRunning(dir)) {
const holder = (() => {
try {
return readlinkSync(join(dir, 'SingletonLock'))
} catch {
return 'unknown'
}
})()
console.log(`[dev-clean] keep ${name} — running (lock held by ${holder})`)
kept += 1
continue
}

if (DRY_RUN) {
console.log(`[dev-clean] would remove ${name} — ${reason}`)
} else {
rmSync(dir, { recursive: true, force: true })
console.log(`[dev-clean] removed ${name} — ${reason}`)
}
removed += 1
}

console.log(
`[dev-clean] ${DRY_RUN ? 'would remove' : 'removed'} ${removed}, kept ${kept} ` +
`(of ${candidates.length} non-default profiles in ${root})`
)
Loading
Loading