diff --git a/apps/desktop/coverage-allowlist.json b/apps/desktop/coverage-allowlist.json index 7c1af46..482e559 100644 --- a/apps/desktop/coverage-allowlist.json +++ b/apps/desktop/coverage-allowlist.json @@ -1,6 +1,7 @@ { "$comment": "Files listed here are exempt from coverage thresholds. Each entry should include a reason. Remove entries as you add tests.", "files": { + "AlertDialog.svelte": { "reason": "Simple UI modal for informational messages" }, "UpdateNotification.svelte": { "reason": "UI component, needs component testing" }, "app-status-store.ts": { "reason": "Depends on Tauri APIs" }, "benchmark.ts": { "reason": "Dev tooling, not critical path" }, @@ -26,6 +27,8 @@ "icon-cache.ts": { "reason": "Depends on Tauri APIs" }, "licensing-store.svelte.ts": { "reason": "Depends on Tauri store APIs" }, "logger.ts": { "reason": "LogTape config, initialization code only" }, + "mtp/PtpcameradDialog.svelte": { "reason": "UI modal for macOS MTP workaround" }, + "mtp/mtp-store.svelte.ts": { "reason": "Depends on Tauri APIs, tests planned in Phase 6" }, "licensing/AboutWindow.svelte": { "reason": "UI component" }, "licensing/CommercialReminderModal.svelte": { "reason": "UI component" }, "licensing/ExpirationModal.svelte": { "reason": "UI component" }, @@ -73,6 +76,7 @@ "write-operations/DirectionIndicator.svelte": { "reason": "Simple UI component, logic tested in copy-dialog-utils.test.ts" }, - "write-operations/CopyProgressDialog.svelte": { "reason": "UI dialog, depends on Tauri events" } + "write-operations/CopyProgressDialog.svelte": { "reason": "UI dialog, depends on Tauri events" }, + "write-operations/CopyErrorDialog.svelte": { "reason": "UI modal, logic tested in copy-error-messages.test.ts" } } } diff --git a/apps/desktop/knip.json b/apps/desktop/knip.json index 32a5198..39c6b79 100644 --- a/apps/desktop/knip.json +++ b/apps/desktop/knip.json @@ -10,7 +10,8 @@ "src/lib/settings/settings-store.ts", "src/lib/settings/settings-window.ts", "src/lib/settings/types.ts", - "src/lib/shortcuts/**" + "src/lib/shortcuts/**", + "src/lib/mtp/mtp-store.svelte.ts" ], "ignoreDependencies": [ "@tauri-apps/cli", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 717547e..96ad0d0 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -309,7 +309,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.3", "slab", "windows-sys 0.61.2", ] @@ -340,7 +340,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.3", ] [[package]] @@ -366,7 +366,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.1.3", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -730,9 +730,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] @@ -997,8 +997,9 @@ dependencies = [ "axum", "base64 0.22.1", "bincode2", + "bytes", "chrono", - "core-foundation", + "core-foundation 0.10.1", "core-services", "criterion", "dirs", @@ -1013,8 +1014,10 @@ dependencies = [ "libc", "log", "memchr", + "mtp-rs", "notify", "notify-debouncer-full", + "nusb", "objc2 0.6.3", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", @@ -1045,6 +1048,7 @@ dependencies = [ "urlencoding", "uuid", "uzers", + "walkdir", ] [[package]] @@ -1056,7 +1060,7 @@ dependencies = [ "bitflags 2.10.0", "block", "cocoa-foundation", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "foreign-types", "libc", @@ -1071,7 +1075,7 @@ checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" dependencies = [ "bitflags 2.10.0", "block", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "objc", ] @@ -1135,6 +1139,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1158,7 +1172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -1171,7 +1185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -1181,7 +1195,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0aa845ab21b847ee46954be761815f18f16469b29ef3ba250241b1b8bab659a" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", ] [[package]] @@ -2239,6 +2253,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -2381,7 +2401,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix", + "rustix 1.1.3", "windows-link 0.2.1", ] @@ -3157,6 +3177,16 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -3473,6 +3503,12 @@ dependencies = [ "redox_syscall 0.7.0", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -3527,6 +3563,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -3702,6 +3747,21 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mtp-rs" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "core-foundation 0.9.4", + "futures", + "futures-timer", + "io-kit-sys", + "num_enum", + "nusb", + "thiserror 1.0.69", +] + [[package]] name = "muda" version = "0.17.1" @@ -3908,6 +3968,25 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "nusb" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f861541f15de120eae5982923d073bfc0c1a65466561988c82d6e197734c19e" +dependencies = [ + "atomic-waker", + "core-foundation 0.9.4", + "core-foundation-sys", + "futures-core", + "io-kit-sys", + "libc", + "log", + "once_cell", + "rustix 0.38.44", + "slab", + "windows-sys 0.48.0", +] + [[package]] name = "objc" version = "0.2.7" @@ -5007,7 +5086,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -5684,6 +5763,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.3" @@ -5693,7 +5785,7 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -5852,7 +5944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -6641,7 +6733,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.10.0", "block2 0.6.2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -7141,7 +7233,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -7905,7 +7997,7 @@ checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs", - "rustix", + "rustix 1.1.3", "smallvec", "wayland-sys", ] @@ -7917,7 +8009,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ "bitflags 2.10.0", - "rustix", + "rustix 1.1.3", "wayland-backend", "wayland-scanner", ] @@ -8442,6 +8534,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -8493,6 +8594,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -8559,6 +8675,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -8577,6 +8699,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -8595,6 +8723,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -8625,6 +8759,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -8643,6 +8783,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -8661,6 +8807,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -8679,6 +8831,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -8734,7 +8892,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix", + "rustix 1.1.3", "thiserror 2.0.17", "tree_magic_mini", "wayland-backend", @@ -8822,7 +8980,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix", + "rustix 1.1.3", "x11rb-protocol", ] @@ -8851,7 +9009,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.3", ] [[package]] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 15d5f38..f40a1fb 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -6,6 +6,7 @@ authors = ["David Veszelovszki "] edition = "2024" license = "LicenseRef-BSL-1.1" default-run = "Cmdr" +publish = false # Private application, not for crates.io [[bin]] name = "Cmdr" @@ -58,10 +59,12 @@ flate2 = "1.1" axum = "0.8" tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "sync", "macros"] } futures-util = "0.3" +bytes = "1" tower-http = { version = "0.6", features = ["cors"] } tauri-plugin-updater = "2" tauri-plugin-process = "2" memchr = "2" +walkdir = "2" [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -85,6 +88,10 @@ smb = "0.11.1" smb-rpc = "=0.11.1" chrono = "0.4" security-framework = "3.2" +# MTP (Android device) support via pure Rust implementation +mtp-rs = { path = "../../../../mtp-rs" } +# USB hotplug detection for MTP device watcher +nusb = "0.1" [dev-dependencies] criterion = { version = "0.8.1", features = ["html_reports"] } diff --git a/apps/desktop/src-tauri/deny.toml b/apps/desktop/src-tauri/deny.toml index 43bd6a3..ebb7fc1 100644 --- a/apps/desktop/src-tauri/deny.toml +++ b/apps/desktop/src-tauri/deny.toml @@ -33,6 +33,8 @@ allow = [ multiple-versions = "allow" wildcards = "deny" highlight = "all" +# Allow mtp-rs local path dependency during development (will use git/crates.io later) +allow-wildcard-paths = true [sources] unknown-registry = "deny" diff --git a/apps/desktop/src-tauri/src/commands/file_system.rs b/apps/desktop/src-tauri/src/commands/file_system.rs index 4cb5d86..caa779a 100644 --- a/apps/desktop/src-tauri/src/commands/file_system.rs +++ b/apps/desktop/src-tauri/src/commands/file_system.rs @@ -7,19 +7,20 @@ use crate::file_system::write_operations::{ resolve_write_conflict as ops_resolve_write_conflict, start_scan_preview as ops_start_scan_preview, }; use crate::file_system::{ - FileEntry, ListingStartResult, ListingStats, OperationStatus, OperationSummary, ResortResult, SortColumn, - SortOrder, StreamingListingStartResult, WriteOperationConfig, WriteOperationError, WriteOperationStartResult, - cancel_listing as ops_cancel_listing, cancel_write_operation as ops_cancel_write_operation, + ConflictInfo, FileEntry, ListingStartResult, ListingStats, OperationStatus, OperationSummary, ResortResult, + SortColumn, SortOrder, StreamingListingStartResult, VolumeCopyConfig, VolumeCopyScanResult, WriteOperationConfig, + WriteOperationError, WriteOperationStartResult, cancel_listing as ops_cancel_listing, + cancel_write_operation as ops_cancel_write_operation, copy_between_volumes as ops_copy_between_volumes, copy_files_start as ops_copy_files_start, delete_files_start as ops_delete_files_start, find_file_index as ops_find_file_index, get_file_at as ops_get_file_at, get_file_range as ops_get_file_range, get_listing_stats as ops_get_listing_stats, get_max_filename_width as ops_get_max_filename_width, - get_operation_status as ops_get_operation_status, get_total_count as ops_get_total_count, + get_operation_status as ops_get_operation_status, get_total_count as ops_get_total_count, get_volume_manager, list_active_operations as ops_list_active_operations, list_directory_end as ops_list_directory_end, list_directory_start_streaming as ops_list_directory_start_streaming, list_directory_start_with_volume as ops_list_directory_start_with_volume, move_files_start as ops_move_files_start, - resort_listing as ops_resort_listing, + resort_listing as ops_resort_listing, scan_for_volume_copy as ops_scan_for_volume_copy, }; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[cfg(target_os = "macos")] use std::sync::mpsc::channel; #[cfg(target_os = "macos")] @@ -28,13 +29,28 @@ use tauri::Manager; /// Checks if a path exists. /// /// # Arguments -/// * `path` - The path to check. Supports tilde expansion (~). +/// * `volume_id` - Optional volume ID. Defaults to "root" for local filesystem. +/// * `path` - The path to check. Supports tilde expansion (~) for local volumes. /// /// # Returns /// True if the path exists. #[tauri::command] -pub fn path_exists(path: String) -> bool { - let expanded_path = expand_tilde(&path); +pub async fn path_exists(volume_id: Option, path: String) -> bool { + let volume_id = volume_id.unwrap_or_else(|| "root".to_string()); + + // For local volumes, expand tilde + let expanded_path = if volume_id == "root" { expand_tilde(&path) } else { path }; + + // Try to use Volume abstraction + if let Some(volume) = get_volume_manager().get(&volume_id) { + let path_for_check = expanded_path.clone(); + // Use spawn_blocking for MTP volumes which need tokio runtime context + return tokio::task::spawn_blocking(move || volume.exists(Path::new(&path_for_check))) + .await + .unwrap_or(false); + } + + // Fallback for unknown volumes (shouldn't happen in practice) let path_buf = PathBuf::from(expanded_path); path_buf.exists() } @@ -42,20 +58,58 @@ pub fn path_exists(path: String) -> bool { /// Creates a new directory. /// /// # Arguments -/// * `parent_path` - The parent directory path. Supports tilde expansion (~). +/// * `volume_id` - Optional volume ID. Defaults to "root" for local filesystem. +/// * `parent_path` - The parent directory path. Supports tilde expansion (~) for local volumes. /// * `name` - The folder name to create. /// /// # Returns /// The full path of the created directory, or an error message. #[tauri::command] -pub fn create_directory(parent_path: String, name: String) -> Result { +pub async fn create_directory(volume_id: Option, parent_path: String, name: String) -> Result { if name.is_empty() { return Err("Folder name cannot be empty".to_string()); } if name.contains('/') || name.contains('\0') { return Err("Folder name contains invalid characters".to_string()); } - let expanded_path = expand_tilde(&parent_path); + + let volume_id = volume_id.unwrap_or_else(|| "root".to_string()); + + // For local volumes, expand tilde + let expanded_path = if volume_id == "root" { + expand_tilde(&parent_path) + } else { + parent_path.clone() + }; + + // Try to use Volume abstraction + if let Some(volume) = get_volume_manager().get(&volume_id) { + let new_path = PathBuf::from(&expanded_path).join(&name); + let new_path_clone = new_path.clone(); + let parent_path_clone = parent_path.clone(); + let name_clone = name.clone(); + + // Use spawn_blocking to run the Volume operation in a context where + // tokio::runtime::Handle::current() is available (needed for MtpVolume) + tokio::task::spawn_blocking(move || { + volume.create_directory(&new_path_clone).map_err(|e| match e { + crate::file_system::VolumeError::AlreadyExists(_) => format!("'{}' already exists", name_clone), + crate::file_system::VolumeError::PermissionDenied(_) => { + format!( + "Permission denied: cannot create '{}' in '{}'", + name_clone, parent_path_clone + ) + } + _ => format!("Failed to create folder: {}", e), + }) + }) + .await + .map_err(|e| format!("Task failed: {}", e))??; + + return Ok(new_path.to_string_lossy().to_string()); + } + + // Fallback for unknown volumes (shouldn't happen in practice) let mut new_path = PathBuf::from(&expanded_path); new_path.push(&name); std::fs::create_dir(&new_path).map_err(|e| match e.kind() { @@ -112,24 +166,39 @@ pub fn list_directory_start( /// /// # Arguments /// * `app` - Tauri app handle (injected by Tauri). -/// * `path` - The directory path to list. Supports tilde expansion (~). +/// * `volume_id` - The volume ID (e.g., "root", "mtp-20-5:65537"). +/// * `path` - The directory path to list. Supports tilde expansion (~) for local volumes. /// * `include_hidden` - Whether to include hidden files in total count. /// * `sort_by` - Column to sort by (name, extension, size, modified, created). /// * `sort_order` - Ascending or descending. #[tauri::command] pub async fn list_directory_start_streaming( app: tauri::AppHandle, + volume_id: String, path: String, include_hidden: bool, sort_by: SortColumn, sort_order: SortOrder, listing_id: String, ) -> Result { - let expanded_path = expand_tilde(&path); + // Only expand tilde for local volumes (not MTP) + let expanded_path = if volume_id == "root" { + expand_tilde(&path) + } else { + path.clone() + }; let path_buf = PathBuf::from(&expanded_path); - ops_list_directory_start_streaming(app, "root", &path_buf, include_hidden, sort_by, sort_order, listing_id) - .await - .map_err(|e| format!("Failed to start directory listing '{}': {}", path, e)) + ops_list_directory_start_streaming( + app, + &volume_id, + &path_buf, + include_hidden, + sort_by, + sort_order, + listing_id, + ) + .await + .map_err(|e| format!("Failed to start directory listing '{}': {}", path, e)) } /// Cancels an in-progress streaming directory listing. @@ -564,6 +633,155 @@ pub fn start_selection_drag( Err("Drag operation is not yet supported on this platform".to_string()) } +// ============================================================================ +// Unified volume copy commands +// ============================================================================ + +/// Copy files between any two volumes (local, MTP, etc.). +/// +/// This is the unified copy command that works for all volume types: +/// - Local → Local (regular file copy) +/// - Local → MTP (upload to Android device) +/// - MTP → Local (download from Android device) +/// +/// # Events emitted +/// * `write-progress` - Progress updates +/// * `write-complete` - On success +/// * `write-error` - On error +/// * `write-cancelled` - If cancelled +/// +/// # Arguments +/// * `app` - Tauri app handle (injected by Tauri) +/// * `source_volume_id` - ID of the source volume (e.g., "root" for local filesystem) +/// * `source_paths` - List of source file/directory paths relative to source volume +/// * `dest_volume_id` - ID of the destination volume +/// * `dest_path` - Destination directory path relative to destination volume +/// * `config` - Optional copy configuration (progress interval, conflict resolution) +#[tauri::command] +pub async fn copy_between_volumes( + app: tauri::AppHandle, + source_volume_id: String, + source_paths: Vec, + dest_volume_id: String, + dest_path: String, + config: Option, +) -> Result { + let source_volume = get_volume_manager() + .get(&source_volume_id) + .ok_or_else(|| WriteOperationError::IoError { + path: source_volume_id.clone(), + message: format!("Source volume '{}' not found", source_volume_id), + })?; + + let dest_volume = get_volume_manager() + .get(&dest_volume_id) + .ok_or_else(|| WriteOperationError::IoError { + path: dest_volume_id.clone(), + message: format!("Destination volume '{}' not found", dest_volume_id), + })?; + + let source_paths: Vec = source_paths.iter().map(PathBuf::from).collect(); + let dest_path = PathBuf::from(dest_path); + let config = config.unwrap_or_default(); + + ops_copy_between_volumes(app, source_volume, source_paths, dest_volume, dest_path, config).await +} + +/// Scans source files for a volume copy operation without executing it. +/// +/// This performs a "pre-flight" scan to determine: +/// - Total file count and bytes to copy +/// - Available space on destination +/// - Any conflicts (files that already exist at destination) +/// +/// Use this to show users what will happen before they confirm a copy. +/// +/// # Arguments +/// * `source_volume_id` - ID of the source volume +/// * `source_paths` - List of source file/directory paths +/// * `dest_volume_id` - ID of the destination volume +/// * `dest_path` - Destination directory path +/// * `max_conflicts` - Maximum number of conflicts to return (for performance) +#[tauri::command] +pub async fn scan_volume_for_copy( + source_volume_id: String, + source_paths: Vec, + dest_volume_id: String, + dest_path: String, + max_conflicts: Option, +) -> Result { + let source_volume = get_volume_manager() + .get(&source_volume_id) + .ok_or_else(|| format!("Source volume '{}' not found", source_volume_id))?; + + let dest_volume = get_volume_manager() + .get(&dest_volume_id) + .ok_or_else(|| format!("Destination volume '{}' not found", dest_volume_id))?; + + let source_paths: Vec = source_paths.iter().map(PathBuf::from).collect(); + let dest_path = PathBuf::from(dest_path); + let max_conflicts = max_conflicts.unwrap_or(100); + + // Run scan in blocking context for MTP volume support + tokio::task::spawn_blocking(move || { + ops_scan_for_volume_copy(&*source_volume, &source_paths, &*dest_volume, &dest_path, max_conflicts) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("Scan task failed: {}", e))? +} + +/// Scans destination volume for conflicts with source items. +/// +/// Checks if any of the source item names already exist at the destination path. +/// Returns detailed conflict information for UI display. +/// +/// # Arguments +/// * `volume_id` - ID of the destination volume to scan +/// * `source_items` - List of source items to check (name, size, modified timestamp) +/// * `dest_path` - Destination directory path on the volume +#[tauri::command] +pub async fn scan_volume_for_conflicts( + volume_id: String, + source_items: Vec, + dest_path: String, +) -> Result, String> { + let volume = get_volume_manager() + .get(&volume_id) + .ok_or_else(|| format!("Volume '{}' not found", volume_id))?; + + let source_items: Vec = source_items + .into_iter() + .map(|item| crate::file_system::SourceItemInfo { + name: item.name, + size: item.size, + modified: item.modified, + }) + .collect(); + let dest_path = PathBuf::from(dest_path); + + // Run in blocking context for MTP volume support + tokio::task::spawn_blocking(move || { + volume + .scan_for_conflicts(&source_items, &dest_path) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("Conflict scan task failed: {}", e))? +} + +/// Input type for source item information (used by scan_volume_for_conflicts). +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SourceItemInput { + /// File/directory name. + pub name: String, + /// Size in bytes. + pub size: u64, + /// Modification time (Unix timestamp in seconds). + pub modified: Option, +} + /// Expands tilde (~) to the user's home directory. fn expand_tilde(path: &str) -> String { if (path.starts_with("~/") || path == "~") @@ -611,11 +829,11 @@ mod tests { assert_eq!(expand_tilde(path), path); } - #[test] - fn test_create_directory_success() { + #[tokio::test] + async fn test_create_directory_success() { let tmp = create_test_dir("create_success"); let parent = tmp.to_string_lossy().to_string(); - let result = create_directory(parent, "new-folder".to_string()); + let result = create_directory(None, parent, "new-folder".to_string()).await; assert!(result.is_ok()); let created_path = result.unwrap(); assert!(PathBuf::from(&created_path).is_dir()); @@ -623,44 +841,44 @@ mod tests { cleanup_test_dir(&tmp); } - #[test] - fn test_create_directory_already_exists() { + #[tokio::test] + async fn test_create_directory_already_exists() { let tmp = create_test_dir("create_exists"); let parent = tmp.to_string_lossy().to_string(); fs::create_dir(tmp.join("existing")).unwrap(); - let result = create_directory(parent, "existing".to_string()); + let result = create_directory(None, parent, "existing".to_string()).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("already exists")); cleanup_test_dir(&tmp); } - #[test] - fn test_create_directory_empty_name() { + #[tokio::test] + async fn test_create_directory_empty_name() { let tmp = create_test_dir("create_empty"); let parent = tmp.to_string_lossy().to_string(); - let result = create_directory(parent, "".to_string()); + let result = create_directory(None, parent, "".to_string()).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("cannot be empty")); cleanup_test_dir(&tmp); } - #[test] - fn test_create_directory_invalid_chars() { + #[tokio::test] + async fn test_create_directory_invalid_chars() { let tmp = create_test_dir("create_invalid"); let parent = tmp.to_string_lossy().to_string(); - let result = create_directory(parent.clone(), "foo/bar".to_string()); + let result = create_directory(None, parent.clone(), "foo/bar".to_string()).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("invalid characters")); - let result = create_directory(parent, "foo\0bar".to_string()); + let result = create_directory(None, parent, "foo\0bar".to_string()).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("invalid characters")); cleanup_test_dir(&tmp); } - #[test] - fn test_create_directory_nonexistent_parent() { - let result = create_directory("/nonexistent_path_12345".to_string(), "test".to_string()); + #[tokio::test] + async fn test_create_directory_nonexistent_parent() { + let result = create_directory(None, "/nonexistent_path_12345".to_string(), "test".to_string()).await; assert!(result.is_err()); } } diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 942eb78..f209256 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -6,6 +6,8 @@ pub mod font_metrics; pub mod icons; pub mod licensing; #[cfg(target_os = "macos")] +pub mod mtp; +#[cfg(target_os = "macos")] pub mod network; pub mod settings; pub mod sync_status; // Has both macOS and non-macOS implementations diff --git a/apps/desktop/src-tauri/src/commands/mtp.rs b/apps/desktop/src-tauri/src/commands/mtp.rs new file mode 100644 index 0000000..f5871d1 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/mtp.rs @@ -0,0 +1,322 @@ +//! Tauri commands for MTP (Android device) operations. + +use log::debug; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::file_system::FileEntry; +use crate::mtp::{ + self, ConnectedDeviceInfo, MtpConnectionError, MtpDeviceInfo, MtpObjectInfo, MtpOperationResult, MtpStorageInfo, +}; +use tauri::AppHandle; + +/// Result of scanning an MTP path for copy operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MtpScanResult { + /// Number of files found. + pub file_count: usize, + /// Number of directories found. + pub dir_count: usize, + /// Total bytes of all files. + pub total_bytes: u64, +} + +/// Lists all connected MTP devices. +/// +/// This returns devices detected via USB that support MTP protocol. +/// Use this to populate the "Mobile" section in the volume picker. +/// +/// # Returns +/// +/// A vector of device info structs. Empty if no devices are connected. +#[tauri::command] +pub fn list_mtp_devices() -> Vec { + mtp::list_mtp_devices() +} + +/// Connects to an MTP device by ID. +/// +/// Opens an MTP session to the device and retrieves storage information. +/// If another process (like ptpcamerad on macOS) has exclusive access, +/// an `mtp-exclusive-access-error` event is emitted to the frontend. +/// +/// # Arguments +/// +/// * `device_id` - The device ID from `list_mtp_devices` (format: "mtp-{bus}-{address}") +/// +/// # Returns +/// +/// Information about the connected device including available storages. +#[tauri::command] +pub async fn connect_mtp_device(app: AppHandle, device_id: String) -> Result { + mtp::connection_manager().connect(&device_id, Some(&app)).await +} + +/// Disconnects from an MTP device. +/// +/// Closes the MTP session gracefully. The device remains available in +/// `list_mtp_devices` for reconnection. +/// +/// # Arguments +/// +/// * `device_id` - The device ID to disconnect from +#[tauri::command] +pub async fn disconnect_mtp_device(app: AppHandle, device_id: String) -> Result<(), MtpConnectionError> { + mtp::connection_manager().disconnect(&device_id, Some(&app)).await +} + +/// Gets information about a connected MTP device. +/// +/// Returns device metadata and storage information for a currently connected device. +/// Returns `None` if the device is not connected. +/// +/// # Arguments +/// +/// * `device_id` - The device ID to query +#[tauri::command] +pub async fn get_mtp_device_info(device_id: String) -> Option { + mtp::connection_manager().get_device_info(&device_id).await +} + +/// Gets the ptpcamerad workaround command for macOS. +/// +/// Returns the Terminal command that users can run to work around +/// ptpcamerad blocking MTP device access. +#[tauri::command] +pub fn get_ptpcamerad_workaround_command() -> String { + mtp::PTPCAMERAD_WORKAROUND_COMMAND.to_string() +} + +/// Gets storage information for all storages on a connected device. +/// +/// # Arguments +/// +/// * `device_id` - The connected device ID +/// +/// # Returns +/// +/// A vector of storage info, or empty if device is not connected. +#[tauri::command] +pub async fn get_mtp_storages(device_id: String) -> Vec { + mtp::connection_manager() + .get_device_info(&device_id) + .await + .map(|info| info.storages) + .unwrap_or_default() +} + +/// Lists the contents of a directory on a connected MTP device. +/// +/// Returns file entries in the same format as local directory listings, +/// allowing the frontend to use the same file list components. +/// +/// # Arguments +/// +/// * `device_id` - The connected device ID +/// * `storage_id` - The storage ID within the device +/// * `path` - Virtual path to list (for example, "/" or "/DCIM") +/// +/// # Returns +/// +/// A vector of FileEntry objects, sorted with directories first. +#[tauri::command] +pub async fn list_mtp_directory( + device_id: String, + storage_id: u32, + path: String, +) -> Result, MtpConnectionError> { + debug!( + "list_mtp_directory: ENTERED - device={}, storage={}, path={}", + device_id, storage_id, path + ); + let result = mtp::connection_manager() + .list_directory(&device_id, storage_id, &path) + .await; + match &result { + Ok(entries) => debug!("list_mtp_directory: SUCCESS - {} entries for {}", entries.len(), path), + Err(e) => debug!("list_mtp_directory: ERROR - {:?}", e), + } + result +} + +// ============================================================================ +// Phase 4: File Operations +// ============================================================================ + +/// Downloads a file from an MTP device to the local filesystem. +/// +/// Emits `mtp-transfer-progress` events during the transfer. +/// +/// # Arguments +/// +/// * `device_id` - The connected device ID +/// * `storage_id` - The storage ID within the device +/// * `object_path` - Virtual path on the device (for example, "/DCIM/photo.jpg") +/// * `local_dest` - Local destination path +/// * `operation_id` - Unique operation ID for progress tracking +#[tauri::command] +pub async fn download_mtp_file( + app: AppHandle, + device_id: String, + storage_id: u32, + object_path: String, + local_dest: String, + operation_id: String, +) -> Result { + let local_path = PathBuf::from(&local_dest); + mtp::connection_manager() + .download_file( + &device_id, + storage_id, + &object_path, + &local_path, + Some(&app), + &operation_id, + ) + .await +} + +/// Uploads a file from the local filesystem to an MTP device. +/// +/// Emits `mtp-transfer-progress` events during the transfer. +/// +/// # Arguments +/// +/// * `device_id` - The connected device ID +/// * `storage_id` - The storage ID within the device +/// * `local_path` - Local file path to upload +/// * `dest_folder` - Destination folder path on device (for example, "/DCIM") +/// * `operation_id` - Unique operation ID for progress tracking +#[tauri::command] +pub async fn upload_to_mtp( + app: AppHandle, + device_id: String, + storage_id: u32, + local_path: String, + dest_folder: String, + operation_id: String, +) -> Result { + let local = PathBuf::from(&local_path); + mtp::connection_manager() + .upload_file(&device_id, storage_id, &local, &dest_folder, Some(&app), &operation_id) + .await +} + +/// Deletes an object (file or folder) from an MTP device. +/// +/// For folders, this recursively deletes all contents first since MTP +/// requires folders to be empty before deletion. +/// +/// # Arguments +/// +/// * `device_id` - The connected device ID +/// * `storage_id` - The storage ID within the device +/// * `object_path` - Virtual path on the device +#[tauri::command] +pub async fn delete_mtp_object( + device_id: String, + storage_id: u32, + object_path: String, +) -> Result<(), MtpConnectionError> { + mtp::connection_manager() + .delete_object(&device_id, storage_id, &object_path) + .await +} + +/// Creates a new folder on an MTP device. +/// +/// # Arguments +/// +/// * `device_id` - The connected device ID +/// * `storage_id` - The storage ID within the device +/// * `parent_path` - Parent folder path (for example, "/DCIM") +/// * `folder_name` - Name of the new folder +#[tauri::command] +pub async fn create_mtp_folder( + device_id: String, + storage_id: u32, + parent_path: String, + folder_name: String, +) -> Result { + mtp::connection_manager() + .create_folder(&device_id, storage_id, &parent_path, &folder_name) + .await +} + +/// Renames an object on an MTP device. +/// +/// # Arguments +/// +/// * `device_id` - The connected device ID +/// * `storage_id` - The storage ID within the device +/// * `object_path` - Current path of the object +/// * `new_name` - New name for the object +#[tauri::command] +pub async fn rename_mtp_object( + device_id: String, + storage_id: u32, + object_path: String, + new_name: String, +) -> Result { + mtp::connection_manager() + .rename_object(&device_id, storage_id, &object_path, &new_name) + .await +} + +/// Moves an object to a new parent folder on an MTP device. +/// +/// May fail if the device doesn't support MoveObject operation. +/// +/// # Arguments +/// +/// * `device_id` - The connected device ID +/// * `storage_id` - The storage ID within the device +/// * `object_path` - Current path of the object +/// * `new_parent_path` - New parent folder path +#[tauri::command] +pub async fn move_mtp_object( + device_id: String, + storage_id: u32, + object_path: String, + new_parent_path: String, +) -> Result { + mtp::connection_manager() + .move_object(&device_id, storage_id, &object_path, &new_parent_path) + .await +} + +// ============================================================================ +// Phase 5: Copy/Export Operations +// ============================================================================ + +/// Scans an MTP path for copy statistics. +/// +/// Recursively scans the specified path to get file count, directory count, +/// and total bytes. Useful for showing progress during copy operations. +/// +/// # Arguments +/// +/// * `device_id` - The connected device ID +/// * `storage_id` - The storage ID within the device +/// * `path` - Virtual path on the device to scan +#[tauri::command] +pub async fn scan_mtp_for_copy( + device_id: String, + storage_id: u32, + path: String, +) -> Result { + debug!( + "scan_mtp_for_copy: device={}, storage={}, path={}", + device_id, storage_id, path + ); + let result = mtp::connection_manager() + .scan_for_copy(&device_id, storage_id, &path) + .await?; + + Ok(MtpScanResult { + file_count: result.file_count, + dir_count: result.dir_count, + total_bytes: result.total_bytes, + }) +} diff --git a/apps/desktop/src-tauri/src/file_system/mod.rs b/apps/desktop/src-tauri/src/file_system/mod.rs index af17d8f..f93bb55 100644 --- a/apps/desktop/src-tauri/src/file_system/mod.rs +++ b/apps/desktop/src-tauri/src/file_system/mod.rs @@ -37,33 +37,63 @@ pub use operations::get_paths_at_indices; pub use provider::FileSystemProvider; // Re-export volume types (some not used externally yet) #[allow(unused_imports, reason = "Public API re-exports for future use")] -pub use volume::{InMemoryVolume, LocalPosixVolume, Volume, VolumeError}; +pub use volume::{ + ConflictInfo, CopyScanResult, InMemoryVolume, LocalPosixVolume, MtpVolume, SourceItemInfo, SpaceInfo, Volume, + VolumeError, +}; #[allow(unused_imports, reason = "Public API re-exports for future use")] pub use volume_manager::VolumeManager; // Watcher management - init_watcher_manager must be called from lib.rs pub use watcher::{init_watcher_manager, update_debounce_ms}; +// Diff types for file watching (used by MTP module for unified diff events) +pub(crate) use watcher::{DirectoryDiff, compute_diff}; // Re-export write operation types pub use write_operations::{ OperationStatus, OperationSummary, WriteOperationConfig, WriteOperationError, WriteOperationStartResult, cancel_write_operation, copy_files_start, delete_files_start, get_operation_status, list_active_operations, move_files_start, }; +// Re-export volume copy types and functions +// TODO: Remove this allow once volume_copy is integrated into Tauri commands (Phase 5) +#[allow(unused_imports, reason = "Volume copy not yet integrated into Tauri commands")] +pub use write_operations::{VolumeCopyConfig, VolumeCopyScanResult, copy_between_volumes, scan_for_volume_copy}; /// Global volume manager instance static VOLUME_MANAGER: LazyLock = LazyLock::new(VolumeManager::new); -/// Initializes the global volume manager with the root volume. +/// Initializes the global volume manager with all discovered volumes. /// /// This should be called during app startup (after init_watcher_manager). -/// Registers the "root" volume pointing to "/" (the entire filesystem). +/// Registers: +/// - "root" volume pointing to "/" (the entire filesystem) +/// - Attached volumes (external drives, USB, etc.) +/// - Cloud drives (Dropbox, iCloud, Google Drive, etc.) pub fn init_volume_manager() { + // Register root volume let root_volume = Arc::new(LocalPosixVolume::new("Macintosh HD", "/")); VOLUME_MANAGER.register("root", root_volume); VOLUME_MANAGER.set_default("root"); + + // Register attached volumes (external drives) + let attached = crate::volumes::get_attached_volumes(); + log::info!("Registering {} attached volume(s)", attached.len()); + for location in attached { + let volume = Arc::new(LocalPosixVolume::new(&location.name, &location.path)); + VOLUME_MANAGER.register(&location.id, volume); + log::info!(" Registered attached volume: {} -> {}", location.id, location.path); + } + + // Register cloud drives + let cloud = crate::volumes::get_cloud_drives(); + log::info!("Registering {} cloud drive(s)", cloud.len()); + for location in cloud { + let volume = Arc::new(LocalPosixVolume::new(&location.name, &location.path)); + VOLUME_MANAGER.register(&location.id, volume); + log::info!(" Registered cloud drive: {} -> {}", location.id, location.path); + } } /// Returns a reference to the global volume manager. -#[allow(dead_code, reason = "Will be used in Phase 4.2 when commands use it")] pub fn get_volume_manager() -> &'static VolumeManager { &VOLUME_MANAGER } diff --git a/apps/desktop/src-tauri/src/file_system/operations.rs b/apps/desktop/src-tauri/src/file_system/operations.rs index 0ddf8ea..9de3d28 100644 --- a/apps/desktop/src-tauri/src/file_system/operations.rs +++ b/apps/desktop/src-tauri/src/file_system/operations.rs @@ -125,6 +125,8 @@ pub struct ListingCompleteEvent { pub listing_id: String, pub total_count: usize, pub max_filename_width: Option, + /// Root path of the volume this listing belongs to + pub volume_root: String, } /// Error event payload @@ -958,7 +960,7 @@ pub(super) fn get_listing_entries(listing_id: &str) -> Option<(PathBuf, Vec) { +pub(crate) fn update_listing_entries(listing_id: &str, entries: Vec) { if let Ok(mut cache) = LISTING_CACHE.write() && let Some(listing) = cache.get_mut(listing_id) { @@ -966,6 +968,32 @@ pub(super) fn update_listing_entries(listing_id: &str, entries: Vec) } } +/// Gets all listings for volumes matching a specific prefix. +/// +/// Used by MTP file watching to find all listings belonging to a device. +/// MTP volume IDs have the format "mtp-{device_id}:{storage_id}". +/// +/// Returns: Vec<(listing_id, volume_id, path, entries)> +pub(crate) fn get_listings_by_volume_prefix(prefix: &str) -> Vec<(String, String, PathBuf, Vec)> { + let cache = match LISTING_CACHE.read() { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + cache + .iter() + .filter(|(_, listing)| listing.volume_id.starts_with(prefix)) + .map(|(listing_id, listing)| { + ( + listing_id.clone(), + listing.volume_id.clone(), + listing.path.clone(), + listing.entries.clone(), + ) + }) + .collect() +} + // ============================================================================ // Two-phase metadata loading: Fast core data, then extended metadata // ============================================================================ @@ -1308,18 +1336,32 @@ pub async fn list_directory_start_streaming( } // Handle task result - if let Err(e) = result { - // Task panicked or was cancelled - use tauri::Emitter; - let _ = app_for_error.emit( - "listing-error", - ListingErrorEvent { - listing_id: listing_id_for_cleanup, - message: format!("Task failed: {}", e), - }, - ); + use tauri::Emitter; + match result { + Err(e) => { + // Task panicked or was cancelled + let _ = app_for_error.emit( + "listing-error", + ListingErrorEvent { + listing_id: listing_id_for_cleanup, + message: format!("Task failed: {}", e), + }, + ); + } + Ok(Err(e)) => { + // Function returned an error (e.g., volume not found, permission denied) + let _ = app_for_error.emit( + "listing-error", + ListingErrorEvent { + listing_id: listing_id_for_cleanup, + message: e.to_string(), + }, + ); + } + Ok(Ok(())) => { + // Success - read_directory_with_progress already emitted listing-complete + } } - // Note: read_directory_with_progress handles its own event emission for success/error/cancel }); benchmark::log_event("list_directory_start_streaming RETURNING"); @@ -1332,6 +1374,7 @@ pub async fn list_directory_start_streaming( /// Reads a directory with progress reporting. /// /// This function runs on a blocking thread pool and emits progress events. +/// Uses the Volume abstraction to support both local filesystem and MTP devices. #[allow( clippy::too_many_arguments, reason = "Streaming operation requires many state parameters" @@ -1341,21 +1384,23 @@ fn read_directory_with_progress( listing_id: &str, state: &Arc, volume_id: &str, - path: &PathBuf, + path: &Path, include_hidden: bool, sort_by: SortColumn, sort_order: SortOrder, ) -> Result<(), std::io::Error> { use tauri::Emitter; - let mut entries = Vec::new(); - let mut last_progress_time = std::time::Instant::now(); - const PROGRESS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); - benchmark::log_event("read_directory_with_progress START"); + log::debug!( + "read_directory_with_progress: listing_id={}, volume_id={}, path={}", + listing_id, + volume_id, + path.display() + ); // Emit opening event - this is the slow part for network folders - // (SMB connection establishment, directory handle creation) + // (SMB connection establishment, directory handle creation, MTP queries) let _ = app.emit( "listing-opening", ListingOpeningEvent { @@ -1363,43 +1408,28 @@ fn read_directory_with_progress( }, ); - // Read directory entries one by one - let read_start = std::time::Instant::now(); - for entry_result in fs::read_dir(path)? { - // Check cancellation - if state.cancelled.load(Ordering::Relaxed) { - benchmark::log_event("read_directory_with_progress CANCELLED"); - let _ = app.emit( - "listing-cancelled", - ListingCancelledEvent { - listing_id: listing_id.to_string(), - }, - ); - return Ok(()); - } - - let entry = match entry_result { - Ok(e) => e, - Err(_) => continue, // Skip unreadable entries - }; + // Check cancellation before starting + if state.cancelled.load(Ordering::Relaxed) { + benchmark::log_event("read_directory_with_progress CANCELLED (before read)"); + let _ = app.emit( + "listing-cancelled", + ListingCancelledEvent { + listing_id: listing_id.to_string(), + }, + ); + return Ok(()); + } - // Process entry (same logic as list_directory_core) - if let Some(file_entry) = process_dir_entry(&entry) { - entries.push(file_entry); - } + // Get the volume from VolumeManager + let volume = super::get_volume_manager() + .get(volume_id) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, format!("Volume not found: {}", volume_id)))?; - // Emit progress every 500ms - if last_progress_time.elapsed() >= PROGRESS_INTERVAL { - let _ = app.emit( - "listing-progress", - ListingProgressEvent { - listing_id: listing_id.to_string(), - loaded_count: entries.len(), - }, - ); - last_progress_time = std::time::Instant::now(); - } - } + // Read directory entries via Volume abstraction + let read_start = std::time::Instant::now(); + let mut entries = volume + .list_directory(path) + .map_err(|e| std::io::Error::other(e.to_string()))?; let read_dir_time = read_start.elapsed(); benchmark::log_event_value("read_dir COMPLETE, entries", entries.len()); @@ -1443,13 +1473,29 @@ fn read_directory_with_progress( crate::font_metrics::calculate_max_width(&filenames, font_id) }; - // Cache the completed listing + // Cache the completed listing, with atomic cancellation check. + // We check cancellation WHILE holding the cache lock to avoid a race condition: + // without this, a cancel arriving between a check and insert would leave a stale + // entry (listDirectoryEnd would try to remove before the entry exists, then this + // insert would add it permanently). if let Ok(mut cache) = LISTING_CACHE.write() { + // Check cancellation while holding the lock - makes check+insert atomic + if state.cancelled.load(Ordering::Relaxed) { + benchmark::log_event("read_directory_with_progress CANCELLED (at cache insert)"); + let _ = app.emit( + "listing-cancelled", + ListingCancelledEvent { + listing_id: listing_id.to_string(), + }, + ); + return Ok(()); + } + cache.insert( listing_id.to_string(), CachedListing { volume_id: volume_id.to_string(), - path: path.clone(), + path: path.to_path_buf(), entries, sort_by, sort_order, @@ -1466,6 +1512,12 @@ fn read_directory_with_progress( // Continue anyway - watcher is optional enhancement } + // Get volume root for the event (used by frontend to determine if at volume root) + let volume_root = super::get_volume_manager() + .get(volume_id) + .map(|v| v.root().to_string_lossy().to_string()) + .unwrap_or_else(|| "/".to_string()); + // Emit completion event let _ = app.emit( "listing-complete", @@ -1473,6 +1525,7 @@ fn read_directory_with_progress( listing_id: listing_id.to_string(), total_count, max_filename_width, + volume_root, }, ); diff --git a/apps/desktop/src-tauri/src/file_system/volume/in_memory.rs b/apps/desktop/src-tauri/src/file_system/volume/in_memory.rs index 49a89df..c1e1122 100644 --- a/apps/desktop/src-tauri/src/file_system/volume/in_memory.rs +++ b/apps/desktop/src-tauri/src/file_system/volume/in_memory.rs @@ -185,6 +185,20 @@ impl Volume for InMemoryVolume { entries.contains_key(&normalized) } + fn is_directory(&self, path: &Path) -> Result { + let entries = self + .entries + .read() + .map_err(|_| VolumeError::IoError("Lock poisoned".into()))?; + + let normalized = self.normalize(path); + + entries + .get(&normalized) + .map(|e| e.metadata.is_directory) + .ok_or_else(|| VolumeError::NotFound(normalized.display().to_string())) + } + fn create_file(&self, path: &Path, content: &[u8]) -> Result<(), VolumeError> { let mut entries = self .entries diff --git a/apps/desktop/src-tauri/src/file_system/volume/local_posix.rs b/apps/desktop/src-tauri/src/file_system/volume/local_posix.rs index 9b8cdce..62ee4c4 100644 --- a/apps/desktop/src-tauri/src/file_system/volume/local_posix.rs +++ b/apps/desktop/src-tauri/src/file_system/volume/local_posix.rs @@ -1,9 +1,10 @@ //! Local POSIX file system volume implementation. -use super::{Volume, VolumeError}; +use super::{ConflictInfo, CopyScanResult, SourceItemInfo, SpaceInfo, Volume, VolumeError}; use crate::file_system::FileEntry; use crate::file_system::operations::{get_single_entry, list_directory_core}; use std::path::{Path, PathBuf}; +use walkdir::WalkDir; /// A volume backed by the local POSIX file system. /// @@ -48,10 +49,19 @@ impl LocalPosixVolume { if path.as_os_str().is_empty() || path == Path::new(".") { self.root.clone() } else if path.is_absolute() { - // Treat absolute paths as relative to volume root - // Strip the leading "/" and join with root - let relative = path.strip_prefix("/").unwrap_or(path); - self.root.join(relative) + // If path already starts with our root, use it directly + // This handles the case where frontend sends full absolute paths + if path.starts_with(&self.root) { + path.to_path_buf() + } else if self.root == Path::new("/") { + // For root volume, absolute paths are valid as-is + path.to_path_buf() + } else { + // Treat absolute paths as relative to volume root + // Strip the leading "/" and join with root + let relative = path.strip_prefix("/").unwrap_or(path); + self.root.join(relative) + } } else { self.root.join(path) } @@ -83,7 +93,183 @@ impl Volume for LocalPosixVolume { std::fs::symlink_metadata(self.resolve(path)).is_ok() } + fn is_directory(&self, path: &Path) -> Result { + let abs_path = self.resolve(path); + let metadata = std::fs::symlink_metadata(&abs_path)?; + Ok(metadata.is_dir()) + } + fn supports_watching(&self) -> bool { true } + + fn create_file(&self, path: &Path, content: &[u8]) -> Result<(), VolumeError> { + let abs_path = self.resolve(path); + std::fs::write(&abs_path, content)?; + Ok(()) + } + + fn create_directory(&self, path: &Path) -> Result<(), VolumeError> { + let abs_path = self.resolve(path); + std::fs::create_dir(&abs_path)?; + Ok(()) + } + + fn delete(&self, path: &Path) -> Result<(), VolumeError> { + let abs_path = self.resolve(path); + let metadata = std::fs::symlink_metadata(&abs_path)?; + if metadata.is_dir() { + std::fs::remove_dir(&abs_path)?; + } else { + std::fs::remove_file(&abs_path)?; + } + Ok(()) + } + + fn rename(&self, from: &Path, to: &Path) -> Result<(), VolumeError> { + let from_abs = self.resolve(from); + let to_abs = self.resolve(to); + std::fs::rename(&from_abs, &to_abs)?; + Ok(()) + } + + fn supports_export(&self) -> bool { + true + } + + fn scan_for_copy(&self, path: &Path) -> Result { + let abs_path = self.resolve(path); + let mut file_count = 0; + let mut dir_count = 0; + let mut total_bytes = 0u64; + + for entry in WalkDir::new(&abs_path).min_depth(0) { + let entry = entry.map_err(|e| VolumeError::IoError(e.to_string()))?; + let ft = entry.file_type(); + if ft.is_file() { + file_count += 1; + if let Ok(meta) = entry.metadata() { + total_bytes += meta.len(); + } + } else if ft.is_dir() { + // Don't count the root itself if it's the starting point + if entry.depth() > 0 { + dir_count += 1; + } + } + } + + // If the path is a single file, count it + if let Ok(meta) = std::fs::metadata(&abs_path) { + if meta.is_file() && file_count == 0 { + file_count = 1; + total_bytes = meta.len(); + } else if meta.is_dir() && dir_count == 0 && file_count == 0 { + dir_count = 1; + } + } + + Ok(CopyScanResult { + file_count, + dir_count, + total_bytes, + }) + } + + fn export_to_local(&self, source: &Path, local_dest: &Path) -> Result { + let src_abs = self.resolve(source); + copy_recursive(&src_abs, local_dest) + } + + fn import_from_local(&self, local_source: &Path, dest: &Path) -> Result { + let dest_abs = self.resolve(dest); + copy_recursive(local_source, &dest_abs) + } + + fn scan_for_conflicts( + &self, + source_items: &[SourceItemInfo], + dest_path: &Path, + ) -> Result, VolumeError> { + let dest_abs = self.resolve(dest_path); + let mut conflicts = Vec::new(); + + for item in source_items { + let dest_file_path = dest_abs.join(&item.name); + if dest_file_path.exists() + && let Ok(meta) = std::fs::metadata(&dest_file_path) + { + let dest_modified = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64)); + + conflicts.push(ConflictInfo { + source_path: item.name.clone(), + dest_path: dest_file_path.to_string_lossy().to_string(), + source_size: item.size, + dest_size: meta.len(), + source_modified: item.modified, + dest_modified, + }); + } + } + + Ok(conflicts) + } + + fn get_space_info(&self) -> Result { + get_space_info_for_path(&self.root) + } +} + +/// Recursively copies a file or directory from source to destination. +/// Returns total bytes copied. +fn copy_recursive(source: &Path, dest: &Path) -> Result { + let meta = std::fs::metadata(source)?; + let mut total_bytes = 0; + + if meta.is_file() { + // Copy single file + std::fs::copy(source, dest)?; + total_bytes = meta.len(); + } else if meta.is_dir() { + // Create destination directory + std::fs::create_dir_all(dest)?; + + // Copy all contents + for entry in std::fs::read_dir(source)? { + let entry = entry?; + let src_path = entry.path(); + let dest_path = dest.join(entry.file_name()); + total_bytes += copy_recursive(&src_path, &dest_path)?; + } + } + + Ok(total_bytes) +} + +/// Gets space information for a path using statvfs. +fn get_space_info_for_path(path: &Path) -> Result { + use std::ffi::CString; + + let path_c = CString::new(path.to_string_lossy().as_bytes()).map_err(|e| VolumeError::IoError(e.to_string()))?; + + unsafe { + let mut stat: libc::statvfs = std::mem::zeroed(); + if libc::statvfs(path_c.as_ptr(), &mut stat) == 0 { + let block_size = stat.f_frsize as u64; + let total_bytes = (stat.f_blocks as u64) * block_size; + let available_bytes = (stat.f_bavail as u64) * block_size; + let used_bytes = total_bytes.saturating_sub((stat.f_bfree as u64) * block_size); + + Ok(SpaceInfo { + total_bytes, + available_bytes, + used_bytes, + }) + } else { + Err(VolumeError::IoError("Failed to get space info".into())) + } + } } diff --git a/apps/desktop/src-tauri/src/file_system/volume/local_posix_test.rs b/apps/desktop/src-tauri/src/file_system/volume/local_posix_test.rs index f7c7963..23b9ab2 100644 --- a/apps/desktop/src-tauri/src/file_system/volume/local_posix_test.rs +++ b/apps/desktop/src-tauri/src/file_system/volume/local_posix_test.rs @@ -102,17 +102,54 @@ fn test_supports_watching_returns_true() { } #[test] -fn test_optional_methods_return_not_supported() { +fn test_supports_streaming_returns_false() { + // LocalPosixVolume uses the default implementation which returns false. + // Streaming is primarily for MTP-to-MTP transfers. let volume = LocalPosixVolume::new("Test", "/tmp"); + assert!(!volume.supports_streaming()); +} + +#[test] +fn test_write_operations() { + use std::fs; + + // Create a temp directory for this test + let test_dir = std::env::temp_dir().join("cmdr_write_ops_test"); + let _ = fs::remove_dir_all(&test_dir); + fs::create_dir_all(&test_dir).unwrap(); - let result = volume.create_file(Path::new("test.txt"), b"content"); - assert!(matches!(result, Err(VolumeError::NotSupported))); + let volume = LocalPosixVolume::new("Test", &test_dir); - let result = volume.create_directory(Path::new("testdir")); - assert!(matches!(result, Err(VolumeError::NotSupported))); + // Test create_file + let result = volume.create_file(Path::new("test.txt"), b"hello world"); + assert!(result.is_ok()); + assert!(test_dir.join("test.txt").exists()); + assert_eq!(fs::read_to_string(test_dir.join("test.txt")).unwrap(), "hello world"); + + // Test create_directory + let result = volume.create_directory(Path::new("subdir")); + assert!(result.is_ok()); + assert!(test_dir.join("subdir").is_dir()); + // Test delete file let result = volume.delete(Path::new("test.txt")); - assert!(matches!(result, Err(VolumeError::NotSupported))); + assert!(result.is_ok()); + assert!(!test_dir.join("test.txt").exists()); + + // Test delete directory + let result = volume.delete(Path::new("subdir")); + assert!(result.is_ok()); + assert!(!test_dir.join("subdir").exists()); + + // Test rename + volume.create_file(Path::new("old.txt"), b"content").unwrap(); + let result = volume.rename(Path::new("old.txt"), Path::new("new.txt")); + assert!(result.is_ok()); + assert!(!test_dir.join("old.txt").exists()); + assert!(test_dir.join("new.txt").exists()); + + // Cleanup + let _ = fs::remove_dir_all(&test_dir); } // ============================================================================ @@ -203,6 +240,233 @@ fn test_broken_symlink_still_exists() { let _ = fs::remove_dir_all(&test_dir); } +// ============================================================================ +// Copy operation tests +// ============================================================================ + +#[test] +fn test_supports_export_returns_true() { + let volume = LocalPosixVolume::new("Test", "/tmp"); + assert!(volume.supports_export()); +} + +#[test] +fn test_scan_for_copy_single_file() { + use std::fs; + + let test_dir = std::env::temp_dir().join("cmdr_scan_copy_file_test"); + let _ = fs::remove_dir_all(&test_dir); + fs::create_dir_all(&test_dir).unwrap(); + + // Create a single file with known content + fs::write(test_dir.join("test.txt"), "Hello, World!").unwrap(); + + let volume = LocalPosixVolume::new("Test", test_dir.to_str().unwrap()); + let result = volume.scan_for_copy(Path::new("test.txt")).unwrap(); + + assert_eq!(result.file_count, 1); + assert_eq!(result.dir_count, 0); + assert_eq!(result.total_bytes, 13); // "Hello, World!" is 13 bytes + + let _ = fs::remove_dir_all(&test_dir); +} + +#[test] +fn test_scan_for_copy_directory() { + use std::fs; + + let test_dir = std::env::temp_dir().join("cmdr_scan_copy_dir_test"); + let _ = fs::remove_dir_all(&test_dir); + fs::create_dir_all(&test_dir).unwrap(); + + // Create directory structure + let subdir = test_dir.join("mydir"); + fs::create_dir(&subdir).unwrap(); + fs::write(subdir.join("file1.txt"), "123").unwrap(); + fs::write(subdir.join("file2.txt"), "456789").unwrap(); + let nested = subdir.join("nested"); + fs::create_dir(&nested).unwrap(); + fs::write(nested.join("file3.txt"), "A").unwrap(); + + let volume = LocalPosixVolume::new("Test", test_dir.to_str().unwrap()); + let result = volume.scan_for_copy(Path::new("mydir")).unwrap(); + + assert_eq!(result.file_count, 3); + assert_eq!(result.dir_count, 1); // Just the nested dir (root not counted) + assert_eq!(result.total_bytes, 10); // 3 + 6 + 1 + + let _ = fs::remove_dir_all(&test_dir); +} + +#[test] +fn test_export_to_local_single_file() { + use std::fs; + + let src_dir = std::env::temp_dir().join("cmdr_export_src_test"); + let dst_dir = std::env::temp_dir().join("cmdr_export_dst_test"); + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + fs::create_dir_all(&src_dir).unwrap(); + fs::create_dir_all(&dst_dir).unwrap(); + + fs::write(src_dir.join("source.txt"), "Test content").unwrap(); + + let volume = LocalPosixVolume::new("Test", src_dir.to_str().unwrap()); + let bytes = volume + .export_to_local(Path::new("source.txt"), &dst_dir.join("dest.txt")) + .unwrap(); + + assert_eq!(bytes, 12); // "Test content" is 12 bytes + assert_eq!(fs::read_to_string(dst_dir.join("dest.txt")).unwrap(), "Test content"); + + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); +} + +#[test] +fn test_export_to_local_directory() { + use std::fs; + + let src_dir = std::env::temp_dir().join("cmdr_export_dir_src_test"); + let dst_dir = std::env::temp_dir().join("cmdr_export_dir_dst_test"); + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + fs::create_dir_all(&src_dir).unwrap(); + fs::create_dir_all(&dst_dir).unwrap(); + + // Create source directory with files + let source_subdir = src_dir.join("sourcedir"); + fs::create_dir(&source_subdir).unwrap(); + fs::write(source_subdir.join("file1.txt"), "AAA").unwrap(); + fs::write(source_subdir.join("file2.txt"), "BBBBB").unwrap(); + + let volume = LocalPosixVolume::new("Test", src_dir.to_str().unwrap()); + let bytes = volume + .export_to_local(Path::new("sourcedir"), &dst_dir.join("destdir")) + .unwrap(); + + assert_eq!(bytes, 8); // 3 + 5 bytes + assert!(dst_dir.join("destdir").is_dir()); + assert_eq!(fs::read_to_string(dst_dir.join("destdir/file1.txt")).unwrap(), "AAA"); + assert_eq!(fs::read_to_string(dst_dir.join("destdir/file2.txt")).unwrap(), "BBBBB"); + + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); +} + +#[test] +fn test_import_from_local_single_file() { + use std::fs; + + let local_dir = std::env::temp_dir().join("cmdr_import_local_test"); + let vol_dir = std::env::temp_dir().join("cmdr_import_vol_test"); + let _ = fs::remove_dir_all(&local_dir); + let _ = fs::remove_dir_all(&vol_dir); + fs::create_dir_all(&local_dir).unwrap(); + fs::create_dir_all(&vol_dir).unwrap(); + + fs::write(local_dir.join("local.txt"), "Imported content").unwrap(); + + let volume = LocalPosixVolume::new("Test", vol_dir.to_str().unwrap()); + let bytes = volume + .import_from_local(&local_dir.join("local.txt"), Path::new("imported.txt")) + .unwrap(); + + assert_eq!(bytes, 16); // "Imported content" is 16 bytes + assert_eq!( + fs::read_to_string(vol_dir.join("imported.txt")).unwrap(), + "Imported content" + ); + + let _ = fs::remove_dir_all(&local_dir); + let _ = fs::remove_dir_all(&vol_dir); +} + +#[test] +fn test_scan_for_conflicts_no_conflicts() { + use std::fs; + + let test_dir = std::env::temp_dir().join("cmdr_conflicts_none_test"); + let _ = fs::remove_dir_all(&test_dir); + fs::create_dir_all(&test_dir).unwrap(); + + let volume = LocalPosixVolume::new("Test", test_dir.to_str().unwrap()); + + let source_items = vec![ + SourceItemInfo { + name: "newfile.txt".to_string(), + size: 100, + modified: None, + }, + SourceItemInfo { + name: "another.txt".to_string(), + size: 200, + modified: None, + }, + ]; + + let conflicts = volume.scan_for_conflicts(&source_items, Path::new("")).unwrap(); + assert!(conflicts.is_empty()); + + let _ = fs::remove_dir_all(&test_dir); +} + +#[test] +fn test_scan_for_conflicts_with_conflicts() { + use std::fs; + + let test_dir = std::env::temp_dir().join("cmdr_conflicts_some_test"); + let _ = fs::remove_dir_all(&test_dir); + fs::create_dir_all(&test_dir).unwrap(); + + // Create existing files + fs::write(test_dir.join("existing.txt"), "Old content").unwrap(); + fs::write(test_dir.join("another.txt"), "Another old").unwrap(); + + let volume = LocalPosixVolume::new("Test", test_dir.to_str().unwrap()); + + let source_items = vec![ + SourceItemInfo { + name: "existing.txt".to_string(), + size: 100, + modified: Some(1_700_000_000), + }, + SourceItemInfo { + name: "newfile.txt".to_string(), + size: 200, + modified: None, + }, + SourceItemInfo { + name: "another.txt".to_string(), + size: 300, + modified: Some(1_700_000_000), + }, + ]; + + let conflicts = volume.scan_for_conflicts(&source_items, Path::new("")).unwrap(); + assert_eq!(conflicts.len(), 2); + + // Verify conflict info + let existing_conflict = conflicts.iter().find(|c| c.source_path == "existing.txt").unwrap(); + assert_eq!(existing_conflict.source_size, 100); + assert_eq!(existing_conflict.dest_size, 11); // "Old content" is 11 bytes + assert_eq!(existing_conflict.source_modified, Some(1_700_000_000)); + + let _ = fs::remove_dir_all(&test_dir); +} + +#[test] +fn test_get_space_info() { + // Test against /tmp which should exist on any POSIX system + let volume = LocalPosixVolume::new("Test", "/tmp"); + let space = volume.get_space_info().unwrap(); + + // Basic sanity checks + assert!(space.total_bytes > 0); + assert!(space.available_bytes <= space.total_bytes); + assert!(space.used_bytes <= space.total_bytes); +} + #[test] fn test_list_directory_includes_symlinks() { use std::fs; diff --git a/apps/desktop/src-tauri/src/file_system/volume/mod.rs b/apps/desktop/src-tauri/src/file_system/volume/mod.rs index a3130e7..4f5d721 100644 --- a/apps/desktop/src-tauri/src/file_system/volume/mod.rs +++ b/apps/desktop/src-tauri/src/file_system/volume/mod.rs @@ -7,8 +7,61 @@ #![allow(dead_code, reason = "Volume abstraction not yet integrated into operations.rs")] use super::FileEntry; +use serde::{Deserialize, Serialize}; use std::path::Path; +/// Result of scanning a path for copy operation. +#[derive(Debug, Clone)] +pub struct CopyScanResult { + /// Number of files found. + pub file_count: usize, + /// Number of directories found. + pub dir_count: usize, + /// Total bytes of all files. + pub total_bytes: u64, +} + +/// Information about a potential conflict when copying. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConflictInfo { + /// Source path (relative to volume root). + pub source_path: String, + /// Destination path (relative to volume root). + pub dest_path: String, + /// Size of source file in bytes. + pub source_size: u64, + /// Size of existing destination file in bytes. + pub dest_size: u64, + /// Source file modification time (Unix timestamp in seconds). + pub source_modified: Option, + /// Destination file modification time (Unix timestamp in seconds). + pub dest_modified: Option, +} + +/// Space information for a volume. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpaceInfo { + /// Total capacity in bytes. + pub total_bytes: u64, + /// Available (free) space in bytes. + pub available_bytes: u64, + /// Used space in bytes. + pub used_bytes: u64, +} + +/// Information about a source item for conflict scanning. +#[derive(Debug, Clone)] +pub struct SourceItemInfo { + /// File/directory name. + pub name: String, + /// Size in bytes. + pub size: u64, + /// Modification time (Unix timestamp in seconds). + pub modified: Option, +} + /// Error type for volume operations. #[derive(Debug, Clone)] pub enum VolumeError { @@ -16,6 +69,8 @@ pub enum VolumeError { NotFound(String), /// Permission denied PermissionDenied(String), + /// Path already exists + AlreadyExists(String), /// Operation not supported by this volume type NotSupported, /// Generic I/O error @@ -27,6 +82,7 @@ impl std::fmt::Display for VolumeError { match self { Self::NotFound(path) => write!(f, "Path not found: {}", path), Self::PermissionDenied(path) => write!(f, "Permission denied: {}", path), + Self::AlreadyExists(path) => write!(f, "Already exists: {}", path), Self::NotSupported => write!(f, "Operation not supported"), Self::IoError(msg) => write!(f, "I/O error: {}", msg), } @@ -35,11 +91,27 @@ impl std::fmt::Display for VolumeError { impl std::error::Error for VolumeError {} +/// A stream of bytes read from a volume. +/// +/// This is a synchronous, blocking iterator-style interface for reading +/// file data in chunks. Used for streaming transfers between volumes. +pub trait VolumeReadStream: Send { + /// Returns the next chunk of data, or None if complete. + fn next_chunk(&mut self) -> Option, VolumeError>>; + + /// Total size of the file in bytes. + fn total_size(&self) -> u64; + + /// Bytes read so far (for progress tracking). + fn bytes_read(&self) -> u64; +} + impl From for VolumeError { fn from(err: std::io::Error) -> Self { match err.kind() { std::io::ErrorKind::NotFound => Self::NotFound(err.to_string()), std::io::ErrorKind::PermissionDenied => Self::PermissionDenied(err.to_string()), + std::io::ErrorKind::AlreadyExists => Self::AlreadyExists(err.to_string()), _ => Self::IoError(err.to_string()), } } @@ -75,6 +147,10 @@ pub trait Volume: Send + Sync { /// Checks if a path exists (relative to volume root). fn exists(&self, path: &Path) -> bool; + /// Checks if a path is a directory. + /// Returns Ok(true) if directory, Ok(false) if file, Err if path doesn't exist. + fn is_directory(&self, path: &Path) -> Result; + // ======================================== // Optional: Default to NotSupported // ======================================== @@ -97,6 +173,14 @@ pub trait Volume: Send + Sync { Err(VolumeError::NotSupported) } + /// Renames/moves a file or directory within this volume. + /// + /// Both source and destination paths are relative to the volume root. + fn rename(&self, from: &Path, to: &Path) -> Result<(), VolumeError> { + let _ = (from, to); + Err(VolumeError::NotSupported) + } + // ======================================== // Watching: Optional, default no-op // ======================================== @@ -105,14 +189,96 @@ pub trait Volume: Send + Sync { fn supports_watching(&self) -> bool { false } + + // ======================================== + // Copy/Export: Optional, default no-op + // ======================================== + + /// Returns whether this volume supports copy/export operations. + fn supports_export(&self) -> bool { + false + } + + /// Scans a path recursively to get statistics for a copy operation. + /// Returns file count, directory count, and total bytes. + fn scan_for_copy(&self, path: &Path) -> Result { + let _ = path; + Err(VolumeError::NotSupported) + } + + /// Downloads/exports a file or directory from this volume to a local path. + /// For local volumes, this is a file copy. For MTP, this downloads. + /// Returns bytes transferred. + fn export_to_local(&self, source: &Path, local_dest: &Path) -> Result { + let _ = (source, local_dest); + Err(VolumeError::NotSupported) + } + + /// Imports/uploads a file or directory from a local path to this volume. + /// For local volumes, this is a file copy. For MTP, this uploads. + /// Returns bytes transferred. + fn import_from_local(&self, local_source: &Path, dest: &Path) -> Result { + let _ = (local_source, dest); + Err(VolumeError::NotSupported) + } + + /// Checks destination for conflicts with source items. + /// Returns list of files that already exist at destination. + fn scan_for_conflicts( + &self, + source_items: &[SourceItemInfo], + dest_path: &Path, + ) -> Result, VolumeError> { + let _ = (source_items, dest_path); + Err(VolumeError::NotSupported) + } + + /// Gets space information for this volume. + fn get_space_info(&self) -> Result { + Err(VolumeError::NotSupported) + } + + // ======================================== + // Streaming: Optional, default not supported + // ======================================== + + /// Returns true if this volume supports streaming read/write operations. + fn supports_streaming(&self) -> bool { + false + } + + /// Opens a streaming reader for the given path. + /// + /// Returns a VolumeReadStream that yields chunks of data. + /// The stream must be fully consumed or dropped before other operations. + fn open_read_stream(&self, path: &Path) -> Result, VolumeError> { + let _ = path; + Err(VolumeError::NotSupported) + } + + /// Writes data from a stream to the given path. + /// + /// # Arguments + /// * `dest` - Destination path (file will be created/overwritten) + /// * `size` - Total size in bytes (required for protocols like MTP) + /// * `stream` - Source data stream + fn write_from_stream(&self, dest: &Path, size: u64, stream: Box) -> Result { + let _ = (dest, size, stream); + Err(VolumeError::NotSupported) + } } // Implementations mod in_memory; mod local_posix; +mod mtp; pub use in_memory::InMemoryVolume; pub use local_posix::LocalPosixVolume; +pub use mtp::MtpVolume; + +// Re-export types defined in this module for convenience +// (they're already public since defined in mod.rs) #[cfg(test)] mod in_memory_test; diff --git a/apps/desktop/src-tauri/src/file_system/volume/mtp.rs b/apps/desktop/src-tauri/src/file_system/volume/mtp.rs new file mode 100644 index 0000000..27ee42a --- /dev/null +++ b/apps/desktop/src-tauri/src/file_system/volume/mtp.rs @@ -0,0 +1,600 @@ +//! MTP (Media Transfer Protocol) volume implementation. +//! +//! Wraps MTP device storage as a Volume, enabling MTP browsing through +//! the standard file listing pipeline (same icons, sorting, view modes as local files). + +use super::{ConflictInfo, CopyScanResult, SourceItemInfo, SpaceInfo, Volume, VolumeError, VolumeReadStream}; +use crate::file_system::FileEntry; +use crate::mtp::connection::{MtpConnectionError, connection_manager}; +use log::debug; +use mtp_rs::FileDownload; +use std::path::{Path, PathBuf}; + +/// A volume backed by an MTP device storage. +/// +/// This implementation wraps the MTP connection manager to provide file system +/// abstraction. The Volume trait is synchronous, so async MTP calls are executed +/// using tokio's `block_on` from within the blocking thread pool context. +/// +/// # Thread safety +/// +/// MtpVolume methods are called from within `tokio::task::spawn_blocking` contexts, +/// which run on a separate OS thread pool. This makes it safe to use `block_on` +/// to execute async MTP operations. +pub struct MtpVolume { + /// Display name (typically the storage description like "Internal storage") + name: String, + /// MTP device ID (for example, "mtp-20-5") + device_id: String, + /// Storage ID within the device + storage_id: u32, + /// Virtual root path for this volume (for example, "/mtp-20-5/65537") + root: PathBuf, +} + +impl MtpVolume { + /// Creates a new MTP volume for a specific device storage. + /// + /// # Arguments + /// * `device_id` - The MTP device ID (format: "mtp-{bus}-{address}") + /// * `storage_id` - The storage ID within the device + /// * `name` - Display name for the storage (for example, "Internal shared storage") + pub fn new(device_id: &str, storage_id: u32, name: &str) -> Self { + Self { + name: name.to_string(), + device_id: device_id.to_string(), + storage_id, + root: PathBuf::from(format!("mtp://{}/{}", device_id, storage_id)), + } + } + + /// Converts a Volume path to an MTP inner path. + /// + /// The path can be in several formats: + /// - MTP URL: `mtp://mtp-0-1/65537` or `mtp://mtp-0-1/65537/DCIM/Camera` + /// - Absolute path: `/DCIM/Camera` + /// - Relative path: `DCIM/Camera` + /// + /// The MTP API expects paths relative to the storage root (for example, `DCIM/Camera`). + fn to_mtp_path(&self, path: &Path) -> String { + let path_str = path.to_string_lossy(); + + // Handle MTP URLs (mtp://device-id/storage-id/optional/path) + if path_str.starts_with("mtp://") { + // Parse: mtp://mtp-0-1/65537/DCIM/Camera -> DCIM/Camera + // The format is: mtp://{device_id}/{storage_id}/{path} + let without_scheme = path_str.strip_prefix("mtp://").unwrap_or(&path_str); + + // Find the device_id/storage_id prefix and skip it + // Device ID format: mtp-{bus}-{address} (e.g., mtp-0-1) + // So we need to skip: device_id/storage_id/ + let parts: Vec<&str> = without_scheme.splitn(3, '/').collect(); + // parts[0] = device_id (e.g., "mtp-0-1") + // parts[1] = storage_id (e.g., "65537") + // parts[2] = inner path (e.g., "DCIM/Camera") or absent for root + + return if parts.len() >= 3 { + parts[2].to_string() + } else { + String::new() // Root of storage + }; + } + + // Handle empty or root paths + if path_str.is_empty() || path_str == "/" || path_str == "." { + return String::new(); + } + + // Strip leading slash if present + path_str.strip_prefix('/').unwrap_or(&path_str).to_string() + } +} + +impl Volume for MtpVolume { + fn name(&self) -> &str { + &self.name + } + + fn root(&self) -> &Path { + &self.root + } + + fn list_directory(&self, path: &Path) -> Result, VolumeError> { + let mtp_path = self.to_mtp_path(path); + let device_id = self.device_id.clone(); + let storage_id = self.storage_id; + + debug!( + "MtpVolume::list_directory: device={}, storage={}, input_path={}, mtp_path={}", + device_id, + storage_id, + path.display(), + mtp_path + ); + + // Get the tokio runtime handle - we're inside spawn_blocking, + // so block_on is safe here + let handle = tokio::runtime::Handle::current(); + + let start = std::time::Instant::now(); + let result = handle.block_on(async move { + connection_manager() + .list_directory(&device_id, storage_id, &mtp_path) + .await + }); + + match &result { + Ok(entries) => debug!( + "MtpVolume::list_directory: completed in {:?}, {} entries", + start.elapsed(), + entries.len() + ), + Err(e) => debug!( + "MtpVolume::list_directory: failed in {:?}, error={:?}", + start.elapsed(), + e + ), + } + + result.map_err(map_mtp_error) + } + + fn get_metadata(&self, path: &Path) -> Result { + // MTP doesn't have a direct "get metadata" API - we need to list the parent + // and find the entry. For now, return NotSupported. + // The listing pipeline doesn't use get_metadata for directory browsing. + let _ = path; + Err(VolumeError::NotSupported) + } + + fn exists(&self, path: &Path) -> bool { + // Check by trying to list the parent directory and finding the entry + let Some(parent) = path.parent() else { + // Root always exists + return true; + }; + + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + return false; + }; + + match self.list_directory(parent) { + Ok(entries) => entries.iter().any(|e| e.name == name), + Err(_) => false, + } + } + + fn is_directory(&self, path: &Path) -> Result { + // Empty path or root is always a directory + let path_str = path.to_string_lossy(); + if path_str.is_empty() || path_str == "/" || path_str == "." { + return Ok(true); + } + + // Check by listing the parent directory and finding the entry + let Some(parent) = path.parent() else { + // Root is a directory + return Ok(true); + }; + + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + return Err(VolumeError::NotFound(path.display().to_string())); + }; + + let entries = self.list_directory(parent)?; + entries + .iter() + .find(|e| e.name == name) + .map(|e| e.is_directory) + .ok_or_else(|| VolumeError::NotFound(path.display().to_string())) + } + + fn supports_watching(&self) -> bool { + // Return false because MTP has its OWN file watching mechanism that is + // independent of the listing pipeline. The MtpConnectionManager starts an + // event loop when a device connects (see start_event_loop) that polls for + // USB interrupt endpoint events (ObjectAdded/ObjectRemoved/ObjectInfoChanged). + // These events emit `mtp-directory-changed` directly to the frontend. + // + // The `supports_watching()` check in operations.rs is used to decide whether + // to start the local notify-based file watcher, which only works for POSIX + // paths. MTP paths like "/DCIM/Camera" don't exist on the local filesystem, + // so we must return false to prevent the notify watcher from failing. + false + } + + fn create_directory(&self, path: &Path) -> Result<(), VolumeError> { + let Some(parent) = path.parent() else { + return Err(VolumeError::IoError("Cannot create root directory".into())); + }; + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + return Err(VolumeError::IoError("Invalid directory name".into())); + }; + + let parent_path = self.to_mtp_path(parent); + let device_id = self.device_id.clone(); + let storage_id = self.storage_id; + let folder_name = name.to_string(); + + let handle = tokio::runtime::Handle::current(); + + handle + .block_on(async move { + connection_manager() + .create_folder(&device_id, storage_id, &parent_path, &folder_name) + .await + }) + .map(|_| ()) + .map_err(map_mtp_error) + } + + fn delete(&self, path: &Path) -> Result<(), VolumeError> { + let mtp_path = self.to_mtp_path(path); + let device_id = self.device_id.clone(); + let storage_id = self.storage_id; + + let handle = tokio::runtime::Handle::current(); + + handle + .block_on(async move { + connection_manager() + .delete_object(&device_id, storage_id, &mtp_path) + .await + }) + .map_err(map_mtp_error) + } + + fn rename(&self, from: &Path, to: &Path) -> Result<(), VolumeError> { + let mtp_path = self.to_mtp_path(from); + // Extract the new name from the destination path + let new_name = to + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| VolumeError::IoError("Invalid destination path".into()))? + .to_string(); + + let device_id = self.device_id.clone(); + let storage_id = self.storage_id; + + let handle = tokio::runtime::Handle::current(); + + handle + .block_on(async move { + connection_manager() + .rename_object(&device_id, storage_id, &mtp_path, &new_name) + .await + }) + .map(|_| ()) + .map_err(map_mtp_error) + } + + fn supports_export(&self) -> bool { + true + } + + fn scan_for_copy(&self, path: &Path) -> Result { + let mtp_path = self.to_mtp_path(path); + let device_id = self.device_id.clone(); + let storage_id = self.storage_id; + + debug!( + "MtpVolume::scan_for_copy: device={}, storage={}, path={}", + device_id, storage_id, mtp_path + ); + + let handle = tokio::runtime::Handle::current(); + + handle + .block_on(async move { + connection_manager() + .scan_for_copy(&device_id, storage_id, &mtp_path) + .await + }) + .map_err(map_mtp_error) + } + + fn export_to_local(&self, source: &Path, local_dest: &Path) -> Result { + let mtp_path = self.to_mtp_path(source); + let device_id = self.device_id.clone(); + let storage_id = self.storage_id; + let local_dest = local_dest.to_path_buf(); + + debug!( + "MtpVolume::export_to_local: device={}, storage={}, source={}, dest={}", + device_id, + storage_id, + mtp_path, + local_dest.display() + ); + + let handle = tokio::runtime::Handle::current(); + + handle + .block_on(async move { + connection_manager() + .download_recursive(&device_id, storage_id, &mtp_path, &local_dest) + .await + }) + .map_err(map_mtp_error) + } + + fn import_from_local(&self, local_source: &Path, dest: &Path) -> Result { + // upload_recursive expects the destination FOLDER, not the full path. + // It derives the filename from the source. So we need to extract the parent. + let dest_folder = dest.parent().map(|p| self.to_mtp_path(p)).unwrap_or_default(); + + let device_id = self.device_id.clone(); + let storage_id = self.storage_id; + let local_source = local_source.to_path_buf(); + + debug!( + "MtpVolume::import_from_local: device={}, storage={}, source={}, dest_folder={}", + device_id, + storage_id, + local_source.display(), + dest_folder + ); + + let handle = tokio::runtime::Handle::current(); + + handle + .block_on(async move { + connection_manager() + .upload_recursive(&device_id, storage_id, &local_source, &dest_folder) + .await + }) + .map_err(map_mtp_error) + } + + fn scan_for_conflicts( + &self, + source_items: &[SourceItemInfo], + dest_path: &Path, + ) -> Result, VolumeError> { + // List destination directory to check for conflicts + let entries = self.list_directory(dest_path)?; + let mut conflicts = Vec::new(); + + for item in source_items { + // Check if a file with the same name exists at destination + if let Some(existing) = entries.iter().find(|e| e.name == item.name) { + // Convert modified_at (milliseconds u64) to i64 seconds + let dest_modified = existing.modified_at.map(|ms| (ms / 1000) as i64); + conflicts.push(ConflictInfo { + source_path: item.name.clone(), + dest_path: existing.path.clone(), + source_size: item.size, + dest_size: existing.size.unwrap_or(0), + source_modified: item.modified, + dest_modified, + }); + } + } + + Ok(conflicts) + } + + fn get_space_info(&self) -> Result { + let device_id = self.device_id.clone(); + let storage_id = self.storage_id; + + let handle = tokio::runtime::Handle::current(); + + handle + .block_on(async move { + let info = connection_manager().get_device_info(&device_id).await.ok_or_else(|| { + MtpConnectionError::NotConnected { + device_id: device_id.clone(), + } + })?; + + // Find this storage in the device info + let storage = + info.storages + .iter() + .find(|s| s.id == storage_id) + .ok_or_else(|| MtpConnectionError::Other { + device_id: device_id.clone(), + message: format!("Storage {} not found", storage_id), + })?; + + Ok(SpaceInfo { + total_bytes: storage.total_bytes, + available_bytes: storage.available_bytes, + used_bytes: storage.total_bytes.saturating_sub(storage.available_bytes), + }) + }) + .map_err(map_mtp_error) + } + + fn supports_streaming(&self) -> bool { + true + } + + fn open_read_stream(&self, path: &Path) -> Result, VolumeError> { + let mtp_path = self.to_mtp_path(path); + let device_id = self.device_id.clone(); + let storage_id = self.storage_id; + + let handle = tokio::runtime::Handle::current(); + + // Get the file download stream from connection manager + let (download, total_size) = handle + .block_on(async { + connection_manager() + .open_download_stream(&device_id, storage_id, &mtp_path) + .await + }) + .map_err(map_mtp_error)?; + + Ok(Box::new(MtpReadStream { + handle, + download: Some(download), + total_size, + bytes_read: 0, + })) + } + + fn write_from_stream( + &self, + dest: &Path, + size: u64, + mut stream: Box, + ) -> Result { + let dest_folder = dest.parent().map(|p| self.to_mtp_path(p)).unwrap_or_default(); + let filename = dest + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| VolumeError::IoError("Invalid filename".into()))? + .to_string(); + + let device_id = self.device_id.clone(); + let storage_id = self.storage_id; + + // IMPORTANT: Collect all chunks BEFORE entering block_on to avoid nested runtime error. + // MtpReadStream::next_chunk() uses block_on internally, so we can't call it from + // within another block_on (which upload_from_stream would do). + let mut chunks: Vec = Vec::new(); + while let Some(result) = stream.next_chunk() { + let data = result?; + chunks.push(bytes::Bytes::from(data)); + } + + let handle = tokio::runtime::Handle::current(); + + handle + .block_on(async { + connection_manager() + .upload_from_chunks(&device_id, storage_id, &dest_folder, &filename, size, chunks) + .await + }) + .map_err(map_mtp_error) + } +} + +/// Streaming reader for MTP files. +/// +/// Wraps the mtp-rs FileDownload to provide sync iteration. +pub struct MtpReadStream { + /// Tokio runtime handle for blocking on async operations. + handle: tokio::runtime::Handle, + /// The underlying async download (wrapped in Option for take semantics). + download: Option, + /// Total file size. + total_size: u64, + /// Bytes read so far. + bytes_read: u64, +} + +impl VolumeReadStream for MtpReadStream { + fn next_chunk(&mut self) -> Option, VolumeError>> { + let download = self.download.as_mut()?; + + self.handle.block_on(async { + match download.next_chunk().await { + Some(Ok(bytes)) => { + self.bytes_read += bytes.len() as u64; + Some(Ok(bytes.to_vec())) + } + Some(Err(e)) => Some(Err(VolumeError::IoError(e.to_string()))), + None => None, + } + }) + } + + fn total_size(&self) -> u64 { + self.total_size + } + + fn bytes_read(&self) -> u64 { + self.bytes_read + } +} + +/// Maps MTP connection errors to Volume errors. +fn map_mtp_error(e: MtpConnectionError) -> VolumeError { + match e { + MtpConnectionError::DeviceNotFound { .. } | MtpConnectionError::NotConnected { .. } => { + VolumeError::NotFound(e.to_string()) + } + MtpConnectionError::ObjectNotFound { path, .. } => VolumeError::NotFound(path), + MtpConnectionError::ExclusiveAccess { .. } => VolumeError::PermissionDenied(e.to_string()), + _ => VolumeError::IoError(e.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_creates_volume() { + let vol = MtpVolume::new("mtp-20-5", 65537, "Internal storage"); + assert_eq!(vol.name(), "Internal storage"); + assert_eq!(vol.device_id, "mtp-20-5"); + assert_eq!(vol.storage_id, 65537); + } + + #[test] + fn test_root_path() { + let vol = MtpVolume::new("mtp-20-5", 65537, "Internal storage"); + assert_eq!(vol.root().to_string_lossy(), "mtp://mtp-20-5/65537"); + } + + #[test] + fn test_to_mtp_path_empty() { + let vol = MtpVolume::new("mtp-20-5", 65537, "Test"); + assert_eq!(vol.to_mtp_path(Path::new("")), ""); + assert_eq!(vol.to_mtp_path(Path::new("/")), ""); + assert_eq!(vol.to_mtp_path(Path::new(".")), ""); + } + + #[test] + fn test_to_mtp_path_relative() { + let vol = MtpVolume::new("mtp-20-5", 65537, "Test"); + assert_eq!(vol.to_mtp_path(Path::new("DCIM")), "DCIM"); + assert_eq!(vol.to_mtp_path(Path::new("DCIM/Camera")), "DCIM/Camera"); + } + + #[test] + fn test_to_mtp_path_absolute() { + let vol = MtpVolume::new("mtp-20-5", 65537, "Test"); + assert_eq!(vol.to_mtp_path(Path::new("/DCIM")), "DCIM"); + assert_eq!(vol.to_mtp_path(Path::new("/DCIM/Camera")), "DCIM/Camera"); + } + + #[test] + fn test_to_mtp_path_mtp_url_root() { + let vol = MtpVolume::new("mtp-0-1", 65537, "Test"); + // MTP URL for storage root + assert_eq!(vol.to_mtp_path(Path::new("mtp://mtp-0-1/65537")), ""); + } + + #[test] + fn test_to_mtp_path_mtp_url_with_path() { + let vol = MtpVolume::new("mtp-0-1", 65537, "Test"); + // MTP URL with nested path + assert_eq!(vol.to_mtp_path(Path::new("mtp://mtp-0-1/65537/DCIM")), "DCIM"); + assert_eq!( + vol.to_mtp_path(Path::new("mtp://mtp-0-1/65537/DCIM/Camera")), + "DCIM/Camera" + ); + } + + #[test] + fn test_supports_watching_returns_false() { + // MTP volumes return false for supports_watching because they have their + // own event loop (in MtpConnectionManager) that handles file watching + // independently. The supports_watching check in operations.rs is only + // for the local notify-based watcher, which doesn't work for MTP paths. + let vol = MtpVolume::new("mtp-20-5", 65537, "Test"); + assert!(!vol.supports_watching()); + } + + #[test] + fn test_supports_streaming_returns_true() { + // MTP volumes support streaming for direct MTP-to-MTP transfers. + let vol = MtpVolume::new("mtp-20-5", 65537, "Test"); + assert!(vol.supports_streaming()); + } +} diff --git a/apps/desktop/src-tauri/src/file_system/watcher.rs b/apps/desktop/src-tauri/src/file_system/watcher.rs index 4720acb..ad1bd99 100644 --- a/apps/desktop/src-tauri/src/file_system/watcher.rs +++ b/apps/desktop/src-tauri/src/file_system/watcher.rs @@ -208,8 +208,11 @@ fn handle_directory_change(listing_id: &str) { } } -/// Compute the diff between old and new directory listings. -pub(crate) fn compute_diff(old: &[FileEntry], new: &[FileEntry]) -> Vec { +/// Computes the diff between old and new directory listings. +/// +/// Used by both local file watcher and MTP file watcher to generate +/// incremental updates for the frontend. +pub fn compute_diff(old: &[FileEntry], new: &[FileEntry]) -> Vec { let mut changes = Vec::new(); // Create lookup maps by path diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/mod.rs b/apps/desktop/src-tauri/src/file_system/write_operations/mod.rs index 449bcb1..5ceb89c 100644 --- a/apps/desktop/src-tauri/src/file_system/write_operations/mod.rs +++ b/apps/desktop/src-tauri/src/file_system/write_operations/mod.rs @@ -23,6 +23,7 @@ mod move_op; mod scan; mod state; mod types; +mod volume_copy; use std::path::PathBuf; use std::sync::Arc; @@ -65,6 +66,9 @@ pub(crate) use helpers::{ #[cfg(test)] pub(crate) use state::{CopyTransaction, WriteOperationState}; +// Re-export volume copy types and functions +pub use volume_copy::{VolumeCopyConfig, VolumeCopyScanResult, copy_between_volumes, scan_for_volume_copy}; + // ============================================================================ // Public API functions // ============================================================================ diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/volume_copy.rs b/apps/desktop/src-tauri/src/file_system/write_operations/volume_copy.rs new file mode 100644 index 0000000..bee2175 --- /dev/null +++ b/apps/desktop/src-tauri/src/file_system/write_operations/volume_copy.rs @@ -0,0 +1,1118 @@ +//! Unified volume copy operations. +//! +//! This module provides copy operations that work across different volume types. +//! It abstracts the differences between local and MTP volumes, providing a unified +//! interface for file copying regardless of source or destination type. +//! +//! Copy operation flow: +//! 1. Scan source files for count and total bytes +//! 2. Check destination space availability +//! 3. Scan for conflicts at destination +//! 4. Execute copy with progress reporting +//! +//! For cross-volume copies: +//! - Local → Local: Uses existing efficient file copy +//! - Local → MTP: Uses volume.import_from_local() +//! - MTP → Local: Uses volume.export_to_local() + +// TODO: Remove this once volume_copy is integrated into Tauri commands (Phase 5) +#![allow(dead_code, reason = "Volume copy not yet integrated into Tauri commands")] + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; +use uuid::Uuid; + +use super::state::{ + WRITE_OPERATION_STATE, WriteOperationState, register_operation_status, unregister_operation_status, + update_operation_status, +}; +use super::types::{ + ConflictResolution, WriteCancelledEvent, WriteCompleteEvent, WriteConflictEvent, WriteErrorEvent, + WriteOperationConfig, WriteOperationError, WriteOperationPhase, WriteOperationStartResult, WriteOperationType, + WriteProgressEvent, +}; +use crate::file_system::volume::{ConflictInfo, SourceItemInfo, SpaceInfo, Volume, VolumeError}; + +/// Copy operation configuration for volume-to-volume copy. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VolumeCopyConfig { + /// Progress update interval in milliseconds. + pub progress_interval_ms: u64, + /// How to handle conflicts (skip, overwrite, stop). + pub conflict_resolution: ConflictResolution, + /// Maximum number of conflicts to return in pre-flight scan. + pub max_conflicts_to_show: usize, +} + +impl Default for VolumeCopyConfig { + fn default() -> Self { + Self { + progress_interval_ms: 200, + conflict_resolution: ConflictResolution::Stop, + max_conflicts_to_show: 100, + } + } +} + +impl From<&WriteOperationConfig> for VolumeCopyConfig { + fn from(config: &WriteOperationConfig) -> Self { + Self { + progress_interval_ms: config.progress_interval_ms, + conflict_resolution: config.conflict_resolution, + max_conflicts_to_show: config.max_conflicts_to_show, + } + } +} + +/// Result of a pre-flight scan for volume copy. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VolumeCopyScanResult { + /// Total number of files to copy. + pub file_count: usize, + /// Total number of directories to create. + pub dir_count: usize, + /// Total bytes to copy. + pub total_bytes: u64, + /// Available space on destination. + pub dest_space: SpaceInfo, + /// Detected conflicts at destination. + pub conflicts: Vec, +} + +/// Starts a copy operation between two volumes. +/// +/// This is the unified entry point for all copy operations: +/// - Local → Local +/// - Local → MTP +/// - MTP → Local +/// +/// The function determines the appropriate copy strategy based on volume types +/// and handles progress reporting, conflict detection, and cancellation. +/// +/// # Arguments +/// +/// * `app` - Tauri app handle for event emission +/// * `source_volume` - The source volume to copy from +/// * `source_paths` - Paths of files/directories to copy (relative to source volume root) +/// * `dest_volume` - The destination volume to copy to +/// * `dest_path` - Destination directory path (relative to dest volume root) +/// * `config` - Copy operation configuration +/// +/// # Events emitted +/// +/// * `write-progress` - Every progress_interval_ms with WriteProgressEvent +/// * `write-complete` - On success with WriteCompleteEvent +/// * `write-error` - On error with WriteErrorEvent +/// * `write-cancelled` - If cancelled with WriteCancelledEvent +pub async fn copy_between_volumes( + app: tauri::AppHandle, + source_volume: Arc, + source_paths: Vec, + dest_volume: Arc, + dest_path: PathBuf, + config: VolumeCopyConfig, +) -> Result { + // Validate that volumes support the required operations + if !source_volume.supports_export() { + return Err(WriteOperationError::IoError { + path: String::new(), + message: format!("Source volume '{}' does not support export", source_volume.name()), + }); + } + + let operation_id = Uuid::new_v4().to_string(); + log::info!( + "copy_between_volumes: operation_id={}, source_volume={}, dest_volume={}, {} sources, dest={}", + operation_id, + source_volume.name(), + dest_volume.name(), + source_paths.len(), + dest_path.display() + ); + + let state = Arc::new(WriteOperationState { + cancelled: AtomicBool::new(false), + skip_rollback: AtomicBool::new(false), + progress_interval: Duration::from_millis(config.progress_interval_ms), + pending_resolution: std::sync::RwLock::new(None), + conflict_condvar: std::sync::Condvar::new(), + conflict_mutex: std::sync::Mutex::new(false), + }); + + // Store state for cancellation + if let Ok(mut cache) = WRITE_OPERATION_STATE.write() { + cache.insert(operation_id.clone(), Arc::clone(&state)); + } + + // Register operation status for query APIs + register_operation_status(&operation_id, WriteOperationType::Copy); + + let operation_id_for_spawn = operation_id.clone(); + + // Spawn background task + tokio::spawn(async move { + let operation_id_for_cleanup = operation_id_for_spawn.clone(); + let app_for_error = app.clone(); + + let result = tokio::task::spawn_blocking(move || { + copy_volumes_with_progress( + &app, + &operation_id_for_spawn, + &state, + source_volume, + &source_paths, + dest_volume, + &dest_path, + &config, + ) + }) + .await; + + // Clean up state + if let Ok(mut cache) = WRITE_OPERATION_STATE.write() { + cache.remove(&operation_id_for_cleanup); + } + unregister_operation_status(&operation_id_for_cleanup); + + // Handle task result - both panics and operation errors + use tauri::Emitter; + match result { + Ok(Ok(())) => { + // Success - write-complete event already emitted by copy_volumes_with_progress + } + Ok(Err(write_err)) => { + // Operation returned an error (not a panic) + log::error!( + "copy_between_volumes: operation {} failed with error: {:?}", + operation_id_for_cleanup, + write_err + ); + let _ = app_for_error.emit( + "write-error", + WriteErrorEvent { + operation_id: operation_id_for_cleanup, + operation_type: WriteOperationType::Copy, + error: write_err, + }, + ); + } + Err(e) => { + // Task panicked + log::error!( + "copy_between_volumes: operation {} panicked: {}", + operation_id_for_cleanup, + e + ); + let _ = app_for_error.emit( + "write-error", + WriteErrorEvent { + operation_id: operation_id_for_cleanup, + operation_type: WriteOperationType::Copy, + error: WriteOperationError::IoError { + path: String::new(), + message: format!("Task failed: {}", e), + }, + }, + ); + } + } + }); + + Ok(WriteOperationStartResult { + operation_id, + operation_type: WriteOperationType::Copy, + }) +} + +/// Performs a pre-flight scan for volume copy without executing. +/// +/// This scans the source files and checks destination for conflicts and space. +/// Use this to show the user what will happen before starting the copy. +/// +/// # Arguments +/// +/// * `source_volume` - The source volume to scan +/// * `source_paths` - Paths of files/directories to copy +/// * `dest_volume` - The destination volume +/// * `dest_path` - Destination directory path +/// * `max_conflicts` - Maximum number of conflicts to return +pub fn scan_for_volume_copy( + source_volume: &dyn Volume, + source_paths: &[PathBuf], + dest_volume: &dyn Volume, + dest_path: &Path, + max_conflicts: usize, +) -> Result { + // Scan source for total bytes and file count + let mut total_files = 0; + let mut total_dirs = 0; + let mut total_bytes = 0u64; + let mut source_items: Vec = Vec::new(); + + for source_path in source_paths { + let scan = source_volume.scan_for_copy(source_path)?; + total_files += scan.file_count; + total_dirs += scan.dir_count; + total_bytes += scan.total_bytes; + + // Collect source item info for conflict detection + // For now, we just use the top-level item name + if let Some(name) = source_path.file_name() { + let metadata = source_volume.get_metadata(source_path).ok(); + source_items.push(SourceItemInfo { + name: name.to_string_lossy().to_string(), + size: metadata.as_ref().and_then(|m| m.size).unwrap_or(0), + modified: metadata + .as_ref() + .and_then(|m| m.modified_at.map(|ms| (ms / 1000) as i64)), + }); + } + } + + // Get destination space info + let dest_space = dest_volume.get_space_info()?; + + // Check if there's enough space + if dest_space.available_bytes < total_bytes { + return Err(VolumeError::IoError(format!( + "Not enough space: need {} bytes, only {} available", + total_bytes, dest_space.available_bytes + ))); + } + + // Scan for conflicts at destination + let all_conflicts = dest_volume.scan_for_conflicts(&source_items, dest_path)?; + + // Limit the number of conflicts returned + let conflicts = if all_conflicts.len() > max_conflicts { + all_conflicts.into_iter().take(max_conflicts).collect() + } else { + all_conflicts + }; + + Ok(VolumeCopyScanResult { + file_count: total_files, + dir_count: total_dirs, + total_bytes, + dest_space, + conflicts, + }) +} + +/// Internal function that performs the actual copy with progress reporting. +#[allow( + clippy::too_many_arguments, + reason = "Volume copy requires passing multiple context parameters" +)] +fn copy_volumes_with_progress( + app: &tauri::AppHandle, + operation_id: &str, + state: &Arc, + source_volume: Arc, + source_paths: &[PathBuf], + dest_volume: Arc, + dest_path: &Path, + config: &VolumeCopyConfig, +) -> Result<(), WriteOperationError> { + use tauri::Emitter; + + log::debug!( + "copy_volumes_with_progress: starting operation_id={}, {} sources", + operation_id, + source_paths.len() + ); + + // Phase 1: Scan sources + log::debug!( + "copy_volumes_with_progress: scanning sources for operation_id={}", + operation_id + ); + + let _ = app.emit( + "write-progress", + WriteProgressEvent { + operation_id: operation_id.to_string(), + operation_type: WriteOperationType::Copy, + phase: WriteOperationPhase::Scanning, + current_file: None, + files_done: 0, + files_total: 0, + bytes_done: 0, + bytes_total: 0, + }, + ); + + let mut total_files = 0; + let mut total_dirs = 0; + let mut total_bytes = 0u64; + + for source_path in source_paths { + // Check cancellation + if state.cancelled.load(Ordering::Relaxed) { + return Err(WriteOperationError::Cancelled { + message: "Operation cancelled by user".to_string(), + }); + } + + let scan = source_volume.scan_for_copy(source_path).map_err(map_volume_error)?; + total_files += scan.file_count; + total_dirs += scan.dir_count; + total_bytes += scan.total_bytes; + } + + log::info!( + "copy_volumes_with_progress: scan complete for operation_id={}, files={}, dirs={}, bytes={}", + operation_id, + total_files, + total_dirs, + total_bytes + ); + + // Phase 2: Check destination space + let dest_space = dest_volume.get_space_info().map_err(map_volume_error)?; + if dest_space.available_bytes < total_bytes { + return Err(WriteOperationError::InsufficientSpace { + required: total_bytes, + available: dest_space.available_bytes, + volume_name: Some(dest_volume.name().to_string()), + }); + } + + // Phase 3: Copy files with progress + let mut files_done = 0; + let mut bytes_done = 0u64; + let mut last_progress_time = Instant::now(); + let progress_interval = Duration::from_millis(config.progress_interval_ms); + + // Emit initial copying phase event + let _ = app.emit( + "write-progress", + WriteProgressEvent { + operation_id: operation_id.to_string(), + operation_type: WriteOperationType::Copy, + phase: WriteOperationPhase::Copying, + current_file: None, + files_done: 0, + files_total: total_files, + bytes_done: 0, + bytes_total: total_bytes, + }, + ); + update_operation_status( + operation_id, + WriteOperationPhase::Copying, + None, + 0, + total_files, + 0, + total_bytes, + ); + + // Track "apply to all" resolution for conflicts + let mut apply_to_all_resolution: Option = None; + + for source_path in source_paths { + // Check cancellation + if state.cancelled.load(Ordering::Relaxed) { + let _ = app.emit( + "write-cancelled", + WriteCancelledEvent { + operation_id: operation_id.to_string(), + operation_type: WriteOperationType::Copy, + files_processed: files_done, + rolled_back: false, // Volume copies don't have rollback yet + }, + ); + return Err(WriteOperationError::Cancelled { + message: "Operation cancelled by user".to_string(), + }); + } + + let file_name = source_path.file_name().map(|n| n.to_string_lossy().to_string()); + let mut dest_item_path = if let Some(name) = source_path.file_name() { + dest_path.join(name) + } else { + dest_path.to_path_buf() + }; + + // Check for conflict: does destination already exist? + if dest_volume.exists(&dest_item_path) { + // Check if both source and destination are directories - directories merge, not conflict + let source_is_dir = source_volume.is_directory(source_path).unwrap_or(false); + let dest_is_dir = dest_volume.is_directory(&dest_item_path).unwrap_or(false); + + if source_is_dir && dest_is_dir { + // Both are directories - this is a merge, not a conflict + // Continue with the copy (contents will be merged) + log::debug!( + "copy_volumes_with_progress: merging directories {} -> {}", + source_path.display(), + dest_item_path.display() + ); + } else { + // Either both are files, or there's a type mismatch - this is a conflict + log::debug!( + "copy_volumes_with_progress: conflict detected at {} (source_is_dir={}, dest_is_dir={})", + dest_item_path.display(), + source_is_dir, + dest_is_dir + ); + + // Resolve the conflict + let resolved = resolve_volume_conflict( + &source_volume, + source_path, + &dest_volume, + &dest_item_path, + config, + app, + operation_id, + state, + &mut apply_to_all_resolution, + )?; + + match resolved { + None => { + // Skip this file + log::debug!( + "copy_volumes_with_progress: skipping {} due to conflict resolution", + source_path.display() + ); + continue; + } + Some(resolved_path) => { + dest_item_path = resolved_path; + } + } + } + } + + log::debug!( + "copy_volumes_with_progress: copying {} -> {}", + source_path.display(), + dest_item_path.display() + ); + + let bytes_copied = copy_single_path(&source_volume, source_path, &dest_volume, &dest_item_path, state) + .map_err(map_volume_error)?; + + files_done += 1; + bytes_done += bytes_copied; + + // Emit progress + if last_progress_time.elapsed() >= progress_interval { + let _ = app.emit( + "write-progress", + WriteProgressEvent { + operation_id: operation_id.to_string(), + operation_type: WriteOperationType::Copy, + phase: WriteOperationPhase::Copying, + current_file: file_name.clone(), + files_done, + files_total: total_files, + bytes_done, + bytes_total: total_bytes, + }, + ); + update_operation_status( + operation_id, + WriteOperationPhase::Copying, + file_name, + files_done, + total_files, + bytes_done, + total_bytes, + ); + last_progress_time = Instant::now(); + } + } + + // Success + log::info!( + "copy_volumes_with_progress: completed op={} files={} bytes={}", + operation_id, + files_done, + bytes_done + ); + + let _ = app.emit( + "write-complete", + WriteCompleteEvent { + operation_id: operation_id.to_string(), + operation_type: WriteOperationType::Copy, + files_processed: files_done, + bytes_processed: bytes_done, + }, + ); + + Ok(()) +} + +/// Resolves a file conflict for volume-to-volume copy. +/// Returns None if file should be skipped, or Some(path) with the resolved destination path. +#[allow( + clippy::too_many_arguments, + reason = "Conflict resolution requires many context parameters" +)] +fn resolve_volume_conflict( + source_volume: &Arc, + source_path: &Path, + dest_volume: &Arc, + dest_path: &Path, + config: &VolumeCopyConfig, + app: &tauri::AppHandle, + operation_id: &str, + state: &Arc, + apply_to_all_resolution: &mut Option, +) -> Result, WriteOperationError> { + use tauri::Emitter; + + // Determine effective conflict resolution + let resolution = if let Some(saved_resolution) = apply_to_all_resolution { + // Use saved "apply to all" resolution + *saved_resolution + } else { + config.conflict_resolution + }; + + match resolution { + ConflictResolution::Stop => { + // Need to prompt user - gather metadata for the conflict event + let source_scan = source_volume.scan_for_copy(source_path).ok(); + let source_size = source_scan.as_ref().map(|s| s.total_bytes).unwrap_or(0); + + // Try to get destination size by scanning (best effort) + let dest_size = dest_volume + .scan_for_copy(dest_path) + .ok() + .map(|s| s.total_bytes) + .unwrap_or(0); + + // We can't easily get modification times from Volume trait, so use None + let source_modified: Option = None; + let destination_modified: Option = None; + let destination_is_newer = false; + let size_difference = dest_size as i64 - source_size as i64; + + let _ = app.emit( + "write-conflict", + WriteConflictEvent { + operation_id: operation_id.to_string(), + source_path: source_path.display().to_string(), + destination_path: dest_path.display().to_string(), + source_size, + destination_size: dest_size, + source_modified, + destination_modified, + destination_is_newer, + size_difference, + }, + ); + + // Wait for user to call resolve_write_conflict + let guard = state.conflict_mutex.lock().unwrap(); + let _guard = state + .conflict_condvar + .wait_while(guard, |_| { + // Keep waiting while: + // 1. No pending resolution + // 2. Not cancelled + let has_resolution = state.pending_resolution.read().map(|r| r.is_some()).unwrap_or(false); + let is_cancelled = state.cancelled.load(Ordering::Relaxed); + !has_resolution && !is_cancelled + }) + .unwrap(); + + // Check if cancelled + if state.cancelled.load(Ordering::Relaxed) { + return Err(WriteOperationError::Cancelled { + message: "Operation cancelled by user".to_string(), + }); + } + + // Get the resolution + let response = state.pending_resolution.write().ok().and_then(|mut r| r.take()); + + if let Some(response) = response { + // Save for future conflicts if apply_to_all + if response.apply_to_all { + *apply_to_all_resolution = Some(response.resolution); + } + + // Apply the chosen resolution + apply_volume_conflict_resolution(response.resolution, dest_volume, dest_path) + } else { + // No resolution provided, treat as error + Err(WriteOperationError::DestinationExists { + path: dest_path.display().to_string(), + }) + } + } + ConflictResolution::Skip => Ok(None), + ConflictResolution::Overwrite => { + apply_volume_conflict_resolution(ConflictResolution::Overwrite, dest_volume, dest_path) + } + ConflictResolution::Rename => { + apply_volume_conflict_resolution(ConflictResolution::Rename, dest_volume, dest_path) + } + } +} + +/// Applies a specific conflict resolution for volume copy. +/// Returns None for Skip, or Some(path) with the path to write to. +fn apply_volume_conflict_resolution( + resolution: ConflictResolution, + dest_volume: &Arc, + dest_path: &Path, +) -> Result, WriteOperationError> { + match resolution { + ConflictResolution::Stop => { + // Should not happen - Stop waits for user input + Err(WriteOperationError::DestinationExists { + path: dest_path.display().to_string(), + }) + } + ConflictResolution::Skip => Ok(None), + ConflictResolution::Overwrite => { + // Delete existing item first, then return the same path + // Note: For directories, this will fail if not empty - that's expected behavior + if let Err(e) = dest_volume.delete(dest_path) { + log::warn!( + "Failed to delete existing item for overwrite: {} - {}", + dest_path.display(), + e + ); + // Continue anyway - the copy might succeed if it's a file being overwritten + } + Ok(Some(dest_path.to_path_buf())) + } + ConflictResolution::Rename => { + // Find a unique name - we need to check what exists on the volume + let unique_path = find_unique_volume_name(dest_volume, dest_path); + Ok(Some(unique_path)) + } + } +} + +/// Finds a unique filename on a volume by appending " (1)", " (2)", etc. +fn find_unique_volume_name(dest_volume: &Arc, path: &Path) -> PathBuf { + let parent = path.parent().unwrap_or(Path::new("")); + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + let extension = path.extension().map(|s| s.to_string_lossy().to_string()); + + let mut counter = 1; + loop { + let new_name = match &extension { + Some(ext) => format!("{} ({}).{}", stem, counter, ext), + None => format!("{} ({})", stem, counter), + }; + let new_path = parent.join(new_name); + if !dest_volume.exists(&new_path) { + return new_path; + } + counter += 1; + + // Safety limit to prevent infinite loop + if counter > 1000 { + // Just return with counter - extremely unlikely to happen + let new_name = match &extension { + Some(ext) => format!("{} ({}).{}", stem, counter, ext), + None => format!("{} ({})", stem, counter), + }; + return parent.join(new_name); + } + } +} + +/// Checks if a volume is a real local filesystem (not MTP or other virtual volumes). +fn is_local_volume(volume: &dyn Volume) -> bool { + let root = volume.root(); + // Local volumes start with "/" but NOT "/mtp-volume/" + root.starts_with("/") && !root.starts_with("/mtp-volume/") +} + +/// Copies a single path from source volume to destination volume. +/// +/// Determines the appropriate strategy based on volume types: +/// - If both are MTP and source is a file: Use streaming for direct transfer +/// - If both are MTP and source is a directory: Use temp local (export then import) +/// - If source is local: dest.import_from_local() +/// - If dest is local: source.export_to_local() +/// - Otherwise: Not supported +fn copy_single_path( + source_volume: &Arc, + source_path: &Path, + dest_volume: &Arc, + dest_path: &Path, + state: &Arc, +) -> Result { + // Check cancellation + if state.cancelled.load(Ordering::Relaxed) { + return Err(VolumeError::IoError("Operation cancelled".to_string())); + } + + let source_is_local = is_local_volume(source_volume.as_ref()); + let dest_is_local = is_local_volume(dest_volume.as_ref()); + + // Handle non-local to non-local (e.g., MTP → MTP) + if !source_is_local && !dest_is_local { + // Check if source is a directory + let is_dir = source_volume.is_directory(source_path).unwrap_or(false); + + if is_dir { + // For directories, use temp local approach: export to temp, import from temp + log::debug!( + "copy_single_path: MTP→MTP directory copy via temp local: {} -> {}", + source_path.display(), + dest_path.display() + ); + return copy_via_temp_local(source_volume, source_path, dest_volume, dest_path); + } + + // For files, try streaming if both volumes support it + if source_volume.supports_streaming() && dest_volume.supports_streaming() { + log::debug!( + "copy_single_path: using streaming for {} -> {}", + source_path.display(), + dest_path.display() + ); + let stream = source_volume.open_read_stream(source_path)?; + let size = stream.total_size(); + return dest_volume.write_from_stream(dest_path, size, stream); + } + + // Neither supports streaming and it's not a directory - not supported + return Err(VolumeError::NotSupported); + } + + if source_is_local && !dest_is_local { + // Source is local, dest is not (e.g., Local → MTP) + // Use import_from_local on destination + let local_source = if source_path.is_absolute() { + source_path.to_path_buf() + } else { + source_volume.root().join(source_path) + }; + dest_volume.import_from_local(&local_source, dest_path) + } else if !source_is_local && dest_is_local { + // Source is not local, dest is local (e.g., MTP → Local) + // Use export_to_local on source + let local_dest = if dest_path.is_absolute() { + dest_path.to_path_buf() + } else { + dest_volume.root().join(dest_path) + }; + source_volume.export_to_local(source_path, &local_dest) + } else { + // Both are local, use export which resolves paths internally + // Note: export_to_local takes a path relative to the volume root for source, + // and an absolute local path for destination + let local_dest = if dest_path.is_absolute() { + dest_path.to_path_buf() + } else { + dest_volume.root().join(dest_path) + }; + source_volume.export_to_local(source_path, &local_dest) + } +} + +/// Copies a path between two non-local volumes via a temporary local directory. +/// +/// This is used for MTP-to-MTP directory copies where streaming doesn't work. +/// The process: +/// 1. Export from source to a temp local directory +/// 2. Import from temp local to destination +/// 3. Clean up temp directory +fn copy_via_temp_local( + source_volume: &Arc, + source_path: &Path, + dest_volume: &Arc, + dest_path: &Path, +) -> Result { + // Create a temporary directory for the transfer + let temp_dir = std::env::temp_dir().join(format!("cmdr_volume_copy_{}", Uuid::new_v4())); + std::fs::create_dir_all(&temp_dir).map_err(|e| VolumeError::IoError(e.to_string()))?; + + // Determine the name of the item being copied + let item_name = source_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "item".to_string()); + let temp_item_path = temp_dir.join(&item_name); + + log::debug!( + "copy_via_temp_local: exporting {} to temp {}", + source_path.display(), + temp_item_path.display() + ); + + // Step 1: Export from source to temp local + let bytes = source_volume.export_to_local(source_path, &temp_item_path)?; + + log::debug!( + "copy_via_temp_local: importing from temp {} to {}", + temp_item_path.display(), + dest_path.display() + ); + + // Step 2: Import from temp local to destination + let result = dest_volume.import_from_local(&temp_item_path, dest_path); + + // Step 3: Clean up temp directory (best effort) + if let Err(e) = std::fs::remove_dir_all(&temp_dir) { + log::warn!("Failed to clean up temp directory {}: {}", temp_dir.display(), e); + } + + // Return the bytes from export (import might report different due to protocol overhead) + result.or(Ok(bytes)) +} + +/// Maps VolumeError to WriteOperationError. +fn map_volume_error(e: VolumeError) -> WriteOperationError { + match e { + VolumeError::NotFound(path) => WriteOperationError::SourceNotFound { path }, + VolumeError::PermissionDenied(msg) => WriteOperationError::PermissionDenied { + path: String::new(), + message: msg, + }, + VolumeError::AlreadyExists(path) => WriteOperationError::DestinationExists { path }, + VolumeError::NotSupported => WriteOperationError::IoError { + path: String::new(), + message: "Operation not supported by this volume type".to_string(), + }, + VolumeError::IoError(msg) => WriteOperationError::IoError { + path: String::new(), + message: msg, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::file_system::volume::{InMemoryVolume, LocalPosixVolume}; + + #[test] + fn test_volume_copy_config_default() { + let config = VolumeCopyConfig::default(); + assert_eq!(config.progress_interval_ms, 200); + assert_eq!(config.max_conflicts_to_show, 100); + } + + #[test] + fn test_scan_for_volume_copy_empty_source_returns_error_for_in_memory() { + // InMemoryVolume doesn't support get_space_info, so scan_for_volume_copy + // will return an error. This is expected behavior. + let source = InMemoryVolume::new("Source"); + let dest = InMemoryVolume::new("Dest"); + + let result = scan_for_volume_copy(&source, &[], &dest, Path::new("/"), 10); + // InMemoryVolume doesn't support get_space_info, so this should fail + assert!(result.is_err()); + } + + #[test] + fn test_map_volume_error_not_found() { + let err = map_volume_error(VolumeError::NotFound("/test/path".to_string())); + assert!(matches!(err, WriteOperationError::SourceNotFound { path } if path == "/test/path")); + } + + #[test] + fn test_map_volume_error_permission_denied() { + let err = map_volume_error(VolumeError::PermissionDenied("Access denied".to_string())); + assert!(matches!(err, WriteOperationError::PermissionDenied { message, .. } if message == "Access denied")); + } + + #[test] + fn test_map_volume_error_already_exists() { + let err = map_volume_error(VolumeError::AlreadyExists("/existing".to_string())); + assert!(matches!(err, WriteOperationError::DestinationExists { path } if path == "/existing")); + } + + #[test] + fn test_map_volume_error_not_supported() { + let err = map_volume_error(VolumeError::NotSupported); + assert!(matches!(err, WriteOperationError::IoError { message, .. } if message.contains("not supported"))); + } + + // ======================================== + // LocalPosixVolume integration tests + // ======================================== + + #[test] + fn test_scan_for_volume_copy_with_local_volumes() { + use std::fs; + + let src_dir = std::env::temp_dir().join("cmdr_volume_scan_src"); + let dst_dir = std::env::temp_dir().join("cmdr_volume_scan_dst"); + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + fs::create_dir_all(&src_dir).unwrap(); + fs::create_dir_all(&dst_dir).unwrap(); + + // Create source files + fs::write(src_dir.join("file1.txt"), "Hello").unwrap(); + fs::write(src_dir.join("file2.txt"), "World").unwrap(); + + let source = LocalPosixVolume::new("Source", src_dir.to_str().unwrap()); + let dest = LocalPosixVolume::new("Dest", dst_dir.to_str().unwrap()); + + let result = scan_for_volume_copy( + &source, + &[PathBuf::from("file1.txt"), PathBuf::from("file2.txt")], + &dest, + Path::new(""), + 10, + ); + + let scan = result.unwrap(); + assert_eq!(scan.file_count, 2); + assert_eq!(scan.total_bytes, 10); // "Hello" + "World" + assert!(scan.conflicts.is_empty()); + assert!(scan.dest_space.total_bytes > 0); + + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + } + + #[test] + fn test_scan_for_volume_copy_detects_conflicts() { + use std::fs; + + let src_dir = std::env::temp_dir().join("cmdr_volume_conflict_src"); + let dst_dir = std::env::temp_dir().join("cmdr_volume_conflict_dst"); + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + fs::create_dir_all(&src_dir).unwrap(); + fs::create_dir_all(&dst_dir).unwrap(); + + // Create source file + fs::write(src_dir.join("conflict.txt"), "New content").unwrap(); + + // Create existing file at destination + fs::write(dst_dir.join("conflict.txt"), "Old content").unwrap(); + + let source = LocalPosixVolume::new("Source", src_dir.to_str().unwrap()); + let dest = LocalPosixVolume::new("Dest", dst_dir.to_str().unwrap()); + + let result = scan_for_volume_copy(&source, &[PathBuf::from("conflict.txt")], &dest, Path::new(""), 10); + + let scan = result.unwrap(); + assert_eq!(scan.file_count, 1); + assert_eq!(scan.conflicts.len(), 1); + assert_eq!(scan.conflicts[0].source_path, "conflict.txt"); + assert_eq!(scan.conflicts[0].source_size, 11); // "New content" + assert_eq!(scan.conflicts[0].dest_size, 11); // "Old content" + + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + } + + #[test] + fn test_scan_for_volume_copy_max_conflicts() { + use std::fs; + + let src_dir = std::env::temp_dir().join("cmdr_volume_max_conflicts_src"); + let dst_dir = std::env::temp_dir().join("cmdr_volume_max_conflicts_dst"); + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + fs::create_dir_all(&src_dir).unwrap(); + fs::create_dir_all(&dst_dir).unwrap(); + + // Create 5 conflicting files + let mut source_paths = Vec::new(); + for i in 0..5 { + let name = format!("file{}.txt", i); + fs::write(src_dir.join(&name), "new").unwrap(); + fs::write(dst_dir.join(&name), "old").unwrap(); + source_paths.push(PathBuf::from(&name)); + } + + let source = LocalPosixVolume::new("Source", src_dir.to_str().unwrap()); + let dest = LocalPosixVolume::new("Dest", dst_dir.to_str().unwrap()); + + // Request max 3 conflicts + let result = scan_for_volume_copy(&source, &source_paths, &dest, Path::new(""), 3); + + let scan = result.unwrap(); + assert_eq!(scan.conflicts.len(), 3); // Limited to max + + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + } + + #[test] + fn test_copy_single_path_local_to_local() { + use std::fs; + + let src_dir = std::env::temp_dir().join("cmdr_copy_single_src"); + let dst_dir = std::env::temp_dir().join("cmdr_copy_single_dst"); + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + fs::create_dir_all(&src_dir).unwrap(); + fs::create_dir_all(&dst_dir).unwrap(); + + fs::write(src_dir.join("source.txt"), "Source content").unwrap(); + + let source: Arc = Arc::new(LocalPosixVolume::new("Source", src_dir.to_str().unwrap())); + let dest: Arc = Arc::new(LocalPosixVolume::new("Dest", dst_dir.to_str().unwrap())); + + let state = Arc::new(WriteOperationState { + cancelled: AtomicBool::new(false), + skip_rollback: AtomicBool::new(false), + progress_interval: Duration::from_millis(200), + pending_resolution: std::sync::RwLock::new(None), + conflict_condvar: std::sync::Condvar::new(), + conflict_mutex: std::sync::Mutex::new(false), + }); + + let bytes = copy_single_path(&source, Path::new("source.txt"), &dest, Path::new("dest.txt"), &state).unwrap(); + + assert_eq!(bytes, 14); // "Source content" + assert_eq!(fs::read_to_string(dst_dir.join("dest.txt")).unwrap(), "Source content"); + + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + } + + #[test] + fn test_copy_single_path_cancelled() { + use std::fs; + + let src_dir = std::env::temp_dir().join("cmdr_copy_cancel_src"); + let dst_dir = std::env::temp_dir().join("cmdr_copy_cancel_dst"); + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + fs::create_dir_all(&src_dir).unwrap(); + fs::create_dir_all(&dst_dir).unwrap(); + + fs::write(src_dir.join("source.txt"), "Content").unwrap(); + + let source: Arc = Arc::new(LocalPosixVolume::new("Source", src_dir.to_str().unwrap())); + let dest: Arc = Arc::new(LocalPosixVolume::new("Dest", dst_dir.to_str().unwrap())); + + let state = Arc::new(WriteOperationState { + cancelled: AtomicBool::new(true), // Already cancelled + skip_rollback: AtomicBool::new(false), + progress_interval: Duration::from_millis(200), + pending_resolution: std::sync::RwLock::new(None), + conflict_condvar: std::sync::Condvar::new(), + conflict_mutex: std::sync::Mutex::new(false), + }); + + let result = copy_single_path(&source, Path::new("source.txt"), &dest, Path::new("dest.txt"), &state); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), VolumeError::IoError(msg) if msg.contains("cancelled"))); + + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d89779c..5228f7e 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -39,6 +39,14 @@ use tauri_plugin_mcp_bridge as _; // security_framework is used in network/keychain.rs for Keychain integration #[cfg(target_os = "macos")] use security_framework as _; +//noinspection ALL +// mtp-rs is used in mtp/ module for Android device support (macOS only, Phase 1 foundation) +#[cfg(target_os = "macos")] +use mtp_rs as _; +//noinspection ALL +// nusb is used in mtp/watcher.rs for USB hotplug detection +#[cfg(target_os = "macos")] +use nusb as _; mod ai; pub mod benchmark; @@ -54,6 +62,8 @@ mod macos_icons; mod mcp; mod menu; #[cfg(target_os = "macos")] +mod mtp; +#[cfg(target_os = "macos")] mod network; #[cfg(target_os = "macos")] mod permissions; @@ -123,6 +133,10 @@ pub fn run() { #[cfg(target_os = "macos")] volumes::watcher::start_volume_watcher(app.handle()); + // Start MTP device hotplug watcher (Android device support) + #[cfg(target_os = "macos")] + mtp::start_mtp_watcher(app.handle()); + // Load known network shares from disk #[cfg(target_os = "macos")] network::known_shares::load_known_shares(app.handle()); @@ -307,6 +321,10 @@ pub fn run() { commands::file_system::resolve_write_conflict, commands::file_system::list_active_operations, commands::file_system::get_operation_status, + // Unified volume copy commands + commands::file_system::copy_between_volumes, + commands::file_system::scan_volume_for_copy, + commands::file_system::scan_volume_for_conflicts, commands::file_system::get_listing_stats, commands::file_system::start_selection_drag, commands::file_viewer::viewer_open, @@ -342,6 +360,63 @@ pub fn run() { mcp::settings_state::mcp_update_shortcuts, // Sync status (macOS uses real implementation, others use stub in commands) commands::sync_status::get_sync_status, + // MTP commands (macOS only - Android device support) + #[cfg(target_os = "macos")] + commands::mtp::list_mtp_devices, + #[cfg(target_os = "macos")] + commands::mtp::connect_mtp_device, + #[cfg(target_os = "macos")] + commands::mtp::disconnect_mtp_device, + #[cfg(target_os = "macos")] + commands::mtp::get_mtp_device_info, + #[cfg(target_os = "macos")] + commands::mtp::get_ptpcamerad_workaround_command, + #[cfg(target_os = "macos")] + commands::mtp::get_mtp_storages, + #[cfg(target_os = "macos")] + commands::mtp::list_mtp_directory, + #[cfg(target_os = "macos")] + commands::mtp::download_mtp_file, + #[cfg(target_os = "macos")] + commands::mtp::upload_to_mtp, + #[cfg(target_os = "macos")] + commands::mtp::delete_mtp_object, + #[cfg(target_os = "macos")] + commands::mtp::create_mtp_folder, + #[cfg(target_os = "macos")] + commands::mtp::rename_mtp_object, + #[cfg(target_os = "macos")] + commands::mtp::move_mtp_object, + #[cfg(target_os = "macos")] + commands::mtp::scan_mtp_for_copy, + #[cfg(not(target_os = "macos"))] + stubs::mtp::list_mtp_devices, + #[cfg(not(target_os = "macos"))] + stubs::mtp::connect_mtp_device, + #[cfg(not(target_os = "macos"))] + stubs::mtp::disconnect_mtp_device, + #[cfg(not(target_os = "macos"))] + stubs::mtp::get_mtp_device_info, + #[cfg(not(target_os = "macos"))] + stubs::mtp::get_ptpcamerad_workaround_command, + #[cfg(not(target_os = "macos"))] + stubs::mtp::get_mtp_storages, + #[cfg(not(target_os = "macos"))] + stubs::mtp::list_mtp_directory, + #[cfg(not(target_os = "macos"))] + stubs::mtp::download_mtp_file, + #[cfg(not(target_os = "macos"))] + stubs::mtp::upload_to_mtp, + #[cfg(not(target_os = "macos"))] + stubs::mtp::delete_mtp_object, + #[cfg(not(target_os = "macos"))] + stubs::mtp::create_mtp_folder, + #[cfg(not(target_os = "macos"))] + stubs::mtp::rename_mtp_object, + #[cfg(not(target_os = "macos"))] + stubs::mtp::move_mtp_object, + #[cfg(not(target_os = "macos"))] + stubs::mtp::scan_mtp_for_copy, // Volume commands (platform-specific) #[cfg(target_os = "macos")] commands::volumes::list_volumes, diff --git a/apps/desktop/src-tauri/src/mtp/connection.rs b/apps/desktop/src-tauri/src/mtp/connection.rs new file mode 100644 index 0000000..1015d0f --- /dev/null +++ b/apps/desktop/src-tauri/src/mtp/connection.rs @@ -0,0 +1,3520 @@ +//! MTP connection management. +//! +//! Manages device connections with a global registry. Each connected device +//! maintains an active MTP session until disconnected or unplugged. +//! +//! ## File watching +//! +//! MTP devices support event notifications via USB interrupt endpoints. When a +//! device is connected, we start a background task that polls for events using +//! `device.next_event()`. Events like ObjectAdded, ObjectRemoved, and ObjectInfoChanged +//! trigger incremental `directory-diff` events to the frontend, using the same +//! unified diff system as local file watching. This provides smooth UI updates +//! without full directory reloads. + +use log::{debug, error, info, warn}; +use mtp_rs::ptp::OperationCode; +use mtp_rs::{MtpDevice, MtpDeviceBuilder, NewObjectInfo, ObjectHandle, StorageId}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, LazyLock, RwLock}; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter}; +use tokio::io::AsyncWriteExt; +use tokio::sync::{Mutex, broadcast}; + +use super::types::{MtpDeviceInfo, MtpStorageInfo}; +use crate::file_system::operations::{get_listings_by_volume_prefix, update_listing_entries}; +use crate::file_system::{CopyScanResult, DirectoryDiff, FileEntry, MtpVolume, compute_diff, get_volume_manager}; + +/// Default timeout for MTP operations (30 seconds - some devices are slow). +const MTP_TIMEOUT_SECS: u64 = 30; + +/// Global counter for generating unique request IDs for debugging. +static REQUEST_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Tracks concurrent list_directory calls for debugging lock contention. +static CONCURRENT_LIST_CALLS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +/// Error types for MTP connection operations. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum MtpConnectionError { + /// Device not found (may have been unplugged). + DeviceNotFound { device_id: String }, + /// Device is not connected. + NotConnected { device_id: String }, + /// Another process has exclusive access to the device. + ExclusiveAccess { + device_id: String, + blocking_process: Option, + }, + /// Connection timed out. + Timeout { device_id: String }, + /// Device was disconnected unexpectedly. + Disconnected { device_id: String }, + /// Protocol error from device. + Protocol { device_id: String, message: String }, + /// Device is busy (retryable). + DeviceBusy { device_id: String }, + /// Storage is full. + StorageFull { device_id: String }, + /// Object not found on device. + ObjectNotFound { device_id: String, path: String }, + /// Other connection error. + Other { device_id: String, message: String }, +} + +impl MtpConnectionError { + /// Returns true if the operation may succeed if retried. + #[allow(dead_code, reason = "Will be used by frontend for retry logic")] + pub fn is_retryable(&self) -> bool { + matches!(self, Self::Timeout { .. } | Self::DeviceBusy { .. }) + } + + /// Returns a user-friendly message for this error. + #[allow(dead_code, reason = "Will be exposed via Tauri commands for UI error display")] + pub fn user_message(&self) -> String { + match self { + Self::DeviceNotFound { .. } => "Device not found. It may have been unplugged.".to_string(), + Self::NotConnected { .. } => { + "Device is not connected. Select it from the volume picker to connect.".to_string() + } + Self::ExclusiveAccess { blocking_process, .. } => { + if let Some(proc) = blocking_process { + format!( + "Another app ({}) is using this device. Close it or use the Terminal workaround.", + proc + ) + } else { + "Another app is using this device. Close other apps that might be accessing it.".to_string() + } + } + Self::Timeout { .. } => { + "The operation timed out. The device may be slow or unresponsive. Try again.".to_string() + } + Self::Disconnected { .. } => "Device was disconnected. Reconnect it to continue.".to_string(), + Self::Protocol { message, .. } => { + format!("Device reported an error: {}. Try reconnecting.", message) + } + Self::DeviceBusy { .. } => "Device is busy. Wait a moment and try again.".to_string(), + Self::StorageFull { .. } => "Device storage is full. Free up some space.".to_string(), + Self::ObjectNotFound { path, .. } => { + format!("File or folder not found: {}. It may have been deleted.", path) + } + Self::Other { message, .. } => message.clone(), + } + } +} + +impl std::fmt::Display for MtpConnectionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DeviceNotFound { device_id } => { + write!(f, "Device not found: {device_id}") + } + Self::NotConnected { device_id } => { + write!(f, "Device not connected: {device_id}") + } + Self::ExclusiveAccess { + device_id, + blocking_process, + } => { + if let Some(proc) = blocking_process { + write!(f, "Device {device_id} is in use by {proc}") + } else { + write!(f, "Device {device_id} is in use by another process") + } + } + Self::Timeout { device_id } => { + write!(f, "Connection timed out for device: {device_id}") + } + Self::Disconnected { device_id } => { + write!(f, "Device disconnected: {device_id}") + } + Self::Protocol { device_id, message } => { + write!(f, "Protocol error for {device_id}: {message}") + } + Self::DeviceBusy { device_id } => { + write!(f, "Device busy: {device_id}") + } + Self::StorageFull { device_id } => { + write!(f, "Storage full on device: {device_id}") + } + Self::ObjectNotFound { device_id, path } => { + write!(f, "Object not found on {device_id}: {path}") + } + Self::Other { device_id, message } => { + write!(f, "Error for {device_id}: {message}") + } + } + } +} + +impl std::error::Error for MtpConnectionError {} + +// ============================================================================ +// Progress events for MTP file operations +// ============================================================================ + +/// Progress event for MTP file transfers (download/upload). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MtpTransferProgress { + /// Unique operation ID. + pub operation_id: String, + /// Device ID. + pub device_id: String, + /// Type of transfer. + pub transfer_type: MtpTransferType, + /// Current file being transferred. + pub current_file: String, + /// Bytes transferred so far. + pub bytes_done: u64, + /// Total bytes to transfer. + pub bytes_total: u64, +} + +/// Type of MTP transfer operation. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MtpTransferType { + Download, + Upload, +} + +/// Result of a successful MTP operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MtpOperationResult { + /// Operation ID (for tracking). + pub operation_id: String, + /// Number of files processed. + pub files_processed: usize, + /// Total bytes transferred. + pub bytes_transferred: u64, +} + +/// State for tracking cancellation of MTP operations. +#[allow(dead_code, reason = "Will be used in Phase 5 for operation cancellation")] +pub struct MtpOperationState { + /// Cancellation flag. + pub cancelled: AtomicBool, +} + +impl Default for MtpOperationState { + fn default() -> Self { + Self { + cancelled: AtomicBool::new(false), + } + } +} + +/// Information about an object on the device (returned after creation). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MtpObjectInfo { + /// Object handle. + pub handle: u32, + /// Object name. + pub name: String, + /// Virtual path on device. + pub path: String, + /// Whether it's a directory. + pub is_directory: bool, + /// Size in bytes (None for directories). + pub size: Option, +} + +/// Information about a connected device, including its storages. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectedDeviceInfo { + /// Device information. + pub device: MtpDeviceInfo, + /// Available storages on the device. + pub storages: Vec, +} + +/// Internal entry for a connected device. +struct DeviceEntry { + /// The MTP device handle (wrapped in Arc for shared access). + device: Arc>, + /// Device metadata. + info: MtpDeviceInfo, + /// Cached storage information. + storages: Vec, + /// Path-to-handle cache per storage. + path_cache: RwLock>, + /// Directory listing cache per storage. + listing_cache: RwLock>, +} + +/// Cache for mapping paths to MTP object handles. +#[derive(Default)] +struct PathHandleCache { + /// Maps virtual path -> MTP object handle. + path_to_handle: HashMap, +} + +/// Cache for directory listings. +#[derive(Default)] +struct ListingCache { + /// Maps directory path -> cached file entries. + listings: HashMap, +} + +/// A cached directory listing with timestamp for invalidation. +struct CachedListing { + /// The cached file entries. + entries: Vec, + /// When this listing was cached (for TTL checks). + cached_at: Instant, +} + +/// How long to keep cached listings (5 seconds). +const LISTING_CACHE_TTL_SECS: u64 = 5; + +/// Debounce duration for MTP directory change events (500ms). +/// MTP devices can emit rapid events during bulk operations (e.g., copying many files). +const EVENT_DEBOUNCE_MS: u64 = 500; + +/// Debouncer for MTP directory change events. +/// +/// Prevents flooding the frontend with events during rapid operations like +/// bulk copy/delete. Each device has its own last-emit timestamp. +struct EventDebouncer { + /// Last emit time per device ID. + last_emit: RwLock>, + /// Debounce duration. + debounce_duration: Duration, +} + +impl EventDebouncer { + /// Creates a new debouncer with the given duration. + fn new(debounce_duration: Duration) -> Self { + Self { + last_emit: RwLock::new(HashMap::new()), + debounce_duration, + } + } + + /// Checks if we should emit an event for the given device. + /// Updates the last emit time if we should emit. + fn should_emit(&self, device_id: &str) -> bool { + let now = Instant::now(); + let mut last_emit = self.last_emit.write().unwrap(); + + if let Some(last) = last_emit.get(device_id) + && now.duration_since(*last) < self.debounce_duration + { + return false; + } + + last_emit.insert(device_id.to_string(), now); + true + } + + /// Clears the debounce state for a device (called on disconnect). + fn clear(&self, device_id: &str) { + let mut last_emit = self.last_emit.write().unwrap(); + last_emit.remove(device_id); + } +} + +/// Global connection manager for MTP devices. +pub struct MtpConnectionManager { + /// Map of device_id -> connected device entry. + devices: Mutex>, + /// Channels to signal event loop shutdown per device. + event_loop_shutdown: RwLock>>, + /// Debouncer for directory change events. + event_debouncer: EventDebouncer, +} + +/// Acquires the device lock with a timeout. +/// This prevents indefinite blocking if the device is unresponsive or another operation is stuck. +async fn acquire_device_lock<'a>( + device_arc: &'a Arc>, + device_id: &str, + operation: &str, +) -> Result, MtpConnectionError> { + tokio::time::timeout(Duration::from_secs(MTP_TIMEOUT_SECS), device_arc.lock()) + .await + .map_err(|_| { + error!("MTP {}: timed out waiting for device lock", operation); + MtpConnectionError::Timeout { + device_id: device_id.to_string(), + } + }) +} + +impl MtpConnectionManager { + /// Creates a new connection manager. + fn new() -> Self { + Self { + devices: Mutex::new(HashMap::new()), + event_loop_shutdown: RwLock::new(HashMap::new()), + event_debouncer: EventDebouncer::new(Duration::from_millis(EVENT_DEBOUNCE_MS)), + } + } + + /// Connects to an MTP device by ID. + /// + /// Opens an MTP session and retrieves storage information. + /// + /// # Returns + /// + /// Information about the connected device including available storages. + pub async fn connect( + &self, + device_id: &str, + app: Option<&AppHandle>, + ) -> Result { + // Check if already connected - if so, return existing connection info (idempotent) + { + let devices = self.devices.lock().await; + if let Some(entry) = devices.get(device_id) { + debug!( + "connect: {} already connected, returning existing connection info", + device_id + ); + return Ok(ConnectedDeviceInfo { + device: entry.info.clone(), + storages: entry.storages.clone(), + }); + } + } + + info!("Connecting to MTP device: {}", device_id); + + // Parse device_id to get location_id (format: "mtp-{location_id}") + let location_id = parse_device_id(device_id).ok_or_else(|| MtpConnectionError::DeviceNotFound { + device_id: device_id.to_string(), + })?; + debug!("Parsed device_id: location_id={}", location_id); + + // Find and open the device + debug!("Opening MTP device (timeout={}s)...", MTP_TIMEOUT_SECS); + let device = match open_device(location_id).await { + Ok(d) => d, + Err(e) => { + // Check for exclusive access error + if e.is_exclusive_access() { + #[cfg(target_os = "macos")] + let blocking_process = super::macos_workaround::get_usb_exclusive_owner(); + #[cfg(not(target_os = "macos"))] + let blocking_process: Option = None; + + // Emit event for frontend to show dialog + if let Some(app) = app { + let _ = app.emit( + "mtp-exclusive-access-error", + serde_json::json!({ + "deviceId": device_id, + "blockingProcess": blocking_process.clone() + }), + ); + } + + return Err(MtpConnectionError::ExclusiveAccess { + device_id: device_id.to_string(), + blocking_process, + }); + } + + // Map other errors + return Err(map_mtp_error(e, device_id)); + } + }; + + // Get device info + let mtp_info = device.device_info(); + let device_info = MtpDeviceInfo { + id: device_id.to_string(), + location_id, + vendor_id: 0, // Not available from device_info + product_id: 0, + manufacturer: if mtp_info.manufacturer.is_empty() { + None + } else { + Some(mtp_info.manufacturer.clone()) + }, + product: if mtp_info.model.is_empty() { + None + } else { + Some(mtp_info.model.clone()) + }, + serial_number: if mtp_info.serial_number.is_empty() { + None + } else { + Some(mtp_info.serial_number.clone()) + }, + }; + + debug!( + "Device opened successfully: {} {}", + mtp_info.manufacturer, mtp_info.model + ); + + // Check if device supports write operations (SendObjectInfo is required for uploads) + // PTP cameras often don't support this, making them effectively read-only + let device_supports_write = mtp_info.supports_operation(OperationCode::SendObjectInfo); + info!( + "Device '{}' write support: {} (operations: {:?})", + mtp_info.model, + device_supports_write, + mtp_info + .operations_supported + .iter() + .filter(|op| matches!( + op, + OperationCode::SendObjectInfo | OperationCode::SendObject | OperationCode::DeleteObject + )) + .collect::>() + ); + + // Get storage information + debug!("Fetching storage information..."); + let storages = match get_storages(&device, device_supports_write).await { + Ok(s) => s, + Err(e) => { + error!("Failed to get storages for {}: {:?}", device_id, e); + Vec::new() + } + }; + + let connected_info = ConnectedDeviceInfo { + device: device_info.clone(), + storages: storages.clone(), + }; + + // Wrap device in Arc for shared access + let device_arc = Arc::new(Mutex::new(device)); + + // Store in registry + { + let mut devices = self.devices.lock().await; + devices.insert( + device_id.to_string(), + DeviceEntry { + device: Arc::clone(&device_arc), + info: device_info, + storages, + path_cache: RwLock::new(HashMap::new()), + listing_cache: RwLock::new(HashMap::new()), + }, + ); + } + + // Register MTP volumes for each storage with the global VolumeManager + // This enables MTP browsing through the standard file listing pipeline + for storage in &connected_info.storages { + let volume_id = format!("{}:{}", device_id, storage.id); + let volume = Arc::new(MtpVolume::new(device_id, storage.id, &storage.name)); + get_volume_manager().register(&volume_id, volume); + debug!("Registered MTP volume: {} ({})", volume_id, storage.name); + } + + // Start the event loop for file watching (requires AppHandle) + if let Some(app) = app { + self.start_event_loop(device_id.to_string(), device_arc, app.clone()); + } + + // Emit connected event + if let Some(app) = app { + let _ = app.emit( + "mtp-device-connected", + serde_json::json!({ + "deviceId": device_id, + "storages": connected_info.storages + }), + ); + } + + info!( + "MTP device connected: {} ({} storages)", + device_id, + connected_info.storages.len() + ); + + Ok(connected_info) + } + + /// Disconnects from an MTP device. + /// + /// Closes the MTP session gracefully. + pub async fn disconnect(&self, device_id: &str, app: Option<&AppHandle>) -> Result<(), MtpConnectionError> { + info!("Disconnecting from MTP device: {}", device_id); + + // Stop the event loop first + self.stop_event_loop(device_id); + + // Remove from registry + let entry = { + let mut devices = self.devices.lock().await; + devices.remove(device_id) + }; + + let Some(entry) = entry else { + return Err(MtpConnectionError::NotConnected { + device_id: device_id.to_string(), + }); + }; + + // Unregister MTP volumes from the VolumeManager + for storage in &entry.storages { + let volume_id = format!("{}:{}", device_id, storage.id); + get_volume_manager().unregister(&volume_id); + debug!("Unregistered MTP volume: {}", volume_id); + } + + // The device will be closed when it's dropped. + // MtpDevice::close() takes ownership, but we have it in an Arc. + // Dropping the entry will drop the Arc, and if this is the last reference, + // the device will be closed (MtpDevice has a Drop impl that closes the session). + // We just drop the entry here - the device handle going out of scope handles cleanup. + drop(entry); + + // Emit disconnected event + if let Some(app) = app { + let _ = app.emit( + "mtp-device-disconnected", + serde_json::json!({ + "deviceId": device_id, + "reason": "user" + }), + ); + } + + info!("MTP device disconnected: {}", device_id); + Ok(()) + } + + // ======================================================================== + // Event Loop for File Watching + // ======================================================================== + + /// Starts the event polling loop for a connected device. + /// + /// This spawns a background task that polls for MTP device events and emits + /// `mtp-directory-changed` events to the frontend when files change on the device. + fn start_event_loop(&self, device_id: String, device: Arc>, app: AppHandle) { + let (shutdown_tx, _) = broadcast::channel(1); + + // Store shutdown sender + { + let mut shutdown_map = self.event_loop_shutdown.write().unwrap(); + shutdown_map.insert(device_id.clone(), shutdown_tx.clone()); + } + + // Clone for the spawned task + let device_id_clone = device_id.clone(); + + // Spawn the event loop task. It uses connection_manager() to access the debouncer + // since the debouncer is part of the global singleton. + tokio::spawn(async move { + let mut shutdown_rx = shutdown_tx.subscribe(); + + debug!("MTP event loop started for device: {}", device_id_clone); + + loop { + // Try to acquire the device lock with a short timeout to check for shutdown + let poll_result = tokio::select! { + biased; + + // Check for shutdown signal first + _ = shutdown_rx.recv() => { + debug!("MTP event loop shutting down (signal): {}", device_id_clone); + break; + } + + // Poll for next event (with timeout built into next_event) + result = async { + // Try to lock the device - use a timeout to prevent deadlocks + match tokio::time::timeout(Duration::from_secs(5), device.lock()).await { + Ok(guard) => { + // Poll for event + guard.next_event().await + } + Err(_) => { + // Timeout acquiring lock - device might be busy with another operation + // Return timeout to continue polling + Err(mtp_rs::Error::Timeout) + } + } + } => { + result + } + }; + + match poll_result { + Ok(event) => { + Self::handle_device_event(&device_id_clone, event, &app); + } + Err(mtp_rs::Error::Timeout) => { + // No event within timeout period - continue polling + // Add a small sleep to avoid tight loop when device is idle + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(mtp_rs::Error::Disconnected) => { + info!("MTP device disconnected (event loop): {}", device_id_clone); + // Device was unplugged - clean up state and emit event + // IMPORTANT: Call handle_device_disconnected to remove from devices registry + // so reconnection attempts don't fail with "already connected" + connection_manager() + .handle_device_disconnected(&device_id_clone, Some(&app)) + .await; + break; + } + Err(e) => { + // Log other errors but continue polling - device might recover + warn!("MTP event error for {}: {:?}", device_id_clone, e); + // Sleep a bit before retrying to avoid tight error loop + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + } + + debug!("MTP event loop exited for device: {}", device_id_clone); + }); + + debug!("MTP event loop spawned for device: {}", device_id); + } + + /// Stops the event loop for a device. + fn stop_event_loop(&self, device_id: &str) { + // Remove and signal shutdown + if let Some(tx) = self.event_loop_shutdown.write().unwrap().remove(device_id) { + let _ = tx.send(()); // Signal shutdown - ignore error if receiver is gone + debug!("MTP event loop shutdown signaled for device: {}", device_id); + } + + // Clear debouncer state for this device + self.event_debouncer.clear(device_id); + } + + /// Handles a device event and emits to frontend if appropriate. + fn handle_device_event(device_id: &str, event: mtp_rs::mtp::DeviceEvent, app: &AppHandle) { + use mtp_rs::mtp::DeviceEvent; + + match event { + DeviceEvent::ObjectAdded { handle } => { + debug!("MTP object added: {:?} on {}", handle, device_id); + Self::emit_directory_changed(device_id, app); + } + DeviceEvent::ObjectRemoved { handle } => { + debug!("MTP object removed: {:?} on {}", handle, device_id); + Self::emit_directory_changed(device_id, app); + } + DeviceEvent::ObjectInfoChanged { handle } => { + debug!("MTP object changed: {:?} on {}", handle, device_id); + Self::emit_directory_changed(device_id, app); + } + DeviceEvent::StorageInfoChanged { storage_id } => { + debug!("MTP storage info changed: {:?} on {}", storage_id, device_id); + // Could emit a storage space update event in the future + } + DeviceEvent::StoreAdded { storage_id } => { + info!("MTP storage added: {:?} on {}", storage_id, device_id); + // Could emit a storage list update event in the future + } + DeviceEvent::StoreRemoved { storage_id } => { + info!("MTP storage removed: {:?} on {}", storage_id, device_id); + // Could emit a storage list update event in the future + } + DeviceEvent::DeviceInfoChanged => { + debug!("MTP device info changed: {}", device_id); + } + DeviceEvent::DeviceReset => { + warn!("MTP device reset: {}", device_id); + } + DeviceEvent::Unknown { code, params } => { + debug!("MTP unknown event {:04x} {:?} on {}", code, params, device_id); + } + } + } + + /// Emits directory-diff events for all affected listings (with debouncing). + /// + /// Uses the unified diff system shared with local file watching, providing + /// smooth incremental UI updates without full directory reloads. + fn emit_directory_changed(device_id: &str, app: &AppHandle) { + // Check debouncer via the global connection manager + if !connection_manager().event_debouncer.should_emit(device_id) { + debug!( + "MTP event loop: directory change DEBOUNCED for device={} (within {}ms window)", + device_id, EVENT_DEBOUNCE_MS + ); + return; + } + + // Find all listings for this device (volume IDs like "mtp-123:65537") + let listings = get_listings_by_volume_prefix(device_id); + if listings.is_empty() { + debug!( + "MTP event loop: no active listings for device={}, skipping diff", + device_id + ); + return; + } + + debug!( + "MTP event loop: found {} listings for device={}, computing diffs", + listings.len(), + device_id + ); + + // Clone what we need for the spawned task + let device_id_owned = device_id.to_string(); + let app_clone = app.clone(); + + // Spawn task to re-read directories and compute diffs + tokio::spawn(async move { + Self::compute_and_emit_diffs(&device_id_owned, listings, &app_clone).await; + }); + } + + /// Re-reads MTP directories and emits directory-diff events. + /// + /// For each listing belonging to this device: + /// 1. Extract the storage_id and path from the volume_id and listing path + /// 2. Re-read the directory from the MTP device + /// 3. Compute the diff between old and new entries + /// 4. Update LISTING_CACHE with new entries + /// 5. Emit directory-diff event + async fn compute_and_emit_diffs( + device_id: &str, + listings: Vec<(String, String, PathBuf, Vec)>, + app: &AppHandle, + ) { + // Track sequence numbers per listing (simple counter, increments each diff) + static SEQUENCE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + for (listing_id, volume_id, path, old_entries) in listings { + // Extract storage_id from volume_id (format: "mtp-{device}:{storage}") + let Some(storage_id) = volume_id.split(':').nth(1).and_then(|s| s.parse::().ok()) else { + warn!( + "MTP diff: could not parse storage_id from volume_id={}, skipping", + volume_id + ); + continue; + }; + + // Convert path to MTP inner path + let mtp_path = path.to_string_lossy(); + let mtp_path = if mtp_path.starts_with("mtp://") { + // Parse: mtp://mtp-0-1/65537/DCIM/Camera -> DCIM/Camera + let without_scheme = mtp_path.strip_prefix("mtp://").unwrap_or(&mtp_path); + let parts: Vec<&str> = without_scheme.splitn(3, '/').collect(); + if parts.len() >= 3 { + parts[2].to_string() + } else { + String::new() + } + } else if mtp_path == "/" || mtp_path.is_empty() { + String::new() + } else { + mtp_path.strip_prefix('/').unwrap_or(&mtp_path).to_string() + }; + + // Invalidate the MTP listing cache before re-reading so we get fresh data + // (otherwise we'd compare stale cached data with itself and detect no changes) + connection_manager() + .invalidate_listing_cache(device_id, storage_id, &path) + .await; + + // Re-read the directory from the MTP device + let new_entries = match connection_manager() + .list_directory(device_id, storage_id, &mtp_path) + .await + { + Ok(entries) => entries, + Err(e) => { + debug!("MTP diff: failed to re-read directory {}: {:?}, skipping", mtp_path, e); + continue; + } + }; + + // Compute diff + let changes = compute_diff(&old_entries, &new_entries); + if changes.is_empty() { + debug!( + "MTP diff: no changes detected for listing_id={}, path={}", + listing_id, mtp_path + ); + continue; + } + + // Update LISTING_CACHE with new entries + update_listing_entries(&listing_id, new_entries); + + // Get sequence number + let sequence = SEQUENCE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + + // Emit directory-diff event (same format as local watcher) + let diff = DirectoryDiff { + listing_id: listing_id.clone(), + sequence, + changes, + }; + + if let Err(e) = app.emit("directory-diff", &diff) { + warn!("MTP diff: failed to emit event: {}", e); + } else { + info!( + "MTP diff: emitted directory-diff for listing_id={}, sequence={}", + listing_id, sequence + ); + } + } + } + + /// Gets information about a connected device. + pub async fn get_device_info(&self, device_id: &str) -> Option { + let devices = self.devices.lock().await; + devices.get(device_id).map(|entry| ConnectedDeviceInfo { + device: entry.info.clone(), + storages: entry.storages.clone(), + }) + } + + /// Checks if a device is connected. + #[allow(dead_code, reason = "Will be used in Phase 3+ for file browsing")] + pub async fn is_connected(&self, device_id: &str) -> bool { + let devices = self.devices.lock().await; + devices.contains_key(device_id) + } + + /// Returns a list of all connected device IDs. + #[allow(dead_code, reason = "Will be used in Phase 5 for multi-device management")] + pub async fn connected_device_ids(&self) -> Vec { + let devices = self.devices.lock().await; + devices.keys().cloned().collect() + } + + /// Lists the contents of a directory on an MTP device. + /// + /// # Arguments + /// + /// * `device_id` - The connected device ID + /// * `storage_id` - The storage ID within the device + /// * `path` - Virtual path to list (for example, "/" or "/DCIM") + /// + /// # Returns + /// + /// A vector of FileEntry objects suitable for the file browser. + pub async fn list_directory( + &self, + device_id: &str, + storage_id: u32, + path: &str, + ) -> Result, MtpConnectionError> { + use std::sync::atomic::Ordering; + + // Generate unique request ID for tracing this call + let request_id = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed); + let call_start = Instant::now(); + + // Track concurrent calls + let concurrent_before = CONCURRENT_LIST_CALLS.fetch_add(1, Ordering::Relaxed); + debug!( + "MTP list_directory [req#{}]: START device={}, storage={}, path={}, concurrent_calls={}", + request_id, + device_id, + storage_id, + path, + concurrent_before + 1 + ); + + // Wrap the entire operation to ensure we decrement the counter on exit + let result = self + .list_directory_inner(request_id, device_id, storage_id, path, call_start) + .await; + + let concurrent_after = CONCURRENT_LIST_CALLS.fetch_sub(1, Ordering::Relaxed); + debug!( + "MTP list_directory [req#{}]: END total_time={:?}, concurrent_calls_remaining={}", + request_id, + call_start.elapsed(), + concurrent_after - 1 + ); + + result + } + + /// Inner implementation of list_directory with detailed phase logging. + async fn list_directory_inner( + &self, + request_id: u64, + device_id: &str, + storage_id: u32, + path: &str, + call_start: Instant, + ) -> Result, MtpConnectionError> { + // Normalize the path for building child paths + let parent_path = normalize_mtp_path(path); + + // Check listing cache first + let cache_check_start = Instant::now(); + { + let devices = self.devices.lock().await; + if let Some(entry) = devices.get(device_id) + && let Ok(cache_map) = entry.listing_cache.read() + && let Some(storage_cache) = cache_map.get(&storage_id) + && let Some(cached) = storage_cache.listings.get(&parent_path) + { + // Check if cache is still valid (within TTL) + if cached.cached_at.elapsed().as_secs() < LISTING_CACHE_TTL_SECS { + debug!( + "MTP list_directory [req#{}]: cache HIT, returning {} entries, cache_check_time={:?}, elapsed_since_start={:?}", + request_id, + cached.entries.len(), + cache_check_start.elapsed(), + call_start.elapsed() + ); + return Ok(cached.entries.clone()); + } else { + debug!( + "MTP list_directory [req#{}]: cache STALE (age={}s > TTL={}s)", + request_id, + cached.cached_at.elapsed().as_secs(), + LISTING_CACHE_TTL_SECS + ); + } + } else { + debug!("MTP list_directory [req#{}]: cache MISS for path={}", request_id, path); + } + } + debug!( + "MTP list_directory [req#{}]: cache check complete, time={:?}", + request_id, + cache_check_start.elapsed() + ); + + // Get the device and resolve path to handle + let path_resolve_start = Instant::now(); + debug!( + "MTP list_directory [req#{}]: acquiring devices registry lock...", + request_id + ); + let (device_arc, parent_handle) = { + let devices = self.devices.lock().await; + debug!( + "MTP list_directory [req#{}]: got devices registry lock in {:?}, looking up device...", + request_id, + path_resolve_start.elapsed() + ); + let entry = devices.get(device_id).ok_or_else(|| MtpConnectionError::NotConnected { + device_id: device_id.to_string(), + })?; + + // Resolve path to parent handle + debug!("MTP list_directory [req#{}]: resolving path to handle...", request_id); + let parent_handle = self.resolve_path_to_handle(entry, storage_id, path)?; + debug!( + "MTP list_directory [req#{}]: resolved to handle {:?} in {:?}", + request_id, + parent_handle, + path_resolve_start.elapsed() + ); + + (Arc::clone(&entry.device), parent_handle) + }; + debug!( + "MTP list_directory [req#{}]: path resolution complete, total_time={:?}", + request_id, + path_resolve_start.elapsed() + ); + + // List directory contents (async operation) + let device_lock_start = Instant::now(); + debug!( + "MTP list_directory [req#{}]: waiting to acquire device USB lock...", + request_id + ); + let device = + acquire_device_lock(&device_arc, device_id, &format!("list_directory[req#{}]", request_id)).await?; + let device_lock_acquired_at = Instant::now(); + debug!( + "MTP list_directory [req#{}]: acquired device USB lock after {:?} wait, getting storage...", + request_id, + device_lock_start.elapsed() + ); + + // Get the storage object + let usb_io_start = Instant::now(); + let storage = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + device.storage(StorageId(storage_id)), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + debug!( + "MTP list_directory [req#{}]: got storage object in {:?}", + request_id, + usb_io_start.elapsed() + ); + + // Use list_objects which returns Vec directly + let parent_opt = if parent_handle == ObjectHandle::ROOT { + None + } else { + Some(parent_handle) + }; + + let list_objects_start = Instant::now(); + debug!( + "MTP list_directory [req#{}]: calling list_objects (parent={:?})...", + request_id, parent_opt + ); + let object_infos = + match tokio::time::timeout(Duration::from_secs(MTP_TIMEOUT_SECS), storage.list_objects(parent_opt)).await { + Ok(Ok(infos)) => infos, + Ok(Err(e)) => { + let mapped_err = map_mtp_error(e, device_id); + error!( + "MTP list_directory [req#{}]: list_objects failed after {:?}: {:?}", + request_id, + list_objects_start.elapsed(), + mapped_err + ); + return Err(mapped_err); + } + Err(_) => { + error!( + "MTP list_directory [req#{}]: list_objects timed out after {:?}", + request_id, + list_objects_start.elapsed() + ); + return Err(MtpConnectionError::Timeout { + device_id: device_id.to_string(), + }); + } + }; + + debug!( + "MTP list_directory [req#{}]: list_objects returned {} objects in {:?}, total USB I/O time={:?}", + request_id, + object_infos.len(), + list_objects_start.elapsed(), + usb_io_start.elapsed() + ); + + let mut entries = Vec::with_capacity(object_infos.len()); + let mut cache_updates: Vec<(PathBuf, ObjectHandle)> = Vec::new(); + + for info in object_infos { + let is_dir = info.format == mtp_rs::ptp::ObjectFormatCode::Association; + let child_path = parent_path.join(&info.filename); + + // Queue cache update + cache_updates.push((child_path.clone(), info.handle)); + + // Convert MTP timestamps + let modified_at = info.modified.map(convert_mtp_datetime); + let created_at = info.created.map(convert_mtp_datetime); + + entries.push(FileEntry { + name: info.filename.clone(), + path: child_path.to_string_lossy().to_string(), + is_directory: is_dir, + is_symlink: false, + size: if is_dir { None } else { Some(info.size) }, + modified_at, + created_at, + added_at: None, + opened_at: None, + permissions: if is_dir { 0o755 } else { 0o644 }, + owner: String::new(), + group: String::new(), + icon_id: get_mtp_icon_id(is_dir, &info.filename), + extended_metadata_loaded: true, + }); + } + + // Release device lock before updating cache + drop(storage); + drop(device); + let lock_held_duration = device_lock_acquired_at.elapsed(); + debug!( + "MTP list_directory [req#{}]: released device USB lock after holding for {:?}", + request_id, lock_held_duration + ); + + // Update path cache + let cache_update_start = Instant::now(); + { + let devices = self.devices.lock().await; + if let Some(entry) = devices.get(device_id) + && let Ok(mut cache_map) = entry.path_cache.write() + { + let storage_cache = cache_map.entry(storage_id).or_default(); + for (path, handle) in cache_updates { + storage_cache.path_to_handle.insert(path, handle); + } + } + } + + // Sort: directories first, then files, both alphabetically + entries.sort_by(|a, b| match (a.is_directory, b.is_directory) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + }); + + // Store in listing cache + { + let devices = self.devices.lock().await; + if let Some(entry) = devices.get(device_id) + && let Ok(mut cache_map) = entry.listing_cache.write() + { + let storage_cache = cache_map.entry(storage_id).or_default(); + storage_cache.listings.insert( + parent_path, + CachedListing { + entries: entries.clone(), + cached_at: Instant::now(), + }, + ); + } + } + debug!( + "MTP list_directory [req#{}]: cache update complete in {:?}", + request_id, + cache_update_start.elapsed() + ); + + debug!( + "MTP list_directory [req#{}]: returning {} entries, total_time={:?}", + request_id, + entries.len(), + call_start.elapsed() + ); + Ok(entries) + } + + /// Invalidates the listing cache for a specific directory. + /// Call this after any operation that modifies the directory contents. + async fn invalidate_listing_cache(&self, device_id: &str, storage_id: u32, dir_path: &Path) { + let devices = self.devices.lock().await; + if let Some(entry) = devices.get(device_id) + && let Ok(mut cache_map) = entry.listing_cache.write() + && let Some(storage_cache) = cache_map.get_mut(&storage_id) + && storage_cache.listings.remove(dir_path).is_some() + { + debug!( + "Invalidated listing cache for {} on device {}", + dir_path.display(), + device_id + ); + } + } + + /// Resolves a virtual path to an MTP object handle. + fn resolve_path_to_handle( + &self, + entry: &DeviceEntry, + storage_id: u32, + path: &str, + ) -> Result { + let path = normalize_mtp_path(path); + + // Root is always ObjectHandle::ROOT + if path.as_os_str() == "/" || path.as_os_str().is_empty() { + return Ok(ObjectHandle::ROOT); + } + + // Check cache + if let Ok(cache_map) = entry.path_cache.read() + && let Some(storage_cache) = cache_map.get(&storage_id) + && let Some(handle) = storage_cache.path_to_handle.get(&path) + { + return Ok(*handle); + } + + // Path not in cache - we need to traverse + // For Phase 3, we'll only support navigating to paths that have been listed + // (the cache is populated as directories are browsed) + Err(MtpConnectionError::Other { + device_id: entry.info.id.clone(), + message: format!( + "Path not in cache: {}. Navigate through parent directories first.", + path.display() + ), + }) + } + + /// Handles a device disconnection (called when we detect the device was unplugged). + /// + /// This cleans up the devices registry and emits a disconnection event. + /// Called from the event loop when MTP reports a disconnect, ensuring that + /// subsequent reconnection attempts don't fail with "already connected". + pub async fn handle_device_disconnected(&self, device_id: &str, app: Option<&AppHandle>) { + debug!( + "handle_device_disconnected: cleaning up device {} from registry", + device_id + ); + + let removed = { + let mut devices = self.devices.lock().await; + let was_present = devices.remove(device_id).is_some(); + debug!( + "handle_device_disconnected: device {} was {} in registry, {} devices remaining", + device_id, + if was_present { "found" } else { "NOT found" }, + devices.len() + ); + was_present + }; + + // Stop the event loop for this device + self.stop_event_loop(device_id); + + if removed { + info!("MTP device disconnected and removed from registry: {}", device_id); + + if let Some(app) = app { + let _ = app.emit( + "mtp-device-disconnected", + serde_json::json!({ + "deviceId": device_id, + "reason": "disconnected" + }), + ); + debug!( + "handle_device_disconnected: emitted mtp-device-disconnected event for {}", + device_id + ); + } + } else { + debug!( + "handle_device_disconnected: device {} was not in registry (already cleaned up?)", + device_id + ); + } + } + + // ======================================================================== + // Phase 4: File Operations + // ======================================================================== + + /// Downloads a file from the MTP device to a local path. + /// + /// # Arguments + /// + /// * `device_id` - The connected device ID + /// * `storage_id` - The storage ID within the device + /// * `object_path` - Virtual path on the device (for example, "/DCIM/photo.jpg") + /// * `local_dest` - Local destination path + /// * `app` - Optional app handle for emitting progress events + /// * `operation_id` - Unique operation ID for progress tracking + pub async fn download_file( + &self, + device_id: &str, + storage_id: u32, + object_path: &str, + local_dest: &Path, + app: Option<&AppHandle>, + operation_id: &str, + ) -> Result { + debug!( + "MTP download_file: device={}, storage={}, path={}, dest={}", + device_id, + storage_id, + object_path, + local_dest.display() + ); + + // Get the device and resolve path to handle + let (device_arc, object_handle) = { + let devices = self.devices.lock().await; + let entry = devices.get(device_id).ok_or_else(|| MtpConnectionError::NotConnected { + device_id: device_id.to_string(), + })?; + + // Resolve path to handle + let handle = self.resolve_path_to_handle(entry, storage_id, object_path)?; + (Arc::clone(&entry.device), handle) + }; + + let device = acquire_device_lock(&device_arc, device_id, "download_file").await?; + + // Get the storage + let storage = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + device.storage(StorageId(storage_id)), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + // Get object info to determine size + let object_info = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + storage.get_object_info(object_handle), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + let total_size = object_info.size; + let filename = object_info.filename.clone(); + + // Emit initial progress + if let Some(app) = app { + let _ = app.emit( + "mtp-transfer-progress", + MtpTransferProgress { + operation_id: operation_id.to_string(), + device_id: device_id.to_string(), + transfer_type: MtpTransferType::Download, + current_file: filename.clone(), + bytes_done: 0, + bytes_total: total_size, + }, + ); + } + + // Download the file as a stream (holds session lock until complete) + let mut download = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS * 10), // Longer timeout for large files + storage.download_stream(object_handle), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + // Create the local file + let mut file = tokio::fs::File::create(local_dest) + .await + .map_err(|e| MtpConnectionError::Other { + device_id: device_id.to_string(), + message: format!("Failed to create local file: {}", e), + })?; + + // Write chunks to file (must complete before releasing device lock) + let mut bytes_written = 0u64; + while let Some(chunk_result) = download.next_chunk().await { + let chunk = chunk_result.map_err(|e| MtpConnectionError::Other { + device_id: device_id.to_string(), + message: format!("Download error: {}", e), + })?; + + file.write_all(&chunk).await.map_err(|e| MtpConnectionError::Other { + device_id: device_id.to_string(), + message: format!("Failed to write local file: {}", e), + })?; + + bytes_written += chunk.len() as u64; + } + + // Release device lock after download completes + drop(storage); + drop(device); + + file.flush().await.map_err(|e| MtpConnectionError::Other { + device_id: device_id.to_string(), + message: format!("Failed to flush local file: {}", e), + })?; + + // Emit completion progress + if let Some(app) = app { + let _ = app.emit( + "mtp-transfer-progress", + MtpTransferProgress { + operation_id: operation_id.to_string(), + device_id: device_id.to_string(), + transfer_type: MtpTransferType::Download, + current_file: filename, + bytes_done: bytes_written, + bytes_total: total_size, + }, + ); + } + + info!( + "MTP download complete: {} bytes to {}", + bytes_written, + local_dest.display() + ); + + Ok(MtpOperationResult { + operation_id: operation_id.to_string(), + files_processed: 1, + bytes_transferred: bytes_written, + }) + } + + /// Uploads a file from the local filesystem to the MTP device. + /// + /// # Arguments + /// + /// * `device_id` - The connected device ID + /// * `storage_id` - The storage ID within the device + /// * `local_path` - Local file path to upload + /// * `dest_folder` - Destination folder path on device (for example, "/DCIM") + /// * `app` - Optional app handle for emitting progress events + /// * `operation_id` - Unique operation ID for progress tracking + pub async fn upload_file( + &self, + device_id: &str, + storage_id: u32, + local_path: &Path, + dest_folder: &str, + app: Option<&AppHandle>, + operation_id: &str, + ) -> Result { + debug!( + "MTP upload_file: device={}, storage={}, local={}, dest={}", + device_id, + storage_id, + local_path.display(), + dest_folder + ); + + // Get file metadata + let metadata = tokio::fs::metadata(local_path) + .await + .map_err(|e| MtpConnectionError::Other { + device_id: device_id.to_string(), + message: format!("Failed to read local file metadata: {}", e), + })?; + + if metadata.is_dir() { + return Err(MtpConnectionError::Other { + device_id: device_id.to_string(), + message: "Cannot upload directories with upload_file. Use create_folder instead.".to_string(), + }); + } + + let file_size = metadata.len(); + let filename = local_path + .file_name() + .ok_or_else(|| MtpConnectionError::Other { + device_id: device_id.to_string(), + message: "Invalid file path".to_string(), + })? + .to_string_lossy() + .to_string(); + + // Read the file data + let data = tokio::fs::read(local_path) + .await + .map_err(|e| MtpConnectionError::Other { + device_id: device_id.to_string(), + message: format!("Failed to read local file: {}", e), + })?; + + // Get device and resolve parent folder + let (device_arc, parent_handle) = { + let devices = self.devices.lock().await; + let entry = devices.get(device_id).ok_or_else(|| MtpConnectionError::NotConnected { + device_id: device_id.to_string(), + })?; + + let parent = self.resolve_path_to_handle(entry, storage_id, dest_folder)?; + (Arc::clone(&entry.device), parent) + }; + + // Emit initial progress + if let Some(app) = app { + let _ = app.emit( + "mtp-transfer-progress", + MtpTransferProgress { + operation_id: operation_id.to_string(), + device_id: device_id.to_string(), + transfer_type: MtpTransferType::Upload, + current_file: filename.clone(), + bytes_done: 0, + bytes_total: file_size, + }, + ); + } + + let device = acquire_device_lock(&device_arc, device_id, "upload_file").await?; + + // Get the storage + let storage = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + device.storage(StorageId(storage_id)), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + // Create object info for the upload (format is auto-detected from filename) + let object_info = NewObjectInfo::file(&filename, file_size); + + // Upload the file - create a stream from the data + let parent_opt = if parent_handle == ObjectHandle::ROOT { + None + } else { + Some(parent_handle) + }; + + // Create a single-chunk stream from the data + // Using iter instead of once because iter's items are ready, making it Unpin + let data_stream = futures_util::stream::iter(vec![Ok::<_, std::io::Error>(bytes::Bytes::from(data))]); + + let new_handle = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS * 10), // Longer timeout for large files + storage.upload(parent_opt, object_info, data_stream), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + // Release device lock + drop(storage); + drop(device); + + // Build the new object path + let new_path = normalize_mtp_path(dest_folder).join(&filename); + let new_path_str = new_path.to_string_lossy().to_string(); + + // Update path cache + { + let devices = self.devices.lock().await; + if let Some(entry) = devices.get(device_id) + && let Ok(mut cache_map) = entry.path_cache.write() + { + let storage_cache = cache_map.entry(storage_id).or_default(); + storage_cache.path_to_handle.insert(new_path.clone(), new_handle); + } + } + + // Emit completion progress + if let Some(app) = app { + let _ = app.emit( + "mtp-transfer-progress", + MtpTransferProgress { + operation_id: operation_id.to_string(), + device_id: device_id.to_string(), + transfer_type: MtpTransferType::Upload, + current_file: filename.clone(), + bytes_done: file_size, + bytes_total: file_size, + }, + ); + } + + info!("MTP upload complete: {} -> {}", local_path.display(), new_path_str); + + // Invalidate the parent directory's listing cache + let dest_folder_path = normalize_mtp_path(dest_folder); + self.invalidate_listing_cache(device_id, storage_id, &dest_folder_path) + .await; + + Ok(MtpObjectInfo { + handle: new_handle.0, + name: filename, + path: new_path_str, + is_directory: false, + size: Some(file_size), + }) + } + + /// Deletes an object (file or folder) from the MTP device. + /// + /// For folders, this recursively deletes all contents first since MTP + /// requires folders to be empty before deletion. + /// + /// # Arguments + /// + /// * `device_id` - The connected device ID + /// * `storage_id` - The storage ID within the device + /// * `object_path` - Virtual path on the device + pub async fn delete_object( + &self, + device_id: &str, + storage_id: u32, + object_path: &str, + ) -> Result<(), MtpConnectionError> { + debug!( + "MTP delete_object: device={}, storage={}, path={}", + device_id, storage_id, object_path + ); + + // Get the device and resolve path to handle + let (device_arc, object_handle) = { + let devices = self.devices.lock().await; + let entry = devices.get(device_id).ok_or_else(|| MtpConnectionError::NotConnected { + device_id: device_id.to_string(), + })?; + + let handle = self.resolve_path_to_handle(entry, storage_id, object_path)?; + (Arc::clone(&entry.device), handle) + }; + + let device = acquire_device_lock(&device_arc, device_id, "delete_object").await?; + + // Get the storage + let storage = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + device.storage(StorageId(storage_id)), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + // Get object info to check if it's a directory + let object_info = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + storage.get_object_info(object_handle), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + let is_dir = object_info.format == mtp_rs::ptp::ObjectFormatCode::Association; + + if is_dir { + // For directories, we need to recursively delete contents first + let children = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + storage.list_objects(Some(object_handle)), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + drop(storage); + drop(device); + + // Recursively delete children + let parent_path = normalize_mtp_path(object_path); + for child_info in children { + let child_path = parent_path.join(&child_info.filename); + let child_path_str = child_path.to_string_lossy().to_string(); + + // Cache the child handle for the recursive call + { + let devices = self.devices.lock().await; + if let Some(entry) = devices.get(device_id) + && let Ok(mut cache_map) = entry.path_cache.write() + { + let storage_cache = cache_map.entry(storage_id).or_default(); + storage_cache + .path_to_handle + .insert(child_path.clone(), child_info.handle); + } + } + + // Use Box::pin for recursive async call + Box::pin(self.delete_object(device_id, storage_id, &child_path_str)).await?; + } + + // Re-acquire device and storage lock to delete the now-empty folder + let device = acquire_device_lock(&device_arc, device_id, "delete_object (empty folder)").await?; + let storage = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + device.storage(StorageId(storage_id)), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + tokio::time::timeout(Duration::from_secs(MTP_TIMEOUT_SECS), storage.delete(object_handle)) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + } else { + // For files, just delete directly + tokio::time::timeout(Duration::from_secs(MTP_TIMEOUT_SECS), storage.delete(object_handle)) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + } + + // Remove from path cache + let object_path_normalized = normalize_mtp_path(object_path); + { + let devices = self.devices.lock().await; + if let Some(entry) = devices.get(device_id) + && let Ok(mut cache_map) = entry.path_cache.write() + && let Some(storage_cache) = cache_map.get_mut(&storage_id) + { + storage_cache.path_to_handle.remove(&object_path_normalized); + } + } + + // Invalidate the parent directory's listing cache + if let Some(parent) = object_path_normalized.parent() { + self.invalidate_listing_cache(device_id, storage_id, parent).await; + } + + info!("MTP delete complete: {}", object_path); + Ok(()) + } + + /// Creates a new folder on the MTP device. + /// + /// # Arguments + /// + /// * `device_id` - The connected device ID + /// * `storage_id` - The storage ID within the device + /// * `parent_path` - Parent folder path (for example, "/DCIM") + /// * `folder_name` - Name of the new folder + pub async fn create_folder( + &self, + device_id: &str, + storage_id: u32, + parent_path: &str, + folder_name: &str, + ) -> Result { + debug!( + "MTP create_folder: device={}, storage={}, parent={}, name={}", + device_id, storage_id, parent_path, folder_name + ); + + // Get device and resolve parent folder + let (device_arc, parent_handle) = { + let devices = self.devices.lock().await; + let entry = devices.get(device_id).ok_or_else(|| MtpConnectionError::NotConnected { + device_id: device_id.to_string(), + })?; + + let parent = self.resolve_path_to_handle(entry, storage_id, parent_path)?; + (Arc::clone(&entry.device), parent) + }; + + let device = acquire_device_lock(&device_arc, device_id, "create_folder").await?; + + // Get the storage + let storage = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + device.storage(StorageId(storage_id)), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + // Create the folder + let parent_opt = if parent_handle == ObjectHandle::ROOT { + None + } else { + Some(parent_handle) + }; + + let new_handle = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + storage.create_folder(parent_opt, folder_name), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + // Release device lock + drop(storage); + drop(device); + + // Build the new folder path + let new_path = normalize_mtp_path(parent_path).join(folder_name); + let new_path_str = new_path.to_string_lossy().to_string(); + + // Update path cache + { + let devices = self.devices.lock().await; + if let Some(entry) = devices.get(device_id) + && let Ok(mut cache_map) = entry.path_cache.write() + { + let storage_cache = cache_map.entry(storage_id).or_default(); + storage_cache.path_to_handle.insert(new_path.clone(), new_handle); + } + } + + // Invalidate the parent directory's listing cache + let parent_path_normalized = normalize_mtp_path(parent_path); + self.invalidate_listing_cache(device_id, storage_id, &parent_path_normalized) + .await; + + info!("MTP folder created: {}", new_path_str); + + Ok(MtpObjectInfo { + handle: new_handle.0, + name: folder_name.to_string(), + path: new_path_str, + is_directory: true, + size: None, + }) + } + + /// Renames an object on the MTP device. + /// + /// # Arguments + /// + /// * `device_id` - The connected device ID + /// * `storage_id` - The storage ID within the device + /// * `object_path` - Current path of the object + /// * `new_name` - New name for the object + pub async fn rename_object( + &self, + device_id: &str, + storage_id: u32, + object_path: &str, + new_name: &str, + ) -> Result { + debug!( + "MTP rename_object: device={}, storage={}, path={}, new_name={}", + device_id, storage_id, object_path, new_name + ); + + // Get device and resolve object handle + let (device_arc, object_handle) = { + let devices = self.devices.lock().await; + let entry = devices.get(device_id).ok_or_else(|| MtpConnectionError::NotConnected { + device_id: device_id.to_string(), + })?; + + let handle = self.resolve_path_to_handle(entry, storage_id, object_path)?; + (Arc::clone(&entry.device), handle) + }; + + let device = acquire_device_lock(&device_arc, device_id, "rename_object").await?; + + // Get the storage + let storage = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + device.storage(StorageId(storage_id)), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + // Get object info to determine if it's a directory + let object_info = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + storage.get_object_info(object_handle), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + let is_dir = object_info.format == mtp_rs::ptp::ObjectFormatCode::Association; + let old_size = object_info.size; + + // Set the new filename using storage.rename() + tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + storage.rename(object_handle, new_name), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + // Release device and storage lock + drop(storage); + drop(device); + + // Update path cache + let old_path = normalize_mtp_path(object_path); + let parent = old_path.parent().unwrap_or(Path::new("/")); + let new_path = parent.join(new_name); + let new_path_str = new_path.to_string_lossy().to_string(); + + { + let devices = self.devices.lock().await; + if let Some(entry) = devices.get(device_id) + && let Ok(mut cache_map) = entry.path_cache.write() + && let Some(storage_cache) = cache_map.get_mut(&storage_id) + { + storage_cache.path_to_handle.remove(&old_path); + storage_cache.path_to_handle.insert(new_path.clone(), object_handle); + } + } + + // Invalidate the parent directory's listing cache (rename affects the parent listing) + self.invalidate_listing_cache(device_id, storage_id, parent).await; + + info!("MTP rename complete: {} -> {}", object_path, new_path_str); + + Ok(MtpObjectInfo { + handle: object_handle.0, + name: new_name.to_string(), + path: new_path_str, + is_directory: is_dir, + size: if is_dir { None } else { Some(old_size) }, + }) + } + + // ======================================================================== + // Phase 5: Copy/Export Operations + // ======================================================================== + + /// Scans an MTP path recursively to get statistics for a copy operation. + /// + /// # Arguments + /// + /// * `device_id` - The connected device ID + /// * `storage_id` - The storage ID within the device + /// * `path` - Virtual path on the device to scan + /// + /// # Returns + /// + /// Statistics including file count, directory count, and total bytes. + pub async fn scan_for_copy( + &self, + device_id: &str, + storage_id: u32, + path: &str, + ) -> Result { + debug!( + "MTP scan_for_copy: device={}, storage={}, path={}", + device_id, storage_id, path + ); + + // Try to list the directory - if it fails or returns empty, it might be a file + let entries = match self.list_directory(device_id, storage_id, path).await { + Ok(entries) => entries, + Err(e) => { + // list_directory failed - this might be because path is a file, not a directory. + // Try to check by listing the parent directory. + debug!( + "MTP scan_for_copy: list_directory failed for '{}', checking if it's a file: {:?}", + path, e + ); + if let Some(result) = self.try_scan_as_file(device_id, storage_id, path).await { + return Ok(result); + } + // Not a file either, propagate the original error + return Err(e); + } + }; + + let mut file_count = 0usize; + let mut dir_count = 0usize; + let mut total_bytes = 0u64; + + // If entries is empty, it might be an empty directory OR a file (some MTP devices + // return empty for files instead of an error) + if entries.is_empty() { + if let Some(result) = self.try_scan_as_file(device_id, storage_id, path).await { + return Ok(result); + } + // Empty directory + return Ok(CopyScanResult { + file_count: 0, + dir_count: 1, + total_bytes: 0, + }); + } + + // Process entries recursively + for entry in &entries { + if entry.is_directory { + dir_count += 1; + // Recursively scan subdirectory + let child_result = Box::pin(self.scan_for_copy(device_id, storage_id, &entry.path)).await?; + file_count += child_result.file_count; + dir_count += child_result.dir_count; + total_bytes += child_result.total_bytes; + } else { + file_count += 1; + total_bytes += entry.size.unwrap_or(0); + } + } + + debug!( + "MTP scan_for_copy: {} files, {} dirs, {} bytes for {}", + file_count, dir_count, total_bytes, path + ); + + Ok(CopyScanResult { + file_count, + dir_count, + total_bytes, + }) + } + + /// Helper to check if a path is a file by listing its parent directory. + /// Returns Some(CopyScanResult) if path is a file, None otherwise. + async fn try_scan_as_file(&self, device_id: &str, storage_id: u32, path: &str) -> Option { + let path_buf = normalize_mtp_path(path); + let parent = path_buf.parent()?; + let name = path_buf.file_name()?.to_str()?; + + let parent_entries = self + .list_directory(device_id, storage_id, &parent.to_string_lossy()) + .await + .ok()?; + + let entry = parent_entries.iter().find(|e| e.name == name)?; + + if entry.is_directory { + // It's a directory, not a file - let caller handle it + return None; + } + + debug!( + "MTP scan_for_copy: path '{}' is a file with size {}", + path, + entry.size.unwrap_or(0) + ); + + Some(CopyScanResult { + file_count: 1, + dir_count: 0, + total_bytes: entry.size.unwrap_or(0), + }) + } + + /// Downloads a file or directory recursively from the MTP device to a local path. + /// + /// # Arguments + /// + /// * `device_id` - The connected device ID + /// * `storage_id` - The storage ID within the device + /// * `object_path` - Virtual path on the device to download + /// * `local_dest` - Local destination path + /// + /// # Returns + /// + /// Total bytes transferred. + pub async fn download_recursive( + &self, + device_id: &str, + storage_id: u32, + object_path: &str, + local_dest: &Path, + ) -> Result { + debug!( + "MTP download_recursive: device={}, storage={}, path={}, dest={}", + device_id, + storage_id, + object_path, + local_dest.display() + ); + + // Try to list the path as a directory first + let entries = self.list_directory(device_id, storage_id, object_path).await; + + match entries { + Ok(entries) if !entries.is_empty() => { + // It's a directory with contents - create local directory and download contents + debug!( + "MTP download_recursive: {} is a directory with {} entries", + object_path, + entries.len() + ); + + tokio::fs::create_dir_all(local_dest) + .await + .map_err(|e| MtpConnectionError::Other { + device_id: device_id.to_string(), + message: format!("Failed to create local directory: {}", e), + })?; + + let mut total_bytes = 0u64; + for entry in entries { + let child_dest = local_dest.join(&entry.name); + let bytes = + Box::pin(self.download_recursive(device_id, storage_id, &entry.path, &child_dest)).await?; + total_bytes += bytes; + } + + debug!( + "MTP download_recursive: directory {} complete, {} bytes", + object_path, total_bytes + ); + Ok(total_bytes) + } + Ok(_) => { + // Empty directory or file - check if it's a file by checking parent listing + let path_buf = normalize_mtp_path(object_path); + let is_file = if let Some(parent) = path_buf.parent() { + let parent_str = parent.to_string_lossy(); + if let Ok(parent_entries) = self.list_directory(device_id, storage_id, &parent_str).await { + if let Some(name) = path_buf.file_name().and_then(|n| n.to_str()) { + parent_entries + .iter() + .find(|e| e.name == name) + .is_some_and(|e| !e.is_directory) + } else { + false + } + } else { + false + } + } else { + false + }; + + if is_file { + // It's a file - download it + debug!("MTP download_recursive: {} is a file, downloading", object_path); + let operation_id = format!("download-{}", uuid::Uuid::new_v4()); + let result = self + .download_file(device_id, storage_id, object_path, local_dest, None, &operation_id) + .await?; + Ok(result.bytes_transferred) + } else { + // Empty directory - create it + debug!("MTP download_recursive: {} is an empty directory", object_path); + tokio::fs::create_dir_all(local_dest) + .await + .map_err(|e| MtpConnectionError::Other { + device_id: device_id.to_string(), + message: format!("Failed to create local directory: {}", e), + })?; + Ok(0) + } + } + Err(e) => { + // list_directory failed - might be a file (MTP returns ObjectNotFound when + // trying to list children of a file). Try to check by listing the parent. + debug!( + "MTP download_recursive: list failed for '{}', checking if it's a file: {:?}", + object_path, e + ); + + let path_buf = normalize_mtp_path(object_path); + let is_file = if let Some(parent) = path_buf.parent() { + let parent_str = parent.to_string_lossy(); + if let Ok(parent_entries) = self.list_directory(device_id, storage_id, &parent_str).await { + if let Some(name) = path_buf.file_name().and_then(|n| n.to_str()) { + parent_entries + .iter() + .find(|e| e.name == name) + .is_some_and(|entry| !entry.is_directory) + } else { + false + } + } else { + false + } + } else { + false + }; + + if is_file { + debug!("MTP download_recursive: {} is a file, downloading", object_path); + let operation_id = format!("download-{}", uuid::Uuid::new_v4()); + let result = self + .download_file(device_id, storage_id, object_path, local_dest, None, &operation_id) + .await?; + Ok(result.bytes_transferred) + } else { + // Not a file, propagate the original error + Err(e) + } + } + } + } + + /// Uploads a file or directory from local filesystem to MTP device recursively. + /// + /// If the source is a directory, creates the directory on the device and + /// recursively uploads all contents. + /// + /// # Arguments + /// + /// * `device_id` - The connected device ID + /// * `storage_id` - The storage ID within the device + /// * `local_source` - Local source path (file or directory) + /// * `dest_folder` - Destination folder path on device + /// + /// # Returns + /// + /// Total bytes transferred. + pub async fn upload_recursive( + &self, + device_id: &str, + storage_id: u32, + local_source: &Path, + dest_folder: &str, + ) -> Result { + debug!( + "MTP upload_recursive: device={}, storage={}, source={}, dest={}", + device_id, + storage_id, + local_source.display(), + dest_folder + ); + + let metadata = tokio::fs::metadata(local_source) + .await + .map_err(|e| MtpConnectionError::Other { + device_id: device_id.to_string(), + message: format!("Failed to read local path: {}", e), + })?; + + if metadata.is_file() { + // Upload single file + let operation_id = format!("upload-{}", uuid::Uuid::new_v4()); + let result = self + .upload_file(device_id, storage_id, local_source, dest_folder, None, &operation_id) + .await?; + Ok(result.size.unwrap_or(0)) + } else if metadata.is_dir() { + // Create directory on device and upload contents + let dir_name = local_source + .file_name() + .ok_or_else(|| MtpConnectionError::Other { + device_id: device_id.to_string(), + message: "Invalid directory path".to_string(), + })? + .to_string_lossy() + .to_string(); + + // Create the directory on the device + let new_folder = self + .create_folder(device_id, storage_id, dest_folder, &dir_name) + .await?; + let new_folder_path = new_folder.path; + + // Upload all contents + let mut total_bytes = 0u64; + let mut entries = tokio::fs::read_dir(local_source) + .await + .map_err(|e| MtpConnectionError::Other { + device_id: device_id.to_string(), + message: format!("Failed to read local directory: {}", e), + })?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| MtpConnectionError::Other { + device_id: device_id.to_string(), + message: format!("Failed to read directory entry: {}", e), + })? { + let entry_path = entry.path(); + let bytes = + Box::pin(self.upload_recursive(device_id, storage_id, &entry_path, &new_folder_path)).await?; + total_bytes += bytes; + } + + debug!( + "MTP upload_recursive: directory {} complete, {} bytes", + local_source.display(), + total_bytes + ); + Ok(total_bytes) + } else { + // Not a file or directory (symlink, etc.) - skip + debug!( + "MTP upload_recursive: skipping non-file/non-directory: {}", + local_source.display() + ); + Ok(0) + } + } + + /// Moves an object to a new parent folder on the MTP device. + /// + /// Falls back to copy+delete if the device doesn't support MoveObject. + /// + /// # Arguments + /// + /// * `device_id` - The connected device ID + /// * `storage_id` - The storage ID within the device + /// * `object_path` - Current path of the object + /// * `new_parent_path` - New parent folder path + pub async fn move_object( + &self, + device_id: &str, + storage_id: u32, + object_path: &str, + new_parent_path: &str, + ) -> Result { + debug!( + "MTP move_object: device={}, storage={}, path={}, new_parent={}", + device_id, storage_id, object_path, new_parent_path + ); + + // Get device and resolve both handles + let (device_arc, object_handle, new_parent_handle) = { + let devices = self.devices.lock().await; + let entry = devices.get(device_id).ok_or_else(|| MtpConnectionError::NotConnected { + device_id: device_id.to_string(), + })?; + + let obj_handle = self.resolve_path_to_handle(entry, storage_id, object_path)?; + let parent_handle = self.resolve_path_to_handle(entry, storage_id, new_parent_path)?; + (Arc::clone(&entry.device), obj_handle, parent_handle) + }; + + let device = acquire_device_lock(&device_arc, device_id, "move_object").await?; + + // Get the storage + let storage = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + device.storage(StorageId(storage_id)), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + // Get object info + let object_info = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + storage.get_object_info(object_handle), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + let is_dir = object_info.format == mtp_rs::ptp::ObjectFormatCode::Association; + let object_size = object_info.size; + let object_name = object_info.filename.clone(); + + // Try to use MoveObject operation + // storage.move_object expects the new parent handle directly, not Option + let new_parent_for_move = if new_parent_handle == ObjectHandle::ROOT { + ObjectHandle::ROOT + } else { + new_parent_handle + }; + + let move_result = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + storage.move_object(object_handle, new_parent_for_move, None), + ) + .await; + + // Release device and storage lock + drop(storage); + drop(device); + + match move_result { + Ok(Ok(())) => { + // Move succeeded + let old_path = normalize_mtp_path(object_path); + let new_path = normalize_mtp_path(new_parent_path).join(&object_name); + let new_path_str = new_path.to_string_lossy().to_string(); + + // Update path cache + { + let devices = self.devices.lock().await; + if let Some(entry) = devices.get(device_id) + && let Ok(mut cache_map) = entry.path_cache.write() + && let Some(storage_cache) = cache_map.get_mut(&storage_id) + { + storage_cache.path_to_handle.remove(&old_path); + storage_cache.path_to_handle.insert(new_path.clone(), object_handle); + } + } + + // Invalidate listing cache for both old and new parent directories + let old_parent = old_path.parent().unwrap_or(Path::new("/")); + self.invalidate_listing_cache(device_id, storage_id, old_parent).await; + let new_parent = normalize_mtp_path(new_parent_path); + self.invalidate_listing_cache(device_id, storage_id, &new_parent).await; + + info!("MTP move complete: {} -> {}", object_path, new_path_str); + + Ok(MtpObjectInfo { + handle: object_handle.0, + name: object_name, + path: new_path_str, + is_directory: is_dir, + size: if is_dir { None } else { Some(object_size) }, + }) + } + Ok(Err(e)) => { + // Move operation returned an error - might not be supported + warn!( + "MTP MoveObject failed for {}: {:?}. Device may not support this operation.", + object_path, e + ); + Err(MtpConnectionError::Other { + device_id: device_id.to_string(), + message: format!("Move operation not supported by device: {}", e), + }) + } + Err(_) => Err(MtpConnectionError::Timeout { + device_id: device_id.to_string(), + }), + } + } + + // ======================================================================== + // Phase 6: Streaming Operations for Volume-to-Volume Copy + // ======================================================================== + + /// Opens a streaming download for a file. + /// + /// Returns the FileDownload stream and the file size. + /// The caller must consume the entire stream before releasing it. + /// + /// # Arguments + /// + /// * `device_id` - The connected device ID + /// * `storage_id` - The storage ID within the device + /// * `path` - Virtual path on the device (e.g., "DCIM/photo.jpg") + pub async fn open_download_stream( + &self, + device_id: &str, + storage_id: u32, + path: &str, + ) -> Result<(mtp_rs::FileDownload, u64), MtpConnectionError> { + debug!( + "MTP open_download_stream: device={}, storage={}, path={}", + device_id, storage_id, path + ); + + // Get the device and resolve path to handle + let (device_arc, object_handle) = { + let devices = self.devices.lock().await; + let entry = devices.get(device_id).ok_or_else(|| MtpConnectionError::NotConnected { + device_id: device_id.to_string(), + })?; + + let handle = self.resolve_path_to_handle(entry, storage_id, path)?; + (Arc::clone(&entry.device), handle) + }; + + let device = acquire_device_lock(&device_arc, device_id, "open_download_stream").await?; + + // Get the storage + let storage = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + device.storage(StorageId(storage_id)), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + // Get object info to determine size + let object_info = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + storage.get_object_info(object_handle), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + let total_size = object_info.size; + + // Open the download stream + let download = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS * 10), + storage.download_stream(object_handle), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + // Note: We intentionally don't drop 'storage' and 'device' here. + // The FileDownload holds a reference to the storage session internally. + // The caller must consume the entire download before other operations. + // This is a design limitation of the current mtp-rs streaming API. + // In practice, the Volume trait methods run in spawn_blocking, so + // the device lock is released when the blocking task completes. + + debug!("MTP open_download_stream: stream opened for {} bytes", total_size); + + Ok((download, total_size)) + } + + /// Uploads pre-collected chunks to the MTP device. + /// + /// This variant takes already-collected chunks instead of a stream reference, + /// avoiding nested `block_on` issues when the stream uses `block_on` internally. + /// + /// # Arguments + /// + /// * `device_id` - The connected device ID + /// * `storage_id` - The storage ID within the device + /// * `dest_folder` - Destination folder path on device (e.g., "DCIM") + /// * `filename` - Name for the new file + /// * `size` - Total size in bytes + /// * `chunks` - Pre-collected data chunks + pub async fn upload_from_chunks( + &self, + device_id: &str, + storage_id: u32, + dest_folder: &str, + filename: &str, + size: u64, + chunks: Vec, + ) -> Result { + debug!( + "MTP upload_from_chunks: device={}, storage={}, dest={}/{}, size={}, chunks={}", + device_id, + storage_id, + dest_folder, + filename, + size, + chunks.len() + ); + + // Get device and resolve parent folder + let (device_arc, parent_handle) = { + let devices = self.devices.lock().await; + let entry = devices.get(device_id).ok_or_else(|| MtpConnectionError::NotConnected { + device_id: device_id.to_string(), + })?; + + let parent = if dest_folder.is_empty() { + ObjectHandle::ROOT + } else { + self.resolve_path_to_handle(entry, storage_id, dest_folder)? + }; + (Arc::clone(&entry.device), parent) + }; + + let device = acquire_device_lock(&device_arc, device_id, "upload_from_chunks").await?; + + // Get the storage + let storage = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS), + device.storage(StorageId(storage_id)), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + // Create object info for the upload + let object_info = NewObjectInfo::file(filename, size); + + let parent_opt = if parent_handle == ObjectHandle::ROOT { + None + } else { + Some(parent_handle) + }; + + // Convert chunks to stream format expected by mtp-rs + let chunk_results: Vec> = chunks.into_iter().map(Ok).collect(); + let data_stream = futures_util::stream::iter(chunk_results); + + let new_handle = tokio::time::timeout( + Duration::from_secs(MTP_TIMEOUT_SECS * 10), + storage.upload(parent_opt, object_info, data_stream), + ) + .await + .map_err(|_| MtpConnectionError::Timeout { + device_id: device_id.to_string(), + })? + .map_err(|e| map_mtp_error(e, device_id))?; + + // Release device lock + drop(storage); + drop(device); + + // Update path cache + let new_path = normalize_mtp_path(dest_folder).join(filename); + { + let devices = self.devices.lock().await; + if let Some(entry) = devices.get(device_id) + && let Ok(mut cache_map) = entry.path_cache.write() + { + let storage_cache = cache_map.entry(storage_id).or_default(); + storage_cache.path_to_handle.insert(new_path.clone(), new_handle); + } + } + + // Invalidate the parent directory's listing cache + let dest_folder_path = normalize_mtp_path(dest_folder); + self.invalidate_listing_cache(device_id, storage_id, &dest_folder_path) + .await; + + info!( + "MTP upload_from_chunks complete: {} bytes to {}/{}", + size, dest_folder, filename + ); + + Ok(size) + } +} + +/// Global connection manager instance. +static CONNECTION_MANAGER: LazyLock = LazyLock::new(MtpConnectionManager::new); + +/// Gets the global connection manager. +pub fn connection_manager() -> &'static MtpConnectionManager { + &CONNECTION_MANAGER +} + +/// Parses a device ID to extract location_id. +/// +/// Format: "mtp-{location_id}" +fn parse_device_id(device_id: &str) -> Option { + let prefix = "mtp-"; + if !device_id.starts_with(prefix) { + return None; + } + + device_id[prefix.len()..].parse().ok() +} + +/// Opens an MTP device by location_id. +async fn open_device(location_id: u64) -> Result { + MtpDeviceBuilder::new() + .timeout(Duration::from_secs(MTP_TIMEOUT_SECS)) + .open_by_location(location_id) + .await +} + +/// Probes whether a storage actually supports writes by attempting to create +/// and delete a hidden test folder. +/// +/// Some devices (especially cameras) report ReadWrite capability but actually +/// reject writes at runtime with `StoreReadOnly`. This probe detects such cases early. +/// +/// Returns `true` if writes are supported (or probe was inconclusive), `false` only +/// if the device explicitly rejected writes with `StoreReadOnly` or `AccessDenied`. +async fn probe_write_capability(storage: &mtp_rs::Storage, storage_name: &str) -> bool { + use mtp_rs::ptp::ResponseCode; + + const PROBE_FOLDER_NAME: &str = ".cmdr_write_probe"; + const PROBE_TIMEOUT_SECS: u64 = 3; + + // Try to create a hidden probe folder at the root + match tokio::time::timeout( + Duration::from_secs(PROBE_TIMEOUT_SECS), + storage.create_folder(None, PROBE_FOLDER_NAME), + ) + .await + { + Ok(Ok(handle)) => { + // Success! Clean up by deleting the probe folder + debug!("Storage '{}': write probe succeeded, cleaning up", storage_name); + if let Err(e) = storage.delete(handle).await { + warn!("Storage '{}': failed to clean up probe folder: {:?}", storage_name, e); + } + true + } + Ok(Err(e)) => { + // Check the specific error code to determine if this is a read-only issue + // or just a restriction on where we can create folders + let is_read_only_error = match &e { + mtp_rs::Error::Protocol { code, .. } => { + matches!(code, ResponseCode::StoreReadOnly | ResponseCode::AccessDenied) + } + _ => false, + }; + + if is_read_only_error { + debug!( + "Storage '{}': write probe failed with read-only error: {:?}", + storage_name, e + ); + false + } else { + // Other errors (InvalidObjectHandle, InvalidParentObject, etc.) likely mean + // we just can't create at root, not that the device is read-only. + // Android devices often don't allow creating at root but are still writable. + debug!( + "Storage '{}': write probe failed with non-fatal error (assuming writable): {:?}", + storage_name, e + ); + true + } + } + Err(_) => { + // Timeout - assume writable (benefit of the doubt) + debug!("Storage '{}': write probe timed out (assuming writable)", storage_name); + true + } + } +} + +/// Gets storage information from a connected device. +/// +/// # Arguments +/// * `device` - The connected MTP device +/// * `device_supports_write` - Whether the device supports write operations (SendObjectInfo) +async fn get_storages(device: &MtpDevice, device_supports_write: bool) -> Result, mtp_rs::Error> { + use mtp_rs::ptp::AccessCapability; + + debug!("Calling device.storages()..."); + let storage_list = device.storages().await?; + debug!("Got {} storage(s)", storage_list.len()); + let mut storages = Vec::new(); + + for storage in storage_list { + let info = storage.info(); + // Check if storage reports read-only capability + let storage_reports_read_only = !matches!(info.access_capability, AccessCapability::ReadWrite); + + // Determine actual read-only status + let is_read_only = if !device_supports_write || storage_reports_read_only { + // Device/storage claims no write support - trust it + true + } else { + // Device claims write support - probe to verify + // This catches cameras that advertise write support but reject writes at runtime + let probe_ok = probe_write_capability(&storage, &info.description).await; + if !probe_ok { + info!( + "Storage '{}' claims write support but probe failed - marking read-only", + info.description + ); + } + !probe_ok // read-only if probe failed + }; + + // Log final determination + info!( + "Storage '{}': access_capability={:?}, device_supports_write={}, is_read_only={}", + info.description, info.access_capability, device_supports_write, is_read_only + ); + + storages.push(MtpStorageInfo { + id: storage.id().0, + name: info.description.clone(), + total_bytes: info.max_capacity, + available_bytes: info.free_space_bytes, + storage_type: Some(format!("{:?}", info.storage_type)), + is_read_only, + }); + } + + Ok(storages) +} + +/// Maps mtp_rs errors to our error types. +fn map_mtp_error(e: mtp_rs::Error, device_id: &str) -> MtpConnectionError { + use mtp_rs::ptp::ResponseCode; + + match e { + mtp_rs::Error::NoDevice => MtpConnectionError::DeviceNotFound { + device_id: device_id.to_string(), + }, + mtp_rs::Error::Disconnected => MtpConnectionError::Disconnected { + device_id: device_id.to_string(), + }, + mtp_rs::Error::Timeout => MtpConnectionError::Timeout { + device_id: device_id.to_string(), + }, + mtp_rs::Error::Cancelled => MtpConnectionError::Other { + device_id: device_id.to_string(), + message: "Operation cancelled".to_string(), + }, + mtp_rs::Error::SessionNotOpen => MtpConnectionError::NotConnected { + device_id: device_id.to_string(), + }, + mtp_rs::Error::Protocol { code, operation } => { + // Map specific response codes to user-friendly errors + match code { + ResponseCode::DeviceBusy => MtpConnectionError::DeviceBusy { + device_id: device_id.to_string(), + }, + ResponseCode::StoreFull => MtpConnectionError::StorageFull { + device_id: device_id.to_string(), + }, + ResponseCode::StoreReadOnly => MtpConnectionError::Other { + device_id: device_id.to_string(), + message: "This device is read-only. You can copy files from it, but not to it.".to_string(), + }, + ResponseCode::InvalidObjectHandle | ResponseCode::InvalidParentObject => { + MtpConnectionError::ObjectNotFound { + device_id: device_id.to_string(), + path: format!("(operation: {:?})", operation), + } + } + ResponseCode::AccessDenied => MtpConnectionError::Other { + device_id: device_id.to_string(), + message: "Access denied. The device rejected the operation.".to_string(), + }, + _ => MtpConnectionError::Protocol { + device_id: device_id.to_string(), + message: format!("{:?}", code), + }, + } + } + mtp_rs::Error::InvalidData { message } => MtpConnectionError::Other { + device_id: device_id.to_string(), + message: format!("Invalid data from device: {}", message), + }, + mtp_rs::Error::Io(io_err) => MtpConnectionError::Other { + device_id: device_id.to_string(), + message: format!("I/O error: {}", io_err), + }, + mtp_rs::Error::Usb(usb_err) => { + // Check for exclusive access errors + let msg = usb_err.to_string().to_lowercase(); + if msg.contains("exclusive access") || msg.contains("device or resource busy") { + MtpConnectionError::ExclusiveAccess { + device_id: device_id.to_string(), + blocking_process: None, + } + } else { + MtpConnectionError::Other { + device_id: device_id.to_string(), + message: format!("USB error: {}", usb_err), + } + } + } + } +} + +/// Normalizes an MTP path. +/// +/// Ensures the path starts with "/" and handles empty/relative paths. +fn normalize_mtp_path(path: &str) -> PathBuf { + if path.is_empty() || path == "." { + PathBuf::from("/") + } else if !path.starts_with('/') { + PathBuf::from("/").join(path) + } else { + PathBuf::from(path) + } +} + +/// Converts MTP DateTime to Unix timestamp. +fn convert_mtp_datetime(dt: mtp_rs::ptp::DateTime) -> u64 { + // Convert the DateTime struct fields to Unix timestamp + // This is a simplified conversion - MTP DateTime has year, month, day, hour, minute, second + + // Create a rough Unix timestamp from the date components + // Note: This is a simplified calculation that doesn't account for leap years perfectly + let year = dt.year as u64; + let month = dt.month as u64; + let day = dt.day as u64; + let hour = dt.hour as u64; + let minute = dt.minute as u64; + let second = dt.second as u64; + + // Simplified calculation: days since epoch + time + // This is approximate but good enough for file listing purposes + let years_since_1970 = year.saturating_sub(1970); + let days = years_since_1970 * 365 + (years_since_1970 / 4) // leap years approximation + + (month.saturating_sub(1)) * 30 // approximate days per month + + day.saturating_sub(1); + + days * 86400 + hour * 3600 + minute * 60 + second +} + +/// Generates icon ID for MTP files. +fn get_mtp_icon_id(is_dir: bool, filename: &str) -> String { + if is_dir { + return "dir".to_string(); + } + if let Some(ext) = Path::new(filename).extension() { + return format!("ext:{}", ext.to_string_lossy().to_lowercase()); + } + "file".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_device_id_valid() { + assert_eq!(parse_device_id("mtp-336592896"), Some(336592896)); + assert_eq!(parse_device_id("mtp-12345"), Some(12345)); + assert_eq!(parse_device_id("mtp-0"), Some(0)); + } + + #[test] + fn test_parse_device_id_invalid() { + assert_eq!(parse_device_id("usb-336592896"), None); + assert_eq!(parse_device_id("mtp-abc"), None); + assert_eq!(parse_device_id("mtp-"), None); + assert_eq!(parse_device_id("mtp"), None); + assert_eq!(parse_device_id(""), None); + } + + #[test] + fn test_connection_error_display() { + let err = MtpConnectionError::DeviceNotFound { + device_id: "mtp-1-5".to_string(), + }; + assert_eq!(err.to_string(), "Device not found: mtp-1-5"); + + let err = MtpConnectionError::ExclusiveAccess { + device_id: "mtp-1-5".to_string(), + blocking_process: Some("ptpcamerad".to_string()), + }; + assert_eq!(err.to_string(), "Device mtp-1-5 is in use by ptpcamerad"); + } + + #[test] + fn test_connection_error_is_retryable() { + // Retryable errors + assert!( + MtpConnectionError::Timeout { + device_id: "mtp-1-5".to_string() + } + .is_retryable() + ); + assert!( + MtpConnectionError::DeviceBusy { + device_id: "mtp-1-5".to_string() + } + .is_retryable() + ); + + // Non-retryable errors + assert!( + !MtpConnectionError::DeviceNotFound { + device_id: "mtp-1-5".to_string() + } + .is_retryable() + ); + assert!( + !MtpConnectionError::Disconnected { + device_id: "mtp-1-5".to_string() + } + .is_retryable() + ); + assert!( + !MtpConnectionError::StorageFull { + device_id: "mtp-1-5".to_string() + } + .is_retryable() + ); + } + + #[test] + fn test_connection_error_user_message() { + let err = MtpConnectionError::DeviceNotFound { + device_id: "mtp-1-5".to_string(), + }; + assert!(err.user_message().contains("not found")); + assert!(err.user_message().contains("unplugged")); + + let err = MtpConnectionError::StorageFull { + device_id: "mtp-1-5".to_string(), + }; + assert!(err.user_message().contains("full")); + + let err = MtpConnectionError::DeviceBusy { + device_id: "mtp-1-5".to_string(), + }; + assert!(err.user_message().contains("busy")); + assert!(err.user_message().contains("try again")); + } + + #[test] + fn test_new_error_types_display() { + let err = MtpConnectionError::DeviceBusy { + device_id: "mtp-1-5".to_string(), + }; + assert_eq!(err.to_string(), "Device busy: mtp-1-5"); + + let err = MtpConnectionError::StorageFull { + device_id: "mtp-1-5".to_string(), + }; + assert_eq!(err.to_string(), "Storage full on device: mtp-1-5"); + + let err = MtpConnectionError::ObjectNotFound { + device_id: "mtp-1-5".to_string(), + path: "/DCIM/photo.jpg".to_string(), + }; + assert!(err.to_string().contains("Object not found")); + assert!(err.to_string().contains("/DCIM/photo.jpg")); + } + + // ======================================================================== + // Path normalization tests + // ======================================================================== + + #[test] + fn test_normalize_mtp_path_empty() { + assert_eq!(normalize_mtp_path(""), PathBuf::from("/")); + } + + #[test] + fn test_normalize_mtp_path_dot() { + assert_eq!(normalize_mtp_path("."), PathBuf::from("/")); + } + + #[test] + fn test_normalize_mtp_path_root() { + assert_eq!(normalize_mtp_path("/"), PathBuf::from("/")); + } + + #[test] + fn test_normalize_mtp_path_absolute() { + assert_eq!(normalize_mtp_path("/DCIM"), PathBuf::from("/DCIM")); + assert_eq!(normalize_mtp_path("/DCIM/Camera"), PathBuf::from("/DCIM/Camera")); + } + + #[test] + fn test_normalize_mtp_path_relative() { + assert_eq!(normalize_mtp_path("DCIM"), PathBuf::from("/DCIM")); + assert_eq!(normalize_mtp_path("DCIM/Camera"), PathBuf::from("/DCIM/Camera")); + } + + #[test] + fn test_normalize_mtp_path_special_characters() { + // Test paths with spaces and special characters + assert_eq!(normalize_mtp_path("/My Files"), PathBuf::from("/My Files")); + assert_eq!(normalize_mtp_path("Photos & Videos"), PathBuf::from("/Photos & Videos")); + } + + // ======================================================================== + // Icon ID generation tests + // ======================================================================== + + #[test] + fn test_get_mtp_icon_id_directory() { + assert_eq!(get_mtp_icon_id(true, "DCIM"), "dir"); + assert_eq!(get_mtp_icon_id(true, "Camera"), "dir"); + assert_eq!(get_mtp_icon_id(true, ""), "dir"); + } + + #[test] + fn test_get_mtp_icon_id_file_with_extension() { + assert_eq!(get_mtp_icon_id(false, "photo.jpg"), "ext:jpg"); + assert_eq!(get_mtp_icon_id(false, "document.PDF"), "ext:pdf"); + assert_eq!(get_mtp_icon_id(false, "video.MP4"), "ext:mp4"); + assert_eq!(get_mtp_icon_id(false, "archive.tar.gz"), "ext:gz"); + } + + #[test] + fn test_get_mtp_icon_id_file_without_extension() { + assert_eq!(get_mtp_icon_id(false, "README"), "file"); + assert_eq!(get_mtp_icon_id(false, "Makefile"), "file"); + // Hidden files starting with . have no "real" extension, Path::extension returns None + assert_eq!(get_mtp_icon_id(false, ".hidden"), "file"); + } + + // ======================================================================== + // Error serialization tests + // ======================================================================== + + #[test] + fn test_connection_error_serialization() { + let err = MtpConnectionError::DeviceNotFound { + device_id: "mtp-1-5".to_string(), + }; + let json = serde_json::to_string(&err).unwrap(); + // Note: With tag = "type" and rename_all = "camelCase", device_id becomes deviceId + assert!(json.contains("\"type\":\"deviceNotFound\""), "JSON: {}", json); + assert!(json.contains("\"device_id\":\"mtp-1-5\""), "JSON: {}", json); + } + + #[test] + fn test_connection_error_exclusive_access_serialization() { + let err = MtpConnectionError::ExclusiveAccess { + device_id: "mtp-1-5".to_string(), + blocking_process: Some("ptpcamerad".to_string()), + }; + let json = serde_json::to_string(&err).unwrap(); + // Note: tag type is camelCase, but inner field names stay snake_case + assert!(json.contains("\"type\":\"exclusiveAccess\""), "JSON: {}", json); + assert!(json.contains("\"blocking_process\":\"ptpcamerad\""), "JSON: {}", json); + } + + #[test] + fn test_connection_error_exclusive_access_no_process() { + let err = MtpConnectionError::ExclusiveAccess { + device_id: "mtp-1-5".to_string(), + blocking_process: None, + }; + let json = serde_json::to_string(&err).unwrap(); + assert!(json.contains("\"blocking_process\":null"), "JSON: {}", json); + + // Test user message for this case + assert!(err.user_message().contains("Another app")); + assert!(!err.user_message().contains("ptpcamerad")); + } + + #[test] + fn test_connection_error_protocol_serialization() { + let err = MtpConnectionError::Protocol { + device_id: "mtp-1-5".to_string(), + message: "InvalidObjectHandle".to_string(), + }; + let json = serde_json::to_string(&err).unwrap(); + assert!(json.contains("\"type\":\"protocol\""), "JSON: {}", json); + assert!(json.contains("\"message\":\"InvalidObjectHandle\""), "JSON: {}", json); + } + + // ======================================================================== + // All error display and user_message coverage + // ======================================================================== + + #[test] + fn test_all_error_variants_display() { + // Test all variants have Display impl + let errors = vec![ + MtpConnectionError::DeviceNotFound { + device_id: "test".to_string(), + }, + MtpConnectionError::NotConnected { + device_id: "test".to_string(), + }, + MtpConnectionError::ExclusiveAccess { + device_id: "test".to_string(), + blocking_process: None, + }, + MtpConnectionError::Timeout { + device_id: "test".to_string(), + }, + MtpConnectionError::Disconnected { + device_id: "test".to_string(), + }, + MtpConnectionError::Protocol { + device_id: "test".to_string(), + message: "error".to_string(), + }, + MtpConnectionError::DeviceBusy { + device_id: "test".to_string(), + }, + MtpConnectionError::StorageFull { + device_id: "test".to_string(), + }, + MtpConnectionError::ObjectNotFound { + device_id: "test".to_string(), + path: "/path".to_string(), + }, + MtpConnectionError::Other { + device_id: "test".to_string(), + message: "other".to_string(), + }, + ]; + + for err in errors { + // Each should have non-empty display + assert!(!err.to_string().is_empty()); + // Each should have non-empty user message + assert!(!err.user_message().is_empty()); + } + } + + #[test] + fn test_not_connected_error() { + let err = MtpConnectionError::NotConnected { + device_id: "mtp-1-5".to_string(), + }; + assert_eq!(err.to_string(), "Device not connected: mtp-1-5"); + assert!(err.user_message().contains("not connected")); + assert!(!err.is_retryable()); + } + + #[test] + fn test_timeout_error() { + let err = MtpConnectionError::Timeout { + device_id: "mtp-1-5".to_string(), + }; + assert!(err.to_string().contains("timed out")); + assert!(err.user_message().contains("timed out")); + assert!(err.is_retryable()); + } + + #[test] + fn test_disconnected_error() { + let err = MtpConnectionError::Disconnected { + device_id: "mtp-1-5".to_string(), + }; + assert!(err.to_string().contains("disconnected")); + assert!(err.user_message().contains("disconnected")); + assert!(!err.is_retryable()); + } + + #[test] + fn test_protocol_error_user_message() { + let err = MtpConnectionError::Protocol { + device_id: "mtp-1-5".to_string(), + message: "InvalidObjectHandle".to_string(), + }; + assert!(err.user_message().contains("InvalidObjectHandle")); + assert!(err.user_message().contains("reconnecting")); + assert!(!err.is_retryable()); + } + + #[test] + fn test_object_not_found_user_message() { + let err = MtpConnectionError::ObjectNotFound { + device_id: "mtp-1-5".to_string(), + path: "/DCIM/photo.jpg".to_string(), + }; + assert!(err.user_message().contains("/DCIM/photo.jpg")); + assert!(err.user_message().contains("deleted")); + assert!(!err.is_retryable()); + } + + #[test] + fn test_other_error() { + let err = MtpConnectionError::Other { + device_id: "mtp-1-5".to_string(), + message: "Custom error message".to_string(), + }; + assert!(err.to_string().contains("Custom error message")); + assert_eq!(err.user_message(), "Custom error message"); + assert!(!err.is_retryable()); + } + + // ======================================================================== + // Transfer types and result tests + // ======================================================================== + + #[test] + fn test_transfer_type_serialization() { + let download = MtpTransferType::Download; + let upload = MtpTransferType::Upload; + + let download_json = serde_json::to_string(&download).unwrap(); + let upload_json = serde_json::to_string(&upload).unwrap(); + + assert_eq!(download_json, "\"download\""); + assert_eq!(upload_json, "\"upload\""); + } + + #[test] + fn test_transfer_progress_serialization() { + let progress = MtpTransferProgress { + operation_id: "op-123".to_string(), + device_id: "mtp-1-5".to_string(), + transfer_type: MtpTransferType::Download, + current_file: "photo.jpg".to_string(), + bytes_done: 1024, + bytes_total: 4096, + }; + + let json = serde_json::to_string(&progress).unwrap(); + assert!(json.contains("\"operationId\":\"op-123\"")); + assert!(json.contains("\"deviceId\":\"mtp-1-5\"")); + assert!(json.contains("\"transferType\":\"download\"")); + assert!(json.contains("\"currentFile\":\"photo.jpg\"")); + assert!(json.contains("\"bytesDone\":1024")); + assert!(json.contains("\"bytesTotal\":4096")); + } + + #[test] + fn test_operation_result_serialization() { + let result = MtpOperationResult { + operation_id: "op-456".to_string(), + files_processed: 5, + bytes_transferred: 1_000_000, + }; + + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"operationId\":\"op-456\"")); + assert!(json.contains("\"filesProcessed\":5")); + assert!(json.contains("\"bytesTransferred\":1000000")); + } + + #[test] + fn test_object_info_serialization() { + let info = MtpObjectInfo { + handle: 12345, + name: "test.jpg".to_string(), + path: "/DCIM/test.jpg".to_string(), + is_directory: false, + size: Some(1024), + }; + + let json = serde_json::to_string(&info).unwrap(); + assert!(json.contains("\"handle\":12345")); + assert!(json.contains("\"name\":\"test.jpg\"")); + assert!(json.contains("\"path\":\"/DCIM/test.jpg\"")); + assert!(json.contains("\"isDirectory\":false")); + assert!(json.contains("\"size\":1024")); + } + + #[test] + fn test_object_info_directory() { + let info = MtpObjectInfo { + handle: 100, + name: "Photos".to_string(), + path: "/Photos".to_string(), + is_directory: true, + size: None, + }; + + let json = serde_json::to_string(&info).unwrap(); + assert!(json.contains("\"isDirectory\":true")); + assert!(json.contains("\"size\":null")); + } + + // ======================================================================== + // Connected device info tests + // ======================================================================== + + #[test] + fn test_connected_device_info_serialization() { + use super::super::types::{MtpDeviceInfo, MtpStorageInfo}; + + let info = ConnectedDeviceInfo { + device: MtpDeviceInfo { + id: "mtp-336592896".to_string(), + location_id: 336592896, + vendor_id: 0x18d1, + product_id: 0x4ee1, + manufacturer: Some("Google".to_string()), + product: Some("Pixel 8".to_string()), + serial_number: None, + }, + storages: vec![MtpStorageInfo { + id: 65537, + name: "Internal shared storage".to_string(), + total_bytes: 128_000_000_000, + available_bytes: 64_000_000_000, + storage_type: Some("FixedRAM".to_string()), + is_read_only: false, + }], + }; + + let json = serde_json::to_string(&info).unwrap(); + assert!(json.contains("\"id\":\"mtp-336592896\"")); + assert!(json.contains("\"locationId\":336592896")); + assert!(json.contains("\"manufacturer\":\"Google\"")); + assert!(json.contains("\"product\":\"Pixel 8\"")); + assert!(json.contains("\"Internal shared storage\"")); + assert!(json.contains("\"isReadOnly\":false")); + } + + // ======================================================================== + // Edge cases for parse_device_id + // ======================================================================== + + #[test] + fn test_parse_device_id_edge_cases() { + // Maximum u64 value + assert_eq!(parse_device_id("mtp-18446744073709551615"), Some(u64::MAX)); + + // Zero value + assert_eq!(parse_device_id("mtp-0"), Some(0)); + + // Typical macOS location_id values + assert_eq!(parse_device_id("mtp-336592896"), Some(336592896)); + + // Wrong prefix (case sensitive) + assert_eq!(parse_device_id("MTP-336592896"), None); + + // Whitespace + assert_eq!(parse_device_id(" mtp-336592896"), None); + assert_eq!(parse_device_id("mtp-336592896 "), None); + + // Negative numbers (not valid for u64) + assert_eq!(parse_device_id("mtp--1"), None); + } + + // ======================================================================== + // MtpOperationState tests + // ======================================================================== + + #[test] + fn test_operation_state_default() { + let state = MtpOperationState::default(); + assert!(!state.cancelled.load(std::sync::atomic::Ordering::Relaxed)); + } + + #[test] + fn test_operation_state_cancel() { + let state = MtpOperationState::default(); + state.cancelled.store(true, std::sync::atomic::Ordering::Relaxed); + assert!(state.cancelled.load(std::sync::atomic::Ordering::Relaxed)); + } + + // ======================================================================== + // EventDebouncer tests + // ======================================================================== + + #[test] + fn test_event_debouncer_allows_first_event() { + let debouncer = EventDebouncer::new(Duration::from_millis(500)); + + // First event for a device should always be allowed + assert!(debouncer.should_emit("device-1")); + + // First event for a different device should also be allowed + assert!(debouncer.should_emit("device-2")); + } + + #[test] + fn test_event_debouncer_throttles_rapid_events() { + let debouncer = EventDebouncer::new(Duration::from_millis(100)); + + // First event should be allowed + assert!(debouncer.should_emit("device-1")); + + // Immediate second event should be throttled + assert!(!debouncer.should_emit("device-1")); + + // Third rapid event should also be throttled + assert!(!debouncer.should_emit("device-1")); + } + + #[test] + fn test_event_debouncer_allows_after_timeout() { + let debouncer = EventDebouncer::new(Duration::from_millis(10)); + + // First event should be allowed + assert!(debouncer.should_emit("device-1")); + + // Wait for debounce period to elapse + std::thread::sleep(Duration::from_millis(20)); + + // Event after timeout should be allowed + assert!(debouncer.should_emit("device-1")); + } + + #[test] + fn test_event_debouncer_clear() { + let debouncer = EventDebouncer::new(Duration::from_millis(500)); + + // First event allowed + assert!(debouncer.should_emit("device-1")); + + // Second event should be throttled + assert!(!debouncer.should_emit("device-1")); + + // Clear the device state + debouncer.clear("device-1"); + + // After clear, next event should be allowed immediately + assert!(debouncer.should_emit("device-1")); + } + + #[test] + fn test_event_debouncer_per_device_isolation() { + let debouncer = EventDebouncer::new(Duration::from_millis(500)); + + // First event for device-1 + assert!(debouncer.should_emit("device-1")); + + // Rapid event for device-1 should be throttled + assert!(!debouncer.should_emit("device-1")); + + // But event for device-2 should be allowed (independent) + assert!(debouncer.should_emit("device-2")); + + // And rapid event for device-2 should be throttled independently + assert!(!debouncer.should_emit("device-2")); + } +} diff --git a/apps/desktop/src-tauri/src/mtp/discovery.rs b/apps/desktop/src-tauri/src/mtp/discovery.rs new file mode 100644 index 0000000..3f6e8ad --- /dev/null +++ b/apps/desktop/src-tauri/src/mtp/discovery.rs @@ -0,0 +1,86 @@ +//! MTP device discovery. +//! +//! Lists connected MTP devices without opening sessions. +//! Used to populate the volume picker with available Android devices. + +use super::types::MtpDeviceInfo; +use log::{debug, warn}; +use mtp_rs::MtpDevice; + +/// Lists all connected MTP devices. +/// +/// This function enumerates USB devices and filters for MTP-capable ones. +/// Device information including friendly names comes directly from mtp-rs. +/// +/// # Returns +/// +/// A vector of `MtpDeviceInfo` structs describing available devices. +/// Returns an empty vector if no devices are found or if enumeration fails. +/// +/// # Example +/// +/// ```ignore +/// let devices = list_mtp_devices(); +/// for device in devices { +/// println!("Found: {}", device.display_name()); +/// } +/// ``` +pub fn list_mtp_devices() -> Vec { + match MtpDevice::list_devices() { + Ok(devices) => { + debug!("Found {} MTP device(s)", devices.len()); + devices + .into_iter() + .map(|d| { + let id = format!("mtp-{}", d.location_id); + debug!( + "MTP device: id={}, vendor={:04x}, product={:04x}", + id, d.vendor_id, d.product_id + ); + + if let Some(ref prod) = d.product { + debug!("MTP device {} has product name: {}", id, prod); + } + + MtpDeviceInfo { + id, + location_id: d.location_id, + vendor_id: d.vendor_id, + product_id: d.product_id, + manufacturer: d.manufacturer, + product: d.product, + serial_number: d.serial_number, + } + }) + .collect() + } + Err(e) => { + // Log the error but return empty list (graceful degradation) + warn!("Failed to enumerate MTP devices: {}", e); + Vec::new() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_list_mtp_devices_returns_vec() { + // This test just verifies the function runs without panicking + // Actual device testing requires hardware + let devices = list_mtp_devices(); + // The function should complete without error (even if empty) + // Using is_empty() to avoid useless comparison warning + let _ = devices.is_empty(); // Just verify it returns a valid vec + } + + #[test] + fn test_device_id_format() { + // Test that our ID format is consistent (location_id based) + let location_id: u64 = 336592896; + let id = format!("mtp-{}", location_id); + assert_eq!(id, "mtp-336592896"); + } +} diff --git a/apps/desktop/src-tauri/src/mtp/macos_workaround.rs b/apps/desktop/src-tauri/src/mtp/macos_workaround.rs new file mode 100644 index 0000000..35083e5 --- /dev/null +++ b/apps/desktop/src-tauri/src/mtp/macos_workaround.rs @@ -0,0 +1,97 @@ +//! macOS-specific workarounds for MTP device access. +//! +//! On macOS, the system daemon `ptpcamerad` automatically claims MTP/PTP devices +//! when connected. This module provides utilities to detect and help users +//! work around this issue. + +use log::debug; +use std::process::Command; + +/// The Terminal command that users can run to work around ptpcamerad. +pub const PTPCAMERAD_WORKAROUND_COMMAND: &str = "while true; do pkill -9 ptpcamerad 2>/dev/null; sleep 1; done"; + +/// Queries IORegistry to find the process holding exclusive access to MTP devices. +/// +/// Returns the process name (e.g., "ptpcamerad") if found. +/// +/// # How it works +/// +/// Uses the `ioreg` command to query USB device ownership. The output contains +/// lines like: `"UsbExclusiveOwner" = "pid 45145, ptpcamerad"` +pub fn get_usb_exclusive_owner() -> Option { + // Run ioreg to query USB device ownership + let output = Command::new("ioreg").args(["-l", "-w", "0"]).output().ok()?; + + if !output.status.success() { + debug!("ioreg command failed"); + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Look for lines containing "UsbExclusiveOwner" and "ptpcamera" + for line in stdout.lines() { + if line.contains("UsbExclusiveOwner") && line.contains("ptpcamera") { + // Parse: "UsbExclusiveOwner" = "pid 45145, ptpcamerad" + if let Some(value) = line.split('=').nth(1) { + let value = value.trim().trim_matches('"'); + // Parse "pid 45145, ptpcamerad" + if let Some(stripped) = value.strip_prefix("pid ") { + let parts: Vec<&str> = stripped.splitn(2, ", ").collect(); + if parts.len() == 2 { + debug!("Found USB exclusive owner: {} (pid {})", parts[1], parts[0]); + return Some(format!("pid {}, {}", parts[0], parts[1])); + } + } + } + } + } + + // Also check for other processes that might hold the device + for line in stdout.lines() { + if line.contains("UsbExclusiveOwner") + && let Some(value) = line.split('=').nth(1) + { + let value = value.trim().trim_matches('"').trim(); + if !value.is_empty() { + debug!("Found USB exclusive owner: {}", value); + return Some(value.to_string()); + } + } + } + + debug!("No USB exclusive owner found"); + None +} + +/// Checks if ptpcamerad is likely blocking MTP access. +/// +/// Returns true if ptpcamerad is running and has a USB device claimed. +#[allow(dead_code, reason = "Utility function for future use in diagnostics")] +pub fn is_ptpcamerad_blocking() -> bool { + get_usb_exclusive_owner() + .map(|owner| owner.contains("ptpcamera")) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_workaround_command_is_valid_bash() { + // Just ensure the constant is set + assert!(!PTPCAMERAD_WORKAROUND_COMMAND.is_empty()); + assert!(PTPCAMERAD_WORKAROUND_COMMAND.contains("pkill")); + assert!(PTPCAMERAD_WORKAROUND_COMMAND.contains("ptpcamerad")); + } + + #[test] + fn test_get_usb_exclusive_owner_returns_option() { + // This test just verifies the function runs without panicking + // The result depends on system state + let result = get_usb_exclusive_owner(); + // Result is Option - either Some or None is valid + let _ = result; + } +} diff --git a/apps/desktop/src-tauri/src/mtp/mod.rs b/apps/desktop/src-tauri/src/mtp/mod.rs new file mode 100644 index 0000000..b436b39 --- /dev/null +++ b/apps/desktop/src-tauri/src/mtp/mod.rs @@ -0,0 +1,29 @@ +//! MTP (Media Transfer Protocol) support for Android devices. +//! +//! This module provides device discovery and file operations for Android devices +//! connected via USB in "File transfer / Android Auto" mode. +//! +//! # Architecture +//! +//! - `types`: Type definitions for frontend communication +//! - `discovery`: Device detection using mtp-rs +//! - `connection`: Device connection management with global registry and file browsing +//! - `macos_workaround`: Handles ptpcamerad interference on macOS +//! +//! # Platform Support +//! +//! MTP support is currently macOS-only due to USB access requirements. +//! On macOS, the system daemon `ptpcamerad` may claim devices first; +//! see `macos_workaround` module for handling this. + +pub mod connection; +mod discovery; +pub mod macos_workaround; +pub mod types; +pub mod watcher; + +pub use connection::{ConnectedDeviceInfo, MtpConnectionError, MtpObjectInfo, MtpOperationResult, connection_manager}; +pub use discovery::list_mtp_devices; +pub use macos_workaround::PTPCAMERAD_WORKAROUND_COMMAND; +pub use types::{MtpDeviceInfo, MtpStorageInfo}; +pub use watcher::start_mtp_watcher; diff --git a/apps/desktop/src-tauri/src/mtp/types.rs b/apps/desktop/src-tauri/src/mtp/types.rs new file mode 100644 index 0000000..e7732f3 --- /dev/null +++ b/apps/desktop/src-tauri/src/mtp/types.rs @@ -0,0 +1,166 @@ +//! MTP type definitions for frontend communication. +//! +//! These types are serialized to JSON for Tauri commands. + +// Phase 1 foundation: some types not yet used, will be used in subsequent phases +#![allow(dead_code, reason = "Phase 1 foundation: types used in later phases")] + +use serde::{Deserialize, Serialize}; + +/// Information about a connected MTP device. +/// +/// This represents a device detected via USB, before opening an MTP session. +/// Used by the frontend to display available devices in the volume picker. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MtpDeviceInfo { + /// Unique identifier for the device (format: "mtp-{location_id}"). + pub id: String, + /// Physical USB location identifier. Stable for a given port. + pub location_id: u64, + /// USB vendor ID (e.g., 0x18d1 for Google). + pub vendor_id: u16, + /// USB product ID. + pub product_id: u16, + /// Device manufacturer name, if available from USB descriptor. + #[serde(skip_serializing_if = "Option::is_none")] + pub manufacturer: Option, + /// Device product name, if available from USB descriptor. + #[serde(skip_serializing_if = "Option::is_none")] + pub product: Option, + /// USB serial number, if available. + #[serde(skip_serializing_if = "Option::is_none")] + pub serial_number: Option, +} + +impl MtpDeviceInfo { + /// Returns a display name for the device. + /// + /// Prefers product name, falls back to "MTP Device (vendor:product)". + pub fn display_name(&self) -> String { + if let Some(product) = &self.product { + return product.clone(); + } + if let Some(manufacturer) = &self.manufacturer { + return format!("{} device", manufacturer); + } + format!("MTP device ({:04x}:{:04x})", self.vendor_id, self.product_id) + } +} + +/// Information about a storage area on an MTP device. +/// +/// Android devices typically have one or more storages: "Internal Storage", "SD Card", etc. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MtpStorageInfo { + /// Storage ID (MTP storage handle). + pub id: u32, + /// Display name (e.g., "Internal shared storage"). + pub name: String, + /// Total capacity in bytes. + pub total_bytes: u64, + /// Available space in bytes. + pub available_bytes: u64, + /// Storage type description (e.g., "FixedROM", "RemovableRAM"). + #[serde(skip_serializing_if = "Option::is_none")] + pub storage_type: Option, + /// Whether this storage is read-only (e.g., PTP cameras). + pub is_read_only: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_device_display_name_with_product() { + let device = MtpDeviceInfo { + id: "mtp-336592896".to_string(), + location_id: 336592896, + vendor_id: 0x18d1, + product_id: 0x4ee1, + manufacturer: Some("Google".to_string()), + product: Some("Pixel 8".to_string()), + serial_number: None, + }; + assert_eq!(device.display_name(), "Pixel 8"); + } + + #[test] + fn test_device_display_name_with_manufacturer() { + let device = MtpDeviceInfo { + id: "mtp-336592897".to_string(), + location_id: 336592897, + vendor_id: 0x04e8, + product_id: 0x6860, + manufacturer: Some("Samsung".to_string()), + product: None, + serial_number: None, + }; + assert_eq!(device.display_name(), "Samsung device"); + } + + #[test] + fn test_device_display_name_fallback() { + let device = MtpDeviceInfo { + id: "mtp-336592898".to_string(), + location_id: 336592898, + vendor_id: 0x1234, + product_id: 0x5678, + manufacturer: None, + product: None, + serial_number: None, + }; + assert_eq!(device.display_name(), "MTP device (1234:5678)"); + } + + #[test] + fn test_device_serialization() { + let device = MtpDeviceInfo { + id: "mtp-336592896".to_string(), + location_id: 336592896, + vendor_id: 0x18d1, + product_id: 0x4ee1, + manufacturer: Some("Google".to_string()), + product: Some("Pixel".to_string()), + serial_number: None, + }; + let json = serde_json::to_string(&device).unwrap(); + assert!(json.contains("\"vendorId\":")); + assert!(json.contains("\"productId\":")); + assert!(json.contains("\"locationId\":")); + // serialNumber should be omitted when None + assert!(!json.contains("serialNumber")); + } + + #[test] + fn test_storage_serialization() { + let storage = MtpStorageInfo { + id: 0x10001, + name: "Internal Storage".to_string(), + total_bytes: 128_000_000_000, + available_bytes: 64_000_000_000, + storage_type: Some("FixedRAM".to_string()), + is_read_only: false, + }; + let json = serde_json::to_string(&storage).unwrap(); + assert!(json.contains("\"totalBytes\":128000000000")); + assert!(json.contains("\"availableBytes\":64000000000")); + assert!(json.contains("\"isReadOnly\":false")); + } + + #[test] + fn test_storage_read_only_serialization() { + let storage = MtpStorageInfo { + id: 0x10001, + name: "Camera Storage".to_string(), + total_bytes: 32_000_000_000, + available_bytes: 16_000_000_000, + storage_type: Some("FixedRAM".to_string()), + is_read_only: true, + }; + let json = serde_json::to_string(&storage).unwrap(); + assert!(json.contains("\"isReadOnly\":true")); + } +} diff --git a/apps/desktop/src-tauri/src/mtp/watcher.rs b/apps/desktop/src-tauri/src/mtp/watcher.rs new file mode 100644 index 0000000..b011de6 --- /dev/null +++ b/apps/desktop/src-tauri/src/mtp/watcher.rs @@ -0,0 +1,236 @@ +//! USB hotplug watcher for MTP devices. +//! +//! Watches for USB device connect/disconnect events and emits Tauri events +//! when MTP devices are detected or removed. Uses nusb's hotplug API. + +use log::{debug, error, info, warn}; +use nusb::hotplug::HotplugEvent; +use std::collections::HashSet; +use std::sync::{Mutex, OnceLock}; +use tauri::{AppHandle, Emitter}; + +/// Global app handle for emitting events from the watcher +static APP_HANDLE: OnceLock = OnceLock::new(); + +/// Track known MTP device IDs for comparison +static KNOWN_DEVICES: OnceLock>> = OnceLock::new(); + +/// Flag to indicate watcher has been started +static WATCHER_STARTED: OnceLock<()> = OnceLock::new(); + +/// Payload for MTP device detected event +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MtpDeviceDetectedPayload { + /// The device ID + pub device_id: String, + /// Device name (if available) + pub name: Option, + /// USB vendor ID + pub vendor_id: u16, + /// USB product ID + pub product_id: u16, +} + +/// Payload for MTP device removed event +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MtpDeviceRemovedPayload { + /// The device ID + pub device_id: String, +} + +/// Gets the current set of MTP devices using mtp-rs discovery. +fn get_current_mtp_devices() -> HashSet { + let devices = super::list_mtp_devices(); + devices.into_iter().map(|d| d.id).collect() +} + +/// Checks for MTP device changes by comparing current state with known state. +/// Emits events for newly detected and removed devices. +fn check_for_device_changes() { + let current_devices = get_current_mtp_devices(); + + let known = match KNOWN_DEVICES.get() { + Some(k) => k, + None => return, + }; + + let mut known_guard = match known.lock() { + Ok(g) => g, + Err(_) => return, + }; + + // Find newly detected devices + for device_id in current_devices.difference(&known_guard) { + debug!("MTP device detected: {}", device_id); + emit_device_detected(device_id); + } + + // Find removed devices + for device_id in known_guard.difference(¤t_devices) { + debug!("MTP device removed: {}", device_id); + emit_device_removed(device_id); + } + + // Update known devices + *known_guard = current_devices; +} + +/// Emit a device detected event to the frontend. +fn emit_device_detected(device_id: &str) { + if let Some(app) = APP_HANDLE.get() { + // Try to get full device info + let devices = super::list_mtp_devices(); + let device_info = devices.iter().find(|d| d.id == device_id); + + let payload = MtpDeviceDetectedPayload { + device_id: device_id.to_string(), + name: device_info.and_then(|d| d.product.clone()), + vendor_id: device_info.map(|d| d.vendor_id).unwrap_or(0), + product_id: device_info.map(|d| d.product_id).unwrap_or(0), + }; + + if let Err(e) = app.emit("mtp-device-detected", payload) { + error!("Failed to emit mtp-device-detected event: {}", e); + } else { + info!("Emitted mtp-device-detected for {}", device_id); + } + } +} + +/// Emit a device removed event to the frontend. +fn emit_device_removed(device_id: &str) { + if let Some(app) = APP_HANDLE.get() { + let payload = MtpDeviceRemovedPayload { + device_id: device_id.to_string(), + }; + + if let Err(e) = app.emit("mtp-device-removed", payload) { + error!("Failed to emit mtp-device-removed event: {}", e); + } else { + info!("Emitted mtp-device-removed for {}", device_id); + } + } +} + +/// Starts the USB hotplug watcher for MTP devices. +/// Call this once at app initialization. +pub fn start_mtp_watcher(app: &AppHandle) { + // Only start once + if WATCHER_STARTED.set(()).is_err() { + debug!("MTP watcher already initialized"); + return; + } + + // Store app handle for event emission + if APP_HANDLE.set(app.clone()).is_err() { + warn!("MTP watcher app handle already set"); + } + + // Initialize known devices with current state + let initial_devices = get_current_mtp_devices(); + let known = KNOWN_DEVICES.get_or_init(|| Mutex::new(HashSet::new())); + if let Ok(mut known_guard) = known.lock() { + *known_guard = initial_devices.clone(); + debug!("Initial MTP devices: {:?}", known_guard); + } + + info!( + "Starting MTP device watcher (found {} initial device(s))", + initial_devices.len() + ); + + // Spawn the async hotplug watcher using Tauri's async runtime + // (tokio::spawn doesn't work here as we're in a synchronous setup hook) + let app_handle = app.clone(); + tauri::async_runtime::spawn(async move { + run_hotplug_watcher(app_handle).await; + }); +} + +/// The async hotplug watcher loop. +async fn run_hotplug_watcher(_app: AppHandle) { + // Use nusb's watch_devices to get notified of USB device changes + let hotplug_stream = match nusb::watch_devices() { + Ok(stream) => stream, + Err(e) => { + error!("Failed to start USB hotplug watcher: {}", e); + return; + } + }; + + debug!("USB hotplug watcher started"); + + // Process hotplug events + use futures_util::StreamExt; + let mut stream = hotplug_stream; + while let Some(event) = stream.next().await { + match event { + HotplugEvent::Connected(device_info) => { + debug!( + "USB device connected: {:04x}:{:04x} at {}:{}", + device_info.vendor_id(), + device_info.product_id(), + device_info.bus_number(), + device_info.device_address() + ); + // Give the device a moment to initialize + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + check_for_device_changes(); + } + HotplugEvent::Disconnected(device_id) => { + debug!("USB device disconnected: {:?}", device_id); + check_for_device_changes(); + } + } + } + + warn!("USB hotplug watcher stream ended unexpectedly"); +} + +/// Stops the MTP device watcher. +/// This is called on app shutdown to clean up resources. +#[allow(dead_code, reason = "Will be used for explicit cleanup on app shutdown")] +pub fn stop_mtp_watcher() { + // The watcher uses static state, so stopping just means we won't process more events + // The tokio task will be cancelled when the runtime shuts down + debug!("MTP watcher stopped"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_device_detected_payload_serialization() { + let payload = MtpDeviceDetectedPayload { + device_id: "mtp-336592896".to_string(), + name: Some("Pixel 8".to_string()), + vendor_id: 0x18d1, + product_id: 0x4ee1, + }; + let json = serde_json::to_string(&payload).unwrap(); + assert!(json.contains("deviceId")); + assert!(json.contains("mtp-336592896")); + assert!(json.contains("vendorId")); + } + + #[test] + fn test_device_removed_payload_serialization() { + let payload = MtpDeviceRemovedPayload { + device_id: "mtp-336592896".to_string(), + }; + let json = serde_json::to_string(&payload).unwrap(); + assert!(json.contains("deviceId")); + assert!(json.contains("mtp-336592896")); + } + + #[test] + fn test_get_current_mtp_devices() { + // This test just verifies the function runs without panicking + let devices = get_current_mtp_devices(); + // The function should complete without error (even if empty) + assert!(devices.is_empty() || !devices.is_empty()); + } +} diff --git a/apps/desktop/src-tauri/src/stubs/mod.rs b/apps/desktop/src-tauri/src/stubs/mod.rs index 9104ccb..37cd09a 100644 --- a/apps/desktop/src-tauri/src/stubs/mod.rs +++ b/apps/desktop/src-tauri/src/stubs/mod.rs @@ -4,6 +4,7 @@ //! on Linux for E2E testing purposes. They return sensible defaults that //! enable the core file manager functionality to work. +pub mod mtp; pub mod network; pub mod permissions; pub mod volumes; diff --git a/apps/desktop/src-tauri/src/stubs/mtp.rs b/apps/desktop/src-tauri/src/stubs/mtp.rs new file mode 100644 index 0000000..ceb4545 --- /dev/null +++ b/apps/desktop/src-tauri/src/stubs/mtp.rs @@ -0,0 +1,261 @@ +//! MTP stubs for Linux/non-macOS platforms. +//! +//! MTP support is currently macOS-only. This stub allows the app to compile +//! and run on Linux for E2E testing. + +use serde::{Deserialize, Serialize}; + +/// Information about a connected MTP device (stub version). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MtpDeviceInfo { + pub id: String, + pub vendor_id: u16, + pub product_id: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub manufacturer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub product: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub serial_number: Option, +} + +/// Information about a storage area on an MTP device (stub version). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MtpStorageInfo { + pub id: u32, + pub name: String, + pub total_bytes: u64, + pub available_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub storage_type: Option, + /// Whether this storage is read-only (e.g., PTP cameras). + pub is_read_only: bool, +} + +/// Information about a connected device (stub version). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectedDeviceInfo { + pub device: MtpDeviceInfo, + pub storages: Vec, +} + +/// Error types for MTP connection operations (stub version). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum MtpConnectionError { + NotSupported { message: String }, +} + +impl std::fmt::Display for MtpConnectionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotSupported { message } => write!(f, "{message}"), + } + } +} + +impl std::error::Error for MtpConnectionError {} + +/// Lists connected MTP devices (stub - always returns empty). +#[tauri::command] +pub fn list_mtp_devices() -> Vec { + // MTP is not supported on non-macOS platforms yet + Vec::new() +} + +/// Connects to an MTP device (stub - returns error). +#[tauri::command] +pub async fn connect_mtp_device(_device_id: String) -> Result { + Err(MtpConnectionError::NotSupported { + message: "MTP is not supported on this platform".to_string(), + }) +} + +/// Disconnects from an MTP device (stub - returns error). +#[tauri::command] +pub async fn disconnect_mtp_device(_device_id: String) -> Result<(), MtpConnectionError> { + Err(MtpConnectionError::NotSupported { + message: "MTP is not supported on this platform".to_string(), + }) +} + +/// Gets information about a connected MTP device (stub - returns None). +#[tauri::command] +pub fn get_mtp_device_info(_device_id: String) -> Option { + None +} + +/// Gets the ptpcamerad workaround command (stub - returns empty string). +#[tauri::command] +pub fn get_ptpcamerad_workaround_command() -> String { + String::new() +} + +/// Gets storage information for a connected device (stub - returns empty). +#[tauri::command] +pub fn get_mtp_storages(_device_id: String) -> Vec { + Vec::new() +} + +/// File entry stub matching the real FileEntry type. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileEntry { + pub name: String, + pub path: String, + pub is_directory: bool, + pub is_symlink: bool, + pub size: Option, + pub modified_at: Option, + pub created_at: Option, + pub added_at: Option, + pub opened_at: Option, + pub permissions: u32, + pub owner: String, + pub group: String, + pub icon_id: String, + pub extended_metadata_loaded: bool, +} + +/// Lists MTP directory contents (stub - returns error). +#[tauri::command] +pub async fn list_mtp_directory( + _device_id: String, + _storage_id: u32, + _path: String, +) -> Result, MtpConnectionError> { + Err(MtpConnectionError::NotSupported { + message: "MTP is not supported on this platform".to_string(), + }) +} + +// ============================================================================ +// Phase 4: File Operation stubs +// ============================================================================ + +/// Result of a successful MTP operation (stub version). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MtpOperationResult { + pub operation_id: String, + pub files_processed: usize, + pub bytes_transferred: u64, +} + +/// Information about an object on the device (stub version). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MtpObjectInfo { + pub handle: u32, + pub name: String, + pub path: String, + pub is_directory: bool, + pub size: Option, +} + +/// Downloads a file from an MTP device (stub - returns error). +#[tauri::command] +pub async fn download_mtp_file( + _device_id: String, + _storage_id: u32, + _object_path: String, + _local_dest: String, + _operation_id: String, +) -> Result { + Err(MtpConnectionError::NotSupported { + message: "MTP is not supported on this platform".to_string(), + }) +} + +/// Uploads a file to an MTP device (stub - returns error). +#[tauri::command] +pub async fn upload_to_mtp( + _device_id: String, + _storage_id: u32, + _local_path: String, + _dest_folder: String, + _operation_id: String, +) -> Result { + Err(MtpConnectionError::NotSupported { + message: "MTP is not supported on this platform".to_string(), + }) +} + +/// Deletes an object from an MTP device (stub - returns error). +#[tauri::command] +pub async fn delete_mtp_object( + _device_id: String, + _storage_id: u32, + _object_path: String, +) -> Result<(), MtpConnectionError> { + Err(MtpConnectionError::NotSupported { + message: "MTP is not supported on this platform".to_string(), + }) +} + +/// Creates a folder on an MTP device (stub - returns error). +#[tauri::command] +pub async fn create_mtp_folder( + _device_id: String, + _storage_id: u32, + _parent_path: String, + _folder_name: String, +) -> Result { + Err(MtpConnectionError::NotSupported { + message: "MTP is not supported on this platform".to_string(), + }) +} + +/// Renames an object on an MTP device (stub - returns error). +#[tauri::command] +pub async fn rename_mtp_object( + _device_id: String, + _storage_id: u32, + _object_path: String, + _new_name: String, +) -> Result { + Err(MtpConnectionError::NotSupported { + message: "MTP is not supported on this platform".to_string(), + }) +} + +/// Moves an object on an MTP device (stub - returns error). +#[tauri::command] +pub async fn move_mtp_object( + _device_id: String, + _storage_id: u32, + _object_path: String, + _new_parent_path: String, +) -> Result { + Err(MtpConnectionError::NotSupported { + message: "MTP is not supported on this platform".to_string(), + }) +} + +// ============================================================================ +// Phase 5: Copy/Export Operation stubs +// ============================================================================ + +/// Result of scanning an MTP path for copy operation (stub version). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MtpScanResult { + pub file_count: usize, + pub dir_count: usize, + pub total_bytes: u64, +} + +/// Scans an MTP path for copy statistics (stub - returns error). +#[tauri::command] +pub async fn scan_mtp_for_copy( + _device_id: String, + _storage_id: u32, + _path: String, +) -> Result { + Err(MtpConnectionError::NotSupported { + message: "MTP is not supported on this platform".to_string(), + }) +} diff --git a/apps/desktop/src-tauri/src/volumes/mod.rs b/apps/desktop/src-tauri/src/volumes/mod.rs index bd3f7da..197a987 100644 --- a/apps/desktop/src-tauri/src/volumes/mod.rs +++ b/apps/desktop/src-tauri/src/volumes/mod.rs @@ -157,7 +157,7 @@ fn get_main_volume() -> Option { } /// Get attached volumes (external drives, USB, etc.). -fn get_attached_volumes() -> Vec { +pub fn get_attached_volumes() -> Vec { use objc2_foundation::{NSArray, NSFileManager, NSURL, NSVolumeEnumerationOptions}; let file_manager = NSFileManager::defaultManager(); @@ -215,7 +215,7 @@ fn get_attached_volumes() -> Vec { } /// Get cloud drives (Dropbox, iCloud, Google Drive, etc.). -fn get_cloud_drives() -> Vec { +pub fn get_cloud_drives() -> Vec { let mut drives = Vec::new(); let home = dirs::home_dir().unwrap_or_default(); diff --git a/apps/desktop/src-tauri/src/volumes/watcher.rs b/apps/desktop/src-tauri/src/volumes/watcher.rs index 1c39840..36844d7 100644 --- a/apps/desktop/src-tauri/src/volumes/watcher.rs +++ b/apps/desktop/src-tauri/src/volumes/watcher.rs @@ -142,8 +142,11 @@ pub fn stop_volume_watcher() { debug!("Volume watcher stopped"); } -/// Emit a volume mounted event to the frontend. +/// Emit a volume mounted event to the frontend and register with VolumeManager. fn emit_volume_mounted(volume_path: &str) { + // Register the new volume with VolumeManager so it can be used for file operations + register_volume_with_manager(volume_path); + if let Some(app) = APP_HANDLE.get() { let payload = VolumeEventPayload { volume_path: volume_path.to_string(), @@ -156,8 +159,37 @@ fn emit_volume_mounted(volume_path: &str) { } } -/// Emit a volume unmounted event to the frontend. +/// Register a mounted volume with the VolumeManager. +fn register_volume_with_manager(volume_path: &str) { + use crate::file_system::get_volume_manager; + use crate::file_system::volume::LocalPosixVolume; + use std::path::Path; + use std::sync::Arc; + + // Generate volume ID from path (same logic as path_to_id in mod.rs) + let volume_id: String = volume_path + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-') + .collect::() + .to_lowercase(); + + // Get volume name from path + let name = Path::new(volume_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown") + .to_string(); + + let volume = Arc::new(LocalPosixVolume::new(&name, volume_path)); + get_volume_manager().register(&volume_id, volume); + debug!("Registered mounted volume: {} -> {}", volume_id, volume_path); +} + +/// Emit a volume unmounted event to the frontend and unregister from VolumeManager. fn emit_volume_unmounted(volume_path: &str) { + // Unregister the volume from VolumeManager + unregister_volume_from_manager(volume_path); + if let Some(app) = APP_HANDLE.get() { let payload = VolumeEventPayload { volume_path: volume_path.to_string(), @@ -170,6 +202,21 @@ fn emit_volume_unmounted(volume_path: &str) { } } +/// Unregister a volume from the VolumeManager. +fn unregister_volume_from_manager(volume_path: &str) { + use crate::file_system::get_volume_manager; + + // Generate volume ID from path (same logic as path_to_id in mod.rs) + let volume_id: String = volume_path + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-') + .collect::() + .to_lowercase(); + + get_volume_manager().unregister(&volume_id); + debug!("Unregistered volume: {} ({})", volume_id, volume_path); +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/desktop/src/app.css b/apps/desktop/src/app.css index 60e998e..cdfb1e4 100644 --- a/apps/desktop/src/app.css +++ b/apps/desktop/src/app.css @@ -43,6 +43,9 @@ /* === Semantic Colors === */ --color-allow: #2e7d32; --color-error: #d32f2f; + --color-error-bg: #fef2f2; + --color-error-border: #fecaca; + --color-error-text: #b91c1c; --color-warning: #e65100; --color-warning-bg: rgba(230, 81, 0, 0.1); @@ -102,6 +105,9 @@ /* === Semantic Colors === */ --color-error: #f44336; + --color-error-bg: #450a0a; + --color-error-border: #7f1d1d; + --color-error-text: #fca5a5; --color-warning: #f5a623; --color-warning-bg: rgba(245, 166, 35, 0.15); diff --git a/apps/desktop/src/lib/AlertDialog.svelte b/apps/desktop/src/lib/AlertDialog.svelte new file mode 100644 index 0000000..e01d3f8 --- /dev/null +++ b/apps/desktop/src/lib/AlertDialog.svelte @@ -0,0 +1,114 @@ + + + + + diff --git a/apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte b/apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte index 9987cdf..7e11661 100644 --- a/apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte +++ b/apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte @@ -6,6 +6,7 @@ import NewFolderDialog from './NewFolderDialog.svelte' import CopyDialog from '../write-operations/CopyDialog.svelte' import CopyProgressDialog from '../write-operations/CopyProgressDialog.svelte' + import CopyErrorDialog from '../write-operations/CopyErrorDialog.svelte' import { toBackendIndices, toBackendCursorIndex } from '../write-operations/copy-dialog-utils' import { formatBytes } from '$lib/tauri-commands' import { @@ -32,7 +33,15 @@ getListingStats, findFileIndex, } from '$lib/tauri-commands' - import type { VolumeInfo, SortColumn, SortOrder, NetworkHost, DirectoryDiff } from './types' + import type { + VolumeInfo, + SortColumn, + SortOrder, + NetworkHost, + DirectoryDiff, + ConflictResolution, + WriteOperationError, + } from './types' import { defaultSortOrders, DEFAULT_SORT_BY } from './types' import { ensureFontMetricsLoaded } from '$lib/font-metrics' import { removeExtension } from './new-folder-utils' @@ -50,6 +59,8 @@ import { initNetworkDiscovery, cleanupNetworkDiscovery } from '$lib/network-store.svelte' import { openFileViewer } from '$lib/file-viewer/open-viewer' import { getAppLogger } from '$lib/logger' + import AlertDialog from '$lib/AlertDialog.svelte' + import { getMtpVolumes } from '$lib/mtp' const log = getAppLogger('fileExplorer') @@ -92,6 +103,8 @@ sourceFolderPath: string sortColumn: SortColumn sortOrder: SortOrder + sourceVolumeId: string + destVolumeId: string } | null>(null) // Copy progress dialog state @@ -104,6 +117,9 @@ sortColumn: SortColumn sortOrder: SortOrder previewId: string | null + sourceVolumeId: string + destVolumeId: string + conflictResolution: ConflictResolution } | null>(null) // New folder dialog state @@ -113,6 +129,20 @@ listingId: string showHiddenFiles: boolean initialName: string + volumeId: string + } | null>(null) + + // Alert dialog state + let showAlertDialog = $state(false) + let alertDialogProps = $state<{ + title: string + message: string + } | null>(null) + + // Copy error dialog state + let showCopyErrorDialog = $state(false) + let copyErrorProps = $state<{ + error: WriteOperationError } | null>(null) // Navigation history for each pane (per-pane, session-only) @@ -695,6 +725,40 @@ volumes = await listVolumes() } + /** + * Handles fatal MTP errors for the left pane. + * Falls back to the default volume when the MTP device becomes unavailable. + */ + async function handleLeftMtpFatalError(errorMessage: string) { + log.warn('Left pane MTP fatal error, falling back to default volume: {error}', { error: errorMessage }) + + const defaultVolumeId = await getDefaultVolumeId() + const defaultVolume = volumes.find((v) => v.id === defaultVolumeId) + const defaultPath = defaultVolume?.path ?? '~' + + leftVolumeId = defaultVolumeId + leftPath = defaultPath + leftHistory = push(leftHistory, { volumeId: defaultVolumeId, path: defaultPath }) + void saveAppStatus({ leftVolumeId: defaultVolumeId, leftPath: defaultPath }) + } + + /** + * Handles fatal MTP errors for the right pane. + * Falls back to the default volume when the MTP device becomes unavailable. + */ + async function handleRightMtpFatalError(errorMessage: string) { + log.warn('Right pane MTP fatal error, falling back to default volume: {error}', { error: errorMessage }) + + const defaultVolumeId = await getDefaultVolumeId() + const defaultVolume = volumes.find((v) => v.id === defaultVolumeId) + const defaultPath = defaultVolume?.path ?? '~' + + rightVolumeId = defaultVolumeId + rightPath = defaultPath + rightHistory = push(rightHistory, { volumeId: defaultVolumeId, path: defaultPath }) + void saveAppStatus({ rightVolumeId: defaultVolumeId, rightPath: defaultPath }) + } + /** * Resolves a path to a valid existing path by walking up the parent tree. * Returns null if even the root doesn't exist (volume unmounted). @@ -855,6 +919,7 @@ const isLeft = focusedPane === 'left' const paneRef = isLeft ? leftPaneRef : rightPaneRef const path = isLeft ? leftPath : rightPath + const volumeIdForPane = isLeft ? leftVolumeId : rightVolumeId // eslint-disable-next-line @typescript-eslint/no-unsafe-call const paneListingId = paneRef?.getListingId?.() as string | undefined @@ -867,6 +932,7 @@ listingId: paneListingId, showHiddenFiles, initialName, + volumeId: volumeIdForPane, } showNewFolderDialog = true } @@ -943,7 +1009,51 @@ export async function openCopyDialog() { const isLeft = focusedPane === 'left' const sourcePaneRef = isLeft ? leftPaneRef : rightPaneRef + const sourceVolId = isLeft ? leftVolumeId : rightVolumeId + const destVolId = isLeft ? rightVolumeId : leftVolumeId + + // Check if destination volume is read-only (e.g., PTP cameras) + const destVolume = getDestinationVolumeInfo(destVolId) + if (destVolume?.isReadOnly) { + alertDialogProps = { + title: 'Read-only device', + message: `"${destVolume.name}" is read-only. You can copy files from it, but not to it.`, + } + showAlertDialog = true + return + } + + // Use unified copy dialog for all supported volume combinations (including MTP-to-MTP) + await openUnifiedCopyDialog(sourcePaneRef, isLeft, sourceVolId, destVolId) + } + /** Gets volume info for a given volume ID, checking both regular volumes and MTP volumes. */ + function getDestinationVolumeInfo(volumeId: string): { name: string; isReadOnly: boolean } | undefined { + // Check MTP volumes first (they have the isReadOnly flag) + if (volumeId.startsWith('mtp-')) { + const mtpVolumes = getMtpVolumes() + const mtpVolume = mtpVolumes.find((v) => v.id === volumeId || v.deviceId === volumeId) + if (mtpVolume) { + return { name: mtpVolume.name, isReadOnly: mtpVolume.isReadOnly } + } + } + + // Regular volumes (currently none are read-only, but this supports future use) + const volume = volumes.find((v) => v.id === volumeId) + if (volume) { + return { name: volume.name, isReadOnly: volume.isReadOnly ?? false } + } + + return undefined + } + + /** Opens the unified copy dialog for all volume types (local, MTP, etc.). */ + async function openUnifiedCopyDialog( + sourcePaneRef: FilePane | undefined, + isLeft: boolean, + sourceVolId: string, + destVolId: string, + ) { // eslint-disable-next-line @typescript-eslint/no-unsafe-call const listingId = sourcePaneRef?.getListingId?.() as string | undefined if (!listingId) return @@ -955,8 +1065,22 @@ const hasSelection = selectedIndices && selectedIndices.length > 0 const props = hasSelection - ? await buildCopyPropsFromSelection(listingId, selectedIndices, hasParent ?? false, isLeft) - : await buildCopyPropsFromCursor(listingId, sourcePaneRef, hasParent ?? false, isLeft) + ? await buildCopyPropsFromSelection( + listingId, + selectedIndices, + hasParent ?? false, + isLeft, + sourceVolId, + destVolId, + ) + : await buildCopyPropsFromCursor( + listingId, + sourcePaneRef, + hasParent ?? false, + isLeft, + sourceVolId, + destVolId, + ) if (props) { copyDialogProps = props @@ -974,6 +1098,8 @@ sourceFolderPath: string sortColumn: SortColumn sortOrder: SortOrder + sourceVolumeId: string + destVolumeId: string } /** Builds copy dialog props from selected files. */ @@ -982,6 +1108,8 @@ selectedIndices: number[], hasParent: boolean, isLeft: boolean, + sourceVolId: string, + destVolId: string, ): Promise { // Convert frontend indices to backend indices (adjust for ".." entry) const backendIndices = toBackendIndices(selectedIndices, hasParent) @@ -1001,6 +1129,8 @@ sourceFolderPath: isLeft ? leftPath : rightPath, sortColumn: isLeft ? leftSortBy : rightSortBy, sortOrder: isLeft ? leftSortOrder : rightSortOrder, + sourceVolumeId: sourceVolId, + destVolumeId: destVolId, } } @@ -1010,6 +1140,8 @@ paneRef: FilePane | undefined, hasParent: boolean, isLeft: boolean, + sourceVolId: string, + destVolId: string, ): Promise { // eslint-disable-next-line @typescript-eslint/no-unsafe-call const cursorIndex = paneRef?.getCursorIndex?.() as number | undefined @@ -1029,10 +1161,17 @@ sourceFolderPath: isLeft ? leftPath : rightPath, sortColumn: isLeft ? leftSortBy : rightSortBy, sortOrder: isLeft ? leftSortOrder : rightSortOrder, + sourceVolumeId: sourceVolId, + destVolumeId: destVolId, } } - function handleCopyConfirm(destination: string, _volumeId: string, previewId: string | null) { + function handleCopyConfirm( + destination: string, + _volumeId: string, + previewId: string | null, + conflictResolution: ConflictResolution, + ) { if (!copyDialogProps) return // Store the props needed for the progress dialog @@ -1045,6 +1184,9 @@ sortColumn: copyDialogProps.sortColumn, sortOrder: copyDialogProps.sortOrder, previewId, + sourceVolumeId: copyDialogProps.sourceVolumeId, + destVolumeId: copyDialogProps.destVolumeId, + conflictResolution, } // Close copy dialog and open progress dialog @@ -1085,11 +1227,25 @@ containerElement?.focus() } - function handleCopyError(error: string) { - log.error(`Copy failed: ${error}`) + function handleCopyError(error: WriteOperationError) { + log.error('Copy failed: {errorType}', { errorType: error.type, error }) + + // Refresh the destination pane to show any files that were partially copied + const destPaneRef = copyProgressProps?.direction === 'right' ? rightPaneRef : leftPaneRef + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + destPaneRef?.refreshView?.() + showCopyProgressDialog = false copyProgressProps = null - // TODO: Show error notification/toast + + // Show the error dialog + copyErrorProps = { error } + showCopyErrorDialog = true + } + + function handleCopyErrorClose() { + showCopyErrorDialog = false + copyErrorProps = null containerElement?.focus() } @@ -1340,6 +1496,7 @@ onSortChange={handleLeftSortChange} onNetworkHostChange={handleLeftNetworkHostChange} onCancelLoading={handleLeftCancelLoading} + onMtpFatalError={handleLeftMtpFatalError} /> @@ -1361,6 +1518,7 @@ onSortChange={handleRightSortChange} onNetworkHostChange={handleRightNetworkHostChange} onCancelLoading={handleRightCancelLoading} + onMtpFatalError={handleRightMtpFatalError} /> {:else} @@ -1380,6 +1538,8 @@ sourceFolderPath={copyDialogProps.sourceFolderPath} sortColumn={copyDialogProps.sortColumn} sortOrder={copyDialogProps.sortOrder} + sourceVolumeId={copyDialogProps.sourceVolumeId} + destVolumeId={copyDialogProps.destVolumeId} onConfirm={handleCopyConfirm} onCancel={handleCopyCancel} /> @@ -1394,6 +1554,9 @@ sortColumn={copyProgressProps.sortColumn} sortOrder={copyProgressProps.sortOrder} previewId={copyProgressProps.previewId} + sourceVolumeId={copyProgressProps.sourceVolumeId} + destVolumeId={copyProgressProps.destVolumeId} + conflictResolution={copyProgressProps.conflictResolution} onComplete={handleCopyComplete} onCancelled={handleCopyCancelled} onError={handleCopyError} @@ -1406,11 +1569,28 @@ listingId={newFolderDialogProps.listingId} showHiddenFiles={newFolderDialogProps.showHiddenFiles} initialName={newFolderDialogProps.initialName} + volumeId={newFolderDialogProps.volumeId} onCreated={handleNewFolderCreated} onCancel={handleNewFolderCancel} /> {/if} +{#if showAlertDialog && alertDialogProps} + { + showAlertDialog = false + alertDialogProps = null + containerElement?.focus() + }} + /> +{/if} + +{#if showCopyErrorDialog && copyErrorProps} + +{/if} + diff --git a/apps/desktop/src/lib/file-explorer/NewFolderDialog.svelte b/apps/desktop/src/lib/file-explorer/NewFolderDialog.svelte index ba1e342..85ca685 100644 --- a/apps/desktop/src/lib/file-explorer/NewFolderDialog.svelte +++ b/apps/desktop/src/lib/file-explorer/NewFolderDialog.svelte @@ -20,11 +20,13 @@ showHiddenFiles: boolean /** Pre-fill name (filename without extension, or empty) */ initialName: string + /** Volume ID for the filesystem (e.g., "root" for local, "mtp-336592896:65537" for MTP) */ + volumeId?: string onCreated: (folderName: string) => void onCancel: () => void } - const { currentPath, listingId, showHiddenFiles, initialName, onCreated, onCancel }: Props = $props() + const { currentPath, listingId, showHiddenFiles, initialName, volumeId, onCreated, onCancel }: Props = $props() let folderName = $state(initialName) let errorMessage = $state('') @@ -140,7 +142,7 @@ const trimmed = folderName.trim() if (!trimmed || errorMessage) return try { - await createDirectory(currentPath, trimmed) + await createDirectory(currentPath, trimmed, volumeId) onCreated(trimmed) } catch (e) { errorMessage = String(e) diff --git a/apps/desktop/src/lib/file-explorer/VolumeBreadcrumb.svelte b/apps/desktop/src/lib/file-explorer/VolumeBreadcrumb.svelte index 8af1880..7964aa7 100644 --- a/apps/desktop/src/lib/file-explorer/VolumeBreadcrumb.svelte +++ b/apps/desktop/src/lib/file-explorer/VolumeBreadcrumb.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from 'svelte' import { listVolumes, findContainingVolume, listen, type UnlistenFn } from '$lib/tauri-commands' import type { VolumeInfo, LocationCategory } from './types' + import { getMtpVolumes, initialize as initMtpStore, scanDevices as scanMtpDevices, type MtpVolume } from '$lib/mtp' interface Props { volumeId: string @@ -12,11 +13,15 @@ const { volumeId, currentPath, onVolumeChange }: Props = $props() let volumes = $state([]) + let mtpVolumes = $state([]) let isOpen = $state(false) let highlightedIndex = $state(-1) let dropdownRef: HTMLDivElement | undefined = $state() let unlistenMount: UnlistenFn | undefined let unlistenUnmount: UnlistenFn | undefined + let unlistenMtpDetected: UnlistenFn | undefined + let unlistenMtpConnected: UnlistenFn | undefined + let unlistenMtpRemoved: UnlistenFn | undefined // The ID of the actual volume that contains the current path // This is used to show the checkmark on the correct volume, not on favorites @@ -24,10 +29,26 @@ // Current volume info derived from volumes list (the actual containing volume) // Special case: 'network' is a virtual volume, not from the backend + // Special case: MTP volumes are handled via mtp:// paths const currentVolume = $derived( volumeId === 'network' ? { id: 'network', name: 'Network', path: 'smb://', category: 'network' as const, isEjectable: false } - : volumes.find((v) => v.id === containingVolumeId), + : volumeId.startsWith('mtp-') + ? (() => { + // Try to find the exact MTP volume (device:storage format) + const mtpVolume = mtpVolumes.find((v) => v.id === volumeId || v.deviceId === volumeId) + return mtpVolume + ? { + id: mtpVolume.id, + name: mtpVolume.name, + path: mtpVolume.path, + category: 'mobile_device' as const, + isEjectable: true, + isReadOnly: mtpVolume.isReadOnly, + } + : undefined + })() + : volumes.find((v) => v.id === containingVolumeId), ) const currentVolumeName = $derived(currentVolume?.name ?? 'Volume') const currentVolumeIcon = $derived(getIconForVolume(currentVolume)) @@ -48,13 +69,17 @@ } }) - // Get appropriate icon for a volume (use cloud icon for cloud drives) + // Get appropriate icon for a volume (use cloud icon for cloud drives, mobile icon for devices) function getIconForVolume(volume: VolumeInfo | undefined): string | undefined { if (!volume) return undefined // Cloud drives use the cloud icon if (volume.category === 'cloud_drive') { return '/icons/sync-online-only.svg' } + // Mobile devices use the mobile device icon + if (volume.category === 'mobile_device') { + return '/icons/mobile-device.svg' + } // Network uses globe/network emoji as fallback if (volume.category === 'network' && !volume.icon) { return undefined // Will use placeholder @@ -68,13 +93,29 @@ { category: 'main_volume', label: 'Volumes' }, { category: 'attached_volume', label: '' }, // No label, continues main volumes { category: 'cloud_drive', label: 'Cloud' }, + { category: 'mobile_device', label: 'Mobile' }, { category: 'network', label: 'Network' }, ] const groups: { category: LocationCategory; label: string; items: VolumeInfo[] }[] = [] for (const { category, label } of categoryOrder) { - if (category === 'network') { + if (category === 'mobile_device') { + // Mobile section: show MTP volumes (one per storage on connected devices) + const mobileItems: VolumeInfo[] = mtpVolumes.map((v) => ({ + id: v.id, + name: v.name, + path: v.path, + category: 'mobile_device' as const, + icon: undefined, // Will use 📱 placeholder + isEjectable: true, + isReadOnly: v.isReadOnly, + })) + + if (mobileItems.length > 0) { + groups.push({ category, label, items: mobileItems }) + } + } else if (category === 'network') { // Network section: show a single "Network" item that opens NetworkBrowser // Also include any pre-mounted network volumes (mounted shares) const networkVolumes = vols.filter((v) => v.category === 'network') @@ -226,6 +267,10 @@ // before the mount event is received $effect(() => { if (volumeId && volumeId !== 'network') { + // Skip check for MTP volumes - they're tracked separately in mtpVolumes + if (volumeId.startsWith('mtp-')) { + return + } const found = volumes.find((v) => v.id === volumeId) if (!found && volumes.length > 0) { // Volume not found but we have a list - might be a newly mounted volume @@ -234,8 +279,23 @@ } }) + async function loadMtpVolumes() { + // Initialize the MTP store if needed, scan for devices, then get volumes + await initMtpStore() + await scanMtpDevices() + mtpVolumes = getMtpVolumes() + } + + async function refreshMtpVolumes() { + // Small delay to let mtp-store's event handler finish scanning first + await new Promise((resolve) => setTimeout(resolve, 100)) + await initMtpStore() + mtpVolumes = getMtpVolumes() + } + onMount(async () => { await loadVolumes() + await loadMtpVolumes() await updateContainingVolume(currentPath) // Listen for volume mount/unmount events @@ -247,6 +307,21 @@ void loadVolumes() }) + // Listen for MTP device hotplug events + // Use refreshMtpVolumes() to avoid race with mtp-store's event handler + unlistenMtpDetected = await listen<{ deviceId: string }>('mtp-device-detected', () => { + void refreshMtpVolumes() + }) + + // Listen for MTP device connection (this is when isReadOnly is determined via probe) + unlistenMtpConnected = await listen<{ deviceId: string }>('mtp-device-connected', () => { + void refreshMtpVolumes() + }) + + unlistenMtpRemoved = await listen<{ deviceId: string }>('mtp-device-removed', () => { + void refreshMtpVolumes() + }) + // Close on click outside document.addEventListener('click', handleClickOutside) document.addEventListener('keydown', handleDocumentKeyDown) @@ -255,6 +330,9 @@ onDestroy(() => { unlistenMount?.() unlistenUnmount?.() + unlistenMtpDetected?.() + unlistenMtpConnected?.() + unlistenMtpRemoved?.() document.removeEventListener('click', handleClickOutside) document.removeEventListener('keydown', handleDocumentKeyDown) }) @@ -280,6 +358,9 @@ 🌐 {/if} {currentVolumeName} + {#if currentVolume?.isReadOnly} + 🔒 + {/if} @@ -314,6 +395,8 @@ {/if} {#if volume.category === 'cloud_drive'} + {:else if volume.category === 'mobile_device'} + {:else if volume.category === 'network'} 🌐 {:else if volume.icon} @@ -322,6 +405,9 @@ 📁 {/if} {volume.name} + {#if volume.isReadOnly} + 🔒 + {/if} {/each} {/each} @@ -454,4 +540,10 @@ width: 14px; flex-shrink: 0; } + + .read-only-indicator { + font-size: 12px; + margin-left: auto; + opacity: 0.7; + } diff --git a/apps/desktop/src/lib/file-explorer/integration.test.ts b/apps/desktop/src/lib/file-explorer/integration.test.ts index d4f4234..ef9ceed 100644 --- a/apps/desktop/src/lib/file-explorer/integration.test.ts +++ b/apps/desktop/src/lib/file-explorer/integration.test.ts @@ -83,6 +83,13 @@ vi.mock('$lib/tauri-commands', () => ({ listNetworkHosts: vi.fn().mockResolvedValue([]), getNetworkDiscoveryState: vi.fn().mockResolvedValue('idle'), resolveNetworkHost: vi.fn().mockResolvedValue(null), + // MTP device mocks + listMtpDevices: vi.fn().mockResolvedValue([]), + onMtpDeviceConnected: vi.fn().mockResolvedValue(() => {}), + onMtpDeviceDisconnected: vi.fn().mockResolvedValue(() => {}), + onMtpExclusiveAccessError: vi.fn().mockResolvedValue(() => {}), + onMtpDeviceDetected: vi.fn().mockResolvedValue(() => {}), + onMtpDeviceRemoved: vi.fn().mockResolvedValue(() => {}), })) vi.mock('$lib/icon-cache', async () => { diff --git a/apps/desktop/src/lib/file-explorer/types.ts b/apps/desktop/src/lib/file-explorer/types.ts index 8a221b3..5bae3fd 100644 --- a/apps/desktop/src/lib/file-explorer/types.ts +++ b/apps/desktop/src/lib/file-explorer/types.ts @@ -73,6 +73,8 @@ export interface ListingCompleteEvent { listingId: string totalCount: number maxFilenameWidth?: number + /** Root path of the volume this listing belongs to */ + volumeRoot: string } /** @@ -123,7 +125,13 @@ export interface DirectoryDiff { /** * Category of a location item. */ -export type LocationCategory = 'favorite' | 'main_volume' | 'attached_volume' | 'cloud_drive' | 'network' +export type LocationCategory = + | 'favorite' + | 'main_volume' + | 'attached_volume' + | 'cloud_drive' + | 'network' + | 'mobile_device' /** * Information about a location (volume, folder, or cloud drive). @@ -141,6 +149,8 @@ export interface VolumeInfo { icon?: string /** Whether this can be ejected */ isEjectable: boolean + /** Whether this volume is read-only (e.g., PTP cameras) */ + isReadOnly?: boolean } // ============================================================================ diff --git a/apps/desktop/src/lib/logger.ts b/apps/desktop/src/lib/logger.ts index 56a5f2e..f7e707c 100644 --- a/apps/desktop/src/lib/logger.ts +++ b/apps/desktop/src/lib/logger.ts @@ -47,6 +47,7 @@ const debugCategories: string[] = [ 'settings', // Enable to debug settings dialog initialization and persistence 'reactive-settings', // Enable to debug reactive settings updates 'shortcuts', // Enable to debug keyboard shortcut persistence + 'mtp', // Enable to debug MTP device operations ] // Track if verbose logging is enabled for reconfiguration diff --git a/apps/desktop/src/lib/mtp/PtpcameradDialog.svelte b/apps/desktop/src/lib/mtp/PtpcameradDialog.svelte new file mode 100644 index 0000000..fbf61b1 --- /dev/null +++ b/apps/desktop/src/lib/mtp/PtpcameradDialog.svelte @@ -0,0 +1,288 @@ + + + + + diff --git a/apps/desktop/src/lib/mtp/index.ts b/apps/desktop/src/lib/mtp/index.ts new file mode 100644 index 0000000..9bd807d --- /dev/null +++ b/apps/desktop/src/lib/mtp/index.ts @@ -0,0 +1,9 @@ +// MTP (Android device) support components + +export { default as PtpcameradDialog } from './PtpcameradDialog.svelte' + +// MTP store for device state management +export * from './mtp-store.svelte' + +// MTP path utilities +export * from './mtp-path-utils' diff --git a/apps/desktop/src/lib/mtp/mtp-path-utils.test.ts b/apps/desktop/src/lib/mtp/mtp-path-utils.test.ts new file mode 100644 index 0000000..2dbe514 --- /dev/null +++ b/apps/desktop/src/lib/mtp/mtp-path-utils.test.ts @@ -0,0 +1,152 @@ +/** + * Tests for MTP path utility functions + */ +import { describe, it, expect } from 'vitest' +import { + parseMtpPath, + constructMtpPath, + isMtpVolumeId, + getMtpParentPath, + joinMtpPath, + getMtpDisplayPath, +} from './mtp-path-utils' + +describe('parseMtpPath', () => { + it('parses a valid MTP path with device and storage IDs', () => { + const result = parseMtpPath('mtp://0-5/65537') + expect(result).toEqual({ + deviceId: '0-5', + storageId: 65537, + path: '', + }) + }) + + it('parses a valid MTP path with nested path', () => { + const result = parseMtpPath('mtp://0-5/65537/DCIM/Camera') + expect(result).toEqual({ + deviceId: '0-5', + storageId: 65537, + path: 'DCIM/Camera', + }) + }) + + it('parses a valid MTP path with single folder', () => { + const result = parseMtpPath('mtp://device-123/1/Documents') + expect(result).toEqual({ + deviceId: 'device-123', + storageId: 1, + path: 'Documents', + }) + }) + + it('returns null for non-MTP paths', () => { + expect(parseMtpPath('/Users/test')).toBeNull() + expect(parseMtpPath('file://path')).toBeNull() + expect(parseMtpPath('')).toBeNull() + }) + + it('returns null for invalid MTP paths', () => { + expect(parseMtpPath('mtp://')).toBeNull() + expect(parseMtpPath('mtp://device')).toBeNull() + expect(parseMtpPath('mtp://device/notanumber')).toBeNull() + }) +}) + +describe('constructMtpPath', () => { + it('constructs a base MTP path without inner path', () => { + expect(constructMtpPath('0-5', 65537)).toBe('mtp://0-5/65537') + }) + + it('constructs a path with empty string inner path', () => { + expect(constructMtpPath('0-5', 65537, '')).toBe('mtp://0-5/65537') + }) + + it('constructs a path with "/" inner path', () => { + expect(constructMtpPath('0-5', 65537, '/')).toBe('mtp://0-5/65537') + }) + + it('constructs a path with nested inner path', () => { + expect(constructMtpPath('0-5', 65537, 'DCIM/Camera')).toBe('mtp://0-5/65537/DCIM/Camera') + }) + + it('normalizes inner path with leading slash', () => { + expect(constructMtpPath('0-5', 65537, '/DCIM/Camera')).toBe('mtp://0-5/65537/DCIM/Camera') + }) + + it('handles single folder path', () => { + expect(constructMtpPath('device', 1, 'Downloads')).toBe('mtp://device/1/Downloads') + }) +}) + +describe('isMtpVolumeId', () => { + it('returns true for volume ID with colon format', () => { + expect(isMtpVolumeId('0-5:65537')).toBe(true) + expect(isMtpVolumeId('device-123:1')).toBe(true) + }) + + it('returns true for volume ID with mtp- prefix', () => { + expect(isMtpVolumeId('mtp-336592896')).toBe(true) + expect(isMtpVolumeId('mtp-336592896:65537')).toBe(true) + }) + + it('returns false for local volume IDs', () => { + expect(isMtpVolumeId('local')).toBe(false) + expect(isMtpVolumeId('/')).toBe(false) + expect(isMtpVolumeId('Macintosh HD')).toBe(false) + }) +}) + +describe('getMtpParentPath', () => { + it('returns null for storage root path', () => { + expect(getMtpParentPath('mtp://0-5/65537')).toBeNull() + expect(getMtpParentPath('mtp://0-5/65537/')).toBeNull() // Edge case: parseMtpPath returns empty path + }) + + it('returns storage root for single-level path', () => { + expect(getMtpParentPath('mtp://0-5/65537/DCIM')).toBe('mtp://0-5/65537') + }) + + it('returns parent folder for nested path', () => { + expect(getMtpParentPath('mtp://0-5/65537/DCIM/Camera')).toBe('mtp://0-5/65537/DCIM') + }) + + it('returns parent for deeply nested path', () => { + expect(getMtpParentPath('mtp://0-5/65537/a/b/c/d')).toBe('mtp://0-5/65537/a/b/c') + }) + + it('returns null for non-MTP paths', () => { + expect(getMtpParentPath('/Users/test')).toBeNull() + }) +}) + +describe('joinMtpPath', () => { + it('joins child to storage root', () => { + expect(joinMtpPath('mtp://0-5/65537', 'DCIM')).toBe('mtp://0-5/65537/DCIM') + }) + + it('joins child to nested path', () => { + expect(joinMtpPath('mtp://0-5/65537/DCIM', 'Camera')).toBe('mtp://0-5/65537/DCIM/Camera') + }) + + it('returns original for non-MTP paths', () => { + expect(joinMtpPath('/Users/test', 'Documents')).toBe('/Users/test') + }) +}) + +describe('getMtpDisplayPath', () => { + it('returns "/" for storage root', () => { + expect(getMtpDisplayPath('mtp://0-5/65537')).toBe('/') + }) + + it('returns display path for nested path', () => { + expect(getMtpDisplayPath('mtp://0-5/65537/DCIM/Camera')).toBe('/DCIM/Camera') + }) + + it('returns display path for single folder', () => { + expect(getMtpDisplayPath('mtp://0-5/65537/Documents')).toBe('/Documents') + }) + + it('returns original path for non-MTP paths', () => { + expect(getMtpDisplayPath('/Users/test')).toBe('/Users/test') + }) +}) diff --git a/apps/desktop/src/lib/mtp/mtp-path-utils.ts b/apps/desktop/src/lib/mtp/mtp-path-utils.ts new file mode 100644 index 0000000..d2abc5e --- /dev/null +++ b/apps/desktop/src/lib/mtp/mtp-path-utils.ts @@ -0,0 +1,114 @@ +/** + * Utilities for parsing and constructing MTP paths. + * + * MTP path format: mtp://{deviceId}/{storageId}/{path} + * Examples: + * - mtp://0-5/65537 (root of storage) + * - mtp://0-5/65537/DCIM/Camera (subfolder) + * + * Volume ID format: "mtp-{deviceId}-{storageId}" or just "{deviceId}:{storageId}" + */ + +/** Parsed MTP path components. */ +export interface ParsedMtpPath { + deviceId: string + storageId: number + /** Path within the storage (empty string for root). */ + path: string +} + +/** + * Parses an MTP path into its components. + * @returns Parsed path, or null if not a valid MTP path. + */ +export function parseMtpPath(path: string): ParsedMtpPath | null { + if (!path.startsWith('mtp://')) { + return null + } + + // Remove the mtp:// prefix + const rest = path.slice(6) + + // Split by / + const parts = rest.split('/') + + if (parts.length < 2) { + return null + } + + const deviceId = parts[0] + const storageId = parseInt(parts[1], 10) + + if (isNaN(storageId)) { + return null + } + + // Remaining parts form the path within the storage + const innerPath = parts.slice(2).join('/') + + return { + deviceId, + storageId, + path: innerPath, + } +} + +/** + * Constructs an MTP path from components. + */ +export function constructMtpPath(deviceId: string, storageId: number, path: string = ''): string { + const base = `mtp://${deviceId}/${String(storageId)}` + if (!path || path === '/') { + return base + } + // Ensure path doesn't start with / to avoid double slashes + const normalizedPath = path.startsWith('/') ? path.slice(1) : path + return `${base}/${normalizedPath}` +} + +/** + * Checks if a volume ID represents an MTP volume. + */ +export function isMtpVolumeId(volumeId: string): boolean { + return volumeId.includes(':') || volumeId.startsWith('mtp-') +} + +/** + * Gets the parent path for an MTP path. + * Returns the storage root if already at root. + */ +export function getMtpParentPath(path: string): string | null { + const parsed = parseMtpPath(path) + if (!parsed) return null + + if (!parsed.path || parsed.path === '/') { + // Already at storage root, no parent + return null + } + + const lastSlash = parsed.path.lastIndexOf('/') + const parentInnerPath = lastSlash > 0 ? parsed.path.slice(0, lastSlash) : '' + + return constructMtpPath(parsed.deviceId, parsed.storageId, parentInnerPath) +} + +/** + * Joins an MTP path with a child folder name. + */ +export function joinMtpPath(basePath: string, childName: string): string { + const parsed = parseMtpPath(basePath) + if (!parsed) return basePath + + const newPath = parsed.path ? `${parsed.path}/${childName}` : childName + return constructMtpPath(parsed.deviceId, parsed.storageId, newPath) +} + +/** + * Gets the display path for an MTP path (the path within the storage). + * Returns "/" for storage root. + */ +export function getMtpDisplayPath(path: string): string { + const parsed = parseMtpPath(path) + if (!parsed) return path + return parsed.path ? `/${parsed.path}` : '/' +} diff --git a/apps/desktop/src/lib/mtp/mtp-store.svelte.ts b/apps/desktop/src/lib/mtp/mtp-store.svelte.ts new file mode 100644 index 0000000..52ffdec --- /dev/null +++ b/apps/desktop/src/lib/mtp/mtp-store.svelte.ts @@ -0,0 +1,443 @@ +/** + * Reactive store for MTP (Android device) state management. + * Tracks connected devices, their connection status, and storages. + */ + +import { SvelteMap } from 'svelte/reactivity' +import { + type ConnectedMtpDeviceInfo, + type MtpDeviceInfo, + type MtpStorageInfo, + type UnlistenFn, + connectMtpDevice, + disconnectMtpDevice, + getMtpDeviceDisplayName, + listMtpDevices, + onMtpDeviceConnected, + onMtpDeviceDetected, + onMtpDeviceDisconnected, + onMtpDeviceRemoved, + onMtpExclusiveAccessError, +} from '$lib/tauri-commands' +import { getAppLogger } from '$lib/logger' + +const logger = getAppLogger('mtp') + +/** Connection state for a device. */ +export type DeviceConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error' + +/** Extended device info with connection state. */ +export interface MtpDeviceState { + device: MtpDeviceInfo + connectionState: DeviceConnectionState + storages: MtpStorageInfo[] + /** Error message if connectionState is 'error'. */ + error?: string + /** Display name for the device. */ + displayName: string +} + +/** Store state. */ +interface MtpStoreState { + /** Map of device ID to device state. */ + devices: SvelteMap + /** Whether the store has been initialized. */ + initialized: boolean + /** Whether a device scan is in progress. */ + scanning: boolean +} + +// Reactive state using Svelte 5 runes +let state = $state({ + devices: new SvelteMap(), + initialized: false, + scanning: false, +}) + +// Event listeners +let unlistenConnected: UnlistenFn | undefined +let unlistenDisconnected: UnlistenFn | undefined +let unlistenExclusiveAccess: UnlistenFn | undefined +let unlistenDeviceDetected: UnlistenFn | undefined +let unlistenDeviceRemoved: UnlistenFn | undefined + +/** + * Gets all devices as an array (for iteration in components). + */ +export function getDevices(): MtpDeviceState[] { + return Array.from(state.devices.values()) +} + +/** + * Gets a specific device by ID. + */ +export function getDevice(deviceId: string): MtpDeviceState | undefined { + return state.devices.get(deviceId) +} + +/** + * Gets all connected devices. + */ +export function getConnectedDevices(): MtpDeviceState[] { + return getDevices().filter((d) => d.connectionState === 'connected') +} + +/** + * Checks if any device is connected. + */ +export function hasConnectedDevices(): boolean { + return getConnectedDevices().length > 0 +} + +/** + * Checks if the store has been initialized. + */ +export function isInitialized(): boolean { + return state.initialized +} + +/** + * Checks if a scan is in progress. + */ +export function isScanning(): boolean { + return state.scanning +} + +/** + * Scans for connected MTP devices and updates the store. + * Preserves connection state for already-known devices. + * After scanning, automatically connects to all disconnected devices. + */ +export async function scanDevices(): Promise { + if (state.scanning) return + + state.scanning = true + try { + const devices = await listMtpDevices() + const newDevices = new SvelteMap() + + for (const device of devices) { + const existing = state.devices.get(device.id) + if (existing) { + // Preserve connection state and storages for known devices + newDevices.set(device.id, { + ...existing, + device, // Update device info in case it changed + displayName: getMtpDeviceDisplayName(device), + }) + } else { + // New device, start disconnected + newDevices.set(device.id, { + device, + connectionState: 'disconnected', + storages: [], + displayName: getMtpDeviceDisplayName(device), + }) + } + } + + state.devices = newDevices + logger.info('Scanned {count} MTP device(s)', { count: devices.length }) + + // Auto-connect to all disconnected devices + void connectAllDisconnected() + } catch (error) { + logger.error('Failed to scan MTP devices: {error}', { error: String(error) }) + } finally { + state.scanning = false + } +} + +/** + * Connects to all disconnected MTP devices. + * Runs connections in parallel for faster startup. + * Errors are logged but don't prevent other devices from connecting. + */ +async function connectAllDisconnected(): Promise { + const disconnectedDevices = Array.from(state.devices.values()).filter((d) => d.connectionState === 'disconnected') + + if (disconnectedDevices.length === 0) return + + logger.info('Auto-connecting to {count} MTP device(s)', { count: disconnectedDevices.length }) + + // Connect in parallel - each connect() handles its own errors + await Promise.allSettled( + disconnectedDevices.map(async (deviceState) => { + try { + await connect(deviceState.device.id) + } catch { + // Error already logged by connect(), just continue with other devices + } + }), + ) +} + +/** + * Connects to an MTP device. + * Updates the store with connection state and storages. + */ +export async function connect(deviceId: string): Promise { + const deviceState = state.devices.get(deviceId) + if (!deviceState) { + logger.warn('Cannot connect: device {deviceId} not found in store', { deviceId }) + return undefined + } + + if (deviceState.connectionState === 'connected') { + logger.debug('Device {deviceId} already connected', { deviceId }) + return { device: deviceState.device, storages: deviceState.storages } + } + + if (deviceState.connectionState === 'connecting') { + logger.debug('Device {deviceId} connection already in progress', { deviceId }) + return undefined + } + + // Update state to connecting + state.devices.set(deviceId, { + ...deviceState, + connectionState: 'connecting', + error: undefined, + }) + + try { + const result = await connectMtpDevice(deviceId) + + // Update state with connected info + state.devices.set(deviceId, { + ...deviceState, + device: result.device, + connectionState: 'connected', + storages: result.storages, + displayName: getMtpDeviceDisplayName(result.device), + error: undefined, + }) + + logger.info('Connected to MTP device: {displayName}', { displayName: deviceState.displayName }) + return result + } catch (error) { + // Handle various error formats from Tauri + let errorMessage: string + if (error instanceof Error) { + errorMessage = error.message + } else if (typeof error === 'object' && error !== null) { + // Tauri errors often come as objects with message or userMessage + const errObj = error as Record + errorMessage = (errObj.userMessage as string) || (errObj.message as string) || JSON.stringify(error) + } else { + errorMessage = String(error) + } + + state.devices.set(deviceId, { + ...deviceState, + connectionState: 'error', + error: errorMessage, + }) + + logger.error('Failed to connect to {displayName}: {error}', { + displayName: deviceState.displayName, + error: errorMessage, + }) + throw error + } +} + +/** + * Disconnects from an MTP device. + */ +export async function disconnect(deviceId: string): Promise { + const deviceState = state.devices.get(deviceId) + if (!deviceState) { + logger.warn('Cannot disconnect: device {deviceId} not found in store', { deviceId }) + return + } + + if (deviceState.connectionState === 'disconnected') { + logger.debug('Device {deviceId} already disconnected', { deviceId }) + return + } + + try { + await disconnectMtpDevice(deviceId) + + state.devices.set(deviceId, { + ...deviceState, + connectionState: 'disconnected', + storages: [], + error: undefined, + }) + + logger.info('Disconnected from MTP device: {displayName}', { displayName: deviceState.displayName }) + } catch (error) { + logger.error('Failed to disconnect from {displayName}: {error}', { + displayName: deviceState.displayName, + error: String(error), + }) + throw error + } +} + +/** + * Initializes the MTP store. + * Sets up event listeners and performs initial device scan. + * Should be called once when the app starts. + */ +export async function initialize(): Promise { + if (state.initialized) return + + // Set up event listeners + unlistenConnected = await onMtpDeviceConnected((event) => { + const deviceState = state.devices.get(event.deviceId) + if (deviceState) { + state.devices.set(event.deviceId, { + ...deviceState, + connectionState: 'connected', + storages: event.storages, + }) + } + }) + + unlistenDisconnected = await onMtpDeviceDisconnected((event) => { + const deviceState = state.devices.get(event.deviceId) + if (deviceState) { + state.devices.set(event.deviceId, { + ...deviceState, + connectionState: 'disconnected', + storages: [], + }) + logger.info('Device {displayName} disconnected ({reason})', { + displayName: deviceState.displayName, + reason: event.reason, + }) + } + }) + + unlistenExclusiveAccess = await onMtpExclusiveAccessError((event) => { + const deviceState = state.devices.get(event.deviceId) + if (deviceState) { + const blockingInfo = event.blockingProcess ? ` (blocked by ${event.blockingProcess})` : '' + state.devices.set(event.deviceId, { + ...deviceState, + connectionState: 'error', + error: `Another process has exclusive access${blockingInfo}`, + }) + } + }) + + // USB hotplug: device detected + unlistenDeviceDetected = await onMtpDeviceDetected((event) => { + logger.info('MTP device detected via hotplug: {deviceId}', { deviceId: event.deviceId }) + // Rescan devices to pick up the new device + void scanDevices() + }) + + // USB hotplug: device removed + unlistenDeviceRemoved = await onMtpDeviceRemoved((event) => { + logger.info('MTP device removed via hotplug: {deviceId}', { deviceId: event.deviceId }) + // Remove from store immediately, then rescan to confirm + const deviceState = state.devices.get(event.deviceId) + if (deviceState) { + state.devices.delete(event.deviceId) + logger.info('Removed {displayName} from store', { displayName: deviceState.displayName }) + } + // Rescan to ensure store is in sync + void scanDevices() + }) + + // Initial scan + await scanDevices() + + state.initialized = true + logger.info('MTP store initialized') +} + +/** + * Cleans up the MTP store. + * Should be called when the app is shutting down. + */ +export function cleanup(): void { + unlistenConnected?.() + unlistenDisconnected?.() + unlistenExclusiveAccess?.() + unlistenDeviceDetected?.() + unlistenDeviceRemoved?.() + + state = { + devices: new SvelteMap(), + initialized: false, + scanning: false, + } +} + +/** + * Gets device state for use in reactive contexts. + * This is a helper that returns the raw state for components. + */ +export function getMtpState(): MtpStoreState { + return state +} + +/** + * Represents a single MTP volume (one storage on a device). + * This is used to show each storage as a separate entry in the volume picker. + */ +export interface MtpVolume { + /** Unique ID for this volume: "mtp-{deviceId}-{storageId}" */ + id: string + /** Device ID */ + deviceId: string + /** Storage ID */ + storageId: number + /** Display name: "{DeviceName} - {StorageName}" or just storage name if device has one storage */ + name: string + /** Virtual path: "mtp://{deviceId}/{storageId}" */ + path: string + /** Whether the device is connected */ + isConnected: boolean + /** Whether this storage is read-only (e.g., PTP cameras) */ + isReadOnly: boolean +} + +/** + * Gets all MTP volumes (one per storage on each connected device). + * For connected devices with multiple storages, each storage is a separate volume. + * For disconnected devices, returns a single volume representing the device. + */ +export function getMtpVolumes(): MtpVolume[] { + const volumes: MtpVolume[] = [] + + for (const deviceState of state.devices.values()) { + if (deviceState.connectionState === 'connected' && deviceState.storages.length > 0) { + // Connected device with storages: create one volume per storage + const showDeviceName = deviceState.storages.length > 1 + for (const storage of deviceState.storages) { + const volumeName = showDeviceName + ? `${deviceState.displayName} - ${storage.name}` + : storage.name || deviceState.displayName + + volumes.push({ + id: `${deviceState.device.id}:${String(storage.id)}`, + deviceId: deviceState.device.id, + storageId: storage.id, + name: volumeName, + path: `mtp://${deviceState.device.id}/${String(storage.id)}`, + isConnected: true, + isReadOnly: storage.isReadOnly, + }) + } + } else { + // Disconnected or connecting device: show as single entry + volumes.push({ + id: deviceState.device.id, + deviceId: deviceState.device.id, + storageId: 0, + name: deviceState.displayName, + path: `mtp://${deviceState.device.id}`, + isConnected: deviceState.connectionState === 'connected', + isReadOnly: false, // Unknown until connected + }) + } + } + + return volumes +} diff --git a/apps/desktop/src/lib/mtp/mtp-store.test.ts b/apps/desktop/src/lib/mtp/mtp-store.test.ts new file mode 100644 index 0000000..9626ef5 --- /dev/null +++ b/apps/desktop/src/lib/mtp/mtp-store.test.ts @@ -0,0 +1,590 @@ +/** + * Tests for MTP store reactive behavior and device state management. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('$lib/tauri-commands', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + listMtpDevices: vi.fn(), + connectMtpDevice: vi.fn(), + disconnectMtpDevice: vi.fn(), + getMtpDeviceDisplayName: original.getMtpDeviceDisplayName, // Use real implementation + onMtpDeviceConnected: vi.fn(), + onMtpDeviceDisconnected: vi.fn(), + onMtpExclusiveAccessError: vi.fn(), + onMtpDeviceDetected: vi.fn(), + onMtpDeviceRemoved: vi.fn(), + } +}) + +import type { MtpDeviceInfo, MtpStorageInfo, ConnectedMtpDeviceInfo } from '$lib/tauri-commands' +import { + listMtpDevices, + connectMtpDevice, + disconnectMtpDevice, + onMtpDeviceConnected, + onMtpDeviceDisconnected, + onMtpExclusiveAccessError, + onMtpDeviceDetected, + onMtpDeviceRemoved, +} from '$lib/tauri-commands' + +const mockDevice: MtpDeviceInfo = { + id: 'mtp-336592896', + locationId: 336592896, + vendorId: 0x18d1, + productId: 0x4ee1, + manufacturer: 'Google', + product: 'Pixel 8', +} + +const mockStorage: MtpStorageInfo = { + id: 65537, + name: 'Internal shared storage', + totalBytes: 128_000_000_000, + availableBytes: 64_000_000_000, + storageType: 'FixedRAM', + isReadOnly: false, +} + +const mockConnectedInfo: ConnectedMtpDeviceInfo = { + device: mockDevice, + storages: [mockStorage], +} + +describe('mtp-store', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + + // Default mock for event listeners - return unlisten functions + vi.mocked(onMtpDeviceConnected).mockResolvedValue(vi.fn()) + vi.mocked(onMtpDeviceDisconnected).mockResolvedValue(vi.fn()) + vi.mocked(onMtpExclusiveAccessError).mockResolvedValue(vi.fn()) + vi.mocked(onMtpDeviceDetected).mockResolvedValue(vi.fn()) + vi.mocked(onMtpDeviceRemoved).mockResolvedValue(vi.fn()) + }) + + async function loadModule() { + return await import('./mtp-store.svelte') + } + + describe('initial state', () => { + it('returns empty devices before initialization', async () => { + const { getDevices, isInitialized } = await loadModule() + expect(getDevices()).toEqual([]) + expect(isInitialized()).toBe(false) + }) + + it('has no connected devices initially', async () => { + const { hasConnectedDevices, getConnectedDevices } = await loadModule() + expect(hasConnectedDevices()).toBe(false) + expect(getConnectedDevices()).toEqual([]) + }) + }) + + describe('scanDevices', () => { + it('scans and adds new devices, then auto-connects', async () => { + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + vi.mocked(connectMtpDevice).mockResolvedValue(mockConnectedInfo) + const { scanDevices, getDevices, getDevice } = await loadModule() + + await scanDevices() + // Wait for auto-connect to complete (it runs asynchronously) + await vi.waitFor(() => { + const device = getDevice('mtp-336592896') + expect(device?.connectionState).toBe('connected') + }) + + const devices = getDevices() + expect(devices).toHaveLength(1) + expect(devices[0].device.id).toBe('mtp-336592896') + expect(devices[0].connectionState).toBe('connected') + expect(devices[0].displayName).toBe('Pixel 8') + + const device = getDevice('mtp-336592896') + expect(device).toBeDefined() + expect(device?.device.product).toBe('Pixel 8') + }) + + it('preserves connection state for known devices', async () => { + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + vi.mocked(connectMtpDevice).mockResolvedValue(mockConnectedInfo) + const { scanDevices, connect, getDevice } = await loadModule() + + await scanDevices() + await connect('mtp-336592896') + + // Scan again + await scanDevices() + + const device = getDevice('mtp-336592896') + expect(device?.connectionState).toBe('connected') + expect(device?.storages).toHaveLength(1) + }) + + it('skips scan if already scanning', async () => { + vi.mocked(listMtpDevices).mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => { + resolve([mockDevice]) + }, 100) + }), + ) + const { scanDevices, isScanning } = await loadModule() + + const promise1 = scanDevices() + expect(isScanning()).toBe(true) + + const promise2 = scanDevices() + await Promise.all([promise1, promise2]) + + // Should only have called listMtpDevices once + expect(listMtpDevices).toHaveBeenCalledTimes(1) + }) + + it('handles scan errors gracefully', async () => { + vi.mocked(listMtpDevices).mockRejectedValue(new Error('USB error')) + const { scanDevices, getDevices, isScanning } = await loadModule() + + await scanDevices() + + expect(getDevices()).toEqual([]) + expect(isScanning()).toBe(false) + }) + + it('removes devices no longer present after scan', async () => { + vi.mocked(listMtpDevices).mockResolvedValueOnce([mockDevice]) + const { scanDevices, getDevices } = await loadModule() + + await scanDevices() + expect(getDevices()).toHaveLength(1) + + // Device was unplugged + vi.mocked(listMtpDevices).mockResolvedValueOnce([]) + await scanDevices() + + expect(getDevices()).toHaveLength(0) + }) + }) + + describe('connect', () => { + it('auto-connects devices after scan and updates state', async () => { + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + vi.mocked(connectMtpDevice).mockResolvedValue(mockConnectedInfo) + const { scanDevices, getDevice, getConnectedDevices, hasConnectedDevices } = await loadModule() + + await scanDevices() + // Wait for auto-connect to complete + await vi.waitFor(() => { + const device = getDevice('mtp-336592896') + expect(device?.connectionState).toBe('connected') + }) + + const device = getDevice('mtp-336592896') + expect(device?.connectionState).toBe('connected') + expect(device?.storages).toEqual([mockStorage]) + + expect(hasConnectedDevices()).toBe(true) + expect(getConnectedDevices()).toHaveLength(1) + expect(connectMtpDevice).toHaveBeenCalledTimes(1) + }) + + it('returns undefined for unknown device', async () => { + const { connect } = await loadModule() + + const result = await connect('mtp-unknown') + + expect(result).toBeUndefined() + }) + + it('returns existing info for already connected device', async () => { + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + vi.mocked(connectMtpDevice).mockResolvedValue(mockConnectedInfo) + const { scanDevices, connect } = await loadModule() + + await scanDevices() + await connect('mtp-336592896') + + // Try to connect again + const result = await connect('mtp-336592896') + + expect(result).toBeDefined() + expect(connectMtpDevice).toHaveBeenCalledTimes(1) // Should not call again + }) + + it('sets error state on auto-connect failure', async () => { + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + vi.mocked(connectMtpDevice).mockRejectedValue(new Error('Exclusive access error')) + const { scanDevices, getDevice } = await loadModule() + + await scanDevices() + // Wait for auto-connect to fail + await vi.waitFor(() => { + const device = getDevice('mtp-336592896') + expect(device?.connectionState).toBe('error') + }) + + const device = getDevice('mtp-336592896') + expect(device?.connectionState).toBe('error') + expect(device?.error).toBe('Exclusive access error') + }) + }) + + describe('disconnect', () => { + it('disconnects from a device and clears storages', async () => { + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + vi.mocked(connectMtpDevice).mockResolvedValue(mockConnectedInfo) + vi.mocked(disconnectMtpDevice).mockResolvedValue(undefined) + const { scanDevices, connect, disconnect, getDevice, hasConnectedDevices } = await loadModule() + + await scanDevices() + await connect('mtp-336592896') + expect(hasConnectedDevices()).toBe(true) + + await disconnect('mtp-336592896') + + const device = getDevice('mtp-336592896') + expect(device?.connectionState).toBe('disconnected') + expect(device?.storages).toEqual([]) + expect(hasConnectedDevices()).toBe(false) + }) + + it('handles disconnect for unknown device gracefully', async () => { + const { disconnect } = await loadModule() + + // Should not throw + await disconnect('mtp-unknown') + }) + + it('handles double disconnect gracefully (only calls backend once)', async () => { + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + vi.mocked(connectMtpDevice).mockResolvedValue(mockConnectedInfo) + vi.mocked(disconnectMtpDevice).mockResolvedValue(undefined) + const { scanDevices, disconnect, getDevice } = await loadModule() + + await scanDevices() + // Wait for auto-connect to complete + await vi.waitFor(() => { + const device = getDevice('mtp-336592896') + expect(device?.connectionState).toBe('connected') + }) + + // First disconnect + await disconnect('mtp-336592896') + expect(disconnectMtpDevice).toHaveBeenCalledTimes(1) + + // Second disconnect - should not call backend again + await disconnect('mtp-336592896') + expect(disconnectMtpDevice).toHaveBeenCalledTimes(1) + }) + }) + + describe('initialize', () => { + it('sets up event listeners and scans devices', async () => { + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + const { initialize, isInitialized, getDevices } = await loadModule() + + await initialize() + + expect(isInitialized()).toBe(true) + expect(getDevices()).toHaveLength(1) + expect(onMtpDeviceConnected).toHaveBeenCalledWith(expect.any(Function)) + expect(onMtpDeviceDisconnected).toHaveBeenCalledWith(expect.any(Function)) + expect(onMtpExclusiveAccessError).toHaveBeenCalledWith(expect.any(Function)) + expect(onMtpDeviceDetected).toHaveBeenCalledWith(expect.any(Function)) + expect(onMtpDeviceRemoved).toHaveBeenCalledWith(expect.any(Function)) + }) + + it('is idempotent (only initializes once)', async () => { + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + const { initialize } = await loadModule() + + await initialize() + await initialize() + + expect(listMtpDevices).toHaveBeenCalledTimes(1) + }) + }) + + describe('cleanup', () => { + it('unregisters event listeners and resets state', async () => { + const unlistenConnected = vi.fn() + const unlistenDisconnected = vi.fn() + const unlistenExclusive = vi.fn() + const unlistenDetected = vi.fn() + const unlistenRemoved = vi.fn() + + vi.mocked(onMtpDeviceConnected).mockResolvedValue(unlistenConnected) + vi.mocked(onMtpDeviceDisconnected).mockResolvedValue(unlistenDisconnected) + vi.mocked(onMtpExclusiveAccessError).mockResolvedValue(unlistenExclusive) + vi.mocked(onMtpDeviceDetected).mockResolvedValue(unlistenDetected) + vi.mocked(onMtpDeviceRemoved).mockResolvedValue(unlistenRemoved) + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + + const { initialize, cleanup, isInitialized, getDevices } = await loadModule() + + await initialize() + expect(isInitialized()).toBe(true) + expect(getDevices()).toHaveLength(1) + + cleanup() + + expect(unlistenConnected).toHaveBeenCalled() + expect(unlistenDisconnected).toHaveBeenCalled() + expect(unlistenExclusive).toHaveBeenCalled() + expect(unlistenDetected).toHaveBeenCalled() + expect(unlistenRemoved).toHaveBeenCalled() + expect(isInitialized()).toBe(false) + expect(getDevices()).toHaveLength(0) + }) + }) + + describe('getMtpVolumes', () => { + it('returns empty array when no devices', async () => { + const { getMtpVolumes } = await loadModule() + + expect(getMtpVolumes()).toEqual([]) + }) + + it('returns single volume for disconnected device', async () => { + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + const { scanDevices, getMtpVolumes } = await loadModule() + + await scanDevices() + + const volumes = getMtpVolumes() + expect(volumes).toHaveLength(1) + expect(volumes[0].id).toBe('mtp-336592896') + expect(volumes[0].deviceId).toBe('mtp-336592896') + expect(volumes[0].storageId).toBe(0) + expect(volumes[0].name).toBe('Pixel 8') + expect(volumes[0].isConnected).toBe(false) + }) + + it('returns one volume per storage for connected device', async () => { + const multiStorageInfo: ConnectedMtpDeviceInfo = { + device: mockDevice, + storages: [ + mockStorage, + { + id: 65538, + name: 'SD Card', + totalBytes: 64_000_000_000, + availableBytes: 32_000_000_000, + storageType: 'RemovableRAM', + isReadOnly: false, + }, + ], + } + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + vi.mocked(connectMtpDevice).mockResolvedValue(multiStorageInfo) + const { scanDevices, connect, getMtpVolumes } = await loadModule() + + await scanDevices() + await connect('mtp-336592896') + + const volumes = getMtpVolumes() + expect(volumes).toHaveLength(2) + expect(volumes[0].id).toBe('mtp-336592896:65537') + expect(volumes[0].name).toBe('Pixel 8 - Internal shared storage') + expect(volumes[0].storageId).toBe(65537) + expect(volumes[0].isConnected).toBe(true) + expect(volumes[1].id).toBe('mtp-336592896:65538') + expect(volumes[1].name).toBe('Pixel 8 - SD Card') + expect(volumes[1].storageId).toBe(65538) + }) + + it('propagates isReadOnly flag from storage to volume', async () => { + const readOnlyStorageInfo: ConnectedMtpDeviceInfo = { + device: mockDevice, + storages: [ + { + id: 65537, + name: 'Camera Storage', + totalBytes: 32_000_000_000, + availableBytes: 16_000_000_000, + storageType: 'FixedRAM', + isReadOnly: true, + }, + ], + } + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + vi.mocked(connectMtpDevice).mockResolvedValue(readOnlyStorageInfo) + const { scanDevices, connect, getMtpVolumes } = await loadModule() + + await scanDevices() + await connect('mtp-336592896') + + const volumes = getMtpVolumes() + expect(volumes).toHaveLength(1) + expect(volumes[0].isReadOnly).toBe(true) + }) + + it('uses storage name only for single storage device', async () => { + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + vi.mocked(connectMtpDevice).mockResolvedValue(mockConnectedInfo) + const { scanDevices, connect, getMtpVolumes } = await loadModule() + + await scanDevices() + await connect('mtp-336592896') + + const volumes = getMtpVolumes() + expect(volumes).toHaveLength(1) + // Single storage: use storage name, not "Device - Storage" + expect(volumes[0].name).toBe('Internal shared storage') + }) + }) + + describe('event handling', () => { + it('updates state on mtp-device-connected event', async () => { + let connectedCallback: ((event: { deviceId: string; storages: MtpStorageInfo[] }) => void) | undefined + vi.mocked(onMtpDeviceConnected).mockImplementation((callback) => { + connectedCallback = callback + return Promise.resolve(vi.fn()) + }) + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + const { initialize, getDevice } = await loadModule() + + await initialize() + + // Simulate event from backend + connectedCallback?.({ deviceId: 'mtp-336592896', storages: [mockStorage] }) + + const device = getDevice('mtp-336592896') + expect(device?.connectionState).toBe('connected') + expect(device?.storages).toEqual([mockStorage]) + }) + + it('updates state on mtp-device-disconnected event', async () => { + let disconnectedCallback: + | ((event: { deviceId: string; reason: 'user' | 'disconnected' }) => void) + | undefined + vi.mocked(onMtpDeviceDisconnected).mockImplementation((callback) => { + disconnectedCallback = callback + return Promise.resolve(vi.fn()) + }) + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + vi.mocked(connectMtpDevice).mockResolvedValue(mockConnectedInfo) + const { initialize, connect, getDevice } = await loadModule() + + await initialize() + await connect('mtp-336592896') + expect(getDevice('mtp-336592896')?.connectionState).toBe('connected') + + // Simulate event from backend + disconnectedCallback?.({ deviceId: 'mtp-336592896', reason: 'disconnected' }) + + const device = getDevice('mtp-336592896') + expect(device?.connectionState).toBe('disconnected') + expect(device?.storages).toEqual([]) + }) + + it('sets error state on mtp-exclusive-access-error event', async () => { + let exclusiveCallback: ((event: { deviceId: string; blockingProcess?: string }) => void) | undefined + vi.mocked(onMtpExclusiveAccessError).mockImplementation((callback) => { + exclusiveCallback = callback + return Promise.resolve(vi.fn()) + }) + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + const { initialize, getDevice } = await loadModule() + + await initialize() + + // Simulate event from backend + exclusiveCallback?.({ deviceId: 'mtp-336592896', blockingProcess: 'ptpcamerad' }) + + const device = getDevice('mtp-336592896') + expect(device?.connectionState).toBe('error') + expect(device?.error).toContain('ptpcamerad') + }) + + it('rescans on mtp-device-detected event', async () => { + let detectedCallback: + | ((event: { deviceId: string; name?: string; vendorId: number; productId: number }) => void) + | undefined + vi.mocked(onMtpDeviceDetected).mockImplementation((callback) => { + detectedCallback = callback + return Promise.resolve(vi.fn()) + }) + vi.mocked(listMtpDevices).mockResolvedValue([]) + const { initialize } = await loadModule() + + await initialize() + expect(listMtpDevices).toHaveBeenCalledTimes(1) + + // Simulate device hotplug + detectedCallback?.({ deviceId: 'mtp-336592896', vendorId: 0x18d1, productId: 0x4ee1 }) + + // Wait for async rescan + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(listMtpDevices).toHaveBeenCalledTimes(2) + }) + + it('removes device and rescans on mtp-device-removed event', async () => { + let removedCallback: ((event: { deviceId: string }) => void) | undefined + vi.mocked(onMtpDeviceRemoved).mockImplementation((callback) => { + removedCallback = callback + return Promise.resolve(vi.fn()) + }) + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + const { initialize, getDevice } = await loadModule() + + await initialize() + expect(getDevice('mtp-336592896')).toBeDefined() + + // Simulate device removal + vi.mocked(listMtpDevices).mockResolvedValue([]) + removedCallback?.({ deviceId: 'mtp-336592896' }) + + // Device should be removed immediately + expect(getDevice('mtp-336592896')).toBeUndefined() + }) + }) + + describe('display name generation', () => { + it('uses product name when available', async () => { + vi.mocked(listMtpDevices).mockResolvedValue([mockDevice]) + const { scanDevices, getDevice } = await loadModule() + + await scanDevices() + + expect(getDevice('mtp-336592896')?.displayName).toBe('Pixel 8') + }) + + it('uses manufacturer name when product is missing', async () => { + const deviceWithoutProduct: MtpDeviceInfo = { + id: 'mtp-336592897', + locationId: 336592897, + vendorId: 0x04e8, + productId: 0x6860, + manufacturer: 'Samsung', + } + vi.mocked(listMtpDevices).mockResolvedValue([deviceWithoutProduct]) + const { scanDevices, getDevice } = await loadModule() + + await scanDevices() + + expect(getDevice('mtp-336592897')?.displayName).toBe('Samsung device') + }) + + it('uses vendor:product format as fallback', async () => { + const deviceWithoutNames: MtpDeviceInfo = { + id: 'mtp-336592898', + locationId: 336592898, + vendorId: 0x1234, + productId: 0x5678, + } + vi.mocked(listMtpDevices).mockResolvedValue([deviceWithoutNames]) + const { scanDevices, getDevice } = await loadModule() + + await scanDevices() + + expect(getDevice('mtp-336592898')?.displayName).toBe('MTP device (1234:5678)') + }) + }) +}) diff --git a/apps/desktop/src/lib/streaming-loading.test.ts b/apps/desktop/src/lib/streaming-loading.test.ts index 5437ff3..2b4269d 100644 --- a/apps/desktop/src/lib/streaming-loading.test.ts +++ b/apps/desktop/src/lib/streaming-loading.test.ts @@ -245,20 +245,24 @@ describe('Streaming types', () => { listingId: 'test-789', totalCount: 5000, maxFilenameWidth: 150.5, + volumeRoot: '/Volumes/External', } expect(event.listingId).toBe('test-789') expect(event.totalCount).toBe(5000) expect(event.maxFilenameWidth).toBe(150.5) + expect(event.volumeRoot).toBe('/Volumes/External') }) it('ListingCompleteEvent maxFilenameWidth is optional', () => { const event: ListingCompleteEvent = { listingId: 'test-abc', totalCount: 100, + volumeRoot: '/', } expect(event.maxFilenameWidth).toBeUndefined() + expect(event.volumeRoot).toBe('/') }) it('ListingErrorEvent has correct shape', () => { @@ -346,6 +350,7 @@ describe('Streaming event handling', () => { listingId: 'test-listing', totalCount: 2500, maxFilenameWidth: 120, + volumeRoot: '/', } // Simulate the event handler diff --git a/apps/desktop/src/lib/tauri-commands.ts b/apps/desktop/src/lib/tauri-commands.ts index 157fc82..5c553c3 100644 --- a/apps/desktop/src/lib/tauri-commands.ts +++ b/apps/desktop/src/lib/tauri-commands.ts @@ -106,13 +106,15 @@ export async function listDirectoryStart( * Starts a new streaming directory listing (async version). * Returns immediately with listing ID and "loading" status. * Progress is reported via events: listing-progress, listing-complete, listing-error, listing-cancelled. - * @param path - Directory path to list. Supports tilde expansion (~). + * @param volumeId - Volume ID (e.g., "root", "mtp-336592896:65537"). + * @param path - Directory path to list. Supports tilde expansion (~) for local volumes. * @param includeHidden - Whether to include hidden files in total count. * @param sortBy - Column to sort by. * @param sortOrder - Ascending or descending. * @param listingId - Unique identifier for the listing (used for cancellation) */ export async function listDirectoryStartStreaming( + volumeId: string, path: string, includeHidden: boolean, sortBy: SortColumn, @@ -120,6 +122,7 @@ export async function listDirectoryStartStreaming( listingId: string, ): Promise { return invoke('list_directory_start_streaming', { + volumeId, path, includeHidden, sortBy, @@ -272,15 +275,22 @@ export async function startSelectionDrag( /** * Checks if a path exists. * @param path - Path to check. + * @param volumeId - Optional volume ID. Defaults to "root" for local filesystem. * @returns True if the path exists. */ -export async function pathExists(path: string): Promise { - return invoke('path_exists', { path }) +export async function pathExists(path: string, volumeId?: string): Promise { + return invoke('path_exists', { volumeId, path }) } -/** Creates a new directory. Returns the full path of the created folder. */ -export async function createDirectory(parentPath: string, name: string): Promise { - return invoke('create_directory', { parentPath, name }) +/** + * Creates a new directory. + * @param parentPath - The parent directory path. + * @param name - The folder name to create. + * @param volumeId - Optional volume ID. Defaults to "root" for local filesystem. + * @returns The full path of the created directory. + */ +export async function createDirectory(parentPath: string, name: string, volumeId?: string): Promise { + return invoke('create_directory', { volumeId, parentPath, name }) } // ============================================================================ @@ -1733,3 +1743,565 @@ export async function updateFileWatcherDebounce(debounceMs: number): Promise { await invoke('update_service_resolve_timeout', { timeoutMs }) } + +// ============================================================================ +// MTP (Android device) support (macOS only) +// ============================================================================ + +/** Information about a connected MTP device. */ +export interface MtpDeviceInfo { + /** Unique identifier for the device (format: "mtp-{locationId}"). */ + id: string + /** Physical USB location identifier. Stable for a given port. */ + locationId: number + /** USB vendor ID (e.g., 0x18d1 for Google). */ + vendorId: number + /** USB product ID. */ + productId: number + /** Device manufacturer name, if available. */ + manufacturer?: string + /** Device product name, if available. */ + product?: string + /** USB serial number, if available. */ + serialNumber?: string +} + +/** + * Gets a display name for an MTP device. + * Prefers product name, falls back to manufacturer, then vendor:product ID. + */ +export function getMtpDeviceDisplayName(device: MtpDeviceInfo): string { + if (device.product) { + return device.product + } + if (device.manufacturer) { + return `${device.manufacturer} device` + } + return `MTP device (${device.vendorId.toString(16).padStart(4, '0')}:${device.productId.toString(16).padStart(4, '0')})` +} + +/** + * Lists all connected MTP devices. + * Only available on macOS. + * @returns Array of MtpDeviceInfo objects + */ +export async function listMtpDevices(): Promise { + try { + return await invoke('list_mtp_devices') + } catch { + // Command not available (non-macOS) - return empty array + return [] + } +} + +/** Information about a storage area on an MTP device. */ +export interface MtpStorageInfo { + /** Storage ID (MTP storage handle). */ + id: number + /** Display name (e.g., "Internal shared storage"). */ + name: string + /** Total capacity in bytes. */ + totalBytes: number + /** Available space in bytes. */ + availableBytes: number + /** Storage type description (e.g., "FixedROM", "RemovableRAM"). */ + storageType?: string + /** Whether this storage is read-only (e.g., PTP cameras). */ + isReadOnly: boolean +} + +/** Information about a connected MTP device including its storages. */ +export interface ConnectedMtpDeviceInfo { + /** Device information. */ + device: MtpDeviceInfo + /** Available storages on the device. */ + storages: MtpStorageInfo[] +} + +/** Error types for MTP connection operations. */ +export type MtpConnectionError = + | { type: 'deviceNotFound'; deviceId: string } + | { type: 'notConnected'; deviceId: string } + | { type: 'exclusiveAccess'; deviceId: string; blockingProcess?: string } + | { type: 'timeout'; deviceId: string } + | { type: 'disconnected'; deviceId: string } + | { type: 'protocol'; deviceId: string; message: string } + | { type: 'other'; deviceId: string; message: string } + | { type: 'notSupported'; message: string } + +/** + * Checks if an error is an MTP connection error. + */ +export function isMtpConnectionError(error: unknown): error is MtpConnectionError { + return ( + typeof error === 'object' && + error !== null && + 'type' in error && + typeof (error as { type: unknown }).type === 'string' + ) +} + +/** + * Connects to an MTP device by ID. + * Opens an MTP session and retrieves storage information. + * If another process has exclusive access, an 'mtp-exclusive-access-error' event is emitted. + * @param deviceId - The device ID from listMtpDevices + * @returns Information about the connected device including storages + */ +export async function connectMtpDevice(deviceId: string): Promise { + return invoke('connect_mtp_device', { deviceId }) +} + +/** + * Disconnects from an MTP device. + * Closes the MTP session gracefully. + * @param deviceId - The device ID to disconnect from + */ +export async function disconnectMtpDevice(deviceId: string): Promise { + await invoke('disconnect_mtp_device', { deviceId }) +} + +/** + * Gets information about a connected MTP device. + * Returns null if the device is not connected. + * @param deviceId - The device ID to query + */ +export async function getMtpDeviceInfo(deviceId: string): Promise { + try { + return await invoke('get_mtp_device_info', { deviceId }) + } catch { + return null + } +} + +/** + * Gets the ptpcamerad workaround command for macOS. + * Returns the Terminal command users can run to work around ptpcamerad blocking MTP. + */ +export async function getPtpcameradWorkaroundCommand(): Promise { + try { + return await invoke('get_ptpcamerad_workaround_command') + } catch { + return '' + } +} + +/** + * Gets storage information for all storages on a connected device. + * @param deviceId - The connected device ID + * @returns Array of storage info, or empty if device is not connected + */ +export async function getMtpStorages(deviceId: string): Promise { + try { + return await invoke('get_mtp_storages', { deviceId }) + } catch { + return [] + } +} + +/** Event payload for mtp-device-detected (USB hotplug). */ +export interface MtpDeviceDetectedEvent { + deviceId: string + name?: string + vendorId: number + productId: number +} + +/** Event payload for mtp-device-removed (USB hotplug). */ +export interface MtpDeviceRemovedEvent { + deviceId: string +} + +/** Event payload for mtp-exclusive-access-error. */ +export interface MtpExclusiveAccessErrorEvent { + deviceId: string + blockingProcess?: string +} + +/** Event payload for mtp-device-connected. */ +export interface MtpDeviceConnectedEvent { + deviceId: string + storages: MtpStorageInfo[] +} + +/** Event payload for mtp-device-disconnected. */ +export interface MtpDeviceDisconnectedEvent { + deviceId: string + reason: 'user' | 'disconnected' +} + +/** + * Subscribes to MTP device detected events (USB hotplug). + * Emitted when an MTP device is connected to the system. + */ +export async function onMtpDeviceDetected(callback: (event: MtpDeviceDetectedEvent) => void): Promise { + return listen('mtp-device-detected', (event) => { + callback(event.payload) + }) +} + +/** + * Subscribes to MTP device removed events (USB hotplug). + * Emitted when an MTP device is disconnected from the system. + */ +export async function onMtpDeviceRemoved(callback: (event: MtpDeviceRemovedEvent) => void): Promise { + return listen('mtp-device-removed', (event) => { + callback(event.payload) + }) +} + +/** + * Subscribes to MTP exclusive access error events. + * Emitted when connecting fails because another process (like ptpcamerad) has the device. + */ +export async function onMtpExclusiveAccessError( + callback: (event: MtpExclusiveAccessErrorEvent) => void, +): Promise { + return listen('mtp-exclusive-access-error', (event) => { + callback(event.payload) + }) +} + +/** + * Subscribes to MTP device connected events. + */ +export async function onMtpDeviceConnected(callback: (event: MtpDeviceConnectedEvent) => void): Promise { + return listen('mtp-device-connected', (event) => { + callback(event.payload) + }) +} + +/** + * Subscribes to MTP device disconnected events. + */ +export async function onMtpDeviceDisconnected( + callback: (event: MtpDeviceDisconnectedEvent) => void, +): Promise { + return listen('mtp-device-disconnected', (event) => { + callback(event.payload) + }) +} + +// NOTE: MTP file watching now uses the unified directory-diff event system (same as local volumes). +// The mtp-directory-changed event and onMtpDirectoryChanged function have been removed. +// MTP events are now handled by the existing directory-diff listener in FilePane.svelte. + +/** + * Lists the contents of a directory on a connected MTP device. + * Returns file entries in the same format as local directory listings. + * @param deviceId - The connected device ID + * @param storageId - The storage ID within the device + * @param path - Virtual path to list (for example, "/" or "/DCIM") + * @returns Array of FileEntry objects, sorted with directories first + */ +export async function listMtpDirectory(deviceId: string, storageId: number, path: string): Promise { + return invoke('list_mtp_directory', { deviceId, storageId, path }) +} + +// ============================================================================ +// MTP File Operations (Phase 4) +// ============================================================================ + +/** Result of a successful MTP operation. */ +export interface MtpOperationResult { + /** Operation ID for tracking. */ + operationId: string + /** Number of files processed. */ + filesProcessed: number + /** Total bytes transferred. */ + bytesTransferred: number +} + +/** Information about an object on the device. */ +export interface MtpObjectInfo { + /** Object handle. */ + handle: number + /** Object name. */ + name: string + /** Virtual path on device. */ + path: string + /** Whether it's a directory. */ + isDirectory: boolean + /** Size in bytes (undefined for directories). */ + size?: number +} + +/** Progress event for MTP file transfers. */ +export interface MtpTransferProgress { + /** Unique operation ID. */ + operationId: string + /** Device ID. */ + deviceId: string + /** Type of transfer. */ + transferType: 'download' | 'upload' + /** Current file being transferred. */ + currentFile: string + /** Bytes transferred so far. */ + bytesDone: number + /** Total bytes to transfer. */ + bytesTotal: number +} + +/** + * Downloads a file from an MTP device to the local filesystem. + * Emits `mtp-transfer-progress` events during the transfer. + * @param deviceId - The connected device ID + * @param storageId - The storage ID within the device + * @param objectPath - Virtual path on the device (for example, "/DCIM/photo.jpg") + * @param localDest - Local destination path + * @param operationId - Unique operation ID for progress tracking + */ +export async function downloadMtpFile( + deviceId: string, + storageId: number, + objectPath: string, + localDest: string, + operationId: string, +): Promise { + return invoke('download_mtp_file', { + deviceId, + storageId, + objectPath, + localDest, + operationId, + }) +} + +/** + * Uploads a file from the local filesystem to an MTP device. + * Emits `mtp-transfer-progress` events during the transfer. + * @param deviceId - The connected device ID + * @param storageId - The storage ID within the device + * @param localPath - Local file path to upload + * @param destFolder - Destination folder path on device (for example, "/DCIM") + * @param operationId - Unique operation ID for progress tracking + */ +export async function uploadToMtp( + deviceId: string, + storageId: number, + localPath: string, + destFolder: string, + operationId: string, +): Promise { + return invoke('upload_to_mtp', { + deviceId, + storageId, + localPath, + destFolder, + operationId, + }) +} + +/** + * Deletes an object (file or folder) from an MTP device. + * For folders, this recursively deletes all contents first. + * @param deviceId - The connected device ID + * @param storageId - The storage ID within the device + * @param objectPath - Virtual path on the device + */ +export async function deleteMtpObject(deviceId: string, storageId: number, objectPath: string): Promise { + await invoke('delete_mtp_object', { deviceId, storageId, objectPath }) +} + +/** + * Creates a new folder on an MTP device. + * @param deviceId - The connected device ID + * @param storageId - The storage ID within the device + * @param parentPath - Parent folder path (for example, "/DCIM") + * @param folderName - Name of the new folder + */ +export async function createMtpFolder( + deviceId: string, + storageId: number, + parentPath: string, + folderName: string, +): Promise { + return invoke('create_mtp_folder', { deviceId, storageId, parentPath, folderName }) +} + +/** + * Renames an object on an MTP device. + * @param deviceId - The connected device ID + * @param storageId - The storage ID within the device + * @param objectPath - Current path of the object + * @param newName - New name for the object + */ +export async function renameMtpObject( + deviceId: string, + storageId: number, + objectPath: string, + newName: string, +): Promise { + return invoke('rename_mtp_object', { deviceId, storageId, objectPath, newName }) +} + +/** + * Moves an object to a new parent folder on an MTP device. + * May fail if the device doesn't support MoveObject operation. + * @param deviceId - The connected device ID + * @param storageId - The storage ID within the device + * @param objectPath - Current path of the object + * @param newParentPath - New parent folder path + */ +export async function moveMtpObject( + deviceId: string, + storageId: number, + objectPath: string, + newParentPath: string, +): Promise { + return invoke('move_mtp_object', { deviceId, storageId, objectPath, newParentPath }) +} + +/** + * Subscribes to MTP transfer progress events. + * Emitted during download and upload operations. + */ +export async function onMtpTransferProgress(callback: (event: MtpTransferProgress) => void): Promise { + return listen('mtp-transfer-progress', (event) => { + callback(event.payload) + }) +} + +/** Result of scanning MTP files/directories for copy operation. */ +export interface MtpScanResult { + fileCount: number + dirCount: number + totalBytes: number +} + +/** + * Scans MTP files/directories to get total counts and size before copying. + * For directories, recursively scans all contents. + * @param deviceId - The connected device ID + * @param storageId - The storage ID within the device + * @param path - Virtual path on the device to scan + * @returns Scan result with file/dir counts and total bytes + */ +export async function scanMtpForCopy(deviceId: string, storageId: number, path: string): Promise { + return invoke('scan_mtp_for_copy', { deviceId, storageId, path }) +} + +// ============================================================================ +// Unified volume copy operations +// ============================================================================ + +/** Space information for a volume. */ +export interface VolumeSpaceInfoExtended { + totalBytes: number + availableBytes: number + usedBytes: number +} + +/** Conflict information for a file that already exists at destination. */ +export interface VolumeConflictInfo { + sourcePath: string + destPath: string + sourceSize: number + destSize: number + sourceModified: number | null + destModified: number | null +} + +/** Result of scanning for a volume copy operation. */ +export interface VolumeCopyScanResult { + fileCount: number + dirCount: number + totalBytes: number + destSpace: VolumeSpaceInfoExtended + conflicts: VolumeConflictInfo[] +} + +/** Configuration for volume copy operations. */ +export interface VolumeCopyConfig { + progressIntervalMs?: number + conflictResolution?: ConflictResolution + maxConflictsToShow?: number +} + +/** Input for source item in conflict scanning. */ +export interface SourceItemInput { + name: string + size: number + modified: number | null +} + +/** + * Copies files between any two volumes (local, MTP, etc.). + * This is the unified copy command that works for all volume types: + * - Local -> Local (regular file copy) + * - Local -> MTP (upload to Android device) + * - MTP -> Local (download from Android device) + * + * @param sourceVolumeId - ID of the source volume (e.g., "root" for local filesystem) + * @param sourcePaths - List of source file/directory paths relative to source volume + * @param destVolumeId - ID of the destination volume + * @param destPath - Destination directory path relative to destination volume + * @param config - Optional copy configuration + * @returns Operation start result with operation ID + */ +export async function copyBetweenVolumes( + sourceVolumeId: string, + sourcePaths: string[], + destVolumeId: string, + destPath: string, + config?: VolumeCopyConfig, +): Promise { + return invoke('copy_between_volumes', { + sourceVolumeId, + sourcePaths, + destVolumeId, + destPath, + config: config ?? {}, + }) +} + +/** + * Scans source files for a volume copy operation without executing it. + * Performs a "pre-flight" scan to determine: + * - Total file count and bytes to copy + * - Available space on destination + * - Any conflicts (files that already exist at destination) + * + * @param sourceVolumeId - ID of the source volume + * @param sourcePaths - List of source file/directory paths + * @param destVolumeId - ID of the destination volume + * @param destPath - Destination directory path + * @param maxConflicts - Maximum number of conflicts to return (default: 100) + * @returns Scan result with file counts, space info, and conflicts + */ +export async function scanVolumeForCopy( + sourceVolumeId: string, + sourcePaths: string[], + destVolumeId: string, + destPath: string, + maxConflicts?: number, +): Promise { + return invoke('scan_volume_for_copy', { + sourceVolumeId, + sourcePaths, + destVolumeId, + destPath, + maxConflicts, + }) +} + +/** + * Scans destination volume for conflicts with source items. + * Checks if any of the source item names already exist at the destination path. + * + * @param volumeId - ID of the destination volume to scan + * @param sourceItems - List of source items to check + * @param destPath - Destination directory path on the volume + * @returns List of conflicts found + */ +export async function scanVolumeForConflicts( + volumeId: string, + sourceItems: SourceItemInput[], + destPath: string, +): Promise { + return invoke('scan_volume_for_conflicts', { + volumeId, + sourceItems, + destPath, + }) +} diff --git a/apps/desktop/src/lib/write-operations/CopyDialog.svelte b/apps/desktop/src/lib/write-operations/CopyDialog.svelte index 091502e..88cc2ba 100644 --- a/apps/desktop/src/lib/write-operations/CopyDialog.svelte +++ b/apps/desktop/src/lib/write-operations/CopyDialog.svelte @@ -9,13 +9,19 @@ onScanPreviewComplete, onScanPreviewError, onScanPreviewCancelled, + scanVolumeForConflicts, type VolumeSpaceInfo, + type VolumeConflictInfo, + type SourceItemInput, type UnlistenFn, } from '$lib/tauri-commands' - import type { VolumeInfo, SortColumn, SortOrder } from '$lib/file-explorer/types' + import type { VolumeInfo, SortColumn, SortOrder, ConflictResolution } from '$lib/file-explorer/types' import { getSetting } from '$lib/settings' import DirectionIndicator from './DirectionIndicator.svelte' import { generateTitle } from './copy-dialog-utils' + import { getAppLogger } from '$lib/logger' + + const log = getAppLogger('copyDialog') interface Props { sourcePaths: string[] @@ -30,7 +36,16 @@ sortColumn: SortColumn /** Current sort order on source pane */ sortOrder: SortOrder - onConfirm: (destination: string, volumeId: string, previewId: string | null) => void + /** Source volume ID (e.g., "root", "mtp-336592896:65537") */ + sourceVolumeId: string + /** Destination volume ID */ + destVolumeId: string + onConfirm: ( + destination: string, + volumeId: string, + previewId: string | null, + conflictResolution: ConflictResolution, + ) => void onCancel: () => void } @@ -45,6 +60,9 @@ sourceFolderPath, sortColumn, sortOrder, + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Passed through for consistency; conflict check uses destVolumeId + sourceVolumeId: _sourceVolumeId, + destVolumeId, onConfirm, onCancel, }: Props = $props() @@ -70,6 +88,12 @@ let scanComplete = $state(false) let unlisteners: UnlistenFn[] = [] + // Conflict detection state + let conflicts = $state([]) + let isCheckingConflicts = $state(false) + let conflictCheckComplete = $state(false) + let conflictPolicy = $state('stop') // Default to "ask for each" + // Filter to only actual volumes (not favorites) const actualVolumes = $derived(volumes.filter((v) => v.category !== 'favorite' && v.category !== 'network')) @@ -117,6 +141,43 @@ unlisteners = [] } + /** Checks for conflicts at the destination. */ + async function checkConflicts() { + if (isCheckingConflicts || conflictCheckComplete) return + + isCheckingConflicts = true + try { + // Build source item info from the source paths + // For conflict detection, we need the name, size, and modified time of each source item + // We extract the filename from each path + const sourceItems: SourceItemInput[] = sourcePaths.map((path) => { + const name = path.split('/').pop() || path + return { + name, + size: 0, // Size not known at this point, but name matching is enough for conflict detection + modified: null, + } + }) + + const maxConflicts = getSetting('fileOperations.maxConflictsToShow') + const foundConflicts = await scanVolumeForConflicts(destVolumeId, sourceItems, editedPath) + + // Limit the conflicts shown + conflicts = foundConflicts.slice(0, maxConflicts) + conflictCheckComplete = true + + if (conflicts.length > 0) { + log.info('Found {count} conflicts at destination', { count: conflicts.length }) + } + } catch (err) { + log.error('Failed to check for conflicts: {error}', { error: err }) + // Don't block the copy operation on conflict check failure + conflictCheckComplete = true + } finally { + isCheckingConflicts = false + } + } + /** Starts the scan preview to count files/dirs/bytes. */ async function startScan() { // Subscribe to events BEFORE starting scan (avoid race condition) @@ -136,6 +197,8 @@ bytesFound = event.bytesTotal isScanning = false scanComplete = true + // After source scan completes, check for conflicts + void checkConflicts() }), ) unlisteners.push( @@ -185,8 +248,8 @@ }) function handleConfirm() { - // Pass the previewId so copy operation can reuse scan results - onConfirm(editedPath, selectedVolumeId, previewId) + // Pass the previewId and conflict policy so copy operation can reuse scan results + onConfirm(editedPath, selectedVolumeId, previewId, conflictPolicy) } function handleCancel() { @@ -318,6 +381,35 @@ {/if} + + {#if isCheckingConflicts} +
+ + Checking for conflicts... +
+ {:else if conflicts.length > 0} +
+

+ {conflicts.length} + {conflicts.length === 1 ? 'file already exists' : 'files already exist'} +

+
+ + + +
+
+ {/if} +
@@ -517,4 +609,58 @@ font-weight: bold; margin-left: 4px; } + + /* Conflicts checking */ + .conflicts-checking { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 24px 12px; + font-size: 12px; + } + + .conflicts-checking-text { + color: var(--color-text-muted); + } + + /* Conflicts section */ + .conflicts-section { + padding: 0 24px 12px; + border-top: 1px solid var(--color-border-primary); + margin-top: 4px; + padding-top: 12px; + } + + .conflicts-summary { + margin: 0 0 12px; + font-size: 13px; + color: var(--color-warning); + text-align: center; + font-weight: 500; + } + + .conflict-policy { + display: flex; + justify-content: center; + gap: 16px; + } + + .policy-option { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--color-text-secondary); + cursor: pointer; + } + + .policy-option input[type='radio'] { + margin: 0; + cursor: pointer; + } + + .policy-option:hover { + color: var(--color-text-primary); + } diff --git a/apps/desktop/src/lib/write-operations/CopyErrorDialog.svelte b/apps/desktop/src/lib/write-operations/CopyErrorDialog.svelte new file mode 100644 index 0000000..dfb5fb2 --- /dev/null +++ b/apps/desktop/src/lib/write-operations/CopyErrorDialog.svelte @@ -0,0 +1,281 @@ + + + + + diff --git a/apps/desktop/src/lib/write-operations/CopyProgressDialog.svelte b/apps/desktop/src/lib/write-operations/CopyProgressDialog.svelte index 2957d15..a2ec5a3 100644 --- a/apps/desktop/src/lib/write-operations/CopyProgressDialog.svelte +++ b/apps/desktop/src/lib/write-operations/CopyProgressDialog.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy, tick } from 'svelte' import { copyFiles, + copyBetweenVolumes, onWriteProgress, onWriteComplete, onWriteError, @@ -11,6 +12,7 @@ cancelWriteOperation, formatBytes, formatDuration, + DEFAULT_VOLUME_ID, type WriteProgressEvent, type WriteCompleteEvent, type WriteErrorEvent, @@ -53,9 +55,15 @@ sortOrder: SortOrder /** Preview scan ID from CopyDialog (for reusing scan results, optional) */ previewId: string | null + /** Source volume ID (e.g., "root", "mtp-336592896:65537") */ + sourceVolumeId: string + /** Destination volume ID */ + destVolumeId: string + /** Conflict resolution policy from CopyDialog */ + conflictResolution: ConflictResolution onComplete: (filesProcessed: number, bytesProcessed: number) => void onCancelled: (filesProcessed: number) => void - onError: (error: string) => void + onError: (error: WriteOperationError) => void } const { @@ -66,6 +74,9 @@ sortColumn, sortOrder, previewId, + sourceVolumeId, + destVolumeId, + conflictResolution, onComplete, onCancelled, onError, @@ -167,32 +178,6 @@ onComplete(event.filesProcessed, event.bytesProcessed) } - /** Converts a WriteOperationError to a user-friendly message. */ - function formatErrorMessage(error: WriteOperationError): string { - switch (error.type) { - case 'source_not_found': - return `Source not found: ${error.path}` - case 'destination_exists': - return `Destination already exists: ${error.path}` - case 'permission_denied': - return `Permission denied: ${error.path}${error.message ? ` - ${error.message}` : ''}` - case 'insufficient_space': - return `Not enough space: need ${formatBytes(error.required)}, only ${formatBytes(error.available)} available` - case 'same_location': - return `Source and destination are the same: ${error.path}` - case 'destination_inside_source': - return `Can't copy a folder into itself` - case 'symlink_loop': - return `Symbolic link loop detected: ${error.path}` - case 'cancelled': - return `Operation cancelled: ${error.message}` - case 'io_error': - return `I/O error at ${error.path}: ${error.message}` - default: - return 'An unknown error occurred' - } - } - function handleError(event: WriteErrorEvent) { // Filter by operationId (events are global) // Accept if operationId is null (race condition) or matches @@ -205,7 +190,7 @@ log.error('Copy error: {errorType}', { errorType: event.error.type, error: event.error }) cleanup() - onError(formatErrorMessage(event.error)) + onError(event.error) } function handleCancelled(event: WriteCancelledEvent) { @@ -289,26 +274,41 @@ unlisteners.push(await onWriteCancelled(handleCancelled)) unlisteners.push(await onWriteConflict(handleConflict)) - log.debug('Event subscriptions ready, starting copyFiles') + log.debug('Event subscriptions ready, starting copy') try { const progressIntervalMs = getSetting('fileOperations.progressUpdateInterval') const maxConflictsToShow = getSetting('fileOperations.maxConflictsToShow') - const result = await copyFiles(sourcePaths, destinationPath, { - conflictResolution: 'stop', - progressIntervalMs, - maxConflictsToShow, - sortColumn, - sortOrder, - previewId, - }) + + // Use unified copyBetweenVolumes for cross-volume operations (including MTP) + // Fall back to copyFiles for local-to-local copies when both volumes are "root" + const isLocalToLocal = sourceVolumeId === DEFAULT_VOLUME_ID && destVolumeId === DEFAULT_VOLUME_ID + const result = isLocalToLocal + ? await copyFiles(sourcePaths, destinationPath, { + conflictResolution, + progressIntervalMs, + maxConflictsToShow, + sortColumn, + sortOrder, + previewId, + }) + : await copyBetweenVolumes(sourceVolumeId, sourcePaths, destVolumeId, destinationPath, { + conflictResolution, + progressIntervalMs, + maxConflictsToShow, + }) operationId = result.operationId log.info('Copy operation started with operationId: {operationId}', { operationId }) } catch (err) { log.error('Failed to start copy operation: {error}', { error: err }) cleanup() - onError(`Failed to start copy: ${String(err)}`) + // Create an io_error type for startup failures + onError({ + type: 'io_error', + path: sourcePaths[0] ?? '', + message: `Failed to start copy: ${String(err)}`, + }) } } diff --git a/apps/desktop/src/lib/write-operations/copy-error-messages.test.ts b/apps/desktop/src/lib/write-operations/copy-error-messages.test.ts new file mode 100644 index 0000000..ccb0a98 --- /dev/null +++ b/apps/desktop/src/lib/write-operations/copy-error-messages.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from 'vitest' +import { getUserFriendlyMessage, getTechnicalDetails } from './copy-error-messages' +import type { WriteOperationError } from '$lib/file-explorer/types' + +describe('getUserFriendlyMessage', () => { + it('returns user-friendly message for source_not_found error', () => { + const error: WriteOperationError = { type: 'source_not_found', path: '/path/to/file.txt' } + const result = getUserFriendlyMessage(error) + + expect(result.title).toBe("Couldn't find the file") + expect(result.message).toContain('no longer exists') + expect(result.suggestion).toContain('refreshing') + }) + + it('returns user-friendly message for destination_exists error', () => { + const error: WriteOperationError = { type: 'destination_exists', path: '/dest/file.txt' } + const result = getUserFriendlyMessage(error) + + expect(result.title).toBe('File already exists') + expect(result.message).toContain('already a file') + }) + + it('returns user-friendly message for permission_denied error', () => { + const error: WriteOperationError = { + type: 'permission_denied', + path: '/protected/dir', + message: 'Operation not permitted', + } + const result = getUserFriendlyMessage(error) + + expect(result.title).toBe("Couldn't access this location") + expect(result.message).toContain('permission') + expect(result.suggestion).toContain('write access') + }) + + it('returns user-friendly message for insufficient_space error', () => { + const error: WriteOperationError = { + type: 'insufficient_space', + required: 1073741824, // 1 GB + available: 536870912, // 512 MB + volumeName: 'Test Volume', + } + const result = getUserFriendlyMessage(error) + + expect(result.title).toBe('Not enough space') + expect(result.message).toContain('1.0 GB') + expect(result.message).toContain('512.0 MB') + expect(result.suggestion).toContain('Free up') + }) + + it('returns user-friendly message for same_location error', () => { + const error: WriteOperationError = { type: 'same_location', path: '/same/path' } + const result = getUserFriendlyMessage(error) + + expect(result.title).toBe("Can't copy to the same location") + expect(result.message).toContain('same') + }) + + it('returns user-friendly message for destination_inside_source error', () => { + const error: WriteOperationError = { + type: 'destination_inside_source', + source: '/folder', + destination: '/folder/subfolder', + } + const result = getUserFriendlyMessage(error) + + expect(result.title).toBe("Can't copy a folder into itself") + expect(result.message).toContain('subfolders') + }) + + it('returns user-friendly message for symlink_loop error', () => { + const error: WriteOperationError = { type: 'symlink_loop', path: '/path/with/loop' } + const result = getUserFriendlyMessage(error) + + expect(result.title).toBe('Link loop detected') + expect(result.message).toContain('infinite loop') + }) + + it('returns user-friendly message for cancelled error', () => { + const error: WriteOperationError = { type: 'cancelled', message: 'User cancelled' } + const result = getUserFriendlyMessage(error) + + expect(result.title).toBe('Copy cancelled') + expect(result.message).toContain('cancelled') + }) + + describe('io_error messages', () => { + it('detects device disconnection', () => { + const error: WriteOperationError = { + type: 'io_error', + path: '/path', + message: 'Device disconnected during transfer', + } + const result = getUserFriendlyMessage(error) + + expect(result.message).toContain('disconnected') + expect(result.suggestion).toContain('properly connected') + }) + + it('detects connection timeout', () => { + const error: WriteOperationError = { + type: 'io_error', + path: '/path', + message: 'Connection timed out', + } + const result = getUserFriendlyMessage(error) + + expect(result.message).toContain('interrupted') + expect(result.suggestion).toContain('connection') + }) + + it('detects read errors', () => { + const error: WriteOperationError = { + type: 'io_error', + path: '/path', + message: 'Read error on source file', + } + const result = getUserFriendlyMessage(error) + + expect(result.message).toContain("Couldn't read") + }) + + it('detects write errors', () => { + const error: WriteOperationError = { + type: 'io_error', + path: '/path', + message: 'Write error on destination', + } + const result = getUserFriendlyMessage(error) + + expect(result.message).toContain("Couldn't write") + }) + + it('detects filename too long', () => { + const error: WriteOperationError = { + type: 'io_error', + path: '/path', + message: 'File name too long for destination filesystem', + } + const result = getUserFriendlyMessage(error) + + expect(result.message).toContain('too long') + expect(result.suggestion).toContain('shorter name') + }) + + it('returns generic message for unknown IO errors', () => { + const error: WriteOperationError = { + type: 'io_error', + path: '/path', + message: 'Some unknown error XYZ123', + } + const result = getUserFriendlyMessage(error) + + expect(result.message).toContain('error occurred') + }) + + it('detects read-only device errors', () => { + const error: WriteOperationError = { + type: 'io_error', + path: '', + message: 'Error for mtp-35651584: This device is read-only. You can copy files from it, but not to it.', + } + const result = getUserFriendlyMessage(error) + + // Should mention read-only and provide helpful suggestion + expect(result.message).toContain('read-only') + expect(result.message).toContain('copy files from it') + expect(result.suggestion).toContain('different destination') + }) + + it('does not misinterpret read-only as read error', () => { + // This test verifies the fix: "read-only" should NOT trigger "Couldn't read from the source" + const error: WriteOperationError = { + type: 'io_error', + path: '', + message: 'Error for mtp-35651584: This device is read-only.', + } + const result = getUserFriendlyMessage(error) + + // Should NOT say "Couldn't read from the source" + expect(result.message).not.toContain("Couldn't read from the source") + expect(result.message).toContain('read-only') + }) + }) +}) + +describe('getTechnicalDetails', () => { + it('includes path for source_not_found error', () => { + const error: WriteOperationError = { type: 'source_not_found', path: '/path/to/file.txt' } + const result = getTechnicalDetails(error) + + expect(result).toContain('Path: /path/to/file.txt') + expect(result).toContain('Error type: source_not_found') + }) + + it('includes path and message for permission_denied error', () => { + const error: WriteOperationError = { + type: 'permission_denied', + path: '/protected/dir', + message: 'Operation not permitted', + } + const result = getTechnicalDetails(error) + + expect(result).toContain('Path: /protected/dir') + expect(result).toContain('Details: Operation not permitted') + }) + + it('includes space info for insufficient_space error', () => { + const error: WriteOperationError = { + type: 'insufficient_space', + required: 1073741824, + available: 536870912, + volumeName: 'Test Volume', + } + const result = getTechnicalDetails(error) + + expect(result).toContain('Required: 1.0 GB') + expect(result).toContain('Available: 512.0 MB') + expect(result).toContain('Volume: Test Volume') + }) + + it('includes source and destination for destination_inside_source error', () => { + const error: WriteOperationError = { + type: 'destination_inside_source', + source: '/folder', + destination: '/folder/subfolder', + } + const result = getTechnicalDetails(error) + + expect(result).toContain('Source: /folder') + expect(result).toContain('Destination: /folder/subfolder') + }) + + it('includes path and error message for io_error', () => { + const error: WriteOperationError = { + type: 'io_error', + path: '/path/to/file', + message: 'Device disconnected', + } + const result = getTechnicalDetails(error) + + expect(result).toContain('Path: /path/to/file') + expect(result).toContain('Error: Device disconnected') + expect(result).toContain('Error type: io_error') + }) +}) + +describe('error messages are volume-agnostic', () => { + it('does not mention MTP in any error message', () => { + const errors: WriteOperationError[] = [ + { type: 'source_not_found', path: '/mtp-device/file.txt' }, + { type: 'permission_denied', path: '/mtp-device/protected', message: 'MTP error' }, + { type: 'io_error', path: '/mtp', message: 'MTP transfer failed' }, + ] + + for (const error of errors) { + const result = getUserFriendlyMessage(error) + const allText = `${result.title} ${result.message} ${result.suggestion}`.toLowerCase() + expect(allText).not.toContain('mtp') + } + }) + + it('does not mention SMB in any error message', () => { + const errors: WriteOperationError[] = [ + { type: 'source_not_found', path: '//server/share/file.txt' }, + { type: 'permission_denied', path: '//server/share', message: 'SMB error' }, + { type: 'io_error', path: '/smb', message: 'SMB connection failed' }, + ] + + for (const error of errors) { + const result = getUserFriendlyMessage(error) + const allText = `${result.title} ${result.message} ${result.suggestion}`.toLowerCase() + expect(allText).not.toContain('smb') + } + }) +}) diff --git a/apps/desktop/src/lib/write-operations/copy-error-messages.ts b/apps/desktop/src/lib/write-operations/copy-error-messages.ts new file mode 100644 index 0000000..572c3c4 --- /dev/null +++ b/apps/desktop/src/lib/write-operations/copy-error-messages.ts @@ -0,0 +1,220 @@ +/** + * User-friendly error message generation for copy operations. + * Extracted from CopyErrorDialog.svelte for testability. + */ + +import type { WriteOperationError } from '$lib/file-explorer/types' +import { formatBytes } from '$lib/tauri-commands' +import { getDevice } from '$lib/mtp/mtp-store.svelte' + +export interface FriendlyErrorMessage { + /** Short title for the error */ + title: string + /** Main explanation of what happened */ + message: string + /** Suggestion for what the user can do */ + suggestion: string +} + +/** + * Returns a user-friendly message for a copy operation error. + * Volume-agnostic: doesn't mention MTP, SMB, etc. directly. + */ +export function getUserFriendlyMessage(error: WriteOperationError): FriendlyErrorMessage { + switch (error.type) { + case 'source_not_found': + return { + title: "Couldn't find the file", + message: 'The file or folder you tried to copy no longer exists.', + suggestion: 'It may have been moved, renamed, or deleted. Try refreshing the file list.', + } + case 'destination_exists': + return { + title: 'File already exists', + message: "There's already a file with this name at the destination.", + suggestion: 'Choose a different name or location, or delete the existing file first.', + } + case 'permission_denied': + return { + title: "Couldn't access this location", + message: "You don't have permission to copy files here.", + suggestion: + 'Check that you have write access to the destination folder. You may need to unlock the device or change folder permissions.', + } + case 'insufficient_space': + return { + title: 'Not enough space', + message: `The destination needs ${formatBytes(error.required)} but only has ${formatBytes(error.available)} available.`, + suggestion: + 'Free up some space on the destination by deleting unnecessary files, or choose a different location.', + } + case 'same_location': + return { + title: "Can't copy to the same location", + message: 'The source and destination are the same.', + suggestion: 'Choose a different destination folder.', + } + case 'destination_inside_source': + return { + title: "Can't copy a folder into itself", + message: "You're trying to copy a folder into one of its own subfolders.", + suggestion: 'Choose a destination outside of the folder you are copying.', + } + case 'symlink_loop': + return { + title: 'Link loop detected', + message: 'This folder contains symbolic links that create an infinite loop.', + suggestion: + 'The folder structure contains circular references. You may need to remove some symbolic links.', + } + case 'cancelled': + return { + title: 'Copy cancelled', + message: 'The copy operation was cancelled.', + suggestion: 'You can try again when ready.', + } + case 'io_error': + return { + title: 'Copy failed', + message: getIoErrorMessage(error.message), + suggestion: getIoErrorSuggestion(error.message), + } + default: + return { + title: 'Copy failed', + message: 'An unexpected error occurred while copying.', + suggestion: 'Try again, or check the technical details below for more information.', + } + } +} + +/** + * Extracts a friendly device name from an MTP device ID in an error message. + * Falls back to "The target device" if device not found. + */ +function getDeviceNameFromError(rawMessage: string): string { + // Extract device ID pattern like "mtp-35651584" from the message + const deviceIdMatch = rawMessage.match(/mtp-\d+/) + if (deviceIdMatch) { + const deviceId = deviceIdMatch[0] + const device = getDevice(deviceId) + if (device) { + return device.displayName + } + } + return 'The target device' +} + +/** + * Parses IO error messages into user-friendly text. + */ +function getIoErrorMessage(rawMessage: string): string { + const lower = rawMessage.toLowerCase() + + // Read-only device (check BEFORE generic "read" + "error" check!) + if (lower.includes('read-only')) { + const deviceName = getDeviceNameFromError(rawMessage) + return `${deviceName} is read-only. You can copy files from it, but not to it.` + } + + // Device disconnected + if (lower.includes('disconnect') || lower.includes('not found') || lower.includes('no such device')) { + return 'The device was disconnected during the copy.' + } + + // Connection errors + if (lower.includes('connection') || lower.includes('timeout') || lower.includes('timed out')) { + return 'The connection was interrupted.' + } + + // Read/write errors + if (lower.includes('read') && lower.includes('error')) { + return "Couldn't read from the source." + } + if (lower.includes('write') && lower.includes('error')) { + return "Couldn't write to the destination." + } + + // File system errors + if (lower.includes('name too long')) { + return 'The file name is too long for the destination.' + } + if (lower.includes('invalid') && lower.includes('name')) { + return 'The file name contains characters not allowed at the destination.' + } + + // Default + return 'An error occurred while copying the file.' +} + +/** + * Returns a helpful suggestion based on the IO error. + */ +function getIoErrorSuggestion(rawMessage: string): string { + const lower = rawMessage.toLowerCase() + + // Read-only device - no action the user can take + if (lower.includes('read-only')) { + return 'Choose a different destination that supports writing.' + } + + if (lower.includes('disconnect') || lower.includes('not found') || lower.includes('no such device')) { + return 'Make sure the device is properly connected and try again.' + } + + if (lower.includes('connection') || lower.includes('timeout') || lower.includes('timed out')) { + return 'Check your connection and try again. If copying to a network location, ensure the server is reachable.' + } + + if (lower.includes('name too long') || (lower.includes('invalid') && lower.includes('name'))) { + return 'Try renaming the file to use a shorter name or remove special characters.' + } + + return 'Try again. If the problem persists, check the technical details below.' +} + +/** + * Returns the technical details for an error (path, raw error message, etc.) + */ +export function getTechnicalDetails(error: WriteOperationError): string { + const lines: string[] = [] + + switch (error.type) { + case 'source_not_found': + case 'destination_exists': + case 'same_location': + case 'symlink_loop': + lines.push(`Path: ${error.path}`) + break + case 'permission_denied': + lines.push(`Path: ${error.path}`) + if (error.message) { + lines.push(`Details: ${error.message}`) + } + break + case 'insufficient_space': + lines.push(`Required: ${formatBytes(error.required)}`) + lines.push(`Available: ${formatBytes(error.available)}`) + if (error.volumeName) { + lines.push(`Volume: ${error.volumeName}`) + } + break + case 'destination_inside_source': + lines.push(`Source: ${error.source}`) + lines.push(`Destination: ${error.destination}`) + break + case 'cancelled': + if (error.message) { + lines.push(`Details: ${error.message}`) + } + break + case 'io_error': + lines.push(`Path: ${error.path}`) + lines.push(`Error: ${error.message}`) + break + } + + lines.push(`Error type: ${error.type}`) + + return lines.join('\n') +} diff --git a/apps/desktop/src/routes/(main)/+layout.svelte b/apps/desktop/src/routes/(main)/+layout.svelte index 3bec2da..e89163b 100644 --- a/apps/desktop/src/routes/(main)/+layout.svelte +++ b/apps/desktop/src/routes/(main)/+layout.svelte @@ -9,44 +9,103 @@ import { initSettingsApplier, cleanupSettingsApplier } from '$lib/settings/settings-applier' import { initReactiveSettings, cleanupReactiveSettings } from '$lib/settings/reactive-settings.svelte' import { initializeShortcuts, setupMcpShortcutsListener, cleanupMcpShortcutsListener } from '$lib/shortcuts' + import { onMtpExclusiveAccessError, connectMtpDevice, type MtpExclusiveAccessErrorEvent } from '$lib/tauri-commands' import AiNotification from '$lib/AiNotification.svelte' import UpdateNotification from '$lib/UpdateNotification.svelte' + import { PtpcameradDialog } from '$lib/mtp' + import type { Snippet } from 'svelte' - let cleanupUpdater: (() => void) | undefined + interface Props { + children?: Snippet + } + + const { children }: Props = $props() + + // State for ptpcamerad dialog + let showPtpcameradDialog = $state(false) + let ptpcameradBlockingProcess = $state(undefined) + let pendingDeviceId = $state(undefined) + + function handleMtpExclusiveAccessError(event: MtpExclusiveAccessErrorEvent) { + ptpcameradBlockingProcess = event.blockingProcess + pendingDeviceId = event.deviceId + showPtpcameradDialog = true + } + + function closePtpcameradDialog() { + showPtpcameradDialog = false + ptpcameradBlockingProcess = undefined + pendingDeviceId = undefined + } + + async function retryMtpConnection() { + if (pendingDeviceId) { + const deviceId = pendingDeviceId + closePtpcameradDialog() + try { + await connectMtpDevice(deviceId) + } catch { + // Error will trigger another event if it's still exclusive access + } + } + } + + // Cleanup functions stored for onDestroy + let mtpUnlistenPromise: Promise<() => void> | undefined + let updateCleanup: (() => void) | undefined + + onMount(() => { + // Initialize all async setup + void (async () => { + // Initialize reactive settings for UI components + await initReactiveSettings() - onMount(async () => { - // Initialize reactive settings for UI components - await initReactiveSettings() + // Initialize settings and apply them to CSS variables + await initSettingsApplier() - // Initialize settings and apply them to CSS variables - await initSettingsApplier() + // Initialize keyboard shortcuts store (loads custom shortcuts from disk) + await initializeShortcuts() - // Initialize keyboard shortcuts store (loads custom shortcuts from disk) - await initializeShortcuts() + // Set up MCP shortcuts listener (allows MCP tools to modify shortcuts) + await setupMcpShortcutsListener() - // Set up MCP shortcuts listener (allows MCP tools to modify shortcuts) - await setupMcpShortcutsListener() + // Initialize window state persistence on resize + // This ensures window size/position survives hot reloads + void initWindowStateListener() - // Initialize window state persistence on resize - // This ensures window size/position survives hot reloads - void initWindowStateListener() + // Listen for MTP exclusive access errors + mtpUnlistenPromise = onMtpExclusiveAccessError(handleMtpExclusiveAccessError) - // Start checking for updates (skips in dev mode) - cleanupUpdater = startUpdateChecker() + // Start checking for updates (skips in dev mode) + updateCleanup = startUpdateChecker() + })() }) onDestroy(() => { + // Cleanup MTP listener + void mtpUnlistenPromise?.then((unlisten) => { + unlisten() + }) + // Cleanup update checker + updateCleanup?.() + // Cleanup other modules cleanupReactiveSettings() cleanupSettingsApplier() cleanupMcpShortcutsListener() - cleanupUpdater?.() }) +{#if showPtpcameradDialog} + +{/if}
- + {@render children?.()}