feat: 优化 MSD 功能体验和控件样式

This commit is contained in:
mofeng-git
2026-07-07 23:52:38 +08:00
parent d9c2854911
commit db2410cc7f
11 changed files with 1281 additions and 308 deletions

View File

@@ -97,14 +97,14 @@ impl ExfatBootSector {
let heap_sectors = volume_length as u32 - cluster_heap_offset; let heap_sectors = volume_length as u32 - cluster_heap_offset;
let cluster_count = heap_sectors / sectors_per_cluster; let cluster_count = heap_sectors / sectors_per_cluster;
// Calculate root directory cluster based on upcase table size // Calculate root directory cluster based on bitmap and upcase table size.
// Cluster 2: Bitmap (1 cluster)
// Cluster 3...: Upcase table (128KB, may span multiple clusters)
// Next available: Root directory
const UPCASE_TABLE_SIZE: u64 = 128 * 1024; const UPCASE_TABLE_SIZE: u64 = 128 * 1024;
let bitmap_size = ((cluster_count + 7) / 8) as u64;
let bitmap_clusters =
((bitmap_size + cluster_size as u64 - 1) / cluster_size as u64).max(1) as u32;
let upcase_clusters = let upcase_clusters =
((UPCASE_TABLE_SIZE + cluster_size as u64 - 1) / cluster_size as u64) as u32; ((UPCASE_TABLE_SIZE + cluster_size as u64 - 1) / cluster_size as u64) as u32;
let first_cluster_of_root = 3 + upcase_clusters; let first_cluster_of_root = 2 + bitmap_clusters + upcase_clusters;
Self { Self {
jump_boot: [0xEB, 0x76, 0x90], jump_boot: [0xEB, 0x76, 0x90],
@@ -211,6 +211,15 @@ const ENTRY_TYPE_VOLUME_LABEL: u8 = 0x83;
const ENTRY_TYPE_BITMAP: u8 = 0x81; const ENTRY_TYPE_BITMAP: u8 = 0x81;
const ENTRY_TYPE_UPCASE: u8 = 0x82; const ENTRY_TYPE_UPCASE: u8 = 0x82;
fn set_cluster_allocated(bitmap: &mut [u8], cluster: u32) {
let index = (cluster - 2) as usize;
let byte_idx = index / 8;
let bit_idx = index % 8;
if byte_idx < bitmap.len() {
bitmap[byte_idx] |= 1 << bit_idx;
}
}
/// Create volume label directory entry /// Create volume label directory entry
fn create_volume_label_entry(label: &str) -> [u8; 32] { fn create_volume_label_entry(label: &str) -> [u8; 32] {
let mut entry = [0u8; 32]; let mut entry = [0u8; 32];
@@ -301,27 +310,42 @@ pub fn format_exfat<W: Write + Seek>(
let fat_offset = partition_offset + boot_sector.fat_offset as u64 * 512; let fat_offset = partition_offset + boot_sector.fat_offset as u64 * 512;
writer.seek(SeekFrom::Start(fat_offset))?; writer.seek(SeekFrom::Start(fat_offset))?;
let bitmap_size = (boot_sector.cluster_count + 7) / 8;
let bitmap_clusters =
((bitmap_size as u64 + cluster_size as u64 - 1) / cluster_size as u64).max(1) as u32;
// Calculate how many clusters the upcase table needs (128KB) // Calculate how many clusters the upcase table needs (128KB)
const UPCASE_TABLE_SIZE: u64 = 128 * 1024; const UPCASE_TABLE_SIZE: u64 = 128 * 1024;
let upcase_clusters = let upcase_clusters =
((UPCASE_TABLE_SIZE + cluster_size as u64 - 1) / cluster_size as u64) as u32; ((UPCASE_TABLE_SIZE + cluster_size as u64 - 1) / cluster_size as u64) as u32;
let root_cluster = 3 + upcase_clusters; // Root comes after bitmap and upcase let bitmap_start_cluster = 2;
let upcase_start_cluster = bitmap_start_cluster + bitmap_clusters;
let root_cluster = upcase_start_cluster + upcase_clusters;
// FAT entries: cluster 0 and 1 are reserved // FAT entries: cluster 0 and 1 are reserved
// 0: Media type (0xFFFFFFF8) // 0: Media type (0xFFFFFFF8)
// 1: Reserved (0xFFFFFFFF) // 1: Reserved (0xFFFFFFFF)
// 2: Bitmap cluster (single cluster, end of chain) // 2..2+bitmap_clusters-1: Bitmap cluster chain
// 3..3+upcase_clusters-1: Upcase table cluster chain // upcase_start_cluster..upcase_start_cluster+upcase_clusters-1: Upcase table cluster chain
// 3+upcase_clusters: Root directory cluster (end of chain) // root_cluster: Root directory cluster (end of chain)
let mut fat_entries = vec![ let mut fat_entries = vec![
0xFFFFFFF8, // Media type 0xFFFFFFF8, // Media type
0xFFFFFFFF, // Reserved 0xFFFFFFFF, // Reserved
0xFFFFFFFF, // Bitmap (single cluster, end of chain)
]; ];
// Build allocation bitmap cluster chain
for i in 0..bitmap_clusters {
let cluster_num = bitmap_start_cluster + i;
if i == bitmap_clusters - 1 {
fat_entries.push(0xFFFFFFFF);
} else {
fat_entries.push(cluster_num + 1);
}
}
// Build upcase table cluster chain // Build upcase table cluster chain
for i in 0..upcase_clusters { for i in 0..upcase_clusters {
let cluster_num = 3 + i; let cluster_num = upcase_start_cluster + i;
if i == upcase_clusters - 1 { if i == upcase_clusters - 1 {
// Last cluster in chain // Last cluster in chain
fat_entries.push(0xFFFFFFFF); fat_entries.push(0xFFFFFFFF);
@@ -345,38 +369,26 @@ pub fn format_exfat<W: Write + Seek>(
// Calculate cluster heap offset // Calculate cluster heap offset
let heap_offset = partition_offset + boot_sector.cluster_heap_offset as u64 * 512; let heap_offset = partition_offset + boot_sector.cluster_heap_offset as u64 * 512;
// Cluster 2: Allocation Bitmap // Allocation Bitmap
let bitmap_size = (boot_sector.cluster_count + 7) / 8; let mut bitmap = vec![0u8; bitmap_clusters as usize * cluster_size as usize];
let _bitmap_clusters =
((bitmap_size as u64 + cluster_size as u64 - 1) / cluster_size as u64).max(1);
let mut bitmap = vec![0u8; cluster_size as usize];
// Mark clusters 2, 3..3+upcase_clusters-1, root_cluster as used // Mark bitmap, upcase, and root directory clusters as used.
// Cluster 2: bitmap // exFAT allocation bitmap bit 0 describes cluster 2.
bitmap[0] |= 0b00000100; // Bit 2 for i in 0..bitmap_clusters {
// Clusters 3..3+upcase_clusters-1: upcase table set_cluster_allocated(&mut bitmap, bitmap_start_cluster + i);
}
for i in 0..upcase_clusters { for i in 0..upcase_clusters {
let cluster = 3 + i; set_cluster_allocated(&mut bitmap, upcase_start_cluster + i);
let byte_idx = (cluster / 8) as usize;
let bit_idx = cluster % 8;
if byte_idx < bitmap.len() {
bitmap[byte_idx] |= 1 << bit_idx;
}
}
// Root directory cluster
let byte_idx = (root_cluster / 8) as usize;
let bit_idx = root_cluster % 8;
if byte_idx < bitmap.len() {
bitmap[byte_idx] |= 1 << bit_idx;
} }
set_cluster_allocated(&mut bitmap, root_cluster);
writer.seek(SeekFrom::Start(heap_offset))?; writer.seek(SeekFrom::Start(heap_offset))?;
writer.write_all(&bitmap)?; writer.write_all(&bitmap)?;
// Cluster 3..3+upcase_clusters-1: Upcase table // Upcase table
let upcase_data = generate_upcase_table(); let upcase_data = generate_upcase_table();
let upcase_checksum = calculate_upcase_checksum(&upcase_data); let upcase_checksum = calculate_upcase_checksum(&upcase_data);
let upcase_offset = heap_offset + cluster_size as u64; // Start at cluster 3 let upcase_offset = heap_offset + bitmap_clusters as u64 * cluster_size as u64;
writer.seek(SeekFrom::Start(upcase_offset))?; writer.seek(SeekFrom::Start(upcase_offset))?;
writer.write_all(&upcase_data)?; writer.write_all(&upcase_data)?;
@@ -388,13 +400,18 @@ pub fn format_exfat<W: Write + Seek>(
} }
// Root directory cluster // Root directory cluster
let root_offset = heap_offset + (1 + upcase_clusters as u64) * cluster_size as u64; let root_offset =
heap_offset + (bitmap_clusters as u64 + upcase_clusters as u64) * cluster_size as u64;
writer.seek(SeekFrom::Start(root_offset))?; writer.seek(SeekFrom::Start(root_offset))?;
// Write directory entries // Write directory entries
let volume_label_entry = create_volume_label_entry(label); let volume_label_entry = create_volume_label_entry(label);
let bitmap_entry = create_bitmap_entry(2, bitmap_size as u64); let bitmap_entry = create_bitmap_entry(bitmap_start_cluster, bitmap_size as u64);
let upcase_entry = create_upcase_entry(3, upcase_data.len() as u64, upcase_checksum); let upcase_entry = create_upcase_entry(
upcase_start_cluster,
upcase_data.len() as u64,
upcase_checksum,
);
writer.write_all(&volume_label_entry)?; writer.write_all(&volume_label_entry)?;
writer.write_all(&bitmap_entry)?; writer.write_all(&bitmap_entry)?;
@@ -413,6 +430,9 @@ pub fn format_exfat<W: Write + Seek>(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::partition::PartitionLayout;
use std::io::Read;
use tempfile::NamedTempFile;
#[test] #[test]
fn test_cluster_size() { fn test_cluster_size() {
@@ -428,4 +448,120 @@ mod tests {
assert_eq!(get_cluster_size(8 * 1024 * 1024 * 1024 / 512), 131072); // 8GB → 128KB assert_eq!(get_cluster_size(8 * 1024 * 1024 * 1024 / 512), 131072); // 8GB → 128KB
assert_eq!(get_cluster_size(16 * 1024 * 1024 * 1024 / 512), 131072); // 16GB → 128KB assert_eq!(get_cluster_size(16 * 1024 * 1024 * 1024 / 512), 131072); // 16GB → 128KB
} }
#[test]
fn test_format_bitmap_uses_cluster_heap_bit_indices() {
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path();
let size = 64 * 1024 * 1024u64;
let layout = PartitionLayout::calculate(size).unwrap();
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.unwrap();
file.set_len(size).unwrap();
format_exfat(&mut file, layout.data_offset(), layout.data_size(), "TEST").unwrap();
let mut boot_sector = [0u8; 512];
file.seek(SeekFrom::Start(layout.data_offset())).unwrap();
file.read_exact(&mut boot_sector).unwrap();
let cluster_heap_offset = u32::from_le_bytes(boot_sector[88..92].try_into().unwrap());
let first_cluster_of_root = u32::from_le_bytes(boot_sector[96..100].try_into().unwrap());
let sectors_per_cluster = 1u64 << boot_sector[109];
let cluster_size = sectors_per_cluster * 512;
let bitmap_offset = layout.data_offset() + cluster_heap_offset as u64 * 512;
let mut bitmap = vec![0u8; cluster_size as usize];
file.seek(SeekFrom::Start(bitmap_offset)).unwrap();
file.read_exact(&mut bitmap).unwrap();
let is_allocated = |cluster: u32| {
let index = (cluster - 2) as usize;
(bitmap[index / 8] & (1 << (index % 8))) != 0
};
assert!(
is_allocated(2),
"allocation bitmap cluster must be allocated"
);
assert!(
is_allocated(3),
"upcase table first cluster must be allocated"
);
assert!(
is_allocated(first_cluster_of_root),
"root directory cluster must be allocated"
);
assert!(
!is_allocated(first_cluster_of_root + 1),
"first data cluster after root should be free after formatting"
);
}
#[test]
fn test_format_supports_multi_cluster_allocation_bitmap() {
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path();
let size = 240 * 1024 * 1024u64;
let layout = PartitionLayout::calculate(size).unwrap();
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.unwrap();
file.set_len(size).unwrap();
format_exfat(&mut file, layout.data_offset(), layout.data_size(), "TEST").unwrap();
let mut boot_sector = [0u8; 512];
file.seek(SeekFrom::Start(layout.data_offset())).unwrap();
file.read_exact(&mut boot_sector).unwrap();
let fat_offset = u32::from_le_bytes(boot_sector[80..84].try_into().unwrap());
let cluster_heap_offset = u32::from_le_bytes(boot_sector[88..92].try_into().unwrap());
let cluster_count = u32::from_le_bytes(boot_sector[92..96].try_into().unwrap());
let first_cluster_of_root = u32::from_le_bytes(boot_sector[96..100].try_into().unwrap());
let sectors_per_cluster = 1u64 << boot_sector[109];
let cluster_size = sectors_per_cluster * 512;
let bitmap_size = ((cluster_count + 7) / 8) as u64;
let bitmap_clusters = bitmap_size.div_ceil(cluster_size) as u32;
assert!(
bitmap_clusters > 1,
"test volume should require a multi-cluster allocation bitmap"
);
let upcase_clusters = (128 * 1024u64).div_ceil(cluster_size) as u32;
assert_eq!(first_cluster_of_root, 2 + bitmap_clusters + upcase_clusters);
let read_fat = |file: &mut std::fs::File, cluster: u32| -> u32 {
let offset = layout.data_offset() + fat_offset as u64 * 512 + cluster as u64 * 4;
let mut bytes = [0u8; 4];
file.seek(SeekFrom::Start(offset)).unwrap();
file.read_exact(&mut bytes).unwrap();
u32::from_le_bytes(bytes)
};
assert_eq!(read_fat(&mut file, 2), 3);
assert_eq!(read_fat(&mut file, 2 + bitmap_clusters - 1), 0xFFFFFFFF);
let root_offset = layout.data_offset()
+ cluster_heap_offset as u64 * 512
+ (first_cluster_of_root - 2) as u64 * cluster_size;
let mut root = vec![0u8; cluster_size as usize];
file.seek(SeekFrom::Start(root_offset)).unwrap();
file.read_exact(&mut root).unwrap();
assert_eq!(root[32], ENTRY_TYPE_BITMAP);
assert_eq!(u32::from_le_bytes(root[52..56].try_into().unwrap()), 2);
assert_eq!(
u64::from_le_bytes(root[56..64].try_into().unwrap()),
bitmap_size
);
assert_eq!(root[64], ENTRY_TYPE_UPCASE);
assert_eq!(
u32::from_le_bytes(root[84..88].try_into().unwrap()),
2 + bitmap_clusters
);
}
} }

View File

@@ -14,9 +14,13 @@ use std::path::Path;
/// FAT entry values /// FAT entry values
const FAT_ENTRY_FREE: u32 = 0x00000000; const FAT_ENTRY_FREE: u32 = 0x00000000;
const FAT_ENTRY_END_OF_CHAIN: u32 = 0xFFFFFFFF; const FAT_ENTRY_END_OF_CHAIN: u32 = 0xFFFFFFFF;
const VOLUME_DIRTY_FLAG: u16 = 0x0002;
const VOLUME_FLAGS_OFFSET: u64 = 106;
/// Directory entry types /// Directory entry types
const ENTRY_TYPE_END: u8 = 0x00; const ENTRY_TYPE_END: u8 = 0x00;
const ENTRY_TYPE_BITMAP: u8 = 0x81;
const ENTRY_TYPE_UPCASE: u8 = 0x82;
const ENTRY_TYPE_FILE: u8 = 0x85; const ENTRY_TYPE_FILE: u8 = 0x85;
const ENTRY_TYPE_STREAM: u8 = 0xC0; const ENTRY_TYPE_STREAM: u8 = 0xC0;
const ENTRY_TYPE_FILE_NAME: u8 = 0xC1; const ENTRY_TYPE_FILE_NAME: u8 = 0xC1;
@@ -137,6 +141,11 @@ pub struct ExfatFs {
cluster_heap_offset: u32, cluster_heap_offset: u32,
cluster_count: u32, cluster_count: u32,
first_cluster_of_root: u32, first_cluster_of_root: u32,
allocation_bitmap_first_cluster: u32,
allocation_bitmap_size: u64,
upcase_table_first_cluster: u32,
upcase_table_size: u64,
upcase_table_checksum: u32,
// Performance caches // Performance caches
/// FAT table segment cache /// FAT table segment cache
fat_cache: FatCache, fat_cache: FatCache,
@@ -182,7 +191,7 @@ impl ExfatFs {
let sectors_per_cluster = 1u32 << sectors_per_cluster_shift; let sectors_per_cluster = 1u32 << sectors_per_cluster_shift;
let cluster_size = bytes_per_sector * sectors_per_cluster; let cluster_size = bytes_per_sector * sectors_per_cluster;
Ok(Self { let mut fs = Self {
file, file,
partition_offset, partition_offset,
bytes_per_sector, bytes_per_sector,
@@ -193,11 +202,151 @@ impl ExfatFs {
cluster_heap_offset, cluster_heap_offset,
cluster_count, cluster_count,
first_cluster_of_root, first_cluster_of_root,
allocation_bitmap_first_cluster: 2,
allocation_bitmap_size: ((cluster_count + 7) / 8) as u64,
upcase_table_first_cluster: 0,
upcase_table_size: 0,
upcase_table_checksum: 0,
// Initialize caches // Initialize caches
fat_cache: FatCache::new(), fat_cache: FatCache::new(),
bitmap_cache: None, bitmap_cache: None,
bitmap_dirty: false, bitmap_dirty: false,
}) };
fs.discover_root_metadata_entries()?;
Ok(fs)
}
fn discover_root_metadata_entries(&mut self) -> Result<()> {
let mut bitmap_found = false;
let mut upcase_found = false;
let root_clusters = self.read_cluster_chain(self.first_cluster_of_root)?;
'outer: for &cluster in &root_clusters {
let cluster_data = self.read_cluster(cluster)?;
let mut i = 0;
while i + 32 <= cluster_data.len() {
let entry_type = cluster_data[i];
match entry_type {
ENTRY_TYPE_END => break 'outer,
ENTRY_TYPE_BITMAP => {
let first_cluster =
u32::from_le_bytes(cluster_data[i + 20..i + 24].try_into().unwrap());
let size =
u64::from_le_bytes(cluster_data[i + 24..i + 32].try_into().unwrap());
if first_cluster < 2 || size == 0 {
return Err(VentoyError::FilesystemError(
"Invalid exFAT allocation bitmap entry".to_string(),
));
}
self.allocation_bitmap_first_cluster = first_cluster;
self.allocation_bitmap_size = size;
bitmap_found = true;
}
ENTRY_TYPE_UPCASE => {
let checksum =
u32::from_le_bytes(cluster_data[i + 4..i + 8].try_into().unwrap());
let first_cluster =
u32::from_le_bytes(cluster_data[i + 20..i + 24].try_into().unwrap());
let size =
u64::from_le_bytes(cluster_data[i + 24..i + 32].try_into().unwrap());
if first_cluster < 2 || size == 0 {
return Err(VentoyError::FilesystemError(
"Invalid exFAT upcase table entry".to_string(),
));
}
self.upcase_table_first_cluster = first_cluster;
self.upcase_table_size = size;
self.upcase_table_checksum = checksum;
upcase_found = true;
}
ENTRY_TYPE_FILE => {
let secondary_count = cluster_data[i + 1] as usize;
i += (1 + secondary_count) * 32;
continue;
}
_ => {}
}
i += 32;
}
}
if !bitmap_found {
return Err(VentoyError::FilesystemError(
"exFAT allocation bitmap entry not found".to_string(),
));
}
if !upcase_found {
return Err(VentoyError::FilesystemError(
"exFAT upcase table entry not found".to_string(),
));
}
let min_bitmap_size = ((self.cluster_count + 7) / 8) as u64;
if self.allocation_bitmap_size < min_bitmap_size {
return Err(VentoyError::FilesystemError(format!(
"exFAT allocation bitmap too small: {} bytes, need at least {}",
self.allocation_bitmap_size, min_bitmap_size
)));
}
Ok(())
}
fn volume_flags_offset(&self) -> u64 {
self.partition_offset + VOLUME_FLAGS_OFFSET
}
fn read_volume_flags(&mut self) -> Result<u16> {
let mut bytes = [0u8; 2];
self.file
.seek(SeekFrom::Start(self.volume_flags_offset()))?;
self.file.read_exact(&mut bytes)?;
Ok(u16::from_le_bytes(bytes))
}
fn is_volume_dirty(&mut self) -> Result<bool> {
Ok((self.read_volume_flags()? & VOLUME_DIRTY_FLAG) != 0)
}
fn set_volume_dirty(&mut self, dirty: bool) -> Result<()> {
let mut flags = self.read_volume_flags()?;
if dirty {
flags |= VOLUME_DIRTY_FLAG;
} else {
flags &= !VOLUME_DIRTY_FLAG;
}
self.file
.seek(SeekFrom::Start(self.volume_flags_offset()))?;
self.file.write_all(&flags.to_le_bytes())?;
self.file.flush()?;
Ok(())
}
fn begin_write_transaction(&mut self) -> Result<bool> {
let was_dirty = self.is_volume_dirty()?;
if !was_dirty {
self.set_volume_dirty(true)?;
}
Ok(was_dirty)
}
fn finish_write_transaction<T>(&mut self, was_dirty: bool, result: Result<T>) -> Result<T> {
match result {
Ok(value) => {
self.file.flush()?;
if !was_dirty {
self.set_volume_dirty(false)?;
}
Ok(value)
}
Err(err) => {
let _ = self.file.flush();
Err(err)
}
}
} }
// ==================== Cluster I/O Operations ==================== // ==================== Cluster I/O Operations ====================
@@ -328,13 +477,89 @@ impl ExfatFs {
// ==================== Allocation Bitmap Operations ==================== // ==================== Allocation Bitmap Operations ====================
fn read_cluster_chain_bytes(&mut self, first_cluster: u32, byte_len: u64) -> Result<Vec<u8>> {
let chain = self.read_cluster_chain(first_cluster)?;
if chain.is_empty() {
return Err(VentoyError::FilesystemError(
"Empty cluster chain".to_string(),
));
}
let capacity = byte_len.min(chain.len() as u64 * self.cluster_size as u64) as usize;
let mut data = Vec::with_capacity(capacity);
for &cluster in &chain {
let cluster_data = self.read_cluster(cluster)?;
data.extend_from_slice(&cluster_data);
if data.len() >= byte_len as usize {
data.truncate(byte_len as usize);
break;
}
}
if data.len() < byte_len as usize {
return Err(VentoyError::FilesystemError(format!(
"Cluster chain for {} is shorter than expected: {} < {} bytes",
first_cluster,
data.len(),
byte_len
)));
}
Ok(data)
}
fn write_cluster_chain_bytes(
&mut self,
first_cluster: u32,
byte_len: u64,
data: &[u8],
) -> Result<()> {
if data.len() < byte_len as usize {
return Err(VentoyError::FilesystemError(format!(
"Not enough data to write cluster chain: {} < {} bytes",
data.len(),
byte_len
)));
}
let chain = self.read_cluster_chain(first_cluster)?;
if chain.is_empty() {
return Err(VentoyError::FilesystemError(
"Empty cluster chain".to_string(),
));
}
let mut bytes_written = 0usize;
let bytes_to_write = byte_len as usize;
for &cluster in &chain {
let end = (bytes_written + self.cluster_size as usize).min(bytes_to_write);
if bytes_written >= end {
break;
}
self.write_cluster(cluster, &data[bytes_written..end])?;
bytes_written = end;
}
if bytes_written < bytes_to_write {
return Err(VentoyError::FilesystemError(format!(
"Cluster chain for {} is shorter than expected: {} < {} bytes",
first_cluster, bytes_written, bytes_to_write
)));
}
Ok(())
}
/// Read the allocation bitmap (with caching) /// Read the allocation bitmap (with caching)
fn read_bitmap(&mut self) -> Result<Vec<u8>> { fn read_bitmap(&mut self) -> Result<Vec<u8>> {
if let Some(ref bitmap) = self.bitmap_cache { if let Some(ref bitmap) = self.bitmap_cache {
return Ok(bitmap.clone()); return Ok(bitmap.clone());
} }
let bitmap = self.read_cluster(2)?; let bitmap = self.read_cluster_chain_bytes(
self.allocation_bitmap_first_cluster,
self.allocation_bitmap_size,
)?;
self.bitmap_cache = Some(bitmap.clone()); self.bitmap_cache = Some(bitmap.clone());
Ok(bitmap) Ok(bitmap)
} }
@@ -342,7 +567,10 @@ impl ExfatFs {
/// Get a mutable reference to the cached bitmap, loading if necessary /// Get a mutable reference to the cached bitmap, loading if necessary
fn get_bitmap_mut(&mut self) -> Result<&mut Vec<u8>> { fn get_bitmap_mut(&mut self) -> Result<&mut Vec<u8>> {
if self.bitmap_cache.is_none() { if self.bitmap_cache.is_none() {
let bitmap = self.read_cluster(2)?; let bitmap = self.read_cluster_chain_bytes(
self.allocation_bitmap_first_cluster,
self.allocation_bitmap_size,
)?;
self.bitmap_cache = Some(bitmap); self.bitmap_cache = Some(bitmap);
} }
Ok(self.bitmap_cache.as_mut().unwrap()) Ok(self.bitmap_cache.as_mut().unwrap())
@@ -351,7 +579,11 @@ impl ExfatFs {
/// Write the allocation bitmap (with cache management) /// Write the allocation bitmap (with cache management)
#[allow(dead_code)] #[allow(dead_code)]
fn write_bitmap(&mut self, bitmap: &[u8]) -> Result<()> { fn write_bitmap(&mut self, bitmap: &[u8]) -> Result<()> {
self.write_cluster(2, bitmap)?; self.write_cluster_chain_bytes(
self.allocation_bitmap_first_cluster,
self.allocation_bitmap_size,
bitmap,
)?;
self.bitmap_cache = Some(bitmap.to_vec()); self.bitmap_cache = Some(bitmap.to_vec());
self.bitmap_dirty = false; self.bitmap_dirty = false;
Ok(()) Ok(())
@@ -362,7 +594,11 @@ impl ExfatFs {
fn flush_bitmap(&mut self) -> Result<()> { fn flush_bitmap(&mut self) -> Result<()> {
if self.bitmap_dirty { if self.bitmap_dirty {
if let Some(bitmap) = self.bitmap_cache.take() { if let Some(bitmap) = self.bitmap_cache.take() {
self.write_cluster(2, &bitmap)?; self.write_cluster_chain_bytes(
self.allocation_bitmap_first_cluster,
self.allocation_bitmap_size,
&bitmap,
)?;
self.bitmap_cache = Some(bitmap); self.bitmap_cache = Some(bitmap);
self.bitmap_dirty = false; self.bitmap_dirty = false;
} }
@@ -400,10 +636,7 @@ impl ExfatFs {
let bitmap = self.read_bitmap()?; let bitmap = self.read_bitmap()?;
let mut free_clusters = Vec::with_capacity(count); let mut free_clusters = Vec::with_capacity(count);
// Start from cluster after root directory for cluster in 2..self.cluster_count + 2 {
// (root is at first_cluster_of_root, which varies based on cluster size)
let start_cluster = self.first_cluster_of_root + 1;
for cluster in start_cluster..self.cluster_count + 2 {
if !Self::is_cluster_allocated(&bitmap, cluster) { if !Self::is_cluster_allocated(&bitmap, cluster) {
free_clusters.push(cluster); free_clusters.push(cluster);
if free_clusters.len() >= count { if free_clusters.len() >= count {
@@ -482,7 +715,11 @@ impl ExfatFs {
/// Flush bitmap to disk immediately /// Flush bitmap to disk immediately
fn flush_bitmap_now(&mut self) -> Result<()> { fn flush_bitmap_now(&mut self) -> Result<()> {
if let Some(bitmap) = self.bitmap_cache.take() { if let Some(bitmap) = self.bitmap_cache.take() {
self.write_cluster(2, &bitmap)?; self.write_cluster_chain_bytes(
self.allocation_bitmap_first_cluster,
self.allocation_bitmap_size,
&bitmap,
)?;
self.bitmap_cache = Some(bitmap); self.bitmap_cache = Some(bitmap);
} }
Ok(()) Ok(())
@@ -823,10 +1060,12 @@ impl ExfatFs {
// Exclude the newly added cluster // Exclude the newly added cluster
let mut cluster_data = self.read_cluster(cluster)?; let mut cluster_data = self.read_cluster(cluster)?;
// Scan for END markers and replace them with 0xFF (invalid entry, will be skipped) // Scan for END markers and replace them with inactive entries. Leaving an
// END marker before later directory clusters makes hosts stop early; using
// an in-use invalid type can make strict hosts treat the directory as bad.
for i in (0..cluster_data.len()).step_by(32) { for i in (0..cluster_data.len()).step_by(32) {
if cluster_data[i] == ENTRY_TYPE_END { if cluster_data[i] == ENTRY_TYPE_END {
cluster_data[i] = 0xFF; // Invalid entry type cluster_data[i] = ENTRY_TYPE_DELETED_FILE;
} }
} }
@@ -905,8 +1144,16 @@ impl ExfatFs {
let empty_cluster = vec![0u8; self.cluster_size as usize]; let empty_cluster = vec![0u8; self.cluster_size as usize];
self.write_cluster(dir_cluster, &empty_cluster)?; self.write_cluster(dir_cluster, &empty_cluster)?;
// Create directory entry in parent // exFAT directories have allocated data. A zero-length directory stream
self.create_entry_in_directory(parent_cluster, name, dir_cluster, 0, true)?; // makes Windows treat the directory as corrupt even when the cluster
// chain and child entries are otherwise valid.
self.create_entry_in_directory(
parent_cluster,
name,
dir_cluster,
self.cluster_size as u64,
true,
)?;
self.file.flush()?; self.file.flush()?;
Ok(dir_cluster) Ok(dir_cluster)
@@ -1120,6 +1367,8 @@ impl ExfatFs {
/// Write a file to the filesystem (root directory, no overwrite) /// Write a file to the filesystem (root directory, no overwrite)
pub fn write_file(&mut self, name: &str, data: &[u8]) -> Result<()> { pub fn write_file(&mut self, name: &str, data: &[u8]) -> Result<()> {
let was_dirty = self.begin_write_transaction()?;
let result = (|| {
// Validate filename // Validate filename
if name.is_empty() || name.len() > 255 { if name.is_empty() || name.len() > 255 {
return Err(VentoyError::FilesystemError( return Err(VentoyError::FilesystemError(
@@ -1136,10 +1385,14 @@ impl ExfatFs {
} }
self.write_file_data_and_entry(self.first_cluster_of_root, name, data) self.write_file_data_and_entry(self.first_cluster_of_root, name, data)
})();
self.finish_write_transaction(was_dirty, result)
} }
/// Write a file to the filesystem with overwrite option /// Write a file to the filesystem with overwrite option
pub fn write_file_overwrite(&mut self, name: &str, data: &[u8], overwrite: bool) -> Result<()> { pub fn write_file_overwrite(&mut self, name: &str, data: &[u8], overwrite: bool) -> Result<()> {
let was_dirty = self.begin_write_transaction()?;
let result = (|| {
// Validate filename // Validate filename
if name.is_empty() || name.len() > 255 { if name.is_empty() || name.len() > 255 {
return Err(VentoyError::FilesystemError( return Err(VentoyError::FilesystemError(
@@ -1160,6 +1413,8 @@ impl ExfatFs {
} }
self.write_file_data_and_entry(self.first_cluster_of_root, name, data) self.write_file_data_and_entry(self.first_cluster_of_root, name, data)
})();
self.finish_write_transaction(was_dirty, result)
} }
/// Write a file to a specific path /// Write a file to a specific path
@@ -1174,6 +1429,8 @@ impl ExfatFs {
create_parents: bool, create_parents: bool,
overwrite: bool, overwrite: bool,
) -> Result<()> { ) -> Result<()> {
let was_dirty = self.begin_write_transaction()?;
let result = (|| {
let resolved = self.resolve_path(path, create_parents)?; let resolved = self.resolve_path(path, create_parents)?;
// Validate filename // Validate filename
@@ -1206,6 +1463,8 @@ impl ExfatFs {
} }
self.write_file_data_and_entry(resolved.parent_cluster, &resolved.name, data) self.write_file_data_and_entry(resolved.parent_cluster, &resolved.name, data)
})();
self.finish_write_transaction(was_dirty, result)
} }
/// Read file data from a location /// Read file data from a location
@@ -1276,9 +1535,11 @@ impl ExfatFs {
/// Delete a file from the filesystem (root directory) /// Delete a file from the filesystem (root directory)
pub fn delete_file(&mut self, name: &str) -> Result<()> { pub fn delete_file(&mut self, name: &str) -> Result<()> {
let location = self let was_dirty = self.begin_write_transaction()?;
.find_file_entry(name)? let result = (|| {
.ok_or_else(|| VentoyError::FilesystemError(format!("File '{}' not found", name)))?; let location = self.find_file_entry(name)?.ok_or_else(|| {
VentoyError::FilesystemError(format!("File '{}' not found", name))
})?;
// Free cluster chain // Free cluster chain
if location.first_cluster >= 2 { if location.first_cluster >= 2 {
@@ -1290,10 +1551,14 @@ impl ExfatFs {
self.file.flush()?; self.file.flush()?;
Ok(()) Ok(())
})();
self.finish_write_transaction(was_dirty, result)
} }
/// Delete a file or directory at a specific path /// Delete a file or directory at a specific path
pub fn delete_path(&mut self, path: &str) -> Result<()> { pub fn delete_path(&mut self, path: &str) -> Result<()> {
let was_dirty = self.begin_write_transaction()?;
let result = (|| {
let resolved = self.resolve_path(path, false)?; let resolved = self.resolve_path(path, false)?;
let location = resolved let location = resolved
@@ -1321,10 +1586,14 @@ impl ExfatFs {
self.file.flush()?; self.file.flush()?;
Ok(()) Ok(())
})();
self.finish_write_transaction(was_dirty, result)
} }
/// Delete a directory and all its contents recursively /// Delete a directory and all its contents recursively
pub fn delete_recursive(&mut self, path: &str) -> Result<()> { pub fn delete_recursive(&mut self, path: &str) -> Result<()> {
let was_dirty = self.begin_write_transaction()?;
let result = (|| {
let resolved = self.resolve_path(path, false)?; let resolved = self.resolve_path(path, false)?;
let location = resolved let location = resolved
@@ -1352,12 +1621,16 @@ impl ExfatFs {
self.file.flush()?; self.file.flush()?;
Ok(()) Ok(())
})();
self.finish_write_transaction(was_dirty, result)
} }
/// Create a directory at a specific path /// Create a directory at a specific path
/// ///
/// If create_parents is true, creates all intermediate directories (mkdir -p behavior) /// If create_parents is true, creates all intermediate directories (mkdir -p behavior)
pub fn create_directory(&mut self, path: &str, create_parents: bool) -> Result<()> { pub fn create_directory(&mut self, path: &str, create_parents: bool) -> Result<()> {
let was_dirty = self.begin_write_transaction()?;
let result = (|| {
let resolved = self.resolve_path(path, create_parents)?; let resolved = self.resolve_path(path, create_parents)?;
if resolved.location.is_some() { if resolved.location.is_some() {
@@ -1369,6 +1642,8 @@ impl ExfatFs {
self.create_directory_in(resolved.parent_cluster, &resolved.name)?; self.create_directory_in(resolved.parent_cluster, &resolved.name)?;
Ok(()) Ok(())
})();
self.finish_write_transaction(was_dirty, result)
} }
} }
@@ -1805,9 +2080,13 @@ impl ExfatFs {
reader: &mut R, reader: &mut R,
size: u64, size: u64,
) -> Result<()> { ) -> Result<()> {
let was_dirty = self.begin_write_transaction()?;
let result = (|| {
let mut writer = ExfatFileWriter::create(self, name, size)?; let mut writer = ExfatFileWriter::create(self, name, size)?;
Self::do_stream_write(&mut writer, reader)?; Self::do_stream_write(&mut writer, reader)?;
writer.finish() writer.finish()
})();
self.finish_write_transaction(was_dirty, result)
} }
/// Write a file from a reader with overwrite option /// Write a file from a reader with overwrite option
@@ -1818,9 +2097,13 @@ impl ExfatFs {
size: u64, size: u64,
overwrite: bool, overwrite: bool,
) -> Result<()> { ) -> Result<()> {
let was_dirty = self.begin_write_transaction()?;
let result = (|| {
let mut writer = ExfatFileWriter::create_overwrite(self, name, size, overwrite)?; let mut writer = ExfatFileWriter::create_overwrite(self, name, size, overwrite)?;
Self::do_stream_write(&mut writer, reader)?; Self::do_stream_write(&mut writer, reader)?;
writer.finish() writer.finish()
})();
self.finish_write_transaction(was_dirty, result)
} }
/// Write a file from a reader to a specific path /// Write a file from a reader to a specific path
@@ -1835,10 +2118,14 @@ impl ExfatFs {
create_parents: bool, create_parents: bool,
overwrite: bool, overwrite: bool,
) -> Result<()> { ) -> Result<()> {
let was_dirty = self.begin_write_transaction()?;
let result = (|| {
let mut writer = let mut writer =
ExfatFileWriter::create_at_path(self, path, size, create_parents, overwrite)?; ExfatFileWriter::create_at_path(self, path, size, create_parents, overwrite)?;
Self::do_stream_write(&mut writer, reader)?; Self::do_stream_write(&mut writer, reader)?;
writer.finish() writer.finish()
})();
self.finish_write_transaction(was_dirty, result)
} }
/// Internal: Stream write from reader to writer /// Internal: Stream write from reader to writer
@@ -1861,9 +2148,18 @@ impl ExfatFs {
mod tests { mod tests {
use super::*; use super::*;
use crate::partition::PartitionLayout; use crate::partition::PartitionLayout;
use std::io::Cursor; use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
fn cluster_offset(
partition_offset: u64,
cluster_heap_offset: u32,
cluster_size: u64,
cluster: u32,
) -> u64 {
partition_offset + cluster_heap_offset as u64 * 512 + (cluster - 2) as u64 * cluster_size
}
/// Test directory extension when filling up a directory cluster /// Test directory extension when filling up a directory cluster
#[test] #[test]
fn test_directory_extension() -> Result<()> { fn test_directory_extension() -> Result<()> {
@@ -2055,6 +2351,168 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn test_write_transactions_clear_volume_dirty_on_success() -> Result<()> {
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path();
let size = 64 * 1024 * 1024u64;
let layout = PartitionLayout::calculate(size).unwrap();
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.unwrap();
file.set_len(size).unwrap();
crate::exfat::format::format_exfat(
&mut file,
layout.data_offset(),
layout.data_size(),
"TEST",
)
.unwrap();
drop(file);
let mut fs = ExfatFs::open(path, &layout).unwrap();
assert!(!fs.is_volume_dirty()?);
let data = b"uploaded from web";
let mut cursor = Cursor::new(data);
fs.write_file_from_reader_path(
"/uploads/test.txt",
&mut cursor,
data.len() as u64,
true,
true,
)?;
assert!(!fs.is_volume_dirty()?);
fs.delete_recursive("/uploads")?;
assert!(!fs.is_volume_dirty()?);
Ok(())
}
#[test]
fn test_created_directory_has_allocated_data_length() -> Result<()> {
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path();
let size = 64 * 1024 * 1024u64;
let layout = PartitionLayout::calculate(size).unwrap();
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.unwrap();
file.set_len(size).unwrap();
crate::exfat::format::format_exfat(
&mut file,
layout.data_offset(),
layout.data_size(),
"TEST",
)
.unwrap();
drop(file);
let mut fs = ExfatFs::open(path, &layout).unwrap();
fs.create_directory("/uploads", true)?;
let location = fs
.find_entry_in_directory(fs.first_cluster_of_root, "uploads")?
.expect("created directory entry should exist");
assert!(location.is_directory);
assert!(location.first_cluster >= 2);
assert_eq!(location.data_length, fs.cluster_size as u64);
Ok(())
}
#[test]
fn test_open_uses_bitmap_location_from_root_directory() -> Result<()> {
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path();
let size = 64 * 1024 * 1024u64;
let layout = PartitionLayout::calculate(size).unwrap();
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.unwrap();
file.set_len(size).unwrap();
crate::exfat::format::format_exfat(
&mut file,
layout.data_offset(),
layout.data_size(),
"TEST",
)
.unwrap();
let mut boot_sector = [0u8; 512];
file.seek(SeekFrom::Start(layout.data_offset())).unwrap();
file.read_exact(&mut boot_sector).unwrap();
let fat_offset = u32::from_le_bytes(boot_sector[80..84].try_into().unwrap());
let cluster_heap_offset = u32::from_le_bytes(boot_sector[88..92].try_into().unwrap());
let first_cluster_of_root = u32::from_le_bytes(boot_sector[96..100].try_into().unwrap());
let cluster_size = (1u64 << boot_sector[109]) * 512;
let relocated_bitmap_cluster = first_cluster_of_root + 1;
let mut bitmap = vec![0u8; cluster_size as usize];
file.seek(SeekFrom::Start(cluster_offset(
layout.data_offset(),
cluster_heap_offset,
cluster_size,
2,
)))
.unwrap();
file.read_exact(&mut bitmap).unwrap();
let relocated_index = (relocated_bitmap_cluster - 2) as usize;
bitmap[relocated_index / 8] |= 1 << (relocated_index % 8);
file.seek(SeekFrom::Start(cluster_offset(
layout.data_offset(),
cluster_heap_offset,
cluster_size,
relocated_bitmap_cluster,
)))
.unwrap();
file.write_all(&bitmap).unwrap();
let relocated_fat_offset =
layout.data_offset() + fat_offset as u64 * 512 + relocated_bitmap_cluster as u64 * 4;
file.seek(SeekFrom::Start(relocated_fat_offset)).unwrap();
file.write_all(&FAT_ENTRY_END_OF_CHAIN.to_le_bytes())
.unwrap();
let root_offset = cluster_offset(
layout.data_offset(),
cluster_heap_offset,
cluster_size,
first_cluster_of_root,
);
file.seek(SeekFrom::Start(root_offset + 32 + 20)).unwrap();
file.write_all(&relocated_bitmap_cluster.to_le_bytes())
.unwrap();
file.flush().unwrap();
drop(file);
let mut fs = ExfatFs::open(path, &layout).unwrap();
assert_eq!(fs.allocation_bitmap_first_cluster, relocated_bitmap_cluster);
assert!(ExfatFs::is_cluster_allocated(
&fs.read_bitmap()?,
relocated_bitmap_cluster
));
let data = b"uses relocated bitmap";
let mut cursor = Cursor::new(data);
fs.write_file_from_reader("relocated.txt", &mut cursor, data.len() as u64)?;
assert_eq!(fs.read_file("relocated.txt")?, &data[..]);
Ok(())
}
/// Test Unicode file names (CJK, Cyrillic, emoji, etc.) /// Test Unicode file names (CJK, Cyrillic, emoji, etc.)
#[test] #[test]
fn test_unicode_filenames() -> Result<()> { fn test_unicode_filenames() -> Result<()> {

View File

@@ -11,6 +11,6 @@ pub use types::{
DownloadProgress, DownloadStatus, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest, DownloadProgress, DownloadStatus, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest,
ImageInfo, MsdConnectRequest, MsdMode, MsdState, ImageInfo, MsdConnectRequest, MsdMode, MsdState,
}; };
pub use ventoy_drive::VentoyDrive; pub use ventoy_drive::{VentoyDrive, MIN_DRIVE_SIZE_MB};
pub use crate::otg::{MsdFunction, MsdLunConfig}; pub use crate::otg::{MsdFunction, MsdLunConfig};

View File

@@ -120,7 +120,7 @@ pub struct DriveInitRequest {
} }
fn default_drive_size() -> u32 { fn default_drive_size() -> u32 {
16 * 1024 64
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]

View File

@@ -10,9 +10,7 @@ use crate::error::{AppError, Result};
const STREAM_CHUNK_SIZE: usize = 64 * 1024; const STREAM_CHUNK_SIZE: usize = 64 * 1024;
const MIN_DRIVE_SIZE_MB: u32 = 1024; pub const MIN_DRIVE_SIZE_MB: u32 = 64;
const MAX_DRIVE_SIZE_MB: u32 = 128 * 1024;
const DEFAULT_LABEL: &str = "ONE-KVM"; const DEFAULT_LABEL: &str = "ONE-KVM";
@@ -37,8 +35,20 @@ impl VentoyDrive {
&self.path &self.path
} }
/// Returns just the raw file size without attempting to parse the filesystem.
/// Used as a fallback when the image has been reformatted to an unsupported
/// filesystem (e.g. NTFS/exFAT) that VentoyImage cannot open.
pub fn raw_size(&self) -> Option<u64> {
std::fs::metadata(&self.path).ok().map(|m| m.len())
}
pub async fn init(&self, size_mb: u32) -> Result<DriveInfo> { pub async fn init(&self, size_mb: u32) -> Result<DriveInfo> {
let size_mb = size_mb.clamp(MIN_DRIVE_SIZE_MB, MAX_DRIVE_SIZE_MB); if size_mb < MIN_DRIVE_SIZE_MB {
return Err(AppError::BadRequest(format!(
"Drive size must be at least {} MB",
MIN_DRIVE_SIZE_MB
)));
}
let size_str = format!("{}M", size_mb); let size_str = format!("{}M", size_mb);
let path = self.path.clone(); let path = self.path.clone();
let _lock = self.lock.write().await; let _lock = self.lock.write().await;

View File

@@ -44,7 +44,11 @@ impl MsdLunConfig {
cdrom: false, cdrom: false,
ro: read_only, ro: read_only,
removable: true, removable: true,
nofua: true, // nofua=false: enforce Force Unit Access so the USB host (e.g. Windows)
// gets proper write-completion acknowledgements when writing to the
// virtual .img file. nofua=true can cause write-verify failures
// that manifest as Windows error 0x80070570 on writable drives.
nofua: false,
} }
} }
} }

View File

@@ -2,7 +2,7 @@ use super::*;
use crate::msd::{ use crate::msd::{
DownloadProgress, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo, DownloadProgress, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo,
ImageManager, MsdConnectRequest, MsdMode, MsdState, VentoyDrive, ImageManager, MsdConnectRequest, MsdMode, MsdState, VentoyDrive, MIN_DRIVE_SIZE_MB,
}; };
#[cfg(unix)] #[cfg(unix)]
use axum::body::Body; use axum::body::Body;
@@ -15,6 +15,63 @@ use axum::response::Response;
#[cfg(unix)] #[cfg(unix)]
use std::collections::HashMap; use std::collections::HashMap;
#[cfg(unix)]
const MIB: u64 = 1024 * 1024;
/// Return an error if the virtual drive is currently connected to the USB host.
/// When connected, the USB host (e.g. Windows) has the filesystem mounted.
/// Any concurrent access from the server side (via VentoyImage::open) would
/// cause double-access corruption, manifesting as Windows error 0x80070570.
#[cfg(unix)]
async fn assert_drive_not_connected(state: &Arc<AppState>) -> Result<()> {
let msd_guard = state.msd.read().await;
if let Some(controller) = msd_guard.as_ref() {
let msd_state = controller.state().await;
if msd_state.connected && msd_state.mode == crate::msd::types::MsdMode::Drive {
return Err(AppError::BadRequest(
"Virtual drive is connected to the USB host; disconnect it before modifying files"
.to_string(),
));
}
}
Ok(())
}
#[cfg(unix)]
fn validate_drive_init_size(size_mb: u32, available_bytes: u64) -> Result<()> {
let requested_bytes = size_mb as u64 * MIB;
if size_mb < MIN_DRIVE_SIZE_MB {
return Err(AppError::BadRequest(format!(
"Virtual drive size must be at least {} MB",
MIN_DRIVE_SIZE_MB
)));
}
if requested_bytes > available_bytes {
return Err(AppError::BadRequest(format!(
"Virtual drive size cannot exceed available space on the MSD directory filesystem (available {} MB, requested {} MB)",
available_bytes / MIB,
size_mb
)));
}
Ok(())
}
#[cfg(unix)]
fn is_unsupported_drive_filesystem(error: &str) -> bool {
error.contains("Filesystem error")
|| error.contains("Image error")
|| error.contains("Partition error")
}
#[cfg(unix)]
fn unsupported_drive_filesystem_error(error: &str) -> AppError {
tracing::warn!(
error = %error,
"Virtual drive filesystem is not supported"
);
AppError::BadRequest("Unsupported drive filesystem".to_string())
}
/// MSD status response /// MSD status response
#[cfg(unix)] #[cfg(unix)]
#[derive(Serialize)] #[derive(Serialize)]
@@ -226,11 +283,24 @@ pub async fn msd_drive_info(State(state): State<Arc<AppState>>) -> Result<Json<D
let drive = VentoyDrive::new(drive_path); let drive = VentoyDrive::new(drive_path);
if !drive.exists() { if !drive.exists() {
// 404: drive image file does not exist at all — truly not initialized
return Err(AppError::NotFound("Drive not initialized".to_string())); return Err(AppError::NotFound("Drive not initialized".to_string()));
} }
let info = drive.info().await?; match drive.info().await {
Ok(Json(info)) Ok(info) => Ok(Json(info)),
Err(e) => {
let msg = e.to_string();
// Detect filesystem-level failures (unrecognized format, bad partition table, etc.)
// These mean the drive FILE exists but was formatted to an unsupported type
// (e.g. the controlled machine reformatted it as NTFS/exFAT).
// Return 400 so the frontend can distinguish this from 404 (file missing).
if is_unsupported_drive_filesystem(&msg) {
return Err(unsupported_drive_filesystem_error(&msg));
}
Err(e)
}
}
} }
/// Initialize Ventoy drive /// Initialize Ventoy drive
@@ -240,6 +310,16 @@ pub async fn msd_drive_init(
Json(req): Json<DriveInitRequest>, Json(req): Json<DriveInitRequest>,
) -> Result<Json<DriveInfo>> { ) -> Result<Json<DriveInfo>> {
let config = state.config.get(); let config = state.config.get();
let msd_dir = config.msd.msd_dir_path();
let disk_space = get_disk_space(&msd_dir).map_err(|e| {
AppError::BadRequest(format!(
"Failed to read available space for the MSD directory filesystem: {}",
e
))
})?;
validate_drive_init_size(req.size_mb, disk_space.available)?;
let drive_path = config.msd.drive_path(); let drive_path = config.msd.drive_path();
let drive = VentoyDrive::new(drive_path); let drive = VentoyDrive::new(drive_path);
@@ -283,12 +363,24 @@ pub async fn msd_drive_files(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Vec<DriveFile>>> { ) -> Result<Json<Vec<DriveFile>>> {
// Block when connected: concurrent access corrupts the filesystem
assert_drive_not_connected(&state).await?;
let config = state.config.get(); let config = state.config.get();
let drive_path = config.msd.drive_path(); let drive_path = config.msd.drive_path();
let drive = VentoyDrive::new(drive_path); let drive = VentoyDrive::new(drive_path);
let dir_path = params.get("path").map(|s| s.as_str()).unwrap_or("/"); let dir_path = params.get("path").map(|s| s.as_str()).unwrap_or("/");
let files = drive.list_files(dir_path).await?; let files = drive.list_files(dir_path).await.map_err(|e| {
// Provide a friendly message when the filesystem format is unrecognized
// (e.g. user formatted it as NTFS/exFAT from the controlled machine)
let msg = e.to_string();
if is_unsupported_drive_filesystem(&msg) {
unsupported_drive_filesystem_error(&msg)
} else {
e
}
})?;
Ok(Json(files)) Ok(Json(files))
} }
@@ -299,6 +391,10 @@ pub async fn msd_drive_upload(
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<Json<LoginResponse>> { ) -> Result<Json<LoginResponse>> {
// Block when connected: writing to image while USB host has it mounted
// causes filesystem corruption (Windows error 0x80070570)
assert_drive_not_connected(&state).await?;
let config = state.config.get(); let config = state.config.get();
let drive_path = config.msd.drive_path(); let drive_path = config.msd.drive_path();
let drive = VentoyDrive::new(drive_path); let drive = VentoyDrive::new(drive_path);
@@ -345,6 +441,10 @@ pub async fn msd_drive_download(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
AxumPath(file_path): AxumPath<String>, AxumPath(file_path): AxumPath<String>,
) -> Result<Response> { ) -> Result<Response> {
// Block when connected: concurrent read from server side can cause
// filesystem inconsistency while USB host has the image mounted
assert_drive_not_connected(&state).await?;
let config = state.config.get(); let config = state.config.get();
let drive_path = config.msd.drive_path(); let drive_path = config.msd.drive_path();
let drive = VentoyDrive::new(drive_path); let drive = VentoyDrive::new(drive_path);
@@ -380,6 +480,10 @@ pub async fn msd_drive_file_delete(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
AxumPath(file_path): AxumPath<String>, AxumPath(file_path): AxumPath<String>,
) -> Result<Json<LoginResponse>> { ) -> Result<Json<LoginResponse>> {
// Block when connected: deleting from image while USB host has it mounted
// causes filesystem corruption
assert_drive_not_connected(&state).await?;
let config = state.config.get(); let config = state.config.get();
let drive_path = config.msd.drive_path(); let drive_path = config.msd.drive_path();
let drive = VentoyDrive::new(drive_path); let drive = VentoyDrive::new(drive_path);
@@ -398,6 +502,10 @@ pub async fn msd_drive_mkdir(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
AxumPath(dir_path): AxumPath<String>, AxumPath(dir_path): AxumPath<String>,
) -> Result<Json<LoginResponse>> { ) -> Result<Json<LoginResponse>> {
// Block when connected: modifying image while USB host has it mounted
// causes filesystem corruption
assert_drive_not_connected(&state).await?;
let config = state.config.get(); let config = state.config.get();
let drive_path = config.msd.drive_path(); let drive_path = config.msd.drive_path();
let drive = VentoyDrive::new(drive_path); let drive = VentoyDrive::new(drive_path);
@@ -409,3 +517,38 @@ pub async fn msd_drive_mkdir(
message: Some(format!("Directory created: {}", dir_path)), message: Some(format!("Directory created: {}", dir_path)),
})) }))
} }
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn validate_drive_init_size_accepts_64mb() {
validate_drive_init_size(MIN_DRIVE_SIZE_MB, MIN_DRIVE_SIZE_MB as u64 * MIB).unwrap();
}
#[test]
fn validate_drive_init_size_rejects_below_64mb() {
let err = validate_drive_init_size(MIN_DRIVE_SIZE_MB - 1, 1024 * MIB).unwrap_err();
assert!(err.to_string().contains("at least 64 MB"));
}
#[test]
fn validate_drive_init_size_rejects_available_space_overflow() {
let err = validate_drive_init_size(65, 64 * MIB).unwrap_err();
assert!(err.to_string().contains("cannot exceed available space"));
}
#[test]
fn detects_unsupported_drive_filesystem_errors() {
assert!(is_unsupported_drive_filesystem(
"Internal error: Filesystem error: Invalid exFAT signature"
));
assert!(is_unsupported_drive_filesystem(
"Internal error: Partition error: invalid partition table"
));
assert!(!is_unsupported_drive_filesystem(
"IO error: permission denied"
));
}
}

View File

@@ -613,19 +613,32 @@ export const msdApi = {
used: number used: number
free: number free: number
initialized: boolean initialized: boolean
}>('/msd/drive'), }>('/msd/drive', {}, { toastOnError: false }),
initDrive: (sizeMb?: number) => initDrive: (sizeMb?: number) =>
request<{ path: string; size_mb: number }>('/msd/drive/init', { request<{
size: number
used: number
free: number
initialized: boolean
}>(
'/msd/drive/init',
{
method: 'POST', method: 'POST',
body: JSON.stringify({ size_mb: sizeMb }), body: JSON.stringify({ size_mb: sizeMb }),
}), },
{ toastOnError: false },
),
deleteDrive: () => deleteDrive: () =>
request<{ success: boolean }>('/msd/drive', { method: 'DELETE' }), request<{ success: boolean }>('/msd/drive', { method: 'DELETE' }),
listDriveFiles: (path = '/') => listDriveFiles: (path = '/') =>
request<DriveFile[]>(`/msd/drive/files?path=${encodeURIComponent(path)}`), request<DriveFile[]>(
`/msd/drive/files?path=${encodeURIComponent(path)}`,
{},
{ toastOnError: false },
),
uploadDriveFile: async (file: File, targetPath = '/', onProgress?: (progress: number) => void) => { uploadDriveFile: async (file: File, targetPath = '/', onProgress?: (progress: number) => void) => {
const formData = new FormData() const formData = new FormData()

View File

@@ -4,6 +4,7 @@ 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 } from '@/api'
import { ApiError } from '@/api/request'
import { useWebSocket } from '@/composables/useWebSocket' import { useWebSocket } from '@/composables/useWebSocket'
import { import {
Dialog, Dialog,
@@ -25,8 +26,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Slider } from '@/components/ui/slider'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { import {
HardDrive, HardDrive,
@@ -45,6 +46,7 @@ import {
Globe, Globe,
X, X,
AlertCircle, AlertCircle,
Info,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue' import HelpTooltip from '@/components/HelpTooltip.vue'
@@ -68,7 +70,8 @@ const uploadProgress = ref(0)
const uploading = ref(false) const uploading = ref(false)
const mountMode = ref<'cdrom' | 'flash'>('flash') const mountMode = ref<'cdrom' | 'flash'>('flash')
const accessMode = ref<'readonly' | 'readwrite'>('readonly') // Default to readwrite for flash mode; cdrom forces readonly anyway
const accessMode = ref<'readonly' | 'readwrite'>('readwrite')
const cdromMode = computed(() => mountMode.value === 'cdrom') const cdromMode = computed(() => mountMode.value === 'cdrom')
const readOnly = computed(() => accessMode.value === 'readonly') const readOnly = computed(() => accessMode.value === 'readonly')
@@ -84,6 +87,7 @@ const driveInfo = ref<{ size: number; used: number; free: number; initialized: b
const driveInitialized = ref(false) const driveInitialized = ref(false)
const uploadingFile = ref(false) const uploadingFile = ref(false)
const fileUploadProgress = ref(0) const fileUploadProgress = ref(0)
const driveError = ref<string | null>(null) // filesystem error (e.g. unsupported format)
const showDeleteDialog = ref(false) const showDeleteDialog = ref(false)
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null) const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null)
@@ -92,8 +96,42 @@ const newFolderName = ref('')
const showDriveInitDialog = ref(false) const showDriveInitDialog = ref(false)
const showDeleteDriveDialog = ref(false) const showDeleteDriveDialog = ref(false)
const selectedDriveSize = ref(256) // Default 256MB
const customDriveSize = ref<number | undefined>(undefined) const MIN_DRIVE_SIZE_MB = 64
const DEFAULT_DRIVE_SIZE_MB = 256
const BYTES_PER_MB = 1024 * 1024
const driveSizeMB = ref(DEFAULT_DRIVE_SIZE_MB)
const availableDriveSizeMB = computed(() => {
if (!systemStore.diskSpace) return null
return Math.floor(systemStore.diskSpace.available / BYTES_PER_MB)
})
const canInitializeDrive = computed(() => {
return availableDriveSizeMB.value !== null && availableDriveSizeMB.value >= MIN_DRIVE_SIZE_MB
})
const sliderMaxDriveSizeMB = computed(() => {
return Math.max(MIN_DRIVE_SIZE_MB, availableDriveSizeMB.value ?? MIN_DRIVE_SIZE_MB)
})
function normalizeDriveSize(value: number) {
const max = availableDriveSizeMB.value
if (max === null || max < MIN_DRIVE_SIZE_MB) return MIN_DRIVE_SIZE_MB
const next = Number.isFinite(value) ? Math.trunc(value) : DEFAULT_DRIVE_SIZE_MB
return Math.max(MIN_DRIVE_SIZE_MB, Math.min(next, max))
}
function updateDriveSizeFromSlider(value: number[] | undefined) {
driveSizeMB.value = normalizeDriveSize(value?.[0] ?? MIN_DRIVE_SIZE_MB)
}
const finalDriveSize = computed(() => {
return normalizeDriveSize(driveSizeMB.value)
})
watch(availableDriveSizeMB, () => {
driveSizeMB.value = finalDriveSize.value
})
const initializingDrive = ref(false) const initializingDrive = ref(false)
const deletingDrive = ref(false) const deletingDrive = ref(false)
@@ -114,15 +152,10 @@ const TWO_POINT_TWO_GB = 2.2 * 1024 * 1024 * 1024
const msdConnected = computed(() => systemStore.msd?.connected ?? false) const msdConnected = computed(() => systemStore.msd?.connected ?? false)
const msdMode = computed(() => systemStore.msd?.mode ?? 'none') const msdMode = computed(() => systemStore.msd?.mode ?? 'none')
// Drive is currently mounted on the target machine via USB — file ops are blocked
const driveConnectedToTarget = computed(() => msdConnected.value && msdMode.value === 'drive')
const connectedImageName = computed(() => {
if (!msdConnected.value) return null
if (msdMode.value === 'drive') return t('msd.drive')
const imageId = systemStore.msd?.imageId
if (!imageId) return null
const image = images.value.find(i => i.id === imageId)
return image?.name ?? null
})
const operationInProgress = computed(() => { const operationInProgress = computed(() => {
return connecting.value || return connecting.value ||
@@ -155,7 +188,21 @@ watch(() => props.open, async (isOpen) => {
} }
}) })
watch(driveConnectedToTarget, async (isConnected, wasConnected) => {
if (!wasConnected || isConnected || !props.open) return
await refreshDriveBrowser()
})
async function refreshDiskSpace() {
try {
await systemStore.fetchSystemInfo()
} catch (e) {
console.error('Failed to refresh disk space:', e)
}
}
async function loadData() { async function loadData() {
await refreshDiskSpace()
await systemStore.fetchMsdState() await systemStore.fetchMsdState()
await loadImages() await loadImages()
await loadDriveInfo() await loadDriveInfo()
@@ -188,6 +235,7 @@ async function handleImageUpload(e: Event) {
uploadProgress.value = progress uploadProgress.value = progress
}) })
images.value.push(image) images.value.push(image)
await refreshDiskSpace()
} catch (e) { } catch (e) {
console.error('Failed to upload image:', e) console.error('Failed to upload image:', e)
} finally { } finally {
@@ -255,17 +303,27 @@ function confirmDelete(type: 'image' | 'file', id: string, name: string) {
async function executeDelete() { async function executeDelete() {
if (!deleteTarget.value || deleting.value) return if (!deleteTarget.value || deleting.value) return
// Guard: never delete drive files while connected to target
if (deleteTarget.value.type === 'file' && driveConnectedToTarget.value) {
toast.error(t('msd.driveConnectedBlocked'))
showDeleteDialog.value = false
deleteTarget.value = null
return
}
deleting.value = true deleting.value = true
try { try {
if (deleteTarget.value.type === 'image') { if (deleteTarget.value.type === 'image') {
await msdApi.deleteImage(deleteTarget.value.id) await msdApi.deleteImage(deleteTarget.value.id)
images.value = images.value.filter(i => i.id !== deleteTarget.value!.id) images.value = images.value.filter(i => i.id !== deleteTarget.value!.id)
await refreshDiskSpace()
} else { } else {
await msdApi.deleteDriveFile(deleteTarget.value.id) await msdApi.deleteDriveFile(deleteTarget.value.id)
await loadDriveFiles() await loadDriveFiles()
} }
} catch (e) { } catch (e: any) {
console.error('Failed to delete:', e) console.error('Failed to delete:', e)
toast.error(t('common.error'), { description: e?.message })
} finally { } finally {
showDeleteDialog.value = false showDeleteDialog.value = false
deleteTarget.value = null deleteTarget.value = null
@@ -274,42 +332,57 @@ async function executeDelete() {
} }
async function loadDriveInfo() { async function loadDriveInfo() {
driveError.value = null
try { try {
driveInfo.value = await msdApi.driveInfo() driveInfo.value = await msdApi.driveInfo()
driveInitialized.value = true driveInitialized.value = true
} catch { } catch (e: any) {
if (e instanceof ApiError) {
if (e.status === 404) {
// Drive image file does not exist — truly not initialized
driveInitialized.value = false driveInitialized.value = false
driveInfo.value = null
} else {
// Drive file exists but unreadable (e.g. wrong filesystem format after
// being reformatted by the controlled machine). Show the drive tab with
// an error banner instead of the misleading "Initialize Drive" button.
driveInitialized.value = true
driveError.value = e.message
driveInfo.value = null
}
} else {
driveInitialized.value = false
driveInfo.value = null
}
console.error('Failed to load drive info:', e)
} }
} }
const driveSizeOptions = computed(() => [ async function initializeDrive() {
{ value: 64, label: '64 MB' }, await refreshDiskSpace()
{ value: 128, label: '128 MB' }, driveSizeMB.value = finalDriveSize.value
{ value: 256, label: `256 MB (${t('common.recommended')})`, recommended: true },
{ value: 512, label: '512 MB' },
{ value: 1024, label: '1 GB' },
{ value: 2048, label: '2 GB' },
{ value: 4096, label: '4 GB' },
{ value: 8192, label: '8 GB' },
])
const finalDriveSize = computed(() => {
return customDriveSize.value || selectedDriveSize.value
})
function initializeDrive() {
showDriveInitDialog.value = true showDriveInitDialog.value = true
} }
async function createDrive() { async function createDrive() {
await refreshDiskSpace()
driveSizeMB.value = finalDriveSize.value
if (!canInitializeDrive.value) {
toast.error(t('msd.driveSpaceUnavailable'))
return
}
initializingDrive.value = true initializingDrive.value = true
try { try {
await msdApi.initDrive(finalDriveSize.value) const sizeMb = finalDriveSize.value
await msdApi.initDrive(sizeMb)
await loadDriveInfo() await loadDriveInfo()
await loadDriveFiles() await loadDriveFiles()
await refreshDiskSpace()
showDriveInitDialog.value = false showDriveInitDialog.value = false
} catch (e) { } catch (e) {
console.error('Failed to initialize drive:', e) console.error('Failed to initialize drive:', e)
toast.error(t('msd.driveCreateFailed'))
} finally { } finally {
initializingDrive.value = false initializingDrive.value = false
} }
@@ -324,6 +397,7 @@ async function deleteDrive() {
driveFiles.value = [] driveFiles.value = []
currentPath.value = '/' currentPath.value = '/'
showDeleteDriveDialog.value = false showDeleteDriveDialog.value = false
await refreshDiskSpace()
} catch (e) { } catch (e) {
console.error('Failed to delete drive:', e) console.error('Failed to delete drive:', e)
} finally { } finally {
@@ -332,16 +406,36 @@ async function deleteDrive() {
} }
async function loadDriveFiles() { async function loadDriveFiles() {
// Do not read image file while it is mounted on the target machine:
// concurrent access causes filesystem corruption (Windows error 0x80070570)
if (driveConnectedToTarget.value) {
driveFiles.value = []
return
}
loadingDrive.value = true loadingDrive.value = true
driveError.value = null
try { try {
driveFiles.value = await msdApi.listDriveFiles(currentPath.value) driveFiles.value = await msdApi.listDriveFiles(currentPath.value)
} catch (e) { } catch (e: any) {
console.error('Failed to load drive files:', e) console.error('Failed to load drive files:', e)
// Surface the error — could be unsupported filesystem format
driveError.value = e?.message ?? String(e)
driveFiles.value = []
} finally { } finally {
loadingDrive.value = false loadingDrive.value = false
} }
} }
async function refreshDriveBrowser() {
await loadDriveInfo()
if (driveInitialized.value) {
await loadDriveFiles()
} else {
driveFiles.value = []
}
await refreshDiskSpace()
}
function navigateTo(path: string) { function navigateTo(path: string) {
currentPath.value = path currentPath.value = path
loadDriveFiles() loadDriveFiles()
@@ -359,6 +453,13 @@ async function handleFileUpload(e: Event) {
const file = input.files?.[0] const file = input.files?.[0]
if (!file) return if (!file) return
// Guard: never upload while drive is connected to target
if (driveConnectedToTarget.value) {
toast.error(t('msd.driveConnectedBlocked'))
input.value = ''
return
}
uploadingFile.value = true uploadingFile.value = true
fileUploadProgress.value = 0 fileUploadProgress.value = 0
@@ -367,8 +468,9 @@ async function handleFileUpload(e: Event) {
fileUploadProgress.value = progress fileUploadProgress.value = progress
}) })
await loadDriveFiles() await loadDriveFiles()
} catch (e) { } catch (e: any) {
console.error('Failed to upload file:', e) console.error('Failed to upload file:', e)
toast.error(t('msd.uploadFailed'), { description: e?.message })
} finally { } finally {
uploadingFile.value = false uploadingFile.value = false
fileUploadProgress.value = 0 fileUploadProgress.value = 0
@@ -379,14 +481,23 @@ async function handleFileUpload(e: Event) {
async function createFolder() { async function createFolder() {
if (!newFolderName.value.trim()) return if (!newFolderName.value.trim()) return
// Guard: never create folders while drive is connected to target
if (driveConnectedToTarget.value) {
toast.error(t('msd.driveConnectedBlocked'))
showNewFolderDialog.value = false
newFolderName.value = ''
return
}
try { try {
const path = currentPath.value === '/' const path = currentPath.value === '/'
? '/' + newFolderName.value ? '/' + newFolderName.value
: currentPath.value + '/' + newFolderName.value : currentPath.value + '/' + newFolderName.value
await msdApi.createDirectory(path) await msdApi.createDirectory(path)
await loadDriveFiles() await loadDriveFiles()
} catch (e) { } catch (e: any) {
console.error('Failed to create folder:', e) console.error('Failed to create folder:', e)
toast.error(t('common.error'), { description: e?.message })
} finally { } finally {
showNewFolderDialog.value = false showNewFolderDialog.value = false
newFolderName.value = '' newFolderName.value = ''
@@ -494,16 +605,8 @@ onUnmounted(() => {
</span> </span>
{{ msdConnected ? t('common.connected') : t('common.disconnected') }} {{ msdConnected ? t('common.connected') : t('common.disconnected') }}
</span> </span>
<template v-if="msdConnected && connectedImageName"> <template v-if="msdConnected">
<span class="text-muted-foreground">·</span> <span class="text-muted-foreground">·</span>
<Tooltip>
<TooltipTrigger as-child>
<span class="truncate max-w-[180px] cursor-help">{{ connectedImageName }}</span>
</TooltipTrigger>
<TooltipContent>
<p>{{ connectedImageName }}</p>
</TooltipContent>
</Tooltip>
<Badge variant="secondary" class="text-xs">{{ msdMode === 'drive' ? t('msd.drive') : t('msd.images') }}</Badge> <Badge variant="secondary" class="text-xs">{{ msdMode === 'drive' ? t('msd.drive') : t('msd.images') }}</Badge>
<Button <Button
variant="outline" variant="outline"
@@ -712,17 +815,53 @@ onUnmounted(() => {
<template v-else> <template v-else>
<!-- Drive Info Card --> <!-- Drive Info Card -->
<div class="shrink-0 p-3 rounded-lg border space-y-3" :class="msdConnected && msdMode === 'drive' ? 'border-primary bg-primary/5' : 'bg-muted/50'"> <div
class="shrink-0 p-3 rounded-lg border space-y-3"
:class="msdConnected && msdMode === 'drive'
? 'border-primary bg-primary/5'
: driveError
? 'border-destructive/40 bg-destructive/5'
: 'bg-muted/50'"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<HardDrive class="h-4 w-4 text-muted-foreground" /> <HardDrive class="h-4 w-4 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('msd.drive') }}</span> <span class="text-sm font-medium">{{ t('msd.drive') }}</span>
<Badge variant="outline" class="text-xs"> <!-- Show size badge only when info is available -->
<Badge v-if="driveInfo" variant="outline" class="text-xs">
{{ Math.round((driveInfo?.size || 0) / 1024 / 1024) }} MB {{ Math.round((driveInfo?.size || 0) / 1024 / 1024) }} MB
</Badge> </Badge>
<!-- Show unreadable badge when format is wrong -->
<template v-else-if="driveError">
<Badge variant="outline" class="text-xs border-destructive/50 text-destructive">
{{ t('msd.driveUnreadable') }}
</Badge>
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-foreground">
<Info class="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('msd.driveUnreadableTooltip') }}</p>
</TooltipContent>
</Tooltip>
</template>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<template v-if="msdConnected && msdMode === 'drive'"> <!-- When drive format is unrecognized, only offer re-initialization -->
<template v-if="driveError && !msdConnected">
<Button
variant="outline"
size="sm"
class="h-7 text-xs"
:disabled="operationInProgress"
@click="initializeDrive"
>
{{ t('msd.reinitializeDrive') }}
</Button>
</template>
<template v-else-if="msdConnected && msdMode === 'drive'">
<Badge variant="default" class="text-xs h-7 px-2"> <Badge variant="default" class="text-xs h-7 px-2">
<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="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
@@ -755,10 +894,9 @@ onUnmounted(() => {
</Button> </Button>
</div> </div>
</div> </div>
<!-- Storage usage bar --> <!-- Storage usage bar hidden when format is unrecognized -->
<div class="space-y-1.5"> <div v-if="driveInfo" class="space-y-1.5">
<Progress <Progress
v-if="driveInfo"
:model-value="driveInfo.size > 0 ? (driveInfo.used / driveInfo.size) * 100 : 0" :model-value="driveInfo.size > 0 ? (driveInfo.used / driveInfo.size) * 100 : 0"
class="h-2" class="h-2"
/> />
@@ -769,8 +907,10 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- File Browser --> <!-- File Browser -->
<div class="flex-1 min-h-0 flex flex-col space-y-2"> <div class="flex-1 min-h-0 flex flex-col space-y-2">
<!-- Toolbar --> <!-- Toolbar -->
<div class="shrink-0 flex items-center justify-between gap-2"> <div class="shrink-0 flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1"> <div class="flex items-center gap-1 min-w-0 flex-1">
@@ -779,6 +919,7 @@ onUnmounted(() => {
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7 shrink-0" class="h-7 w-7 shrink-0"
:disabled="driveConnectedToTarget"
@click="navigateUp" @click="navigateUp"
> >
<ArrowLeft class="h-3.5 w-3.5" /> <ArrowLeft class="h-3.5 w-3.5" />
@@ -788,25 +929,63 @@ onUnmounted(() => {
<ChevronRight v-if="index > 0" class="h-3 w-3 text-muted-foreground mx-0.5 shrink-0" /> <ChevronRight v-if="index > 0" class="h-3 w-3 text-muted-foreground mx-0.5 shrink-0" />
<button <button
class="hover:text-primary transition-colors truncate" class="hover:text-primary transition-colors truncate"
:class="index === breadcrumbs.length - 1 ? 'font-medium' : 'text-muted-foreground'" :class="[
@click="navigateTo(crumb.path)" index === breadcrumbs.length - 1 ? 'font-medium' : 'text-muted-foreground',
driveConnectedToTarget ? 'cursor-not-allowed opacity-50' : ''
]"
:disabled="driveConnectedToTarget"
@click="!driveConnectedToTarget && navigateTo(crumb.path)"
> >
{{ crumb.name }} {{ crumb.name }}
</button> </button>
</template> </template>
</nav> </nav>
</div> </div>
<div class="shrink-0 flex items-center gap-1 shrink-0"> <div class="shrink-0 flex items-center gap-1">
<Tooltip>
<TooltipTrigger as-child>
<label> <label>
<input type="file" class="hidden" :disabled="uploadingFile" @change="handleFileUpload" /> <!-- ③ Upload disabled when drive connected to target -->
<Button variant="ghost" size="icon" as="span" class="h-7 w-7 cursor-pointer"> <input type="file" class="hidden" :disabled="uploadingFile || driveConnectedToTarget" @change="handleFileUpload" />
<Button
variant="ghost"
size="icon"
as="span"
class="h-7 w-7"
: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" />
</Button> </Button>
</label> </label>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showNewFolderDialog = true"> </TooltipTrigger>
<TooltipContent v-if="driveConnectedToTarget">
<p>{{ t('msd.driveConnectedBlocked') }}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<!-- ③ New folder disabled when drive connected to target -->
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="driveConnectedToTarget"
@click="showNewFolderDialog = true"
>
<FolderPlus class="h-3.5 w-3.5" /> <FolderPlus class="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadDriveFiles"> </TooltipTrigger>
<TooltipContent v-if="driveConnectedToTarget">
<p>{{ t('msd.driveConnectedBlocked') }}</p>
</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="driveConnectedToTarget"
@click="loadDriveFiles"
>
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingDrive }" /> <RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingDrive }" />
</Button> </Button>
</div> </div>
@@ -815,10 +994,21 @@ onUnmounted(() => {
<Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1 shrink-0" /> <Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1 shrink-0" />
<!-- File List --> <!-- File List -->
<div v-if="driveFiles.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm"> <div
v-if="driveFiles.length === 0 && !driveConnectedToTarget && !driveError"
class="shrink-0 text-center py-6 text-muted-foreground text-sm"
>
{{ t('msd.emptyFolder') }} {{ t('msd.emptyFolder') }}
</div> </div>
<!-- Connected placeholder: file list hidden while drive mounted on target -->
<div
v-else-if="driveConnectedToTarget"
class="shrink-0 text-center py-6 text-muted-foreground text-sm"
>
{{ t('msd.driveConnectedFilesHidden') }}
</div>
<div v-else class="flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar"> <div v-else class="flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar">
<div class="space-y-1"> <div class="space-y-1">
<div <div
@@ -858,10 +1048,12 @@ onUnmounted(() => {
> >
<Download class="h-3.5 w-3.5" /> <Download class="h-3.5 w-3.5" />
</Button> </Button>
<!-- ③ Delete disabled when drive connected to target -->
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7 text-destructive" class="h-7 w-7 text-destructive"
:disabled="driveConnectedToTarget"
@click="confirmDelete('file', file.path, file.name)" @click="confirmDelete('file', file.path, file.name)"
> >
<Trash2 class="h-3.5 w-3.5" /> <Trash2 class="h-3.5 w-3.5" />
@@ -929,54 +1121,55 @@ onUnmounted(() => {
<DialogDescription>{{ t('msd.selectDriveSize') }}</DialogDescription> <DialogDescription>{{ t('msd.selectDriveSize') }}</DialogDescription>
</DialogHeader> </DialogHeader>
<div class="space-y-6 py-4">
<div class="space-y-4"> <div class="space-y-4">
<!-- Preset size selection --> <div class="flex items-center justify-between">
<div class="space-y-2">
<Label>{{ t('msd.driveSize') }}</Label> <Label>{{ t('msd.driveSize') }}</Label>
<RadioGroup v-model="selectedDriveSize">
<div v-for="size in driveSizeOptions" :key="size.value" class="flex items-center space-x-2">
<RadioGroupItem :id="`size-${size.value}`" :value="size.value" />
<Label :for="`size-${size.value}`" class="font-normal cursor-pointer flex-1">
{{ size.label }}
</Label>
</div>
</RadioGroup>
</div>
<!-- Custom size -->
<div class="space-y-2">
<Label for="custom-size">{{ t('msd.customSize') }}</Label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Input <Input
id="custom-size" v-model.number="driveSizeMB"
v-model.number="customDriveSize"
type="number" type="number"
:min="64" :min="MIN_DRIVE_SIZE_MB"
:max="32768" :max="sliderMaxDriveSizeMB"
placeholder="256" class="w-24 text-right"
class="flex-1" :disabled="!canInitializeDrive || initializingDrive"
@blur="driveSizeMB = finalDriveSize"
/> />
<span class="text-sm text-muted-foreground">MB</span> <span class="text-sm text-muted-foreground">MB</span>
</div> </div>
<p class="text-xs text-muted-foreground">
{{ t('msd.driveSizeHint') }}
</p>
</div> </div>
<!-- Final size display --> <Slider
<div class="p-3 rounded-lg bg-muted/50"> :model-value="[driveSizeMB]"
<div class="flex items-center justify-between text-sm"> @update:model-value="updateDriveSizeFromSlider"
<span class="text-muted-foreground">{{ t('msd.selectedSize') }}:</span> :min="MIN_DRIVE_SIZE_MB"
<span class="font-medium">{{ finalDriveSize }} MB</span> :max="sliderMaxDriveSizeMB"
:step="1"
:disabled="!canInitializeDrive || initializingDrive"
class="w-full"
/>
<div class="flex justify-between text-xs text-muted-foreground">
<span>{{ MIN_DRIVE_SIZE_MB }} MB</span>
<span>
{{ availableDriveSizeMB === null ? t('msd.driveSpaceUnknown') : formatBytes((availableDriveSizeMB || 0) * BYTES_PER_MB) }}
</span>
</div> </div>
</div> </div>
<p v-if="availableDriveSizeMB === null" class="text-xs text-destructive">
{{ t('msd.driveSpaceUnknown') }}
</p>
<p v-else-if="availableDriveSizeMB < MIN_DRIVE_SIZE_MB" class="text-xs text-destructive">
{{ t('msd.driveSpaceTooSmall', { min: MIN_DRIVE_SIZE_MB }) }}
</p>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" @click="showDriveInitDialog = false" :disabled="initializingDrive"> <Button variant="outline" @click="showDriveInitDialog = false" :disabled="initializingDrive">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</Button> </Button>
<Button @click="createDrive" :disabled="initializingDrive"> <Button @click="createDrive" :disabled="initializingDrive || !canInitializeDrive">
<span v-if="initializingDrive">{{ t('common.creating') }}...</span> <span v-if="initializingDrive">{{ t('common.creating') }}...</span>
<span v-else>{{ t('common.create') }}</span> <span v-else>{{ t('common.create') }}</span>
</Button> </Button>

View File

@@ -478,12 +478,20 @@ export default {
driveConnected: 'Virtual USB drive connected', driveConnected: 'Virtual USB drive connected',
imageConnected: 'Image {name} connected', imageConnected: 'Image {name} connected',
selectDriveSize: 'Select virtual drive size', selectDriveSize: 'Select virtual drive size',
selectedSize: 'Selected size',
customSize: 'Custom size', customSize: 'Custom size',
driveSizeHint: 'Custom size overrides selection above (64MB - 32GB)', 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',
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',
driveCreated: 'Virtual drive created ({size} MB)', driveCreated: 'Virtual drive created ({size} MB)',
fileDeleted: 'File deleted', fileDeleted: 'File deleted',
imageDeleted: 'Image deleted', imageDeleted: 'Image deleted',
driveConnectedBlocked: 'Disconnect the drive before editing files',
driveConnectedFilesHidden: 'Drive is connected to target machine — file list unavailable',
uploadFailed: 'File upload failed',
driveUnreadable: 'Format unsupported',
driveUnreadableTooltip: 'Unable to parse the exFAT filesystem. It may have been formatted with an unsupported format.',
reinitializeDrive: 'Re-initialize',
}, },
settings: { settings: {
title: 'Settings', title: 'Settings',

View File

@@ -477,12 +477,20 @@ export default {
driveConnected: '虚拟U盘已连接', driveConnected: '虚拟U盘已连接',
imageConnected: '镜像 {name} 已连接', imageConnected: '镜像 {name} 已连接',
selectDriveSize: '选择虚拟驱动器大小', selectDriveSize: '选择虚拟驱动器大小',
selectedSize: '选定大小',
customSize: '自定义大小', customSize: '自定义大小',
driveSizeHint: '输入自定义大小会覆盖上方选择 (64MB - 32GB)', driveSpaceUnknown: '无法读取 MSD 目录所在储存空间的剩余可用空间',
driveSpaceUnavailable: 'MSD 目录所在储存空间不足无法创建虚拟U盘',
driveSpaceTooSmall: '剩余可用空间不足 {min} MB无法创建虚拟U盘',
driveCreateFailed: '虚拟U盘创建失败请检查 MSD 目录剩余空间',
driveCreated: '虚拟驱动器已创建 ({size} MB)', driveCreated: '虚拟驱动器已创建 ({size} MB)',
fileDeleted: '文件已删除', fileDeleted: '文件已删除',
imageDeleted: '镜像已删除', imageDeleted: '镜像已删除',
driveConnectedBlocked: '请先断开连接,再操作文件',
driveConnectedFilesHidden: '已连接到被控机,文件列表暂不可用',
uploadFailed: '文件上传失败',
driveUnreadable: '格式不支持',
driveUnreadableTooltip: '无法解析 exFAT 文件系统,可能已被格式化为不支持的格式。',
reinitializeDrive: '重新初始化',
}, },
settings: { settings: {
title: '系统设置', title: '系统设置',