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.
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.
MAKA_DEV_TCC=1 npm run devThe 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. Each TCC launch gets separate private log and result files: logs are observation only, while the one-shot result carries the single-instance verdict.
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 long-lived session
protocol or supervising process: the app instance and the dev session are
separate lifecycles. Ctrl-C stops the launcher and, for npm run dev, its Vite
server; it does not quit a TCC app launched through LaunchServices. Quit that
app with Cmd-Q. Likewise, quitting the TCC app does not stop the dev server.
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, which keeps
development isolated from the packaged Maka profile and is shared by the plain
dev build and the TCC dev build; an explicit --user-data-dir takes
precedence. (The README previously also claimed the repository CLI
(npm run cli:dev) shares it — unverified; the CLI entry does not go through
the desktop dev launcher, so this claim is dropped until verified.) Electron's
single-instance lock is the only authority for the shared development profile.
A second launch exits with an explicit conflict instead of scanning for or
terminating an existing Electron process. Plain dev owns its direct child;
the TCC launcher consumes only the one-shot lock verdict and never owns or
signals the detached app process.
Known limitation: Chromium may kill an unresponsive lock holder after its 20-second acknowledgement timeout and let the new instance take the lock. Sharing the profile widens the launch pairs that can reach this existing behavior; investigation and evidence live in #3539.
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.
| Layer | Path | Role |
|---|---|---|
| main | src/main/ |
Node/Electron client process. Owns windows, OS capabilities, client-local settings, IPC projection, and the Runtime Host connection. Runtime execution and canonical runtime policy belong to Runtime Host. |
| 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. |
src/main/ is flat with a naming convention:
| Suffix | Role | Examples |
|---|---|---|
runtime-host-*-ipc-main.ts |
Projects one Runtime Host protocol domain onto renderer IPC | runtime-host-connections-ipc-main, runtime-host-session-execution-ipc-main, runtime-host-settings-ipc-main |
*-ipc-main.ts |
Registers a client-local Electron or OS-facing IPC domain | browser-ipc-main, notifications-ipc-main, workspace-search-ipc-main |
*-service.ts / *-controller.ts |
A client-local service without direct IPC ownership | app-update-service, project-management-service, project-root-controller |
*-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 hold OS-facing implementations such as browser/, computer-use/, oauth/, and permission-overlay/. Runtime Host adapters stay flat and carry the runtime-host- prefix so ownership is visible at the import boundary.
main.ts performs only pre-ready Electron identity and single-instance work. After app.whenReady(), it dynamically imports runtime-host-boot.ts. Boot validates the storage root before any Runtime Host state can be written, registers persistent client-local IPC, connects or spawns Runtime Host, registers connection-scoped Host IPC, and only then creates the first renderer window. The window remains hidden until the renderer's first AppShell paint (window:notifyRendererReady), with a fallback reveal timer for fail-soft startup.
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>', …). Runtime domains are projected byruntime-host-*-ipc-main.ts; OS-facing client domains use a focused*-ipc-main.tsmodule. - Main→renderer push — main sends through the safe-send guard (
safeSendToRendererviamainWindowController.send), not rawwebContents.send(which throws when the window/webContentsis destroyed); preload subscribes viaipcRenderer.onand returns an unsubscribe fn (e.g.sessions:changed,scheduled-tasks:changed,artifacts:changed). The safe-send contract test scans a fixed list of main-source files for directmainWindow.webContents.send(...)forms — new*-ipc-main.tsfiles aren't auto-covered, so route sends through the guard in every new file (an alias formainWindowcan bypass the literal scan). - Renderer→main 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.
renderer (React)
└─ window.maka.<ns>.<method>(…) // typed surface, see preload.ts
└─ ipcRenderer.invoke / send / on
└─ main: safeSendToRenderer / ipcMain.handle / ipcMain.on
└─ Runtime Host protocol → @maka/runtime + @maka/storage
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).
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.