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 }) => (
)}
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