mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-07-29 16:21:45 +08:00
feat: 虚拟媒体支持同时挂载多个镜像
This commit is contained in:
@@ -6,7 +6,7 @@ use self::types::EXACT_EVENT_TOPICS;
|
|||||||
|
|
||||||
pub use types::{
|
pub use types::{
|
||||||
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo,
|
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo,
|
||||||
StreamDeviceLostKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
|
MsdDeviceMediaInfo, StreamDeviceLostKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|||||||
@@ -42,12 +42,24 @@ pub struct HidDeviceInfo {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MsdDeviceInfo {
|
pub struct MsdDeviceInfo {
|
||||||
pub available: bool,
|
pub available: bool,
|
||||||
pub mode: String,
|
pub disk_mode: String,
|
||||||
pub connected: bool,
|
pub slot_capacity: u8,
|
||||||
pub image_id: Option<String>,
|
pub mounted_count: u8,
|
||||||
|
pub mounted_media: Vec<MsdDeviceMediaInfo>,
|
||||||
|
pub usb_reenumerating: bool,
|
||||||
pub error: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AtxDeviceInfo {
|
pub struct AtxDeviceInfo {
|
||||||
pub available: bool,
|
pub available: bool,
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ pub trait HidBackend: Send + Sync {
|
|||||||
|
|
||||||
async fn reset(&self) -> Result<()>;
|
async fn reset(&self) -> Result<()>;
|
||||||
|
|
||||||
|
async fn prepare_rebuild(&self) -> Result<()> {
|
||||||
|
self.shutdown().await
|
||||||
|
}
|
||||||
|
|
||||||
async fn shutdown(&self) -> Result<()>;
|
async fn shutdown(&self) -> Result<()>;
|
||||||
|
|
||||||
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot;
|
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot;
|
||||||
|
|||||||
@@ -234,6 +234,30 @@ impl HidController {
|
|||||||
Ok(())
|
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<()> {
|
pub async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
|
||||||
if !self.backend_available.load(Ordering::Acquire) {
|
if !self.backend_available.load(Ordering::Acquire) {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
|
|||||||
@@ -894,6 +894,19 @@ impl HidBackend for OtgBackend {
|
|||||||
Ok(())
|
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<()> {
|
async fn shutdown(&self) -> Result<()> {
|
||||||
self.stop_runtime_worker();
|
self.stop_runtime_worker();
|
||||||
|
|
||||||
@@ -948,6 +961,7 @@ impl Drop for OtgBackend {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::io::{Seek, SeekFrom, Write};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_led_state() {
|
fn test_led_state() {
|
||||||
@@ -964,4 +978,22 @@ mod tests {
|
|||||||
let kb_report = KeyboardReport::default();
|
let kb_report = KeyboardReport::default();
|
||||||
assert_eq!(kb_report.to_bytes().len(), 8);
|
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::image::ImageManager;
|
||||||
use super::monitor::MsdHealthMonitor;
|
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::error::{AppError, Result};
|
||||||
use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
|
use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
|
||||||
|
|
||||||
@@ -62,17 +65,23 @@ impl MsdController {
|
|||||||
*self.msd_function.write().await = Some(msd_func);
|
*self.msd_function.write().await = Some(msd_func);
|
||||||
|
|
||||||
let mut state = self.state.write().await;
|
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;
|
state.available = true;
|
||||||
|
|
||||||
if self.drive_path.exists() {
|
if self.drive_path.exists() {
|
||||||
if let Ok(metadata) = std::fs::metadata(&self.drive_path) {
|
if let Ok(metadata) = std::fs::metadata(&self.drive_path) {
|
||||||
state.drive_info = Some(DriveInfo {
|
let drive_info = DriveInfo {
|
||||||
size: metadata.len(),
|
size: metadata.len(),
|
||||||
used: 0,
|
used: 0,
|
||||||
free: metadata.len(),
|
free: metadata.len(),
|
||||||
initialized: true,
|
initialized: true,
|
||||||
path: self.drive_path.clone(),
|
path: self.drive_path.clone(),
|
||||||
});
|
};
|
||||||
|
state.drive_info = Some(drive_info.clone());
|
||||||
debug!(
|
debug!(
|
||||||
"Found existing virtual drive: {}",
|
"Found existing virtual drive: {}",
|
||||||
self.drive_path.display()
|
self.drive_path.display()
|
||||||
@@ -104,20 +113,12 @@ impl MsdController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_available(&self) -> bool {
|
pub async fn mount_image(&self, image: &ImageInfo, cdrom: bool, read_only: bool) -> Result<()> {
|
||||||
self.state.read().await.available
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn connect_image(
|
|
||||||
&self,
|
|
||||||
image: &ImageInfo,
|
|
||||||
cdrom: bool,
|
|
||||||
read_only: bool,
|
|
||||||
) -> Result<()> {
|
|
||||||
let _op_guard = self.operation_lock.write().await;
|
let _op_guard = self.operation_lock.write().await;
|
||||||
let mut state = self.state.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() {
|
if !image.path.exists() {
|
||||||
let error_msg = format!("Image file not found: {}", image.path.display());
|
let error_msg = format!("Image file not found: {}", image.path.display());
|
||||||
@@ -127,20 +128,30 @@ impl MsdController {
|
|||||||
return Err(AppError::Internal(error_msg));
|
return Err(AppError::Internal(error_msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = if cdrom {
|
if state
|
||||||
MsdLunConfig::cdrom(image.path.clone())
|
.mounted_media
|
||||||
} else {
|
.iter()
|
||||||
MsdLunConfig::disk(image.path.clone(), read_only)
|
.any(|media| media.kind == MountedMediaKind::Image && media.id == image.id)
|
||||||
};
|
{
|
||||||
self.configure_lun_now(&config).await?;
|
return Err(AppError::BadRequest("Image is already mounted".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
state.connected = true;
|
let lun = Self::lowest_free_lun(&state)
|
||||||
state.mode = MsdMode::Image;
|
.ok_or_else(|| AppError::BadRequest("Media slots are full".to_string()))?;
|
||||||
state.current_image = Some(image.clone());
|
|
||||||
|
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!(
|
info!(
|
||||||
"Connected image: {} (cdrom={}, ro={})",
|
"Mounted image: {} on LUN {} (cdrom={}, ro={})",
|
||||||
image.name, cdrom, read_only
|
image.name,
|
||||||
|
lun,
|
||||||
|
cdrom,
|
||||||
|
cdrom || read_only
|
||||||
);
|
);
|
||||||
|
|
||||||
drop(state);
|
drop(state);
|
||||||
@@ -150,11 +161,12 @@ impl MsdController {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn connect_drive(&self) -> Result<()> {
|
pub async fn mount_drive(&self) -> Result<()> {
|
||||||
let _op_guard = self.operation_lock.write().await;
|
let _op_guard = self.operation_lock.write().await;
|
||||||
let mut state = self.state.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() {
|
if !self.drive_path.exists() {
|
||||||
let err =
|
let err =
|
||||||
@@ -165,14 +177,48 @@ impl MsdController {
|
|||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = MsdLunConfig::disk(self.drive_path.clone(), false);
|
let drive_info = state.drive_info.clone().or_else(|| {
|
||||||
self.configure_lun_now(&config).await?;
|
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;
|
if state
|
||||||
state.mode = MsdMode::Drive;
|
.mounted_media
|
||||||
state.current_image = None;
|
.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(state);
|
||||||
drop(_op_guard);
|
drop(_op_guard);
|
||||||
@@ -181,22 +227,138 @@ impl MsdController {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn assert_can_connect(&self, state: &MsdState) -> Result<()> {
|
async fn assert_available(&self, state: &MsdState) -> Result<()> {
|
||||||
if !state.available {
|
if !state.available {
|
||||||
self.monitor
|
self.monitor
|
||||||
.report_error("MSD not available", "not_available")
|
.report_error("MSD not available", "not_available")
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Internal("MSD not available".to_string()));
|
return Err(AppError::Internal("MSD not available".to_string()));
|
||||||
}
|
}
|
||||||
if state.connected {
|
|
||||||
return Err(AppError::Internal(
|
|
||||||
"Already connected. Disconnect first.".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
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 gadget_path = self.active_gadget_path().await?;
|
||||||
let msd_hold = self.msd_function.read().await;
|
let msd_hold = self.msd_function.read().await;
|
||||||
let Some(ref msd) = *msd_hold else {
|
let Some(ref msd) = *msd_hold else {
|
||||||
@@ -207,8 +369,11 @@ impl MsdController {
|
|||||||
"MSD function not initialized".to_string(),
|
"MSD function not initialized".to_string(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, config).await {
|
if let Err(e) = msd
|
||||||
let error_msg = format!("Failed to configure LUN: {}", e);
|
.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
|
self.monitor
|
||||||
.report_error(&error_msg, "configfs_error")
|
.report_error(&error_msg, "configfs_error")
|
||||||
.await;
|
.await;
|
||||||
@@ -217,6 +382,29 @@ impl MsdController {
|
|||||||
Ok(())
|
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) {
|
async fn finish_connect_success(&self) {
|
||||||
if self.monitor.is_error().await {
|
if self.monitor.is_error().await {
|
||||||
self.monitor.report_recovered().await;
|
self.monitor.report_recovered().await;
|
||||||
@@ -228,22 +416,31 @@ impl MsdController {
|
|||||||
let _op_guard = self.operation_lock.write().await;
|
let _op_guard = self.operation_lock.write().await;
|
||||||
|
|
||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
|
if state.mounted_media.is_empty() {
|
||||||
if !state.connected {
|
debug!("Nothing mounted, skipping disconnect");
|
||||||
debug!("Nothing connected, skipping disconnect");
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let gadget_path = self.active_gadget_path().await?;
|
let mounted_media = state.mounted_media.clone();
|
||||||
if let Some(ref msd) = *self.msd_function.read().await {
|
let mut disconnected = Vec::new();
|
||||||
msd.disconnect_lun_async(&gadget_path, 0).await?;
|
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.mounted_media.clear();
|
||||||
state.mode = MsdMode::None;
|
info!("Disconnected all mounted media");
|
||||||
state.current_image = None;
|
|
||||||
|
|
||||||
info!("Disconnected storage");
|
|
||||||
|
|
||||||
drop(state);
|
drop(state);
|
||||||
drop(_op_guard);
|
drop(_op_guard);
|
||||||
@@ -253,29 +450,29 @@ impl MsdController {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn images_path(&self) -> &PathBuf {
|
pub async fn is_drive_connected(&self) -> bool {
|
||||||
&self.images_path
|
self.state
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.mounted_media
|
||||||
|
.iter()
|
||||||
|
.any(|media| media.kind == MountedMediaKind::Drive)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ventoy_dir(&self) -> &PathBuf {
|
pub async fn delete_image(&self, image_id: &str) -> Result<()> {
|
||||||
&self.ventoy_dir
|
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 {
|
ImageManager::new(self.images_path.clone()).delete(image_id)
|
||||||
&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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn download_image(
|
pub async fn download_image(
|
||||||
@@ -423,6 +620,8 @@ impl MsdController {
|
|||||||
|
|
||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
state.available = false;
|
state.available = false;
|
||||||
|
state.mounted_media.clear();
|
||||||
|
state.usb_reenumerating = false;
|
||||||
|
|
||||||
info!("MSD controller shutdown complete");
|
info!("MSD controller shutdown complete");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -436,6 +635,7 @@ impl MsdController {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::msd::MULTI_DISK_MSD_LUNS;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -462,7 +662,205 @@ mod tests {
|
|||||||
|
|
||||||
let state = controller.state().await;
|
let state = controller.state().await;
|
||||||
assert!(!state.available);
|
assert!(!state.available);
|
||||||
assert!(!state.connected);
|
assert_eq!(state.disk_mode, DiskMode::Single);
|
||||||
assert_eq!(state.mode, MsdMode::None);
|
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)
|
self.get_by_name(&final_filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn images_path(&self) -> &PathBuf {
|
|
||||||
&self.images_path
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stable_image_id_from_filename(name: &str) -> String {
|
fn stable_image_id_from_filename(name: &str) -> String {
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ pub use controller::MsdController;
|
|||||||
pub use image::ImageManager;
|
pub use image::ImageManager;
|
||||||
pub use monitor::MsdHealthMonitor;
|
pub use monitor::MsdHealthMonitor;
|
||||||
pub use types::{
|
pub use types::{
|
||||||
DownloadProgress, DownloadStatus, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest,
|
DiskMode, DiskModeRequest, DownloadProgress, DownloadStatus, DriveFile, DriveInfo,
|
||||||
ImageInfo, MsdConnectRequest, MsdMode, MsdState,
|
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};
|
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 std::path::PathBuf;
|
||||||
use time::OffsetDateTime;
|
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")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum MsdMode {
|
pub enum DiskMode {
|
||||||
#[default]
|
#[default]
|
||||||
None,
|
Single,
|
||||||
Image,
|
Multi,
|
||||||
Drive,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -50,23 +49,109 @@ impl ImageInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MsdState {
|
pub struct MsdState {
|
||||||
pub available: bool,
|
pub available: bool,
|
||||||
pub mode: MsdMode,
|
pub disk_mode: DiskMode,
|
||||||
pub connected: bool,
|
pub mounted_media: Vec<MountedMedia>,
|
||||||
pub current_image: Option<ImageInfo>,
|
|
||||||
pub drive_info: Option<DriveInfo>,
|
pub drive_info: Option<DriveInfo>,
|
||||||
|
pub usb_reenumerating: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for MsdState {
|
impl Default for MsdState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
available: false,
|
available: false,
|
||||||
mode: MsdMode::None,
|
disk_mode: DiskMode::Single,
|
||||||
connected: false,
|
mounted_media: Vec::new(),
|
||||||
current_image: None,
|
|
||||||
drive_info: None,
|
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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct MsdConnectRequest {
|
pub struct DiskModeRequest {
|
||||||
pub mode: MsdMode,
|
pub disk_mode: DiskMode,
|
||||||
pub image_id: Option<String>,
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ImageMountRequest {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cdrom: Option<bool>,
|
pub cdrom: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub read_only: Option<bool>,
|
pub read_only: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -164,4 +252,19 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(info.size_display().contains("GB"));
|
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)
|
Ok(device_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_msd(&mut self) -> Result<MsdFunction> {
|
pub fn add_msd(&mut self, lun_capacity: u8) -> Result<MsdFunction> {
|
||||||
let func = MsdFunction::new(self.msd_instance);
|
let func = MsdFunction::new(self.msd_instance, lun_capacity)?;
|
||||||
let func_clone = func.clone();
|
let func_clone = func.clone();
|
||||||
self.add_function(Box::new(func))?;
|
self.add_function(Box::new(func))?;
|
||||||
self.msd_instance += 1;
|
self.msd_instance += 1;
|
||||||
|
|||||||
195
src/otg/msd.rs
195
src/otg/msd.rs
@@ -56,13 +56,21 @@ impl MsdLunConfig {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MsdFunction {
|
pub struct MsdFunction {
|
||||||
name: String,
|
name: String,
|
||||||
|
lun_capacity: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MsdFunction {
|
impl MsdFunction {
|
||||||
pub fn new(instance: u8) -> Self {
|
pub fn new(instance: u8, lun_capacity: u8) -> Result<Self> {
|
||||||
Self {
|
if lun_capacity != 1 && lun_capacity != 8 {
|
||||||
name: format!("mass_storage.usb{}", instance),
|
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 {
|
fn function_path(&self, gadget_path: &Path) -> PathBuf {
|
||||||
@@ -73,6 +81,32 @@ impl MsdFunction {
|
|||||||
self.function_path(gadget_path).join(format!("lun.{}", lun))
|
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(
|
pub async fn configure_lun_async(
|
||||||
&self,
|
&self,
|
||||||
gadget_path: &Path,
|
gadget_path: &Path,
|
||||||
@@ -88,11 +122,32 @@ impl MsdFunction {
|
|||||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
|
.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<()> {
|
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);
|
let lun_path = self.lun_path(gadget_path, lun);
|
||||||
|
|
||||||
if !lun_path.exists() {
|
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 {
|
let read_attr = |attr: &str| -> String {
|
||||||
@@ -210,8 +265,18 @@ impl MsdFunction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn disconnect_lun(&self, gadget_path: &Path, lun: u8) -> Result<()> {
|
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);
|
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() {
|
if lun_path.exists() {
|
||||||
let forced_eject_path = lun_path.join("forced_eject");
|
let forced_eject_path = lun_path.join("forced_eject");
|
||||||
if forced_eject_path.exists() {
|
if forced_eject_path.exists() {
|
||||||
@@ -226,11 +291,17 @@ impl MsdFunction {
|
|||||||
"forced_eject write failed: {}, falling back to clearing file",
|
"forced_eject write failed: {}, falling back to clearing file",
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
write_file(&lun_path.join("file"), "")?;
|
let file_path = lun_path.join("file");
|
||||||
|
if file_path.exists() {
|
||||||
|
write_file(&file_path, "")?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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);
|
info!("LUN {} disconnected", lun);
|
||||||
}
|
}
|
||||||
@@ -275,16 +346,10 @@ impl GadgetFunction for MsdFunction {
|
|||||||
let _ = write_file(&stall_path, "0");
|
let _ = write_file(&stall_path, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
let lun0_path = func_path.join("lun.0");
|
for lun in 0..self.lun_capacity {
|
||||||
if !lun0_path.exists() {
|
self.clear_lun_unbound(gadget_path, lun)?;
|
||||||
create_dir(&lun0_path)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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());
|
debug!("Created MSD function: {}", self.name());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -311,8 +376,25 @@ impl GadgetFunction for MsdFunction {
|
|||||||
fn cleanup(&self, gadget_path: &Path) -> Result<()> {
|
fn cleanup(&self, gadget_path: &Path) -> Result<()> {
|
||||||
let func_path = self.function_path(gadget_path);
|
let func_path = self.function_path(gadget_path);
|
||||||
|
|
||||||
for lun in 0..8 {
|
let lun_paths = match self.existing_lun_paths(gadget_path) {
|
||||||
let _ = self.disconnect_lun(gadget_path, lun);
|
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) {
|
if let Err(e) = remove_dir(&func_path) {
|
||||||
@@ -327,6 +409,7 @@ impl GadgetFunction for MsdFunction {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_lun_config_cdrom() {
|
fn test_lun_config_cdrom() {
|
||||||
@@ -346,8 +429,86 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_msd_function_name() {
|
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.name(), "mass_storage.usb0");
|
||||||
assert_eq!(msd.endpoints_required(), 2);
|
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 hid_functions: Option<OtgHidFunctions>,
|
||||||
pub keyboard_leds: bool,
|
pub keyboard_leds: bool,
|
||||||
pub msd_enabled: bool,
|
pub msd_enabled: bool,
|
||||||
|
pub msd_lun_capacity: u8,
|
||||||
pub max_endpoints: u8,
|
pub max_endpoints: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ impl Default for OtgDesiredState {
|
|||||||
hid_functions: None,
|
hid_functions: None,
|
||||||
keyboard_leds: false,
|
keyboard_leds: false,
|
||||||
msd_enabled: false,
|
msd_enabled: false,
|
||||||
|
msd_lun_capacity: 1,
|
||||||
max_endpoints: super::endpoint::DEFAULT_MAX_ENDPOINTS,
|
max_endpoints: super::endpoint::DEFAULT_MAX_ENDPOINTS,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +73,7 @@ impl OtgDesiredState {
|
|||||||
hid_functions,
|
hid_functions,
|
||||||
keyboard_leds: hid.effective_otg_keyboard_leds(),
|
keyboard_leds: hid.effective_otg_keyboard_leds(),
|
||||||
msd_enabled: msd.enabled,
|
msd_enabled: msd.enabled,
|
||||||
|
msd_lun_capacity: 1,
|
||||||
max_endpoints: hid
|
max_endpoints: hid
|
||||||
.resolved_otg_endpoint_limit()
|
.resolved_otg_endpoint_limit()
|
||||||
.unwrap_or(super::endpoint::DEFAULT_MAX_ENDPOINTS),
|
.unwrap_or(super::endpoint::DEFAULT_MAX_ENDPOINTS),
|
||||||
@@ -83,11 +86,12 @@ impl OtgDesiredState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone)]
|
||||||
struct OtgServiceState {
|
struct OtgServiceState {
|
||||||
pub gadget_active: bool,
|
pub gadget_active: bool,
|
||||||
pub hid_enabled: bool,
|
pub hid_enabled: bool,
|
||||||
pub msd_enabled: bool,
|
pub msd_enabled: bool,
|
||||||
|
pub msd_lun_capacity: u8,
|
||||||
pub configured_udc: Option<String>,
|
pub configured_udc: Option<String>,
|
||||||
pub hid_paths: Option<HidDevicePaths>,
|
pub hid_paths: Option<HidDevicePaths>,
|
||||||
pub hid_functions: Option<OtgHidFunctions>,
|
pub hid_functions: Option<OtgHidFunctions>,
|
||||||
@@ -97,6 +101,24 @@ struct OtgServiceState {
|
|||||||
pub error: Option<String>,
|
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 {
|
pub struct OtgService {
|
||||||
manager: Mutex<Option<OtgGadgetManager>>,
|
manager: Mutex<Option<OtgGadgetManager>>,
|
||||||
state: RwLock<OtgServiceState>,
|
state: RwLock<OtgServiceState>,
|
||||||
@@ -131,8 +153,41 @@ impl OtgService {
|
|||||||
self.msd_function.read().await.clone()
|
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<()> {
|
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
|
self.apply_desired_state(desired).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +215,7 @@ impl OtgService {
|
|||||||
if state.gadget_active
|
if state.gadget_active
|
||||||
&& state.hid_enabled == desired.hid_enabled()
|
&& state.hid_enabled == desired.hid_enabled()
|
||||||
&& state.msd_enabled == desired.msd_enabled
|
&& state.msd_enabled == desired.msd_enabled
|
||||||
|
&& state.msd_lun_capacity == desired.msd_lun_capacity
|
||||||
&& state.configured_udc == desired.udc
|
&& state.configured_udc == desired.udc
|
||||||
&& state.hid_functions == desired.hid_functions
|
&& state.hid_functions == desired.hid_functions
|
||||||
&& state.keyboard_leds_enabled == desired.keyboard_leds
|
&& state.keyboard_leds_enabled == desired.keyboard_leds
|
||||||
@@ -188,6 +244,7 @@ impl OtgService {
|
|||||||
state.gadget_active = false;
|
state.gadget_active = false;
|
||||||
state.hid_enabled = false;
|
state.hid_enabled = false;
|
||||||
state.msd_enabled = false;
|
state.msd_enabled = false;
|
||||||
|
state.msd_lun_capacity = 1;
|
||||||
state.configured_udc = None;
|
state.configured_udc = None;
|
||||||
state.hid_paths = None;
|
state.hid_paths = None;
|
||||||
state.hid_functions = None;
|
state.hid_functions = None;
|
||||||
@@ -280,7 +337,7 @@ impl OtgService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let msd_func = if desired.msd_enabled {
|
let msd_func = if desired.msd_enabled {
|
||||||
match manager.add_msd() {
|
match manager.add_msd(desired.msd_lun_capacity) {
|
||||||
Ok(func) => {
|
Ok(func) => {
|
||||||
debug!("MSD function added to gadget");
|
debug!("MSD function added to gadget");
|
||||||
Some(func)
|
Some(func)
|
||||||
@@ -323,6 +380,7 @@ impl OtgService {
|
|||||||
state.gadget_active = true;
|
state.gadget_active = true;
|
||||||
state.hid_enabled = desired.hid_enabled();
|
state.hid_enabled = desired.hid_enabled();
|
||||||
state.msd_enabled = desired.msd_enabled;
|
state.msd_enabled = desired.msd_enabled;
|
||||||
|
state.msd_lun_capacity = desired.msd_lun_capacity;
|
||||||
state.configured_udc = Some(udc);
|
state.configured_udc = Some(udc);
|
||||||
state.hid_paths = hid_paths;
|
state.hid_paths = hid_paths;
|
||||||
state.hid_functions = desired.hid_functions;
|
state.hid_functions = desired.hid_functions;
|
||||||
@@ -393,4 +451,32 @@ mod tests {
|
|||||||
let _service = OtgService::new();
|
let _service = OtgService::new();
|
||||||
let _ = OtgService::is_available();
|
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) => {
|
Some(msd) => {
|
||||||
let msd_state = msd.state().await;
|
let msd_state = msd.state().await;
|
||||||
let img_name = msd_state
|
let img_name = msd_state
|
||||||
.current_image
|
.mounted_media
|
||||||
.as_ref()
|
.first()
|
||||||
.map(|i| i.name.clone())
|
.map(|media| media.name.clone());
|
||||||
.or_else(|| {
|
|
||||||
msd_state
|
|
||||||
.drive_info
|
|
||||||
.as_ref()
|
|
||||||
.map(|_| "Virtual Drive".to_string())
|
|
||||||
});
|
|
||||||
(
|
(
|
||||||
msd_state.connected,
|
!msd_state.mounted_media.is_empty(),
|
||||||
img_name,
|
img_name,
|
||||||
if msd_state.connected {
|
if !msd_state.mounted_media.is_empty() {
|
||||||
Some("Applet".to_string())
|
Some("Applet".to_string())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -153,7 +147,7 @@ async fn virtual_media_insert(
|
|||||||
None => return service_unavailable("MSD not available"),
|
None => return service_unavailable("MSD not available"),
|
||||||
};
|
};
|
||||||
|
|
||||||
if msd.state().await.connected {
|
if !msd.state().await.mounted_media.is_empty() {
|
||||||
return (
|
return (
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
Json(RedfishError::general_error(
|
Json(RedfishError::general_error(
|
||||||
@@ -164,7 +158,7 @@ async fn virtual_media_insert(
|
|||||||
}
|
}
|
||||||
|
|
||||||
info!("Redfish: VirtualMedia.InsertMedia image='{}'", req.image);
|
info!("Redfish: VirtualMedia.InsertMedia image='{}'", req.image);
|
||||||
msd.connect_drive().await
|
msd.mount_drive().await
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -201,7 +195,7 @@ async fn virtual_media_eject(
|
|||||||
None => return service_unavailable("MSD not available"),
|
None => return service_unavailable("MSD not available"),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !msd.state().await.connected {
|
if msd.state().await.mounted_media.is_empty() {
|
||||||
return (
|
return (
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
Json(RedfishError::general_error("No virtual media inserted")),
|
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::config::ConfigStore;
|
||||||
use crate::db::DatabasePool;
|
use crate::db::DatabasePool;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, LedState, MsdDeviceInfo, SystemEvent,
|
AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, LedState, MsdDeviceInfo,
|
||||||
TtydDeviceInfo, VideoDeviceInfo,
|
MsdDeviceMediaInfo, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
|
||||||
};
|
};
|
||||||
use crate::extensions::{ExtensionId, ExtensionManager};
|
use crate::extensions::{ExtensionId, ExtensionManager};
|
||||||
use crate::hid::HidController;
|
use crate::hid::HidController;
|
||||||
@@ -231,16 +231,33 @@ impl AppState {
|
|||||||
|
|
||||||
let state = msd.state().await;
|
let state = msd.state().await;
|
||||||
let error = msd.monitor().error_message().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 {
|
Some(MsdDeviceInfo {
|
||||||
available: state.available,
|
available: state.available,
|
||||||
mode: match state.mode {
|
disk_mode: match state.disk_mode {
|
||||||
crate::msd::MsdMode::None => "none",
|
crate::msd::DiskMode::Single => "single",
|
||||||
crate::msd::MsdMode::Image => "image",
|
crate::msd::DiskMode::Multi => "multi",
|
||||||
crate::msd::MsdMode::Drive => "drive",
|
|
||||||
}
|
}
|
||||||
.to_string(),
|
.to_string(),
|
||||||
connected: state.connected,
|
slot_capacity: state.disk_mode.capacity(),
|
||||||
image_id: state.current_image.map(|img| img.id),
|
mounted_count: state.mounted_media.len() as u8,
|
||||||
|
mounted_media,
|
||||||
|
usb_reenumerating: state.usb_reenumerating,
|
||||||
error,
|
error,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
use super::config::apply::try_apply_lock;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use crate::msd::{
|
use crate::msd::{
|
||||||
DownloadProgress, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo,
|
DiskModeRequest, DownloadProgress, DriveFile, DriveInfo, DriveInitRequest,
|
||||||
ImageManager, MsdConnectRequest, MsdMode, MsdState, VentoyDrive, MIN_DRIVE_SIZE_MB,
|
ImageDownloadRequest, ImageInfo, ImageManager, ImageMountRequest, MsdState, MsdStateResponse,
|
||||||
|
VentoyDrive, MIN_DRIVE_SIZE_MB,
|
||||||
};
|
};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
@@ -26,8 +28,7 @@ const MIB: u64 = 1024 * 1024;
|
|||||||
async fn assert_drive_not_connected(state: &Arc<AppState>) -> Result<()> {
|
async fn assert_drive_not_connected(state: &Arc<AppState>) -> Result<()> {
|
||||||
let msd_guard = state.msd.read().await;
|
let msd_guard = state.msd.read().await;
|
||||||
if let Some(controller) = msd_guard.as_ref() {
|
if let Some(controller) = msd_guard.as_ref() {
|
||||||
let msd_state = controller.state().await;
|
if controller.is_drive_connected().await {
|
||||||
if msd_state.connected && msd_state.mode == crate::msd::types::MsdMode::Drive {
|
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"Virtual drive is connected to the USB host; disconnect it before modifying files"
|
"Virtual drive is connected to the USB host; disconnect it before modifying files"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -77,7 +78,7 @@ fn unsupported_drive_filesystem_error(error: &str) -> AppError {
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct MsdStatus {
|
pub struct MsdStatus {
|
||||||
pub available: bool,
|
pub available: bool,
|
||||||
pub state: MsdState,
|
pub state: MsdStateResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get MSD status
|
/// 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;
|
let msd_state = controller.state().await;
|
||||||
Ok(Json(MsdStatus {
|
Ok(Json(MsdStatus {
|
||||||
available: true,
|
available: true,
|
||||||
state: msd_state,
|
state: MsdStateResponse::from(&msd_state),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
None => Ok(Json(MsdStatus {
|
None => Ok(Json(MsdStatus {
|
||||||
available: false,
|
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>>,
|
State(state): State<Arc<AppState>>,
|
||||||
AxumPath(id): AxumPath<String>,
|
AxumPath(id): AxumPath<String>,
|
||||||
) -> Result<Json<LoginResponse>> {
|
) -> Result<Json<LoginResponse>> {
|
||||||
let config = state.config.get();
|
let msd_guard = state.msd.read().await;
|
||||||
let images_path = config.msd.images_dir();
|
let controller = msd_guard
|
||||||
let manager = ImageManager::new(images_path);
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
||||||
manager.delete(&id)?;
|
controller.delete_image(&id).await?;
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: Some("Image deleted".to_string()),
|
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)]
|
#[cfg(unix)]
|
||||||
pub async fn msd_connect(
|
pub async fn msd_disk_mode_put(
|
||||||
State(state): State<Arc<AppState>>,
|
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>> {
|
) -> Result<Json<LoginResponse>> {
|
||||||
let config = state.config.get();
|
let config = state.config.get();
|
||||||
let mut msd_guard = state.msd.write().await;
|
let mut msd_guard = state.msd.write().await;
|
||||||
@@ -228,50 +299,68 @@ pub async fn msd_connect(
|
|||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
||||||
|
|
||||||
match req.mode {
|
let images_path = config.msd.images_dir();
|
||||||
MsdMode::Image => {
|
let manager = ImageManager::new(images_path);
|
||||||
let image_id = req.image_id.ok_or_else(|| {
|
let image = manager.get(&id)?;
|
||||||
AppError::BadRequest("image_id required for image mode".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Get image info from ImageManager
|
controller
|
||||||
let images_path = config.msd.images_dir();
|
.mount_image(&image, req.cdrom, req.read_only)
|
||||||
let manager = ImageManager::new(images_path);
|
.await?;
|
||||||
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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
success: true,
|
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)]
|
#[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 mut msd_guard = state.msd.write().await;
|
||||||
let controller = msd_guard
|
let controller = msd_guard
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
||||||
|
|
||||||
controller.disconnect().await?;
|
controller.unmount_image(&id).await?;
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
success: true,
|
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
|
// Check if drive is currently connected
|
||||||
let msd_guard = state.msd.write().await;
|
let msd_guard = state.msd.write().await;
|
||||||
if let Some(controller) = msd_guard.as_ref() {
|
if let Some(controller) = msd_guard.as_ref() {
|
||||||
let msd_state = controller.state().await;
|
if controller.is_drive_connected().await {
|
||||||
if msd_state.connected && msd_state.mode == crate::msd::types::MsdMode::Drive {
|
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"Cannot delete drive while connected. Disconnect first.".to_string(),
|
"Cannot delete drive while connected. Disconnect first.".to_string(),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
use axum::{extract::DefaultBodyLimit, routing::delete};
|
use axum::{extract::DefaultBodyLimit, routing::delete};
|
||||||
use axum::{
|
use axum::{
|
||||||
middleware,
|
middleware,
|
||||||
routing::{any, get, patch, post},
|
routing::{any, get, patch, post, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
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}", get(handlers::msd_image_get))
|
||||||
.route("/msd/images/{id}", delete(handlers::msd_image_delete))
|
.route("/msd/images/{id}", delete(handlers::msd_image_delete))
|
||||||
.route("/msd/connect", post(handlers::msd_connect))
|
.route("/msd/disk-mode", put(handlers::msd_disk_mode_put))
|
||||||
.route("/msd/disconnect", post(handlers::msd_disconnect))
|
.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", get(handlers::msd_drive_info))
|
||||||
.route("/msd/drive", delete(handlers::msd_drive_delete))
|
.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/init", post(handlers::msd_drive_init))
|
||||||
.route("/msd/drive/files", get(handlers::msd_drive_files))
|
.route("/msd/drive/files", get(handlers::msd_drive_files))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ MSD 测试依赖 OTG。流程如下:
|
|||||||
2. 如果资源缺失,默认从 `libs/ventoy-img-rs/resources` 解压并通过 SSH/SFTP 同步到目标机。
|
2. 如果资源缺失,默认从 `libs/ventoy-img-rs/resources` 解压并通过 SSH/SFTP 同步到目标机。
|
||||||
3. 启用 MSD 后重启 `one-kvm`,确保 Ventoy 资源在服务进程中初始化。
|
3. 启用 MSD 后重启 `one-kvm`,确保 Ventoy 资源在服务进程中初始化。
|
||||||
4. 通过 `/api/msd/drive/init` 创建小型虚拟盘。
|
4. 通过 `/api/msd/drive/init` 创建小型虚拟盘。
|
||||||
5. 通过 `/api/msd/connect {"mode":"drive"}` 连接到 Windows。
|
5. 通过 `/api/msd/drive/mount` 连接到 Windows。
|
||||||
6. Windows agent 等待新盘符出现。
|
6. Windows agent 等待新盘符出现。
|
||||||
7. Windows agent 写入测试文件、同步到虚拟盘、优先用未缓存读取读回并校验 SHA-256,同时输出简单写入/读取速度。若 Windows/驱动不支持未缓存读取,会退回缓存读取并在报告中标为“仅校验”,不作为真实读盘速度。
|
7. Windows agent 写入测试文件、同步到虚拟盘、优先用未缓存读取读回并校验 SHA-256,同时输出简单写入/读取速度。若 Windows/驱动不支持未缓存读取,会退回缓存读取并在报告中标为“仅校验”,不作为真实读盘速度。
|
||||||
8. 控制端断开 MSD,Windows agent 确认盘符消失。
|
8. 控制端断开 MSD,Windows agent 确认盘符消失。
|
||||||
|
|||||||
@@ -542,25 +542,34 @@ export interface DriveFile {
|
|||||||
size: number
|
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 = {
|
export const msdApi = {
|
||||||
status: () =>
|
status: () =>
|
||||||
request<{
|
request<{
|
||||||
available: boolean
|
available: boolean
|
||||||
state: {
|
state: {
|
||||||
connected: boolean
|
disk_mode: DiskMode
|
||||||
mode: 'none' | 'image' | 'drive'
|
slot_capacity: number
|
||||||
current_image: {
|
mounted_count: number
|
||||||
id: string
|
mounted_media: MountedMedia[]
|
||||||
name: string
|
|
||||||
size: number
|
|
||||||
created_at: string
|
|
||||||
} | null
|
|
||||||
drive_info: {
|
drive_info: {
|
||||||
size: number
|
size: number
|
||||||
used: number
|
used: number
|
||||||
free: number
|
free: number
|
||||||
initialized: boolean
|
initialized: boolean
|
||||||
} | null
|
} | null
|
||||||
|
usb_reenumerating: boolean
|
||||||
}
|
}
|
||||||
}>('/msd/status'),
|
}>('/msd/status'),
|
||||||
|
|
||||||
@@ -597,14 +606,26 @@ export const msdApi = {
|
|||||||
deleteImage: (id: string) =>
|
deleteImage: (id: string) =>
|
||||||
request<{ success: boolean }>(`/msd/images/${id}`, { method: 'DELETE' }),
|
request<{ success: boolean }>(`/msd/images/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
connect: (mode: 'image' | 'drive', imageId?: string, cdrom?: boolean, readOnly?: boolean) =>
|
setDiskMode: (diskMode: DiskMode) =>
|
||||||
request<{ success: boolean }>('/msd/connect', {
|
request<{ success: boolean }>('/msd/disk-mode', {
|
||||||
method: 'POST',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ mode, image_id: imageId, cdrom, read_only: readOnly }),
|
body: JSON.stringify({ disk_mode: diskMode }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
disconnect: () =>
|
mountImage: (id: string, cdrom: boolean, readOnly: boolean) =>
|
||||||
request<{ success: boolean }>('/msd/disconnect', { method: 'POST' }),
|
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: () =>
|
driveInfo: () =>
|
||||||
request<{
|
request<{
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { useSystemStore } from '@/stores/system'
|
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 { ApiError } from '@/api/request'
|
||||||
import { useWebSocket } from '@/composables/useWebSocket'
|
import { useWebSocket } from '@/composables/useWebSocket'
|
||||||
import {
|
import {
|
||||||
@@ -48,7 +48,6 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Info,
|
Info,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import HelpTooltip from '@/components/HelpTooltip.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -77,8 +76,11 @@ const cdromMode = computed(() => mountMode.value === 'cdrom')
|
|||||||
const readOnly = computed(() => accessMode.value === 'readonly')
|
const readOnly = computed(() => accessMode.value === 'readonly')
|
||||||
|
|
||||||
const connecting = ref(false)
|
const connecting = ref(false)
|
||||||
const disconnecting = ref(false)
|
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
|
const modeChanging = ref(false)
|
||||||
|
const unmountingMediaId = ref<string | null>(null)
|
||||||
|
const pendingMountImage = ref<MsdImage | null>(null)
|
||||||
|
const showMountOptionsDialog = ref(false)
|
||||||
|
|
||||||
const driveFiles = ref<DriveFile[]>([])
|
const driveFiles = ref<DriveFile[]>([])
|
||||||
const currentPath = ref('/')
|
const currentPath = ref('/')
|
||||||
@@ -149,28 +151,62 @@ const downloadProgress = ref<{
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const TWO_POINT_TWO_GB = 2.2 * 1024 * 1024 * 1024
|
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 diskMode = computed(() => systemStore.msd?.diskMode ?? 'single')
|
||||||
const msdMode = computed(() => systemStore.msd?.mode ?? 'none')
|
const slotCapacity = computed(() => systemStore.msd?.slotCapacity ?? 1)
|
||||||
|
const mountedMedia = computed<MountedMedia[]>(() => 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
|
// 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(() => {
|
const operationInProgress = computed(() => {
|
||||||
return connecting.value ||
|
return connecting.value ||
|
||||||
disconnecting.value ||
|
|
||||||
deleting.value ||
|
deleting.value ||
|
||||||
uploading.value ||
|
uploading.value ||
|
||||||
uploadingFile.value ||
|
uploadingFile.value ||
|
||||||
initializingDrive.value ||
|
initializingDrive.value ||
|
||||||
deletingDrive.value
|
deletingDrive.value ||
|
||||||
|
modeChanging.value ||
|
||||||
|
!!unmountingMediaId.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const usbReenumerating = computed(() => systemStore.msd?.usbReenumerating || modeChanging.value)
|
||||||
|
|
||||||
function isLargeFile(image: MsdImage): boolean {
|
function isLargeFile(image: MsdImage): boolean {
|
||||||
return image.size > TWO_POINT_TWO_GB
|
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 breadcrumbs = computed(() => {
|
||||||
const parts = currentPath.value.split('/').filter(Boolean)
|
const parts = currentPath.value.split('/').filter(Boolean)
|
||||||
const crumbs = [{ name: '/', path: '/' }]
|
const crumbs = [{ name: '/', path: '/' }]
|
||||||
@@ -251,12 +287,30 @@ async function connectImage(image: MsdImage) {
|
|||||||
return
|
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
|
connecting.value = true
|
||||||
try {
|
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()
|
await systemStore.fetchMsdState()
|
||||||
|
showMountOptionsDialog.value = false
|
||||||
|
pendingMountImage.value = null
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to connect image:', e)
|
console.error('Failed to mount image:', e)
|
||||||
} finally {
|
} finally {
|
||||||
connecting.value = false
|
connecting.value = false
|
||||||
}
|
}
|
||||||
@@ -268,30 +322,61 @@ async function connectDrive() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (driveConnectedToTarget.value) return
|
||||||
|
if (mediaSlotsFull.value) {
|
||||||
|
toast.error(t('msd.mediaSlotsFull'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
connecting.value = true
|
connecting.value = true
|
||||||
try {
|
try {
|
||||||
await msdApi.connect('drive')
|
await msdApi.mountDrive()
|
||||||
await systemStore.fetchMsdState()
|
await systemStore.fetchMsdState()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to connect drive:', e)
|
console.error('Failed to mount drive:', e)
|
||||||
} finally {
|
} finally {
|
||||||
connecting.value = false
|
connecting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disconnect() {
|
async function unmountMedia(media: MountedMedia) {
|
||||||
if (operationInProgress.value) {
|
if (operationInProgress.value) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnecting.value = true
|
unmountingMediaId.value = `${media.kind}:${media.id}`
|
||||||
try {
|
try {
|
||||||
await msdApi.disconnect()
|
if (media.kind === 'drive') {
|
||||||
|
await msdApi.unmountDrive()
|
||||||
|
await refreshDriveBrowser()
|
||||||
|
} else {
|
||||||
|
await msdApi.unmountImage(media.id)
|
||||||
|
}
|
||||||
await systemStore.fetchMsdState()
|
await systemStore.fetchMsdState()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to disconnect:', e)
|
console.error('Failed to unmount media:', e)
|
||||||
} finally {
|
} 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(() => {
|
|||||||
<template>
|
<template>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||||
<DialogContent class="sm:max-w-[600px] max-h-[90vh] overflow-hidden flex flex-col p-0">
|
<DialogContent class="max-h-[90vh] overflow-hidden p-0 sm:max-w-[760px] flex flex-col">
|
||||||
<DialogHeader class="px-6 pt-6 shrink-0">
|
<DialogHeader class="px-6 pt-6 shrink-0">
|
||||||
<DialogTitle class="flex items-center gap-2">
|
<DialogTitle class="flex items-center gap-2">
|
||||||
<HardDrive class="h-5 w-5" />
|
<HardDrive class="h-5 w-5" />
|
||||||
{{ t('msd.title') }}
|
{{ t('msd.title') }}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription class="flex items-center flex-wrap gap-x-2 gap-y-1 mt-1">
|
<DialogDescription as="div" class="mt-1 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span :class="msdConnected ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'" class="flex items-center gap-1.5">
|
<span class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span class="relative flex h-2 w-2">
|
<span :class="msdConnected ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'" class="flex items-center gap-1.5">
|
||||||
<span v-if="msdConnected" class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
<span class="relative flex h-2 w-2">
|
||||||
<span :class="msdConnected ? 'bg-green-500' : 'bg-muted-foreground'" class="relative inline-flex rounded-full h-2 w-2"></span>
|
<span v-if="msdConnected" class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||||
|
<span :class="msdConnected ? 'bg-green-500' : 'bg-muted-foreground'" class="relative inline-flex rounded-full h-2 w-2"></span>
|
||||||
|
</span>
|
||||||
|
{{ msdConnected ? t('common.connected') : t('common.disconnected') }}
|
||||||
</span>
|
</span>
|
||||||
{{ msdConnected ? t('common.connected') : t('common.disconnected') }}
|
|
||||||
</span>
|
|
||||||
<template v-if="msdConnected">
|
|
||||||
<span class="text-muted-foreground">·</span>
|
<span class="text-muted-foreground">·</span>
|
||||||
<Badge variant="secondary" class="text-xs">{{ msdMode === 'drive' ? t('msd.drive') : t('msd.images') }}</Badge>
|
<Badge variant="secondary" class="h-6 rounded-md px-2 text-xs">{{ t('msd.mediaCount', { count: mountedCount, capacity: slotCapacity }) }}</Badge>
|
||||||
<Button
|
<Badge
|
||||||
|
v-if="mediaSlotsFull"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
class="h-6 rounded-md border-amber-500/40 bg-amber-500/10 px-2 text-xs text-amber-700 dark:text-amber-400"
|
||||||
class="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10 border-destructive/30"
|
|
||||||
:disabled="operationInProgress"
|
|
||||||
@click="disconnect"
|
|
||||||
>
|
>
|
||||||
<Unlink v-if="!disconnecting" class="h-3 w-3 mr-1" />
|
{{ t('msd.mediaSlotsFull') }}
|
||||||
<span v-if="disconnecting">{{ t('common.disconnecting') }}...</span>
|
</Badge>
|
||||||
<span v-else>{{ t('msd.disconnect') }}</span>
|
<span v-if="usbReenumerating" class="text-xs text-amber-600 dark:text-amber-400">
|
||||||
</Button>
|
{{ t('msd.reenumerating') }}
|
||||||
</template>
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex w-full shrink-0 items-center gap-2 sm:w-auto">
|
||||||
|
<span class="flex shrink-0 items-center gap-1.5">
|
||||||
|
<span class="font-medium text-foreground">{{ t('msd.diskMode') }}</span>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<span class="inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:text-foreground">
|
||||||
|
<Info class="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p class="max-w-xs">{{ t('msd.diskModeHint') }}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
<ToggleGroup
|
||||||
|
:model-value="diskMode"
|
||||||
|
type="single"
|
||||||
|
size="sm"
|
||||||
|
:class="[segmentedGroupClass, 'min-w-0 flex-1 sm:w-[200px] sm:flex-none']"
|
||||||
|
:disabled="operationInProgress"
|
||||||
|
@update:model-value="changeDiskMode"
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="single" :class="segmentedItemClass">{{ t('msd.singleDiskMode') }}</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="multi" :class="segmentedItemClass">{{ t('msd.multiDiskMode') }}</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</span>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -627,86 +737,51 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div class="flex-1 min-h-0 flex flex-col px-6 pb-6 pt-4">
|
<div class="flex-1 min-h-0 flex flex-col px-6 pb-6 pt-4">
|
||||||
<Tabs v-model="activeTab" class="flex-1 flex flex-col min-h-0">
|
<Tabs v-model="activeTab" class="flex-1 flex flex-col min-h-0">
|
||||||
<TabsList class="w-full grid grid-cols-2 shrink-0">
|
<TabsList class="grid h-auto w-full shrink-0 grid-cols-2 gap-1 rounded-md border border-border bg-muted p-1">
|
||||||
<TabsTrigger value="images">
|
<TabsTrigger value="images" :class="tabTriggerClass">
|
||||||
<Disc class="h-4 w-4 mr-1.5" />
|
<Disc class="h-4 w-4 mr-1.5" />
|
||||||
{{ t('msd.images') }}
|
{{ t('msd.images') }}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="drive">
|
<TabsTrigger value="drive" :class="tabTriggerClass">
|
||||||
<HardDrive class="h-4 w-4 mr-1.5" />
|
<HardDrive class="h-4 w-4 mr-1.5" />
|
||||||
{{ t('msd.drive') }}
|
{{ t('msd.drive') }}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<!-- Tab Description -->
|
<TabsContent value="images" class="m-0 flex min-h-0 flex-1 flex-col space-y-3 pt-3">
|
||||||
<p class="text-xs text-muted-foreground mt-2 mb-1 shrink-0">
|
|
||||||
{{ activeTab === 'images' ? t('msd.imagesDesc') : t('msd.driveDesc') }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<TabsContent value="images" class="flex-1 min-h-0 m-0 flex flex-col space-y-3">
|
|
||||||
<!-- Compact Upload Toolbar -->
|
|
||||||
<div class="shrink-0 flex items-center gap-2 min-w-0">
|
|
||||||
<label class="flex-1">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
class="hidden"
|
|
||||||
accept=".iso,.img"
|
|
||||||
:disabled="uploading"
|
|
||||||
@change="handleImageUpload"
|
|
||||||
/>
|
|
||||||
<Button variant="outline" size="sm" as="span" class="w-full cursor-pointer">
|
|
||||||
<Upload class="h-4 w-4 mr-1.5" />
|
|
||||||
{{ t('msd.uploadImage') }}
|
|
||||||
</Button>
|
|
||||||
</label>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="flex-1"
|
|
||||||
@click="showUrlDialog = true"
|
|
||||||
>
|
|
||||||
<Globe class="h-4 w-4 mr-1.5" />
|
|
||||||
{{ t('msd.downloadFromUrl') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Progress v-if="uploading" :model-value="uploadProgress" class="h-1 shrink-0" />
|
|
||||||
|
|
||||||
<!-- Options - Vertical compact layout -->
|
|
||||||
<div class="shrink-0 flex flex-wrap items-center gap-x-4 gap-y-2 p-2 rounded-lg bg-muted/50 text-xs min-w-0">
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<span class="text-muted-foreground whitespace-nowrap">{{ t('msd.storageMode') }}:</span>
|
|
||||||
<HelpTooltip :content="mountMode === 'flash' ? t('help.flashMode') : t('help.cdromMode')" icon-size="sm" />
|
|
||||||
<ToggleGroup v-model="mountMode" type="single" variant="outline" size="sm">
|
|
||||||
<ToggleGroupItem value="flash" class="h-6 px-2 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
|
||||||
{{ t('msd.flash') }}
|
|
||||||
</ToggleGroupItem>
|
|
||||||
<ToggleGroupItem value="cdrom" class="h-6 px-2 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
|
||||||
{{ t('msd.cdrom') }}
|
|
||||||
</ToggleGroupItem>
|
|
||||||
</ToggleGroup>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<span class="text-muted-foreground whitespace-nowrap">{{ t('msd.accessMode') }}:</span>
|
|
||||||
<HelpTooltip :content="accessMode === 'readonly' ? t('help.readOnlyMode') : t('help.readWriteMode')" icon-size="sm" />
|
|
||||||
<ToggleGroup v-model="accessMode" type="single" variant="outline" size="sm">
|
|
||||||
<ToggleGroupItem value="readonly" class="h-6 px-2 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
|
||||||
{{ t('msd.readOnly') }}
|
|
||||||
</ToggleGroupItem>
|
|
||||||
<ToggleGroupItem value="readwrite" class="h-6 px-2 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
|
||||||
{{ t('msd.readWrite') }}
|
|
||||||
</ToggleGroupItem>
|
|
||||||
</ToggleGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Image List -->
|
<!-- Image List -->
|
||||||
<div class="flex-1 min-h-0 flex flex-col space-y-2 min-w-0">
|
<div class="flex-1 min-h-0 flex flex-col space-y-2 min-w-0">
|
||||||
<div class="shrink-0 flex items-center justify-between">
|
<div class="flex shrink-0 flex-wrap items-center justify-between gap-2">
|
||||||
<h4 class="text-sm font-medium">{{ t('msd.imageList') }}</h4>
|
<h4 class="text-sm font-medium">{{ t('msd.imageList') }}</h4>
|
||||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadImages">
|
<div class="flex flex-wrap items-center justify-end gap-1.5">
|
||||||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingImages }" />
|
<label>
|
||||||
</Button>
|
<input
|
||||||
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
accept=".iso,.img"
|
||||||
|
:disabled="uploading"
|
||||||
|
@change="handleImageUpload"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" as="span" class="h-8 cursor-pointer px-2.5 text-xs">
|
||||||
|
<Upload class="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{{ t('msd.uploadImage') }}
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 px-2.5 text-xs"
|
||||||
|
@click="showUrlDialog = true"
|
||||||
|
>
|
||||||
|
<Globe class="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{{ t('msd.downloadFromUrl') }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" class="h-8 w-8" @click="loadImages">
|
||||||
|
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingImages }" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Progress v-if="uploading" :model-value="uploadProgress" class="h-1 shrink-0" />
|
||||||
|
|
||||||
<div v-if="images.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
|
<div v-if="images.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
|
||||||
{{ t('msd.noImages') }}
|
{{ t('msd.noImages') }}
|
||||||
@@ -717,16 +792,17 @@ onUnmounted(() => {
|
|||||||
<div
|
<div
|
||||||
v-for="image in images"
|
v-for="image in images"
|
||||||
:key="image.id"
|
:key="image.id"
|
||||||
class="p-3 rounded-lg border transition-colors"
|
class="rounded-md border p-2.5 transition-colors"
|
||||||
:class="[
|
:class="[
|
||||||
msdConnected && systemStore.msd?.imageId === image.id
|
mountedImage(image.id)
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary/40 bg-muted/50'
|
||||||
: 'hover:bg-accent/50'
|
: 'border-border bg-background hover:bg-muted/40'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="flex items-start gap-2 w-0 flex-1">
|
<div class="flex min-w-0 flex-1 items-start gap-2">
|
||||||
<Disc class="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
|
<span v-if="mountedImage(image.id)" class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||||
|
<Disc v-else class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<div class="w-0 flex-1">
|
<div class="w-0 flex-1">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
@@ -755,22 +831,25 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 shrink-0">
|
<div class="flex shrink-0 items-center justify-end gap-1.5">
|
||||||
<template v-if="msdConnected && systemStore.msd?.imageId === image.id">
|
<template v-if="mountedImage(image.id)">
|
||||||
<Badge variant="default" class="text-xs h-7 px-2">
|
<Button
|
||||||
<span class="relative flex h-1.5 w-1.5 mr-1.5">
|
variant="outline"
|
||||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
|
size="sm"
|
||||||
<span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-white"></span>
|
class="h-8 text-xs"
|
||||||
</span>
|
:disabled="operationInProgress"
|
||||||
{{ t('common.connected') }}
|
@click="unmountImageById(image.id)"
|
||||||
</Badge>
|
>
|
||||||
|
<Unlink class="h-3.5 w-3.5 mr-1" />
|
||||||
|
{{ t('msd.disconnect') }}
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-7 text-xs"
|
class="h-8 text-xs"
|
||||||
:disabled="operationInProgress"
|
:disabled="operationInProgress || mediaSlotsFull"
|
||||||
@click="connectImage(image)"
|
@click="connectImage(image)"
|
||||||
>
|
>
|
||||||
<Link v-if="!connecting" class="h-3.5 w-3.5 mr-1" />
|
<Link v-if="!connecting" class="h-3.5 w-3.5 mr-1" />
|
||||||
@@ -781,8 +860,8 @@ onUnmounted(() => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7 text-destructive hover:text-destructive"
|
class="h-8 w-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||||
:disabled="operationInProgress || (msdConnected && systemStore.msd?.imageId === image.id)"
|
:disabled="operationInProgress || !!mountedImage(image.id)"
|
||||||
@click="confirmDelete('image', image.id, image.name)"
|
@click="confirmDelete('image', image.id, image.name)"
|
||||||
>
|
>
|
||||||
<Trash2 class="h-3.5 w-3.5" />
|
<Trash2 class="h-3.5 w-3.5" />
|
||||||
@@ -794,15 +873,15 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System Storage Footer -->
|
<!-- System Storage Footer -->
|
||||||
<div v-if="systemStore.diskSpace" class="shrink-0 pt-2 border-t mt-2">
|
<div v-if="systemStore.diskSpace" class="mt-2 shrink-0 border-t pt-2">
|
||||||
<p class="text-[11px] text-muted-foreground text-center">
|
<p class="text-right text-[11px] text-muted-foreground">
|
||||||
{{ t('msd.systemAvailable') }}: {{ formatBytes(systemStore.diskSpace.available) }}
|
{{ t('msd.systemAvailable') }}: {{ formatBytes(systemStore.diskSpace.available) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="drive" class="flex-1 min-h-0 m-0 flex flex-col space-y-4">
|
<TabsContent value="drive" class="m-0 flex min-h-0 flex-1 flex-col space-y-4 pt-3">
|
||||||
<template v-if="!driveInitialized">
|
<template v-if="!driveInitialized">
|
||||||
<div class="shrink-0 text-center py-8 space-y-4">
|
<div class="shrink-0 text-center py-8 space-y-4">
|
||||||
<HardDrive class="h-10 w-10 mx-auto text-muted-foreground" />
|
<HardDrive class="h-10 w-10 mx-auto text-muted-foreground" />
|
||||||
@@ -816,8 +895,8 @@ onUnmounted(() => {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Drive Info Card -->
|
<!-- Drive Info Card -->
|
||||||
<div
|
<div
|
||||||
class="shrink-0 p-3 rounded-lg border space-y-3"
|
class="shrink-0 space-y-3 rounded-md border p-3"
|
||||||
:class="msdConnected && msdMode === 'drive'
|
:class="driveConnectedToTarget
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: driveError
|
: driveError
|
||||||
? 'border-destructive/40 bg-destructive/5'
|
? 'border-destructive/40 bg-destructive/5'
|
||||||
@@ -854,28 +933,39 @@ onUnmounted(() => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-7 text-xs"
|
class="h-8 text-xs"
|
||||||
:disabled="operationInProgress"
|
:disabled="operationInProgress"
|
||||||
@click="initializeDrive"
|
@click="initializeDrive"
|
||||||
>
|
>
|
||||||
{{ t('msd.reinitializeDrive') }}
|
{{ t('msd.reinitializeDrive') }}
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="msdConnected && msdMode === 'drive'">
|
<template v-else-if="driveConnectedToTarget">
|
||||||
<Badge variant="default" class="text-xs h-7 px-2">
|
<Badge variant="default" class="h-8 px-2 text-xs">
|
||||||
<span class="relative flex h-1.5 w-1.5 mr-1.5">
|
<span class="relative flex h-1.5 w-1.5 mr-1.5">
|
||||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
|
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary-foreground opacity-75"></span>
|
||||||
<span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-white"></span>
|
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-primary-foreground"></span>
|
||||||
</span>
|
</span>
|
||||||
{{ t('common.connected') }}
|
{{ t('common.connected') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Button
|
||||||
|
v-if="driveMedia"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 text-xs"
|
||||||
|
:disabled="operationInProgress"
|
||||||
|
@click="unmountMedia(driveMedia)"
|
||||||
|
>
|
||||||
|
<Unlink class="h-3.5 w-3.5 mr-1" />
|
||||||
|
{{ t('msd.disconnect') }}
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-7 text-xs"
|
class="h-8 text-xs"
|
||||||
:disabled="operationInProgress"
|
:disabled="operationInProgress || mediaSlotsFull || !!driveError"
|
||||||
@click="connectDrive"
|
@click="connectDrive"
|
||||||
>
|
>
|
||||||
<Link v-if="!connecting" class="h-3.5 w-3.5 mr-1" />
|
<Link v-if="!connecting" class="h-3.5 w-3.5 mr-1" />
|
||||||
@@ -886,8 +976,8 @@ onUnmounted(() => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7 text-destructive hover:text-destructive"
|
class="h-8 w-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||||
:disabled="operationInProgress || msdConnected"
|
:disabled="operationInProgress || driveConnectedToTarget"
|
||||||
@click="showDeleteDriveDialog = true"
|
@click="showDeleteDriveDialog = true"
|
||||||
>
|
>
|
||||||
<Trash2 class="h-3.5 w-3.5" />
|
<Trash2 class="h-3.5 w-3.5" />
|
||||||
@@ -918,7 +1008,7 @@ onUnmounted(() => {
|
|||||||
v-if="currentPath !== '/'"
|
v-if="currentPath !== '/'"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7 shrink-0"
|
class="h-8 w-8 shrink-0"
|
||||||
:disabled="driveConnectedToTarget"
|
:disabled="driveConnectedToTarget"
|
||||||
@click="navigateUp"
|
@click="navigateUp"
|
||||||
>
|
>
|
||||||
@@ -951,7 +1041,7 @@ onUnmounted(() => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
as="span"
|
as="span"
|
||||||
class="h-7 w-7"
|
class="h-8 w-8"
|
||||||
:class="driveConnectedToTarget ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'"
|
:class="driveConnectedToTarget ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'"
|
||||||
>
|
>
|
||||||
<Upload class="h-3.5 w-3.5" />
|
<Upload class="h-3.5 w-3.5" />
|
||||||
@@ -968,7 +1058,7 @@ onUnmounted(() => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7"
|
class="h-8 w-8"
|
||||||
:disabled="driveConnectedToTarget"
|
:disabled="driveConnectedToTarget"
|
||||||
@click="showNewFolderDialog = true"
|
@click="showNewFolderDialog = true"
|
||||||
>
|
>
|
||||||
@@ -982,7 +1072,7 @@ onUnmounted(() => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7"
|
class="h-8 w-8"
|
||||||
:disabled="driveConnectedToTarget"
|
:disabled="driveConnectedToTarget"
|
||||||
@click="loadDriveFiles"
|
@click="loadDriveFiles"
|
||||||
>
|
>
|
||||||
@@ -1014,7 +1104,7 @@ onUnmounted(() => {
|
|||||||
<div
|
<div
|
||||||
v-for="file in driveFiles"
|
v-for="file in driveFiles"
|
||||||
:key="file.path"
|
:key="file.path"
|
||||||
class="flex items-center justify-between p-2 rounded-lg hover:bg-accent/50 transition-colors"
|
class="flex items-center justify-between rounded-md p-2 transition-colors hover:bg-accent/50"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 cursor-pointer flex-1 min-w-0"
|
class="flex items-center gap-2 cursor-pointer flex-1 min-w-0"
|
||||||
@@ -1041,7 +1131,7 @@ onUnmounted(() => {
|
|||||||
v-if="!file.is_dir"
|
v-if="!file.is_dir"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7"
|
class="h-8 w-8"
|
||||||
as="a"
|
as="a"
|
||||||
:href="msdApi.downloadDriveFile(file.path)"
|
:href="msdApi.downloadDriveFile(file.path)"
|
||||||
download
|
download
|
||||||
@@ -1052,7 +1142,7 @@ onUnmounted(() => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7 text-destructive"
|
class="h-8 w-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||||
:disabled="driveConnectedToTarget"
|
:disabled="driveConnectedToTarget"
|
||||||
@click="confirmDelete('file', file.path, file.name)"
|
@click="confirmDelete('file', file.path, file.name)"
|
||||||
>
|
>
|
||||||
@@ -1177,6 +1267,65 @@ onUnmounted(() => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Image Mount Options Dialog -->
|
||||||
|
<Dialog v-model:open="showMountOptionsDialog">
|
||||||
|
<DialogContent class="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ t('msd.mountImage') }}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{{ pendingMountImage?.name }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ t('msd.storageMode') }}</Label>
|
||||||
|
<ToggleGroup
|
||||||
|
:model-value="mountMode"
|
||||||
|
type="single"
|
||||||
|
size="sm"
|
||||||
|
:class="segmentedGroupClass"
|
||||||
|
@update:model-value="updateMountMode"
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="flash" :class="segmentedItemClass">{{ t('msd.flash') }}</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="cdrom" :class="segmentedItemClass">{{ t('msd.cdrom') }}</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ t('msd.accessMode') }}</Label>
|
||||||
|
<ToggleGroup
|
||||||
|
:model-value="accessMode"
|
||||||
|
type="single"
|
||||||
|
size="sm"
|
||||||
|
:class="segmentedGroupClass"
|
||||||
|
@update:model-value="updateAccessMode"
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="readonly" :class="segmentedItemClass">{{ t('msd.readOnly') }}</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem
|
||||||
|
value="readwrite"
|
||||||
|
:class="segmentedItemClass"
|
||||||
|
:disabled="cdromMode"
|
||||||
|
>
|
||||||
|
{{ t('msd.readWrite') }}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="showMountOptionsDialog = false" :disabled="connecting">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</Button>
|
||||||
|
<Button @click="confirmImageMount" :disabled="connecting || mediaSlotsFull">
|
||||||
|
<Link v-if="!connecting" class="h-4 w-4 mr-1" />
|
||||||
|
<span v-if="connecting">{{ t('common.connecting') }}...</span>
|
||||||
|
<span v-else>{{ t('msd.connect') }}</span>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<!-- New Folder Dialog -->
|
<!-- New Folder Dialog -->
|
||||||
<Dialog v-model:open="showNewFolderDialog">
|
<Dialog v-model:open="showNewFolderDialog">
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -1223,7 +1372,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Download Progress -->
|
<!-- Download Progress -->
|
||||||
<div v-if="downloadProgress" class="space-y-2 p-3 rounded-lg bg-muted/50">
|
<div v-if="downloadProgress" class="space-y-2 rounded-md bg-muted/50 p-3">
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<span class="truncate">{{ downloadProgress.filename }}</span>
|
<span class="truncate">{{ downloadProgress.filename }}</span>
|
||||||
<span class="text-muted-foreground shrink-0 ml-2">
|
<span class="text-muted-foreground shrink-0 ml-2">
|
||||||
|
|||||||
@@ -1,610 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed, watch } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useSystemStore } from '@/stores/system'
|
|
||||||
import { msdApi, type MsdImage, type DriveFile } from '@/api'
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetDescription,
|
|
||||||
} from '@/components/ui/sheet'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { Progress } from '@/components/ui/progress'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import {
|
|
||||||
HardDrive,
|
|
||||||
Upload,
|
|
||||||
Trash2,
|
|
||||||
Link,
|
|
||||||
Unlink,
|
|
||||||
Disc,
|
|
||||||
File,
|
|
||||||
Folder,
|
|
||||||
FolderPlus,
|
|
||||||
Download,
|
|
||||||
RefreshCw,
|
|
||||||
ChevronRight,
|
|
||||||
ArrowLeft,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
open: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:open', value: boolean): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const systemStore = useSystemStore()
|
|
||||||
|
|
||||||
const activeTab = ref('images')
|
|
||||||
|
|
||||||
const images = ref<MsdImage[]>([])
|
|
||||||
const loadingImages = ref(false)
|
|
||||||
const uploadProgress = ref(0)
|
|
||||||
const uploading = ref(false)
|
|
||||||
const cdromMode = ref(true)
|
|
||||||
const readOnly = ref(true)
|
|
||||||
|
|
||||||
const driveFiles = ref<DriveFile[]>([])
|
|
||||||
const currentPath = ref('/')
|
|
||||||
const loadingDrive = ref(false)
|
|
||||||
const driveInfo = ref<{ size: number; used: number; free: number; initialized: boolean } | null>(null)
|
|
||||||
const driveInitialized = ref(false)
|
|
||||||
const uploadingFile = ref(false)
|
|
||||||
const fileUploadProgress = ref(0)
|
|
||||||
|
|
||||||
const showDeleteDialog = ref(false)
|
|
||||||
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null)
|
|
||||||
const showNewFolderDialog = ref(false)
|
|
||||||
const newFolderName = ref('')
|
|
||||||
|
|
||||||
const msdConnected = computed(() => systemStore.msd?.connected ?? false)
|
|
||||||
const msdMode = computed(() => systemStore.msd?.mode ?? 'none')
|
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
|
||||||
const parts = currentPath.value.split('/').filter(Boolean)
|
|
||||||
const crumbs = [{ name: '/', path: '/' }]
|
|
||||||
let path = ''
|
|
||||||
for (const part of parts) {
|
|
||||||
path += '/' + part
|
|
||||||
crumbs.push({ name: part, path })
|
|
||||||
}
|
|
||||||
return crumbs
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.open, async (isOpen) => {
|
|
||||||
if (isOpen) {
|
|
||||||
await loadData()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
await systemStore.fetchMsdState()
|
|
||||||
await loadImages()
|
|
||||||
await loadDriveInfo()
|
|
||||||
if (driveInitialized.value) {
|
|
||||||
await loadDriveFiles()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadImages() {
|
|
||||||
loadingImages.value = true
|
|
||||||
try {
|
|
||||||
images.value = await msdApi.listImages()
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load images:', e)
|
|
||||||
} finally {
|
|
||||||
loadingImages.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleImageUpload(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
uploading.value = true
|
|
||||||
uploadProgress.value = 0
|
|
||||||
|
|
||||||
try {
|
|
||||||
const image = await msdApi.uploadImage(file, (progress) => {
|
|
||||||
uploadProgress.value = progress
|
|
||||||
})
|
|
||||||
images.value.push(image)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to upload image:', e)
|
|
||||||
} finally {
|
|
||||||
uploading.value = false
|
|
||||||
uploadProgress.value = 0
|
|
||||||
input.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectImage(image: MsdImage) {
|
|
||||||
try {
|
|
||||||
await msdApi.connect('image', image.id, cdromMode.value, readOnly.value)
|
|
||||||
await systemStore.fetchMsdState()
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to connect image:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function disconnect() {
|
|
||||||
try {
|
|
||||||
await msdApi.disconnect()
|
|
||||||
await systemStore.fetchMsdState()
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to disconnect:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDelete(type: 'image' | 'file', id: string, name: string) {
|
|
||||||
deleteTarget.value = { type, id, name }
|
|
||||||
showDeleteDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeDelete() {
|
|
||||||
if (!deleteTarget.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (deleteTarget.value.type === 'image') {
|
|
||||||
await msdApi.deleteImage(deleteTarget.value.id)
|
|
||||||
images.value = images.value.filter(i => i.id !== deleteTarget.value!.id)
|
|
||||||
} else {
|
|
||||||
await msdApi.deleteDriveFile(deleteTarget.value.id)
|
|
||||||
await loadDriveFiles()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to delete:', e)
|
|
||||||
} finally {
|
|
||||||
showDeleteDialog.value = false
|
|
||||||
deleteTarget.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDriveInfo() {
|
|
||||||
try {
|
|
||||||
driveInfo.value = await msdApi.driveInfo()
|
|
||||||
driveInitialized.value = true
|
|
||||||
} catch {
|
|
||||||
driveInitialized.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializeDrive() {
|
|
||||||
try {
|
|
||||||
await msdApi.initDrive(256)
|
|
||||||
await loadDriveInfo()
|
|
||||||
await loadDriveFiles()
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to initialize drive:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDriveFiles() {
|
|
||||||
loadingDrive.value = true
|
|
||||||
try {
|
|
||||||
driveFiles.value = await msdApi.listDriveFiles(currentPath.value)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load drive files:', e)
|
|
||||||
} finally {
|
|
||||||
loadingDrive.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateTo(path: string) {
|
|
||||||
currentPath.value = path
|
|
||||||
loadDriveFiles()
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateUp() {
|
|
||||||
const parts = currentPath.value.split('/').filter(Boolean)
|
|
||||||
parts.pop()
|
|
||||||
currentPath.value = '/' + parts.join('/')
|
|
||||||
loadDriveFiles()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFileUpload(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
uploadingFile.value = true
|
|
||||||
fileUploadProgress.value = 0
|
|
||||||
|
|
||||||
try {
|
|
||||||
await msdApi.uploadDriveFile(file, currentPath.value, (progress) => {
|
|
||||||
fileUploadProgress.value = progress
|
|
||||||
})
|
|
||||||
await loadDriveFiles()
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to upload file:', e)
|
|
||||||
} finally {
|
|
||||||
uploadingFile.value = false
|
|
||||||
fileUploadProgress.value = 0
|
|
||||||
input.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createFolder() {
|
|
||||||
if (!newFolderName.value.trim()) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const path = currentPath.value === '/'
|
|
||||||
? '/' + newFolderName.value
|
|
||||||
: currentPath.value + '/' + newFolderName.value
|
|
||||||
await msdApi.createDirectory(path)
|
|
||||||
await loadDriveFiles()
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to create folder:', e)
|
|
||||||
} finally {
|
|
||||||
showNewFolderDialog.value = false
|
|
||||||
newFolderName.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectDrive() {
|
|
||||||
try {
|
|
||||||
await msdApi.connect('drive')
|
|
||||||
await systemStore.fetchMsdState()
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to connect drive:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes < 1024) return bytes + ' B'
|
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
|
||||||
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
|
||||||
return (bytes / 1024 / 1024 / 1024).toFixed(1) + ' GB'
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (props.open) {
|
|
||||||
await loadData()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Sheet :open="open" @update:open="emit('update:open', $event)">
|
|
||||||
<SheetContent side="right" class="w-full sm:max-w-lg overflow-hidden flex flex-col h-[dvh]">
|
|
||||||
<SheetHeader class="shrink-0">
|
|
||||||
<div class="flex items-center justify-between pr-8">
|
|
||||||
<div>
|
|
||||||
<SheetTitle class="flex items-center gap-2">
|
|
||||||
<HardDrive class="h-5 w-5" />
|
|
||||||
{{ t('msd.title') }}
|
|
||||||
</SheetTitle>
|
|
||||||
<SheetDescription class="flex items-center gap-2 mt-1">
|
|
||||||
{{ msdConnected ? t('common.connected') : t('common.disconnected') }}
|
|
||||||
<Badge v-if="msdConnected" variant="secondary" class="text-xs">{{ msdMode }}</Badge>
|
|
||||||
</SheetDescription>
|
|
||||||
</div>
|
|
||||||
<Button v-if="msdConnected" variant="destructive" size="sm" @click="disconnect">
|
|
||||||
<Unlink class="h-4 w-4 mr-1" />
|
|
||||||
{{ t('msd.disconnect') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<Separator class="my-4 shrink-0" />
|
|
||||||
|
|
||||||
<Tabs v-model="activeTab" class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
|
||||||
<TabsList class="w-full grid grid-cols-2 shrink-0">
|
|
||||||
<TabsTrigger value="images">
|
|
||||||
<Disc class="h-4 w-4 mr-1.5" />
|
|
||||||
{{ t('msd.images') }}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="drive">
|
|
||||||
<HardDrive class="h-4 w-4 mr-1.5" />
|
|
||||||
{{ t('msd.drive') }}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<div class="flex-1 min-h-0 mt-4 flex flex-col">
|
|
||||||
<!-- Images Tab -->
|
|
||||||
<TabsContent value="images" class="flex-1 min-h-0 m-0 flex flex-col space-y-4">
|
|
||||||
<!-- Upload Area -->
|
|
||||||
<div class="shrink-0 space-y-3">
|
|
||||||
<label class="block">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".iso,.img"
|
|
||||||
class="hidden"
|
|
||||||
:disabled="uploading"
|
|
||||||
@change="handleImageUpload"
|
|
||||||
/>
|
|
||||||
<div class="flex items-center justify-center h-16 border-2 border-dashed rounded-lg cursor-pointer hover:border-primary transition-colors">
|
|
||||||
<div class="text-center">
|
|
||||||
<Upload class="h-5 w-5 mx-auto text-muted-foreground" />
|
|
||||||
<p class="text-xs text-muted-foreground mt-1">{{ t('msd.uploadImage') }} (ISO/IMG)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<Progress v-if="uploading" :model-value="uploadProgress" class="h-1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Options -->
|
|
||||||
<div class="shrink-0 flex items-center gap-4 p-3 rounded-lg bg-muted/50">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Switch id="cdrom" v-model:checked="cdromMode" />
|
|
||||||
<Label for="cdrom" class="text-xs">{{ t('msd.cdromMode') }}</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Switch id="readonly" v-model:checked="readOnly" />
|
|
||||||
<Label for="readonly" class="text-xs">{{ t('msd.readOnly') }}</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Image List -->
|
|
||||||
<div class="flex-1 min-h-0 flex flex-col space-y-2">
|
|
||||||
<div class="shrink-0 flex items-center justify-between">
|
|
||||||
<h4 class="text-sm font-medium">{{ t('msd.imageList') }}</h4>
|
|
||||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadImages">
|
|
||||||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingImages }" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="images.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
|
|
||||||
{{ t('msd.noImages') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea v-else class="flex-1 min-h-0 pr-4">
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<div
|
|
||||||
v-for="image in images"
|
|
||||||
:key="image.id"
|
|
||||||
class="flex items-center justify-between p-2.5 rounded-lg border hover:bg-accent/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
|
||||||
<Disc class="h-4 w-4 text-muted-foreground shrink-0" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="text-sm font-medium truncate">{{ image.name }}</p>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
{{ formatBytes(image.size) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
|
||||||
<Button
|
|
||||||
v-if="!msdConnected || systemStore.msd?.imageId !== image.id"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="h-7 text-xs"
|
|
||||||
@click="connectImage(image)"
|
|
||||||
>
|
|
||||||
<Link class="h-3.5 w-3.5 mr-1" />
|
|
||||||
{{ t('msd.connect') }}
|
|
||||||
</Button>
|
|
||||||
<Badge v-else variant="default" class="text-xs">{{ t('common.connected') }}</Badge>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-7 w-7 text-destructive"
|
|
||||||
@click="confirmDelete('image', image.id, image.name)"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<!-- Drive Tab -->
|
|
||||||
<TabsContent value="drive" class="flex-1 min-h-0 m-0 flex flex-col space-y-4">
|
|
||||||
<template v-if="!driveInitialized">
|
|
||||||
<div class="shrink-0 text-center py-8 space-y-4">
|
|
||||||
<HardDrive class="h-10 w-10 mx-auto text-muted-foreground" />
|
|
||||||
<p class="text-sm text-muted-foreground">{{ t('msd.driveNotInitialized') }}</p>
|
|
||||||
<Button size="sm" @click="initializeDrive">
|
|
||||||
{{ t('msd.initializeDrive') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<!-- Drive Info -->
|
|
||||||
<div class="shrink-0 p-3 rounded-lg bg-muted/50 space-y-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="space-y-0.5">
|
|
||||||
<p class="text-xs text-muted-foreground">{{ t('msd.driveSize') }}: {{ (driveInfo?.size || 0) / 1024 / 1024 }}MB</p>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
{{ formatBytes(driveInfo?.used || 0) }} / {{ formatBytes(driveInfo?.size || 0) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
v-if="!msdConnected || msdMode !== 'drive'"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="h-7 text-xs"
|
|
||||||
@click="connectDrive"
|
|
||||||
>
|
|
||||||
<Link class="h-3.5 w-3.5 mr-1" />
|
|
||||||
{{ t('msd.connect') }}
|
|
||||||
</Button>
|
|
||||||
<Badge v-else class="text-xs">{{ t('common.connected') }}</Badge>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
v-if="driveInfo"
|
|
||||||
:model-value="driveInfo.size > 0 ? (driveInfo.used / driveInfo.size) * 100 : 0"
|
|
||||||
class="h-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- File Browser -->
|
|
||||||
<div class="flex-1 min-h-0 flex flex-col space-y-2">
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<div class="shrink-0 flex items-center justify-between gap-2">
|
|
||||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
|
||||||
<Button
|
|
||||||
v-if="currentPath !== '/'"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-7 w-7 shrink-0"
|
|
||||||
@click="navigateUp"
|
|
||||||
>
|
|
||||||
<ArrowLeft class="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<nav class="flex items-center text-xs min-w-0 overflow-hidden">
|
|
||||||
<template v-for="(crumb, index) in breadcrumbs" :key="crumb.path">
|
|
||||||
<ChevronRight v-if="index > 0" class="h-3 w-3 text-muted-foreground mx-0.5 shrink-0" />
|
|
||||||
<button
|
|
||||||
class="hover:text-primary transition-colors truncate"
|
|
||||||
:class="index === breadcrumbs.length - 1 ? 'font-medium' : 'text-muted-foreground'"
|
|
||||||
@click="navigateTo(crumb.path)"
|
|
||||||
>
|
|
||||||
{{ crumb.name }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="shrink-0 flex items-center gap-1 shrink-0">
|
|
||||||
<label>
|
|
||||||
<input type="file" class="hidden" :disabled="uploadingFile" @change="handleFileUpload" />
|
|
||||||
<Button variant="ghost" size="icon" as="span" class="h-7 w-7 cursor-pointer">
|
|
||||||
<Upload class="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</label>
|
|
||||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showNewFolderDialog = true">
|
|
||||||
<FolderPlus class="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadDriveFiles">
|
|
||||||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingDrive }" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1 shrink-0" />
|
|
||||||
|
|
||||||
<!-- File List -->
|
|
||||||
<div v-if="driveFiles.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
|
|
||||||
{{ t('msd.emptyFolder') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea v-else class="flex-1 min-h-0 pr-4">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div
|
|
||||||
v-for="file in driveFiles"
|
|
||||||
:key="file.path"
|
|
||||||
class="flex items-center justify-between p-2 rounded-lg hover:bg-accent/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 cursor-pointer flex-1 min-w-0"
|
|
||||||
@click="file.is_dir && navigateTo(file.path)"
|
|
||||||
>
|
|
||||||
<Folder v-if="file.is_dir" class="h-4 w-4 text-blue-500 shrink-0" />
|
|
||||||
<File v-else class="h-4 w-4 text-muted-foreground shrink-0" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="text-sm font-medium truncate">{{ file.name }}</p>
|
|
||||||
<p v-if="!file.is_dir" class="text-xs text-muted-foreground">
|
|
||||||
{{ formatBytes(file.size) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-0.5 shrink-0">
|
|
||||||
<Button
|
|
||||||
v-if="!file.is_dir"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-7 w-7"
|
|
||||||
as="a"
|
|
||||||
:href="msdApi.downloadDriveFile(file.path)"
|
|
||||||
download
|
|
||||||
>
|
|
||||||
<Download class="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-7 w-7 text-destructive"
|
|
||||||
@click="confirmDelete('file', file.path, file.name)"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</TabsContent>
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
<!-- Delete Dialog -->
|
|
||||||
<Dialog v-model:open="showDeleteDialog">
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{{ t('common.confirm') }}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{{ t('msd.confirmDelete', { name: deleteTarget?.name }) }}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" @click="showDeleteDialog = false">{{ t('common.cancel') }}</Button>
|
|
||||||
<Button variant="destructive" @click="executeDelete">{{ t('common.delete') }}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<!-- New Folder Dialog -->
|
|
||||||
<Dialog v-model:open="showNewFolderDialog">
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{{ t('msd.createFolder') }}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<Input v-model="newFolderName" :placeholder="t('msd.folderName')" @keyup.enter="createFolder" />
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" @click="showNewFolderDialog = false">{{ t('common.cancel') }}</Button>
|
|
||||||
<Button @click="createFolder">{{ t('common.confirm') }}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
||||||
background: hsl(var(--muted-foreground) / 0.3);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: hsl(var(--muted-foreground) / 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* For Firefox */
|
|
||||||
.custom-scrollbar {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -427,15 +427,12 @@ export default {
|
|||||||
msd: {
|
msd: {
|
||||||
title: 'Virtual Media',
|
title: 'Virtual Media',
|
||||||
images: 'ISO/IMG Mount',
|
images: 'ISO/IMG Mount',
|
||||||
imagesDesc: 'Mount ISO/IMG images to target device',
|
|
||||||
drive: 'Virtual USB',
|
drive: 'Virtual USB',
|
||||||
driveDesc: 'Transfer files to target device like a USB drive, supports Ventoy boot',
|
|
||||||
imageList: 'Image List',
|
imageList: 'Image List',
|
||||||
uploadImage: 'Upload Image',
|
uploadImage: 'Upload Image',
|
||||||
noImages: 'No images',
|
noImages: 'No images',
|
||||||
connect: 'Connect',
|
connect: 'Connect',
|
||||||
disconnect: 'Disconnect',
|
disconnect: 'Disconnect',
|
||||||
connectedTo: 'Connected to',
|
|
||||||
cdrom: 'CD-ROM',
|
cdrom: 'CD-ROM',
|
||||||
flash: 'Flash',
|
flash: 'Flash',
|
||||||
storageMode: 'Storage Mode',
|
storageMode: 'Storage Mode',
|
||||||
@@ -452,14 +449,10 @@ export default {
|
|||||||
freeSpace: 'Free',
|
freeSpace: 'Free',
|
||||||
deleteDrive: 'Delete Drive',
|
deleteDrive: 'Delete Drive',
|
||||||
confirmDeleteDrive: 'Are you sure you want to delete the virtual drive? All files will be erased.',
|
confirmDeleteDrive: 'Are you sure you want to delete the virtual drive? All files will be erased.',
|
||||||
driveDeleted: 'Drive deleted',
|
|
||||||
systemAvailable: 'System Available',
|
systemAvailable: 'System Available',
|
||||||
emptyFolder: 'Empty folder',
|
emptyFolder: 'Empty folder',
|
||||||
confirmDelete: 'Are you sure you want to delete "{name}"?',
|
confirmDelete: 'Are you sure you want to delete "{name}"?',
|
||||||
folderName: 'Folder name',
|
folderName: 'Folder name',
|
||||||
uploadImageHint: 'Click to upload ISO/IMG',
|
|
||||||
imageMounted: 'Image {name} mounted',
|
|
||||||
imageUnmounted: 'Image unmounted',
|
|
||||||
downloadFromUrl: 'Download from URL',
|
downloadFromUrl: 'Download from URL',
|
||||||
downloadFromUrlDesc: 'Enter the URL of an image file (ISO/IMG supported)',
|
downloadFromUrlDesc: 'Enter the URL of an image file (ISO/IMG supported)',
|
||||||
url: 'URL',
|
url: 'URL',
|
||||||
@@ -467,31 +460,28 @@ export default {
|
|||||||
filenameAutoDetect: 'Auto-detect from URL',
|
filenameAutoDetect: 'Auto-detect from URL',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
downloadComplete: 'Download complete',
|
downloadComplete: 'Download complete',
|
||||||
downloadFailed: 'Download failed',
|
|
||||||
largeFileWarning: '>2.2GB',
|
largeFileWarning: '>2.2GB',
|
||||||
largeFileTooltip: 'File is larger than 2.2GB, please use Flash mode to mount',
|
largeFileTooltip: 'File is larger than 2.2GB, please use Flash mode to mount',
|
||||||
error: 'MSD Error',
|
|
||||||
errorDesc: '{reason}',
|
|
||||||
recovered: 'MSD Recovered',
|
|
||||||
recoveredDesc: 'MSD operation completed successfully',
|
|
||||||
operationInProgress: 'Operation in progress, please wait',
|
operationInProgress: 'Operation in progress, please wait',
|
||||||
driveConnected: 'Virtual USB drive connected',
|
|
||||||
imageConnected: 'Image {name} connected',
|
|
||||||
selectDriveSize: 'Select virtual drive size',
|
selectDriveSize: 'Select virtual drive size',
|
||||||
customSize: 'Custom size',
|
|
||||||
driveSpaceUnknown: 'Unable to read available space for the MSD directory filesystem',
|
driveSpaceUnknown: 'Unable to read available space for the MSD directory filesystem',
|
||||||
driveSpaceUnavailable: 'Not enough space on the MSD directory filesystem to create a virtual drive',
|
driveSpaceUnavailable: 'Not enough space on the MSD directory filesystem to create a virtual drive',
|
||||||
driveSpaceTooSmall: 'Available space is less than {min} MB; cannot create a virtual drive',
|
driveSpaceTooSmall: 'Available space is less than {min} MB; cannot create a virtual drive',
|
||||||
driveCreateFailed: 'Failed to create virtual drive. Check available space for the MSD directory',
|
driveCreateFailed: 'Failed to create virtual drive. Check available space for the MSD directory',
|
||||||
driveCreated: 'Virtual drive created ({size} MB)',
|
|
||||||
fileDeleted: 'File deleted',
|
|
||||||
imageDeleted: 'Image deleted',
|
|
||||||
driveConnectedBlocked: 'Disconnect the drive before editing files',
|
driveConnectedBlocked: 'Disconnect the drive before editing files',
|
||||||
driveConnectedFilesHidden: 'Drive is connected to target machine — file list unavailable',
|
driveConnectedFilesHidden: 'Drive is connected to target machine — file list unavailable',
|
||||||
uploadFailed: 'File upload failed',
|
uploadFailed: 'File upload failed',
|
||||||
driveUnreadable: 'Format unsupported',
|
driveUnreadable: 'Format unsupported',
|
||||||
driveUnreadableTooltip: 'Unable to parse the exFAT filesystem. It may have been formatted with an unsupported format.',
|
driveUnreadableTooltip: 'Unable to parse the exFAT filesystem. It may have been formatted with an unsupported format.',
|
||||||
reinitializeDrive: 'Re-initialize',
|
reinitializeDrive: 'Re-initialize',
|
||||||
|
diskMode: 'Drive Mode',
|
||||||
|
singleDiskMode: 'Single',
|
||||||
|
multiDiskMode: 'Multi',
|
||||||
|
diskModeHint: 'Single drive provides 1 media slot; multi drive provides 8. Switching modes clears mounted media and re-enumerates USB.',
|
||||||
|
mountImage: 'Mount Image',
|
||||||
|
mediaCount: 'Media {count}/{capacity}',
|
||||||
|
mediaSlotsFull: 'Media slots are full; no more media can be mounted',
|
||||||
|
reenumerating: 'USB is re-enumerating',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
@@ -949,6 +939,7 @@ export default {
|
|||||||
msdStandby: 'Idle',
|
msdStandby: 'Idle',
|
||||||
msdImageMode: 'Image Mode',
|
msdImageMode: 'Image Mode',
|
||||||
msdDriveMode: 'Virtual USB',
|
msdDriveMode: 'Virtual USB',
|
||||||
|
msdMultiMode: 'Virtual Media: {count} mounted',
|
||||||
msdMountType: 'Mount Type',
|
msdMountType: 'Mount Type',
|
||||||
msdCurrentImage: 'Current Image',
|
msdCurrentImage: 'Current Image',
|
||||||
msdNoImage: 'None',
|
msdNoImage: 'None',
|
||||||
|
|||||||
@@ -426,15 +426,12 @@ export default {
|
|||||||
msd: {
|
msd: {
|
||||||
title: '虚拟媒体',
|
title: '虚拟媒体',
|
||||||
images: 'ISO/IMG 挂载',
|
images: 'ISO/IMG 挂载',
|
||||||
imagesDesc: '将 ISO/IMG 镜像挂载到目标设备',
|
|
||||||
drive: '虚拟U盘',
|
drive: '虚拟U盘',
|
||||||
driveDesc: '像U盘一样向目标设备传输文件,支持 Ventoy 引导',
|
|
||||||
imageList: '镜像列表',
|
imageList: '镜像列表',
|
||||||
uploadImage: '上传镜像',
|
uploadImage: '上传镜像',
|
||||||
noImages: '暂无镜像',
|
noImages: '暂无镜像',
|
||||||
connect: '连接',
|
connect: '连接',
|
||||||
disconnect: '断开',
|
disconnect: '断开',
|
||||||
connectedTo: '已连接至',
|
|
||||||
cdrom: 'CD-ROM',
|
cdrom: 'CD-ROM',
|
||||||
flash: 'Flash',
|
flash: 'Flash',
|
||||||
storageMode: '存储模式',
|
storageMode: '存储模式',
|
||||||
@@ -451,14 +448,10 @@ export default {
|
|||||||
freeSpace: '剩余',
|
freeSpace: '剩余',
|
||||||
deleteDrive: '删除驱动器',
|
deleteDrive: '删除驱动器',
|
||||||
confirmDeleteDrive: '确定要删除虚拟驱动器吗?所有文件将被清除。',
|
confirmDeleteDrive: '确定要删除虚拟驱动器吗?所有文件将被清除。',
|
||||||
driveDeleted: '驱动器已删除',
|
|
||||||
systemAvailable: '系统可用',
|
systemAvailable: '系统可用',
|
||||||
emptyFolder: '空文件夹',
|
emptyFolder: '空文件夹',
|
||||||
confirmDelete: '确定要删除 "{name}" 吗?',
|
confirmDelete: '确定要删除 "{name}" 吗?',
|
||||||
folderName: '文件夹名称',
|
folderName: '文件夹名称',
|
||||||
uploadImageHint: '点击上传 ISO/IMG 镜像',
|
|
||||||
imageMounted: '镜像 {name} 已挂载',
|
|
||||||
imageUnmounted: '镜像已卸载',
|
|
||||||
downloadFromUrl: '从 URL 下载',
|
downloadFromUrl: '从 URL 下载',
|
||||||
downloadFromUrlDesc: '输入镜像文件的 URL 地址,支持 ISO/IMG 格式',
|
downloadFromUrlDesc: '输入镜像文件的 URL 地址,支持 ISO/IMG 格式',
|
||||||
url: 'URL 地址',
|
url: 'URL 地址',
|
||||||
@@ -466,31 +459,28 @@ export default {
|
|||||||
filenameAutoDetect: '自动从 URL 获取',
|
filenameAutoDetect: '自动从 URL 获取',
|
||||||
download: '下载',
|
download: '下载',
|
||||||
downloadComplete: '下载完成',
|
downloadComplete: '下载完成',
|
||||||
downloadFailed: '下载失败',
|
|
||||||
largeFileWarning: '>2.2GB',
|
largeFileWarning: '>2.2GB',
|
||||||
largeFileTooltip: '文件大于 2.2GB,请使用 Flash 模式挂载',
|
largeFileTooltip: '文件大于 2.2GB,请使用 Flash 模式挂载',
|
||||||
error: 'MSD 错误',
|
|
||||||
errorDesc: '{reason}',
|
|
||||||
recovered: 'MSD 已恢复',
|
|
||||||
recoveredDesc: 'MSD 设备已恢复正常',
|
|
||||||
operationInProgress: '操作进行中,请稍候',
|
operationInProgress: '操作进行中,请稍候',
|
||||||
driveConnected: '虚拟U盘已连接',
|
|
||||||
imageConnected: '镜像 {name} 已连接',
|
|
||||||
selectDriveSize: '选择虚拟驱动器大小',
|
selectDriveSize: '选择虚拟驱动器大小',
|
||||||
customSize: '自定义大小',
|
|
||||||
driveSpaceUnknown: '无法读取 MSD 目录所在储存空间的剩余可用空间',
|
driveSpaceUnknown: '无法读取 MSD 目录所在储存空间的剩余可用空间',
|
||||||
driveSpaceUnavailable: 'MSD 目录所在储存空间不足,无法创建虚拟U盘',
|
driveSpaceUnavailable: 'MSD 目录所在储存空间不足,无法创建虚拟U盘',
|
||||||
driveSpaceTooSmall: '剩余可用空间不足 {min} MB,无法创建虚拟U盘',
|
driveSpaceTooSmall: '剩余可用空间不足 {min} MB,无法创建虚拟U盘',
|
||||||
driveCreateFailed: '虚拟U盘创建失败,请检查 MSD 目录剩余空间',
|
driveCreateFailed: '虚拟U盘创建失败,请检查 MSD 目录剩余空间',
|
||||||
driveCreated: '虚拟驱动器已创建 ({size} MB)',
|
|
||||||
fileDeleted: '文件已删除',
|
|
||||||
imageDeleted: '镜像已删除',
|
|
||||||
driveConnectedBlocked: '请先断开连接,再操作文件',
|
driveConnectedBlocked: '请先断开连接,再操作文件',
|
||||||
driveConnectedFilesHidden: '已连接到被控机,文件列表暂不可用',
|
driveConnectedFilesHidden: '已连接到被控机,文件列表暂不可用',
|
||||||
uploadFailed: '文件上传失败',
|
uploadFailed: '文件上传失败',
|
||||||
driveUnreadable: '格式不支持',
|
driveUnreadable: '格式不支持',
|
||||||
driveUnreadableTooltip: '无法解析 exFAT 文件系统,可能已被格式化为不支持的格式。',
|
driveUnreadableTooltip: '无法解析 exFAT 文件系统,可能已被格式化为不支持的格式。',
|
||||||
reinitializeDrive: '重新初始化',
|
reinitializeDrive: '重新初始化',
|
||||||
|
diskMode: '驱动器模式',
|
||||||
|
singleDiskMode: '单驱动器',
|
||||||
|
multiDiskMode: '多驱动器',
|
||||||
|
diskModeHint: '单驱动器提供 1 个介质槽位,多驱动器提供 8 个;切换模式会清空已挂载介质并重新枚举 USB。',
|
||||||
|
mountImage: '挂载镜像',
|
||||||
|
mediaCount: '介质 {count}/{capacity}',
|
||||||
|
mediaSlotsFull: '介质槽已满,无法挂载更多介质',
|
||||||
|
reenumerating: 'USB 正在重新枚举',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: '系统设置',
|
title: '系统设置',
|
||||||
@@ -948,6 +938,7 @@ export default {
|
|||||||
msdStandby: '空闲',
|
msdStandby: '空闲',
|
||||||
msdImageMode: '镜像模式',
|
msdImageMode: '镜像模式',
|
||||||
msdDriveMode: '虚拟U盘',
|
msdDriveMode: '虚拟U盘',
|
||||||
|
msdMultiMode: '多媒体:{count} 个已挂载',
|
||||||
msdMountType: '挂载类型',
|
msdMountType: '挂载类型',
|
||||||
msdCurrentImage: '当前镜像',
|
msdCurrentImage: '当前镜像',
|
||||||
msdNoImage: '无',
|
msdNoImage: '无',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { systemApi, streamApi, hidApi, atxApi, msdApi, type DeviceInfo, type PlatformCapabilities } from '@/api'
|
import { systemApi, streamApi, hidApi, atxApi, msdApi, type DeviceInfo, type DiskMode, type MountedMedia, type PlatformCapabilities } from '@/api'
|
||||||
|
|
||||||
interface SystemCapabilities {
|
interface SystemCapabilities {
|
||||||
video: { available: boolean; backend?: string; reason?: string }
|
video: { available: boolean; backend?: string; reason?: string }
|
||||||
@@ -56,9 +56,11 @@ interface AtxState {
|
|||||||
|
|
||||||
interface MsdState {
|
interface MsdState {
|
||||||
available: boolean
|
available: boolean
|
||||||
connected: boolean
|
diskMode: DiskMode
|
||||||
mode: 'none' | 'image' | 'drive'
|
slotCapacity: number
|
||||||
imageId: string | null
|
mountedCount: number
|
||||||
|
mountedMedia: MountedMedia[]
|
||||||
|
usbReenumerating: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,9 +113,18 @@ export interface HidDeviceInfo {
|
|||||||
|
|
||||||
export interface MsdDeviceInfo {
|
export interface MsdDeviceInfo {
|
||||||
available: boolean
|
available: boolean
|
||||||
mode: string
|
disk_mode: DiskMode
|
||||||
connected: boolean
|
slot_capacity: number
|
||||||
image_id: string | null
|
mounted_count: number
|
||||||
|
mounted_media: Array<{
|
||||||
|
id: string
|
||||||
|
kind: 'drive' | 'image'
|
||||||
|
name: string
|
||||||
|
cdrom: boolean
|
||||||
|
read_only: boolean
|
||||||
|
size: number
|
||||||
|
}>
|
||||||
|
usb_reenumerating: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,9 +261,11 @@ export const useSystemStore = defineStore('system', () => {
|
|||||||
const result = await msdApi.status()
|
const result = await msdApi.status()
|
||||||
msd.value = {
|
msd.value = {
|
||||||
available: result.available,
|
available: result.available,
|
||||||
connected: result.state.connected,
|
diskMode: result.state.disk_mode,
|
||||||
mode: result.state.mode,
|
slotCapacity: result.state.slot_capacity,
|
||||||
imageId: result.state.current_image?.id ?? null,
|
mountedCount: result.state.mounted_count,
|
||||||
|
mountedMedia: result.state.mounted_media,
|
||||||
|
usbReenumerating: result.state.usb_reenumerating,
|
||||||
error: null,
|
error: null,
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -361,9 +374,11 @@ export const useSystemStore = defineStore('system', () => {
|
|||||||
if (data.msd) {
|
if (data.msd) {
|
||||||
msd.value = {
|
msd.value = {
|
||||||
available: data.msd.available,
|
available: data.msd.available,
|
||||||
connected: data.msd.connected,
|
diskMode: data.msd.disk_mode,
|
||||||
mode: data.msd.mode as 'none' | 'image' | 'drive',
|
slotCapacity: data.msd.slot_capacity,
|
||||||
imageId: data.msd.image_id,
|
mountedCount: data.msd.mounted_count,
|
||||||
|
mountedMedia: data.msd.mounted_media,
|
||||||
|
usbReenumerating: data.msd.usb_reenumerating,
|
||||||
error: data.msd.error,
|
error: data.msd.error,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -481,17 +481,15 @@ const msdStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'
|
|||||||
const msd = systemStore.msd
|
const msd = systemStore.msd
|
||||||
if (!msd?.available) return 'disconnected'
|
if (!msd?.available) return 'disconnected'
|
||||||
if (msd.error) return 'error'
|
if (msd.error) return 'error'
|
||||||
if (msd.connected) return 'connected'
|
if (msd.mountedCount > 0) return 'connected'
|
||||||
return 'disconnected'
|
return 'disconnected'
|
||||||
})
|
})
|
||||||
|
|
||||||
const msdQuickInfo = computed(() => {
|
const msdQuickInfo = computed(() => {
|
||||||
const msd = systemStore.msd
|
const msd = systemStore.msd
|
||||||
if (!msd?.available) return ''
|
if (!msd?.available) return ''
|
||||||
if (msd.mode === 'none') return t('statusCard.msdStandby')
|
if (msd.mountedCount === 0) return t('statusCard.msdStandby')
|
||||||
if (msd.mode === 'image') return t('statusCard.msdImageMode')
|
return `${msd.diskMode === 'single' ? t('msd.singleDiskMode') : t('msd.multiDiskMode')} · ${t('msd.mediaCount', { count: msd.mountedCount, capacity: msd.slotCapacity })}`
|
||||||
if (msd.mode === 'drive') return t('statusCard.msdDriveMode')
|
|
||||||
return t('common.unknown')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const msdErrorMessage = computed(() => {
|
const msdErrorMessage = computed(() => {
|
||||||
@@ -504,7 +502,7 @@ const msdDetails = computed<StatusDetail[]>(() => {
|
|||||||
|
|
||||||
const details: StatusDetail[] = []
|
const details: StatusDetail[] = []
|
||||||
|
|
||||||
if (msd.mode === 'none') {
|
if (msd.mountedCount === 0) {
|
||||||
details.push({
|
details.push({
|
||||||
label: t('statusCard.msdStatus'),
|
label: t('statusCard.msdStatus'),
|
||||||
value: t('statusCard.msdStandby'),
|
value: t('statusCard.msdStandby'),
|
||||||
@@ -518,22 +516,22 @@ const msdDetails = computed<StatusDetail[]>(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const modeDisplay = msd.mode === 'none'
|
|
||||||
? '-'
|
|
||||||
: msd.mode === 'image'
|
|
||||||
? t('statusCard.msdImageMode')
|
|
||||||
: t('statusCard.msdDriveMode')
|
|
||||||
details.push({
|
details.push({
|
||||||
label: t('statusCard.currentMode'),
|
label: t('statusCard.currentMode'),
|
||||||
value: modeDisplay,
|
value: msd.diskMode === 'single' ? t('msd.singleDiskMode') : t('msd.multiDiskMode'),
|
||||||
status: msd.mode !== 'none' ? 'ok' : undefined
|
status: msd.mountedCount > 0 ? 'ok' : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
if (msd.mode === 'image') {
|
if (msd.mountedMedia.length > 0) {
|
||||||
details.push({
|
for (const media of msd.mountedMedia) {
|
||||||
label: t('statusCard.msdCurrentImage'),
|
details.push({
|
||||||
value: msd.imageId || t('statusCard.msdNoImage')
|
label: media.kind === 'drive' ? t('statusCard.msdDriveMode') : t('statusCard.msdCurrentImage'),
|
||||||
})
|
value: media.kind === 'drive'
|
||||||
|
? t('statusCard.msdDriveMode')
|
||||||
|
: `${media.name || media.id || t('statusCard.msdNoImage')} (${media.cdrom ? t('msd.cdrom') : t('msd.flash')})`,
|
||||||
|
status: 'ok'
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return details
|
return details
|
||||||
|
|||||||
Reference in New Issue
Block a user