diff --git a/Cargo.lock b/Cargo.lock index ba3694a..6b4ced2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -616,7 +616,7 @@ dependencies = [ [[package]] name = "mb-adapters" -version = "0.3.9" +version = "0.3.10" dependencies = [ "mb-protocol", "notify", @@ -628,7 +628,7 @@ dependencies = [ [[package]] name = "mb-device" -version = "0.3.9" +version = "0.3.10" dependencies = [ "hidapi", "mb-protocol", @@ -639,7 +639,7 @@ dependencies = [ [[package]] name = "mb-protocol" -version = "0.3.9" +version = "0.3.10" dependencies = [ "serde", "serde_json", @@ -653,7 +653,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "microbridgectl" -version = "0.3.9" +version = "0.3.10" dependencies = [ "mb-device", "mb-protocol", @@ -663,7 +663,7 @@ dependencies = [ [[package]] name = "microbridged" -version = "0.3.9" +version = "0.3.10" dependencies = [ "keyring", "mb-adapters", diff --git a/Cargo.toml b/Cargo.toml index 2288f96..b257c51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.3.9" +version = "0.3.10" edition = "2021" license = "MIT" repository = "https://github.com/DevVig/microbridge" diff --git a/adapters/claude/hooks/microbridge-permission.mjs b/adapters/claude/hooks/microbridge-permission.mjs index 3d9ac54..446bd3f 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.9", + adapter_version: "0.3.10", capabilities: {}, }, { diff --git a/adapters/sdk/package.json b/adapters/sdk/package.json index 0b928b0..ecf67da 100644 --- a/adapters/sdk/package.json +++ b/adapters/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@microbridge/adapter-sdk", - "version": "0.3.9", + "version": "0.3.10", "description": "Zero-dependency SDK for publishing AI agent session states to Microbridge", "main": "index.mjs", "type": "module", diff --git a/apps/microbridge-ui/package-lock.json b/apps/microbridge-ui/package-lock.json index 51dc782..fc13121 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.9", + "version": "0.3.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "microbridge-ui", - "version": "0.3.9", + "version": "0.3.10", "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 0c50b9f..0f48669 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.9", + "version": "0.3.10", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/microbridge-ui/src-tauri/Cargo.lock b/apps/microbridge-ui/src-tauri/Cargo.lock index 291e9b5..c7af031 100644 --- a/apps/microbridge-ui/src-tauri/Cargo.lock +++ b/apps/microbridge-ui/src-tauri/Cargo.lock @@ -1800,7 +1800,7 @@ dependencies = [ [[package]] name = "mb-protocol" -version = "0.3.9" +version = "0.3.10" dependencies = [ "serde", ] @@ -1822,7 +1822,7 @@ dependencies = [ [[package]] name = "microbridge-ui" -version = "0.3.9" +version = "0.3.10" dependencies = [ "mb-protocol", "objc2-service-management", diff --git a/apps/microbridge-ui/src-tauri/Cargo.toml b/apps/microbridge-ui/src-tauri/Cargo.toml index a447c36..e3f6f86 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.9" +version = "0.3.10" description = "Microbridge menu bar app (primary UI)" authors = ["Microbridge contributors"] edition = "2021" @@ -27,4 +27,10 @@ mb-protocol = { path = "../../../crates/mb-protocol" } [target.'cfg(target_os = "macos")'.dependencies] objc2-service-management = "0.3.2" +[profile.release] +# Cargo's macOS debuginfo strip pass can leave freshly built proc-macro dylibs +# unloadable before the app itself links. Release builds do not emit debug info, +# so skipping that redundant pass keeps clean Tauri builds deterministic. +strip = false + [workspace] diff --git a/apps/microbridge-ui/src-tauri/src/lib.rs b/apps/microbridge-ui/src-tauri/src/lib.rs index d97d70d..8173541 100644 --- a/apps/microbridge-ui/src-tauri/src/lib.rs +++ b/apps/microbridge-ui/src-tauri/src/lib.rs @@ -25,6 +25,8 @@ struct AppState { bus: BusHandle, snapshot: CachedSnapshot, bundled_daemon: StdMutex>, + supervise_bundled_daemon: bool, + shutting_down: AtomicBool, } fn daemon_socket_path() -> PathBuf { @@ -93,6 +95,54 @@ fn start_bundled_daemon() -> Option { spawn_owned_daemon(&binary) } +fn should_start_bundled_daemon( + daemon_reachable: bool, + owned_child_running: bool, + supervision_enabled: bool, + shutting_down: bool, +) -> bool { + supervision_enabled && !shutting_down && !daemon_reachable && !owned_child_running +} + +/// Keep an installed GUI usable if the daemon it initially discovered exits. +/// This matters during updater/login overlap: a new UI process may attach to +/// the old UI's child, then lose that daemon when the old process terminates. +fn spawn_bundled_daemon_supervisor(app: AppHandle) { + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(2)).await; + let Some(state) = app.try_state::() else { + return; + }; + if state.shutting_down.load(Ordering::Acquire) { + return; + } + if daemon_is_reachable() { + continue; + } + let Ok(mut child_slot) = state.bundled_daemon.lock() else { + continue; + }; + // Recheck after serializing with app shutdown/restart work. + let daemon_reachable = daemon_is_reachable(); + let owned_child_running = child_slot + .as_mut() + .is_some_and(|child| matches!(child.try_wait(), Ok(None))); + if !owned_child_running { + child_slot.take(); + } + if should_start_bundled_daemon( + daemon_reachable, + owned_child_running, + state.supervise_bundled_daemon, + state.shutting_down.load(Ordering::Acquire), + ) { + *child_slot = start_bundled_daemon(); + } + } + }); +} + const DAEMON_MIGRATION_MARKER: &str = "app-owned-daemon-v1"; fn daemon_migration_marker_path() -> PathBuf { @@ -338,6 +388,15 @@ mod daemon_migration_tests { ); } + #[test] + fn daemon_supervision_restarts_only_when_the_gui_should_own_recovery() { + assert!(should_start_bundled_daemon(false, false, true, false)); + assert!(!should_start_bundled_daemon(true, false, true, false)); + assert!(!should_start_bundled_daemon(false, true, true, false)); + assert!(!should_start_bundled_daemon(false, false, false, false)); + assert!(!should_start_bundled_daemon(false, false, true, true)); + } + #[test] fn successful_conversion_records_marker_then_removes_owned_plists() { let directory = test_directory("migration-success"); @@ -1631,8 +1690,7 @@ struct HardwareMenuPresentation { } fn is_physical_micro(device_name: &str) -> bool { - device_name.starts_with("codex-micro-") - || device_name.starts_with("creator-micro-v2-") + device_name.starts_with("codex-micro-") || device_name.starts_with("creator-micro-v2-") } fn hardware_menu_presentation_for( @@ -1982,12 +2040,12 @@ pub fn run() { } migrate_legacy_ui_login_item(); - let bundled_daemon = match migrate_legacy_daemon_to_app() { - Ok(Some(child)) => Some(child), - Ok(None) => start_bundled_daemon(), + let (bundled_daemon, supervise_bundled_daemon) = match migrate_legacy_daemon_to_app() { + Ok(Some(child)) => (Some(child), true), + Ok(None) => (start_bundled_daemon(), true), // The legacy service was restored; do not race it with a // second daemon if the migration could not be completed. - Err(()) => None, + Err(()) => (None, false), }; let (bus, mut event_rx) = spawn_bus_loop(); let snapshot: CachedSnapshot = Arc::new(Mutex::new(None)); @@ -2143,7 +2201,10 @@ pub fn run() { bus, snapshot, bundled_daemon: StdMutex::new(bundled_daemon), + supervise_bundled_daemon, + shutting_down: AtomicBool::new(false), }); + spawn_bundled_daemon_supervisor(app.handle().clone()); // Dedicated monochrome tray glyph (bridge silhouette), rendered as a // template image so macOS tints it for light/dark menu bars. The full @@ -2303,6 +2364,7 @@ pub fn run() { .run(|app, event| { if matches!(event, tauri::RunEvent::Exit) { if let Some(state) = app.try_state::() { + state.shutting_down.store(true, Ordering::Release); if let Ok(mut guard) = state.bundled_daemon.lock() { if let Some(mut child) = guard.take() { let _ = child.kill(); diff --git a/apps/microbridge-ui/src-tauri/tauri.conf.json b/apps/microbridge-ui/src-tauri/tauri.conf.json index 08ab5a2..d1ec1d8 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.9", + "version": "0.3.10", "identifier": "ai.microbridge.ui", "build": { "beforeDevCommand": "npm run dev", diff --git a/docs/releases/v0.3.10.md b/docs/releases/v0.3.10.md new file mode 100644 index 0000000..b4e9e3f --- /dev/null +++ b/docs/releases/v0.3.10.md @@ -0,0 +1,17 @@ +# Microbridge v0.3.10 + +## Daemon recovery + +- Restore the bundled daemon automatically when the UI loses a daemon that was + owned by an older overlapping app process during login, update, or relaunch. +- Preserve explicit standalone/headless daemon ownership while it remains + reachable; the GUI only starts its bundled daemon after that service exits. +- Stop daemon recovery during clean app shutdown so no replacement child can be + orphaned as Microbridge exits. + +## Hardware status + +- Keep Codex Micro USB and Bluetooth detection available after daemon recovery, + including the native Claim, Retry, and Release actions added in v0.3.9. +- Continue requesting only Input Monitoring for hardware control. Open at Login + may separately require approval in macOS Login Items.