Merge branch 'dev'

This commit is contained in:
SilentWind
2026-07-08 07:07:03 +08:00
committed by GitHub
64 changed files with 7567 additions and 1658 deletions

View File

@@ -139,126 +139,9 @@ jobs:
if-no-files-found: error
retention-days: 7
android:
runs-on: ubuntu-22.04
needs: frontend
timeout-minutes: 240
steps:
- uses: actions/checkout@v4
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: web-dist
path: web/dist
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Android Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache/android
key: android-buildx-${{ runner.os }}-${{ hashFiles('build/cross/Dockerfile.android') }}
restore-keys: |
android-buildx-${{ runner.os }}-
- name: Build Android Docker image
uses: docker/build-push-action@v6
with:
context: build/cross
file: build/cross/Dockerfile.android
tags: one-kvm-android-build:ci
load: true
cache-from: type=local,src=/tmp/.buildx-cache/android
cache-to: type=local,dest=/tmp/.buildx-cache/android-new,mode=max
- name: Rotate Android Docker layer cache
run: |
rm -rf /tmp/.buildx-cache/android
mv /tmp/.buildx-cache/android-new /tmp/.buildx-cache/android
- name: Cache Android build dependencies
uses: actions/cache@v4
with:
path: |
.github/android-cache/gradle
.github/android-cache/cargo-registry
.github/android-cache/cargo-git
.tmp/android-ffmpeg-check
.tmp/android-turbojpeg-src
.tmp/android-libyuv-src
.tmp/android-alsa-src
.tmp/android-opus-src
dist/android-ffmpeg-mediacodec
dist/android-turbojpeg
dist/android-libyuv
dist/android-alsa
dist/android-opus
key: android-deps-${{ runner.os }}-${{ hashFiles('android/**/*.gradle.kts', 'android/gradle/wrapper/gradle-wrapper.properties', 'android/native/Cargo.lock', 'Cargo.lock', 'scripts/build-android-*.sh') }}
restore-keys: |
android-deps-${{ runner.os }}-
- name: Prepare Android FFmpeg source
run: |
chmod +x android/gradlew
if [ ! -x .tmp/android-ffmpeg-check/src/ffmpeg-rockchip/configure ]; then
rm -rf .tmp/android-ffmpeg-check/src
mkdir -p .tmp/android-ffmpeg-check/src
wget -q https://files.mofeng.run/src/image/other/ffmpeg.tar.gz -O .tmp/android-ffmpeg-check/ffmpeg.tar.gz
tar -xzf .tmp/android-ffmpeg-check/ffmpeg.tar.gz -C .tmp/android-ffmpeg-check/src --strip-components=1
fi
- name: Build Android APK
env:
ONE_KVM_ANDROID_DOCKER_IMAGE: one-kvm-android-build:ci
ONE_KVM_ANDROID_SKIP_DOCKER_BUILD: "1"
ONE_KVM_ANDROID_GRADLE_CACHE_DIR: ${{ github.workspace }}/.github/android-cache/gradle
ONE_KVM_ANDROID_CARGO_REGISTRY_CACHE_DIR: ${{ github.workspace }}/.github/android-cache/cargo-registry
ONE_KVM_ANDROID_CARGO_GIT_CACHE_DIR: ${{ github.workspace }}/.github/android-cache/cargo-git
run: bash build/build-android.sh all
- name: Fix Android build permissions
if: ${{ always() }}
run: |
paths=(
.github/android-cache
.tmp/android-ffmpeg-check
.tmp/android-turbojpeg-src
.tmp/android-libyuv-src
.tmp/android-alsa-src
.tmp/android-opus-src
dist/android-ffmpeg-mediacodec
dist/android-turbojpeg
dist/android-libyuv
dist/android-alsa
dist/android-opus
target/android
android/.gradle
android/app/build
android/native/target
)
existing=()
for path in "${paths[@]}"; do
if [ -e "$path" ]; then
existing+=("$path")
fi
done
if [ "${#existing[@]}" -gt 0 ]; then
sudo chown -R "$USER:$USER" "${existing[@]}"
fi
- name: Upload Android APK
uses: actions/upload-artifact@v4
with:
name: one-kvm-android-apk
path: target/android/one-kvm_*.apk
if-no-files-found: error
retention-days: 7
release:
runs-on: ubuntu-22.04
needs: [deb, windows, android]
needs: [deb, windows]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_release }}
timeout-minutes: 30
permissions:
@@ -283,12 +166,6 @@ jobs:
name: one-kvm-windows-exe
path: release-artifacts/windows
- name: Download Android artifact
uses: actions/download-artifact@v4
with:
name: one-kvm-android-apk
path: release-artifacts/android
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
@@ -298,4 +175,3 @@ jobs:
files: |
release-artifacts/deb/*.deb
release-artifacts/windows/*.exe
release-artifacts/android/*.apk

View File

@@ -1,6 +1,6 @@
[package]
name = "one-kvm"
version = "0.2.3"
version = "0.2.4"
edition = "2021"
authors = ["SilentWind"]
description = "A open and lightweight IP-KVM solution written in Rust"

View File

@@ -2,21 +2,6 @@ use std::fs;
use std::path::Path;
fn main() {
// Set BUILD_DATE environment variable for compile-time access
// Use system time to avoid adding chrono as a build dependency
let now = std::time::SystemTime::now();
let duration = now.duration_since(std::time::UNIX_EPOCH).unwrap();
let secs = duration.as_secs();
// Convert Unix timestamp to date (simplified calculation)
// Days since epoch
let days = secs / 86400;
// Calculate year, month, day from days since 1970-01-01
let (year, month, day) = days_to_ymd(days as i64);
let build_date = format!("{:04}-{:02}-{:02}", year, month, day);
println!("cargo:rustc-env=BUILD_DATE={}", build_date);
// Compile protobuf files for RustDesk protocol
compile_protos();
@@ -86,19 +71,3 @@ pub mod rustdesk {
fs::write(&dest_path, code).expect("Failed to write secrets_generated.rs");
}
/// Convert days since Unix epoch to year-month-day
fn days_to_ymd(days: i64) -> (i32, u32, u32) {
// Algorithm from http://howardhinnant.github.io/date_algorithms.html
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if m <= 2 { y + 1 } else { y };
(year as i32, m, d)
}

View File

@@ -15,6 +15,8 @@ ARG LIBYUV_REV=957f295ea946cbbd13fcfc46e7066f2efa801233
ARG LIBVPX_VERSION=1.16.0
ARG X265_VERSION=3.4
ARG OPUS_VERSION=1.5.2
ARG RKMPP_BRANCH=jellyfin-mpp
ARG RKRGA_BRANCH=jellyfin-rga
ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b
# Optionally use China mirrors for builds in China.
@@ -252,8 +254,8 @@ RUN github_prefix="" \
RUN mkdir -p /tmp/ffmpeg-build && cd /tmp/ffmpeg-build \
&& github_prefix="" \
&& if [ "$CHINAMIRRO" = "1" ]; then github_prefix="${GH_PROXY%/}/"; fi \
&& git clone --depth 1 "https://gitee.com/nyanmisaka/mpp.git" rkmpp \
&& git clone --depth 1 "https://gitee.com/nyanmisaka/rga.git" rkrga \
&& git clone --depth 1 --branch ${RKMPP_BRANCH} "${github_prefix}https://github.com/nyanmisaka/mpp.git" rkmpp \
&& git clone --depth 1 --branch ${RKRGA_BRANCH} "${github_prefix}https://github.com/nyanmisaka/rk-mirrors.git" rkrga \
&& git init ffmpeg-rockchip \
&& cd ffmpeg-rockchip \
&& git remote add origin "${github_prefix}https://github.com/nyanmisaka/ffmpeg-rockchip.git" \

View File

@@ -15,6 +15,8 @@ ARG LIBYUV_REV=957f295ea946cbbd13fcfc46e7066f2efa801233
ARG LIBVPX_VERSION=1.16.0
ARG X265_VERSION=3.4
ARG OPUS_VERSION=1.5.2
ARG RKMPP_BRANCH=jellyfin-mpp
ARG RKRGA_BRANCH=jellyfin-rga
ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b
# Optionally use China mirrors for builds in China.
@@ -241,8 +243,8 @@ RUN github_prefix="" \
RUN mkdir -p /tmp/ffmpeg-build && cd /tmp/ffmpeg-build \
&& github_prefix="" \
&& if [ "$CHINAMIRRO" = "1" ]; then github_prefix="${GH_PROXY%/}/"; fi \
&& git clone --depth 1 "https://gitee.com/nyanmisaka/mpp.git" rkmpp \
&& git clone --depth 1 "https://gitee.com/nyanmisaka/rga.git" rkrga \
&& git clone --depth 1 --branch ${RKMPP_BRANCH} "${github_prefix}https://github.com/nyanmisaka/mpp.git" rkmpp \
&& git clone --depth 1 --branch ${RKRGA_BRANCH} "${github_prefix}https://github.com/nyanmisaka/rk-mirrors.git" rkrga \
&& git init ffmpeg-rockchip \
&& cd ffmpeg-rockchip \
&& git remote add origin "${github_prefix}https://github.com/nyanmisaka/ffmpeg-rockchip.git" \

View File

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

View File

@@ -14,9 +14,13 @@ use std::path::Path;
/// FAT entry values
const FAT_ENTRY_FREE: u32 = 0x00000000;
const FAT_ENTRY_END_OF_CHAIN: u32 = 0xFFFFFFFF;
const VOLUME_DIRTY_FLAG: u16 = 0x0002;
const VOLUME_FLAGS_OFFSET: u64 = 106;
/// Directory entry types
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_STREAM: u8 = 0xC0;
const ENTRY_TYPE_FILE_NAME: u8 = 0xC1;
@@ -137,6 +141,11 @@ pub struct ExfatFs {
cluster_heap_offset: u32,
cluster_count: 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
/// FAT table segment cache
fat_cache: FatCache,
@@ -182,7 +191,7 @@ impl ExfatFs {
let sectors_per_cluster = 1u32 << sectors_per_cluster_shift;
let cluster_size = bytes_per_sector * sectors_per_cluster;
Ok(Self {
let mut fs = Self {
file,
partition_offset,
bytes_per_sector,
@@ -193,11 +202,151 @@ impl ExfatFs {
cluster_heap_offset,
cluster_count,
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
fat_cache: FatCache::new(),
bitmap_cache: None,
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 ====================
@@ -328,13 +477,89 @@ impl ExfatFs {
// ==================== 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)
fn read_bitmap(&mut self) -> Result<Vec<u8>> {
if let Some(ref bitmap) = self.bitmap_cache {
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());
Ok(bitmap)
}
@@ -342,7 +567,10 @@ impl ExfatFs {
/// Get a mutable reference to the cached bitmap, loading if necessary
fn get_bitmap_mut(&mut self) -> Result<&mut Vec<u8>> {
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);
}
Ok(self.bitmap_cache.as_mut().unwrap())
@@ -351,7 +579,11 @@ impl ExfatFs {
/// Write the allocation bitmap (with cache management)
#[allow(dead_code)]
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_dirty = false;
Ok(())
@@ -362,7 +594,11 @@ impl ExfatFs {
fn flush_bitmap(&mut self) -> Result<()> {
if self.bitmap_dirty {
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_dirty = false;
}
@@ -400,10 +636,7 @@ impl ExfatFs {
let bitmap = self.read_bitmap()?;
let mut free_clusters = Vec::with_capacity(count);
// Start from cluster after root directory
// (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 {
for cluster in 2..self.cluster_count + 2 {
if !Self::is_cluster_allocated(&bitmap, cluster) {
free_clusters.push(cluster);
if free_clusters.len() >= count {
@@ -482,7 +715,11 @@ impl ExfatFs {
/// Flush bitmap to disk immediately
fn flush_bitmap_now(&mut self) -> Result<()> {
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);
}
Ok(())
@@ -823,10 +1060,12 @@ impl ExfatFs {
// Exclude the newly added 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) {
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];
self.write_cluster(dir_cluster, &empty_cluster)?;
// Create directory entry in parent
self.create_entry_in_directory(parent_cluster, name, dir_cluster, 0, true)?;
// exFAT directories have allocated data. A zero-length directory stream
// 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()?;
Ok(dir_cluster)
@@ -1120,46 +1367,54 @@ impl ExfatFs {
/// Write a file to the filesystem (root directory, no overwrite)
pub fn write_file(&mut self, name: &str, data: &[u8]) -> Result<()> {
// Validate filename
if name.is_empty() || name.len() > 255 {
return Err(VentoyError::FilesystemError(
"Invalid filename length".to_string(),
));
}
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() {
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 {
// Check if file already exists
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)
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
@@ -1174,38 +1429,42 @@ impl ExfatFs {
create_parents: bool,
overwrite: 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)?;
// Validate filename
if resolved.name.is_empty() || resolved.name.len() > 255 {
return Err(VentoyError::FilesystemError(
"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
)));
// Validate filename
if resolved.name.is_empty() || resolved.name.len() > 255 {
return Err(VentoyError::FilesystemError(
"Invalid filename length".to_string(),
));
}
if overwrite {
// Delete existing file
if location.first_cluster >= 2 {
self.free_cluster_chain(location.first_cluster)?;
// 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
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
@@ -1276,99 +1535,115 @@ impl ExfatFs {
/// Delete a file from the filesystem (root directory)
pub fn delete_file(&mut self, name: &str) -> Result<()> {
let location = self
.find_file_entry(name)?
.ok_or_else(|| VentoyError::FilesystemError(format!("File '{}' not found", name)))?;
let was_dirty = self.begin_write_transaction()?;
let result = (|| {
let location = self.find_file_entry(name)?.ok_or_else(|| {
VentoyError::FilesystemError(format!("File '{}' not found", name))
})?;
// Free cluster chain
if location.first_cluster >= 2 {
self.free_cluster_chain(location.first_cluster)?;
}
// Free cluster chain
if location.first_cluster >= 2 {
self.free_cluster_chain(location.first_cluster)?;
}
// Delete directory entry
self.delete_file_entry(&location)?;
// Delete directory entry
self.delete_file_entry(&location)?;
self.file.flush()?;
Ok(())
self.file.flush()?;
Ok(())
})();
self.finish_write_transaction(was_dirty, result)
}
/// Delete a file or directory at a specific path
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
.location
.ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?;
let location = resolved
.location
.ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?;
// If it's a directory, check if it's empty
if location.is_directory {
let contents = self.list_files_in_directory(location.first_cluster, "")?;
if !contents.is_empty() {
return Err(VentoyError::FilesystemError(format!(
"Directory '{}' is not empty",
path
)));
// If it's a directory, check if it's empty
if location.is_directory {
let contents = self.list_files_in_directory(location.first_cluster, "")?;
if !contents.is_empty() {
return Err(VentoyError::FilesystemError(format!(
"Directory '{}' is not empty",
path
)));
}
}
}
// Free cluster chain
if location.first_cluster >= 2 {
self.free_cluster_chain(location.first_cluster)?;
}
// Free cluster chain
if location.first_cluster >= 2 {
self.free_cluster_chain(location.first_cluster)?;
}
// Delete directory entry
self.delete_file_entry(&location)?;
// Delete directory entry
self.delete_file_entry(&location)?;
self.file.flush()?;
Ok(())
self.file.flush()?;
Ok(())
})();
self.finish_write_transaction(was_dirty, result)
}
/// Delete a directory and all its contents recursively
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
.location
.ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?;
let location = resolved
.location
.ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?;
if location.is_directory {
// Get all contents and delete them first
let contents = self.list_files_in_directory(location.first_cluster, "")?;
for item in contents {
let item_path = if path.ends_with('/') {
format!("{}{}", path, item.name)
} else {
format!("{}/{}", path, item.name)
};
self.delete_recursive(&item_path)?;
if location.is_directory {
// Get all contents and delete them first
let contents = self.list_files_in_directory(location.first_cluster, "")?;
for item in contents {
let item_path = if path.ends_with('/') {
format!("{}{}", path, item.name)
} else {
format!("{}/{}", path, item.name)
};
self.delete_recursive(&item_path)?;
}
}
}
// Now delete the item itself
if location.first_cluster >= 2 {
self.free_cluster_chain(location.first_cluster)?;
}
self.delete_file_entry(&location)?;
// Now delete the item itself
if location.first_cluster >= 2 {
self.free_cluster_chain(location.first_cluster)?;
}
self.delete_file_entry(&location)?;
self.file.flush()?;
Ok(())
self.file.flush()?;
Ok(())
})();
self.finish_write_transaction(was_dirty, result)
}
/// Create a directory at a specific path
///
/// If create_parents is true, creates all intermediate directories (mkdir -p behavior)
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() {
return Err(VentoyError::FilesystemError(format!(
"'{}' already exists",
path
)));
}
if resolved.location.is_some() {
return Err(VentoyError::FilesystemError(format!(
"'{}' already exists",
path
)));
}
self.create_directory_in(resolved.parent_cluster, &resolved.name)?;
Ok(())
self.create_directory_in(resolved.parent_cluster, &resolved.name)?;
Ok(())
})();
self.finish_write_transaction(was_dirty, result)
}
}
@@ -1805,9 +2080,13 @@ impl ExfatFs {
reader: &mut R,
size: u64,
) -> Result<()> {
let mut writer = ExfatFileWriter::create(self, name, size)?;
Self::do_stream_write(&mut writer, reader)?;
writer.finish()
let was_dirty = self.begin_write_transaction()?;
let result = (|| {
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
@@ -1818,9 +2097,13 @@ impl ExfatFs {
size: u64,
overwrite: bool,
) -> Result<()> {
let mut writer = ExfatFileWriter::create_overwrite(self, name, size, overwrite)?;
Self::do_stream_write(&mut writer, reader)?;
writer.finish()
let was_dirty = self.begin_write_transaction()?;
let result = (|| {
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
@@ -1835,10 +2118,14 @@ impl ExfatFs {
create_parents: bool,
overwrite: bool,
) -> Result<()> {
let mut writer =
ExfatFileWriter::create_at_path(self, path, size, create_parents, overwrite)?;
Self::do_stream_write(&mut writer, reader)?;
writer.finish()
let was_dirty = self.begin_write_transaction()?;
let result = (|| {
let mut writer =
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
@@ -1861,9 +2148,18 @@ impl ExfatFs {
mod tests {
use super::*;
use crate::partition::PartitionLayout;
use std::io::Cursor;
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
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]
fn test_directory_extension() -> Result<()> {
@@ -2055,6 +2351,168 @@ mod tests {
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]
fn test_unicode_filenames() -> Result<()> {

View File

@@ -20,6 +20,9 @@ typeshare "$PROJECT_ROOT/src" \
--lang=typescript \
--output-file="$OUTPUT_FILE"
# Keep generated output stable for git diff --check.
perl -0pi -e 's/\n+\z/\n/' "$OUTPUT_FILE"
echo ""
echo "TypeScript types generated successfully!"
echo "Output: $OUTPUT_FILE"

View File

@@ -8,15 +8,22 @@ use tracing::{debug, info, warn};
use super::executor::{timing, AtxKeyExecutor};
use super::led::LedSensor;
use super::types::{AtxAction, AtxKeyConfig, AtxLedConfig, AtxState, PowerStatus};
use super::types::{
AtxAction, AtxDriverType, AtxInputBinding, AtxKeyConfig, AtxOutputBinding, AtxState, HddStatus,
PowerStatus,
};
use crate::error::{AppError, Result};
#[derive(Debug, Clone, Default)]
pub struct AtxControllerConfig {
pub enabled: bool,
pub power: AtxKeyConfig,
pub reset: AtxKeyConfig,
pub led: AtxLedConfig,
pub driver: AtxDriverType,
pub device: String,
pub baud_rate: u32,
pub power: AtxOutputBinding,
pub reset: AtxOutputBinding,
pub led: AtxInputBinding,
pub hdd: AtxInputBinding,
}
/// Grouped together to reduce lock acquisitions
@@ -25,6 +32,7 @@ struct AtxInner {
power_executor: Option<AtxKeyExecutor>,
reset_executor: Option<AtxKeyExecutor>,
led_sensor: Option<LedSensor>,
hdd_sensor: Option<LedSensor>,
}
/// Manages ATX power control through independent executors for each action.
@@ -34,14 +42,44 @@ pub struct AtxController {
}
impl AtxController {
fn should_share_serial_device(power: &AtxKeyConfig, reset: &AtxKeyConfig) -> bool {
power.is_configured()
&& reset.is_configured()
&& power.driver == super::types::AtxDriverType::Serial
&& reset.driver == super::types::AtxDriverType::Serial
&& !power.device.is_empty()
&& power.device == reset.device
&& power.baud_rate == reset.baud_rate
fn build_key_config(
config: &AtxControllerConfig,
binding: &AtxOutputBinding,
) -> Option<AtxKeyConfig> {
if !binding.is_configured_for(config.driver, &config.device) {
return None;
}
let device = match config.driver {
AtxDriverType::Gpio => binding.device.clone(),
AtxDriverType::UsbRelay | AtxDriverType::Serial => config.device.clone(),
AtxDriverType::None => return None,
};
Some(AtxKeyConfig {
driver: config.driver,
device,
pin: binding.pin,
active_level: binding.active_level,
baud_rate: config.baud_rate,
})
}
fn runtime_key_configs(
config: &AtxControllerConfig,
) -> (Option<AtxKeyConfig>, Option<AtxKeyConfig>) {
(
Self::build_key_config(config, &config.power),
Self::build_key_config(config, &config.reset),
)
}
fn should_share_serial_device(config: &AtxControllerConfig) -> bool {
if config.driver != AtxDriverType::Serial || config.device.trim().is_empty() {
return false;
}
config.power.enabled && config.reset.enabled
}
async fn init_key_executor(
@@ -63,75 +101,80 @@ impl AtxController {
}
async fn init_components(inner: &mut AtxInner) {
if Self::should_share_serial_device(&inner.config.power, &inner.config.reset) {
match AtxKeyExecutor::open_shared_serial(
&inner.config.power.device,
inner.config.power.baud_rate,
) {
let (power_config, reset_config) = Self::runtime_key_configs(&inner.config);
if Self::should_share_serial_device(&inner.config) {
match AtxKeyExecutor::open_shared_serial(&inner.config.device, inner.config.baud_rate) {
Ok(shared_serial) => {
for (slot, warn_label, info_label, config, serial) in [
(
&mut inner.power_executor,
"power",
"Power",
inner.config.power.clone(),
power_config.clone(),
shared_serial.clone(),
),
(
&mut inner.reset_executor,
"reset",
"Reset",
inner.config.reset.clone(),
reset_config.clone(),
shared_serial,
),
] {
let executor =
AtxKeyExecutor::new_with_shared_serial(config.clone(), serial);
*slot =
Self::init_key_executor(warn_label, info_label, config, executor).await;
if let Some(config) = config {
let executor =
AtxKeyExecutor::new_with_shared_serial(config.clone(), serial);
*slot =
Self::init_key_executor(warn_label, info_label, config, executor)
.await;
}
}
}
Err(e) => {
warn!(
"Failed to open shared serial device {} for ATX power/reset: {}",
inner.config.power.device, e
inner.config.device, e
);
}
}
} else {
for (slot, warn_label, info_label, config) in [
(
&mut inner.power_executor,
"power",
"Power",
inner.config.power.clone(),
),
(
&mut inner.reset_executor,
"reset",
"Reset",
inner.config.reset.clone(),
),
(&mut inner.power_executor, "power", "Power", power_config),
(&mut inner.reset_executor, "reset", "Reset", reset_config),
] {
if config.is_configured() {
if let Some(config) = config {
let executor = AtxKeyExecutor::new(config.clone());
*slot = Self::init_key_executor(warn_label, info_label, config, executor).await;
}
}
}
if inner.config.led.is_configured() {
if inner.config.driver == AtxDriverType::Gpio && inner.config.led.is_configured() {
let mut sensor = LedSensor::new(inner.config.led.clone());
if let Err(e) = sensor.init().await {
warn!("Failed to initialize LED sensor: {}", e);
} else {
info!(
"LED sensor initialized on {} pin {}",
inner.config.led.gpio_chip, inner.config.led.gpio_pin
inner.config.led.device, inner.config.led.pin
);
inner.led_sensor = Some(sensor);
}
}
if inner.config.driver == AtxDriverType::Gpio && inner.config.hdd.is_configured() {
let mut sensor = LedSensor::new(inner.config.hdd.clone());
if let Err(e) = sensor.init().await {
warn!("Failed to initialize HDD sensor: {}", e);
} else {
info!(
"HDD sensor initialized on {} pin {}",
inner.config.hdd.device, inner.config.hdd.pin
);
inner.hdd_sensor = Some(sensor);
}
}
}
async fn shutdown_components(inner: &mut AtxInner) {
@@ -153,6 +196,13 @@ impl AtxController {
}
}
inner.led_sensor = None;
if let Some(sensor) = inner.hdd_sensor.as_mut() {
if let Err(e) = sensor.shutdown().await {
warn!("Failed to shutdown HDD sensor: {}", e);
}
}
inner.hdd_sensor = None;
}
async fn read_power_status(sensor: Option<&LedSensor>) -> PowerStatus {
@@ -169,6 +219,21 @@ impl AtxController {
}
}
async fn read_hdd_status(sensor: Option<&LedSensor>) -> HddStatus {
let Some(sensor) = sensor else {
return HddStatus::Unknown;
};
match sensor.read_active().await {
Ok(true) => HddStatus::Active,
Ok(false) => HddStatus::Inactive,
Err(e) => {
debug!("Failed to read ATX HDD sensor: {}", e);
HddStatus::Unknown
}
}
}
pub fn new(config: AtxControllerConfig) -> Self {
Self {
inner: RwLock::new(AtxInner {
@@ -176,6 +241,7 @@ impl AtxController {
power_executor: None,
reset_executor: None,
led_sensor: None,
hdd_sensor: None,
}),
}
}
@@ -270,13 +336,21 @@ impl AtxController {
let inner = self.inner.read().await;
let power_status = Self::read_power_status(inner.led_sensor.as_ref()).await;
let hdd_status = Self::read_hdd_status(inner.hdd_sensor.as_ref()).await;
AtxState {
available: inner.config.enabled,
driver: if inner.config.enabled {
inner.config.driver
} else {
AtxDriverType::None
},
power_configured: inner.power_executor.is_some(),
reset_configured: inner.reset_executor.is_some(),
power_status,
led_supported: inner.led_sensor.is_some(),
hdd_status,
hdd_supported: inner.hdd_sensor.is_some(),
}
}
}
@@ -284,45 +358,96 @@ impl AtxController {
#[cfg(test)]
mod tests {
use super::*;
use crate::atx::AtxDriverType;
use crate::atx::{AtxDriverType, AtxOutputBinding};
#[test]
fn test_should_share_serial_device_true() {
let power = AtxKeyConfig {
let config = AtxControllerConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 1,
active_level: super::super::types::ActiveLevel::High,
baud_rate: 9600,
};
let reset = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 2,
active_level: super::super::types::ActiveLevel::High,
baud_rate: 9600,
power: AtxOutputBinding {
enabled: true,
pin: 1,
..Default::default()
},
reset: AtxOutputBinding {
enabled: true,
pin: 2,
..Default::default()
},
..Default::default()
};
assert!(AtxController::should_share_serial_device(&power, &reset));
assert!(AtxController::should_share_serial_device(&config));
}
#[test]
fn test_should_share_serial_device_false_on_different_baud() {
let power = AtxKeyConfig {
fn test_should_share_serial_device_false_when_reset_disabled() {
let config = AtxControllerConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 1,
active_level: super::super::types::ActiveLevel::High,
baud_rate: 9600,
};
let reset = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 2,
active_level: super::super::types::ActiveLevel::High,
baud_rate: 115200,
power: AtxOutputBinding {
enabled: true,
pin: 1,
..Default::default()
},
reset: AtxOutputBinding {
enabled: false,
pin: 2,
..Default::default()
},
..Default::default()
};
assert!(!AtxController::should_share_serial_device(&power, &reset));
assert!(!AtxController::should_share_serial_device(&config));
}
#[test]
fn test_gpio_runtime_key_uses_binding_device() {
let config = AtxControllerConfig {
driver: AtxDriverType::Gpio,
device: "/dev/ignored".to_string(),
baud_rate: 9600,
power: AtxOutputBinding {
enabled: true,
device: "/dev/gpiochip1".to_string(),
pin: 4,
active_level: super::super::types::ActiveLevel::Low,
},
..Default::default()
};
let (power, reset) = AtxController::runtime_key_configs(&config);
let power = power.unwrap();
assert!(reset.is_none());
assert_eq!(power.driver, AtxDriverType::Gpio);
assert_eq!(power.device, "/dev/gpiochip1");
assert_eq!(power.pin, 4);
assert_eq!(power.active_level, super::super::types::ActiveLevel::Low);
}
#[test]
fn test_serial_runtime_key_uses_top_level_device() {
let config = AtxControllerConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
baud_rate: 115200,
power: AtxOutputBinding {
enabled: true,
device: "/dev/ignored".to_string(),
pin: 3,
..Default::default()
},
..Default::default()
};
let (power, _) = AtxController::runtime_key_configs(&config);
let power = power.unwrap();
assert_eq!(power.driver, AtxDriverType::Serial);
assert_eq!(power.device, "/dev/ttyUSB0");
assert_eq!(power.pin, 3);
assert_eq!(power.baud_rate, 115200);
}
}

View File

@@ -1,14 +1,14 @@
#![allow(dead_code)]
use super::types::{AtxLedConfig, PowerStatus};
use super::types::{AtxInputBinding, PowerStatus};
use crate::error::Result;
pub struct LedSensor {
config: AtxLedConfig,
config: AtxInputBinding,
}
impl LedSensor {
pub fn new(config: AtxLedConfig) -> Self {
pub fn new(config: AtxInputBinding) -> Self {
Self { config }
}
@@ -28,6 +28,10 @@ impl LedSensor {
Ok(PowerStatus::Unknown)
}
pub async fn read_active(&self) -> Result<bool> {
Ok(false)
}
pub async fn shutdown(&mut self) -> Result<()> {
Ok(())
}

View File

@@ -1,7 +1,6 @@
use async_trait::async_trait;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::os::fd::AsRawFd;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Duration;
@@ -9,12 +8,10 @@ use tokio::time::sleep;
use tracing::{debug, info};
use super::traits::AtxKeyBackend;
use super::types::AtxKeyConfig;
use super::types::{AtxKeyConfig, LCUS_RELAY_MAX_CHANNEL};
use crate::error::{AppError, Result};
const USB_RELAY_MAX_CHANNEL: u8 = 8;
const USB_RELAY_REPORT_LEN: usize = 9;
const HIDIOCSFEATURE_9: libc::c_ulong = 0xC009_4806;
const LCUS_RELAY_COMMAND_LEN: usize = 4;
pub struct HidrawLinuxRelayBackend {
config: AtxKeyConfig,
@@ -34,13 +31,13 @@ impl HidrawLinuxRelayBackend {
fn validate_config(&self) -> Result<()> {
if self.config.pin == 0 {
return Err(AppError::Config(
"USB relay channel must be 1-based (>= 1)".to_string(),
"LCUS HID relay channel must be 1-based (>= 1)".to_string(),
));
}
if self.config.pin > USB_RELAY_MAX_CHANNEL as u32 {
if self.config.pin > LCUS_RELAY_MAX_CHANNEL as u32 {
return Err(AppError::Config(format!(
"USB HID relay channel must be <= {}",
USB_RELAY_MAX_CHANNEL
"LCUS HID relay channel must be <= {}",
LCUS_RELAY_MAX_CHANNEL
)));
}
Ok(())
@@ -49,20 +46,19 @@ impl HidrawLinuxRelayBackend {
fn send_command(&self, on: bool) -> Result<()> {
let channel = u8::try_from(self.config.pin).map_err(|_| {
AppError::Config(format!(
"USB relay channel {} exceeds max {}",
self.config.pin,
u8::MAX
"LCUS HID relay channel {} exceeds max {}",
self.config.pin, LCUS_RELAY_MAX_CHANNEL
))
})?;
if channel == 0 {
return Err(AppError::Config(
"USB relay channel must be 1-based (>= 1)".to_string(),
"LCUS HID relay channel must be 1-based (>= 1)".to_string(),
));
}
if channel > USB_RELAY_MAX_CHANNEL {
if channel > LCUS_RELAY_MAX_CHANNEL {
return Err(AppError::Config(format!(
"USB HID relay channel must be <= {}",
USB_RELAY_MAX_CHANNEL
"LCUS HID relay channel must be <= {}",
LCUS_RELAY_MAX_CHANNEL
)));
}
@@ -70,44 +66,22 @@ impl HidrawLinuxRelayBackend {
let mut guard = self.handle.lock().unwrap();
let device = guard
.as_mut()
.ok_or_else(|| AppError::Internal("USB relay not initialized".to_string()))?;
.ok_or_else(|| AppError::Internal("LCUS HID relay not initialized".to_string()))?;
if let Err(feature_err) = Self::send_feature_report(device, &cmd) {
debug!(
"USB relay feature report failed ({}), falling back to hidraw write",
feature_err
);
device.write_all(&cmd).map_err(|write_err| {
AppError::Internal(format!(
"USB relay feature report failed: {}; raw write failed: {}",
feature_err, write_err
))
})?;
device
.flush()
.map_err(|e| AppError::Internal(format!("USB relay flush failed: {}", e)))?;
}
device
.write_all(&cmd)
.map_err(|e| AppError::Internal(format!("LCUS HID relay write failed: {}", e)))?;
device
.flush()
.map_err(|e| AppError::Internal(format!("LCUS HID relay flush failed: {}", e)))?;
Ok(())
}
pub fn build_command(channel: u8, on: bool) -> [u8; USB_RELAY_REPORT_LEN] {
let mut cmd = [0x00; USB_RELAY_REPORT_LEN];
cmd[1] = if on { 0xFF } else { 0xFD };
cmd[2] = channel;
cmd
}
fn send_feature_report(
device: &File,
report: &[u8; USB_RELAY_REPORT_LEN],
) -> std::io::Result<()> {
let rc = unsafe { libc::ioctl(device.as_raw_fd(), HIDIOCSFEATURE_9 as _, report.as_ptr()) };
if rc < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
pub fn build_command(channel: u8, on: bool) -> [u8; LCUS_RELAY_COMMAND_LEN] {
let state = if on { 1 } else { 0 };
let checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state);
[0xA0, channel, state, checksum]
}
}
@@ -117,7 +91,7 @@ impl AtxKeyBackend for HidrawLinuxRelayBackend {
self.validate_config()?;
info!(
"Initializing USB relay ATX backend on {} channel {}",
"Initializing LCUS HID relay ATX backend on {} channel {}",
self.config.device, self.config.pin
);
@@ -125,14 +99,14 @@ impl AtxKeyBackend for HidrawLinuxRelayBackend {
.read(true)
.write(true)
.open(&self.config.device)
.map_err(|e| AppError::Internal(format!("USB relay device open failed: {}", e)))?;
.map_err(|e| AppError::Internal(format!("LCUS HID relay device open failed: {}", e)))?;
*self.handle.lock().unwrap() = Some(device);
self.send_command(false)?;
self.initialized.store(true, Ordering::Relaxed);
debug!(
"USB relay channel {} configured successfully",
"LCUS HID relay channel {} configured successfully",
self.config.pin
);
Ok(())
@@ -140,7 +114,9 @@ impl AtxKeyBackend for HidrawLinuxRelayBackend {
async fn pulse(&self, duration: Duration) -> Result<()> {
if !self.is_initialized() {
return Err(AppError::Internal("USB relay not initialized".to_string()));
return Err(AppError::Internal(
"LCUS HID relay not initialized".to_string(),
));
}
self.send_command(true)?;
@@ -177,14 +153,14 @@ mod tests {
use super::HidrawLinuxRelayBackend;
#[test]
fn usb_relay_command_format() {
fn lcus_hid_relay_command_format() {
assert_eq!(
HidrawLinuxRelayBackend::build_command(1, true),
[0x00, 0xFF, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
[0xA0, 0x01, 0x01, 0xA2]
);
assert_eq!(
HidrawLinuxRelayBackend::build_command(1, false),
[0x00, 0xFD, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
[0xA0, 0x01, 0x00, 0xA1]
);
}
}

View File

@@ -7,17 +7,17 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use tracing::{debug, info};
use super::types::{AtxLedConfig, PowerStatus};
use super::types::{ActiveLevel, AtxInputBinding, PowerStatus};
use crate::error::{AppError, Result};
pub struct LedSensor {
config: AtxLedConfig,
config: AtxInputBinding,
handle: Mutex<Option<LineHandle>>,
initialized: AtomicBool,
}
impl LedSensor {
pub fn new(config: AtxLedConfig) -> Self {
pub fn new(config: AtxInputBinding) -> Self {
Self {
config,
handle: Mutex::new(None),
@@ -33,17 +33,14 @@ impl LedSensor {
info!(
"Initializing LED sensor on {} pin {}",
self.config.gpio_chip, self.config.gpio_pin
self.config.device, self.config.pin
);
let mut chip = Chip::new(&self.config.gpio_chip)
let mut chip = Chip::new(&self.config.device)
.map_err(|e| AppError::Internal(format!("LED GPIO chip failed: {}", e)))?;
let line = chip.get_line(self.config.gpio_pin).map_err(|e| {
AppError::Internal(format!(
"LED GPIO line {} failed: {}",
self.config.gpio_pin, e
))
let line = chip.get_line(self.config.pin).map_err(|e| {
AppError::Internal(format!("LED GPIO line {} failed: {}", self.config.pin, e))
})?;
let handle = line
@@ -57,9 +54,11 @@ impl LedSensor {
Ok(())
}
pub async fn read(&self) -> Result<PowerStatus> {
pub async fn read_active(&self) -> Result<bool> {
if !self.config.is_configured() || !self.initialized.load(Ordering::Relaxed) {
return Ok(PowerStatus::Unknown);
return Err(AppError::Internal(
"GPIO input sensor not initialized".to_string(),
));
}
let guard = self.handle.lock().unwrap();
@@ -69,22 +68,31 @@ impl LedSensor {
.get_value()
.map_err(|e| AppError::Internal(format!("LED read failed: {}", e)))?;
let is_on = if self.config.inverted {
value == 0
} else {
value == 1
let active = match self.config.active_level {
ActiveLevel::High => value == 1,
ActiveLevel::Low => value == 0,
};
Ok(if is_on {
PowerStatus::On
} else {
PowerStatus::Off
})
Ok(active)
}
None => Ok(PowerStatus::Unknown),
None => Err(AppError::Internal(
"GPIO input sensor not initialized".to_string(),
)),
}
}
pub async fn read(&self) -> Result<PowerStatus> {
if !self.config.is_configured() || !self.initialized.load(Ordering::Relaxed) {
return Ok(PowerStatus::Unknown);
}
Ok(if self.read_active().await? {
PowerStatus::On
} else {
PowerStatus::Off
})
}
pub async fn shutdown(&mut self) -> Result<()> {
*self.handle.lock().unwrap() = None;
self.initialized.store(false, Ordering::Relaxed);
@@ -105,7 +113,7 @@ mod tests {
#[test]
fn test_led_sensor_creation() {
let config = AtxLedConfig::default();
let config = AtxInputBinding::default();
let sensor = LedSensor::new(config);
assert!(!sensor.config.is_configured());
assert!(!sensor.initialized.load(Ordering::Relaxed));
@@ -113,11 +121,11 @@ mod tests {
#[test]
fn test_led_sensor_with_config() {
let config = AtxLedConfig {
let config = AtxInputBinding {
enabled: true,
gpio_chip: "/dev/gpiochip0".to_string(),
gpio_pin: 7,
inverted: false,
device: "/dev/gpiochip0".to_string(),
pin: 7,
active_level: ActiveLevel::High,
};
let sensor = LedSensor::new(config);
assert!(sensor.config.is_configured());
@@ -126,13 +134,13 @@ mod tests {
#[test]
fn test_led_sensor_inverted_config() {
let config = AtxLedConfig {
let config = AtxInputBinding {
enabled: true,
gpio_chip: "/dev/gpiochip0".to_string(),
gpio_pin: 7,
inverted: true,
device: "/dev/gpiochip0".to_string(),
pin: 7,
active_level: ActiveLevel::Low,
};
let sensor = LedSensor::new(config);
assert!(sensor.config.inverted);
assert_eq!(sensor.config.active_level, ActiveLevel::Low);
}
}

View File

@@ -24,22 +24,17 @@ mod wol;
pub use controller::{AtxController, AtxControllerConfig};
pub use executor::timing;
pub use types::{
ActiveLevel, AtxAction, AtxDevices, AtxDriverType, AtxKeyConfig, AtxLedConfig, AtxPowerRequest,
AtxState, PowerStatus,
ActiveLevel, AtxAction, AtxDevices, AtxDriverType, AtxInputBinding, AtxKeyConfig,
AtxOutputBinding, AtxPowerRequest, AtxState, HddStatus, PowerStatus, LCUS_RELAY_MAX_CHANNEL,
};
pub use wol::{list_wol_history, record_wol_history, send_wol};
#[cfg(any(unix, test))]
fn hidraw_uevent_is_usb_relay(uevent: &str) -> bool {
let upper = uevent.to_ascii_uppercase();
upper.contains("000016C0:000005DF")
|| upper.contains("00005131:00002007")
|| upper.contains("16C0:05DF")
upper.contains("00005131:00002007")
|| upper.contains("5131:2007")
|| upper.contains("PRODUCT=16C0/5DF")
|| upper.contains("PRODUCT=5131/2007")
|| upper.contains("USBRELAY")
|| upper.contains("USB RELAY")
}
#[cfg(unix)]
@@ -94,14 +89,14 @@ mod tests {
}
#[test]
fn test_hidraw_uevent_detects_usb_relay_id() {
assert!(hidraw_uevent_is_usb_relay(
fn test_hidraw_uevent_rejects_non_lcus_usb_relay_id() {
assert!(!hidraw_uevent_is_usb_relay(
"HID_ID=0003:000016C0:000005DF\nHID_NAME=www.dcttech.com USBRelay2\n"
));
}
#[test]
fn test_hidraw_uevent_detects_5131_usb_relay_id() {
fn test_hidraw_uevent_detects_lcus_hid_relay_id() {
assert!(hidraw_uevent_is_usb_relay(
"HID_ID=0003:00005131:00002007\n"
));
@@ -120,7 +115,8 @@ mod tests {
let _: AtxDriverType = AtxDriverType::None;
let _: ActiveLevel = ActiveLevel::High;
let _: AtxKeyConfig = AtxKeyConfig::default();
let _: AtxLedConfig = AtxLedConfig::default();
let _: AtxInputBinding = AtxInputBinding::default();
let _: AtxOutputBinding = AtxOutputBinding::default();
let _: AtxState = AtxState::default();
let _: AtxDevices = AtxDevices::default();
}

View File

@@ -7,7 +7,7 @@ use tokio::time::sleep;
use tracing::{debug, info};
use super::traits::{validate_serial_config, AtxKeyBackend, SharedSerialHandle};
use super::types::AtxKeyConfig;
use super::types::{AtxKeyConfig, LCUS_RELAY_MAX_CHANNEL};
use crate::error::{AppError, Result};
pub struct SerialRelayBackend {
@@ -44,15 +44,23 @@ impl SerialRelayBackend {
fn send_command(&self, on: bool) -> Result<()> {
let channel = u8::try_from(self.config.pin).map_err(|_| {
AppError::Config(format!(
"Serial relay channel {} exceeds max {}",
self.config.pin,
u8::MAX
"LCUS serial relay channel {} exceeds max {}",
self.config.pin, LCUS_RELAY_MAX_CHANNEL
))
})?;
if channel == 0 {
return Err(AppError::Config(
"LCUS serial relay channel must be 1-based (>= 1)".to_string(),
));
}
if channel > LCUS_RELAY_MAX_CHANNEL {
return Err(AppError::Config(format!(
"LCUS serial relay channel must be <= {}",
LCUS_RELAY_MAX_CHANNEL
)));
}
let state = if on { 1 } else { 0 };
let checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state);
let cmd = [0xA0, channel, state, checksum];
let cmd = Self::build_command(channel, on);
let serial_handle = self
.serial_handle
@@ -60,16 +68,22 @@ impl SerialRelayBackend {
.unwrap()
.as_ref()
.cloned()
.ok_or_else(|| AppError::Internal("Serial relay not initialized".to_string()))?;
.ok_or_else(|| AppError::Internal("LCUS serial relay not initialized".to_string()))?;
let mut port = serial_handle.lock().unwrap();
port.write_all(&cmd)
.map_err(|e| AppError::Internal(format!("Serial relay write failed: {}", e)))?;
.map_err(|e| AppError::Internal(format!("LCUS serial relay write failed: {}", e)))?;
port.flush()
.map_err(|e| AppError::Internal(format!("Serial relay flush failed: {}", e)))?;
.map_err(|e| AppError::Internal(format!("LCUS serial relay flush failed: {}", e)))?;
Ok(())
}
pub fn build_command(channel: u8, on: bool) -> [u8; 4] {
let state = if on { 1 } else { 0 };
let checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state);
[0xA0, channel, state, checksum]
}
}
#[async_trait]
@@ -78,7 +92,7 @@ impl AtxKeyBackend for SerialRelayBackend {
validate_serial_config(&self.config)?;
info!(
"Initializing Serial relay ATX backend on {} channel {}",
"Initializing LCUS serial relay ATX backend on {} channel {}",
self.config.device, self.config.pin
);
@@ -92,7 +106,7 @@ impl AtxKeyBackend for SerialRelayBackend {
self.initialized.store(true, Ordering::Relaxed);
debug!(
"Serial relay channel {} configured successfully",
"LCUS serial relay channel {} configured successfully",
self.config.pin
);
Ok(())
@@ -101,12 +115,12 @@ impl AtxKeyBackend for SerialRelayBackend {
async fn pulse(&self, duration: Duration) -> Result<()> {
if !self.is_initialized() {
return Err(AppError::Internal(
"Serial relay not initialized".to_string(),
"LCUS serial relay not initialized".to_string(),
));
}
info!(
"Pulse serial relay on {} pin {}",
"Pulse LCUS serial relay on {} channel {}",
self.config.device, self.config.pin
);
self.send_command(true)?;
@@ -131,6 +145,23 @@ impl AtxKeyBackend for SerialRelayBackend {
}
}
#[cfg(test)]
mod tests {
use super::SerialRelayBackend;
#[test]
fn lcus_serial_relay_command_format() {
assert_eq!(
SerialRelayBackend::build_command(1, true),
[0xA0, 0x01, 0x01, 0xA2]
);
assert_eq!(
SerialRelayBackend::build_command(1, false),
[0xA0, 0x01, 0x00, 0xA1]
);
}
}
impl Drop for SerialRelayBackend {
fn drop(&mut self) {
if self.is_initialized() {

View File

@@ -3,7 +3,7 @@ use serialport::SerialPort;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use super::types::AtxKeyConfig;
use super::types::{AtxKeyConfig, LCUS_RELAY_MAX_CHANNEL};
use crate::error::Result;
pub type SharedSerialHandle = Arc<Mutex<Box<dyn SerialPort>>>;
@@ -36,10 +36,10 @@ pub fn validate_serial_config(config: &AtxKeyConfig) -> Result<()> {
"Serial ATX channel must be 1-based (>= 1)".to_string(),
));
}
if config.pin > u8::MAX as u32 {
if config.pin > LCUS_RELAY_MAX_CHANNEL as u32 {
return Err(crate::error::AppError::Config(format!(
"Serial ATX channel must be <= {}",
u8::MAX
"LCUS serial relay channel must be <= {}",
LCUS_RELAY_MAX_CHANNEL
)));
}
if config.baud_rate == 0 {

View File

@@ -1,11 +1,12 @@
//! ATX data types and structures
//!
//! Defines the configuration and state types for the flexible ATX power control system.
//! Each ATX action (power, reset) can be independently configured with different hardware.
//! Defines the configuration and state types for the ATX power control system.
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
pub const LCUS_RELAY_MAX_CHANNEL: u8 = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PowerStatus {
@@ -15,6 +16,15 @@ pub enum PowerStatus {
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum HddStatus {
Active,
Inactive,
#[default]
Unknown,
}
#[typeshare]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
@@ -36,6 +46,56 @@ pub enum ActiveLevel {
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct AtxOutputBinding {
pub enabled: bool,
pub device: String,
pub pin: u32,
pub active_level: ActiveLevel,
}
impl Default for AtxOutputBinding {
fn default() -> Self {
Self {
enabled: false,
device: String::new(),
pin: 1,
active_level: ActiveLevel::High,
}
}
}
impl AtxOutputBinding {
pub fn is_configured_for(&self, driver: AtxDriverType, top_level_device: &str) -> bool {
if !self.enabled || driver == AtxDriverType::None {
return false;
}
match driver {
AtxDriverType::Gpio => !self.device.trim().is_empty(),
AtxDriverType::UsbRelay | AtxDriverType::Serial => !top_level_device.trim().is_empty(),
AtxDriverType::None => false,
}
}
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct AtxInputBinding {
pub enabled: bool,
pub device: String,
pub pin: u32,
pub active_level: ActiveLevel,
}
impl AtxInputBinding {
pub fn is_configured(&self) -> bool {
self.enabled && !self.device.trim().is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct AtxKeyConfig {
@@ -64,29 +124,16 @@ impl AtxKeyConfig {
}
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct AtxLedConfig {
pub enabled: bool,
pub gpio_chip: String,
pub gpio_pin: u32,
pub inverted: bool,
}
impl AtxLedConfig {
pub fn is_configured(&self) -> bool {
self.enabled && !self.gpio_chip.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AtxState {
pub available: bool,
pub driver: AtxDriverType,
pub power_configured: bool,
pub reset_configured: bool,
pub power_status: PowerStatus,
pub led_supported: bool,
pub hdd_status: HddStatus,
pub hdd_supported: bool,
}
#[derive(Debug, Clone, Deserialize)]
@@ -139,6 +186,29 @@ mod tests {
assert_eq!(ActiveLevel::default(), ActiveLevel::High);
}
#[test]
fn test_atx_output_binding_default() {
let config = AtxOutputBinding::default();
assert!(!config.enabled);
assert!(config.device.is_empty());
assert_eq!(config.pin, 1);
assert!(!config.is_configured_for(AtxDriverType::Gpio, ""));
}
#[test]
fn test_atx_output_binding_is_configured() {
let mut config = AtxOutputBinding::default();
assert!(!config.is_configured_for(AtxDriverType::Gpio, ""));
config.enabled = true;
assert!(!config.is_configured_for(AtxDriverType::Gpio, ""));
config.device = "/dev/gpiochip0".to_string();
assert!(config.is_configured_for(AtxDriverType::Gpio, ""));
assert!(!config.is_configured_for(AtxDriverType::Serial, ""));
assert!(config.is_configured_for(AtxDriverType::Serial, "/dev/ttyUSB0"));
}
#[test]
fn test_atx_key_config_default() {
let config = AtxKeyConfig::default();
@@ -149,37 +219,22 @@ mod tests {
}
#[test]
fn test_atx_key_config_is_configured() {
let mut config = AtxKeyConfig::default();
assert!(!config.is_configured());
config.driver = AtxDriverType::Gpio;
assert!(!config.is_configured());
config.device = "/dev/gpiochip0".to_string();
assert!(config.is_configured());
config.driver = AtxDriverType::None;
assert!(!config.is_configured());
}
#[test]
fn test_atx_led_config_default() {
let config = AtxLedConfig::default();
fn test_atx_input_binding_default() {
let config = AtxInputBinding::default();
assert!(!config.enabled);
assert!(config.gpio_chip.is_empty());
assert!(config.device.is_empty());
assert!(!config.is_configured());
}
#[test]
fn test_atx_led_config_is_configured() {
let mut config = AtxLedConfig::default();
fn test_atx_input_binding_is_configured() {
let mut config = AtxInputBinding::default();
assert!(!config.is_configured());
config.enabled = true;
assert!(!config.is_configured());
config.gpio_chip = "/dev/gpiochip0".to_string();
config.device = "/dev/gpiochip0".to_string();
assert!(config.is_configured());
}
@@ -187,9 +242,12 @@ mod tests {
fn test_atx_state_default() {
let state = AtxState::default();
assert!(!state.available);
assert_eq!(state.driver, AtxDriverType::None);
assert!(!state.power_configured);
assert!(!state.reset_configured);
assert_eq!(state.power_status, PowerStatus::Unknown);
assert!(!state.led_supported);
assert_eq!(state.hdd_status, HddStatus::Unknown);
assert!(!state.hdd_supported);
}
}

View File

@@ -29,7 +29,8 @@ impl Default for ComputerUseButton {
}
}
#[typeshare]
// Kept out of typeshare because these internally tagged enums are consumed by
// manually maintained frontend types and changing serde shape would break WS/API.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ComputerUseAction {
@@ -89,7 +90,6 @@ pub struct ComputerUseScreenshot {
pub height: u32,
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "role", rename_all = "snake_case")]
pub enum ComputerUseConversationMessage {
@@ -145,7 +145,6 @@ pub struct ComputerUseSessionSummary {
pub final_message: Option<String>,
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ComputerUseWsClientMessage {
@@ -155,7 +154,6 @@ pub enum ComputerUseWsClientMessage {
},
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ComputerUseWsServerMessage {
@@ -166,3 +164,43 @@ pub enum ComputerUseWsServerMessage {
ActionsExecuted { actions: Vec<ComputerUseAction> },
Error { message: String },
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn computer_use_action_keeps_flat_tagged_json_shape() {
let action = ComputerUseAction::Click {
x: 10,
y: 20,
button: ComputerUseButton::Left,
};
assert_eq!(
serde_json::to_value(action).unwrap(),
json!({
"type": "click",
"x": 10,
"y": 20,
"button": "left"
})
);
}
#[test]
fn computer_use_ws_message_keeps_flat_tagged_json_shape() {
let message = ComputerUseWsServerMessage::ScreenshotRequested {
request_id: "req-1".to_string(),
};
assert_eq!(
serde_json::to_value(message).unwrap(),
json!({
"type": "screenshot_requested",
"request_id": "req-1"
})
);
}
}

View File

@@ -1,27 +1,61 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
pub use crate::atx::{ActiveLevel, AtxDriverType, AtxKeyConfig, AtxLedConfig};
pub use crate::atx::{ActiveLevel, AtxDriverType, AtxInputBinding, AtxOutputBinding};
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct AtxConfig {
pub enabled: bool,
pub power: AtxKeyConfig,
pub reset: AtxKeyConfig,
pub led: AtxLedConfig,
pub driver: AtxDriverType,
pub device: String,
pub baud_rate: u32,
pub power: AtxOutputBinding,
pub reset: AtxOutputBinding,
pub led: AtxInputBinding,
pub hdd: AtxInputBinding,
pub wol_interface: String,
}
impl AtxConfig {
pub fn to_controller_config(&self) -> crate::atx::AtxControllerConfig {
crate::atx::AtxControllerConfig {
enabled: self.enabled,
power: self.power.clone(),
reset: self.reset.clone(),
led: self.led.clone(),
impl Default for AtxConfig {
fn default() -> Self {
Self {
enabled: false,
driver: AtxDriverType::None,
device: String::new(),
baud_rate: 9600,
power: AtxOutputBinding::default(),
reset: AtxOutputBinding::default(),
led: AtxInputBinding::default(),
hdd: AtxInputBinding::default(),
wol_interface: String::new(),
}
}
}
impl AtxConfig {
pub fn normalize(&mut self) {
if self.driver == AtxDriverType::None {
self.enabled = false;
}
if self.driver != AtxDriverType::Gpio {
self.led.enabled = false;
self.hdd.enabled = false;
}
}
pub fn to_controller_config(&self) -> crate::atx::AtxControllerConfig {
crate::atx::AtxControllerConfig {
enabled: self.enabled,
driver: self.driver,
device: self.device.clone(),
baud_rate: self.baud_rate,
power: self.power.clone(),
reset: self.reset.clone(),
led: self.led.clone(),
hdd: self.hdd.clone(),
}
}
}

View File

@@ -45,6 +45,7 @@ impl AppConfig {
if self.hid.backend != HidBackend::Otg {
self.msd.enabled = false;
}
self.atx.normalize();
}
pub fn apply_platform_defaults(&mut self) {

View File

@@ -54,6 +54,7 @@ pub struct AtxDeviceInfo {
pub backend: String,
pub initialized: bool,
pub power_on: bool,
pub hdd_status: String,
pub error: Option<String>,
}

View File

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

View File

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

View File

@@ -10,9 +10,7 @@ use crate::error::{AppError, Result};
const STREAM_CHUNK_SIZE: usize = 64 * 1024;
const MIN_DRIVE_SIZE_MB: u32 = 1024;
const MAX_DRIVE_SIZE_MB: u32 = 128 * 1024;
pub const MIN_DRIVE_SIZE_MB: u32 = 64;
const DEFAULT_LABEL: &str = "ONE-KVM";
@@ -37,8 +35,20 @@ impl VentoyDrive {
&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> {
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 path = self.path.clone();
let _lock = self.lock.write().await;

View File

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

View File

@@ -83,34 +83,23 @@ fn apply_windows(config: &mut AppConfig) {
}
if matches!(
config.atx.power.driver,
config.atx.driver,
AtxDriverType::Gpio | AtxDriverType::UsbRelay
) {
config.atx.power.driver = AtxDriverType::None;
}
if matches!(
config.atx.reset.driver,
AtxDriverType::Gpio | AtxDriverType::UsbRelay
) {
config.atx.reset.driver = AtxDriverType::None;
config.atx.driver = AtxDriverType::None;
config.atx.enabled = false;
}
if !config.initialized
&& config.atx.power.driver == AtxDriverType::None
&& config.atx.power.device.is_empty()
&& config.atx.driver == AtxDriverType::None
&& config.atx.device.is_empty()
{
config.atx.power.driver = AtxDriverType::Serial;
config.atx.power.device = "COM4".to_string();
config.atx.driver = AtxDriverType::Serial;
config.atx.device = "COM4".to_string();
config.atx.baud_rate = 9600;
config.atx.power.enabled = true;
config.atx.power.pin = 1;
config.atx.power.baud_rate = 9600;
}
if !config.initialized
&& config.atx.reset.driver == AtxDriverType::None
&& config.atx.reset.device.is_empty()
{
config.atx.reset.driver = AtxDriverType::Serial;
config.atx.reset.device = "COM4".to_string();
config.atx.reset.enabled = true;
config.atx.reset.pin = 2;
config.atx.reset.baud_rate = 9600;
}
config

View File

@@ -8,6 +8,7 @@ use tokio::sync::{broadcast, Mutex, RwLock};
use crate::config::RtspConfig;
use crate::error::{AppError, Result};
use crate::utils::{bind_socket_addr, bind_tcp_listener};
use crate::video::VideoStreamManager;
use super::auth::{extract_basic_auth, rtsp_auth_credentials};
@@ -73,13 +74,18 @@ impl RtspService {
tracing::debug!("Failed to request keyframe on RTSP start: {}", err);
}
let bind_addr: SocketAddr = format!("{}:{}", config.bind, config.port)
.parse()
let bind_addr = bind_socket_addr(&config.bind, config.port)
.map_err(|e| AppError::BadRequest(format!("Invalid RTSP bind address: {}", e)))?;
let listener = TcpListener::bind(bind_addr).await.map_err(|e| {
let listener = bind_tcp_listener(bind_addr).map_err(|e| {
AppError::Io(io::Error::new(e.kind(), format!("RTSP bind failed: {}", e)))
})?;
let listener = TcpListener::from_std(listener).map_err(|e| {
AppError::Io(io::Error::new(
e.kind(),
format!("RTSP listener setup failed: {}", e),
))
})?;
let service_config = self.config.clone();
let video_manager = self.video_manager.clone();

View File

@@ -247,26 +247,27 @@ impl AppState {
}
async fn collect_atx_info(&self) -> Option<AtxDeviceInfo> {
const BACKEND_POWER_ONLY: &str = "power: configured, reset: none";
const BACKEND_RESET_ONLY: &str = "power: none, reset: configured";
const BACKEND_BOTH: &str = "power: configured, reset: configured";
const BACKEND_NONE: &str = "none";
let atx_guard = self.atx.read().await;
let atx = atx_guard.as_ref()?;
let state = atx.state().await;
Some(AtxDeviceInfo {
available: state.available,
backend: match (state.power_configured, state.reset_configured) {
(true, true) => BACKEND_BOTH,
(true, false) => BACKEND_POWER_ONLY,
(false, true) => BACKEND_RESET_ONLY,
(false, false) => BACKEND_NONE,
backend: match state.driver {
crate::atx::AtxDriverType::Gpio => "gpio",
crate::atx::AtxDriverType::UsbRelay => "usbrelay",
crate::atx::AtxDriverType::Serial => "serial",
crate::atx::AtxDriverType::None => "none",
}
.to_string(),
initialized: state.power_configured || state.reset_configured,
power_on: state.power_status == crate::atx::PowerStatus::On,
hdd_status: match state.hdd_status {
crate::atx::HddStatus::Active => "active",
crate::atx::HddStatus::Inactive => "inactive",
crate::atx::HddStatus::Unknown => "unknown",
}
.to_string(),
error: None,
})
}

View File

@@ -15,3 +15,38 @@ pub use host::{hostname_from_etc, hostname_uname};
pub use net::{bind_tcp_listener, bind_udp_socket};
pub use serial::list_serial_ports;
pub use throttle::LogThrottler;
use std::net::{IpAddr, SocketAddr};
pub fn bind_socket_addr(bind: &str, port: u16) -> Result<SocketAddr, std::net::AddrParseError> {
bind.parse::<IpAddr>().map(|ip| SocketAddr::new(ip, port))
}
#[cfg(test)]
mod tests {
use super::bind_socket_addr;
#[test]
fn ipv6_bind_socket_addr_formats_ipv4_and_ipv6() {
assert_eq!(
bind_socket_addr("0.0.0.0", 5900).unwrap().to_string(),
"0.0.0.0:5900"
);
assert_eq!(
bind_socket_addr("127.0.0.1", 5900).unwrap().to_string(),
"127.0.0.1:5900"
);
assert_eq!(
bind_socket_addr("::", 8554).unwrap().to_string(),
"[::]:8554"
);
assert_eq!(
bind_socket_addr("::1", 8554).unwrap().to_string(),
"[::1]:8554"
);
assert_eq!(
bind_socket_addr("2001:db8::1", 8554).unwrap().to_string(),
"[2001:db8::1]:8554"
);
}
}

View File

@@ -3,6 +3,8 @@
pub(crate) mod runtime;
pub(crate) mod status;
pub const DEFAULT_CAPTURE_BUFFER_COUNT: u32 = 4;
#[cfg(unix)]
mod linux;
#[cfg(windows)]

View File

@@ -38,7 +38,6 @@ const CSI_BRIDGE_NOSIGNAL_INTERVAL_MS: u64 = 500;
const NOSIGNAL_POLL_MAX: Duration = Duration::from_secs(20);
/// Throttle repeated encoding errors to avoid log flooding
const ENCODE_ERROR_THROTTLE_SECS: u64 = 5;
const INVALID_MJPEG_LOG_THROTTLE_SECS: u64 = 5;
static PROCESS_START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
@@ -61,6 +60,7 @@ use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
use crate::video::signal::SignalStatus;
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
#[cfg(all(
any(target_arch = "aarch64", target_arch = "arm"),
not(target_os = "android")
@@ -870,8 +870,6 @@ impl SharedVideoPipeline {
let mut sequence: u64 = 0;
let mut consecutive_timeouts: u32 = 0;
let capture_error_throttler = LogThrottler::with_secs(5);
let invalid_mjpeg_throttler =
LogThrottler::with_secs(INVALID_MJPEG_LOG_THROTTLE_SECS);
let mut suppressed_capture_errors: HashMap<String, u64> = HashMap::new();
while pipeline.running_flag.load(Ordering::Acquire) {
@@ -1232,24 +1230,8 @@ impl SharedVideoPipeline {
}
owned.truncate(frame_size);
if pixel_format.is_compressed() && !VideoFrame::is_valid_jpeg_bytes(&owned) {
if invalid_mjpeg_throttler.should_log("invalid_mjpeg_capture_frame") {
let b0 = owned.first().copied().unwrap_or_default();
let b1 = owned.get(1).copied().unwrap_or_default();
warn!(
"Dropping invalid MJPEG capture frame: size={}, starts with 0x{:02x} 0x{:02x}",
owned.len(),
b0,
b1
);
}
continue;
}
// Notify streaming only after frame validation passes
// stale/warm-up frames from V4L2 kernel queues can cause
// DQBUF Ok with invalid data, which would prematurely
// clear the frontend error overlay.
// Notify streaming only after the short-frame guard passes.
pipeline.notify_state(PipelineStateNotification::streaming());
let frame = Arc::new(VideoFrame::from_pooled(
Arc::new(FrameBuffer::new(owned, Some(buffer_pool.clone()))),

View File

@@ -37,7 +37,6 @@ use crate::events::{EventBus, SystemEvent, VideoDeviceInfo};
use crate::hid::HidController;
use crate::stream::MjpegStreamHandler;
use crate::video::codec_constraints::StreamCodecConstraints;
use crate::video::device::is_csi_hdmi_bridge;
use crate::video::format::{PixelFormat, Resolution};
use crate::video::streamer::{Streamer, StreamerState, StreamerStats};
use crate::video::traits::VideoOutput;
@@ -439,33 +438,6 @@ impl VideoStreamManager {
StreamMode::Mjpeg => {
info!("Starting MJPEG streaming");
// Auto-switch to MJPEG format if device supports it
if let Some(device) = self.streamer.current_device().await {
let (current_format, resolution, fps) =
self.streamer.current_video_config().await;
let available_formats: Vec<PixelFormat> =
device.formats.iter().map(|f| f.format).collect();
// If current format is not MJPEG and device supports MJPEG, switch to it
if !is_csi_hdmi_bridge(&device)
&& current_format != PixelFormat::Mjpeg
&& available_formats.contains(&PixelFormat::Mjpeg)
{
info!("Auto-switching to MJPEG format for MJPEG mode");
let device_path = device.path.to_string_lossy().to_string();
if let Err(e) = self
.streamer
.apply_video_config(&device_path, PixelFormat::Mjpeg, resolution, fps)
.await
{
warn!(
"Failed to auto-switch to MJPEG format: {}, keeping current format",
e
);
}
}
}
if let Err(e) = self.streamer.start().await {
error!("Failed to start MJPEG streamer: {}", e);
return Err(e);

View File

@@ -27,7 +27,9 @@ use crate::video::capture::status::{
capture_error_log_key, classify_capture_io_error, signal_status_from_capture_kind,
CaptureIoErrorKind,
};
use crate::video::capture::{is_source_changed_error, BridgeContext, CaptureStream};
use crate::video::capture::{
is_source_changed_error, BridgeContext, CaptureStream, DEFAULT_CAPTURE_BUFFER_COUNT,
};
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
@@ -769,7 +771,7 @@ impl Streamer {
const MAX_RETRIES: u32 = 5;
const RETRY_DELAY_MS: u64 = 200;
const IDLE_STOP_DELAY_SECS: u64 = 5;
const BUFFER_COUNT: u32 = 2;
const BUFFER_COUNT: u32 = DEFAULT_CAPTURE_BUFFER_COUNT;
/// Initial back-off after signal loss before the first soft restart.
///
/// PiKVM/ustreamer drops to sub-second recovery because it subscribes to

View File

@@ -17,6 +17,7 @@ use crate::config::{VncConfig, VncEncoding};
use crate::error::{AppError, Result};
use crate::hid::HidController;
use crate::stream::mjpeg::ClientGuard;
use crate::utils::{bind_socket_addr, bind_tcp_listener};
use crate::video::codec::{BitratePreset, VideoCodecType};
use crate::video::stream_manager::VideoStreamManager;
@@ -108,15 +109,20 @@ impl VncService {
return Err(err);
}
let bind_addr: SocketAddr = format!("{}:{}", config.bind, config.port)
.parse()
let bind_addr = bind_socket_addr(&config.bind, config.port)
.map_err(|e| AppError::BadRequest(format!("Invalid VNC bind address: {}", e)))?;
let listener = TcpListener::bind(bind_addr).await.map_err(|e| {
let listener = bind_tcp_listener(bind_addr).map_err(|e| {
AppError::Io(std::io::Error::new(
e.kind(),
format!("VNC bind failed: {}", e),
))
})?;
let listener = TcpListener::from_std(listener).map_err(|e| {
AppError::Io(std::io::Error::new(
e.kind(),
format!("VNC listener setup failed: {}", e),
))
})?;
let config_ref = self.config.clone();
let video_manager = self.video_manager.clone();

View File

@@ -1,6 +1,6 @@
use super::*;
use crate::atx::{AtxState, PowerStatus};
use crate::atx::{AtxDriverType, AtxState, HddStatus, PowerStatus};
const WOL_HISTORY_DEFAULT_LIMIT: usize = 5;
const WOL_HISTORY_MAX_LIMIT: usize = 50;
@@ -13,21 +13,21 @@ pub struct AtxStateResponse {
pub initialized: bool,
pub power_status: String,
pub led_supported: bool,
pub hdd_status: String,
pub hdd_supported: bool,
}
impl From<AtxState> for AtxStateResponse {
fn from(state: AtxState) -> Self {
Self {
available: state.available,
backend: if state.power_configured || state.reset_configured {
format!(
"power: {}, reset: {}",
if state.power_configured { "yes" } else { "no" },
if state.reset_configured { "yes" } else { "no" }
)
} else {
"none".to_string()
},
backend: match state.driver {
AtxDriverType::Gpio => "gpio",
AtxDriverType::UsbRelay => "usbrelay",
AtxDriverType::Serial => "serial",
AtxDriverType::None => "none",
}
.to_string(),
initialized: state.power_configured || state.reset_configured,
power_status: match state.power_status {
PowerStatus::On => "on".to_string(),
@@ -35,6 +35,12 @@ impl From<AtxState> for AtxStateResponse {
PowerStatus::Unknown => "unknown".to_string(),
},
led_supported: state.led_supported,
hdd_status: match state.hdd_status {
HddStatus::Active => "active".to_string(),
HddStatus::Inactive => "inactive".to_string(),
HddStatus::Unknown => "unknown".to_string(),
},
hdd_supported: state.hdd_supported,
}
}
}
@@ -54,6 +60,8 @@ pub async fn atx_status(State(state): State<Arc<AppState>>) -> Result<Json<AtxSt
initialized: false,
power_status: "unknown".to_string(),
led_supported: false,
hdd_status: "unknown".to_string(),
hdd_supported: false,
})),
}
}

View File

@@ -47,13 +47,10 @@ fn validate_windows_atx_backends(atx: &AtxConfig) -> Result<()> {
return Ok(());
}
for (name, key) in [("power", &atx.power), ("reset", &atx.reset)] {
if !matches!(key.driver, AtxDriverType::Serial | AtxDriverType::None) {
return Err(AppError::BadRequest(format!(
"Windows ATX {} only supports serial relay or none",
name
)));
}
if !matches!(atx.driver, AtxDriverType::Serial | AtxDriverType::None) {
return Err(AppError::BadRequest(
"Windows ATX only supports serial relay or none".to_string(),
));
}
Ok(())
@@ -68,13 +65,11 @@ fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<(
return Ok(());
}
for (name, key) in [("power", &atx.power), ("reset", &atx.reset)] {
if key.driver == AtxDriverType::Serial && key.device.trim() == reserved {
return Err(AppError::BadRequest(format!(
"ATX {} serial device '{}' conflicts with HID CH9329 serial device",
name, reserved
)));
}
if atx.driver == AtxDriverType::Serial && atx.device.trim() == reserved {
return Err(AppError::BadRequest(format!(
"ATX serial device '{}' conflicts with HID CH9329 serial device",
reserved
)));
}
Ok(())
@@ -87,8 +82,8 @@ mod tests {
#[test]
fn test_validate_serial_device_conflict_rejects_ch9329_overlap() {
let mut atx = AtxConfig::default();
atx.power.driver = AtxDriverType::Serial;
atx.power.device = "/dev/ttyUSB0".to_string();
atx.driver = AtxDriverType::Serial;
atx.device = "/dev/ttyUSB0".to_string();
let mut hid = HidConfig::default();
hid.backend = HidBackend::Ch9329;
@@ -100,8 +95,8 @@ mod tests {
#[test]
fn test_validate_serial_device_conflict_allows_non_ch9329_backend() {
let mut atx = AtxConfig::default();
atx.power.driver = AtxDriverType::Serial;
atx.power.device = "/dev/ttyUSB0".to_string();
atx.driver = AtxDriverType::Serial;
atx.device = "/dev/ttyUSB0".to_string();
let mut hid = HidConfig::default();
hid.backend = HidBackend::None;
@@ -113,8 +108,7 @@ mod tests {
#[test]
fn test_validate_windows_atx_backends_allows_serial() {
let mut atx = AtxConfig::default();
atx.power.driver = AtxDriverType::Serial;
atx.reset.driver = AtxDriverType::None;
atx.driver = AtxDriverType::Serial;
assert!(validate_windows_atx_backends(&atx).is_ok());
}

View File

@@ -453,25 +453,24 @@ impl MsdConfigUpdate {
}
}
/// Update for a single ATX key configuration
/// Update for a single ATX output binding
#[typeshare]
#[derive(Debug, Deserialize)]
pub struct AtxKeyConfigUpdate {
pub driver: Option<crate::atx::AtxDriverType>,
pub struct AtxOutputBindingUpdate {
pub enabled: Option<bool>,
pub device: Option<String>,
pub baud_rate: Option<u32>,
pub pin: Option<u32>,
pub active_level: Option<crate::atx::ActiveLevel>,
}
/// Update for LED sensing configuration
/// Update for ATX GPIO input sensing
#[typeshare]
#[derive(Debug, Deserialize)]
pub struct AtxLedConfigUpdate {
pub struct AtxInputBindingUpdate {
pub enabled: Option<bool>,
pub gpio_chip: Option<String>,
pub gpio_pin: Option<u32>,
pub inverted: Option<bool>,
pub device: Option<String>,
pub pin: Option<u32>,
pub active_level: Option<crate::atx::ActiveLevel>,
}
/// ATX configuration update request
@@ -479,26 +478,46 @@ pub struct AtxLedConfigUpdate {
#[derive(Debug, Deserialize)]
pub struct AtxConfigUpdate {
pub enabled: Option<bool>,
pub driver: Option<crate::atx::AtxDriverType>,
pub device: Option<String>,
pub baud_rate: Option<u32>,
/// Power button configuration
pub power: Option<AtxKeyConfigUpdate>,
pub power: Option<AtxOutputBindingUpdate>,
/// Reset button configuration
pub reset: Option<AtxKeyConfigUpdate>,
pub reset: Option<AtxOutputBindingUpdate>,
/// LED sensing configuration
pub led: Option<AtxLedConfigUpdate>,
pub led: Option<AtxInputBindingUpdate>,
/// HDD activity sensing configuration
pub hdd: Option<AtxInputBindingUpdate>,
/// Network interface for WOL packets (empty = auto)
pub wol_interface: Option<String>,
}
impl AtxConfigUpdate {
pub fn validate(&self) -> crate::error::Result<()> {
// Validate power key config if present
if let Some(ref power) = self.power {
Self::validate_key_config(power, "power")?;
if let Some(baud_rate) = self.baud_rate {
if baud_rate == 0 {
return Err(AppError::BadRequest(
"ATX baud_rate must be greater than 0".to_string(),
));
}
}
// Validate reset key config if present
if let Some(ref reset) = self.reset {
Self::validate_key_config(reset, "reset")?;
for (name, binding) in [
("power", self.power.as_ref()),
("reset", self.reset.as_ref()),
] {
if let Some(binding) = binding {
Self::validate_output_binding_update(binding, name)?;
}
}
for (name, binding) in [("led", self.led.as_ref()), ("hdd", self.hdd.as_ref())] {
if let Some(binding) = binding {
Self::validate_input_binding_update(binding, name)?;
}
}
Ok(())
}
@@ -509,152 +528,143 @@ impl AtxConfigUpdate {
let mut merged = current.clone();
self.apply_to(&mut merged);
Self::validate_effective_key_config(&merged.power, "power")?;
Self::validate_effective_key_config(&merged.reset, "reset")?;
Self::validate_shared_serial_baud_rate(&merged)?;
if merged.driver == crate::atx::AtxDriverType::None {
if merged.enabled {
return Err(AppError::BadRequest(
"ATX driver must not be none when ATX is enabled".to_string(),
));
}
return Ok(());
}
merged.normalize();
if !merged.enabled {
return Ok(());
}
Self::validate_effective_config(&merged)?;
Ok(())
}
fn validate_shared_serial_baud_rate(config: &AtxConfig) -> crate::error::Result<()> {
let power = &config.power;
let reset = &config.reset;
let same_serial_device = power.driver == crate::atx::AtxDriverType::Serial
&& reset.driver == crate::atx::AtxDriverType::Serial
&& !power.device.trim().is_empty()
&& power.device.trim() == reset.device.trim();
fn validate_device_path(device: &str, name: &str) -> crate::error::Result<()> {
let trimmed = device.trim();
if trimmed.is_empty() {
return Err(AppError::BadRequest(format!(
"{} device cannot be empty",
name
)));
}
if same_serial_device && power.baud_rate != reset.baud_rate {
return Err(AppError::BadRequest(
"ATX power/reset sharing the same serial relay device must use one baud_rate"
.to_string(),
));
if !cfg!(windows) && !std::path::Path::new(trimmed).exists() {
return Err(AppError::BadRequest(format!(
"{} device '{}' does not exist",
name, trimmed
)));
}
Ok(())
}
fn validate_key_config(key: &AtxKeyConfigUpdate, name: &str) -> crate::error::Result<()> {
if let Some(ref device) = key.device {
if !device.trim().is_empty() && !cfg!(windows) && !std::path::Path::new(device).exists()
{
return Err(AppError::BadRequest(format!(
"{} device '{}' does not exist",
name, device
)));
}
}
if let Some(baud_rate) = key.baud_rate {
if baud_rate == 0 {
return Err(AppError::BadRequest(format!(
"{} baud_rate must be greater than 0",
name
)));
}
}
if let Some(driver) = key.driver {
match driver {
crate::atx::AtxDriverType::Serial => {
if let Some(pin) = key.pin {
if pin == 0 {
return Err(AppError::BadRequest(format!(
"{} serial channel must be 1-based (>= 1)",
name
)));
}
if pin > u8::MAX as u32 {
return Err(AppError::BadRequest(format!(
"{} serial channel must be <= {}",
name,
u8::MAX
)));
}
}
}
crate::atx::AtxDriverType::UsbRelay => {
if let Some(pin) = key.pin {
if pin == 0 {
return Err(AppError::BadRequest(format!(
"{} USB relay channel must be 1-based (>= 1)",
name
)));
}
if pin > u8::MAX as u32 {
return Err(AppError::BadRequest(format!(
"{} USB relay channel must be <= {}",
name,
u8::MAX
)));
}
if pin > 8 {
return Err(AppError::BadRequest(format!(
"{} USB HID relay channel must be <= 8",
name
)));
}
}
}
crate::atx::AtxDriverType::Gpio | crate::atx::AtxDriverType::None => {}
}
}
Ok(())
}
fn validate_effective_key_config(
key: &crate::atx::AtxKeyConfig,
fn validate_output_binding_update(
binding: &AtxOutputBindingUpdate,
name: &str,
) -> crate::error::Result<()> {
match key.driver {
crate::atx::AtxDriverType::Serial => {
if key.device.trim().is_empty() {
return Err(AppError::BadRequest(format!(
"{} serial device cannot be empty",
name
)));
if let Some(ref device) = binding.device {
if !device.trim().is_empty() {
Self::validate_device_path(device, name)?;
}
}
Ok(())
}
fn validate_input_binding_update(
binding: &AtxInputBindingUpdate,
name: &str,
) -> crate::error::Result<()> {
if let Some(ref device) = binding.device {
if !device.trim().is_empty() {
Self::validate_device_path(device, name)?;
}
}
Ok(())
}
fn validate_effective_config(config: &AtxConfig) -> crate::error::Result<()> {
match config.driver {
crate::atx::AtxDriverType::Gpio => {
for (name, binding) in [("power", &config.power), ("reset", &config.reset)] {
if binding.enabled {
Self::validate_device_path(&binding.device, name)?;
}
}
if key.pin == 0 {
return Err(AppError::BadRequest(format!(
"{} serial channel must be 1-based (>= 1)",
name
)));
}
if key.pin > u8::MAX as u32 {
return Err(AppError::BadRequest(format!(
"{} serial channel must be <= {}",
name,
u8::MAX
)));
}
if key.baud_rate == 0 {
return Err(AppError::BadRequest(format!(
"{} baud_rate must be greater than 0",
name
)));
for (name, binding) in [("led", &config.led), ("hdd", &config.hdd)] {
if binding.enabled {
Self::validate_device_path(&binding.device, name)?;
}
}
}
crate::atx::AtxDriverType::UsbRelay => {
if key.pin == 0 {
return Err(AppError::BadRequest(format!(
"{} USB relay channel must be 1-based (>= 1)",
name
)));
}
if key.pin > u8::MAX as u32 {
return Err(AppError::BadRequest(format!(
"{} USB relay channel must be <= {}",
name,
u8::MAX
)));
}
if key.pin > 8 {
return Err(AppError::BadRequest(format!(
"{} USB HID relay channel must be <= 8",
name
)));
}
Self::validate_device_path(&config.device, "ATX LCUS HID relay")?;
Self::validate_output_channel(
&config.power,
"power",
1,
crate::atx::LCUS_RELAY_MAX_CHANNEL as u32,
)?;
Self::validate_output_channel(
&config.reset,
"reset",
1,
crate::atx::LCUS_RELAY_MAX_CHANNEL as u32,
)?;
}
crate::atx::AtxDriverType::Gpio | crate::atx::AtxDriverType::None => {}
crate::atx::AtxDriverType::Serial => {
Self::validate_device_path(&config.device, "ATX LCUS serial relay")?;
if config.baud_rate == 0 {
return Err(AppError::BadRequest(
"ATX baud_rate must be greater than 0".to_string(),
));
}
Self::validate_output_channel(
&config.power,
"power",
1,
crate::atx::LCUS_RELAY_MAX_CHANNEL as u32,
)?;
Self::validate_output_channel(
&config.reset,
"reset",
1,
crate::atx::LCUS_RELAY_MAX_CHANNEL as u32,
)?;
}
crate::atx::AtxDriverType::None => {}
};
Ok(())
}
fn validate_output_channel(
binding: &crate::atx::AtxOutputBinding,
name: &str,
min: u32,
max: u32,
) -> crate::error::Result<()> {
if !binding.enabled {
return Ok(());
}
if binding.pin < min || binding.pin > max {
return Err(AppError::BadRequest(format!(
"{} channel must be between {} and {}",
name, min, max
)));
}
Ok(())
}
@@ -662,50 +672,65 @@ impl AtxConfigUpdate {
if let Some(enabled) = self.enabled {
config.enabled = enabled;
}
if let Some(driver) = self.driver {
config.driver = driver;
}
if let Some(ref device) = self.device {
config.device = device.clone();
}
if let Some(baud_rate) = self.baud_rate {
config.baud_rate = baud_rate;
}
if let Some(ref power) = self.power {
Self::apply_key_update(power, &mut config.power);
Self::apply_output_binding_update(power, &mut config.power);
}
if let Some(ref reset) = self.reset {
Self::apply_key_update(reset, &mut config.reset);
Self::apply_output_binding_update(reset, &mut config.reset);
}
if let Some(ref led) = self.led {
Self::apply_led_update(led, &mut config.led);
Self::apply_input_binding_update(led, &mut config.led);
}
if let Some(ref hdd) = self.hdd {
Self::apply_input_binding_update(hdd, &mut config.hdd);
}
if let Some(ref wol_interface) = self.wol_interface {
config.wol_interface = wol_interface.clone();
}
}
fn apply_key_update(update: &AtxKeyConfigUpdate, config: &mut crate::atx::AtxKeyConfig) {
if let Some(driver) = update.driver {
config.driver = driver;
fn apply_output_binding_update(
update: &AtxOutputBindingUpdate,
config: &mut crate::atx::AtxOutputBinding,
) {
if let Some(enabled) = update.enabled {
config.enabled = enabled;
}
if let Some(ref device) = update.device {
config.device = device.clone();
}
if let Some(baud_rate) = update.baud_rate {
config.baud_rate = baud_rate;
if let Some(pin) = update.pin {
config.pin = pin;
}
if let Some(active_level) = update.active_level {
config.active_level = active_level;
}
}
fn apply_input_binding_update(
update: &AtxInputBindingUpdate,
config: &mut crate::atx::AtxInputBinding,
) {
if let Some(enabled) = update.enabled {
config.enabled = enabled;
}
if let Some(ref device) = update.device {
config.device = device.clone();
}
if let Some(pin) = update.pin {
config.pin = pin;
}
if let Some(level) = update.active_level {
config.active_level = level;
}
}
fn apply_led_update(update: &AtxLedConfigUpdate, config: &mut crate::atx::AtxLedConfig) {
if let Some(enabled) = update.enabled {
config.enabled = enabled;
}
if let Some(ref chip) = update.gpio_chip {
config.gpio_chip = chip.clone();
}
if let Some(pin) = update.gpio_pin {
config.gpio_pin = pin;
}
if let Some(inverted) = update.inverted {
config.inverted = inverted;
if let Some(active_level) = update.active_level {
config.active_level = active_level;
}
}
}
@@ -1233,77 +1258,111 @@ impl RedfishConfigUpdate {
mod tests {
use super::*;
#[test]
fn test_atx_apply_key_update_applies_baud_rate() {
let mut config = AtxConfig::default();
let update = AtxConfigUpdate {
fn empty_atx_update() -> AtxConfigUpdate {
AtxConfigUpdate {
enabled: None,
power: Some(AtxKeyConfigUpdate {
driver: Some(crate::atx::AtxDriverType::Serial),
device: Some("/dev/null".to_string()),
baud_rate: Some(19200),
pin: Some(1),
active_level: None,
}),
driver: None,
device: None,
baud_rate: None,
power: None,
reset: None,
led: None,
hdd: None,
wol_interface: None,
};
}
}
#[test]
fn test_atx_apply_update_applies_global_baud_rate() {
let mut config = AtxConfig::default();
let mut update = empty_atx_update();
update.driver = Some(crate::atx::AtxDriverType::Serial);
update.device = Some("/dev/null".to_string());
update.baud_rate = Some(19200);
update.power = Some(AtxOutputBindingUpdate {
enabled: Some(true),
device: Some("/dev/ignored".to_string()),
pin: Some(1),
active_level: None,
});
update.apply_to(&mut config);
assert_eq!(config.power.baud_rate, 19200);
assert_eq!(config.driver, crate::atx::AtxDriverType::Serial);
assert_eq!(config.device, "/dev/null");
assert_eq!(config.baud_rate, 19200);
assert!(config.power.enabled);
assert_eq!(config.power.pin, 1);
}
#[test]
fn test_atx_validate_with_current_rejects_serial_pin_zero() {
let mut current = AtxConfig::default();
current.power.driver = crate::atx::AtxDriverType::Serial;
current.power.device = "/dev/null".to_string();
current.enabled = true;
current.driver = crate::atx::AtxDriverType::Serial;
current.device = "/dev/null".to_string();
current.power.enabled = true;
current.power.pin = 1;
current.power.baud_rate = 9600;
current.baud_rate = 9600;
let update = AtxConfigUpdate {
let mut update = empty_atx_update();
update.power = Some(AtxOutputBindingUpdate {
enabled: None,
power: Some(AtxKeyConfigUpdate {
driver: None,
device: None,
baud_rate: None,
pin: Some(0),
active_level: None,
}),
reset: None,
led: None,
wol_interface: None,
};
device: None,
pin: Some(0),
active_level: None,
});
assert!(update.validate_with_current(&current).is_err());
}
#[test]
fn test_atx_validate_with_current_rejects_shared_serial_baud_mismatch() {
fn test_atx_validate_with_current_rejects_lcus_serial_channel_over_8() {
let mut current = AtxConfig::default();
current.power.driver = crate::atx::AtxDriverType::Serial;
current.power.device = "/dev/ttyUSB0".to_string();
current.enabled = true;
current.driver = crate::atx::AtxDriverType::Serial;
current.device = "/dev/null".to_string();
current.power.enabled = true;
current.power.pin = 1;
current.power.baud_rate = 9600;
current.reset.driver = crate::atx::AtxDriverType::Serial;
current.reset.device = "/dev/ttyUSB0".to_string();
current.reset.pin = 2;
current.reset.baud_rate = 9600;
current.baud_rate = 9600;
let update = AtxConfigUpdate {
let mut update = empty_atx_update();
update.power = Some(AtxOutputBindingUpdate {
enabled: None,
power: None,
reset: Some(AtxKeyConfigUpdate {
driver: None,
device: None,
baud_rate: Some(115200),
pin: None,
active_level: None,
}),
led: None,
wol_interface: None,
};
device: None,
pin: Some(9),
active_level: None,
});
assert!(update.validate_with_current(&current).is_err());
}
#[test]
fn test_atx_validate_with_current_rejects_enabled_none_driver() {
let mut current = AtxConfig::default();
current.driver = crate::atx::AtxDriverType::None;
let mut update = empty_atx_update();
update.enabled = Some(true);
assert!(update.validate_with_current(&current).is_err());
}
#[test]
fn test_atx_validate_with_current_rejects_usb_relay_channel_over_8() {
let mut current = AtxConfig::default();
current.enabled = true;
current.driver = crate::atx::AtxDriverType::UsbRelay;
current.device = "/dev/null".to_string();
current.power.enabled = true;
current.power.pin = 1;
let mut update = empty_atx_update();
update.power = Some(AtxOutputBindingUpdate {
enabled: None,
device: None,
pin: Some(9),
active_level: None,
});
assert!(update.validate_with_current(&current).is_err());
}
@@ -1335,4 +1394,82 @@ mod tests {
};
assert!(update.validate().is_err());
}
#[test]
fn ipv6_bind_vnc_config_accepts_ipv6_literals() {
for bind in ["::", "::1", "2001:db8::1"] {
let update = VncConfigUpdate {
enabled: None,
bind: Some(bind.to_string()),
port: Some(5900),
encoding: None,
jpeg_quality: None,
allow_one_client: None,
password: None,
};
assert!(
update.validate().is_ok(),
"bind address should pass: {bind}"
);
}
}
#[test]
fn ipv6_bind_vnc_config_rejects_non_ip_or_socket_syntax() {
for bind in ["localhost", "127.0.0.1:5900", "[::1]:5900"] {
let update = VncConfigUpdate {
enabled: None,
bind: Some(bind.to_string()),
port: Some(5900),
encoding: None,
jpeg_quality: None,
allow_one_client: None,
password: None,
};
assert!(
update.validate().is_err(),
"bind address should fail: {bind}"
);
}
}
#[test]
fn ipv6_bind_rtsp_config_accepts_ipv6_literals() {
for bind in ["::", "::1", "2001:db8::1"] {
let update = RtspConfigUpdate {
enabled: None,
bind: Some(bind.to_string()),
port: Some(8554),
path: None,
allow_one_client: None,
codec: None,
username: None,
password: None,
};
assert!(
update.validate().is_ok(),
"bind address should pass: {bind}"
);
}
}
#[test]
fn ipv6_bind_rtsp_config_rejects_non_ip_or_socket_syntax() {
for bind in ["localhost", "127.0.0.1:8554", "[::1]:8554"] {
let update = RtspConfigUpdate {
enabled: None,
bind: Some(bind.to_string()),
port: Some(8554),
path: None,
allow_one_client: None,
codec: None,
username: None,
password: None,
};
assert!(
update.validate().is_err(),
"bind address should fail: {bind}"
);
}
}
}

View File

@@ -2,7 +2,7 @@ use super::*;
use crate::msd::{
DownloadProgress, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo,
ImageManager, MsdConnectRequest, MsdMode, MsdState, VentoyDrive,
ImageManager, MsdConnectRequest, MsdMode, MsdState, VentoyDrive, MIN_DRIVE_SIZE_MB,
};
#[cfg(unix)]
use axum::body::Body;
@@ -15,6 +15,63 @@ use axum::response::Response;
#[cfg(unix)]
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
#[cfg(unix)]
#[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);
if !drive.exists() {
// 404: drive image file does not exist at all — truly not initialized
return Err(AppError::NotFound("Drive not initialized".to_string()));
}
let info = drive.info().await?;
Ok(Json(info))
match drive.info().await {
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
@@ -240,6 +310,16 @@ pub async fn msd_drive_init(
Json(req): Json<DriveInitRequest>,
) -> Result<Json<DriveInfo>> {
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 = VentoyDrive::new(drive_path);
@@ -283,12 +363,24 @@ pub async fn msd_drive_files(
State(state): State<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Vec<DriveFile>>> {
// Block when connected: concurrent access corrupts the filesystem
assert_drive_not_connected(&state).await?;
let config = state.config.get();
let drive_path = config.msd.drive_path();
let drive = VentoyDrive::new(drive_path);
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))
}
@@ -299,6 +391,10 @@ pub async fn msd_drive_upload(
Query(params): Query<HashMap<String, String>>,
mut multipart: Multipart,
) -> 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 drive_path = config.msd.drive_path();
let drive = VentoyDrive::new(drive_path);
@@ -345,6 +441,10 @@ pub async fn msd_drive_download(
State(state): State<Arc<AppState>>,
AxumPath(file_path): AxumPath<String>,
) -> 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 drive_path = config.msd.drive_path();
let drive = VentoyDrive::new(drive_path);
@@ -380,6 +480,10 @@ pub async fn msd_drive_file_delete(
State(state): State<Arc<AppState>>,
AxumPath(file_path): AxumPath<String>,
) -> 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 drive_path = config.msd.drive_path();
let drive = VentoyDrive::new(drive_path);
@@ -398,6 +502,10 @@ pub async fn msd_drive_mkdir(
State(state): State<Arc<AppState>>,
AxumPath(dir_path): AxumPath<String>,
) -> 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 drive_path = config.msd.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)),
}))
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn validate_drive_init_size_accepts_64mb() {
validate_drive_init_size(MIN_DRIVE_SIZE_MB, MIN_DRIVE_SIZE_MB as u64 * MIB).unwrap();
}
#[test]
fn validate_drive_init_size_rejects_below_64mb() {
let err = validate_drive_init_size(MIN_DRIVE_SIZE_MB - 1, 1024 * MIB).unwrap_err();
assert!(err.to_string().contains("at least 64 MB"));
}
#[test]
fn validate_drive_init_size_rejects_available_space_overflow() {
let err = validate_drive_init_size(65, 64 * MIB).unwrap_err();
assert!(err.to_string().contains("cannot exceed available space"));
}
#[test]
fn detects_unsupported_drive_filesystem_errors() {
assert!(is_unsupported_drive_filesystem(
"Internal error: Filesystem error: Invalid exFAT signature"
));
assert!(is_unsupported_drive_filesystem(
"Internal error: Partition error: invalid partition table"
));
assert!(!is_unsupported_drive_filesystem(
"IO error: permission denied"
));
}
}

View File

@@ -18,7 +18,6 @@ pub async fn health_check() -> Json<HealthResponse> {
#[derive(Serialize)]
pub struct SystemInfo {
pub version: &'static str,
pub build_date: &'static str,
pub initialized: bool,
pub platform: PlatformCapabilities,
pub capabilities: Capabilities,
@@ -66,7 +65,6 @@ pub async fn system_info(State(state): State<Arc<AppState>>) -> Json<SystemInfo>
Json(SystemInfo {
version: env!("CARGO_PKG_VERSION"),
build_date: env!("BUILD_DATE"),
initialized: config.initialized,
platform: platform.clone(),
capabilities: Capabilities {
@@ -88,10 +86,7 @@ pub async fn system_info(State(state): State<Arc<AppState>>) -> Json<SystemInfo>
atx: CapabilityInfo {
available: config.atx.enabled,
backend: if config.atx.enabled {
Some(format!(
"power: {:?}, reset: {:?}",
config.atx.power.driver, config.atx.reset.driver
))
Some(format!("{:?}", config.atx.driver))
} else {
None
},

View File

@@ -12,6 +12,7 @@ use crate::audio::{AudioController, OpusFrame};
use crate::error::{AppError, Result};
use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
use crate::hid::HidController;
use crate::video::capture::DEFAULT_CAPTURE_BUFFER_COUNT;
use crate::video::codec::h264_bitstream;
use crate::video::device::{
enumerate_devices, select_recovery_device, VideoDevice, VideoDeviceRecoveryHint,
@@ -319,7 +320,7 @@ impl WebRtcStreamer {
current_capture
.as_ref()
.map(|capture| capture.buffer_count)
.unwrap_or(2),
.unwrap_or(DEFAULT_CAPTURE_BUFFER_COUNT),
)
};
@@ -745,7 +746,7 @@ impl WebRtcStreamer {
});
*self.capture_device.write().await = Some(CaptureDeviceConfig {
device_path,
buffer_count: 2,
buffer_count: DEFAULT_CAPTURE_BUFFER_COUNT,
jpeg_quality,
subdev_path,
bridge_kind,

4
test/okvm-test/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.venv/
__pycache__/
reports/
bin/*.exe

187
test/okvm-test/README.md Normal file
View File

@@ -0,0 +1,187 @@
# One-KVM 自动化产测工具
工具由两部分组成:
- `okvm_testctl.py`Windows 本地控制端。负责 SSH 操作、One-KVM HTTP API 调用、MJPEG/WebRTC 客户端、HID 检测、MSD 检测、ATX API 检测和报告输出。`run` 子命令只支持原生 Windows不支持 WSL/Linux。
- `agent/`Windows 被控机配套程序。使用 Go 编写,编译为单文件 `.exe`
## 编译 Windows 配套程序
在 Windows PowerShell 安装 Go 后执行:
```powershell
cd test/okvm-test/agent
New-Item -ItemType Directory -Force ..\bin
go build -o ..\bin\okvm-win-agent-amd64.exe .
```
编译产物输出到:
```text
test/okvm-test/bin/okvm-win-agent-amd64.exe
```
将该 exe 放到被控 Windows 机器上,在开始产测前运行:
```powershell
.\okvm-win-agent-amd64.exe -listen 0.0.0.0:8765
```
Windows 配套程序会监听所有网卡地址,本地控制端通过 `--agent-host <Windows测试机IP>` 主动连接它。配套程序默认隐藏测试窗口只有视频延迟、HID 等需要采集画面/输入时才显示。
## 安装本地控制端依赖
Windows 控制端使用 PowerShell
```powershell
cd test/okvm-test
py -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python -m playwright install chrome
```
控制端固定使用 Google Chrome (`chrome`) 跑 WebRTC/H.265 测试,以便使用 Windows 核显/系统解码。所有 Chrome 启动都会加入 `--enable-features=WebRtcAllowH265Receive``--force-fieldtrials=WebRTC-Video-H26xPacketBuffer/Enabled`
## 针对当前测试机运行
PowerShell
```powershell
cd test/okvm-test
$env:OKVM_SSH_PASSWORD="1234"
python okvm_testctl.py run `
--target 192.168.137.67 `
--ssh-user root `
--ssh-password-prompt `
--reset `
--agent-host <Windows测试机IP> `
--agent-port 8765
```
默认流程:
1. 通过 SSH 停止 `one-kvm`
2. 备份 `/etc/one-kvm/one-kvm.db`
3. 删除当前数据库,实现强制重置。
4. 启动 `one-kvm`
5. 采集控制端到目标机的 TCP connect 和 HTTP `/health` 往返延迟,调用初始化接口创建测试账号,登录后发现设备并应用测试配置。
6. 启用 OTG MSD 后,默认把本仓库的 Ventoy 资源同步到目标机 `/etc/one-kvm/ventoy`,并重启一次 `one-kvm` 让资源初始化生效。
7. 执行视频输入/输出、每个视频输入的 MJPEG 端到端画面延迟、HDMI 画面确认、颜色偏差、HID、MSD、ATX API 和性能检测。
8. 输出中文完整报告 `reports/<run-id>/report-YYYYMMDD-HHMMSS.md` 和面向用户的迷你摘要 `reports/<run-id>/user-report-YYYYMMDD-HHMMSS.md`,截图、日志和采集帧保存在 `reports/<run-id>/evidence/`
终端结果默认使用颜色区分:绿色为 `PASS`,黄色为 `WARN/SKIP`,红色为 `FAIL`。可通过 `--no-color``NO_COLOR=1` 关闭颜色。
## 视频测试规则
控制端会先通过 SSH 执行 `lsusb -t`,再结合 `/api/devices` 自动选择视频输入:
- USB2.0 采集卡:测试 `1080p30 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率最高帧率;如果没有 1080p YUYV才退到不超过 1080p 的最高分辨率。
- USB3.0 采集卡:测试 `1080p60 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率最高帧率;如果没有 1080p YUYV才退到不超过 1080p 的最高分辨率。
- CSI/MIPI只测试一套 `1080p60 NV12`,不做输入格式切换。
每个输入配置都会跑三种输出:
- MJPEG/HTTP
- H.264 WebRTC
- H.265 WebRTC
默认每个视频输出模式采样 30 秒;可通过 `--sample-seconds <秒数>` 覆盖。
MJPEG/HTTP 测试时,控制端会让 Windows agent 输出默认 60fps 的全屏动态画面,避免静态画面触发 MJPEG “无变化不发帧”策略导致 fps 误判;可通过 `--mjpeg-motion-fps <fps>` 覆盖。
如果浏览器或编码器不支持 H.265,会记录为 `SKIP`
默认情况下,视频链路只要实际 fps 大于 0 就视为功能成功;报告保留实际 fps、RTT 和 jitter。需要把低帧率作为失败时`--strict-performance`
## HDMI 采集与视频延迟测试
连接 Windows agent 后,控制端会让 Windows 配套程序打开全屏纯色窗口,然后通过 One-KVM 的 MJPEG/HTTP 或浏览器 WebRTC 解码画面抓帧检测:
- `hdmi_identity`:确认 HDMI 采集画面确实跟随被控 Windows 画面变化,至少验证红、绿、蓝三种高饱和颜色。
- `hdmi_color_range`:采样画面中心区域,输出期望 RGB、实测 RGB 均值、标准差、平均误差和最大通道误差。
- `video_latency_<输入>_mjpeg` / `video_latency_<输入>_h264` / `video_latency_<输入>_h265`:每个被选中的视频输入 case 都会分别执行 MJPEG、H.264、H.265 延迟测试。Windows agent 延迟切换纯色Windows 控制端从 MJPEG 帧或 Chrome 解码后的 WebRTC video 帧中检测首次颜色变化,输出端到端视觉延迟 p50、p95、max。延迟测试不保存命中帧截图。
纯色采样不会再用第一帧直接判定;控制端会在超时时间内持续拉取 MJPEG等待目标颜色出现。若始终未匹配会保存最佳接近帧并在 Markdown 报告中写出实测 RGB、未命中数量和采样帧数。
默认颜色误差阈值:
- 平均 RGB 误差 `<=30``PASS`
- 平均 RGB 误差 `<=60``WARN`
- 平均 RGB 误差 `>60``FAIL`
默认视频视觉延迟阈值只用于基本功能判定:
- p95 `<=3000ms``PASS`
- p95 `>3000ms` 或未检测到目标颜色:`FAIL`
相关开关:
- `--no-hdmi-tests`:跳过 HDMI 画面、颜色和视频视觉延迟测试。
- `--hdmi-capture-timeout <秒>`:单个纯色采样等待目标颜色的最长时间,默认 8 秒。
- `--hdmi-match-frames <帧数>`:目标颜色需要连续匹配的帧数,默认 1静态 MJPEG 场景建议保持默认。
- `--hdmi-color-warn <level>` / `--hdmi-color-fail <level>`:调整颜色偏差阈值。
- `--video-latency-trials <次数>`:调整切色延迟测试次数,默认 5 次。旧名 `--hdmi-latency-trials` 仍兼容。
- `--video-latency-fail-ms <ms>`:调整基本功能阈值,默认 3000ms。旧名 `--hdmi-latency-fail-ms` 仍兼容。
WebRTC 性能表仍保留连接 RTT/jitter真实画面级延迟会额外按 MJPEG、H.264、H.265 逐输入输出。
## 网页截图证据
默认保留关键过程网页截图到 `reports/<run-id>/evidence/screenshots/`
- 登录后控制台首页。
- MJPEG 模式网页截图。
- H.264 WebRTC 模式网页截图。
- H.265 WebRTC 尝试网页截图。
- HID/MSD/ATX 测试后的控制台状态。
视频模式截图会等待页面脱离“等待首帧/连接中”等过渡状态后再保存H.265 不支持等真实失败页面会直接保留作为证据。等待时间可用 `--screenshot-wait-ms <ms>` 调整,默认 6000ms。
如需跳过网页截图,可加 `--no-screenshots`
## HID 测试
HID 测试依赖 Windows agent。流程如下
1. `/hid/status` 确认 HID 后端可用。
2. 键盘矩阵覆盖全部字母、数字、F1-F12以及不影响系统运行的 Ctrl/Shift + 功能键组合。
3. 鼠标矩阵覆盖绝对坐标移动和相对移动。
4. 键盘鼠标矩阵完成后,使用 F8 做 HID 延迟采样,输出 p50、p95、max。
HID 延迟默认 5 次采样,可通过 `--hid-latency-trials <次数>` 调整。默认 p95 `<=80ms``PASS``<=200ms``WARN`,更高为 `FAIL`;阈值可用 `--hid-latency-warn-ms <ms>``--hid-latency-fail-ms <ms>` 调整。
## MSD 测试
MSD 测试依赖 OTG。流程如下
1. 确认目标机 `/etc/one-kvm/ventoy` 存在 `boot.img``core.img``ventoy.disk.img`
2. 如果资源缺失,默认从 `libs/ventoy-img-rs/resources` 解压并通过 SSH/SFTP 同步到目标机。
3. 启用 MSD 后重启 `one-kvm`,确保 Ventoy 资源在服务进程中初始化。
4. 通过 `/api/msd/drive/init` 创建小型虚拟盘。
5. 通过 `/api/msd/connect {"mode":"drive"}` 连接到 Windows。
6. Windows agent 等待新盘符出现。
7. Windows agent 写入测试文件、同步到虚拟盘、优先用未缓存读取读回并校验 SHA-256同时输出简单写入/读取速度。若 Windows/驱动不支持未缓存读取,会退回缓存读取并在报告中标为“仅校验”,不作为真实读盘速度。
8. 控制端断开 MSDWindows agent 确认盘符消失。
相关开关:
- `--ventoy-resources-dir <dir>`:指定本地 Ventoy 资源目录。
- `--no-ventoy-sync`:不向目标机同步 Ventoy 资源。
- `--no-msd-restart-after-enable`:启用 MSD 后不重启 `one-kvm`
## ATX 测试
默认不执行真实电源按键动作,因为当前测试环境不会连接真实 ATX 设备。
默认只验证:
- `/api/atx/status`
- `/api/config/atx`
- `/api/atx/wol`
## 注意事项
- `--reset` 会删除 One-KVM 当前 SQLite 数据库,虽然会先备份,但这是破坏性操作。
- 旧数据库不会自动恢复,备份保存在目标机 `/etc/one-kvm/test-backups/<run-id>/`
- 编译出的 `.exe` 和测试报告默认被 `.gitignore` 忽略。

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
arch="${GOARCH:-amd64}"
out="../bin/okvm-win-agent-${arch}.exe"
GOOS=windows GOARCH="$arch" CGO_ENABLED=0 \
go build -trimpath -ldflags "-s -w" -o "$out" .
echo "Built $out"

View File

@@ -0,0 +1,5 @@
module okvm-win-agent
go 1.22
require github.com/gorilla/websocket v1.5.3

View File

@@ -0,0 +1,2 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

1202
test/okvm-test/agent/main.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
import io
from typing import Any
HDMI_COLOR_SEQUENCE: tuple[tuple[str, str], ...] = (
("black", "#000000"),
("white", "#ffffff"),
("gray16", "#101010"),
("gray128", "#808080"),
("gray235", "#ebebeb"),
("red", "#ff0000"),
("green", "#00ff00"),
("blue", "#0000ff"),
)
def hex_to_rgb(value: str) -> tuple[int, int, int]:
s = value.strip().lstrip("#")
if len(s) != 6:
raise ValueError(f"invalid RGB color: {value}")
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
def rgb_error(expected: tuple[int, int, int], measured: tuple[float, float, float]) -> dict[str, float]:
errors = [abs(float(measured[i]) - float(expected[i])) for i in range(3)]
return {
"mean_abs_error": sum(errors) / 3,
"max_abs_error": max(errors),
}
def closest_hdmi_color(measured: tuple[float, float, float]) -> tuple[str, float]:
best_name = ""
best_error = 999.0
for name, color in HDMI_COLOR_SEQUENCE:
err = rgb_error(hex_to_rgb(color), measured)["mean_abs_error"]
if err < best_error:
best_name = name
best_error = err
return best_name, best_error
def jpeg_rgb_stats(frame: bytes) -> dict[str, Any]:
try:
from PIL import Image, ImageStat
except ImportError as exc:
raise RuntimeError("Pillow is required for HDMI color/latency tests; run pip install -r requirements.txt") from exc
with Image.open(io.BytesIO(frame)) as image:
rgb = image.convert("RGB")
width, height = rgb.size
left = max(0, int(width * 0.35))
top = max(0, int(height * 0.35))
right = min(width, int(width * 0.65))
bottom = min(height, int(height * 0.65))
crop = rgb.crop((left, top, right, bottom))
stat = ImageStat.Stat(crop)
return {
"width": width,
"height": height,
"sample_box": [left, top, right, bottom],
"mean_rgb": tuple(round(float(v), 2) for v in stat.mean),
"stddev_rgb": tuple(round(float(v), 2) for v in stat.stddev),
}
def percentile(values: list[float], pct: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
if len(ordered) == 1:
return ordered[0]
rank = (len(ordered) - 1) * (pct / 100)
lower = int(rank)
upper = min(lower + 1, len(ordered) - 1)
weight = rank - lower
return ordered[lower] * (1 - weight) + ordered[upper] * weight

View File

@@ -0,0 +1,671 @@
from __future__ import annotations
import os
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass
class CheckResult:
name: str
status: str
message: str = ""
data: dict[str, Any] = field(default_factory=dict)
@dataclass
class Metric:
name: str
value: Any
unit: str = ""
labels: dict[str, Any] = field(default_factory=dict)
@dataclass
class Evidence:
category: str
title: str
path: str
STATUS_TEXT = {
"PASS": "通过",
"FAIL": "失败",
"WARN": "警告",
"SKIP": "跳过",
}
STATUS_COLOR = {
"PASS": "\033[32m",
"FAIL": "\033[31m",
"WARN": "\033[33m",
"SKIP": "\033[33m",
"INFO": "\033[36m",
}
CATEGORIES_WITH_DATA_TABLE = {
"初始化与环境",
"视频性能",
"HDMI 画面与颜色",
"HID / MSD / ATX",
}
CATEGORIES_WITHOUT_DATA_TABLE: set[str] = set()
REPORT_HIDDEN_RESULTS = {
"log_collection",
"screenshot",
}
CATEGORY_RULES = (
("初始化与环境", ("target_", "setup_", "login", "network_", "target_inventory", "stream_codecs", "windows_agent", "ventoy_resources", "msd_restart")),
("视频性能", ("video_", "config_video_")),
("HDMI 画面与颜色", ("hdmi_", "config_video_hdmi_probe")),
("HID / MSD / ATX", ("hid_", "msd", "atx_")),
)
DISPLAY_NAMES = {
"target_reset": "目标机重置",
"setup_init": "初始化账号",
"login": "登录",
"login_after_restart": "重启后登录",
"network_latency": "网络延迟",
"target_inventory": "目标机设备清单",
"hid_msd_config": "HID/MSD 配置",
"ventoy_resources": "Ventoy 资源",
"msd_restart": "MSD 启用后重启",
"video_input_select": "视频输入选择",
"windows_agent": "Windows 配套程序连接",
"config_video_hdmi_probe": "HDMI 采集配置",
"hdmi_identity": "HDMI 画面来源确认",
"hdmi_color_range": "HDMI 颜色偏差",
"hdmi_latency_mjpeg": "MJPEG 画面延迟",
"hid_status": "HID 状态",
"hid_input": "HID 输入",
"hid_latency": "HID 延迟",
"msd": "MSD 虚拟盘",
"atx_api": "ATX API",
"log_collection": "日志采集",
"screenshot": "网页截图",
}
class Reporter:
def __init__(self, report_dir: Path, color: bool | None = None):
self.report_dir = report_dir
self.results: list[CheckResult] = []
self.metrics: list[Metric] = []
self.evidence: list[Evidence] = []
self.report_dir.mkdir(parents=True, exist_ok=True)
(self.report_dir / "evidence").mkdir(exist_ok=True)
self.use_color = color if color is not None else (sys.stdout.isatty() and not os.environ.get("NO_COLOR"))
def add(self, check_name: str, outcome: str, detail: str = "", **data: Any) -> None:
self.results.append(CheckResult(name=check_name, status=outcome, message=detail, data=data))
prefix = {"PASS": "[PASS]", "FAIL": "[FAIL]", "SKIP": "[SKIP]", "WARN": "[WARN]"}.get(outcome, "[INFO]")
print(f"{self.colorize(prefix, outcome)} {check_name}: {detail}")
def info(self, check_name: str, detail: str) -> None:
print(f"{self.colorize('[INFO]', 'INFO')} {check_name}: {detail}")
def metric(self, name: str, value: Any, unit: str = "", **labels: Any) -> None:
self.metrics.append(Metric(name=name, value=value, unit=unit, labels=labels))
def add_evidence(self, category: str, title: str, path: Path) -> None:
rel = path.relative_to(self.report_dir) if path.is_relative_to(self.report_dir) else path
self.evidence.append(Evidence(category=category, title=title, path=str(rel)))
def write(self, run_id: str, target: str) -> None:
generated_at = time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime())
report_timestamp = time.strftime("%Y%m%d-%H%M%S", time.localtime())
status = "失败" if any(r.status == "FAIL" for r in self.report_results()) else "通过"
md_path = self.report_dir / f"report-{report_timestamp}.md"
user_md_path = self.report_dir / f"user-report-{report_timestamp}.md"
md_path.write_text(self.render_markdown(run_id, target, generated_at, status), encoding="utf-8")
user_md_path.write_text(self.render_user_markdown(run_id), encoding="utf-8")
self.info("markdown_report", str(md_path))
self.info("user_markdown_report", str(user_md_path))
def colorize(self, text: str, status: str) -> str:
if not self.use_color:
return text
return f"{STATUS_COLOR.get(status, '')}{text}\033[0m"
def render_markdown(self, run_id: str, target: str, generated_at: str, status: str) -> str:
counts = self.status_counts()
lines = [
"# One-KVM 自动化测试报告",
"",
f"- 运行编号:`{run_id}`",
f"- 目标设备:`{target}`",
f"- 生成时间:`{generated_at}`",
f"- 总体结果:**{status}**",
f"- 统计:通过 {counts.get('PASS', 0)},警告 {counts.get('WARN', 0)},跳过 {counts.get('SKIP', 0)},失败 {counts.get('FAIL', 0)}",
"",
]
grouped = self.group_results()
for category, results in grouped.items():
if not results:
continue
lines.extend([f"## {category}", ""])
lines.extend(self.render_result_table(category, results))
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def render_user_markdown(self, run_id: str) -> str:
lines = [
"# One-KVM 测试摘要",
"",
"## 背景",
"",
f"- 运行编号:`{run_id}`",
"- 测试设备:",
f"- 视频设备:{markdown_inline(self.user_video_device())}",
f"- HID设备{markdown_inline(self.user_hid_backend())}",
f"- 网络延迟:{markdown_inline(self.user_network_latency())}",
"",
"## 视频性能",
"",
"| 视频输入参数 | 测试帧率 | 延迟统计(中位数 p50 / 95%分位 p95 / 最大值 max |",
"| --- | --- | --- |",
]
video_rows = self.user_video_rows()
if video_rows:
for params, fps, latency in video_rows:
lines.append(
"| "
+ " | ".join(
(
markdown_table_cell(params),
markdown_table_cell(fps),
markdown_table_cell(latency),
)
)
+ " |"
)
else:
lines.append("| 无数据 | - | - |")
lines.extend(
[
"",
"## HID性能",
"",
"| 输入方式 | 延迟统计(中位数 p50 / 95%分位 p95 / 最大值 max |",
"| --- | --- |",
]
)
hid_rows = self.user_hid_rows()
if hid_rows:
for method, latency in hid_rows:
lines.append(f"| {markdown_table_cell(method)} | {markdown_table_cell(latency)} |")
else:
lines.append("| 无数据 | - |")
lines.extend(
[
"",
"## MSD性能",
"",
"| 操作 | 数据 |",
"| --- | --- |",
]
)
msd_rows = self.user_msd_rows()
if msd_rows:
for operation, data in msd_rows:
lines.append(f"| {markdown_table_cell(operation)} | {markdown_table_cell(data)} |")
else:
lines.append("| 无数据 | - |")
return "\n".join(lines).rstrip() + "\n"
def find_result(self, name: str) -> CheckResult | None:
for result in self.results:
if result.name == name:
return result
return None
def user_network_latency(self) -> str:
result = self.find_result("network_latency")
if not result:
return "无数据"
tcp = result.data.get("tcp_connect") or {}
if tcp.get("samples"):
return format_latency_values(tcp)
return result.message or "无数据"
def user_video_device(self) -> str:
result = self.find_result("video_input_select")
cases = result.data.get("cases") if result else None
if not cases:
return "无数据"
by_device: dict[str, list[str]] = {}
for case in cases:
if not isinstance(case, dict):
continue
device = str(case.get("device") or "未知设备")
by_device.setdefault(device, []).append(format_video_case(case))
return "".join(f"{device}{', '.join(items)}" for device, items in by_device.items()) or "无数据"
def user_hid_backend(self) -> str:
config = self.find_result("hid_msd_config")
if config:
if config.data.get("udc"):
return "OTG"
if config.data.get("port"):
return "CH9329"
status = self.find_result("hid_status")
if status:
data = status.data.get("status") or {}
backend = str(data.get("backend") or data.get("hid_backend") or "").strip()
if backend:
return backend.upper() if backend.lower() == "otg" else backend
return "无数据"
def user_video_rows(self) -> list[tuple[str, str, str]]:
fps_by_key: dict[tuple[str, str], str] = {}
for result in self.results:
if not result.name.startswith("video_") or result.name.startswith("video_latency_"):
continue
data = result.data
case = data.get("case") or {}
if not isinstance(case, dict):
continue
output_mode = str(data.get("codec") or result.name.rsplit("_", 1)[-1]).lower()
label = str(case.get("label") or "")
fps = data.get("fps")
if fps is None:
fps = data.get("avgFps")
if label and output_mode and fps is not None:
fps_by_key[(label, output_mode)] = format_fps(float(fps))
rows: list[tuple[str, str, str]] = []
for result in self.results:
if not result.name.startswith("video_latency_"):
continue
data = result.data
case = data.get("video_case") or {}
if not isinstance(case, dict):
case = {}
output_mode = str(data.get("output_mode") or video_latency_output_from_name(result.name) or "").lower()
label = str(case.get("label") or "")
params = format_video_latency_params(case, output_mode)
fps = fps_by_key.get((label, output_mode), "-")
latency = format_latency_data(data)
if not latency:
latency = f"{STATUS_TEXT.get(result.status, result.status)}{result.message}" if result.message else STATUS_TEXT.get(result.status, result.status)
rows.append((params, fps, latency))
return rows
def user_hid_rows(self) -> list[tuple[str, str]]:
result = self.find_result("hid_latency")
if not result:
return []
latency = format_latency_data(result.data)
if not latency:
latency = f"{STATUS_TEXT.get(result.status, result.status)}{result.message}" if result.message else STATUS_TEXT.get(result.status, result.status)
return [(self.user_hid_backend(), latency)]
def user_msd_rows(self) -> list[tuple[str, str]]:
result = self.find_result("msd")
if not result:
return []
verify = result.data.get("verify") or {}
rows: list[tuple[str, str]] = []
if "write_mib_s" in verify:
rows.append(("", f"{float(verify.get('write_mib_s') or 0):.2f}MiB/s"))
if "read_mib_s" in verify:
rows.append(("", f"{float(verify.get('read_mib_s') or 0):.2f}MiB/s"))
elif "cached_read_mib_s" in verify:
rows.append(("读(缓存,仅校验)", f"{float(verify.get('cached_read_mib_s') or 0):.2f}MiB/s"))
if rows:
return rows
return [("MSD", f"{STATUS_TEXT.get(result.status, result.status)}{result.message}" if result.message else STATUS_TEXT.get(result.status, result.status))]
def report_results(self) -> list[CheckResult]:
return [result for result in self.results if result.name not in REPORT_HIDDEN_RESULTS]
def status_counts(self) -> dict[str, int]:
counts: dict[str, int] = {}
for result in self.report_results():
counts[result.status] = counts.get(result.status, 0) + 1
return counts
def group_results(self) -> dict[str, list[CheckResult]]:
grouped = {name: [] for name, _ in CATEGORY_RULES}
grouped["其他"] = []
for result in self.report_results():
for category, prefixes in CATEGORY_RULES:
if any(result.name == prefix or result.name.startswith(prefix) for prefix in prefixes):
grouped[category].append(result)
break
else:
grouped["其他"].append(result)
return grouped
def render_result(self, result: CheckResult) -> list[str]:
title = display_name(result.name)
status = STATUS_TEXT.get(result.status, result.status)
lines = [f"### {title}", "", f"- 结果:**{status}**"]
summary = summarize_result(result, self.metrics)
if summary:
lines.append(f"- 数据:{summary}")
if result.status == "FAIL":
lines.append(f"- 失败日志:`{markdown_inline(result.message)}`")
elif result.status in {"WARN", "SKIP"} and result.message:
lines.append(f"- 说明:{markdown_inline(result.message)}")
return lines
def render_result_table(self, category: str, results: list[CheckResult]) -> list[str]:
with_data = category in CATEGORIES_WITH_DATA_TABLE or (
category not in CATEGORIES_WITHOUT_DATA_TABLE
and any(has_numeric_data(result, self.metrics) for result in results)
)
if with_data:
lines = ["| 项目 | 结果 | 数据 |", "| --- | --- | --- |"]
for result in results:
lines.append(
"| "
+ " | ".join(
(
markdown_table_cell(display_name(result.name)),
markdown_table_cell(STATUS_TEXT.get(result.status, result.status)),
markdown_table_cell(summarize_result(result, self.metrics)),
)
)
+ " |"
)
else:
lines = ["| 项目 | 结果 |", "| --- | --- |"]
for result in results:
lines.append(
"| "
+ " | ".join(
(
markdown_table_cell(display_name(result.name)),
markdown_table_cell(STATUS_TEXT.get(result.status, result.status)),
)
)
+ " |"
)
sections = (
("失败项目", [result for result in results if result.status == "FAIL"]),
("跳过项目", [result for result in results if result.status == "SKIP"]),
("警告项目", [result for result in results if result.status == "WARN"]),
)
for heading, items in sections:
if not items:
continue
lines.extend(["", f"{heading}"])
for result in items:
title = display_name(result.name)
status = STATUS_TEXT.get(result.status, result.status)
reason = result.message or summarize_result(result, self.metrics) or "无详细日志"
lines.append(f"- **{markdown_inline(title)}**{status}{markdown_inline(reason)}")
return lines
def summarize_result(result: CheckResult, metrics: list[Metric]) -> str:
data = result.data
name = result.name
if name == "network_latency":
tcp = data.get("tcp_connect") or {}
http = data.get("http_health") or {}
parts = []
if tcp.get("samples"):
parts.append(
f"TCP p50={float(tcp.get('p50_ms', 0)):.1f}msp95={float(tcp.get('p95_ms', 0)):.1f}msmax={float(tcp.get('max_ms', 0)):.1f}ms"
)
if http.get("samples"):
parts.append(
f"HTTP p50={float(http.get('p50_ms', 0)):.1f}msp95={float(http.get('p95_ms', 0)):.1f}msmax={float(http.get('max_ms', 0)):.1f}ms"
)
errors = data.get("errors") or []
if errors:
parts.append(f"异常样本={len(errors)}")
return "".join(parts)
if name.startswith("config_video_") and isinstance(data.get("case"), dict):
case = data["case"]
return f"{case.get('device')} {case.get('fmt')} {case.get('width')}x{case.get('height')}@{case.get('fps')}"
if name.startswith("video_latency_") or name == "hdmi_latency_mjpeg":
if not any(k in data for k in ("p50_ms", "p95_ms", "max_ms")):
return ""
case = data.get("video_case") or {}
output_mode = str(data.get("output_mode") or video_latency_output_from_name(name) or "").upper()
parts = []
if output_mode:
parts.append(output_mode)
if isinstance(case, dict) and case.get("fmt"):
parts.append(f"{case.get('fmt')} {case.get('width')}x{case.get('height')}@{case.get('fps')}")
parts.extend(
[
f"p50={float(data.get('p50_ms', 0)):.1f}ms",
f"p95={float(data.get('p95_ms', 0)):.1f}ms",
f"max={float(data.get('max_ms', 0)):.1f}ms",
]
)
return "".join(parts)
if name.startswith("video_"):
parts = []
if "fps" in data:
parts.append(f"fps={float(data['fps']):.1f}")
if "avgFps" in data:
parts.append(f"平均fps={float(data['avgFps']):.1f}")
if "input_requested_fps" in data:
parts.append(f"请求输入={float(data['input_requested_fps']):.1f}fps")
if "maxRtt" in data:
parts.append(f"最大RTT={float(data['maxRtt']) * 1000:.1f}ms")
if "min_expected_fps" in data:
parts.append(f"目标下限={float(data['min_expected_fps']):.1f}fps")
if "dynamic_source_fps" in data:
parts.append(f"动态源={int(data['dynamic_source_fps'])}fps")
if data.get("fps_above_requested"):
parts.append("输出帧率高于请求输入")
if data.get("unsupported"):
parts.append("不支持")
return "".join(parts)
if name == "hdmi_identity":
samples = data.get("samples") or []
ok = sum(1 for item in samples if item.get("identity_match"))
parts = [f"颜色匹配 {ok}/{len(samples)}"]
if samples:
detail = []
for item in samples:
rgb = item.get("measured_rgb_mean")
status = "匹配" if item.get("identity_match") else "不匹配"
detail.append(f"{item.get('name')}={rgb}({status})")
parts.append("".join(detail))
return "".join(parts)
if name == "hdmi_color_range":
samples = data.get("samples") or []
if not samples:
return result.message
worst = max(samples, key=lambda item: float(item.get("mean_abs_error") or 0))
missed = sum(1 for item in samples if not item.get("target_detected"))
max_mean_error = max((float(item.get("mean_abs_error") or 0) for item in samples), default=0.0)
max_abs_error = max((float(item.get("max_abs_error") or 0) for item in samples), default=0.0)
return (
f"最大平均误差={max_mean_error:.1f},最大通道误差={max_abs_error:.1f}"
f"未命中颜色={missed}/{len(samples)}"
f"最差={worst.get('name')} 实测RGB={worst.get('measured_rgb_mean')}"
f"采样帧={worst.get('frames_seen', 0)}"
)
if name == "hid_input":
count = int(data.get("event_count") or len(data.get("events") or []))
keyboard = data.get("keyboard") or {}
mouse = data.get("mouse") or {}
parts = [
f"捕获事件数={count}",
f"字母数字={keyboard.get('alphanumeric_tested', 0)}",
f"功能键={keyboard.get('function_keys_tested', 0)}",
f"安全组合键={keyboard.get('safe_combos_tested', 0)}",
f"绝对鼠标={'通过' if mouse.get('absolute_ok') else '失败'}",
f"相对鼠标={'通过' if mouse.get('relative_ok') else '失败'}",
]
missing = []
for key in (
"missing_alphanumeric_down",
"missing_alphanumeric_up",
"missing_function_down",
"missing_function_up",
"missing_combos",
):
values = keyboard.get(key) or []
if values:
missing.append(f"{key}={','.join(map(str, values[:10]))}")
if missing:
parts.append("缺失:" + "".join(missing))
return "".join(parts)
if name == "hid_latency":
if not any(k in data for k in ("p50_ms", "p95_ms", "max_ms")):
return ""
parts = [
f"按键={data.get('key', '')}",
f"p50={float(data.get('p50_ms', 0)):.1f}ms",
f"p95={float(data.get('p95_ms', 0)):.1f}ms",
f"max={float(data.get('max_ms', 0)):.1f}ms",
]
missing = int(data.get("missing_trials") or 0)
if missing:
parts.append(f"未捕获={missing}")
return "".join(parts)
if name == "msd":
verify = data.get("verify") or {}
size = verify.get("size_bytes")
if not size:
return ""
parts = [f"读写校验大小={size} bytes"]
if "write_mib_s" in verify:
parts.append(f"写入={float(verify.get('write_mib_s') or 0):.2f}MiB/s")
if "read_mib_s" in verify:
parts.append(f"未缓存读取={float(verify.get('read_mib_s') or 0):.2f}MiB/s")
elif "cached_read_mib_s" in verify:
parts.append(f"缓存读取={float(verify.get('cached_read_mib_s') or 0):.2f}MiB/s仅校验")
if "write_ms" in verify and "read_ms" in verify:
parts.append(f"写耗时={float(verify.get('write_ms') or 0):.1f}ms读耗时={float(verify.get('read_ms') or 0):.1f}ms")
if verify.get("read_cached"):
parts.append("读速未作为真实盘读速")
return "".join(parts)
if name == "atx_api":
return "status/config/WOL API 均有响应"
if name == "windows_agent":
hello = data.get("hello") or {}
return f"主机={hello.get('hostname', 'unknown')}"
if name == "target_inventory":
return "已采集 lsusb、uname、服务状态"
if name == "target_reset":
return "数据库已备份,服务已重启"
if name == "setup_init":
return "初始化检查完成"
if name in {"login", "login_after_restart"}:
return "认证成功"
if name == "hid_msd_config":
return "已按设备能力配置 HID/MSD"
if name == "ventoy_resources":
return "Ventoy 资源检查完成"
if name == "msd_restart":
return "服务重启完成"
if name == "video_input_select":
cases = data.get("cases") or []
return "".join(f"{c.get('fmt')} {c.get('width')}x{c.get('height')}@{c.get('fps')}" for c in cases if isinstance(c, dict))
return ""
def display_name(name: str) -> str:
if name.startswith("video_latency_"):
body = name.removeprefix("video_latency_")
for mode in ("mjpeg", "h264", "h265"):
suffix = f"_{mode}"
if body == mode:
return f"视频延迟 {mode.upper()}"
if body.endswith(suffix):
label = body[: -len(suffix)]
return f"视频延迟 {label} {mode.upper()}"
return f"视频延迟 {body}"
return DISPLAY_NAMES.get(name, name)
def format_video_case(case: dict[str, Any]) -> str:
fmt = str(case.get("fmt") or "").lower()
resolution = format_resolution(case)
fps = format_fps_value(case.get("fps"))
return " ".join(part for part in (f"{resolution}@{fps}" if resolution and fps else resolution or fps, fmt) if part)
def format_video_latency_params(case: dict[str, Any], output_mode: str) -> str:
fmt = str(case.get("fmt") or "").lower()
output = output_mode.lower() if output_mode else "unknown"
resolution = format_resolution(case)
fps = format_fps_value(case.get("fps"))
input_part = " ".join(part for part in (f"{resolution}@{fps}" if resolution and fps else resolution or fps, fmt) if part)
return f"{input_part}-->{output}" if input_part else output
def format_resolution(case: dict[str, Any]) -> str:
try:
width = int(case.get("width") or 0)
height = int(case.get("height") or 0)
except Exception:
return ""
if height > 0 and width in {1280, 1920, 2560, 3840, 4096}:
return f"{height}p"
if width > 0 and height > 0:
return f"{width}x{height}"
return ""
def format_fps(value: float) -> str:
return f"{value:.0f}fps" if abs(value - round(value)) < 0.05 else f"{value:.1f}fps"
def format_fps_value(value: Any) -> str:
try:
return format_fps(float(value))
except Exception:
return ""
def format_latency_data(data: dict[str, Any]) -> str:
if not any(key in data for key in ("p50_ms", "p95_ms", "max_ms")):
return ""
return format_latency_values(data)
def format_latency_values(data: dict[str, Any]) -> str:
return (
f"p50={float(data.get('p50_ms', 0)):.1f}ms"
f"p95={float(data.get('p95_ms', 0)):.1f}ms"
f"max={float(data.get('max_ms', 0)):.1f}ms"
)
def video_latency_output_from_name(name: str) -> str:
body = name.removeprefix("video_latency_") if name.startswith("video_latency_") else name
for mode in ("mjpeg", "h264", "h265"):
if body == mode or body.endswith(f"_{mode}"):
return mode
return ""
def has_numeric_data(result: CheckResult, metrics: list[Metric]) -> bool:
summary = summarize_result(result, metrics)
return any(ch.isdigit() for ch in summary)
def markdown_inline(value: str) -> str:
return str(value).replace("\n", " | ").replace("`", "'")
def markdown_table_cell(value: str) -> str:
return markdown_inline(value).replace("|", "\\|")
def markdown_link(path: str) -> str:
return path.replace(" ", "%20")

2319
test/okvm-test/okvm_testctl.py Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
httpx>=0.27
paramiko>=3.4
websockets>=12
playwright>=1.45
Pillow>=10

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json",
"name": "one-kvm",
"version-string": "0.2.3",
"version-string": "0.2.4",
"dependencies": [
{
"name": "ffmpeg",

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "web",
"version": "0.2.3",
"version": "0.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web",
"version": "0.2.3",
"version": "0.2.4",
"dependencies": {
"@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1",

View File

@@ -1,7 +1,7 @@
{
"name": "web",
"private": true,
"version": "0.2.3",
"version": "0.2.4",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,14 @@
import { request, ApiError } from './request'
import type { CanonicalKey, Ch9329DescriptorState } from '@/types/generated'
import type {
CanonicalKey,
Ch9329DescriptorState,
ComputerUseButton,
ComputerUseConfigResponse,
ComputerUseConfigUpdate,
ComputerUseSessionStatus,
ComputerUseSessionSummary,
ComputerUseStartRequest,
} from '@/types/generated'
import { useHidWebSocket, type HidKeyboardEvent, type HidMouseEvent } from '@/composables/useHidWebSocket'
const API_BASE = '/api'
@@ -77,7 +86,6 @@ export const systemApi = {
info: () =>
request<{
version: string
build_date: string
initialized: boolean
platform: PlatformCapabilities
capabilities: {
@@ -457,16 +465,7 @@ export const hidApi = {
isWebSocketConnected: () => hidWs.connected.value,
}
export type ComputerUseStatus =
| 'idle'
| 'waiting_screenshot'
| 'thinking'
| 'executing'
| 'completed'
| 'failed'
| 'stopped'
export type ComputerUseButton = 'left' | 'middle' | 'right'
export type ComputerUseStatus = ComputerUseSessionStatus
export type ComputerUseAction =
| { type: 'click'; x: number; y: number; button?: ComputerUseButton }
@@ -479,49 +478,18 @@ export type ComputerUseAction =
| { type: 'wait'; ms: number }
| { type: 'screenshot' }
export interface ComputerUseScreenshot {
data_url: string
width: number
height: number
}
export type ComputerUseConversationMessage =
| { role: 'user'; text: string }
| { role: 'assistant'; text: string }
export interface ComputerUseConfig {
enabled: boolean
provider: string
base_url: string
model: string
max_steps: number
timeout_seconds: number
api_key_configured: boolean
api_key_source: string
}
export type ComputerUseConfig = ComputerUseConfigResponse
export interface ComputerUseSession {
id: string | null
status: ComputerUseStatus
prompt: string | null
step: number
max_steps: number
last_error: string | null
final_message: string | null
}
export type ComputerUseSession = ComputerUseSessionSummary
export const computerUseApi = {
config: () => request<ComputerUseConfig>('/config/computer-use'),
updateConfig: (data: {
enabled?: boolean
base_url?: string
model?: string
max_steps?: number
timeout_seconds?: number
openai_api_key?: string
clear_openai_api_key?: boolean
}) =>
updateConfig: (data: ComputerUseConfigUpdate) =>
request<ComputerUseConfig>('/config/computer-use', {
method: 'PATCH',
body: JSON.stringify(data),
@@ -529,7 +497,7 @@ export const computerUseApi = {
session: () => request<ComputerUseSession>('/computer-use/session'),
start: (data: { prompt: string; continue_conversation?: boolean; client_id: string; max_steps?: number; timeout_seconds?: number }) =>
start: (data: ComputerUseStartRequest) =>
request<ComputerUseSession>('/computer-use/session', {
method: 'POST',
body: JSON.stringify(data),
@@ -549,6 +517,8 @@ export const atxApi = {
initialized: boolean
power_status: 'on' | 'off' | 'unknown'
led_supported: boolean
hdd_status: 'active' | 'inactive' | 'unknown'
hdd_supported: boolean
}>('/atx/status'),
power: (action: 'short' | 'long' | 'reset') =>
@@ -642,19 +612,32 @@ export const msdApi = {
used: number
free: number
initialized: boolean
}>('/msd/drive'),
}>('/msd/drive', {}, { toastOnError: false }),
initDrive: (sizeMb?: number) =>
request<{ path: string; size_mb: number }>('/msd/drive/init', {
method: 'POST',
body: JSON.stringify({ size_mb: sizeMb }),
}),
request<{
size: number
used: number
free: number
initialized: boolean
}>(
'/msd/drive/init',
{
method: 'POST',
body: JSON.stringify({ size_mb: sizeMb }),
},
{ toastOnError: false },
),
deleteDrive: () =>
request<{ success: boolean }>('/msd/drive', { method: 'DELETE' }),
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) => {
const formData = new FormData()
@@ -845,6 +828,13 @@ export type {
StreamMode,
EncoderType,
BitratePreset,
ComputerUseButton,
ComputerUseConfigResponse,
ComputerUseConfigUpdate,
ComputerUseScreenshot,
ComputerUseSessionStatus,
ComputerUseSessionSummary,
ComputerUseStartRequest,
} from '@/types/generated'
export const audioApi = {

View File

@@ -66,6 +66,7 @@ const props = defineProps<{
showTerminal?: boolean
showComputerUse?: boolean
}>()
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
const emit = defineEmits<{
(e: 'toggleFullscreen'): void
@@ -196,6 +197,7 @@ const RIGHT_FIXED_PX = 120
const collapsibleItems = computed(() => {
const items = ITEM_SPECS.slice(3).filter(item => {
if (item.id === 'msd' && !showMsd.value) return false
if (item.id === 'stats' && !showStats.value) return false
if (item.id === 'terminal' && props.showTerminal === false) return false
return true
})
@@ -244,6 +246,12 @@ const isVisible = (id: CollapsibleItem) => visibleSet.value.has(id)
const hasOverflow = computed(() => {
return collapsibleItems.value.some(i => !visibleSet.value.has(i.id))
})
const hasLeftOverflow = computed(() => {
return collapsibleItems.value.some(i => i.side === 'left' && !visibleSet.value.has(i.id))
})
const hasRightOverflow = computed(() => {
return collapsibleItems.value.some(i => i.side === 'right' && !visibleSet.value.has(i.id))
})
</script>
<template>
@@ -469,10 +477,10 @@ const hasOverflow = computed(() => {
{{ t('actionbar.paste') }}
</DropdownMenuItem>
<DropdownMenuSeparator v-if="(!isVisible('msd') || !isVisible('atx') || !isVisible('paste')) && (!isVisible('stats') || (props.showTerminal !== false && !isVisible('terminal')) || !isVisible('settings'))" />
<DropdownMenuSeparator v-if="hasLeftOverflow && hasRightOverflow" />
<!-- Stats -->
<DropdownMenuItem v-if="!isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
<DropdownMenuItem v-if="showStats && !isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
<BarChart3 class="h-4 w-4 mr-2" />
{{ t('actionbar.stats') }}
</DropdownMenuItem>

View File

@@ -2,25 +2,23 @@
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Power, RotateCcw, CircleDot, Wifi, Send } from 'lucide-vue-next'
import { Power, RotateCcw, CircleDot, Wifi, Send, HardDrive } from 'lucide-vue-next'
import { atxApi } from '@/api'
import { atxConfigApi } from '@/api/config'
type AtxAction = 'short' | 'long' | 'reset'
const minActionFeedbackMs = 800
const actionDurations: Record<AtxAction, number> = {
short: 500,
long: 5000,
reset: 500,
}
const emit = defineEmits<{
(e: 'close'): void
(e: 'powerShort'): void
@@ -34,21 +32,30 @@ const { t } = useI18n()
const activeTab = ref('atx')
const powerState = ref<'on' | 'off' | 'unknown'>('unknown')
const hddState = ref<'active' | 'inactive' | 'unknown'>('unknown')
let powerStateTimer: number | null = null
// Decouple action data from dialog visibility to prevent race conditions
const pendingAction = ref<'short' | 'long' | 'reset' | null>(null)
const confirmDialogOpen = ref(false)
let actionTimer: number | null = null
const wolMacAddress = ref('')
const wolHistory = ref<string[]>([])
const wolSending = ref(false)
const wolLoadingHistory = ref(false)
const activeAction = ref<AtxAction | null>(null)
const powerStateColor = computed(() => {
const actionBusy = computed(() => activeAction.value !== null)
const powerStateIconColor = computed(() => {
switch (powerState.value) {
case 'on': return 'bg-green-500'
case 'off': return 'bg-slate-400'
default: return 'bg-yellow-500'
case 'on': return 'text-green-600'
case 'off': return 'text-slate-500'
default: return 'text-yellow-600'
}
})
const powerStateTextColor = computed(() => {
switch (powerState.value) {
case 'on': return 'text-green-600'
default: return ''
}
})
@@ -60,39 +67,49 @@ const powerStateText = computed(() => {
}
})
function requestAction(action: 'short' | 'long' | 'reset') {
pendingAction.value = action
confirmDialogOpen.value = true
}
const hddStateIconColor = computed(() => {
switch (hddState.value) {
case 'active': return 'text-green-600'
case 'inactive': return 'text-slate-500'
default: return 'text-yellow-600'
}
})
function handleAction() {
console.log('[AtxPopover] Confirming action:', pendingAction.value)
if (pendingAction.value === 'short') emit('powerShort')
else if (pendingAction.value === 'long') emit('powerLong')
else if (pendingAction.value === 'reset') emit('reset')
confirmDialogOpen.value = false
setTimeout(() => {
const hddStateTextColor = computed(() => {
switch (hddState.value) {
case 'active': return 'text-green-600'
default: return ''
}
})
const hddStateText = computed(() => {
switch (hddState.value) {
case 'active': return t('atx.hddActive')
case 'inactive': return t('atx.hddInactive')
default: return t('atx.stateUnknown')
}
})
function handleAction(action: AtxAction) {
if (actionBusy.value) return
console.log('[AtxPopover] Running action:', action)
activeAction.value = action
if (action === 'short') emit('powerShort')
else if (action === 'long') emit('powerLong')
else emit('reset')
if (actionTimer !== null) {
window.clearTimeout(actionTimer)
}
actionTimer = window.setTimeout(() => {
activeAction.value = null
actionTimer = null
refreshPowerState().catch(() => {})
}, 1200)
}, Math.max(actionDurations[action], minActionFeedbackMs))
}
const confirmTitle = computed(() => {
switch (pendingAction.value) {
case 'short': return t('atx.confirmShortTitle')
case 'long': return t('atx.confirmLongTitle')
case 'reset': return t('atx.confirmResetTitle')
default: return ''
}
})
const confirmDescription = computed(() => {
switch (pendingAction.value) {
case 'short': return t('atx.confirmShortDesc')
case 'long': return t('atx.confirmLongDesc')
case 'reset': return t('atx.confirmResetDesc')
default: return ''
}
})
const isValidMac = computed(() => {
const mac = wolMacAddress.value.trim()
const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$|^([0-9A-Fa-f]{12})$/
@@ -142,8 +159,10 @@ async function refreshPowerState() {
try {
const state = await atxApi.status()
powerState.value = state.power_status
hddState.value = state.hdd_status
} catch {
powerState.value = 'unknown'
hddState.value = 'unknown'
}
}
@@ -159,6 +178,10 @@ onUnmounted(() => {
window.clearInterval(powerStateTimer)
powerStateTimer = null
}
if (actionTimer !== null) {
window.clearTimeout(actionTimer)
actionTimer = null
}
})
watch(
@@ -173,70 +196,91 @@ watch(
</script>
<template>
<div class="p-3 space-y-3">
<div class="p-2.5 space-y-2.5">
<Tabs v-model="activeTab">
<TabsList class="w-full grid grid-cols-2">
<TabsTrigger value="atx" class="text-xs">
<Power class="h-3.5 w-3.5 mr-1" />
<TabsList class="h-8 w-full grid grid-cols-2">
<TabsTrigger value="atx" class="h-7 text-xs">
<Power class="h-3 w-3 mr-1" />
{{ t('atx.title') }}
</TabsTrigger>
<TabsTrigger value="wol" class="text-xs">
<Wifi class="h-3.5 w-3.5 mr-1" />
<TabsTrigger value="wol" class="h-7 text-xs">
<Wifi class="h-3 w-3 mr-1" />
WOL
</TabsTrigger>
</TabsList>
<!-- ATX Tab -->
<TabsContent value="atx" class="mt-3 space-y-3">
<TabsContent value="atx" class="mt-2.5 space-y-2.5">
<p class="text-xs text-muted-foreground">{{ t('atx.description') }}</p>
<!-- Power State -->
<div class="flex items-center justify-between p-2 rounded-md bg-muted/50">
<span class="text-xs text-muted-foreground">{{ t('atx.powerState') }}</span>
<Badge variant="outline" class="gap-1.5 text-xs">
<span :class="['h-2 w-2 rounded-full', powerStateColor]" />
{{ powerStateText }}
</Badge>
<!-- Status -->
<div class="grid grid-cols-2 gap-2">
<div class="flex min-w-0 items-center gap-2 rounded-md border bg-muted/40 px-2 py-1.5">
<Power :class="['h-4 w-4 shrink-0', powerStateIconColor]" />
<div class="min-w-0">
<p class="truncate text-[11px] leading-none text-muted-foreground">{{ t('atx.powerState') }}</p>
<p :class="['mt-1 truncate text-xs font-medium leading-none', powerStateTextColor]">{{ powerStateText }}</p>
</div>
</div>
<div class="flex min-w-0 items-center gap-2 rounded-md border bg-muted/40 px-2 py-1.5">
<HardDrive :class="['h-4 w-4 shrink-0', hddStateIconColor]" />
<div class="min-w-0">
<p class="truncate text-[11px] leading-none text-muted-foreground">{{ t('atx.hddState') }}</p>
<p :class="['mt-1 truncate text-xs font-medium leading-none', hddStateTextColor]">{{ hddStateText }}</p>
</div>
</div>
</div>
<Separator />
<!-- Power Actions -->
<div class="space-y-1.5">
<div class="space-y-1">
<Button
variant="outline"
size="sm"
class="w-full justify-start gap-2 h-8 text-xs"
@click="requestAction('short')"
:disabled="actionBusy"
:class="[
'w-full justify-start gap-2 h-7 text-xs',
activeAction === 'short' ? 'bg-muted text-muted-foreground' : '',
]"
@click="handleAction('short')"
>
<Power class="h-3.5 w-3.5" />
<Power class="h-3 w-3" />
{{ t('atx.shortPress') }}
</Button>
<Button
variant="outline"
size="sm"
class="w-full justify-start gap-2 h-8 text-xs text-orange-600 hover:text-orange-700 hover:bg-orange-50 dark:hover:bg-orange-950"
@click="requestAction('long')"
:disabled="actionBusy"
:class="[
'w-full justify-start gap-2 h-7 text-xs text-orange-600 hover:text-orange-700 hover:bg-orange-50 dark:hover:bg-orange-950',
activeAction === 'long' ? 'bg-muted text-muted-foreground hover:text-muted-foreground hover:bg-muted dark:hover:bg-muted' : '',
]"
@click="handleAction('long')"
>
<CircleDot class="h-3.5 w-3.5" />
<CircleDot class="h-3 w-3" />
{{ t('atx.longPress') }}
</Button>
<Button
variant="outline"
size="sm"
class="w-full justify-start gap-2 h-8 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950"
@click="requestAction('reset')"
:disabled="actionBusy"
:class="[
'w-full justify-start gap-2 h-7 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950',
activeAction === 'reset' ? 'bg-muted text-muted-foreground hover:text-muted-foreground hover:bg-muted dark:hover:bg-muted' : '',
]"
@click="handleAction('reset')"
>
<RotateCcw class="h-3.5 w-3.5" />
<RotateCcw class="h-3 w-3" />
{{ t('atx.reset') }}
</Button>
</div>
</TabsContent>
<!-- WOL Tab -->
<TabsContent value="wol" class="mt-3 space-y-3">
<TabsContent value="wol" class="mt-2.5 space-y-2.5">
<p class="text-xs text-muted-foreground">
{{ t('atx.wolDescription') }}
</p>
@@ -287,18 +331,4 @@ watch(
</TabsContent>
</Tabs>
</div>
<!-- Confirm Dialog -->
<AlertDialog :open="confirmDialogOpen" @update:open="confirmDialogOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ confirmTitle }}</AlertDialogTitle>
<AlertDialogDescription>{{ confirmDescription }}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('common.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleAction">{{ t('common.confirm') }}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { useSystemStore } from '@/stores/system'
import { msdApi, type MsdImage, type DriveFile } from '@/api'
import { ApiError } from '@/api/request'
import { useWebSocket } from '@/composables/useWebSocket'
import {
Dialog,
@@ -25,8 +26,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Progress } from '@/components/ui/progress'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Slider } from '@/components/ui/slider'
import { Separator } from '@/components/ui/separator'
import {
HardDrive,
@@ -45,6 +46,7 @@ import {
Globe,
X,
AlertCircle,
Info,
} from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue'
@@ -68,7 +70,8 @@ const uploadProgress = ref(0)
const uploading = ref(false)
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 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 uploadingFile = ref(false)
const fileUploadProgress = ref(0)
const driveError = ref<string | null>(null) // filesystem error (e.g. unsupported format)
const showDeleteDialog = ref(false)
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null)
@@ -92,8 +96,42 @@ const newFolderName = ref('')
const showDriveInitDialog = 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 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 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(() => {
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() {
await refreshDiskSpace()
await systemStore.fetchMsdState()
await loadImages()
await loadDriveInfo()
@@ -188,6 +235,7 @@ async function handleImageUpload(e: Event) {
uploadProgress.value = progress
})
images.value.push(image)
await refreshDiskSpace()
} catch (e) {
console.error('Failed to upload image:', e)
} finally {
@@ -255,17 +303,27 @@ function confirmDelete(type: 'image' | 'file', id: string, name: string) {
async function executeDelete() {
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
try {
if (deleteTarget.value.type === 'image') {
await msdApi.deleteImage(deleteTarget.value.id)
images.value = images.value.filter(i => i.id !== deleteTarget.value!.id)
await refreshDiskSpace()
} else {
await msdApi.deleteDriveFile(deleteTarget.value.id)
await loadDriveFiles()
}
} catch (e) {
} catch (e: any) {
console.error('Failed to delete:', e)
toast.error(t('common.error'), { description: e?.message })
} finally {
showDeleteDialog.value = false
deleteTarget.value = null
@@ -274,42 +332,57 @@ async function executeDelete() {
}
async function loadDriveInfo() {
driveError.value = null
try {
driveInfo.value = await msdApi.driveInfo()
driveInitialized.value = true
} catch {
driveInitialized.value = false
} catch (e: any) {
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(() => [
{ value: 64, label: '64 MB' },
{ value: 128, label: '128 MB' },
{ 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() {
async function initializeDrive() {
await refreshDiskSpace()
driveSizeMB.value = finalDriveSize.value
showDriveInitDialog.value = true
}
async function createDrive() {
await refreshDiskSpace()
driveSizeMB.value = finalDriveSize.value
if (!canInitializeDrive.value) {
toast.error(t('msd.driveSpaceUnavailable'))
return
}
initializingDrive.value = true
try {
await msdApi.initDrive(finalDriveSize.value)
const sizeMb = finalDriveSize.value
await msdApi.initDrive(sizeMb)
await loadDriveInfo()
await loadDriveFiles()
await refreshDiskSpace()
showDriveInitDialog.value = false
} catch (e) {
console.error('Failed to initialize drive:', e)
toast.error(t('msd.driveCreateFailed'))
} finally {
initializingDrive.value = false
}
@@ -324,6 +397,7 @@ async function deleteDrive() {
driveFiles.value = []
currentPath.value = '/'
showDeleteDriveDialog.value = false
await refreshDiskSpace()
} catch (e) {
console.error('Failed to delete drive:', e)
} finally {
@@ -332,16 +406,36 @@ async function deleteDrive() {
}
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
driveError.value = null
try {
driveFiles.value = await msdApi.listDriveFiles(currentPath.value)
} catch (e) {
} catch (e: any) {
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 {
loadingDrive.value = false
}
}
async function refreshDriveBrowser() {
await loadDriveInfo()
if (driveInitialized.value) {
await loadDriveFiles()
} else {
driveFiles.value = []
}
await refreshDiskSpace()
}
function navigateTo(path: string) {
currentPath.value = path
loadDriveFiles()
@@ -359,6 +453,13 @@ async function handleFileUpload(e: Event) {
const file = input.files?.[0]
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
fileUploadProgress.value = 0
@@ -367,8 +468,9 @@ async function handleFileUpload(e: Event) {
fileUploadProgress.value = progress
})
await loadDriveFiles()
} catch (e) {
} catch (e: any) {
console.error('Failed to upload file:', e)
toast.error(t('msd.uploadFailed'), { description: e?.message })
} finally {
uploadingFile.value = false
fileUploadProgress.value = 0
@@ -379,14 +481,23 @@ async function handleFileUpload(e: Event) {
async function createFolder() {
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 {
const path = currentPath.value === '/'
? '/' + newFolderName.value
: currentPath.value + '/' + newFolderName.value
await msdApi.createDirectory(path)
await loadDriveFiles()
} catch (e) {
} catch (e: any) {
console.error('Failed to create folder:', e)
toast.error(t('common.error'), { description: e?.message })
} finally {
showNewFolderDialog.value = false
newFolderName.value = ''
@@ -494,16 +605,8 @@ onUnmounted(() => {
</span>
{{ msdConnected ? t('common.connected') : t('common.disconnected') }}
</span>
<template v-if="msdConnected && connectedImageName">
<template v-if="msdConnected">
<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>
<Button
variant="outline"
@@ -712,17 +815,53 @@ onUnmounted(() => {
<template v-else>
<!-- 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 gap-2">
<HardDrive class="h-4 w-4 text-muted-foreground" />
<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
</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 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">
<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>
@@ -755,10 +894,9 @@ onUnmounted(() => {
</Button>
</div>
</div>
<!-- Storage usage bar -->
<div class="space-y-1.5">
<!-- Storage usage bar hidden when format is unrecognized -->
<div v-if="driveInfo" class="space-y-1.5">
<Progress
v-if="driveInfo"
:model-value="driveInfo.size > 0 ? (driveInfo.used / driveInfo.size) * 100 : 0"
class="h-2"
/>
@@ -769,8 +907,10 @@ onUnmounted(() => {
</div>
</div>
<!-- File Browser -->
<div class="flex-1 min-h-0 flex flex-col space-y-2">
<!-- Toolbar -->
<div class="shrink-0 flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
@@ -779,6 +919,7 @@ onUnmounted(() => {
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
:disabled="driveConnectedToTarget"
@click="navigateUp"
>
<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" />
<button
class="hover:text-primary transition-colors truncate"
:class="index === breadcrumbs.length - 1 ? 'font-medium' : 'text-muted-foreground'"
@click="navigateTo(crumb.path)"
:class="[
index === breadcrumbs.length - 1 ? 'font-medium' : 'text-muted-foreground',
driveConnectedToTarget ? 'cursor-not-allowed opacity-50' : ''
]"
:disabled="driveConnectedToTarget"
@click="!driveConnectedToTarget && navigateTo(crumb.path)"
>
{{ crumb.name }}
</button>
</template>
</nav>
</div>
<div class="shrink-0 flex items-center gap-1 shrink-0">
<label>
<input type="file" class="hidden" :disabled="uploadingFile" @change="handleFileUpload" />
<Button variant="ghost" size="icon" as="span" class="h-7 w-7 cursor-pointer">
<Upload class="h-3.5 w-3.5" />
</Button>
</label>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showNewFolderDialog = true">
<FolderPlus class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadDriveFiles">
<div class="shrink-0 flex items-center gap-1">
<Tooltip>
<TooltipTrigger as-child>
<label>
<!-- ③ Upload disabled when drive connected to target -->
<input type="file" class="hidden" :disabled="uploadingFile || driveConnectedToTarget" @change="handleFileUpload" />
<Button
variant="ghost"
size="icon"
as="span"
class="h-7 w-7"
:class="driveConnectedToTarget ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'"
>
<Upload class="h-3.5 w-3.5" />
</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 }" />
</Button>
</div>
@@ -815,10 +994,21 @@ onUnmounted(() => {
<Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1 shrink-0" />
<!-- File List -->
<div v-if="driveFiles.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
<div
v-if="driveFiles.length === 0 && !driveConnectedToTarget && !driveError"
class="shrink-0 text-center py-6 text-muted-foreground text-sm"
>
{{ t('msd.emptyFolder') }}
</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 class="space-y-1">
<div
@@ -858,10 +1048,12 @@ onUnmounted(() => {
>
<Download class="h-3.5 w-3.5" />
</Button>
<!-- ③ Delete disabled when drive connected to target -->
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive"
:disabled="driveConnectedToTarget"
@click="confirmDelete('file', file.path, file.name)"
>
<Trash2 class="h-3.5 w-3.5" />
@@ -929,54 +1121,55 @@ onUnmounted(() => {
<DialogDescription>{{ t('msd.selectDriveSize') }}</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<!-- Preset size selection -->
<div class="space-y-2">
<Label>{{ t('msd.driveSize') }}</Label>
<RadioGroup v-model="selectedDriveSize">
<div v-for="size in driveSizeOptions" :key="size.value" class="flex items-center space-x-2">
<RadioGroupItem :id="`size-${size.value}`" :value="size.value" />
<Label :for="`size-${size.value}`" class="font-normal cursor-pointer flex-1">
{{ size.label }}
</Label>
<div class="space-y-6 py-4">
<div class="space-y-4">
<div class="flex items-center justify-between">
<Label>{{ t('msd.driveSize') }}</Label>
<div class="flex items-center gap-2">
<Input
v-model.number="driveSizeMB"
type="number"
:min="MIN_DRIVE_SIZE_MB"
:max="sliderMaxDriveSizeMB"
class="w-24 text-right"
:disabled="!canInitializeDrive || initializingDrive"
@blur="driveSizeMB = finalDriveSize"
/>
<span class="text-sm text-muted-foreground">MB</span>
</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>
<p class="text-xs text-muted-foreground">
{{ t('msd.driveSizeHint') }}
</p>
</div>
<!-- Final size display -->
<div class="p-3 rounded-lg bg-muted/50">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('msd.selectedSize') }}:</span>
<span class="font-medium">{{ finalDriveSize }} MB</span>
<Slider
:model-value="[driveSizeMB]"
@update:model-value="updateDriveSizeFromSlider"
:min="MIN_DRIVE_SIZE_MB"
: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>
<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>
<DialogFooter>
<Button variant="outline" @click="showDriveInitDialog = false" :disabled="initializingDrive">
{{ t('common.cancel') }}
</Button>
<Button @click="createDrive" :disabled="initializingDrive">
<Button @click="createDrive" :disabled="initializingDrive || !canInitializeDrive">
<span v-if="initializingDrive">{{ t('common.creating') }}...</span>
<span v-else>{{ t('common.create') }}</span>
</Button>

View File

@@ -17,11 +17,6 @@ const { t } = useI18n()
const props = defineProps<{
open: boolean
videoMode: 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
// MJPEG stats
mjpegFps?: number
wsLatency?: number
// WebRTC stats
webrtcStats?: WebRTCStats
}>()
@@ -51,9 +46,6 @@ let lastBytesReceived = 0
let lastPacketsLost = 0
let lastTimestamp = 0
// Is WebRTC mode
const isWebRTC = computed(() => props.videoMode !== 'mjpeg')
function formatTime(ts: number): string {
const date = new Date(ts * 1000)
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
@@ -256,14 +248,13 @@ function addDataPoint() {
timestamps.value.shift()
}
if (isWebRTC.value && props.webrtcStats) {
if (props.webrtcStats) {
const jitter = (props.webrtcStats.jitter || 0) * 1000
jitterHistory.value.push(jitter)
const rtt = (props.webrtcStats.roundTripTime || 0) * 1000
delayHistory.value.push(rtt)
// Packet loss delta
const currentLost = props.webrtcStats.packetsLost || 0
const lostDelta = lastPacketsLost > 0 ? Math.max(0, currentLost - lastPacketsLost) : 0
lastPacketsLost = currentLost
@@ -284,11 +275,10 @@ function addDataPoint() {
lastBytesReceived = currentBytes
lastTimestamp = currentTime
} else {
// MJPEG mode
jitterHistory.value.push(0)
delayHistory.value.push(props.wsLatency || 0)
delayHistory.value.push(0)
packetLossHistory.value.push(0)
fpsHistory.value.push(props.mjpegFps || 0)
fpsHistory.value.push(0)
bitrateHistory.value.push(0)
}
@@ -335,7 +325,7 @@ function formatCandidateType(type: string): string {
// Current stats for header display
const currentStats = computed(() => {
if (isWebRTC.value && props.webrtcStats) {
if (props.webrtcStats) {
const lastBitrate = bitrateHistory.value[bitrateHistory.value.length - 1]
const bitrate = lastBitrate !== undefined ? lastBitrate : 0
return {
@@ -356,8 +346,8 @@ const currentStats = computed(() => {
}
return {
jitter: 0,
delay: props.wsLatency || 0,
fps: props.mjpegFps || 0,
delay: 0,
fps: 0,
resolution: '-',
bitrate: '0',
packetsLost: 0,
@@ -422,7 +412,7 @@ onUnmounted(() => {
<div class="flex items-center gap-2">
<SheetTitle class="text-base">{{ t('stats.title') }}</SheetTitle>
<span class="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-muted-foreground">
{{ isWebRTC ? 'WebRTC' : 'MJPEG' }}
WebRTC
</span>
</div>
</SheetHeader>
@@ -438,7 +428,7 @@ onUnmounted(() => {
</div>
<!-- Network Stability (Jitter) -->
<div class="space-y-2" v-if="isWebRTC">
<div class="space-y-2">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">{{ t('stats.stability') }}</h4>
</div>
@@ -462,7 +452,7 @@ onUnmounted(() => {
</div>
<!-- Playback Delay -->
<div class="space-y-2" v-if="isWebRTC">
<div class="space-y-2">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">{{ t('stats.delay') }}</h4>
<span class="text-xs text-muted-foreground">
@@ -489,7 +479,7 @@ onUnmounted(() => {
</div>
<!-- Packet Loss -->
<div class="space-y-2" v-if="isWebRTC">
<div class="space-y-2">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">{{ t('stats.packetLoss') }}</h4>
<span class="text-xs text-muted-foreground">
@@ -543,7 +533,7 @@ onUnmounted(() => {
</div>
<!-- Additional Stats -->
<div class="space-y-3 pt-2 border-t border-slate-200 dark:border-slate-800" v-if="isWebRTC">
<div class="space-y-3 pt-2 border-t border-slate-200 dark:border-slate-800">
<h4 class="text-sm font-medium">{{ t('stats.additional') }}</h4>
<div class="grid grid-cols-2 gap-3">
<div class="rounded-lg bg-slate-50 dark:bg-slate-900/50 p-3">

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ExternalLink, Terminal, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const { t } = useI18n()
function openTerminalInNewTab() {
window.open('/api/terminal/', '_blank')
}
function hideFrameScrollbars(event: Event) {
const frame = event.currentTarget as HTMLIFrameElement | null
try {
const doc = frame?.contentDocument
if (!doc) return
const styleId = 'one-kvm-terminal-scrollbar-style'
let style = doc.getElementById(styleId) as HTMLStyleElement | null
if (!style) {
style = doc.createElement('style')
style.id = styleId
const parent = doc.head || doc.documentElement
parent.appendChild(style)
}
style.textContent = `
html,
body,
.xterm-viewport {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
.xterm-viewport::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important;
}
`
} catch {
// The terminal is served from the same origin; keep the dialog usable if a browser blocks access.
}
}
</script>
<template>
<Dialog :open="props.open" @update:open="emit('update:open', $event)">
<DialogContent
:show-close-button="false"
class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col gap-0 overflow-hidden"
>
<DialogHeader class="px-3 sm:px-4 py-1 border-b shrink-0">
<DialogTitle class="flex h-8 items-center justify-between w-full">
<div class="flex items-center gap-2 min-w-0">
<Terminal class="h-4 w-4 shrink-0" />
<span class="truncate text-sm font-semibold">{{ t('extensions.ttyd.title') }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="openTerminalInNewTab"
:aria-label="t('extensions.ttyd.openInNewTab')"
:title="t('extensions.ttyd.openInNewTab')"
>
<ExternalLink class="h-3.5 w-3.5" />
</Button>
<DialogClose as-child>
<Button variant="ghost" size="icon" class="h-7 w-7">
<X class="h-3.5 w-3.5" />
<span class="sr-only">Close</span>
</Button>
</DialogClose>
</div>
</DialogTitle>
</DialogHeader>
<div class="flex-1 min-h-0">
<iframe
v-if="props.open"
src="/api/terminal/"
class="block w-full h-full border-0"
allow="clipboard-read; clipboard-write"
scrolling="no"
@load="hideFrameScrollbars"
/>
</div>
</DialogContent>
</Dialog>
</template>

View File

@@ -193,18 +193,15 @@ export default {
title: 'Power Control',
description: 'Control remote host power state',
powerState: 'Power State',
hddState: 'HDD Activity',
stateOn: 'On',
stateOff: 'Off',
stateUnknown: 'Unknown',
hddActive: 'Active',
hddInactive: 'Inactive',
shortPress: 'Power (Short)',
longPress: 'Power (Long/Force Off)',
reset: 'Reset',
confirmShortTitle: 'Confirm Power Press',
confirmShortDesc: 'This will simulate pressing the power button, same as a physical short press.',
confirmLongTitle: 'Confirm Force Power Off',
confirmLongDesc: 'This will force power off the host, which may cause data loss. Are you sure?',
confirmResetTitle: 'Confirm Reset',
confirmResetDesc: 'This will reset the host, which may cause unsaved data loss. Are you sure?',
wol: 'Wake-on-LAN',
wolDescription: 'Send Wake-on-LAN magic packet to power on a remote machine.',
macAddress: 'MAC Address',
@@ -481,12 +478,20 @@ export default {
driveConnected: 'Virtual USB drive connected',
imageConnected: 'Image {name} connected',
selectDriveSize: 'Select virtual drive size',
selectedSize: 'Selected 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)',
fileDeleted: 'File 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: {
title: 'Settings',
@@ -521,7 +526,7 @@ export default {
environmentSubtitle: 'System runtime environment and USB device maintenance',
aboutSubtitle: 'Online upgrade, version info and hardware overview',
extTtydSubtitle: 'Open a host Shell terminal in the browser',
thirdPartyAccessSubtitle: 'Configure external RustDesk, VNC, and RTSP access',
thirdPartyAccessSubtitle: 'Configure external RustDesk, VNC, RTSP, and Redfish access',
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
extFrpcSubtitle: 'NAT traversal through the FRP client',
aboutDesc: 'Open and Lightweight IP-KVM Solution',
@@ -543,12 +548,10 @@ export default {
usernameDesc: 'Change the console login username',
passwordDesc: 'Change the console login password',
version: 'Version',
buildInfo: 'Build Info',
detectDevices: 'Detect Devices',
detecting: 'Detecting...',
networkSettings: 'Network Settings',
msdSettings: 'MSD Settings',
atxSettings: 'ATX Settings',
httpSettings: 'HTTP Settings',
httpPort: 'HTTP Port',
configureHttpPort: 'Configure HTTP server port',
@@ -573,10 +576,13 @@ export default {
httpsEnabledDesc: 'Serve over an encrypted connection. A self-signed certificate is generated automatically if none is provided.',
portConfig: 'Port & Protocol',
portConfigDesc: 'The service listens on a single port at a time, determined by the HTTPS toggle',
redfishTitle: 'Redfish API',
redfishTitle: 'Redfish',
redfishDesc: 'DMTF Redfish standard management interface',
redfishEnabled: 'Enable Redfish API',
redfishEnabledDesc: 'When enabled, the standard Redfish management interface is available at /redfish/v1/',
redfishEndpoint: 'Redfish Endpoint',
copyRedfishUrl: 'Copy Redfish URL',
openRedfishEndpoint: 'Open Redfish endpoint',
httpPortReserved: 'HTTP port (reserved)',
httpsPortReserved: 'HTTPS port (reserved)',
portActive: 'Active',
@@ -673,33 +679,22 @@ export default {
disabled: 'Disabled',
msdDesc: 'Mass Storage Device allows you to mount ISO images and virtual drives to the target machine. Use the MSD panel on the main page to manage images.',
atxDesc: 'ATX power control allows you to remotely power on/off and reset the target machine. Use the ATX panel on the main page to control power.',
atxSettingsDesc: 'Configure ATX power control hardware bindings',
atxEnable: 'Enable ATX Control',
atxEnableDesc: 'Enable remote control of power and reset buttons',
atxPowerButton: 'Power Button',
atxPowerButtonDesc: 'For power on (short press) and force off (long press)',
atxResetButton: 'Reset Button',
atxResetButtonDesc: 'For resetting the target machine',
atxDriver: 'Driver Type',
atxDriverNone: 'Disabled',
atxDriverGpio: 'GPIO',
atxDriverUsbRelay: 'USB LCUS HID Relay',
atxDriverSerial: 'USB LCUS Serial Relay',
atxDevice: 'Device',
atxGpioChip: 'GPIO Chip',
atxPin: 'GPIO Pin',
atxChannel: 'Relay Channel',
atxSharedSerialBaudHint: 'When Power and Reset share one serial relay device, baud rate is controlled by the first config',
atxActiveLevel: 'Active Level',
atxLevelHigh: 'Active High',
atxLevelLow: 'Active Low',
atxActiveLevel: 'Trigger Level',
atxLevelHigh: 'High-level trigger',
atxLevelLow: 'Low-level trigger',
atxLedSensing: 'LED Status Sensing',
atxLedSensingDesc: 'Detect host power LED to determine power state (optional)',
atxLedEnable: 'Enable LED Sensing',
atxLedEnableDesc: 'Read power LED status via GPIO',
atxLedChip: 'GPIO Chip',
atxLedPin: 'GPIO Pin',
atxLedInverted: 'Invert Logic',
atxLedInvertedDesc: 'GPIO is low when LED is on',
atxHddSensing: 'HDD Activity Sensing',
atxWolSettings: 'Wake-on-LAN Settings',
atxWolSettingsDesc: 'Configure WOL magic packet sending options',
atxWolInterface: 'Network Interface',
@@ -1075,6 +1070,7 @@ export default {
title: 'RTSP Streaming',
desc: 'Configure RTSP video output service (H.264/H.265)',
bind: 'Bind Address',
bindHint: 'Enter an IPv4 or IPv6 address; configure the port in the port field.',
port: 'Port',
path: 'Stream Path',
pathPlaceholder: 'live',
@@ -1092,6 +1088,7 @@ export default {
title: 'VNC Remote',
desc: 'Access via TigerVNC client',
bind: 'Bind Address',
bindHint: 'Enter an IPv4 or IPv6 address; configure the port in the port field.',
port: 'Port',
encoding: 'Video Encoding',
encodingTightJpeg: 'Tight JPEG',
@@ -1111,8 +1108,6 @@ export default {
},
stats: {
title: 'Connection Stats',
webrtcMode: 'WebRTC Real-time Stats',
mjpegMode: 'MJPEG Real-time Stats',
current: 'Current Status',
video: 'Video',
videoDesc: 'Video stream from server to client.',
@@ -1132,7 +1127,6 @@ export default {
fps: 'FPS',
resolution: 'Resolution',
packetsLost: 'Packets Lost',
wsLatency: 'WS Latency',
connection: 'Connection Info',
connectionType: 'Connection Type',
transport: 'Transport',

View File

@@ -193,18 +193,15 @@ export default {
title: '电源控制',
description: '控制远程主机的电源状态',
powerState: '电源状态',
hddState: '硬盘活动',
stateOn: '已开机',
stateOff: '已关机',
stateUnknown: '未知',
hddActive: '活动',
hddInactive: '空闲',
shortPress: '短按电源',
longPress: '长按电源 (强制关机)',
reset: '重启',
confirmShortTitle: '确认短按电源',
confirmShortDesc: '这将模拟按下电源按钮,与物理短按电源键效果相同。',
confirmLongTitle: '确认强制关机',
confirmLongDesc: '这将强制关闭主机,可能导致数据丢失。确定继续吗?',
confirmResetTitle: '确认重启',
confirmResetDesc: '这将重启主机,可能导致未保存的数据丢失。确定继续吗?',
wol: '网络唤醒',
wolDescription: '发送 Wake-on-LAN 魔术包以远程开机。',
macAddress: 'MAC 地址',
@@ -480,12 +477,20 @@ export default {
driveConnected: '虚拟U盘已连接',
imageConnected: '镜像 {name} 已连接',
selectDriveSize: '选择虚拟驱动器大小',
selectedSize: '选定大小',
customSize: '自定义大小',
driveSizeHint: '输入自定义大小会覆盖上方选择 (64MB - 32GB)',
driveSpaceUnknown: '无法读取 MSD 目录所在储存空间的剩余可用空间',
driveSpaceUnavailable: 'MSD 目录所在储存空间不足无法创建虚拟U盘',
driveSpaceTooSmall: '剩余可用空间不足 {min} MB无法创建虚拟U盘',
driveCreateFailed: '虚拟U盘创建失败请检查 MSD 目录剩余空间',
driveCreated: '虚拟驱动器已创建 ({size} MB)',
fileDeleted: '文件已删除',
imageDeleted: '镜像已删除',
driveConnectedBlocked: '请先断开连接,再操作文件',
driveConnectedFilesHidden: '已连接到被控机,文件列表暂不可用',
uploadFailed: '文件上传失败',
driveUnreadable: '格式不支持',
driveUnreadableTooltip: '无法解析 exFAT 文件系统,可能已被格式化为不支持的格式。',
reinitializeDrive: '重新初始化',
},
settings: {
title: '系统设置',
@@ -520,7 +525,7 @@ export default {
environmentSubtitle: '系统级运行环境与 USB 设备维护',
aboutSubtitle: '在线升级、版本信息与设备硬件概览',
extTtydSubtitle: '在浏览器中打开本机 Shell 终端',
thirdPartyAccessSubtitle: '集中配置 RustDesk、VNCRTSP 外部接入',
thirdPartyAccessSubtitle: '集中配置 RustDesk、VNCRTSP 与 Redfish 外部接入',
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
extFrpcSubtitle: '通过 FRP 客户端实现内网穿透',
aboutDesc: '开放轻量的 IP-KVM 解决方案',
@@ -542,12 +547,10 @@ export default {
usernameDesc: '修改控制台登录用户名',
passwordDesc: '修改控制台登录密码',
version: '版本',
buildInfo: '构建信息',
detectDevices: '探测设备',
detecting: '探测中...',
networkSettings: '网络设置',
msdSettings: 'MSD 设置',
atxSettings: 'ATX 设置',
httpSettings: 'HTTP 设置',
httpPort: 'HTTP 端口',
configureHttpPort: '配置 HTTP 服务器端口',
@@ -572,10 +575,13 @@ export default {
httpsEnabledDesc: '使用加密连接对外提供服务,未配置证书时自动生成自签名证书',
portConfig: '端口与协议',
portConfigDesc: '服务一次仅监听一个端口,由 HTTPS 开关决定生效端口',
redfishTitle: 'Redfish API',
redfishTitle: 'Redfish',
redfishDesc: 'DMTF Redfish 标准管理接口',
redfishEnabled: '启用 Redfish API',
redfishEnabledDesc: '开启后可通过 /redfish/v1/ 访问标准 Redfish 管理接口',
redfishEndpoint: 'Redfish 入口',
copyRedfishUrl: '复制 Redfish 地址',
openRedfishEndpoint: '打开 Redfish 入口',
httpPortReserved: 'HTTP 端口(备用)',
httpsPortReserved: 'HTTPS 端口(备用)',
portActive: '当前生效',
@@ -672,33 +678,22 @@ export default {
disabled: '已禁用',
msdDesc: '虚拟存储设备允许您将 ISO 镜像和虚拟驱动器挂载到目标机器。请在主页面的 MSD 面板中管理镜像。',
atxDesc: 'ATX 电源控制允许您远程开关机和重启目标机器。请在主页面的 ATX 面板中控制电源。',
atxSettingsDesc: '配置 ATX 电源控制硬件绑定',
atxEnable: '启用 ATX 控制',
atxEnableDesc: '启用后可以远程控制电源和重启按钮',
atxPowerButton: '电源按钮',
atxPowerButtonDesc: '用于开机(短按)和强制关机(长按)',
atxResetButton: '重启按钮',
atxResetButtonDesc: '用于重启目标机器',
atxDriver: '驱动类型',
atxDriverNone: '禁用',
atxDriverGpio: 'GPIO',
atxDriverUsbRelay: 'USB LCUS HID继电器',
atxDriverSerial: 'USB LCUS 串口继电器',
atxDevice: '设备',
atxGpioChip: 'GPIO 芯片',
atxPin: 'GPIO 引脚',
atxChannel: '继电器通道',
atxSharedSerialBaudHint: 'Power 与 Reset 使用同一串口继电器时,波特率由第一个配置统一控制',
atxActiveLevel: '有效电平',
atxLevelHigh: '电平有效',
atxLevelLow: '低电平有效',
atxActiveLevel: '触发电平',
atxLevelHigh: '高电平触发',
atxLevelLow: '电平触发',
atxLedSensing: 'LED 状态检测',
atxLedSensingDesc: '检测主机电源 LED 以确定电源状态(可选)',
atxLedEnable: '启用 LED 检测',
atxLedEnableDesc: '通过 GPIO 读取电源 LED 状态',
atxLedChip: 'GPIO 芯片',
atxLedPin: 'GPIO 引脚',
atxLedInverted: '反转逻辑',
atxLedInvertedDesc: 'LED 亮起时 GPIO 为低电平',
atxHddSensing: 'HDD 活动检测',
atxWolSettings: '网络唤醒设置',
atxWolSettingsDesc: '配置 Wake-on-LAN 魔术包发送选项',
atxWolInterface: '网络接口',
@@ -1074,6 +1069,7 @@ export default {
title: 'RTSP 视频流',
desc: '配置 RTSP 推流服务H.264/H.265',
bind: '监听地址',
bindHint: '填写 IPv4 或 IPv6 地址;端口请在端口字段配置。',
port: '端口',
path: '流路径',
pathPlaceholder: 'live',
@@ -1091,6 +1087,7 @@ export default {
title: 'VNC 远程',
desc: '通过 TigerVNC 客户端访问',
bind: '监听地址',
bindHint: '填写 IPv4 或 IPv6 地址;端口请在端口字段配置。',
port: '端口',
encoding: '视频编码',
encodingTightJpeg: 'Tight JPEG',
@@ -1110,8 +1107,6 @@ export default {
},
stats: {
title: '连接统计',
webrtcMode: 'WebRTC 实时统计',
mjpegMode: 'MJPEG 实时统计',
current: '当前状态',
video: '视频',
videoDesc: '从服务器到客户端的视频流。',
@@ -1131,7 +1126,6 @@ export default {
fps: '帧率',
resolution: '分辨率',
packetsLost: '丢包数',
wsLatency: 'WS 延迟',
connection: '连接信息',
connectionType: '连接类型',
transport: '传输协议',

View File

@@ -50,6 +50,7 @@ interface AtxState {
backend: string
initialized: boolean
powerOn: boolean
hddStatus: 'active' | 'inactive' | 'unknown'
error: string | null
}
@@ -121,6 +122,7 @@ export interface AtxDeviceInfo {
backend: string
initialized: boolean
power_on: boolean
hdd_status: 'active' | 'inactive' | 'unknown'
error: string | null
}
@@ -148,7 +150,6 @@ export interface DeviceInfoEvent {
export const useSystemStore = defineStore('system', () => {
const version = ref<string>('')
const buildDate = ref<string>('')
const platform = ref<PlatformCapabilities | null>(null)
const capabilities = ref<SystemCapabilities | null>(null)
const diskSpace = ref<DiskSpaceInfo | null>(null)
@@ -171,7 +172,6 @@ export const useSystemStore = defineStore('system', () => {
try {
const info = await systemApi.info()
version.value = info.version
buildDate.value = info.build_date
platform.value = info.platform
capabilities.value = info.capabilities
diskSpace.value = info.disk_space ?? null
@@ -235,6 +235,7 @@ export const useSystemStore = defineStore('system', () => {
backend: state.backend,
initialized: state.initialized,
powerOn: state.power_status === 'on',
hddStatus: state.hdd_status,
error: null,
}
return state
@@ -376,6 +377,7 @@ export const useSystemStore = defineStore('system', () => {
backend: data.atx.backend,
initialized: data.atx.initialized,
powerOn: data.atx.power_on,
hddStatus: data.atx.hdd_status,
error: data.atx.error,
}
} else {
@@ -425,7 +427,6 @@ export const useSystemStore = defineStore('system', () => {
return {
version,
buildDate,
platform,
capabilities,
diskSpace,

View File

@@ -94,26 +94,29 @@ export enum ActiveLevel {
Low = "low",
}
export interface AtxKeyConfig {
driver: AtxDriverType;
export interface AtxOutputBinding {
enabled: boolean;
device: string;
pin: number;
active_level: ActiveLevel;
baud_rate: number;
}
export interface AtxLedConfig {
export interface AtxInputBinding {
enabled: boolean;
gpio_chip: string;
gpio_pin: number;
inverted: boolean;
device: string;
pin: number;
active_level: ActiveLevel;
}
export interface AtxConfig {
enabled: boolean;
power: AtxKeyConfig;
reset: AtxKeyConfig;
led: AtxLedConfig;
driver: AtxDriverType;
device: string;
baud_rate: number;
power: AtxOutputBinding;
reset: AtxOutputBinding;
led: AtxInputBinding;
hdd: AtxInputBinding;
wol_interface: string;
}
@@ -165,6 +168,15 @@ export interface WebConfig {
ssl_key_path?: string;
}
export interface ComputerUseConfig {
enabled: boolean;
provider: string;
base_url: string;
model: string;
max_steps: number;
timeout_seconds: number;
}
export interface TtydConfig {
enabled: boolean;
shell: string;
@@ -224,6 +236,11 @@ export interface ExtensionsConfig {
frpc: FrpcConfig;
}
export enum RustDeskCodec {
H264 = "h264",
H265 = "h265",
}
export interface RustDeskConfig {
enabled: boolean;
codec: RustDeskCodec;
@@ -232,11 +249,6 @@ export interface RustDeskConfig {
device_id: string;
}
export enum RustDeskCodec {
H264 = "h264",
H265 = "h265",
}
export enum VncEncoding {
TightJpeg = "tight_jpeg",
H264 = "h264",
@@ -280,6 +292,7 @@ export interface AppConfig {
audio: AudioConfig;
stream: StreamConfig;
web: WebConfig;
computer_use: ComputerUseConfig;
extensions: ExtensionsConfig;
rustdesk: RustDeskConfig;
vnc: VncConfig;
@@ -287,32 +300,36 @@ export interface AppConfig {
redfish: RedfishConfig;
}
/** Update for a single ATX key configuration */
export interface AtxKeyConfigUpdate {
driver?: AtxDriverType;
/** Update for a single ATX output binding */
export interface AtxOutputBindingUpdate {
enabled?: boolean;
device?: string;
baud_rate?: number;
pin?: number;
active_level?: ActiveLevel;
}
/** Update for LED sensing configuration */
export interface AtxLedConfigUpdate {
/** Update for ATX GPIO input sensing */
export interface AtxInputBindingUpdate {
enabled?: boolean;
gpio_chip?: string;
gpio_pin?: number;
inverted?: boolean;
device?: string;
pin?: number;
active_level?: ActiveLevel;
}
/** ATX configuration update request */
export interface AtxConfigUpdate {
enabled?: boolean;
driver?: AtxDriverType;
device?: string;
baud_rate?: number;
/** Power button configuration */
power?: AtxKeyConfigUpdate;
power?: AtxOutputBindingUpdate;
/** Reset button configuration */
reset?: AtxKeyConfigUpdate;
reset?: AtxOutputBindingUpdate;
/** LED sensing configuration */
led?: AtxLedConfigUpdate;
led?: AtxInputBindingUpdate;
/** HDD activity sensing configuration */
hdd?: AtxInputBindingUpdate;
/** Network interface for WOL packets (empty = auto) */
wol_interface?: string;
}
@@ -349,6 +366,66 @@ export interface Ch9329DescriptorState {
config_mode_available: boolean;
}
export interface ComputerUseConfigResponse {
enabled: boolean;
provider: string;
base_url: string;
model: string;
max_steps: number;
timeout_seconds: number;
api_key_configured: boolean;
api_key_source: string;
}
export interface ComputerUseConfigUpdate {
enabled?: boolean;
base_url?: string;
model?: string;
max_steps?: number;
timeout_seconds?: number;
openai_api_key?: string;
clear_openai_api_key?: boolean;
}
export interface ComputerUsePoint {
x: number;
y: number;
}
export interface ComputerUseScreenshot {
data_url: string;
width: number;
height: number;
}
export enum ComputerUseSessionStatus {
Idle = "idle",
WaitingScreenshot = "waiting_screenshot",
Thinking = "thinking",
Executing = "executing",
Completed = "completed",
Failed = "failed",
Stopped = "stopped",
}
export interface ComputerUseSessionSummary {
id?: string;
status: ComputerUseSessionStatus;
prompt?: string;
step: number;
max_steps: number;
last_error?: string;
final_message?: string;
}
export interface ComputerUseStartRequest {
prompt: string;
continue_conversation?: boolean;
client_id: string;
max_steps?: number;
timeout_seconds?: number;
}
export interface EasytierConfigUpdate {
enabled?: boolean;
network_name?: string;
@@ -731,3 +808,9 @@ export enum CanonicalKey {
AltRight = "AltRight",
MetaRight = "MetaRight",
}
export enum ComputerUseButton {
Left = "left",
Middle = "middle",
Right = "right",
}

View File

@@ -33,6 +33,7 @@ import InfoBar from '@/components/InfoBar.vue'
import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
import StatsSheet from '@/components/StatsSheet.vue'
import ComputerUseSheet from '@/components/ComputerUseSheet.vue'
import TerminalDialog from '@/components/TerminalDialog.vue'
import type { ComputerUseTimelineItem, NewComputerUseTimelineItem } from '@/types/computerUseTimeline'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import BrandMark from '@/components/BrandMark.vue'
@@ -61,8 +62,6 @@ import {
Sun,
Moon,
ChevronDown,
Terminal,
ExternalLink,
KeyRound,
Loader2,
} from 'lucide-vue-next'
@@ -171,6 +170,7 @@ function syncMouseModeFromConfig() {
const virtualKeyboardVisible = ref(false)
const virtualKeyboardAttached = ref(true)
const statsSheetOpen = ref(false)
const showConnectionStats = computed(() => videoMode.value !== 'mjpeg')
const virtualKeyboardConsumerEnabled = computed(() => {
const hid = configStore.hid
if (!hid) return true
@@ -207,6 +207,18 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
return 'disconnected'
})
function openStatsSheet() {
if (showConnectionStats.value) {
statsSheetOpen.value = true
}
}
watch(showConnectionStats, (canShow) => {
if (!canShow) {
statsSheetOpen.value = false
}
})
function getResolutionShortName(width: number, height: number): string {
if (height === 2160 || (height === 2160 && width === 4096)) return '4K'
if (height === 1440) return '2K'
@@ -2010,12 +2022,6 @@ function openTerminal() {
showTerminalDialog.value = true
}
function openTerminalInNewTab() {
if (!showTerminal.value) return
if (!ttydStatus.value?.running) return
window.open('/api/terminal/', '_blank')
}
async function handlePowerShort() {
try {
await atxApi.power('short')
@@ -2835,7 +2841,7 @@ onUnmounted(() => {
:show-terminal="showTerminal"
:show-computer-use="showComputerUse"
@toggle-fullscreen="toggleFullscreen"
@toggle-stats="statsSheetOpen = true"
@toggle-stats="openStatsSheet"
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
@toggle-mouse-mode="handleToggleMouseMode"
@update:video-mode="handleVideoModeChange"
@@ -3086,43 +3092,11 @@ onUnmounted(() => {
:debug-mode="false"
/>
<StatsSheet
v-if="showConnectionStats"
v-model:open="statsSheetOpen"
:video-mode="videoMode"
:mjpeg-fps="backendFps"
:ws-latency="0"
:webrtc-stats="webrtc.stats.value"
/>
<Dialog v-if="showTerminal" v-model:open="showTerminalDialog">
<DialogContent class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
<DialogHeader class="px-3 sm:px-4 py-2 sm:py-3 border-b shrink-0">
<DialogTitle class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<Terminal class="h-4 w-4 sm:h-5 sm:w-5" />
<span class="text-sm sm:text-base">{{ t('extensions.ttyd.title') }}</span>
</div>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 sm:h-8 sm:w-8 mr-6 sm:mr-8"
@click="openTerminalInNewTab"
:aria-label="t('extensions.ttyd.openInNewTab')"
:title="t('extensions.ttyd.openInNewTab')"
>
<ExternalLink class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</Button>
</DialogTitle>
</DialogHeader>
<div class="flex-1 min-h-0">
<iframe
v-if="showTerminalDialog"
src="/api/terminal/"
class="w-full h-full border-0"
allow="clipboard-read; clipboard-write"
scrolling="no"
/>
</div>
</DialogContent>
</Dialog>
<TerminalDialog v-if="showTerminal" v-model:open="showTerminalDialog" />
<Dialog v-model:open="changePasswordDialogOpen">
<DialogContent class="w-[95vw] max-w-md">
<DialogHeader>

View File

@@ -56,6 +56,7 @@ import { getVideoFormatState } from '@/lib/video-format-support'
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
import AppLayout from '@/components/AppLayout.vue'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import TerminalDialog from '@/components/TerminalDialog.vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@@ -224,6 +225,7 @@ function normalizeSettingsSection(value: unknown): SettingsSectionId | null {
if (typeof value !== 'string') return null
if (value === 'access-control') return 'account'
if (value === 'ext-frpc') return 'ext-remote-access'
if (value === 'redfish') return 'third-party-access'
if (value === 'ext-rustdesk' || value === 'ext-vnc' || value === 'ext-rtsp') return 'third-party-access'
return isSettingsSectionId(value) ? value : null
}
@@ -242,10 +244,7 @@ async function loadSectionData(section: SettingsSectionId) {
await loadAuthConfig()
return
case 'network':
await Promise.all([
loadWebServerConfig(),
loadRedfishConfig(),
])
await loadWebServerConfig()
return
case 'video':
await Promise.all([
@@ -277,6 +276,8 @@ async function loadSectionData(section: SettingsSectionId) {
return
case 'third-party-access':
await Promise.all([
loadWebServerConfig(),
loadRedfishConfig(),
loadRustdeskConfig(),
loadRustdeskPassword(),
loadRtspConfig(),
@@ -520,9 +521,12 @@ const previewAccessUrl = computed(() => {
const host = firstAddr.includes(':') ? `[${firstAddr}]` : firstAddr
return `${scheme}://${host}:${port}`
})
const redfishAccessUrl = computed(() => `${previewAccessUrl.value}/redfish/v1/`)
const previewUrlCopied = ref(false)
const redfishUrlCopied = ref(false)
let previewUrlCopiedTimer: ReturnType<typeof setTimeout> | null = null
let redfishUrlCopiedTimer: ReturnType<typeof setTimeout> | null = null
async function copyPreviewUrl() {
const ok = await clipboardCopy(previewAccessUrl.value)
@@ -538,6 +542,20 @@ function openPreviewUrl() {
window.open(previewAccessUrl.value, '_blank', 'noopener,noreferrer')
}
async function copyRedfishUrl() {
const ok = await clipboardCopy(redfishAccessUrl.value)
if (!ok) return
redfishUrlCopied.value = true
if (redfishUrlCopiedTimer) clearTimeout(redfishUrlCopiedTimer)
redfishUrlCopiedTimer = setTimeout(() => {
redfishUrlCopied.value = false
}, 1500)
}
function openRedfishUrl() {
window.open(redfishAccessUrl.value, '_blank', 'noopener,noreferrer')
}
interface DeviceConfig {
video: Array<{
path: string
@@ -1120,29 +1138,41 @@ watch(bindMode, (mode) => {
const atxConfig = ref({
enabled: false,
driver: 'none' as AtxDriverType,
device: '',
baud_rate: 9600,
power: {
driver: 'none' as AtxDriverType,
enabled: false,
device: '',
pin: 1,
active_level: 'high' as ActiveLevel,
baud_rate: 9600,
},
reset: {
driver: 'none' as AtxDriverType,
enabled: false,
device: '',
pin: 1,
active_level: 'high' as ActiveLevel,
baud_rate: 9600,
},
led: {
enabled: false,
gpio_chip: '',
gpio_pin: 0,
inverted: false,
device: '',
pin: 0,
active_level: 'high' as ActiveLevel,
},
hdd: {
enabled: false,
device: '',
pin: 0,
active_level: 'high' as ActiveLevel,
},
wol_interface: '',
})
const atxSaving = ref(false)
const atxSaved = ref(false)
const wolSaving = ref(false)
const wolSaved = ref(false)
const atxDevices = ref<AtxDevices>({
gpio_chips: [],
usb_relays: [],
@@ -1166,17 +1196,6 @@ const atxDriverOptions = computed(() => {
: options
})
const isSharedAtxSerialRelay = computed(() => {
const power = atxConfig.value.power
const reset = atxConfig.value.reset
return (
power.driver === 'serial'
&& reset.driver === 'serial'
&& !!power.device.trim()
&& power.device === reset.device
)
})
const availableBackends = ref<EncoderBackendInfo[]>([])
const selectedBackendFormats = computed(() => {
@@ -1678,10 +1697,6 @@ function openTerminal() {
showTerminalDialog.value = true
}
function openTerminalInNewTab() {
window.open('/api/terminal/', '_blank')
}
function getExtStatusText(status: ExtensionStatus | undefined): string {
if (!status) return t('extensions.stopped')
switch (status.state) {
@@ -1720,14 +1735,29 @@ async function loadAtxConfig() {
const config = await configStore.refreshAtx()
atxConfig.value = {
enabled: config.enabled,
power: { ...config.power },
reset: { ...config.reset },
led: { ...config.led },
driver: config.enabled ? config.driver : 'none' as AtxDriverType,
device: config.device || '',
baud_rate: config.baud_rate || 9600,
power: {
...config.power,
active_level: config.power.active_level || 'high',
},
reset: {
...config.reset,
active_level: config.reset.active_level || 'high',
},
led: {
...config.led,
active_level: config.led.active_level || 'high',
},
hdd: {
...config.hdd,
active_level: config.hdd.active_level || 'high',
},
wol_interface: config.wol_interface || '',
}
clearAtxSerialDeviceConflicts()
normalizeAtxRelayChannels()
syncSharedAtxSerialBaudRate()
} catch {
}
}
@@ -1739,43 +1769,64 @@ async function loadAtxDevices() {
}
}
async function saveAtxConfig() {
loading.value = true
saved.value = false
async function saveAtxSettings() {
atxSaving.value = true
atxSaved.value = false
try {
normalizeAtxRelayChannels()
syncSharedAtxSerialBaudRate()
const isGpio = atxConfig.value.driver === 'gpio'
const isRelay = ['usbrelay', 'serial'].includes(atxConfig.value.driver)
await configStore.updateAtx({
enabled: atxConfig.value.enabled,
enabled: atxConfig.value.driver !== 'none',
driver: atxConfig.value.driver,
device: atxConfig.value.device || undefined,
baud_rate: atxConfig.value.baud_rate,
power: {
driver: atxConfig.value.power.driver,
enabled: isGpio ? !!atxConfig.value.power.device : isRelay,
device: atxConfig.value.power.device || undefined,
pin: atxConfig.value.power.pin,
active_level: atxConfig.value.power.active_level,
baud_rate: atxConfig.value.power.baud_rate,
},
reset: {
driver: atxConfig.value.reset.driver,
enabled: isGpio ? !!atxConfig.value.reset.device : isRelay,
device: atxConfig.value.reset.device || undefined,
pin: atxConfig.value.reset.pin,
active_level: atxConfig.value.reset.active_level,
baud_rate: isSharedAtxSerialRelay.value
? atxConfig.value.power.baud_rate
: atxConfig.value.reset.baud_rate,
},
led: {
enabled: atxConfig.value.led.enabled,
gpio_chip: atxConfig.value.led.gpio_chip || undefined,
gpio_pin: atxConfig.value.led.gpio_pin,
inverted: atxConfig.value.led.inverted,
enabled: isGpio && !!atxConfig.value.led.device,
device: atxConfig.value.led.device || undefined,
pin: atxConfig.value.led.pin,
active_level: atxConfig.value.led.active_level,
},
hdd: {
enabled: isGpio && !!atxConfig.value.hdd.device,
device: atxConfig.value.hdd.device || undefined,
pin: atxConfig.value.hdd.pin,
active_level: atxConfig.value.hdd.active_level,
},
wol_interface: atxConfig.value.wol_interface || undefined,
})
saved.value = true
setTimeout(() => (saved.value = false), 2000)
atxConfig.value.enabled = atxConfig.value.driver !== 'none'
atxSaved.value = true
setTimeout(() => (atxSaved.value = false), 2000)
} catch {
} finally {
loading.value = false
atxSaving.value = false
}
}
async function saveWolSettings() {
wolSaving.value = true
wolSaved.value = false
try {
await configStore.updateAtx({
wol_interface: atxConfig.value.wol_interface || undefined,
})
wolSaved.value = true
setTimeout(() => (wolSaved.value = false), 2000)
} catch {
} finally {
wolSaving.value = false
}
}
@@ -1806,25 +1857,21 @@ function clearAtxSerialDeviceConflicts() {
const reserved = ch9329ReservedSerialDevice.value
if (!reserved) return
if (atxConfig.value.power.driver === 'serial' && atxConfig.value.power.device === reserved) {
atxConfig.value.power.device = ''
if (atxConfig.value.driver === 'serial' && atxConfig.value.device === reserved) {
atxConfig.value.device = ''
}
if (atxConfig.value.reset.driver === 'serial' && atxConfig.value.reset.device === reserved) {
atxConfig.value.reset.device = ''
}
}
function syncSharedAtxSerialBaudRate() {
if (!isSharedAtxSerialRelay.value) return
atxConfig.value.reset.baud_rate = atxConfig.value.power.baud_rate
}
function normalizeAtxRelayChannels() {
for (const key of [atxConfig.value.power, atxConfig.value.reset]) {
if (['usbrelay', 'serial'].includes(key.driver) && key.pin < 1) {
if (['usbrelay', 'serial'].includes(atxConfig.value.driver) && key.pin < 1) {
key.pin = 1
}
}
if (atxConfig.value.driver !== 'gpio') {
atxConfig.value.led.enabled = false
atxConfig.value.hdd.enabled = false
}
}
watch(
@@ -1835,22 +1882,10 @@ watch(
)
watch(
() => [atxConfig.value.power.driver, atxConfig.value.reset.driver],
() => atxConfig.value.driver,
() => {
normalizeAtxRelayChannels()
},
)
watch(
() => [
atxConfig.value.power.driver,
atxConfig.value.power.device,
atxConfig.value.power.baud_rate,
atxConfig.value.reset.driver,
atxConfig.value.reset.device,
],
() => {
syncSharedAtxSerialBaudRate()
clearAtxSerialDeviceConflicts()
},
)
@@ -3890,34 +3925,6 @@ watch(isWindows, () => {
</Button>
</CardFooter>
</Card>
<!-- Redfish API Card -->
<Card>
<CardHeader>
<CardTitle>{{ t('settings.redfishTitle') }}</CardTitle>
<CardDescription>{{ t('settings.redfishDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-5">
<div class="flex items-start justify-between gap-4">
<div class="space-y-0.5">
<Label>{{ t('settings.redfishEnabled') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.redfishEnabledDesc') }}</p>
</div>
<Switch v-model="redfishEnabled" />
</div>
</CardContent>
<CardFooter class="flex items-center justify-between gap-3 border-t pt-4">
<p class="text-xs text-muted-foreground flex items-center gap-1.5">
<AlertTriangle class="h-3.5 w-3.5 text-amber-500" />
{{ t('settings.restartRequiredHint') }}
</p>
<Button @click="saveRedfishConfig" :disabled="redfishSaving || autoRestarting">
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
<Save v-else class="h-4 w-4 mr-2" />
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
</Button>
</CardFooter>
</Card>
</div>
<!-- MSD Section -->
@@ -3958,83 +3965,40 @@ watch(isWindows, () => {
<!-- ATX Section -->
<div v-show="activeSection === 'atx'" class="space-y-6">
<!-- Enable ATX -->
<Card>
<CardHeader class="flex flex-row items-start justify-between space-y-0">
<div class="space-y-1.5">
<CardTitle>{{ t('settings.atxSettings') }}</CardTitle>
<CardDescription>{{ t('settings.atxSettingsDesc') }}</CardDescription>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.refresh')" @click="loadAtxDevices">
<RefreshCw class="h-4 w-4" />
</Button>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label for="atx-enabled">{{ t('settings.atxEnable') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.atxEnableDesc') }}</p>
</div>
<Switch
id="atx-enabled"
v-model="atxConfig.enabled"
/>
</div>
</CardContent>
</Card>
<!-- Power Button Config -->
<Card v-if="atxConfig.enabled">
<CardHeader>
<CardTitle>{{ t('settings.atxPowerButton') }}</CardTitle>
<CardDescription>{{ t('settings.atxPowerButtonDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="power-driver">{{ t('settings.atxDriver') }}</Label>
<select id="power-driver" v-model="atxConfig.power.driver" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<CardContent class="space-y-4 pt-6">
<div class="flex items-center justify-between gap-3">
<div class="flex min-w-0 flex-1 items-center gap-3">
<Label for="atx-driver" class="shrink-0">{{ t('settings.atxDriver') }}</Label>
<select id="atx-driver" v-model="atxConfig.driver" class="h-9 w-full max-w-xs px-3 rounded-md border border-input bg-background text-sm">
<option v-for="option in atxDriverOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div class="space-y-2">
<Label for="power-device">{{ t('settings.atxDevice') }}</Label>
<select id="power-device" v-model="atxConfig.power.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="atxConfig.power.driver === 'none'">
<Button variant="ghost" size="icon" class="h-9 w-9 shrink-0" :aria-label="t('common.refresh')" @click="loadAtxDevices">
<RefreshCw class="h-4 w-4" />
</Button>
</div>
<div v-if="['usbrelay', 'serial'].includes(atxConfig.driver)" class="grid gap-4 sm:grid-cols-2">
<div v-if="['usbrelay', 'serial'].includes(atxConfig.driver)" class="space-y-2">
<Label for="atx-device">{{ t('settings.atxDevice') }}</Label>
<select id="atx-device" v-model="atxConfig.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="">{{ t('settings.selectDevice') }}</option>
<option
v-for="dev in getAtxDevicesForDriver(atxConfig.power.driver)"
v-for="dev in getAtxDevicesForDriver(atxConfig.driver)"
:key="dev"
:value="dev"
:disabled="atxConfig.power.driver === 'serial' && isAtxSerialDeviceReserved(dev)"
:disabled="atxConfig.driver === 'serial' && isAtxSerialDeviceReserved(dev)"
>
{{ formatAtxDeviceLabel(atxConfig.power.driver, dev) }}
{{ formatAtxDeviceLabel(atxConfig.driver, dev) }}
</option>
</select>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="power-pin">{{ ['usbrelay', 'serial'].includes(atxConfig.power.driver) ? t('settings.atxChannel') : t('settings.atxPin') }}</Label>
<Input
id="power-pin"
type="number"
v-model.number="atxConfig.power.pin"
:min="['usbrelay', 'serial'].includes(atxConfig.power.driver) ? 1 : 0"
:disabled="atxConfig.power.driver === 'none'"
/>
</div>
<div v-if="atxConfig.power.driver === 'gpio'" class="space-y-2">
<Label for="power-level">{{ t('settings.atxActiveLevel') }}</Label>
<select id="power-level" v-model="atxConfig.power.active_level" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="high">{{ t('settings.atxLevelHigh') }}</option>
<option value="low">{{ t('settings.atxLevelLow') }}</option>
</select>
</div>
<div v-if="atxConfig.power.driver === 'serial'" class="space-y-2">
<Label for="power-baudrate">{{ t('settings.baudRate') }}</Label>
<select id="power-baudrate" v-model.number="atxConfig.power.baud_rate" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<div v-if="atxConfig.driver === 'serial'" class="space-y-2">
<Label for="atx-baudrate">{{ t('settings.baudRate') }}</Label>
<select id="atx-baudrate" v-model.number="atxConfig.baud_rate" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option :value="9600">9600</option>
<option :value="19200">19200</option>
<option :value="38400">38400</option>
@@ -4043,128 +4007,138 @@ watch(isWindows, () => {
</select>
</div>
</div>
</CardContent>
</Card>
<!-- Reset Button Config -->
<Card v-if="atxConfig.enabled">
<CardHeader>
<CardTitle>{{ t('settings.atxResetButton') }}</CardTitle>
<CardDescription>{{ t('settings.atxResetButtonDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="reset-driver">{{ t('settings.atxDriver') }}</Label>
<select id="reset-driver" v-model="atxConfig.reset.driver" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option v-for="option in atxDriverOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div class="space-y-2">
<Label for="reset-device">{{ t('settings.atxDevice') }}</Label>
<select id="reset-device" v-model="atxConfig.reset.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="atxConfig.reset.driver === 'none'">
<option value="">{{ t('settings.selectDevice') }}</option>
<option
v-for="dev in getAtxDevicesForDriver(atxConfig.reset.driver)"
:key="dev"
:value="dev"
:disabled="atxConfig.reset.driver === 'serial' && isAtxSerialDeviceReserved(dev)"
>
{{ formatAtxDeviceLabel(atxConfig.reset.driver, dev) }}
</option>
</select>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="reset-pin">{{ ['usbrelay', 'serial'].includes(atxConfig.reset.driver) ? t('settings.atxChannel') : t('settings.atxPin') }}</Label>
<Input
id="reset-pin"
type="number"
v-model.number="atxConfig.reset.pin"
:min="['usbrelay', 'serial'].includes(atxConfig.reset.driver) ? 1 : 0"
:disabled="atxConfig.reset.driver === 'none'"
/>
</div>
<div v-if="atxConfig.reset.driver === 'gpio'" class="space-y-2">
<Label for="reset-level">{{ t('settings.atxActiveLevel') }}</Label>
<select id="reset-level" v-model="atxConfig.reset.active_level" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="high">{{ t('settings.atxLevelHigh') }}</option>
<option value="low">{{ t('settings.atxLevelLow') }}</option>
</select>
</div>
<div v-if="atxConfig.reset.driver === 'serial'" class="space-y-2">
<Label for="reset-baudrate">{{ t('settings.baudRate') }}</Label>
<select
id="reset-baudrate"
v-model.number="atxConfig.reset.baud_rate"
class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
:disabled="isSharedAtxSerialRelay"
>
<option :value="9600">9600</option>
<option :value="19200">19200</option>
<option :value="38400">38400</option>
<option :value="57600">57600</option>
<option :value="115200">115200</option>
</select>
<p v-if="isSharedAtxSerialRelay" class="text-xs text-muted-foreground">
{{ t('settings.atxSharedSerialBaudHint') }}
</p>
</div>
</div>
</CardContent>
</Card>
<!-- LED Sensing Config -->
<Card v-if="atxConfig.enabled && !isWindows">
<CardHeader>
<CardTitle>{{ t('settings.atxLedSensing') }}</CardTitle>
<CardDescription>{{ t('settings.atxLedSensingDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label for="led-enabled">{{ t('settings.atxLedEnable') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.atxLedEnableDesc') }}</p>
</div>
<Switch
id="led-enabled"
v-model="atxConfig.led.enabled"
/>
</div>
<template v-if="atxConfig.led.enabled">
<template v-if="atxConfig.driver === 'gpio'">
<Separator />
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="led-chip">{{ t('settings.atxLedChip') }}</Label>
<select id="led-chip" v-model="atxConfig.led.gpio_chip" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="">{{ t('settings.selectDevice') }}</option>
<option v-for="dev in atxDevices.gpio_chips" :key="dev" :value="dev">{{ dev }}</option>
</select>
<div class="space-y-4">
<div class="space-y-3 rounded-md border p-3">
<Label>{{ t('settings.atxPowerButton') }}</Label>
<div class="grid gap-3 sm:grid-cols-3">
<div class="space-y-2">
<Label for="power-device">{{ t('settings.atxGpioChip') }}</Label>
<select id="power-device" v-model="atxConfig.power.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="">{{ t('settings.atxDriverNone') }}</option>
<option v-for="dev in atxDevices.gpio_chips" :key="dev" :value="dev">{{ dev }}</option>
</select>
</div>
<div class="space-y-2">
<Label for="power-pin">{{ t('settings.atxPin') }}</Label>
<Input id="power-pin" type="number" v-model.number="atxConfig.power.pin" min="0" :disabled="!atxConfig.power.device" />
</div>
<div class="space-y-2">
<Label for="power-active-level">{{ t('settings.atxActiveLevel') }}</Label>
<select id="power-active-level" v-model="atxConfig.power.active_level" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!atxConfig.power.device">
<option value="high">{{ t('settings.atxLevelHigh') }}</option>
<option value="low">{{ t('settings.atxLevelLow') }}</option>
</select>
</div>
</div>
</div>
<div class="space-y-2">
<Label for="led-pin">{{ t('settings.atxLedPin') }}</Label>
<Input id="led-pin" type="number" v-model.number="atxConfig.led.gpio_pin" min="0" />
<div class="space-y-3 rounded-md border p-3">
<Label>{{ t('settings.atxResetButton') }}</Label>
<div class="grid gap-3 sm:grid-cols-3">
<div class="space-y-2">
<Label for="reset-device">{{ t('settings.atxGpioChip') }}</Label>
<select id="reset-device" v-model="atxConfig.reset.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="">{{ t('settings.atxDriverNone') }}</option>
<option v-for="dev in atxDevices.gpio_chips" :key="dev" :value="dev">{{ dev }}</option>
</select>
</div>
<div class="space-y-2">
<Label for="reset-pin">{{ t('settings.atxPin') }}</Label>
<Input id="reset-pin" type="number" v-model.number="atxConfig.reset.pin" min="0" :disabled="!atxConfig.reset.device" />
</div>
<div class="space-y-2">
<Label for="reset-active-level">{{ t('settings.atxActiveLevel') }}</Label>
<select id="reset-active-level" v-model="atxConfig.reset.active_level" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!atxConfig.reset.device">
<option value="high">{{ t('settings.atxLevelHigh') }}</option>
<option value="low">{{ t('settings.atxLevelLow') }}</option>
</select>
</div>
</div>
</div>
<div class="space-y-3 rounded-md border p-3">
<Label>{{ t('settings.atxLedSensing') }}</Label>
<div class="grid gap-3 sm:grid-cols-3">
<div class="space-y-2">
<Label for="led-device">{{ t('settings.atxGpioChip') }}</Label>
<select id="led-device" v-model="atxConfig.led.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="">{{ t('settings.atxDriverNone') }}</option>
<option v-for="dev in atxDevices.gpio_chips" :key="dev" :value="dev">{{ dev }}</option>
</select>
</div>
<div class="space-y-2">
<Label for="led-pin">{{ t('settings.atxPin') }}</Label>
<Input id="led-pin" type="number" v-model.number="atxConfig.led.pin" min="0" :disabled="!atxConfig.led.device" />
</div>
<div class="space-y-2">
<Label for="led-active-level">{{ t('settings.atxActiveLevel') }}</Label>
<select id="led-active-level" v-model="atxConfig.led.active_level" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!atxConfig.led.device">
<option value="high">{{ t('settings.atxLevelHigh') }}</option>
<option value="low">{{ t('settings.atxLevelLow') }}</option>
</select>
</div>
</div>
</div>
<div class="space-y-3 rounded-md border p-3">
<Label>{{ t('settings.atxHddSensing') }}</Label>
<div class="grid gap-3 sm:grid-cols-3">
<div class="space-y-2">
<Label for="hdd-device">{{ t('settings.atxGpioChip') }}</Label>
<select id="hdd-device" v-model="atxConfig.hdd.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="">{{ t('settings.atxDriverNone') }}</option>
<option v-for="dev in atxDevices.gpio_chips" :key="dev" :value="dev">{{ dev }}</option>
</select>
</div>
<div class="space-y-2">
<Label for="hdd-pin">{{ t('settings.atxPin') }}</Label>
<Input id="hdd-pin" type="number" v-model.number="atxConfig.hdd.pin" min="0" :disabled="!atxConfig.hdd.device" />
</div>
<div class="space-y-2">
<Label for="hdd-active-level">{{ t('settings.atxActiveLevel') }}</Label>
<select id="hdd-active-level" v-model="atxConfig.hdd.active_level" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!atxConfig.hdd.device">
<option value="high">{{ t('settings.atxLevelHigh') }}</option>
<option value="low">{{ t('settings.atxLevelLow') }}</option>
</select>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label for="led-inverted">{{ t('settings.atxLedInverted') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.atxLedInvertedDesc') }}</p>
</template>
<template v-else-if="['usbrelay', 'serial'].includes(atxConfig.driver)">
<Separator />
<div class="space-y-4">
<div class="space-y-3 rounded-md border p-3">
<Label>{{ t('settings.atxPowerButton') }}</Label>
<div class="space-y-2">
<Label for="power-channel">{{ t('settings.atxChannel') }}</Label>
<Input id="power-channel" type="number" v-model.number="atxConfig.power.pin" min="1" />
</div>
</div>
<div class="space-y-3 rounded-md border p-3">
<Label>{{ t('settings.atxResetButton') }}</Label>
<div class="space-y-2">
<Label for="reset-channel">{{ t('settings.atxChannel') }}</Label>
<Input id="reset-channel" type="number" v-model.number="atxConfig.reset.pin" min="1" />
</div>
</div>
<Switch
id="led-inverted"
v-model="atxConfig.led.inverted"
/>
</div>
</template>
</CardContent>
</Card>
<div class="flex justify-end">
<Button :disabled="atxSaving" @click="saveAtxSettings">
<Loader2 v-if="atxSaving" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="atxSaved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ atxSaving ? t('actionbar.applying') : atxSaved ? t('common.success') : t('common.save') }}
</Button>
</div>
<!-- WOL Config -->
<Card v-if="atxConfig.enabled">
<Card>
<CardHeader>
<CardTitle>{{ t('settings.atxWolSettings') }}</CardTitle>
<CardDescription>{{ t('settings.atxWolSettingsDesc') }}</CardDescription>
@@ -4181,11 +4155,9 @@ watch(isWindows, () => {
</div>
</CardContent>
</Card>
<!-- Save Button -->
<div class="flex justify-end">
<Button :disabled="loading" @click="saveAtxConfig">
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
<Button :disabled="wolSaving" @click="saveWolSettings">
<Loader2 v-if="wolSaving" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="wolSaved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ wolSaving ? t('actionbar.applying') : wolSaved ? t('common.success') : t('common.save') }}
</Button>
</div>
</div>
@@ -4726,7 +4698,10 @@ watch(isWindows, () => {
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.rtsp.bind') }}</Label>
<Input v-model="rtspLocalConfig.bind" class="sm:col-span-3" placeholder="0.0.0.0" :disabled="rtspStatus?.service_status === 'running'" />
<div class="sm:col-span-3 space-y-1">
<Input v-model="rtspLocalConfig.bind" placeholder="0.0.0.0 / ::" :disabled="rtspStatus?.service_status === 'running'" />
<p class="text-xs text-muted-foreground">{{ t('extensions.rtsp.bindHint') }}</p>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.rtsp.port') }}</Label>
@@ -4856,7 +4831,10 @@ watch(isWindows, () => {
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.vnc.bind') }}</Label>
<Input v-model="vncLocalConfig.bind" class="sm:col-span-3" placeholder="0.0.0.0" :disabled="vncStatus?.service_status === 'running'" />
<div class="sm:col-span-3 space-y-1">
<Input v-model="vncLocalConfig.bind" placeholder="0.0.0.0 / ::" :disabled="vncStatus?.service_status === 'running'" />
<p class="text-xs text-muted-foreground">{{ t('extensions.vnc.bindHint') }}</p>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.vnc.port') }}</Label>
@@ -5112,6 +5090,112 @@ watch(isWindows, () => {
</div>
</div>
<!-- Redfish Section -->
<div v-show="activeSection === 'third-party-access'" class="space-y-6">
<!-- Auto-restart: restarting progress -->
<div
v-if="autoRestarting"
class="flex items-center gap-3 rounded-lg border bg-card px-4 py-3 text-sm shadow-sm"
>
<RefreshCw class="h-4 w-4 animate-spin text-primary shrink-0" />
<div class="flex-1 min-w-0">
<p class="font-medium">{{ t('settings.autoRestarting') }}</p>
<p class="text-xs text-muted-foreground">
{{ webServerConfig.https_enabled
? t('settings.autoRestartingHttpsDesc', { sec: autoRestartCountdown })
: t('settings.autoRestartingDesc') }}
</p>
</div>
<span v-if="webServerConfig.https_enabled && autoRestartCountdown > 0"
class="tabular-nums text-lg font-bold text-primary shrink-0">
{{ autoRestartCountdown }}
</span>
</div>
<!-- Auto-restart: HTTPS manual redirect (cert must be accepted by user) -->
<div
v-if="autoRestartManualUrl"
class="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-700 px-4 py-4 space-y-3"
>
<div class="flex items-start gap-2 text-sm text-amber-800 dark:text-amber-300">
<Lock class="h-4 w-4 shrink-0 mt-0.5" />
<div>
<p class="font-medium">{{ t('settings.httpsManualRedirectTitle') }}</p>
<p class="text-xs mt-0.5 opacity-80">{{ t('settings.httpsManualRedirectDesc') }}</p>
</div>
</div>
<a
:href="autoRestartManualUrl"
class="flex items-center justify-center gap-2 w-full rounded-md bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium px-4 py-2 transition-colors"
>
<ExternalLink class="h-4 w-4" />
{{ autoRestartManualUrl }}
</a>
</div>
<!-- Auto-restart: failure / timeout -->
<div
v-if="autoRestartFailed"
class="flex items-center justify-between rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm"
>
<p class="text-destructive">{{ t('settings.autoRestartFailed') }}</p>
<Button variant="outline" size="sm" @click="triggerAutoRestart">
<RefreshCw class="h-3 w-3 mr-1" />
{{ t('common.retry') }}
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>{{ t('settings.redfishTitle') }}</CardTitle>
<CardDescription>{{ t('settings.redfishDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-5">
<div class="flex items-start justify-between gap-4">
<div class="space-y-0.5">
<Label>{{ t('settings.redfishEnabled') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.redfishEnabledDesc') }}</p>
</div>
<Switch v-model="redfishEnabled" />
</div>
<div class="rounded-md border bg-muted/40 p-3 space-y-1.5">
<p class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{{ t('settings.redfishEndpoint') }}</p>
<div class="flex items-center gap-2">
<code class="font-mono text-xs sm:text-sm break-all flex-1 min-w-0">{{ redfishAccessUrl }}</code>
<Button
variant="ghost" size="icon" class="h-7 w-7 shrink-0"
:title="t('settings.copyRedfishUrl')"
:aria-label="t('settings.copyRedfishUrl')"
@click="copyRedfishUrl"
>
<Check v-if="redfishUrlCopied" class="h-3.5 w-3.5 text-emerald-600" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon" class="h-7 w-7 shrink-0"
:title="t('settings.openRedfishEndpoint')"
:aria-label="t('settings.openRedfishEndpoint')"
@click="openRedfishUrl"
>
<ExternalLink class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardContent>
<CardFooter class="flex items-center justify-between gap-3 border-t pt-4">
<p class="text-xs text-muted-foreground flex items-center gap-1.5">
<AlertTriangle class="h-3.5 w-3.5 text-amber-500" />
{{ t('settings.restartRequiredHint') }}
</p>
<Button @click="saveRedfishConfig" :disabled="redfishSaving || autoRestarting">
<RefreshCw v-if="autoRestarting || redfishSaving" class="h-4 w-4 mr-2 animate-spin" />
<Save v-else class="h-4 w-4 mr-2" />
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
</Button>
</CardFooter>
</Card>
</div>
<!-- About Section -->
<div v-show="activeSection === 'about'" class="space-y-6">
<Card v-if="!isAndroid">
@@ -5137,7 +5221,6 @@ watch(isWindows, () => {
<Label>{{ t('settings.currentVersion') }}</Label>
<Badge variant="outline">
{{ updateOverview?.current_version || systemStore.version || t('common.unknown') }}
({{ systemStore.buildDate || t('common.unknown') }})
</Badge>
</div>
<div class="space-y-2">
@@ -5267,37 +5350,7 @@ watch(isWindows, () => {
</div>
<!-- Terminal Dialog -->
<Dialog v-model:open="showTerminalDialog">
<DialogContent class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
<DialogHeader class="px-3 sm:px-4 py-2 sm:py-3 border-b shrink-0">
<DialogTitle class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<Terminal class="h-4 w-4 sm:h-5 sm:w-5" />
<span class="text-sm sm:text-base">{{ t('extensions.ttyd.title') }}</span>
</div>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 sm:h-8 sm:w-8 mr-6 sm:mr-8"
@click="openTerminalInNewTab"
:aria-label="t('extensions.ttyd.openInNewTab')"
:title="t('extensions.ttyd.openInNewTab')"
>
<ExternalLink class="h-4 w-4" />
</Button>
</DialogTitle>
</DialogHeader>
<div class="flex-1 min-h-0">
<iframe
v-if="showTerminalDialog"
src="/api/terminal/"
class="w-full h-full border-0"
allow="clipboard-read; clipboard-write"
scrolling="no"
/>
</div>
</DialogContent>
</Dialog>
<TerminalDialog v-model:open="showTerminalDialog" />
<!-- Restart Confirmation Dialog -->
<Dialog v-model:open="showRestartDialog">