A cross-platform Tauri app that recreates the main functionality of Apple's
old USB Prober.app: a hierarchical tree of connected USB devices, with
parsed Device descriptors, Configuration descriptors, and HID Report
descriptors.
Starts on macOS (using nusb for device/config descriptors and ioreg
for HID report descriptors), with the backend factored so Linux and
Windows can be added without touching the frontend or the data model.
The output format we are matching is in
tests/fixtures/usb-prober-reference0.txt — a real USB Prober.app dump
that includes a hub, a composite CDC+HID+Audio device, and a parsed HID
Report Descriptor covering keyboard, mouse, and consumer-control
collections. The HID parser's output must visually match the "Parsed
Report Descriptor" block in that fixture.
There is a similar output in usb-prober-referencee1.txt
A single normalized data model in Rust (crates/usb-types/src/lib.rs),
with per-platform "collectors" populating it. The frontend never sees
platform-specific shapes. Also derives specta::Type for automatic
TypeScript type generation via tauri-specta.
Key types (see crate for full definitions):
UsbDevice— location_id (hex string), bus_number, port_path, speed, device_descriptor, configurations, strings, hid_interfaces, childrenDeviceDescriptor— standard USB fields +raw_bytes: Vec<u8>(18 bytes)ConfigDescriptor— standard fields +raw_bytes: Vec<u8>(full config blob)InterfaceDescriptor— class/subclass/protocol/endpointsEndpointDescriptor— address, attributes, max_packet_size, intervalHidInterface— interface_number, raw_report_descriptor (bytes);parsedfield TBD in step 3UsbSpeed— Low/Full/High/Super/SuperPlus/Unknown
A trait with cfg-gated impls:
pub trait UsbCollector {
fn enumerate(&self) -> Result<Vec<UsbDevice>, CollectorError>;
}
#[cfg(target_os = "macos")]
pub use macos::MacCollector as PlatformCollector;
#[cfg(target_os = "linux")]
pub use linux::LinuxCollector as PlatformCollector;
#[cfg(target_os = "windows")]
pub use windows::WinCollector as PlatformCollector;Tauri command is then trivial:
#[tauri::command]
fn enumerate_usb() -> Result<Vec<UsbDevice>, String> {
PlatformCollector::default()
.enumerate()
.map_err(|e| e.to_string())
}Two-pass approach in crates/usb-collector-macos/src/lib.rs:
Pass 1 — nusb crate (primary enumerator):
ioreg -a -p IOUSB does NOT expose raw config descriptor bytes — only
parsed field values. Instead, use the nusb crate (v0.2.3), which wraps
IOKit directly and returns raw descriptor bytes from GetConfigurationDescriptorPtr().
No libusb dependency; no exclusive device open required for descriptor reads.
// nusb returns raw bytes on DeviceInfo without needing open()
for dev_info in nusb::list_devices().wait()? {
let device = dev_info.open().wait()?; // needed for device_descriptor()
let raw_cfg = cfg.as_bytes().to_vec(); // full raw config blob ✓
let raw_dev = device.device_descriptor().as_bytes().to_vec(); // 18 bytes ✓
}Pass 2 — ioreg HID pass:
IOHIDInterface nodes do NOT appear in the IOUSB plane. Run a separate
ioreg -a -c IOHIDInterface -l -r -w 0 and walk the plist for nodes
where IOObjectClass = "IOHIDInterface" AND Transport = "USB". Extract
ReportDescriptor (Data blob) keyed by LocationID (capital L, u32).
Correlation key: nusb's dev_info.location_id() == LocationID from
the IOHIDInterface node. Both are the same IOKit locationID u32 value
((bus << 24) | (port_chain_nibbles...)).
Known gaps vs USB Prober output:
- Interface string descriptors not fetched (only manufacturer/product/serial
come from
DeviceInfo; per-interface strings needdevice.get_string_descriptor()) @ Nin the header shows last port in port_chain, not USB device address- Hub Descriptor, Device Qualifier, Other Speed Config sections omitted (need additional class-specific control transfers)
- HID parsed descriptor output blocked on step 3 (hid-parser crate)
Examples:
examples/dump_one.rs— structured field dumpexamples/prober_fmt.rs— USB Prober-style text output matchingtests/fixtures/usb-prober-reference0.txt
All data read from sysfs — no device open, no elevated privileges required:
/sys/bus/usb/devices/<bus>-<port>/
descriptors ← raw binary: device descriptor + all config blobs
<bus>-<port>:<cfg>.<iface>/
0003:<VID>:<PID>.<N>/
report_descriptor ← raw HID bytes (if HID interface)
Implementation in crates/usb-collector-linux/:
src/lib.rs—LinuxCollector::enumerate()+device_from_descriptor_bytes()helpersrc/descriptor.rs— parses raw device/config/interface/endpoint descriptorssrc/hid.rs— walks sysfs looking for0003:subdirs and readsreport_descriptor
nusb::list_devices() supplies metadata (busnum, port_chain, string caches, speed).
The descriptors sysfs file provides all raw descriptor bytes without opening the
device. location_id is set to the sysfs basename (e.g. "2-4", "2-2.3").
Examples:
examples/dump_one.rs— live USB enumeration with HID parsingexamples/from_sysfs_file.rs— parse a stored sysfsdescriptorsbinary; falls back to a hardcoded blink(1) fixture when no path is given (no hardware needed)
crates/usb-collector-windows/src/lib.rs — follows the macOS pattern using nusb.
What works:
nusb::list_devices()enumerates all USB devices- nusb reads device/config descriptors via Windows USB device interface (hub-level); works for all devices regardless of driver (WinUSB, HID.sys, usbstor, usbaudio, etc.)
- All devices: VID/PID, speed, manufacturer/product/serial strings, config descriptors
location_idconstructed from{vid:04x}:{pid:04x}:{serial_or_port_chain}- HID report descriptors via
src/hid.rs:- SetupDi enumerates all HID device interfaces (
GUID_DEVINTERFACE_HID) - Opens each with
CreateFile→HidD_GetAttributesfor VID/PID HidD_GetSerialNumberStringfor serial (used as map key)HidD_GetPreparsedData→HidP_GetCaps/HidP_GetButtonCaps/HidP_GetValueCapsto enumerate all input/output/feature capabilities- Reconstructs a synthetic but valid HID report descriptor from those capabilities
- Interface number parsed from device path (
MI_xxsegment)
- SetupDi enumerates all HID device interfaces (
Approach rationale:
The Windows kernel unconditionally overwrites the bmRequest field in
IOCTL_USB_GET_DESCRIPTOR_FROM_NODE_CONNECTION to 0x80 (standard device
request), making it impossible to retrieve HID class descriptors (types 0x21,
0x22) via hub IOCTLs. HidD_GetReportDescriptor is kernel-mode only and not
exported from user-mode hid.dll. The preparsed-data approach (hidapi style)
is the correct user-mode path and works for all HID devices regardless of driver.
Synthetic descriptor limitations:
- Not byte-identical to the device's original descriptor
- Vendor-specific items are absent (not exposed via HidP_ APIs)
- Sub-collection nesting is flattened to a single Application collection
- Item ordering may differ from the original
- Despite these differences the output is valid HID and fully parseable
Custom item-stream walker in crates/hid-parser/src/lib.rs.
parse(&[u8]) -> Result<Vec<HidNode>, ParseError>— reads byte stream, builds typed tree (Collection nodes contain their children)render_text(&[HidNode], base_indent) -> String— USB Prober-style text outputHidNodeand supporting types (CollectionKind,HidIoFlags) live incrates/usb-typesso they serialize to JSON automatically via serde + specta- Golden test in
crates/hid-parser/tests/golden_pico2_parsed.txt— 156-byte Pico 2 descriptor rendered output matches reference fixture lines 199–276 exactly
Key implementation details:
- Logical/Physical Min/Max sign-extended from raw bytes (e.g.
0x81→ -127) - Input with Variable (bit 1): show 8 flags; Input with Array: show 3 flags
- Output/Feature: always show 9 flags including Volatile (bit 7)
- Usage table is a minimal static match covering common pages; unknown usages
fall back to
Usage N (0xN)matching USB Prober behaviour
HID output hierarchy (matches USB Prober reference fixture):
Interface #N - HID
HID Descriptor
Descriptor Version Number: 0x0111
Country Code: 0
Descriptor Count: 1
Descriptor 1
Type: 0x22 (Report Descriptor)
Length (and contents): 156
Raw Descriptor (hex) 0000: ...
Parsed Report Descriptor:
Usage Page (Generic Desktop)
...
Endpoint 0x84 - Interrupt Input
The formatter (crates/usb-formatter) and the GUI tree view both produce this
hierarchy. The HID Report Descriptor is nested inside HID Descriptor →
Descriptor N, not rendered as a separate top-level sibling.
Two view modes toggled in the header:
Tree view — single-pane collapsible tree matching Mac USB Prober's layout.
Each device is a top-level TreeNode; children are Device Descriptor,
Configuration Descriptor (with interfaces/endpoints/HID inline), and
Number of Endpoints. Uses React context (DepthCtx) to track nesting depth
and compute label-area pixel width so the value column lands at a constant
absolute x-position at every nesting level.
Split view — left panel lists devices; right panel shows Device Descriptor / Configuration / HID tabs for the selected device. Tab is preserved when switching devices (unless the new device has no HID and "HID" was active).
UI features:
- Refresh button + Auto toggle — manual re-enumeration or live hotplug via
nusb::watch_devices()emitting ausb-changedTauri event - Save Output button (
Cmd+S) — callsformat_as_textTauri command (Mac USB Prober-style text, same formatter as the CLI), shows native save dialog, writes.txt - Save JSON button (
Cmd+Shift+S) — serialises the loaded device list, writes.json - Row selection — click or click-drag selects rows line-by-line (no character-level text selection); shift+click extends the range; Cmd+C copies selected rows as formatter-matched indented text; double-click or click the ▾/▸ arrow to expand/collapse
- Dark mode via CSS custom properties; all colors defined in
:root, overridden once in@media (prefers-color-scheme: dark) - Window: 1000 × 600 px; font: SF Mono 13px weight 500
Tauri commands:
enumerate_usb() -> Result<Vec<UsbDevice>, String>— platform-dispatchedformat_as_text(devices: Vec<UsbDevice>) -> String— Mac USB Prober text formatwrite_text_file(path: String, content: String) -> Result<(), String>— file write
Text formatter lives in src-tauri/src/formatter.rs; same logic duplicated in
crates/usb-cli/src/main.rs for the standalone CLI.
Hotplug: implemented via nusb::watch_devices() in a background thread that
emits app.emit("usb-changed", ()) on every attach/detach event. The frontend
listens with listen("usb-changed", ...) when the Auto toggle is on.
crates/usb-cli — standalone usb-probester-cli binary.
usb-probester-cli # Mac USB Prober-style text tree (default)
usb-probester-cli --format json # pretty-printed JSON
cargo build --release -p usb-cli # → target/release/usb-probester-cliDepends on the same collector and parser crates as the Tauri app.
Platform dispatch mirrors src-tauri/src/lib.rs.
Define the✓ doneusb-typescrate.macOS collector via nusb + ioreg HID pass.✓ done✓ donehid-parsercrate.Tauri wiring + basic frontend.✓ doneLinux collector via✓ done/sys.Frontend tree + descriptor panels.✓ doneHotplug (nusb✓ donewatch_devices+ Tauriusb-changedevent + auto-refresh toggle).Windows basic enumeration (nusb).✓ done — hub IOCTLs + HID still TODOClass-specific descriptors (CS_INTERFACE/HID/IAD, CDC, Audio, MIDI).✓ doneRow selection — click/drag line-by-line; Cmd+C copies formatter-matched text.✓ done
The design holds up because the report descriptor bytes are the same
bytes regardless of how they were obtained, AND the parser is fully
device-agnostic. The OS backends only need to produce Vec<u8> for each
descriptor type plus topology metadata.
These sections appear in the original USB Prober output but are not yet rendered by the CLI or GUI.
-
BOS Descriptor — Binary Object Store, present on SuperSpeed devices. Contains capability descriptors: USB 2.0 Extension (
bmAttributes/ LPM), SuperSpeed USB Device (speeds, U1/U2 latencies), and ContainerID (UUID). nusb can issueGET_DESCRIPTOR(BOS)to obtain the raw bytes; the formatter then needs to parse and render the capability sub-descriptors. -
Hub Descriptor (
bDescriptorType = 0x29) — Hub-class descriptor returned by aGET_DESCRIPTOR(class, hub)control transfer. Fields: number of ports, hub characteristics, power-on-to-good time, controller current, device-removable bitmap, port power control mask. Requires opening the device and issuing a class-specific control transfer. -
Device Qualifier Descriptor — Present on High Speed devices; describes the device's configuration at the alternate speed.
GET_DESCRIPTOR(0x06). Only meaningful for USB 2.0 HS devices. -
Other Speed Configuration Descriptor — Full configuration descriptor for the alternate speed.
GET_DESCRIPTOR(0x07). Same format as a normal config descriptor; the formatter can reuseformat_config_descriptor.
- IAD sub-fields — The Interface Association block currently renders only
a one-line header. USB Prober also shows:
First Interface,Interface Count,Function Class(with label),Function Subclass,Interface Protocol,Function String. All fields are already inInterfaceAssociationinusb-types; the formatter just needs to emit them.
These are cosmetic divergences from the USB Prober reference; data is present but rendered differently:
-
Polling interval for SuperSpeed endpoints — USB Prober shows
8 (128 microframes (16 msecs))for a bInterval of 8 on a SuperSpeed device. CLI shows8 ms. SuperSpeed interval is2^(bInterval-1)× 125 µs in microframes; the formatter should detect SuperSpeed and compute accordingly. -
Endpoint max packet size for interrupt EPs — USB Prober shows plain
64; CLI shows0x0040 (1 x 64 transactions per microframe). Reference format uses plain decimal for non-HS interrupt endpoints; the multiplier form is only correct for High Speed where bits 12:11 encode the transaction count. -
Interface subclass label — CLI adds a human-readable label (e.g.,
(Abstract Control Model)) that USB Prober omits. Minor; keep or drop. -
CDC functional descriptor rendering — USB Prober shows raw hex bytes for each Comm Class functional descriptor. CLI parses them into named fields, which is more useful. No change needed; CLI is richer here.
-
Pipe real
ioregoutput into the repo as test fixtures.ioreg -a -p IOUSB -l -w 0 > tests/fixtures/macos-mymachine.plist. Same later for Linux (cp -r /sys/bus/usb/devices tests/fixtures/linux-sysfs/, carefully) and for Windows ioctl response captures. -
Expand the HUT usage table as needed. Currently a minimal static match covering pages used by the Pico 2 fixture. Unknown usages fall back to
Usage N (0xN). Grow the table when real devices expose gaps.