Skip to content

Commit 938e87c

Browse files
committed
MTP: Add module structure and device discovery
Phase 1 foundation for Android device support: - Add mtp-rs dependency (local path, macOS only) - Create mtp module with types.rs and discovery.rs - Add list_mtp_devices Tauri command - Add TypeScript wrapper for frontend - Add Linux stubs for E2E testing compatibility - Update cargo deny config for local path deps
1 parent 4a51b80 commit 938e87c

13 files changed

Lines changed: 538 additions & 31 deletions

File tree

apps/desktop/src-tauri/Cargo.lock

Lines changed: 173 additions & 20 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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ authors = ["David Veszelovszki <veszelovszki@gmail.com>"]
66
edition = "2024"
77
license = "LicenseRef-BSL-1.1"
88
default-run = "Cmdr"
9+
publish = false # Private application, not for crates.io
910

1011
[[bin]]
1112
name = "Cmdr"
@@ -85,6 +86,8 @@ smb = "0.11.1"
8586
smb-rpc = "=0.11.1"
8687
chrono = "0.4"
8788
security-framework = "3.2"
89+
# MTP (Android device) support via pure Rust implementation
90+
mtp-rs = { path = "../../../../mtp-rs" }
8891

8992
[dev-dependencies]
9093
criterion = { version = "0.8.1", features = ["html_reports"] }

