From a42013fd44975819140f547b4bcc69efe1f7f43b Mon Sep 17 00:00:00 2001 From: Jonathan Borgwing Date: Wed, 22 Jul 2026 20:59:38 -0400 Subject: [PATCH 1/3] fix: detect and control Codex Micro over HID (v0.3.9) --- Cargo.lock | 10 +- Cargo.toml | 2 +- INSTALL.md | 9 +- .../claude/hooks/microbridge-permission.mjs | 2 +- adapters/sdk/package.json | 2 +- apps/microbridge-ui/package-lock.json | 4 +- apps/microbridge-ui/package.json | 2 +- apps/microbridge-ui/src-tauri/Cargo.lock | 4 +- apps/microbridge-ui/src-tauri/Cargo.toml | 2 +- apps/microbridge-ui/src-tauri/src/lib.rs | 50 +++++++- apps/microbridge-ui/src-tauri/tauri.conf.json | 2 +- apps/microbridge-ui/src/App.tsx | 30 ++++- .../src/components/DeviceEcho.tsx | 8 +- .../src/lib/hardwareControl.test.ts | 17 ++- .../microbridge-ui/src/lib/hardwareControl.ts | 22 +++- .../src/surfaces/Disconnected.tsx | 2 +- apps/microbridge-ui/src/surfaces/Popover.tsx | 10 +- apps/microbridge-ui/src/surfaces/Settings.tsx | 11 +- .../src/surfaces/surfaces.test.tsx | 16 +++ crates/mb-device/src/claim.rs | 8 ++ crates/mb-device/src/lib.rs | 76 +++++------ crates/mb-device/src/probe.rs | 72 ++++++++++- crates/microbridged/src/main.rs | 5 + crates/microbridged/src/state.rs | 120 +++++++++++++++++- docs/architecture.md | 2 +- docs/design/README.md | 3 +- docs/device-hid.md | 11 +- docs/governance.md | 4 +- docs/hardware-bringup.md | 14 +- docs/releases/v0.3.9.md | 22 ++++ scripts/install-from-release.sh | 9 +- scripts/uninstall.sh | 7 + 32 files changed, 457 insertions(+), 101 deletions(-) create mode 100644 docs/releases/v0.3.9.md diff --git a/Cargo.lock b/Cargo.lock index a14ab19..ba3694a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -616,7 +616,7 @@ dependencies = [ [[package]] name = "mb-adapters" -version = "0.3.8" +version = "0.3.9" dependencies = [ "mb-protocol", "notify", @@ -628,7 +628,7 @@ dependencies = [ [[package]] name = "mb-device" -version = "0.3.8" +version = "0.3.9" dependencies = [ "hidapi", "mb-protocol", @@ -639,7 +639,7 @@ dependencies = [ [[package]] name = "mb-protocol" -version = "0.3.8" +version = "0.3.9" dependencies = [ "serde", "serde_json", @@ -653,7 +653,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "microbridgectl" -version = "0.3.8" +version = "0.3.9" dependencies = [ "mb-device", "mb-protocol", @@ -663,7 +663,7 @@ dependencies = [ [[package]] name = "microbridged" -version = "0.3.8" +version = "0.3.9" dependencies = [ "keyring", "mb-adapters", diff --git a/Cargo.toml b/Cargo.toml index 107e763..2288f96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.3.8" +version = "0.3.9" edition = "2021" license = "MIT" repository = "https://github.com/DevVig/microbridge" diff --git a/INSTALL.md b/INSTALL.md index f4121cc..29a2f7c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -39,6 +39,7 @@ Uninstall: ```sh microbridge-app uninstall +brew services stop microbridge # no-op unless headless mode was enabled brew uninstall microbridge # optional: brew untap DevVig/microbridge ``` @@ -58,7 +59,7 @@ 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; claim it from the popover, the menu-bar icon’s right-click menu, or **Settings → Device** (`MICROBRIDGE_HID_CLAIM=1` remains a developer override) | +| Hardware LEDs/keys | Codex Micro over USB-C or Bluetooth; 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) @@ -197,9 +198,11 @@ headless operation: brew services restart microbridge ``` -**LEDs stay dark** — by default Microbridge only probes USB (Detected). Choose +**LEDs stay dark** — by default Microbridge detects but does not control the +USB/Bluetooth HID interface. 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 +busy, allow Microbridge under **System Settings → Privacy & Security → Input +Monitoring**, 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/adapters/claude/hooks/microbridge-permission.mjs b/adapters/claude/hooks/microbridge-permission.mjs index 4e5e1b1..3d9ac54 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.8", + adapter_version: "0.3.9", capabilities: {}, }, { diff --git a/adapters/sdk/package.json b/adapters/sdk/package.json index 361f247..0b928b0 100644 --- a/adapters/sdk/package.json +++ b/adapters/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@microbridge/adapter-sdk", - "version": "0.3.8", + "version": "0.3.9", "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 335a0cc..51dc782 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.8", + "version": "0.3.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "microbridge-ui", - "version": "0.3.8", + "version": "0.3.9", "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 7b35f2d..0c50b9f 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.8", + "version": "0.3.9", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/microbridge-ui/src-tauri/Cargo.lock b/apps/microbridge-ui/src-tauri/Cargo.lock index be27815..291e9b5 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.8" +version = "0.3.9" dependencies = [ "serde", ] @@ -1822,7 +1822,7 @@ dependencies = [ [[package]] name = "microbridge-ui" -version = "0.3.8" +version = "0.3.9" 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 dd65dfc..a447c36 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.8" +version = "0.3.9" description = "Microbridge menu bar app (primary UI)" authors = ["Microbridge contributors"] edition = "2021" diff --git a/apps/microbridge-ui/src-tauri/src/lib.rs b/apps/microbridge-ui/src-tauri/src/lib.rs index 3dceb31..d97d70d 100644 --- a/apps/microbridge-ui/src-tauri/src/lib.rs +++ b/apps/microbridge-ui/src-tauri/src/lib.rs @@ -1589,6 +1589,40 @@ fn trigger_update_check(app: &AppHandle) { let _ = app.emit("menu://check-updates", ()); } +#[cfg(target_os = "macos")] +#[link(name = "IOKit", kind = "framework")] +extern "C" { + fn IOHIDCheckAccess(request_type: i32) -> i32; + fn IOHIDRequestAccess(request_type: i32) -> bool; +} + +/// Ask for the Input Monitoring grant that macOS requires before a process may +/// receive reports from keyboard-class IOHID devices. The request originates +/// from the signed main app so System Settings attributes it to Microbridge, +/// not to the bundled daemon executable. +#[tauri::command] +fn request_input_monitoring_access() -> bool { + #[cfg(target_os = "macos")] + unsafe { + // IOHIDLib.h: ListenEvent = 1; Granted = 0; Denied = 1; + // Unknown = 2. A denied request will not show the consent prompt + // again, so take the user directly to the relevant System Settings + // pane on their next explicit Claim/Retry action. + match IOHIDCheckAccess(1) { + 0 => true, + 1 => { + let _ = std::process::Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent") + .spawn(); + false + } + _ => IOHIDRequestAccess(1), + } + } + #[cfg(not(target_os = "macos"))] + true +} + #[derive(Debug, PartialEq, Eq)] struct HardwareMenuPresentation { label: &'static str, @@ -1596,6 +1630,11 @@ struct HardwareMenuPresentation { target_enabled: Option, } +fn is_physical_micro(device_name: &str) -> bool { + device_name.starts_with("codex-micro-") + || device_name.starts_with("creator-micro-v2-") +} + fn hardware_menu_presentation_for( device_connected: bool, device_name: &str, @@ -1608,7 +1647,7 @@ fn hardware_menu_presentation_for( target_enabled: Some(false), }; } - if device_name.contains("usb") { + if is_physical_micro(device_name) { return HardwareMenuPresentation { label: if control_requested { "Retry Codex Micro Claim" @@ -1655,6 +1694,10 @@ async fn apply_hardware_menu_action(app: AppHandle) -> Result<(), String> { config }; + if config.hardware_control_enabled { + let _ = request_input_monitoring_access(); + } + // Preserve the daemon's normalized response as the source of truth. let next = state.bus.set_config(config.clone()).await?; config = next; @@ -1684,6 +1727,10 @@ mod hardware_menu_tests { hardware_menu_presentation_for(false, "codex-micro-usb", true).label, "Retry Codex Micro Claim" ); + assert_eq!( + hardware_menu_presentation_for(false, "codex-micro-bluetooth", false).label, + "Claim Codex Micro" + ); assert_eq!( hardware_menu_presentation_for(true, "codex-micro-usb", false).label, "Release Codex Micro" @@ -2244,6 +2291,7 @@ pub fn run() { quit_ui, update_channel, app_version, + request_input_monitoring_access, launch_at_login_status, set_launch_at_login, open_login_items_settings, diff --git a/apps/microbridge-ui/src-tauri/tauri.conf.json b/apps/microbridge-ui/src-tauri/tauri.conf.json index 8a73275..08ab5a2 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.8", + "version": "0.3.9", "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 27f0083..0864d8b 100644 --- a/apps/microbridge-ui/src/App.tsx +++ b/apps/microbridge-ui/src/App.tsx @@ -8,6 +8,7 @@ import { subscribeSnapshot, } from "./lib/bus"; import { promptLaunchAtLoginOnce } from "./lib/autostart"; +import { requestInputMonitoringAccess } from "./lib/hardwareControl"; import { resolveAppearance } from "./lib/theme"; import type { DaemonConfig, Snapshot } from "./lib/types"; import { runAutomaticUpdateCheck, runUpdateCheck } from "./lib/updater"; @@ -116,6 +117,14 @@ export default function App() { } }; + const applyHardwareControl = async (enabled: boolean) => { + if (enabled) await requestInputMonitoringAccess(); + await applyConfig({ + ...snapshot.config, + hardware_control_enabled: enabled, + }); + }; + if (view === "hud") { return ; } @@ -127,7 +136,19 @@ export default function App() { dark={dark} tab={tab} onTab={setTab} - onConfig={(c) => void applyConfig(c)} + onConfig={(c) => { + if ( + c.hardware_control_enabled && + !snapshot.config.hardware_control_enabled + ) { + void (async () => { + await requestInputMonitoringAccess(); + await applyConfig(c); + })(); + } else { + void applyConfig(c); + } + }} onClose={() => void closeSettings()} onAgentKey={(index, open) => void activateAgentKey(index, open)} /> @@ -145,12 +166,7 @@ export default function App() { pause_leds: !snapshot.config.pause_leds, }) } - onHardwareControl={(enabled) => - void applyConfig({ - ...snapshot.config, - hardware_control_enabled: enabled, - }) - } + onHardwareControl={(enabled) => void applyHardwareControl(enabled)} onQuit={() => void quitUi()} onAgentKey={(index, open) => void activateAgentKey(index, open)} /> diff --git a/apps/microbridge-ui/src/components/DeviceEcho.tsx b/apps/microbridge-ui/src/components/DeviceEcho.tsx index 2ff003a..364f5ef 100644 --- a/apps/microbridge-ui/src/components/DeviceEcho.tsx +++ b/apps/microbridge-ui/src/components/DeviceEcho.tsx @@ -1,6 +1,7 @@ import type { Snapshot } from "../lib/types"; import { agentKeyLedFrame } from "../lib/types"; import type { ThemeTokens } from "../lib/theme"; +import { deviceTransportLabel, isPhysicalMicro } from "../lib/hardwareControl"; /** * Miniature, read-only echo of the kbd-1.0 deck (MagicPath AgentKeyEcho). @@ -111,7 +112,8 @@ export function DeviceEcho({ const connected = snapshot.device_connected || snapshot.device_name === "mock" || - snapshot.device_name.includes("usb"); + isPhysicalMicro(snapshot.device_name); + const transport = deviceTransportLabel(snapshot.device_name); return (
{snapshot.device_connected ? "Live on your deck · read-only" - : snapshot.device_name.includes("usb") - ? "USB detected · HID map pending" + : isPhysicalMicro(snapshot.device_name) + ? `${transport ?? "HID"} detected · hardware control available` : snapshot.device_name === "mock" ? "Simulator · read-only" : "Deck offline"} diff --git a/apps/microbridge-ui/src/lib/hardwareControl.test.ts b/apps/microbridge-ui/src/lib/hardwareControl.test.ts index 71adb88..bfd39a1 100644 --- a/apps/microbridge-ui/src/lib/hardwareControl.test.ts +++ b/apps/microbridge-ui/src/lib/hardwareControl.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { hardwareControlState } from "./hardwareControl"; +import { + deviceTransportLabel, + hardwareControlState, + isPhysicalMicro, +} from "./hardwareControl"; import type { Snapshot } from "./types"; function snapshot( @@ -53,6 +57,17 @@ describe("hardwareControlState", () => { expect(hardwareControlState(snapshot("codex-micro-usb", true, false))).toBe( "connected", ); + expect(hardwareControlState(snapshot("codex-micro-bluetooth"))).toBe( + "available", + ); + }); + + it("recognizes supported HID transports", () => { + expect(isPhysicalMicro("codex-micro-usb")).toBe(true); + expect(isPhysicalMicro("codex-micro-bluetooth")).toBe(true); + expect(deviceTransportLabel("codex-micro-bluetooth")).toBe("Bluetooth"); + expect(deviceTransportLabel("unrelated-usb")).toBeNull(); + expect(isPhysicalMicro("daemon-offline")).toBe(false); }); it("does not offer hardware actions for non-device surfaces", () => { diff --git a/apps/microbridge-ui/src/lib/hardwareControl.ts b/apps/microbridge-ui/src/lib/hardwareControl.ts index a626fc4..ca099bf 100644 --- a/apps/microbridge-ui/src/lib/hardwareControl.ts +++ b/apps/microbridge-ui/src/lib/hardwareControl.ts @@ -1,4 +1,5 @@ import type { Snapshot } from "./types"; +import { invokeTauri } from "./tauri"; export type HardwareControlState = | "available" @@ -6,6 +7,25 @@ export type HardwareControlState = | "claim_failed" | "unavailable"; +export function isPhysicalMicro(deviceName: string): boolean { + return ( + deviceName.startsWith("codex-micro-") || + deviceName.startsWith("creator-micro-v2-") + ); +} + +export function deviceTransportLabel(deviceName: string): string | null { + if (!isPhysicalMicro(deviceName)) return null; + if (deviceName.endsWith("-bluetooth")) return "Bluetooth"; + if (deviceName.endsWith("-usb")) return "USB"; + if (deviceName.endsWith("-hid")) return "HID"; + return null; +} + +export async function requestInputMonitoringAccess(): Promise { + return (await invokeTauri("request_input_monitoring_access")) ?? true; +} + /** * Keep requested ownership separate from an actual HID claim. * `hardware_control_enabled` is consent/intent; only `device_connected` proves @@ -20,7 +40,7 @@ export function hardwareControlState( snapshot.device_name === "mock" || snapshot.device_name === "demo-browser" || snapshot.device_name === "daemon-offline" || - !snapshot.device_name.includes("usb"); + !isPhysicalMicro(snapshot.device_name); if (unavailable) return "unavailable"; return snapshot.config.hardware_control_enabled diff --git a/apps/microbridge-ui/src/surfaces/Disconnected.tsx b/apps/microbridge-ui/src/surfaces/Disconnected.tsx index 715b78c..d4ee9c3 100644 --- a/apps/microbridge-ui/src/surfaces/Disconnected.tsx +++ b/apps/microbridge-ui/src/surfaces/Disconnected.tsx @@ -71,7 +71,7 @@ export function Disconnected({ style={{ color: t.textSecondary }} > The menu bar app reads your sessions from the local daemon. It isn't - answering yet. Quit and reopen Microbridge to restart its bundled + answering yet. {view === "settings" ? "Close this window, then quit and reopen Microbridge" : "Quit and reopen Microbridge"} to restart its bundled daemon; this window fills in as soon as the local socket is ready.

diff --git a/apps/microbridge-ui/src/surfaces/Popover.tsx b/apps/microbridge-ui/src/surfaces/Popover.tsx index 34dab7b..67acf4e 100644 --- a/apps/microbridge-ui/src/surfaces/Popover.tsx +++ b/apps/microbridge-ui/src/surfaces/Popover.tsx @@ -8,7 +8,7 @@ import { } from "../lib/threads"; import { usePopoverFit } from "../lib/popoverFit"; import { DeviceEcho } from "../components/DeviceEcho"; -import { hardwareControlState } from "../lib/hardwareControl"; +import { hardwareControlState, isPhysicalMicro } from "../lib/hardwareControl"; const MicroGlyph = ({ color }: { color: string }) => ( {daemonOffline ? "No live daemon connection is available, so the app is showing no threads rather than simulated data." - : "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."} + : "Connect over Bluetooth or 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 2509046..a8216b5 100644 --- a/apps/microbridge-ui/src/surfaces/Settings.tsx +++ b/apps/microbridge-ui/src/surfaces/Settings.tsx @@ -38,6 +38,7 @@ import { } from "../lib/hosts"; import { integrationIcon } from "../lib/integrationIcons"; import { openHostApp, openableHostApp } from "../lib/openHostApp"; +import { deviceTransportLabel, isPhysicalMicro } from "../lib/hardwareControl"; import { integrationGuidance } from "../lib/integrationSetup"; import { MeshBackground } from "../components/MeshBackground"; import { agentKeyLedFrame } from "../lib/types"; @@ -632,8 +633,8 @@ export function Settings({ {snapshot.device_connected ? "Connected — keys, dial, joystick, and lighting are active." - : cfg.hardware_control_enabled && snapshot.device_name.includes("usb") - ? "Control was requested, but another app may own the HID interface. Retry from the popover or right-click menu, or toggle off and on here." + : cfg.hardware_control_enabled && isPhysicalMicro(snapshot.device_name) + ? "Control was requested, but Input Monitoring may need approval or another app may own the HID interface. Retry from the popover or right-click menu, or toggle off and on here." : "Off by default to avoid competing with another device owner. Changes apply immediately."} @@ -671,12 +672,12 @@ export function Settings({ Device: {snapshot.device_name} {snapshot.device_connected ? " · connected" - : snapshot.device_name.includes("usb") - ? " · USB detected; hardware control disabled or interface busy" + : isPhysicalMicro(snapshot.device_name) + ? ` · ${deviceTransportLabel(snapshot.device_name) ?? "HID"} detected; hardware control disabled or interface busy` : snapshot.device_name === "mock" ? " · simulator" : " · not connected"} - {" · "}local USB control + {" · "}local USB/Bluetooth control

)} diff --git a/apps/microbridge-ui/src/surfaces/surfaces.test.tsx b/apps/microbridge-ui/src/surfaces/surfaces.test.tsx index 22cde69..98ecc4d 100644 --- a/apps/microbridge-ui/src/surfaces/surfaces.test.tsx +++ b/apps/microbridge-ui/src/surfaces/surfaces.test.tsx @@ -242,6 +242,22 @@ describe("Popover", () => { expect(availableHtml).toContain("Codex Micro ready"); expect(availableHtml).toContain(">Claim<"); + detected.device_name = "codex-micro-bluetooth"; + const bluetoothHtml = renderToStaticMarkup( + , + ); + expect(bluetoothHtml).toContain("Bluetooth detected"); + expect(bluetoothHtml).toContain(">Claim<"); + + detected.device_name = "codex-micro-usb"; + detected.config.hardware_control_enabled = true; const retryHtml = renderToStaticMarkup( ) -> Result let product_id = info.product_id(); let name = product_name(product_id).to_string(); + let transport = match info.bus_type() { + hidapi::BusType::Usb => DeviceTransport::Usb, + hidapi::BusType::Bluetooth => DeviceTransport::Bluetooth, + _ => DeviceTransport::Unknown, + }; let device = api .open_path(info.path()) .map_err(|e| format!("open_path failed: {e}"))?; @@ -53,6 +59,7 @@ pub fn open_device(preferred_pid: Option) -> Result device: Mutex::new(device), product_id, name, + transport, rpc_id: 1, rx_buf: String::new(), pending: Vec::new(), @@ -64,6 +71,7 @@ pub struct ClaimedDevice { device: Mutex, pub product_id: u16, pub name: String, + pub transport: DeviceTransport, rpc_id: u32, rx_buf: String, pending: Vec, diff --git a/crates/mb-device/src/lib.rs b/crates/mb-device/src/lib.rs index 6986f82..6db2eeb 100644 --- a/crates/mb-device/src/lib.rs +++ b/crates/mb-device/src/lib.rs @@ -22,7 +22,7 @@ pub use capture::run_capture; pub use framing::{frame_rpc, parse_report, CHANNEL_DEBUG, CHANNEL_RPC, REPORT_ID}; pub use ids::{is_supported_pid, CODEX_MICRO_PID, WL_MANUFACTURERS, WL_USAGE_PAGE, WL_VID}; pub use lighting::{parse_rgb_hex, threads_lighting_rpc}; -pub use probe::{match_usb_text, probe_usb_micro, ProbeResult}; +pub use probe::{match_usb_text, probe_usb_micro, DeviceTransport, ProbeResult}; pub use rpc::{ parse_notify, threads_lighting_request, DeviceNotify, LightingEffect, METHOD_RGB_CONFIG, METHOD_THREADS_LIGHTING, @@ -154,7 +154,7 @@ impl Device for MockDevice { } } -/// Best-effort USB HID driver for the Codex Micro. +/// Best-effort USB/Bluetooth HID driver for the Codex Micro. /// /// Without a probed device this behaves like [`MockDevice`] and reports /// `connected: false`. Presence (Detected) uses VID/PID from ChatGPT's kit. @@ -163,8 +163,8 @@ pub struct HidDevice { inner: MockDevice, /// True only when the vendor HID interface is claimed for writes. connected: bool, - /// USB present (Detected) even if not claimed. - usb_present: bool, + /// Physical HID present (Detected) even if not claimed. + device_present: bool, name: String, product_id: Option, rpc_seq: u32, @@ -176,7 +176,7 @@ impl std::fmt::Debug for HidDevice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("HidDevice") .field("connected", &self.connected) - .field("usb_present", &self.usb_present) + .field("device_present", &self.device_present) .field("name", &self.name) .field("product_id", &self.product_id) .finish_non_exhaustive() @@ -188,7 +188,7 @@ impl Default for HidDevice { Self { inner: MockDevice::default(), connected: false, - usb_present: false, + device_present: false, name: "codex-micro".into(), product_id: None, rpc_seq: 1, @@ -199,7 +199,7 @@ impl Default for HidDevice { } impl HidDevice { - /// Attempt to open the first matching USB device. Falls back to + /// Attempt to open the first matching HID device. Falls back to /// disconnected (mock rendering) when none is found or HID is unavailable. /// /// Claim is opt-in (`open_with_claim(true)` + `hid` feature) so we do not @@ -216,11 +216,11 @@ impl HidDevice { } let pid = probe.product_id.unwrap_or(CODEX_MICRO_PID); - let name = format!("{}-usb", ids::product_name(pid)); + let name = format!("{}-{}", ids::product_name(pid), probe.transport.suffix()); let mut device = Self { inner: MockDevice::default(), connected: false, - usb_present: true, + device_present: true, name, product_id: Some(pid), rpc_seq: 1, @@ -236,11 +236,11 @@ impl HidDevice { pub fn set_connected_for_tests(&mut self, connected: bool) { self.connected = connected; - self.usb_present = connected || self.usb_present; + self.device_present = connected || self.device_present; } - pub fn usb_present(&self) -> bool { - self.usb_present + pub fn device_present(&self) -> bool { + self.device_present } #[cfg(feature = "hid")] @@ -252,9 +252,12 @@ impl HidDevice { name = %claimed.name, "claimed Work Louder HID interface" ); - self.name = claimed.name.clone(); + self.name = format!("{}-{}", claimed.name, claimed.transport.suffix()); self.product_id = Some(claimed.product_id); - self.connected = true; + // Opening is not proof of control on macOS: TCC can allow the + // handle and still reject IOHIDDeviceSetReport. A successful + // write below is the ownership boundary exposed to the UI. + self.connected = false; self.claimed = Some(claimed); } Err(error) => { @@ -302,34 +305,35 @@ impl Device for HidDevice { let request = threads_lighting_rpc(frame, rpc_id); let reports = frame_rpc(&request); - if self.connected { - #[cfg(feature = "hid")] - if let Some(claimed) = self.claimed.as_ref() { - if let Err(error) = claimed.write_rpc(&request) { - tracing::warn!(%error, "failed to write thread lighting RPC"); - } else { - tracing::debug!( - device = %self.name, - bytes = request.len(), - packets = reports.len(), - "hid rpc v.oai.thstatus" - ); - } - } - #[cfg(not(feature = "hid"))] - { - let _ = reports; - tracing::debug!(device = %self.name, %request, "hid led rpc (no claim backend)"); + #[cfg(feature = "hid")] + if let Some(claimed) = self.claimed.as_ref() { + if let Err(error) = claimed.write_rpc(&request) { + self.connected = false; + tracing::warn!(%error, "failed to write thread lighting RPC"); + } else { + self.connected = true; + tracing::debug!( + device = %self.name, + bytes = request.len(), + packets = reports.len(), + "hid rpc v.oai.thstatus" + ); } - } else if self.usb_present { + } else if self.device_present { tracing::debug!( device = %self.name, packets = reports.len(), request = %request, - "usb present; packed LED RPC ready (enable Device hardware control to write)" + "device present; packed LED RPC ready (enable Device hardware control to write)" ); } + #[cfg(not(feature = "hid"))] + if self.connected { + let _ = reports; + tracing::debug!(device = %self.name, %request, "hid led rpc (no claim backend)"); + } + self.inner.set_leds(frame); } @@ -414,7 +418,7 @@ fn joystick_from_angle(angle: i64) -> Option { Some(DeviceInput::JoystickFlick { direction }) } -/// Prefer a claimed HID device; else a detected-but-unclaimed USB Micro; +/// Prefer a claimed HID device; else a detected-but-unclaimed physical Micro; /// else the mock simulator. pub fn open_default_device() -> Box { open_default_device_with_claim(claim_requested()) @@ -424,7 +428,7 @@ pub fn open_default_device() -> Box { /// override is handled by the daemon before calling this function. pub fn open_default_device_with_claim(should_claim: bool) -> Box { let hid = HidDevice::open_with_claim(should_claim); - if hid.usb_present() || hid.descriptor().connected { + if hid.device_present() || hid.descriptor().connected { Box::new(hid) } else { Box::new(MockDevice::default()) diff --git a/crates/mb-device/src/probe.rs b/crates/mb-device/src/probe.rs index 9ab2cdf..770db26 100644 --- a/crates/mb-device/src/probe.rs +++ b/crates/mb-device/src/probe.rs @@ -1,4 +1,4 @@ -//! Best-effort USB presence probe — does not claim the HID interface. +//! Best-effort HID presence probe — does not claim the HID interface. use crate::ids::{is_supported_pid, CODEX_MICRO_PID, WL_VID}; @@ -6,8 +6,27 @@ use crate::ids::{is_supported_pid, CODEX_MICRO_PID, WL_VID}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProbeResult { pub present: bool, - /// Best-effort product id when parsed from the host USB listing. + /// Best-effort product id reported by the HID registry or host USB listing. pub product_id: Option, + pub transport: DeviceTransport, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum DeviceTransport { + Usb, + Bluetooth, + #[default] + Unknown, +} + +impl DeviceTransport { + pub fn suffix(self) -> &'static str { + match self { + Self::Usb => "usb", + Self::Bluetooth => "bluetooth", + Self::Unknown => "hid", + } + } } impl ProbeResult { @@ -15,16 +34,34 @@ impl ProbeResult { Self { present: false, product_id: None, + transport: DeviceTransport::Unknown, } } } -/// Probe for a supported Work Louder / Codex Micro USB device. +/// Probe for a supported Work Louder / Codex Micro HID device. +/// +/// The HID registry is authoritative because macOS exposes both USB and BLE +/// HID devices there. `system_profiler` remains a USB-only fallback for builds +/// compiled without the optional HID backend or if HID initialization fails. pub fn probe_usb_micro() -> ProbeResult { + #[cfg(feature = "hid")] + match probe_hid_micro() { + Ok(Some(result)) => return result, + Ok(None) => return ProbeResult::absent(), + Err(()) => {} + } + #[cfg(target_os = "macos")] { match system_profiler_usb_text(std::time::Duration::from_secs(3)) { - Some(text) => match_usb_text(&text), + Some(text) => { + let mut result = match_usb_text(&text); + if result.present { + result.transport = DeviceTransport::Usb; + } + result + } None => ProbeResult::absent(), } } @@ -34,6 +71,30 @@ pub fn probe_usb_micro() -> ProbeResult { } } +#[cfg(feature = "hid")] +fn probe_hid_micro() -> Result, ()> { + use hidapi::{BusType, HidApi}; + + let api = HidApi::new().map_err(|_| ())?; + let Some(info) = api.device_list().find(|info| { + info.vendor_id() == WL_VID + && is_supported_pid(info.product_id()) + && info.usage_page() == crate::WL_USAGE_PAGE + }) else { + return Ok(None); + }; + let transport = match info.bus_type() { + BusType::Usb => DeviceTransport::Usb, + BusType::Bluetooth => DeviceTransport::Bluetooth, + _ => DeviceTransport::Unknown, + }; + Ok(Some(ProbeResult { + present: true, + product_id: Some(info.product_id()), + transport, + })) +} + /// Match `system_profiler SPUSBDataType` (or similar) text against known IDs. pub fn match_usb_text(raw: &str) -> ProbeResult { let lower = raw.to_ascii_lowercase(); @@ -44,6 +105,7 @@ pub fn match_usb_text(raw: &str) -> ProbeResult { return ProbeResult { present: true, product_id: Some(pid), + transport: DeviceTransport::Usb, }; } } @@ -57,6 +119,7 @@ pub fn match_usb_text(raw: &str) -> ProbeResult { return ProbeResult { present: true, product_id: Some(CODEX_MICRO_PID), + transport: DeviceTransport::Usb, }; } @@ -143,6 +206,7 @@ Codex Micro: let r = match_usb_text(sample); assert!(r.present); assert_eq!(r.product_id, Some(CODEX_MICRO_PID)); + assert_eq!(r.transport, DeviceTransport::Usb); } #[test] diff --git a/crates/microbridged/src/main.rs b/crates/microbridged/src/main.rs index e72b7ed..c4d1f4a 100644 --- a/crates/microbridged/src/main.rs +++ b/crates/microbridged/src/main.rs @@ -88,11 +88,16 @@ async fn main() -> std::io::Result<()> { let input_bus = Arc::clone(&shared); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_millis(16)); + let mut next_device_probe = tokio::time::Instant::now(); loop { interval.tick().await; let mut state = input_bus.lock().await; state.poll_device_inputs(); state.expire_leased_sessions(); + if tokio::time::Instant::now() >= next_device_probe { + state.refresh_device_presence(); + next_device_probe = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + } } }); diff --git a/crates/microbridged/src/state.rs b/crates/microbridged/src/state.rs index 1243752..46c4bac 100644 --- a/crates/microbridged/src/state.rs +++ b/crates/microbridged/src/state.rs @@ -31,6 +31,15 @@ fn should_reopen_device(previous: &DaemonConfig, next: &DaemonConfig, connected: || (next.hardware_control_enabled && !connected) } +fn unrendered_led_frame() -> LedFrame { + LedFrame { + // Normalized user brightness is at most 100, so this sentinel forces + // one real HID write even when the resolved frame is otherwise empty. + brightness: u8::MAX, + ..LedFrame::default() + } +} + pub struct DaemonState { pub registry: Registry, pub config: DaemonConfig, @@ -62,7 +71,7 @@ impl DaemonState { pub fn new(device: Box, mut config: DaemonConfig) -> Self { config.normalize(); let adapters = initial_adapter_statuses(&config); - Self { + let mut state = Self { registry: Registry::default(), config, device, @@ -76,8 +85,11 @@ impl DaemonState { observed_session_owners: HashMap::new(), hosted_claims: HashMap::new(), last_agent_key_press: [None; AGENT_KEY_COUNT], - last_leds: LedFrame::default(), - } + last_leds: unrendered_led_frame(), + }; + let keys = state.registry.agent_key_ids(&state.config); + state.render_leds(&keys); + state } pub fn snapshot(&self) -> Snapshot { @@ -303,7 +315,7 @@ impl DaemonState { self.device = mb_device::open_default_device_with_claim( self.config.hardware_control_enabled || hid_claim_env_enabled(), ); - self.last_leds = LedFrame::default(); + self.last_leds = unrendered_led_frame(); let descriptor = self.device.descriptor(); self.broadcast_ui(BusEvent::DeviceChanged { connected: descriptor.connected, @@ -638,7 +650,15 @@ impl DaemonState { pub fn render_leds(&mut self, keys: &[Option; AGENT_KEY_COUNT]) { let frame = self.resolved_led_frame(keys); if frame != self.last_leds { + let before = self.device.descriptor(); self.device.set_leds(&frame); + let after = self.device.descriptor(); + if before.connected != after.connected || before.name != after.name { + self.broadcast_ui(BusEvent::DeviceChanged { + connected: after.connected, + name: after.name, + }); + } self.last_leds = frame; } } @@ -802,6 +822,42 @@ impl DaemonState { } } + /// Refresh physical presence without repeatedly retrying a failed claim. + /// The 2-second caller cadence is only for hot-plug discovery; stable + /// detected/connected devices keep their existing handle. + pub fn refresh_device_presence(&mut self) { + self.refresh_device_presence_with(|should_claim| { + mb_device::open_default_device_with_claim(should_claim) + }); + } + + fn refresh_device_presence_with(&mut self, mut open: F) + where + F: FnMut(bool) -> Box, + { + let current = self.device.descriptor(); + let observed_device = open(false); + let observed = observed_device.descriptor(); + if current.name == observed.name { + return; + } + + let replacement = if observed.name != "mock" && self.config.hardware_control_enabled { + open(true) + } else { + observed_device + }; + let before_render = replacement.descriptor(); + self.device = replacement; + self.last_leds = unrendered_led_frame(); + self.broadcast_ui(BusEvent::DeviceChanged { + connected: before_render.connected, + name: before_render.name, + }); + let keys = self.registry.agent_key_ids(&self.config); + self.render_leds(&keys); + } + fn move_focus(&mut self, offset: isize) { let sessions = self.registry.session_list(); if sessions.is_empty() { @@ -1005,7 +1061,27 @@ mod tests { use mb_adapters::{ObservedSession, SessionContext}; const CNVS_OWNER_FOR_TEST: u64 = u64::MAX - 3; - use mb_device::MockDevice; + use mb_device::{DeviceDescriptor, MockDevice}; + + struct DescriptorDevice(DeviceDescriptor); + + impl Device for DescriptorDevice { + fn descriptor(&self) -> DeviceDescriptor { + self.0.clone() + } + + fn set_leds(&mut self, _frame: &LedFrame) {} + } + + fn physical(name: &str, connected: bool) -> Box { + Box::new(DescriptorDevice(DeviceDescriptor { + name: name.into(), + agent_key_count: AGENT_KEY_COUNT, + has_dial: true, + has_joystick: true, + connected, + })) + } fn session(id: &str, state: AgentState) -> SessionStatus { SessionStatus { @@ -1034,6 +1110,40 @@ mod tests { assert!(should_reopen_device(&enabled, &disabled, true)); } + #[test] + fn hotplug_discovers_and_claims_a_new_transport_when_requested() { + let mut state = state(); + state.config.hardware_control_enabled = true; + let mut calls = Vec::new(); + + state.refresh_device_presence_with(|should_claim| { + calls.push(should_claim); + physical("codex-micro-bluetooth", should_claim) + }); + + assert_eq!(calls, vec![false, true]); + assert_eq!(state.device.descriptor().name, "codex-micro-bluetooth"); + assert!(state.device.descriptor().connected); + } + + #[test] + fn stable_failed_claim_does_not_retry_until_requested() { + let config = DaemonConfig { + hardware_control_enabled: true, + ..DaemonConfig::default() + }; + let mut state = DaemonState::new(physical("codex-micro-usb", false), config); + let mut calls = Vec::new(); + + state.refresh_device_presence_with(|should_claim| { + calls.push(should_claim); + physical("codex-micro-usb", false) + }); + + assert_eq!(calls, vec![false]); + assert!(!state.device.descriptor().connected); + } + #[test] fn hosted_terminal_replaces_and_then_restores_raw_journal() { let mut state = state(); diff --git a/docs/architecture.md b/docs/architecture.md index b648931..2f891e6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ line; regressions are release blockers. | Metric | Budget | How | |---|---|---| -| Idle CPU | near-idle with bounded wakeups | local session and frontmost-app watchers are event-driven; enabled hardware input is drained on a 16 ms tick, CNVS refreshes its local snapshot every 2 seconds while agents are active and every 10 seconds while idle, and an enabled paired T3 adapter refreshes at 900 ms with exponential backoff. | +| Idle CPU | near-idle with bounded wakeups | local session and frontmost-app watchers are event-driven; enabled hardware input is drained on a 16 ms tick, physical HID presence refreshes every 2 seconds without reopening a stable device, CNVS refreshes its local snapshot every 2 seconds while agents are active and every 10 seconds while idle, and an enabled paired T3 adapter refreshes at 900 ms with exponential backoff. | | Idle RSS (daemon) | < 15 MB, target single-digit | single static Rust binary, no runtime | | Network | explicit only | no telemetry; CNVS uses only its token-authenticated loopback endpoint, update checks are opt-in, T3 traffic requires an enabled paired environment, and Factory invokes the signed-in Droid CLI only for a requested control | | Device traffic | bytes per state *transition* | LED frames written only when resolved state changes; a 32–64 byte HID report each | diff --git a/docs/design/README.md b/docs/design/README.md index 8222b52..04fc46c 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -115,7 +115,8 @@ The daily driver. **Read-mostly** — no agent actions, no theme toggle. - **Mini device echo**: a passive miniature of the real deck (dial, joystick, six lit Agent Keys, command row) — read-only, labeled as such - Threads list: state dot + app + title + elapsed — no approve/reject, no click-to-focus - Contextual hardware card: Claim when detected, Release when connected, Retry - when another process owns the HID interface. + for recoverable claim failures such as missing Input Monitoring permission or + another process owning the HID interface. - Footer: Settings · Pause LEDs · Quit When disconnected, the popover shows a connection-first empty state ("Connect diff --git a/docs/device-hid.md b/docs/device-hid.md index 6f731f0..0f1c4b8 100644 --- a/docs/device-hid.md +++ b/docs/device-hid.md @@ -9,15 +9,17 @@ Firmware changes may invalidate this document — treat it as a living map. | Capability | Status | |---|---| | USB identity (VID/PID) | **known** — from ChatGPT Desktop `@worklouder/wl-device-kit` | -| USB presence probe | macOS `system_profiler` matches VID `0x303A` + known PIDs (Detected) | +| USB/Bluetooth presence | macOS HID registry matches VID `0x303A`, known PIDs, and vendor usage page `0xFF00` (Detected) | | HID claim / LED write | opt-in: build with `hid` feature (default) + Device → Hardware control; `MICROBRIDGE_HID_CLAIM=1` is a diagnostic override | | LED frames (6 Agent Keys) | packed as JSON-RPC `v.oai.thstatus` over framed HID reports | | Key / dial / joystick input | notify parsers and conservative routing map implemented (`v.oai.hid` / `v.oai.rad`); exact codes remain gated on physical capture | -| Bluetooth | out of scope for M2 (USB-first) | +| Bluetooth | supported when the paired Micro exposes its vendor HID interface; transport is reported separately from USB | Without a claimed device the daemon uses [`MockDevice`](../crates/mb-device/src/lib.rs) -so CI and headless installs stay green. Detected-but-unclaimed USB still shows +so CI and headless installs stay green. A detected-but-unclaimed device still shows in the UI as **Detected** (not Connected). +The daemon refreshes presence every two seconds while keeping explicit Retry +semantics for a stable device whose claim failed. ## Source of truth (no hardware required) @@ -117,7 +119,8 @@ Build flag: `mb-device` feature `hid` (on by default). Disable with - 13 mechanical switches, rotary encoder, planar joystick, capacitive touch - 6 frosted Agent Keys with per-key RGB -- USB-C and BLE; Microbridge M2 targets USB only +- USB-C and BLE; Microbridge discovers BLE when the paired device exposes its + vendor HID interface through the macOS HID registry - Also configurable via Work Louder Input / VIA for non-agent layers ## Remaining validation (needs hardware) diff --git a/docs/governance.md b/docs/governance.md index 397d4ca..5ace931 100644 --- a/docs/governance.md +++ b/docs/governance.md @@ -34,7 +34,9 @@ on merge, auto-merge enabled. 1. Land changes on `main` via PR. 2. Tag `vX.Y.Z` and push the tag → **Release** workflow builds archives and updates the Homebrew formula checksums. -3. Users upgrade with `brew update && brew upgrade microbridge`. +3. Users upgrade with `brew update && brew upgrade microbridge`, then run + `microbridge-app install` to refresh the marker-owned signed app (the full + workflow is below). ## macOS install + auto-update (Homebrew) diff --git a/docs/hardware-bringup.md b/docs/hardware-bringup.md index 37c634d..a4d6a76 100644 --- a/docs/hardware-bringup.md +++ b/docs/hardware-bringup.md @@ -14,16 +14,17 @@ Do the steps in order. Each one has an exact command and a "pass" condition. fighting for the interface during capture. - If `microbridged` is running with a live claim, stop it for the capture step: it only needs to be running for the LED step (§4). -- Plug the Micro in over **USB-C** (BLE is out of scope for M2). +- Connect the Micro over **USB-C** or pair and connect it over **Bluetooth**. ## 1. Confirm detection ```sh -system_profiler SPUSBDataType -detailLevel mini | grep -iA3 "work louder\|codex\|0x303a" +ioreg -r -c IOHIDDevice -l | rg -i -A8 "codex micro|creator micro" ``` -**Pass:** a record with `Vendor ID: 0x303a` and `Product ID: 0x8360` -(Codex Micro) or `0x8297` / `0x8298` (Creator Micro V2). +**Pass:** a record with `VendorID = 12346` (`0x303a`), `ProductID = 33632` +(`0x8360`, Codex Micro), `33431` (`0x8297`, Creator Micro V2), or `33432` +(`0x8298`, Creator Micro V2), and `Transport = USB` or `Bluetooth`. Then confirm the daemon sees it: @@ -33,13 +34,14 @@ cargo run -p microbridgectl status # in another — device should show "Detect ``` **Pass:** the snapshot reports the Micro as **Detected** (not `mock`). -Record the real `iProduct` string and confirmed PID: +Record the real product string, confirmed PID, and transport: | Field | Documented | Observed | |---|---|---| -| Product ID | `0x8360` | | +| Product ID | `0x8360`, `0x8297`, or `0x8298` | | | iProduct string | _(unknown)_ | | | Manufacturer | `Work Louder` | | +| Transport | `USB` / `Bluetooth` | | ## 2. Capture the real input map (the important one) diff --git a/docs/releases/v0.3.9.md b/docs/releases/v0.3.9.md new file mode 100644 index 0000000..b9f5d64 --- /dev/null +++ b/docs/releases/v0.3.9.md @@ -0,0 +1,22 @@ +# Microbridge v0.3.9 + +## Hardware detection and control + +- Detect Codex Micro directly through the macOS HID registry instead of the + slower USB-only `system_profiler` path. +- Support the same vendor HID control channel over USB-C and, when a paired + Micro exposes that interface, Bluetooth; expose the active transport in the + home popover and Device settings. +- Refresh physical presence on a bounded two-second cadence so plugging in or + connecting over Bluetooth does not require restarting the app. +- Request macOS Input Monitoring from the signed Microbridge app when the user + chooses Claim or Retry; denied requests open the correct System Settings pane. +- Treat a successful HID write—not merely an open handle or configured intent— + as proof that Microbridge controls the device. +- Keep failed or permission-blocked claims in the truthful Retry state. + +## Native QA + +- A Developer ID–signed isolated app detected a physical Codex Micro, claimed + it from the native right-click menu, changed the daemon snapshot to Connected, + refreshed the menu to Release, and returned to Detected after release. diff --git a/scripts/install-from-release.sh b/scripts/install-from-release.sh index d9c5b28..6f690d3 100755 --- a/scripts/install-from-release.sh +++ b/scripts/install-from-release.sh @@ -47,7 +47,8 @@ fi ASSET="microbridge-${TAG}-${TARGET}.tar.gz" URL="https://github.com/${REPO}/releases/download/${TAG}/${ASSET}" TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT +APP_STAGING="" +trap 'rm -rf "$TMP"; if [[ -n "${APP_STAGING:-}" ]]; then rm -rf "$APP_STAGING"; fi' EXIT echo "==> Downloading $URL" if ! curl -fsSL -o "$TMP/$ASSET" "$URL"; then @@ -79,6 +80,7 @@ if [[ "$OS" == "Darwin" ]]; then return 0 fi local STAGING="$HOME/Applications/.Microbridge.app.installing.$$" + APP_STAGING="$STAGING" mkdir -p "$HOME/Applications" rm -rf "$STAGING" /usr/bin/ditto "$APP_SRC" "$STAGING" @@ -87,9 +89,14 @@ if [[ "$OS" == "Darwin" ]]; then while read -r pid; do kill "$pid" 2>/dev/null || true done < <(/usr/bin/pgrep -f "^${DEST}/Contents/MacOS/microbridge-ui$" 2>/dev/null || true) + for _ in 1 2 3 4 5 6 7 8 9 10; do + /usr/bin/pgrep -f "^${DEST}/Contents/MacOS/microbridge-ui$" >/dev/null 2>&1 || break + /bin/sleep 0.1 + done fi rm -rf "$DEST" mv "$STAGING" "$DEST" + APP_STAGING="" xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true touch "$MARKER" rm -f "$LEGACY_MARKER" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index a0e5373..e6bfa53 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -40,6 +40,13 @@ if [[ "$(uname -s)" == "Darwin" ]]; then echo "==> Removing menu bar app" "$APP/Contents/MacOS/microbridge-ui" \ --unregister-login-item 2>/dev/null || true + while read -r pid; do + kill "$pid" 2>/dev/null || true + done < <(/usr/bin/pgrep -f "^${APP}/Contents/MacOS/microbridge-ui$" 2>/dev/null || true) + for _ in 1 2 3 4 5 6 7 8 9 10; do + /usr/bin/pgrep -f "^${APP}/Contents/MacOS/microbridge-ui$" >/dev/null 2>&1 || break + /bin/sleep 0.1 + done rm -rf "$APP" rm -f "$SOURCE_MARKER" "$BREW_MARKER" "$RELEASE_MARKER" elif [[ -d "$APP" ]]; then From a57e007b63edbcd982f43067b957302ee3e8e00a Mon Sep 17 00:00:00 2001 From: Jonathan Borgwing Date: Wed, 22 Jul 2026 21:06:40 -0400 Subject: [PATCH 2/3] fix: address hardware review feedback --- crates/mb-device/src/probe.rs | 2 +- docs/hardware-bringup.md | 2 +- scripts/install-from-release.sh | 6 ++++-- scripts/uninstall.sh | 9 +++++++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/mb-device/src/probe.rs b/crates/mb-device/src/probe.rs index 770db26..c4df89d 100644 --- a/crates/mb-device/src/probe.rs +++ b/crates/mb-device/src/probe.rs @@ -2,7 +2,7 @@ use crate::ids::{is_supported_pid, CODEX_MICRO_PID, WL_VID}; -/// Result of a non-claiming USB probe. +/// Result of a non-claiming HID-registry probe, with a USB fallback. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProbeResult { pub present: bool, diff --git a/docs/hardware-bringup.md b/docs/hardware-bringup.md index a4d6a76..18b8d17 100644 --- a/docs/hardware-bringup.md +++ b/docs/hardware-bringup.md @@ -19,7 +19,7 @@ Do the steps in order. Each one has an exact command and a "pass" condition. ## 1. Confirm detection ```sh -ioreg -r -c IOHIDDevice -l | rg -i -A8 "codex micro|creator micro" +ioreg -r -c IOHIDDevice -l | grep -i -E -A8 "codex micro|creator micro" ``` **Pass:** a record with `VendorID = 12346` (`0x303a`), `ProductID = 33632` diff --git a/scripts/install-from-release.sh b/scripts/install-from-release.sh index 6f690d3..c6717f0 100755 --- a/scripts/install-from-release.sh +++ b/scripts/install-from-release.sh @@ -86,11 +86,13 @@ if [[ "$OS" == "Darwin" ]]; then /usr/bin/ditto "$APP_SRC" "$STAGING" /usr/bin/codesign --verify --deep --strict "$STAGING" if [[ -d "$DEST" ]]; then + local APP_COMMAND_PATTERN + APP_COMMAND_PATTERN="^$(printf '%s\n' "$DEST/Contents/MacOS/microbridge-ui" | /usr/bin/sed 's/[][(){}.^$*+?|\\/]/\\&/g')$" while read -r pid; do kill "$pid" 2>/dev/null || true - done < <(/usr/bin/pgrep -f "^${DEST}/Contents/MacOS/microbridge-ui$" 2>/dev/null || true) + done < <(/usr/bin/pgrep -f "$APP_COMMAND_PATTERN" 2>/dev/null || true) for _ in 1 2 3 4 5 6 7 8 9 10; do - /usr/bin/pgrep -f "^${DEST}/Contents/MacOS/microbridge-ui$" >/dev/null 2>&1 || break + /usr/bin/pgrep -f "$APP_COMMAND_PATTERN" >/dev/null 2>&1 || break /bin/sleep 0.1 done fi diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index e6bfa53..1665b68 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -8,6 +8,10 @@ UI_LABEL="ai.microbridge.ui" BREW_LABEL="homebrew.mxcl.microbridge" PURGE=0 +regex_escape() { + printf '%s\n' "$1" | /usr/bin/sed 's/[][(){}.^$*+?|\\/]/\\&/g' +} + usage() { cat < Removing menu bar app" "$APP/Contents/MacOS/microbridge-ui" \ --unregister-login-item 2>/dev/null || true + APP_COMMAND_PATTERN="^$(regex_escape "$APP/Contents/MacOS/microbridge-ui")$" while read -r pid; do kill "$pid" 2>/dev/null || true - done < <(/usr/bin/pgrep -f "^${APP}/Contents/MacOS/microbridge-ui$" 2>/dev/null || true) + done < <(/usr/bin/pgrep -f "$APP_COMMAND_PATTERN" 2>/dev/null || true) for _ in 1 2 3 4 5 6 7 8 9 10; do - /usr/bin/pgrep -f "^${APP}/Contents/MacOS/microbridge-ui$" >/dev/null 2>&1 || break + /usr/bin/pgrep -f "$APP_COMMAND_PATTERN" >/dev/null 2>&1 || break /bin/sleep 0.1 done rm -rf "$APP" From 0645c4f6deac4ce2bba3a371608252e692b5269d Mon Sep 17 00:00:00 2001 From: Jonathan Borgwing Date: Wed, 22 Jul 2026 21:44:28 -0400 Subject: [PATCH 3/3] fix: address final hardware review --- crates/mb-device/src/claim.rs | 11 +--- crates/mb-device/src/probe.rs | 98 ++++++++++++++++++++++++++++---- crates/microbridged/src/main.rs | 26 +++++++-- crates/microbridged/src/state.rs | 67 ++++++++++++++++++++-- docs/device-hid.md | 7 ++- docs/hardware-bringup.md | 11 +++- docs/releases/v0.3.9.md | 8 ++- scripts/install-from-release.sh | 11 ++++ scripts/uninstall.sh | 11 ++++ 9 files changed, 212 insertions(+), 38 deletions(-) diff --git a/crates/mb-device/src/claim.rs b/crates/mb-device/src/claim.rs index 5e64fec..fe987ec 100644 --- a/crates/mb-device/src/claim.rs +++ b/crates/mb-device/src/claim.rs @@ -11,6 +11,7 @@ use hidapi::{HidApi, HidDevice as RawHid}; use crate::framing::{frame_rpc, parse_report, CHANNEL_RPC, REPORT_ID}; use crate::ids::{is_supported_pid, product_name, WL_USAGE_PAGE, WL_VID}; +use crate::probe::{hid_candidate_sort_key, transport_from_bus_type}; use crate::rpc::{parse_notify, DeviceNotify}; use crate::DeviceTransport; @@ -35,9 +36,7 @@ pub fn open_device(preferred_pid: Option) -> Result }) .collect(); - if let Some(pid) = preferred_pid { - candidates.sort_by_key(|info| usize::from(info.product_id() != pid)); - } + candidates.sort_by_key(|info| hid_candidate_sort_key(info, preferred_pid)); let info = candidates .first() @@ -45,11 +44,7 @@ pub fn open_device(preferred_pid: Option) -> Result let product_id = info.product_id(); let name = product_name(product_id).to_string(); - let transport = match info.bus_type() { - hidapi::BusType::Usb => DeviceTransport::Usb, - hidapi::BusType::Bluetooth => DeviceTransport::Bluetooth, - _ => DeviceTransport::Unknown, - }; + let transport = transport_from_bus_type(info.bus_type()); let device = api .open_path(info.path()) .map_err(|e| format!("open_path failed: {e}"))?; diff --git a/crates/mb-device/src/probe.rs b/crates/mb-device/src/probe.rs index c4df89d..0bbdffc 100644 --- a/crates/mb-device/src/probe.rs +++ b/crates/mb-device/src/probe.rs @@ -27,6 +27,50 @@ impl DeviceTransport { Self::Unknown => "hid", } } + + fn preference_rank(self) -> u8 { + match self { + // A cable is the least ambiguous route when firmware exposes both + // transports at once. Bluetooth remains preferred over an unknown + // backend, and is selected whenever USB is absent. + Self::Usb => 0, + Self::Bluetooth => 1, + Self::Unknown => 2, + } + } +} + +#[cfg(feature = "hid")] +pub(crate) fn transport_from_bus_type(bus_type: hidapi::BusType) -> DeviceTransport { + match bus_type { + hidapi::BusType::Usb => DeviceTransport::Usb, + hidapi::BusType::Bluetooth => DeviceTransport::Bluetooth, + _ => DeviceTransport::Unknown, + } +} + +#[cfg(feature = "hid")] +pub(crate) fn hid_candidate_sort_key( + info: &hidapi::DeviceInfo, + preferred_pid: Option, +) -> (bool, u8, u16) { + candidate_preference_key( + info.product_id(), + transport_from_bus_type(info.bus_type()), + preferred_pid, + ) +} + +fn candidate_preference_key( + product_id: u16, + transport: DeviceTransport, + preferred_pid: Option, +) -> (bool, u8, u16) { + ( + preferred_pid.is_some_and(|pid| product_id != pid), + transport.preference_rank(), + product_id, + ) } impl ProbeResult { @@ -73,25 +117,25 @@ pub fn probe_usb_micro() -> ProbeResult { #[cfg(feature = "hid")] fn probe_hid_micro() -> Result, ()> { - use hidapi::{BusType, HidApi}; + use hidapi::HidApi; let api = HidApi::new().map_err(|_| ())?; - let Some(info) = api.device_list().find(|info| { - info.vendor_id() == WL_VID - && is_supported_pid(info.product_id()) - && info.usage_page() == crate::WL_USAGE_PAGE - }) else { + let mut candidates: Vec<_> = api + .device_list() + .filter(|info| { + info.vendor_id() == WL_VID + && is_supported_pid(info.product_id()) + && info.usage_page() == crate::WL_USAGE_PAGE + }) + .collect(); + candidates.sort_by_key(|info| hid_candidate_sort_key(info, None)); + let Some(info) = candidates.first() else { return Ok(None); }; - let transport = match info.bus_type() { - BusType::Usb => DeviceTransport::Usb, - BusType::Bluetooth => DeviceTransport::Bluetooth, - _ => DeviceTransport::Unknown, - }; Ok(Some(ProbeResult { present: true, product_id: Some(info.product_id()), - transport, + transport: transport_from_bus_type(info.bus_type()), })) } @@ -194,6 +238,36 @@ fn system_profiler_usb_text(timeout: std::time::Duration) -> Option { mod tests { use super::*; + #[test] + fn transport_preference_is_usb_then_bluetooth_then_unknown() { + assert!( + DeviceTransport::Usb.preference_rank() < DeviceTransport::Bluetooth.preference_rank() + ); + assert!( + DeviceTransport::Bluetooth.preference_rank() + < DeviceTransport::Unknown.preference_rank() + ); + } + + #[test] + fn preferred_pid_precedes_transport_tie_break() { + let preferred_bluetooth = candidate_preference_key( + CODEX_MICRO_PID, + DeviceTransport::Bluetooth, + Some(CODEX_MICRO_PID), + ); + let other_usb = candidate_preference_key( + crate::ids::CREATOR_MICRO_V2_PIDS[0], + DeviceTransport::Usb, + Some(CODEX_MICRO_PID), + ); + assert!(preferred_bluetooth < other_usb); + + let preferred_usb = + candidate_preference_key(CODEX_MICRO_PID, DeviceTransport::Usb, Some(CODEX_MICRO_PID)); + assert!(preferred_usb < preferred_bluetooth); + } + #[test] fn matches_vid_pid_block() { let sample = r#" diff --git a/crates/microbridged/src/main.rs b/crates/microbridged/src/main.rs index c4d1f4a..35b7517 100644 --- a/crates/microbridged/src/main.rs +++ b/crates/microbridged/src/main.rs @@ -91,11 +91,27 @@ async fn main() -> std::io::Result<()> { let mut next_device_probe = tokio::time::Instant::now(); loop { interval.tick().await; - let mut state = input_bus.lock().await; - state.poll_device_inputs(); - state.expire_leased_sessions(); - if tokio::time::Instant::now() >= next_device_probe { - state.refresh_device_presence(); + let probe_due = tokio::time::Instant::now() >= next_device_probe; + { + let mut state = input_bus.lock().await; + state.poll_device_inputs(); + state.expire_leased_sessions(); + } + if probe_due { + // HID enumeration/opening can block in the OS. Keep it outside + // the shared state mutex so UI and adapter sockets remain live. + let observed = mb_device::open_default_device_with_claim(false); + let observed_descriptor = observed.descriptor(); + let plan = { + let state = input_bus.lock().await; + state.plan_device_presence_refresh(&observed_descriptor) + }; + if let Some((expected_name, should_claim)) = plan { + let claimed = + should_claim.then(|| mb_device::open_default_device_with_claim(true)); + let mut state = input_bus.lock().await; + state.apply_device_presence_refresh(&expected_name, observed, claimed); + } next_device_probe = tokio::time::Instant::now() + std::time::Duration::from_secs(2); } } diff --git a/crates/microbridged/src/state.rs b/crates/microbridged/src/state.rs index 46c4bac..d7c3c0c 100644 --- a/crates/microbridged/src/state.rs +++ b/crates/microbridged/src/state.rs @@ -826,24 +826,64 @@ impl DaemonState { /// The 2-second caller cadence is only for hot-plug discovery; stable /// detected/connected devices keep their existing handle. pub fn refresh_device_presence(&mut self) { - self.refresh_device_presence_with(|should_claim| { - mb_device::open_default_device_with_claim(should_claim) - }); + self.refresh_device_presence_with(mb_device::open_default_device_with_claim); } fn refresh_device_presence_with(&mut self, mut open: F) where F: FnMut(bool) -> Box, { - let current = self.device.descriptor(); let observed_device = open(false); let observed = observed_device.descriptor(); - if current.name == observed.name { + let Some((expected_name, should_claim)) = self.plan_device_presence_refresh(&observed) + else { + return; + }; + let claimed_device = should_claim.then(|| open(true)); + self.apply_device_presence_refresh(&expected_name, observed_device, claimed_device); + } + + /// Decide whether a separately probed descriptor represents a hot-plug or + /// transport change. The caller may perform the potentially blocking HID + /// probe and claim without holding the daemon state mutex. + pub fn plan_device_presence_refresh( + &self, + observed: &mb_device::DeviceDescriptor, + ) -> Option<(String, bool)> { + let current = self.device.descriptor(); + (current.name != observed.name).then(|| { + ( + current.name, + observed.name != "mock" && self.config.hardware_control_enabled, + ) + }) + } + + /// Apply devices opened outside the daemon state mutex. If state or the + /// physical transport changed while I/O was in flight, defer to the next + /// bounded refresh instead of overwriting newer state. + pub fn apply_device_presence_refresh( + &mut self, + expected_current_name: &str, + observed_device: Box, + claimed_device: Option>, + ) { + if self.device.descriptor().name != expected_current_name { + return; + } + let observed = observed_device.descriptor(); + if observed.name == expected_current_name { return; } let replacement = if observed.name != "mock" && self.config.hardware_control_enabled { - open(true) + let Some(claimed) = claimed_device else { + return; + }; + if claimed.descriptor().name != observed.name { + return; + } + claimed } else { observed_device }; @@ -1144,6 +1184,21 @@ mod tests { assert!(!state.device.descriptor().connected); } + #[test] + fn stale_presence_result_does_not_replace_newer_device_state() { + let mut state = + DaemonState::new(physical("codex-micro-usb", false), DaemonConfig::default()); + let observed = physical("codex-micro-bluetooth", false); + let (expected_name, _) = state + .plan_device_presence_refresh(&observed.descriptor()) + .expect("transport changed"); + + state.device = physical("codex-micro-hid", false); + state.apply_device_presence_refresh(&expected_name, observed, None); + + assert_eq!(state.device.descriptor().name, "codex-micro-hid"); + } + #[test] fn hosted_terminal_replaces_and_then_restores_raw_journal() { let mut state = state(); diff --git a/docs/device-hid.md b/docs/device-hid.md index 0f1c4b8..1b0d2d4 100644 --- a/docs/device-hid.md +++ b/docs/device-hid.md @@ -15,9 +15,10 @@ Firmware changes may invalidate this document — treat it as a living map. | Key / dial / joystick input | notify parsers and conservative routing map implemented (`v.oai.hid` / `v.oai.rad`); exact codes remain gated on physical capture | | Bluetooth | supported when the paired Micro exposes its vendor HID interface; transport is reported separately from USB | -Without a claimed device the daemon uses [`MockDevice`](../crates/mb-device/src/lib.rs) -so CI and headless installs stay green. A detected-but-unclaimed device still shows -in the UI as **Detected** (not Connected). +The daemon uses [`MockDevice`](../crates/mb-device/src/lib.rs) only when no supported +physical device is detected, so CI and headless installs stay green. A +detected-but-unclaimed Micro remains a `HidDevice`, preserving its +`codex-micro-` descriptor and **Detected** UI status (not Connected). The daemon refreshes presence every two seconds while keeping explicit Retry semantics for a stable device whose claim failed. diff --git a/docs/hardware-bringup.md b/docs/hardware-bringup.md index 18b8d17..9369101 100644 --- a/docs/hardware-bringup.md +++ b/docs/hardware-bringup.md @@ -19,7 +19,16 @@ Do the steps in order. Each one has an exact command and a "pass" condition. ## 1. Confirm detection ```sh -ioreg -r -c IOHIDDevice -l | grep -i -E -A8 "codex micro|creator micro" +ioreg -r -c IOHIDDevice -l -w 0 | awk ' + /^\+-o / { + if (tolower(record) ~ /codex micro|creator micro/) printf "%s", record + record = "" + } + { record = record $0 ORS } + END { + if (tolower(record) ~ /codex micro|creator micro/) printf "%s", record + } +' ``` **Pass:** a record with `VendorID = 12346` (`0x303a`), `ProductID = 33632` diff --git a/docs/releases/v0.3.9.md b/docs/releases/v0.3.9.md index b9f5d64..8ac0801 100644 --- a/docs/releases/v0.3.9.md +++ b/docs/releases/v0.3.9.md @@ -17,6 +17,8 @@ ## Native QA -- A Developer ID–signed isolated app detected a physical Codex Micro, claimed - it from the native right-click menu, changed the daemon snapshot to Connected, - refreshed the menu to Release, and returned to Detected after release. +- A Developer ID–signed isolated app detected and controlled a physical Codex + Micro over USB-C, using the native right-click Claim and Release commands. +- The same signed candidate detected the paired device as Bluetooth Low Energy, + claimed it through the native menu, proved a successful HID write, refreshed + the menu to Release, and returned to Detected after release. diff --git a/scripts/install-from-release.sh b/scripts/install-from-release.sh index c6717f0..c732672 100755 --- a/scripts/install-from-release.sh +++ b/scripts/install-from-release.sh @@ -95,6 +95,17 @@ if [[ "$OS" == "Darwin" ]]; then /usr/bin/pgrep -f "$APP_COMMAND_PATTERN" >/dev/null 2>&1 || break /bin/sleep 0.1 done + if /usr/bin/pgrep -f "$APP_COMMAND_PATTERN" >/dev/null 2>&1; then + echo " app did not exit after SIGTERM; forcing shutdown" + while read -r pid; do + kill -KILL "$pid" 2>/dev/null || true + done < <(/usr/bin/pgrep -f "$APP_COMMAND_PATTERN" 2>/dev/null || true) + /bin/sleep 0.1 + fi + if /usr/bin/pgrep -f "$APP_COMMAND_PATTERN" >/dev/null 2>&1; then + echo "error: could not stop $DEST before replacement" >&2 + return 1 + fi fi rm -rf "$DEST" mv "$STAGING" "$DEST" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 1665b68..2eab33b 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -52,6 +52,17 @@ if [[ "$(uname -s)" == "Darwin" ]]; then /usr/bin/pgrep -f "$APP_COMMAND_PATTERN" >/dev/null 2>&1 || break /bin/sleep 0.1 done + if /usr/bin/pgrep -f "$APP_COMMAND_PATTERN" >/dev/null 2>&1; then + echo " app did not exit after SIGTERM; forcing shutdown" + while read -r pid; do + kill -KILL "$pid" 2>/dev/null || true + done < <(/usr/bin/pgrep -f "$APP_COMMAND_PATTERN" 2>/dev/null || true) + /bin/sleep 0.1 + fi + if /usr/bin/pgrep -f "$APP_COMMAND_PATTERN" >/dev/null 2>&1; then + echo "error: could not stop $APP before removal" >&2 + exit 1 + fi rm -rf "$APP" rm -f "$SOURCE_MARKER" "$BREW_MARKER" "$RELEASE_MARKER" elif [[ -d "$APP" ]]; then