Skip to content

Commit 637b152

Browse files
committed
Volume switcher: USB link speed indicator
- Color-coded dot on the volume chip and on each dropdown row for MTP volumes, mirroring the SMB indicator placement and tooltip pattern. - Five tiers: USB 1.0 low / 1.1 full / 2.0 / 3.2 Gen 1 / 3.2 Gen 2 → red, orange, yellow, light green, dark green. Dark green is `--color-allow`, same shade as SMB direct. - Tooltip shows the generation name, theoretical max MB/s, and a "Negotiated for this cable, port, and device" line on a second row. - Backend: `usb_speed: Option<UsbSpeed>` plumbed from `mtp-rs` through MTP discovery and `MtpConnectionManager::connect` into `LocationInfo`. New `crate::usb_speed` module mirrors `mtp_rs::UsbSpeed` cross-platform so macOS, Linux, and stub volumes share one shape. - Bug: the `commands/volumes.rs` and `volume_broadcast.rs` MTP enrichment paths are duplicated; the IPC variant hardcoded `usb_speed: None`, leaving the dot missing on bootstrap until a `volumes-changed` push refreshed it. Both call sites now read from `ConnectedDeviceInfo`. Documented in `volumes/CLAUDE.md` so future agents catch the two-site contract. - Bumped `mtp-rs` to 0.14.0. - Docs: new "USB link-speed indicator (MTP)" section in `navigation/CLAUDE.md`; `mtp/CLAUDE.md` notes the field; `volumes/CLAUDE.md` gains the two-site `append_mtp_volumes` gotcha.
1 parent b365076 commit 637b152

20 files changed

Lines changed: 248 additions & 6 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ mime_guess = "2"
161161

162162
[target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies]
163163
# MTP (Android device) support via pure Rust implementation
164-
mtp-rs = "0.13.2"
164+
mtp-rs = "0.14.0"
165165
# USB hotplug detection for MTP device watcher
166166
nusb = "0.2.3"
167167
bytes = "1"

apps/desktop/src-tauri/src/commands/volumes.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ pub async fn resolve_path_volume(path: String) -> PathVolumeResolution {
104104
supports_trash: false,
105105
is_read_only: false,
106106
smb_connection_state: None,
107+
usb_speed: None,
107108
}),
108109
timed_out: false,
109110
};
@@ -163,6 +164,7 @@ async fn append_mtp_volumes(volumes: &mut Vec<VolumeInfo>) {
163164
fs_type: Some("mtp".to_string()),
164165
supports_trash: false,
165166
smb_connection_state: None,
167+
usb_speed: device.device.usb_speed,
166168
});
167169
}
168170
}

apps/desktop/src-tauri/src/commands/volumes_linux.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ pub async fn resolve_path_volume(path: String) -> PathVolumeResolution {
7878
supports_trash: false,
7979
is_read_only: false,
8080
smb_connection_state: None,
81+
usb_speed: None,
8182
}),
8283
timed_out: false,
8384
};
@@ -138,6 +139,7 @@ async fn append_mtp_volumes(volumes: &mut Vec<VolumeInfo>) {
138139
fs_type: Some("mtp".to_string()),
139140
supports_trash: false,
140141
smb_connection_state: None,
142+
usb_speed: device.device.usb_speed,
141143
});
142144
}
143145
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ pub mod test_mode;
127127
mod text_size;
128128
#[cfg(target_os = "macos")]
129129
mod updater;
130+
mod usb_speed;
130131
mod volume_broadcast;
131132
#[cfg(target_os = "macos")]
132133
mod volumes;