apps/desktop/src-tauri/deny.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ allow = [
3333
multiple-versions = "allow"
3434
wildcards = "deny"
3535
highlight = "all"
36+
# Allow mtp-rs local path dependency during development (will use git/crates.io later)
37+
allow-wildcard-paths = true
3638

3739
[sources]
3840
unknown-registry = "deny"

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ pub mod font_metrics;
66
pub mod icons;
77
pub mod licensing;
88
#[cfg(target_os = "macos")]
9+
pub mod mtp;
10+
#[cfg(target_os = "macos")]
911
pub mod network;
1012
pub mod settings;
1113
pub mod sync_status; // Has both macOS and non-macOS implementations
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//! Tauri commands for MTP (Android device) operations.
2+
3+
use crate::mtp::{self, MtpDeviceInfo};
4+
5+
/// Lists all connected MTP devices.
6+
///
7+
/// This returns devices detected via USB that support MTP protocol.
8+
/// Use this to populate the "Mobile" section in the volume picker.
9+
///
10+
/// # Returns
11+
///
12+
/// A vector of device info structs. Empty if no devices are connected.
13+
#[tauri::command]
14+
pub fn list_mtp_devices() -> Vec<MtpDeviceInfo> {
15+
mtp::list_mtp_devices()
16+
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ use tauri_plugin_mcp_bridge as _;
3939
// security_framework is used in network/keychain.rs for Keychain integration
4040
#[cfg(target_os = "macos")]
4141
use security_framework as _;
42+
//noinspection ALL
43+
// mtp-rs is used in mtp/ module for Android device support (macOS only, Phase 1 foundation)
44+
#[cfg(target_os = "macos")]
45+
use mtp_rs as _;
4246

4347
mod ai;
4448
pub mod benchmark;
@@ -54,6 +58,8 @@ mod macos_icons;
5458
mod mcp;
5559
mod menu;
5660
#[cfg(target_os = "macos")]
61+
mod mtp;
62+
#[cfg(target_os = "macos")]
5763
mod network;
5864
#[cfg(target_os = "macos")]
5965
mod permissions;
@@ -342,6 +348,11 @@ pub fn run() {
342348
mcp::settings_state::mcp_update_shortcuts,
343349
// Sync status (macOS uses real implementation, others use stub in commands)
344350
commands::sync_status::get_sync_status,
351+
// MTP commands (macOS only - Android device support)
352+
#[cfg(target_os = "macos")]
353+
commands::mtp::list_mtp_devices,
354+
#[cfg(not(target_os = "macos"))]
355+
stubs::mtp::list_mtp_devices,
345356
// Volume commands (platform-specific)
346357
#[cfg(target_os = "macos")]
347358
commands::volumes::list_volumes,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//! MTP device discovery.
2+
//!
3+
//! Lists connected MTP devices without opening sessions.
4+
//! Used to populate the volume picker with available Android devices.
5+
6+
use super::types::MtpDeviceInfo;
7+
use log::debug;
8+
use mtp_rs::MtpDevice;
9+
10+
/// Lists all connected MTP devices.
11+
///
12+
/// This function enumerates USB devices and filters for MTP-capable ones.
13+
/// It does not open connections to the devices, so it's fast and non-blocking.
14+
///
15+
/// # Returns
16+
///
17+
/// A vector of `MtpDeviceInfo` structs describing available devices.
18+
/// Returns an empty vector if no devices are found or if enumeration fails.
19+
///
20+
/// # Example
21+
///
22+
/// ```ignore
23+
/// let devices = list_mtp_devices();
24+
/// for device in devices {
25+
/// println!("Found: {}", device.display_name());
26+
/// }
27+
/// ```
28+
pub fn list_mtp_devices() -> Vec<MtpDeviceInfo> {
29+
match MtpDevice::list_devices() {
30+
Ok(devices) => {
31+
debug!("Found {} MTP device(s)", devices.len());
32+
devices
33+
.into_iter()
34+
.map(|d| {
35+
let id = format!("mtp-{}-{}", d.bus, d.address);
36+
debug!(
37+
"MTP device: id={}, vendor={:04x}, product={:04x}",
38+
id, d.vendor_id, d.product_id
39+
);
40+
MtpDeviceInfo {
41+
id,
42+
vendor_id: d.vendor_id,
43+
product_id: d.product_id,
44+
// mtp-rs doesn't expose string descriptors in list_devices() yet
45+
// We could get them by opening the device, but that's slower
46+
manufacturer: None,
47+
product: None,
48+
serial_number: None,
49+
}
50+
})
51+
.collect()
52+
}
53+
Err(e) => {
54+
// Log the error but return empty list (graceful degradation)
55+
log::warn!("Failed to enumerate MTP devices: {}", e);
56+
Vec::new()
57+
}
58+
}
59+
}
60+
61+
#[cfg(test)]
62+
mod tests {
63+
use super::*;
64+
65+
#[test]
66+
fn test_list_mtp_devices_returns_vec() {
67+
// This test just verifies the function runs without panicking
68+
// Actual device testing requires hardware
69+
let devices = list_mtp_devices();
70+
// The function should complete without error (even if empty)
71+
// Using is_empty() to avoid useless comparison warning
72+
let _ = devices.is_empty(); // Just verify it returns a valid vec
73+
}
74+
75+
#[test]
76+
fn test_device_id_format() {
77+
// Test that our ID format is consistent
78+
let id = format!("mtp-{}-{}", 1, 5);
79+
assert_eq!(id, "mtp-1-5");
80+
}
81+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//! MTP (Media Transfer Protocol) support for Android devices.
2+
//!
3+
//! This module provides device discovery and file operations for Android devices
4+
//! connected via USB in "File transfer / Android Auto" mode.
5+
//!
6+
//! # Architecture
7+
//!
8+
//! - `types`: Type definitions for frontend communication
9+
//! - `discovery`: Device detection using mtp-rs
10+
//!
11+
//! # Platform Support
12+
//!
13+
//! MTP support is currently macOS-only due to USB access requirements.
14+
//! On macOS, the system daemon `ptpcamerad` may claim devices first;
15+
//! see `macos_workaround` module (Phase 2) for handling this.
16+
17+
mod discovery;
18+
pub mod types;
19+
20+
pub use discovery::list_mtp_devices;
21+
pub use types::MtpDeviceInfo;
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
//! MTP type definitions for frontend communication.
2+
//!
3+
//! These types are serialized to JSON for Tauri commands.
4+
5+
// Phase 1 foundation: some types not yet used, will be used in subsequent phases
6+
#![allow(dead_code, reason = "Phase 1 foundation: types used in later phases")]
7+
8+
use serde::{Deserialize, Serialize};
9+
10+
/// Information about a connected MTP device.
11+
///
12+
/// This represents a device detected via USB, before opening an MTP session.
13+
/// Used by the frontend to display available devices in the volume picker.
14+
#[derive(Debug, Clone, Serialize, Deserialize)]
15+
#[serde(rename_all = "camelCase")]
16+
pub struct MtpDeviceInfo {
17+
/// Unique identifier for the device (based on USB bus/address).
18+
pub id: String,
19+
/// USB vendor ID (e.g., 0x18d1 for Google).
20+
pub vendor_id: u16,
21+
/// USB product ID.
22+
pub product_id: u16,
23+
/// Device manufacturer name, if available from USB descriptor.
24+
#[serde(skip_serializing_if = "Option::is_none")]
25+
pub manufacturer: Option<String>,
26+
/// Device product name, if available from USB descriptor.
27+
#[serde(skip_serializing_if = "Option::is_none")]
28+
pub product: Option<String>,
29+
/// USB serial number, if available.
30+
#[serde(skip_serializing_if = "Option::is_none")]
31+
pub serial_number: Option<String>,
32+
}
33+
34+
impl MtpDeviceInfo {
35+
/// Returns a display name for the device.
36+
///
37+
/// Prefers product name, falls back to "MTP Device (vendor:product)".
38+
pub fn display_name(&self) -> String {
39+
if let Some(product) = &self.product {
40+
return product.clone();
41+
}
42+
if let Some(manufacturer) = &self.manufacturer {
43+
return format!("{} device", manufacturer);
44+
}
45+
format!("MTP device ({:04x}:{:04x})", self.vendor_id, self.product_id)
46+
}
47+
}
48+
49+
/// Information about a storage area on an MTP device.
50+
///
51+
/// Android devices typically have one or more storages: "Internal Storage", "SD Card", etc.
52+
#[derive(Debug, Clone, Serialize, Deserialize)]
53+
#[serde(rename_all = "camelCase")]
54+
pub struct MtpStorageInfo {
55+
/// Storage ID (MTP storage handle).
56+
pub id: u32,
57+
/// Display name (e.g., "Internal shared storage").
58+
pub name: String,
59+
/// Total capacity in bytes.
60+
pub total_bytes: u64,
61+
/// Available space in bytes.
62+
pub available_bytes: u64,
63+
/// Storage type description (e.g., "FixedROM", "RemovableRAM").
64+
#[serde(skip_serializing_if = "Option::is_none")]
65+
pub storage_type: Option<String>,
66+
}
67+
68+
#[cfg(test)]
69+
mod tests {
70+
use super::*;
71+
72+
#[test]
73+
fn test_device_display_name_with_product() {
74+
let device = MtpDeviceInfo {
75+
id: "usb-1-2".to_string(),
76+
vendor_id: 0x18d1,
77+
product_id: 0x4ee1,
78+
manufacturer: Some("Google".to_string()),
79+
product: Some("Pixel 8".to_string()),
80+
serial_number: None,
81+
};
82+
assert_eq!(device.display_name(), "Pixel 8");
83+
}
84+
85+
#[test]
86+
fn test_device_display_name_with_manufacturer() {
87+
let device = MtpDeviceInfo {
88+
id: "usb-1-3".to_string(),
89+
vendor_id: 0x04e8,
90+
product_id: 0x6860,
91+
manufacturer: Some("Samsung".to_string()),
92+
product: None,
93+
serial_number: None,
94+
};
95+
assert_eq!(device.display_name(), "Samsung device");
96+
}
97+
98+
#[test]
99+
fn test_device_display_name_fallback() {
100+
let device = MtpDeviceInfo {
101+
id: "usb-1-4".to_string(),
102+
vendor_id: 0x1234,
103+
product_id: 0x5678,
104+
manufacturer: None,
105+
product: None,
106+
serial_number: None,
107+
};
108+
assert_eq!(device.display_name(), "MTP device (1234:5678)");
109+
}
110+
111+
#[test]
112+
fn test_device_serialization() {
113+
let device = MtpDeviceInfo {
114+
id: "test".to_string(),
115+
vendor_id: 0x18d1,
116+
product_id: 0x4ee1,
117+
manufacturer: Some("Google".to_string()),
118+
product: Some("Pixel".to_string()),
119+
serial_number: None,
120+
};
121+
let json = serde_json::to_string(&device).unwrap();
122+
assert!(json.contains("\"vendorId\":"));
123+
assert!(json.contains("\"productId\":"));
124+
// serialNumber should be omitted when None
125+
assert!(!json.contains("serialNumber"));
126+
}
127+
128+
#[test]
129+
fn test_storage_serialization() {
130+
let storage = MtpStorageInfo {
131+
id: 0x10001,
132+
name: "Internal Storage".to_string(),
133+
total_bytes: 128_000_000_000,
134+
available_bytes: 64_000_000_000,
135+
storage_type: Some("FixedRAM".to_string()),
136+
};
137+
let json = serde_json::to_string(&storage).unwrap();
138+
assert!(json.contains("\"totalBytes\":128000000000"));
139+
assert!(json.contains("\"availableBytes\":64000000000"));
140+
}
141+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! on Linux for E2E testing purposes. They return sensible defaults that
55
//! enable the core file manager functionality to work.
66
7+
pub mod mtp;
78
pub mod network;
89
pub mod permissions;
910
pub mod volumes;

0 commit comments

Comments
 (0)