diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml
index 714d5be..9636731 100644
--- a/.github/workflows/finalize-release.yml
+++ b/.github/workflows/finalize-release.yml
@@ -60,7 +60,7 @@ jobs:
MARKER="$HOME/Applications/.Microbridge.app.microbridge-brew"
LEGACY_MARKER="$APP/.microbridge-brew"
test -x "$(brew --prefix DevVig/microbridge/microbridge)/bin/microbridged"
- brew services start DevVig/microbridge/microbridge
+ microbridge-app install
for _ in {1..30}; do
[[ -f "$MARKER" ]] && break
sleep 1
@@ -72,6 +72,33 @@ jobs:
spctl --assess --type execute --verbose=4 "$APP"
xcrun stapler validate "$APP"
syspolicy_check distribution "$APP"
+ APP_EXECUTABLE="$APP/Contents/MacOS/microbridge-ui"
+ APP_LOG="$RUNNER_TEMP/microbridge-app.log"
+ APP_PID=""
+ for _ in {1..30}; do
+ APP_PID="$(pgrep -f "^${APP_EXECUTABLE}$" | head -n1 || true)"
+ [[ -n "$APP_PID" ]] && break
+ sleep 1
+ done
+ if [[ -z "$APP_PID" ]] || ! kill -0 "$APP_PID"; then
+ cat "$APP_LOG"
+ exit 1
+ fi
+ microbridgectl status >"$APP_LOG"
+ kill "$APP_PID" || true
+ wait "$APP_PID" || true
+ APP_DAEMON_STOPPED=0
+ for _ in {1..30}; do
+ if ! microbridgectl status >/dev/null 2>&1; then
+ APP_DAEMON_STOPPED=1
+ break
+ fi
+ sleep 1
+ done
+ test "$APP_DAEMON_STOPPED" -eq 1
+ # Verify the separately opted-in headless service after the normal
+ # app-owned lifecycle.
+ brew services start DevVig/microbridge/microbridge
SERVICE_STATE=""
for _ in {1..30}; do
SERVICE_STATE="$(brew services list --json | jq -r 'map(select(.name=="microbridge")) | .[0].status // empty')"
@@ -83,25 +110,13 @@ jobs:
tail -200 "$(brew --prefix)/var/log/microbridge.log" || true
exit 1
fi
- APP_LOG="$RUNNER_TEMP/microbridge-app.log"
- "$APP/Contents/MacOS/microbridge-ui" >"$APP_LOG" 2>&1 &
- APP_PID=$!
- sleep 3
- if ! kill -0 "$APP_PID"; then
- cat "$APP_LOG"
- exit 1
- fi
- kill "$APP_PID" || true
- wait "$APP_PID" || true
brew services stop DevVig/microbridge/microbridge
+ microbridge-app uninstall
HOMEBREW_NO_INSTALL_CLEANUP=1 brew uninstall DevVig/microbridge/microbridge
if brew services list --json | jq -e '.[] | select(.name=="microbridge")' >/dev/null; then
echo "microbridge service is still registered after uninstall" >&2
exit 1
fi
- test -f "$MARKER"
- rm -rf "$APP"
- rm -f "$MARKER"
test ! -e "$APP"
test ! -e "$MARKER"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 166b8fb..b46a1e0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -364,11 +364,12 @@ jobs:
```sh
brew tap DevVig/microbridge https://github.com/DevVig/microbridge
brew install microbridge
- brew services start microbridge
- open ~/Applications/Microbridge.app
+ microbridge-app install
```
- Upgrade later: `brew update && brew upgrade microbridge`
+ Upgrade later: `brew update && brew upgrade microbridge && microbridge-app install`
+
+ Headless-only daemon service: `brew services start microbridge`
### Signed + notarized DMG
diff --git a/Cargo.lock b/Cargo.lock
index ec8ed3c..a14ab19 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -616,7 +616,7 @@ dependencies = [
[[package]]
name = "mb-adapters"
-version = "0.3.7"
+version = "0.3.8"
dependencies = [
"mb-protocol",
"notify",
@@ -628,7 +628,7 @@ dependencies = [
[[package]]
name = "mb-device"
-version = "0.3.7"
+version = "0.3.8"
dependencies = [
"hidapi",
"mb-protocol",
@@ -639,7 +639,7 @@ dependencies = [
[[package]]
name = "mb-protocol"
-version = "0.3.7"
+version = "0.3.8"
dependencies = [
"serde",
"serde_json",
@@ -653,7 +653,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "microbridgectl"
-version = "0.3.7"
+version = "0.3.8"
dependencies = [
"mb-device",
"mb-protocol",
@@ -663,7 +663,7 @@ dependencies = [
[[package]]
name = "microbridged"
-version = "0.3.7"
+version = "0.3.8"
dependencies = [
"keyring",
"mb-adapters",
diff --git a/Cargo.toml b/Cargo.toml
index f6de07d..107e763 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,7 +9,7 @@ members = [
]
[workspace.package]
-version = "0.3.7"
+version = "0.3.8"
edition = "2021"
license = "MIT"
repository = "https://github.com/DevVig/microbridge"
diff --git a/Formula/microbridge.rb b/Formula/microbridge.rb
index f1c4744..86951af 100644
--- a/Formula/microbridge.rb
+++ b/Formula/microbridge.rb
@@ -7,8 +7,7 @@
#
# brew tap DevVig/microbridge https://github.com/DevVig/microbridge
# brew install microbridge
-# brew services start microbridge
-# open ~/Applications/Microbridge.app
+# microbridge-app install
#
# Upgrade:
# brew update && brew upgrade microbridge
@@ -58,11 +57,11 @@ def install
# INSTALL.md ships inside the daemon archive when present.
doc.install "INSTALL.md" if File.exist?("INSTALL.md")
- # Homebrew sandboxes formula post_install and forbids writes to $HOME.
- # The launch-agent runs in the user's session, so this wrapper performs the
- # marker-guarded app copy immediately before starting the daemon.
- service_script = libexec/"microbridge-service"
- service_script.write <<~SH
+ # Homebrew sandboxes formula installation from $HOME. This explicit helper
+ # performs the marker-guarded GUI install without registering a daemon
+ # service; `brew services` remains available for deliberate headless use.
+ app_installer = bin/"microbridge-app"
+ app_installer.write <<~SH
#!/bin/sh
set -eu
source_app="#{opt_prefix}/Microbridge.app"
@@ -70,25 +69,60 @@ def install
dest="${apps_dir}/Microbridge.app"
marker="${apps_dir}/.Microbridge.app.microbridge-brew"
legacy_marker="${dest}/.microbridge-brew"
+ stop_managed_app() {
+ executable="${dest}/Contents/MacOS/microbridge-ui"
+ /usr/bin/pgrep -f "^${executable}$" 2>/dev/null | while read -r pid; do
+ /bin/kill "${pid}" 2>/dev/null || true
+ done
+ for _ in 1 2 3 4 5 6 7 8 9 10; do
+ /usr/bin/pgrep -f "^${executable}$" >/dev/null 2>&1 || return 0
+ /bin/sleep 0.1
+ done
+ }
+ action="${1:-install}"
+ if [ "${action}" = "uninstall" ]; then
+ if [ -f "${marker}" ] || [ -f "${legacy_marker}" ]; then
+ if [ -x "${dest}/Contents/MacOS/microbridge-ui" ]; then
+ "${dest}/Contents/MacOS/microbridge-ui" --unregister-login-item || true
+ fi
+ stop_managed_app
+ /bin/rm -rf "${dest}"
+ /bin/rm -f "${marker}"
+ else
+ echo "Microbridge: preserving unowned ${dest}" >&2
+ fi
+ exit 0
+ fi
+ if [ "${action}" != "install" ]; then
+ echo "usage: microbridge-app [install|uninstall]" >&2
+ exit 2
+ fi
/bin/mkdir -p "${apps_dir}"
if [ -e "${dest}" ] && [ ! -f "${marker}" ] && [ ! -f "${legacy_marker}" ]; then
echo "Microbridge: preserving unowned ${dest}" >&2
+ exit 1
else
+ staging="${apps_dir}/.Microbridge.app.installing.$$"
+ trap '/bin/rm -rf "${staging}"' EXIT
+ /bin/rm -rf "${staging}"
+ /usr/bin/ditto "${source_app}" "${staging}"
+ /usr/bin/codesign --verify --deep --strict "${staging}"
if [ -e "${dest}" ]; then
+ stop_managed_app
/bin/rm -rf "${dest}"
fi
- /usr/bin/ditto "${source_app}" "${dest}"
+ /bin/mv "${staging}" "${dest}"
# Keep ownership state beside the signed bundle. Adding any file to
# Microbridge.app invalidates its sealed code signature.
/usr/bin/touch "${marker}"
+ /usr/bin/open "${dest}"
fi
- exec "#{opt_bin}/microbridged"
SH
- service_script.chmod 0755
+ app_installer.chmod 0755
end
service do
- run [opt_libexec/"microbridge-service"]
+ run [opt_bin/"microbridged"]
keep_alive true
log_path var/"log/microbridge.log"
error_log_path var/"log/microbridge.log"
@@ -100,24 +134,28 @@ def caveats
Microbridge is the menu bar app + a local daemon (not CLI-only).
App: ~/Applications/Microbridge.app
- Daemon: brew services start microbridge
+ Daemon: app-owned (standard) or brew services (headless)
Status: microbridgectl status
Config: ~/.microbridge/
- Start the service once to install the marker-owned app, then open it. The
- app will offer to start itself at login (change it in Settings > General):
+ Install or refresh the marker-owned app, then let it own the bundled daemon:
+ microbridge-app install
+
+ The app will offer to start itself at login (change it in Settings > General).
+
+ Optional headless daemon service (this creates a separate background item):
brew services start microbridge
- open ~/Applications/Microbridge.app
Hardware LEDs/keys need a connected Codex Micro and explicit consent in
Microbridge Settings → Device → Enable hardware control.
- Upgrade: brew update && brew upgrade microbridge
+ Upgrade: brew update && brew upgrade microbridge && microbridge-app install
EOS
end
test do
assert_match "Usage", shell_output("#{bin}/microbridgectl help")
assert_path_exists prefix/"Microbridge.app"
+ assert_path_exists bin/"microbridge-app"
end
end
diff --git a/INSTALL.md b/INSTALL.md
index 0c7cd0f..f4121cc 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -7,14 +7,13 @@ runs on your machine.
## Recommended on macOS: Homebrew (with updates)
This is the easy path. You do **not** need to clone the repo. Homebrew installs
-the **menu bar app** (primary UI) and the daemon, then owns upgrades and the
-daemon service. This is not a CLI-only product.
+the **menu bar app** (primary UI), its bundled daemon, and the CLI. The explicit
+app helper preserves the signed bundle and avoids a separate background item.
```sh
brew tap DevVig/microbridge https://github.com/DevVig/microbridge
brew install microbridge
-brew services start microbridge
-open ~/Applications/Microbridge.app
+microbridge-app install
microbridgectl status
```
@@ -22,7 +21,7 @@ microbridgectl status
```sh
brew update && brew upgrade microbridge
-brew services restart microbridge
+microbridge-app install
```
Optional **background** upgrades (Homebrew’s autoupdate):
@@ -39,11 +38,16 @@ tarball.
Uninstall:
```sh
-brew services stop microbridge
+microbridge-app uninstall
brew uninstall microbridge
# optional: brew untap DevVig/microbridge
```
+Advanced headless mode: `brew services start microbridge` runs the standalone
+daemon without the menu-bar app and intentionally creates a separate background
+item. Stop it with `brew services stop microbridge` before returning to the
+standard app-owned lifecycle.
+
Governance / why this path: [docs/governance.md](docs/governance.md).
---
@@ -54,14 +58,14 @@ Governance / why this path: [docs/governance.md](docs/governance.md).
|---|---|
| macOS (Homebrew) | Homebrew + **Xcode Command Line Tools** (`xcode-select --install`); Rust + Node pulled in as **build** deps (builds `.app` + daemon) |
| From source | Rust stable, Node ≥ 20; macOS also needs Xcode CLT for the `.app` |
-| Hardware LEDs/keys | Codex Micro over USB; enable **Settings → Device → Hardware control** (`MICROBRIDGE_HID_CLAIM=1` remains a developer override) |
+| Hardware LEDs/keys | Codex Micro over USB; claim it from the popover, the menu-bar icon’s right-click menu, or **Settings → Device** (`MICROBRIDGE_HID_CLAIM=1` remains a developer override) |
## From source (developers)
```sh
git clone https://github.com/DevVig/microbridge.git
cd microbridge
-./scripts/install.sh # macOS: daemon + menu bar app + launchd
+./scripts/install.sh # macOS: menu bar app + app-owned daemon
# ./scripts/install.sh --no-ui # daemon/CLI only (headless)
# ./scripts/install-linux-systemd.sh
```
@@ -105,9 +109,10 @@ app-originated network call. The daemon also contacts a T3 Code environment
only after you explicitly enable that integration and exchange a one-time pairing
link; Microbridge has no telemetry or cloud relay.
-Homebrew installs are managed by brew instead: the app detects the brew
-marker and points you at `brew upgrade microbridge` rather than self-replacing,
-so the formula version and the on-disk app never drift apart.
+Homebrew installs are managed by brew instead: the app detects the brew marker
+and points you at `brew update && brew upgrade microbridge && microbridge-app
+install` rather than self-replacing, so the formula version and the stable app
+copy never drift apart.
### Cursor integration
@@ -166,35 +171,36 @@ Tauri build). The formula checksums are refreshed by CI after each `v*` tag.
| `~/.local/bin/microbridged` | Daemon (source / release install) |
| `~/.microbridge/microbridged.sock` | Local NDJSON socket |
| `~/.microbridge/config.toml` | Key source, lighting, appearance |
-| `~/.microbridge/daemon.log` | launchd / service logs |
+| `~/.microbridge/microbridged-app.log` | Standard app-owned daemon log |
+| `~/.microbridge/daemon.log` | Headless launchd / service log |
| `~/.cursor/plugins/local/microbridge` | Bundled Cursor lifecycle integration (only after consent) |
| `~/.factory/hooks.json` | Existing Factory hooks plus Microbridge-owned lifecycle entries (only after consent) |
| `~/.microbridge/integrations/factory/microbridgectl` | Signed Factory hook helper (only after consent) |
| `~/.config/opencode/plugins/microbridge.mjs` | Bundled OpenCode lifecycle and interrupt integration (only after consent) |
-| `~/Library/LaunchAgents/ai.microbridge.ui.plist` | Login item (only if you enable launch at login) |
+| macOS Login Items | Branded Microbridge main-app registration (only if enabled in Settings → General) |
## Launch at login
The menu bar app asks once, on first launch, whether to start automatically at
-login, and writes the `ai.microbridge.ui` LaunchAgent if you say yes. Toggle it
-any time in **Settings → General**; it takes effect at your next login. This is
-handled by the app rather than the installer, so Homebrew, DMG, and source
-installs all behave the same way.
+login, and registers the signed main app with macOS ServiceManagement if you say
+yes. Toggle it any time in **Settings → General**; if macOS requires approval,
+the same surface opens Login Items directly. The standard GUI path shows the
+Microbridge name and icon rather than a Unix executable.
## Troubleshooting
-**`microbridgectl: connect …`** — daemon not running. Direct installs start the
-bundled daemon with the app; relaunch Microbridge first. For Homebrew installs:
+**`microbridgectl: connect …`** — daemon not running. Standard GUI installs
+start the bundled daemon with the app; relaunch Microbridge first. For explicit
+headless operation:
```sh
brew services restart microbridge
-# or:
-launchctl kickstart -k "gui/$(id -u)/ai.microbridge.daemon"
```
-**LEDs stay dark** — by default Microbridge only probes USB (Detected). Enable
-**Settings → Device → Hardware control**. If the interface is busy, pause the
-other device owner and try again. Developers can still set
+**LEDs stay dark** — by default Microbridge only probes USB (Detected). Choose
+**Claim Codex Micro** in the popover or right-click menu. If the interface is
+busy, pause the other device owner and choose **Retry**. The advanced control
+also remains in **Settings → Device**. Developers can still set
`MICROBRIDGE_HID_CLAIM=1` before starting the daemon. See
[docs/device-hid.md](docs/device-hid.md).
diff --git a/README.md b/README.md
index d5ac773..b4f7b95 100644
--- a/README.md
+++ b/README.md
@@ -115,16 +115,20 @@ separate daemon or Marketplace plugin download is required.
```sh
brew tap DevVig/microbridge https://github.com/DevVig/microbridge
brew install microbridge
-brew services start microbridge
-open ~/Applications/Microbridge.app
-# updates: brew update && brew upgrade microbridge
+microbridge-app install
+# updates: brew update && brew upgrade microbridge && microbridge-app install
# optional background updates: brew autoupdate start --upgrade --cleanup
```
+The menu-bar app owns its bundled daemon. `brew services start microbridge`
+remains available as an explicit headless mode and creates a separate background
+item.
+
From source / Linux:
```sh
-./scripts/install.sh # macOS launchd
+./scripts/install.sh # macOS app + app-owned daemon
+./scripts/install.sh --no-ui # macOS headless launchd service
./scripts/install-linux-systemd.sh # Linux systemd --user
```
diff --git a/adapters/claude/hooks/microbridge-permission.mjs b/adapters/claude/hooks/microbridge-permission.mjs
index 73df43f..4e5e1b1 100644
--- a/adapters/claude/hooks/microbridge-permission.mjs
+++ b/adapters/claude/hooks/microbridge-permission.mjs
@@ -61,7 +61,7 @@ function ingestLifecycle(id, state) {
adapter: "claude-hook",
protocol_version: 0,
role: "ui",
- adapter_version: "0.3.7",
+ adapter_version: "0.3.8",
capabilities: {},
},
{
diff --git a/adapters/sdk/package.json b/adapters/sdk/package.json
index aec6bc8..361f247 100644
--- a/adapters/sdk/package.json
+++ b/adapters/sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@microbridge/adapter-sdk",
- "version": "0.3.7",
+ "version": "0.3.8",
"description": "Zero-dependency SDK for publishing AI agent session states to Microbridge",
"main": "index.mjs",
"type": "module",
diff --git a/apps/microbridge-ui/README.md b/apps/microbridge-ui/README.md
index 7f4cd53..687238c 100644
--- a/apps/microbridge-ui/README.md
+++ b/apps/microbridge-ui/README.md
@@ -5,8 +5,8 @@ actions (approve / reject / interrupt) stay on the physical Codex Micro.
| Surface | Behavior |
|---|---|
-| Menu bar icon | Template tray icon; click toggles the popover |
-| Popover | Connection, focus card, device echo, threads, Settings / Pause LEDs / Quit |
+| Menu bar icon | Left-click toggles the popover; right-click exposes claim/release, updates, Settings, and Quit |
+| Popover | Contextual Codex Micro claim/release/retry, focus card, device echo, threads, and utilities |
| Settings | Keys (device twin) · Agent Keys · Integrations · Device · Updates |
| Focus HUD | ~2.5s toast when deck focus changes |
@@ -32,5 +32,8 @@ npm run build # frontend only (CI)
npm run tauri build # Microbridge.app
```
+Installed macOS builds register the signed main app through `SMAppService` for
+Launch at Login. Development binaries under `target/debug` never register.
+
The UI connects as `role:ui` on the Microbridge Unix socket, keeps a live
subscribe, and never opens HID.
diff --git a/apps/microbridge-ui/package-lock.json b/apps/microbridge-ui/package-lock.json
index 218f9eb..335a0cc 100644
--- a/apps/microbridge-ui/package-lock.json
+++ b/apps/microbridge-ui/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "microbridge-ui",
- "version": "0.3.7",
+ "version": "0.3.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "microbridge-ui",
- "version": "0.3.7",
+ "version": "0.3.8",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
diff --git a/apps/microbridge-ui/package.json b/apps/microbridge-ui/package.json
index 5e74d9b..7b35f2d 100644
--- a/apps/microbridge-ui/package.json
+++ b/apps/microbridge-ui/package.json
@@ -1,7 +1,7 @@
{
"name": "microbridge-ui",
"private": true,
- "version": "0.3.7",
+ "version": "0.3.8",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/apps/microbridge-ui/src-tauri/Cargo.lock b/apps/microbridge-ui/src-tauri/Cargo.lock
index 21c414e..be27815 100644
--- a/apps/microbridge-ui/src-tauri/Cargo.lock
+++ b/apps/microbridge-ui/src-tauri/Cargo.lock
@@ -85,17 +85,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
-[[package]]
-name = "auto-launch"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
-dependencies = [
- "dirs 4.0.0",
- "thiserror 1.0.69",
- "winreg 0.10.1",
-]
-
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -579,33 +568,13 @@ dependencies = [
"crypto-common",
]
-[[package]]
-name = "dirs"
-version = "4.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
-dependencies = [
- "dirs-sys 0.3.7",
-]
-
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
- "dirs-sys 0.5.0",
-]
-
-[[package]]
-name = "dirs-sys"
-version = "0.3.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
-dependencies = [
- "libc",
- "redox_users 0.4.6",
- "winapi",
+ "dirs-sys",
]
[[package]]
@@ -616,7 +585,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
- "redox_users 0.5.2",
+ "redox_users",
"windows-sys 0.61.2",
]
@@ -743,7 +712,7 @@ dependencies = [
"rustc_version",
"toml 1.1.3+spec-1.1.0",
"vswhom",
- "winreg 0.55.0",
+ "winreg",
]
[[package]]
@@ -1831,7 +1800,7 @@ dependencies = [
[[package]]
name = "mb-protocol"
-version = "0.3.7"
+version = "0.3.8"
dependencies = [
"serde",
]
@@ -1853,14 +1822,15 @@ dependencies = [
[[package]]
name = "microbridge-ui"
-version = "0.3.7"
+version = "0.3.8"
dependencies = [
"mb-protocol",
+ "objc2-service-management",
+ "plist",
"serde",
"serde_json",
"tauri",
"tauri-build",
- "tauri-plugin-autostart",
"tauri-plugin-dialog",
"tauri-plugin-process",
"tauri-plugin-shell",
@@ -2152,6 +2122,30 @@ dependencies = [
"objc2-foundation",
]
+[[package]]
+name = "objc2-security"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
+dependencies = [
+ "bitflags 2.13.1",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-service-management"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8"
+dependencies = [
+ "block2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-security",
+]
+
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
@@ -2536,17 +2530,6 @@ dependencies = [
"bitflags 2.13.1",
]
-[[package]]
-name = "redox_users"
-version = "0.4.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
-dependencies = [
- "getrandom 0.2.17",
- "libredox",
- "thiserror 1.0.69",
-]
-
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -3428,7 +3411,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
- "dirs 6.0.0",
+ "dirs",
"dunce",
"embed_plist",
"getrandom 0.3.4",
@@ -3478,7 +3461,7 @@ checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632"
dependencies = [
"anyhow",
"cargo_toml",
- "dirs 6.0.0",
+ "dirs",
"glob",
"heck 0.5.0",
"json-patch",
@@ -3548,20 +3531,6 @@ dependencies = [
"walkdir",
]
-[[package]]
-name = "tauri-plugin-autostart"
-version = "2.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70"
-dependencies = [
- "auto-launch",
- "serde",
- "serde_json",
- "tauri",
- "tauri-plugin",
- "thiserror 2.0.19",
-]
-
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.2"
@@ -3642,7 +3611,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
- "dirs 6.0.0",
+ "dirs",
"flate2",
"futures-util",
"http",
@@ -4125,7 +4094,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
dependencies = [
"crossbeam-channel",
- "dirs 6.0.0",
+ "dirs",
"libappindicator",
"muda",
"objc2",
@@ -4963,15 +4932,6 @@ dependencies = [
"memchr",
]
-[[package]]
-name = "winreg"
-version = "0.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
-dependencies = [
- "winapi",
-]
-
[[package]]
name = "winreg"
version = "0.55.0"
@@ -5004,7 +4964,7 @@ dependencies = [
"block2",
"cookie",
"crossbeam-channel",
- "dirs 6.0.0",
+ "dirs",
"dom_query",
"dpi",
"dunce",
diff --git a/apps/microbridge-ui/src-tauri/Cargo.toml b/apps/microbridge-ui/src-tauri/Cargo.toml
index 3b0031b..dd65dfc 100644
--- a/apps/microbridge-ui/src-tauri/Cargo.toml
+++ b/apps/microbridge-ui/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "microbridge-ui"
-version = "0.3.7"
+version = "0.3.8"
description = "Microbridge menu bar app (primary UI)"
authors = ["Microbridge contributors"]
edition = "2021"
@@ -20,8 +20,11 @@ tauri-plugin-process = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+plist = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync"] }
mb-protocol = { path = "../../../crates/mb-protocol" }
-tauri-plugin-autostart = "2.5.1"
+
+[target.'cfg(target_os = "macos")'.dependencies]
+objc2-service-management = "0.3.2"
[workspace]
diff --git a/apps/microbridge-ui/src-tauri/src/lib.rs b/apps/microbridge-ui/src-tauri/src/lib.rs
index cf4b1db..3dceb31 100644
--- a/apps/microbridge-ui/src-tauri/src/lib.rs
+++ b/apps/microbridge-ui/src-tauri/src/lib.rs
@@ -19,7 +19,6 @@ use tauri::{
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Emitter, LogicalSize, Manager, PhysicalPosition, Position, Size, WebviewWindow,
};
-use tauri_plugin_autostart::ManagerExt;
use tokio::sync::Mutex;
struct AppState {
@@ -42,6 +41,36 @@ fn daemon_is_reachable() -> bool {
StdUnixStream::connect(daemon_socket_path()).is_ok()
}
+fn bundled_daemon_path() -> Option {
+ std::env::current_exe()
+ .ok()?
+ .parent()
+ .map(|directory| directory.join("microbridged"))
+ .filter(|candidate| candidate.is_file())
+}
+
+fn spawn_owned_daemon(binary: &Path) -> Option {
+ let log_path = daemon_socket_path().with_file_name("microbridged-app.log");
+ if let Some(directory) = log_path.parent() {
+ let _ = fs::create_dir_all(directory);
+ }
+ let stdout = OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&log_path)
+ .ok()?;
+ let stderr = stdout.try_clone().ok()?;
+ Command::new(binary)
+ .arg("--exit-with-parent")
+ // The daemon watches this pipe only in app-owned mode. A crash closes
+ // the descriptor too, so it cannot become an unowned orphan.
+ .stdin(Stdio::piped())
+ .stdout(Stdio::from(stdout))
+ .stderr(Stdio::from(stderr))
+ .spawn()
+ .ok()
+}
+
/// Direct-download builds carry `microbridged` beside the UI executable. If a
/// Homebrew/launchd daemon is already reachable we leave it alone; otherwise
/// the app owns this child for its lifetime.
@@ -50,10 +79,8 @@ fn start_bundled_daemon() -> Option {
return None;
}
let mut candidates = Vec::new();
- if let Ok(executable) = std::env::current_exe() {
- if let Some(directory) = executable.parent() {
- candidates.push(directory.join("microbridged"));
- }
+ if let Some(bundled) = bundled_daemon_path() {
+ candidates.push(bundled);
}
candidates.extend([
PathBuf::from("/opt/homebrew/bin/microbridged"),
@@ -63,22 +90,288 @@ fn start_bundled_daemon() -> Option {
.into_iter()
.find(|candidate| candidate.is_file())?;
- let log_path = daemon_socket_path().with_file_name("microbridged-app.log");
- if let Some(directory) = log_path.parent() {
- let _ = fs::create_dir_all(directory);
+ spawn_owned_daemon(&binary)
+}
+
+const DAEMON_MIGRATION_MARKER: &str = "app-owned-daemon-v1";
+
+fn daemon_migration_marker_path() -> PathBuf {
+ let user_home = std::env::var_os("HOME").unwrap_or_else(|| ".".into());
+ PathBuf::from(user_home)
+ .join(".microbridge")
+ .join("migrations")
+ .join(DAEMON_MIGRATION_MARKER)
+}
+
+fn known_legacy_daemon_agent(path: &Path, contents: &str) -> Option<&'static str> {
+ let value = plist::Value::from_reader_xml(contents.as_bytes()).ok()?;
+ let dictionary = value.as_dictionary()?;
+ let label = dictionary.get("Label")?.as_string()?;
+ let executable = dictionary
+ .get("ProgramArguments")?
+ .as_array()?
+ .first()?
+ .as_string()?;
+ let executable = Path::new(executable);
+
+ match (path.file_name().and_then(|name| name.to_str()), label) {
+ (Some("ai.microbridge.daemon.plist"), "ai.microbridge.daemon")
+ if direct_daemon_executable(executable) =>
+ {
+ Some("ai.microbridge.daemon")
+ }
+ (Some("homebrew.mxcl.microbridge.plist"), "homebrew.mxcl.microbridge")
+ if homebrew_daemon_executable(executable) =>
+ {
+ Some("homebrew.mxcl.microbridge")
+ }
+ _ => None,
+ }
+}
+
+fn direct_daemon_executable(executable: &Path) -> bool {
+ let home_binary = std::env::var_os("HOME")
+ .map(PathBuf::from)
+ .map(|home| home.join(".local/bin/microbridged"));
+ home_binary.as_deref() == Some(executable)
+ || executable == Path::new("/usr/local/bin/microbridged")
+ || executable == Path::new("/opt/homebrew/bin/microbridged")
+}
+
+fn homebrew_daemon_executable(executable: &Path) -> bool {
+ [
+ "/opt/homebrew/opt/microbridge/bin/microbridged",
+ "/usr/local/opt/microbridge/bin/microbridged",
+ "/opt/homebrew/opt/microbridge/libexec/microbridge-service",
+ "/usr/local/opt/microbridge/libexec/microbridge-service",
+ ]
+ .into_iter()
+ .any(|known| executable == Path::new(known))
+}
+
+struct LegacyDaemonAgent {
+ path: PathBuf,
+ label: &'static str,
+ contents: String,
+}
+
+fn restore_legacy_agent_files(agents: &[LegacyDaemonAgent]) {
+ for agent in agents {
+ let _ = fs::write(&agent.path, &agent.contents);
+ }
+}
+
+fn finalize_legacy_daemon_migration(marker: &Path, agents: &[LegacyDaemonAgent]) -> Result<(), ()> {
+ if let Some(directory) = marker.parent() {
+ fs::create_dir_all(directory).map_err(|_| ())?;
+ }
+ fs::write(marker, b"Microbridge app owns the bundled daemon.\n").map_err(|_| ())?;
+ if agents
+ .iter()
+ .any(|agent| fs::remove_file(&agent.path).is_err())
+ {
+ let _ = fs::remove_file(marker);
+ restore_legacy_agent_files(agents);
+ return Err(());
+ }
+ Ok(())
+}
+
+fn legacy_daemon_agents() -> Vec {
+ let Some(home) = std::env::var_os("HOME") else {
+ return Vec::new();
+ };
+ let directory = PathBuf::from(home).join("Library/LaunchAgents");
+ [
+ "ai.microbridge.daemon.plist",
+ "homebrew.mxcl.microbridge.plist",
+ ]
+ .into_iter()
+ .filter_map(|name| {
+ let path = directory.join(name);
+ let contents = fs::read_to_string(&path).ok()?;
+ known_legacy_daemon_agent(&path, &contents).map(|label| LegacyDaemonAgent {
+ path,
+ label,
+ contents,
+ })
+ })
+ .collect()
+}
+
+#[cfg(target_os = "macos")]
+fn bootstrap_legacy_agent(path: &Path) {
+ let Some(domain) = current_user_launchd_domain() else {
+ return;
+ };
+ let _ = Command::new("/bin/launchctl")
+ .args(["bootstrap", &domain])
+ .arg(path)
+ .status();
+}
+
+/// One-time conversion of standard GUI installs from a separately registered
+/// daemon to the daemon bundled inside the app. A later explicit headless
+/// service start is respected because the completed marker suppresses this.
+#[cfg(target_os = "macos")]
+fn migrate_legacy_daemon_to_app() -> Result
)}
diff --git a/apps/microbridge-ui/src/surfaces/Settings.tsx b/apps/microbridge-ui/src/surfaces/Settings.tsx
index aed2189..2509046 100644
--- a/apps/microbridge-ui/src/surfaces/Settings.tsx
+++ b/apps/microbridge-ui/src/surfaces/Settings.tsx
@@ -22,9 +22,10 @@ import {
} from "../components/DeviceTwin";
import { forgetAdapter, pairAdapter, setAdapterEnabled } from "../lib/bus";
import {
- canLaunchAtLogin,
- launchAtLoginEnabled,
+ launchAtLoginStatus,
+ openLoginItemsSettings,
setLaunchAtLogin,
+ type LaunchAtLoginStatus,
} from "../lib/autostart";
import {
IntegrationCard,
@@ -144,10 +145,9 @@ export function Settings({
);
const pairingInputRef = useRef(null);
const integrationDetailRef = useRef(null);
- // null until the login item has been read, and permanently null where a login
- // item is meaningless: outside Tauri, or in a dev build whose executable path
- // points into `target/debug`.
- const [atLogin, setAtLogin] = useState(null);
+ // null only while the native ServiceManagement state is loading.
+ const [atLogin, setAtLogin] = useState(null);
+ const [atLoginMessage, setAtLoginMessage] = useState(null);
const selectIntegration = (adapterId: string) => {
setSelectedIntegration(adapterId);
@@ -162,9 +162,7 @@ export function Settings({
useEffect(() => {
void appVersion().then(setVersion);
void updateChannel().then(setChannel);
- void canLaunchAtLogin().then(async (supported) => {
- if (supported) setAtLogin(await launchAtLoginEnabled());
- });
+ void launchAtLoginStatus().then(setAtLogin);
}, []);
const runAdapterOperation = async (adapterId: string, work: () => Promise) => {
@@ -186,13 +184,17 @@ export function Settings({
// Write first, then adopt what the system actually reports — a failed write
// must not leave the checkbox claiming something that isn't true.
const toggleAtLogin = async (next: boolean) => {
- setAtLogin(next);
+ setAtLoginMessage(null);
try {
- await setLaunchAtLogin(next);
- } catch {
- /* fall through to the re-read below */
+ setAtLogin(await setLaunchAtLogin(next));
+ } catch (error) {
+ setAtLogin(await launchAtLoginStatus());
+ setAtLoginMessage(
+ error instanceof Error
+ ? error.message
+ : "macOS could not update the Login Item.",
+ );
}
- setAtLogin(await launchAtLoginEnabled());
};
const tabs: { id: Tab; label: string }[] = [
@@ -255,34 +257,59 @@ export function Settings({
your deck.
-
+ Open Login Items…
+
+ )}
+ {atLoginMessage && (
+
+ {atLoginMessage}
+
+ )}
+
)}
@@ -601,10 +628,13 @@ export function Settings({
className="mt-0.5"
/>
- Claim the Codex Micro for keys, dial, joystick, and lighting
+ Use Microbridge for Codex Micro hardware control
- Off by default to avoid competing with another device owner. Changes apply
- immediately.
+ {snapshot.device_connected
+ ? "Connected — keys, dial, joystick, and lighting are active."
+ : cfg.hardware_control_enabled && snapshot.device_name.includes("usb")
+ ? "Control was requested, but another app may own the HID interface. Retry from the popover or right-click menu, or toggle off and on here."
+ : "Off by default to avoid competing with another device owner. Changes apply immediately."}
diff --git a/apps/microbridge-ui/src/surfaces/surfaces.test.tsx b/apps/microbridge-ui/src/surfaces/surfaces.test.tsx
index 63db590..22cde69 100644
--- a/apps/microbridge-ui/src/surfaces/surfaces.test.tsx
+++ b/apps/microbridge-ui/src/surfaces/surfaces.test.tsx
@@ -226,6 +226,76 @@ describe("Settings", () => {
});
describe("Popover", () => {
+ it("shows contextual claim, release, and retry actions", () => {
+ const detected = snapshot();
+ detected.device_name = "codex-micro-usb";
+ const availableHtml = renderToStaticMarkup(
+ ,
+ );
+ expect(availableHtml).toContain("Codex Micro ready");
+ expect(availableHtml).toContain(">Claim<");
+
+ detected.config.hardware_control_enabled = true;
+ const retryHtml = renderToStaticMarkup(
+ ,
+ );
+ expect(retryHtml).toContain("Couldn’t claim Codex Micro");
+ expect(retryHtml).toContain(">Retry<");
+
+ detected.device_connected = true;
+ const connectedHtml = renderToStaticMarkup(
+ ,
+ );
+ expect(connectedHtml).toContain("Controlled by Microbridge");
+ expect(connectedHtml).toContain(">Release<");
+ });
+
+ it("keeps guidance but hides hardware actions when unavailable", () => {
+ for (const [deviceName, guidance] of [
+ ["not-connected", "Codex Micro not detected"],
+ ["mock", "Connect a Codex Micro"],
+ ["daemon-offline", "Microbridge daemon offline"],
+ ]) {
+ const unavailable = snapshot();
+ unavailable.device_name = deviceName;
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain(guidance);
+ expect(html).not.toContain(">Claim<");
+ expect(html).not.toContain(">Retry<");
+ expect(html).not.toContain(">Release<");
+ }
+ });
+
it("renders every thread and makes only the thread list scrollable", () => {
const sessions = Array.from({ length: 12 }, (_, index): SessionStatus => ({
id: `thread-${index}`,
@@ -240,6 +310,7 @@ describe("Popover", () => {
dark
onOpenSettings={noop}
onTogglePause={noop}
+ onHardwareControl={noop}
onQuit={noop}
/>,
);
@@ -261,6 +332,7 @@ describe("Popover", () => {
dark
onOpenSettings={noop}
onTogglePause={noop}
+ onHardwareControl={noop}
onQuit={noop}
/>,
);
@@ -298,6 +370,7 @@ describe("Popover", () => {
dark
onOpenSettings={noop}
onTogglePause={noop}
+ onHardwareControl={noop}
onQuit={noop}
onAgentKey={noop}
/>,
@@ -315,6 +388,7 @@ describe("Popover", () => {
dark
onOpenSettings={noop}
onTogglePause={noop}
+ onHardwareControl={noop}
onQuit={noop}
/>,
);
diff --git a/crates/microbridged/src/main.rs b/crates/microbridged/src/main.rs
index 4d69c16..e72b7ed 100644
--- a/crates/microbridged/src/main.rs
+++ b/crates/microbridged/src/main.rs
@@ -4,6 +4,7 @@
//! session owns the device, and renders Agent Key LEDs. Fully event-driven:
//! the daemon does no work between messages.
+use std::io::Read;
use std::sync::Arc;
use mb_adapters::{spawn_claude_adapter, spawn_codex_adapter, spawn_cursor_adapter, AdapterEvent};
@@ -23,6 +24,7 @@ use tracing::info;
#[tokio::main]
async fn main() -> std::io::Result<()> {
+ let exit_with_parent = std::env::args().any(|argument| argument == "--exit-with-parent");
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
@@ -131,5 +133,22 @@ async fn main() -> std::io::Result<()> {
}
});
- serve(shared).await
+ if exit_with_parent {
+ let (parent_exit_tx, parent_exit_rx) = tokio::sync::oneshot::channel();
+ std::thread::spawn(move || {
+ let mut stdin = std::io::stdin();
+ let mut buffer = [0_u8; 1];
+ while stdin.read(&mut buffer).is_ok_and(|read| read > 0) {}
+ let _ = parent_exit_tx.send(());
+ });
+ tokio::select! {
+ result = serve(shared) => result,
+ _ = parent_exit_rx => {
+ info!("parent app exited; stopping app-owned daemon");
+ Ok(())
+ }
+ }
+ } else {
+ serve(shared).await
+ }
}
diff --git a/crates/microbridged/src/state.rs b/crates/microbridged/src/state.rs
index 82e5da8..1243752 100644
--- a/crates/microbridged/src/state.rs
+++ b/crates/microbridged/src/state.rs
@@ -26,6 +26,11 @@ pub fn next_conn_id() -> u64 {
NEXT_CONN.fetch_add(1, Ordering::Relaxed)
}
+fn should_reopen_device(previous: &DaemonConfig, next: &DaemonConfig, connected: bool) -> bool {
+ next.hardware_control_enabled != previous.hardware_control_enabled
+ || (next.hardware_control_enabled && !connected)
+}
+
pub struct DaemonState {
pub registry: Registry,
pub config: DaemonConfig,
@@ -281,8 +286,11 @@ impl DaemonState {
}
pub fn set_config(&mut self, mut config: DaemonConfig) -> Result<(), String> {
- let hardware_control_changed =
- config.hardware_control_enabled != self.config.hardware_control_enabled;
+ // Re-sending enabled while disconnected is an explicit retry. The
+ // config bit records consent/intent; the descriptor is the only proof
+ // that the HID interface was actually claimed.
+ let reopen_device =
+ should_reopen_device(&self.config, &config, self.device.descriptor().connected);
// `frontmost_app` is watcher-owned runtime state — clients cannot set it.
let frontmost = self.config.frontmost_app.clone();
config.normalize();
@@ -291,7 +299,7 @@ impl DaemonState {
let prev_focus = self.registry.focused.clone();
self.config = config;
- if hardware_control_changed {
+ if reopen_device {
self.device = mb_device::open_default_device_with_claim(
self.config.hardware_control_enabled || hid_claim_env_enabled(),
);
@@ -1014,6 +1022,18 @@ mod tests {
DaemonState::new(Box::::default(), DaemonConfig::default())
}
+ #[test]
+ fn hardware_control_retries_when_requested_but_disconnected() {
+ let disabled = DaemonConfig::default();
+ let mut enabled = disabled.clone();
+ enabled.hardware_control_enabled = true;
+
+ assert!(should_reopen_device(&disabled, &enabled, false));
+ assert!(should_reopen_device(&enabled, &enabled, false));
+ assert!(!should_reopen_device(&enabled, &enabled, true));
+ assert!(should_reopen_device(&enabled, &disabled, true));
+ }
+
#[test]
fn hosted_terminal_replaces_and_then_restores_raw_journal() {
let mut state = state();
diff --git a/docs/architecture.md b/docs/architecture.md
index c483a62..b648931 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -4,7 +4,7 @@
| Component | Runs as | Language | Required? |
|---|---|---|---|
-| `microbridged` | resident daemon (launchd agent) | Rust | yes |
+| `microbridged` | app-owned child (standard GUI) or launchd service (explicit headless mode) | Rust | yes |
| First-party integrations (ChatGPT, Claude Desktop, Codex CLI, Claude Code, CNVS, Synara/Conductor attribution) | in-process modules of the daemon | Rust | bundled |
| Managed integrations (OpenCode, Cursor, Factory, T3 Code) | host plugins/hooks or socket clients | Rust/Node | opt-in |
| Community integrations | separate processes on the socket | any | optional |
@@ -15,6 +15,12 @@ adapters), the **focus policy** (which session owns the deck), and the
**device layer** (LED frames out, key events in). Integrations never touch the
device — see [protocol.md](protocol.md).
+On macOS, the signed menu-bar app is the only standard Login Item and starts
+the bundled daemon for its own lifetime. Legacy UI/daemon LaunchAgents migrate
+only after the native login item and bundled socket are proven working. Users
+who deliberately enable the Homebrew headless service may still have a separate
+daemon background item.
+
## Footprint budget
These are commitments, not aspirations. CI and release checklists hold the
diff --git a/docs/design/README.md b/docs/design/README.md
index a951aff..8222b52 100644
--- a/docs/design/README.md
+++ b/docs/design/README.md
@@ -114,6 +114,8 @@ The daily driver. **Read-mostly** — no agent actions, no theme toggle.
- Hero focus card: app, thread title, read-only state chip, elapsed time, reasoning pill (dial echo), press-behavior hint
- **Mini device echo**: a passive miniature of the real deck (dial, joystick, six lit Agent Keys, command row) — read-only, labeled as such
- Threads list: state dot + app + title + elapsed — no approve/reject, no click-to-focus
+- Contextual hardware card: Claim when detected, Release when connected, Retry
+ when another process owns the HID interface.
- Footer: Settings · Pause LEDs · Quit
When disconnected, the popover shows a connection-first empty state ("Connect
diff --git a/docs/device-hid.md b/docs/device-hid.md
index 0db1a3f..6f731f0 100644
--- a/docs/device-hid.md
+++ b/docs/device-hid.md
@@ -98,8 +98,10 @@ Effects: `off=0`, `solid=1`, `snake=2`, `rainbow=3`, `breath=4`,
## Claiming the device
-Default daemon behavior: **probe only** (Detected). Enable **Settings → Device
-→ Hardware control** to claim the interface and apply changes immediately.
+Default daemon behavior: **probe only** (Detected). Choose **Claim Codex Micro**
+in the home popover or the menu-bar icon’s right-click menu. The same advanced
+control remains under **Settings → Device**. A requested claim that remains
+Detected can be retried after closing the other HID owner.
For command-line diagnostics:
```bash
diff --git a/docs/governance.md b/docs/governance.md
index 4984410..397d4ca 100644
--- a/docs/governance.md
+++ b/docs/governance.md
@@ -45,16 +45,14 @@ on merge, auto-merge enabled.
```sh
brew tap DevVig/microbridge https://github.com/DevVig/microbridge
brew install microbridge
-brew services start microbridge
-open ~/Applications/Microbridge.app
+microbridge-app install
```
Updates:
```sh
brew update && brew upgrade microbridge
-brew services restart microbridge # apply formula changes and refresh the marker-owned app
-open ~/Applications/Microbridge.app
+microbridge-app install # refresh the marker-owned signed app
```
Optional background updates (Homebrew’s own updater):
@@ -63,4 +61,8 @@ Optional background updates (Homebrew’s own updater):
brew autoupdate start --upgrade --cleanup --immediate
```
+The standard GUI app owns its bundled daemon. `brew services start microbridge`
+is retained as an explicit headless mode and is verified separately in release
+smokes.
+
Details: [INSTALL.md](../INSTALL.md#homebrew-recommended-on-macos).
diff --git a/docs/releases/v0.3.8.md b/docs/releases/v0.3.8.md
new file mode 100644
index 0000000..f0081f3
--- /dev/null
+++ b/docs/releases/v0.3.8.md
@@ -0,0 +1,35 @@
+# Microbridge 0.3.8 (2026-07-22)
+
+Native Codex Micro ownership and macOS identity release.
+
+## Highlights
+
+- **Daily hardware controls:** Claim, release, or retry Codex Micro ownership
+ from the home popover or the menu-bar icon's right-click menu.
+- **Honest device state:** Microbridge reports Connected only after the daemon
+ acquires the HID interface; requested control alone never appears claimed.
+- **Native Login Item:** Launch at Login now uses the signed Microbridge main
+ app through macOS ServiceManagement, so System Settings shows the app's name
+ and icon instead of a daemon or terminal-style executable.
+- **App-owned daemon:** Standard GUI installs launch the bundled daemon for the
+ app's lifetime. The standalone Homebrew service remains available for
+ explicit headless operation.
+- **Safer upgrades:** Legacy UI and daemon LaunchAgents migrate transactionally,
+ with rollback if native registration or bundled-daemon startup fails.
+
+## Upgrade notes
+
+- Homebrew: `brew update && brew upgrade microbridge && microbridge-app install`.
+- Direct download: install the v0.3.8 DMG. Microbridge converts known legacy
+ startup entries after the native app and bundled daemon are working.
+- If macOS requires approval, open **Settings → General → Open Login Items…**.
+- No wire-protocol or persisted-configuration migration is required.
+
+## Risk / notes
+
+- `MICROBRIDGE_HID_CLAIM=1` remains a developer override and can supersede a
+ release action in the UI.
+- `brew services start microbridge` is now the explicit headless path and may
+ intentionally create a separate background item.
+- Physical HID ownership and System Settings identity are validated separately
+ from the signed/notarized artifact pipeline.
diff --git a/scripts/install-from-release.sh b/scripts/install-from-release.sh
index c1cab7e..d9c5b28 100755
--- a/scripts/install-from-release.sh
+++ b/scripts/install-from-release.sh
@@ -1,12 +1,11 @@
#!/usr/bin/env bash
-# Download a GitHub Release archive and install the menu bar app + daemon
-# (+ launchd on macOS). Not a CLI-only install.
+# Download a GitHub Release archive and install the menu bar app + daemon.
+# The GUI owns its bundled daemon; launchd is reserved for explicit headless installs.
set -euo pipefail
REPO="${MICROBRIDGE_REPO:-DevVig/microbridge}"
BIN_DIR="${MICROBRIDGE_BIN:-$HOME/.local/bin}"
TAG="${1:-}"
-LABEL="ai.microbridge.daemon"
need() {
command -v "$1" >/dev/null 2>&1 || {
@@ -69,59 +68,32 @@ install -m 755 "$BIN_SRC" "$BIN_DIR/microbridged"
install -m 755 "$CTL_SRC" "$BIN_DIR/microbridgectl"
if [[ "$OS" == "Darwin" ]]; then
- PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
- mkdir -p "$HOME/Library/LaunchAgents"
- cat >"$PLIST" <
-
-
-
- Label
- ${LABEL}
- ProgramArguments
-
- ${BIN_DIR}/microbridged
-
- RunAtLoad
-
- KeepAlive
-
- StandardOutPath
- ${HOME}/.microbridge/daemon.log
- StandardErrorPath
- ${HOME}/.microbridge/daemon.log
- EnvironmentVariables
-
- HOME
- ${HOME}
- PATH
- ${BIN_DIR}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin
-
-
-
-EOF
- launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true
- launchctl bootstrap "gui/$(id -u)" "$PLIST"
- launchctl enable "gui/$(id -u)/${LABEL}"
- launchctl kickstart -k "gui/$(id -u)/${LABEL}"
-
DEST="$HOME/Applications/Microbridge.app"
- MARKER="$DEST/.microbridge-release"
+ MARKER="$HOME/Applications/.Microbridge.app.microbridge-release"
+ LEGACY_MARKER="$DEST/.microbridge-release"
install_app_bundle() {
local APP_SRC="$1"
- if [[ -d "$DEST" && ! -f "$MARKER" && "${MICROBRIDGE_FORCE_APP:-}" != "1" ]]; then
+ if [[ -d "$DEST" && ! -f "$MARKER" && ! -f "$LEGACY_MARKER" && "${MICROBRIDGE_FORCE_APP:-}" != "1" ]]; then
echo " warning: $DEST exists and is not release-managed — leave it"
echo " set MICROBRIDGE_FORCE_APP=1 to replace"
return 0
fi
- rm -rf "$DEST"
+ local STAGING="$HOME/Applications/.Microbridge.app.installing.$$"
mkdir -p "$HOME/Applications"
- cp -R "$APP_SRC" "$DEST"
+ rm -rf "$STAGING"
+ /usr/bin/ditto "$APP_SRC" "$STAGING"
+ /usr/bin/codesign --verify --deep --strict "$STAGING"
+ if [[ -d "$DEST" ]]; then
+ while read -r pid; do
+ kill "$pid" 2>/dev/null || true
+ done < <(/usr/bin/pgrep -f "^${DEST}/Contents/MacOS/microbridge-ui$" 2>/dev/null || true)
+ fi
+ rm -rf "$DEST"
+ mv "$STAGING" "$DEST"
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
- echo "owned-by-release" >"$MARKER"
- # Launch at login is the app's job now, not the installer's — it asks on
- # first launch and owns the ai.microbridge.ui LaunchAgent from there
- # (Settings → General), so brew/DMG/source all behave the same.
+ touch "$MARKER"
+ rm -f "$LEGACY_MARKER"
+ # Launch at login and the bundled daemon are both owned by the app.
open "$HOME/Applications/Microbridge.app" 2>/dev/null || true
echo " installed ~/Applications/Microbridge.app"
}
diff --git a/scripts/install.sh b/scripts/install.sh
index 7a593b1..fcdc46b 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -78,7 +78,7 @@ TOML
echo " wrote ~/.microbridge/config.toml"
fi
-if [[ "$WITH_LAUNCHD" -eq 1 ]]; then
+if [[ "$WITH_LAUNCHD" -eq 1 && "$WITH_UI" -eq 0 ]]; then
echo "==> Installing launchd agent ($LABEL)"
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
mkdir -p "$HOME/Library/LaunchAgents"
@@ -125,7 +125,11 @@ EOF
echo " warning: daemon not responding yet — check ~/.microbridge/daemon.log"
fi
else
- echo "==> Skipping launchd (run manually: $BIN_DIR/microbridged)"
+ if [[ "$WITH_UI" -eq 1 ]]; then
+ echo "==> App-owned daemon (no separate launchd background item)"
+ else
+ echo "==> Skipping launchd (run manually: $BIN_DIR/microbridged)"
+ fi
fi
if [[ "$WITH_UI" -eq 1 ]]; then
@@ -138,15 +142,23 @@ if [[ "$WITH_UI" -eq 1 ]]; then
if npm run tauri build; then
APP_SRC="$(find "$ROOT/apps/microbridge-ui/src-tauri/target/release/bundle" -name 'Microbridge.app' -type d 2>/dev/null | head -n1 || true)"
if [[ -n "$APP_SRC" && "$(uname -s)" == "Darwin" ]]; then
- rm -rf "$HOME/Applications/Microbridge.app"
- mkdir -p "$HOME/Applications"
- cp -R "$APP_SRC" "$HOME/Applications/Microbridge.app"
- echo " installed ~/Applications/Microbridge.app"
- # Launch at login is the app's job now, not the installer's — it asks
- # on first launch and owns the ai.microbridge.ui LaunchAgent from there
- # (Settings → General). Doing it here too would only have covered
- # source installs, and would race the app for the same plist.
- open "$HOME/Applications/Microbridge.app" 2>/dev/null || true
+ APP_DEST="$HOME/Applications/Microbridge.app"
+ APP_MARKER="$HOME/Applications/.Microbridge.app.microbridge-source"
+ if [[ -e "$APP_DEST" && ! -f "$APP_MARKER" && "${MICROBRIDGE_FORCE_APP:-}" != "1" ]]; then
+ echo " warning: preserving unowned $APP_DEST"
+ echo " set MICROBRIDGE_FORCE_APP=1 to replace"
+ else
+ while read -r pid; do
+ kill "$pid" 2>/dev/null || true
+ done < <(/usr/bin/pgrep -f "^${APP_DEST}/Contents/MacOS/microbridge-ui$" 2>/dev/null || true)
+ rm -rf "$APP_DEST"
+ mkdir -p "$HOME/Applications"
+ /usr/bin/ditto "$APP_SRC" "$APP_DEST"
+ touch "$APP_MARKER"
+ echo " installed ~/Applications/Microbridge.app"
+ # Launch at login and the bundled daemon are both owned by the app.
+ open "$APP_DEST" 2>/dev/null || true
+ fi
else
echo " note: .app bundle not found — web build is in apps/microbridge-ui/dist"
echo " run: cd apps/microbridge-ui && npm run tauri dev"
diff --git a/scripts/smoke-formula.sh b/scripts/smoke-formula.sh
index 8ee2f29..e0a8d14 100755
--- a/scripts/smoke-formula.sh
+++ b/scripts/smoke-formula.sh
@@ -17,6 +17,7 @@ APP_LOG="${RUNNER_TEMP:-/tmp}/microbridge-app.log"
cleanup() {
brew services stop "$TAP/microbridge" >/dev/null 2>&1 || true
+ microbridge-app uninstall >/dev/null 2>&1 || true
HOMEBREW_NO_INSTALL_CLEANUP=1 brew uninstall "$TAP/microbridge" >/dev/null 2>&1 || true
if [[ -f "$MARKER" || -f "$LEGACY_MARKER" ]]; then
rm -rf "$APP"
@@ -67,7 +68,7 @@ PREFIX="$(brew --prefix "$TAP/microbridge")"
test -x "$PREFIX/bin/microbridged"
test -x "$PREFIX/bin/microbridgectl"
-brew services start "$TAP/microbridge"
+microbridge-app install
for _ in {1..30}; do
[[ -f "$MARKER" ]] && break
sleep 1
@@ -81,6 +82,31 @@ spctl --assess --type execute --verbose=4 "$APP"
xcrun stapler validate "$APP"
syspolicy_check distribution "$APP"
+APP_EXECUTABLE="$APP/Contents/MacOS/microbridge-ui"
+APP_PID=""
+for _ in {1..30}; do
+ APP_PID="$(pgrep -f "^${APP_EXECUTABLE}$" | head -n1 || true)"
+ [[ -n "$APP_PID" ]] && break
+ sleep 1
+done
+[[ -n "$APP_PID" ]]
+kill -0 "$APP_PID"
+microbridgectl status >"$APP_LOG"
+kill "$APP_PID" || true
+wait "$APP_PID" || true
+APP_DAEMON_STOPPED=0
+for _ in {1..30}; do
+ if ! microbridgectl status >/dev/null 2>&1; then
+ APP_DAEMON_STOPPED=1
+ break
+ fi
+ sleep 1
+done
+test "$APP_DAEMON_STOPPED" -eq 1
+
+# The daemon service is an explicit headless path, verified separately from
+# the standard app-owned GUI lifecycle above.
+brew services start "$TAP/microbridge"
SERVICE_STATE=""
for _ in {1..30}; do
SERVICE_STATE="$(brew services list --json | jq -r 'map(select(.name=="microbridge")) | .[0].status // empty')"
@@ -89,14 +115,8 @@ for _ in {1..30}; do
done
test "$SERVICE_STATE" = "started" || test "$SERVICE_STATE" = "scheduled"
-"$APP/Contents/MacOS/microbridge-ui" >"$APP_LOG" 2>&1 &
-APP_PID=$!
-sleep 3
-kill -0 "$APP_PID"
-kill "$APP_PID" || true
-wait "$APP_PID" || true
-
brew services stop "$TAP/microbridge"
+microbridge-app uninstall
HOMEBREW_NO_INSTALL_CLEANUP=1 brew uninstall "$TAP/microbridge"
test ! -e "$(brew --cellar)/microbridge/$VERSION"
if brew services list --json | jq -e '.[] | select(.name=="microbridge")' >/dev/null; then
@@ -104,11 +124,6 @@ if brew services list --json | jq -e '.[] | select(.name=="microbridge")' >/dev/
exit 1
fi
-# The app is deliberately outside the Cellar so the menu-bar UI survives
-# formula upgrades. Remove it only after verifying this install's marker.
-test -f "$MARKER"
-rm -rf "$APP"
-rm -f "$MARKER"
test ! -e "$APP"
test ! -e "$MARKER"
brew untap "$TAP"
diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh
index 73ebef5..a0e5373 100755
--- a/scripts/uninstall.sh
+++ b/scripts/uninstall.sh
@@ -5,6 +5,7 @@ set -euo pipefail
BIN_DIR="${MICROBRIDGE_BIN:-$HOME/.local/bin}"
LABEL="ai.microbridge.daemon"
UI_LABEL="ai.microbridge.ui"
+BREW_LABEL="homebrew.mxcl.microbridge"
PURGE=0
usage() {
@@ -27,11 +28,22 @@ if [[ "$(uname -s)" == "Darwin" ]]; then
echo "==> Stopping launchd agents"
launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true
launchctl bootout "gui/$(id -u)/${UI_LABEL}" 2>/dev/null || true
+ launchctl bootout "gui/$(id -u)/${BREW_LABEL}" 2>/dev/null || true
rm -f "$HOME/Library/LaunchAgents/${LABEL}.plist"
rm -f "$HOME/Library/LaunchAgents/${UI_LABEL}.plist"
- if [[ -d "$HOME/Applications/Microbridge.app" ]]; then
+ rm -f "$HOME/Library/LaunchAgents/${BREW_LABEL}.plist"
+ APP="$HOME/Applications/Microbridge.app"
+ SOURCE_MARKER="$HOME/Applications/.Microbridge.app.microbridge-source"
+ BREW_MARKER="$HOME/Applications/.Microbridge.app.microbridge-brew"
+ RELEASE_MARKER="$HOME/Applications/.Microbridge.app.microbridge-release"
+ if [[ -d "$APP" && ( -f "$SOURCE_MARKER" || -f "$BREW_MARKER" || -f "$RELEASE_MARKER" || -f "$APP/.microbridge-brew" || -f "$APP/.microbridge-release" ) ]]; then
echo "==> Removing menu bar app"
- rm -rf "$HOME/Applications/Microbridge.app"
+ "$APP/Contents/MacOS/microbridge-ui" \
+ --unregister-login-item 2>/dev/null || true
+ rm -rf "$APP"
+ rm -f "$SOURCE_MARKER" "$BREW_MARKER" "$RELEASE_MARKER"
+ elif [[ -d "$APP" ]]; then
+ echo "==> Preserving unowned $APP"
fi
fi