diff --git a/core/src/api/volumes.rs b/core/src/api/volumes.rs index e14a46550ea2..4ffe63e4cc3b 100644 --- a/core/src/api/volumes.rs +++ b/core/src/api/volumes.rs @@ -59,9 +59,9 @@ pub(crate) fn mount() -> AlphaRouter { .procedure( "unmount", R.with2(library()) - .mutation(|(node, _), volume_id: Vec| async move { + .mutation(|(node, _), fingerprint: Vec| async move { node.volumes - .unmount_volume(volume_id) + .unmount_volume(fingerprint) .await .map_err(Into::into) }), diff --git a/core/src/volume/actor.rs b/core/src/volume/actor.rs index 49cac9d331a9..2393cbff2f18 100644 --- a/core/src/volume/actor.rs +++ b/core/src/volume/actor.rs @@ -376,7 +376,10 @@ impl VolumeManagerActor { VolumeManagerMessage::UnmountVolume { volume_fingerprint, ack, - } => todo!(), + } => { + let result = self.handle_unmount_volume(volume_fingerprint).await; + let _ = ack.send(result); + } VolumeManagerMessage::SpeedTest { volume_fingerprint, ack, @@ -495,6 +498,45 @@ impl VolumeManagerActor { Ok(()) } + async fn handle_unmount_volume( + &mut self, + volume_fingerprint: Vec, + ) -> Result<(), VolumeError> { + // First get the volume from state to get its mount point + let volume = self + .state + .write() + .await + .volumes + .get(&volume_fingerprint) + .ok_or(VolumeError::NotFound(volume_fingerprint.clone()))? + .clone(); + + // Check if volume is actually mounted + if !volume.is_mounted { + return Err(VolumeError::NotMounted(volume.mount_point.clone())); + } + + // Call the platform-specific unmount function + super::os::unmount_volume(&volume.mount_point).await?; + + // If unmount succeeded, update our state + let mut state = self.state.write().await; + if let Some(vol) = state.volumes.get_mut(&volume_fingerprint) { + vol.is_mounted = false; + } + + // Emit unmount event + if let Some(pub_id) = volume.pub_id { + let _ = self.event_tx.send(VolumeEvent::VolumeMountChanged { + id: pub_id, + is_mounted: false, + }); + } + + Ok(()) + } + async fn handle_library_deletion(&mut self, library: Arc) -> Result<(), VolumeError> { // Clean up volumes associated with deleted library let _state = self.state.write().await; diff --git a/core/src/volume/error.rs b/core/src/volume/error.rs index 61d9c5bcedcb..47b1c434d69a 100644 --- a/core/src/volume/error.rs +++ b/core/src/volume/error.rs @@ -29,6 +29,10 @@ pub enum VolumeError { #[error("No mount point found for volume")] NoMountPoint, + /// Volume is already mounted + #[error("Volume with fingerprint {} is not found", hex::encode(.0))] + NotFound(Vec), + /// Volume isn't in database yet #[error("Volume not yet tracked in database")] NotInDatabase, diff --git a/core/src/volume/os.rs b/core/src/volume/os.rs index 2fd4826dad0b..0658bce94974 100644 --- a/core/src/volume/os.rs +++ b/core/src/volume/os.rs @@ -10,6 +10,14 @@ pub use self::macos::get_volumes; #[cfg(target_os = "windows")] pub use self::windows::get_volumes; +// Re-export platform-specific unmount_volume function +#[cfg(target_os = "linux")] +pub use self::linux::unmount_volume; +#[cfg(target_os = "macos")] +pub use self::macos::unmount_volume; +#[cfg(target_os = "windows")] +pub use self::windows::unmount_volume; + /// Common utilities for volume detection across platforms mod common { pub fn parse_size(size_str: &str) -> u64 { @@ -135,6 +143,43 @@ pub mod macos { .map(|line| line.contains("read-only")) .unwrap_or(false)) } + pub async fn unmount_volume(path: &std::path::Path) -> Result<(), VolumeError> { + use std::process::Command; + use tokio::process::Command as TokioCommand; + + // First try diskutil + let result = TokioCommand::new("diskutil") + .arg("unmount") + .arg(path) + .output() + .await; + + match result { + Ok(output) => { + if output.status.success() { + return Ok(()); + } + // If diskutil fails, try umount as fallback + let fallback = Command::new("umount") + .arg(path) + .output() + .map_err(|e| VolumeError::Platform(format!("Unmount failed: {}", e)))?; + + if fallback.status.success() { + Ok(()) + } else { + Err(VolumeError::Platform(format!( + "Failed to unmount volume: {}", + String::from_utf8_lossy(&fallback.stderr) + ))) + } + } + Err(e) => Err(VolumeError::Platform(format!( + "Failed to execute unmount command: {}", + e + ))), + } + } } #[cfg(target_os = "linux")] @@ -209,6 +254,52 @@ pub mod linux { disk.available_space(), )) } + pub async fn unmount_volume(path: &std::path::Path) -> Result<(), VolumeError> { + use tokio::process::Command; + + // Try umount first + let result = Command::new("umount") + .arg(path) + .output() + .await + .map_err(|e| VolumeError::Platform(format!("Unmount failed: {}", e)))?; + + if result.status.success() { + Ok(()) + } else { + // If regular unmount fails, try with force option + let force_result = Command::new("umount") + .arg("-f") // Force unmount + .arg(path) + .output() + .await + .map_err(|e| VolumeError::Platform(format!("Force unmount failed: {}", e)))?; + + if force_result.status.success() { + Ok(()) + } else { + // If both attempts fail, try udisksctl as a last resort + let udisks_result = Command::new("udisksctl") + .arg("unmount") + .arg("-b") + .arg(path) + .output() + .await + .map_err(|e| { + VolumeError::Platform(format!("udisksctl unmount failed: {}", e)) + })?; + + if udisks_result.status.success() { + Ok(()) + } else { + Err(VolumeError::Platform(format!( + "All unmount attempts failed: {}", + String::from_utf8_lossy(&udisks_result.stderr) + ))) + } + } + } + } } #[cfg(target_os = "windows")] pub mod windows { @@ -337,4 +428,44 @@ pub mod windows { // using IOCTL_STORAGE_QUERY_PROPERTY DiskType::Unknown } + pub async fn unmount_volume(path: &std::path::Path) -> Result<(), VolumeError> { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows::core::PWSTR; + use windows::Win32::Storage::FileSystem::{ + DeleteVolumeMountPointW, GetVolumeNameForVolumeMountPointW, + }; + + // Convert path to wide string for Windows API + let wide_path: Vec = OsStr::new(path) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + unsafe { + // Buffer for volume name + let mut volume_name = [0u16; 50]; + let mut volume_name_ptr = PWSTR(volume_name.as_mut_ptr()); + + // Get the volume name for the mount point + let result = GetVolumeNameForVolumeMountPointW(wide_path.as_ptr(), volume_name_ptr); + + if !result.as_bool() { + return Err(VolumeError::Platform( + "Failed to get volume name".to_string(), + )); + } + + // Delete the mount point + let result = DeleteVolumeMountPointW(wide_path.as_ptr()); + + if result.as_bool() { + Ok(()) + } else { + Err(VolumeError::Platform( + "Failed to unmount volume".to_string(), + )) + } + } + } } diff --git a/core/src/volume/state.rs b/core/src/volume/state.rs index 0669c135d2e5..d90c8469e378 100644 --- a/core/src/volume/state.rs +++ b/core/src/volume/state.rs @@ -16,7 +16,7 @@ pub struct VolumeManagerState { /// All tracked volumes by fingerprint pub volumes: HashMap, Volume>, /// Mapping of library volumes to system volumes - /// LibraryPubId -> VolumePubId -> Fingerprint + /// LibraryPubId -> Fingerprint -> VolumePubId pub library_volume_mapping: HashMap, HashMap, Vec>>, /// Volume manager options pub options: VolumeOptions, diff --git a/core/src/volume/watcher.rs b/core/src/volume/watcher.rs index cf49090d7abb..c1c88294eb53 100644 --- a/core/src/volume/watcher.rs +++ b/core/src/volume/watcher.rs @@ -12,13 +12,6 @@ use tracing::{debug, error, warn}; const DEBOUNCE_MS: u64 = 100; -#[derive(Debug)] -pub struct WatcherState { - pub watcher: Arc, - pub last_event: Instant, - pub paused: bool, -} - #[derive(Debug)] pub struct VolumeWatcher { event_tx: broadcast::Sender, diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx index 99b8a689bfc1..31225bc70072 100644 --- a/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx @@ -24,7 +24,13 @@ import { SeeMore } from '../../SidebarLayout/SeeMore'; const Name = tw.span`truncate`; // Improved eject button that actually unmounts the volume -const EjectButton = ({ volumeId, className }: { volumeId: Uint8Array; className?: string }) => { +const EjectButton = ({ + fingerprint, + className +}: { + fingerprint: Uint8Array; + className?: string; +}) => { const unmountMutation = useLibraryMutation('volumes.unmount'); return ( @@ -34,9 +40,7 @@ const EjectButton = ({ volumeId, className }: { volumeId: Uint8Array; className? onClick={async (e: MouseEvent) => { e.preventDefault(); // Prevent navigation try { - await unmountMutation.mutateAsync({ - fingerprint: Array.from(volumeId) // Convert Uint8Array to number[] - }); + await unmountMutation.mutateAsync(Array.from(fingerprint)); toast.success('Volume ejected successfully'); } catch (error) { toast.error('Failed to eject volume'); @@ -164,8 +168,8 @@ export default function LocalSection() { > {displayName} - {volume.mount_type === 'External' && volume.pub_id && ( - + {volume.mount_type === 'External' && volume.fingerprint && ( + )} );