Skip to content

Commit d1e9f80

Browse files
committed
MTP: Add file browsing and volume integration
1 parent 672fa6e commit d1e9f80

15 files changed

Lines changed: 705 additions & 30 deletions

File tree

apps/desktop/coverage-allowlist.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"licensing-store.svelte.ts": { "reason": "Depends on Tauri store APIs" },
2828
"logger.ts": { "reason": "LogTape config, initialization code only" },
2929
"mtp/PtpcameradDialog.svelte": { "reason": "UI modal for macOS MTP workaround" },
30+
"mtp/mtp-store.svelte.ts": { "reason": "Depends on Tauri APIs, tests planned in Phase 6" },
3031
"licensing/AboutWindow.svelte": { "reason": "UI component" },
3132
"licensing/CommercialReminderModal.svelte": { "reason": "UI component" },
3233
"licensing/ExpirationModal.svelte": { "reason": "UI component" },

apps/desktop/knip.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"src/lib/settings/settings-store.ts",
1111
"src/lib/settings/settings-window.ts",
1212
"src/lib/settings/types.ts",
13-
"src/lib/shortcuts/**"
13+
"src/lib/shortcuts/**",
14+
"src/lib/mtp/mtp-store.svelte.ts"
1415
],
1516
"ignoreDependencies": [
1617
"@tauri-apps/cli",

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Tauri commands for MTP (Android device) operations.
22
3+
use crate::file_system::FileEntry;
34
use crate::mtp::{self, ConnectedDeviceInfo, MtpConnectionError, MtpDeviceInfo, MtpStorageInfo};
45
use tauri::AppHandle;
56

@@ -85,3 +86,28 @@ pub fn get_mtp_storages(device_id: String) -> Vec<MtpStorageInfo> {
8586
.map(|info| info.storages)
8687
.unwrap_or_default()
8788
}
89+
90+
/// Lists the contents of a directory on a connected MTP device.
91+
///
92+
/// Returns file entries in the same format as local directory listings,
93+
/// allowing the frontend to use the same file list components.
94+
///
95+
/// # Arguments
96+
///
97+
/// * `device_id` - The connected device ID
98+
/// * `storage_id` - The storage ID within the device
99+
/// * `path` - Virtual path to list (for example, "/" or "/DCIM")
100+
///
101+
/// # Returns
102+
///
103+
/// A vector of FileEntry objects, sorted with directories first.
104+
#[tauri::command]
105+
pub async fn list_mtp_directory(
106+
device_id: String,
107+
storage_id: u32,
108+
path: String,
109+
) -> Result<Vec<FileEntry>, MtpConnectionError> {
110+
mtp::connection_manager()
111+
.list_directory(&device_id, storage_id, &path)
112+
.await
113+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,8 @@ pub fn run() {
361361
commands::mtp::get_ptpcamerad_workaround_command,
362362
#[cfg(target_os = "macos")]
363363
commands::mtp::get_mtp_storages,
364+
#[cfg(target_os = "macos")]
365+
commands::mtp::list_mtp_directory,
364366
#[cfg(not(target_os = "macos"))]
365367
stubs::mtp::list_mtp_devices,
366368
#[cfg(not(target_os = "macos"))]
@@ -373,6 +375,8 @@ pub fn run() {
373375
stubs::mtp::get_ptpcamerad_workaround_command,
374376
#[cfg(not(target_os = "macos"))]
375377
stubs::mtp::get_mtp_storages,
378+
#[cfg(not(target_os = "macos"))]
379+
stubs::mtp::list_mtp_directory,
376380
// Volume commands (platform-specific)
377381
#[cfg(target_os = "macos")]
378382
commands::volumes::list_volumes,

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

Lines changed: 235 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
//! maintains an active MTP session until disconnected or unplugged.
55
66
use log::{debug, error, info, warn};
7-
use mtp_rs::{MtpDevice, MtpDeviceBuilder};
7+
use mtp_rs::{MtpDevice, MtpDeviceBuilder, ObjectHandle, StorageId};
88
use std::collections::HashMap;
9-
use std::sync::{LazyLock, Mutex};
9+
use std::path::{Path, PathBuf};
10+
use std::sync::{Arc, LazyLock, Mutex, RwLock};
1011
use std::time::Duration;
1112
use tauri::{AppHandle, Emitter};
1213

1314
use super::types::{MtpDeviceInfo, MtpStorageInfo};
15+
use crate::file_system::FileEntry;
1416

1517
/// Default timeout for MTP operations (30 seconds - some devices are slow).
1618
const MTP_TIMEOUT_SECS: u64 = 30;
@@ -92,12 +94,21 @@ pub struct ConnectedDeviceInfo {
9294

9395
/// Internal entry for a connected device.
9496
struct DeviceEntry {
95-
/// The MTP device handle.
96-
device: MtpDevice,
97+
/// The MTP device handle (wrapped in Arc for shared access).
98+
device: Arc<tokio::sync::Mutex<MtpDevice>>,
9799
/// Device metadata.
98100
info: MtpDeviceInfo,
99101
/// Cached storage information.
100102
storages: Vec<MtpStorageInfo>,
103+
/// Path-to-handle cache per storage.
104+
path_cache: RwLock<HashMap<u32, PathHandleCache>>,
105+
}
106+
107+
/// Cache for mapping paths to MTP object handles.
108+
#[derive(Default)]
109+
struct PathHandleCache {
110+
/// Maps virtual path -> MTP object handle.
111+
path_to_handle: HashMap<PathBuf, ObjectHandle>,
101112
}
102113

103114
/// Global connection manager for MTP devices.
@@ -215,15 +226,16 @@ impl MtpConnectionManager {
215226
storages: storages.clone(),
216227
};
217228

218-
// Store in registry
229+
// Store in registry with Arc-wrapped device for shared access
219230
{
220231
let mut devices = self.devices.lock().unwrap();
221232
devices.insert(
222233
device_id.to_string(),
223234
DeviceEntry {
224-
device,
235+
device: Arc::new(tokio::sync::Mutex::new(device)),
225236
info: device_info,
226237
storages,
238+
path_cache: RwLock::new(HashMap::new()),
227239
},
228240
);
229241
}
@@ -266,11 +278,12 @@ impl MtpConnectionManager {
266278
});
267279
};
268280

269-
// Close the device gracefully
270-
if let Err(e) = entry.device.close().await {
271-
warn!("Error closing MTP device {}: {:?}", device_id, e);
272-
// Continue anyway - device might have been unplugged
273-
}
281+
// The device will be closed when it's dropped.
282+
// MtpDevice::close() takes ownership, but we have it in an Arc<Mutex>.
283+
// Dropping the entry will drop the Arc, and if this is the last reference,
284+
// the device will be closed (MtpDevice has a Drop impl that closes the session).
285+
// We just drop the entry here - the device handle going out of scope handles cleanup.
286+
drop(entry);
274287

275288
// Emit disconnected event
276289
if let Some(app) = app {
@@ -310,6 +323,169 @@ impl MtpConnectionManager {
310323
devices.keys().cloned().collect()
311324
}
312325

326+
/// Lists the contents of a directory on an MTP device.
327+
///
328+
/// # Arguments
329+
///
330+
/// * `device_id` - The connected device ID
331+
/// * `storage_id` - The storage ID within the device
332+
/// * `path` - Virtual path to list (for example, "/" or "/DCIM")
333+
///
334+
/// # Returns
335+
///
336+
/// A vector of FileEntry objects suitable for the file browser.
337+
pub async fn list_directory(
338+
&self,
339+
device_id: &str,
340+
storage_id: u32,
341+
path: &str,
342+
) -> Result<Vec<FileEntry>, MtpConnectionError> {
343+
debug!(
344+
"MTP list_directory: device={}, storage={}, path={}",
345+
device_id, storage_id, path
346+
);
347+
348+
// Get the device and resolve path to handle
349+
let (device_arc, parent_handle) = {
350+
let devices = self.devices.lock().unwrap();
351+
let entry = devices.get(device_id).ok_or_else(|| MtpConnectionError::NotConnected {
352+
device_id: device_id.to_string(),
353+
})?;
354+
355+
// Resolve path to parent handle
356+
let parent_handle = self.resolve_path_to_handle(entry, storage_id, path)?;
357+
358+
(Arc::clone(&entry.device), parent_handle)
359+
};
360+
361+
// Normalize the path for building child paths
362+
let parent_path = normalize_mtp_path(path);
363+
364+
// List directory contents (async operation)
365+
let device = device_arc.lock().await;
366+
367+
// Get the storage object
368+
let storage = tokio::time::timeout(
369+
Duration::from_secs(MTP_TIMEOUT_SECS),
370+
device.storage(StorageId(storage_id)),
371+
)
372+
.await
373+
.map_err(|_| MtpConnectionError::Timeout {
374+
device_id: device_id.to_string(),
375+
})?
376+
.map_err(|e| map_mtp_error(e, device_id))?;
377+
378+
// Use list_objects which returns Vec<ObjectInfo> directly
379+
let parent_opt = if parent_handle == ObjectHandle::ROOT {
380+
None
381+
} else {
382+
Some(parent_handle)
383+
};
384+
385+
let object_infos =
386+
tokio::time::timeout(Duration::from_secs(MTP_TIMEOUT_SECS), storage.list_objects(parent_opt))
387+
.await
388+
.map_err(|_| MtpConnectionError::Timeout {
389+
device_id: device_id.to_string(),
390+
})?
391+
.map_err(|e| map_mtp_error(e, device_id))?;
392+
393+
debug!("MTP list_directory: found {} objects", object_infos.len());
394+
395+
let mut entries = Vec::with_capacity(object_infos.len());
396+
let mut cache_updates: Vec<(PathBuf, ObjectHandle)> = Vec::new();
397+
398+
for info in object_infos {
399+
let is_dir = info.format == mtp_rs::ptp::ObjectFormatCode::Association;
400+
let child_path = parent_path.join(&info.filename);
401+
402+
// Queue cache update
403+
cache_updates.push((child_path.clone(), info.handle));
404+
405+
// Convert MTP timestamps
406+
let modified_at = info.modified.map(convert_mtp_datetime);
407+
let created_at = info.created.map(convert_mtp_datetime);
408+
409+
entries.push(FileEntry {
410+
name: info.filename.clone(),
411+
path: child_path.to_string_lossy().to_string(),
412+
is_directory: is_dir,
413+
is_symlink: false,
414+
size: if is_dir { None } else { Some(info.size) },
415+
modified_at,
416+
created_at,
417+
added_at: None,
418+
opened_at: None,
419+
permissions: if is_dir { 0o755 } else { 0o644 },
420+
owner: String::new(),
421+
group: String::new(),
422+
icon_id: get_mtp_icon_id(is_dir, &info.filename),
423+
extended_metadata_loaded: true,
424+
});
425+
}
426+
427+
// Release device lock before updating cache
428+
drop(storage);
429+
drop(device);
430+
431+
// Update path cache
432+
{
433+
let devices = self.devices.lock().unwrap();
434+
if let Some(entry) = devices.get(device_id)
435+
&& let Ok(mut cache_map) = entry.path_cache.write()
436+
{
437+
let storage_cache = cache_map.entry(storage_id).or_default();
438+
for (path, handle) in cache_updates {
439+
storage_cache.path_to_handle.insert(path, handle);
440+
}
441+
}
442+
}
443+
444+
// Sort: directories first, then files, both alphabetically
445+
entries.sort_by(|a, b| match (a.is_directory, b.is_directory) {
446+
(true, false) => std::cmp::Ordering::Less,
447+
(false, true) => std::cmp::Ordering::Greater,
448+
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
449+
});
450+
451+
debug!("MTP list_directory: returning {} entries", entries.len());
452+
Ok(entries)
453+
}
454+
455+
/// Resolves a virtual path to an MTP object handle.
456+
fn resolve_path_to_handle(
457+
&self,
458+
entry: &DeviceEntry,
459+
storage_id: u32,
460+
path: &str,
461+
) -> Result<ObjectHandle, MtpConnectionError> {
462+
let path = normalize_mtp_path(path);
463+
464+
// Root is always ObjectHandle::ROOT
465+
if path.as_os_str() == "/" || path.as_os_str().is_empty() {
466+
return Ok(ObjectHandle::ROOT);
467+
}
468+
469+
// Check cache
470+
if let Ok(cache_map) = entry.path_cache.read()
471+
&& let Some(storage_cache) = cache_map.get(&storage_id)
472+
&& let Some(handle) = storage_cache.path_to_handle.get(&path)
473+
{
474+
return Ok(*handle);
475+
}
476+
477+
// Path not in cache - we need to traverse
478+
// For Phase 3, we'll only support navigating to paths that have been listed
479+
// (the cache is populated as directories are browsed)
480+
Err(MtpConnectionError::Other {
481+
device_id: entry.info.id.clone(),
482+
message: format!(
483+
"Path not in cache: {}. Navigate through parent directories first.",
484+
path.display()
485+
),
486+
})
487+
}
488+
313489
/// Handles a device disconnection (called when we detect the device was unplugged).
314490
#[allow(dead_code, reason = "Will be used in Phase 5 for USB hotplug detection")]
315491
pub fn handle_device_disconnected(&self, device_id: &str, app: Option<&AppHandle>) {
@@ -407,6 +583,54 @@ fn map_mtp_error(e: mtp_rs::Error, device_id: &str) -> MtpConnectionError {
407583
}
408584
}
409585

586+
/// Normalizes an MTP path.
587+
///
588+
/// Ensures the path starts with "/" and handles empty/relative paths.
589+
fn normalize_mtp_path(path: &str) -> PathBuf {
590+
if path.is_empty() || path == "." {
591+
PathBuf::from("/")
592+
} else if !path.starts_with('/') {
593+
PathBuf::from("/").join(path)
594+
} else {
595+
PathBuf::from(path)
596+
}
597+
}
598+
599+
/// Converts MTP DateTime to Unix timestamp.
600+
fn convert_mtp_datetime(dt: mtp_rs::ptp::DateTime) -> u64 {
601+
// Convert the DateTime struct fields to Unix timestamp
602+
// This is a simplified conversion - MTP DateTime has year, month, day, hour, minute, second
603+
604+
// Create a rough Unix timestamp from the date components
605+
// Note: This is a simplified calculation that doesn't account for leap years perfectly
606+
let year = dt.year as u64;
607+
let month = dt.month as u64;
608+
let day = dt.day as u64;
609+
let hour = dt.hour as u64;
610+
let minute = dt.minute as u64;
611+
let second = dt.second as u64;
612+
613+
// Simplified calculation: days since epoch + time
614+
// This is approximate but good enough for file listing purposes
615+
let years_since_1970 = year.saturating_sub(1970);
616+
let days = years_since_1970 * 365 + (years_since_1970 / 4) // leap years approximation
617+
+ (month.saturating_sub(1)) * 30 // approximate days per month
618+
+ day.saturating_sub(1);
619+
620+
days * 86400 + hour * 3600 + minute * 60 + second
621+
}
622+
623+
/// Generates icon ID for MTP files.
624+
fn get_mtp_icon_id(is_dir: bool, filename: &str) -> String {
625+
if is_dir {
626+
return "dir".to_string();
627+
}
628+
if let Some(ext) = Path::new(filename).extension() {
629+
return format!("ext:{}", ext.to_string_lossy().to_lowercase());
630+
}
631+
"file".to_string()
632+
}
633+
410634
#[cfg(test)]
411635
mod tests {
412636
use super::*;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
//!
88
//! - `types`: Type definitions for frontend communication
99
//! - `discovery`: Device detection using mtp-rs
10-
//! - `connection`: Device connection management with global registry
10+
//! - `connection`: Device connection management with global registry and file browsing
1111
//! - `macos_workaround`: Handles ptpcamerad interference on macOS
1212
//!
1313
//! # Platform Support

0 commit comments

Comments
 (0)