Files
Claude-Code-Monitor/desktop/README.md
T
nntrivi2001 57dc91585d feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
2026-07-30 14:39:03 +07:00

834 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `desktop/` — Native macOS App (Electron)
The **desktop workspace** ships the Claude Code Agent Monitor dashboard as a
native macOS `.app` (distributed as a `.dmg`). It is an Electron shell that
**embeds the existing Express server in-process** and renders the already-built
React client in a `BrowserWindow`.
> **One-line mental model:** *Electron is a window onto the same code.* The
> desktop app does not reimplement the dashboard — it `require()`s
> `server/index.js` directly, in the same Node runtime as the Electron main
> process, and points a Chromium window at it.
For the **user-facing** guide (download, install, Gatekeeper, tray menu,
auto-start) see [`../DESKTOP.md`](../DESKTOP.md). This file is the
**contributor / architecture** reference.
---
## Table of contents
- [TL;DR](#tldr)
- [Where the desktop app sits](#where-the-desktop-app-sits)
- [Process model](#process-model)
- [Boot lifecycle](#boot-lifecycle)
- [Server hosting & port discovery](#server-hosting--port-discovery)
- [`better-sqlite3` native-module handling](#better-sqlite3-native-module-handling)
- [Background services & hook bootstrap](#background-services--hook-bootstrap)
- [Window, tray & menu](#window-tray--menu)
- [Auto-start (Login Items)](#auto-start-login-items)
- [Source tree](#source-tree)
- [Packaged app layout](#packaged-app-layout)
- [Build pipeline](#build-pipeline)
- [Commands](#commands)
- [Build performance — read this](#build-performance--read-this)
- [Code signing & notarization](#code-signing--notarization)
- [Continuous integration](#continuous-integration)
- [Smoke test](#smoke-test)
- [Environment variables](#environment-variables)
- [Logs & troubleshooting](#logs--troubleshooting)
- [What this workspace does *not* touch](#what-this-workspace-does-not-touch)
---
## TL;DR
```bash
# From the repo root:
npm run setup # install root + client deps, build client, install hooks
npm run build # build client/dist (the SPA the Electron window loads)
npm run desktop:install # install Electron, electron-builder, types into desktop/
npm run desktop:dev # tsc → launch Electron pointing at out/main.js
npm run desktop:test # smoke test (spawn Electron + probe /api/health)
# Build a DMG (macOS):
npm run desktop:dmg # both per-arch DMGs (arm64 + x64) — correct for release, SLOWER
npm run desktop:dmg:arm64 # Apple Silicon only — fast, for your own machine
npm run desktop:dmg:x64 # Intel only — fast
# Build a Windows .exe (run on Windows x64):
npm run desktop:win # NSIS installer → release/ClaudeCodeMonitor-Setup-<ver>-x64.exe
npm run desktop:win:portable # no-install portable → release/ClaudeCodeMonitor-<ver>-x64-portable.exe
```
> ⚠️ `desktop:dmg` is slower because it builds the app **twice** — once per
> architecture — and emits **two** per-arch DMGs (`arm64` + `x64`). It does not
> merge them into a universal binary. For running on your own Mac, use the
> arch-specific command. See [Build performance](#build-performance--read-this).
> 🪟 **Windows builds run on Windows** (DMGs build on macOS). `desktop:win`
> produces an **unsigned** installer — fine to run; SmartScreen may show a
> "More info → Run anyway" prompt on first launch. The icon (`assets/icon.ico`)
> is generated from `assets/icon.png` by `npm run build:win-icon`
> (PowerShell + .NET, no extra tooling). `better-sqlite3` is fetched as a
> prebuilt Electron binary by `npm run desktop:install`, so no Visual Studio
> C++ toolchain is required for the common case. If that fetch/rebuild *does*
> fail (no C++ toolchain, or a Node version with no prebuilt binary),
> `npm run desktop:install` — and any `desktop:*` build, gated by `prebuild.js` —
> prints the exact per-OS prerequisite (Windows: Visual Studio Build Tools with
> the "Desktop development with C++" workload; macOS: `xcode-select --install`;
> Linux: build-essential + python3) plus a no-toolchain alternative, then exits
> non-zero rather than crashing at runtime:
>
> ```bash
> cd desktop
> npm install --ignore-scripts
> node node_modules/electron/install.js
> npx electron-builder install-app-deps
> ```
>
> A Node LTS (20/22) ships prebuilt binaries and avoids the compile entirely.
---
## Where the desktop app sits
`desktop/` is a **sibling workspace** — not a npm-workspaces conversion. It has
its own `package.json`, its own `node_modules`, and its own toolchain. It
consumes the rest of the repo as plain files.
```mermaid
flowchart TD
subgraph repo["Claude-Code-Agent-Monitor (repo root)"]
server["server/<br/>Express API · SQLite · WebSocket"]
client["client/<br/>React + Vite SPA"]
scripts["scripts/<br/>hook installer/handler, import, seed"]
mcp["mcp/<br/>local MCP server"]
vscode["vscode-extension/"]
desktop["desktop/<br/>★ Electron shell (this workspace)"]
end
desktop -- "require() in-process" --> server
desktop -- "loads built SPA from" --> client
desktop -- "auto-installs hooks via" --> scripts
server -- "serves static" --> client
style desktop fill:#1f6feb,stroke:#1158c7,color:#fff
style server fill:#238636,stroke:#196c2e,color:#fff
```
The desktop app touches **no other workspace's runtime behavior**. The only
change outside `desktop/` is a behavior-preserving refactor of
`server/index.js` (see [the last section](#what-this-workspace-does-not-touch)).
---
## Process model
Electron runs a **main process** (Node.js) and one or more **renderer
processes** (Chromium). In this app:
- The **main process** hosts the embedded Express server *and* manages the
window, tray, and menus. There is **no child process and no IPC** for the
server — it runs inside the main process's own event loop.
- The **renderer** is just Chromium loading `http://127.0.0.1:<port>` — exactly
the same origin a normal browser would. The `preload.ts` is intentionally
empty, so the renderer has zero privileged surface.
```mermaid
flowchart LR
subgraph main["Electron Main Process (Node 22 / Electron 35)"]
boot["main.ts<br/>lifecycle"]
host["server-host.ts<br/>embedded server"]
express["server/index.js<br/>Express + WS + SQLite"]
tray["tray.ts"]
menu["menu.ts"]
host --> express
boot --> host
boot --> tray
boot --> menu
end
subgraph renderer["Renderer Process (Chromium)"]
win["BrowserWindow<br/>React dashboard"]
preload["preload.ts<br/>(empty — no bridge)"]
end
express -- "http + ws on 127.0.0.1:port" --> win
win -.->|loads| preload
hooks["Claude Code hooks<br/>(separate node processes)"] -- "POST /api/hooks/event" --> express
style main fill:#0d1117,stroke:#30363d,color:#e6edf3
style renderer fill:#161b22,stroke:#30363d,color:#e6edf3
```
---
## Boot lifecycle
```mermaid
sequenceDiagram
autonumber
participant OS as macOS
participant Main as main.ts
participant Host as server-host.ts
participant Srv as server/index.js
participant UI as BrowserWindow
OS->>Main: launch app
Main->>Main: requestSingleInstanceLock()
alt lock not acquired
Main->>OS: exit(0) — focus existing instance
end
Main->>Host: startEmbeddedServer()
Host->>Host: probe port 4820 — adopt if a healthy server answers
alt no server to adopt
Host->>Host: pickFreePort() · patch better-sqlite3 ABI
Host->>Srv: require() · createApp() · startServer(port)
Host->>Srv: waitForHealthy() — poll /api/health
Host->>Srv: bootstrapOwnedServer() — schedulers, cc-watcher, install hooks
end
Host-->>Main: ServerHandle { url, port, ownedByUs, stop }
Main->>Main: installApplicationMenu() · createTray()
alt launched at login
Main->>OS: stay tray-only, hide dock
else normal launch
Main->>UI: createDashboardWindow(url)
UI->>Srv: GET http://127.0.0.1:port
end
Note over Main: window "close" → hide (server keeps running)
Note over Main: before-quit → stop server + closeEmbeddedDatabase()
```
Key behaviors:
| Event | Behavior |
|---|---|
| Second launch | `requestSingleInstanceLock()` fails → the new process exits and the existing window is focused. |
| Window close | Intercepted — the window **hides**, the server and tray keep running. |
| `window-all-closed` | App stays alive (tray-only mode). |
| Launched at login | The dashboard window is **not** shown — only the tray icon. |
| `before-quit` | If we own the server: stop the HTTP server, then `closeEmbeddedDatabase()` for a clean WAL checkpoint, then `app.exit(0)`. |
---
## Server hosting & port discovery
`server-host.ts` is the **only file** that imports `server/index.js`. It picks
a port, boots the server, and returns a `ServerHandle`.
```mermaid
flowchart TD
start["startEmbeddedServer()"] --> forced{"CCAM_DESKTOP_BIND_PORT set?"}
forced -->|yes| bind["bind exactly that port<br/>(no adoption, no fallback)"]
forced -->|no| adopt{"healthy server<br/>already on :4820?"}
adopt -->|yes| reuse["adopt it<br/>ownedByUs = false"]
adopt -->|no| pick["pickFreePort()"]
pick --> p1{":4820 free?"}
p1 -->|yes| use4820["use 4820"]
p1 -->|no| p2{"any of<br/>:4821:4829 free?"}
p2 -->|yes| usefb["use that"]
p2 -->|no| p3{"any of<br/>:49152:49500 free?"}
p3 -->|yes| userand["use that"]
p3 -->|no| fail["throw — no free port"]
bind --> boot["createApp() + startServer()"]
use4820 --> boot
usefb --> boot
userand --> boot
boot --> healthy["waitForHealthy()<br/>poll /api/health ≤ 30s"]
healthy --> bg["bootstrapOwnedServer()"]
bg --> handle["ServerHandle ownedByUs = true"]
reuse --> handleR["ServerHandle ownedByUs = false"]
style reuse fill:#9e6a03,stroke:#7d5300,color:#fff
style fail fill:#da3633,stroke:#b62324,color:#fff
```
**Adoption**`probePort()` connects, then checks that the listener answers
`GET /api/health` with `{ status: "ok" }`. If a healthy dashboard server is
already on `:4820` (e.g. you ran `npm start` in a terminal), the desktop app
**adopts** it rather than double-binding. An adopted server is *not* owned by
the app — quitting the app leaves it running.
**`ServerHandle`:**
```ts
interface ServerHandle {
url: string; // e.g. "http://127.0.0.1:4820"
port: number;
ownedByUs: boolean; // false when adopted
stop: () => Promise<void>;
}
```
**Hook port discovery** — because the embedded server may bind a fallback port
(4821+) when 4820 is taken, the Claude Code hook handler must not assume 4820.
On startup the server writes its live port to `~/.claude/.agent-dashboard.json`
(`server/lib/server-info.js`); `scripts/hook-handler.js` reads that file to
target the running server. Without this, hook events would be POSTed to 4820 —
nothing would receive them and the dashboard would stay empty.
---
## `better-sqlite3` native-module handling
`better-sqlite3` is the only **native** module in the dependency tree, and a
native module must be compiled against the exact Node ABI it runs on. The repo
root's copy is built for the **system Node** (so `npm run test:server` works);
Electron ships its **own Node ABI**.
The desktop workspace solves this without disturbing the root install:
```mermaid
flowchart TD
subgraph desk["desktop/node_modules"]
d1["better-sqlite3<br/>rebuilt for Electron's ABI<br/>(by electron-builder install-app-deps)"]
end
subgraph root["node_modules (repo root)"]
r1["better-sqlite3<br/>built for system Node<br/>(used by npm run test:server)"]
end
patch["ensureNativeModulesPatched()<br/>overrides Module._resolveFilename"]
srv["server/db.js<br/>require('better-sqlite3')"]
srv -->|"request intercepted"| patch
patch -->|"redirected to"| d1
patch -.->|"everything else<br/>passes through"| root
style d1 fill:#238636,stroke:#196c2e,color:#fff
style patch fill:#1f6feb,stroke:#1158c7,color:#fff
```
- The patch is **process-local** and installed exactly once, before
`server/index.js` is `require()`d.
- It rewrites *only* `require("better-sqlite3")`; every other module resolves
normally.
- `electron-builder.yml` therefore **excludes** the root `better-sqlite3` from
the bundle (it would trip `@electron/universal`'s identical-file detector)
and `asarUnpack`s the desktop copy (native `.node` files cannot live inside
an `asar` archive).
- PR #37's `compat-sqlite` (`node:sqlite`) fallback remains as a safety net —
one reason the desktop app pins **Electron 35** (its bundled Node 22.16 has
`node:sqlite`; Electron 31's Node 20 did not).
- **No toolchain needed in the common case** — `npm run desktop:install` runs
`scripts/install.js`, which wraps `npm install` (whose `postinstall` runs
`electron-builder install-app-deps`). On success it behaves like a bare
`npm install`. On failure — or if the native binary is missing afterward — it
prints actionable help (`scripts/preflight.js`'s `printNativeDepHelp()`) and
exits non-zero, never leaving a half-set-up `node_modules`. The help lists the
per-OS C++ prerequisite (Windows: Visual Studio Build Tools with the "Desktop
development with C++" workload; macOS: `xcode-select --install`; Linux:
build-essential + python3), notes that a Node LTS (20/22) ships prebuilt
binaries (avoiding the compile), and gives a no-toolchain alternative:
```bash
cd desktop
npm install --ignore-scripts
node node_modules/electron/install.js
npx electron-builder install-app-deps
```
`prebuild.js` runs the same check (`hasBetterSqliteBinary()`) before **every**
`desktop:*` build/dev script and fails fast with the same help if the binary
is missing — turning what was a runtime fatal-dialog crash into a build-time,
copy-pasteable error.
---
## Background services & hook bootstrap
`node server/index.js` runs its production bootstrap from an
`if (require.main === module)` block. Because the desktop app **`require()`s**
that module, the block never fires — so the bootstrap was extracted into an
exported `startBackgroundServices()` that both paths call.
```mermaid
flowchart LR
subgraph standalone["node server/index.js"]
s1["require.main === module"] --> s2["startBackgroundServices()"]
end
subgraph desktopapp["desktop app"]
d1["server-host.ts<br/>bootstrapOwnedServer()"] --> d2["startBackgroundServices()"]
d1 --> d3["installHooks()"]
end
d2 --> svc
s2 --> svc
subgraph svc["Background services"]
u["update scheduler"]
w["cc-watcher (Claude config watcher)"]
r["orphaned-run reconciliation"]
end
style d1 fill:#1f6feb,stroke:#1158c7,color:#fff
```
`bootstrapOwnedServer()` runs **once** (guarded by a module-level flag so a
*Restart Server* does not double-register schedulers/watchers) and:
1. Calls `startBackgroundServices()` — the update scheduler, the `cc-watcher`
config watcher, and one-time orphaned-run reconciliation.
2. Calls `installHooks()` — writes the Claude Code hook configuration to
`~/.claude/settings.json`, so a **DMG-only user gets events flowing**
without ever running `npm run install-hooks` from a checkout.
It runs only when the server is **owned** by the app — an adopted server has
already done its own bootstrap.
---
## Window, tray & menu
```mermaid
flowchart TD
tray["Menu-bar (tray) icon"]
tray -->|left-click| toggle["toggle dashboard window"]
tray -->|right-click| menu["context menu (built fresh)"]
menu --> m1["Open Dashboard"]
menu --> m2["Open in Browser…"]
menu --> m3["Restart Server"]
menu --> m4["Show Logs"]
menu --> m5["Open at Login ☑"]
menu --> m6["Quit"]
win["BrowserWindow"]
win -->|"close"| hide["hide() — server stays up"]
win -->|"resize / move"| persist["debounced save →<br/>userData/window-state.json"]
win -->|"external link"| ext["shell.openExternal()"]
style tray fill:#1f6feb,stroke:#1158c7,color:#fff
```
- **Tray** — the always-on surface. Left-click toggles the window; right-click
pops the context menu. The menu is rebuilt on each open so the port label and
*Open at Login* checkbox are always current. (The tray deliberately does
**not** use `setContextMenu`, which on macOS would make a left-click open the
menu and collide with the toggle behavior.)
- **Window** — `BrowserWindow` with `contextIsolation: true`,
`nodeIntegration: false`, an empty preload, and `webSecurity: true`. Geometry
is persisted to `window-state.json` under `app.getPath('userData')`. External
links open in the system browser, never inside Electron. Its `icon` is set to
the colored app logo via `appIconPath()` (`icon.ico` on Windows, `icon.png`
elsewhere — the same logo as the macOS Dock, rendered from `assets/icon.svg`),
resolving dev vs packaged asset paths, so an unpackaged `desktop:dev` run shows
the real logo in the title bar / taskbar instead of the generic Electron icon.
macOS ignores `BrowserWindow#icon` (the dev Dock icon is set separately in
`main.ts`; packaged apps get theirs from the bundle `.icns`/`.exe`).
- **Application menu** — standard menu (`About`, `Open at Login`, `File`,
`Edit`, `View`, `Window`, `Help`). `⌘R` / `Ctrl+R` is owned by `View ▸ reload`.
The `File ▸ Open Dashboard` item (`⌘1`) is gated behind `isMac`: macOS keeps a
global menu bar after the window hides so it can reopen it, but on
Windows/Linux the menu is attached to the window and a menu accelerator can't
fire while it's hidden — reopening there is the tray's *Open Dashboard*, and
`focusOrCreateWindow` calls `show()` unconditionally so it reliably raises a
backgrounded/minimized window (a bare `focus()` on Windows often only flashes
the taskbar button).
---
## Auto-start (Login Items)
Auto-start uses Electron's first-party `app.setLoginItemSettings` — which wraps
the modern macOS `SMAppService` / `ServiceManagement` framework — **not** a
`LaunchAgent` plist. The toggle therefore appears in
**System Settings → General → Login Items** where users expect to manage it.
```mermaid
stateDiagram-v2
[*] --> Disabled
Disabled --> Enabled: tray / menu "Open at Login"
Enabled --> Disabled: toggle again
Enabled --> LaunchedAtLogin: macOS login
LaunchedAtLogin --> TrayOnly: window hidden,<br/>dock hidden
TrayOnly --> WindowShown: user clicks tray
```
When macOS launches the app at login (`wasOpenedAtLogin`), it starts
**tray-only** with `openAsHidden: true` — no window jumps into the user's face.
---
## Source tree
```
desktop/
├── src/
│ ├── main.ts # main process entry — lifecycle, dialogs, wiring
│ ├── server-host.ts # ★ in-process Express boot, port discovery, adoption,
│ │ # better-sqlite3 ABI patch, DB + discovery-file close,
│ │ # /api/stats snapshot poller for the tray dropdown
│ ├── window.ts # BrowserWindow + persisted geometry; native macOS
│ │ # titleBarStyle: 'default' (clear traffic-light row)
│ ├── menu.ts # native application menu
│ ├── tray.ts # menu-bar icon + single-click dropdown w/ live
│ │ # {sessions, agents, events-today} snapshot
│ ├── login-item.ts # macOS Login Items (SMAppService)
│ ├── shell-path.ts # recover the user's shell PATH (so `claude` is found)
│ ├── logger.ts # file logger → app.getPath('logs')/desktop.log
│ ├── constants.ts # APP_NAME, ports, timeouts, window size
│ └── preload.ts # intentionally empty (zero renderer privilege)
├── scripts/
│ ├── install.js # desktop:install wrapper: npm install + actionable
│ │ # native-dep help on failure (exits non-zero)
│ ├── preflight.js # shared hasBetterSqliteBinary() + printNativeDepHelp()
│ ├── prebuild.js # ensures client/dist + root node_modules exist; fails
│ │ # fast with setup help if better-sqlite3 binary missing
│ ├── notarize.js # electron-builder afterSign hook (opt-in)
│ └── build-icons.sh # regenerate icon.icns + tray PNGs from SVG
├── assets/ # icon.icns, icon.png, tray-icon-Template*.png, SVGs
├── tests/
│ └── smoke.test.mjs # spawn Electron + probe /api/health
├── electron-builder.yml # DMG packaging config
├── tsconfig.json # strict; src/ → out/
└── package.json
```
Compiled output lands in `desktop/out/` (git-ignored); packaged artifacts in
`desktop/release/` (git-ignored).
---
## Packaged app layout
`electron-builder` produces `Claude Code Monitor.app`. The Electron main
process code is packed into `app.asar`; the rest of the repo is shipped as
**`extraResources`** (plain files under `Resources/app/`):
```mermaid
flowchart TD
app["Claude Code Monitor.app"]
app --> contents["Contents/"]
contents --> macos["MacOS/ — Electron binary"]
contents --> res["Resources/"]
res --> asar["app.asar<br/>(compiled out/**, package.json)"]
res --> unpacked["app.asar.unpacked/<br/>node_modules/better-sqlite3 (.node)"]
res --> appdir["app/"]
appdir --> a1["server/ — Express server (no tests)"]
appdir --> a2["client/dist/ — built React SPA"]
appdir --> a3["scripts/ — hook-handler, install-hooks"]
appdir --> a4["node_modules/ — server runtime deps"]
appdir --> a5["package.json"]
style asar fill:#1f6feb,stroke:#1158c7,color:#fff
style appdir fill:#238636,stroke:#196c2e,color:#fff
```
At runtime `server-host.ts` resolves this root: `process.resourcesPath/app`
when packaged, or the repo root in development.
---
## Build pipeline
```mermaid
flowchart LR
src["src/*.ts"] -->|prebuild guard| pre["scripts/prebuild.js<br/>verify client/dist + node_modules"]
pre --> tsc["tsc → out/*.js"]
tsc --> eb["electron-builder"]
eb --> dl["download Electron runtime"]
eb --> rebuild["@electron/rebuild<br/>better-sqlite3 per arch"]
eb --> asar["pack out/** → app.asar"]
eb --> extra["copy server/ client/dist/ scripts/ node_modules/<br/>→ Resources/app/"]
asar --> appbundle[".app bundle"]
extra --> appbundle
rebuild --> appbundle
appbundle --> sign["codesign (ad-hoc by default)"]
sign --> notarize["notarize (opt-in, afterSign hook)"]
notarize --> dmg["hdiutil → .dmg"]
style tsc fill:#1f6feb,stroke:#1158c7,color:#fff
style dmg fill:#238636,stroke:#196c2e,color:#fff
```
`desktop:dmg` runs the *packaging → rebuild → sign → DMG* steps **twice**
(once per architecture) and emits two separate DMGs (`…-arm64.dmg` +
`…-x64.dmg`). There is no `@electron/universal` merge step — the release ships
the two per-arch DMGs rather than one fat universal binary.
---
## Commands
All commands are runnable from the **repo root** (`desktop:*`) or from inside
`desktop/`. Every script that packages first runs `npm run build`, so you never
need to invoke `electron-builder` bare (doing so skips the TypeScript compile
and fails with *"entry file out/main.js does not exist"*).
| Repo-root command | `desktop/` command | What it does |
|---|---|---|
| `npm run desktop:install` | `node scripts/install.js` | Install Electron, electron-builder, types; rebuild `better-sqlite3` for Electron's ABI (`postinstall`). Preflights native deps — on failure (or a missing binary) prints per-OS setup help + a no-toolchain alternative and exits non-zero. |
| `npm run desktop:build` | `npm run build` | Prebuild guard + `tsc` → `out/`. |
| `npm run desktop:dev` | `npm run dev` | Build, then launch Electron against `out/main.js`. |
| `npm run desktop:test` | `npm test` | Build, then run the smoke test. |
| `npm run desktop:dmg` | `npm run dmg` | **macOS:** both per-arch DMGs (arm64 + x64). Correct for release. **Slower.** |
| `npm run desktop:dmg:arm64` | `npm run dmg:arm64` | **macOS:** Apple-Silicon-only DMG. **Fast.** |
| `npm run desktop:dmg:x64` | `npm run dmg:x64` | **macOS:** Intel-only DMG. **Fast.** |
| `npm run desktop:dmg:universal` | `npm run dmg:universal` | **macOS:** one merged universal DMG (arm64 + x86_64 via `@electron/universal`). Optional — not what the release ships. **Slowest.** |
| `npm run desktop:win` | `npm run win` | **Windows:** NSIS installer `.exe` (x64). |
| `npm run desktop:win:portable` | `npm run win:portable` | **Windows:** no-install portable `.exe` (x64). |
| — | `npm run build:icons` | **macOS:** regenerate `icon.icns` + tray PNGs from the SVGs. |
| — | `npm run build:win-icon` | **Windows:** regenerate `icon.ico` from `icon.png` (PowerShell + .NET). |
| — | `npm run clean` | Remove `out/` and `release/`. |
> **After `npm run clean`** you must `npm run build` again before packaging —
> `clean` deletes `out/`, and `electron-builder` only *packages*, it does not
> compile. The `dmg*` scripts chain the build for you; a bare
> `electron-builder` call does not.
---
## Build performance — read this
**`desktop:dmg` is slower than a single-arch build.** This is expected — it is
the standard Electron packaging cost, paid **once per architecture**:
```mermaid
flowchart TD
u["npm run desktop:dmg (both arches)"] --> b1["build x64 app tree → sign → …-x64.dmg"]
u --> b2["build arm64 app tree → sign → …-arm64.dmg"]
a["npm run desktop:dmg:arm64 (single arch)"] --> sb["build one app tree"]
sb --> ssign["sign"]
ssign --> sdmg["hdiutil → …-arm64.dmg"]
style u fill:#9e6a03,stroke:#7d5300,color:#fff
style a fill:#238636,stroke:#196c2e,color:#fff
```
Why `desktop:dmg` is slow:
1. **Everything happens twice** — electron-builder builds a full x64 app tree
*and* a full arm64 app tree, rebuilding `better-sqlite3` and packaging a DMG
for each. (There is no universal merge; each arch produces its own DMG.)
2. **The app tree is large** — the server's entire production dependency tree
(`express`, `swagger-ui-express`, `ws`, …) ships as `extraResources`; that's
tens of thousands of files, walked and copied for each architecture.
3. **Per-binary code signing** runs over each architecture's bundle.
Net effect: a ~250 MB app is built, copied, and signed once per architecture —
gigabytes of disk I/O. The Electron runtime downloads (~110 MB each) are *not*
the bottleneck; packaging two architectures back-to-back is.
**Guidance:**
- Building for **your own Mac** → use `desktop:dmg:arm64` (Apple Silicon) or
`desktop:dmg:x64` (Intel). One architecture — finishes in roughly a minute
instead of two.
- Building the **release artifacts for everyone** → use `desktop:dmg` (builds
both arches) and expect it to take about twice as long. CI runs `desktop:dmg`
and uploads both DMGs as the `ClaudeCodeMonitor-dmg` artifact, so you rarely
need to build them locally.
- Each DMG is **~80 MB / ~250 MB on disk** — the standard Electron tax.
---
## Code signing & notarization
The DMG is **ad-hoc signed by default** so anyone can build a working `.dmg`
without a paid Apple Developer account.
- The `package` script sets **`CSC_IDENTITY_AUTO_DISCOVERY=false`** so a
code-signing certificate already in the contributor's macOS keychain is
**never** picked up. (Without this, electron-builder auto-discovers such a
cert and attempts `type=distribution` signing, which fails on a nonDeveloper
ID cert with *"Application … could not be found"*.)
- **Real Developer ID signing** activates when `CSC_LINK` (a base64-encoded
`.p12`) and `CSC_KEY_PASSWORD` are provided — `CSC_LINK` is an *explicit*
certificate and is unaffected by the auto-discovery flag.
- **Notarization** is opt-in: `desktop/scripts/notarize.js` (an
`electron-builder` `afterSign` hook) runs only when `APPLE_ID`,
`APPLE_TEAM_ID`, and `APPLE_APP_SPECIFIC_PASSWORD` are all set. Otherwise it
is a no-op.
```mermaid
flowchart TD
build["DMG build"] --> q{"CSC_LINK set?"}
q -->|yes| real["sign with Developer ID cert"]
q -->|no| adhoc["ad-hoc sign<br/>(keychain scan disabled)"]
real --> n{"APPLE_ID + TEAM_ID + PASSWORD set?"}
adhoc --> n
n -->|yes| notar["notarize via notarytool"]
n -->|no| skip["skip notarization"]
notar --> out[".dmg"]
skip --> out
style adhoc fill:#9e6a03,stroke:#7d5300,color:#fff
style real fill:#238636,stroke:#196c2e,color:#fff
```
An ad-hoc DMG triggers a Gatekeeper warning on first launch. The one-line
workaround is in [`../DESKTOP.md`](../DESKTOP.md):
`xattr -cr "/Applications/Claude Code Monitor.app"`.
---
## Continuous integration
The `🍎 macOS Desktop (DMG)` job in `.github/workflows/ci.yml`:
```mermaid
flowchart LR
ch["changes job<br/>dorny/paths-filter"] -->|"desktop/** changed?"| gate{run?}
push["push to any branch"] --> gate
label["PR has 'desktop' label"] --> gate
gate -->|yes| job["desktop job (macos-latest)"]
job --> j1["npm ci (root, client, desktop)"]
j1 --> j2["tsc build"]
j2 --> j3["smoke test"]
j3 --> j4["build both per-arch DMGs<br/>(retry on flaky hdiutil detach)"]
j4 --> j5["upload ClaudeCodeMonitor-dmg artifact"]
j5 --> rel["release job (master only)<br/>publish vX.Y.Z if new"]
style job fill:#1f6feb,stroke:#1158c7,color:#fff
style rel fill:#238636,stroke:#1a6e2c,color:#fff
```
- The job is **path-filtered** — a `changes` job (`dorny/paths-filter`)
detects `desktop/**` edits; the desktop job also runs on any `push` or when a
PR carries the `desktop` label.
- **DMG build resilience** — `electron-builder` finalizes the DMG with
`hdiutil detach`, which is intermittently flaky on GitHub macOS runners. The
step disables Spotlight indexing and retries the build up to 3 times,
force-detaching any stale volume between attempts.
- The built DMG is uploaded as the **`ClaudeCodeMonitor-dmg`** artifact
(downloadable from the workflow run).
- On `master`, a follow-on **`release`** job reads the version from
`package.json` and publishes `vX.Y.Z` as a GitHub Release with the DMG
attached — but only when no release exists for that version yet, so bumping
the version is what cuts a release. The result is a permanent, anonymous
download URL at `releases/latest`.
---
## Smoke test
`tests/smoke.test.mjs` is intentionally minimal — it proves the embedded server
boots, without needing a display (so CI needs no `xvfb`).
```mermaid
sequenceDiagram
autonumber
participant T as smoke.test.mjs
participant E as Electron (out/main.js)
participant S as embedded server
T->>T: pick a unique high port
T->>E: spawn with CCAM_DESKTOP_BIND_PORT=<port>
E->>S: startEmbeddedServer() — bind exactly <port>
loop until healthy or 60s
T->>S: GET /api/health
end
T->>T: assert status == "ok" AND <port> matched
T->>T: assert Electron process still alive
T->>E: SIGTERM
```
`CCAM_DESKTOP_BIND_PORT` forces the server onto an exact port (no adoption, no
fallback) so the test can be certain it probed *this* process and not an
unrelated server on `:4820`.
---
## Environment variables
| Variable | Used by | Effect |
|---|---|---|
| `CCAM_DESKTOP_BIND_PORT` | `server-host.ts` | Bind exactly this port — disables adoption and fallback. Used by the smoke test. |
| `CCAM_DESKTOP_NO_ADOPT` | `server-host.ts` | `=1` → never adopt an existing `:4820` server; always start our own. |
| `CCAM_DESKTOP_VERBOSE` | `logger.ts` | Mirror `info`/`warn` log lines to stdout (errors always go to stderr). |
| `DASHBOARD_DATA_DIR` | `server-host.ts` → server | Set automatically to `app.getPath('userData')/data` so the SQLite database and VAPID keys live in the per-user Application Support directory, never inside the (possibly read-only) `.app` bundle. |
| `CSC_IDENTITY_AUTO_DISCOVERY` | electron-builder | Set to `false` by the `package` script — forces ad-hoc signing. |
| `CSC_LINK` / `CSC_KEY_PASSWORD` | electron-builder | Explicit Developer ID `.p12` for real signing. |
| `APPLE_ID` / `APPLE_TEAM_ID` / `APPLE_APP_SPECIFIC_PASSWORD` | `notarize.js` | Enable Apple notarization when all three are set. |
The embedded server also honors the dashboard's own env vars (`DASHBOARD_PORT`
and `DASHBOARD_DATA_DIR` are set automatically by `server-host.ts`; everything
else in [`../SETUP.md`](../SETUP.md) applies).
> **Writable state never lives in the `.app` bundle.** A packaged, code-signed,
> or app-translocated bundle is read-only; a database written there would break
> History Import and event persistence. `server-host.ts` points
> `DASHBOARD_DATA_DIR` at `~/Library/Application Support/Claude Code Monitor/data/`,
> which is also why your imported history survives an app reinstall or update.
---
## Logs & troubleshooting
The Electron main process has no console when launched from Finder, so
`logger.ts` writes to a per-user file:
```
~/Library/Logs/Claude Code Monitor/desktop.log
```
Reach it from the tray menu → **Show Logs**.
| Symptom | Cause / fix |
|---|---|
| `entry file out/main.js does not exist` | You ran `electron-builder` without building first. Run `npm run build` (or use a `dmg*` script). |
| Signing fails: `Application … could not be found` after retries | A keychain cert was auto-discovered. The `package` script now sets `CSC_IDENTITY_AUTO_DISCOVERY=false`; ensure you build via `npm run dmg*`, not bare `electron-builder`. |
| DMG build seems slow | Not hung — `desktop:dmg` packages two architectures back-to-back. See [Build performance](#build-performance--read-this). Use `dmg:arm64` / `dmg:x64` for a single arch. |
| `hdiutil detach … exit code 1` in CI | Flaky GitHub runner; the CI step already retries with Spotlight disabled. Re-run the job if it still fails. |
| Dashboard window is blank | The embedded server failed `/api/health` within 30 s — check `desktop.log`. |
| Gatekeeper blocks the app | Ad-hoc DMG. `xattr -cr "/Applications/Claude Code Monitor.app"`. |
| Hooks not firing | The app installs hooks on first owned-server boot; start a **new** Claude Code session afterwards. Verify entries in `~/.claude/settings.json`. |
| "Run Claude" says `claude` isn't on your PATH | `shell-path.ts` recovers the login-shell PATH at startup. If `claude` is a shell _alias_ or _function_ (not a real binary), it cannot be spawned — install the `claude` CLI as an executable. Check `desktop.log` for the `user PATH resolved` line. |
| `desktop:dev` / `desktop:test` fail with `ERR_DLOPEN_FAILED` | A prior DMG build left `better-sqlite3` built for the other CPU arch. `prebuild.js` auto-heals this on the next build; if needed, run `npm run desktop:install`. |
| Imported history disappeared after reinstall | Fixed — the database now lives in `~/Library/Application Support/Claude Code Monitor/data/`, outside the bundle. A one-time gap exists only across the upgrade from a build that predated this fix; re-run **Import History → Rescan**. |
---
## What this workspace does *not* touch
By design, changes outside `desktop/` are kept to a minimum:
- **`server/index.js`** — its post-listen bootstrap was extracted into an
exported `startBackgroundServices()` so the embedded server boots the same
one-time legacy-session import, update scheduler, `cc-watcher`, and
orphaned-run reconciliation that `node server/index.js` does. A
**behavior-preserving refactor** — the standalone server path is functionally
unchanged. (The legacy-session import previously lived in the
`require.main === module` block, so the embedded server never ran it and the
desktop dashboard started empty; moving it into `startBackgroundServices()`
fixes that.) The server also publishes its live port on startup.
- **`server/lib/server-info.js`** *(new)* — multi-server discovery file at
`~/.claude/.agent-dashboard.json`. Every running dashboard appends its
`{port, pid, startedAt}` entry on startup, removes it on clean shutdown,
and stale entries are pruned by a `process.kill(pid, 0)` liveness check on
read. Exposes `writeServerInfo`, `removeServerInfo`,
`resolveAllDashboardPorts` (fan-out targets), and the legacy single-port
`resolveDashboardPort`. The file also carries legacy root-level
`port`/`pid`/`startedAt` fields populated from the most recently started
live server, so older hook handlers bundled inside an already-installed
`.app` still resolve to a reachable port.
- **`scripts/hook-handler.js`** — `Promise.all` fan-out of every hook
payload to every live server returned by `resolveAllDashboardPorts()`
(`CLAUDE_DASHBOARD_PORT` overrides to a single target). This is what lets
the desktop app coexist with `npm run dev` — both dashboards receive every
event and both stay real-time.
- **`server/lib/push.js`** — `sendPushToAll()` now also fires a **native
Electron notification** when `process.versions.electron` is set, so the
desktop app surfaces notifications via the OS API instead of relying on Web
Push (which fails inside Electron — no FCM credentials in the Chromium
build). The standalone server path is unchanged: the native leg is a no-op
there, and Web Push delivers as before.
- **`scripts/dev.js`** *(new)* — `npm run dev`'s entry point. Probes both
`127.0.0.1` and `::1` for a free port in `48204859` (so an SSH
`LocalForward` with loopback-specific binds can't shadow Node's wildcard
listen), exports `DASHBOARD_PORT`, then spawns the existing
`concurrently` server + client pipeline. `npm run dev:raw` bypasses it
for parity with the old behaviour.
`client/`, `mcp/`, and `vscode-extension/` are **untouched**. If you find
yourself wanting to edit those, that belongs in a separate PR.
---
*User-facing docs: [`../DESKTOP.md`](../DESKTOP.md) · Project architecture:
[`../ARCHITECTURE.md`](../ARCHITECTURE.md) · Setup: [`../SETUP.md`](../SETUP.md)*