From 7c9166a8cf9e73e3e5e052fa0da3ca1c9e87155b Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sun, 12 Jul 2026 11:40:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=99=9A=E6=8B=9F=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=90=8C=E6=97=B6=E6=8C=82=E8=BD=BD=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=E9=95=9C=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/events/mod.rs | 2 +- src/events/types.rs | 18 +- src/hid/backend.rs | 4 + src/hid/mod.rs | 24 ++ src/hid/otg.rs | 32 ++ src/msd/controller.rs | 548 +++++++++++++++++++++---- src/msd/image.rs | 4 - src/msd/mod.rs | 5 +- src/msd/types.rs | 137 ++++++- src/otg/manager.rs | 4 +- src/otg/msd.rs | 195 ++++++++- src/otg/service.rs | 92 ++++- src/redfish/routes/virtual_media.rs | 22 +- src/state.rs | 33 +- src/web/handlers/msd_api.rs | 178 ++++++-- src/web/routes.rs | 12 +- test/okvm-test/README.md | 2 +- web/src/api/index.ts | 49 ++- web/src/components/MsdDialog.vue | 449 +++++++++++++------- web/src/components/MsdSheet.vue | 610 ---------------------------- web/src/i18n/en-US.ts | 27 +- web/src/i18n/zh-CN.ts | 27 +- web/src/stores/system.ts | 41 +- web/src/views/ConsoleView.vue | 34 +- 24 files changed, 1513 insertions(+), 1036 deletions(-) delete mode 100644 web/src/components/MsdSheet.vue diff --git a/src/events/mod.rs b/src/events/mod.rs index fce3c612..b657bdd2 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -6,7 +6,7 @@ use self::types::EXACT_EVENT_TOPICS; pub use types::{ AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo, - StreamDeviceLostKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo, + MsdDeviceMediaInfo, StreamDeviceLostKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo, }; use tokio::sync::broadcast; diff --git a/src/events/types.rs b/src/events/types.rs index 1b320d53..2cf37551 100644 --- a/src/events/types.rs +++ b/src/events/types.rs @@ -42,12 +42,24 @@ pub struct HidDeviceInfo { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MsdDeviceInfo { pub available: bool, - pub mode: String, - pub connected: bool, - pub image_id: Option, + pub disk_mode: String, + pub slot_capacity: u8, + pub mounted_count: u8, + pub mounted_media: Vec, + pub usb_reenumerating: bool, pub error: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MsdDeviceMediaInfo { + pub id: String, + pub kind: String, + pub name: String, + pub cdrom: bool, + pub read_only: bool, + pub size: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AtxDeviceInfo { pub available: bool, diff --git a/src/hid/backend.rs b/src/hid/backend.rs index ac9c7b48..8ffa899c 100644 --- a/src/hid/backend.rs +++ b/src/hid/backend.rs @@ -83,6 +83,10 @@ pub trait HidBackend: Send + Sync { async fn reset(&self) -> Result<()>; + async fn prepare_rebuild(&self) -> Result<()> { + self.shutdown().await + } + async fn shutdown(&self) -> Result<()>; fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot; diff --git a/src/hid/mod.rs b/src/hid/mod.rs index 12554586..ea643c97 100644 --- a/src/hid/mod.rs +++ b/src/hid/mod.rs @@ -234,6 +234,30 @@ impl HidController { Ok(()) } + pub async fn prepare_otg_rebuild(&self) -> Result<()> { + if !matches!(*self.backend_type.read().await, HidBackendType::Otg) { + return Ok(()); + } + + info!("Preparing OTG HID backend for gadget rebuild"); + self.backend_available.store(false, Ordering::Release); + self.stop_runtime_worker().await; + + if let Some(backend) = self.backend.write().await.take() { + backend.prepare_rebuild().await?; + } + + let current = self.runtime_state.read().await.clone(); + let rebuilding_state = HidRuntimeState::with_error( + &HidBackendType::Otg, + ¤t, + "OTG gadget is rebuilding", + "rebuilding", + ); + self.apply_runtime_state(rebuilding_state).await; + Ok(()) + } + pub async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> { if !self.backend_available.load(Ordering::Acquire) { return Err(AppError::BadRequest( diff --git a/src/hid/otg.rs b/src/hid/otg.rs index 83b5ef8b..334e50b2 100644 --- a/src/hid/otg.rs +++ b/src/hid/otg.rs @@ -894,6 +894,19 @@ impl HidBackend for OtgBackend { Ok(()) } + async fn prepare_rebuild(&self) -> Result<()> { + self.stop_runtime_worker(); + *self.keyboard_dev.lock() = None; + *self.mouse_rel_dev.lock() = None; + *self.mouse_abs_dev.lock() = None; + *self.consumer_dev.lock() = None; + self.initialized.store(false, Ordering::Relaxed); + self.online.store(false, Ordering::Relaxed); + self.notify_runtime_changed(); + info!("OTG backend prepared for gadget rebuild"); + Ok(()) + } + async fn shutdown(&self) -> Result<()> { self.stop_runtime_worker(); @@ -948,6 +961,7 @@ impl Drop for OtgBackend { #[cfg(test)] mod tests { use super::*; + use std::io::{Seek, SeekFrom, Write}; #[test] fn test_led_state() { @@ -964,4 +978,22 @@ mod tests { let kb_report = KeyboardReport::default(); assert_eq!(kb_report.to_bytes().len(), 8); } + + #[tokio::test] + async fn prepare_rebuild_closes_devices_without_writing_reset_reports() { + let mut file = tempfile::tempfile().unwrap(); + file.write_all(b"sentinel").unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + + let backend = OtgBackend::from_handles(HidDevicePaths::default()).unwrap(); + *backend.keyboard_dev.lock() = Some(file); + backend.initialized.store(true, Ordering::Relaxed); + backend.online.store(true, Ordering::Relaxed); + + backend.prepare_rebuild().await.unwrap(); + + assert!(backend.keyboard_dev.lock().is_none()); + assert!(!backend.initialized.load(Ordering::Relaxed)); + assert!(!backend.online.load(Ordering::Relaxed)); + } } diff --git a/src/msd/controller.rs b/src/msd/controller.rs index 5498b217..d8b9de85 100644 --- a/src/msd/controller.rs +++ b/src/msd/controller.rs @@ -7,7 +7,10 @@ use tracing::{debug, info, warn}; use super::image::ImageManager; use super::monitor::MsdHealthMonitor; -use super::types::{DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MsdMode, MsdState}; +use super::types::{ + DiskMode, DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MountedMedia, + MountedMediaKind, MsdState, +}; use crate::error::{AppError, Result}; use crate::otg::{MsdFunction, MsdLunConfig, OtgService}; @@ -62,17 +65,23 @@ impl MsdController { *self.msd_function.write().await = Some(msd_func); let mut state = self.state.write().await; + state.disk_mode = if self.otg_service.msd_lun_capacity().await == 1 { + DiskMode::Single + } else { + DiskMode::Multi + }; state.available = true; if self.drive_path.exists() { if let Ok(metadata) = std::fs::metadata(&self.drive_path) { - state.drive_info = Some(DriveInfo { + let drive_info = DriveInfo { size: metadata.len(), used: 0, free: metadata.len(), initialized: true, path: self.drive_path.clone(), - }); + }; + state.drive_info = Some(drive_info.clone()); debug!( "Found existing virtual drive: {}", self.drive_path.display() @@ -104,20 +113,12 @@ impl MsdController { } } - pub async fn is_available(&self) -> bool { - self.state.read().await.available - } - - pub async fn connect_image( - &self, - image: &ImageInfo, - cdrom: bool, - read_only: bool, - ) -> Result<()> { + pub async fn mount_image(&self, image: &ImageInfo, cdrom: bool, read_only: bool) -> Result<()> { let _op_guard = self.operation_lock.write().await; let mut state = self.state.write().await; + let previous_state = state.clone(); - self.assert_can_connect(&state).await?; + self.assert_available(&state).await?; if !image.path.exists() { let error_msg = format!("Image file not found: {}", image.path.display()); @@ -127,20 +128,30 @@ impl MsdController { return Err(AppError::Internal(error_msg)); } - let config = if cdrom { - MsdLunConfig::cdrom(image.path.clone()) - } else { - MsdLunConfig::disk(image.path.clone(), read_only) - }; - self.configure_lun_now(&config).await?; + if state + .mounted_media + .iter() + .any(|media| media.kind == MountedMediaKind::Image && media.id == image.id) + { + return Err(AppError::BadRequest("Image is already mounted".to_string())); + } - state.connected = true; - state.mode = MsdMode::Image; - state.current_image = Some(image.clone()); + let lun = Self::lowest_free_lun(&state) + .ok_or_else(|| AppError::BadRequest("Media slots are full".to_string()))?; + + let media = MountedMedia::image(lun, image, cdrom, read_only); + if let Err(e) = self.configure_media(&media).await { + *state = previous_state; + return Err(e); + } + state.mounted_media.push(media); info!( - "Connected image: {} (cdrom={}, ro={})", - image.name, cdrom, read_only + "Mounted image: {} on LUN {} (cdrom={}, ro={})", + image.name, + lun, + cdrom, + cdrom || read_only ); drop(state); @@ -150,11 +161,12 @@ impl MsdController { Ok(()) } - pub async fn connect_drive(&self) -> Result<()> { + pub async fn mount_drive(&self) -> Result<()> { let _op_guard = self.operation_lock.write().await; let mut state = self.state.write().await; + let previous_state = state.clone(); - self.assert_can_connect(&state).await?; + self.assert_available(&state).await?; if !self.drive_path.exists() { let err = @@ -165,14 +177,48 @@ impl MsdController { return Err(err); } - let config = MsdLunConfig::disk(self.drive_path.clone(), false); - self.configure_lun_now(&config).await?; + let drive_info = state.drive_info.clone().or_else(|| { + std::fs::metadata(&self.drive_path) + .ok() + .map(|metadata| DriveInfo { + size: metadata.len(), + used: 0, + free: metadata.len(), + initialized: true, + path: self.drive_path.clone(), + }) + }); + if state.drive_info.is_none() { + state.drive_info = drive_info.clone(); + } - state.connected = true; - state.mode = MsdMode::Drive; - state.current_image = None; + if state + .mounted_media + .iter() + .any(|media| media.kind == MountedMediaKind::Drive) + { + return Err(AppError::BadRequest( + "Virtual drive is already mounted".to_string(), + )); + } - info!("Connected virtual drive: {}", self.drive_path.display()); + let drive_info = drive_info + .ok_or_else(|| AppError::Internal("Virtual drive info is unavailable".to_string()))?; + let lun = Self::lowest_free_lun(&state) + .ok_or_else(|| AppError::BadRequest("Media slots are full".to_string()))?; + + let media = MountedMedia::drive(lun, &drive_info); + if let Err(e) = self.configure_media(&media).await { + *state = previous_state; + return Err(e); + } + state.mounted_media.push(media); + + info!( + "Mounted virtual drive on LUN {}: {}", + lun, + self.drive_path.display() + ); drop(state); drop(_op_guard); @@ -181,22 +227,138 @@ impl MsdController { Ok(()) } - async fn assert_can_connect(&self, state: &MsdState) -> Result<()> { + async fn assert_available(&self, state: &MsdState) -> Result<()> { if !state.available { self.monitor .report_error("MSD not available", "not_available") .await; return Err(AppError::Internal("MSD not available".to_string())); } - if state.connected { - return Err(AppError::Internal( - "Already connected. Disconnect first.".to_string(), - )); - } Ok(()) } - async fn configure_lun_now(&self, config: &MsdLunConfig) -> Result<()> { + fn media_config(media: &MountedMedia) -> MsdLunConfig { + if media.cdrom { + MsdLunConfig::cdrom(media.path.clone()) + } else { + MsdLunConfig::disk(media.path.clone(), media.read_only) + } + } + + fn lowest_free_lun(state: &MsdState) -> Option { + (0..state.disk_mode.capacity()) + .find(|lun| !state.mounted_media.iter().any(|media| media.lun == *lun)) + } + + fn reset_mounts_for_mode(state: &mut MsdState, disk_mode: DiskMode) { + state.disk_mode = disk_mode; + state.mounted_media.clear(); + } + + pub async fn set_disk_mode(&self, disk_mode: DiskMode) -> Result { + let _op_guard = self.operation_lock.write().await; + let previous_state = { + let mut state = self.state.write().await; + self.assert_available(&state).await?; + if state.disk_mode == disk_mode { + return Ok(false); + } + let previous_state = state.clone(); + state.usb_reenumerating = true; + previous_state + }; + self.mark_device_info_dirty().await; + + let switch_result = async { + self.otg_service + .set_msd_lun_capacity(disk_mode.capacity()) + .await?; + self.otg_service.msd_function().await.ok_or_else(|| { + AppError::Internal("MSD function missing after OTG rebuild".to_string()) + }) + } + .await; + + let msd_function = match switch_result { + Ok(msd_function) => msd_function, + Err(switch_error) => { + if let Err(rollback_error) = self.rollback_mode_switch(&previous_state).await { + let mut state = self.state.write().await; + state.available = false; + state.mounted_media.clear(); + state.usb_reenumerating = false; + *self.msd_function.write().await = None; + let error_msg = format!( + "Failed to switch MSD disk mode: {switch_error}; rollback failed: {rollback_error}" + ); + self.monitor + .report_error(&error_msg, "disk_mode_rollback_failed") + .await; + self.mark_device_info_dirty().await; + return Err(AppError::Internal(error_msg)); + } + + let mut state = self.state.write().await; + *state = previous_state; + state.usb_reenumerating = false; + let error_msg = format!("Failed to switch MSD disk mode: {switch_error}"); + self.monitor + .report_error(&error_msg, "disk_mode_switch_failed") + .await; + self.mark_device_info_dirty().await; + return Err(AppError::Internal(error_msg)); + } + }; + *self.msd_function.write().await = Some(msd_function); + + let mut state = self.state.write().await; + Self::reset_mounts_for_mode(&mut state, disk_mode); + state.usb_reenumerating = false; + info!("Switched MSD disk mode to {:?}", disk_mode); + + drop(state); + drop(_op_guard); + + self.mark_device_info_dirty().await; + Ok(true) + } + + pub async fn unmount_image(&self, image_id: &str) -> Result<()> { + self.unmount_media(|media| media.kind == MountedMediaKind::Image && media.id == image_id) + .await + } + + pub async fn unmount_drive(&self) -> Result<()> { + self.unmount_media(|media| media.kind == MountedMediaKind::Drive) + .await + } + + async fn unmount_media(&self, predicate: F) -> Result<()> + where + F: Fn(&MountedMedia) -> bool, + { + let _op_guard = self.operation_lock.write().await; + + let mut state = self.state.write().await; + let Some(index) = state.mounted_media.iter().position(predicate) else { + debug!("Requested media was not mounted, skipping unmount"); + return Ok(()); + }; + let media = state.mounted_media[index].clone(); + + self.disconnect_lun(media.lun).await?; + state.mounted_media.remove(index); + info!("Unmounted media"); + + drop(state); + drop(_op_guard); + + self.mark_device_info_dirty().await; + + Ok(()) + } + + async fn configure_media(&self, media: &MountedMedia) -> Result<()> { let gadget_path = self.active_gadget_path().await?; let msd_hold = self.msd_function.read().await; let Some(ref msd) = *msd_hold else { @@ -207,8 +369,11 @@ impl MsdController { "MSD function not initialized".to_string(), )); }; - if let Err(e) = msd.configure_lun_async(&gadget_path, 0, config).await { - let error_msg = format!("Failed to configure LUN: {}", e); + if let Err(e) = msd + .configure_lun_async(&gadget_path, media.lun, &Self::media_config(media)) + .await + { + let error_msg = format!("Failed to configure LUN {}: {}", media.lun, e); self.monitor .report_error(&error_msg, "configfs_error") .await; @@ -217,6 +382,29 @@ impl MsdController { Ok(()) } + async fn disconnect_lun(&self, lun: u8) -> Result<()> { + let gadget_path = self.active_gadget_path().await?; + let msd_hold = self.msd_function.read().await; + let msd = msd_hold + .as_ref() + .ok_or_else(|| AppError::Internal("MSD function not initialized".to_string()))?; + msd.disconnect_lun_async(&gadget_path, lun).await + } + + async fn rollback_mode_switch(&self, previous_state: &MsdState) -> Result<()> { + self.otg_service + .set_msd_lun_capacity(previous_state.disk_mode.capacity()) + .await?; + let msd_function = self.otg_service.msd_function().await.ok_or_else(|| { + AppError::Internal("MSD function missing after OTG rollback".to_string()) + })?; + *self.msd_function.write().await = Some(msd_function); + for media in &previous_state.mounted_media { + self.configure_media(media).await?; + } + Ok(()) + } + async fn finish_connect_success(&self) { if self.monitor.is_error().await { self.monitor.report_recovered().await; @@ -228,22 +416,31 @@ impl MsdController { let _op_guard = self.operation_lock.write().await; let mut state = self.state.write().await; - - if !state.connected { - debug!("Nothing connected, skipping disconnect"); + if state.mounted_media.is_empty() { + debug!("Nothing mounted, skipping disconnect"); return Ok(()); } - let gadget_path = self.active_gadget_path().await?; - if let Some(ref msd) = *self.msd_function.read().await { - msd.disconnect_lun_async(&gadget_path, 0).await?; + let mounted_media = state.mounted_media.clone(); + let mut disconnected = Vec::new(); + for media in &mounted_media { + if let Err(error) = self.disconnect_lun(media.lun).await { + for prior in &disconnected { + if let Err(restore_error) = self.configure_media(prior).await { + state.available = false; + return Err(AppError::Internal(format!( + "Failed to disconnect LUN {}: {error}; restore failed: {restore_error}", + media.lun + ))); + } + } + return Err(error); + } + disconnected.push(media.clone()); } - state.connected = false; - state.mode = MsdMode::None; - state.current_image = None; - - info!("Disconnected storage"); + state.mounted_media.clear(); + info!("Disconnected all mounted media"); drop(state); drop(_op_guard); @@ -253,29 +450,29 @@ impl MsdController { Ok(()) } - pub fn images_path(&self) -> &PathBuf { - &self.images_path + pub async fn is_drive_connected(&self) -> bool { + self.state + .read() + .await + .mounted_media + .iter() + .any(|media| media.kind == MountedMediaKind::Drive) } - pub fn ventoy_dir(&self) -> &PathBuf { - &self.ventoy_dir - } + pub async fn delete_image(&self, image_id: &str) -> Result<()> { + let _op_guard = self.operation_lock.write().await; + let state = self.state.read().await; + if state + .mounted_media + .iter() + .any(|media| media.kind == MountedMediaKind::Image && media.id == image_id) + { + return Err(AppError::BadRequest( + "Cannot delete image while it is mounted".to_string(), + )); + } - pub fn drive_path(&self) -> &PathBuf { - &self.drive_path - } - - pub async fn is_connected(&self) -> bool { - self.state.read().await.connected - } - - pub async fn mode(&self) -> MsdMode { - self.state.read().await.mode.clone() - } - - pub async fn update_drive_info(&self, info: DriveInfo) { - let mut state = self.state.write().await; - state.drive_info = Some(info); + ImageManager::new(self.images_path.clone()).delete(image_id) } pub async fn download_image( @@ -423,6 +620,8 @@ impl MsdController { let mut state = self.state.write().await; state.available = false; + state.mounted_media.clear(); + state.usb_reenumerating = false; info!("MSD controller shutdown complete"); Ok(()) @@ -436,6 +635,7 @@ impl MsdController { #[cfg(test)] mod tests { use super::*; + use crate::msd::MULTI_DISK_MSD_LUNS; use tempfile::TempDir; #[tokio::test] @@ -462,7 +662,205 @@ mod tests { let state = controller.state().await; assert!(!state.available); - assert!(!state.connected); - assert_eq!(state.mode, MsdMode::None); + assert_eq!(state.disk_mode, DiskMode::Single); + assert!(state.mounted_media.is_empty()); + } + + #[test] + fn single_disk_mode_only_exposes_lun_zero() { + let mut state = MsdState::default(); + MsdController::reset_mounts_for_mode(&mut state, DiskMode::Single); + assert_eq!(state.disk_mode.capacity(), 1); + assert_eq!(MsdController::lowest_free_lun(&state), Some(0)); + + let temp_dir = TempDir::new().unwrap(); + let image_path = temp_dir.path().join("test.iso"); + std::fs::write(&image_path, b"iso").unwrap(); + let image = ImageInfo::new("test".into(), "test.iso".into(), image_path, 3); + state + .mounted_media + .push(MountedMedia::image(0, &image, true, false)); + + assert_eq!(MsdController::lowest_free_lun(&state), None); + let config = MsdController::media_config(&state.mounted_media[0]); + assert!(config.cdrom); + assert!(config.ro); + } + + #[test] + fn multi_disk_mode_allocates_lowest_free_lun() { + let temp_dir = TempDir::new().unwrap(); + let mut state = MsdState::default(); + MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi); + + for lun in [0, 1, 3] { + let image_path = temp_dir.path().join(format!("test{lun}.img")); + std::fs::write(&image_path, b"img").unwrap(); + let image = ImageInfo::new( + format!("test{lun}"), + format!("test{lun}.img"), + image_path, + 3, + ); + state + .mounted_media + .push(MountedMedia::image(lun, &image, false, false)); + } + + assert_eq!(MsdController::lowest_free_lun(&state), Some(2)); + } + + #[test] + fn multi_disk_mode_supports_eight_images_and_rejects_ninth_slot() { + let temp_dir = TempDir::new().unwrap(); + let mut state = MsdState::default(); + MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi); + + for lun in 0..MULTI_DISK_MSD_LUNS { + let image_path = temp_dir.path().join(format!("test{lun}.img")); + std::fs::write(&image_path, b"img").unwrap(); + let image = ImageInfo::new( + format!("test{lun}"), + format!("test{lun}.img"), + image_path, + 3, + ); + let next_lun = MsdController::lowest_free_lun(&state).unwrap(); + assert_eq!(next_lun, lun); + state + .mounted_media + .push(MountedMedia::image(next_lun, &image, false, false)); + } + + assert_eq!(state.mounted_media.len(), 8); + assert_eq!(MsdController::lowest_free_lun(&state), None); + } + + #[test] + fn multi_disk_mode_supports_drive_plus_seven_images() { + let temp_dir = TempDir::new().unwrap(); + let drive_path = temp_dir.path().join("ventoy.img"); + std::fs::write(&drive_path, b"drive").unwrap(); + let drive = DriveInfo { + size: 5, + used: 0, + free: 5, + initialized: true, + path: drive_path, + }; + let mut state = MsdState::default(); + MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi); + state.mounted_media.push(MountedMedia::drive(0, &drive)); + + for lun in 1..MULTI_DISK_MSD_LUNS { + let image_path = temp_dir.path().join(format!("test{lun}.img")); + std::fs::write(&image_path, b"img").unwrap(); + let image = ImageInfo::new( + format!("test{lun}"), + format!("test{lun}.img"), + image_path, + 3, + ); + state + .mounted_media + .push(MountedMedia::image(lun, &image, false, false)); + } + + assert_eq!(state.mounted_media.len(), 8); + assert_eq!(MsdController::lowest_free_lun(&state), None); + assert!(state + .mounted_media + .iter() + .any(|media| media.kind == MountedMediaKind::Drive)); + } + + #[test] + fn mode_switch_clears_mount_state() { + let temp_dir = TempDir::new().unwrap(); + let image_path = temp_dir.path().join("test.img"); + std::fs::write(&image_path, b"img").unwrap(); + let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3); + let mut state = MsdState::default(); + MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi); + state + .mounted_media + .push(MountedMedia::image(0, &image, false, false)); + + MsdController::reset_mounts_for_mode(&mut state, DiskMode::Single); + + assert_eq!(state.disk_mode, DiskMode::Single); + assert_eq!(state.disk_mode.capacity(), 1); + assert!(state.mounted_media.is_empty()); + } + + #[test] + fn duplicate_image_and_drive_detection_use_media_identity() { + let temp_dir = TempDir::new().unwrap(); + let image_path = temp_dir.path().join("test.img"); + std::fs::write(&image_path, b"img").unwrap(); + let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3); + let drive = DriveInfo { + size: 5, + used: 0, + free: 5, + initialized: true, + path: temp_dir.path().join("ventoy.img"), + }; + let mut state = MsdState::default(); + state + .mounted_media + .push(MountedMedia::image(0, &image, false, false)); + state.mounted_media.push(MountedMedia::drive(1, &drive)); + + assert!(state + .mounted_media + .iter() + .any(|media| media.kind == MountedMediaKind::Image && media.id == "test")); + assert!(state + .mounted_media + .iter() + .any(|media| media.kind == MountedMediaKind::Drive)); + } + + #[tokio::test] + async fn delete_image_is_serialized_with_mount_operations() { + let temp_dir = TempDir::new().unwrap(); + let otg_service = Arc::new(OtgService::new()); + let controller = MsdController::new(otg_service, temp_dir.path()); + std::fs::create_dir_all(&controller.images_path).unwrap(); + let image_path = controller.images_path.join("test.img"); + std::fs::write(&image_path, b"img").unwrap(); + let image = ImageManager::new(controller.images_path.clone()) + .get_by_name("test.img") + .unwrap(); + + controller + .state + .write() + .await + .mounted_media + .push(MountedMedia::image(0, &image, false, false)); + assert!(controller.delete_image(&image.id).await.is_err()); + assert!(image_path.exists()); + + controller.state.write().await.mounted_media.clear(); + controller.delete_image(&image.id).await.unwrap(); + assert!(!image_path.exists()); + } + + #[test] + fn slot_configs_force_cdrom_read_only() { + let temp_dir = TempDir::new().unwrap(); + let image_path = temp_dir.path().join("test.iso"); + std::fs::write(&image_path, b"iso").unwrap(); + let image = ImageInfo::new("test".into(), "test.iso".into(), image_path, 3); + let mut state = MsdState::default(); + state + .mounted_media + .push(MountedMedia::image(0, &image, true, false)); + + let config = MsdController::media_config(&state.mounted_media[0]); + assert!(config.cdrom); + assert!(config.ro); } } diff --git a/src/msd/image.rs b/src/msd/image.rs index 1cb4b3da..630f03d9 100644 --- a/src/msd/image.rs +++ b/src/msd/image.rs @@ -393,10 +393,6 @@ impl ImageManager { self.get_by_name(&final_filename) } - - pub fn images_path(&self) -> &PathBuf { - &self.images_path - } } fn stable_image_id_from_filename(name: &str) -> String { diff --git a/src/msd/mod.rs b/src/msd/mod.rs index a5656b4b..fc21f851 100644 --- a/src/msd/mod.rs +++ b/src/msd/mod.rs @@ -8,8 +8,9 @@ pub use controller::MsdController; pub use image::ImageManager; pub use monitor::MsdHealthMonitor; pub use types::{ - DownloadProgress, DownloadStatus, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest, - ImageInfo, MsdConnectRequest, MsdMode, MsdState, + DiskMode, DiskModeRequest, DownloadProgress, DownloadStatus, DriveFile, DriveInfo, + DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageMountRequest, MountedMedia, + MountedMediaKind, MsdState, MsdStateResponse, MULTI_DISK_MSD_LUNS, SINGLE_DISK_MSD_LUNS, }; pub use ventoy_drive::{VentoyDrive, MIN_DRIVE_SIZE_MB}; diff --git a/src/msd/types.rs b/src/msd/types.rs index 296fab35..e7d6be80 100644 --- a/src/msd/types.rs +++ b/src/msd/types.rs @@ -2,13 +2,12 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use time::OffsetDateTime; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] -pub enum MsdMode { +pub enum DiskMode { #[default] - None, - Image, - Drive, + Single, + Multi, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -50,23 +49,109 @@ impl ImageInfo { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct MsdState { pub available: bool, - pub mode: MsdMode, - pub connected: bool, - pub current_image: Option, + pub disk_mode: DiskMode, + pub mounted_media: Vec, pub drive_info: Option, + pub usb_reenumerating: bool, } impl Default for MsdState { fn default() -> Self { Self { available: false, - mode: MsdMode::None, - connected: false, - current_image: None, + disk_mode: DiskMode::Single, + mounted_media: Vec::new(), drive_info: None, + usb_reenumerating: false, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct MsdStateResponse { + pub available: bool, + pub disk_mode: DiskMode, + pub slot_capacity: u8, + pub mounted_count: u8, + pub mounted_media: Vec, + pub drive_info: Option, + pub usb_reenumerating: bool, +} + +impl From<&MsdState> for MsdStateResponse { + fn from(state: &MsdState) -> Self { + Self { + available: state.available, + disk_mode: state.disk_mode, + slot_capacity: state.disk_mode.capacity(), + mounted_count: state.mounted_media.len() as u8, + mounted_media: state.mounted_media.clone(), + drive_info: state.drive_info.clone(), + usb_reenumerating: state.usb_reenumerating, + } + } +} + +pub const SINGLE_DISK_MSD_LUNS: u8 = 1; +pub const MULTI_DISK_MSD_LUNS: u8 = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MountedMediaKind { + Drive, + Image, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MountedMedia { + pub id: String, + pub kind: MountedMediaKind, + pub name: String, + pub cdrom: bool, + pub read_only: bool, + pub size: u64, + #[serde(skip)] + pub lun: u8, + #[serde(skip)] + pub path: PathBuf, +} + +impl MountedMedia { + pub fn image(lun: u8, image: &ImageInfo, cdrom: bool, read_only: bool) -> Self { + Self { + id: image.id.clone(), + lun, + kind: MountedMediaKind::Image, + name: image.name.clone(), + cdrom, + read_only: cdrom || read_only, + size: image.size, + path: image.path.clone(), + } + } + + pub fn drive(lun: u8, info: &DriveInfo) -> Self { + Self { + id: "drive".to_string(), + lun, + kind: MountedMediaKind::Drive, + name: "Virtual USB".to_string(), + cdrom: false, + read_only: false, + size: info.size, + path: info.path.clone(), + } + } +} + +impl DiskMode { + pub fn capacity(self) -> u8 { + match self { + DiskMode::Single => SINGLE_DISK_MSD_LUNS, + DiskMode::Multi => MULTI_DISK_MSD_LUNS, } } } @@ -104,13 +189,16 @@ pub struct DriveFile { } #[derive(Debug, Clone, Deserialize)] -pub struct MsdConnectRequest { - pub mode: MsdMode, - pub image_id: Option, +pub struct DiskModeRequest { + pub disk_mode: DiskMode, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ImageMountRequest { #[serde(default)] - pub cdrom: Option, + pub cdrom: bool, #[serde(default)] - pub read_only: Option, + pub read_only: bool, } #[derive(Debug, Clone, Deserialize)] @@ -164,4 +252,19 @@ mod tests { ); assert!(info.size_display().contains("GB")); } + + #[test] + fn default_state_serializes_single_disk_mode() { + assert_eq!(DiskMode::default(), DiskMode::Single); + + let state = MsdState::default(); + assert_eq!(state.disk_mode, DiskMode::Single); + + let json = serde_json::to_value(MsdStateResponse::from(&state)).unwrap(); + assert_eq!(json["disk_mode"], "single"); + assert_eq!(json["slot_capacity"], 1); + assert!(json.get("mode").is_none()); + assert!(json.get("current_image").is_none()); + assert!(json.get("slots").is_none()); + } } diff --git a/src/otg/manager.rs b/src/otg/manager.rs index 4f8bd2f9..4a0b2f9f 100644 --- a/src/otg/manager.rs +++ b/src/otg/manager.rs @@ -135,8 +135,8 @@ impl OtgGadgetManager { Ok(device_path) } - pub fn add_msd(&mut self) -> Result { - let func = MsdFunction::new(self.msd_instance); + pub fn add_msd(&mut self, lun_capacity: u8) -> Result { + let func = MsdFunction::new(self.msd_instance, lun_capacity)?; let func_clone = func.clone(); self.add_function(Box::new(func))?; self.msd_instance += 1; diff --git a/src/otg/msd.rs b/src/otg/msd.rs index 89be3617..d342b5ac 100644 --- a/src/otg/msd.rs +++ b/src/otg/msd.rs @@ -56,13 +56,21 @@ impl MsdLunConfig { #[derive(Debug, Clone)] pub struct MsdFunction { name: String, + lun_capacity: u8, } impl MsdFunction { - pub fn new(instance: u8) -> Self { - Self { - name: format!("mass_storage.usb{}", instance), + pub fn new(instance: u8, lun_capacity: u8) -> Result { + if lun_capacity != 1 && lun_capacity != 8 { + return Err(AppError::BadRequest(format!( + "MSD LUN capacity must be 1 or 8, got {lun_capacity}" + ))); } + + Ok(Self { + name: format!("mass_storage.usb{}", instance), + lun_capacity, + }) } fn function_path(&self, gadget_path: &Path) -> PathBuf { @@ -73,6 +81,32 @@ impl MsdFunction { self.function_path(gadget_path).join(format!("lun.{}", lun)) } + fn existing_lun_paths(&self, gadget_path: &Path) -> Result> { + let func_path = self.function_path(gadget_path); + if !func_path.exists() { + return Ok(Vec::new()); + } + + let entries = fs::read_dir(&func_path).map_err(|e| { + AppError::Internal(format!( + "Failed to read MSD function directory {}: {}", + func_path.display(), + e + )) + })?; + let mut luns = entries + .filter_map(|entry| { + let entry = entry.ok()?; + let name = entry.file_name(); + let name = name.to_str()?; + let lun = name.strip_prefix("lun.")?.parse::().ok()?; + Some((lun, entry.path())) + }) + .collect::>(); + luns.sort_by_key(|(lun, _)| *lun); + Ok(luns) + } + pub async fn configure_lun_async( &self, gadget_path: &Path, @@ -88,11 +122,32 @@ impl MsdFunction { .map_err(|e| AppError::Internal(format!("Task join error: {}", e)))? } + fn clear_lun_unbound(&self, gadget_path: &Path, lun: u8) -> Result<()> { + let lun_path = self.lun_path(gadget_path, lun); + if !lun_path.exists() { + create_dir(&lun_path)?; + } + write_file(&lun_path.join("file"), "")?; + let _ = write_file(&lun_path.join("cdrom"), "0"); + let _ = write_file(&lun_path.join("ro"), "0"); + let _ = write_file(&lun_path.join("removable"), "1"); + let _ = write_file(&lun_path.join("nofua"), "1"); + Ok(()) + } + pub fn configure_lun(&self, gadget_path: &Path, lun: u8, config: &MsdLunConfig) -> Result<()> { + if lun >= self.lun_capacity { + return Err(AppError::BadRequest(format!( + "LUN {lun} is outside MSD capacity {}", + self.lun_capacity + ))); + } let lun_path = self.lun_path(gadget_path, lun); if !lun_path.exists() { - create_dir(&lun_path)?; + return Err(AppError::Internal(format!( + "Configured MSD LUN {lun} does not exist" + ))); } let read_attr = |attr: &str| -> String { @@ -210,8 +265,18 @@ impl MsdFunction { } pub fn disconnect_lun(&self, gadget_path: &Path, lun: u8) -> Result<()> { + if lun >= self.lun_capacity { + return Err(AppError::BadRequest(format!( + "LUN {lun} is outside MSD capacity {}", + self.lun_capacity + ))); + } let lun_path = self.lun_path(gadget_path, lun); + self.disconnect_lun_path(&lun_path, lun as u16) + } + + fn disconnect_lun_path(&self, lun_path: &Path, lun: u16) -> Result<()> { if lun_path.exists() { let forced_eject_path = lun_path.join("forced_eject"); if forced_eject_path.exists() { @@ -226,11 +291,17 @@ impl MsdFunction { "forced_eject write failed: {}, falling back to clearing file", e ); - write_file(&lun_path.join("file"), "")?; + let file_path = lun_path.join("file"); + if file_path.exists() { + write_file(&file_path, "")?; + } } } } else { - write_file(&lun_path.join("file"), "")?; + let file_path = lun_path.join("file"); + if file_path.exists() { + write_file(&file_path, "")?; + } } info!("LUN {} disconnected", lun); } @@ -275,16 +346,10 @@ impl GadgetFunction for MsdFunction { let _ = write_file(&stall_path, "0"); } - let lun0_path = func_path.join("lun.0"); - if !lun0_path.exists() { - create_dir(&lun0_path)?; + for lun in 0..self.lun_capacity { + self.clear_lun_unbound(gadget_path, lun)?; } - let _ = write_file(&lun0_path.join("cdrom"), "0"); - let _ = write_file(&lun0_path.join("ro"), "0"); - let _ = write_file(&lun0_path.join("removable"), "1"); - let _ = write_file(&lun0_path.join("nofua"), "1"); - debug!("Created MSD function: {}", self.name()); Ok(()) } @@ -311,8 +376,25 @@ impl GadgetFunction for MsdFunction { fn cleanup(&self, gadget_path: &Path) -> Result<()> { let func_path = self.function_path(gadget_path); - for lun in 0..8 { - let _ = self.disconnect_lun(gadget_path, lun); + let lun_paths = match self.existing_lun_paths(gadget_path) { + Ok(luns) => luns, + Err(e) => { + warn!("Could not enumerate MSD LUN directories: {}", e); + Vec::new() + } + }; + for (lun, lun_path) in lun_paths { + if let Err(e) = self.disconnect_lun_path(&lun_path, lun) { + warn!("Could not disconnect LUN {} during cleanup: {}", lun, e); + } + // lun.0 is the mass-storage function's configfs default group. It + // cannot be removed directly and is released with the function. + if lun == 0 { + continue; + } + if let Err(e) = remove_dir(&lun_path) { + warn!("Could not remove LUN {} directory: {}", lun, e); + } } if let Err(e) = remove_dir(&func_path) { @@ -327,6 +409,7 @@ impl GadgetFunction for MsdFunction { #[cfg(test)] mod tests { use super::*; + use tempfile::TempDir; #[test] fn test_lun_config_cdrom() { @@ -346,8 +429,86 @@ mod tests { #[test] fn test_msd_function_name() { - let msd = MsdFunction::new(0); + let msd = MsdFunction::new(0, 1).unwrap(); assert_eq!(msd.name(), "mass_storage.usb0"); assert_eq!(msd.endpoints_required(), 2); + assert_eq!(msd.lun_capacity, 1); + + let multi = MsdFunction::new(0, 8).unwrap(); + assert_eq!(multi.lun_capacity, 8); + } + + #[test] + fn test_msd_function_rejects_invalid_capacity() { + assert!(MsdFunction::new(0, 0).is_err()); + assert!(MsdFunction::new(0, 2).is_err()); + assert!(MsdFunction::new(0, 9).is_err()); + } + + #[test] + fn create_uses_configured_lun_capacity() { + for capacity in [1, 8] { + let temp_dir = TempDir::new().unwrap(); + std::fs::create_dir_all(temp_dir.path().join("functions")).unwrap(); + let msd = MsdFunction::new(0, capacity).unwrap(); + + msd.create(temp_dir.path()).unwrap(); + + for lun in 0..capacity { + assert!(msd.lun_path(temp_dir.path(), lun).exists()); + } + assert!(!msd.lun_path(temp_dir.path(), capacity).exists()); + } + } + + #[test] + fn configure_lun_does_not_rebind_udc() { + let temp_dir = TempDir::new().unwrap(); + let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0"); + std::fs::create_dir_all(&lun_path).unwrap(); + for attr in ["file", "cdrom", "ro", "removable", "nofua"] { + std::fs::write(lun_path.join(attr), b"0\n").unwrap(); + } + std::fs::write(temp_dir.path().join("UDC"), b"test.udc\n").unwrap(); + let image_path = temp_dir.path().join("test.img"); + std::fs::write(&image_path, b"image").unwrap(); + let msd = MsdFunction::new(0, 1).unwrap(); + + msd.configure_lun(temp_dir.path(), 0, &MsdLunConfig::disk(image_path, false)) + .unwrap(); + + assert_eq!( + std::fs::read_to_string(temp_dir.path().join("UDC")).unwrap(), + "test.udc\n" + ); + } + + #[test] + fn cleanup_removes_all_dynamic_luns_including_stale_capacity() { + let temp_dir = TempDir::new().unwrap(); + let func_path = temp_dir.path().join("functions/mass_storage.usb0"); + for lun in 1..8 { + std::fs::create_dir_all(func_path.join(format!("lun.{lun}"))).unwrap(); + } + let msd = MsdFunction::new(0, 1).unwrap(); + + msd.cleanup(temp_dir.path()).unwrap(); + + assert!(!func_path.exists()); + } + + #[test] + fn cleanup_leaves_default_lun_for_configfs_function_removal() { + let temp_dir = TempDir::new().unwrap(); + let func_path = temp_dir.path().join("functions/mass_storage.usb0"); + for lun in 0..2 { + std::fs::create_dir_all(func_path.join(format!("lun.{lun}"))).unwrap(); + } + let msd = MsdFunction::new(0, 1).unwrap(); + + msd.cleanup(temp_dir.path()).unwrap(); + + assert!(func_path.join("lun.0").exists()); + assert!(!func_path.join("lun.1").exists()); } } diff --git a/src/otg/service.rs b/src/otg/service.rs index fe9b6f6d..f54acb7c 100644 --- a/src/otg/service.rs +++ b/src/otg/service.rs @@ -38,6 +38,7 @@ pub(crate) struct OtgDesiredState { pub hid_functions: Option, pub keyboard_leds: bool, pub msd_enabled: bool, + pub msd_lun_capacity: u8, pub max_endpoints: u8, } @@ -49,6 +50,7 @@ impl Default for OtgDesiredState { hid_functions: None, keyboard_leds: false, msd_enabled: false, + msd_lun_capacity: 1, max_endpoints: super::endpoint::DEFAULT_MAX_ENDPOINTS, } } @@ -71,6 +73,7 @@ impl OtgDesiredState { hid_functions, keyboard_leds: hid.effective_otg_keyboard_leds(), msd_enabled: msd.enabled, + msd_lun_capacity: 1, max_endpoints: hid .resolved_otg_endpoint_limit() .unwrap_or(super::endpoint::DEFAULT_MAX_ENDPOINTS), @@ -83,11 +86,12 @@ impl OtgDesiredState { } } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] struct OtgServiceState { pub gadget_active: bool, pub hid_enabled: bool, pub msd_enabled: bool, + pub msd_lun_capacity: u8, pub configured_udc: Option, pub hid_paths: Option, pub hid_functions: Option, @@ -97,6 +101,24 @@ struct OtgServiceState { pub error: Option, } +impl Default for OtgServiceState { + fn default() -> Self { + Self { + gadget_active: false, + hid_enabled: false, + msd_enabled: false, + msd_lun_capacity: 1, + configured_udc: None, + hid_paths: None, + hid_functions: None, + keyboard_leds_enabled: false, + max_endpoints: super::endpoint::DEFAULT_MAX_ENDPOINTS, + descriptor: None, + error: None, + } + } +} + pub struct OtgService { manager: Mutex>, state: RwLock, @@ -131,8 +153,41 @@ impl OtgService { self.msd_function.read().await.clone() } + pub async fn msd_lun_capacity(&self) -> u8 { + self.desired.read().await.msd_lun_capacity + } + pub async fn apply_config(&self, hid: &HidConfig, msd: &MsdConfig) -> Result<()> { - let desired = OtgDesiredState::from_config(hid, msd)?; + let desired = self + .desired_from_config_preserving_runtime(hid, msd) + .await?; + self.apply_desired_state(desired).await + } + + async fn desired_from_config_preserving_runtime( + &self, + hid: &HidConfig, + msd: &MsdConfig, + ) -> Result { + let mut desired = OtgDesiredState::from_config(hid, msd)?; + desired.msd_lun_capacity = self.desired.read().await.msd_lun_capacity; + Ok(desired) + } + + pub async fn set_msd_lun_capacity(&self, capacity: u8) -> Result<()> { + if capacity != 1 && capacity != 8 { + return Err(AppError::BadRequest(format!( + "MSD LUN capacity must be 1 or 8, got {capacity}" + ))); + } + + let mut desired = self.desired.read().await.clone(); + if !desired.msd_enabled { + return Err(AppError::Internal( + "MSD is not enabled in the OTG gadget".to_string(), + )); + } + desired.msd_lun_capacity = capacity; self.apply_desired_state(desired).await } @@ -160,6 +215,7 @@ impl OtgService { if state.gadget_active && state.hid_enabled == desired.hid_enabled() && state.msd_enabled == desired.msd_enabled + && state.msd_lun_capacity == desired.msd_lun_capacity && state.configured_udc == desired.udc && state.hid_functions == desired.hid_functions && state.keyboard_leds_enabled == desired.keyboard_leds @@ -188,6 +244,7 @@ impl OtgService { state.gadget_active = false; state.hid_enabled = false; state.msd_enabled = false; + state.msd_lun_capacity = 1; state.configured_udc = None; state.hid_paths = None; state.hid_functions = None; @@ -280,7 +337,7 @@ impl OtgService { } let msd_func = if desired.msd_enabled { - match manager.add_msd() { + match manager.add_msd(desired.msd_lun_capacity) { Ok(func) => { debug!("MSD function added to gadget"); Some(func) @@ -323,6 +380,7 @@ impl OtgService { state.gadget_active = true; state.hid_enabled = desired.hid_enabled(); state.msd_enabled = desired.msd_enabled; + state.msd_lun_capacity = desired.msd_lun_capacity; state.configured_udc = Some(udc); state.hid_paths = hid_paths; state.hid_functions = desired.hid_functions; @@ -393,4 +451,32 @@ mod tests { let _service = OtgService::new(); let _ = OtgService::is_available(); } + + #[tokio::test] + async fn service_starts_with_single_lun_capacity() { + let service = OtgService::new(); + assert_eq!(service.desired.read().await.msd_lun_capacity, 1); + assert_eq!(service.state.read().await.msd_lun_capacity, 1); + } + + #[tokio::test] + async fn config_updates_preserve_runtime_lun_capacity() { + let service = OtgService::new(); + service.desired.write().await.msd_lun_capacity = 8; + + let desired = service + .desired_from_config_preserving_runtime(&HidConfig::default(), &MsdConfig::default()) + .await + .unwrap(); + + assert_eq!(desired.msd_lun_capacity, 8); + } + + #[test] + fn lun_capacity_participates_in_desired_state_equality() { + let single = OtgDesiredState::default(); + let mut multi = single.clone(); + multi.msd_lun_capacity = 8; + assert_ne!(single, multi); + } } diff --git a/src/redfish/routes/virtual_media.rs b/src/redfish/routes/virtual_media.rs index ebde46ad..e937c332 100644 --- a/src/redfish/routes/virtual_media.rs +++ b/src/redfish/routes/virtual_media.rs @@ -70,19 +70,13 @@ async fn virtual_media_detail( Some(msd) => { let msd_state = msd.state().await; let img_name = msd_state - .current_image - .as_ref() - .map(|i| i.name.clone()) - .or_else(|| { - msd_state - .drive_info - .as_ref() - .map(|_| "Virtual Drive".to_string()) - }); + .mounted_media + .first() + .map(|media| media.name.clone()); ( - msd_state.connected, + !msd_state.mounted_media.is_empty(), img_name, - if msd_state.connected { + if !msd_state.mounted_media.is_empty() { Some("Applet".to_string()) } else { None @@ -153,7 +147,7 @@ async fn virtual_media_insert( None => return service_unavailable("MSD not available"), }; - if msd.state().await.connected { + if !msd.state().await.mounted_media.is_empty() { return ( StatusCode::CONFLICT, Json(RedfishError::general_error( @@ -164,7 +158,7 @@ async fn virtual_media_insert( } info!("Redfish: VirtualMedia.InsertMedia image='{}'", req.image); - msd.connect_drive().await + msd.mount_drive().await }; match result { @@ -201,7 +195,7 @@ async fn virtual_media_eject( None => return service_unavailable("MSD not available"), }; - if !msd.state().await.connected { + if msd.state().await.mounted_media.is_empty() { return ( StatusCode::CONFLICT, Json(RedfishError::general_error("No virtual media inserted")), diff --git a/src/state.rs b/src/state.rs index bc8346ef..8b264738 100644 --- a/src/state.rs +++ b/src/state.rs @@ -8,8 +8,8 @@ use crate::computer_use::ComputerUseManager; use crate::config::ConfigStore; use crate::db::DatabasePool; use crate::events::{ - AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, LedState, MsdDeviceInfo, SystemEvent, - TtydDeviceInfo, VideoDeviceInfo, + AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, LedState, MsdDeviceInfo, + MsdDeviceMediaInfo, SystemEvent, TtydDeviceInfo, VideoDeviceInfo, }; use crate::extensions::{ExtensionId, ExtensionManager}; use crate::hid::HidController; @@ -231,16 +231,33 @@ impl AppState { let state = msd.state().await; let error = msd.monitor().error_message().await; + let mounted_media = state + .mounted_media + .iter() + .map(|media| MsdDeviceMediaInfo { + id: media.id.clone(), + kind: match media.kind { + crate::msd::MountedMediaKind::Drive => "drive", + crate::msd::MountedMediaKind::Image => "image", + } + .to_string(), + name: media.name.clone(), + cdrom: media.cdrom, + read_only: media.read_only, + size: media.size, + }) + .collect::>(); Some(MsdDeviceInfo { available: state.available, - mode: match state.mode { - crate::msd::MsdMode::None => "none", - crate::msd::MsdMode::Image => "image", - crate::msd::MsdMode::Drive => "drive", + disk_mode: match state.disk_mode { + crate::msd::DiskMode::Single => "single", + crate::msd::DiskMode::Multi => "multi", } .to_string(), - connected: state.connected, - image_id: state.current_image.map(|img| img.id), + slot_capacity: state.disk_mode.capacity(), + mounted_count: state.mounted_media.len() as u8, + mounted_media, + usb_reenumerating: state.usb_reenumerating, error, }) } diff --git a/src/web/handlers/msd_api.rs b/src/web/handlers/msd_api.rs index 9eaebe01..9f0e7943 100644 --- a/src/web/handlers/msd_api.rs +++ b/src/web/handlers/msd_api.rs @@ -1,8 +1,10 @@ +use super::config::apply::try_apply_lock; use super::*; use crate::msd::{ - DownloadProgress, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo, - ImageManager, MsdConnectRequest, MsdMode, MsdState, VentoyDrive, MIN_DRIVE_SIZE_MB, + DiskModeRequest, DownloadProgress, DriveFile, DriveInfo, DriveInitRequest, + ImageDownloadRequest, ImageInfo, ImageManager, ImageMountRequest, MsdState, MsdStateResponse, + VentoyDrive, MIN_DRIVE_SIZE_MB, }; #[cfg(unix)] use axum::body::Body; @@ -26,8 +28,7 @@ const MIB: u64 = 1024 * 1024; async fn assert_drive_not_connected(state: &Arc) -> Result<()> { let msd_guard = state.msd.read().await; if let Some(controller) = msd_guard.as_ref() { - let msd_state = controller.state().await; - if msd_state.connected && msd_state.mode == crate::msd::types::MsdMode::Drive { + if controller.is_drive_connected().await { return Err(AppError::BadRequest( "Virtual drive is connected to the USB host; disconnect it before modifying files" .to_string(), @@ -77,7 +78,7 @@ fn unsupported_drive_filesystem_error(error: &str) -> AppError { #[derive(Serialize)] pub struct MsdStatus { pub available: bool, - pub state: MsdState, + pub state: MsdStateResponse, } /// Get MSD status @@ -89,12 +90,12 @@ pub async fn msd_status(State(state): State>) -> Result Ok(Json(MsdStatus { available: false, - state: MsdState::default(), + state: MsdStateResponse::from(&MsdState::default()), })), } } @@ -164,11 +165,11 @@ pub async fn msd_image_delete( State(state): State>, AxumPath(id): AxumPath, ) -> Result> { - let config = state.config.get(); - let images_path = config.msd.images_dir(); - let manager = ImageManager::new(images_path); - - manager.delete(&id)?; + let msd_guard = state.msd.read().await; + let controller = msd_guard + .as_ref() + .ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?; + controller.delete_image(&id).await?; Ok(Json(LoginResponse { success: true, message: Some("Image deleted".to_string()), @@ -216,11 +217,81 @@ pub async fn msd_image_download_cancel( })) } -/// Connect MSD (image or drive) +/// Change MSD disk mode. This clears all mounted media and re-enumerates USB. #[cfg(unix)] -pub async fn msd_connect( +pub async fn msd_disk_mode_put( State(state): State>, - Json(req): Json, + Json(req): Json, +) -> Result> { + let _otg_guard = try_apply_lock(&state.config_apply_locks.otg, "OTG")?; + let current_mode = { + let msd_guard = state.msd.read().await; + let controller = msd_guard + .as_ref() + .ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?; + controller.state().await.disk_mode + }; + if current_mode == req.disk_mode { + return Ok(Json(LoginResponse { + success: true, + message: Some("MSD disk mode updated".to_string()), + })); + } + + let hid_is_otg = matches!( + state.hid.backend_type().await, + crate::hid::HidBackendType::Otg + ); + + if hid_is_otg { + state + .hid + .prepare_otg_rebuild() + .await + .map_err(|e| AppError::Config(format!("Failed to prepare OTG HID for rebuild: {e}")))?; + } + + let switch_result = { + let mut msd_guard = state.msd.write().await; + let controller = msd_guard + .as_mut() + .ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?; + controller.set_disk_mode(req.disk_mode).await + }; + + let hid_reload_result = if hid_is_otg { + state + .hid + .reload(crate::hid::HidBackendType::Otg) + .await + .map_err(|e| AppError::Config(format!("OTG HID reload failed: {e}"))) + } else { + Ok(()) + }; + + match (switch_result, hid_reload_result) { + (Err(switch_error), Err(hid_error)) => { + return Err(AppError::Internal(format!( + "MSD disk mode switch failed: {switch_error}; HID recovery failed: {hid_error}" + ))); + } + (Err(switch_error), Ok(())) => return Err(switch_error), + (Ok(_), Err(hid_error)) => return Err(hid_error), + (Ok(_), Ok(())) => {} + } + + Ok(Json(LoginResponse { + success: true, + message: Some("MSD disk mode updated".to_string()), + })) +} + +/// Mount an image into the next available media slot. +#[cfg(unix)] +pub async fn msd_image_mount( + State(state): State>, + AxumPath(id): AxumPath, + Json(req): Json, ) -> Result> { let config = state.config.get(); let mut msd_guard = state.msd.write().await; @@ -228,50 +299,68 @@ pub async fn msd_connect( .as_mut() .ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?; - match req.mode { - MsdMode::Image => { - let image_id = req.image_id.ok_or_else(|| { - AppError::BadRequest("image_id required for image mode".to_string()) - })?; + let images_path = config.msd.images_dir(); + let manager = ImageManager::new(images_path); + let image = manager.get(&id)?; - // Get image info from ImageManager - let images_path = config.msd.images_dir(); - let manager = ImageManager::new(images_path); - let image = manager.get(&image_id)?; - - // Get mount options from request (defaults: cdrom=false, read_only=false) - let cdrom = req.cdrom.unwrap_or(false); - let read_only = req.read_only.unwrap_or(false); - - controller.connect_image(&image, cdrom, read_only).await?; - } - MsdMode::Drive => { - controller.connect_drive().await?; - } - MsdMode::None => { - return Err(AppError::BadRequest("Invalid mode: none".to_string())); - } - } + controller + .mount_image(&image, req.cdrom, req.read_only) + .await?; Ok(Json(LoginResponse { success: true, - message: Some("MSD connected".to_string()), + message: Some("Image mounted".to_string()), })) } -/// Disconnect MSD +/// Unmount an image from whichever internal LUN currently holds it. #[cfg(unix)] -pub async fn msd_disconnect(State(state): State>) -> Result> { +pub async fn msd_image_unmount( + State(state): State>, + AxumPath(id): AxumPath, +) -> Result> { let mut msd_guard = state.msd.write().await; let controller = msd_guard .as_mut() .ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?; - controller.disconnect().await?; + controller.unmount_image(&id).await?; Ok(Json(LoginResponse { success: true, - message: Some("MSD disconnected".to_string()), + message: Some("Image unmounted".to_string()), + })) +} + +/// Mount the virtual USB drive into the next available media slot. +#[cfg(unix)] +pub async fn msd_drive_mount(State(state): State>) -> Result> { + let mut msd_guard = state.msd.write().await; + let controller = msd_guard + .as_mut() + .ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?; + + controller.mount_drive().await?; + + Ok(Json(LoginResponse { + success: true, + message: Some("Virtual drive mounted".to_string()), + })) +} + +/// Unmount the virtual USB drive. +#[cfg(unix)] +pub async fn msd_drive_unmount(State(state): State>) -> Result> { + let mut msd_guard = state.msd.write().await; + let controller = msd_guard + .as_mut() + .ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?; + + controller.unmount_drive().await?; + + Ok(Json(LoginResponse { + success: true, + message: Some("Virtual drive unmounted".to_string()), })) } @@ -335,8 +424,7 @@ pub async fn msd_drive_delete(State(state): State>) -> Result) -> Router { ) .route("/msd/images/{id}", get(handlers::msd_image_get)) .route("/msd/images/{id}", delete(handlers::msd_image_delete)) - .route("/msd/connect", post(handlers::msd_connect)) - .route("/msd/disconnect", post(handlers::msd_disconnect)) + .route("/msd/disk-mode", put(handlers::msd_disk_mode_put)) + .route("/msd/images/{id}/mount", post(handlers::msd_image_mount)) + .route( + "/msd/images/{id}/mount", + delete(handlers::msd_image_unmount), + ) .route("/msd/drive", get(handlers::msd_drive_info)) .route("/msd/drive", delete(handlers::msd_drive_delete)) + .route("/msd/drive/mount", post(handlers::msd_drive_mount)) + .route("/msd/drive/mount", delete(handlers::msd_drive_unmount)) .route("/msd/drive/init", post(handlers::msd_drive_init)) .route("/msd/drive/files", get(handlers::msd_drive_files)) .route( diff --git a/test/okvm-test/README.md b/test/okvm-test/README.md index 92d47e2b..b26f19ca 100644 --- a/test/okvm-test/README.md +++ b/test/okvm-test/README.md @@ -159,7 +159,7 @@ MSD 测试依赖 OTG。流程如下: 2. 如果资源缺失,默认从 `libs/ventoy-img-rs/resources` 解压并通过 SSH/SFTP 同步到目标机。 3. 启用 MSD 后重启 `one-kvm`,确保 Ventoy 资源在服务进程中初始化。 4. 通过 `/api/msd/drive/init` 创建小型虚拟盘。 -5. 通过 `/api/msd/connect {"mode":"drive"}` 连接到 Windows。 +5. 通过 `/api/msd/drive/mount` 连接到 Windows。 6. Windows agent 等待新盘符出现。 7. Windows agent 写入测试文件、同步到虚拟盘、优先用未缓存读取读回并校验 SHA-256,同时输出简单写入/读取速度。若 Windows/驱动不支持未缓存读取,会退回缓存读取并在报告中标为“仅校验”,不作为真实读盘速度。 8. 控制端断开 MSD,Windows agent 确认盘符消失。 diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 6297e7cc..5d29f6d5 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -542,25 +542,34 @@ export interface DriveFile { size: number } +export type DiskMode = 'single' | 'multi' +export type MountedMediaKind = 'drive' | 'image' + +export interface MountedMedia { + id: string + kind: MountedMediaKind + name: string + cdrom: boolean + read_only: boolean + size: number +} + export const msdApi = { status: () => request<{ available: boolean state: { - connected: boolean - mode: 'none' | 'image' | 'drive' - current_image: { - id: string - name: string - size: number - created_at: string - } | null + disk_mode: DiskMode + slot_capacity: number + mounted_count: number + mounted_media: MountedMedia[] drive_info: { size: number used: number free: number initialized: boolean } | null + usb_reenumerating: boolean } }>('/msd/status'), @@ -597,14 +606,26 @@ export const msdApi = { deleteImage: (id: string) => request<{ success: boolean }>(`/msd/images/${id}`, { method: 'DELETE' }), - connect: (mode: 'image' | 'drive', imageId?: string, cdrom?: boolean, readOnly?: boolean) => - request<{ success: boolean }>('/msd/connect', { - method: 'POST', - body: JSON.stringify({ mode, image_id: imageId, cdrom, read_only: readOnly }), + setDiskMode: (diskMode: DiskMode) => + request<{ success: boolean }>('/msd/disk-mode', { + method: 'PUT', + body: JSON.stringify({ disk_mode: diskMode }), }), - disconnect: () => - request<{ success: boolean }>('/msd/disconnect', { method: 'POST' }), + mountImage: (id: string, cdrom: boolean, readOnly: boolean) => + request<{ success: boolean }>(`/msd/images/${id}/mount`, { + method: 'POST', + body: JSON.stringify({ cdrom, read_only: readOnly }), + }), + + unmountImage: (id: string) => + request<{ success: boolean }>(`/msd/images/${id}/mount`, { method: 'DELETE' }), + + mountDrive: () => + request<{ success: boolean }>('/msd/drive/mount', { method: 'POST' }), + + unmountDrive: () => + request<{ success: boolean }>('/msd/drive/mount', { method: 'DELETE' }), driveInfo: () => request<{ diff --git a/web/src/components/MsdDialog.vue b/web/src/components/MsdDialog.vue index a334b4bf..c1836efc 100644 --- a/web/src/components/MsdDialog.vue +++ b/web/src/components/MsdDialog.vue @@ -3,7 +3,7 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue' import { useI18n } from 'vue-i18n' import { toast } from 'vue-sonner' import { useSystemStore } from '@/stores/system' -import { msdApi, type MsdImage, type DriveFile } from '@/api' +import { msdApi, type MsdImage, type DriveFile, type MountedMedia, type DiskMode } from '@/api' import { ApiError } from '@/api/request' import { useWebSocket } from '@/composables/useWebSocket' import { @@ -48,7 +48,6 @@ import { AlertCircle, Info, } from 'lucide-vue-next' -import HelpTooltip from '@/components/HelpTooltip.vue' const props = defineProps<{ open: boolean @@ -77,8 +76,11 @@ const cdromMode = computed(() => mountMode.value === 'cdrom') const readOnly = computed(() => accessMode.value === 'readonly') const connecting = ref(false) -const disconnecting = ref(false) const deleting = ref(false) +const modeChanging = ref(false) +const unmountingMediaId = ref(null) +const pendingMountImage = ref(null) +const showMountOptionsDialog = ref(false) const driveFiles = ref([]) const currentPath = ref('/') @@ -149,28 +151,62 @@ const downloadProgress = ref<{ } | null>(null) const TWO_POINT_TWO_GB = 2.2 * 1024 * 1024 * 1024 +const tabTriggerClass = 'h-9 rounded-md border border-transparent bg-transparent text-center text-muted-foreground shadow-none hover:text-foreground data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm' +const segmentedGroupClass = 'grid w-full grid-cols-2 items-center gap-1 rounded-md bg-muted p-1' +const segmentedItemClass = 'h-8 w-full justify-center rounded-md border border-transparent bg-transparent px-3 text-center text-xs text-muted-foreground shadow-none hover:bg-background/60 hover:text-foreground data-[state=on]:border-border data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm' -const msdConnected = computed(() => systemStore.msd?.connected ?? false) -const msdMode = computed(() => systemStore.msd?.mode ?? 'none') +const diskMode = computed(() => systemStore.msd?.diskMode ?? 'single') +const slotCapacity = computed(() => systemStore.msd?.slotCapacity ?? 1) +const mountedMedia = computed(() => systemStore.msd?.mountedMedia ?? []) +const mountedCount = computed(() => systemStore.msd?.mountedCount ?? mountedMedia.value.length) +const msdConnected = computed(() => mountedCount.value > 0) +const mediaSlotsFull = computed(() => mountedCount.value >= slotCapacity.value) +const driveMedia = computed(() => mountedMedia.value.find(media => media.kind === 'drive') ?? null) // Drive is currently mounted on the target machine via USB — file ops are blocked -const driveConnectedToTarget = computed(() => msdConnected.value && msdMode.value === 'drive') +const driveConnectedToTarget = computed(() => !!driveMedia.value) const operationInProgress = computed(() => { return connecting.value || - disconnecting.value || deleting.value || uploading.value || uploadingFile.value || initializingDrive.value || - deletingDrive.value + deletingDrive.value || + modeChanging.value || + !!unmountingMediaId.value }) +const usbReenumerating = computed(() => systemStore.msd?.usbReenumerating || modeChanging.value) + function isLargeFile(image: MsdImage): boolean { return image.size > TWO_POINT_TWO_GB } +function isIsoImage(image: MsdImage): boolean { + return image.name.toLowerCase().endsWith('.iso') +} + +function mountedImage(imageId: string): MountedMedia | null { + return mountedMedia.value.find(media => media.kind === 'image' && media.id === imageId) ?? null +} + +function updateMountMode(value: unknown) { + const next = Array.isArray(value) ? value[0] : value + if (next !== 'cdrom' && next !== 'flash') return + mountMode.value = next + if (next === 'cdrom') { + accessMode.value = 'readonly' + } +} + +function updateAccessMode(value: unknown) { + const next = Array.isArray(value) ? value[0] : value + if (next !== 'readonly' && next !== 'readwrite') return + accessMode.value = next +} + const breadcrumbs = computed(() => { const parts = currentPath.value.split('/').filter(Boolean) const crumbs = [{ name: '/', path: '/' }] @@ -251,12 +287,30 @@ async function connectImage(image: MsdImage) { return } + if (mountedImage(image.id)) return + if (mediaSlotsFull.value) { + toast.error(t('msd.mediaSlotsFull')) + return + } + + pendingMountImage.value = image + mountMode.value = isIsoImage(image) ? 'cdrom' : 'flash' + accessMode.value = isIsoImage(image) ? 'readonly' : 'readwrite' + showMountOptionsDialog.value = true +} + +async function confirmImageMount() { + const image = pendingMountImage.value + if (!image || operationInProgress.value) return + connecting.value = true try { - await msdApi.connect('image', image.id, cdromMode.value, readOnly.value) + await msdApi.mountImage(image.id, cdromMode.value, cdromMode.value || readOnly.value) await systemStore.fetchMsdState() + showMountOptionsDialog.value = false + pendingMountImage.value = null } catch (e) { - console.error('Failed to connect image:', e) + console.error('Failed to mount image:', e) } finally { connecting.value = false } @@ -268,30 +322,61 @@ async function connectDrive() { return } + if (driveConnectedToTarget.value) return + if (mediaSlotsFull.value) { + toast.error(t('msd.mediaSlotsFull')) + return + } + connecting.value = true try { - await msdApi.connect('drive') + await msdApi.mountDrive() await systemStore.fetchMsdState() } catch (e) { - console.error('Failed to connect drive:', e) + console.error('Failed to mount drive:', e) } finally { connecting.value = false } } -async function disconnect() { - if (operationInProgress.value) { - return - } +async function unmountMedia(media: MountedMedia) { + if (operationInProgress.value) return - disconnecting.value = true + unmountingMediaId.value = `${media.kind}:${media.id}` try { - await msdApi.disconnect() + if (media.kind === 'drive') { + await msdApi.unmountDrive() + await refreshDriveBrowser() + } else { + await msdApi.unmountImage(media.id) + } await systemStore.fetchMsdState() } catch (e) { - console.error('Failed to disconnect:', e) + console.error('Failed to unmount media:', e) } finally { - disconnecting.value = false + unmountingMediaId.value = null + } +} + +async function unmountImageById(imageId: string) { + const media = mountedImage(imageId) + if (media) { + await unmountMedia(media) + } +} + +async function changeDiskMode(value: unknown) { + const next = Array.isArray(value) ? value[0] : value + if ((next !== 'single' && next !== 'multi') || next === diskMode.value || operationInProgress.value) return + + modeChanging.value = true + try { + await msdApi.setDiskMode(next as DiskMode) + await systemStore.fetchMsdState() + } catch (e) { + console.error('Failed to change MSD disk mode:', e) + } finally { + modeChanging.value = false } } @@ -591,35 +676,60 @@ onUnmounted(() => {