diff --git a/crates/mb-device/src/probe.rs b/crates/mb-device/src/probe.rs index 9ab2cdf..0497ed7 100644 --- a/crates/mb-device/src/probe.rs +++ b/crates/mb-device/src/probe.rs @@ -23,10 +23,18 @@ impl ProbeResult { pub fn probe_usb_micro() -> ProbeResult { #[cfg(target_os = "macos")] { - match system_profiler_usb_text(std::time::Duration::from_secs(3)) { - Some(text) => match_usb_text(&text), - None => ProbeResult::absent(), + if let Some(text) = system_profiler_usb_text(std::time::Duration::from_secs(3)) { + let result = match_usb_text(&text); + if result.present { + return result; + } + } + // `system_profiler SPUSBDataType` returns empty on some macOS builds; + // `ioreg` still lists attached USB devices. + if let Some(text) = ioreg_usb_text() { + return match_ioreg_text(&text); } + ProbeResult::absent() } #[cfg(not(target_os = "macos"))] { @@ -63,6 +71,69 @@ pub fn match_usb_text(raw: &str) -> ProbeResult { ProbeResult::absent() } +/// Match `ioreg -p IOUSB -l` text (decimal idVendor/idProduct or product name). +pub fn match_ioreg_text(raw: &str) -> ProbeResult { + let lower = raw.to_ascii_lowercase(); + + if let Some(pid) = ioreg_matching_pid(&lower) { + return ProbeResult { + present: true, + product_id: Some(pid), + }; + } + + if lower.contains("codex micro") || (lower.contains("work louder") && lower.contains("codex")) { + return ProbeResult { + present: true, + product_id: Some(CODEX_MICRO_PID), + }; + } + + ProbeResult::absent() +} + +/// Find a supported VID/PID pair in `ioreg -p IOUSB -l` output. +/// +/// Scoped per device node (`ioreg` starts each with `+-o `) for the same +/// reason [`match_usb_text`] scopes to a record block: the ids only mean +/// anything together. A flat line scan would pair one device's vendor with the +/// next device's product — and would depend on `ioreg` emitting `idVendor` +/// before `idProduct`, which is a property-dictionary order it does not promise. +fn ioreg_matching_pid(lower: &str) -> Option { + // `split` on the node marker rather than `lines`, so the first fragment is + // the (device-less) header and every other fragment is exactly one node. + lower.split("+-o ").find_map(ioreg_node_matching_pid) +} + +fn ioreg_node_matching_pid(node: &str) -> Option { + let mut vid = None; + let mut pid = None; + for line in node.lines() { + let line = line.trim(); + // A malformed value must skip that property, never abort the scan: the + // Codex Micro may be listed after whatever failed to parse. + if let Some(rest) = line.strip_prefix("\"idvendor\" =") { + vid = vid.or_else(|| parse_ioreg_id(rest)); + } else if let Some(rest) = line.strip_prefix("\"idproduct\" =") { + pid = pid.or_else(|| parse_ioreg_id(rest)); + } + } + if vid != Some(WL_VID) { + return None; + } + pid.filter(|pid| is_supported_pid(*pid)) +} + +/// `ioreg -l` prints these as decimal, but tolerate a `0x` form too rather than +/// silently treating a hex listing as "no device attached". +fn parse_ioreg_id(raw: &str) -> Option { + let value = raw.trim(); + if let Some(hex) = value.strip_prefix("0x") { + return u16::from_str_radix(hex, 16).ok(); + } + value.parse::().ok() +} + fn block_matching_pid(block: &str) -> Option { let vid = parse_id_field(block, "vendor id")?; if vid != WL_VID { @@ -90,6 +161,22 @@ fn parse_hex_id(raw: &str) -> Option { u16::from_str_radix(hex, 16).ok() } +#[cfg(target_os = "macos")] +fn ioreg_usb_text() -> Option { + use std::process::{Command, Stdio}; + + let output = Command::new("/usr/sbin/ioreg") + .args(["-p", "IOUSB", "-l"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} + #[cfg(target_os = "macos")] fn system_profiler_usb_text(timeout: std::time::Duration) -> Option { use std::io::Read; @@ -97,7 +184,7 @@ fn system_profiler_usb_text(timeout: std::time::Duration) -> Option { use std::thread; use std::time::Instant; - let mut child = Command::new("system_profiler") + let mut child = Command::new("/usr/sbin/system_profiler") .args(["SPUSBDataType", "-detailLevel", "mini"]) .stdout(Stdio::piped()) .stderr(Stdio::null()) @@ -161,4 +248,70 @@ ESP Serial: let r = match_usb_text("Something Codex Micro attached"); assert!(r.present); } + + #[test] + fn matches_ioreg_decimal_ids() { + let sample = r#" + | +-o Codex Micro@02100000 + | "USB Product Name" = "Codex Micro" + | "idVendor" = 12346 + | "idProduct" = 33632 +"#; + let r = match_ioreg_text(sample); + assert!(r.present); + assert_eq!(r.product_id, Some(CODEX_MICRO_PID)); + } + + #[test] + fn matches_ioreg_name_fallback() { + let sample = r#""kUSBProductString" = "Codex Micro""#; + assert!(match_ioreg_text(sample).present); + } + + /// A device whose ids do not parse must not hide devices listed after it. + #[test] + fn ioreg_unparseable_id_does_not_abort_the_scan() { + let sample = r#" + | +-o SomeHub@01000000 + | "idVendor" = + | "idProduct" = not-a-number + | +-o Codex Micro@02100000 + | "idVendor" = 12346 + | "idProduct" = 33632 +"#; + let r = match_ioreg_text(sample); + assert!(r.present, "a malformed earlier device hid the Micro"); + assert_eq!(r.product_id, Some(CODEX_MICRO_PID)); + } + + /// Ids are only meaningful together, so they must come from one node. + #[test] + fn ioreg_does_not_pair_ids_across_devices() { + let sample = r#" + | +-o WorkLouderOther@01000000 + | "idVendor" = 12346 + | "idProduct" = 4097 + | +-o UnrelatedVendor@02100000 + | "idProduct" = 33632 +"#; + // The supported PID belongs to a node with no Work Louder vendor id. + assert!(!match_ioreg_text(sample).present); + } + + #[test] + fn ioreg_tolerates_property_order_and_hex() { + let sample = r#" + | +-o Codex Micro@02100000 + | "idProduct" = 33632 + | "idVendor" = 12346 +"#; + assert!(match_ioreg_text(sample).present, "idProduct listed first"); + + let hex = r#" + | +-o Codex Micro@02100000 + | "idVendor" = 0x303a + | "idProduct" = 0x8360 +"#; + assert!(match_ioreg_text(hex).present, "hex-formatted ids"); + } }