blob: 7a425355b725b62427aa4f08d3d30c90c7a81652 [file] [view]
# @maka/desktop
The Electron desktop app: `main` (Node/Electron main process) + `preload` (context bridge) + `renderer` (React UI). This file covers the three-layer split and the IPC contract. For build/test commands and the test-layer selection guide, see the top-level `README.md`; for the renderer interior, see `src/renderer/README.md`.
## macOS development permissions
`npm run dev` and `npm start` use the plain Electron executable on every
platform. Working on Accessibility or Screen Recording is the exception: macOS
TCC will not keep a grant for an unsigned executable launched from a terminal.
Set `MAKA_DEV_TCC=1` to launch through a generated, ad-hoc-signed
`apps/desktop/.maka-dev/Maka Dev.app` instead.
```sh
MAKA_DEV_TCC=1 npm run dev
```
The bundle gives TCC a stable identity (`com.maka.dev.<worktree-id>`), a
verifiable signature, and a responsible process that is the app, not the terminal.
It is launched through LaunchServices for the same reason running its inner
binary directly puts the terminal back in the responsibility chain. The
generated app is ignored by Git and rebuilt when the installed Electron version
or this repository's path changes; run
`npm --workspace @maka/desktop run prepare:dev-app` to prepare it explicitly.
This workflow is opt-in because it costs a codesign rebuild and puts an extra
app in your Dock and in System Settings. Developers who are not touching OS
permissions should not pay for it. Main-process logs are still streamed to the
terminal — `open` redirects them to `.maka-dev/app.log`, which the launcher
follows.
Everything the bundle needs is fixed when it is built, so a launch with no
arguments and no environment — the Dock, Spotlight, or Screen Recording's
Quit & Reopen produces a correct app. There is no session protocol or
supervising process: the app instance and the dev session are separate
lifecycles. Application-control variables (API keys, `MAKA_*`, the Vite URL)
are published to an ignored `0600` file at `.maka-dev/dev-env.json` rather than
a command line. That file, not the shell, is what makes a Dock or Quit &
Reopen launch work, since those have no parent shell at all. `PATH` is not
recorded in it: a stored `PATH` goes stale, and `shell-env.ts` resolves the
login-shell `PATH` in the main process for exactly this case.
The bundle identifier is scoped to the worktree because TCC keys its rows on
that identifier: a shared one would make each worktree overwrite the previous
one's stored requirement and silently break it. Scoping it gives every worktree
its own durable, independently revocable grant.
The ad-hoc signature keeps its default designated requirement, a bare `cdhash`.
That means a rebuild costs a re-grant — but a rebuild happens only on an
Electron bump or a repository move, not on an ordinary `npm run dev`. Pinning
the identifier instead would survive rebuilds, and it is tempting for exactly
that reason, but `codesign --sign -` is available to every unprivileged process:
any binary anywhere on the disk could claim the identifier and satisfy the
requirement. Since TCC rows outlive the code they were granted to, that would
leave a permanently redeemable Accessibility and Screen Recording token behind
even after this repository is deleted. Re-granting after an Electron bump is the
cheaper side of that trade.
Note that the payload at `dist/main/main.js` is loaded from outside the
signature seal. Write access to this repository is therefore a deliberate trust
assumption of the development workflow — a separate matter from who may claim
the bundle's identity.
The default profile is `~/Library/Application Support/Maka Dev-<worktree-id>`,
which keeps development isolated from the packaged Maka profile; an explicit
`--user-data-dir` takes precedence. Shutdown matches this worktree's own bundle
path, so a concurrent worktree's app is unaffected. Because that lock is keyed
on the profile, a launch first reclaims any app left over from a hard-killed
session otherwise the stale app would absorb the new launch and keep showing
its old, dead Vite URL.
Known limitation: `dev-env.json` outlives the session, so launching from the
Dock long after `npm run dev` has stopped points the app at a Vite URL that is
no longer served, and the window stays blank. Start a dev session first. If
another worktree has since taken that port, the window loads *its* renderer
instead, which looks like it worked.
Grant permissions to **Maka Dev**, not a generic Electron entry. Screen
Recording changes require restarting the development app. Without
`MAKA_DEV_TCC`, the permission overlay still runs, but its drag target is the
npm Electron bundle, which macOS will not accept as a durable grant.
## Three layers
| Layer | Path | Role |
|---|---|---|
| main | `src/main/` | Node/Electron main process. Owns window lifecycle, credentials, attachments, permissions, IPC handlers, and the bridge to `@maka/runtime` + `@maka/storage`. |
| preload | `src/preload/preload.ts` (single file) | `contextBridge.exposeInMainWorld('maka', …)` the only surface the renderer may call to reach Node/Electron. No Node API is directly exposed. |
| renderer | `src/renderer/` | React UI body. See `src/renderer/README.md`. |
## main process layout
`src/main/` is flat with a naming convention:
| Suffix | Role | Examples |
|---|---|---|
| `*-ipc-main.ts` | Exports a `register*Ipc(...)` that wires `ipcMain.handle` / `ipcMain.on` for one IPC domain | `connections-ipc-main`, `daily-review-ipc-main`, `memory-ipc-main`, `web-search-ipc-main`, `workspace-resources-ipc-main` |
| `*-main.ts` / `*-service.ts` | A service owned by main (no `ipcMain` calls of its own) | `daily-review-main`, `system-prompt-main`, `oauth-model-connections-main`, `local-memory-service` |
| `*-guard.ts` | Validation / security boundary | `external-link-guard`, `open-path-guard`, `permission-response-guard` |
| (other) | Window, state, platform wiring | `main.ts` (entry), `main-window`, `window-state`, `theme-source`, `credential-store`, `skills`, `attachment-*` |
Sub-folders: `browser/` (embedded browser view), `oauth/`, `search/` (thread search), `web-search/`, `types/`. The browser IPC handler itself (`browser-ipc-main.ts`) is flat in `src/main/`, not under `browser/`.
`main.ts` startup order: stores and the runtime/controller are created synchronously at module load; `registerIpc()` runs at top level, **before** `app.whenReady()`; inside `whenReady`, the main window is created **hidden** early and background startup (connection bootstrapping, telemetry, bots, schedulers) runs concurrently without blocking first paint. The window is created hidden and revealed after the renderer's first AppShell paint (the `window:notifyRendererReady` gate in `app.tsx`); a fallback timer reveals it if the renderer never signals, so a fail-soft loading state can show (e.g. if `main.tsx`'s onboarding prefetch times out). The real invariant for IPC: handlers must be registered before the renderer entry runs, because `main.tsx` prefetches the onboarding snapshot before mounting React. Background startup may mutate state after the renderer's first read, so don't assume it has already settled when wiring the UI.
## IPC contract
Three patterns, all rooted in preload's `maka` namespace. Channel names are `<domain>:<action>`.
- **Request/response** — `ipcRenderer.invoke('<domain>:<action>', …args)` in preload ↔ `ipcMain.handle('<domain>:<action>', …)`. The handler lives either inline in `main.ts` (e.g. `sessions:list`, `settings:get`) or in a `*-ipc-main.ts` extracted by domain (e.g. `connections-ipc-main`, `daily-review-ipc-main`). Both forms coexist; prefer extracting a new domain to its own `*-ipc-main.ts`.
- **Main→renderer push** — main sends through the safe-send guard (`safeSendToRenderer` via `mainWindowController.send`), not raw `webContents.send` (which throws when the window/`webContents` is destroyed); preload subscribes via `ipcRenderer.on` and returns an unsubscribe fn (e.g. `sessions:changed`, `plans:changed`, `artifacts:changed`). The safe-send contract test scans a fixed list of main-source files for direct `mainWindow.webContents.send(...)` forms — new `*-ipc-main.ts` files aren't auto-covered, so route sends through the guard in every new file (an alias for `mainWindow` can bypass the literal scan).
- **Renderermain fire-and-forget** `ipcRenderer.send('<domain>:<action>', …)` in preload `ipcMain.on('<domain>:<action>', …)`. Used when no response is needed (e.g. `browser:active-session`, `browser:setViewport`).
Adding a new IPC surface: if extracting, write the `*-ipc-main.ts` exporting a `register*Ipc(...)`, import it in `main.ts`, and call it inside `registerIpc()`; add the matching method to the `maka` namespace in `preload.ts`; add the method to the `window.maka` type in `src/global.d.ts` (the renderer's typed bridge — without it, renderer calls get a TS error); keep the `<domain>:<action>` channel naming. A handler file that isn't registered in `registerIpc()` compiles but never mounts.
## Data flow
```
renderer (React)
└─ window.maka.<ns>.<method>(…) // typed surface, see preload.ts
└─ ipcRenderer.invoke / send / on
└─ main: safeSendToRenderer / ipcMain.handle / ipcMain.on
└─ @maka/runtime (agent runtime) + @maka/storage (JSONL persistence)
```
The renderer never imports `@maka/runtime` or `@maka/storage` at runtime all Node-side access goes through the preload `maka` bridge. The renderer only pulls `import type` from them for a few shared types. Types shared across the IPC boundary mostly come from `@maka/core`, with some from `@maka/runtime`, `@maka/storage`, and `@maka/ui` (see `preload.ts` imports).
## Convergence note
The renderer side carries the frontend convergence debt (hand-rolled CSS, primitive overrides); see `src/renderer/README.md`. The main process itself is not part of that convergence its boundaries (IPC channel names, the preload bridge, the `*-guard.ts` files) are stable contract seams.