mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-07-29 20:11:46 +08:00
feat: 虚拟媒体支持同时挂载多个镜像
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
pub disk_mode: String,
|
||||
pub slot_capacity: u8,
|
||||
pub mounted_count: u8,
|
||||
pub mounted_media: Vec<MsdDeviceMediaInfo>,
|
||||
pub usb_reenumerating: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u8> {
|
||||
(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<bool> {
|
||||
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<F>(&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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
137
src/msd/types.rs
137
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<ImageInfo>,
|
||||
pub disk_mode: DiskMode,
|
||||
pub mounted_media: Vec<MountedMedia>,
|
||||
pub drive_info: Option<DriveInfo>,
|
||||
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<MountedMedia>,
|
||||
pub drive_info: Option<DriveInfo>,
|
||||
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<String>,
|
||||
pub struct DiskModeRequest {
|
||||
pub disk_mode: DiskMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ImageMountRequest {
|
||||
#[serde(default)]
|
||||
pub cdrom: Option<bool>,
|
||||
pub cdrom: bool,
|
||||
#[serde(default)]
|
||||
pub read_only: Option<bool>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +135,8 @@ impl OtgGadgetManager {
|
||||
Ok(device_path)
|
||||
}
|
||||
|
||||
pub fn add_msd(&mut self) -> Result<MsdFunction> {
|
||||
let func = MsdFunction::new(self.msd_instance);
|
||||
pub fn add_msd(&mut self, lun_capacity: u8) -> Result<MsdFunction> {
|
||||
let func = MsdFunction::new(self.msd_instance, lun_capacity)?;
|
||||
let func_clone = func.clone();
|
||||
self.add_function(Box::new(func))?;
|
||||
self.msd_instance += 1;
|
||||
|
||||
195
src/otg/msd.rs
195
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<Self> {
|
||||
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<Vec<(u16, PathBuf)>> {
|
||||
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::<u16>().ok()?;
|
||||
Some((lun, entry.path()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ pub(crate) struct OtgDesiredState {
|
||||
pub hid_functions: Option<OtgHidFunctions>,
|
||||
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<String>,
|
||||
pub hid_paths: Option<HidDevicePaths>,
|
||||
pub hid_functions: Option<OtgHidFunctions>,
|
||||
@@ -97,6 +101,24 @@ struct OtgServiceState {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
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<Option<OtgGadgetManager>>,
|
||||
state: RwLock<OtgServiceState>,
|
||||
@@ -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<OtgDesiredState> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
|
||||
33
src/state.rs
33
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::<Vec<_>>();
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<AppState>) -> 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<Arc<AppState>>) -> Result<Json<MsdSt
|
||||
let msd_state = controller.state().await;
|
||||
Ok(Json(MsdStatus {
|
||||
available: true,
|
||||
state: msd_state,
|
||||
state: MsdStateResponse::from(&msd_state),
|
||||
}))
|
||||
}
|
||||
None => 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<Arc<AppState>>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
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<Arc<AppState>>,
|
||||
Json(req): Json<MsdConnectRequest>,
|
||||
Json(req): Json<DiskModeRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
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<Arc<AppState>>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
Json(req): Json<ImageMountRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
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<Arc<AppState>>) -> Result<Json<LoginResponse>> {
|
||||
pub async fn msd_image_unmount(
|
||||
State(state): State<Arc<AppState>>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
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<Arc<AppState>>) -> Result<Json<LoginResponse>> {
|
||||
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<Arc<AppState>>) -> Result<Json<LoginResponse>> {
|
||||
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<Arc<AppState>>) -> Result<Json
|
||||
// Check if drive is currently connected
|
||||
let msd_guard = state.msd.write().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(
|
||||
"Cannot delete drive while connected. Disconnect first.".to_string(),
|
||||
));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
use axum::{extract::DefaultBodyLimit, routing::delete};
|
||||
use axum::{
|
||||
middleware,
|
||||
routing::{any, get, patch, post},
|
||||
routing::{any, get, patch, post, put},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
@@ -255,10 +255,16 @@ pub fn create_router(state: Arc<AppState>) -> 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(
|
||||
|
||||
Reference in New Issue
Block a user