Skip to content

Internals

The macOS app

src/mac/ is a SwiftPM package with no dependencies and no Xcode project. It builds Browsentic.app, a SwiftUI control panel that installs the Node half of Browsentic and then drives it. It holds no intelligence and reimplements nothing: the daemon stays the daemon.

3 min read Edit this page on GitHub

Why the daemon stays

The daemon is where the extension socket, the handshake, the MCP server, the guardrails and the agent runners live, all of it in TypeScript shared with the extension through src/lib/. A native rewrite would fork the action registry and the wire protocol for no gain — the daemon idles at a few megabytes and the expensive thing it does is spawn an agent CLI. What is native is everything the app does continuously: it speaks the daemon’s /control WebSocket itself, so a status refresh is one frame on an open socket rather than a node process every two seconds.

Two ways in, by cost

Path Used for Where
The control socketws://127.0.0.1:<port>/control, bearer token from daemon.json status, sessions, pair, revoke, agent set and grant ControlClient.swift, the same frames as remote-bridge.ts
The installed commandnode ~/.browsentic/cli/dist/cli.js … start, stop, restart, setup, uninstall, and the --json listings for agent, skills, approvals, downloads CLI.swift

Anything that reads or writes disk goes through the command, so config overrides (downloadDir, skillsDir, extensionDir) are resolved in exactly one place. The open control socket is also what keeps the daemon from idling out while the app is up.

A ControlRequest change in control.ts needs the matching change in ControlClient.swift and Models.swift; nothing checks that for you.

The payload

build-app.sh stages Contents/Resources/payload/ in the npm package’s layout — dist/cli.js, dist/daemon-main.js, skills/, extension/chrome-mv3/, package.json — because cli.js resolves ../skills and ../extension from where it sits. Like stage-extension.mjs it validates and never builds: a manifest version that disagrees with src/daemon/package.json is fatal.

Payload.swift copies that to ~/.browsentic/cli with a directory swap and writes .browsentic-app.json beside it. installKind() in npx.ts reads that marker and answers app, which makes upgradeCli() step aside: an app install is only ever replaced by a newer app. The extension is still installed by browsentic setup, file by file, for the reasons install.ts gives — and to the same path, for the reasons paths.ts gives.

PATH

A GUI app inherits launchd’s PATH, which has neither Homebrew nor ~/.local/bin. The daemon spawns the agent by name and inherits its environment from whoever started it, so Shell.swift asks the user’s login shell for its PATH once and starts every child with that. Skip this and a daemon started from the app reports every agent as AGENT_MISSING.

Build and test

cd src/mac
swift build && swift test            # swift-testing, no XCTest
ARCHS=arm64 Scripts/build-app.sh     # quick local bundle; the default is a universal binary

build-app.sh signs with the hardened runtime when CODESIGN_IDENTITY names a Developer ID, and make-dmg.sh notarizes and staples when APPLE_ID, APPLE_TEAM_ID and APPLE_APP_PASSWORD are set too. With none of them the build is ad hoc, which runs where it was built and is blocked by Gatekeeper anywhere it was downloaded to.

That is why the install path people are pointed at is curl -fsSL https://browsentic.com/install.sh | sh: curl sets no quarantine flag, so Gatekeeper never assesses the app. The script lives on the website branch as public/install.sh, resolves the newest release from the releases/latest redirect, verifies the signature, and copies the app into Applications. It depends on the release asset being named Browsentic-<version>.dmg.

CI builds and tests it on macos-15; the release workflow’s mac job attaches the DMG to the GitHub release after the npm publish. Colours in Theme.swift are Ember and Daylight from globals.css, converted from oklch — re-derive them when the extension’s palette changes.