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, ()> { + let marker = daemon_migration_marker_path(); + if marker.is_file() { + return Ok(None); + } + let agents = legacy_daemon_agents(); + if agents.is_empty() { + return Ok(None); + } + let Some(binary) = bundled_daemon_path() else { + return Err(()); + }; + + for agent in &agents { + bootout_legacy_agent(agent.label); + } + for _ in 0..20 { + if !daemon_is_reachable() { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + if daemon_is_reachable() { + for agent in &agents { + bootstrap_legacy_agent(&agent.path); + } + return Err(()); + } + + let Some(mut child) = spawn_owned_daemon(&binary) else { + for agent in &agents { + bootstrap_legacy_agent(&agent.path); + } + return Err(()); + }; + for _ in 0..60 { + if daemon_is_reachable() { + if finalize_legacy_daemon_migration(&marker, &agents).is_ok() { + return Ok(Some(child)); + } + break; + } + if child.try_wait().ok().flatten().is_some() { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + + let _ = child.kill(); + let _ = child.wait(); + restore_legacy_agent_files(&agents); + for agent in &agents { + bootstrap_legacy_agent(&agent.path); + } + Err(()) +} + +#[cfg(not(target_os = "macos"))] +fn migrate_legacy_daemon_to_app() -> Result, ()> { + Ok(None) +} + +#[cfg(test)] +mod daemon_migration_tests { + use super::*; + + fn launch_agent_plist(label: &str, executable: &str) -> String { + format!( + r#" + +Label{label} +ProgramArguments{executable} +"# + ) + } + + fn test_directory(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "microbridge-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + #[test] + fn recognizes_only_owned_legacy_agents() { + let direct = Path::new("/tmp/ai.microbridge.daemon.plist"); + assert_eq!( + known_legacy_daemon_agent( + direct, + &launch_agent_plist("ai.microbridge.daemon", "/usr/local/bin/microbridged") + ), + Some("ai.microbridge.daemon") + ); + assert_eq!(known_legacy_daemon_agent(direct, "unrelated"), None); + assert_eq!( + known_legacy_daemon_agent( + Path::new("/tmp/com.example.agent.plist"), + &launch_agent_plist("ai.microbridge.daemon", "/usr/local/bin/microbridged") + ), + None + ); + assert_eq!( + known_legacy_daemon_agent( + direct, + &launch_agent_plist("ai.microbridge.daemon", "/tmp/unrelated/microbridged") + ), + None + ); + assert_eq!( + known_legacy_daemon_agent( + Path::new("/tmp/homebrew.mxcl.microbridge.plist"), + &launch_agent_plist( + "homebrew.mxcl.microbridge", + "/opt/homebrew/opt/microbridge/bin/microbridged" + ) + ), + Some("homebrew.mxcl.microbridge") + ); + } + + #[test] + fn successful_conversion_records_marker_then_removes_owned_plists() { + let directory = test_directory("migration-success"); + fs::create_dir_all(&directory).unwrap(); + let path = directory.join("ai.microbridge.daemon.plist"); + fs::write(&path, "owned service").unwrap(); + let marker = directory.join("migration-complete"); + let agents = vec![LegacyDaemonAgent { + path: path.clone(), + label: "ai.microbridge.daemon", + contents: "owned service".into(), + }]; + + assert_eq!(finalize_legacy_daemon_migration(&marker, &agents), Ok(())); + assert!(marker.is_file()); + assert!(!path.exists()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn daemon_start_rollback_restores_original_service_files() { + let directory = test_directory("migration-rollback"); + fs::create_dir_all(&directory).unwrap(); + let path = directory.join("ai.microbridge.daemon.plist"); + let agents = vec![LegacyDaemonAgent { + path: path.clone(), + label: "ai.microbridge.daemon", + contents: "original service".into(), + }]; + + restore_legacy_agent_files(&agents); + assert_eq!(fs::read_to_string(&path).unwrap(), "original service"); + fs::remove_dir_all(directory).unwrap(); } - let stdout = OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - .ok()?; - let stderr = stdout.try_clone().ok()?; - Command::new(binary) - .stdin(Stdio::null()) - .stdout(Stdio::from(stdout)) - .stderr(Stdio::from(stderr)) - .spawn() - .ok() } const CURSOR_PLUGIN_NAME: &str = "microbridge"; @@ -546,7 +839,9 @@ fn claude_hook_source(app: &AppHandle) -> Result { fn install_claude_hooks(app: &AppHandle) -> Result { let home = std::env::var("HOME").map_err(|_| "HOME is unset".to_string())?; - let hook_dir = PathBuf::from(&home).join(".microbridge").join("claude-hooks"); + let hook_dir = PathBuf::from(&home) + .join(".microbridge") + .join("claude-hooks"); fs::create_dir_all(&hook_dir).map_err(|e| e.to_string())?; let dest = hook_dir.join("microbridge-permission.mjs"); let source = claude_hook_source(app)?; @@ -587,8 +882,7 @@ fn install_claude_hooks(app: &AppHandle) -> Result { let mut changed = false; let mb_owned_or_empty = |existing: &serde_json::Value| { let text = existing.to_string(); - text.contains("microbridge-permission") - || existing.as_array().is_some_and(|a| a.is_empty()) + text.contains("microbridge-permission") || existing.as_array().is_some_and(|a| a.is_empty()) }; if hooks_obj.get("PermissionRequest") != Some(&permission_entry) { let existing = hooks_obj @@ -1247,7 +1541,7 @@ fn resize_popover(app: AppHandle, height: f64) { } /// Install channel of the running app. Homebrew drops an ownership marker next -/// to the bundle (installed by the formula's service wrapper); its absence means +/// to the bundle (installed by `microbridge-app`); its absence means /// a DMG/manual install. The marker must stay outside `Microbridge.app`, because /// adding a file to the bundle after signing invalidates its sealed signature. /// The in-app self-updater only replaces `direct` installs — brew copies are @@ -1295,90 +1589,359 @@ fn trigger_update_check(app: &AppHandle) { let _ = app.emit("menu://check-updates", ()); } -/// launchd label for the login item. Deliberately the same label `install.sh` -/// used to write by hand, so the autostart plugin owns that exact file -/// (`~/Library/LaunchAgents/ai.microbridge.ui.plist`) instead of creating a -/// second one. Without this the plugin would default to `package_info().name` -/// ("Microbridge") and a source install would end up with two login entries. -const LOGIN_ITEM_LABEL: &str = "ai.microbridge.ui"; +#[derive(Debug, PartialEq, Eq)] +struct HardwareMenuPresentation { + label: &'static str, + enabled: bool, + target_enabled: Option, +} + +fn hardware_menu_presentation_for( + device_connected: bool, + device_name: &str, + control_requested: bool, +) -> HardwareMenuPresentation { + if device_connected { + return HardwareMenuPresentation { + label: "Release Codex Micro", + enabled: true, + target_enabled: Some(false), + }; + } + if device_name.contains("usb") { + return HardwareMenuPresentation { + label: if control_requested { + "Retry Codex Micro Claim" + } else { + "Claim Codex Micro" + }, + enabled: true, + target_enabled: Some(true), + }; + } + HardwareMenuPresentation { + label: "Codex Micro Not Detected", + enabled: false, + target_enabled: None, + } +} + +fn hardware_menu_presentation(snapshot: Option<&Snapshot>) -> HardwareMenuPresentation { + snapshot.map_or_else( + || hardware_menu_presentation_for(false, "daemon-offline", false), + |snapshot| { + hardware_menu_presentation_for( + snapshot.device_connected, + &snapshot.device_name, + snapshot.config.hardware_control_enabled, + ) + }, + ) +} -/// True when the executable sits inside a `.app` bundle. -/// -/// `tauri dev` runs the bare binary out of `target/debug`, and the login item -/// records whatever `current_exe()` returns — so accepting the prompt during -/// development would register a throwaway build to launch at every login, and -/// leave a dangling login item behind the moment `target/` is cleaned. -fn running_from_app_bundle() -> bool { +async fn apply_hardware_menu_action(app: AppHandle) -> Result<(), String> { + let state = app.state::(); + let mut config = { + let snapshot = state.snapshot.lock().await; + let snapshot = snapshot + .as_ref() + .ok_or_else(|| "waiting for microbridged".to_string())?; + let presentation = hardware_menu_presentation(Some(snapshot)); + let Some(enabled) = presentation.target_enabled else { + return Ok(()); + }; + let mut config = snapshot.config.clone(); + config.hardware_control_enabled = enabled; + config + }; + + // Preserve the daemon's normalized response as the source of truth. + let next = state.bus.set_config(config.clone()).await?; + config = next; + let mut snapshot = state.snapshot.lock().await; + if let Some(snapshot) = snapshot.as_mut() { + snapshot.config = config; + let _ = app.emit("bus-snapshot", snapshot.clone()); + } + Ok(()) +} + +#[cfg(test)] +mod hardware_menu_tests { + use super::*; + + #[test] + fn menu_labels_follow_actual_claim_and_requested_state() { + assert_eq!( + hardware_menu_presentation_for(false, "codex-micro-usb", false), + HardwareMenuPresentation { + label: "Claim Codex Micro", + enabled: true, + target_enabled: Some(true), + } + ); + assert_eq!( + hardware_menu_presentation_for(false, "codex-micro-usb", true).label, + "Retry Codex Micro Claim" + ); + assert_eq!( + hardware_menu_presentation_for(true, "codex-micro-usb", false).label, + "Release Codex Micro" + ); + assert!(!hardware_menu_presentation_for(false, "mock", false).enabled); + } +} + +const LEGACY_UI_LOGIN_LABEL: &str = "ai.microbridge.ui"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +enum LoginItemStatus { + Unavailable, + NotRegistered, + Enabled, + RequiresApproval, + NotFound, +} + +fn is_installed_app_executable(executable: &Path) -> bool { + let display = executable.to_string_lossy(); + !display.contains("/target/debug/") + && executable + .ancestors() + .any(|path| path.extension().is_some_and(|extension| extension == "app")) +} + +fn can_register_login_item() -> bool { std::env::current_exe() .ok() - .and_then(|exe| { - exe.ancestors() - .nth(3) - .map(|bundle| bundle.extension().is_some_and(|ext| ext == "app")) - }) - .unwrap_or(false) + .is_some_and(|executable| is_installed_app_executable(&executable)) +} + +#[cfg(target_os = "macos")] +fn login_item_status_from_native( + status: objc2_service_management::SMAppServiceStatus, +) -> LoginItemStatus { + use objc2_service_management::SMAppServiceStatus; + + match status { + SMAppServiceStatus::NotRegistered => LoginItemStatus::NotRegistered, + SMAppServiceStatus::Enabled => LoginItemStatus::Enabled, + SMAppServiceStatus::RequiresApproval => LoginItemStatus::RequiresApproval, + SMAppServiceStatus::NotFound => LoginItemStatus::NotFound, + _ => LoginItemStatus::NotFound, + } +} + +#[cfg(target_os = "macos")] +fn native_login_item_status() -> LoginItemStatus { + use objc2_service_management::SMAppService; + + if !can_register_login_item() { + return LoginItemStatus::Unavailable; + } + let service = unsafe { SMAppService::mainAppService() }; + login_item_status_from_native(unsafe { service.status() }) +} + +#[cfg(not(target_os = "macos"))] +fn native_login_item_status() -> LoginItemStatus { + LoginItemStatus::Unavailable +} + +fn bounded_login_item_error(action: &str, error: impl std::fmt::Display) -> String { + let message = format!("Could not {action} launch at login: {error}"); + message.chars().take(280).collect() +} + +#[cfg(target_os = "macos")] +fn set_native_launch_at_login(enabled: bool) -> Result { + use objc2_service_management::SMAppService; + + if !can_register_login_item() { + return Err("Launch at login is only available from an installed Microbridge app.".into()); + } + let service = unsafe { SMAppService::mainAppService() }; + let status = native_login_item_status(); + if enabled && status != LoginItemStatus::Enabled { + unsafe { service.registerAndReturnError() } + .map_err(|error| bounded_login_item_error("enable", error))?; + } else if !enabled + && !matches!( + status, + LoginItemStatus::NotRegistered | LoginItemStatus::NotFound + ) + { + unsafe { service.unregisterAndReturnError() } + .map_err(|error| bounded_login_item_error("disable", error))?; + } + Ok(native_login_item_status()) +} + +#[cfg(not(target_os = "macos"))] +fn set_native_launch_at_login(_enabled: bool) -> Result { + Err("Launch at login is only available on macOS.".into()) } -/// Whether a login item can meaningfully be registered for this build. #[tauri::command] -fn can_launch_at_login() -> bool { - running_from_app_bundle() +fn launch_at_login_status() -> LoginItemStatus { + native_login_item_status() } #[tauri::command] -fn launch_at_login_enabled(app: AppHandle) -> bool { - app.autolaunch().is_enabled().unwrap_or(false) +fn set_launch_at_login(enabled: bool) -> Result { + set_native_launch_at_login(enabled) } #[tauri::command] -fn set_launch_at_login(app: AppHandle, enabled: bool) -> Result<(), String> { - let manager = app.autolaunch(); - if enabled { - // Writes the plist with RunAtLoad; launchd picks it up at next login. - // Deliberately not bootstrapped here — that would fire RunAtLoad - // immediately and start a second copy of the app. - manager.enable().map_err(|e| e.to_string())?; - } else { - manager.disable().map_err(|e| e.to_string())?; - bootout_login_item(); +fn open_login_items_settings() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + use objc2_service_management::SMAppService; + unsafe { SMAppService::openSystemSettingsLoginItems() }; + Ok(()) + } + #[cfg(not(target_os = "macos"))] + { + Err("Login Items settings are only available on macOS.".into()) } - Ok(()) } -/// `disable()` only deletes the plist. Installs that came from `install.sh` also -/// had the agent *bootstrapped* into the running launchd session, so without -/// this it would linger in `launchctl print` until the next logout. Best-effort: -/// a missing agent is the normal case and its error is not interesting. +fn legacy_ui_login_item_path() -> Option { + std::env::var_os("HOME").map(|home| { + PathBuf::from(home) + .join("Library/LaunchAgents") + .join(format!("{LEGACY_UI_LOGIN_LABEL}.plist")) + }) +} + +#[cfg(target_os = "macos")] +fn current_user_launchd_domain() -> Option { + let output = Command::new("/usr/bin/id").arg("-u").output().ok()?; + if !output.status.success() { + return None; + } + let uid = String::from_utf8(output.stdout).ok()?; + Some(format!("gui/{}", uid.trim())) +} + #[cfg(target_os = "macos")] -fn bootout_login_item() { - let _ = std::process::Command::new("/bin/sh") - .arg("-c") - .arg(format!("launchctl bootout gui/$(id -u)/{LOGIN_ITEM_LABEL}")) +fn bootout_legacy_agent(label: &str) { + let Some(domain) = current_user_launchd_domain() else { + return; + }; + let _ = Command::new("/bin/launchctl") + .args(["bootout", &format!("{domain}/{label}")]) .status(); } -#[cfg(not(target_os = "macos"))] -fn bootout_login_item() {} +fn migrate_legacy_ui_login_item() { + let Some(path) = legacy_ui_login_item_path() else { + return; + }; + if !path.is_file() || !can_register_login_item() { + return; + } + let initial_status = native_login_item_status(); + let registration_succeeded = + initial_status == LoginItemStatus::Enabled || set_native_launch_at_login(true).is_ok(); + let final_status = native_login_item_status(); + if legacy_ui_login_migration_completed(initial_status, registration_succeeded, final_status) { + #[cfg(target_os = "macos")] + bootout_legacy_agent(LEGACY_UI_LOGIN_LABEL); + let _ = fs::remove_file(path); + } +} + +fn legacy_ui_login_migration_completed( + initial_status: LoginItemStatus, + registration_succeeded: bool, + final_status: LoginItemStatus, +) -> bool { + (initial_status == LoginItemStatus::Enabled || registration_succeeded) + && final_status == LoginItemStatus::Enabled +} + +#[cfg(test)] +mod login_item_tests { + use super::*; + + #[cfg(target_os = "macos")] + #[test] + fn maps_all_native_login_item_statuses() { + use objc2_service_management::SMAppServiceStatus; + + assert_eq!( + login_item_status_from_native(SMAppServiceStatus::NotRegistered), + LoginItemStatus::NotRegistered + ); + assert_eq!( + login_item_status_from_native(SMAppServiceStatus::Enabled), + LoginItemStatus::Enabled + ); + assert_eq!( + login_item_status_from_native(SMAppServiceStatus::RequiresApproval), + LoginItemStatus::RequiresApproval + ); + assert_eq!( + login_item_status_from_native(SMAppServiceStatus::NotFound), + LoginItemStatus::NotFound + ); + } + + #[test] + fn dev_executables_cannot_register_as_login_items() { + assert!(!is_installed_app_executable(Path::new( + "/tmp/target/debug/bundle/macos/Microbridge.app/Contents/MacOS/microbridge-ui" + ))); + assert!(is_installed_app_executable(Path::new( + "/Applications/Microbridge.app/Contents/MacOS/microbridge-ui" + ))); + assert!(!is_installed_app_executable(Path::new( + "/tmp/microbridge-ui" + ))); + } + + #[test] + fn native_registration_failure_preserves_legacy_login_item() { + assert!(!legacy_ui_login_migration_completed( + LoginItemStatus::NotRegistered, + false, + LoginItemStatus::NotRegistered, + )); + assert!(legacy_ui_login_migration_completed( + LoginItemStatus::NotRegistered, + true, + LoginItemStatus::Enabled, + )); + } +} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + if std::env::args().any(|argument| argument == "--unregister-login-item") { + let _ = set_native_launch_at_login(false); + return; + } tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_dialog::init()) - .plugin( - tauri_plugin_autostart::Builder::new() - .app_name(LOGIN_ITEM_LABEL) - .build(), - ) .setup(|app| { #[cfg(target_os = "macos")] { app.set_activation_policy(tauri::ActivationPolicy::Accessory); } - let bundled_daemon = start_bundled_daemon(); + migrate_legacy_ui_login_item(); + let bundled_daemon = match migrate_legacy_daemon_to_app() { + Ok(Some(child)) => Some(child), + Ok(None) => start_bundled_daemon(), + // The legacy service was restored; do not race it with a + // second daemon if the migration could not be completed. + Err(()) => None, + }; let (bus, mut event_rx) = spawn_bus_loop(); let snapshot: CachedSnapshot = Arc::new(Mutex::new(None)); let hud_generation = Arc::new(AtomicU64::new(0)); @@ -1543,6 +2106,13 @@ pub fn run() { // Right-click context menu. Left-click still toggles the popover // (`show_menu_on_left_click(false)` keeps the menu on right-click only). + let hardware_item = MenuItem::with_id( + app, + "hardware-control", + "Codex Micro Not Detected", + false, + None::<&str>, + )?; let check_updates_item = MenuItem::with_id( app, "check-updates", @@ -1557,6 +2127,8 @@ pub fn run() { let tray_menu = Menu::with_items( app, &[ + &hardware_item, + &PredefinedMenuItem::separator(app)?, &check_updates_item, &settings_item, &PredefinedMenuItem::separator(app)?, @@ -1564,6 +2136,7 @@ pub fn run() { ], )?; let context_menu = tray_menu.clone(); + let hardware_item_for_tray = hardware_item.clone(); let blur_hide: BlurHideClock = Arc::new(std::sync::Mutex::new(None)); let blur_hide_tray = Arc::clone(&blur_hide); @@ -1577,6 +2150,12 @@ pub fn run() { // making left and right clicks indistinguishable. We pop it up // explicitly only for a right-button release below. .on_menu_event(|app, event| match event.id.as_ref() { + "hardware-control" => { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let _ = apply_hardware_menu_action(app).await; + }); + } "check-updates" => trigger_update_check(app), "settings" => show_settings_window(app), "quit" => app.exit(0), @@ -1610,7 +2189,17 @@ pub fn run() { button_state: MouseButtonState::Up, .. } => { - if let Some(window) = tray.app_handle().get_webview_window("popover") { + let app = tray.app_handle(); + if let Some(state) = app.try_state::() { + // This cached lock is held only for in-memory event + // updates. Read it synchronously so the native menu + // can never open with a stale action or label. + let snapshot = state.snapshot.blocking_lock(); + let presentation = hardware_menu_presentation(snapshot.as_ref()); + let _ = hardware_item_for_tray.set_text(presentation.label); + let _ = hardware_item_for_tray.set_enabled(presentation.enabled); + } + if let Some(window) = app.get_webview_window("popover") { let _ = context_menu.popup(window.as_ref().window()); } } @@ -1655,9 +2244,9 @@ pub fn run() { quit_ui, update_channel, app_version, - launch_at_login_enabled, + launch_at_login_status, set_launch_at_login, - can_launch_at_login, + open_login_items_settings, popover_max_height, resize_popover ]) diff --git a/apps/microbridge-ui/src-tauri/tauri.conf.json b/apps/microbridge-ui/src-tauri/tauri.conf.json index d5a7ee0..8a73275 100644 --- a/apps/microbridge-ui/src-tauri/tauri.conf.json +++ b/apps/microbridge-ui/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Microbridge", - "version": "0.3.7", + "version": "0.3.8", "identifier": "ai.microbridge.ui", "build": { "beforeDevCommand": "npm run dev", diff --git a/apps/microbridge-ui/src/App.tsx b/apps/microbridge-ui/src/App.tsx index 9014c59..27f0083 100644 --- a/apps/microbridge-ui/src/App.tsx +++ b/apps/microbridge-ui/src/App.tsx @@ -145,6 +145,12 @@ export default function App() { pause_leds: !snapshot.config.pause_leds, }) } + onHardwareControl={(enabled) => + void applyConfig({ + ...snapshot.config, + hardware_control_enabled: enabled, + }) + } onQuit={() => void quitUi()} onAgentKey={(index, open) => void activateAgentKey(index, open)} /> diff --git a/apps/microbridge-ui/src/lib/autostart.ts b/apps/microbridge-ui/src/lib/autostart.ts index 16a573f..6977efc 100644 --- a/apps/microbridge-ui/src/lib/autostart.ts +++ b/apps/microbridge-ui/src/lib/autostart.ts @@ -6,9 +6,8 @@ * a LaunchAgent), which meant Homebrew and DMG installs never got it. It's now * a property of the app, so every channel behaves the same. * - * The Rust side owns the launchd plist via tauri-plugin-autostart, pinned to - * the same `ai.microbridge.ui` label the installer used so there is exactly one - * login entry. See `set_launch_at_login` in src-tauri/src/lib.rs. + * The Rust side registers the signed main app through SMAppService, so macOS + * shows Microbridge's app identity and icon instead of a Unix executable. */ import { invokeTauri } from "./tauri"; @@ -32,33 +31,35 @@ function markAsked(): void { } } -/** - * Whether a login item can meaningfully be registered for this build. - * - * False under `tauri dev`: the plist records `current_exe()`, so accepting the - * prompt from a dev build would register `target/debug/microbridge-ui` to launch - * at every login — a throwaway binary that breaks the moment `target/` is - * cleaned. Also false outside Tauri. - */ -export async function canLaunchAtLogin(): Promise { +export type LaunchAtLoginStatus = + | "unavailable" + | "not_registered" + | "enabled" + | "requires_approval" + | "not_found"; + +export async function launchAtLoginStatus(): Promise { try { - return (await invokeTauri("can_launch_at_login")) ?? false; + return ( + (await invokeTauri("launch_at_login_status")) ?? + "unavailable" + ); } catch { - return false; + return "unavailable"; } } -/** `null` outside Tauri, where there is no login item to report on. */ -export async function launchAtLoginEnabled(): Promise { - try { - return await invokeTauri("launch_at_login_enabled"); - } catch { - return null; - } +export async function setLaunchAtLogin( + enabled: boolean, +): Promise { + const status = await invokeTauri("set_launch_at_login", { + enabled, + }); + return status ?? "unavailable"; } -export async function setLaunchAtLogin(enabled: boolean): Promise { - await invokeTauri("set_launch_at_login", { enabled }); +export async function openLoginItemsSettings(): Promise { + await invokeTauri("open_login_items_settings"); } /** @@ -76,11 +77,9 @@ export async function setLaunchAtLogin(enabled: boolean): Promise { */ export async function promptLaunchAtLoginOnce(): Promise { if (alreadyAsked()) return; - if (!(await canLaunchAtLogin())) return; - - const enabled = await launchAtLoginEnabled(); - if (enabled === null) return; // not under Tauri - if (enabled) { + const status = await launchAtLoginStatus(); + if (status === "unavailable") return; + if (status === "enabled" || status === "requires_approval") { markAsked(); return; } diff --git a/apps/microbridge-ui/src/lib/hardwareControl.test.ts b/apps/microbridge-ui/src/lib/hardwareControl.test.ts new file mode 100644 index 0000000..71adb88 --- /dev/null +++ b/apps/microbridge-ui/src/lib/hardwareControl.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { hardwareControlState } from "./hardwareControl"; +import type { Snapshot } from "./types"; + +function snapshot( + deviceName: string, + connected = false, + requested = false, +): Snapshot { + return { + sessions: [], + focused_session_id: null, + agent_key_session_ids: [null, null, null, null, null, null], + agent_key_led_frame: { keys: [], brightness: 80, paused: false }, + device_connected: connected, + device_name: deviceName, + config: { + key_source: "most_recent", + pinned_session_ids: [], + app_priority: [], + custom_key_ids: [], + pinned_focus: null, + approvals_interrupt: true, + pause_leds: false, + appearance: "system", + lighting_preset: "codex", + state_colors: { + idle: "#000000", + thinking: "#000000", + working: "#000000", + awaiting_approval: "#000000", + done: "#000000", + error: "#000000", + }, + brightness: 80, + sleep_minutes: 3, + hardware_control_enabled: requested, + adapters: {}, + frontmost_app: null, + }, + adapters: [], + }; +} + +describe("hardwareControlState", () => { + it("distinguishes actual ownership from requested ownership", () => { + expect(hardwareControlState(snapshot("codex-micro-usb"))).toBe( + "available", + ); + expect(hardwareControlState(snapshot("codex-micro-usb", false, true))).toBe( + "claim_failed", + ); + expect(hardwareControlState(snapshot("codex-micro-usb", true, false))).toBe( + "connected", + ); + }); + + it("does not offer hardware actions for non-device surfaces", () => { + expect(hardwareControlState(snapshot("mock"))).toBe("unavailable"); + expect(hardwareControlState(snapshot("demo-browser"))).toBe("unavailable"); + expect(hardwareControlState(snapshot("daemon-offline"))).toBe( + "unavailable", + ); + expect(hardwareControlState(snapshot("not connected"))).toBe( + "unavailable", + ); + }); +}); diff --git a/apps/microbridge-ui/src/lib/hardwareControl.ts b/apps/microbridge-ui/src/lib/hardwareControl.ts new file mode 100644 index 0000000..a626fc4 --- /dev/null +++ b/apps/microbridge-ui/src/lib/hardwareControl.ts @@ -0,0 +1,29 @@ +import type { Snapshot } from "./types"; + +export type HardwareControlState = + | "available" + | "connected" + | "claim_failed" + | "unavailable"; + +/** + * Keep requested ownership separate from an actual HID claim. + * `hardware_control_enabled` is consent/intent; only `device_connected` proves + * that Microbridge currently owns the interface. + */ +export function hardwareControlState( + snapshot: Snapshot, +): HardwareControlState { + if (snapshot.device_connected) return "connected"; + + const unavailable = + snapshot.device_name === "mock" || + snapshot.device_name === "demo-browser" || + snapshot.device_name === "daemon-offline" || + !snapshot.device_name.includes("usb"); + if (unavailable) return "unavailable"; + + return snapshot.config.hardware_control_enabled + ? "claim_failed" + : "available"; +} diff --git a/apps/microbridge-ui/src/lib/updater.ts b/apps/microbridge-ui/src/lib/updater.ts index a5468f5..04facb5 100644 --- a/apps/microbridge-ui/src/lib/updater.ts +++ b/apps/microbridge-ui/src/lib/updater.ts @@ -103,7 +103,7 @@ export async function runUpdateCheck({ if (!silent) { const { message } = await import("@tauri-apps/plugin-dialog"); await message( - "Microbridge was installed with Homebrew.\n\nUpdate it from Terminal:\n\n brew update && brew upgrade microbridge", + "Microbridge was installed with Homebrew.\n\nUpdate and refresh the app from Terminal:\n\n brew update && brew upgrade microbridge && microbridge-app install", { title: "Update Microbridge", kind: "info" }, ); } diff --git a/apps/microbridge-ui/src/surfaces/Disconnected.tsx b/apps/microbridge-ui/src/surfaces/Disconnected.tsx index a88962a..715b78c 100644 --- a/apps/microbridge-ui/src/surfaces/Disconnected.tsx +++ b/apps/microbridge-ui/src/surfaces/Disconnected.tsx @@ -1,7 +1,5 @@ -import { useEffect, useState } from "react"; import { DARK, LIGHT, type ThemeTokens } from "../lib/theme"; import { usePopoverFit } from "../lib/popoverFit"; -import { updateChannel, type UpdateChannel } from "../lib/updater"; /** * Shown when microbridged hasn't sent a snapshot yet. @@ -30,22 +28,6 @@ export function Disconnected({ const { ref: cardRef, maxHeight } = usePopoverFit( view === "popover", ); - const [channel, setChannel] = useState(null); - - useEffect(() => { - void updateChannel().then(setChannel); - }, []); - - // Homebrew owns the daemon as a service; direct installs get the launchd - // agent written by install.sh. Showing the wrong one is worse than waiting, - // so hold the command back until the channel is known. - const startCommand = - channel === "brew" - ? "brew services start microbridge" - : channel === "direct" - ? "launchctl kickstart -k gui/$(id -u)/ai.microbridge.daemon" - : null; - return (
The menu bar app reads your sessions from the local daemon. It isn't - answering yet — start it and this window fills in on its own. + answering yet. Quit and reopen Microbridge to restart its bundled + daemon; this window fills in as soon as the local socket is ready.

- - {startCommand && ( -
-              {startCommand}
-            
- )}
( void; onTogglePause: () => void; + onHardwareControl: (enabled: boolean) => void; onQuit: () => void; onAgentKey?: (index: number, open: boolean) => void; }) { @@ -87,8 +90,21 @@ export function Popover({ const daemonOffline = snapshot.device_name === "daemon-offline"; const detected = !snapshot.device_connected && snapshot.device_name.includes("usb"); + const hardwareControl = hardwareControlState(snapshot); + const unavailableHardwareTitle = daemonOffline + ? "Microbridge daemon offline" + : demo + ? "Codex Micro unavailable in browser demo" + : simulator + ? "Connect a Codex Micro" + : "Codex Micro not detected"; + const unavailableHardwareDescription = daemonOffline + ? "Reopen Microbridge to restart its bundled daemon." + : demo + ? "Open the installed menu-bar app for live hardware control." + : "Connect the device over USB-C to make hardware control available."; // Show the live UI shell in simulator/detected modes; only "Connected" - // means claimed HID (not yet shipped for production hardware). + // means Microbridge actually owns the HID interface. const showLiveShell = snapshot.device_connected || simulator || detected; const chipLabel = snapshot.device_connected @@ -194,18 +210,63 @@ export function Popover({
- {(simulator || detected) && ( -

- {demo - ? "Browser demo data — start microbridged + the Tauri app for a live bus." - : simulator - ? "No Micro claimed — LED frames are simulated. Enable hardware control in Device settings to connect." - : "USB Micro seen, but hardware control is disabled or another process owns the HID interface."} -

- )} +
+
+

+ {hardwareControl === "connected" + ? "Controlled by Microbridge" + : hardwareControl === "claim_failed" + ? "Couldn’t claim Codex Micro" + : hardwareControl === "available" + ? "Codex Micro ready" + : unavailableHardwareTitle} +

+

+ {hardwareControl === "connected" + ? "Keys, dial, joystick, and lighting are active." + : hardwareControl === "claim_failed" + ? "Another app may own the HID interface. Close it, then retry." + : hardwareControl === "available" + ? "Let Microbridge use its keys, dial, joystick, and lighting." + : unavailableHardwareDescription} +

+
+ {hardwareControl !== "unavailable" && ( + + )} +
{showLiveShell ? ( <> @@ -366,7 +427,7 @@ export function Popover({ > {daemonOffline ? "No live daemon connection is available, so the app is showing no threads rather than simulated data." - : "Plug in over USB-C, then enable hardware control in Device settings. If another app owns the HID interface, Microbridge keeps observing threads without claiming the deck."} + : "Plug in over USB-C, then claim the Codex Micro here or from the menu-bar icon’s right-click menu. Microbridge keeps observing threads if another app owns the HID interface."}

)} 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.

-