diff --git a/Formula/microbridge.rb b/Formula/microbridge.rb index fbea108..3cfcd94 100644 --- a/Formula/microbridge.rb +++ b/Formula/microbridge.rb @@ -101,7 +101,8 @@ def caveats Status: microbridgectl status Config: ~/.microbridge/ - Start the service once to install the marker-owned app, then open it: + 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): brew services start microbridge open ~/Applications/Microbridge.app diff --git a/INSTALL.md b/INSTALL.md index fb1901f..848f922 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -132,6 +132,15 @@ Tauri build). The formula checksums are refreshed by CI after each `v*` tag. | `~/.microbridge/config.toml` | Key source, lighting, appearance | | `~/.microbridge/daemon.log` | launchd / service logs | | `~/.cursor/plugins/local/microbridge` | Bundled Cursor lifecycle integration (only after consent) | +| `~/Library/LaunchAgents/ai.microbridge.ui.plist` | Login item (only if you enable launch at login) | + +## 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. ## Troubleshooting diff --git a/apps/microbridge-ui/src-tauri/Cargo.lock b/apps/microbridge-ui/src-tauri/Cargo.lock index f00941c..edf338f 100644 --- a/apps/microbridge-ui/src-tauri/Cargo.lock +++ b/apps/microbridge-ui/src-tauri/Cargo.lock @@ -85,6 +85,17 @@ 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" @@ -568,13 +579,33 @@ 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", + "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", ] [[package]] @@ -585,7 +616,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -712,7 +743,7 @@ dependencies = [ "rustc_version", "toml 1.1.3+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1829,6 +1860,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-process", "tauri-plugin-shell", @@ -2504,6 +2536,17 @@ 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" @@ -3374,7 +3417,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3424,7 +3467,7 @@ checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3494,6 +3537,20 @@ 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.18", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.1" @@ -3574,7 +3631,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http", @@ -4057,7 +4114,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -4895,6 +4952,15 @@ 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" @@ -4927,7 +4993,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", diff --git a/apps/microbridge-ui/src-tauri/Cargo.toml b/apps/microbridge-ui/src-tauri/Cargo.toml index d4ab89c..70f56ca 100644 --- a/apps/microbridge-ui/src-tauri/Cargo.toml +++ b/apps/microbridge-ui/src-tauri/Cargo.toml @@ -22,5 +22,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "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" [workspace] diff --git a/apps/microbridge-ui/src-tauri/src/lib.rs b/apps/microbridge-ui/src-tauri/src/lib.rs index 176aec7..25a0e15 100644 --- a/apps/microbridge-ui/src-tauri/src/lib.rs +++ b/apps/microbridge-ui/src-tauri/src/lib.rs @@ -16,8 +16,9 @@ use mb_protocol::{BusEvent, ClientMessage, DaemonConfig, ServerMessage, Snapshot use tauri::{ menu::{ContextMenu, Menu, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, Position, Size, WebviewWindow, + AppHandle, Emitter, LogicalSize, Manager, PhysicalPosition, Position, Size, WebviewWindow, }; +use tauri_plugin_autostart::ManagerExt; use tokio::sync::Mutex; struct AppState { @@ -57,7 +58,9 @@ fn start_bundled_daemon() -> Option { PathBuf::from("/opt/homebrew/bin/microbridged"), PathBuf::from("/usr/local/bin/microbridged"), ]); - let binary = candidates.into_iter().find(|candidate| candidate.is_file())?; + let binary = candidates + .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() { @@ -93,7 +96,11 @@ fn validate_cursor_plugin(path: &Path) -> Result<(), String> { path.display() )); } - for relative in ["hooks/hooks.json", "hooks/microbridge-event.mjs", "hooks/event.mjs"] { + for relative in [ + "hooks/hooks.json", + "hooks/microbridge-event.mjs", + "hooks/event.mjs", + ] { if !path.join(relative).is_file() { return Err(format!("Cursor integration is missing {relative}")); } @@ -129,8 +136,7 @@ fn cursor_plugin_source(app: &AppHandle) -> Result { } // `tauri dev` reads the repository copy; release bundles use Resources. - let repository = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../../adapters/cursor"); + let repository = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../adapters/cursor"); validate_cursor_plugin(&repository)?; Ok(repository) } @@ -208,8 +214,7 @@ fn install_cursor_integration_at( return Err(format!("install Cursor integration: {error}")); } if backup.exists() { - fs::remove_dir_all(&backup) - .map_err(|e| format!("remove old Cursor integration: {e}"))?; + fs::remove_dir_all(&backup).map_err(|e| format!("remove old Cursor integration: {e}"))?; } Ok(()) } @@ -253,8 +258,7 @@ mod cursor_integration_tests { std::process::id() )); let destination = root.join("local/microbridge"); - let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../../adapters/cursor"); + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../adapters/cursor"); install_cursor_integration_at(&source, &destination, "0.2.1").unwrap(); validate_cursor_plugin(&destination).unwrap(); @@ -285,8 +289,7 @@ mod cursor_integration_tests { std::process::id() )); let destination = root.join("local/microbridge"); - let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../../adapters/cursor"); + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../adapters/cursor"); copy_dir(&source, &destination).unwrap(); assert!(install_cursor_integration_at(&source, &destination, "0.2.1").is_err()); @@ -321,11 +324,65 @@ fn physical_tray_rect(rect: &tauri::Rect, scale: f64) -> (f64, f64, f64, f64) { (x, y, w, h) } -fn position_below_tray(window: &WebviewWindow, tray_x: f64, tray_y: f64, tray_w: f64, tray_h: f64) { - let Ok(size) = window.outer_size() else { - return; +/// Popover geometry, in logical pixels. +const POPOVER_WIDTH: f64 = 380.0; +/// Below this the card has nothing useful to show, so never shrink past it. +const POPOVER_MIN_HEIGHT: f64 = 160.0; +/// Ceiling, not a target — the webview drives the real height via +/// `resize_popover`, so the window is usually well under this. Sized to clear +/// the tallest the card can legitimately be: ~438px of chrome (header, focused +/// thread, the device echo at ~150px, the simulator note, footer) plus the +/// 10-row thread viewport, plus the shadow slack the webview adds. +const POPOVER_MAX_HEIGHT: f64 = 780.0; +/// Gap between the menu bar icon and the top of the popover window. +const POPOVER_TRAY_GAP: f64 = 6.0; +/// Breathing room left between the popover and the bottom of the work area. +const POPOVER_BOTTOM_MARGIN: f64 = 12.0; + +/// Scale factor for `window`, falling back to its monitor and finally to 1.0. +/// +/// Assuming Retina would place the popover at half the intended offset on a 1x +/// external display. +fn window_scale(window: &WebviewWindow) -> f64 { + window + .scale_factor() + .ok() + .or_else(|| { + window + .current_monitor() + .ok() + .flatten() + .map(|m| m.scale_factor()) + }) + .unwrap_or(1.0) +} + +/// Logical height available between `top_y` (physical, in screen coordinates) +/// and the bottom of the monitor's work area — which on macOS already excludes +/// the menu bar and the Dock, so the popover lands above the Dock rather than +/// behind it. Falls back to the maximum when the monitor can't be read. +fn available_popover_height(window: &WebviewWindow, top_y: i32) -> f64 { + let Ok(Some(monitor)) = window.current_monitor() else { + return POPOVER_MAX_HEIGHT; }; - let width = f64::from(size.width); + let work = monitor.work_area(); + let bottom = work.position.y + work.size.height as i32; + let room = f64::from(bottom - top_y) / window_scale(window) - POPOVER_BOTTOM_MARGIN; + room.clamp(POPOVER_MIN_HEIGHT, POPOVER_MAX_HEIGHT) +} + +/// Room below the popover's current top edge, recomputed from where the window +/// actually sits — so moving between monitors needs no shared state. +fn popover_available_height(window: &WebviewWindow) -> f64 { + window + .outer_position() + .map(|pos| available_popover_height(window, pos.y)) + .unwrap_or(POPOVER_MAX_HEIGHT) +} + +fn position_below_tray(window: &WebviewWindow, tray_x: f64, tray_y: f64, tray_w: f64, tray_h: f64) { + let scale = window_scale(window); + let width = POPOVER_WIDTH * scale; let mut x = tray_x + tray_w / 2.0 - width / 2.0; if let Ok(Some(monitor)) = window.current_monitor() { let origin = monitor.position(); @@ -334,14 +391,39 @@ fn position_below_tray(window: &WebviewWindow, tray_x: f64, tray_y: f64, tray_w: let max_x = min_x + f64::from(screen.width) - width; x = x.clamp(min_x + 8.0, max_x - 8.0); } - let y = (tray_y + tray_h + 6.0).round() as i32; + let y = (tray_y + tray_h + POPOVER_TRAY_GAP * scale).round() as i32; let _ = window.set_position(Position::Physical(PhysicalPosition::new( x.round() as i32, y, ))); + // Size to the room actually left below the menu bar *before* showing, so + // the popover can't hang off the bottom of the screen even if the webview + // hasn't reported its content height yet. + let _ = window.set_size(LogicalSize::new( + POPOVER_WIDTH, + available_popover_height(window, y), + )); } -fn toggle_popover(app: &AppHandle, tray_x: f64, tray_y: f64, tray_w: f64, tray_h: f64) { +/// When the popover last hid itself because it lost focus. Shared by the blur +/// handler and the tray click handler; see `toggle_popover`. +type BlurHideClock = Arc>>; + +/// A click on the tray icon steals focus from the popover on mouse *down*, +/// which fires `Focused(false)` and hides it — before the tray's mouse *up* +/// click event arrives. By then the window reads as hidden, so a naive toggle +/// would reopen it and the popover could never be dismissed by clicking the +/// icon. Treat a click landing right after a blur-hide as the dismiss it was. +const BLUR_HIDE_DISMISS_WINDOW: Duration = Duration::from_millis(250); + +fn toggle_popover( + app: &AppHandle, + tray_x: f64, + tray_y: f64, + tray_w: f64, + tray_h: f64, + blur_hide: &BlurHideClock, +) { let Some(window) = app.get_webview_window("popover") else { return; }; @@ -349,7 +431,18 @@ fn toggle_popover(app: &AppHandle, tray_x: f64, tray_y: f64, tray_w: f64, tray_h let _ = window.hide(); return; } + let just_blurred = blur_hide + .lock() + .ok() + .and_then(|t| *t) + .is_some_and(|t| t.elapsed() < BLUR_HIDE_DISMISS_WINDOW); + if just_blurred { + return; + } position_below_tray(&window, tray_x, tray_y, tray_w, tray_h); + // The cap depends on the monitor the popover just landed on, so hand the + // webview the new one before it paints. + let _ = window.emit("popover-fit", popover_available_height(&window)); let _ = window.show(); let _ = window.set_focus(); } @@ -509,7 +602,9 @@ async fn forget_adapter( }) .await; } - return Err(format!("Adapter removal failed and the prior state was restored: {error}")); + return Err(format!( + "Adapter removal failed and the prior state was restored: {error}" + )); } }; if adapter_id == "cursor" && removed { @@ -532,33 +627,6 @@ async fn activate_agent_key( .await } -/// Fit the popover to its measured card while leaving a safety margin at the -/// bottom of the active display. The frontend gives only Threads the overflow. -#[tauri::command] -fn fit_popover(content_height: f64, app: AppHandle) -> Result<(), String> { - let window = app - .get_webview_window("popover") - .ok_or_else(|| "popover window unavailable".to_string())?; - let scale = window.scale_factor().map_err(|e| e.to_string())?; - let current_size = window.outer_size().map_err(|e| e.to_string())?; - let current_position = window.outer_position().map_err(|e| e.to_string())?; - let monitor = window - .current_monitor() - .map_err(|e| e.to_string())? - .ok_or_else(|| "active monitor unavailable".to_string())?; - let monitor_bottom = i64::from(monitor.position().y) + i64::from(monitor.size().height); - let safety_margin = (24.0 * scale).round() as i64; - let available = - (monitor_bottom - i64::from(current_position.y) - safety_margin).max(180) as u32; - let desired = (content_height.max(180.0) * scale).round() as u32; - window - .set_size(Size::Physical(PhysicalSize::new( - current_size.width, - desired.min(available), - ))) - .map_err(|e| e.to_string()) -} - /// Show the settings window (and hide the popover). Shared by the `open_settings` /// command and the tray right-click menu. fn show_settings_window(app: &AppHandle) { @@ -598,6 +666,30 @@ fn quit_ui(app: AppHandle) { app.exit(0); } +/// Height cap, in logical pixels, that the popover webview applies to its card. +#[tauri::command] +fn popover_max_height(app: AppHandle) -> f64 { + app.get_webview_window("popover") + .map(|window| popover_available_height(&window)) + .unwrap_or(POPOVER_MAX_HEIGHT) +} + +/// Resize the popover to hug its content. +/// +/// The webview measures its card and calls this, so the window is never taller +/// than what it draws — a transparent window is still hit-testable, and the +/// unused remainder of a fixed frame swallows clicks meant for the app +/// underneath. The clamp keeps the window inside the room below the menu bar, +/// so content can't push the footer off the bottom of the screen either. +#[tauri::command] +fn resize_popover(app: AppHandle, height: f64) { + let Some(window) = app.get_webview_window("popover") else { + return; + }; + let clamped = height.clamp(POPOVER_MIN_HEIGHT, popover_available_height(&window)); + let _ = window.set_size(LogicalSize::new(POPOVER_WIDTH, clamped)); +} + /// Install channel of the running app. Homebrew drops a `.microbridge-brew` /// marker at the bundle root (installed by the formula's service wrapper); its absence /// means a DMG/manual install. The in-app self-updater only replaces `direct` @@ -639,6 +731,71 @@ 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"; + +/// 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 { + 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) +} + +/// Whether a login item can meaningfully be registered for this build. +#[tauri::command] +fn can_launch_at_login() -> bool { + running_from_app_bundle() +} + +#[tauri::command] +fn launch_at_login_enabled(app: AppHandle) -> bool { + app.autolaunch().is_enabled().unwrap_or(false) +} + +#[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(); + } + 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. +#[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}")) + .status(); +} + +#[cfg(not(target_os = "macos"))] +fn bootout_login_item() {} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -646,6 +803,11 @@ pub fn run() { .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")] { @@ -699,11 +861,7 @@ pub fn run() { .get("cursor") .map(|preference| preference.enabled) .unwrap_or(false); - sync_cursor_integration( - &handle, - &cursor_sync_loop, - cursor_enabled, - ); + sync_cursor_integration(&handle, &cursor_sync_loop, cursor_enabled); } let focus_changed = matches!(&event, BusEvent::FocusChanged { .. }); let mut guard = snap_for_loop.lock().await; @@ -778,6 +936,9 @@ pub fn run() { )?; let context_menu = tray_menu.clone(); + let blur_hide: BlurHideClock = Arc::new(std::sync::Mutex::new(None)); + let blur_hide_tray = Arc::clone(&blur_hide); + let _tray = TrayIconBuilder::new() .icon(tray_icon) .icon_as_template(true) @@ -792,41 +953,39 @@ pub fn run() { "quit" => app.exit(0), _ => {} }) - .on_tray_icon_event(move |tray, event| { - match event { - TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - rect, - .. - } => { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - let previous = last_left_click_ms.swap(now, Ordering::Relaxed); - if now.saturating_sub(previous) < 180 { - return; - } - let app = tray.app_handle(); - let scale = app - .get_webview_window("popover") - .and_then(|w| w.scale_factor().ok()) - .unwrap_or(2.0); - let (x, y, w, h) = physical_tray_rect(&rect, scale); - toggle_popover(app, x, y, w, h); + .on_tray_icon_event(move |tray, event| match event { + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + rect, + .. + } => { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let previous = last_left_click_ms.swap(now, Ordering::Relaxed); + if now.saturating_sub(previous) < 180 { + return; } - TrayIconEvent::Click { - button: MouseButton::Right, - button_state: MouseButtonState::Up, - .. - } => { - if let Some(window) = tray.app_handle().get_webview_window("popover") { - let _ = context_menu.popup(window.as_ref().window()); - } + let app = tray.app_handle(); + let scale = app + .get_webview_window("popover") + .map(|w| window_scale(&w)) + .unwrap_or(1.0); + let (x, y, w, h) = physical_tray_rect(&rect, scale); + toggle_popover(app, x, y, w, h, &blur_hide_tray); + } + TrayIconEvent::Click { + button: MouseButton::Right, + button_state: MouseButtonState::Up, + .. + } => { + if let Some(window) = tray.app_handle().get_webview_window("popover") { + let _ = context_menu.popup(window.as_ref().window()); } - _ => {} } + _ => {} }) .build(app)?; @@ -834,6 +993,9 @@ pub fn run() { let popover_hide = popover.clone(); popover.on_window_event(move |event| { if let tauri::WindowEvent::Focused(false) = event { + if let Ok(mut last) = blur_hide.lock() { + *last = Some(std::time::Instant::now()); + } let _ = popover_hide.hide(); } }); @@ -858,13 +1020,17 @@ pub fn run() { pair_adapter, forget_adapter, activate_agent_key, - fit_popover, open_settings, close_settings, hide_popover, quit_ui, update_channel, - app_version + app_version, + launch_at_login_enabled, + set_launch_at_login, + can_launch_at_login, + popover_max_height, + resize_popover ]) .build(tauri::generate_context!()) .expect("error while building microbridge-ui") diff --git a/apps/microbridge-ui/src-tauri/tauri.conf.json b/apps/microbridge-ui/src-tauri/tauri.conf.json index 4c5d949..5a2ae2a 100644 --- a/apps/microbridge-ui/src-tauri/tauri.conf.json +++ b/apps/microbridge-ui/src-tauri/tauri.conf.json @@ -25,7 +25,7 @@ "skipTaskbar": true, "alwaysOnTop": true, "fullscreen": false, - "focus": false + "focus": true }, { "label": "settings", @@ -45,7 +45,7 @@ "title": "Microbridge Focus", "url": "index.html?view=hud", "width": 360, - "height": 120, + "height": 152, "resizable": false, "decorations": false, "transparent": true, diff --git a/apps/microbridge-ui/src/App.tsx b/apps/microbridge-ui/src/App.tsx index d5a4c31..01e7a03 100644 --- a/apps/microbridge-ui/src/App.tsx +++ b/apps/microbridge-ui/src/App.tsx @@ -7,15 +7,23 @@ import { setConfig, subscribeSnapshot, } from "./lib/bus"; +import { promptLaunchAtLoginOnce } from "./lib/autostart"; import { resolveAppearance } from "./lib/theme"; import type { DaemonConfig, Snapshot } from "./lib/types"; -import { autoCheckEnabled, runUpdateCheck } from "./lib/updater"; +import { runAutomaticUpdateCheck, runUpdateCheck } from "./lib/updater"; +import { Disconnected } from "./surfaces/Disconnected"; import { Hud } from "./surfaces/Hud"; import { Popover } from "./surfaces/Popover"; import { Settings } from "./surfaces/Settings"; type View = "popover" | "settings" | "hud"; -type SettingsTab = "keys" | "agent" | "adapters" | "device" | "updates"; +type SettingsTab = + | "general" + | "keys" + | "agent" + | "adapters" + | "device" + | "updates"; function initialView(): View { const q = new URLSearchParams(window.location.search).get("view"); @@ -31,11 +39,16 @@ export default function App() { useEffect(() => { let active = true; let unsub: (() => void) | undefined; - void subscribeSnapshot((snap) => { + subscribeSnapshot((snap) => { if (active) setSnapshot(snap); - }).then((u) => { - unsub = u; - }); + }).then( + (u) => { + unsub = u; + }, + () => { + /* no bus — the disconnected surface already says so */ + }, + ); return () => { active = false; unsub?.(); @@ -50,9 +63,10 @@ export default function App() { let disposed = false; let unlisten: (() => void) | undefined; - if (autoCheckEnabled()) { - void runUpdateCheck({ silent: true }); - } + void (async () => { + await promptLaunchAtLoginOnce(); + await runAutomaticUpdateCheck(); + })(); void (async () => { try { @@ -73,28 +87,33 @@ export default function App() { }; }, [view]); + // No snapshot means microbridged hasn't sent one yet. The HUD is a transient + // overlay with nothing to say here, so it stays blank; the other two surfaces + // explain the situation and offer a way out. if (!snapshot) { + if (view === "hud") return null; return ( -
- Connecting to microbridged… -
+ void quitUi()} + onOpenSettings={() => void openSettings()} + onClose={() => void closeSettings()} + /> ); } const dark = resolveAppearance(snapshot.config.appearance) === "dark"; + // On failure, leave local state alone so the control snaps back to what the + // daemon actually has, rather than showing a change that didn't take. const applyConfig = async (config: DaemonConfig) => { - const next = await setConfig(config); - setSnapshot({ ...snapshot, config: next }); + try { + const next = await setConfig(config); + setSnapshot({ ...snapshot, config: next }); + } catch { + /* daemon rejected the write — keep showing its last known config */ + } }; if (view === "hud") { diff --git a/apps/microbridge-ui/src/components/DeviceEcho.tsx b/apps/microbridge-ui/src/components/DeviceEcho.tsx index 1643b99..d8dc51f 100644 --- a/apps/microbridge-ui/src/components/DeviceEcho.tsx +++ b/apps/microbridge-ui/src/components/DeviceEcho.tsx @@ -7,8 +7,17 @@ import type { ThemeTokens } from "../lib/theme"; * White device in both themes; only Agent Key LEDs carry color. */ -const U = 26; -const GAP = 6; +/** + * Key unit and gap, in px. Every other measurement here is derived from these, + * so the deck scales as one piece. + * + * These were 26/6. The echo is the single tallest thing in the popover, and at + * that size it cost ~193px — enough that a 10-row thread list pushed the card + * past 760px, which is a lot of menu bar to take over. Scaled to 18/5 it reads + * the same and costs ~145px. + */ +const U = 18; +const GAP = 5; function MiniAgentKey({ index, @@ -42,7 +51,7 @@ function MiniAgentKey({ onDoubleClick={() => onActivate?.(index, true)} aria-label={session ? `Agent Key ${index + 1}: ${session.title || session.id}` : `Agent Key ${index + 1}: unassigned`} title={session ? `${session.app} · ${session.title || session.id}` : "Unassigned"} - className="relative rounded-[6px]" + className="relative rounded-[5px]" style={{ width: U, height: U, @@ -55,18 +64,18 @@ function MiniAgentKey({ > {color && ( )} @@ -76,7 +85,7 @@ function MiniAgentKey({ function MiniWhiteKey({ wide = false }: { wide?: boolean }) { return ( +
{ + try { + return (await invokeTauri("can_launch_at_login")) ?? false; + } catch { + return false; + } +} + +/** `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 { + await invokeTauri("set_launch_at_login", { enabled }); +} + +/** + * Ask once, on first launch, whether to start at login. + * + * Runs from the popover webview only — the same reasoning as the update check + * in App.tsx: it's the always-loaded window, so this fires once rather than + * once per window. Silently does nothing outside Tauri, in a dev build, when + * already answered, or when a login item already exists (an existing + * `install.sh` user has already expressed the preference; re-asking would be + * noise). + * + * The dev-build check deliberately runs *before* `markAsked()`, so developers + * still get the prompt the first time they run a real installed build. + */ +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) { + markAsked(); + return; + } + + try { + const { ask } = await import("@tauri-apps/plugin-dialog"); + const proceed = await ask( + "Start Microbridge automatically when you log in?\n\nIt lives in the menu bar, so it needs to be running to light up your deck. You can change this any time in Settings.", + { + title: "Launch at Login", + kind: "info", + okLabel: "Start at Login", + cancelLabel: "Not Now", + }, + ); + // Mark asked only once the user has actually answered, so a dialog that + // fails to open is retried next launch rather than silently swallowed. + markAsked(); + if (proceed) await setLaunchAtLogin(true); + } catch { + /* dialog unavailable — try again next launch */ + } +} diff --git a/apps/microbridge-ui/src/lib/bus.ts b/apps/microbridge-ui/src/lib/bus.ts index aa5fc76..da77aca 100644 --- a/apps/microbridge-ui/src/lib/bus.ts +++ b/apps/microbridge-ui/src/lib/bus.ts @@ -1,6 +1,12 @@ +import { hasTauri, invokeQuiet, invokeTauri } from "./tauri"; import type { DaemonConfig, Snapshot } from "./types"; -/** Talks to microbridged via Tauri when available; demo snapshot in browser only. */ +/** + * Talks to microbridged via Tauri when available; demo snapshot in browser only. + * + * "Browser only" is load-bearing: inside the app a daemon that isn't up yet must + * read as *not connected*, never as a populated deck. See `hasTauri` in ./tauri. + */ const DEMO: Snapshot = { sessions: [ @@ -144,95 +150,110 @@ const DAEMON_OFFLINE: Snapshot = { })), }; -function runningInTauri(): boolean { - return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +/** + * Demo snapshot, optionally padded out to `?threads=N` sessions. + * + * Three sessions aren't enough to exercise the popover's scrolling thread list + * in a browser preview, and this is the only place that can produce sessions + * without a daemon. Reachable only outside Tauri, so it can't leak into the app. + */ +function demoSnapshot(): Snapshot { + const requested = Number( + new URLSearchParams(window.location.search).get("threads"), + ); + if (!Number.isFinite(requested) || requested <= DEMO.sessions.length) { + return DEMO; + } + const sessions = Array.from({ length: Math.min(requested, 200) }, (_, i) => { + const base = DEMO.sessions[i % DEMO.sessions.length]; + return i < DEMO.sessions.length + ? base + : { ...base, id: `${base.id}-${i}`, title: `${base.title} (${i + 1})` }; + }); + return { ...DEMO, sessions }; } -async function invoke(cmd: string, args?: Record): Promise { +/** + * Current snapshot, or `null` when microbridged hasn't sent one yet. + * + * Outside Tauri this is the demo snapshot so the surfaces are previewable in a + * browser. Inside Tauri, `get_snapshot` rejects with "waiting for microbridged" + * until the first snapshot lands — that rejection must stay visible as `null`. + */ +export async function fetchSnapshot(): Promise { + if (!hasTauri()) return demoSnapshot(); try { - const { invoke } = await import("@tauri-apps/api/core"); - return await invoke(cmd, args); + return await invokeTauri("get_snapshot"); } catch { return null; } } - -export async function fetchSnapshot(): Promise { - const snap = await invoke("get_snapshot"); - if (snap) return snap; - if (runningInTauri()) throw new Error("waiting for microbridged"); - return DEMO; -} - export function isDemoSnapshot(snapshot: Snapshot): boolean { return snapshot.device_name === "demo-browser"; } +/** Rejects if the daemon refuses the write, so the UI can revert rather than lie. */ export async function setConfig(config: DaemonConfig): Promise { - const next = await invoke("set_config", { config }); - return next ?? config; + const next = await invokeTauri("set_config", { config }); + return next ?? config; // no Tauri: browser preview, echo the optimistic value } export async function setAdapterEnabled(adapterId: string, enabled: boolean): Promise { - const message = await invoke("set_adapter_enabled", { adapterId, enabled }); + const message = await invokeTauri("set_adapter_enabled", { adapterId, enabled }); if (message === null) throw new Error("Adapter controls require the Microbridge app."); return message; } export async function pairAdapter(adapterId: string, pairingUrl: string): Promise { - const message = await invoke("pair_adapter", { adapterId, pairingUrl }); + const message = await invokeTauri("pair_adapter", { adapterId, pairingUrl }); if (message === null) throw new Error("Pairing requires the Microbridge app."); return message; } export async function forgetAdapter(adapterId: string): Promise { - const message = await invoke("forget_adapter", { adapterId }); + const message = await invokeTauri("forget_adapter", { adapterId }); if (message === null) throw new Error("Adapter controls require the Microbridge app."); return message; } export async function activateAgentKey(index: number, open = false): Promise { - const message = await invoke("activate_agent_key", { index, open }); + const message = await invokeTauri("activate_agent_key", { index, open }); if (message === null) throw new Error("Agent Key simulation requires the Microbridge app."); return message; } -export async function fitPopover(contentHeight: number): Promise { - await invoke("fit_popover", { contentHeight }); -} - export async function openSettings(): Promise { - await invoke("open_settings"); + await invokeQuiet("open_settings"); } export async function closeSettings(): Promise { - await invoke("close_settings"); + await invokeQuiet("close_settings"); } export async function quitUi(): Promise { - await invoke("quit_ui"); + await invokeQuiet("quit_ui"); } /** Subscribe to live bus snapshots. Returns an unsubscribe fn. */ export async function subscribeSnapshot( onSnapshot: (snapshot: Snapshot) => void, ): Promise<() => void> { - if (!runningInTauri()) { - onSnapshot(DEMO); - const id = window.setInterval(() => onSnapshot(DEMO), 2000); - return () => window.clearInterval(id); + if (!hasTauri()) { + onSnapshot(demoSnapshot()); + return () => {}; } try { const { listen } = await import("@tauri-apps/api/event"); + let receivedLiveSnapshot = false; const unlisten = await listen("bus-snapshot", (event) => { + receivedLiveSnapshot = true; onSnapshot(event.payload); }); - try { - onSnapshot(await fetchSnapshot()); - } catch { - // Keep the listener: the native reconnect loop will publish a real - // snapshot as soon as the bundled or installed daemon is reachable. - onSnapshot(DAEMON_OFFLINE); + // Keep the listener when the daemon is offline; the native reconnect loop + // publishes a real snapshot as soon as it becomes reachable. + const initialSnapshot = await fetchSnapshot(); + if (!receivedLiveSnapshot) { + onSnapshot(initialSnapshot ?? DAEMON_OFFLINE); } return unlisten; } catch { diff --git a/apps/microbridge-ui/src/lib/popoverFit.ts b/apps/microbridge-ui/src/lib/popoverFit.ts new file mode 100644 index 0000000..a7f4941 --- /dev/null +++ b/apps/microbridge-ui/src/lib/popoverFit.ts @@ -0,0 +1,136 @@ +/** + * Fitting the popover window to its content and to the screen. + * + * The popover window used to be a fixed 380×540 frame that `position_below_tray` + * only ever moved. That was wrong in both directions: on a short display (or + * with a large Dock) the card ran past the bottom edge and took the footer with + * it, and when the card was short the leftover transparent frame stayed + * hit-testable, swallowing clicks meant for the app underneath. + * + * So the window is now a function of two things: what the card actually draws + * (measured here) and how much room is left below the menu bar (measured in + * Rust from the monitor work area, which on macOS already excludes the Dock). + * See `resize_popover` / `popover_max_height` in src-tauri/src/lib.rs. + */ + +import { useEffect, useRef, useState } from "react"; +import { invokeQuiet, invokeTauri } from "./tauri"; + +/** + * Space the window needs beyond the card itself: the wrapper's `pt-1` (4px), + * plus slack for the card's drop shadow (`0 24px 64px` reaches ~56px below it). + * Without the slack a window hugging the card would clip its own shadow. + * + * One constant governs both directions — it's added when asking for a window + * size and subtracted when capping the card — so the two can't drift apart. + */ +export const POPOVER_CHROME = 4 + 52; + +/** + * Card height below which the popover has to choose what to spend space on. + * + * The device echo costs ~150px with its padding. On a card this short, keeping + * it leaves under three thread rows — and on the shortest screens it squeezed + * the list to nothing at all. The thread list is why the popover is open, so + * below this the echo gives way. Only reachable on a small 1x display or with a + * very large Dock; every current Mac display clears it comfortably. + */ +export const COMPACT_CARD_HEIGHT = 520; + +/** + * @param enabled Pass false when the surface is rendering into some other + * window. `Disconnected` renders in both the popover and the settings window, + * and `resize_popover` addresses the popover by label — a settings webview + * calling it would resize the wrong window. + */ +export function usePopoverFit(enabled = true) { + const ref = useRef(null); + const [windowHeight, setWindowHeight] = useState(null); + const [viewport, setViewport] = useState(() => { + const height = typeof window === "undefined" ? 0 : window.innerHeight; + // SSR and the Node test renderer have no meaningful viewport. Treat that + // as normal-sized; compact mode should only follow a real measurement. + return height > 0 ? height : COMPACT_CARD_HEIGHT + POPOVER_CHROME + 1; + }); + + // The cap depends on which monitor the popover is on, so besides the initial + // read we listen for `popover-fit`, which Rust emits each time the popover is + // positioned — that's when it may have moved to a different display. + useEffect(() => { + if (!enabled) return; + let active = true; + let unlisten: (() => void) | undefined; + + void (async () => { + try { + const height = await invokeTauri("popover_max_height"); + if (active && height !== null) setWindowHeight(height); + } catch { + /* no cap available — the CSS viewport fallback still applies */ + } + })(); + + void (async () => { + try { + const { listen } = await import("@tauri-apps/api/event"); + const un = await listen("popover-fit", (event) => { + if (active) setWindowHeight(event.payload); + }); + if (active) unlisten = un; + else un(); + } catch { + /* not running under Tauri */ + } + })(); + + return () => { + active = false; + unlisten?.(); + }; + }, [enabled]); + + // Browser-preview fallback for the cap below. Under Tauri the cap comes from + // the monitor, not from the window, so tracking the viewport there would only + // re-render on every resize we ourselves asked for. + useEffect(() => { + if (windowHeight !== null) return; + const onResize = () => setViewport(window.innerHeight); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [windowHeight]); + + // Report the card's height so the window hugs it. This fires while the + // popover is hidden too, so by the time the tray icon is clicked the window + // is normally already the right size — no resize flash on open. + useEffect(() => { + if (!enabled) return; + const el = ref.current; + if (!el || typeof ResizeObserver === "undefined") return; + + let last = 0; + const observer = new ResizeObserver(() => { + // getBoundingClientRect over contentRect: the card has a border, and the + // window has to contain it. + const height = el.getBoundingClientRect().height; + if (height <= 0 || Math.abs(height - last) < 1) return; + last = height; + void invokeQuiet("resize_popover", { height: height + POPOVER_CHROME }); + }); + observer.observe(el); + return () => observer.disconnect(); + }, [enabled]); + + // What the card is allowed to be, in px. Outside Tauri that's the CSS + // fallback's value (`max-h-[calc(100vh-8px)]`) so a browser preview is honest + // about how cramped a given height is. + const cardCap = + windowHeight === null ? viewport - 8 : windowHeight - POPOVER_CHROME; + + return { + ref, + /** Card cap in px, or undefined outside Tauri (CSS falls back to the viewport). */ + maxHeight: windowHeight === null ? undefined : windowHeight - POPOVER_CHROME, + /** Too little room to draw everything — drop what isn't the thread list. */ + compact: cardCap < COMPACT_CARD_HEIGHT, + }; +} diff --git a/apps/microbridge-ui/src/lib/tauri.ts b/apps/microbridge-ui/src/lib/tauri.ts new file mode 100644 index 0000000..85e684a --- /dev/null +++ b/apps/microbridge-ui/src/lib/tauri.ts @@ -0,0 +1,41 @@ +/** + * Shared Tauri-runtime helpers. + * + * Every surface also runs in a plain browser (`npm run dev` + `?view=…`), so + * Tauri imports are dynamic and guarded. The distinction this module exists to + * enforce is between "there is no Tauri runtime" and "a command ran and + * failed" — collapsing those two is what let the browser demo snapshot render + * inside the real app whenever microbridged wasn't up yet. + */ + +/** True when running inside the Tauri webview (v2 injects this global). */ +export function hasTauri(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +/** + * Invoke a Tauri command. + * + * Returns `null` **only** when there is no Tauri runtime. A command that runs + * and fails rejects, so callers can tell a real error from a browser preview. + */ +export async function invokeTauri( + cmd: string, + args?: Record, +): Promise { + if (!hasTauri()) return null; + const { invoke } = await import("@tauri-apps/api/core"); + return await invoke(cmd, args); +} + +/** Fire-and-forget variant for window controls, where a failure isn't worth surfacing. */ +export async function invokeQuiet( + cmd: string, + args?: Record, +): Promise { + try { + await invokeTauri(cmd, args); + } catch { + /* window control failed — nothing useful to tell the user */ + } +} diff --git a/apps/microbridge-ui/src/lib/threads.ts b/apps/microbridge-ui/src/lib/threads.ts index f867792..1a61fac 100644 --- a/apps/microbridge-ui/src/lib/threads.ts +++ b/apps/microbridge-ui/src/lib/threads.ts @@ -1,5 +1,22 @@ import type { SessionStatus, Snapshot } from "./types"; +/** + * Rows visible in the popover before the list scrolls. The ranking below is + * what makes a viewport this size workable: whatever you're most likely to be + * looking for is already at the top. + */ +export const VISIBLE_THREAD_ROWS = 10; + +/** Fixed row height, so `VISIBLE_THREAD_ROWS` is exact rather than approximate. */ +export const THREAD_ROW_HEIGHT = 28; + +/** + * Safety valve, not a product limit — the list scrolls, so threads past the + * viewport are still reachable. This only exists so a runaway session count + * can't put thousands of rows in the DOM. + */ +const RENDER_LIMIT = 50; + function rank(session: SessionStatus, snapshot: Snapshot, onKeys: Set): number { let score = 0; if (session.id === snapshot.focused_session_id) score += 1000; @@ -27,6 +44,7 @@ function rank(session: SessionStatus, snapshot: Snapshot, onKeys: Set): /** Threads shown in the menu bar popover — focused / on keys / active first. */ export function visibleThreads( snapshot: Snapshot, + limit = RENDER_LIMIT, ): { threads: SessionStatus[]; total: number; truncated: boolean } { const onKeys = new Set( snapshot.agent_key_session_ids.filter((id): id is string => Boolean(id)), @@ -36,7 +54,7 @@ export function visibleThreads( if (diff !== 0) return diff; return b.updated_at_ms - a.updated_at_ms; }); - const threads = ranked; + const threads = ranked.slice(0, limit); return { threads, total: snapshot.sessions.length, diff --git a/apps/microbridge-ui/src/lib/updater.test.ts b/apps/microbridge-ui/src/lib/updater.test.ts new file mode 100644 index 0000000..55069de --- /dev/null +++ b/apps/microbridge-ui/src/lib/updater.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { automaticUpdateCheckDue } from "./updater"; + +const DAY = 24 * 60 * 60 * 1000; + +describe("automaticUpdateCheckDue", () => { + it("checks when there is no prior attempt", () => { + expect(automaticUpdateCheckDue(null, DAY)).toBe(true); + }); + + it("waits for 24 hours between attempts", () => { + expect(automaticUpdateCheckDue(String(DAY), DAY * 2 - 1)).toBe(false); + expect(automaticUpdateCheckDue(String(DAY), DAY * 2)).toBe(true); + }); + + it("recovers when the system clock moves backwards", () => { + expect(automaticUpdateCheckDue(String(DAY * 2), DAY)).toBe(true); + }); +}); diff --git a/apps/microbridge-ui/src/lib/updater.ts b/apps/microbridge-ui/src/lib/updater.ts index 14af5d7..a5468f5 100644 --- a/apps/microbridge-ui/src/lib/updater.ts +++ b/apps/microbridge-ui/src/lib/updater.ts @@ -9,6 +9,21 @@ */ const AUTO_CHECK_KEY = "microbridge.autoCheckUpdates"; +const LAST_AUTO_CHECK_KEY = "microbridge.lastAutoCheckAt"; +const AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; + +export function automaticUpdateCheckDue( + lastAttempt: string | null, + now = Date.now(), +): boolean { + const last = Number(lastAttempt); + return ( + !Number.isFinite(last) || + last <= 0 || + last > now || + now - last >= AUTO_CHECK_INTERVAL_MS + ); +} /** Opt-in launch check — defaults off to honor "no update pings" by default. */ export function autoCheckEnabled(): boolean { @@ -27,6 +42,28 @@ export function setAutoCheckEnabled(value: boolean): void { } } +/** + * Run the opt-in background check at most once per 24 hours. + * + * The timestamp is recorded when the attempt starts, not only on success, so a + * temporary network failure cannot turn every app restart into another ping. + * Manual checks bypass this throttle. + */ +export async function runAutomaticUpdateCheck(): Promise { + if (!autoCheckEnabled()) return; + try { + const now = Date.now(); + if (!automaticUpdateCheckDue(localStorage.getItem(LAST_AUTO_CHECK_KEY), now)) { + return; + } + localStorage.setItem(LAST_AUTO_CHECK_KEY, String(now)); + } catch { + // Without durable storage there is no honest way to enforce once a day. + return; + } + await runUpdateCheck({ silent: true }); +} + async function invokeCmd(cmd: string): Promise { try { const { invoke } = await import("@tauri-apps/api/core"); diff --git a/apps/microbridge-ui/src/surfaces/Disconnected.tsx b/apps/microbridge-ui/src/surfaces/Disconnected.tsx new file mode 100644 index 0000000..b40472f --- /dev/null +++ b/apps/microbridge-ui/src/surfaces/Disconnected.tsx @@ -0,0 +1,148 @@ +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. + * + * This state used to be nearly unreachable: the bus layer fell back to the + * browser demo snapshot on *any* failure, so a fresh install with no daemon + * running rendered three fabricated sessions. The fallback is now scoped to + * "no Tauri runtime", which makes this the honest answer inside the app. + */ +export function Disconnected({ + dark, + view, + onQuit, + onOpenSettings, + onClose, +}: { + dark: boolean; + view: "popover" | "settings"; + onQuit: () => void; + onOpenSettings: () => void; + onClose: () => void; +}) { + const t = dark ? DARK : LIGHT; + // This surface renders in the settings window too, where it must not touch + // the popover's size. + 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 ( +
+
+
+ + Microbridge + + + + Not running + +
+ +
+

+ Waiting for microbridged +

+

+ 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. +

+ + {startCommand && ( +
+              {startCommand}
+            
+ )} +
+ +
+ + + + +
+
+
+ ); +} + +function FooterButton({ + label, + onClick, + t, +}: { + label: string; + onClick: () => void; + t: ThemeTokens; +}) { + return ( + + ); +} diff --git a/apps/microbridge-ui/src/surfaces/Popover.tsx b/apps/microbridge-ui/src/surfaces/Popover.tsx index f6723c8..1ee3d7b 100644 --- a/apps/microbridge-ui/src/surfaces/Popover.tsx +++ b/apps/microbridge-ui/src/surfaces/Popover.tsx @@ -1,10 +1,13 @@ -import { useEffect, useRef } from "react"; import type { Snapshot } from "../lib/types"; import { STATE_COLORS, STATE_LABELS, elapsed } from "../lib/types"; import { DARK, LIGHT, type ThemeTokens } from "../lib/theme"; -import { visibleThreads } from "../lib/threads"; +import { + THREAD_ROW_HEIGHT, + VISIBLE_THREAD_ROWS, + visibleThreads, +} from "../lib/threads"; +import { usePopoverFit } from "../lib/popoverFit"; import { DeviceEcho } from "../components/DeviceEcho"; -import { fitPopover } from "../lib/bus"; const MicroGlyph = ({ color }: { color: string }) => ( void; }) { const t = dark ? DARK : LIGHT; + const { ref: cardRef, maxHeight, compact } = usePopoverFit(); const demo = snapshot.device_name === "demo-browser"; const simulator = snapshot.device_name === "mock" || demo; const daemonOffline = snapshot.device_name === "daemon-offline"; @@ -106,29 +110,6 @@ export function Popover({ ); const liveCount = snapshot.agent_key_session_ids.filter(Boolean).length; const { threads, total, truncated } = visibleThreads(snapshot); - const adapterLayoutKey = snapshot.adapters - .map((adapter) => `${adapter.id}:${adapter.state}:${adapter.diagnostic}`) - .join("|"); - const cardRef = useRef(null); - const threadListRef = useRef(null); - - useEffect(() => { - const card = cardRef.current; - if (!card) return; - const measure = () => { - const threadList = threadListRef.current; - const current = card.getBoundingClientRect().height; - const desired = threadList - ? current - threadList.clientHeight + threadList.scrollHeight + 8 - : card.scrollHeight + 8; - void fitPopover(Math.ceil(desired)); - }; - const observer = new ResizeObserver(measure); - observer.observe(card); - if (threadListRef.current) observer.observe(threadListRef.current); - measure(); - return () => observer.disconnect(); - }, [threads.length, snapshot.device_connected, snapshot.device_name, adapterLayoutKey]); const footerButton = ( label: string, @@ -159,10 +140,16 @@ export function Popover({ className="flex h-screen w-full items-start justify-center overflow-hidden bg-transparent pt-1" style={{ fontFamily: "Inter, system-ui, sans-serif" }} > + {/* The thread list scrolls so the footer stays put — without this, a full + list pushed Settings/Pause/Quit off the bottom of a fixed window. + `maxHeight` is the room left below the menu bar on this monitor; the + class is the browser-preview fallback for when there's no Tauri to + ask. The window itself then hugs this card — see usePopoverFit. */}
-
- -
+ {/* First thing to go when the screen can't hold everything — see + COMPACT_CARD_HEIGHT. Threads outrank the device picture. */} + {!compact && ( +
+ +
+ )}
-
+ {/* Outside the scroll region: with a scrolling list this label + would otherwise scroll away from the rows it describes. */} +
{liveCount} on keys - {truncated ? ` · ${threads.length}/${total}` : ""} + {truncated + ? ` · ${threads.length}/${total}` + : total > VISIBLE_THREAD_ROWS + ? ` · ${total} threads` + : ""}
- {threads.length === 0 ? ( -

- No live sessions -

- ) : ( -
- {threads.map((s) => ( + {/* Shows VISIBLE_THREAD_ROWS and scrolls for the rest. A shorter + screen shrinks the card first, so this is an upper bound. */} +
+ {threads.length === 0 ? ( +

+ No live sessions +

+ ) : ( + threads.map((s) => ( - ))} -
- )} + )) + )} +
) : ( @@ -366,7 +373,7 @@ export function Popover({ )}
{footerButton("Settings", onOpenSettings)} diff --git a/apps/microbridge-ui/src/surfaces/Settings.tsx b/apps/microbridge-ui/src/surfaces/Settings.tsx index 5be0778..6ff88f9 100644 --- a/apps/microbridge-ui/src/surfaces/Settings.tsx +++ b/apps/microbridge-ui/src/surfaces/Settings.tsx @@ -21,6 +21,11 @@ import { type ControlId, } from "../components/DeviceTwin"; import { forgetAdapter, pairAdapter, setAdapterEnabled } from "../lib/bus"; +import { + canLaunchAtLogin, + launchAtLoginEnabled, + setLaunchAtLogin, +} from "../lib/autostart"; const LIGHTING_STATES: { id: keyof StateColors; label: string }[] = [ { id: "idle", label: "Idle" }, @@ -73,7 +78,7 @@ const KEY_SOURCES: { }, ]; -type Tab = "keys" | "agent" | "adapters" | "device" | "updates"; +type Tab = "general" | "keys" | "agent" | "adapters" | "device" | "updates"; export function Settings({ snapshot, @@ -105,10 +110,17 @@ export function Settings({ const [pairingUrl, setPairingUrl] = useState(""); const [adapterMessage, setAdapterMessage] = useState(null); const [adapterBusy, setAdapterBusy] = useState>(() => new Set()); + // 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); useEffect(() => { void appVersion().then(setVersion); void updateChannel().then(setChannel); + void canLaunchAtLogin().then(async (supported) => { + if (supported) setAtLogin(await launchAtLoginEnabled()); + }); }, []); const runAdapterOperation = async (adapterId: string, work: () => Promise) => { @@ -127,7 +139,20 @@ 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); + try { + await setLaunchAtLogin(next); + } catch { + /* fall through to the re-read below */ + } + setAtLogin(await launchAtLoginEnabled()); + }; + const tabs: { id: Tab; label: string }[] = [ + { id: "general", label: "General" }, { id: "keys", label: "Keys" }, { id: "agent", label: "Agent Keys" }, { id: "adapters", label: "Adapters" }, @@ -177,6 +202,45 @@ export function Settings({
+ {tab === "general" && ( +
+

General

+

+ Microbridge lives in the menu bar — it has to be running to drive + your deck. +

+ + +
+ )} + {tab === "keys" && (
@@ -725,15 +789,15 @@ export function Settings({ setAutoCheckEnabled(e.target.checked); }} /> - Check for updates automatically when Microbridge starts + Check for updates automatically (at most once a day)

- Off by default. When on, Microbridge quietly checks once at launch - and only speaks up if an update is ready. No background polling, - ever. + Off by default. When on, Microbridge quietly checks at launch only + if 24 hours have passed, and speaks up only when an update is ready. + No background polling, ever.

)} diff --git a/apps/microbridge-ui/src/surfaces/surfaces.test.tsx b/apps/microbridge-ui/src/surfaces/surfaces.test.tsx index 7b2f441..4211b61 100644 --- a/apps/microbridge-ui/src/surfaces/surfaces.test.tsx +++ b/apps/microbridge-ui/src/surfaces/surfaces.test.tsx @@ -127,6 +127,28 @@ describe("Popover", () => { expect(html.match(/overflow-y-auto/g)).toHaveLength(1); }); + it("caps a runaway thread list while reporting the full total", () => { + const sessions = Array.from({ length: 60 }, (_, index): SessionStatus => ({ + id: `thread-${index}`, + app: "Codex", + title: `Safety row ${index}`, + state: "idle", + updated_at_ms: 60_000 - index, + })); + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Safety row 49"); + expect(html).not.toContain("Safety row 50"); + expect(html).toContain("50/60"); + }); + it("renders the daemon LED frame and clickable Agent Key assignment", () => { const live = snapshot([ { diff --git a/scripts/install-from-release.sh b/scripts/install-from-release.sh index e11af22..c1cab7e 100755 --- a/scripts/install-from-release.sh +++ b/scripts/install-from-release.sh @@ -7,7 +7,6 @@ REPO="${MICROBRIDGE_REPO:-DevVig/microbridge}" BIN_DIR="${MICROBRIDGE_BIN:-$HOME/.local/bin}" TAG="${1:-}" LABEL="ai.microbridge.daemon" -UI_LABEL="ai.microbridge.ui" need() { command -v "$1" >/dev/null 2>&1 || { @@ -120,29 +119,10 @@ EOF cp -R "$APP_SRC" "$DEST" xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true echo "owned-by-release" >"$MARKER" - UI_PLIST="$HOME/Library/LaunchAgents/${UI_LABEL}.plist" - cat >"$UI_PLIST" < - - - - Label - ${UI_LABEL} - ProgramArguments - - ${HOME}/Applications/Microbridge.app/Contents/MacOS/microbridge-ui - - RunAtLoad - - KeepAlive - - - -EOF - launchctl bootout "gui/$(id -u)/${UI_LABEL}" 2>/dev/null || true - launchctl bootstrap "gui/$(id -u)" "$UI_PLIST" - launchctl enable "gui/$(id -u)/${UI_LABEL}" - launchctl kickstart -k "gui/$(id -u)/${UI_LABEL}" 2>/dev/null || open "$HOME/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), so brew/DMG/source all behave the same. + 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 fcf00c2..7a593b1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -7,7 +7,6 @@ BIN_DIR="${MICROBRIDGE_BIN:-$HOME/.local/bin}" WITH_UI=1 WITH_LAUNCHD=1 LABEL="ai.microbridge.daemon" -UI_LABEL="ai.microbridge.ui" usage() { cat <"$UI_PLIST" < - - - - Label - ${UI_LABEL} - ProgramArguments - - ${HOME}/Applications/Microbridge.app/Contents/MacOS/microbridge-ui - - RunAtLoad - - KeepAlive - - - -EOF - launchctl bootout "gui/$(id -u)/${UI_LABEL}" 2>/dev/null || true - launchctl bootstrap "gui/$(id -u)" "$UI_PLIST" - launchctl enable "gui/$(id -u)/${UI_LABEL}" - launchctl kickstart -k "gui/$(id -u)/${UI_LABEL}" 2>/dev/null || open "$HOME/Applications/Microbridge.app" - echo " menu bar app set to launch at login" - else - open "$HOME/Applications/Microbridge.app" 2>/dev/null || true - fi + # 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 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"