apps/desktop/src-tauri/src/mtp/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ On Linux, users may need udev rules for USB device permissions (see `resources/9
99
| File | Purpose |
1010
|------|---------|
1111
| `mod.rs` | Re-exports public surface; module-level doc |
12-
| `types.rs` | `MtpDeviceInfo`, `MtpStorageInfo`: camelCase JSON via `serde(rename_all)` |
12+
| `types.rs` | `MtpDeviceInfo`, `MtpStorageInfo`: camelCase JSON via `serde(rename_all)`. `MtpDeviceInfo::usb_speed` mirrors `mtp_rs::UsbSpeed` via the shared `crate::usb_speed::UsbSpeed` (also surfaced on `LocationInfo` for MTP volumes so the volume switcher can show a colored dot). |
1313
| `discovery.rs` | `list_mtp_devices()` via `mtp_rs::MtpDevice::list_devices()`; device IDs formatted as `"mtp-{location_id}"` |
1414
| `watcher.rs` | `start_mtp_watcher()`: nusb hotplug watcher; 500 ms delay on connect before re-checking; auto-connects detected devices via `MtpConnectionManager::connect()` and auto-disconnects removed ones |
1515
| `macos_workaround.rs` | macOS-only (`#[cfg(target_os = "macos")]`). Auto-suppresses `ptpcamerad` via `launchctl disable` + `pkill`; restores on disconnect/exit; `ensure_ptpcamerad_enabled()` on startup for crash recovery. Falls back to manual `PTPCAMERAD_WORKAROUND_COMMAND` dialog if suppression fails |
16-
| `connection/mod.rs` | `MtpConnectionManager` singleton (`LazyLock`); `DeviceEntry` map; `connect()` (idempotent, probes write capability, registers `MtpVolume`); `disconnect()` |
16+
| `connection/mod.rs` | `MtpConnectionManager` singleton (`LazyLock`); `DeviceEntry` map; `connect()` (idempotent, probes write capability, registers `MtpVolume`, re-runs `MtpDevice::list_devices()` once to fetch the negotiated USB link speed since the open `MtpDevice` doesn't expose it); `disconnect()` |
1717
| `connection/cache.rs` | `PathHandleCache` (path → MTP object handle), `ListingCache` (5 s TTL), `EventDebouncer` (500 ms per device) |
1818
| `connection/errors.rs` | `MtpConnectionError` enum with typed variants and `map_mtp_error()` from `mtp_rs::Error` |
1919
| `connection/event_loop.rs` | Background tokio task per device: polls `device.next_event()`, computes diffs, emits `directory-diff` events using the unified diff system |

apps/desktop/src-tauri/src/mtp/connection/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,15 @@ impl MtpConnectionManager {
249249

250250
// Get device info
251251
let mtp_info = device.device_info();
252+
253+
// Speed isn't exposed by the open MTP session — read it from a fresh USB
254+
// enumeration. `list_devices()` is a cheap syscall (no device open).
255+
let usb_speed = MtpDevice::list_devices()
256+
.ok()
257+
.and_then(|devs| devs.into_iter().find(|d| d.location_id == location_id))
258+
.and_then(|d| d.speed)
259+
.map(crate::mtp::types::UsbSpeed::from);
260+
252261
let device_info = MtpDeviceInfo {
253262
id: device_id.to_string(),
254263
location_id,
@@ -269,6 +278,7 @@ impl MtpConnectionManager {
269278
} else {
270279
Some(mtp_info.serial_number.clone())
271280
},
281+
usb_speed,
272282
};
273283

274284
debug!(
@@ -1007,6 +1017,7 @@ mod tests {
10071017
manufacturer: Some("Google".to_string()),
10081018
product: Some("Pixel 8".to_string()),
10091019
serial_number: None,
1020+
usb_speed: None,
10101021
},
10111022
storages: vec![MtpStorageInfo {
10121023
id: 65537,

apps/desktop/src-tauri/src/mtp/discovery.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ pub fn list_mtp_devices() -> Vec<MtpDeviceInfo> {
4141
manufacturer: d.manufacturer,
4242
product: d.product,
4343
serial_number: d.serial_number,
44+
usb_speed: d.speed.map(Into::into),
4445
}
4546
})
4647
.collect()

apps/desktop/src-tauri/src/mtp/types.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
55
use serde::Serialize;
66

7+
pub use crate::usb_speed::UsbSpeed;
8+
79
/// Information about a connected MTP device.
810
///
911
/// This represents a device detected via USB, before opening an MTP session.
@@ -25,6 +27,9 @@ pub struct MtpDeviceInfo {
2527
pub manufacturer: Option<String>,
2628
pub product: Option<String>,
2729
pub serial_number: Option<String>,
30+
/// Negotiated USB link speed (slowest of host port, cable, device).
31+
/// `None` if the OS doesn't report it.
32+
pub usb_speed: Option<UsbSpeed>,
2833
}
2934

3035
/// Information about a storage area on an MTP device.
@@ -64,13 +69,15 @@ mod tests {
6469
manufacturer: Some("Google".to_string()),
6570
product: Some("Pixel".to_string()),
6671
serial_number: None,
72+
usb_speed: Some(UsbSpeed::Super),
6773
};
6874
let json = serde_json::to_string(&device).unwrap();
6975
assert!(json.contains("\"vendorId\":"));
7076
assert!(json.contains("\"productId\":"));
7177
assert!(json.contains("\"locationId\":"));
7278
// serialNumber serializes as explicit null (no longer omitted)
7379
assert!(json.contains("\"serialNumber\":null"));
80+
assert!(json.contains("\"usbSpeed\":\"super\""));
7481
}
7582

7683
#[test]

apps/desktop/src-tauri/src/stubs/volumes.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ pub struct VolumeInfo {
3535
pub is_read_only: bool,
3636
/// SMB connection state indicator. Always `None` on stub platforms.
3737
pub smb_connection_state: Option<String>,
38+
/// Negotiated USB link speed. Always `None` on stub platforms (no MTP).
39+
pub usb_speed: Option<crate::usb_speed::UsbSpeed>,
3840
}
3941

4042
/// Information about volume space.
@@ -75,6 +77,7 @@ pub fn list_volumes() -> Vec<VolumeInfo> {
7577
supports_trash: true,
7678
is_read_only: false,
7779
smb_connection_state: None,
80+
usb_speed: None,
7881
});
7982
}
8083
}
@@ -91,6 +94,7 @@ pub fn list_volumes() -> Vec<VolumeInfo> {
9194
supports_trash: true,
9295
is_read_only: false,
9396
smb_connection_state: None,
97+
usb_speed: None,
9498
});
9599

96100
// Add home directory
@@ -105,6 +109,7 @@ pub fn list_volumes() -> Vec<VolumeInfo> {
105109
supports_trash: true,
106110
is_read_only: false,
107111
smb_connection_state: None,
112+
usb_speed: None,
108113
});
109114

110115
locations

0 commit comments

Comments
 (0)