mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-07-29 07:31:45 +08:00
feat: 优化 MSD 功能体验和控件样式
This commit is contained in:
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,46 +1367,54 @@ 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<()> {
|
||||||
// Validate filename
|
let was_dirty = self.begin_write_transaction()?;
|
||||||
if name.is_empty() || name.len() > 255 {
|
let result = (|| {
|
||||||
return Err(VentoyError::FilesystemError(
|
// Validate filename
|
||||||
"Invalid filename length".to_string(),
|
if name.is_empty() || name.len() > 255 {
|
||||||
));
|
return Err(VentoyError::FilesystemError(
|
||||||
}
|
"Invalid filename length".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if self.find_file_entry(name)?.is_some() {
|
if self.find_file_entry(name)?.is_some() {
|
||||||
return Err(VentoyError::FilesystemError(format!(
|
|
||||||
"File '{}' already exists",
|
|
||||||
name
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
self.write_file_data_and_entry(self.first_cluster_of_root, name, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write a file to the filesystem with overwrite option
|
|
||||||
pub fn write_file_overwrite(&mut self, name: &str, data: &[u8], overwrite: bool) -> Result<()> {
|
|
||||||
// Validate filename
|
|
||||||
if name.is_empty() || name.len() > 255 {
|
|
||||||
return Err(VentoyError::FilesystemError(
|
|
||||||
"Invalid filename length".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if file already exists
|
|
||||||
if self.find_file_entry(name)?.is_some() {
|
|
||||||
if overwrite {
|
|
||||||
self.delete_file(name)?;
|
|
||||||
} else {
|
|
||||||
return Err(VentoyError::FilesystemError(format!(
|
return Err(VentoyError::FilesystemError(format!(
|
||||||
"File '{}' already exists",
|
"File '{}' already exists",
|
||||||
name
|
name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
|
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
|
||||||
|
if name.is_empty() || name.len() > 255 {
|
||||||
|
return Err(VentoyError::FilesystemError(
|
||||||
|
"Invalid filename length".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file already exists
|
||||||
|
if self.find_file_entry(name)?.is_some() {
|
||||||
|
if overwrite {
|
||||||
|
self.delete_file(name)?;
|
||||||
|
} else {
|
||||||
|
return Err(VentoyError::FilesystemError(format!(
|
||||||
|
"File '{}' already exists",
|
||||||
|
name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,38 +1429,42 @@ impl ExfatFs {
|
|||||||
create_parents: bool,
|
create_parents: bool,
|
||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let resolved = self.resolve_path(path, create_parents)?;
|
let was_dirty = self.begin_write_transaction()?;
|
||||||
|
let result = (|| {
|
||||||
|
let resolved = self.resolve_path(path, create_parents)?;
|
||||||
|
|
||||||
// Validate filename
|
// Validate filename
|
||||||
if resolved.name.is_empty() || resolved.name.len() > 255 {
|
if resolved.name.is_empty() || resolved.name.len() > 255 {
|
||||||
return Err(VentoyError::FilesystemError(
|
return Err(VentoyError::FilesystemError(
|
||||||
"Invalid filename length".to_string(),
|
"Invalid filename length".to_string(),
|
||||||
));
|
));
|
||||||
}
|
|
||||||
|
|
||||||
// Handle existing file
|
|
||||||
if let Some(location) = resolved.location {
|
|
||||||
if location.is_directory {
|
|
||||||
return Err(VentoyError::FilesystemError(format!(
|
|
||||||
"'{}' is a directory",
|
|
||||||
path
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
if overwrite {
|
|
||||||
// Delete existing file
|
// Handle existing file
|
||||||
if location.first_cluster >= 2 {
|
if let Some(location) = resolved.location {
|
||||||
self.free_cluster_chain(location.first_cluster)?;
|
if location.is_directory {
|
||||||
|
return Err(VentoyError::FilesystemError(format!(
|
||||||
|
"'{}' is a directory",
|
||||||
|
path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if overwrite {
|
||||||
|
// Delete existing file
|
||||||
|
if location.first_cluster >= 2 {
|
||||||
|
self.free_cluster_chain(location.first_cluster)?;
|
||||||
|
}
|
||||||
|
self.delete_file_entry(&location)?;
|
||||||
|
} else {
|
||||||
|
return Err(VentoyError::FilesystemError(format!(
|
||||||
|
"File '{}' already exists",
|
||||||
|
path
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
self.delete_file_entry(&location)?;
|
|
||||||
} else {
|
|
||||||
return Err(VentoyError::FilesystemError(format!(
|
|
||||||
"File '{}' already exists",
|
|
||||||
path
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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,99 +1535,115 @@ 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 {
|
||||||
self.free_cluster_chain(location.first_cluster)?;
|
self.free_cluster_chain(location.first_cluster)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete directory entry
|
// Delete directory entry
|
||||||
self.delete_file_entry(&location)?;
|
self.delete_file_entry(&location)?;
|
||||||
|
|
||||||
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 resolved = self.resolve_path(path, false)?;
|
let was_dirty = self.begin_write_transaction()?;
|
||||||
|
let result = (|| {
|
||||||
|
let resolved = self.resolve_path(path, false)?;
|
||||||
|
|
||||||
let location = resolved
|
let location = resolved
|
||||||
.location
|
.location
|
||||||
.ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?;
|
.ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?;
|
||||||
|
|
||||||
// If it's a directory, check if it's empty
|
// If it's a directory, check if it's empty
|
||||||
if location.is_directory {
|
if location.is_directory {
|
||||||
let contents = self.list_files_in_directory(location.first_cluster, "")?;
|
let contents = self.list_files_in_directory(location.first_cluster, "")?;
|
||||||
if !contents.is_empty() {
|
if !contents.is_empty() {
|
||||||
return Err(VentoyError::FilesystemError(format!(
|
return Err(VentoyError::FilesystemError(format!(
|
||||||
"Directory '{}' is not empty",
|
"Directory '{}' is not empty",
|
||||||
path
|
path
|
||||||
)));
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Free cluster chain
|
// Free cluster chain
|
||||||
if location.first_cluster >= 2 {
|
if location.first_cluster >= 2 {
|
||||||
self.free_cluster_chain(location.first_cluster)?;
|
self.free_cluster_chain(location.first_cluster)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete directory entry
|
// Delete directory entry
|
||||||
self.delete_file_entry(&location)?;
|
self.delete_file_entry(&location)?;
|
||||||
|
|
||||||
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 resolved = self.resolve_path(path, false)?;
|
let was_dirty = self.begin_write_transaction()?;
|
||||||
|
let result = (|| {
|
||||||
|
let resolved = self.resolve_path(path, false)?;
|
||||||
|
|
||||||
let location = resolved
|
let location = resolved
|
||||||
.location
|
.location
|
||||||
.ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?;
|
.ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?;
|
||||||
|
|
||||||
if location.is_directory {
|
if location.is_directory {
|
||||||
// Get all contents and delete them first
|
// Get all contents and delete them first
|
||||||
let contents = self.list_files_in_directory(location.first_cluster, "")?;
|
let contents = self.list_files_in_directory(location.first_cluster, "")?;
|
||||||
for item in contents {
|
for item in contents {
|
||||||
let item_path = if path.ends_with('/') {
|
let item_path = if path.ends_with('/') {
|
||||||
format!("{}{}", path, item.name)
|
format!("{}{}", path, item.name)
|
||||||
} else {
|
} else {
|
||||||
format!("{}/{}", path, item.name)
|
format!("{}/{}", path, item.name)
|
||||||
};
|
};
|
||||||
self.delete_recursive(&item_path)?;
|
self.delete_recursive(&item_path)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Now delete the item itself
|
// Now delete the item itself
|
||||||
if location.first_cluster >= 2 {
|
if location.first_cluster >= 2 {
|
||||||
self.free_cluster_chain(location.first_cluster)?;
|
self.free_cluster_chain(location.first_cluster)?;
|
||||||
}
|
}
|
||||||
self.delete_file_entry(&location)?;
|
self.delete_file_entry(&location)?;
|
||||||
|
|
||||||
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 resolved = self.resolve_path(path, create_parents)?;
|
let was_dirty = self.begin_write_transaction()?;
|
||||||
|
let result = (|| {
|
||||||
|
let resolved = self.resolve_path(path, create_parents)?;
|
||||||
|
|
||||||
if resolved.location.is_some() {
|
if resolved.location.is_some() {
|
||||||
return Err(VentoyError::FilesystemError(format!(
|
return Err(VentoyError::FilesystemError(format!(
|
||||||
"'{}' already exists",
|
"'{}' already exists",
|
||||||
path
|
path
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
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 mut writer = ExfatFileWriter::create(self, name, size)?;
|
let was_dirty = self.begin_write_transaction()?;
|
||||||
Self::do_stream_write(&mut writer, reader)?;
|
let result = (|| {
|
||||||
writer.finish()
|
let mut writer = ExfatFileWriter::create(self, name, size)?;
|
||||||
|
Self::do_stream_write(&mut writer, reader)?;
|
||||||
|
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 mut writer = ExfatFileWriter::create_overwrite(self, name, size, overwrite)?;
|
let was_dirty = self.begin_write_transaction()?;
|
||||||
Self::do_stream_write(&mut writer, reader)?;
|
let result = (|| {
|
||||||
writer.finish()
|
let mut writer = ExfatFileWriter::create_overwrite(self, name, size, overwrite)?;
|
||||||
|
Self::do_stream_write(&mut writer, reader)?;
|
||||||
|
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 mut writer =
|
let was_dirty = self.begin_write_transaction()?;
|
||||||
ExfatFileWriter::create_at_path(self, path, size, create_parents, overwrite)?;
|
let result = (|| {
|
||||||
Self::do_stream_write(&mut writer, reader)?;
|
let mut writer =
|
||||||
writer.finish()
|
ExfatFileWriter::create_at_path(self, path, size, create_parents, overwrite)?;
|
||||||
|
Self::do_stream_write(&mut writer, reader)?;
|
||||||
|
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<()> {
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<{
|
||||||
method: 'POST',
|
size: number
|
||||||
body: JSON.stringify({ size_mb: sizeMb }),
|
used: number
|
||||||
}),
|
free: number
|
||||||
|
initialized: boolean
|
||||||
|
}>(
|
||||||
|
'/msd/drive/init',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
driveInitialized.value = false
|
if (e instanceof ApiError) {
|
||||||
|
if (e.status === 404) {
|
||||||
|
// Drive image file does not exist — truly not initialized
|
||||||
|
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">
|
||||||
<label>
|
<Tooltip>
|
||||||
<input type="file" class="hidden" :disabled="uploadingFile" @change="handleFileUpload" />
|
<TooltipTrigger as-child>
|
||||||
<Button variant="ghost" size="icon" as="span" class="h-7 w-7 cursor-pointer">
|
<label>
|
||||||
<Upload class="h-3.5 w-3.5" />
|
<!-- ③ Upload disabled when drive connected to target -->
|
||||||
</Button>
|
<input type="file" class="hidden" :disabled="uploadingFile || driveConnectedToTarget" @change="handleFileUpload" />
|
||||||
</label>
|
<Button
|
||||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showNewFolderDialog = true">
|
variant="ghost"
|
||||||
<FolderPlus class="h-3.5 w-3.5" />
|
size="icon"
|
||||||
</Button>
|
as="span"
|
||||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadDriveFiles">
|
class="h-7 w-7"
|
||||||
|
:class="driveConnectedToTarget ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'"
|
||||||
|
>
|
||||||
|
<Upload class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
</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" />
|
||||||
|
</Button>
|
||||||
|
</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-4">
|
<div class="space-y-6 py-4">
|
||||||
<!-- Preset size selection -->
|
<div class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="flex items-center justify-between">
|
||||||
<Label>{{ t('msd.driveSize') }}</Label>
|
<Label>{{ t('msd.driveSize') }}</Label>
|
||||||
<RadioGroup v-model="selectedDriveSize">
|
<div class="flex items-center gap-2">
|
||||||
<div v-for="size in driveSizeOptions" :key="size.value" class="flex items-center space-x-2">
|
<Input
|
||||||
<RadioGroupItem :id="`size-${size.value}`" :value="size.value" />
|
v-model.number="driveSizeMB"
|
||||||
<Label :for="`size-${size.value}`" class="font-normal cursor-pointer flex-1">
|
type="number"
|
||||||
{{ size.label }}
|
:min="MIN_DRIVE_SIZE_MB"
|
||||||
</Label>
|
:max="sliderMaxDriveSizeMB"
|
||||||
|
class="w-24 text-right"
|
||||||
|
:disabled="!canInitializeDrive || initializingDrive"
|
||||||
|
@blur="driveSizeMB = finalDriveSize"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-muted-foreground">MB</span>
|
||||||
</div>
|
</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">
|
|
||||||
<Input
|
|
||||||
id="custom-size"
|
|
||||||
v-model.number="customDriveSize"
|
|
||||||
type="number"
|
|
||||||
:min="64"
|
|
||||||
:max="32768"
|
|
||||||
placeholder="256"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-muted-foreground">MB</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
{{ t('msd.driveSizeHint') }}
|
|
||||||
</p>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '系统设置',
|
||||||
|
|||||||
Reference in New Issue
Block a user