mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-01-28 16:41:52 +08:00
feat(video): 事务化切换与前端统一编排,增强视频输入格式支持
- 后端:切换事务+transition_id,/stream/mode 返回 switching/transition_id 与实际 codec - 事件:新增 mode_switching/mode_ready,config/webrtc_ready/mode_changed 关联事务 - 编码/格式:扩展 NV21/NV16/NV24/RGB/BGR 输入与转换链路,RKMPP direct input 优化 - 前端:useVideoSession 统一切换,失败回退真实切回 MJPEG,菜单格式同步修复 - 清理:useVideoStream 降级为 MJPEG-only
This commit is contained in:
@@ -56,7 +56,10 @@ fn build_common(builder: &mut Build) {
|
|||||||
|
|
||||||
// Unsupported platforms
|
// Unsupported platforms
|
||||||
if target_os != "windows" && target_os != "linux" {
|
if target_os != "windows" && target_os != "linux" {
|
||||||
panic!("Unsupported OS: {}. Only Windows and Linux are supported.", target_os);
|
panic!(
|
||||||
|
"Unsupported OS: {}. Only Windows and Linux are supported.",
|
||||||
|
target_os
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// tool
|
// tool
|
||||||
@@ -103,7 +106,9 @@ mod ffmpeg {
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
// Check if static linking is requested
|
// Check if static linking is requested
|
||||||
let use_static = std::env::var("FFMPEG_STATIC").map(|v| v == "1").unwrap_or(false);
|
let use_static = std::env::var("FFMPEG_STATIC")
|
||||||
|
.map(|v| v == "1")
|
||||||
|
.unwrap_or(false);
|
||||||
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||||
|
|
||||||
// Try custom library path first:
|
// Try custom library path first:
|
||||||
@@ -172,10 +177,7 @@ mod ffmpeg {
|
|||||||
|
|
||||||
for lib in &libs {
|
for lib in &libs {
|
||||||
// Get cflags
|
// Get cflags
|
||||||
if let Ok(output) = Command::new("pkg-config")
|
if let Ok(output) = Command::new("pkg-config").args(["--cflags", lib]).output() {
|
||||||
.args(["--cflags", lib])
|
|
||||||
.output()
|
|
||||||
{
|
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
let cflags = String::from_utf8_lossy(&output.stdout);
|
let cflags = String::from_utf8_lossy(&output.stdout);
|
||||||
for flag in cflags.split_whitespace() {
|
for flag in cflags.split_whitespace() {
|
||||||
@@ -193,10 +195,7 @@ mod ffmpeg {
|
|||||||
vec!["--libs", lib]
|
vec!["--libs", lib]
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Ok(output) = Command::new("pkg-config")
|
if let Ok(output) = Command::new("pkg-config").args(&pkg_config_args).output() {
|
||||||
.args(&pkg_config_args)
|
|
||||||
.output()
|
|
||||||
{
|
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
let libs_str = String::from_utf8_lossy(&output.stdout);
|
let libs_str = String::from_utf8_lossy(&output.stdout);
|
||||||
for flag in libs_str.split_whitespace() {
|
for flag in libs_str.split_whitespace() {
|
||||||
@@ -221,7 +220,9 @@ mod ffmpeg {
|
|||||||
panic!("pkg-config failed for {}. Install FFmpeg development libraries: sudo apt install libavcodec-dev libavutil-dev", lib);
|
panic!("pkg-config failed for {}. Install FFmpeg development libraries: sudo apt install libavcodec-dev libavutil-dev", lib);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
panic!("pkg-config not found. Install pkg-config and FFmpeg development libraries.");
|
panic!(
|
||||||
|
"pkg-config not found. Install pkg-config and FFmpeg development libraries."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +302,10 @@ mod ffmpeg {
|
|||||||
// ARM (aarch64, arm): no X11 needed, uses RKMPP/V4L2
|
// ARM (aarch64, arm): no X11 needed, uses RKMPP/V4L2
|
||||||
v
|
v
|
||||||
} else {
|
} else {
|
||||||
panic!("Unsupported OS: {}. Only Windows and Linux are supported.", target_os);
|
panic!(
|
||||||
|
"Unsupported OS: {}. Only Windows and Linux are supported.",
|
||||||
|
target_os
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
for lib in dyn_libs.iter() {
|
for lib in dyn_libs.iter() {
|
||||||
@@ -312,10 +316,9 @@ mod ffmpeg {
|
|||||||
fn ffmpeg_ffi() {
|
fn ffmpeg_ffi() {
|
||||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
let ffmpeg_ram_dir = manifest_dir.join("cpp").join("common");
|
let ffmpeg_ram_dir = manifest_dir.join("cpp").join("common");
|
||||||
let ffi_header = ffmpeg_ram_dir
|
let ffi_header_path = ffmpeg_ram_dir.join("ffmpeg_ffi.h");
|
||||||
.join("ffmpeg_ffi.h")
|
println!("cargo:rerun-if-changed={}", ffi_header_path.display());
|
||||||
.to_string_lossy()
|
let ffi_header = ffi_header_path.to_string_lossy().to_string();
|
||||||
.to_string();
|
|
||||||
bindgen::builder()
|
bindgen::builder()
|
||||||
.header(ffi_header)
|
.header(ffi_header)
|
||||||
.rustified_enum("*")
|
.rustified_enum("*")
|
||||||
@@ -340,8 +343,6 @@ mod ffmpeg {
|
|||||||
.write_to_file(Path::new(&env::var_os("OUT_DIR").unwrap()).join("ffmpeg_ram_ffi.rs"))
|
.write_to_file(Path::new(&env::var_os("OUT_DIR").unwrap()).join("ffmpeg_ram_ffi.rs"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
builder.files(
|
builder.files(["ffmpeg_ram_encode.cpp"].map(|f| ffmpeg_ram_dir.join(f)));
|
||||||
["ffmpeg_ram_encode.cpp"].map(|f| ffmpeg_ram_dir.join(f)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,15 @@
|
|||||||
enum AVPixelFormat {
|
enum AVPixelFormat {
|
||||||
AV_PIX_FMT_YUV420P = 0,
|
AV_PIX_FMT_YUV420P = 0,
|
||||||
AV_PIX_FMT_YUYV422 = 1,
|
AV_PIX_FMT_YUYV422 = 1,
|
||||||
|
AV_PIX_FMT_RGB24 = 2,
|
||||||
|
AV_PIX_FMT_BGR24 = 3,
|
||||||
AV_PIX_FMT_YUV422P = 4, // planar YUV 4:2:2
|
AV_PIX_FMT_YUV422P = 4, // planar YUV 4:2:2
|
||||||
AV_PIX_FMT_YUVJ420P = 12, // JPEG full-range YUV420P (same layout as YUV420P)
|
AV_PIX_FMT_YUVJ420P = 12, // JPEG full-range YUV420P (same layout as YUV420P)
|
||||||
AV_PIX_FMT_YUVJ422P = 13, // JPEG full-range YUV422P (same layout as YUV422P)
|
AV_PIX_FMT_YUVJ422P = 13, // JPEG full-range YUV422P (same layout as YUV422P)
|
||||||
AV_PIX_FMT_NV12 = 23,
|
AV_PIX_FMT_NV12 = 23,
|
||||||
AV_PIX_FMT_NV21 = 24,
|
AV_PIX_FMT_NV21 = 24,
|
||||||
|
AV_PIX_FMT_NV16 = 101,
|
||||||
|
AV_PIX_FMT_NV24 = 188,
|
||||||
};
|
};
|
||||||
|
|
||||||
int av_log_get_level(void);
|
int av_log_get_level(void);
|
||||||
|
|||||||
@@ -388,7 +388,9 @@ private:
|
|||||||
}
|
}
|
||||||
_exit:
|
_exit:
|
||||||
av_packet_unref(pkt_);
|
av_packet_unref(pkt_);
|
||||||
return encoded ? 0 : -1;
|
// If no packet is produced for this input frame, treat it as EAGAIN.
|
||||||
|
// This is not a fatal error: encoders may buffer internally (e.g., startup delay).
|
||||||
|
return encoded ? 0 : AVERROR(EAGAIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
int fill_frame(AVFrame *frame, uint8_t *data, int data_length,
|
int fill_frame(AVFrame *frame, uint8_t *data, int data_length,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ use crate::{
|
|||||||
ffmpeg::{init_av_log, AVPixelFormat},
|
ffmpeg::{init_av_log, AVPixelFormat},
|
||||||
ffmpeg_ram::{
|
ffmpeg_ram::{
|
||||||
ffmpeg_linesize_offset_length, ffmpeg_ram_encode, ffmpeg_ram_free_encoder,
|
ffmpeg_linesize_offset_length, ffmpeg_ram_encode, ffmpeg_ram_free_encoder,
|
||||||
ffmpeg_ram_new_encoder, ffmpeg_ram_request_keyframe, ffmpeg_ram_set_bitrate, CodecInfo, AV_NUM_DATA_POINTERS,
|
ffmpeg_ram_new_encoder, ffmpeg_ram_request_keyframe, ffmpeg_ram_set_bitrate, CodecInfo,
|
||||||
|
AV_NUM_DATA_POINTERS,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use log::trace;
|
use log::trace;
|
||||||
@@ -123,6 +124,12 @@ impl Encoder {
|
|||||||
self.frames as *const _ as *const c_void,
|
self.frames as *const _ as *const c_void,
|
||||||
ms,
|
ms,
|
||||||
);
|
);
|
||||||
|
// ffmpeg_ram_encode returns AVERROR(EAGAIN) when the encoder accepts the frame
|
||||||
|
// but does not output a packet yet (e.g., startup delay / internal buffering).
|
||||||
|
// Treat this as a successful call with an empty output list.
|
||||||
|
if result == -11 {
|
||||||
|
return Ok(&mut *self.frames);
|
||||||
|
}
|
||||||
if result != 0 {
|
if result != 0 {
|
||||||
return Err(result);
|
return Err(result);
|
||||||
}
|
}
|
||||||
@@ -358,7 +365,8 @@ impl Encoder {
|
|||||||
if frames[0].key == 1 && elapsed < TEST_TIMEOUT_MS as _ {
|
if frames[0].key == 1 && elapsed < TEST_TIMEOUT_MS as _ {
|
||||||
debug!(
|
debug!(
|
||||||
"Encoder {} test passed on attempt {}",
|
"Encoder {} test passed on attempt {}",
|
||||||
codec.name, attempt + 1
|
codec.name,
|
||||||
|
attempt + 1
|
||||||
);
|
);
|
||||||
res.push(codec.clone());
|
res.push(codec.clone());
|
||||||
passed = true;
|
passed = true;
|
||||||
|
|||||||
@@ -75,11 +75,7 @@ struct ExfatBootSector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ExfatBootSector {
|
impl ExfatBootSector {
|
||||||
fn new(
|
fn new(volume_length: u64, cluster_size: u32, volume_serial: u32) -> Self {
|
||||||
volume_length: u64,
|
|
||||||
cluster_size: u32,
|
|
||||||
volume_serial: u32,
|
|
||||||
) -> Self {
|
|
||||||
let sector_size: u32 = 512;
|
let sector_size: u32 = 512;
|
||||||
let sectors_per_cluster = cluster_size / sector_size;
|
let sectors_per_cluster = cluster_size / sector_size;
|
||||||
let spc_shift = sectors_per_cluster_shift(cluster_size);
|
let spc_shift = sectors_per_cluster_shift(cluster_size);
|
||||||
@@ -106,7 +102,8 @@ impl ExfatBootSector {
|
|||||||
// Cluster 3...: Upcase table (128KB, may span multiple clusters)
|
// Cluster 3...: Upcase table (128KB, may span multiple clusters)
|
||||||
// Next available: Root directory
|
// Next available: Root directory
|
||||||
const UPCASE_TABLE_SIZE: u64 = 128 * 1024;
|
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 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 = 3 + upcase_clusters;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -306,7 +303,8 @@ pub fn format_exfat<W: Write + Seek>(
|
|||||||
|
|
||||||
// Calculate how many clusters the upcase table needs (128KB)
|
// Calculate how many clusters the upcase table needs (128KB)
|
||||||
const UPCASE_TABLE_SIZE: u64 = 128 * 1024;
|
const UPCASE_TABLE_SIZE: u64 = 128 * 1024;
|
||||||
let upcase_clusters = ((UPCASE_TABLE_SIZE + cluster_size as u64 - 1) / cluster_size as u64) as u32;
|
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 root_cluster = 3 + upcase_clusters; // Root comes after bitmap and upcase
|
||||||
|
|
||||||
// FAT entries: cluster 0 and 1 are reserved
|
// FAT entries: cluster 0 and 1 are reserved
|
||||||
@@ -349,7 +347,8 @@ pub fn format_exfat<W: Write + Seek>(
|
|||||||
|
|
||||||
// Cluster 2: Allocation Bitmap
|
// Cluster 2: Allocation Bitmap
|
||||||
let bitmap_size = (boot_sector.cluster_count + 7) / 8;
|
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 _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];
|
let mut bitmap = vec![0u8; cluster_size as usize];
|
||||||
|
|
||||||
// Mark clusters 2, 3..3+upcase_clusters-1, root_cluster as used
|
// Mark clusters 2, 3..3+upcase_clusters-1, root_cluster as used
|
||||||
|
|||||||
@@ -53,8 +53,7 @@ impl FatCache {
|
|||||||
if self.entries.is_empty() {
|
if self.entries.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
cluster >= self.start_cluster
|
cluster >= self.start_cluster && cluster < self.start_cluster + self.entries.len() as u32
|
||||||
&& cluster < self.start_cluster + self.entries.len() as u32
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a FAT entry from cache (if present)
|
/// Get a FAT entry from cache (if present)
|
||||||
@@ -243,7 +242,9 @@ impl ExfatFs {
|
|||||||
|
|
||||||
/// Get the byte offset of a FAT entry
|
/// Get the byte offset of a FAT entry
|
||||||
fn fat_entry_offset(&self, cluster: u32) -> u64 {
|
fn fat_entry_offset(&self, cluster: u32) -> u64 {
|
||||||
self.partition_offset + self.fat_offset as u64 * self.bytes_per_sector as u64 + cluster as u64 * 4
|
self.partition_offset
|
||||||
|
+ self.fat_offset as u64 * self.bytes_per_sector as u64
|
||||||
|
+ cluster as u64 * 4
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a FAT segment into cache starting from the given cluster
|
/// Load a FAT segment into cache starting from the given cluster
|
||||||
@@ -287,7 +288,10 @@ impl ExfatFs {
|
|||||||
|
|
||||||
// Should be in cache now
|
// Should be in cache now
|
||||||
self.fat_cache.get(cluster).ok_or_else(|| {
|
self.fat_cache.get(cluster).ok_or_else(|| {
|
||||||
VentoyError::FilesystemError(format!("Failed to cache FAT entry for cluster {}", cluster))
|
VentoyError::FilesystemError(format!(
|
||||||
|
"Failed to cache FAT entry for cluster {}",
|
||||||
|
cluster
|
||||||
|
))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,9 +494,9 @@ impl ExfatFs {
|
|||||||
fn extend_cluster_chain(&mut self, first_cluster: u32) -> Result<u32> {
|
fn extend_cluster_chain(&mut self, first_cluster: u32) -> Result<u32> {
|
||||||
// Find the last cluster in the chain
|
// Find the last cluster in the chain
|
||||||
let chain = self.read_cluster_chain(first_cluster)?;
|
let chain = self.read_cluster_chain(first_cluster)?;
|
||||||
let last_cluster = *chain.last().ok_or_else(|| {
|
let last_cluster = *chain
|
||||||
VentoyError::FilesystemError("Empty cluster chain".to_string())
|
.last()
|
||||||
})?;
|
.ok_or_else(|| VentoyError::FilesystemError("Empty cluster chain".to_string()))?;
|
||||||
|
|
||||||
// Allocate one new cluster
|
// Allocate one new cluster
|
||||||
let new_cluster = self.allocate_clusters(1)?;
|
let new_cluster = self.allocate_clusters(1)?;
|
||||||
@@ -532,7 +536,12 @@ impl ExfatFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create file directory entries for a new file
|
/// Create file directory entries for a new file
|
||||||
fn create_file_entries(name: &str, first_cluster: u32, size: u64, is_dir: bool) -> Vec<[u8; 32]> {
|
fn create_file_entries(
|
||||||
|
name: &str,
|
||||||
|
first_cluster: u32,
|
||||||
|
size: u64,
|
||||||
|
is_dir: bool,
|
||||||
|
) -> Vec<[u8; 32]> {
|
||||||
let name_utf16: Vec<u16> = name.encode_utf16().collect();
|
let name_utf16: Vec<u16> = name.encode_utf16().collect();
|
||||||
let name_entries_needed = (name_utf16.len() + 14) / 15; // 15 chars per name entry
|
let name_entries_needed = (name_utf16.len() + 14) / 15; // 15 chars per name entry
|
||||||
let secondary_count = 1 + name_entries_needed; // Stream + Name entries
|
let secondary_count = 1 + name_entries_needed; // Stream + Name entries
|
||||||
@@ -552,11 +561,15 @@ impl ExfatFs {
|
|||||||
.map(|d| d.as_secs() as u32)
|
.map(|d| d.as_secs() as u32)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
// DOS timestamp format (simplified)
|
// DOS timestamp format (simplified)
|
||||||
let dos_time = ((now / 2) & 0x1F) | (((now / 60) & 0x3F) << 5) | (((now / 3600) & 0x1F) << 11);
|
let dos_time =
|
||||||
|
((now / 2) & 0x1F) | (((now / 60) & 0x3F) << 5) | (((now / 3600) & 0x1F) << 11);
|
||||||
let dos_date = 1 | (1 << 5) | ((45) << 9); // Jan 1, 2025
|
let dos_date = 1 | (1 << 5) | ((45) << 9); // Jan 1, 2025
|
||||||
file_entry[8..12].copy_from_slice(&(dos_date as u32 | ((dos_time as u32) << 16)).to_le_bytes());
|
file_entry[8..12]
|
||||||
file_entry[12..16].copy_from_slice(&(dos_date as u32 | ((dos_time as u32) << 16)).to_le_bytes());
|
.copy_from_slice(&(dos_date as u32 | ((dos_time as u32) << 16)).to_le_bytes());
|
||||||
file_entry[16..20].copy_from_slice(&(dos_date as u32 | ((dos_time as u32) << 16)).to_le_bytes());
|
file_entry[12..16]
|
||||||
|
.copy_from_slice(&(dos_date as u32 | ((dos_time as u32) << 16)).to_le_bytes());
|
||||||
|
file_entry[16..20]
|
||||||
|
.copy_from_slice(&(dos_date as u32 | ((dos_time as u32) << 16)).to_le_bytes());
|
||||||
entries.push(file_entry);
|
entries.push(file_entry);
|
||||||
|
|
||||||
// 2. Stream Extension Entry (0xC0)
|
// 2. Stream Extension Entry (0xC0)
|
||||||
@@ -588,7 +601,8 @@ impl ExfatFs {
|
|||||||
for i in 0..15 {
|
for i in 0..15 {
|
||||||
if char_index < name_utf16.len() {
|
if char_index < name_utf16.len() {
|
||||||
let offset = 2 + i * 2;
|
let offset = 2 + i * 2;
|
||||||
name_entry[offset..offset + 2].copy_from_slice(&name_utf16[char_index].to_le_bytes());
|
name_entry[offset..offset + 2]
|
||||||
|
.copy_from_slice(&name_utf16[char_index].to_le_bytes());
|
||||||
char_index += 1;
|
char_index += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -603,7 +617,11 @@ impl ExfatFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find a file entry in a specific directory cluster
|
/// Find a file entry in a specific directory cluster
|
||||||
fn find_entry_in_directory(&mut self, dir_cluster: u32, name: &str) -> Result<Option<FileEntryLocation>> {
|
fn find_entry_in_directory(
|
||||||
|
&mut self,
|
||||||
|
dir_cluster: u32,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<FileEntryLocation>> {
|
||||||
let target_name_lower = name.to_lowercase();
|
let target_name_lower = name.to_lowercase();
|
||||||
|
|
||||||
// Read all clusters in the directory chain
|
// Read all clusters in the directory chain
|
||||||
@@ -747,7 +765,11 @@ impl ExfatFs {
|
|||||||
///
|
///
|
||||||
/// If no free slot is found in existing clusters, this method will
|
/// If no free slot is found in existing clusters, this method will
|
||||||
/// automatically extend the directory by allocating a new cluster.
|
/// automatically extend the directory by allocating a new cluster.
|
||||||
fn find_free_slot_in_directory(&mut self, dir_cluster: u32, entries_needed: usize) -> Result<(u32, u32)> {
|
fn find_free_slot_in_directory(
|
||||||
|
&mut self,
|
||||||
|
dir_cluster: u32,
|
||||||
|
entries_needed: usize,
|
||||||
|
) -> Result<(u32, u32)> {
|
||||||
let dir_clusters = self.read_cluster_chain(dir_cluster)?;
|
let dir_clusters = self.read_cluster_chain(dir_cluster)?;
|
||||||
|
|
||||||
for &cluster in &dir_clusters {
|
for &cluster in &dir_clusters {
|
||||||
@@ -759,10 +781,12 @@ impl ExfatFs {
|
|||||||
while i < cluster_data.len() {
|
while i < cluster_data.len() {
|
||||||
let entry_type = cluster_data[i];
|
let entry_type = cluster_data[i];
|
||||||
|
|
||||||
if entry_type == ENTRY_TYPE_END || entry_type == 0x00
|
if entry_type == ENTRY_TYPE_END
|
||||||
|
|| entry_type == 0x00
|
||||||
|| entry_type == ENTRY_TYPE_DELETED_FILE
|
|| entry_type == ENTRY_TYPE_DELETED_FILE
|
||||||
|| entry_type == ENTRY_TYPE_DELETED_STREAM
|
|| entry_type == ENTRY_TYPE_DELETED_STREAM
|
||||||
|| entry_type == ENTRY_TYPE_DELETED_NAME {
|
|| entry_type == ENTRY_TYPE_DELETED_NAME
|
||||||
|
{
|
||||||
if consecutive_free == 0 {
|
if consecutive_free == 0 {
|
||||||
slot_start = i;
|
slot_start = i;
|
||||||
}
|
}
|
||||||
@@ -795,7 +819,8 @@ impl ExfatFs {
|
|||||||
// This is critical: when we extend a directory, we need to clear any END markers
|
// This is critical: when we extend a directory, we need to clear any END markers
|
||||||
// that may exist in previous clusters, otherwise list_files will stop prematurely
|
// that may exist in previous clusters, otherwise list_files will stop prematurely
|
||||||
let dir_clusters_before = self.read_cluster_chain(dir_cluster)?;
|
let dir_clusters_before = self.read_cluster_chain(dir_cluster)?;
|
||||||
for &cluster in &dir_clusters_before[..dir_clusters_before.len()-1] { // Exclude the newly added cluster
|
for &cluster in &dir_clusters_before[..dir_clusters_before.len() - 1] {
|
||||||
|
// Exclude the newly added cluster
|
||||||
let mut cluster_data = self.read_cluster(cluster)?;
|
let mut cluster_data = self.read_cluster(cluster)?;
|
||||||
|
|
||||||
// Scan for END markers and replace them with 0xFF (invalid entry, will be skipped)
|
// Scan for END markers and replace them with 0xFF (invalid entry, will be skipped)
|
||||||
@@ -815,14 +840,23 @@ impl ExfatFs {
|
|||||||
/// Find a free slot in the root directory for new entries (backward compatible)
|
/// Find a free slot in the root directory for new entries (backward compatible)
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn find_free_directory_slot(&mut self, entries_needed: usize) -> Result<u32> {
|
fn find_free_directory_slot(&mut self, entries_needed: usize) -> Result<u32> {
|
||||||
let (_, offset) = self.find_free_slot_in_directory(self.first_cluster_of_root, entries_needed)?;
|
let (_, offset) =
|
||||||
|
self.find_free_slot_in_directory(self.first_cluster_of_root, entries_needed)?;
|
||||||
Ok(offset)
|
Ok(offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an entry in a specific directory
|
/// Create an entry in a specific directory
|
||||||
fn create_entry_in_directory(&mut self, dir_cluster: u32, name: &str, first_cluster: u32, size: u64, is_dir: bool) -> Result<()> {
|
fn create_entry_in_directory(
|
||||||
|
&mut self,
|
||||||
|
dir_cluster: u32,
|
||||||
|
name: &str,
|
||||||
|
first_cluster: u32,
|
||||||
|
size: u64,
|
||||||
|
is_dir: bool,
|
||||||
|
) -> Result<()> {
|
||||||
let entries = Self::create_file_entries(name, first_cluster, size, is_dir);
|
let entries = Self::create_file_entries(name, first_cluster, size, is_dir);
|
||||||
let (slot_cluster, slot_offset) = self.find_free_slot_in_directory(dir_cluster, entries.len())?;
|
let (slot_cluster, slot_offset) =
|
||||||
|
self.find_free_slot_in_directory(dir_cluster, entries.len())?;
|
||||||
|
|
||||||
let mut cluster_data = self.read_cluster(slot_cluster)?;
|
let mut cluster_data = self.read_cluster(slot_cluster)?;
|
||||||
|
|
||||||
@@ -854,7 +888,10 @@ impl ExfatFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if already exists
|
// Check if already exists
|
||||||
if self.find_entry_in_directory(parent_cluster, name)?.is_some() {
|
if self
|
||||||
|
.find_entry_in_directory(parent_cluster, name)?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
return Err(VentoyError::FilesystemError(format!(
|
return Err(VentoyError::FilesystemError(format!(
|
||||||
"Entry '{}' already exists",
|
"Entry '{}' already exists",
|
||||||
name
|
name
|
||||||
@@ -903,7 +940,11 @@ impl ExfatFs {
|
|||||||
// ==================== Public File Operations ====================
|
// ==================== Public File Operations ====================
|
||||||
|
|
||||||
/// List files in a specific directory cluster
|
/// List files in a specific directory cluster
|
||||||
fn list_files_in_directory(&mut self, dir_cluster: u32, current_path: &str) -> Result<Vec<FileInfo>> {
|
fn list_files_in_directory(
|
||||||
|
&mut self,
|
||||||
|
dir_cluster: u32,
|
||||||
|
current_path: &str,
|
||||||
|
) -> Result<Vec<FileInfo>> {
|
||||||
let dir_clusters = self.read_cluster_chain(dir_cluster)?;
|
let dir_clusters = self.read_cluster_chain(dir_cluster)?;
|
||||||
|
|
||||||
// Pre-allocate Vec based on estimated entries
|
// Pre-allocate Vec based on estimated entries
|
||||||
@@ -1038,7 +1079,12 @@ impl ExfatFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Write file data to allocated clusters and create directory entry
|
/// Write file data to allocated clusters and create directory entry
|
||||||
fn write_file_data_and_entry(&mut self, dir_cluster: u32, name: &str, data: &[u8]) -> Result<()> {
|
fn write_file_data_and_entry(
|
||||||
|
&mut self,
|
||||||
|
dir_cluster: u32,
|
||||||
|
name: &str,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
// Calculate clusters needed
|
// Calculate clusters needed
|
||||||
let clusters_needed = if data.is_empty() {
|
let clusters_needed = if data.is_empty() {
|
||||||
0
|
0
|
||||||
@@ -1121,7 +1167,13 @@ impl ExfatFs {
|
|||||||
/// Path can include directories, e.g., "iso/linux/ubuntu.iso"
|
/// Path can include directories, e.g., "iso/linux/ubuntu.iso"
|
||||||
/// If create_parents is true, intermediate directories will be created.
|
/// If create_parents is true, intermediate directories will be created.
|
||||||
/// If overwrite is true, existing files will be replaced.
|
/// If overwrite is true, existing files will be replaced.
|
||||||
pub fn write_file_path(&mut self, path: &str, data: &[u8], create_parents: bool, overwrite: bool) -> Result<()> {
|
pub fn write_file_path(
|
||||||
|
&mut self,
|
||||||
|
path: &str,
|
||||||
|
data: &[u8],
|
||||||
|
create_parents: bool,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> Result<()> {
|
||||||
let resolved = self.resolve_path(path, create_parents)?;
|
let resolved = self.resolve_path(path, create_parents)?;
|
||||||
|
|
||||||
// Validate filename
|
// Validate filename
|
||||||
@@ -1177,9 +1229,9 @@ impl ExfatFs {
|
|||||||
|
|
||||||
/// Read a file from the filesystem (root directory)
|
/// Read a file from the filesystem (root directory)
|
||||||
pub fn read_file(&mut self, name: &str) -> Result<Vec<u8>> {
|
pub fn read_file(&mut self, name: &str) -> Result<Vec<u8>> {
|
||||||
let location = self.find_file_entry(name)?.ok_or_else(|| {
|
let location = self
|
||||||
VentoyError::FilesystemError(format!("File '{}' not found", name))
|
.find_file_entry(name)?
|
||||||
})?;
|
.ok_or_else(|| VentoyError::FilesystemError(format!("File '{}' not found", name)))?;
|
||||||
|
|
||||||
self.read_file_from_location(&location)
|
self.read_file_from_location(&location)
|
||||||
}
|
}
|
||||||
@@ -1224,9 +1276,9 @@ impl ExfatFs {
|
|||||||
|
|
||||||
/// Delete a file from the filesystem (root directory)
|
/// Delete a file from the filesystem (root directory)
|
||||||
pub fn delete_file(&mut self, name: &str) -> Result<()> {
|
pub fn delete_file(&mut self, name: &str) -> Result<()> {
|
||||||
let location = self.find_file_entry(name)?.ok_or_else(|| {
|
let location = self
|
||||||
VentoyError::FilesystemError(format!("File '{}' not found", name))
|
.find_file_entry(name)?
|
||||||
})?;
|
.ok_or_else(|| VentoyError::FilesystemError(format!("File '{}' not found", name)))?;
|
||||||
|
|
||||||
// Free cluster chain
|
// Free cluster chain
|
||||||
if location.first_cluster >= 2 {
|
if location.first_cluster >= 2 {
|
||||||
@@ -1244,9 +1296,9 @@ impl ExfatFs {
|
|||||||
pub fn delete_path(&mut self, path: &str) -> Result<()> {
|
pub fn delete_path(&mut self, path: &str) -> Result<()> {
|
||||||
let resolved = self.resolve_path(path, false)?;
|
let resolved = self.resolve_path(path, false)?;
|
||||||
|
|
||||||
let location = resolved.location.ok_or_else(|| {
|
let location = resolved
|
||||||
VentoyError::FilesystemError(format!("'{}' not found", path))
|
.location
|
||||||
})?;
|
.ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?;
|
||||||
|
|
||||||
// If it's a directory, check if it's empty
|
// If it's a directory, check if it's empty
|
||||||
if location.is_directory {
|
if location.is_directory {
|
||||||
@@ -1275,9 +1327,9 @@ impl ExfatFs {
|
|||||||
pub fn delete_recursive(&mut self, path: &str) -> Result<()> {
|
pub fn delete_recursive(&mut self, path: &str) -> Result<()> {
|
||||||
let resolved = self.resolve_path(path, false)?;
|
let resolved = self.resolve_path(path, false)?;
|
||||||
|
|
||||||
let location = resolved.location.ok_or_else(|| {
|
let location = resolved
|
||||||
VentoyError::FilesystemError(format!("'{}' not found", path))
|
.location
|
||||||
})?;
|
.ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?;
|
||||||
|
|
||||||
if location.is_directory {
|
if location.is_directory {
|
||||||
// Get all contents and delete them first
|
// Get all contents and delete them first
|
||||||
@@ -1344,7 +1396,12 @@ impl<'a> ExfatFileWriter<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new file writer with overwrite option
|
/// Create a new file writer with overwrite option
|
||||||
pub fn create_overwrite(fs: &'a mut ExfatFs, name: &str, total_size: u64, overwrite: bool) -> Result<Self> {
|
pub fn create_overwrite(
|
||||||
|
fs: &'a mut ExfatFs,
|
||||||
|
name: &str,
|
||||||
|
total_size: u64,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> Result<Self> {
|
||||||
let root_cluster = fs.first_cluster_of_root;
|
let root_cluster = fs.first_cluster_of_root;
|
||||||
Self::create_in_directory(fs, root_cluster, name, total_size, overwrite)
|
Self::create_in_directory(fs, root_cluster, name, total_size, overwrite)
|
||||||
}
|
}
|
||||||
@@ -1353,7 +1410,13 @@ impl<'a> ExfatFileWriter<'a> {
|
|||||||
///
|
///
|
||||||
/// If create_parents is true, intermediate directories will be created.
|
/// If create_parents is true, intermediate directories will be created.
|
||||||
/// If overwrite is true, existing files will be replaced.
|
/// If overwrite is true, existing files will be replaced.
|
||||||
pub fn create_at_path(fs: &'a mut ExfatFs, path: &str, total_size: u64, create_parents: bool, overwrite: bool) -> Result<Self> {
|
pub fn create_at_path(
|
||||||
|
fs: &'a mut ExfatFs,
|
||||||
|
path: &str,
|
||||||
|
total_size: u64,
|
||||||
|
create_parents: bool,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> Result<Self> {
|
||||||
let resolved = fs.resolve_path(path, create_parents)?;
|
let resolved = fs.resolve_path(path, create_parents)?;
|
||||||
|
|
||||||
// Handle existing file
|
// Handle existing file
|
||||||
@@ -1378,11 +1441,23 @@ impl<'a> ExfatFileWriter<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Self::create_in_directory(fs, resolved.parent_cluster, &resolved.name, total_size, false)
|
Self::create_in_directory(
|
||||||
|
fs,
|
||||||
|
resolved.parent_cluster,
|
||||||
|
&resolved.name,
|
||||||
|
total_size,
|
||||||
|
false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal: Create a file writer in a specific directory
|
/// Internal: Create a file writer in a specific directory
|
||||||
fn create_in_directory(fs: &'a mut ExfatFs, dir_cluster: u32, name: &str, total_size: u64, overwrite: bool) -> Result<Self> {
|
fn create_in_directory(
|
||||||
|
fs: &'a mut ExfatFs,
|
||||||
|
dir_cluster: u32,
|
||||||
|
name: &str,
|
||||||
|
total_size: u64,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> Result<Self> {
|
||||||
// Validate filename
|
// Validate filename
|
||||||
if name.is_empty() || name.len() > 255 {
|
if name.is_empty() || name.len() > 255 {
|
||||||
return Err(VentoyError::FilesystemError(
|
return Err(VentoyError::FilesystemError(
|
||||||
@@ -1473,7 +1548,9 @@ impl<'a> ExfatFileWriter<'a> {
|
|||||||
/// This must be called after all data has been written.
|
/// This must be called after all data has been written.
|
||||||
pub fn finish(self) -> Result<()> {
|
pub fn finish(self) -> Result<()> {
|
||||||
// Write any remaining data in buffer
|
// Write any remaining data in buffer
|
||||||
if !self.cluster_buffer.is_empty() && self.current_cluster_index < self.allocated_clusters.len() {
|
if !self.cluster_buffer.is_empty()
|
||||||
|
&& self.current_cluster_index < self.allocated_clusters.len()
|
||||||
|
{
|
||||||
let cluster = self.allocated_clusters[self.current_cluster_index];
|
let cluster = self.allocated_clusters[self.current_cluster_index];
|
||||||
self.fs.write_cluster(cluster, &self.cluster_buffer)?;
|
self.fs.write_cluster(cluster, &self.cluster_buffer)?;
|
||||||
}
|
}
|
||||||
@@ -1485,7 +1562,13 @@ impl<'a> ExfatFileWriter<'a> {
|
|||||||
self.allocated_clusters[0]
|
self.allocated_clusters[0]
|
||||||
};
|
};
|
||||||
|
|
||||||
self.fs.create_entry_in_directory(self.dir_cluster, &self.name, first_cluster, self.total_size, false)?;
|
self.fs.create_entry_in_directory(
|
||||||
|
self.dir_cluster,
|
||||||
|
&self.name,
|
||||||
|
first_cluster,
|
||||||
|
self.total_size,
|
||||||
|
false,
|
||||||
|
)?;
|
||||||
self.fs.file.flush()?;
|
self.fs.file.flush()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1517,9 +1600,9 @@ pub struct ExfatFileReader<'a> {
|
|||||||
impl<'a> ExfatFileReader<'a> {
|
impl<'a> ExfatFileReader<'a> {
|
||||||
/// Open a file for reading from root directory
|
/// Open a file for reading from root directory
|
||||||
pub fn open(fs: &'a mut ExfatFs, name: &str) -> Result<Self> {
|
pub fn open(fs: &'a mut ExfatFs, name: &str) -> Result<Self> {
|
||||||
let location = fs.find_file_entry(name)?.ok_or_else(|| {
|
let location = fs
|
||||||
VentoyError::FilesystemError(format!("File '{}' not found", name))
|
.find_file_entry(name)?
|
||||||
})?;
|
.ok_or_else(|| VentoyError::FilesystemError(format!("File '{}' not found", name)))?;
|
||||||
|
|
||||||
if location.is_directory {
|
if location.is_directory {
|
||||||
return Err(VentoyError::FilesystemError(format!(
|
return Err(VentoyError::FilesystemError(format!(
|
||||||
@@ -1635,7 +1718,8 @@ impl<'a> Read for ExfatFileReader<'a> {
|
|||||||
|
|
||||||
// Read the cluster and copy data
|
// Read the cluster and copy data
|
||||||
{
|
{
|
||||||
let cluster_data = self.read_cluster_cached(cluster_index)
|
let cluster_data = self
|
||||||
|
.read_cluster_cached(cluster_index)
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
||||||
|
|
||||||
// Copy data to buffer
|
// Copy data to buffer
|
||||||
@@ -1676,11 +1760,7 @@ impl ExfatFs {
|
|||||||
/// Read a file to a writer (streaming)
|
/// Read a file to a writer (streaming)
|
||||||
///
|
///
|
||||||
/// This is useful for reading large files without loading them into memory.
|
/// This is useful for reading large files without loading them into memory.
|
||||||
pub fn read_file_to_writer<W: Write>(
|
pub fn read_file_to_writer<W: Write>(&mut self, name: &str, writer: &mut W) -> Result<u64> {
|
||||||
&mut self,
|
|
||||||
name: &str,
|
|
||||||
writer: &mut W,
|
|
||||||
) -> Result<u64> {
|
|
||||||
let mut reader = ExfatFileReader::open(self, name)?;
|
let mut reader = ExfatFileReader::open(self, name)?;
|
||||||
Self::do_stream_read(&mut reader, writer)
|
Self::do_stream_read(&mut reader, writer)
|
||||||
}
|
}
|
||||||
@@ -1701,13 +1781,13 @@ impl ExfatFs {
|
|||||||
let mut total_bytes = 0u64;
|
let mut total_bytes = 0u64;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let bytes_read = reader.read(&mut buffer).map_err(|e| {
|
let bytes_read = reader.read(&mut buffer).map_err(|e| VentoyError::Io(e))?;
|
||||||
VentoyError::Io(e)
|
|
||||||
})?;
|
|
||||||
if bytes_read == 0 {
|
if bytes_read == 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
writer.write_all(&buffer[..bytes_read]).map_err(VentoyError::Io)?;
|
writer
|
||||||
|
.write_all(&buffer[..bytes_read])
|
||||||
|
.map_err(VentoyError::Io)?;
|
||||||
total_bytes += bytes_read as u64;
|
total_bytes += bytes_read as u64;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1755,7 +1835,8 @@ impl ExfatFs {
|
|||||||
create_parents: bool,
|
create_parents: bool,
|
||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut writer = ExfatFileWriter::create_at_path(self, path, size, create_parents, overwrite)?;
|
let mut writer =
|
||||||
|
ExfatFileWriter::create_at_path(self, path, size, create_parents, overwrite)?;
|
||||||
Self::do_stream_write(&mut writer, reader)?;
|
Self::do_stream_write(&mut writer, reader)?;
|
||||||
writer.finish()
|
writer.finish()
|
||||||
}
|
}
|
||||||
@@ -1804,7 +1885,12 @@ mod tests {
|
|||||||
file.set_len(size).unwrap();
|
file.set_len(size).unwrap();
|
||||||
|
|
||||||
// Format data partition (this will use 4KB clusters for 64MB volume)
|
// Format data partition (this will use 4KB clusters for 64MB volume)
|
||||||
crate::exfat::format::format_exfat(&mut file, layout.data_offset(), layout.data_size(), "TEST")
|
crate::exfat::format::format_exfat(
|
||||||
|
&mut file,
|
||||||
|
layout.data_offset(),
|
||||||
|
layout.data_size(),
|
||||||
|
"TEST",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
drop(file);
|
drop(file);
|
||||||
@@ -1826,21 +1912,12 @@ mod tests {
|
|||||||
let data = format!("content {}", i);
|
let data = format!("content {}", i);
|
||||||
let mut cursor = Cursor::new(data.as_bytes());
|
let mut cursor = Cursor::new(data.as_bytes());
|
||||||
|
|
||||||
fs.write_file_from_reader(
|
fs.write_file_from_reader(&filename, &mut cursor, data.len() as u64)?;
|
||||||
&filename,
|
|
||||||
&mut cursor,
|
|
||||||
data.len() as u64,
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify all files were created
|
// Verify all files were created
|
||||||
let files = fs.list_files().unwrap();
|
let files = fs.list_files().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(files.len(), 50, "Expected 50 files, found {}", files.len());
|
||||||
files.len(),
|
|
||||||
50,
|
|
||||||
"Expected 50 files, found {}",
|
|
||||||
files.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify we can read all files back
|
// Verify we can read all files back
|
||||||
for i in 0..50 {
|
for i in 0..50 {
|
||||||
@@ -1882,7 +1959,12 @@ mod tests {
|
|||||||
file.set_len(size).unwrap();
|
file.set_len(size).unwrap();
|
||||||
|
|
||||||
// Format data partition
|
// Format data partition
|
||||||
crate::exfat::format::format_exfat(&mut file, layout.data_offset(), layout.data_size(), "TEST")
|
crate::exfat::format::format_exfat(
|
||||||
|
&mut file,
|
||||||
|
layout.data_offset(),
|
||||||
|
layout.data_size(),
|
||||||
|
"TEST",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
drop(file);
|
drop(file);
|
||||||
@@ -1903,7 +1985,8 @@ mod tests {
|
|||||||
assert_eq!(reader.position(), 0);
|
assert_eq!(reader.position(), 0);
|
||||||
|
|
||||||
let mut read_data = Vec::new();
|
let mut read_data = Vec::new();
|
||||||
let bytes_read = reader.read_to_end(&mut read_data)
|
let bytes_read = reader
|
||||||
|
.read_to_end(&mut read_data)
|
||||||
.map_err(|e| VentoyError::Io(e))?;
|
.map_err(|e| VentoyError::Io(e))?;
|
||||||
|
|
||||||
assert_eq!(bytes_read, test_data.len());
|
assert_eq!(bytes_read, test_data.len());
|
||||||
@@ -1932,22 +2015,32 @@ mod tests {
|
|||||||
let mut reader = ExfatFileReader::open(&mut fs, "large_file.bin")?;
|
let mut reader = ExfatFileReader::open(&mut fs, "large_file.bin")?;
|
||||||
|
|
||||||
// Seek to middle
|
// Seek to middle
|
||||||
reader.seek(SeekFrom::Start(10000)).map_err(|e| VentoyError::Io(e))?;
|
reader
|
||||||
|
.seek(SeekFrom::Start(10000))
|
||||||
|
.map_err(|e| VentoyError::Io(e))?;
|
||||||
assert_eq!(reader.position(), 10000);
|
assert_eq!(reader.position(), 10000);
|
||||||
|
|
||||||
let mut buffer = [0u8; 10];
|
let mut buffer = [0u8; 10];
|
||||||
reader.read_exact(&mut buffer).map_err(|e| VentoyError::Io(e))?;
|
reader
|
||||||
|
.read_exact(&mut buffer)
|
||||||
|
.map_err(|e| VentoyError::Io(e))?;
|
||||||
assert_eq!(&buffer, &test_data[10000..10010]);
|
assert_eq!(&buffer, &test_data[10000..10010]);
|
||||||
|
|
||||||
// Seek from current position
|
// Seek from current position
|
||||||
reader.seek(SeekFrom::Current(-5)).map_err(|e| VentoyError::Io(e))?;
|
reader
|
||||||
|
.seek(SeekFrom::Current(-5))
|
||||||
|
.map_err(|e| VentoyError::Io(e))?;
|
||||||
assert_eq!(reader.position(), 10005);
|
assert_eq!(reader.position(), 10005);
|
||||||
|
|
||||||
// Seek from end
|
// Seek from end
|
||||||
reader.seek(SeekFrom::End(-100)).map_err(|e| VentoyError::Io(e))?;
|
reader
|
||||||
|
.seek(SeekFrom::End(-100))
|
||||||
|
.map_err(|e| VentoyError::Io(e))?;
|
||||||
assert_eq!(reader.position(), test_data.len() as u64 - 100);
|
assert_eq!(reader.position(), test_data.len() as u64 - 100);
|
||||||
|
|
||||||
reader.read_exact(&mut buffer).map_err(|e| VentoyError::Io(e))?;
|
reader
|
||||||
|
.read_exact(&mut buffer)
|
||||||
|
.map_err(|e| VentoyError::Io(e))?;
|
||||||
let expected_start = test_data.len() - 100;
|
let expected_start = test_data.len() - 100;
|
||||||
assert_eq!(&buffer, &test_data[expected_start..expected_start + 10]);
|
assert_eq!(&buffer, &test_data[expected_start..expected_start + 10]);
|
||||||
}
|
}
|
||||||
@@ -1981,7 +2074,12 @@ mod tests {
|
|||||||
file.set_len(size).unwrap();
|
file.set_len(size).unwrap();
|
||||||
|
|
||||||
// Format data partition
|
// Format data partition
|
||||||
crate::exfat::format::format_exfat(&mut file, layout.data_offset(), layout.data_size(), "TEST")
|
crate::exfat::format::format_exfat(
|
||||||
|
&mut file,
|
||||||
|
layout.data_offset(),
|
||||||
|
layout.data_size(),
|
||||||
|
"TEST",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
drop(file);
|
drop(file);
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ impl VentoyImage {
|
|||||||
let size = parse_size(size_str)?;
|
let size = parse_size(size_str)?;
|
||||||
let layout = PartitionLayout::calculate(size)?;
|
let layout = PartitionLayout::calculate(size)?;
|
||||||
|
|
||||||
println!("[INFO] Creating {}MB image: {}", size / (1024 * 1024), path.display());
|
println!(
|
||||||
|
"[INFO] Creating {}MB image: {}",
|
||||||
|
size / (1024 * 1024),
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
|
||||||
// Create sparse file
|
// Create sparse file
|
||||||
let mut file = File::create(path)?;
|
let mut file = File::create(path)?;
|
||||||
@@ -247,7 +251,11 @@ impl VentoyImage {
|
|||||||
///
|
///
|
||||||
/// This is the preferred method for large files as it doesn't load
|
/// This is the preferred method for large files as it doesn't load
|
||||||
/// the entire file into memory.
|
/// the entire file into memory.
|
||||||
pub fn read_file_to_writer<W: std::io::Write>(&self, path: &str, writer: &mut W) -> Result<u64> {
|
pub fn read_file_to_writer<W: std::io::Write>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
writer: &mut W,
|
||||||
|
) -> Result<u64> {
|
||||||
let mut fs = ExfatFs::open(&self.path, &self.layout)?;
|
let mut fs = ExfatFs::open(&self.path, &self.layout)?;
|
||||||
fs.read_file_path_to_writer(path, writer)
|
fs.read_file_path_to_writer(path, writer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,4 +45,4 @@ pub use error::{Result, VentoyError};
|
|||||||
pub use exfat::FileInfo;
|
pub use exfat::FileInfo;
|
||||||
pub use image::VentoyImage;
|
pub use image::VentoyImage;
|
||||||
pub use partition::{parse_size, PartitionLayout};
|
pub use partition::{parse_size, PartitionLayout};
|
||||||
pub use resources::{init_resources, get_resource_dir, is_initialized, required_files};
|
pub use resources::{get_resource_dir, init_resources, is_initialized, required_files};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use clap::{Parser, Subcommand};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use ventoy_img::{VentoyImage, Result, VentoyError};
|
use ventoy_img::{Result, VentoyError, VentoyImage};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "ventoy-img")]
|
#[command(name = "ventoy-img")]
|
||||||
@@ -103,11 +103,33 @@ fn main() -> ExitCode {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
let result = match cli.command {
|
let result = match cli.command {
|
||||||
Commands::Create { size, output, label } => cmd_create(&output, &size, &label),
|
Commands::Create {
|
||||||
Commands::Add { image, file, dest, force, parents } => cmd_add(&image, &file, dest.as_deref(), force, parents),
|
size,
|
||||||
Commands::List { image, path, recursive } => cmd_list(&image, path.as_deref(), recursive),
|
output,
|
||||||
Commands::Remove { image, path, recursive } => cmd_remove(&image, &path, recursive),
|
label,
|
||||||
Commands::Mkdir { image, path, parents } => cmd_mkdir(&image, &path, parents),
|
} => cmd_create(&output, &size, &label),
|
||||||
|
Commands::Add {
|
||||||
|
image,
|
||||||
|
file,
|
||||||
|
dest,
|
||||||
|
force,
|
||||||
|
parents,
|
||||||
|
} => cmd_add(&image, &file, dest.as_deref(), force, parents),
|
||||||
|
Commands::List {
|
||||||
|
image,
|
||||||
|
path,
|
||||||
|
recursive,
|
||||||
|
} => cmd_list(&image, path.as_deref(), recursive),
|
||||||
|
Commands::Remove {
|
||||||
|
image,
|
||||||
|
path,
|
||||||
|
recursive,
|
||||||
|
} => cmd_remove(&image, &path, recursive),
|
||||||
|
Commands::Mkdir {
|
||||||
|
image,
|
||||||
|
path,
|
||||||
|
parents,
|
||||||
|
} => cmd_mkdir(&image, &path, parents),
|
||||||
Commands::Info { image } => cmd_info(&image),
|
Commands::Info { image } => cmd_info(&image),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,7 +160,13 @@ fn cmd_create(output: &PathBuf, size: &str, label: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_add(image: &PathBuf, file: &PathBuf, dest: Option<&str>, force: bool, parents: bool) -> Result<()> {
|
fn cmd_add(
|
||||||
|
image: &PathBuf,
|
||||||
|
file: &PathBuf,
|
||||||
|
dest: Option<&str>,
|
||||||
|
force: bool,
|
||||||
|
parents: bool,
|
||||||
|
) -> Result<()> {
|
||||||
if !file.exists() {
|
if !file.exists() {
|
||||||
return Err(VentoyError::FileNotFound(file.display().to_string()));
|
return Err(VentoyError::FileNotFound(file.display().to_string()));
|
||||||
}
|
}
|
||||||
@@ -234,18 +262,23 @@ fn cmd_info(image: &PathBuf) -> Result<()> {
|
|||||||
println!();
|
println!();
|
||||||
println!("Partition Layout:");
|
println!("Partition Layout:");
|
||||||
println!(" Data partition:");
|
println!(" Data partition:");
|
||||||
println!(" Start: sector {} (offset {})",
|
println!(
|
||||||
|
" Start: sector {} (offset {})",
|
||||||
layout.data_start_sector,
|
layout.data_start_sector,
|
||||||
format_size(layout.data_offset()));
|
format_size(layout.data_offset())
|
||||||
println!(" Size: {} sectors ({})",
|
);
|
||||||
|
println!(
|
||||||
|
" Size: {} sectors ({})",
|
||||||
layout.data_size_sectors,
|
layout.data_size_sectors,
|
||||||
format_size(layout.data_size()));
|
format_size(layout.data_size())
|
||||||
|
);
|
||||||
println!(" EFI partition:");
|
println!(" EFI partition:");
|
||||||
println!(" Start: sector {} (offset {})",
|
println!(
|
||||||
|
" Start: sector {} (offset {})",
|
||||||
layout.efi_start_sector,
|
layout.efi_start_sector,
|
||||||
format_size(layout.efi_offset()));
|
format_size(layout.efi_offset())
|
||||||
println!(" Size: {} sectors (32 MB)",
|
);
|
||||||
layout.efi_size_sectors);
|
println!(" Size: {} sectors (32 MB)", layout.efi_size_sectors);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,7 +225,8 @@ fn link_system() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Then standard paths
|
// Then standard paths
|
||||||
lib_paths.extend([
|
lib_paths.extend(
|
||||||
|
[
|
||||||
"/usr/local/lib", // Custom builds
|
"/usr/local/lib", // Custom builds
|
||||||
"/usr/local/lib64",
|
"/usr/local/lib64",
|
||||||
"/usr/lib",
|
"/usr/lib",
|
||||||
@@ -233,7 +234,10 @@ fn link_system() -> bool {
|
|||||||
"/usr/lib/x86_64-linux-gnu", // Debian/Ubuntu x86_64
|
"/usr/lib/x86_64-linux-gnu", // Debian/Ubuntu x86_64
|
||||||
"/usr/lib/aarch64-linux-gnu", // Debian/Ubuntu ARM64
|
"/usr/lib/aarch64-linux-gnu", // Debian/Ubuntu ARM64
|
||||||
"/usr/lib/arm-linux-gnueabihf", // Debian/Ubuntu ARMv7
|
"/usr/lib/arm-linux-gnueabihf", // Debian/Ubuntu ARMv7
|
||||||
].iter().map(|s| s.to_string()));
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
for path in &lib_paths {
|
for path in &lib_paths {
|
||||||
let lib_path = Path::new(path);
|
let lib_path = Path::new(path);
|
||||||
@@ -245,7 +249,10 @@ fn link_system() -> bool {
|
|||||||
println!("cargo:rustc-link-search=native={}", path);
|
println!("cargo:rustc-link-search=native={}", path);
|
||||||
println!("cargo:rustc-link-lib=static=yuv");
|
println!("cargo:rustc-link-lib=static=yuv");
|
||||||
println!("cargo:rustc-link-lib=stdc++");
|
println!("cargo:rustc-link-lib=stdc++");
|
||||||
println!("cargo:info=Using system libyuv from {} (static linking)", path);
|
println!(
|
||||||
|
"cargo:info=Using system libyuv from {} (static linking)",
|
||||||
|
path
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +264,10 @@ fn link_system() -> bool {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
println!("cargo:rustc-link-lib=stdc++");
|
println!("cargo:rustc-link-lib=stdc++");
|
||||||
|
|
||||||
println!("cargo:info=Using system libyuv from {} (dynamic linking)", path);
|
println!(
|
||||||
|
"cargo:info=Using system libyuv from {} (dynamic linking)",
|
||||||
|
path
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -404,6 +404,37 @@ pub fn nv12_to_i420(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Resu
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert NV21 to I420 (YUV420P)
|
||||||
|
pub fn nv21_to_i420(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Result<()> {
|
||||||
|
if width % 2 != 0 || height % 2 != 0 {
|
||||||
|
return Err(YuvError::InvalidDimensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
let w = width as usize;
|
||||||
|
let h = height as usize;
|
||||||
|
let y_size = w * h;
|
||||||
|
let uv_size = (w / 2) * (h / 2);
|
||||||
|
|
||||||
|
if src.len() < nv12_size(w, h) || dst.len() < i420_size(w, h) {
|
||||||
|
return Err(YuvError::BufferTooSmall);
|
||||||
|
}
|
||||||
|
|
||||||
|
call_yuv!(NV21ToI420(
|
||||||
|
src.as_ptr(),
|
||||||
|
width,
|
||||||
|
src[y_size..].as_ptr(),
|
||||||
|
width,
|
||||||
|
dst.as_mut_ptr(),
|
||||||
|
width,
|
||||||
|
dst[y_size..].as_mut_ptr(),
|
||||||
|
width / 2,
|
||||||
|
dst[y_size + uv_size..].as_mut_ptr(),
|
||||||
|
width / 2,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ARGB/BGRA conversions (32-bit)
|
// ARGB/BGRA conversions (32-bit)
|
||||||
// Note: libyuv ARGB = BGRA in memory on little-endian systems
|
// Note: libyuv ARGB = BGRA in memory on little-endian systems
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ impl LedSensor {
|
|||||||
.map_err(|e| AppError::Internal(format!("LED GPIO chip failed: {}", e)))?;
|
.map_err(|e| AppError::Internal(format!("LED GPIO chip failed: {}", e)))?;
|
||||||
|
|
||||||
let line = chip.get_line(self.config.gpio_pin).map_err(|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))
|
AppError::Internal(format!(
|
||||||
|
"LED GPIO line {} failed: {}",
|
||||||
|
self.config.gpio_pin, e
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let handle = line
|
let handle = line
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ mod wol;
|
|||||||
pub use controller::{AtxController, AtxControllerConfig};
|
pub use controller::{AtxController, AtxControllerConfig};
|
||||||
pub use executor::timing;
|
pub use executor::timing;
|
||||||
pub use types::{
|
pub use types::{
|
||||||
ActiveLevel, AtxAction, AtxDevices, AtxDriverType, AtxKeyConfig, AtxLedConfig,
|
ActiveLevel, AtxAction, AtxDevices, AtxDriverType, AtxKeyConfig, AtxLedConfig, AtxPowerRequest,
|
||||||
AtxPowerRequest, AtxState, PowerStatus,
|
AtxState, PowerStatus,
|
||||||
};
|
};
|
||||||
pub use wol::send_wol;
|
pub use wol::send_wol;
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ fn parse_mac_address(mac: &str) -> Result<[u8; 6]> {
|
|||||||
} else if mac.contains('-') {
|
} else if mac.contains('-') {
|
||||||
mac.split('-').collect()
|
mac.split('-').collect()
|
||||||
} else {
|
} else {
|
||||||
return Err(AppError::Config(format!("Invalid MAC address format: {}", mac)));
|
return Err(AppError::Config(format!(
|
||||||
|
"Invalid MAC address format: {}",
|
||||||
|
mac
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
if parts.len() != 6 {
|
if parts.len() != 6 {
|
||||||
@@ -34,9 +37,8 @@ fn parse_mac_address(mac: &str) -> Result<[u8; 6]> {
|
|||||||
|
|
||||||
let mut bytes = [0u8; 6];
|
let mut bytes = [0u8; 6];
|
||||||
for (i, part) in parts.iter().enumerate() {
|
for (i, part) in parts.iter().enumerate() {
|
||||||
bytes[i] = u8::from_str_radix(part, 16).map_err(|_| {
|
bytes[i] = u8::from_str_radix(part, 16)
|
||||||
AppError::Config(format!("Invalid MAC address byte: {}", part))
|
.map_err(|_| AppError::Config(format!("Invalid MAC address byte: {}", part)))?;
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
|
|||||||
@@ -201,7 +201,15 @@ impl AudioCapturer {
|
|||||||
let log_throttler = self.log_throttler.clone();
|
let log_throttler = self.log_throttler.clone();
|
||||||
|
|
||||||
let handle = tokio::task::spawn_blocking(move || {
|
let handle = tokio::task::spawn_blocking(move || {
|
||||||
capture_loop(config, state, stats, frame_tx, stop_flag, sequence, log_throttler);
|
capture_loop(
|
||||||
|
config,
|
||||||
|
state,
|
||||||
|
stats,
|
||||||
|
frame_tx,
|
||||||
|
stop_flag,
|
||||||
|
sequence,
|
||||||
|
log_throttler,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
*self.capture_handle.lock().await = Some(handle);
|
*self.capture_handle.lock().await = Some(handle);
|
||||||
@@ -274,40 +282,34 @@ fn run_capture(
|
|||||||
|
|
||||||
// Configure hardware parameters
|
// Configure hardware parameters
|
||||||
{
|
{
|
||||||
let hwp = HwParams::any(&pcm).map_err(|e| {
|
let hwp = HwParams::any(&pcm)
|
||||||
AppError::AudioError(format!("Failed to get HwParams: {}", e))
|
.map_err(|e| AppError::AudioError(format!("Failed to get HwParams: {}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
hwp.set_channels(config.channels).map_err(|e| {
|
hwp.set_channels(config.channels)
|
||||||
AppError::AudioError(format!("Failed to set channels: {}", e))
|
.map_err(|e| AppError::AudioError(format!("Failed to set channels: {}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
hwp.set_rate(config.sample_rate, ValueOr::Nearest).map_err(|e| {
|
hwp.set_rate(config.sample_rate, ValueOr::Nearest)
|
||||||
AppError::AudioError(format!("Failed to set sample rate: {}", e))
|
.map_err(|e| AppError::AudioError(format!("Failed to set sample rate: {}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
hwp.set_format(Format::s16()).map_err(|e| {
|
hwp.set_format(Format::s16())
|
||||||
AppError::AudioError(format!("Failed to set format: {}", e))
|
.map_err(|e| AppError::AudioError(format!("Failed to set format: {}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
hwp.set_access(Access::RWInterleaved).map_err(|e| {
|
hwp.set_access(Access::RWInterleaved)
|
||||||
AppError::AudioError(format!("Failed to set access: {}", e))
|
.map_err(|e| AppError::AudioError(format!("Failed to set access: {}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
hwp.set_buffer_size_near(config.buffer_frames as Frames).map_err(|e| {
|
hwp.set_buffer_size_near(config.buffer_frames as Frames)
|
||||||
AppError::AudioError(format!("Failed to set buffer size: {}", e))
|
.map_err(|e| AppError::AudioError(format!("Failed to set buffer size: {}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
hwp.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest)
|
hwp.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest)
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to set period size: {}", e)))?;
|
.map_err(|e| AppError::AudioError(format!("Failed to set period size: {}", e)))?;
|
||||||
|
|
||||||
pcm.hw_params(&hwp).map_err(|e| {
|
pcm.hw_params(&hwp)
|
||||||
AppError::AudioError(format!("Failed to apply hw params: {}", e))
|
.map_err(|e| AppError::AudioError(format!("Failed to apply hw params: {}", e)))?;
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get actual configuration
|
// Get actual configuration
|
||||||
let actual_rate = pcm.hw_params_current()
|
let actual_rate = pcm
|
||||||
|
.hw_params_current()
|
||||||
.map(|h| h.get_rate().unwrap_or(config.sample_rate))
|
.map(|h| h.get_rate().unwrap_or(config.sample_rate))
|
||||||
.unwrap_or(config.sample_rate);
|
.unwrap_or(config.sample_rate);
|
||||||
|
|
||||||
@@ -317,9 +319,8 @@ fn run_capture(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Prepare for capture
|
// Prepare for capture
|
||||||
pcm.prepare().map_err(|e| {
|
pcm.prepare()
|
||||||
AppError::AudioError(format!("Failed to prepare PCM: {}", e))
|
.map_err(|e| AppError::AudioError(format!("Failed to prepare PCM: {}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
let _ = state.send(CaptureState::Running);
|
let _ = state.send(CaptureState::Running);
|
||||||
|
|
||||||
@@ -340,7 +341,11 @@ fn run_capture(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
State::Suspended => {
|
State::Suspended => {
|
||||||
warn_throttled!(log_throttler, "suspended", "Audio device suspended, recovering");
|
warn_throttled!(
|
||||||
|
log_throttler,
|
||||||
|
"suspended",
|
||||||
|
"Audio device suspended, recovering"
|
||||||
|
);
|
||||||
let _ = pcm.resume();
|
let _ = pcm.resume();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -363,11 +368,8 @@ fn run_capture(
|
|||||||
|
|
||||||
// Directly use the buffer slice (already in correct byte format)
|
// Directly use the buffer slice (already in correct byte format)
|
||||||
let seq = sequence.fetch_add(1, Ordering::Relaxed);
|
let seq = sequence.fetch_add(1, Ordering::Relaxed);
|
||||||
let frame = AudioFrame::new(
|
let frame =
|
||||||
Bytes::copy_from_slice(&buffer[..byte_count]),
|
AudioFrame::new(Bytes::copy_from_slice(&buffer[..byte_count]), config, seq);
|
||||||
config,
|
|
||||||
seq,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send to subscribers
|
// Send to subscribers
|
||||||
if frame_tx.receiver_count() > 0 {
|
if frame_tx.receiver_count() > 0 {
|
||||||
|
|||||||
@@ -193,7 +193,9 @@ impl AudioController {
|
|||||||
pub async fn select_device(&self, device: &str) -> Result<()> {
|
pub async fn select_device(&self, device: &str) -> Result<()> {
|
||||||
// Validate device exists
|
// Validate device exists
|
||||||
let devices = self.list_devices().await?;
|
let devices = self.list_devices().await?;
|
||||||
let found = devices.iter().any(|d| d.name == device || d.description.contains(device));
|
let found = devices
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.name == device || d.description.contains(device));
|
||||||
|
|
||||||
if !found && device != "default" {
|
if !found && device != "default" {
|
||||||
return Err(AppError::AudioError(format!(
|
return Err(AppError::AudioError(format!(
|
||||||
@@ -244,7 +246,11 @@ impl AudioController {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
info!("Audio quality set to: {:?} ({}bps)", quality, quality.bitrate());
|
info!(
|
||||||
|
"Audio quality set to: {:?} ({}bps)",
|
||||||
|
quality,
|
||||||
|
quality.bitrate()
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,11 +352,14 @@ impl AudioController {
|
|||||||
let streaming = self.is_streaming().await;
|
let streaming = self.is_streaming().await;
|
||||||
let error = self.last_error.read().await.clone();
|
let error = self.last_error.read().await.clone();
|
||||||
|
|
||||||
let (subscriber_count, frames_encoded, bytes_output) = if let Some(ref streamer) =
|
let (subscriber_count, frames_encoded, bytes_output) =
|
||||||
*self.streamer.read().await
|
if let Some(ref streamer) = *self.streamer.read().await {
|
||||||
{
|
|
||||||
let stats = streamer.stats().await;
|
let stats = streamer.stats().await;
|
||||||
(stats.subscriber_count, stats.frames_encoded, stats.bytes_output)
|
(
|
||||||
|
stats.subscriber_count,
|
||||||
|
stats.frames_encoded,
|
||||||
|
stats.bytes_output,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(0, 0, 0)
|
(0, 0, 0)
|
||||||
};
|
};
|
||||||
@@ -383,7 +392,11 @@ impl AudioController {
|
|||||||
|
|
||||||
/// Subscribe to Opus frames (async version)
|
/// Subscribe to Opus frames (async version)
|
||||||
pub async fn subscribe_opus_async(&self) -> Option<broadcast::Receiver<OpusFrame>> {
|
pub async fn subscribe_opus_async(&self) -> Option<broadcast::Receiver<OpusFrame>> {
|
||||||
self.streamer.read().await.as_ref().map(|s| s.subscribe_opus())
|
self.streamer
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.subscribe_opus())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable or disable audio
|
/// Enable or disable audio
|
||||||
|
|||||||
@@ -55,7 +55,12 @@ fn get_usb_bus_info(card_index: i32) -> Option<String> {
|
|||||||
// Match patterns like "1-1", "1-2", "1-1.2", "2-1.3.1"
|
// Match patterns like "1-1", "1-2", "1-1.2", "2-1.3.1"
|
||||||
if component.contains('-') && !component.contains(':') {
|
if component.contains('-') && !component.contains(':') {
|
||||||
// Verify it looks like a USB port (starts with digit)
|
// Verify it looks like a USB port (starts with digit)
|
||||||
if component.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
|
if component
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.map(|c| c.is_ascii_digit())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
return Some(component.to_string());
|
return Some(component.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,15 +228,14 @@ pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
|
|||||||
let devices = enumerate_audio_devices()?;
|
let devices = enumerate_audio_devices()?;
|
||||||
|
|
||||||
if devices.is_empty() {
|
if devices.is_empty() {
|
||||||
return Err(AppError::AudioError("No audio capture devices found".to_string()));
|
return Err(AppError::AudioError(
|
||||||
|
"No audio capture devices found".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, look for HDMI/capture card devices that support 48kHz stereo
|
// First, look for HDMI/capture card devices that support 48kHz stereo
|
||||||
for device in &devices {
|
for device in &devices {
|
||||||
if device.is_hdmi
|
if device.is_hdmi && device.sample_rates.contains(&48000) && device.channels.contains(&2) {
|
||||||
&& device.sample_rates.contains(&48000)
|
|
||||||
&& device.channels.contains(&2)
|
|
||||||
{
|
|
||||||
info!("Selected HDMI audio device: {}", device.description);
|
info!("Selected HDMI audio device: {}", device.description);
|
||||||
return Ok(device.clone());
|
return Ok(device.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,9 +137,8 @@ impl OpusEncoder {
|
|||||||
let channels = config.to_audiopus_channels();
|
let channels = config.to_audiopus_channels();
|
||||||
let application = config.to_audiopus_application();
|
let application = config.to_audiopus_application();
|
||||||
|
|
||||||
let mut encoder = Encoder::new(sample_rate, channels, application).map_err(|e| {
|
let mut encoder = Encoder::new(sample_rate, channels, application)
|
||||||
AppError::AudioError(format!("Failed to create Opus encoder: {:?}", e))
|
.map_err(|e| AppError::AudioError(format!("Failed to create Opus encoder: {:?}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
// Configure encoder
|
// Configure encoder
|
||||||
encoder
|
encoder
|
||||||
|
|||||||
@@ -22,5 +22,7 @@ pub use controller::{AudioController, AudioControllerConfig, AudioQuality, Audio
|
|||||||
pub use device::{enumerate_audio_devices, enumerate_audio_devices_with_current, AudioDeviceInfo};
|
pub use device::{enumerate_audio_devices, enumerate_audio_devices_with_current, AudioDeviceInfo};
|
||||||
pub use encoder::{OpusConfig, OpusEncoder, OpusFrame};
|
pub use encoder::{OpusConfig, OpusEncoder, OpusFrame};
|
||||||
pub use monitor::{AudioHealthMonitor, AudioHealthStatus, AudioMonitorConfig};
|
pub use monitor::{AudioHealthMonitor, AudioHealthStatus, AudioMonitorConfig};
|
||||||
pub use shared_pipeline::{SharedAudioPipeline, SharedAudioPipelineConfig, SharedAudioPipelineStats};
|
pub use shared_pipeline::{
|
||||||
|
SharedAudioPipeline, SharedAudioPipelineConfig, SharedAudioPipelineStats,
|
||||||
|
};
|
||||||
pub use streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig};
|
pub use streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig};
|
||||||
|
|||||||
@@ -329,9 +329,7 @@ mod tests {
|
|||||||
let monitor = AudioHealthMonitor::with_defaults();
|
let monitor = AudioHealthMonitor::with_defaults();
|
||||||
|
|
||||||
for i in 1..=5 {
|
for i in 1..=5 {
|
||||||
monitor
|
monitor.report_error(None, "Error", "io_error").await;
|
||||||
.report_error(None, "Error", "io_error")
|
|
||||||
.await;
|
|
||||||
assert_eq!(monitor.retry_count(), i);
|
assert_eq!(monitor.retry_count(), i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,9 +338,7 @@ mod tests {
|
|||||||
async fn test_reset() {
|
async fn test_reset() {
|
||||||
let monitor = AudioHealthMonitor::with_defaults();
|
let monitor = AudioHealthMonitor::with_defaults();
|
||||||
|
|
||||||
monitor
|
monitor.report_error(None, "Error", "io_error").await;
|
||||||
.report_error(None, "Error", "io_error")
|
|
||||||
.await;
|
|
||||||
assert!(monitor.is_error().await);
|
assert!(monitor.is_error().await);
|
||||||
|
|
||||||
monitor.reset().await;
|
monitor.reset().await;
|
||||||
|
|||||||
@@ -320,11 +320,8 @@ impl SharedAudioPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Receive audio frame with timeout
|
// Receive audio frame with timeout
|
||||||
let recv_result = tokio::time::timeout(
|
let recv_result =
|
||||||
std::time::Duration::from_secs(2),
|
tokio::time::timeout(std::time::Duration::from_secs(2), audio_rx.recv()).await;
|
||||||
audio_rx.recv(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match recv_result {
|
match recv_result {
|
||||||
Ok(Ok(audio_frame)) => {
|
Ok(Ok(audio_frame)) => {
|
||||||
|
|||||||
@@ -297,11 +297,8 @@ impl AudioStreamer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Receive PCM frame with timeout
|
// Receive PCM frame with timeout
|
||||||
let recv_result = tokio::time::timeout(
|
let recv_result =
|
||||||
std::time::Duration::from_secs(2),
|
tokio::time::timeout(std::time::Duration::from_secs(2), pcm_rx.recv()).await;
|
||||||
pcm_rx.recv(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match recv_result {
|
match recv_result {
|
||||||
Ok(Ok(audio_frame)) => {
|
Ok(Ok(audio_frame)) => {
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ pub async fn auth_middleware(
|
|||||||
if !state.config.is_initialized() {
|
if !state.config.is_initialized() {
|
||||||
// Allow access to setup endpoints when not initialized
|
// Allow access to setup endpoints when not initialized
|
||||||
let path = request.uri().path();
|
let path = request.uri().path();
|
||||||
if path.starts_with("/api/setup") || path == "/api/info" || path.starts_with("/") && !path.starts_with("/api/") {
|
if path.starts_with("/api/setup")
|
||||||
|
|| path == "/api/info"
|
||||||
|
|| path.starts_with("/") && !path.starts_with("/api/")
|
||||||
|
{
|
||||||
return Ok(next.run(request).await);
|
return Ok(next.run(request).await);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
pub mod middleware;
|
||||||
mod password;
|
mod password;
|
||||||
mod session;
|
mod session;
|
||||||
mod user;
|
mod user;
|
||||||
pub mod middleware;
|
|
||||||
|
|
||||||
|
pub use middleware::{auth_middleware, require_admin, AuthLayer, SESSION_COOKIE};
|
||||||
pub use password::{hash_password, verify_password};
|
pub use password::{hash_password, verify_password};
|
||||||
pub use session::{Session, SessionStore};
|
pub use session::{Session, SessionStore};
|
||||||
pub use user::{User, UserStore};
|
pub use user::{User, UserStore};
|
||||||
pub use middleware::{AuthLayer, SESSION_COOKIE, auth_middleware, require_admin};
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use sqlx::{Pool, Sqlite};
|
use sqlx::{Pool, Sqlite};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::{AppError, Result};
|
|
||||||
use super::password::{hash_password, verify_password};
|
use super::password::{hash_password, verify_password};
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
/// User row type from database
|
/// User row type from database
|
||||||
type UserRow = (String, String, String, i32, String, String);
|
type UserRow = (String, String, String, i32, String, String);
|
||||||
@@ -134,9 +134,8 @@ impl UserStore {
|
|||||||
let password_hash = hash_password(new_password)?;
|
let password_hash = hash_password(new_password)?;
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
let result = sqlx::query(
|
let result =
|
||||||
"UPDATE users SET password_hash = ?1, updated_at = ?2 WHERE id = ?3",
|
sqlx::query("UPDATE users SET password_hash = ?1, updated_at = ?2 WHERE id = ?3")
|
||||||
)
|
|
||||||
.bind(&password_hash)
|
.bind(&password_hash)
|
||||||
.bind(now.to_rfc3339())
|
.bind(now.to_rfc3339())
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
use crate::video::encoder::BitratePreset;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
use crate::video::encoder::BitratePreset;
|
|
||||||
|
|
||||||
// Re-export ExtensionsConfig from extensions module
|
// Re-export ExtensionsConfig from extensions module
|
||||||
pub use crate::extensions::ExtensionsConfig;
|
pub use crate::extensions::ExtensionsConfig;
|
||||||
@@ -425,8 +425,15 @@ impl StreamConfig {
|
|||||||
/// Check if using public ICE servers (user left fields empty)
|
/// Check if using public ICE servers (user left fields empty)
|
||||||
pub fn is_using_public_ice_servers(&self) -> bool {
|
pub fn is_using_public_ice_servers(&self) -> bool {
|
||||||
use crate::webrtc::config::public_ice;
|
use crate::webrtc::config::public_ice;
|
||||||
self.stun_server.as_ref().map(|s| s.is_empty()).unwrap_or(true)
|
self.stun_server
|
||||||
&& self.turn_server.as_ref().map(|s| s.is_empty()).unwrap_or(true)
|
.as_ref()
|
||||||
|
.map(|s| s.is_empty())
|
||||||
|
.unwrap_or(true)
|
||||||
|
&& self
|
||||||
|
.turn_server
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.is_empty())
|
||||||
|
.unwrap_or(true)
|
||||||
&& public_ice::is_configured()
|
&& public_ice::is_configured()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,9 +126,8 @@ impl ConfigStore {
|
|||||||
|
|
||||||
/// Load configuration from database
|
/// Load configuration from database
|
||||||
async fn load_config(pool: &Pool<Sqlite>) -> Result<AppConfig> {
|
async fn load_config(pool: &Pool<Sqlite>) -> Result<AppConfig> {
|
||||||
let row: Option<(String,)> = sqlx::query_as(
|
let row: Option<(String,)> =
|
||||||
"SELECT value FROM config WHERE key = 'app_config'"
|
sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'")
|
||||||
)
|
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -245,10 +244,13 @@ mod tests {
|
|||||||
assert!(!config.initialized);
|
assert!(!config.initialized);
|
||||||
|
|
||||||
// Update config
|
// Update config
|
||||||
store.update(|c| {
|
store
|
||||||
|
.update(|c| {
|
||||||
c.initialized = true;
|
c.initialized = true;
|
||||||
c.web.http_port = 9000;
|
c.web.http_port = 9000;
|
||||||
}).await.unwrap();
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Verify update
|
// Verify update
|
||||||
let config = store.get();
|
let config = store.get();
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use types::{
|
pub use types::{
|
||||||
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, MsdDeviceInfo, SystemEvent, VideoDeviceInfo,
|
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, MsdDeviceInfo, SystemEvent,
|
||||||
|
VideoDeviceInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|||||||
@@ -128,6 +128,20 @@ pub enum SystemEvent {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Video Stream Events
|
// Video Stream Events
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
/// Stream mode switching started (transactional, correlates all following events)
|
||||||
|
///
|
||||||
|
/// Sent immediately after a mode switch request is accepted.
|
||||||
|
/// Clients can use `transition_id` to correlate subsequent `stream.*` events.
|
||||||
|
#[serde(rename = "stream.mode_switching")]
|
||||||
|
StreamModeSwitching {
|
||||||
|
/// Unique transition ID for this mode switch transaction
|
||||||
|
transition_id: String,
|
||||||
|
/// Target mode: "mjpeg", "h264", "h265", "vp8", "vp9"
|
||||||
|
to_mode: String,
|
||||||
|
/// Previous mode: "mjpeg", "h264", "h265", "vp8", "vp9"
|
||||||
|
from_mode: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Stream state changed (e.g., started, stopped, error)
|
/// Stream state changed (e.g., started, stopped, error)
|
||||||
#[serde(rename = "stream.state_changed")]
|
#[serde(rename = "stream.state_changed")]
|
||||||
StreamStateChanged {
|
StreamStateChanged {
|
||||||
@@ -143,6 +157,9 @@ pub enum SystemEvent {
|
|||||||
/// the stream will be interrupted temporarily.
|
/// the stream will be interrupted temporarily.
|
||||||
#[serde(rename = "stream.config_changing")]
|
#[serde(rename = "stream.config_changing")]
|
||||||
StreamConfigChanging {
|
StreamConfigChanging {
|
||||||
|
/// Optional transition ID if this config change is part of a mode switch transaction
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
transition_id: Option<String>,
|
||||||
/// Reason for change: "device_switch", "resolution_change", "format_change"
|
/// Reason for change: "device_switch", "resolution_change", "format_change"
|
||||||
reason: String,
|
reason: String,
|
||||||
},
|
},
|
||||||
@@ -152,6 +169,9 @@ pub enum SystemEvent {
|
|||||||
/// Sent after new configuration is active. Clients can reconnect now.
|
/// Sent after new configuration is active. Clients can reconnect now.
|
||||||
#[serde(rename = "stream.config_applied")]
|
#[serde(rename = "stream.config_applied")]
|
||||||
StreamConfigApplied {
|
StreamConfigApplied {
|
||||||
|
/// Optional transition ID if this config change is part of a mode switch transaction
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
transition_id: Option<String>,
|
||||||
/// Device path
|
/// Device path
|
||||||
device: String,
|
device: String,
|
||||||
/// Resolution (width, height)
|
/// Resolution (width, height)
|
||||||
@@ -193,6 +213,9 @@ pub enum SystemEvent {
|
|||||||
/// Clients should wait for this event before attempting to create WebRTC sessions.
|
/// Clients should wait for this event before attempting to create WebRTC sessions.
|
||||||
#[serde(rename = "stream.webrtc_ready")]
|
#[serde(rename = "stream.webrtc_ready")]
|
||||||
WebRTCReady {
|
WebRTCReady {
|
||||||
|
/// Optional transition ID if this readiness is part of a mode switch transaction
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
transition_id: Option<String>,
|
||||||
/// Current video codec
|
/// Current video codec
|
||||||
codec: String,
|
codec: String,
|
||||||
/// Whether hardware encoding is being used
|
/// Whether hardware encoding is being used
|
||||||
@@ -215,12 +238,26 @@ pub enum SystemEvent {
|
|||||||
/// from the current stream and reconnect using the new mode.
|
/// from the current stream and reconnect using the new mode.
|
||||||
#[serde(rename = "stream.mode_changed")]
|
#[serde(rename = "stream.mode_changed")]
|
||||||
StreamModeChanged {
|
StreamModeChanged {
|
||||||
|
/// Optional transition ID if this change is part of a mode switch transaction
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
transition_id: Option<String>,
|
||||||
/// New mode: "mjpeg", "h264", "h265", "vp8", or "vp9"
|
/// New mode: "mjpeg", "h264", "h265", "vp8", or "vp9"
|
||||||
mode: String,
|
mode: String,
|
||||||
/// Previous mode: "mjpeg", "h264", "h265", "vp8", or "vp9"
|
/// Previous mode: "mjpeg", "h264", "h265", "vp8", or "vp9"
|
||||||
previous_mode: String,
|
previous_mode: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Stream mode switching completed (transactional end marker)
|
||||||
|
///
|
||||||
|
/// Sent when the backend considers the new mode ready for clients to connect.
|
||||||
|
#[serde(rename = "stream.mode_ready")]
|
||||||
|
StreamModeReady {
|
||||||
|
/// Unique transition ID for this mode switch transaction
|
||||||
|
transition_id: String,
|
||||||
|
/// Active mode after switch: "mjpeg", "h264", "h265", "vp8", "vp9"
|
||||||
|
mode: String,
|
||||||
|
},
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HID Events
|
// HID Events
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -491,6 +528,7 @@ impl SystemEvent {
|
|||||||
/// Get the event name (for filtering/routing)
|
/// Get the event name (for filtering/routing)
|
||||||
pub fn event_name(&self) -> &'static str {
|
pub fn event_name(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
Self::StreamModeSwitching { .. } => "stream.mode_switching",
|
||||||
Self::StreamStateChanged { .. } => "stream.state_changed",
|
Self::StreamStateChanged { .. } => "stream.state_changed",
|
||||||
Self::StreamConfigChanging { .. } => "stream.config_changing",
|
Self::StreamConfigChanging { .. } => "stream.config_changing",
|
||||||
Self::StreamConfigApplied { .. } => "stream.config_applied",
|
Self::StreamConfigApplied { .. } => "stream.config_applied",
|
||||||
@@ -500,6 +538,7 @@ impl SystemEvent {
|
|||||||
Self::WebRTCReady { .. } => "stream.webrtc_ready",
|
Self::WebRTCReady { .. } => "stream.webrtc_ready",
|
||||||
Self::StreamStatsUpdate { .. } => "stream.stats_update",
|
Self::StreamStatsUpdate { .. } => "stream.stats_update",
|
||||||
Self::StreamModeChanged { .. } => "stream.mode_changed",
|
Self::StreamModeChanged { .. } => "stream.mode_changed",
|
||||||
|
Self::StreamModeReady { .. } => "stream.mode_ready",
|
||||||
Self::HidStateChanged { .. } => "hid.state_changed",
|
Self::HidStateChanged { .. } => "hid.state_changed",
|
||||||
Self::HidBackendSwitching { .. } => "hid.backend_switching",
|
Self::HidBackendSwitching { .. } => "hid.backend_switching",
|
||||||
Self::HidDeviceLost { .. } => "hid.device_lost",
|
Self::HidDeviceLost { .. } => "hid.device_lost",
|
||||||
@@ -589,6 +628,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_serialization() {
|
fn test_serialization() {
|
||||||
let event = SystemEvent::StreamConfigApplied {
|
let event = SystemEvent::StreamConfigApplied {
|
||||||
|
transition_id: None,
|
||||||
device: "/dev/video0".to_string(),
|
device: "/dev/video0".to_string(),
|
||||||
resolution: (1920, 1080),
|
resolution: (1920, 1080),
|
||||||
format: "mjpeg".to_string(),
|
format: "mjpeg".to_string(),
|
||||||
@@ -600,6 +640,9 @@ mod tests {
|
|||||||
assert!(json.contains("/dev/video0"));
|
assert!(json.contains("/dev/video0"));
|
||||||
|
|
||||||
let deserialized: SystemEvent = serde_json::from_str(&json).unwrap();
|
let deserialized: SystemEvent = serde_json::from_str(&json).unwrap();
|
||||||
assert!(matches!(deserialized, SystemEvent::StreamConfigApplied { .. }));
|
assert!(matches!(
|
||||||
|
deserialized,
|
||||||
|
SystemEvent::StreamConfigApplied { .. }
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,7 +210,11 @@ impl ExtensionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build command arguments for an extension
|
/// Build command arguments for an extension
|
||||||
async fn build_args(&self, id: ExtensionId, config: &ExtensionsConfig) -> Result<Vec<String>, String> {
|
async fn build_args(
|
||||||
|
&self,
|
||||||
|
id: ExtensionId,
|
||||||
|
config: &ExtensionsConfig,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
match id {
|
match id {
|
||||||
ExtensionId::Ttyd => {
|
ExtensionId::Ttyd => {
|
||||||
let c = &config.ttyd;
|
let c = &config.ttyd;
|
||||||
@@ -219,8 +223,10 @@ impl ExtensionManager {
|
|||||||
Self::prepare_ttyd_socket().await?;
|
Self::prepare_ttyd_socket().await?;
|
||||||
|
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"-i".to_string(), TTYD_SOCKET_PATH.to_string(), // Unix socket
|
"-i".to_string(),
|
||||||
"-b".to_string(), "/api/terminal".to_string(), // Base path for reverse proxy
|
TTYD_SOCKET_PATH.to_string(), // Unix socket
|
||||||
|
"-b".to_string(),
|
||||||
|
"/api/terminal".to_string(), // Base path for reverse proxy
|
||||||
"-W".to_string(), // Writable (allow input)
|
"-W".to_string(), // Writable (allow input)
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -313,7 +319,10 @@ impl ExtensionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove old socket file if exists
|
// Remove old socket file if exists
|
||||||
if tokio::fs::try_exists(TTYD_SOCKET_PATH).await.unwrap_or(false) {
|
if tokio::fs::try_exists(TTYD_SOCKET_PATH)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
tokio::fs::remove_file(TTYD_SOCKET_PATH)
|
tokio::fs::remove_file(TTYD_SOCKET_PATH)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to remove old socket: {}", e))?;
|
.map_err(|e| format!("Failed to remove old socket: {}", e))?;
|
||||||
@@ -374,8 +383,8 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
/// Start all enabled extensions in parallel
|
/// Start all enabled extensions in parallel
|
||||||
pub async fn start_enabled(&self, config: &ExtensionsConfig) {
|
pub async fn start_enabled(&self, config: &ExtensionsConfig) {
|
||||||
use std::pin::Pin;
|
|
||||||
use futures::Future;
|
use futures::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
let mut start_futures: Vec<Pin<Box<dyn Future<Output = ()> + Send + '_>>> = Vec::new();
|
let mut start_futures: Vec<Pin<Box<dyn Future<Output = ()> + Send + '_>>> = Vec::new();
|
||||||
|
|
||||||
@@ -416,10 +425,7 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
/// Stop all running extensions in parallel
|
/// Stop all running extensions in parallel
|
||||||
pub async fn stop_all(&self) {
|
pub async fn stop_all(&self) {
|
||||||
let stop_futures: Vec<_> = ExtensionId::all()
|
let stop_futures: Vec<_> = ExtensionId::all().iter().map(|id| self.stop(*id)).collect();
|
||||||
.iter()
|
|
||||||
.map(|id| self.stop(*id))
|
|
||||||
.collect();
|
|
||||||
futures::future::join_all(stop_futures).await;
|
futures::future::join_all(stop_futures).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use async_trait::async_trait;
|
|||||||
use parking_lot::{Mutex, RwLock};
|
use parking_lot::{Mutex, RwLock};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU8, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, AtomicU8, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tracing::{debug, info, trace, warn};
|
use tracing::{debug, info, trace, warn};
|
||||||
|
|
||||||
@@ -358,8 +358,7 @@ impl Response {
|
|||||||
|
|
||||||
/// Check if the response indicates success
|
/// Check if the response indicates success
|
||||||
pub fn is_success(&self) -> bool {
|
pub fn is_success(&self) -> bool {
|
||||||
!self.is_error
|
!self.is_error && (self.data.is_empty() || self.data[0] == Ch9329Error::Success as u8)
|
||||||
&& (self.data.is_empty() || self.data[0] == Ch9329Error::Success as u8)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,7 +488,10 @@ impl Ch9329Backend {
|
|||||||
.map_err(|e| Self::serial_error_to_hid_error(e, "Failed to open serial port"))?;
|
.map_err(|e| Self::serial_error_to_hid_error(e, "Failed to open serial port"))?;
|
||||||
|
|
||||||
*self.port.lock() = Some(port);
|
*self.port.lock() = Some(port);
|
||||||
info!("CH9329 serial port reopened: {} @ {} baud", self.port_path, self.baud_rate);
|
info!(
|
||||||
|
"CH9329 serial port reopened: {} @ {} baud",
|
||||||
|
self.port_path, self.baud_rate
|
||||||
|
);
|
||||||
|
|
||||||
// Verify connection with GET_INFO command
|
// Verify connection with GET_INFO command
|
||||||
self.query_chip_info().map_err(|e| {
|
self.query_chip_info().map_err(|e| {
|
||||||
@@ -518,7 +520,10 @@ impl Ch9329Backend {
|
|||||||
/// Returns the packet buffer and the actual length
|
/// Returns the packet buffer and the actual length
|
||||||
#[inline]
|
#[inline]
|
||||||
fn build_packet_buf(&self, cmd: u8, data: &[u8]) -> ([u8; MAX_PACKET_SIZE], usize) {
|
fn build_packet_buf(&self, cmd: u8, data: &[u8]) -> ([u8; MAX_PACKET_SIZE], usize) {
|
||||||
debug_assert!(data.len() <= MAX_DATA_LEN, "Data too long for CH9329 packet");
|
debug_assert!(
|
||||||
|
data.len() <= MAX_DATA_LEN,
|
||||||
|
"Data too long for CH9329 packet"
|
||||||
|
);
|
||||||
|
|
||||||
let len = data.len() as u8;
|
let len = data.len() as u8;
|
||||||
let packet_len = 6 + data.len();
|
let packet_len = 6 + data.len();
|
||||||
@@ -554,16 +559,19 @@ impl Ch9329Backend {
|
|||||||
|
|
||||||
let mut port_guard = self.port.lock();
|
let mut port_guard = self.port.lock();
|
||||||
if let Some(ref mut port) = *port_guard {
|
if let Some(ref mut port) = *port_guard {
|
||||||
port.write_all(&packet[..packet_len]).map_err(|e| {
|
port.write_all(&packet[..packet_len])
|
||||||
AppError::HidError {
|
.map_err(|e| AppError::HidError {
|
||||||
backend: "ch9329".to_string(),
|
backend: "ch9329".to_string(),
|
||||||
reason: format!("Failed to write to CH9329: {}", e),
|
reason: format!("Failed to write to CH9329: {}", e),
|
||||||
error_code: "write_failed".to_string(),
|
error_code: "write_failed".to_string(),
|
||||||
}
|
|
||||||
})?;
|
})?;
|
||||||
// Only log mouse button events at debug level to avoid flooding
|
// Only log mouse button events at debug level to avoid flooding
|
||||||
if cmd == cmd::SEND_MS_ABS_DATA && data.len() >= 2 && data[1] != 0 {
|
if cmd == cmd::SEND_MS_ABS_DATA && data.len() >= 2 && data[1] != 0 {
|
||||||
debug!("CH9329 TX [cmd=0x{:02X}]: {:02X?}", cmd, &packet[..packet_len]);
|
debug!(
|
||||||
|
"CH9329 TX [cmd=0x{:02X}]: {:02X?}",
|
||||||
|
cmd,
|
||||||
|
&packet[..packet_len]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -655,7 +663,11 @@ impl Ch9329Backend {
|
|||||||
info!(
|
info!(
|
||||||
"CH9329: Recovery successful, chip version: {}, USB: {}",
|
"CH9329: Recovery successful, chip version: {}, USB: {}",
|
||||||
info.version,
|
info.version,
|
||||||
if info.usb_connected { "connected" } else { "disconnected" }
|
if info.usb_connected {
|
||||||
|
"connected"
|
||||||
|
} else {
|
||||||
|
"disconnected"
|
||||||
|
}
|
||||||
);
|
);
|
||||||
// Reset error count on successful recovery
|
// Reset error count on successful recovery
|
||||||
self.error_count.store(0, Ordering::Relaxed);
|
self.error_count.store(0, Ordering::Relaxed);
|
||||||
@@ -695,9 +707,8 @@ impl Ch9329Backend {
|
|||||||
let mut port_guard = self.port.lock();
|
let mut port_guard = self.port.lock();
|
||||||
if let Some(ref mut port) = *port_guard {
|
if let Some(ref mut port) = *port_guard {
|
||||||
// Send packet
|
// Send packet
|
||||||
port.write_all(&packet).map_err(|e| {
|
port.write_all(&packet)
|
||||||
AppError::Internal(format!("Failed to write to CH9329: {}", e))
|
.map_err(|e| AppError::Internal(format!("Failed to write to CH9329: {}", e)))?;
|
||||||
})?;
|
|
||||||
trace!("CH9329 TX: {:02X?}", packet);
|
trace!("CH9329 TX: {:02X?}", packet);
|
||||||
|
|
||||||
// Wait for response - use shorter delay for faster response
|
// Wait for response - use shorter delay for faster response
|
||||||
@@ -725,7 +736,10 @@ impl Ch9329Backend {
|
|||||||
debug!("CH9329 response timeout (may be normal)");
|
debug!("CH9329 response timeout (may be normal)");
|
||||||
Err(AppError::Internal("CH9329 response timeout".to_string()))
|
Err(AppError::Internal("CH9329 response timeout".to_string()))
|
||||||
}
|
}
|
||||||
Err(e) => Err(AppError::Internal(format!("Failed to read from CH9329: {}", e))),
|
Err(e) => Err(AppError::Internal(format!(
|
||||||
|
"Failed to read from CH9329: {}",
|
||||||
|
e
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::Internal("CH9329 port not opened".to_string()))
|
Err(AppError::Internal("CH9329 port not opened".to_string()))
|
||||||
@@ -799,7 +813,9 @@ impl Ch9329Backend {
|
|||||||
if response.is_success() {
|
if response.is_success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::Internal("Failed to restore factory defaults".to_string()))
|
Err(AppError::Internal(
|
||||||
|
"Failed to restore factory defaults".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,7 +836,9 @@ impl Ch9329Backend {
|
|||||||
/// For other multimedia keys: data = [0x02, byte2, byte3, byte4]
|
/// For other multimedia keys: data = [0x02, byte2, byte3, byte4]
|
||||||
pub fn send_media_key(&self, data: &[u8]) -> Result<()> {
|
pub fn send_media_key(&self, data: &[u8]) -> Result<()> {
|
||||||
if data.len() < 2 || data.len() > 4 {
|
if data.len() < 2 || data.len() > 4 {
|
||||||
return Err(AppError::Internal("Invalid media key data length".to_string()));
|
return Err(AppError::Internal(
|
||||||
|
"Invalid media key data length".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
self.send_packet(cmd::SEND_KB_MEDIA_DATA, data)
|
self.send_packet(cmd::SEND_KB_MEDIA_DATA, data)
|
||||||
}
|
}
|
||||||
@@ -871,10 +889,7 @@ impl Ch9329Backend {
|
|||||||
// Use send_packet which has retry logic built-in
|
// Use send_packet which has retry logic built-in
|
||||||
self.send_packet(cmd::SEND_MS_ABS_DATA, &data)?;
|
self.send_packet(cmd::SEND_MS_ABS_DATA, &data)?;
|
||||||
|
|
||||||
trace!(
|
trace!("CH9329 mouse: buttons=0x{:02X} pos=({},{})", buttons, x, y);
|
||||||
"CH9329 mouse: buttons=0x{:02X} pos=({},{})",
|
|
||||||
buttons, x, y
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -930,7 +945,11 @@ impl HidBackend for Ch9329Backend {
|
|||||||
info!(
|
info!(
|
||||||
"CH9329 chip detected: {}, USB: {}, LEDs: NumLock={}, CapsLock={}, ScrollLock={}",
|
"CH9329 chip detected: {}, USB: {}, LEDs: NumLock={}, CapsLock={}, ScrollLock={}",
|
||||||
info.version,
|
info.version,
|
||||||
if info.usb_connected { "connected" } else { "disconnected" },
|
if info.usb_connected {
|
||||||
|
"connected"
|
||||||
|
} else {
|
||||||
|
"disconnected"
|
||||||
|
},
|
||||||
info.num_lock,
|
info.num_lock,
|
||||||
info.caps_lock,
|
info.caps_lock,
|
||||||
info.scroll_lock
|
info.scroll_lock
|
||||||
@@ -1128,10 +1147,7 @@ pub fn detect_ch9329() -> Option<String> {
|
|||||||
&& response[0] == PACKET_HEADER[0]
|
&& response[0] == PACKET_HEADER[0]
|
||||||
&& response[1] == PACKET_HEADER[1]
|
&& response[1] == PACKET_HEADER[1]
|
||||||
{
|
{
|
||||||
info!(
|
info!("CH9329 detected on {} @ {} baud", port_path, baud_rate);
|
||||||
"CH9329 detected on {} @ {} baud",
|
|
||||||
port_path, baud_rate
|
|
||||||
);
|
|
||||||
return Some(port_path.to_string());
|
return Some(port_path.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1176,10 +1192,7 @@ pub fn detect_ch9329_with_baud() -> Option<(String, u32)> {
|
|||||||
&& response[0] == PACKET_HEADER[0]
|
&& response[0] == PACKET_HEADER[0]
|
||||||
&& response[1] == PACKET_HEADER[1]
|
&& response[1] == PACKET_HEADER[1]
|
||||||
{
|
{
|
||||||
info!(
|
info!("CH9329 detected on {} @ {} baud", port_path, baud_rate);
|
||||||
"CH9329 detected on {} @ {} baud",
|
|
||||||
port_path, baud_rate
|
|
||||||
);
|
|
||||||
return Some((port_path.to_string(), baud_rate));
|
return Some((port_path.to_string(), baud_rate));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1248,7 +1261,9 @@ mod tests {
|
|||||||
assert_eq!(checksum, 0x03);
|
assert_eq!(checksum, 0x03);
|
||||||
|
|
||||||
// Known packet: Keyboard 'A' press
|
// Known packet: Keyboard 'A' press
|
||||||
let packet = [0x57u8, 0xAB, 0x00, 0x02, 0x08, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00];
|
let packet = [
|
||||||
|
0x57u8, 0xAB, 0x00, 0x02, 0x08, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
];
|
||||||
let checksum = Ch9329Backend::calculate_checksum(&packet);
|
let checksum = Ch9329Backend::calculate_checksum(&packet);
|
||||||
assert_eq!(checksum, 0x10);
|
assert_eq!(checksum, 0x10);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,27 +210,23 @@ pub fn encode_mouse_event(event: &MouseEvent) -> Vec<u8> {
|
|||||||
let y_bytes = (event.y as i16).to_le_bytes();
|
let y_bytes = (event.y as i16).to_le_bytes();
|
||||||
|
|
||||||
let extra = match event.event_type {
|
let extra = match event.event_type {
|
||||||
MouseEventType::Down | MouseEventType::Up => {
|
MouseEventType::Down | MouseEventType::Up => event
|
||||||
event.button.as_ref().map(|b| match b {
|
.button
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| match b {
|
||||||
MouseButton::Left => 0u8,
|
MouseButton::Left => 0u8,
|
||||||
MouseButton::Middle => 1u8,
|
MouseButton::Middle => 1u8,
|
||||||
MouseButton::Right => 2u8,
|
MouseButton::Right => 2u8,
|
||||||
MouseButton::Back => 3u8,
|
MouseButton::Back => 3u8,
|
||||||
MouseButton::Forward => 4u8,
|
MouseButton::Forward => 4u8,
|
||||||
}).unwrap_or(0)
|
})
|
||||||
}
|
.unwrap_or(0),
|
||||||
MouseEventType::Scroll => event.scroll as u8,
|
MouseEventType::Scroll => event.scroll as u8,
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
vec![
|
vec![
|
||||||
MSG_MOUSE,
|
MSG_MOUSE, event_type, x_bytes[0], x_bytes[1], y_bytes[0], y_bytes[1], extra,
|
||||||
event_type,
|
|
||||||
x_bytes[0],
|
|
||||||
x_bytes[1],
|
|
||||||
y_bytes[0],
|
|
||||||
y_bytes[1],
|
|
||||||
extra,
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,8 +102,14 @@ impl HidController {
|
|||||||
info!("Creating OTG HID backend from device paths");
|
info!("Creating OTG HID backend from device paths");
|
||||||
Box::new(otg::OtgBackend::from_handles(handles)?)
|
Box::new(otg::OtgBackend::from_handles(handles)?)
|
||||||
}
|
}
|
||||||
HidBackendType::Ch9329 { ref port, baud_rate } => {
|
HidBackendType::Ch9329 {
|
||||||
info!("Initializing CH9329 HID backend on {} @ {} baud", port, baud_rate);
|
ref port,
|
||||||
|
baud_rate,
|
||||||
|
} => {
|
||||||
|
info!(
|
||||||
|
"Initializing CH9329 HID backend on {} @ {} baud",
|
||||||
|
port, baud_rate
|
||||||
|
);
|
||||||
Box::new(ch9329::Ch9329Backend::with_baud_rate(port, baud_rate)?)
|
Box::new(ch9329::Ch9329Backend::with_baud_rate(port, baud_rate)?)
|
||||||
}
|
}
|
||||||
HidBackendType::None => {
|
HidBackendType::None => {
|
||||||
@@ -157,16 +163,25 @@ impl HidController {
|
|||||||
// Report error to monitor, but skip temporary EAGAIN retries
|
// Report error to monitor, but skip temporary EAGAIN retries
|
||||||
// - "eagain_retry": within threshold, just temporary busy
|
// - "eagain_retry": within threshold, just temporary busy
|
||||||
// - "eagain": exceeded threshold, report as error
|
// - "eagain": exceeded threshold, report as error
|
||||||
if let AppError::HidError { ref backend, ref reason, ref error_code } = e {
|
if let AppError::HidError {
|
||||||
|
ref backend,
|
||||||
|
ref reason,
|
||||||
|
ref error_code,
|
||||||
|
} = e
|
||||||
|
{
|
||||||
if error_code != "eagain_retry" {
|
if error_code != "eagain_retry" {
|
||||||
self.monitor.report_error(backend, None, reason, error_code).await;
|
self.monitor
|
||||||
|
.report_error(backend, None, reason, error_code)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e)
|
Err(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => Err(AppError::BadRequest("HID backend not available".to_string())),
|
None => Err(AppError::BadRequest(
|
||||||
|
"HID backend not available".to_string(),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,16 +203,25 @@ impl HidController {
|
|||||||
// Report error to monitor, but skip temporary EAGAIN retries
|
// Report error to monitor, but skip temporary EAGAIN retries
|
||||||
// - "eagain_retry": within threshold, just temporary busy
|
// - "eagain_retry": within threshold, just temporary busy
|
||||||
// - "eagain": exceeded threshold, report as error
|
// - "eagain": exceeded threshold, report as error
|
||||||
if let AppError::HidError { ref backend, ref reason, ref error_code } = e {
|
if let AppError::HidError {
|
||||||
|
ref backend,
|
||||||
|
ref reason,
|
||||||
|
ref error_code,
|
||||||
|
} = e
|
||||||
|
{
|
||||||
if error_code != "eagain_retry" {
|
if error_code != "eagain_retry" {
|
||||||
self.monitor.report_error(backend, None, reason, error_code).await;
|
self.monitor
|
||||||
|
.report_error(backend, None, reason, error_code)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e)
|
Err(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => Err(AppError::BadRequest("HID backend not available".to_string())),
|
None => Err(AppError::BadRequest(
|
||||||
|
"HID backend not available".to_string(),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,8 +229,7 @@ impl HidController {
|
|||||||
pub async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
|
pub async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
|
||||||
let backend = self.backend.read().await;
|
let backend = self.backend.read().await;
|
||||||
match backend.as_ref() {
|
match backend.as_ref() {
|
||||||
Some(b) => {
|
Some(b) => match b.send_consumer(event).await {
|
||||||
match b.send_consumer(event).await {
|
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
if self.monitor.is_error().await {
|
if self.monitor.is_error().await {
|
||||||
let backend_type = self.backend_type.read().await;
|
let backend_type = self.backend_type.read().await;
|
||||||
@@ -215,16 +238,24 @@ impl HidController {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if let AppError::HidError { ref backend, ref reason, ref error_code } = e {
|
if let AppError::HidError {
|
||||||
|
ref backend,
|
||||||
|
ref reason,
|
||||||
|
ref error_code,
|
||||||
|
} = e
|
||||||
|
{
|
||||||
if error_code != "eagain_retry" {
|
if error_code != "eagain_retry" {
|
||||||
self.monitor.report_error(backend, None, reason, error_code).await;
|
self.monitor
|
||||||
|
.report_error(backend, None, reason, error_code)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e)
|
Err(e)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
None => Err(AppError::BadRequest(
|
||||||
None => Err(AppError::BadRequest("HID backend not available".to_string())),
|
"HID backend not available".to_string(),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,9 +300,9 @@ impl HidController {
|
|||||||
|
|
||||||
// Include error information from monitor
|
// Include error information from monitor
|
||||||
let (error, error_code) = match self.monitor.status().await {
|
let (error, error_code) = match self.monitor.status().await {
|
||||||
HidHealthStatus::Error { reason, error_code, .. } => {
|
HidHealthStatus::Error {
|
||||||
(Some(reason), Some(error_code))
|
reason, error_code, ..
|
||||||
}
|
} => (Some(reason), Some(error_code)),
|
||||||
_ => (None, None),
|
_ => (None, None),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -320,7 +351,7 @@ impl HidController {
|
|||||||
None => {
|
None => {
|
||||||
warn!("OTG backend requires OtgService, but it's not available");
|
warn!("OTG backend requires OtgService, but it's not available");
|
||||||
return Err(AppError::Config(
|
return Err(AppError::Config(
|
||||||
"OTG backend not available (OtgService missing)".to_string()
|
"OTG backend not available (OtgService missing)".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -341,7 +372,10 @@ impl HidController {
|
|||||||
warn!("Failed to initialize OTG backend: {}", e);
|
warn!("Failed to initialize OTG backend: {}", e);
|
||||||
// Cleanup: disable HID in OtgService
|
// Cleanup: disable HID in OtgService
|
||||||
if let Err(e2) = otg_service.disable_hid().await {
|
if let Err(e2) = otg_service.disable_hid().await {
|
||||||
warn!("Failed to cleanup HID after init failure: {}", e2);
|
warn!(
|
||||||
|
"Failed to cleanup HID after init failure: {}",
|
||||||
|
e2
|
||||||
|
);
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -363,8 +397,14 @@ impl HidController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HidBackendType::Ch9329 { ref port, baud_rate } => {
|
HidBackendType::Ch9329 {
|
||||||
info!("Initializing CH9329 HID backend on {} @ {} baud", port, baud_rate);
|
ref port,
|
||||||
|
baud_rate,
|
||||||
|
} => {
|
||||||
|
info!(
|
||||||
|
"Initializing CH9329 HID backend on {} @ {} baud",
|
||||||
|
port, baud_rate
|
||||||
|
);
|
||||||
match ch9329::Ch9329Backend::with_baud_rate(port, baud_rate) {
|
match ch9329::Ch9329Backend::with_baud_rate(port, baud_rate) {
|
||||||
Ok(b) => {
|
Ok(b) => {
|
||||||
let boxed = Box::new(b);
|
let boxed = Box::new(b);
|
||||||
|
|||||||
@@ -144,7 +144,8 @@ impl HidHealthMonitor {
|
|||||||
// Check if we're in cooldown period after recent recovery
|
// Check if we're in cooldown period after recent recovery
|
||||||
let current_ms = self.start_instant.elapsed().as_millis() as u64;
|
let current_ms = self.start_instant.elapsed().as_millis() as u64;
|
||||||
let last_recovery = self.last_recovery_ms.load(Ordering::Relaxed);
|
let last_recovery = self.last_recovery_ms.load(Ordering::Relaxed);
|
||||||
let in_cooldown = last_recovery > 0 && current_ms < last_recovery + self.config.recovery_cooldown_ms;
|
let in_cooldown =
|
||||||
|
last_recovery > 0 && current_ms < last_recovery + self.config.recovery_cooldown_ms;
|
||||||
|
|
||||||
// Check if error code changed
|
// Check if error code changed
|
||||||
let error_changed = {
|
let error_changed = {
|
||||||
@@ -229,10 +230,7 @@ impl HidHealthMonitor {
|
|||||||
// Only log and publish events if there were multiple retries
|
// Only log and publish events if there were multiple retries
|
||||||
// (avoid log spam for transient single-retry recoveries)
|
// (avoid log spam for transient single-retry recoveries)
|
||||||
if retry_count > 1 {
|
if retry_count > 1 {
|
||||||
debug!(
|
debug!("HID {} recovered after {} retries", backend, retry_count);
|
||||||
"HID {} recovered after {} retries",
|
|
||||||
backend, retry_count
|
|
||||||
);
|
|
||||||
|
|
||||||
// Publish recovery event
|
// Publish recovery event
|
||||||
if let Some(ref events) = *self.events.read().await {
|
if let Some(ref events) = *self.events.read().await {
|
||||||
@@ -372,9 +370,7 @@ mod tests {
|
|||||||
let monitor = HidHealthMonitor::with_defaults();
|
let monitor = HidHealthMonitor::with_defaults();
|
||||||
|
|
||||||
for i in 1..=5 {
|
for i in 1..=5 {
|
||||||
monitor
|
monitor.report_error("otg", None, "Error", "io_error").await;
|
||||||
.report_error("otg", None, "Error", "io_error")
|
|
||||||
.await;
|
|
||||||
assert_eq!(monitor.retry_count(), i);
|
assert_eq!(monitor.retry_count(), i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,9 +383,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for _ in 0..100 {
|
for _ in 0..100 {
|
||||||
monitor
|
monitor.report_error("otg", None, "Error", "io_error").await;
|
||||||
.report_error("otg", None, "Error", "io_error")
|
|
||||||
.await;
|
|
||||||
assert!(monitor.should_retry());
|
assert!(monitor.should_retry());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,9 +411,7 @@ mod tests {
|
|||||||
async fn test_reset() {
|
async fn test_reset() {
|
||||||
let monitor = HidHealthMonitor::with_defaults();
|
let monitor = HidHealthMonitor::with_defaults();
|
||||||
|
|
||||||
monitor
|
monitor.report_error("otg", None, "Error", "io_error").await;
|
||||||
.report_error("otg", None, "Error", "io_error")
|
|
||||||
.await;
|
|
||||||
assert!(monitor.is_error().await);
|
assert!(monitor.is_error().await);
|
||||||
|
|
||||||
monitor.reset().await;
|
monitor.reset().await;
|
||||||
|
|||||||
128
src/hid/otg.rs
128
src/hid/otg.rs
@@ -30,9 +30,11 @@ use tracing::{debug, info, trace, warn};
|
|||||||
|
|
||||||
use super::backend::HidBackend;
|
use super::backend::HidBackend;
|
||||||
use super::keymap;
|
use super::keymap;
|
||||||
use super::types::{ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType};
|
use super::types::{
|
||||||
|
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType,
|
||||||
|
};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::otg::{HidDevicePaths, wait_for_hid_devices};
|
use crate::otg::{wait_for_hid_devices, HidDevicePaths};
|
||||||
|
|
||||||
/// Device type for ensure_device operations
|
/// Device type for ensure_device operations
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
@@ -73,11 +75,21 @@ impl LedState {
|
|||||||
/// Convert to raw byte
|
/// Convert to raw byte
|
||||||
pub fn to_byte(&self) -> u8 {
|
pub fn to_byte(&self) -> u8 {
|
||||||
let mut b = 0u8;
|
let mut b = 0u8;
|
||||||
if self.num_lock { b |= 0x01; }
|
if self.num_lock {
|
||||||
if self.caps_lock { b |= 0x02; }
|
b |= 0x01;
|
||||||
if self.scroll_lock { b |= 0x04; }
|
}
|
||||||
if self.compose { b |= 0x08; }
|
if self.caps_lock {
|
||||||
if self.kana { b |= 0x10; }
|
b |= 0x02;
|
||||||
|
}
|
||||||
|
if self.scroll_lock {
|
||||||
|
b |= 0x04;
|
||||||
|
}
|
||||||
|
if self.compose {
|
||||||
|
b |= 0x08;
|
||||||
|
}
|
||||||
|
if self.kana {
|
||||||
|
b |= 0x10;
|
||||||
|
}
|
||||||
b
|
b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,7 +157,9 @@ impl OtgBackend {
|
|||||||
keyboard_path: paths.keyboard,
|
keyboard_path: paths.keyboard,
|
||||||
mouse_rel_path: paths.mouse_relative,
|
mouse_rel_path: paths.mouse_relative,
|
||||||
mouse_abs_path: paths.mouse_absolute,
|
mouse_abs_path: paths.mouse_absolute,
|
||||||
consumer_path: paths.consumer.unwrap_or_else(|| PathBuf::from("/dev/hidg3")),
|
consumer_path: paths
|
||||||
|
.consumer
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/dev/hidg3")),
|
||||||
keyboard_dev: Mutex::new(None),
|
keyboard_dev: Mutex::new(None),
|
||||||
mouse_rel_dev: Mutex::new(None),
|
mouse_rel_dev: Mutex::new(None),
|
||||||
mouse_abs_dev: Mutex::new(None),
|
mouse_abs_dev: Mutex::new(None),
|
||||||
@@ -198,7 +212,8 @@ impl OtgBackend {
|
|||||||
Ok(1) => {
|
Ok(1) => {
|
||||||
// Device ready, check for errors
|
// Device ready, check for errors
|
||||||
if let Some(revents) = pollfd[0].revents() {
|
if let Some(revents) = pollfd[0].revents() {
|
||||||
if revents.contains(PollFlags::POLLERR) || revents.contains(PollFlags::POLLHUP) {
|
if revents.contains(PollFlags::POLLERR) || revents.contains(PollFlags::POLLHUP)
|
||||||
|
{
|
||||||
return Err(std::io::Error::new(
|
return Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::BrokenPipe,
|
std::io::ErrorKind::BrokenPipe,
|
||||||
"Device error or hangup",
|
"Device error or hangup",
|
||||||
@@ -297,7 +312,10 @@ impl OtgBackend {
|
|||||||
// Close the device if open (device was removed)
|
// Close the device if open (device was removed)
|
||||||
let mut dev = dev_mutex.lock();
|
let mut dev = dev_mutex.lock();
|
||||||
if dev.is_some() {
|
if dev.is_some() {
|
||||||
debug!("Device path {} no longer exists, closing handle", path.display());
|
debug!(
|
||||||
|
"Device path {} no longer exists, closing handle",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
*dev = None;
|
*dev = None;
|
||||||
}
|
}
|
||||||
self.online.store(false, Ordering::Relaxed);
|
self.online.store(false, Ordering::Relaxed);
|
||||||
@@ -335,7 +353,11 @@ impl OtgBackend {
|
|||||||
.custom_flags(libc::O_NONBLOCK)
|
.custom_flags(libc::O_NONBLOCK)
|
||||||
.open(path)
|
.open(path)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
AppError::Internal(format!("Failed to open HID device {}: {}", path.display(), e))
|
AppError::Internal(format!(
|
||||||
|
"Failed to open HID device {}: {}",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,9 +383,7 @@ impl OtgBackend {
|
|||||||
|
|
||||||
/// Check if all HID device files exist
|
/// Check if all HID device files exist
|
||||||
pub fn check_devices_exist(&self) -> bool {
|
pub fn check_devices_exist(&self) -> bool {
|
||||||
self.keyboard_path.exists()
|
self.keyboard_path.exists() && self.mouse_rel_path.exists() && self.mouse_abs_path.exists()
|
||||||
&& self.mouse_rel_path.exists()
|
|
||||||
&& self.mouse_abs_path.exists()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get list of missing device paths
|
/// Get list of missing device paths
|
||||||
@@ -415,7 +435,10 @@ impl OtgBackend {
|
|||||||
self.eagain_count.store(0, Ordering::Relaxed);
|
self.eagain_count.store(0, Ordering::Relaxed);
|
||||||
debug!("Keyboard ESHUTDOWN, closing for recovery");
|
debug!("Keyboard ESHUTDOWN, closing for recovery");
|
||||||
*dev = None;
|
*dev = None;
|
||||||
Err(Self::io_error_to_hid_error(e, "Failed to write keyboard report"))
|
Err(Self::io_error_to_hid_error(
|
||||||
|
e,
|
||||||
|
"Failed to write keyboard report",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
Some(11) => {
|
Some(11) => {
|
||||||
// EAGAIN after poll - should be rare, silently drop
|
// EAGAIN after poll - should be rare, silently drop
|
||||||
@@ -426,7 +449,10 @@ impl OtgBackend {
|
|||||||
self.online.store(false, Ordering::Relaxed);
|
self.online.store(false, Ordering::Relaxed);
|
||||||
self.eagain_count.store(0, Ordering::Relaxed);
|
self.eagain_count.store(0, Ordering::Relaxed);
|
||||||
warn!("Keyboard write error: {}", e);
|
warn!("Keyboard write error: {}", e);
|
||||||
Err(Self::io_error_to_hid_error(e, "Failed to write keyboard report"))
|
Err(Self::io_error_to_hid_error(
|
||||||
|
e,
|
||||||
|
"Failed to write keyboard report",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,7 +498,10 @@ impl OtgBackend {
|
|||||||
self.eagain_count.store(0, Ordering::Relaxed);
|
self.eagain_count.store(0, Ordering::Relaxed);
|
||||||
debug!("Relative mouse ESHUTDOWN, closing for recovery");
|
debug!("Relative mouse ESHUTDOWN, closing for recovery");
|
||||||
*dev = None;
|
*dev = None;
|
||||||
Err(Self::io_error_to_hid_error(e, "Failed to write mouse report"))
|
Err(Self::io_error_to_hid_error(
|
||||||
|
e,
|
||||||
|
"Failed to write mouse report",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
Some(11) => {
|
Some(11) => {
|
||||||
// EAGAIN after poll - should be rare, silently drop
|
// EAGAIN after poll - should be rare, silently drop
|
||||||
@@ -482,7 +511,10 @@ impl OtgBackend {
|
|||||||
self.online.store(false, Ordering::Relaxed);
|
self.online.store(false, Ordering::Relaxed);
|
||||||
self.eagain_count.store(0, Ordering::Relaxed);
|
self.eagain_count.store(0, Ordering::Relaxed);
|
||||||
warn!("Relative mouse write error: {}", e);
|
warn!("Relative mouse write error: {}", e);
|
||||||
Err(Self::io_error_to_hid_error(e, "Failed to write mouse report"))
|
Err(Self::io_error_to_hid_error(
|
||||||
|
e,
|
||||||
|
"Failed to write mouse report",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -534,7 +566,10 @@ impl OtgBackend {
|
|||||||
self.eagain_count.store(0, Ordering::Relaxed);
|
self.eagain_count.store(0, Ordering::Relaxed);
|
||||||
debug!("Absolute mouse ESHUTDOWN, closing for recovery");
|
debug!("Absolute mouse ESHUTDOWN, closing for recovery");
|
||||||
*dev = None;
|
*dev = None;
|
||||||
Err(Self::io_error_to_hid_error(e, "Failed to write mouse report"))
|
Err(Self::io_error_to_hid_error(
|
||||||
|
e,
|
||||||
|
"Failed to write mouse report",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
Some(11) => {
|
Some(11) => {
|
||||||
// EAGAIN after poll - should be rare, silently drop
|
// EAGAIN after poll - should be rare, silently drop
|
||||||
@@ -544,7 +579,10 @@ impl OtgBackend {
|
|||||||
self.online.store(false, Ordering::Relaxed);
|
self.online.store(false, Ordering::Relaxed);
|
||||||
self.eagain_count.store(0, Ordering::Relaxed);
|
self.eagain_count.store(0, Ordering::Relaxed);
|
||||||
warn!("Absolute mouse write error: {}", e);
|
warn!("Absolute mouse write error: {}", e);
|
||||||
Err(Self::io_error_to_hid_error(e, "Failed to write mouse report"))
|
Err(Self::io_error_to_hid_error(
|
||||||
|
e,
|
||||||
|
"Failed to write mouse report",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -590,7 +628,10 @@ impl OtgBackend {
|
|||||||
self.online.store(false, Ordering::Relaxed);
|
self.online.store(false, Ordering::Relaxed);
|
||||||
debug!("Consumer control ESHUTDOWN, closing for recovery");
|
debug!("Consumer control ESHUTDOWN, closing for recovery");
|
||||||
*dev = None;
|
*dev = None;
|
||||||
Err(Self::io_error_to_hid_error(e, "Failed to write consumer report"))
|
Err(Self::io_error_to_hid_error(
|
||||||
|
e,
|
||||||
|
"Failed to write consumer report",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
Some(11) => {
|
Some(11) => {
|
||||||
// EAGAIN after poll - silently drop
|
// EAGAIN after poll - silently drop
|
||||||
@@ -599,7 +640,10 @@ impl OtgBackend {
|
|||||||
_ => {
|
_ => {
|
||||||
self.online.store(false, Ordering::Relaxed);
|
self.online.store(false, Ordering::Relaxed);
|
||||||
warn!("Consumer control write error: {}", e);
|
warn!("Consumer control write error: {}", e);
|
||||||
Err(Self::io_error_to_hid_error(e, "Failed to write consumer report"))
|
Err(Self::io_error_to_hid_error(
|
||||||
|
e,
|
||||||
|
"Failed to write consumer report",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -632,7 +676,10 @@ impl OtgBackend {
|
|||||||
}
|
}
|
||||||
Ok(_) => Ok(None), // No data available
|
Ok(_) => Ok(None), // No data available
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
|
||||||
Err(e) => Err(AppError::Internal(format!("Failed to read LED state: {}", e))),
|
Err(e) => Err(AppError::Internal(format!(
|
||||||
|
"Failed to read LED state: {}",
|
||||||
|
e
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -677,34 +724,55 @@ impl HidBackend for OtgBackend {
|
|||||||
*self.keyboard_dev.lock() = Some(file);
|
*self.keyboard_dev.lock() = Some(file);
|
||||||
info!("Keyboard device opened: {}", self.keyboard_path.display());
|
info!("Keyboard device opened: {}", self.keyboard_path.display());
|
||||||
} else {
|
} else {
|
||||||
warn!("Keyboard device not found: {}", self.keyboard_path.display());
|
warn!(
|
||||||
|
"Keyboard device not found: {}",
|
||||||
|
self.keyboard_path.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open relative mouse device
|
// Open relative mouse device
|
||||||
if self.mouse_rel_path.exists() {
|
if self.mouse_rel_path.exists() {
|
||||||
let file = Self::open_device(&self.mouse_rel_path)?;
|
let file = Self::open_device(&self.mouse_rel_path)?;
|
||||||
*self.mouse_rel_dev.lock() = Some(file);
|
*self.mouse_rel_dev.lock() = Some(file);
|
||||||
info!("Relative mouse device opened: {}", self.mouse_rel_path.display());
|
info!(
|
||||||
|
"Relative mouse device opened: {}",
|
||||||
|
self.mouse_rel_path.display()
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
warn!("Relative mouse device not found: {}", self.mouse_rel_path.display());
|
warn!(
|
||||||
|
"Relative mouse device not found: {}",
|
||||||
|
self.mouse_rel_path.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open absolute mouse device
|
// Open absolute mouse device
|
||||||
if self.mouse_abs_path.exists() {
|
if self.mouse_abs_path.exists() {
|
||||||
let file = Self::open_device(&self.mouse_abs_path)?;
|
let file = Self::open_device(&self.mouse_abs_path)?;
|
||||||
*self.mouse_abs_dev.lock() = Some(file);
|
*self.mouse_abs_dev.lock() = Some(file);
|
||||||
info!("Absolute mouse device opened: {}", self.mouse_abs_path.display());
|
info!(
|
||||||
|
"Absolute mouse device opened: {}",
|
||||||
|
self.mouse_abs_path.display()
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
warn!("Absolute mouse device not found: {}", self.mouse_abs_path.display());
|
warn!(
|
||||||
|
"Absolute mouse device not found: {}",
|
||||||
|
self.mouse_abs_path.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open consumer control device (optional, may not exist on older setups)
|
// Open consumer control device (optional, may not exist on older setups)
|
||||||
if self.consumer_path.exists() {
|
if self.consumer_path.exists() {
|
||||||
let file = Self::open_device(&self.consumer_path)?;
|
let file = Self::open_device(&self.consumer_path)?;
|
||||||
*self.consumer_dev.lock() = Some(file);
|
*self.consumer_dev.lock() = Some(file);
|
||||||
info!("Consumer control device opened: {}", self.consumer_path.display());
|
info!(
|
||||||
|
"Consumer control device opened: {}",
|
||||||
|
self.consumer_path.display()
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
debug!("Consumer control device not found: {}", self.consumer_path.display());
|
debug!(
|
||||||
|
"Consumer control device not found: {}",
|
||||||
|
self.consumer_path.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as online if all devices opened successfully
|
// Mark as online if all devices opened successfully
|
||||||
|
|||||||
@@ -341,12 +341,7 @@ pub struct MouseReport {
|
|||||||
impl MouseReport {
|
impl MouseReport {
|
||||||
/// Convert to bytes for USB HID (relative mouse)
|
/// Convert to bytes for USB HID (relative mouse)
|
||||||
pub fn to_bytes_relative(&self) -> [u8; 4] {
|
pub fn to_bytes_relative(&self) -> [u8; 4] {
|
||||||
[
|
[self.buttons, self.x as u8, self.y as u8, self.wheel as u8]
|
||||||
self.buttons,
|
|
||||||
self.x as u8,
|
|
||||||
self.y as u8,
|
|
||||||
self.wheel as u8,
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert to bytes for USB HID (absolute mouse)
|
/// Convert to bytes for USB HID (absolute mouse)
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ async fn handle_hid_socket(socket: WebSocket, state: Arc<AppState>) {
|
|||||||
vec![RESP_ERR_HID_UNAVAILABLE]
|
vec![RESP_ERR_HID_UNAVAILABLE]
|
||||||
};
|
};
|
||||||
|
|
||||||
if sender.send(Message::Binary(initial_response.into())).await.is_err() {
|
if sender
|
||||||
|
.send(Message::Binary(initial_response.into()))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
error!("Failed to send initial HID status");
|
error!("Failed to send initial HID status");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -66,7 +70,9 @@ async fn handle_hid_socket(socket: WebSocket, state: Arc<AppState>) {
|
|||||||
warn!("HID controller not available, ignoring message");
|
warn!("HID controller not available, ignoring message");
|
||||||
}
|
}
|
||||||
// Send error response (optional, for client awareness)
|
// Send error response (optional, for client awareness)
|
||||||
let _ = sender.send(Message::Binary(vec![RESP_ERR_HID_UNAVAILABLE].into())).await;
|
let _ = sender
|
||||||
|
.send(Message::Binary(vec![RESP_ERR_HID_UNAVAILABLE].into()))
|
||||||
|
.await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,9 +87,14 @@ async fn handle_hid_socket(socket: WebSocket, state: Arc<AppState>) {
|
|||||||
Ok(Message::Text(text)) => {
|
Ok(Message::Text(text)) => {
|
||||||
// Text messages are no longer supported
|
// Text messages are no longer supported
|
||||||
if log_throttler.should_log("text_message_rejected") {
|
if log_throttler.should_log("text_message_rejected") {
|
||||||
debug!("Received text message (not supported): {} bytes", text.len());
|
debug!(
|
||||||
|
"Received text message (not supported): {} bytes",
|
||||||
|
text.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let _ = sender.send(Message::Binary(vec![RESP_ERR_INVALID_MESSAGE].into())).await;
|
let _ = sender
|
||||||
|
.send(Message::Binary(vec![RESP_ERR_INVALID_MESSAGE].into()))
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
Ok(Message::Ping(data)) => {
|
Ok(Message::Ping(data)) => {
|
||||||
let _ = sender.send(Message::Pong(data)).await;
|
let _ = sender.send(Message::Pong(data)).await;
|
||||||
@@ -142,7 +153,7 @@ async fn handle_binary_message(data: &[u8], state: &AppState) -> Result<(), Stri
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::hid::datachannel::{MSG_KEYBOARD, MSG_MOUSE, KB_EVENT_DOWN, MS_EVENT_MOVE};
|
use crate::hid::datachannel::{KB_EVENT_DOWN, MSG_KEYBOARD, MSG_MOUSE, MS_EVENT_MOVE};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_response_codes() {
|
fn test_response_codes() {
|
||||||
|
|||||||
139
src/main.rs
139
src/main.rs
@@ -4,9 +4,9 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use axum_server::tls_rustls::RustlsConfig;
|
use axum_server::tls_rustls::RustlsConfig;
|
||||||
use clap::{Parser, ValueEnum};
|
use clap::{Parser, ValueEnum};
|
||||||
|
use rustls::crypto::{ring, CryptoProvider};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
use rustls::crypto::{ring, CryptoProvider};
|
|
||||||
|
|
||||||
use one_kvm::atx::AtxController;
|
use one_kvm::atx::AtxController;
|
||||||
use one_kvm::audio::{AudioController, AudioControllerConfig, AudioQuality};
|
use one_kvm::audio::{AudioController, AudioControllerConfig, AudioQuality};
|
||||||
@@ -26,7 +26,15 @@ use one_kvm::webrtc::{WebRtcStreamer, WebRtcStreamerConfig};
|
|||||||
|
|
||||||
/// Log level for the application
|
/// Log level for the application
|
||||||
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
|
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
|
||||||
enum LogLevel {Error, Warn, #[default] Info, Verbose, Debug, Trace,}
|
enum LogLevel {
|
||||||
|
Error,
|
||||||
|
Warn,
|
||||||
|
#[default]
|
||||||
|
Info,
|
||||||
|
Verbose,
|
||||||
|
Debug,
|
||||||
|
Trace,
|
||||||
|
}
|
||||||
|
|
||||||
/// One-KVM command line arguments
|
/// One-KVM command line arguments
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -82,10 +90,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
CryptoProvider::install_default(ring::default_provider())
|
CryptoProvider::install_default(ring::default_provider())
|
||||||
.expect("Failed to install rustls crypto provider");
|
.expect("Failed to install rustls crypto provider");
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!("Starting One-KVM v{}", env!("CARGO_PKG_VERSION"));
|
||||||
"Starting One-KVM v{}",
|
|
||||||
env!("CARGO_PKG_VERSION")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Determine data directory (CLI arg takes precedence)
|
// Determine data directory (CLI arg takes precedence)
|
||||||
let data_dir = args.data_dir.unwrap_or_else(get_data_dir);
|
let data_dir = args.data_dir.unwrap_or_else(get_data_dir);
|
||||||
@@ -153,21 +158,37 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Parse video configuration once (avoid duplication)
|
// Parse video configuration once (avoid duplication)
|
||||||
let (video_format, video_resolution) = parse_video_config(&config);
|
let (video_format, video_resolution) = parse_video_config(&config);
|
||||||
tracing::debug!("Parsed video config: {} @ {}x{}", video_format, video_resolution.width, video_resolution.height);
|
tracing::debug!(
|
||||||
|
"Parsed video config: {} @ {}x{}",
|
||||||
|
video_format,
|
||||||
|
video_resolution.width,
|
||||||
|
video_resolution.height
|
||||||
|
);
|
||||||
|
|
||||||
// Create video streamer and initialize with config if device is set
|
// Create video streamer and initialize with config if device is set
|
||||||
let streamer = Streamer::new();
|
let streamer = Streamer::new();
|
||||||
streamer.set_event_bus(events.clone()).await;
|
streamer.set_event_bus(events.clone()).await;
|
||||||
if let Some(ref device_path) = config.video.device {
|
if let Some(ref device_path) = config.video.device {
|
||||||
if let Err(e) = streamer
|
if let Err(e) = streamer
|
||||||
.apply_video_config(device_path, video_format, video_resolution, config.video.fps)
|
.apply_video_config(
|
||||||
|
device_path,
|
||||||
|
video_format,
|
||||||
|
video_resolution,
|
||||||
|
config.video.fps,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!("Failed to initialize video with config: {}, will auto-detect", e);
|
tracing::warn!(
|
||||||
|
"Failed to initialize video with config: {}, will auto-detect",
|
||||||
|
e
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Video configured: {} @ {}x{} {}",
|
"Video configured: {} @ {}x{} {}",
|
||||||
device_path, video_resolution.width, video_resolution.height, video_format
|
device_path,
|
||||||
|
video_resolution.width,
|
||||||
|
video_resolution.height,
|
||||||
|
video_format
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,8 +206,18 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let mut turn_servers = vec![];
|
let mut turn_servers = vec![];
|
||||||
|
|
||||||
// Check if user configured custom servers
|
// Check if user configured custom servers
|
||||||
let has_custom_stun = config.stream.stun_server.as_ref().map(|s| !s.is_empty()).unwrap_or(false);
|
let has_custom_stun = config
|
||||||
let has_custom_turn = config.stream.turn_server.as_ref().map(|s| !s.is_empty()).unwrap_or(false);
|
.stream
|
||||||
|
.stun_server
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| !s.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let has_custom_turn = config
|
||||||
|
.stream
|
||||||
|
.turn_server
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| !s.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
// If no custom servers, use public ICE servers (like RustDesk)
|
// If no custom servers, use public ICE servers (like RustDesk)
|
||||||
if !has_custom_stun && !has_custom_turn {
|
if !has_custom_stun && !has_custom_turn {
|
||||||
@@ -201,7 +232,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
turn_servers.push(turn);
|
turn_servers.push(turn);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::info!("No public ICE servers configured, using host candidates only");
|
tracing::info!(
|
||||||
|
"No public ICE servers configured, using host candidates only"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use custom servers
|
// Use custom servers
|
||||||
@@ -214,13 +247,18 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
if let Some(ref turn) = config.stream.turn_server {
|
if let Some(ref turn) = config.stream.turn_server {
|
||||||
if !turn.is_empty() {
|
if !turn.is_empty() {
|
||||||
let username = config.stream.turn_username.clone().unwrap_or_default();
|
let username = config.stream.turn_username.clone().unwrap_or_default();
|
||||||
let credential = config.stream.turn_password.clone().unwrap_or_default();
|
let credential =
|
||||||
|
config.stream.turn_password.clone().unwrap_or_default();
|
||||||
turn_servers.push(one_kvm::webrtc::config::TurnServer::new(
|
turn_servers.push(one_kvm::webrtc::config::TurnServer::new(
|
||||||
turn.clone(),
|
turn.clone(),
|
||||||
username.clone(),
|
username.clone(),
|
||||||
credential,
|
credential,
|
||||||
));
|
));
|
||||||
tracing::info!("Using custom TURN server: {} (user: {})", turn, username);
|
tracing::info!(
|
||||||
|
"Using custom TURN server: {} (user: {})",
|
||||||
|
turn,
|
||||||
|
username
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,7 +275,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
};
|
};
|
||||||
tracing::info!("WebRTC streamer created (supports H264, extensible to VP8/VP9/H265)");
|
tracing::info!("WebRTC streamer created (supports H264, extensible to VP8/VP9/H265)");
|
||||||
|
|
||||||
|
|
||||||
// Create OTG Service (single instance for centralized USB gadget management)
|
// Create OTG Service (single instance for centralized USB gadget management)
|
||||||
let otg_service = Arc::new(OtgService::new());
|
let otg_service = Arc::new(OtgService::new());
|
||||||
tracing::info!("OTG Service created");
|
tracing::info!("OTG Service created");
|
||||||
@@ -285,14 +322,26 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
if ventoy_resource_dir.exists() {
|
if ventoy_resource_dir.exists() {
|
||||||
if let Err(e) = ventoy_img::init_resources(&ventoy_resource_dir) {
|
if let Err(e) = ventoy_img::init_resources(&ventoy_resource_dir) {
|
||||||
tracing::warn!("Failed to initialize Ventoy resources: {}", e);
|
tracing::warn!("Failed to initialize Ventoy resources: {}", e);
|
||||||
tracing::info!("Ventoy resource files should be placed in: {}", ventoy_resource_dir.display());
|
tracing::info!(
|
||||||
|
"Ventoy resource files should be placed in: {}",
|
||||||
|
ventoy_resource_dir.display()
|
||||||
|
);
|
||||||
tracing::info!("Required files: {:?}", ventoy_img::required_files());
|
tracing::info!("Required files: {:?}", ventoy_img::required_files());
|
||||||
} else {
|
} else {
|
||||||
tracing::info!("Ventoy resources initialized from {}", ventoy_resource_dir.display());
|
tracing::info!(
|
||||||
|
"Ventoy resources initialized from {}",
|
||||||
|
ventoy_resource_dir.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("Ventoy resource directory not found: {}", ventoy_resource_dir.display());
|
tracing::warn!(
|
||||||
tracing::info!("Create the directory and place the following files: {:?}", ventoy_img::required_files());
|
"Ventoy resource directory not found: {}",
|
||||||
|
ventoy_resource_dir.display()
|
||||||
|
);
|
||||||
|
tracing::info!(
|
||||||
|
"Create the directory and place the following files: {:?}",
|
||||||
|
ventoy_img::required_files()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let controller = MsdController::new(
|
let controller = MsdController::new(
|
||||||
@@ -382,27 +431,42 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let (actual_format, actual_resolution, actual_fps) = streamer.current_video_config().await;
|
let (actual_format, actual_resolution, actual_fps) = streamer.current_video_config().await;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Initial video config from capturer: {}x{} {:?} @ {}fps",
|
"Initial video config from capturer: {}x{} {:?} @ {}fps",
|
||||||
actual_resolution.width, actual_resolution.height, actual_format, actual_fps
|
actual_resolution.width,
|
||||||
|
actual_resolution.height,
|
||||||
|
actual_format,
|
||||||
|
actual_fps
|
||||||
);
|
);
|
||||||
webrtc_streamer.update_video_config(actual_resolution, actual_format, actual_fps).await;
|
webrtc_streamer
|
||||||
|
.update_video_config(actual_resolution, actual_format, actual_fps)
|
||||||
|
.await;
|
||||||
webrtc_streamer.set_video_source(frame_tx).await;
|
webrtc_streamer.set_video_source(frame_tx).await;
|
||||||
tracing::info!("WebRTC streamer connected to video frame source");
|
tracing::info!("WebRTC streamer connected to video frame source");
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("Video capturer not ready, WebRTC will connect to frame source when available");
|
tracing::warn!(
|
||||||
|
"Video capturer not ready, WebRTC will connect to frame source when available"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create video stream manager (unified MJPEG/WebRTC management)
|
// Create video stream manager (unified MJPEG/WebRTC management)
|
||||||
// Use with_webrtc_streamer to ensure we use the same WebRtcStreamer instance
|
// Use with_webrtc_streamer to ensure we use the same WebRtcStreamer instance
|
||||||
let stream_manager = VideoStreamManager::with_webrtc_streamer(streamer.clone(), webrtc_streamer.clone());
|
let stream_manager =
|
||||||
|
VideoStreamManager::with_webrtc_streamer(streamer.clone(), webrtc_streamer.clone());
|
||||||
stream_manager.set_event_bus(events.clone()).await;
|
stream_manager.set_event_bus(events.clone()).await;
|
||||||
stream_manager.set_config_store(config_store.clone()).await;
|
stream_manager.set_config_store(config_store.clone()).await;
|
||||||
|
|
||||||
// Initialize stream manager with configured mode
|
// Initialize stream manager with configured mode
|
||||||
let initial_mode = config.stream.mode.clone();
|
let initial_mode = config.stream.mode.clone();
|
||||||
if let Err(e) = stream_manager.init_with_mode(initial_mode.clone()).await {
|
if let Err(e) = stream_manager.init_with_mode(initial_mode.clone()).await {
|
||||||
tracing::warn!("Failed to initialize stream manager with mode {:?}: {}", initial_mode, e);
|
tracing::warn!(
|
||||||
|
"Failed to initialize stream manager with mode {:?}: {}",
|
||||||
|
initial_mode,
|
||||||
|
e
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::info!("Video stream manager initialized with mode: {:?}", initial_mode);
|
tracing::info!(
|
||||||
|
"Video stream manager initialized with mode: {:?}",
|
||||||
|
initial_mode
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create RustDesk service (optional, based on config)
|
// Create RustDesk service (optional, based on config)
|
||||||
@@ -421,7 +485,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Some(Arc::new(service))
|
Some(Arc::new(service))
|
||||||
} else {
|
} else {
|
||||||
if config.rustdesk.enabled {
|
if config.rustdesk.enabled {
|
||||||
tracing::warn!("RustDesk enabled but configuration is incomplete (missing server or credentials)");
|
tracing::warn!(
|
||||||
|
"RustDesk enabled but configuration is incomplete (missing server or credentials)"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::info!("RustDesk disabled in configuration");
|
tracing::info!("RustDesk disabled in configuration");
|
||||||
}
|
}
|
||||||
@@ -458,7 +524,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
cfg.rustdesk.public_key = updated_config.public_key.clone();
|
cfg.rustdesk.public_key = updated_config.public_key.clone();
|
||||||
cfg.rustdesk.private_key = updated_config.private_key.clone();
|
cfg.rustdesk.private_key = updated_config.private_key.clone();
|
||||||
cfg.rustdesk.signing_public_key = updated_config.signing_public_key.clone();
|
cfg.rustdesk.signing_public_key = updated_config.signing_public_key.clone();
|
||||||
cfg.rustdesk.signing_private_key = updated_config.signing_private_key.clone();
|
cfg.rustdesk.signing_private_key =
|
||||||
|
updated_config.signing_private_key.clone();
|
||||||
cfg.rustdesk.uuid = updated_config.uuid.clone();
|
cfg.rustdesk.uuid = updated_config.uuid.clone();
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -542,8 +609,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
tracing::info!("Starting HTTPS server on {}", bind_addr);
|
tracing::info!("Starting HTTPS server on {}", bind_addr);
|
||||||
|
|
||||||
let server = axum_server::bind_rustls(bind_addr, tls_config)
|
let server = axum_server::bind_rustls(bind_addr, tls_config).serve(app.into_make_service());
|
||||||
.serve(app.into_make_service());
|
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = shutdown_signal => {
|
_ = shutdown_signal => {
|
||||||
@@ -600,8 +666,8 @@ fn init_logging(level: LogLevel, verbose_count: u8) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Environment variable takes highest priority
|
// Environment variable takes highest priority
|
||||||
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
let env_filter =
|
||||||
.unwrap_or_else(|_| filter.into());
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into());
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(env_filter)
|
.with(env_filter)
|
||||||
@@ -662,7 +728,8 @@ fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
|
|||||||
loop {
|
loop {
|
||||||
// Use timeout to handle pending broadcasts
|
// Use timeout to handle pending broadcasts
|
||||||
let recv_result = if pending_broadcast {
|
let recv_result = if pending_broadcast {
|
||||||
let remaining = DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64);
|
let remaining =
|
||||||
|
DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64);
|
||||||
tokio::time::timeout(Duration::from_millis(remaining), rx.recv()).await
|
tokio::time::timeout(Duration::from_millis(remaining), rx.recv()).await
|
||||||
} else {
|
} else {
|
||||||
Ok(rx.recv().await)
|
Ok(rx.recv().await)
|
||||||
@@ -674,6 +741,7 @@ fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
|
|||||||
event,
|
event,
|
||||||
SystemEvent::StreamStateChanged { .. }
|
SystemEvent::StreamStateChanged { .. }
|
||||||
| SystemEvent::StreamConfigApplied { .. }
|
| SystemEvent::StreamConfigApplied { .. }
|
||||||
|
| SystemEvent::StreamModeReady { .. }
|
||||||
| SystemEvent::HidStateChanged { .. }
|
| SystemEvent::HidStateChanged { .. }
|
||||||
| SystemEvent::MsdStateChanged { .. }
|
| SystemEvent::MsdStateChanged { .. }
|
||||||
| SystemEvent::AtxStateChanged { .. }
|
| SystemEvent::AtxStateChanged { .. }
|
||||||
@@ -706,7 +774,10 @@ fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
tracing::info!("DeviceInfo broadcaster task started (debounce: {}ms)", DEBOUNCE_MS);
|
tracing::info!(
|
||||||
|
"DeviceInfo broadcaster task started (debounce: {}ms)",
|
||||||
|
DEBOUNCE_MS
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clean up subsystems on shutdown
|
/// Clean up subsystems on shutdown
|
||||||
|
|||||||
@@ -99,7 +99,10 @@ impl MsdController {
|
|||||||
initialized: true,
|
initialized: true,
|
||||||
path: self.drive_path.clone(),
|
path: self.drive_path.clone(),
|
||||||
});
|
});
|
||||||
debug!("Found existing virtual drive: {}", self.drive_path.display());
|
debug!(
|
||||||
|
"Found existing virtual drive: {}",
|
||||||
|
self.drive_path.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +149,12 @@ impl MsdController {
|
|||||||
/// * `image` - Image info to mount
|
/// * `image` - Image info to mount
|
||||||
/// * `cdrom` - Mount as CD-ROM (read-only, removable)
|
/// * `cdrom` - Mount as CD-ROM (read-only, removable)
|
||||||
/// * `read_only` - Mount as read-only
|
/// * `read_only` - Mount as read-only
|
||||||
pub async fn connect_image(&self, image: &ImageInfo, cdrom: bool, read_only: bool) -> Result<()> {
|
pub async fn connect_image(
|
||||||
|
&self,
|
||||||
|
image: &ImageInfo,
|
||||||
|
cdrom: bool,
|
||||||
|
read_only: bool,
|
||||||
|
) -> Result<()> {
|
||||||
// Acquire operation lock to prevent concurrent operations
|
// Acquire operation lock to prevent concurrent operations
|
||||||
let _op_guard = self.operation_lock.write().await;
|
let _op_guard = self.operation_lock.write().await;
|
||||||
|
|
||||||
@@ -154,7 +162,9 @@ impl MsdController {
|
|||||||
|
|
||||||
if !state.available {
|
if !state.available {
|
||||||
let err = AppError::Internal("MSD not available".to_string());
|
let err = AppError::Internal("MSD not available".to_string());
|
||||||
self.monitor.report_error("MSD not available", "not_available").await;
|
self.monitor
|
||||||
|
.report_error("MSD not available", "not_available")
|
||||||
|
.await;
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +177,9 @@ impl MsdController {
|
|||||||
// Verify image exists
|
// Verify image exists
|
||||||
if !image.path.exists() {
|
if !image.path.exists() {
|
||||||
let error_msg = format!("Image file not found: {}", image.path.display());
|
let error_msg = format!("Image file not found: {}", image.path.display());
|
||||||
self.monitor.report_error(&error_msg, "image_not_found").await;
|
self.monitor
|
||||||
|
.report_error(&error_msg, "image_not_found")
|
||||||
|
.await;
|
||||||
return Err(AppError::Internal(error_msg));
|
return Err(AppError::Internal(error_msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,12 +194,16 @@ impl MsdController {
|
|||||||
if let Some(ref msd) = *self.msd_function.read().await {
|
if let Some(ref msd) = *self.msd_function.read().await {
|
||||||
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
|
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
|
||||||
let error_msg = format!("Failed to configure LUN: {}", e);
|
let error_msg = format!("Failed to configure LUN: {}", e);
|
||||||
self.monitor.report_error(&error_msg, "configfs_error").await;
|
self.monitor
|
||||||
|
.report_error(&error_msg, "configfs_error")
|
||||||
|
.await;
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let err = AppError::Internal("MSD function not initialized".to_string());
|
let err = AppError::Internal("MSD function not initialized".to_string());
|
||||||
self.monitor.report_error("MSD function not initialized", "not_initialized").await;
|
self.monitor
|
||||||
|
.report_error("MSD function not initialized", "not_initialized")
|
||||||
|
.await;
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +252,9 @@ impl MsdController {
|
|||||||
|
|
||||||
if !state.available {
|
if !state.available {
|
||||||
let err = AppError::Internal("MSD not available".to_string());
|
let err = AppError::Internal("MSD not available".to_string());
|
||||||
self.monitor.report_error("MSD not available", "not_available").await;
|
self.monitor
|
||||||
|
.report_error("MSD not available", "not_available")
|
||||||
|
.await;
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,10 +266,11 @@ impl MsdController {
|
|||||||
|
|
||||||
// Check drive exists
|
// Check drive exists
|
||||||
if !self.drive_path.exists() {
|
if !self.drive_path.exists() {
|
||||||
let err = AppError::Internal(
|
let err =
|
||||||
"Virtual drive not initialized. Call init first.".to_string(),
|
AppError::Internal("Virtual drive not initialized. Call init first.".to_string());
|
||||||
);
|
self.monitor
|
||||||
self.monitor.report_error("Virtual drive not initialized", "drive_not_found").await;
|
.report_error("Virtual drive not initialized", "drive_not_found")
|
||||||
|
.await;
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,12 +281,16 @@ impl MsdController {
|
|||||||
if let Some(ref msd) = *self.msd_function.read().await {
|
if let Some(ref msd) = *self.msd_function.read().await {
|
||||||
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
|
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
|
||||||
let error_msg = format!("Failed to configure LUN: {}", e);
|
let error_msg = format!("Failed to configure LUN: {}", e);
|
||||||
self.monitor.report_error(&error_msg, "configfs_error").await;
|
self.monitor
|
||||||
|
.report_error(&error_msg, "configfs_error")
|
||||||
|
.await;
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let err = AppError::Internal("MSD function not initialized".to_string());
|
let err = AppError::Internal("MSD function not initialized".to_string());
|
||||||
self.monitor.report_error("MSD function not initialized", "not_initialized").await;
|
self.monitor
|
||||||
|
.report_error("MSD function not initialized", "not_initialized")
|
||||||
|
.await;
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,12 +404,9 @@ impl MsdController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract filename for initial response
|
// Extract filename for initial response
|
||||||
let display_filename = filename.clone().unwrap_or_else(|| {
|
let display_filename = filename
|
||||||
url.rsplit('/')
|
.clone()
|
||||||
.next()
|
.unwrap_or_else(|| url.rsplit('/').next().unwrap_or("download").to_string());
|
||||||
.unwrap_or("download")
|
|
||||||
.to_string()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create initial progress
|
// Create initial progress
|
||||||
let initial_progress = DownloadProgress {
|
let initial_progress = DownloadProgress {
|
||||||
|
|||||||
@@ -42,9 +42,8 @@ impl ImageManager {
|
|||||||
|
|
||||||
/// Ensure images directory exists
|
/// Ensure images directory exists
|
||||||
pub fn ensure_dir(&self) -> Result<()> {
|
pub fn ensure_dir(&self) -> Result<()> {
|
||||||
fs::create_dir_all(&self.images_path).map_err(|e| {
|
fs::create_dir_all(&self.images_path)
|
||||||
AppError::Internal(format!("Failed to create images directory: {}", e))
|
.map_err(|e| AppError::Internal(format!("Failed to create images directory: {}", e)))?;
|
||||||
})?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +53,9 @@ impl ImageManager {
|
|||||||
|
|
||||||
let mut images = Vec::new();
|
let mut images = Vec::new();
|
||||||
|
|
||||||
for entry in fs::read_dir(&self.images_path).map_err(|e| {
|
for entry in fs::read_dir(&self.images_path)
|
||||||
AppError::Internal(format!("Failed to read images directory: {}", e))
|
.map_err(|e| AppError::Internal(format!("Failed to read images directory: {}", e)))?
|
||||||
})? {
|
{
|
||||||
let entry = entry.map_err(|e| {
|
let entry = entry.map_err(|e| {
|
||||||
AppError::Internal(format!("Failed to read directory entry: {}", e))
|
AppError::Internal(format!("Failed to read directory entry: {}", e))
|
||||||
})?;
|
})?;
|
||||||
@@ -146,9 +145,8 @@ impl ImageManager {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut file = File::create(&path).map_err(|e| {
|
let mut file = File::create(&path)
|
||||||
AppError::Internal(format!("Failed to create image file: {}", e))
|
.map_err(|e| AppError::Internal(format!("Failed to create image file: {}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
file.write_all(data).map_err(|e| {
|
file.write_all(data).map_err(|e| {
|
||||||
// Try to clean up on error
|
// Try to clean up on error
|
||||||
@@ -193,9 +191,8 @@ impl ImageManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create file and copy data
|
// Create file and copy data
|
||||||
let mut file = File::create(&path).map_err(|e| {
|
let mut file = File::create(&path)
|
||||||
AppError::Internal(format!("Failed to create image file: {}", e))
|
.map_err(|e| AppError::Internal(format!("Failed to create image file: {}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
let bytes_written = io::copy(reader, &mut file).map_err(|e| {
|
let bytes_written = io::copy(reader, &mut file).map_err(|e| {
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
@@ -244,9 +241,11 @@ impl ImageManager {
|
|||||||
let mut bytes_written: u64 = 0;
|
let mut bytes_written: u64 = 0;
|
||||||
|
|
||||||
// Stream chunks directly to disk
|
// Stream chunks directly to disk
|
||||||
while let Some(chunk) = field.chunk().await.map_err(|e| {
|
while let Some(chunk) = field
|
||||||
AppError::Internal(format!("Failed to read upload chunk: {}", e))
|
.chunk()
|
||||||
})? {
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to read upload chunk: {}", e)))?
|
||||||
|
{
|
||||||
// Check size limit
|
// Check size limit
|
||||||
bytes_written += chunk.len() as u64;
|
bytes_written += chunk.len() as u64;
|
||||||
if bytes_written > MAX_IMAGE_SIZE {
|
if bytes_written > MAX_IMAGE_SIZE {
|
||||||
@@ -260,15 +259,15 @@ impl ImageManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write chunk to file
|
// Write chunk to file
|
||||||
file.write_all(&chunk).await.map_err(|e| {
|
file.write_all(&chunk)
|
||||||
AppError::Internal(format!("Failed to write chunk: {}", e))
|
.await
|
||||||
})?;
|
.map_err(|e| AppError::Internal(format!("Failed to write chunk: {}", e)))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush and close file
|
// Flush and close file
|
||||||
file.flush().await.map_err(|e| {
|
file.flush()
|
||||||
AppError::Internal(format!("Failed to flush file: {}", e))
|
.await
|
||||||
})?;
|
.map_err(|e| AppError::Internal(format!("Failed to flush file: {}", e)))?;
|
||||||
drop(file);
|
drop(file);
|
||||||
|
|
||||||
// Move temp file to final location
|
// Move temp file to final location
|
||||||
@@ -279,7 +278,10 @@ impl ImageManager {
|
|||||||
AppError::Internal(format!("Failed to rename temp file: {}", e))
|
AppError::Internal(format!("Failed to rename temp file: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
info!("Created image (streaming): {} ({} bytes)", name, bytes_written);
|
info!(
|
||||||
|
"Created image (streaming): {} ({} bytes)",
|
||||||
|
name, bytes_written
|
||||||
|
);
|
||||||
|
|
||||||
self.get_by_name(&name)
|
self.get_by_name(&name)
|
||||||
}
|
}
|
||||||
@@ -288,9 +290,8 @@ impl ImageManager {
|
|||||||
pub fn delete(&self, id: &str) -> Result<()> {
|
pub fn delete(&self, id: &str) -> Result<()> {
|
||||||
let image = self.get(id)?;
|
let image = self.get(id)?;
|
||||||
|
|
||||||
fs::remove_file(&image.path).map_err(|e| {
|
fs::remove_file(&image.path)
|
||||||
AppError::Internal(format!("Failed to delete image: {}", e))
|
.map_err(|e| AppError::Internal(format!("Failed to delete image: {}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
info!("Deleted image: {}", image.name);
|
info!("Deleted image: {}", image.name);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -304,9 +305,8 @@ impl ImageManager {
|
|||||||
return Err(AppError::NotFound(format!("Image not found: {}", name)));
|
return Err(AppError::NotFound(format!("Image not found: {}", name)));
|
||||||
}
|
}
|
||||||
|
|
||||||
fs::remove_file(&path).map_err(|e| {
|
fs::remove_file(&path)
|
||||||
AppError::Internal(format!("Failed to delete image: {}", e))
|
.map_err(|e| AppError::Internal(format!("Failed to delete image: {}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
info!("Deleted image: {}", name);
|
info!("Deleted image: {}", name);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -414,7 +414,9 @@ impl ImageManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if final_filename.is_empty() {
|
if final_filename.is_empty() {
|
||||||
return Err(AppError::BadRequest("Could not determine filename".to_string()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Could not determine filename".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
@@ -468,12 +470,10 @@ impl ImageManager {
|
|||||||
progress_callback(0, content_length);
|
progress_callback(0, content_length);
|
||||||
|
|
||||||
while let Some(chunk_result) = stream.next().await {
|
while let Some(chunk_result) = stream.next().await {
|
||||||
let chunk = chunk_result
|
let chunk =
|
||||||
.map_err(|e| AppError::Internal(format!("Download error: {}", e)))?;
|
chunk_result.map_err(|e| AppError::Internal(format!("Download error: {}", e)))?;
|
||||||
|
|
||||||
file.write_all(&chunk)
|
file.write_all(&chunk).await.map_err(|e| {
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
// Cleanup on error
|
// Cleanup on error
|
||||||
let _ = std::fs::remove_file(&temp_path);
|
let _ = std::fs::remove_file(&temp_path);
|
||||||
AppError::Internal(format!("Failed to write data: {}", e))
|
AppError::Internal(format!("Failed to write data: {}", e))
|
||||||
|
|||||||
@@ -15,19 +15,19 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub mod controller;
|
pub mod controller;
|
||||||
pub mod ventoy_drive;
|
|
||||||
pub mod image;
|
pub mod image;
|
||||||
pub mod monitor;
|
pub mod monitor;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
pub mod ventoy_drive;
|
||||||
|
|
||||||
pub use controller::MsdController;
|
pub use controller::MsdController;
|
||||||
pub use ventoy_drive::VentoyDrive;
|
|
||||||
pub use image::ImageManager;
|
pub use image::ImageManager;
|
||||||
pub use monitor::{MsdHealthMonitor, MsdHealthStatus, MsdMonitorConfig};
|
pub use monitor::{MsdHealthMonitor, MsdHealthStatus, MsdMonitorConfig};
|
||||||
pub use types::{
|
pub use types::{
|
||||||
DownloadProgress, DownloadStatus, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest,
|
DownloadProgress, DownloadStatus, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest,
|
||||||
ImageInfo, MsdConnectRequest, MsdMode, MsdState,
|
ImageInfo, MsdConnectRequest, MsdMode, MsdState,
|
||||||
};
|
};
|
||||||
|
pub use ventoy_drive::VentoyDrive;
|
||||||
|
|
||||||
// Re-export from otg module for backward compatibility
|
// Re-export from otg module for backward compatibility
|
||||||
pub use crate::otg::{MsdFunction, MsdLunConfig};
|
pub use crate::otg::{MsdFunction, MsdLunConfig};
|
||||||
|
|||||||
@@ -120,7 +120,10 @@ impl MsdHealthMonitor {
|
|||||||
// Log with throttling (always log if error type changed)
|
// Log with throttling (always log if error type changed)
|
||||||
let throttle_key = format!("msd_{}", error_code);
|
let throttle_key = format!("msd_{}", error_code);
|
||||||
if error_changed || self.throttler.should_log(&throttle_key) {
|
if error_changed || self.throttler.should_log(&throttle_key) {
|
||||||
warn!("MSD error: {} (code: {}, count: {})", reason, error_code, count);
|
warn!(
|
||||||
|
"MSD error: {} (code: {}, count: {})",
|
||||||
|
reason, error_code, count
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last error code
|
// Update last error code
|
||||||
|
|||||||
@@ -71,13 +71,11 @@ impl VentoyDrive {
|
|||||||
|
|
||||||
// Run Ventoy creation in blocking task
|
// Run Ventoy creation in blocking task
|
||||||
let info = tokio::task::spawn_blocking(move || {
|
let info = tokio::task::spawn_blocking(move || {
|
||||||
VentoyImage::create(&path, &size_str, DEFAULT_LABEL)
|
VentoyImage::create(&path, &size_str, DEFAULT_LABEL).map_err(ventoy_to_app_error)?;
|
||||||
.map_err(ventoy_to_app_error)?;
|
|
||||||
|
|
||||||
// Get file metadata for DriveInfo
|
// Get file metadata for DriveInfo
|
||||||
let metadata = std::fs::metadata(&path).map_err(|e| {
|
let metadata = std::fs::metadata(&path)
|
||||||
AppError::Internal(format!("Failed to read drive metadata: {}", e))
|
.map_err(|e| AppError::Internal(format!("Failed to read drive metadata: {}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok::<DriveInfo, AppError>(DriveInfo {
|
Ok::<DriveInfo, AppError>(DriveInfo {
|
||||||
size: metadata.len(),
|
size: metadata.len(),
|
||||||
@@ -104,16 +102,13 @@ impl VentoyDrive {
|
|||||||
let _lock = self.lock.read().await; // Read lock for info query
|
let _lock = self.lock.read().await; // Read lock for info query
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let metadata = std::fs::metadata(&path).map_err(|e| {
|
let metadata = std::fs::metadata(&path)
|
||||||
AppError::Internal(format!("Failed to read drive metadata: {}", e))
|
.map_err(|e| AppError::Internal(format!("Failed to read drive metadata: {}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
// Open image to get file list and calculate used space
|
// Open image to get file list and calculate used space
|
||||||
let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?;
|
let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?;
|
||||||
|
|
||||||
let files = image
|
let files = image.list_files_recursive().map_err(ventoy_to_app_error)?;
|
||||||
.list_files_recursive()
|
|
||||||
.map_err(ventoy_to_app_error)?;
|
|
||||||
|
|
||||||
let used: u64 = files
|
let used: u64 = files
|
||||||
.iter()
|
.iter()
|
||||||
@@ -190,9 +185,11 @@ impl VentoyDrive {
|
|||||||
|
|
||||||
let mut bytes_written: u64 = 0;
|
let mut bytes_written: u64 = 0;
|
||||||
|
|
||||||
while let Some(chunk) = field.chunk().await.map_err(|e| {
|
while let Some(chunk) = field
|
||||||
AppError::Internal(format!("Failed to read upload chunk: {}", e))
|
.chunk()
|
||||||
})? {
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to read upload chunk: {}", e)))?
|
||||||
|
{
|
||||||
bytes_written += chunk.len() as u64;
|
bytes_written += chunk.len() as u64;
|
||||||
tokio::io::AsyncWriteExt::write_all(&mut temp_file, &chunk)
|
tokio::io::AsyncWriteExt::write_all(&mut temp_file, &chunk)
|
||||||
.await
|
.await
|
||||||
@@ -248,9 +245,7 @@ impl VentoyDrive {
|
|||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?;
|
let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?;
|
||||||
|
|
||||||
image
|
image.read_file(&file_path).map_err(ventoy_to_app_error)
|
||||||
.read_file(&file_path)
|
|
||||||
.map_err(ventoy_to_app_error)
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
|
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
|
||||||
@@ -321,7 +316,8 @@ impl VentoyDrive {
|
|||||||
let lock = self.lock.clone();
|
let lock = self.lock.clone();
|
||||||
|
|
||||||
// Create a channel for streaming data
|
// Create a channel for streaming data
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel::<std::result::Result<bytes::Bytes, std::io::Error>>(8);
|
let (tx, rx) =
|
||||||
|
tokio::sync::mpsc::channel::<std::result::Result<bytes::Bytes, std::io::Error>>(8);
|
||||||
|
|
||||||
// Spawn blocking task to read and send chunks
|
// Spawn blocking task to read and send chunks
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
@@ -404,20 +400,14 @@ fn ventoy_to_app_error(err: VentoyError) -> AppError {
|
|||||||
match err {
|
match err {
|
||||||
VentoyError::Io(e) => AppError::Io(e),
|
VentoyError::Io(e) => AppError::Io(e),
|
||||||
VentoyError::InvalidSize(s) => AppError::BadRequest(format!("Invalid size: {}", s)),
|
VentoyError::InvalidSize(s) => AppError::BadRequest(format!("Invalid size: {}", s)),
|
||||||
VentoyError::SizeParseError(s) => {
|
VentoyError::SizeParseError(s) => AppError::BadRequest(format!("Size parse error: {}", s)),
|
||||||
AppError::BadRequest(format!("Size parse error: {}", s))
|
VentoyError::FilesystemError(s) => AppError::Internal(format!("Filesystem error: {}", s)),
|
||||||
}
|
|
||||||
VentoyError::FilesystemError(s) => {
|
|
||||||
AppError::Internal(format!("Filesystem error: {}", s))
|
|
||||||
}
|
|
||||||
VentoyError::ImageError(s) => AppError::Internal(format!("Image error: {}", s)),
|
VentoyError::ImageError(s) => AppError::Internal(format!("Image error: {}", s)),
|
||||||
VentoyError::FileNotFound(s) => AppError::NotFound(format!("File not found: {}", s)),
|
VentoyError::FileNotFound(s) => AppError::NotFound(format!("File not found: {}", s)),
|
||||||
VentoyError::ResourceNotFound(s) => {
|
VentoyError::ResourceNotFound(s) => {
|
||||||
AppError::Internal(format!("Resource not found: {}", s))
|
AppError::Internal(format!("Resource not found: {}", s))
|
||||||
}
|
}
|
||||||
VentoyError::PartitionError(s) => {
|
VentoyError::PartitionError(s) => AppError::Internal(format!("Partition error: {}", s)),
|
||||||
AppError::Internal(format!("Partition error: {}", s))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,7 +471,8 @@ impl std::io::Write for ChannelWriter {
|
|||||||
let space = STREAM_CHUNK_SIZE - self.buffer.len();
|
let space = STREAM_CHUNK_SIZE - self.buffer.len();
|
||||||
let to_copy = std::cmp::min(space, buf.len() - written);
|
let to_copy = std::cmp::min(space, buf.len() - written);
|
||||||
|
|
||||||
self.buffer.extend_from_slice(&buf[written..written + to_copy]);
|
self.buffer
|
||||||
|
.extend_from_slice(&buf[written..written + to_copy]);
|
||||||
written += to_copy;
|
written += to_copy;
|
||||||
|
|
||||||
if self.buffer.len() >= STREAM_CHUNK_SIZE {
|
if self.buffer.len() >= STREAM_CHUNK_SIZE {
|
||||||
@@ -512,10 +503,7 @@ mod tests {
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
/// Path to ventoy resources directory
|
/// Path to ventoy resources directory
|
||||||
static RESOURCE_DIR: &str = concat!(
|
static RESOURCE_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../ventoy-img-rs/resources");
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/../ventoy-img-rs/resources"
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Initialize ventoy resources once
|
/// Initialize ventoy resources once
|
||||||
fn init_ventoy_resources() -> bool {
|
fn init_ventoy_resources() -> bool {
|
||||||
@@ -561,7 +549,10 @@ mod tests {
|
|||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return Err(std::io::Error::new(
|
return Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::Other,
|
std::io::ErrorKind::Other,
|
||||||
format!("xz decompress failed: {}", String::from_utf8_lossy(&output.stderr)),
|
format!(
|
||||||
|
"xz decompress failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,15 +109,25 @@ pub fn read_file(path: &Path) -> Result<String> {
|
|||||||
|
|
||||||
/// Create directory if not exists
|
/// Create directory if not exists
|
||||||
pub fn create_dir(path: &Path) -> Result<()> {
|
pub fn create_dir(path: &Path) -> Result<()> {
|
||||||
fs::create_dir_all(path)
|
fs::create_dir_all(path).map_err(|e| {
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to create directory {}: {}", path.display(), e)))
|
AppError::Internal(format!(
|
||||||
|
"Failed to create directory {}: {}",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove directory
|
/// Remove directory
|
||||||
pub fn remove_dir(path: &Path) -> Result<()> {
|
pub fn remove_dir(path: &Path) -> Result<()> {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
fs::remove_dir(path)
|
fs::remove_dir(path).map_err(|e| {
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to remove directory {}: {}", path.display(), e)))?;
|
AppError::Internal(format!(
|
||||||
|
"Failed to remove directory {}: {}",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -125,14 +135,21 @@ pub fn remove_dir(path: &Path) -> Result<()> {
|
|||||||
/// Remove file
|
/// Remove file
|
||||||
pub fn remove_file(path: &Path) -> Result<()> {
|
pub fn remove_file(path: &Path) -> Result<()> {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
fs::remove_file(path)
|
fs::remove_file(path).map_err(|e| {
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to remove file {}: {}", path.display(), e)))?;
|
AppError::Internal(format!("Failed to remove file {}: {}", path.display(), e))
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create symlink
|
/// Create symlink
|
||||||
pub fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
|
pub fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
|
||||||
std::os::unix::fs::symlink(src, dest)
|
std::os::unix::fs::symlink(src, dest).map_err(|e| {
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to create symlink {} -> {}: {}", dest.display(), src.display(), e)))
|
AppError::Internal(format!(
|
||||||
|
"Failed to create symlink {} -> {}: {}",
|
||||||
|
dest.display(),
|
||||||
|
src.display(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use super::configfs::{create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file};
|
use super::configfs::{
|
||||||
|
create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file,
|
||||||
|
};
|
||||||
use super::function::{FunctionMeta, GadgetFunction};
|
use super::function::{FunctionMeta, GadgetFunction};
|
||||||
use super::report_desc::{CONSUMER_CONTROL, KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE};
|
use super::report_desc::{CONSUMER_CONTROL, KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
@@ -169,14 +171,27 @@ impl GadgetFunction for HidFunction {
|
|||||||
create_dir(&func_path)?;
|
create_dir(&func_path)?;
|
||||||
|
|
||||||
// Set HID parameters
|
// Set HID parameters
|
||||||
write_file(&func_path.join("protocol"), &self.func_type.protocol().to_string())?;
|
write_file(
|
||||||
write_file(&func_path.join("subclass"), &self.func_type.subclass().to_string())?;
|
&func_path.join("protocol"),
|
||||||
write_file(&func_path.join("report_length"), &self.func_type.report_length().to_string())?;
|
&self.func_type.protocol().to_string(),
|
||||||
|
)?;
|
||||||
|
write_file(
|
||||||
|
&func_path.join("subclass"),
|
||||||
|
&self.func_type.subclass().to_string(),
|
||||||
|
)?;
|
||||||
|
write_file(
|
||||||
|
&func_path.join("report_length"),
|
||||||
|
&self.func_type.report_length().to_string(),
|
||||||
|
)?;
|
||||||
|
|
||||||
// Write report descriptor
|
// Write report descriptor
|
||||||
write_bytes(&func_path.join("report_desc"), self.func_type.report_desc())?;
|
write_bytes(&func_path.join("report_desc"), self.func_type.report_desc())?;
|
||||||
|
|
||||||
debug!("Created HID function: {} at {}", self.name(), func_path.display());
|
debug!(
|
||||||
|
"Created HID function: {} at {}",
|
||||||
|
self.name(),
|
||||||
|
func_path.display()
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ use tracing::{debug, error, info, warn};
|
|||||||
|
|
||||||
use super::configfs::{
|
use super::configfs::{
|
||||||
create_dir, find_udc, is_configfs_available, remove_dir, write_file, CONFIGFS_PATH,
|
create_dir, find_udc, is_configfs_available, remove_dir, write_file, CONFIGFS_PATH,
|
||||||
DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, USB_BCD_USB, DEFAULT_USB_PRODUCT_ID, DEFAULT_USB_VENDOR_ID,
|
DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID, DEFAULT_USB_VENDOR_ID,
|
||||||
|
USB_BCD_USB,
|
||||||
};
|
};
|
||||||
use super::endpoint::{EndpointAllocator, DEFAULT_MAX_ENDPOINTS};
|
use super::endpoint::{EndpointAllocator, DEFAULT_MAX_ENDPOINTS};
|
||||||
use super::function::{FunctionMeta, GadgetFunction};
|
use super::function::{FunctionMeta, GadgetFunction};
|
||||||
@@ -77,7 +78,11 @@ impl OtgGadgetManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new gadget manager with custom descriptor
|
/// Create a new gadget manager with custom descriptor
|
||||||
pub fn with_descriptor(gadget_name: &str, max_endpoints: u8, descriptor: GadgetDescriptor) -> Self {
|
pub fn with_descriptor(
|
||||||
|
gadget_name: &str,
|
||||||
|
max_endpoints: u8,
|
||||||
|
descriptor: GadgetDescriptor,
|
||||||
|
) -> Self {
|
||||||
let gadget_path = PathBuf::from(CONFIGFS_PATH).join(gadget_name);
|
let gadget_path = PathBuf::from(CONFIGFS_PATH).join(gadget_name);
|
||||||
let config_path = gadget_path.join("configs/c.1");
|
let config_path = gadget_path.join("configs/c.1");
|
||||||
|
|
||||||
@@ -303,10 +308,22 @@ impl OtgGadgetManager {
|
|||||||
|
|
||||||
/// Set USB device descriptors
|
/// Set USB device descriptors
|
||||||
fn set_device_descriptors(&self) -> Result<()> {
|
fn set_device_descriptors(&self) -> Result<()> {
|
||||||
write_file(&self.gadget_path.join("idVendor"), &format!("0x{:04x}", self.descriptor.vendor_id))?;
|
write_file(
|
||||||
write_file(&self.gadget_path.join("idProduct"), &format!("0x{:04x}", self.descriptor.product_id))?;
|
&self.gadget_path.join("idVendor"),
|
||||||
write_file(&self.gadget_path.join("bcdDevice"), &format!("0x{:04x}", self.descriptor.device_version))?;
|
&format!("0x{:04x}", self.descriptor.vendor_id),
|
||||||
write_file(&self.gadget_path.join("bcdUSB"), &format!("0x{:04x}", USB_BCD_USB))?;
|
)?;
|
||||||
|
write_file(
|
||||||
|
&self.gadget_path.join("idProduct"),
|
||||||
|
&format!("0x{:04x}", self.descriptor.product_id),
|
||||||
|
)?;
|
||||||
|
write_file(
|
||||||
|
&self.gadget_path.join("bcdDevice"),
|
||||||
|
&format!("0x{:04x}", self.descriptor.device_version),
|
||||||
|
)?;
|
||||||
|
write_file(
|
||||||
|
&self.gadget_path.join("bcdUSB"),
|
||||||
|
&format!("0x{:04x}", USB_BCD_USB),
|
||||||
|
)?;
|
||||||
write_file(&self.gadget_path.join("bDeviceClass"), "0x00")?; // Composite device
|
write_file(&self.gadget_path.join("bDeviceClass"), "0x00")?; // Composite device
|
||||||
write_file(&self.gadget_path.join("bDeviceSubClass"), "0x00")?;
|
write_file(&self.gadget_path.join("bDeviceSubClass"), "0x00")?;
|
||||||
write_file(&self.gadget_path.join("bDeviceProtocol"), "0x00")?;
|
write_file(&self.gadget_path.join("bDeviceProtocol"), "0x00")?;
|
||||||
@@ -319,8 +336,14 @@ impl OtgGadgetManager {
|
|||||||
let strings_path = self.gadget_path.join("strings/0x409");
|
let strings_path = self.gadget_path.join("strings/0x409");
|
||||||
create_dir(&strings_path)?;
|
create_dir(&strings_path)?;
|
||||||
|
|
||||||
write_file(&strings_path.join("serialnumber"), &self.descriptor.serial_number)?;
|
write_file(
|
||||||
write_file(&strings_path.join("manufacturer"), &self.descriptor.manufacturer)?;
|
&strings_path.join("serialnumber"),
|
||||||
|
&self.descriptor.serial_number,
|
||||||
|
)?;
|
||||||
|
write_file(
|
||||||
|
&strings_path.join("manufacturer"),
|
||||||
|
&self.descriptor.manufacturer,
|
||||||
|
)?;
|
||||||
write_file(&strings_path.join("product"), &self.descriptor.product)?;
|
write_file(&strings_path.join("product"), &self.descriptor.product)?;
|
||||||
debug!("Created USB strings");
|
debug!("Created USB strings");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -349,7 +372,10 @@ impl OtgGadgetManager {
|
|||||||
|
|
||||||
/// Get endpoint usage info
|
/// Get endpoint usage info
|
||||||
pub fn endpoint_info(&self) -> (u8, u8) {
|
pub fn endpoint_info(&self) -> (u8, u8) {
|
||||||
(self.endpoint_allocator.used(), self.endpoint_allocator.max())
|
(
|
||||||
|
self.endpoint_allocator.used(),
|
||||||
|
self.endpoint_allocator.max(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get gadget path
|
/// Get gadget path
|
||||||
|
|||||||
@@ -161,7 +161,10 @@ impl MsdFunction {
|
|||||||
// Write only changed attributes
|
// Write only changed attributes
|
||||||
let cdrom_changed = current_cdrom != new_cdrom;
|
let cdrom_changed = current_cdrom != new_cdrom;
|
||||||
if cdrom_changed {
|
if cdrom_changed {
|
||||||
debug!("Updating LUN {} cdrom: {} -> {}", lun, current_cdrom, new_cdrom);
|
debug!(
|
||||||
|
"Updating LUN {} cdrom: {} -> {}",
|
||||||
|
lun, current_cdrom, new_cdrom
|
||||||
|
);
|
||||||
write_file(&lun_path.join("cdrom"), new_cdrom)?;
|
write_file(&lun_path.join("cdrom"), new_cdrom)?;
|
||||||
}
|
}
|
||||||
if current_ro != new_ro {
|
if current_ro != new_ro {
|
||||||
@@ -169,11 +172,17 @@ impl MsdFunction {
|
|||||||
write_file(&lun_path.join("ro"), new_ro)?;
|
write_file(&lun_path.join("ro"), new_ro)?;
|
||||||
}
|
}
|
||||||
if current_removable != new_removable {
|
if current_removable != new_removable {
|
||||||
debug!("Updating LUN {} removable: {} -> {}", lun, current_removable, new_removable);
|
debug!(
|
||||||
|
"Updating LUN {} removable: {} -> {}",
|
||||||
|
lun, current_removable, new_removable
|
||||||
|
);
|
||||||
write_file(&lun_path.join("removable"), new_removable)?;
|
write_file(&lun_path.join("removable"), new_removable)?;
|
||||||
}
|
}
|
||||||
if current_nofua != new_nofua {
|
if current_nofua != new_nofua {
|
||||||
debug!("Updating LUN {} nofua: {} -> {}", lun, current_nofua, new_nofua);
|
debug!(
|
||||||
|
"Updating LUN {} nofua: {} -> {}",
|
||||||
|
lun, current_nofua, new_nofua
|
||||||
|
);
|
||||||
write_file(&lun_path.join("nofua"), new_nofua)?;
|
write_file(&lun_path.join("nofua"), new_nofua)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,11 +267,17 @@ impl MsdFunction {
|
|||||||
// forced_eject forcibly detaches the backing file regardless of host state
|
// forced_eject forcibly detaches the backing file regardless of host state
|
||||||
let forced_eject_path = lun_path.join("forced_eject");
|
let forced_eject_path = lun_path.join("forced_eject");
|
||||||
if forced_eject_path.exists() {
|
if forced_eject_path.exists() {
|
||||||
debug!("Using forced_eject to disconnect LUN {} at {:?}", lun, forced_eject_path);
|
debug!(
|
||||||
|
"Using forced_eject to disconnect LUN {} at {:?}",
|
||||||
|
lun, forced_eject_path
|
||||||
|
);
|
||||||
match write_file(&forced_eject_path, "1") {
|
match write_file(&forced_eject_path, "1") {
|
||||||
Ok(_) => debug!("forced_eject write succeeded"),
|
Ok(_) => debug!("forced_eject write succeeded"),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("forced_eject write failed: {}, falling back to clearing file", e);
|
warn!(
|
||||||
|
"forced_eject write failed: {}, falling back to clearing file",
|
||||||
|
e
|
||||||
|
);
|
||||||
write_file(&lun_path.join("file"), "")?;
|
write_file(&lun_path.join("file"), "")?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ use tracing::{debug, info, warn};
|
|||||||
|
|
||||||
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
|
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
|
||||||
use super::msd::MsdFunction;
|
use super::msd::MsdFunction;
|
||||||
use crate::error::{AppError, Result};
|
|
||||||
use crate::config::OtgDescriptorConfig;
|
use crate::config::OtgDescriptorConfig;
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
/// Bitflags for requested functions (lock-free)
|
/// Bitflags for requested functions (lock-free)
|
||||||
const FLAG_HID: u8 = 0b01;
|
const FLAG_HID: u8 = 0b01;
|
||||||
@@ -254,8 +254,9 @@ impl OtgService {
|
|||||||
|
|
||||||
// Get MSD function
|
// Get MSD function
|
||||||
let msd = self.msd_function.read().await;
|
let msd = self.msd_function.read().await;
|
||||||
msd.clone()
|
msd.clone().ok_or_else(|| {
|
||||||
.ok_or_else(|| AppError::Internal("MSD function not set after gadget setup".to_string()))
|
AppError::Internal("MSD function not set after gadget setup".to_string())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disable MSD function
|
/// Disable MSD function
|
||||||
@@ -465,7 +466,10 @@ impl OtgService {
|
|||||||
device_version: super::configfs::DEFAULT_USB_BCD_DEVICE,
|
device_version: super::configfs::DEFAULT_USB_BCD_DEVICE,
|
||||||
manufacturer: config.manufacturer.clone(),
|
manufacturer: config.manufacturer.clone(),
|
||||||
product: config.product.clone(),
|
product: config.product.clone(),
|
||||||
serial_number: config.serial_number.clone().unwrap_or_else(|| "0123456789".to_string()),
|
serial_number: config
|
||||||
|
.serial_number
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "0123456789".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update stored descriptor
|
// Update stored descriptor
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ pub fn encode_frame(data: &[u8]) -> io::Result<Vec<u8>> {
|
|||||||
let h = ((len << 2) as u32) | 0x3;
|
let h = ((len << 2) as u32) | 0x3;
|
||||||
buf.extend_from_slice(&h.to_le_bytes());
|
buf.extend_from_slice(&h.to_le_bytes());
|
||||||
} else {
|
} else {
|
||||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "Message too large"));
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"Message too large",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
buf.extend_from_slice(data);
|
buf.extend_from_slice(data);
|
||||||
@@ -79,7 +82,10 @@ pub async fn read_frame<R: AsyncRead + Unpin>(reader: &mut R) -> io::Result<Byte
|
|||||||
let (_, msg_len) = decode_header(first_byte[0], &header_rest);
|
let (_, msg_len) = decode_header(first_byte[0], &header_rest);
|
||||||
|
|
||||||
if msg_len > MAX_PACKET_LENGTH {
|
if msg_len > MAX_PACKET_LENGTH {
|
||||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "Message too large"));
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"Message too large",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read message body
|
// Read message body
|
||||||
@@ -133,7 +139,10 @@ pub fn encode_frame_into(data: &[u8], buf: &mut BytesMut) -> io::Result<()> {
|
|||||||
} else if len <= MAX_PACKET_LENGTH {
|
} else if len <= MAX_PACKET_LENGTH {
|
||||||
buf.put_u32_le(((len << 2) as u32) | 0x3);
|
buf.put_u32_le(((len << 2) as u32) | 0x3);
|
||||||
} else {
|
} else {
|
||||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "Message too large"));
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"Message too large",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
buf.extend_from_slice(data);
|
buf.extend_from_slice(data);
|
||||||
@@ -216,7 +225,10 @@ impl BytesCodec {
|
|||||||
n >>= 2;
|
n >>= 2;
|
||||||
|
|
||||||
if n > self.max_packet_length {
|
if n > self.max_packet_length {
|
||||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "Message too large"));
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"Message too large",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
src.advance(head_len);
|
src.advance(head_len);
|
||||||
@@ -245,7 +257,10 @@ impl BytesCodec {
|
|||||||
} else if len <= MAX_PACKET_LENGTH {
|
} else if len <= MAX_PACKET_LENGTH {
|
||||||
buf.put_u32_le(((len << 2) as u32) | 0x3);
|
buf.put_u32_le(((len << 2) as u32) | 0x3);
|
||||||
} else {
|
} else {
|
||||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "Message too large"));
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"Message too large",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
buf.extend(data);
|
buf.extend(data);
|
||||||
|
|||||||
@@ -116,9 +116,9 @@ impl RustDeskConfig {
|
|||||||
|
|
||||||
/// Get the UUID bytes (returns None if not set)
|
/// Get the UUID bytes (returns None if not set)
|
||||||
pub fn get_uuid_bytes(&self) -> Option<[u8; 16]> {
|
pub fn get_uuid_bytes(&self) -> Option<[u8; 16]> {
|
||||||
self.uuid.as_ref().and_then(|s| {
|
self.uuid
|
||||||
uuid::Uuid::parse_str(s).ok().map(|u| *u.as_bytes())
|
.as_ref()
|
||||||
})
|
.and_then(|s| uuid::Uuid::parse_str(s).ok().map(|u| *u.as_bytes()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the rendezvous server address with default port
|
/// Get the rendezvous server address with default port
|
||||||
@@ -135,13 +135,16 @@ impl RustDeskConfig {
|
|||||||
|
|
||||||
/// Get the relay server address with default port
|
/// Get the relay server address with default port
|
||||||
pub fn relay_addr(&self) -> Option<String> {
|
pub fn relay_addr(&self) -> Option<String> {
|
||||||
self.relay_server.as_ref().map(|s| {
|
self.relay_server
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| {
|
||||||
if s.contains(':') {
|
if s.contains(':') {
|
||||||
s.clone()
|
s.clone()
|
||||||
} else {
|
} else {
|
||||||
format!("{}:21117", s)
|
format!("{}:21117", s)
|
||||||
}
|
}
|
||||||
}).or_else(|| {
|
})
|
||||||
|
.or_else(|| {
|
||||||
// Default: same host as rendezvous server
|
// Default: same host as rendezvous server
|
||||||
let server = &self.rendezvous_server;
|
let server = &self.rendezvous_server;
|
||||||
if !server.is_empty() {
|
if !server.is_empty() {
|
||||||
@@ -222,7 +225,10 @@ mod tests {
|
|||||||
|
|
||||||
// Explicit relay server
|
// Explicit relay server
|
||||||
config.relay_server = Some("relay.example.com".to_string());
|
config.relay_server = Some("relay.example.com".to_string());
|
||||||
assert_eq!(config.relay_addr(), Some("relay.example.com:21117".to_string()));
|
assert_eq!(
|
||||||
|
config.relay_addr(),
|
||||||
|
Some("relay.example.com:21117".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
// No rendezvous server, relay is None
|
// No rendezvous server, relay is None
|
||||||
config.rendezvous_server = String::new();
|
config.rendezvous_server = String::new();
|
||||||
|
|||||||
@@ -13,16 +13,16 @@ use std::sync::Arc;
|
|||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use bytes::{Bytes, BytesMut};
|
use bytes::{Bytes, BytesMut};
|
||||||
use sodiumoxide::crypto::box_;
|
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use protobuf::Message as ProtobufMessage;
|
use protobuf::Message as ProtobufMessage;
|
||||||
use tokio::net::TcpStream;
|
use sodiumoxide::crypto::box_;
|
||||||
use tokio::net::tcp::OwnedWriteHalf;
|
use tokio::net::tcp::OwnedWriteHalf;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
use tokio::sync::{broadcast, mpsc, Mutex};
|
use tokio::sync::{broadcast, mpsc, Mutex};
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::audio::AudioController;
|
use crate::audio::AudioController;
|
||||||
use crate::hid::{HidController, KeyboardEvent, KeyEventType, KeyboardModifiers};
|
use crate::hid::{HidController, KeyEventType, KeyboardEvent, KeyboardModifiers};
|
||||||
use crate::video::encoder::registry::{EncoderRegistry, VideoEncoderType};
|
use crate::video::encoder::registry::{EncoderRegistry, VideoEncoderType};
|
||||||
use crate::video::encoder::BitratePreset;
|
use crate::video::encoder::BitratePreset;
|
||||||
use crate::video::stream_manager::VideoStreamManager;
|
use crate::video::stream_manager::VideoStreamManager;
|
||||||
@@ -33,10 +33,9 @@ use super::crypto::{self, KeyPair, SigningKeyPair};
|
|||||||
use super::frame_adapters::{AudioFrameAdapter, VideoCodec, VideoFrameAdapter};
|
use super::frame_adapters::{AudioFrameAdapter, VideoCodec, VideoFrameAdapter};
|
||||||
use super::hid_adapter::{convert_key_event, convert_mouse_event, mouse_type};
|
use super::hid_adapter::{convert_key_event, convert_mouse_event, mouse_type};
|
||||||
use super::protocol::{
|
use super::protocol::{
|
||||||
message, misc, login_response,
|
decode_message, login_response, message, misc, Clipboard, ControlKey, DisplayInfo, Hash,
|
||||||
KeyEvent, MouseEvent, Clipboard, Misc, LoginRequest, LoginResponse, PeerInfo,
|
HbbMessage, IdPk, KeyEvent, LoginRequest, LoginResponse, Misc, MouseEvent, OptionMessage,
|
||||||
IdPk, SignedId, Hash, TestDelay, ControlKey,
|
PeerInfo, PublicKey, SignedId, SupportedEncoding, TestDelay,
|
||||||
decode_message, HbbMessage, DisplayInfo, SupportedEncoding, OptionMessage, PublicKey,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use sodiumoxide::crypto::secretbox;
|
use sodiumoxide::crypto::secretbox;
|
||||||
@@ -268,7 +267,11 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle an incoming TCP connection
|
/// Handle an incoming TCP connection
|
||||||
pub async fn handle_tcp(&mut self, stream: TcpStream, peer_addr: SocketAddr) -> anyhow::Result<()> {
|
pub async fn handle_tcp(
|
||||||
|
&mut self,
|
||||||
|
stream: TcpStream,
|
||||||
|
peer_addr: SocketAddr,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
info!("New connection from {}", peer_addr);
|
info!("New connection from {}", peer_addr);
|
||||||
*self.state.write() = ConnectionState::Handshaking;
|
*self.state.write() = ConnectionState::Handshaking;
|
||||||
|
|
||||||
@@ -279,7 +282,9 @@ impl Connection {
|
|||||||
// Send our SignedId first (this is what RustDesk protocol expects)
|
// Send our SignedId first (this is what RustDesk protocol expects)
|
||||||
// The SignedId contains our device ID and temporary public key
|
// The SignedId contains our device ID and temporary public key
|
||||||
let signed_id_msg = self.create_signed_id_message(&self.device_id.clone());
|
let signed_id_msg = self.create_signed_id_message(&self.device_id.clone());
|
||||||
let signed_id_bytes = signed_id_msg.write_to_bytes().map_err(|e| anyhow::anyhow!("Failed to encode SignedId: {}", e))?;
|
let signed_id_bytes = signed_id_msg
|
||||||
|
.write_to_bytes()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to encode SignedId: {}", e))?;
|
||||||
debug!("Sending SignedId with device_id={}", self.device_id);
|
debug!("Sending SignedId with device_id={}", self.device_id);
|
||||||
self.send_framed_arc(&writer, &signed_id_bytes).await?;
|
self.send_framed_arc(&writer, &signed_id_bytes).await?;
|
||||||
|
|
||||||
@@ -402,7 +407,11 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Send framed message using Arc<Mutex<OwnedWriteHalf>> with RustDesk's variable-length encoding
|
/// Send framed message using Arc<Mutex<OwnedWriteHalf>> with RustDesk's variable-length encoding
|
||||||
async fn send_framed_arc(&self, writer: &Arc<Mutex<OwnedWriteHalf>>, data: &[u8]) -> anyhow::Result<()> {
|
async fn send_framed_arc(
|
||||||
|
&self,
|
||||||
|
writer: &Arc<Mutex<OwnedWriteHalf>>,
|
||||||
|
data: &[u8],
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
let mut w = writer.lock().await;
|
let mut w = writer.lock().await;
|
||||||
write_frame(&mut *w, data).await?;
|
write_frame(&mut *w, data).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -480,7 +489,9 @@ impl Connection {
|
|||||||
pk.symmetric_value.len()
|
pk.symmetric_value.len()
|
||||||
);
|
);
|
||||||
if pk.asymmetric_value.is_empty() && pk.symmetric_value.is_empty() {
|
if pk.asymmetric_value.is_empty() && pk.symmetric_value.is_empty() {
|
||||||
warn!("Received EMPTY PublicKey - client may have failed signature verification!");
|
warn!(
|
||||||
|
"Received EMPTY PublicKey - client may have failed signature verification!"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
self.handle_peer_public_key(pk, writer).await?;
|
self.handle_peer_public_key(pk, writer).await?;
|
||||||
}
|
}
|
||||||
@@ -535,7 +546,7 @@ impl Connection {
|
|||||||
info!("Received SignedId from peer, id_len={}", si.id.len());
|
info!("Received SignedId from peer, id_len={}", si.id.len());
|
||||||
self.handle_signed_id(si, writer).await?;
|
self.handle_signed_id(si, writer).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
},
|
}
|
||||||
message::Union::Hash(_) => "Hash",
|
message::Union::Hash(_) => "Hash",
|
||||||
message::Union::VideoFrame(_) => "VideoFrame",
|
message::Union::VideoFrame(_) => "VideoFrame",
|
||||||
message::Union::CursorData(_) => "CursorData",
|
message::Union::CursorData(_) => "CursorData",
|
||||||
@@ -564,16 +575,26 @@ impl Connection {
|
|||||||
lr: &LoginRequest,
|
lr: &LoginRequest,
|
||||||
writer: &Arc<Mutex<OwnedWriteHalf>>,
|
writer: &Arc<Mutex<OwnedWriteHalf>>,
|
||||||
) -> anyhow::Result<bool> {
|
) -> anyhow::Result<bool> {
|
||||||
info!("Login request from {} ({}), password_len={}", lr.my_id, lr.my_name, lr.password.len());
|
info!(
|
||||||
|
"Login request from {} ({}), password_len={}",
|
||||||
|
lr.my_id,
|
||||||
|
lr.my_name,
|
||||||
|
lr.password.len()
|
||||||
|
);
|
||||||
|
|
||||||
// Check if our server requires a password
|
// Check if our server requires a password
|
||||||
if !self.password.is_empty() {
|
if !self.password.is_empty() {
|
||||||
// Server requires password
|
// Server requires password
|
||||||
if lr.password.is_empty() {
|
if lr.password.is_empty() {
|
||||||
// Client sent empty password - tell them to enter password
|
// Client sent empty password - tell them to enter password
|
||||||
info!("Empty password from {}, requesting password input", lr.my_id);
|
info!(
|
||||||
|
"Empty password from {}, requesting password input",
|
||||||
|
lr.my_id
|
||||||
|
);
|
||||||
let error_response = self.create_login_error_response("Empty Password");
|
let error_response = self.create_login_error_response("Empty Password");
|
||||||
let response_bytes = error_response.write_to_bytes().map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
let response_bytes = error_response
|
||||||
|
.write_to_bytes()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
||||||
self.send_encrypted_arc(writer, &response_bytes).await?;
|
self.send_encrypted_arc(writer, &response_bytes).await?;
|
||||||
// Don't close connection - wait for retry with password
|
// Don't close connection - wait for retry with password
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -583,7 +604,9 @@ impl Connection {
|
|||||||
if !self.verify_password(&lr.password) {
|
if !self.verify_password(&lr.password) {
|
||||||
warn!("Wrong password from {}", lr.my_id);
|
warn!("Wrong password from {}", lr.my_id);
|
||||||
let error_response = self.create_login_error_response("Wrong Password");
|
let error_response = self.create_login_error_response("Wrong Password");
|
||||||
let response_bytes = error_response.write_to_bytes().map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
let response_bytes = error_response
|
||||||
|
.write_to_bytes()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
||||||
self.send_encrypted_arc(writer, &response_bytes).await?;
|
self.send_encrypted_arc(writer, &response_bytes).await?;
|
||||||
// Don't close connection - wait for retry with correct password
|
// Don't close connection - wait for retry with correct password
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -601,7 +624,9 @@ impl Connection {
|
|||||||
info!("Negotiated video codec: {:?}", negotiated);
|
info!("Negotiated video codec: {:?}", negotiated);
|
||||||
|
|
||||||
let response = self.create_login_response(true);
|
let response = self.create_login_response(true);
|
||||||
let response_bytes = response.write_to_bytes().map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
let response_bytes = response
|
||||||
|
.write_to_bytes()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
||||||
self.send_encrypted_arc(writer, &response_bytes).await?;
|
self.send_encrypted_arc(writer, &response_bytes).await?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -679,7 +704,10 @@ impl Connection {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Some(preset) = preset {
|
if let Some(preset) = preset {
|
||||||
info!("Client requested quality preset: {:?} (image_quality={})", preset, image_quality);
|
info!(
|
||||||
|
"Client requested quality preset: {:?} (image_quality={})",
|
||||||
|
preset, image_quality
|
||||||
|
);
|
||||||
if let Some(ref video_manager) = self.video_manager {
|
if let Some(ref video_manager) = self.video_manager {
|
||||||
if let Err(e) = video_manager.set_bitrate_preset(preset).await {
|
if let Err(e) = video_manager.set_bitrate_preset(preset).await {
|
||||||
warn!("Failed to set bitrate preset: {}", e);
|
warn!("Failed to set bitrate preset: {}", e);
|
||||||
@@ -729,7 +757,10 @@ impl Connection {
|
|||||||
|
|
||||||
// Log custom_image_quality (accept but don't process)
|
// Log custom_image_quality (accept but don't process)
|
||||||
if opt.custom_image_quality > 0 {
|
if opt.custom_image_quality > 0 {
|
||||||
debug!("Client sent custom_image_quality: {} (ignored)", opt.custom_image_quality);
|
debug!(
|
||||||
|
"Client sent custom_image_quality: {} (ignored)",
|
||||||
|
opt.custom_image_quality
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if opt.custom_fps > 0 {
|
if opt.custom_fps > 0 {
|
||||||
debug!("Client requested FPS: {}", opt.custom_fps);
|
debug!("Client requested FPS: {}", opt.custom_fps);
|
||||||
@@ -779,7 +810,10 @@ impl Connection {
|
|||||||
let negotiated_codec = self.negotiated_codec.unwrap_or(VideoEncoderType::H264);
|
let negotiated_codec = self.negotiated_codec.unwrap_or(VideoEncoderType::H264);
|
||||||
|
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
info!("Starting video streaming for connection {} with codec {:?}", conn_id, negotiated_codec);
|
info!(
|
||||||
|
"Starting video streaming for connection {} with codec {:?}",
|
||||||
|
conn_id, negotiated_codec
|
||||||
|
);
|
||||||
|
|
||||||
if let Err(e) = run_video_streaming(
|
if let Err(e) = run_video_streaming(
|
||||||
conn_id,
|
conn_id,
|
||||||
@@ -788,7 +822,9 @@ impl Connection {
|
|||||||
state,
|
state,
|
||||||
shutdown_tx,
|
shutdown_tx,
|
||||||
negotiated_codec,
|
negotiated_codec,
|
||||||
).await {
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
error!("Video streaming error for connection {}: {}", conn_id, e);
|
error!("Video streaming error for connection {}: {}", conn_id, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,13 +851,9 @@ impl Connection {
|
|||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
info!("Starting audio streaming for connection {}", conn_id);
|
info!("Starting audio streaming for connection {}", conn_id);
|
||||||
|
|
||||||
if let Err(e) = run_audio_streaming(
|
if let Err(e) =
|
||||||
conn_id,
|
run_audio_streaming(conn_id, audio_controller, audio_tx, state, shutdown_tx).await
|
||||||
audio_controller,
|
{
|
||||||
audio_tx,
|
|
||||||
state,
|
|
||||||
shutdown_tx,
|
|
||||||
).await {
|
|
||||||
error!("Audio streaming error for connection {}: {}", conn_id, e);
|
error!("Audio streaming error for connection {}: {}", conn_id, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -894,7 +926,10 @@ impl Connection {
|
|||||||
self.encryption_enabled = true;
|
self.encryption_enabled = true;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to decrypt session key: {:?}, falling back to unencrypted", e);
|
warn!(
|
||||||
|
"Failed to decrypt session key: {:?}, falling back to unencrypted",
|
||||||
|
e
|
||||||
|
);
|
||||||
// Continue without encryption - some clients may not support it
|
// Continue without encryption - some clients may not support it
|
||||||
self.encryption_enabled = false;
|
self.encryption_enabled = false;
|
||||||
}
|
}
|
||||||
@@ -917,8 +952,13 @@ impl Connection {
|
|||||||
// This tells the client what salt to use for password hashing
|
// This tells the client what salt to use for password hashing
|
||||||
// Must be encrypted if session key was negotiated
|
// Must be encrypted if session key was negotiated
|
||||||
let hash_msg = self.create_hash_message();
|
let hash_msg = self.create_hash_message();
|
||||||
let hash_bytes = hash_msg.write_to_bytes().map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
let hash_bytes = hash_msg
|
||||||
debug!("Sending Hash message for password authentication (encrypted={})", self.encryption_enabled);
|
.write_to_bytes()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
||||||
|
debug!(
|
||||||
|
"Sending Hash message for password authentication (encrypted={})",
|
||||||
|
self.encryption_enabled
|
||||||
|
);
|
||||||
self.send_encrypted_arc(writer, &hash_bytes).await?;
|
self.send_encrypted_arc(writer, &hash_bytes).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -971,7 +1011,9 @@ impl Connection {
|
|||||||
// If we haven't sent our SignedId yet, send it now
|
// If we haven't sent our SignedId yet, send it now
|
||||||
// (This handles the case where client sends SignedId before we do)
|
// (This handles the case where client sends SignedId before we do)
|
||||||
let signed_id_msg = self.create_signed_id_message(&self.device_id.clone());
|
let signed_id_msg = self.create_signed_id_message(&self.device_id.clone());
|
||||||
let signed_id_bytes = signed_id_msg.write_to_bytes().map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
let signed_id_bytes = signed_id_msg
|
||||||
|
.write_to_bytes()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
||||||
self.send_framed_arc(writer, &signed_id_bytes).await?;
|
self.send_framed_arc(writer, &signed_id_bytes).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1073,7 +1115,8 @@ impl Connection {
|
|||||||
msg
|
msg
|
||||||
} else {
|
} else {
|
||||||
let mut login_response = LoginResponse::new();
|
let mut login_response = LoginResponse::new();
|
||||||
login_response.union = Some(login_response::Union::Error("Invalid password".to_string()));
|
login_response.union =
|
||||||
|
Some(login_response::Union::Error("Invalid password".to_string()));
|
||||||
login_response.enable_trusted_devices = false;
|
login_response.enable_trusted_devices = false;
|
||||||
|
|
||||||
let mut msg = HbbMessage::new();
|
let mut msg = HbbMessage::new();
|
||||||
@@ -1133,7 +1176,9 @@ impl Connection {
|
|||||||
let mut response = HbbMessage::new();
|
let mut response = HbbMessage::new();
|
||||||
response.union = Some(message::Union::TestDelay(test_delay));
|
response.union = Some(message::Union::TestDelay(test_delay));
|
||||||
|
|
||||||
let data = response.write_to_bytes().map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
let data = response
|
||||||
|
.write_to_bytes()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
||||||
self.send_encrypted_arc(writer, &data).await?;
|
self.send_encrypted_arc(writer, &data).await?;
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
@@ -1161,10 +1206,7 @@ impl Connection {
|
|||||||
/// The client will echo this back, allowing us to calculate RTT.
|
/// The client will echo this back, allowing us to calculate RTT.
|
||||||
/// The measured delay is then included in future TestDelay messages
|
/// The measured delay is then included in future TestDelay messages
|
||||||
/// for the client to display.
|
/// for the client to display.
|
||||||
async fn send_test_delay(
|
async fn send_test_delay(&mut self, writer: &Arc<Mutex<OwnedWriteHalf>>) -> anyhow::Result<()> {
|
||||||
&mut self,
|
|
||||||
writer: &Arc<Mutex<OwnedWriteHalf>>,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
// Get current time in milliseconds since epoch
|
// Get current time in milliseconds since epoch
|
||||||
let time_ms = SystemTime::now()
|
let time_ms = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
@@ -1180,13 +1222,18 @@ impl Connection {
|
|||||||
let mut msg = HbbMessage::new();
|
let mut msg = HbbMessage::new();
|
||||||
msg.union = Some(message::Union::TestDelay(test_delay));
|
msg.union = Some(message::Union::TestDelay(test_delay));
|
||||||
|
|
||||||
let data = msg.write_to_bytes().map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
let data = msg
|
||||||
|
.write_to_bytes()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
||||||
self.send_encrypted_arc(writer, &data).await?;
|
self.send_encrypted_arc(writer, &data).await?;
|
||||||
|
|
||||||
// Record when we sent this, so we can calculate RTT when client echoes back
|
// Record when we sent this, so we can calculate RTT when client echoes back
|
||||||
self.last_test_delay_sent = Some(Instant::now());
|
self.last_test_delay_sent = Some(Instant::now());
|
||||||
|
|
||||||
debug!("TestDelay sent: time={}, last_delay={}ms", time_ms, self.last_delay);
|
debug!(
|
||||||
|
"TestDelay sent: time={}, last_delay={}ms",
|
||||||
|
time_ms, self.last_delay
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,7 +1255,10 @@ impl Connection {
|
|||||||
self.last_caps_lock = caps_lock_in_modifiers;
|
self.last_caps_lock = caps_lock_in_modifiers;
|
||||||
// Send CapsLock key press (down + up) to toggle state on target
|
// Send CapsLock key press (down + up) to toggle state on target
|
||||||
if let Some(ref hid) = self.hid {
|
if let Some(ref hid) = self.hid {
|
||||||
debug!("CapsLock state changed to {}, sending CapsLock key", caps_lock_in_modifiers);
|
debug!(
|
||||||
|
"CapsLock state changed to {}, sending CapsLock key",
|
||||||
|
caps_lock_in_modifiers
|
||||||
|
);
|
||||||
let caps_down = KeyboardEvent {
|
let caps_down = KeyboardEvent {
|
||||||
event_type: KeyEventType::Down,
|
event_type: KeyEventType::Down,
|
||||||
key: 0x39, // USB HID CapsLock
|
key: 0x39, // USB HID CapsLock
|
||||||
@@ -1234,7 +1284,9 @@ impl Connection {
|
|||||||
if let Some(kb_event) = convert_key_event(ke) {
|
if let Some(kb_event) = convert_key_event(ke) {
|
||||||
debug!(
|
debug!(
|
||||||
"Converted to HID: key=0x{:02X}, event_type={:?}, modifiers={:02X}",
|
"Converted to HID: key=0x{:02X}, event_type={:?}, modifiers={:02X}",
|
||||||
kb_event.key, kb_event.event_type, kb_event.modifiers.to_hid_byte()
|
kb_event.key,
|
||||||
|
kb_event.event_type,
|
||||||
|
kb_event.modifiers.to_hid_byte()
|
||||||
);
|
);
|
||||||
// Send to HID controller if available
|
// Send to HID controller if available
|
||||||
if let Some(ref hid) = self.hid {
|
if let Some(ref hid) = self.hid {
|
||||||
@@ -1393,7 +1445,11 @@ impl ConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Accept a new connection
|
/// Accept a new connection
|
||||||
pub async fn accept_connection(&self, stream: TcpStream, peer_addr: SocketAddr) -> anyhow::Result<u32> {
|
pub async fn accept_connection(
|
||||||
|
&self,
|
||||||
|
stream: TcpStream,
|
||||||
|
peer_addr: SocketAddr,
|
||||||
|
) -> anyhow::Result<u32> {
|
||||||
let id = {
|
let id = {
|
||||||
let mut next = self.next_id.write();
|
let mut next = self.next_id.write();
|
||||||
let id = *next;
|
let id = *next;
|
||||||
@@ -1406,14 +1462,14 @@ impl ConnectionManager {
|
|||||||
let hid = self.hid.read().clone();
|
let hid = self.hid.read().clone();
|
||||||
let audio = self.audio.read().clone();
|
let audio = self.audio.read().clone();
|
||||||
let video_manager = self.video_manager.read().clone();
|
let video_manager = self.video_manager.read().clone();
|
||||||
let (mut conn, _rx) = Connection::new(id, &config, signing_keypair, hid, audio, video_manager);
|
let (mut conn, _rx) =
|
||||||
|
Connection::new(id, &config, signing_keypair, hid, audio, video_manager);
|
||||||
|
|
||||||
// Track connection state for external access
|
// Track connection state for external access
|
||||||
let state = conn.state.clone();
|
let state = conn.state.clone();
|
||||||
self.connections.write().push(Arc::new(RwLock::new(ConnectionInfo {
|
self.connections
|
||||||
id,
|
.write()
|
||||||
state,
|
.push(Arc::new(RwLock::new(ConnectionInfo { id, state })));
|
||||||
})));
|
|
||||||
|
|
||||||
// Spawn connection handler - Connection is moved, not locked
|
// Spawn connection handler - Connection is moved, not locked
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -1466,7 +1522,10 @@ async fn run_video_streaming(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Set the video codec on the shared pipeline before subscribing
|
// Set the video codec on the shared pipeline before subscribing
|
||||||
info!("Setting video codec to {:?} for connection {}", negotiated_codec, conn_id);
|
info!(
|
||||||
|
"Setting video codec to {:?} for connection {}",
|
||||||
|
negotiated_codec, conn_id
|
||||||
|
);
|
||||||
if let Err(e) = video_manager.set_video_codec(webrtc_codec).await {
|
if let Err(e) = video_manager.set_video_codec(webrtc_codec).await {
|
||||||
error!("Failed to set video codec: {}", e);
|
error!("Failed to set video codec: {}", e);
|
||||||
// Continue anyway, will use whatever codec the pipeline already has
|
// Continue anyway, will use whatever codec the pipeline already has
|
||||||
@@ -1485,7 +1544,10 @@ async fn run_video_streaming(
|
|||||||
let mut encoded_count: u64 = 0;
|
let mut encoded_count: u64 = 0;
|
||||||
let mut last_log_time = Instant::now();
|
let mut last_log_time = Instant::now();
|
||||||
|
|
||||||
info!("Started shared video streaming for connection {} (codec: {:?})", conn_id, codec);
|
info!(
|
||||||
|
"Started shared video streaming for connection {} (codec: {:?})",
|
||||||
|
conn_id, codec
|
||||||
|
);
|
||||||
|
|
||||||
// Outer loop: handles pipeline restarts by re-subscribing
|
// Outer loop: handles pipeline restarts by re-subscribing
|
||||||
'subscribe_loop: loop {
|
'subscribe_loop: loop {
|
||||||
@@ -1500,7 +1562,10 @@ async fn run_video_streaming(
|
|||||||
Some(rx) => rx,
|
Some(rx) => rx,
|
||||||
None => {
|
None => {
|
||||||
// Pipeline not ready yet, wait and retry
|
// Pipeline not ready yet, wait and retry
|
||||||
debug!("No encoded frame source available for connection {}, retrying...", conn_id);
|
debug!(
|
||||||
|
"No encoded frame source available for connection {}, retrying...",
|
||||||
|
conn_id
|
||||||
|
);
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
continue 'subscribe_loop;
|
continue 'subscribe_loop;
|
||||||
}
|
}
|
||||||
@@ -1619,13 +1684,19 @@ async fn run_audio_streaming(
|
|||||||
Some(rx) => rx,
|
Some(rx) => rx,
|
||||||
None => {
|
None => {
|
||||||
// Audio not available, wait and retry
|
// Audio not available, wait and retry
|
||||||
debug!("No audio source available for connection {}, retrying...", conn_id);
|
debug!(
|
||||||
|
"No audio source available for connection {}, retrying...",
|
||||||
|
conn_id
|
||||||
|
);
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
continue 'subscribe_loop;
|
continue 'subscribe_loop;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
info!("RustDesk connection {} subscribed to audio pipeline", conn_id);
|
info!(
|
||||||
|
"RustDesk connection {} subscribed to audio pipeline",
|
||||||
|
conn_id
|
||||||
|
);
|
||||||
|
|
||||||
// Send audio format message once before sending frames
|
// Send audio format message once before sending frames
|
||||||
if !audio_adapter.format_sent() {
|
if !audio_adapter.format_sent() {
|
||||||
|
|||||||
@@ -86,8 +86,12 @@ impl KeyPair {
|
|||||||
|
|
||||||
/// Create from base64-encoded keys
|
/// Create from base64-encoded keys
|
||||||
pub fn from_base64(public_key: &str, secret_key: &str) -> Result<Self, CryptoError> {
|
pub fn from_base64(public_key: &str, secret_key: &str) -> Result<Self, CryptoError> {
|
||||||
let pk_bytes = BASE64.decode(public_key).map_err(|_| CryptoError::InvalidKeyLength)?;
|
let pk_bytes = BASE64
|
||||||
let sk_bytes = BASE64.decode(secret_key).map_err(|_| CryptoError::InvalidKeyLength)?;
|
.decode(public_key)
|
||||||
|
.map_err(|_| CryptoError::InvalidKeyLength)?;
|
||||||
|
let sk_bytes = BASE64
|
||||||
|
.decode(secret_key)
|
||||||
|
.map_err(|_| CryptoError::InvalidKeyLength)?;
|
||||||
Self::from_keys(&pk_bytes, &sk_bytes)
|
Self::from_keys(&pk_bytes, &sk_bytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,7 +144,10 @@ pub fn decrypt_with_key(
|
|||||||
|
|
||||||
/// Compute a shared symmetric key from public/private keypair
|
/// Compute a shared symmetric key from public/private keypair
|
||||||
/// This is the precomputed key for the NaCl box
|
/// This is the precomputed key for the NaCl box
|
||||||
pub fn precompute_key(their_public_key: &PublicKey, our_secret_key: &SecretKey) -> box_::PrecomputedKey {
|
pub fn precompute_key(
|
||||||
|
their_public_key: &PublicKey,
|
||||||
|
our_secret_key: &SecretKey,
|
||||||
|
) -> box_::PrecomputedKey {
|
||||||
box_::precompute(their_public_key, our_secret_key)
|
box_::precompute(their_public_key, our_secret_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,8 +214,8 @@ pub fn decrypt_symmetric_key(
|
|||||||
return Err(CryptoError::InvalidKeyLength);
|
return Err(CryptoError::InvalidKeyLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
let their_pk = PublicKey::from_slice(their_temp_public_key)
|
let their_pk =
|
||||||
.ok_or(CryptoError::InvalidKeyLength)?;
|
PublicKey::from_slice(their_temp_public_key).ok_or(CryptoError::InvalidKeyLength)?;
|
||||||
|
|
||||||
// Use zero nonce as per RustDesk protocol
|
// Use zero nonce as per RustDesk protocol
|
||||||
let nonce = box_::Nonce([0u8; box_::NONCEBYTES]);
|
let nonce = box_::Nonce([0u8; box_::NONCEBYTES]);
|
||||||
@@ -294,8 +301,12 @@ impl SigningKeyPair {
|
|||||||
|
|
||||||
/// Create from base64-encoded keys
|
/// Create from base64-encoded keys
|
||||||
pub fn from_base64(public_key: &str, secret_key: &str) -> Result<Self, CryptoError> {
|
pub fn from_base64(public_key: &str, secret_key: &str) -> Result<Self, CryptoError> {
|
||||||
let pk_bytes = BASE64.decode(public_key).map_err(|_| CryptoError::InvalidKeyLength)?;
|
let pk_bytes = BASE64
|
||||||
let sk_bytes = BASE64.decode(secret_key).map_err(|_| CryptoError::InvalidKeyLength)?;
|
.decode(public_key)
|
||||||
|
.map_err(|_| CryptoError::InvalidKeyLength)?;
|
||||||
|
let sk_bytes = BASE64
|
||||||
|
.decode(secret_key)
|
||||||
|
.map_err(|_| CryptoError::InvalidKeyLength)?;
|
||||||
Self::from_keys(&pk_bytes, &sk_bytes)
|
Self::from_keys(&pk_bytes, &sk_bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,8 +332,7 @@ impl SigningKeyPair {
|
|||||||
/// which is required by RustDesk's protocol where clients encrypt the
|
/// which is required by RustDesk's protocol where clients encrypt the
|
||||||
/// symmetric key using the public key from IdPk.
|
/// symmetric key using the public key from IdPk.
|
||||||
pub fn to_curve25519_pk(&self) -> Result<PublicKey, CryptoError> {
|
pub fn to_curve25519_pk(&self) -> Result<PublicKey, CryptoError> {
|
||||||
ed25519::to_curve25519_pk(&self.public_key)
|
ed25519::to_curve25519_pk(&self.public_key).map_err(|_| CryptoError::KeyConversionFailed)
|
||||||
.map_err(|_| CryptoError::KeyConversionFailed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert Ed25519 secret key to Curve25519 secret key for decryption
|
/// Convert Ed25519 secret key to Curve25519 secret key for decryption
|
||||||
@@ -330,14 +340,16 @@ impl SigningKeyPair {
|
|||||||
/// This allows decrypting messages that were encrypted using the
|
/// This allows decrypting messages that were encrypted using the
|
||||||
/// converted public key.
|
/// converted public key.
|
||||||
pub fn to_curve25519_sk(&self) -> Result<SecretKey, CryptoError> {
|
pub fn to_curve25519_sk(&self) -> Result<SecretKey, CryptoError> {
|
||||||
ed25519::to_curve25519_sk(&self.secret_key)
|
ed25519::to_curve25519_sk(&self.secret_key).map_err(|_| CryptoError::KeyConversionFailed)
|
||||||
.map_err(|_| CryptoError::KeyConversionFailed)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a signed message
|
/// Verify a signed message
|
||||||
/// Returns the original message if signature is valid
|
/// Returns the original message if signature is valid
|
||||||
pub fn verify_signed(signed_message: &[u8], public_key: &sign::PublicKey) -> Result<Vec<u8>, CryptoError> {
|
pub fn verify_signed(
|
||||||
|
signed_message: &[u8],
|
||||||
|
public_key: &sign::PublicKey,
|
||||||
|
) -> Result<Vec<u8>, CryptoError> {
|
||||||
sign::verify(signed_message, public_key).map_err(|_| CryptoError::SignatureVerificationFailed)
|
sign::verify(signed_message, public_key).map_err(|_| CryptoError::SignatureVerificationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +386,8 @@ mod tests {
|
|||||||
let message = b"Hello, RustDesk!";
|
let message = b"Hello, RustDesk!";
|
||||||
let (nonce, ciphertext) = encrypt_box(message, &bob.public_key, &alice.secret_key);
|
let (nonce, ciphertext) = encrypt_box(message, &bob.public_key, &alice.secret_key);
|
||||||
|
|
||||||
let plaintext = decrypt_box(&ciphertext, &nonce, &alice.public_key, &bob.secret_key).unwrap();
|
let plaintext =
|
||||||
|
decrypt_box(&ciphertext, &nonce, &alice.public_key, &bob.secret_key).unwrap();
|
||||||
assert_eq!(plaintext, message);
|
assert_eq!(plaintext, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ use bytes::Bytes;
|
|||||||
use protobuf::Message as ProtobufMessage;
|
use protobuf::Message as ProtobufMessage;
|
||||||
|
|
||||||
use super::protocol::hbb::message::{
|
use super::protocol::hbb::message::{
|
||||||
message as msg_union, misc as misc_union, video_frame as vf_union,
|
message as msg_union, misc as misc_union, video_frame as vf_union, AudioFormat, AudioFrame,
|
||||||
AudioFormat, AudioFrame, CursorData, CursorPosition,
|
CursorData, CursorPosition, EncodedVideoFrame, EncodedVideoFrames, Message, Misc, VideoFrame,
|
||||||
EncodedVideoFrame, EncodedVideoFrames, Message, Misc, VideoFrame,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Video codec type for RustDesk
|
/// Video codec type for RustDesk
|
||||||
@@ -63,7 +62,12 @@ impl VideoFrameAdapter {
|
|||||||
/// Convert encoded video data to RustDesk Message (zero-copy version)
|
/// Convert encoded video data to RustDesk Message (zero-copy version)
|
||||||
///
|
///
|
||||||
/// This version takes Bytes directly to avoid copying the frame data.
|
/// This version takes Bytes directly to avoid copying the frame data.
|
||||||
pub fn encode_frame_from_bytes(&mut self, data: Bytes, is_keyframe: bool, timestamp_ms: u64) -> Message {
|
pub fn encode_frame_from_bytes(
|
||||||
|
&mut self,
|
||||||
|
data: Bytes,
|
||||||
|
is_keyframe: bool,
|
||||||
|
timestamp_ms: u64,
|
||||||
|
) -> Message {
|
||||||
// Calculate relative timestamp
|
// Calculate relative timestamp
|
||||||
if self.seq == 0 {
|
if self.seq == 0 {
|
||||||
self.timestamp_base = timestamp_ms;
|
self.timestamp_base = timestamp_ms;
|
||||||
@@ -104,13 +108,23 @@ impl VideoFrameAdapter {
|
|||||||
/// Encode frame to bytes for sending (zero-copy version)
|
/// Encode frame to bytes for sending (zero-copy version)
|
||||||
///
|
///
|
||||||
/// Takes Bytes directly to avoid copying the frame data.
|
/// Takes Bytes directly to avoid copying the frame data.
|
||||||
pub fn encode_frame_bytes_zero_copy(&mut self, data: Bytes, is_keyframe: bool, timestamp_ms: u64) -> Bytes {
|
pub fn encode_frame_bytes_zero_copy(
|
||||||
|
&mut self,
|
||||||
|
data: Bytes,
|
||||||
|
is_keyframe: bool,
|
||||||
|
timestamp_ms: u64,
|
||||||
|
) -> Bytes {
|
||||||
let msg = self.encode_frame_from_bytes(data, is_keyframe, timestamp_ms);
|
let msg = self.encode_frame_from_bytes(data, is_keyframe, timestamp_ms);
|
||||||
Bytes::from(msg.write_to_bytes().unwrap_or_default())
|
Bytes::from(msg.write_to_bytes().unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode frame to bytes for sending
|
/// Encode frame to bytes for sending
|
||||||
pub fn encode_frame_bytes(&mut self, data: &[u8], is_keyframe: bool, timestamp_ms: u64) -> Bytes {
|
pub fn encode_frame_bytes(
|
||||||
|
&mut self,
|
||||||
|
data: &[u8],
|
||||||
|
is_keyframe: bool,
|
||||||
|
timestamp_ms: u64,
|
||||||
|
) -> Bytes {
|
||||||
self.encode_frame_bytes_zero_copy(Bytes::copy_from_slice(data), is_keyframe, timestamp_ms)
|
self.encode_frame_bytes_zero_copy(Bytes::copy_from_slice(data), is_keyframe, timestamp_ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,15 +248,13 @@ mod tests {
|
|||||||
let msg = adapter.encode_frame(&data, true, 0);
|
let msg = adapter.encode_frame(&data, true, 0);
|
||||||
|
|
||||||
match &msg.union {
|
match &msg.union {
|
||||||
Some(msg_union::Union::VideoFrame(vf)) => {
|
Some(msg_union::Union::VideoFrame(vf)) => match &vf.union {
|
||||||
match &vf.union {
|
|
||||||
Some(vf_union::Union::H264s(frames)) => {
|
Some(vf_union::Union::H264s(frames)) => {
|
||||||
assert_eq!(frames.frames.len(), 1);
|
assert_eq!(frames.frames.len(), 1);
|
||||||
assert!(frames.frames[0].key);
|
assert!(frames.frames[0].key);
|
||||||
}
|
}
|
||||||
_ => panic!("Expected H264s"),
|
_ => panic!("Expected H264s"),
|
||||||
}
|
},
|
||||||
}
|
|
||||||
_ => panic!("Expected VideoFrame"),
|
_ => panic!("Expected VideoFrame"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,15 +268,13 @@ mod tests {
|
|||||||
assert!(adapter.format_sent());
|
assert!(adapter.format_sent());
|
||||||
|
|
||||||
match &msg.union {
|
match &msg.union {
|
||||||
Some(msg_union::Union::Misc(misc)) => {
|
Some(msg_union::Union::Misc(misc)) => match &misc.union {
|
||||||
match &misc.union {
|
|
||||||
Some(misc_union::Union::AudioFormat(fmt)) => {
|
Some(misc_union::Union::AudioFormat(fmt)) => {
|
||||||
assert_eq!(fmt.sample_rate, 48000);
|
assert_eq!(fmt.sample_rate, 48000);
|
||||||
assert_eq!(fmt.channels, 2);
|
assert_eq!(fmt.channels, 2);
|
||||||
}
|
}
|
||||||
_ => panic!("Expected AudioFormat"),
|
_ => panic!("Expected AudioFormat"),
|
||||||
}
|
},
|
||||||
}
|
|
||||||
_ => panic!("Expected Misc"),
|
_ => panic!("Expected Misc"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
//!
|
//!
|
||||||
//! Converts RustDesk HID events (KeyEvent, MouseEvent) to One-KVM HID events.
|
//! Converts RustDesk HID events (KeyEvent, MouseEvent) to One-KVM HID events.
|
||||||
|
|
||||||
use protobuf::Enum;
|
|
||||||
use crate::hid::{
|
|
||||||
KeyboardEvent, KeyboardModifiers, KeyEventType,
|
|
||||||
MouseButton, MouseEvent as OneKvmMouseEvent, MouseEventType,
|
|
||||||
};
|
|
||||||
use super::protocol::{KeyEvent, MouseEvent, ControlKey};
|
|
||||||
use super::protocol::hbb::message::key_event as ke_union;
|
use super::protocol::hbb::message::key_event as ke_union;
|
||||||
|
use super::protocol::{ControlKey, KeyEvent, MouseEvent};
|
||||||
|
use crate::hid::{
|
||||||
|
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent as OneKvmMouseEvent,
|
||||||
|
MouseEventType,
|
||||||
|
};
|
||||||
|
use protobuf::Enum;
|
||||||
|
|
||||||
/// Mouse event types from RustDesk protocol
|
/// Mouse event types from RustDesk protocol
|
||||||
/// mask = (button << 3) | event_type
|
/// mask = (button << 3) | event_type
|
||||||
@@ -32,7 +32,11 @@ pub mod mouse_button {
|
|||||||
/// Convert RustDesk MouseEvent to One-KVM MouseEvent(s)
|
/// Convert RustDesk MouseEvent to One-KVM MouseEvent(s)
|
||||||
/// Returns a Vec because a single RustDesk event may need multiple One-KVM events
|
/// Returns a Vec because a single RustDesk event may need multiple One-KVM events
|
||||||
/// (e.g., move + button + scroll)
|
/// (e.g., move + button + scroll)
|
||||||
pub fn convert_mouse_event(event: &MouseEvent, screen_width: u32, screen_height: u32) -> Vec<OneKvmMouseEvent> {
|
pub fn convert_mouse_event(
|
||||||
|
event: &MouseEvent,
|
||||||
|
screen_width: u32,
|
||||||
|
screen_height: u32,
|
||||||
|
) -> Vec<OneKvmMouseEvent> {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
|
||||||
// RustDesk uses absolute coordinates
|
// RustDesk uses absolute coordinates
|
||||||
@@ -533,7 +537,9 @@ mod tests {
|
|||||||
let events = convert_mouse_event(&event, 1920, 1080);
|
let events = convert_mouse_event(&event, 1920, 1080);
|
||||||
assert!(events.len() >= 2);
|
assert!(events.len() >= 2);
|
||||||
// Should have a button down event
|
// Should have a button down event
|
||||||
assert!(events.iter().any(|e| e.event_type == MouseEventType::Down && e.button == Some(MouseButton::Left)));
|
assert!(events
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.event_type == MouseEventType::Down && e.button == Some(MouseButton::Left)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -542,7 +548,9 @@ mod tests {
|
|||||||
let mut key_event = KeyEvent::new();
|
let mut key_event = KeyEvent::new();
|
||||||
key_event.down = true;
|
key_event.down = true;
|
||||||
key_event.press = false;
|
key_event.press = false;
|
||||||
key_event.union = Some(ke_union::Union::ControlKey(EnumOrUnknown::new(ControlKey::Return)));
|
key_event.union = Some(ke_union::Union::ControlKey(EnumOrUnknown::new(
|
||||||
|
ControlKey::Return,
|
||||||
|
)));
|
||||||
|
|
||||||
let result = convert_key_event(&key_event);
|
let result = convert_key_event(&key_event);
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
|
|||||||
@@ -205,7 +205,8 @@ impl RustDeskService {
|
|||||||
self.connection_manager.set_audio(self.audio.clone());
|
self.connection_manager.set_audio(self.audio.clone());
|
||||||
|
|
||||||
// Set the video manager on connection manager for video streaming
|
// Set the video manager on connection manager for video streaming
|
||||||
self.connection_manager.set_video_manager(self.video_manager.clone());
|
self.connection_manager
|
||||||
|
.set_video_manager(self.video_manager.clone());
|
||||||
|
|
||||||
*self.rendezvous.write() = Some(mediator.clone());
|
*self.rendezvous.write() = Some(mediator.clone());
|
||||||
|
|
||||||
@@ -231,7 +232,8 @@ impl RustDeskService {
|
|||||||
let audio_punch = self.audio.clone();
|
let audio_punch = self.audio.clone();
|
||||||
let service_config_punch = self.config.clone();
|
let service_config_punch = self.config.clone();
|
||||||
|
|
||||||
mediator.set_punch_callback(Arc::new(move |peer_addr, rendezvous_addr, relay_server, uuid, socket_addr, device_id| {
|
mediator.set_punch_callback(Arc::new(
|
||||||
|
move |peer_addr, rendezvous_addr, relay_server, uuid, socket_addr, device_id| {
|
||||||
let conn_mgr = connection_manager_punch.clone();
|
let conn_mgr = connection_manager_punch.clone();
|
||||||
let video = video_manager_punch.clone();
|
let video = video_manager_punch.clone();
|
||||||
let hid = hid_punch.clone();
|
let hid = hid_punch.clone();
|
||||||
@@ -274,14 +276,18 @@ impl RustDeskService {
|
|||||||
video,
|
video,
|
||||||
hid,
|
hid,
|
||||||
audio,
|
audio,
|
||||||
).await {
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
error!("Failed to handle relay request: {}", e);
|
error!("Failed to handle relay request: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}));
|
},
|
||||||
|
));
|
||||||
|
|
||||||
// Set the relay callback on the mediator
|
// Set the relay callback on the mediator
|
||||||
mediator.set_relay_callback(Arc::new(move |rendezvous_addr, relay_server, uuid, socket_addr, device_id| {
|
mediator.set_relay_callback(Arc::new(
|
||||||
|
move |rendezvous_addr, relay_server, uuid, socket_addr, device_id| {
|
||||||
let conn_mgr = connection_manager.clone();
|
let conn_mgr = connection_manager.clone();
|
||||||
let video = video_manager.clone();
|
let video = video_manager.clone();
|
||||||
let hid = hid.clone();
|
let hid = hid.clone();
|
||||||
@@ -306,15 +312,19 @@ impl RustDeskService {
|
|||||||
video,
|
video,
|
||||||
hid,
|
hid,
|
||||||
audio,
|
audio,
|
||||||
).await {
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
error!("Failed to handle relay request: {}", e);
|
error!("Failed to handle relay request: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}));
|
},
|
||||||
|
));
|
||||||
|
|
||||||
// Set the intranet callback on the mediator for same-LAN connections
|
// Set the intranet callback on the mediator for same-LAN connections
|
||||||
let connection_manager2 = self.connection_manager.clone();
|
let connection_manager2 = self.connection_manager.clone();
|
||||||
mediator.set_intranet_callback(Arc::new(move |rendezvous_addr, peer_socket_addr, local_addr, relay_server, device_id| {
|
mediator.set_intranet_callback(Arc::new(
|
||||||
|
move |rendezvous_addr, peer_socket_addr, local_addr, relay_server, device_id| {
|
||||||
let conn_mgr = connection_manager2.clone();
|
let conn_mgr = connection_manager2.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -325,11 +335,14 @@ impl RustDeskService {
|
|||||||
&relay_server,
|
&relay_server,
|
||||||
&device_id,
|
&device_id,
|
||||||
conn_mgr,
|
conn_mgr,
|
||||||
).await {
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
error!("Failed to handle intranet request: {}", e);
|
error!("Failed to handle intranet request: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}));
|
},
|
||||||
|
));
|
||||||
|
|
||||||
// Spawn rendezvous task
|
// Spawn rendezvous task
|
||||||
let status = self.status.clone();
|
let status = self.status.clone();
|
||||||
@@ -471,7 +484,9 @@ impl RustDeskService {
|
|||||||
// Save signing keypair (Ed25519)
|
// Save signing keypair (Ed25519)
|
||||||
let signing_pk = skp.public_key_base64();
|
let signing_pk = skp.public_key_base64();
|
||||||
let signing_sk = skp.secret_key_base64();
|
let signing_sk = skp.secret_key_base64();
|
||||||
if config.signing_public_key.as_ref() != Some(&signing_pk) || config.signing_private_key.as_ref() != Some(&signing_sk) {
|
if config.signing_public_key.as_ref() != Some(&signing_pk)
|
||||||
|
|| config.signing_private_key.as_ref() != Some(&signing_sk)
|
||||||
|
{
|
||||||
config.signing_public_key = Some(signing_pk);
|
config.signing_public_key = Some(signing_pk);
|
||||||
config.signing_private_key = Some(signing_sk);
|
config.signing_private_key = Some(signing_sk);
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -522,13 +537,18 @@ async fn handle_relay_request(
|
|||||||
_hid: Arc<HidController>,
|
_hid: Arc<HidController>,
|
||||||
_audio: Arc<AudioController>,
|
_audio: Arc<AudioController>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
info!("Handling relay request: rendezvous={}, relay={}, uuid={}", rendezvous_addr, relay_server, uuid);
|
info!(
|
||||||
|
"Handling relay request: rendezvous={}, relay={}, uuid={}",
|
||||||
|
rendezvous_addr, relay_server, uuid
|
||||||
|
);
|
||||||
|
|
||||||
// Step 1: Connect to RENDEZVOUS server and send RelayResponse
|
// Step 1: Connect to RENDEZVOUS server and send RelayResponse
|
||||||
let rendezvous_socket_addr: SocketAddr = tokio::net::lookup_host(rendezvous_addr)
|
let rendezvous_socket_addr: SocketAddr = tokio::net::lookup_host(rendezvous_addr)
|
||||||
.await?
|
.await?
|
||||||
.next()
|
.next()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Failed to resolve rendezvous server: {}", rendezvous_addr))?;
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("Failed to resolve rendezvous server: {}", rendezvous_addr)
|
||||||
|
})?;
|
||||||
|
|
||||||
let mut rendezvous_stream = tokio::time::timeout(
|
let mut rendezvous_stream = tokio::time::timeout(
|
||||||
Duration::from_millis(RELAY_CONNECT_TIMEOUT_MS),
|
Duration::from_millis(RELAY_CONNECT_TIMEOUT_MS),
|
||||||
@@ -537,12 +557,17 @@ async fn handle_relay_request(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow::anyhow!("Rendezvous connection timeout"))??;
|
.map_err(|_| anyhow::anyhow!("Rendezvous connection timeout"))??;
|
||||||
|
|
||||||
debug!("Connected to rendezvous server at {}", rendezvous_socket_addr);
|
debug!(
|
||||||
|
"Connected to rendezvous server at {}",
|
||||||
|
rendezvous_socket_addr
|
||||||
|
);
|
||||||
|
|
||||||
// Send RelayResponse to rendezvous server with client's socket_addr
|
// Send RelayResponse to rendezvous server with client's socket_addr
|
||||||
// IMPORTANT: Include our device ID so rendezvous server can look up and sign our public key
|
// IMPORTANT: Include our device ID so rendezvous server can look up and sign our public key
|
||||||
let relay_response = make_relay_response(uuid, socket_addr, relay_server, device_id);
|
let relay_response = make_relay_response(uuid, socket_addr, relay_server, device_id);
|
||||||
let bytes = relay_response.write_to_bytes().map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
let bytes = relay_response
|
||||||
|
.write_to_bytes()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
||||||
bytes_codec::write_frame(&mut rendezvous_stream, &bytes).await?;
|
bytes_codec::write_frame(&mut rendezvous_stream, &bytes).await?;
|
||||||
debug!("Sent RelayResponse to rendezvous server for uuid={}", uuid);
|
debug!("Sent RelayResponse to rendezvous server for uuid={}", uuid);
|
||||||
|
|
||||||
@@ -568,7 +593,9 @@ async fn handle_relay_request(
|
|||||||
// The licence_key is required if the relay server is configured with -k option
|
// The licence_key is required if the relay server is configured with -k option
|
||||||
// The socket_addr is CRITICAL - the relay server uses it to match us with the peer
|
// The socket_addr is CRITICAL - the relay server uses it to match us with the peer
|
||||||
let request_relay = make_request_relay(uuid, relay_key, socket_addr);
|
let request_relay = make_request_relay(uuid, relay_key, socket_addr);
|
||||||
let bytes = request_relay.write_to_bytes().map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
let bytes = request_relay
|
||||||
|
.write_to_bytes()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
||||||
bytes_codec::write_frame(&mut stream, &bytes).await?;
|
bytes_codec::write_frame(&mut stream, &bytes).await?;
|
||||||
debug!("Sent RequestRelay to relay server for uuid={}", uuid);
|
debug!("Sent RequestRelay to relay server for uuid={}", uuid);
|
||||||
|
|
||||||
@@ -576,8 +603,13 @@ async fn handle_relay_request(
|
|||||||
let peer_addr = rendezvous::AddrMangle::decode(socket_addr).unwrap_or(relay_addr);
|
let peer_addr = rendezvous::AddrMangle::decode(socket_addr).unwrap_or(relay_addr);
|
||||||
|
|
||||||
// Step 3: Accept connection - relay server bridges the connection
|
// Step 3: Accept connection - relay server bridges the connection
|
||||||
connection_manager.accept_connection(stream, peer_addr).await?;
|
connection_manager
|
||||||
info!("Relay connection established for uuid={}, peer={}", uuid, peer_addr);
|
.accept_connection(stream, peer_addr)
|
||||||
|
.await?;
|
||||||
|
info!(
|
||||||
|
"Relay connection established for uuid={}, peer={}",
|
||||||
|
uuid, peer_addr
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -608,14 +640,15 @@ async fn handle_intranet_request(
|
|||||||
debug!("Peer address from FetchLocalAddr: {:?}", peer_addr);
|
debug!("Peer address from FetchLocalAddr: {:?}", peer_addr);
|
||||||
|
|
||||||
// Connect to rendezvous server via TCP with timeout
|
// Connect to rendezvous server via TCP with timeout
|
||||||
let mut stream = tokio::time::timeout(
|
let mut stream =
|
||||||
Duration::from_secs(5),
|
tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(rendezvous_addr))
|
||||||
TcpStream::connect(rendezvous_addr),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow::anyhow!("Timeout connecting to rendezvous server"))??;
|
.map_err(|_| anyhow::anyhow!("Timeout connecting to rendezvous server"))??;
|
||||||
|
|
||||||
info!("Connected to rendezvous server for intranet: {}", rendezvous_addr);
|
info!(
|
||||||
|
"Connected to rendezvous server for intranet: {}",
|
||||||
|
rendezvous_addr
|
||||||
|
);
|
||||||
|
|
||||||
// Build LocalAddr message with our local address (mangled)
|
// Build LocalAddr message with our local address (mangled)
|
||||||
let local_addr_bytes = AddrMangle::encode(local_addr);
|
let local_addr_bytes = AddrMangle::encode(local_addr);
|
||||||
@@ -626,7 +659,9 @@ async fn handle_intranet_request(
|
|||||||
device_id,
|
device_id,
|
||||||
env!("CARGO_PKG_VERSION"),
|
env!("CARGO_PKG_VERSION"),
|
||||||
);
|
);
|
||||||
let bytes = msg.write_to_bytes().map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
let bytes = msg
|
||||||
|
.write_to_bytes()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
||||||
|
|
||||||
// Send LocalAddr using RustDesk's variable-length framing
|
// Send LocalAddr using RustDesk's variable-length framing
|
||||||
bytes_codec::write_frame(&mut stream, &bytes).await?;
|
bytes_codec::write_frame(&mut stream, &bytes).await?;
|
||||||
@@ -640,11 +675,15 @@ async fn handle_intranet_request(
|
|||||||
// Get peer address for logging/connection tracking
|
// Get peer address for logging/connection tracking
|
||||||
let effective_peer_addr = peer_addr.unwrap_or_else(|| {
|
let effective_peer_addr = peer_addr.unwrap_or_else(|| {
|
||||||
// If we can't decode the peer address, use the rendezvous server address
|
// If we can't decode the peer address, use the rendezvous server address
|
||||||
rendezvous_addr.parse().unwrap_or_else(|_| "0.0.0.0:0".parse().unwrap())
|
rendezvous_addr
|
||||||
|
.parse()
|
||||||
|
.unwrap_or_else(|_| "0.0.0.0:0".parse().unwrap())
|
||||||
});
|
});
|
||||||
|
|
||||||
// Accept the connection - the stream is now a proxied connection to the client
|
// Accept the connection - the stream is now a proxied connection to the client
|
||||||
connection_manager.accept_connection(stream, effective_peer_addr).await?;
|
connection_manager
|
||||||
|
.accept_connection(stream, effective_peer_addr)
|
||||||
|
.await?;
|
||||||
info!("Intranet connection established via rendezvous server proxy");
|
info!("Intranet connection established via rendezvous server proxy");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -14,22 +14,20 @@ pub mod hbb {
|
|||||||
|
|
||||||
// Re-export commonly used types
|
// Re-export commonly used types
|
||||||
pub use hbb::rendezvous::{
|
pub use hbb::rendezvous::{
|
||||||
rendezvous_message, relay_response, punch_hole_response,
|
punch_hole_response, relay_response, rendezvous_message, ConfigUpdate, ConnType,
|
||||||
ConnType, ConfigUpdate, FetchLocalAddr, HealthCheck, KeyExchange, LocalAddr, NatType,
|
FetchLocalAddr, HealthCheck, KeyExchange, LocalAddr, NatType, OnlineRequest, OnlineResponse,
|
||||||
OnlineRequest, OnlineResponse, PeerDiscovery, PunchHole, PunchHoleRequest, PunchHoleResponse,
|
PeerDiscovery, PunchHole, PunchHoleRequest, PunchHoleResponse, PunchHoleSent, RegisterPeer,
|
||||||
PunchHoleSent, RegisterPeer, RegisterPeerResponse, RegisterPk, RegisterPkResponse,
|
RegisterPeerResponse, RegisterPk, RegisterPkResponse, RelayResponse, RendezvousMessage,
|
||||||
RelayResponse, RendezvousMessage, RequestRelay, SoftwareUpdate, TestNatRequest,
|
RequestRelay, SoftwareUpdate, TestNatRequest, TestNatResponse,
|
||||||
TestNatResponse,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-export message.proto types
|
// Re-export message.proto types
|
||||||
pub use hbb::message::{
|
pub use hbb::message::{
|
||||||
message, misc, login_response, key_event,
|
key_event, login_response, message, misc, AudioFormat, AudioFrame, Auth2FA, Clipboard,
|
||||||
AudioFormat, AudioFrame, Auth2FA, Clipboard, CursorData, CursorPosition, EncodedVideoFrame,
|
ControlKey, CursorData, CursorPosition, DisplayInfo, EncodedVideoFrame, EncodedVideoFrames,
|
||||||
EncodedVideoFrames, Hash, IdPk, KeyEvent, LoginRequest, LoginResponse, MouseEvent, Misc,
|
Features, Hash, IdPk, KeyEvent, LoginRequest, LoginResponse, Message as HbbMessage, Misc,
|
||||||
OptionMessage, PeerInfo, PublicKey, SignedId, SupportedDecoding, VideoFrame, TestDelay,
|
MouseEvent, OptionMessage, PeerInfo, PublicKey, SignedId, SupportedDecoding, SupportedEncoding,
|
||||||
Features, SupportedResolutions, WindowsSessions, Message as HbbMessage, ControlKey,
|
SupportedResolutions, TestDelay, VideoFrame, WindowsSessions,
|
||||||
DisplayInfo, SupportedEncoding,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Helper to create a RendezvousMessage with RegisterPeer
|
/// Helper to create a RendezvousMessage with RegisterPeer
|
||||||
@@ -80,7 +78,12 @@ pub fn make_punch_hole_sent(
|
|||||||
/// IMPORTANT: The union field should be `Id` (our device ID), NOT `Pk`.
|
/// IMPORTANT: The union field should be `Id` (our device ID), NOT `Pk`.
|
||||||
/// The rendezvous server will look up our registered public key using this ID,
|
/// The rendezvous server will look up our registered public key using this ID,
|
||||||
/// sign it with the server's private key, and set the `pk` field before forwarding to client.
|
/// sign it with the server's private key, and set the `pk` field before forwarding to client.
|
||||||
pub fn make_relay_response(uuid: &str, socket_addr: &[u8], relay_server: &str, device_id: &str) -> RendezvousMessage {
|
pub fn make_relay_response(
|
||||||
|
uuid: &str,
|
||||||
|
socket_addr: &[u8],
|
||||||
|
relay_server: &str,
|
||||||
|
device_id: &str,
|
||||||
|
) -> RendezvousMessage {
|
||||||
let mut rr = RelayResponse::new();
|
let mut rr = RelayResponse::new();
|
||||||
rr.socket_addr = socket_addr.to_vec().into();
|
rr.socket_addr = socket_addr.to_vec().into();
|
||||||
rr.uuid = uuid.to_string();
|
rr.uuid = uuid.to_string();
|
||||||
|
|||||||
@@ -69,10 +69,7 @@ impl PunchHoleHandler {
|
|||||||
///
|
///
|
||||||
/// Tries direct connection first, falls back to relay if needed.
|
/// Tries direct connection first, falls back to relay if needed.
|
||||||
/// Returns true if direct connection succeeded, false if relay is needed.
|
/// Returns true if direct connection succeeded, false if relay is needed.
|
||||||
pub async fn handle_punch_hole(
|
pub async fn handle_punch_hole(&self, peer_addr: Option<SocketAddr>) -> bool {
|
||||||
&self,
|
|
||||||
peer_addr: Option<SocketAddr>,
|
|
||||||
) -> bool {
|
|
||||||
let peer_addr = match peer_addr {
|
let peer_addr = match peer_addr {
|
||||||
Some(addr) => addr,
|
Some(addr) => addr,
|
||||||
None => {
|
None => {
|
||||||
@@ -84,7 +81,11 @@ impl PunchHoleHandler {
|
|||||||
match try_direct_connection(peer_addr).await {
|
match try_direct_connection(peer_addr).await {
|
||||||
PunchResult::DirectConnection(stream) => {
|
PunchResult::DirectConnection(stream) => {
|
||||||
// Direct connection succeeded, accept it
|
// Direct connection succeeded, accept it
|
||||||
match self.connection_manager.accept_connection(stream, peer_addr).await {
|
match self
|
||||||
|
.connection_manager
|
||||||
|
.accept_connection(stream, peer_addr)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
info!("P2P direct connection established with {}", peer_addr);
|
info!("P2P direct connection established with {}", peer_addr);
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ use tracing::{debug, error, info, warn};
|
|||||||
use super::config::RustDeskConfig;
|
use super::config::RustDeskConfig;
|
||||||
use super::crypto::{KeyPair, SigningKeyPair};
|
use super::crypto::{KeyPair, SigningKeyPair};
|
||||||
use super::protocol::{
|
use super::protocol::{
|
||||||
rendezvous_message, make_punch_hole_sent, make_register_peer,
|
decode_rendezvous_message, make_punch_hole_sent, make_register_peer, make_register_pk,
|
||||||
make_register_pk, NatType, RendezvousMessage, decode_rendezvous_message,
|
rendezvous_message, NatType, RendezvousMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Registration interval in milliseconds
|
/// Registration interval in milliseconds
|
||||||
@@ -81,7 +81,8 @@ pub type RelayCallback = Arc<dyn Fn(String, String, String, Vec<u8>, String) + S
|
|||||||
/// Callback type for P2P punch hole requests
|
/// Callback type for P2P punch hole requests
|
||||||
/// Parameters: peer_addr (decoded), relay_callback_params (rendezvous_addr, relay_server, uuid, socket_addr, device_id)
|
/// Parameters: peer_addr (decoded), relay_callback_params (rendezvous_addr, relay_server, uuid, socket_addr, device_id)
|
||||||
/// Returns: should call relay callback if P2P fails
|
/// Returns: should call relay callback if P2P fails
|
||||||
pub type PunchCallback = Arc<dyn Fn(Option<SocketAddr>, String, String, String, Vec<u8>, String) + Send + Sync>;
|
pub type PunchCallback =
|
||||||
|
Arc<dyn Fn(Option<SocketAddr>, String, String, String, Vec<u8>, String) + Send + Sync>;
|
||||||
|
|
||||||
/// Callback type for intranet/local address connections
|
/// Callback type for intranet/local address connections
|
||||||
/// Parameters: rendezvous_addr, peer_socket_addr (mangled), local_addr, relay_server, device_id
|
/// Parameters: rendezvous_addr, peer_socket_addr (mangled), local_addr, relay_server, device_id
|
||||||
@@ -232,7 +233,8 @@ impl RendezvousMediator {
|
|||||||
if signing_guard.is_none() {
|
if signing_guard.is_none() {
|
||||||
let config = self.config.read();
|
let config = self.config.read();
|
||||||
// Try to load from config first
|
// Try to load from config first
|
||||||
if let (Some(pk), Some(sk)) = (&config.signing_public_key, &config.signing_private_key) {
|
if let (Some(pk), Some(sk)) = (&config.signing_public_key, &config.signing_private_key)
|
||||||
|
{
|
||||||
if let Ok(skp) = SigningKeyPair::from_base64(pk, sk) {
|
if let Ok(skp) = SigningKeyPair::from_base64(pk, sk) {
|
||||||
debug!("Loaded signing keypair from config");
|
debug!("Loaded signing keypair from config");
|
||||||
*signing_guard = Some(skp.clone());
|
*signing_guard = Some(skp.clone());
|
||||||
@@ -265,14 +267,20 @@ impl RendezvousMediator {
|
|||||||
config.enabled, effective_server
|
config.enabled, effective_server
|
||||||
);
|
);
|
||||||
if !config.enabled || effective_server.is_empty() {
|
if !config.enabled || effective_server.is_empty() {
|
||||||
info!("Rendezvous mediator not starting: enabled={}, server='{}'", config.enabled, effective_server);
|
info!(
|
||||||
|
"Rendezvous mediator not starting: enabled={}, server='{}'",
|
||||||
|
config.enabled, effective_server
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
*self.status.write() = RendezvousStatus::Connecting;
|
*self.status.write() = RendezvousStatus::Connecting;
|
||||||
|
|
||||||
let addr = config.rendezvous_addr();
|
let addr = config.rendezvous_addr();
|
||||||
info!("Starting rendezvous mediator for {} to {}", config.device_id, addr);
|
info!(
|
||||||
|
"Starting rendezvous mediator for {} to {}",
|
||||||
|
config.device_id, addr
|
||||||
|
);
|
||||||
|
|
||||||
// Resolve server address
|
// Resolve server address
|
||||||
let server_addr: SocketAddr = tokio::net::lookup_host(&addr)
|
let server_addr: SocketAddr = tokio::net::lookup_host(&addr)
|
||||||
@@ -376,7 +384,9 @@ impl RendezvousMediator {
|
|||||||
let serial = *self.serial.read();
|
let serial = *self.serial.read();
|
||||||
|
|
||||||
let msg = make_register_peer(&id, serial);
|
let msg = make_register_peer(&id, serial);
|
||||||
let bytes = msg.write_to_bytes().map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
let bytes = msg
|
||||||
|
.write_to_bytes()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
||||||
socket.send(&bytes).await?;
|
socket.send(&bytes).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -393,7 +403,9 @@ impl RendezvousMediator {
|
|||||||
|
|
||||||
debug!("Sending RegisterPk: id={}", id);
|
debug!("Sending RegisterPk: id={}", id);
|
||||||
let msg = make_register_pk(&id, &uuid, pk, "");
|
let msg = make_register_pk(&id, &uuid, pk, "");
|
||||||
let bytes = msg.write_to_bytes().map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
let bytes = msg
|
||||||
|
.write_to_bytes()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
||||||
socket.send(&bytes).await?;
|
socket.send(&bytes).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -570,9 +582,22 @@ impl RendezvousMediator {
|
|||||||
// Use punch callback if set (tries P2P first, then relay)
|
// Use punch callback if set (tries P2P first, then relay)
|
||||||
// Otherwise fall back to relay callback directly
|
// Otherwise fall back to relay callback directly
|
||||||
if let Some(callback) = self.punch_callback.read().as_ref() {
|
if let Some(callback) = self.punch_callback.read().as_ref() {
|
||||||
callback(peer_addr, rendezvous_addr, relay_server, uuid, ph.socket_addr.to_vec(), device_id);
|
callback(
|
||||||
|
peer_addr,
|
||||||
|
rendezvous_addr,
|
||||||
|
relay_server,
|
||||||
|
uuid,
|
||||||
|
ph.socket_addr.to_vec(),
|
||||||
|
device_id,
|
||||||
|
);
|
||||||
} else if let Some(callback) = self.relay_callback.read().as_ref() {
|
} else if let Some(callback) = self.relay_callback.read().as_ref() {
|
||||||
callback(rendezvous_addr, relay_server, uuid, ph.socket_addr.to_vec(), device_id);
|
callback(
|
||||||
|
rendezvous_addr,
|
||||||
|
relay_server,
|
||||||
|
uuid,
|
||||||
|
ph.socket_addr.to_vec(),
|
||||||
|
device_id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -591,7 +616,13 @@ impl RendezvousMediator {
|
|||||||
let config = self.config.read().clone();
|
let config = self.config.read().clone();
|
||||||
let rendezvous_addr = config.rendezvous_addr();
|
let rendezvous_addr = config.rendezvous_addr();
|
||||||
let device_id = config.device_id.clone();
|
let device_id = config.device_id.clone();
|
||||||
callback(rendezvous_addr, relay_server, rr.uuid.clone(), rr.socket_addr.to_vec(), device_id);
|
callback(
|
||||||
|
rendezvous_addr,
|
||||||
|
relay_server,
|
||||||
|
rr.uuid.clone(),
|
||||||
|
rr.socket_addr.to_vec(),
|
||||||
|
device_id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(rendezvous_message::Union::FetchLocalAddr(fla)) => {
|
Some(rendezvous_message::Union::FetchLocalAddr(fla)) => {
|
||||||
@@ -602,7 +633,8 @@ impl RendezvousMediator {
|
|||||||
peer_addr, fla.socket_addr.len(), fla.relay_server
|
peer_addr, fla.socket_addr.len(), fla.relay_server
|
||||||
);
|
);
|
||||||
// Respond with our local address for same-LAN direct connection
|
// Respond with our local address for same-LAN direct connection
|
||||||
self.send_local_addr(socket, &fla.socket_addr, &fla.relay_server).await?;
|
self.send_local_addr(socket, &fla.socket_addr, &fla.relay_server)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
Some(rendezvous_message::Union::ConfigureUpdate(cu)) => {
|
Some(rendezvous_message::Union::ConfigureUpdate(cu)) => {
|
||||||
info!("Received ConfigureUpdate, serial={}", cu.serial);
|
info!("Received ConfigureUpdate, serial={}", cu.serial);
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ use crate::atx::AtxController;
|
|||||||
use crate::audio::AudioController;
|
use crate::audio::AudioController;
|
||||||
use crate::auth::{SessionStore, UserStore};
|
use crate::auth::{SessionStore, UserStore};
|
||||||
use crate::config::ConfigStore;
|
use crate::config::ConfigStore;
|
||||||
use crate::events::{AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, MsdDeviceInfo, SystemEvent, VideoDeviceInfo};
|
use crate::events::{
|
||||||
|
AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, MsdDeviceInfo, SystemEvent,
|
||||||
|
VideoDeviceInfo,
|
||||||
|
};
|
||||||
use crate::extensions::ExtensionManager;
|
use crate::extensions::ExtensionManager;
|
||||||
use crate::hid::HidController;
|
use crate::hid::HidController;
|
||||||
use crate::msd::MsdController;
|
use crate::msd::MsdController;
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ use std::time::{Duration, Instant};
|
|||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::video::encoder::JpegEncoder;
|
|
||||||
use crate::video::encoder::traits::{Encoder, EncoderConfig};
|
use crate::video::encoder::traits::{Encoder, EncoderConfig};
|
||||||
|
use crate::video::encoder::JpegEncoder;
|
||||||
use crate::video::format::PixelFormat;
|
use crate::video::format::PixelFormat;
|
||||||
use crate::video::VideoFrame;
|
use crate::video::VideoFrame;
|
||||||
|
|
||||||
@@ -256,7 +256,10 @@ impl MjpegStreamHandler {
|
|||||||
let config = EncoderConfig::jpeg(resolution, 85);
|
let config = EncoderConfig::jpeg(resolution, 85);
|
||||||
match JpegEncoder::new(config) {
|
match JpegEncoder::new(config) {
|
||||||
Ok(enc) => {
|
Ok(enc) => {
|
||||||
debug!("Created JPEG encoder for MJPEG stream: {}x{}", resolution.width, resolution.height);
|
debug!(
|
||||||
|
"Created JPEG encoder for MJPEG stream: {}x{}",
|
||||||
|
resolution.width, resolution.height
|
||||||
|
);
|
||||||
enc
|
enc
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -270,37 +273,40 @@ impl MjpegStreamHandler {
|
|||||||
|
|
||||||
// Check if resolution changed
|
// Check if resolution changed
|
||||||
if encoder.config().resolution != resolution {
|
if encoder.config().resolution != resolution {
|
||||||
debug!("Resolution changed, recreating JPEG encoder: {}x{}", resolution.width, resolution.height);
|
debug!(
|
||||||
|
"Resolution changed, recreating JPEG encoder: {}x{}",
|
||||||
|
resolution.width, resolution.height
|
||||||
|
);
|
||||||
let config = EncoderConfig::jpeg(resolution, 85);
|
let config = EncoderConfig::jpeg(resolution, 85);
|
||||||
*encoder = JpegEncoder::new(config).map_err(|e| format!("Failed to create encoder: {}", e))?;
|
*encoder =
|
||||||
|
JpegEncoder::new(config).map_err(|e| format!("Failed to create encoder: {}", e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode based on input format
|
// Encode based on input format
|
||||||
let encoded = match frame.format {
|
let encoded = match frame.format {
|
||||||
PixelFormat::Yuyv => {
|
PixelFormat::Yuyv => encoder
|
||||||
encoder.encode_yuyv(frame.data(), sequence)
|
.encode_yuyv(frame.data(), sequence)
|
||||||
.map_err(|e| format!("YUYV encode failed: {}", e))?
|
.map_err(|e| format!("YUYV encode failed: {}", e))?,
|
||||||
}
|
PixelFormat::Nv12 => encoder
|
||||||
PixelFormat::Nv12 => {
|
.encode_nv12(frame.data(), sequence)
|
||||||
encoder.encode_nv12(frame.data(), sequence)
|
.map_err(|e| format!("NV12 encode failed: {}", e))?,
|
||||||
.map_err(|e| format!("NV12 encode failed: {}", e))?
|
PixelFormat::Rgb24 => encoder
|
||||||
}
|
.encode_rgb(frame.data(), sequence)
|
||||||
PixelFormat::Rgb24 => {
|
.map_err(|e| format!("RGB encode failed: {}", e))?,
|
||||||
encoder.encode_rgb(frame.data(), sequence)
|
PixelFormat::Bgr24 => encoder
|
||||||
.map_err(|e| format!("RGB encode failed: {}", e))?
|
.encode_bgr(frame.data(), sequence)
|
||||||
}
|
.map_err(|e| format!("BGR encode failed: {}", e))?,
|
||||||
PixelFormat::Bgr24 => {
|
|
||||||
encoder.encode_bgr(frame.data(), sequence)
|
|
||||||
.map_err(|e| format!("BGR encode failed: {}", e))?
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
return Err(format!("Unsupported format for JPEG encoding: {}", frame.format));
|
return Err(format!(
|
||||||
|
"Unsupported format for JPEG encoding: {}",
|
||||||
|
frame.format
|
||||||
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create new VideoFrame with JPEG data
|
// Create new VideoFrame with JPEG data (zero-copy: Bytes -> Arc<Bytes>)
|
||||||
Ok(VideoFrame::from_vec(
|
Ok(VideoFrame::new(
|
||||||
encoded.data.to_vec(),
|
encoded.data,
|
||||||
resolution,
|
resolution,
|
||||||
PixelFormat::Mjpeg,
|
PixelFormat::Mjpeg,
|
||||||
0, // stride not relevant for JPEG
|
0, // stride not relevant for JPEG
|
||||||
@@ -333,7 +339,11 @@ impl MjpegStreamHandler {
|
|||||||
pub fn register_client(&self, client_id: ClientId) {
|
pub fn register_client(&self, client_id: ClientId) {
|
||||||
let session = ClientSession::new(client_id.clone());
|
let session = ClientSession::new(client_id.clone());
|
||||||
self.clients.write().insert(client_id.clone(), session);
|
self.clients.write().insert(client_id.clone(), session);
|
||||||
info!("Client {} connected (total: {})", client_id, self.client_count());
|
info!(
|
||||||
|
"Client {} connected (total: {})",
|
||||||
|
client_id,
|
||||||
|
self.client_count()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unregister a client
|
/// Unregister a client
|
||||||
@@ -391,7 +401,9 @@ impl MjpegStreamHandler {
|
|||||||
*self.auto_pause_config.write() = config;
|
*self.auto_pause_config.write() = config;
|
||||||
info!(
|
info!(
|
||||||
"Auto-pause config updated: enabled={}, delay={}s, timeout={}s",
|
"Auto-pause config updated: enabled={}, delay={}s, timeout={}s",
|
||||||
config_clone.enabled, config_clone.shutdown_delay_secs, config_clone.client_timeout_secs
|
config_clone.enabled,
|
||||||
|
config_clone.shutdown_delay_secs,
|
||||||
|
config_clone.client_timeout_secs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,10 +452,7 @@ impl ClientGuard {
|
|||||||
/// Create a new client guard
|
/// Create a new client guard
|
||||||
pub fn new(client_id: ClientId, handler: Arc<MjpegStreamHandler>) -> Self {
|
pub fn new(client_id: ClientId, handler: Arc<MjpegStreamHandler>) -> Self {
|
||||||
handler.register_client(client_id.clone());
|
handler.register_client(client_id.clone());
|
||||||
Self {
|
Self { client_id, handler }
|
||||||
client_id,
|
|
||||||
handler,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get client ID
|
/// Get client ID
|
||||||
@@ -535,8 +544,8 @@ fn frames_are_identical(a: &VideoFrame, b: &VideoFrame) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use bytes::Bytes;
|
|
||||||
use crate::video::{format::Resolution, PixelFormat};
|
use crate::video::{format::Resolution, PixelFormat};
|
||||||
|
use bytes::Bytes;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_stream_handler() {
|
async fn test_stream_handler() {
|
||||||
|
|||||||
@@ -228,7 +228,8 @@ impl MjpegStreamer {
|
|||||||
let device = self.current_device.read().await;
|
let device = self.current_device.read().await;
|
||||||
let config = self.config.read().await;
|
let config = self.config.read().await;
|
||||||
|
|
||||||
let (resolution, format, frames_captured) = if let Some(ref cap) = *self.capturer.read().await {
|
let (resolution, format, frames_captured) =
|
||||||
|
if let Some(ref cap) = *self.capturer.read().await {
|
||||||
let stats = cap.stats().await;
|
let stats = cap.stats().await;
|
||||||
(
|
(
|
||||||
Some((config.resolution.width, config.resolution.height)),
|
Some((config.resolution.width, config.resolution.height)),
|
||||||
@@ -286,7 +287,10 @@ impl MjpegStreamer {
|
|||||||
|
|
||||||
/// Initialize with specific device
|
/// Initialize with specific device
|
||||||
pub async fn init_with_device(self: &Arc<Self>, device: VideoDeviceInfo) -> Result<()> {
|
pub async fn init_with_device(self: &Arc<Self>, device: VideoDeviceInfo) -> Result<()> {
|
||||||
info!("MjpegStreamer: Initializing with device: {}", device.path.display());
|
info!(
|
||||||
|
"MjpegStreamer: Initializing with device: {}",
|
||||||
|
device.path.display()
|
||||||
|
);
|
||||||
|
|
||||||
let config = self.config.read().await.clone();
|
let config = self.config.read().await.clone();
|
||||||
|
|
||||||
@@ -322,7 +326,9 @@ impl MjpegStreamer {
|
|||||||
let _lock = self.start_lock.lock().await;
|
let _lock = self.start_lock.lock().await;
|
||||||
|
|
||||||
if self.config_changing.load(Ordering::SeqCst) {
|
if self.config_changing.load(Ordering::SeqCst) {
|
||||||
return Err(AppError::VideoError("Config change in progress".to_string()));
|
return Err(AppError::VideoError(
|
||||||
|
"Config change in progress".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let state = *self.state.read().await;
|
let state = *self.state.read().await;
|
||||||
@@ -332,7 +338,8 @@ impl MjpegStreamer {
|
|||||||
|
|
||||||
// Get capturer
|
// Get capturer
|
||||||
let capturer = self.capturer.read().await.clone();
|
let capturer = self.capturer.read().await.clone();
|
||||||
let capturer = capturer.ok_or_else(|| AppError::VideoError("Not initialized".to_string()))?;
|
let capturer =
|
||||||
|
capturer.ok_or_else(|| AppError::VideoError("Not initialized".to_string()))?;
|
||||||
|
|
||||||
// Start capture
|
// Start capture
|
||||||
capturer.start().await?;
|
capturer.start().await?;
|
||||||
@@ -412,7 +419,9 @@ impl MjpegStreamer {
|
|||||||
let device = devices
|
let device = devices
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|d| d.path == *path)
|
.find(|d| d.path == *path)
|
||||||
.ok_or_else(|| AppError::VideoError(format!("Device not found: {}", path.display())))?;
|
.ok_or_else(|| {
|
||||||
|
AppError::VideoError(format!("Device not found: {}", path.display()))
|
||||||
|
})?;
|
||||||
|
|
||||||
self.init_with_device(device).await?;
|
self.init_with_device(device).await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,7 @@ pub mod mjpeg_streamer;
|
|||||||
pub mod ws_hid;
|
pub mod ws_hid;
|
||||||
|
|
||||||
pub use mjpeg::{ClientGuard, MjpegStreamHandler};
|
pub use mjpeg::{ClientGuard, MjpegStreamHandler};
|
||||||
pub use mjpeg_streamer::{MjpegStreamer, MjpegStreamerConfig, MjpegStreamerState, MjpegStreamerStats};
|
pub use mjpeg_streamer::{
|
||||||
|
MjpegStreamer, MjpegStreamerConfig, MjpegStreamerState, MjpegStreamerStats,
|
||||||
|
};
|
||||||
pub use ws_hid::WsHidHandler;
|
pub use ws_hid::WsHidHandler;
|
||||||
|
|||||||
@@ -142,7 +142,9 @@ impl WsHidHandler {
|
|||||||
shutdown_tx,
|
shutdown_tx,
|
||||||
});
|
});
|
||||||
|
|
||||||
self.clients.write().insert(client_id.clone(), client.clone());
|
self.clients
|
||||||
|
.write()
|
||||||
|
.insert(client_id.clone(), client.clone());
|
||||||
info!(
|
info!(
|
||||||
"WsHidHandler: Client {} connected (total: {})",
|
"WsHidHandler: Client {} connected (total: {})",
|
||||||
client_id,
|
client_id,
|
||||||
@@ -182,7 +184,11 @@ impl WsHidHandler {
|
|||||||
let (mut sender, mut receiver) = socket.split();
|
let (mut sender, mut receiver) = socket.split();
|
||||||
|
|
||||||
// Send initial status as binary: 0x00 = ok, 0x01 = error
|
// Send initial status as binary: 0x00 = ok, 0x01 = error
|
||||||
let status_byte = if self.is_hid_available() { 0x00u8 } else { 0x01u8 };
|
let status_byte = if self.is_hid_available() {
|
||||||
|
0x00u8
|
||||||
|
} else {
|
||||||
|
0x01u8
|
||||||
|
};
|
||||||
let _ = sender.send(Message::Binary(vec![status_byte].into())).await;
|
let _ = sender.send(Message::Binary(vec![status_byte].into())).await;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -230,7 +236,10 @@ impl WsHidHandler {
|
|||||||
let hid = self.hid_controller.read().clone();
|
let hid = self.hid_controller.read().clone();
|
||||||
if let Some(hid) = hid {
|
if let Some(hid) = hid {
|
||||||
if let Err(e) = hid.reset().await {
|
if let Err(e) = hid.reset().await {
|
||||||
warn!("WsHidHandler: Failed to reset HID on client {} disconnect: {}", client_id, e);
|
warn!(
|
||||||
|
"WsHidHandler: Failed to reset HID on client {} disconnect: {}",
|
||||||
|
client_id, e
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
debug!("WsHidHandler: HID reset on client {} disconnect", client_id);
|
debug!("WsHidHandler: HID reset on client {} disconnect", client_id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use std::time::{Duration, Instant};
|
|||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// use one_kvm::utils::LogThrottler;
|
/// use one_kvm::utils::LogThrottler;
|
||||||
|
/// use std::time::Duration;
|
||||||
///
|
///
|
||||||
/// let throttler = LogThrottler::new(Duration::from_secs(5));
|
/// let throttler = LogThrottler::new(Duration::from_secs(5));
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -231,7 +231,9 @@ impl VideoCapturer {
|
|||||||
let last_error = self.last_error.clone();
|
let last_error = self.last_error.clone();
|
||||||
|
|
||||||
let handle = tokio::task::spawn_blocking(move || {
|
let handle = tokio::task::spawn_blocking(move || {
|
||||||
capture_loop(config, state, stats, frame_tx, stop_flag, sequence, last_error);
|
capture_loop(
|
||||||
|
config, state, stats, frame_tx, stop_flag, sequence, last_error,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
*self.capture_handle.lock().await = Some(handle);
|
*self.capture_handle.lock().await = Some(handle);
|
||||||
@@ -275,14 +277,7 @@ fn capture_loop(
|
|||||||
sequence: Arc<AtomicU64>,
|
sequence: Arc<AtomicU64>,
|
||||||
error_holder: Arc<parking_lot::RwLock<Option<(String, String)>>>,
|
error_holder: Arc<parking_lot::RwLock<Option<(String, String)>>>,
|
||||||
) {
|
) {
|
||||||
let result = run_capture(
|
let result = run_capture(&config, &state, &stats, &frame_tx, &stop_flag, &sequence);
|
||||||
&config,
|
|
||||||
&state,
|
|
||||||
&stats,
|
|
||||||
&frame_tx,
|
|
||||||
&stop_flag,
|
|
||||||
&sequence,
|
|
||||||
);
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -503,7 +498,10 @@ fn run_capture_inner(
|
|||||||
|
|
||||||
// Validate frame
|
// Validate frame
|
||||||
if frame_size < MIN_FRAME_SIZE {
|
if frame_size < MIN_FRAME_SIZE {
|
||||||
debug!("Dropping small frame: {} bytes (bytesused={})", frame_size, meta.bytesused);
|
debug!(
|
||||||
|
"Dropping small frame: {} bytes (bytesused={})",
|
||||||
|
frame_size, meta.bytesused
|
||||||
|
);
|
||||||
if let Ok(mut s) = stats.try_lock() {
|
if let Ok(mut s) = stats.try_lock() {
|
||||||
s.frames_dropped += 1;
|
s.frames_dropped += 1;
|
||||||
}
|
}
|
||||||
@@ -606,16 +604,10 @@ impl FrameGrabber {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Capture a single frame
|
/// Capture a single frame
|
||||||
pub async fn grab(
|
pub async fn grab(&self, resolution: Resolution, format: PixelFormat) -> Result<VideoFrame> {
|
||||||
&self,
|
|
||||||
resolution: Resolution,
|
|
||||||
format: PixelFormat,
|
|
||||||
) -> Result<VideoFrame> {
|
|
||||||
let device_path = self.device_path.clone();
|
let device_path = self.device_path.clone();
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || grab_single_frame(&device_path, resolution, format))
|
||||||
grab_single_frame(&device_path, resolution, format)
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::VideoError(format!("Grab task failed: {}", e)))?
|
.map_err(|e| AppError::VideoError(format!("Grab task failed: {}", e)))?
|
||||||
}
|
}
|
||||||
@@ -626,14 +618,13 @@ fn grab_single_frame(
|
|||||||
resolution: Resolution,
|
resolution: Resolution,
|
||||||
format: PixelFormat,
|
format: PixelFormat,
|
||||||
) -> Result<VideoFrame> {
|
) -> Result<VideoFrame> {
|
||||||
let device = Device::with_path(device_path).map_err(|e| {
|
let device = Device::with_path(device_path)
|
||||||
AppError::VideoError(format!("Failed to open device: {}", e))
|
.map_err(|e| AppError::VideoError(format!("Failed to open device: {}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
let fmt = Format::new(resolution.width, resolution.height, format.to_fourcc());
|
let fmt = Format::new(resolution.width, resolution.height, format.to_fourcc());
|
||||||
let actual = device.set_format(&fmt).map_err(|e| {
|
let actual = device
|
||||||
AppError::VideoError(format!("Failed to set format: {}", e))
|
.set_format(&fmt)
|
||||||
})?;
|
.map_err(|e| AppError::VideoError(format!("Failed to set format: {}", e)))?;
|
||||||
|
|
||||||
let mut stream = MmapStream::with_buffers(&device, BufferType::VideoCapture, 2)
|
let mut stream = MmapStream::with_buffers(&device, BufferType::VideoCapture, 2)
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to create stream: {}", e)))?;
|
.map_err(|e| AppError::VideoError(format!("Failed to create stream: {}", e)))?;
|
||||||
@@ -643,8 +634,7 @@ fn grab_single_frame(
|
|||||||
match stream.next() {
|
match stream.next() {
|
||||||
Ok((buf, _meta)) => {
|
Ok((buf, _meta)) => {
|
||||||
if buf.len() >= MIN_FRAME_SIZE {
|
if buf.len() >= MIN_FRAME_SIZE {
|
||||||
let actual_format =
|
let actual_format = PixelFormat::from_fourcc(actual.fourcc).unwrap_or(format);
|
||||||
PixelFormat::from_fourcc(actual.fourcc).unwrap_or(format);
|
|
||||||
|
|
||||||
return Ok(VideoFrame::new(
|
return Ok(VideoFrame::new(
|
||||||
Bytes::copy_from_slice(buf),
|
Bytes::copy_from_slice(buf),
|
||||||
@@ -657,16 +647,15 @@ fn grab_single_frame(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if attempt == 4 {
|
if attempt == 4 {
|
||||||
return Err(AppError::VideoError(format!(
|
return Err(AppError::VideoError(format!("Failed to grab frame: {}", e)));
|
||||||
"Failed to grab frame: {}",
|
|
||||||
e
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(AppError::VideoError("Failed to capture valid frame".to_string()))
|
Err(AppError::VideoError(
|
||||||
|
"Failed to capture valid frame".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -233,6 +233,16 @@ impl PixelConverter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new converter for NV21 → YUV420P
|
||||||
|
pub fn nv21_to_yuv420p(resolution: Resolution) -> Self {
|
||||||
|
Self {
|
||||||
|
src_format: PixelFormat::Nv21,
|
||||||
|
dst_format: PixelFormat::Yuv420,
|
||||||
|
resolution,
|
||||||
|
output_buffer: Yuv420pBuffer::new(resolution),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new converter for YVU420 → YUV420P (swap U and V planes)
|
/// Create a new converter for YVU420 → YUV420P (swap U and V planes)
|
||||||
pub fn yvu420_to_yuv420p(resolution: Resolution) -> Self {
|
pub fn yvu420_to_yuv420p(resolution: Resolution) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -272,23 +282,39 @@ impl PixelConverter {
|
|||||||
match (self.src_format, self.dst_format) {
|
match (self.src_format, self.dst_format) {
|
||||||
(PixelFormat::Yuyv, PixelFormat::Yuv420) => {
|
(PixelFormat::Yuyv, PixelFormat::Yuv420) => {
|
||||||
libyuv::yuy2_to_i420(input, self.output_buffer.as_bytes_mut(), width, height)
|
libyuv::yuy2_to_i420(input, self.output_buffer.as_bytes_mut(), width, height)
|
||||||
.map_err(|e| AppError::VideoError(format!("libyuv conversion failed: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
AppError::VideoError(format!("libyuv conversion failed: {}", e))
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
(PixelFormat::Uyvy, PixelFormat::Yuv420) => {
|
(PixelFormat::Uyvy, PixelFormat::Yuv420) => {
|
||||||
libyuv::uyvy_to_i420(input, self.output_buffer.as_bytes_mut(), width, height)
|
libyuv::uyvy_to_i420(input, self.output_buffer.as_bytes_mut(), width, height)
|
||||||
.map_err(|e| AppError::VideoError(format!("libyuv conversion failed: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
AppError::VideoError(format!("libyuv conversion failed: {}", e))
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
(PixelFormat::Nv12, PixelFormat::Yuv420) => {
|
(PixelFormat::Nv12, PixelFormat::Yuv420) => {
|
||||||
libyuv::nv12_to_i420(input, self.output_buffer.as_bytes_mut(), width, height)
|
libyuv::nv12_to_i420(input, self.output_buffer.as_bytes_mut(), width, height)
|
||||||
.map_err(|e| AppError::VideoError(format!("libyuv conversion failed: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
AppError::VideoError(format!("libyuv conversion failed: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
(PixelFormat::Nv21, PixelFormat::Yuv420) => {
|
||||||
|
libyuv::nv21_to_i420(input, self.output_buffer.as_bytes_mut(), width, height)
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::VideoError(format!("libyuv conversion failed: {}", e))
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
(PixelFormat::Rgb24, PixelFormat::Yuv420) => {
|
(PixelFormat::Rgb24, PixelFormat::Yuv420) => {
|
||||||
libyuv::rgb24_to_i420(input, self.output_buffer.as_bytes_mut(), width, height)
|
libyuv::rgb24_to_i420(input, self.output_buffer.as_bytes_mut(), width, height)
|
||||||
.map_err(|e| AppError::VideoError(format!("libyuv conversion failed: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
AppError::VideoError(format!("libyuv conversion failed: {}", e))
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
(PixelFormat::Bgr24, PixelFormat::Yuv420) => {
|
(PixelFormat::Bgr24, PixelFormat::Yuv420) => {
|
||||||
libyuv::bgr24_to_i420(input, self.output_buffer.as_bytes_mut(), width, height)
|
libyuv::bgr24_to_i420(input, self.output_buffer.as_bytes_mut(), width, height)
|
||||||
.map_err(|e| AppError::VideoError(format!("libyuv conversion failed: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
AppError::VideoError(format!("libyuv conversion failed: {}", e))
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
(PixelFormat::Yvyu, PixelFormat::Yuv420) => {
|
(PixelFormat::Yvyu, PixelFormat::Yuv420) => {
|
||||||
// YVYU is not directly supported by libyuv, use software conversion
|
// YVYU is not directly supported by libyuv, use software conversion
|
||||||
@@ -307,7 +333,9 @@ impl PixelConverter {
|
|||||||
expected_size
|
expected_size
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
self.output_buffer.as_bytes_mut().copy_from_slice(&input[..expected_size]);
|
self.output_buffer
|
||||||
|
.as_bytes_mut()
|
||||||
|
.copy_from_slice(&input[..expected_size]);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::VideoError(format!(
|
return Err(AppError::VideoError(format!(
|
||||||
@@ -426,6 +454,8 @@ pub struct Nv12Converter {
|
|||||||
resolution: Resolution,
|
resolution: Resolution,
|
||||||
/// Output buffer (reused across conversions)
|
/// Output buffer (reused across conversions)
|
||||||
output_buffer: Nv12Buffer,
|
output_buffer: Nv12Buffer,
|
||||||
|
/// Optional I420 buffer for intermediate conversions
|
||||||
|
i420_buffer: Option<Yuv420pBuffer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Nv12Converter {
|
impl Nv12Converter {
|
||||||
@@ -435,6 +465,7 @@ impl Nv12Converter {
|
|||||||
src_format: PixelFormat::Bgr24,
|
src_format: PixelFormat::Bgr24,
|
||||||
resolution,
|
resolution,
|
||||||
output_buffer: Nv12Buffer::new(resolution),
|
output_buffer: Nv12Buffer::new(resolution),
|
||||||
|
i420_buffer: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,6 +475,7 @@ impl Nv12Converter {
|
|||||||
src_format: PixelFormat::Rgb24,
|
src_format: PixelFormat::Rgb24,
|
||||||
resolution,
|
resolution,
|
||||||
output_buffer: Nv12Buffer::new(resolution),
|
output_buffer: Nv12Buffer::new(resolution),
|
||||||
|
i420_buffer: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,6 +485,37 @@ impl Nv12Converter {
|
|||||||
src_format: PixelFormat::Yuyv,
|
src_format: PixelFormat::Yuyv,
|
||||||
resolution,
|
resolution,
|
||||||
output_buffer: Nv12Buffer::new(resolution),
|
output_buffer: Nv12Buffer::new(resolution),
|
||||||
|
i420_buffer: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new converter for YUV420P (I420) → NV12
|
||||||
|
pub fn yuv420_to_nv12(resolution: Resolution) -> Self {
|
||||||
|
Self {
|
||||||
|
src_format: PixelFormat::Yuv420,
|
||||||
|
resolution,
|
||||||
|
output_buffer: Nv12Buffer::new(resolution),
|
||||||
|
i420_buffer: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new converter for NV21 → NV12
|
||||||
|
pub fn nv21_to_nv12(resolution: Resolution) -> Self {
|
||||||
|
Self {
|
||||||
|
src_format: PixelFormat::Nv21,
|
||||||
|
resolution,
|
||||||
|
output_buffer: Nv12Buffer::new(resolution),
|
||||||
|
i420_buffer: Some(Yuv420pBuffer::new(resolution)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new converter for NV16 → NV12 (downsample chroma vertically)
|
||||||
|
pub fn nv16_to_nv12(resolution: Resolution) -> Self {
|
||||||
|
Self {
|
||||||
|
src_format: PixelFormat::Nv16,
|
||||||
|
resolution,
|
||||||
|
output_buffer: Nv12Buffer::new(resolution),
|
||||||
|
i420_buffer: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,12 +523,45 @@ impl Nv12Converter {
|
|||||||
pub fn convert(&mut self, input: &[u8]) -> Result<&[u8]> {
|
pub fn convert(&mut self, input: &[u8]) -> Result<&[u8]> {
|
||||||
let width = self.resolution.width as i32;
|
let width = self.resolution.width as i32;
|
||||||
let height = self.resolution.height as i32;
|
let height = self.resolution.height as i32;
|
||||||
let dst = self.output_buffer.as_bytes_mut();
|
|
||||||
|
|
||||||
|
// Handle formats that need custom conversion without holding dst borrow
|
||||||
|
match self.src_format {
|
||||||
|
PixelFormat::Nv21 => {
|
||||||
|
let mut i420 = self.i420_buffer.take().ok_or_else(|| {
|
||||||
|
AppError::VideoError("NV21 I420 buffer not initialized".to_string())
|
||||||
|
})?;
|
||||||
|
{
|
||||||
|
let dst = self.output_buffer.as_bytes_mut();
|
||||||
|
Self::convert_nv21_to_nv12_with_dims(
|
||||||
|
self.resolution.width as usize,
|
||||||
|
self.resolution.height as usize,
|
||||||
|
input,
|
||||||
|
dst,
|
||||||
|
&mut i420,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
self.i420_buffer = Some(i420);
|
||||||
|
return Ok(self.output_buffer.as_bytes());
|
||||||
|
}
|
||||||
|
PixelFormat::Nv16 => {
|
||||||
|
let dst = self.output_buffer.as_bytes_mut();
|
||||||
|
Self::convert_nv16_to_nv12_with_dims(
|
||||||
|
self.resolution.width as usize,
|
||||||
|
self.resolution.height as usize,
|
||||||
|
input,
|
||||||
|
dst,
|
||||||
|
)?;
|
||||||
|
return Ok(self.output_buffer.as_bytes());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dst = self.output_buffer.as_bytes_mut();
|
||||||
let result = match self.src_format {
|
let result = match self.src_format {
|
||||||
PixelFormat::Bgr24 => libyuv::bgr24_to_nv12(input, dst, width, height),
|
PixelFormat::Bgr24 => libyuv::bgr24_to_nv12(input, dst, width, height),
|
||||||
PixelFormat::Rgb24 => libyuv::rgb24_to_nv12(input, dst, width, height),
|
PixelFormat::Rgb24 => libyuv::rgb24_to_nv12(input, dst, width, height),
|
||||||
PixelFormat::Yuyv => libyuv::yuy2_to_nv12(input, dst, width, height),
|
PixelFormat::Yuyv => libyuv::yuy2_to_nv12(input, dst, width, height),
|
||||||
|
PixelFormat::Yuv420 => libyuv::i420_to_nv12(input, dst, width, height),
|
||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::VideoError(format!(
|
return Err(AppError::VideoError(format!(
|
||||||
"Unsupported conversion to NV12: {}",
|
"Unsupported conversion to NV12: {}",
|
||||||
@@ -474,10 +570,71 @@ impl Nv12Converter {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
result.map_err(|e| AppError::VideoError(format!("libyuv NV12 conversion failed: {}", e)))?;
|
result
|
||||||
|
.map_err(|e| AppError::VideoError(format!("libyuv NV12 conversion failed: {}", e)))?;
|
||||||
Ok(self.output_buffer.as_bytes())
|
Ok(self.output_buffer.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn convert_nv21_to_nv12_with_dims(
|
||||||
|
width: usize,
|
||||||
|
height: usize,
|
||||||
|
input: &[u8],
|
||||||
|
dst: &mut [u8],
|
||||||
|
yuv: &mut Yuv420pBuffer,
|
||||||
|
) -> Result<()> {
|
||||||
|
libyuv::nv21_to_i420(input, yuv.as_bytes_mut(), width as i32, height as i32)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("libyuv NV21->I420 failed: {}", e)))?;
|
||||||
|
libyuv::i420_to_nv12(yuv.as_bytes(), dst, width as i32, height as i32)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("libyuv I420->NV12 failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_nv16_to_nv12_with_dims(
|
||||||
|
width: usize,
|
||||||
|
height: usize,
|
||||||
|
input: &[u8],
|
||||||
|
dst: &mut [u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
let y_size = width * height;
|
||||||
|
let uv_size_nv16 = y_size; // NV16 chroma plane is full height
|
||||||
|
let uv_size_nv12 = y_size / 2;
|
||||||
|
|
||||||
|
if input.len() < y_size + uv_size_nv16 {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"NV16 data too small: {} < {}",
|
||||||
|
input.len(),
|
||||||
|
y_size + uv_size_nv16
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy Y plane as-is
|
||||||
|
dst[..y_size].copy_from_slice(&input[..y_size]);
|
||||||
|
|
||||||
|
// Downsample chroma vertically: average pairs of rows
|
||||||
|
let src_uv = &input[y_size..y_size + uv_size_nv16];
|
||||||
|
let dst_uv = &mut dst[y_size..y_size + uv_size_nv12];
|
||||||
|
|
||||||
|
let src_row_bytes = width;
|
||||||
|
let dst_row_bytes = width;
|
||||||
|
let dst_rows = height / 2;
|
||||||
|
|
||||||
|
for row in 0..dst_rows {
|
||||||
|
let src_row0 =
|
||||||
|
&src_uv[row * 2 * src_row_bytes..row * 2 * src_row_bytes + src_row_bytes];
|
||||||
|
let src_row1 = &src_uv
|
||||||
|
[(row * 2 + 1) * src_row_bytes..(row * 2 + 1) * src_row_bytes + src_row_bytes];
|
||||||
|
let dst_row = &mut dst_uv[row * dst_row_bytes..row * dst_row_bytes + dst_row_bytes];
|
||||||
|
|
||||||
|
for i in 0..dst_row_bytes {
|
||||||
|
let sum = src_row0[i] as u16 + src_row1[i] as u16;
|
||||||
|
dst_row[i] = (sum / 2) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get output buffer length
|
/// Get output buffer length
|
||||||
pub fn output_len(&self) -> usize {
|
pub fn output_len(&self) -> usize {
|
||||||
self.output_buffer.len()
|
self.output_buffer.len()
|
||||||
@@ -542,10 +699,8 @@ mod tests {
|
|||||||
|
|
||||||
// Create YUYV data (4x4 = 32 bytes)
|
// Create YUYV data (4x4 = 32 bytes)
|
||||||
let yuyv = vec![
|
let yuyv = vec![
|
||||||
16, 128, 17, 129, 18, 130, 19, 131,
|
16, 128, 17, 129, 18, 130, 19, 131, 20, 132, 21, 133, 22, 134, 23, 135, 24, 136, 25,
|
||||||
20, 132, 21, 133, 22, 134, 23, 135,
|
137, 26, 138, 27, 139, 28, 140, 29, 141, 30, 142, 31, 143,
|
||||||
24, 136, 25, 137, 26, 138, 27, 139,
|
|
||||||
28, 140, 29, 141, 30, 142, 31, 143,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = converter.convert(&yuyv).unwrap();
|
let result = converter.convert(&yuyv).unwrap();
|
||||||
|
|||||||
@@ -95,9 +95,10 @@ impl VideoDevice {
|
|||||||
|
|
||||||
/// Get device capabilities
|
/// Get device capabilities
|
||||||
pub fn capabilities(&self) -> Result<DeviceCapabilities> {
|
pub fn capabilities(&self) -> Result<DeviceCapabilities> {
|
||||||
let caps = self.device.query_caps().map_err(|e| {
|
let caps = self
|
||||||
AppError::VideoError(format!("Failed to query capabilities: {}", e))
|
.device
|
||||||
})?;
|
.query_caps()
|
||||||
|
.map_err(|e| AppError::VideoError(format!("Failed to query capabilities: {}", e)))?;
|
||||||
|
|
||||||
Ok(DeviceCapabilities {
|
Ok(DeviceCapabilities {
|
||||||
video_capture: caps.capabilities.contains(Flags::VIDEO_CAPTURE),
|
video_capture: caps.capabilities.contains(Flags::VIDEO_CAPTURE),
|
||||||
@@ -110,9 +111,10 @@ impl VideoDevice {
|
|||||||
|
|
||||||
/// Get detailed device information
|
/// Get detailed device information
|
||||||
pub fn info(&self) -> Result<VideoDeviceInfo> {
|
pub fn info(&self) -> Result<VideoDeviceInfo> {
|
||||||
let caps = self.device.query_caps().map_err(|e| {
|
let caps = self
|
||||||
AppError::VideoError(format!("Failed to query capabilities: {}", e))
|
.device
|
||||||
})?;
|
.query_caps()
|
||||||
|
.map_err(|e| AppError::VideoError(format!("Failed to query capabilities: {}", e)))?;
|
||||||
|
|
||||||
let capabilities = DeviceCapabilities {
|
let capabilities = DeviceCapabilities {
|
||||||
video_capture: caps.capabilities.contains(Flags::VIDEO_CAPTURE),
|
video_capture: caps.capabilities.contains(Flags::VIDEO_CAPTURE),
|
||||||
@@ -128,7 +130,8 @@ impl VideoDevice {
|
|||||||
let is_capture_card = Self::detect_capture_card(&caps.card, &caps.driver, &formats);
|
let is_capture_card = Self::detect_capture_card(&caps.card, &caps.driver, &formats);
|
||||||
|
|
||||||
// Calculate priority score
|
// Calculate priority score
|
||||||
let priority = Self::calculate_priority(&caps.card, &caps.driver, &formats, is_capture_card);
|
let priority =
|
||||||
|
Self::calculate_priority(&caps.card, &caps.driver, &formats, is_capture_card);
|
||||||
|
|
||||||
Ok(VideoDeviceInfo {
|
Ok(VideoDeviceInfo {
|
||||||
path: self.path.clone(),
|
path: self.path.clone(),
|
||||||
@@ -148,9 +151,10 @@ impl VideoDevice {
|
|||||||
let mut formats = Vec::new();
|
let mut formats = Vec::new();
|
||||||
|
|
||||||
// Get supported formats
|
// Get supported formats
|
||||||
let format_descs = self.device.enum_formats().map_err(|e| {
|
let format_descs = self
|
||||||
AppError::VideoError(format!("Failed to enumerate formats: {}", e))
|
.device
|
||||||
})?;
|
.enum_formats()
|
||||||
|
.map_err(|e| AppError::VideoError(format!("Failed to enumerate formats: {}", e)))?;
|
||||||
|
|
||||||
for desc in format_descs {
|
for desc in format_descs {
|
||||||
// Try to convert FourCC to our PixelFormat
|
// Try to convert FourCC to our PixelFormat
|
||||||
@@ -186,7 +190,9 @@ impl VideoDevice {
|
|||||||
for size in sizes {
|
for size in sizes {
|
||||||
match size.size {
|
match size.size {
|
||||||
v4l::framesize::FrameSizeEnum::Discrete(d) => {
|
v4l::framesize::FrameSizeEnum::Discrete(d) => {
|
||||||
let fps = self.enumerate_fps(fourcc, d.width, d.height).unwrap_or_default();
|
let fps = self
|
||||||
|
.enumerate_fps(fourcc, d.width, d.height)
|
||||||
|
.unwrap_or_default();
|
||||||
resolutions.push(ResolutionInfo::new(d.width, d.height, fps));
|
resolutions.push(ResolutionInfo::new(d.width, d.height, fps));
|
||||||
}
|
}
|
||||||
v4l::framesize::FrameSizeEnum::Stepwise(s) => {
|
v4l::framesize::FrameSizeEnum::Stepwise(s) => {
|
||||||
@@ -202,8 +208,11 @@ impl VideoDevice {
|
|||||||
&& res.height >= s.min_height
|
&& res.height >= s.min_height
|
||||||
&& res.height <= s.max_height
|
&& res.height <= s.max_height
|
||||||
{
|
{
|
||||||
let fps = self.enumerate_fps(fourcc, res.width, res.height).unwrap_or_default();
|
let fps = self
|
||||||
resolutions.push(ResolutionInfo::new(res.width, res.height, fps));
|
.enumerate_fps(fourcc, res.width, res.height)
|
||||||
|
.unwrap_or_default();
|
||||||
|
resolutions
|
||||||
|
.push(ResolutionInfo::new(res.width, res.height, fps));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,9 +272,9 @@ impl VideoDevice {
|
|||||||
|
|
||||||
/// Get current format
|
/// Get current format
|
||||||
pub fn get_format(&self) -> Result<Format> {
|
pub fn get_format(&self) -> Result<Format> {
|
||||||
self.device.format().map_err(|e| {
|
self.device
|
||||||
AppError::VideoError(format!("Failed to get format: {}", e))
|
.format()
|
||||||
})
|
.map_err(|e| AppError::VideoError(format!("Failed to get format: {}", e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set capture format
|
/// Set capture format
|
||||||
@@ -273,9 +282,10 @@ impl VideoDevice {
|
|||||||
let fmt = Format::new(width, height, format.to_fourcc());
|
let fmt = Format::new(width, height, format.to_fourcc());
|
||||||
|
|
||||||
// Request the format
|
// Request the format
|
||||||
let actual = self.device.set_format(&fmt).map_err(|e| {
|
let actual = self
|
||||||
AppError::VideoError(format!("Failed to set format: {}", e))
|
.device
|
||||||
})?;
|
.set_format(&fmt)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("Failed to set format: {}", e)))?;
|
||||||
|
|
||||||
if actual.width != width || actual.height != height {
|
if actual.width != width || actual.height != height {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -374,9 +384,9 @@ pub fn enumerate_devices() -> Result<Vec<VideoDeviceInfo>> {
|
|||||||
let mut devices = Vec::new();
|
let mut devices = Vec::new();
|
||||||
|
|
||||||
// Scan /dev/video* devices
|
// Scan /dev/video* devices
|
||||||
for entry in std::fs::read_dir("/dev").map_err(|e| {
|
for entry in std::fs::read_dir("/dev")
|
||||||
AppError::VideoError(format!("Failed to read /dev: {}", e))
|
.map_err(|e| AppError::VideoError(format!("Failed to read /dev: {}", e)))?
|
||||||
})? {
|
{
|
||||||
let entry = match entry {
|
let entry = match entry {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
@@ -432,9 +442,10 @@ pub fn enumerate_devices() -> Result<Vec<VideoDeviceInfo>> {
|
|||||||
pub fn find_best_device() -> Result<VideoDeviceInfo> {
|
pub fn find_best_device() -> Result<VideoDeviceInfo> {
|
||||||
let devices = enumerate_devices()?;
|
let devices = enumerate_devices()?;
|
||||||
|
|
||||||
devices.into_iter().next().ok_or_else(|| {
|
devices
|
||||||
AppError::VideoError("No video capture devices found".to_string())
|
.into_iter()
|
||||||
})
|
.next()
|
||||||
|
.ok_or_else(|| AppError::VideoError("No video capture devices found".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -99,8 +99,18 @@ pub enum H264InputFormat {
|
|||||||
Yuv420p,
|
Yuv420p,
|
||||||
/// NV12 - Y plane + interleaved UV plane (optimal for VAAPI)
|
/// NV12 - Y plane + interleaved UV plane (optimal for VAAPI)
|
||||||
Nv12,
|
Nv12,
|
||||||
|
/// NV21 - Y plane + interleaved VU plane
|
||||||
|
Nv21,
|
||||||
|
/// NV16 - Y plane + interleaved UV plane (4:2:2)
|
||||||
|
Nv16,
|
||||||
|
/// NV24 - Y plane + interleaved UV plane (4:4:4)
|
||||||
|
Nv24,
|
||||||
/// YUYV422 - packed YUV 4:2:2 format (optimal for RKMPP direct input)
|
/// YUYV422 - packed YUV 4:2:2 format (optimal for RKMPP direct input)
|
||||||
Yuyv422,
|
Yuyv422,
|
||||||
|
/// RGB24 - packed RGB format (RKMPP direct input)
|
||||||
|
Rgb24,
|
||||||
|
/// BGR24 - packed BGR format (RKMPP direct input)
|
||||||
|
Bgr24,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for H264InputFormat {
|
impl Default for H264InputFormat {
|
||||||
@@ -270,9 +280,8 @@ impl H264Encoder {
|
|||||||
// Detect best encoder
|
// Detect best encoder
|
||||||
let (_encoder_type, codec_name) = detect_best_encoder(width, height);
|
let (_encoder_type, codec_name) = detect_best_encoder(width, height);
|
||||||
|
|
||||||
let codec_name = codec_name.ok_or_else(|| {
|
let codec_name = codec_name
|
||||||
AppError::VideoError("No H.264 encoder available".to_string())
|
.ok_or_else(|| AppError::VideoError("No H.264 encoder available".to_string()))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
Self::with_codec(config, &codec_name)
|
Self::with_codec(config, &codec_name)
|
||||||
}
|
}
|
||||||
@@ -287,8 +296,13 @@ impl H264Encoder {
|
|||||||
// Select pixel format based on config
|
// Select pixel format based on config
|
||||||
let pixfmt = match config.input_format {
|
let pixfmt = match config.input_format {
|
||||||
H264InputFormat::Nv12 => AVPixelFormat::AV_PIX_FMT_NV12,
|
H264InputFormat::Nv12 => AVPixelFormat::AV_PIX_FMT_NV12,
|
||||||
|
H264InputFormat::Nv21 => AVPixelFormat::AV_PIX_FMT_NV21,
|
||||||
|
H264InputFormat::Nv16 => AVPixelFormat::AV_PIX_FMT_NV16,
|
||||||
|
H264InputFormat::Nv24 => AVPixelFormat::AV_PIX_FMT_NV24,
|
||||||
H264InputFormat::Yuv420p => AVPixelFormat::AV_PIX_FMT_YUV420P,
|
H264InputFormat::Yuv420p => AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||||
H264InputFormat::Yuyv422 => AVPixelFormat::AV_PIX_FMT_YUYV422,
|
H264InputFormat::Yuyv422 => AVPixelFormat::AV_PIX_FMT_YUYV422,
|
||||||
|
H264InputFormat::Rgb24 => AVPixelFormat::AV_PIX_FMT_RGB24,
|
||||||
|
H264InputFormat::Bgr24 => AVPixelFormat::AV_PIX_FMT_BGR24,
|
||||||
};
|
};
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
@@ -353,9 +367,9 @@ impl H264Encoder {
|
|||||||
|
|
||||||
/// Update bitrate dynamically
|
/// Update bitrate dynamically
|
||||||
pub fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
pub fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||||
self.inner.set_bitrate(bitrate_kbps as i32).map_err(|_| {
|
self.inner
|
||||||
AppError::VideoError("Failed to set bitrate".to_string())
|
.set_bitrate(bitrate_kbps as i32)
|
||||||
})?;
|
.map_err(|_| AppError::VideoError("Failed to set bitrate".to_string()))?;
|
||||||
self.config.bitrate_kbps = bitrate_kbps;
|
self.config.bitrate_kbps = bitrate_kbps;
|
||||||
debug!("Bitrate updated to {} kbps", bitrate_kbps);
|
debug!("Bitrate updated to {} kbps", bitrate_kbps);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -394,16 +408,7 @@ impl H264Encoder {
|
|||||||
Ok(owned_frames)
|
Ok(owned_frames)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// For the first ~30 frames, x264 may fail due to initialization
|
|
||||||
// Log as warning instead of error to avoid alarming users
|
|
||||||
if self.frame_count <= 30 {
|
|
||||||
warn!(
|
|
||||||
"Encode failed during initialization (frame {}): {} - this is normal for x264",
|
|
||||||
self.frame_count, e
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
error!("Encode failed: {}", e);
|
error!("Encode failed: {}", e);
|
||||||
}
|
|
||||||
Err(AppError::VideoError(format!("Encode failed: {}", e)))
|
Err(AppError::VideoError(format!("Encode failed: {}", e)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,7 +463,9 @@ impl Encoder for H264Encoder {
|
|||||||
if frames.is_empty() {
|
if frames.is_empty() {
|
||||||
// Encoder needs more frames (shouldn't happen with our config)
|
// Encoder needs more frames (shouldn't happen with our config)
|
||||||
warn!("Encoder returned no frames");
|
warn!("Encoder returned no frames");
|
||||||
return Err(AppError::VideoError("Encoder returned no frames".to_string()));
|
return Err(AppError::VideoError(
|
||||||
|
"Encoder returned no frames".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Take ownership of the first frame (zero-copy)
|
// Take ownership of the first frame (zero-copy)
|
||||||
@@ -493,8 +500,13 @@ impl Encoder for H264Encoder {
|
|||||||
// Check if the format matches our configured input format
|
// Check if the format matches our configured input format
|
||||||
match self.config.input_format {
|
match self.config.input_format {
|
||||||
H264InputFormat::Nv12 => matches!(format, PixelFormat::Nv12),
|
H264InputFormat::Nv12 => matches!(format, PixelFormat::Nv12),
|
||||||
|
H264InputFormat::Nv21 => matches!(format, PixelFormat::Nv21),
|
||||||
|
H264InputFormat::Nv16 => matches!(format, PixelFormat::Nv16),
|
||||||
|
H264InputFormat::Nv24 => matches!(format, PixelFormat::Nv24),
|
||||||
H264InputFormat::Yuv420p => matches!(format, PixelFormat::Yuv420),
|
H264InputFormat::Yuv420p => matches!(format, PixelFormat::Yuv420),
|
||||||
H264InputFormat::Yuyv422 => matches!(format, PixelFormat::Yuyv),
|
H264InputFormat::Yuyv422 => matches!(format, PixelFormat::Yuyv),
|
||||||
|
H264InputFormat::Rgb24 => matches!(format, PixelFormat::Rgb24),
|
||||||
|
H264InputFormat::Bgr24 => matches!(format, PixelFormat::Bgr24),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -538,7 +550,11 @@ mod tests {
|
|||||||
let config = H264Config::low_latency(Resolution::HD720, 2000);
|
let config = H264Config::low_latency(Resolution::HD720, 2000);
|
||||||
match H264Encoder::new(config) {
|
match H264Encoder::new(config) {
|
||||||
Ok(encoder) => {
|
Ok(encoder) => {
|
||||||
println!("Created encoder: {} ({})", encoder.codec_name(), encoder.encoder_type());
|
println!(
|
||||||
|
"Created encoder: {} ({})",
|
||||||
|
encoder.codec_name(),
|
||||||
|
encoder.encoder_type()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("Failed to create encoder: {}", e);
|
println!("Failed to create encoder: {}", e);
|
||||||
|
|||||||
@@ -92,8 +92,18 @@ pub enum H265InputFormat {
|
|||||||
Yuv420p,
|
Yuv420p,
|
||||||
/// NV12 - Y plane + interleaved UV plane (optimal for hardware encoders)
|
/// NV12 - Y plane + interleaved UV plane (optimal for hardware encoders)
|
||||||
Nv12,
|
Nv12,
|
||||||
|
/// NV21 - Y plane + interleaved VU plane
|
||||||
|
Nv21,
|
||||||
|
/// NV16 - Y plane + interleaved UV plane (4:2:2)
|
||||||
|
Nv16,
|
||||||
|
/// NV24 - Y plane + interleaved UV plane (4:4:4)
|
||||||
|
Nv24,
|
||||||
/// YUYV422 - packed YUV 4:2:2 format (optimal for RKMPP direct input)
|
/// YUYV422 - packed YUV 4:2:2 format (optimal for RKMPP direct input)
|
||||||
Yuyv422,
|
Yuyv422,
|
||||||
|
/// RGB24 - packed RGB format (RKMPP direct input)
|
||||||
|
Rgb24,
|
||||||
|
/// BGR24 - packed BGR format (RKMPP direct input)
|
||||||
|
Bgr24,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for H265InputFormat {
|
impl Default for H265InputFormat {
|
||||||
@@ -252,10 +262,7 @@ pub fn detect_best_h265_encoder(width: u32, height: u32) -> (H265EncoderType, Op
|
|||||||
H265EncoderType::Software // Default to software for unknown
|
H265EncoderType::Software // Default to software for unknown
|
||||||
};
|
};
|
||||||
|
|
||||||
info!(
|
info!("Selected H.265 encoder: {} ({})", codec.name, encoder_type);
|
||||||
"Selected H.265 encoder: {} ({})",
|
|
||||||
codec.name, encoder_type
|
|
||||||
);
|
|
||||||
(encoder_type, Some(codec.name.clone()))
|
(encoder_type, Some(codec.name.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +311,8 @@ impl H265Encoder {
|
|||||||
|
|
||||||
if encoder_type == H265EncoderType::None {
|
if encoder_type == H265EncoderType::None {
|
||||||
return Err(AppError::VideoError(
|
return Err(AppError::VideoError(
|
||||||
"No H.265 encoder available. Please ensure FFmpeg is built with libx265 support.".to_string(),
|
"No H.265 encoder available. Please ensure FFmpeg is built with libx265 support."
|
||||||
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,8 +344,17 @@ impl H265Encoder {
|
|||||||
} else {
|
} else {
|
||||||
match config.input_format {
|
match config.input_format {
|
||||||
H265InputFormat::Nv12 => (AVPixelFormat::AV_PIX_FMT_NV12, H265InputFormat::Nv12),
|
H265InputFormat::Nv12 => (AVPixelFormat::AV_PIX_FMT_NV12, H265InputFormat::Nv12),
|
||||||
H265InputFormat::Yuv420p => (AVPixelFormat::AV_PIX_FMT_YUV420P, H265InputFormat::Yuv420p),
|
H265InputFormat::Nv21 => (AVPixelFormat::AV_PIX_FMT_NV21, H265InputFormat::Nv21),
|
||||||
H265InputFormat::Yuyv422 => (AVPixelFormat::AV_PIX_FMT_YUYV422, H265InputFormat::Yuyv422),
|
H265InputFormat::Nv16 => (AVPixelFormat::AV_PIX_FMT_NV16, H265InputFormat::Nv16),
|
||||||
|
H265InputFormat::Nv24 => (AVPixelFormat::AV_PIX_FMT_NV24, H265InputFormat::Nv24),
|
||||||
|
H265InputFormat::Yuv420p => {
|
||||||
|
(AVPixelFormat::AV_PIX_FMT_YUV420P, H265InputFormat::Yuv420p)
|
||||||
|
}
|
||||||
|
H265InputFormat::Yuyv422 => {
|
||||||
|
(AVPixelFormat::AV_PIX_FMT_YUYV422, H265InputFormat::Yuyv422)
|
||||||
|
}
|
||||||
|
H265InputFormat::Rgb24 => (AVPixelFormat::AV_PIX_FMT_RGB24, H265InputFormat::Rgb24),
|
||||||
|
H265InputFormat::Bgr24 => (AVPixelFormat::AV_PIX_FMT_BGR24, H265InputFormat::Bgr24),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -407,9 +424,9 @@ impl H265Encoder {
|
|||||||
|
|
||||||
/// Update bitrate dynamically
|
/// Update bitrate dynamically
|
||||||
pub fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
pub fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||||
self.inner.set_bitrate(bitrate_kbps as i32).map_err(|_| {
|
self.inner
|
||||||
AppError::VideoError("Failed to set H.265 bitrate".to_string())
|
.set_bitrate(bitrate_kbps as i32)
|
||||||
})?;
|
.map_err(|_| AppError::VideoError("Failed to set H.265 bitrate".to_string()))?;
|
||||||
self.config.bitrate_kbps = bitrate_kbps;
|
self.config.bitrate_kbps = bitrate_kbps;
|
||||||
debug!("H.265 bitrate updated to {} kbps", bitrate_kbps);
|
debug!("H.265 bitrate updated to {} kbps", bitrate_kbps);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -464,7 +481,10 @@ impl H265Encoder {
|
|||||||
if keyframe || self.frame_count % 30 == 1 {
|
if keyframe || self.frame_count % 30 == 1 {
|
||||||
debug!(
|
debug!(
|
||||||
"[H265] Encoded frame #{}: output_size={}, keyframe={}, frame_count={}",
|
"[H265] Encoded frame #{}: output_size={}, keyframe={}, frame_count={}",
|
||||||
self.frame_count, total_size, keyframe, owned_frames.len()
|
self.frame_count,
|
||||||
|
total_size,
|
||||||
|
keyframe,
|
||||||
|
owned_frames.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log first few bytes of keyframe for debugging
|
// Log first few bytes of keyframe for debugging
|
||||||
@@ -477,7 +497,10 @@ impl H265Encoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!("[H265] Encoder returned empty frame list for frame #{}", self.frame_count);
|
warn!(
|
||||||
|
"[H265] Encoder returned empty frame list for frame #{}",
|
||||||
|
self.frame_count
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(owned_frames)
|
Ok(owned_frames)
|
||||||
@@ -567,8 +590,13 @@ impl Encoder for H265Encoder {
|
|||||||
fn supports_format(&self, format: PixelFormat) -> bool {
|
fn supports_format(&self, format: PixelFormat) -> bool {
|
||||||
match self.config.input_format {
|
match self.config.input_format {
|
||||||
H265InputFormat::Nv12 => matches!(format, PixelFormat::Nv12),
|
H265InputFormat::Nv12 => matches!(format, PixelFormat::Nv12),
|
||||||
|
H265InputFormat::Nv21 => matches!(format, PixelFormat::Nv21),
|
||||||
|
H265InputFormat::Nv16 => matches!(format, PixelFormat::Nv16),
|
||||||
|
H265InputFormat::Nv24 => matches!(format, PixelFormat::Nv24),
|
||||||
H265InputFormat::Yuv420p => matches!(format, PixelFormat::Yuv420),
|
H265InputFormat::Yuv420p => matches!(format, PixelFormat::Yuv420),
|
||||||
H265InputFormat::Yuyv422 => matches!(format, PixelFormat::Yuyv),
|
H265InputFormat::Yuyv422 => matches!(format, PixelFormat::Yuyv),
|
||||||
|
H265InputFormat::Rgb24 => matches!(format, PixelFormat::Rgb24),
|
||||||
|
H265InputFormat::Bgr24 => matches!(format, PixelFormat::Bgr24),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,7 +608,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_detect_h265_encoder() {
|
fn test_detect_h265_encoder() {
|
||||||
let (encoder_type, codec_name) = detect_best_h265_encoder(1280, 720);
|
let (encoder_type, codec_name) = detect_best_h265_encoder(1280, 720);
|
||||||
println!("Detected H.265 encoder: {:?} ({:?})", encoder_type, codec_name);
|
println!(
|
||||||
|
"Detected H.265 encoder: {:?} ({:?})",
|
||||||
|
encoder_type, codec_name
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ impl JpegEncoder {
|
|||||||
// I420: Y = width*height, U = width*height/4, V = width*height/4
|
// I420: Y = width*height, U = width*height/4, V = width*height/4
|
||||||
let i420_size = width * height * 3 / 2;
|
let i420_size = width * height * 3 / 2;
|
||||||
|
|
||||||
let mut compressor = turbojpeg::Compressor::new()
|
let mut compressor = turbojpeg::Compressor::new().map_err(|e| {
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to create turbojpeg compressor: {}", e)))?;
|
AppError::VideoError(format!("Failed to create turbojpeg compressor: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
compressor.set_quality(config.quality.min(100) as i32)
|
compressor
|
||||||
|
.set_quality(config.quality.min(100) as i32)
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to set JPEG quality: {}", e)))?;
|
.map_err(|e| AppError::VideoError(format!("Failed to set JPEG quality: {}", e)))?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -56,7 +58,8 @@ impl JpegEncoder {
|
|||||||
|
|
||||||
/// Set JPEG quality (1-100)
|
/// Set JPEG quality (1-100)
|
||||||
pub fn set_quality(&mut self, quality: u32) -> Result<()> {
|
pub fn set_quality(&mut self, quality: u32) -> Result<()> {
|
||||||
self.compressor.set_quality(quality.min(100) as i32)
|
self.compressor
|
||||||
|
.set_quality(quality.min(100) as i32)
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to set JPEG quality: {}", e)))?;
|
.map_err(|e| AppError::VideoError(format!("Failed to set JPEG quality: {}", e)))?;
|
||||||
self.config.quality = quality;
|
self.config.quality = quality;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -78,7 +81,9 @@ impl JpegEncoder {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Compress YUV directly to JPEG (skips color space conversion!)
|
// Compress YUV directly to JPEG (skips color space conversion!)
|
||||||
let jpeg_data = self.compressor.compress_yuv_to_vec(yuv_image)
|
let jpeg_data = self
|
||||||
|
.compressor
|
||||||
|
.compress_yuv_to_vec(yuv_image)
|
||||||
.map_err(|e| AppError::VideoError(format!("JPEG compression failed: {}", e)))?;
|
.map_err(|e| AppError::VideoError(format!("JPEG compression failed: {}", e)))?;
|
||||||
|
|
||||||
Ok(EncodedFrame::jpeg(
|
Ok(EncodedFrame::jpeg(
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ pub mod vp8;
|
|||||||
pub mod vp9;
|
pub mod vp9;
|
||||||
|
|
||||||
// Core traits and types
|
// Core traits and types
|
||||||
pub use traits::{BitratePreset, EncodedFormat, EncodedFrame, Encoder, EncoderConfig, EncoderFactory};
|
pub use traits::{
|
||||||
|
BitratePreset, EncodedFormat, EncodedFrame, Encoder, EncoderConfig, EncoderFactory,
|
||||||
|
};
|
||||||
|
|
||||||
// WebRTC codec abstraction
|
// WebRTC codec abstraction
|
||||||
pub use codec::{CodecFrame, VideoCodec, VideoCodecConfig, VideoCodecFactory, VideoCodecType};
|
pub use codec::{CodecFrame, VideoCodec, VideoCodecConfig, VideoCodecFactory, VideoCodecType};
|
||||||
|
|||||||
@@ -264,10 +264,7 @@ impl EncoderRegistry {
|
|||||||
if let Some(encoder) = AvailableEncoder::from_codec_info(codec_info) {
|
if let Some(encoder) = AvailableEncoder::from_codec_info(codec_info) {
|
||||||
debug!(
|
debug!(
|
||||||
"Detected encoder: {} ({}) - {} priority={}",
|
"Detected encoder: {} ({}) - {} priority={}",
|
||||||
encoder.codec_name,
|
encoder.codec_name, encoder.format, encoder.backend, encoder.priority
|
||||||
encoder.format,
|
|
||||||
encoder.backend,
|
|
||||||
encoder.priority
|
|
||||||
);
|
);
|
||||||
|
|
||||||
self.encoders
|
self.encoders
|
||||||
@@ -336,13 +333,15 @@ impl EncoderRegistry {
|
|||||||
format: VideoEncoderType,
|
format: VideoEncoderType,
|
||||||
hardware_only: bool,
|
hardware_only: bool,
|
||||||
) -> Option<&AvailableEncoder> {
|
) -> Option<&AvailableEncoder> {
|
||||||
self.encoders.get(&format)?.iter().find(|e| {
|
self.encoders.get(&format)?.iter().find(
|
||||||
|
|e| {
|
||||||
if hardware_only {
|
if hardware_only {
|
||||||
e.is_hardware
|
e.is_hardware
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all encoders for a format
|
/// Get all encoders for a format
|
||||||
@@ -523,9 +522,6 @@ mod tests {
|
|||||||
|
|
||||||
// Should have detected at least H264 (software fallback available)
|
// Should have detected at least H264 (software fallback available)
|
||||||
println!("Available formats: {:?}", registry.available_formats(false));
|
println!("Available formats: {:?}", registry.available_formats(false));
|
||||||
println!(
|
println!("Selectable formats: {:?}", registry.selectable_formats());
|
||||||
"Selectable formats: {:?}",
|
|
||||||
registry.selectable_formats()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
|
|
||||||
/// Bitrate preset for video encoding
|
/// Bitrate preset for video encoding
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -186,10 +186,7 @@ pub fn detect_best_vp8_encoder(width: u32, height: u32) -> (VP8EncoderType, Opti
|
|||||||
VP8EncoderType::Software // Default to software for unknown
|
VP8EncoderType::Software // Default to software for unknown
|
||||||
};
|
};
|
||||||
|
|
||||||
info!(
|
info!("Selected VP8 encoder: {} ({})", codec.name, encoder_type);
|
||||||
"Selected VP8 encoder: {} ({})",
|
|
||||||
codec.name, encoder_type
|
|
||||||
);
|
|
||||||
(encoder_type, Some(codec.name.clone()))
|
(encoder_type, Some(codec.name.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +235,8 @@ impl VP8Encoder {
|
|||||||
|
|
||||||
if encoder_type == VP8EncoderType::None {
|
if encoder_type == VP8EncoderType::None {
|
||||||
return Err(AppError::VideoError(
|
return Err(AppError::VideoError(
|
||||||
"No VP8 encoder available. Please ensure FFmpeg is built with libvpx support.".to_string(),
|
"No VP8 encoder available. Please ensure FFmpeg is built with libvpx support."
|
||||||
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +268,9 @@ impl VP8Encoder {
|
|||||||
} else {
|
} else {
|
||||||
match config.input_format {
|
match config.input_format {
|
||||||
VP8InputFormat::Nv12 => (AVPixelFormat::AV_PIX_FMT_NV12, VP8InputFormat::Nv12),
|
VP8InputFormat::Nv12 => (AVPixelFormat::AV_PIX_FMT_NV12, VP8InputFormat::Nv12),
|
||||||
VP8InputFormat::Yuv420p => (AVPixelFormat::AV_PIX_FMT_YUV420P, VP8InputFormat::Yuv420p),
|
VP8InputFormat::Yuv420p => {
|
||||||
|
(AVPixelFormat::AV_PIX_FMT_YUV420P, VP8InputFormat::Yuv420p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -340,9 +340,9 @@ impl VP8Encoder {
|
|||||||
|
|
||||||
/// Update bitrate dynamically
|
/// Update bitrate dynamically
|
||||||
pub fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
pub fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||||
self.inner.set_bitrate(bitrate_kbps as i32).map_err(|_| {
|
self.inner
|
||||||
AppError::VideoError("Failed to set VP8 bitrate".to_string())
|
.set_bitrate(bitrate_kbps as i32)
|
||||||
})?;
|
.map_err(|_| AppError::VideoError("Failed to set VP8 bitrate".to_string()))?;
|
||||||
self.config.bitrate_kbps = bitrate_kbps;
|
self.config.bitrate_kbps = bitrate_kbps;
|
||||||
debug!("VP8 bitrate updated to {} kbps", bitrate_kbps);
|
debug!("VP8 bitrate updated to {} kbps", bitrate_kbps);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -470,7 +470,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_detect_vp8_encoder() {
|
fn test_detect_vp8_encoder() {
|
||||||
let (encoder_type, codec_name) = detect_best_vp8_encoder(1280, 720);
|
let (encoder_type, codec_name) = detect_best_vp8_encoder(1280, 720);
|
||||||
println!("Detected VP8 encoder: {:?} ({:?})", encoder_type, codec_name);
|
println!(
|
||||||
|
"Detected VP8 encoder: {:?} ({:?})",
|
||||||
|
encoder_type, codec_name
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -186,10 +186,7 @@ pub fn detect_best_vp9_encoder(width: u32, height: u32) -> (VP9EncoderType, Opti
|
|||||||
VP9EncoderType::Software // Default to software for unknown
|
VP9EncoderType::Software // Default to software for unknown
|
||||||
};
|
};
|
||||||
|
|
||||||
info!(
|
info!("Selected VP9 encoder: {} ({})", codec.name, encoder_type);
|
||||||
"Selected VP9 encoder: {} ({})",
|
|
||||||
codec.name, encoder_type
|
|
||||||
);
|
|
||||||
(encoder_type, Some(codec.name.clone()))
|
(encoder_type, Some(codec.name.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +235,8 @@ impl VP9Encoder {
|
|||||||
|
|
||||||
if encoder_type == VP9EncoderType::None {
|
if encoder_type == VP9EncoderType::None {
|
||||||
return Err(AppError::VideoError(
|
return Err(AppError::VideoError(
|
||||||
"No VP9 encoder available. Please ensure FFmpeg is built with libvpx support.".to_string(),
|
"No VP9 encoder available. Please ensure FFmpeg is built with libvpx support."
|
||||||
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +268,9 @@ impl VP9Encoder {
|
|||||||
} else {
|
} else {
|
||||||
match config.input_format {
|
match config.input_format {
|
||||||
VP9InputFormat::Nv12 => (AVPixelFormat::AV_PIX_FMT_NV12, VP9InputFormat::Nv12),
|
VP9InputFormat::Nv12 => (AVPixelFormat::AV_PIX_FMT_NV12, VP9InputFormat::Nv12),
|
||||||
VP9InputFormat::Yuv420p => (AVPixelFormat::AV_PIX_FMT_YUV420P, VP9InputFormat::Yuv420p),
|
VP9InputFormat::Yuv420p => {
|
||||||
|
(AVPixelFormat::AV_PIX_FMT_YUV420P, VP9InputFormat::Yuv420p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -340,9 +340,9 @@ impl VP9Encoder {
|
|||||||
|
|
||||||
/// Update bitrate dynamically
|
/// Update bitrate dynamically
|
||||||
pub fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
pub fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||||
self.inner.set_bitrate(bitrate_kbps as i32).map_err(|_| {
|
self.inner
|
||||||
AppError::VideoError("Failed to set VP9 bitrate".to_string())
|
.set_bitrate(bitrate_kbps as i32)
|
||||||
})?;
|
.map_err(|_| AppError::VideoError("Failed to set VP9 bitrate".to_string()))?;
|
||||||
self.config.bitrate_kbps = bitrate_kbps;
|
self.config.bitrate_kbps = bitrate_kbps;
|
||||||
debug!("VP9 bitrate updated to {} kbps", bitrate_kbps);
|
debug!("VP9 bitrate updated to {} kbps", bitrate_kbps);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -470,7 +470,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_detect_vp9_encoder() {
|
fn test_detect_vp9_encoder() {
|
||||||
let (encoder_type, codec_name) = detect_best_vp9_encoder(1280, 720);
|
let (encoder_type, codec_name) = detect_best_vp9_encoder(1280, 720);
|
||||||
println!("Detected VP9 encoder: {:?} ({:?})", encoder_type, codec_name);
|
println!(
|
||||||
|
"Detected VP9 encoder: {:?} ({:?})",
|
||||||
|
encoder_type, codec_name
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ pub enum PixelFormat {
|
|||||||
Uyvy,
|
Uyvy,
|
||||||
/// NV12 semi-planar format (Y plane + interleaved UV)
|
/// NV12 semi-planar format (Y plane + interleaved UV)
|
||||||
Nv12,
|
Nv12,
|
||||||
|
/// NV21 semi-planar format (Y plane + interleaved VU)
|
||||||
|
Nv21,
|
||||||
/// NV16 semi-planar format
|
/// NV16 semi-planar format
|
||||||
Nv16,
|
Nv16,
|
||||||
/// NV24 semi-planar format
|
/// NV24 semi-planar format
|
||||||
@@ -48,6 +50,7 @@ impl PixelFormat {
|
|||||||
PixelFormat::Yvyu => fourcc::FourCC::new(b"YVYU"),
|
PixelFormat::Yvyu => fourcc::FourCC::new(b"YVYU"),
|
||||||
PixelFormat::Uyvy => fourcc::FourCC::new(b"UYVY"),
|
PixelFormat::Uyvy => fourcc::FourCC::new(b"UYVY"),
|
||||||
PixelFormat::Nv12 => fourcc::FourCC::new(b"NV12"),
|
PixelFormat::Nv12 => fourcc::FourCC::new(b"NV12"),
|
||||||
|
PixelFormat::Nv21 => fourcc::FourCC::new(b"NV21"),
|
||||||
PixelFormat::Nv16 => fourcc::FourCC::new(b"NV16"),
|
PixelFormat::Nv16 => fourcc::FourCC::new(b"NV16"),
|
||||||
PixelFormat::Nv24 => fourcc::FourCC::new(b"NV24"),
|
PixelFormat::Nv24 => fourcc::FourCC::new(b"NV24"),
|
||||||
PixelFormat::Yuv420 => fourcc::FourCC::new(b"YU12"),
|
PixelFormat::Yuv420 => fourcc::FourCC::new(b"YU12"),
|
||||||
@@ -69,6 +72,7 @@ impl PixelFormat {
|
|||||||
b"YVYU" => Some(PixelFormat::Yvyu),
|
b"YVYU" => Some(PixelFormat::Yvyu),
|
||||||
b"UYVY" => Some(PixelFormat::Uyvy),
|
b"UYVY" => Some(PixelFormat::Uyvy),
|
||||||
b"NV12" => Some(PixelFormat::Nv12),
|
b"NV12" => Some(PixelFormat::Nv12),
|
||||||
|
b"NV21" => Some(PixelFormat::Nv21),
|
||||||
b"NV16" => Some(PixelFormat::Nv16),
|
b"NV16" => Some(PixelFormat::Nv16),
|
||||||
b"NV24" => Some(PixelFormat::Nv24),
|
b"NV24" => Some(PixelFormat::Nv24),
|
||||||
b"YU12" | b"I420" => Some(PixelFormat::Yuv420),
|
b"YU12" | b"I420" => Some(PixelFormat::Yuv420),
|
||||||
@@ -92,7 +96,9 @@ impl PixelFormat {
|
|||||||
match self {
|
match self {
|
||||||
PixelFormat::Mjpeg | PixelFormat::Jpeg => None,
|
PixelFormat::Mjpeg | PixelFormat::Jpeg => None,
|
||||||
PixelFormat::Yuyv | PixelFormat::Yvyu | PixelFormat::Uyvy => Some(2),
|
PixelFormat::Yuyv | PixelFormat::Yvyu | PixelFormat::Uyvy => Some(2),
|
||||||
PixelFormat::Nv12 | PixelFormat::Yuv420 | PixelFormat::Yvu420 => None, // Variable
|
PixelFormat::Nv12 | PixelFormat::Nv21 | PixelFormat::Yuv420 | PixelFormat::Yvu420 => {
|
||||||
|
None
|
||||||
|
} // Variable
|
||||||
PixelFormat::Nv16 => None,
|
PixelFormat::Nv16 => None,
|
||||||
PixelFormat::Nv24 => None,
|
PixelFormat::Nv24 => None,
|
||||||
PixelFormat::Rgb565 => Some(2),
|
PixelFormat::Rgb565 => Some(2),
|
||||||
@@ -108,7 +114,9 @@ impl PixelFormat {
|
|||||||
match self {
|
match self {
|
||||||
PixelFormat::Mjpeg | PixelFormat::Jpeg => None,
|
PixelFormat::Mjpeg | PixelFormat::Jpeg => None,
|
||||||
PixelFormat::Yuyv | PixelFormat::Yvyu | PixelFormat::Uyvy => Some(pixels * 2),
|
PixelFormat::Yuyv | PixelFormat::Yvyu | PixelFormat::Uyvy => Some(pixels * 2),
|
||||||
PixelFormat::Nv12 | PixelFormat::Yuv420 | PixelFormat::Yvu420 => Some(pixels * 3 / 2),
|
PixelFormat::Nv12 | PixelFormat::Nv21 | PixelFormat::Yuv420 | PixelFormat::Yvu420 => {
|
||||||
|
Some(pixels * 3 / 2)
|
||||||
|
}
|
||||||
PixelFormat::Nv16 => Some(pixels * 2),
|
PixelFormat::Nv16 => Some(pixels * 2),
|
||||||
PixelFormat::Nv24 => Some(pixels * 3),
|
PixelFormat::Nv24 => Some(pixels * 3),
|
||||||
PixelFormat::Rgb565 => Some(pixels * 2),
|
PixelFormat::Rgb565 => Some(pixels * 2),
|
||||||
@@ -125,6 +133,7 @@ impl PixelFormat {
|
|||||||
PixelFormat::Jpeg => 99,
|
PixelFormat::Jpeg => 99,
|
||||||
PixelFormat::Yuyv => 80,
|
PixelFormat::Yuyv => 80,
|
||||||
PixelFormat::Nv12 => 75,
|
PixelFormat::Nv12 => 75,
|
||||||
|
PixelFormat::Nv21 => 74,
|
||||||
PixelFormat::Yuv420 => 70,
|
PixelFormat::Yuv420 => 70,
|
||||||
PixelFormat::Uyvy => 65,
|
PixelFormat::Uyvy => 65,
|
||||||
PixelFormat::Yvyu => 64,
|
PixelFormat::Yvyu => 64,
|
||||||
@@ -144,7 +153,10 @@ impl PixelFormat {
|
|||||||
/// Software encoding prefers: YUYV > NV12
|
/// Software encoding prefers: YUYV > NV12
|
||||||
///
|
///
|
||||||
/// Returns None if no suitable format is available
|
/// Returns None if no suitable format is available
|
||||||
pub fn recommended_for_encoding(available: &[PixelFormat], is_hardware: bool) -> Option<PixelFormat> {
|
pub fn recommended_for_encoding(
|
||||||
|
available: &[PixelFormat],
|
||||||
|
is_hardware: bool,
|
||||||
|
) -> Option<PixelFormat> {
|
||||||
if is_hardware {
|
if is_hardware {
|
||||||
// Hardware encoding: NV12 > YUYV
|
// Hardware encoding: NV12 > YUYV
|
||||||
if available.contains(&PixelFormat::Nv12) {
|
if available.contains(&PixelFormat::Nv12) {
|
||||||
@@ -175,6 +187,7 @@ impl PixelFormat {
|
|||||||
PixelFormat::Yvyu,
|
PixelFormat::Yvyu,
|
||||||
PixelFormat::Uyvy,
|
PixelFormat::Uyvy,
|
||||||
PixelFormat::Nv12,
|
PixelFormat::Nv12,
|
||||||
|
PixelFormat::Nv21,
|
||||||
PixelFormat::Nv16,
|
PixelFormat::Nv16,
|
||||||
PixelFormat::Nv24,
|
PixelFormat::Nv24,
|
||||||
PixelFormat::Yuv420,
|
PixelFormat::Yuv420,
|
||||||
@@ -196,6 +209,7 @@ impl fmt::Display for PixelFormat {
|
|||||||
PixelFormat::Yvyu => "YVYU",
|
PixelFormat::Yvyu => "YVYU",
|
||||||
PixelFormat::Uyvy => "UYVY",
|
PixelFormat::Uyvy => "UYVY",
|
||||||
PixelFormat::Nv12 => "NV12",
|
PixelFormat::Nv12 => "NV12",
|
||||||
|
PixelFormat::Nv21 => "NV21",
|
||||||
PixelFormat::Nv16 => "NV16",
|
PixelFormat::Nv16 => "NV16",
|
||||||
PixelFormat::Nv24 => "NV24",
|
PixelFormat::Nv24 => "NV24",
|
||||||
PixelFormat::Yuv420 => "YUV420",
|
PixelFormat::Yuv420 => "YUV420",
|
||||||
@@ -220,6 +234,7 @@ impl std::str::FromStr for PixelFormat {
|
|||||||
"YVYU" => Ok(PixelFormat::Yvyu),
|
"YVYU" => Ok(PixelFormat::Yvyu),
|
||||||
"UYVY" => Ok(PixelFormat::Uyvy),
|
"UYVY" => Ok(PixelFormat::Uyvy),
|
||||||
"NV12" => Ok(PixelFormat::Nv12),
|
"NV12" => Ok(PixelFormat::Nv12),
|
||||||
|
"NV21" => Ok(PixelFormat::Nv21),
|
||||||
"NV16" => Ok(PixelFormat::Nv16),
|
"NV16" => Ok(PixelFormat::Nv16),
|
||||||
"NV24" => Ok(PixelFormat::Nv24),
|
"NV24" => Ok(PixelFormat::Nv24),
|
||||||
"YUV420" | "I420" => Ok(PixelFormat::Yuv420),
|
"YUV420" | "I420" => Ok(PixelFormat::Yuv420),
|
||||||
|
|||||||
@@ -106,9 +106,9 @@ impl VideoFrame {
|
|||||||
/// Get hash of frame data (computed once, cached)
|
/// Get hash of frame data (computed once, cached)
|
||||||
/// Used for fast frame deduplication comparison
|
/// Used for fast frame deduplication comparison
|
||||||
pub fn get_hash(&self) -> u64 {
|
pub fn get_hash(&self) -> u64 {
|
||||||
*self.hash.get_or_init(|| {
|
*self
|
||||||
xxhash_rust::xxh64::xxh64(self.data.as_ref(), 0)
|
.hash
|
||||||
})
|
.get_or_init(|| xxhash_rust::xxh64::xxh64(self.data.as_ref(), 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if format is JPEG/MJPEG
|
/// Check if format is JPEG/MJPEG
|
||||||
|
|||||||
@@ -93,10 +93,7 @@ impl H264Pipeline {
|
|||||||
pub fn new(config: H264PipelineConfig) -> Result<Self> {
|
pub fn new(config: H264PipelineConfig) -> Result<Self> {
|
||||||
info!(
|
info!(
|
||||||
"Creating H264 pipeline: {}x{} @ {} kbps, {} fps",
|
"Creating H264 pipeline: {}x{} @ {} kbps, {} fps",
|
||||||
config.resolution.width,
|
config.resolution.width, config.resolution.height, config.bitrate_kbps, config.fps
|
||||||
config.resolution.height,
|
|
||||||
config.bitrate_kbps,
|
|
||||||
config.fps
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine encoder input format based on pipeline input
|
// Determine encoder input format based on pipeline input
|
||||||
@@ -154,7 +151,7 @@ impl H264Pipeline {
|
|||||||
// MJPEG/JPEG input - not supported (requires libjpeg for decoding)
|
// MJPEG/JPEG input - not supported (requires libjpeg for decoding)
|
||||||
PixelFormat::Mjpeg | PixelFormat::Jpeg => {
|
PixelFormat::Mjpeg | PixelFormat::Jpeg => {
|
||||||
return Err(AppError::VideoError(
|
return Err(AppError::VideoError(
|
||||||
"MJPEG input format not supported in this build".to_string()
|
"MJPEG input format not supported in this build".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +213,10 @@ impl H264Pipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _ = self.running.send(true);
|
let _ = self.running.send(true);
|
||||||
info!("Starting H264 pipeline (input format: {})", self.config.input_format);
|
info!(
|
||||||
|
"Starting H264 pipeline (input format: {})",
|
||||||
|
self.config.input_format
|
||||||
|
);
|
||||||
|
|
||||||
let encoder = self.encoder.lock().await.take();
|
let encoder = self.encoder.lock().await.take();
|
||||||
let nv12_converter = self.nv12_converter.lock().await.take();
|
let nv12_converter = self.nv12_converter.lock().await.take();
|
||||||
|
|||||||
@@ -18,11 +18,15 @@ pub mod video_session;
|
|||||||
pub use capture::VideoCapturer;
|
pub use capture::VideoCapturer;
|
||||||
pub use convert::{PixelConverter, Yuv420pBuffer};
|
pub use convert::{PixelConverter, Yuv420pBuffer};
|
||||||
pub use device::{VideoDevice, VideoDeviceInfo};
|
pub use device::{VideoDevice, VideoDeviceInfo};
|
||||||
pub use encoder::{JpegEncoder, H264Encoder, H264EncoderType};
|
pub use encoder::{H264Encoder, H264EncoderType, JpegEncoder};
|
||||||
pub use format::PixelFormat;
|
pub use format::PixelFormat;
|
||||||
pub use frame::VideoFrame;
|
pub use frame::VideoFrame;
|
||||||
pub use h264_pipeline::{H264Pipeline, H264PipelineBuilder, H264PipelineConfig};
|
pub use h264_pipeline::{H264Pipeline, H264PipelineBuilder, H264PipelineConfig};
|
||||||
pub use shared_video_pipeline::{EncodedVideoFrame, SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats};
|
pub use shared_video_pipeline::{
|
||||||
|
EncodedVideoFrame, SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats,
|
||||||
|
};
|
||||||
pub use stream_manager::VideoStreamManager;
|
pub use stream_manager::VideoStreamManager;
|
||||||
pub use streamer::{Streamer, StreamerState};
|
pub use streamer::{Streamer, StreamerState};
|
||||||
pub use video_session::{VideoSessionManager, VideoSessionManagerConfig, VideoSessionInfo, VideoSessionState, CodecInfo};
|
pub use video_session::{
|
||||||
|
CodecInfo, VideoSessionInfo, VideoSessionManager, VideoSessionManagerConfig, VideoSessionState,
|
||||||
|
};
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ const AUTO_STOP_GRACE_PERIOD_SECS: u64 = 3;
|
|||||||
|
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::video::convert::{Nv12Converter, PixelConverter};
|
use crate::video::convert::{Nv12Converter, PixelConverter};
|
||||||
use crate::video::encoder::h264::{H264Config, H264Encoder};
|
use crate::video::encoder::h264::{detect_best_encoder, H264Config, H264Encoder, H264InputFormat};
|
||||||
use crate::video::encoder::h265::{H265Config, H265Encoder};
|
use crate::video::encoder::h265::{
|
||||||
|
detect_best_h265_encoder, H265Config, H265Encoder, H265InputFormat,
|
||||||
|
};
|
||||||
use crate::video::encoder::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
use crate::video::encoder::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||||
use crate::video::encoder::traits::EncoderConfig;
|
use crate::video::encoder::traits::EncoderConfig;
|
||||||
use crate::video::encoder::vp8::{VP8Config, VP8Encoder};
|
use crate::video::encoder::vp8::{VP8Config, VP8Encoder};
|
||||||
@@ -157,7 +159,6 @@ pub struct SharedVideoPipelineStats {
|
|||||||
pub subscribers: u64,
|
pub subscribers: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Universal video encoder trait object
|
/// Universal video encoder trait object
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
trait VideoEncoderTrait: Send {
|
trait VideoEncoderTrait: Send {
|
||||||
@@ -300,7 +301,7 @@ pub struct SharedVideoPipeline {
|
|||||||
/// Whether the encoder needs YUV420P (true) or NV12 (false)
|
/// Whether the encoder needs YUV420P (true) or NV12 (false)
|
||||||
encoder_needs_yuv420p: AtomicBool,
|
encoder_needs_yuv420p: AtomicBool,
|
||||||
/// Whether YUYV direct input is enabled (RKMPP optimization)
|
/// Whether YUYV direct input is enabled (RKMPP optimization)
|
||||||
yuyv_direct_input: AtomicBool,
|
direct_input: AtomicBool,
|
||||||
frame_tx: broadcast::Sender<EncodedVideoFrame>,
|
frame_tx: broadcast::Sender<EncodedVideoFrame>,
|
||||||
stats: Mutex<SharedVideoPipelineStats>,
|
stats: Mutex<SharedVideoPipelineStats>,
|
||||||
running: watch::Sender<bool>,
|
running: watch::Sender<bool>,
|
||||||
@@ -335,7 +336,7 @@ impl SharedVideoPipeline {
|
|||||||
nv12_converter: Mutex::new(None),
|
nv12_converter: Mutex::new(None),
|
||||||
yuv420p_converter: Mutex::new(None),
|
yuv420p_converter: Mutex::new(None),
|
||||||
encoder_needs_yuv420p: AtomicBool::new(false),
|
encoder_needs_yuv420p: AtomicBool::new(false),
|
||||||
yuyv_direct_input: AtomicBool::new(false),
|
direct_input: AtomicBool::new(false),
|
||||||
frame_tx,
|
frame_tx,
|
||||||
stats: Mutex::new(SharedVideoPipelineStats::default()),
|
stats: Mutex::new(SharedVideoPipelineStats::default()),
|
||||||
running: running_tx,
|
running: running_tx,
|
||||||
@@ -354,29 +355,108 @@ impl SharedVideoPipeline {
|
|||||||
let registry = EncoderRegistry::global();
|
let registry = EncoderRegistry::global();
|
||||||
|
|
||||||
// Helper to get codec name for specific backend
|
// Helper to get codec name for specific backend
|
||||||
let get_codec_name = |format: VideoEncoderType, backend: Option<EncoderBackend>| -> Option<String> {
|
let get_codec_name =
|
||||||
|
|format: VideoEncoderType, backend: Option<EncoderBackend>| -> Option<String> {
|
||||||
match backend {
|
match backend {
|
||||||
Some(b) => registry.encoder_with_backend(format, b).map(|e| e.codec_name.clone()),
|
Some(b) => registry
|
||||||
None => registry.best_encoder(format, false).map(|e| e.codec_name.clone()),
|
.encoder_with_backend(format, b)
|
||||||
|
.map(|e| e.codec_name.clone()),
|
||||||
|
None => registry
|
||||||
|
.best_encoder(format, false)
|
||||||
|
.map(|e| e.codec_name.clone()),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if RKMPP backend is available for YUYV direct input optimization
|
// Check if RKMPP backend is available for direct input optimization
|
||||||
let is_rkmpp_available = registry.encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Rkmpp).is_some();
|
let is_rkmpp_available = registry
|
||||||
|
.encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Rkmpp)
|
||||||
|
.is_some();
|
||||||
let use_yuyv_direct = is_rkmpp_available && config.input_format == PixelFormat::Yuyv;
|
let use_yuyv_direct = is_rkmpp_available && config.input_format == PixelFormat::Yuyv;
|
||||||
|
let use_rkmpp_direct = is_rkmpp_available
|
||||||
|
&& matches!(
|
||||||
|
config.input_format,
|
||||||
|
PixelFormat::Yuyv
|
||||||
|
| PixelFormat::Yuv420
|
||||||
|
| PixelFormat::Rgb24
|
||||||
|
| PixelFormat::Bgr24
|
||||||
|
| PixelFormat::Nv12
|
||||||
|
| PixelFormat::Nv16
|
||||||
|
| PixelFormat::Nv21
|
||||||
|
| PixelFormat::Nv24
|
||||||
|
);
|
||||||
|
|
||||||
if use_yuyv_direct {
|
if use_yuyv_direct {
|
||||||
info!("RKMPP backend detected with YUYV input, enabling YUYV direct input optimization");
|
info!(
|
||||||
|
"RKMPP backend detected with YUYV input, enabling YUYV direct input optimization"
|
||||||
|
);
|
||||||
|
} else if use_rkmpp_direct {
|
||||||
|
info!(
|
||||||
|
"RKMPP backend detected with {} input, enabling direct input optimization",
|
||||||
|
config.input_format
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create encoder based on codec type
|
// Create encoder based on codec type
|
||||||
let encoder: Box<dyn VideoEncoderTrait + Send> = match config.output_codec {
|
let encoder: Box<dyn VideoEncoderTrait + Send> = match config.output_codec {
|
||||||
VideoEncoderType::H264 => {
|
VideoEncoderType::H264 => {
|
||||||
// Determine H264 input format based on backend and input format
|
let codec_name = if use_rkmpp_direct {
|
||||||
let h264_input_format = if use_yuyv_direct {
|
// Force RKMPP backend for direct input
|
||||||
crate::video::encoder::h264::H264InputFormat::Yuyv422
|
get_codec_name(VideoEncoderType::H264, Some(EncoderBackend::Rkmpp)).ok_or_else(
|
||||||
|
|| {
|
||||||
|
AppError::VideoError(
|
||||||
|
"RKMPP backend not available for H.264".to_string(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)?
|
||||||
|
} else if let Some(ref backend) = config.encoder_backend {
|
||||||
|
// Specific backend requested
|
||||||
|
get_codec_name(VideoEncoderType::H264, Some(*backend)).ok_or_else(|| {
|
||||||
|
AppError::VideoError(format!(
|
||||||
|
"Backend {:?} does not support H.264",
|
||||||
|
backend
|
||||||
|
))
|
||||||
|
})?
|
||||||
} else {
|
} else {
|
||||||
crate::video::encoder::h264::H264InputFormat::Nv12
|
// Auto select best available encoder
|
||||||
|
let (_encoder_type, detected) =
|
||||||
|
detect_best_encoder(config.resolution.width, config.resolution.height);
|
||||||
|
detected.ok_or_else(|| {
|
||||||
|
AppError::VideoError("No H.264 encoder available".to_string())
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_rkmpp = codec_name.contains("rkmpp");
|
||||||
|
let direct_input_format = if is_rkmpp {
|
||||||
|
match config.input_format {
|
||||||
|
PixelFormat::Yuyv => Some(H264InputFormat::Yuyv422),
|
||||||
|
PixelFormat::Yuv420 => Some(H264InputFormat::Yuv420p),
|
||||||
|
PixelFormat::Rgb24 => Some(H264InputFormat::Rgb24),
|
||||||
|
PixelFormat::Bgr24 => Some(H264InputFormat::Bgr24),
|
||||||
|
PixelFormat::Nv12 => Some(H264InputFormat::Nv12),
|
||||||
|
PixelFormat::Nv16 => Some(H264InputFormat::Nv16),
|
||||||
|
PixelFormat::Nv21 => Some(H264InputFormat::Nv21),
|
||||||
|
PixelFormat::Nv24 => Some(H264InputFormat::Nv24),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
} else if codec_name.contains("libx264") {
|
||||||
|
match config.input_format {
|
||||||
|
PixelFormat::Nv12 => Some(H264InputFormat::Nv12),
|
||||||
|
PixelFormat::Nv16 => Some(H264InputFormat::Nv16),
|
||||||
|
PixelFormat::Nv21 => Some(H264InputFormat::Nv21),
|
||||||
|
PixelFormat::Yuv420 => Some(H264InputFormat::Yuv420p),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Choose input format: prefer direct input when supported
|
||||||
|
let h264_input_format = if let Some(fmt) = direct_input_format {
|
||||||
|
fmt
|
||||||
|
} else if codec_name.contains("libx264") {
|
||||||
|
H264InputFormat::Yuv420p
|
||||||
|
} else {
|
||||||
|
H264InputFormat::Nv12
|
||||||
};
|
};
|
||||||
|
|
||||||
let encoder_config = H264Config {
|
let encoder_config = H264Config {
|
||||||
@@ -387,69 +467,124 @@ impl SharedVideoPipeline {
|
|||||||
input_format: h264_input_format,
|
input_format: h264_input_format,
|
||||||
};
|
};
|
||||||
|
|
||||||
let encoder = if use_yuyv_direct {
|
if use_rkmpp_direct {
|
||||||
// Force RKMPP backend for YUYV direct input
|
info!(
|
||||||
let codec_name = get_codec_name(VideoEncoderType::H264, Some(EncoderBackend::Rkmpp))
|
"Creating H264 encoder with RKMPP backend for {} direct input (codec: {})",
|
||||||
.ok_or_else(|| AppError::VideoError(
|
config.input_format, codec_name
|
||||||
"RKMPP backend not available for H.264".to_string()
|
);
|
||||||
))?;
|
|
||||||
info!("Creating H264 encoder with RKMPP backend for YUYV direct input (codec: {})", codec_name);
|
|
||||||
H264Encoder::with_codec(encoder_config, &codec_name)?
|
|
||||||
} else if let Some(ref backend) = config.encoder_backend {
|
} else if let Some(ref backend) = config.encoder_backend {
|
||||||
// Specific backend requested
|
info!(
|
||||||
let codec_name = get_codec_name(VideoEncoderType::H264, Some(*backend))
|
"Creating H264 encoder with backend {:?} (codec: {})",
|
||||||
.ok_or_else(|| AppError::VideoError(format!(
|
backend, codec_name
|
||||||
"Backend {:?} does not support H.264", backend
|
);
|
||||||
)))?;
|
}
|
||||||
info!("Creating H264 encoder with backend {:?} (codec: {})", backend, codec_name);
|
|
||||||
H264Encoder::with_codec(encoder_config, &codec_name)?
|
let encoder = H264Encoder::with_codec(encoder_config, &codec_name)?;
|
||||||
} else {
|
|
||||||
// Auto select
|
|
||||||
H264Encoder::new(encoder_config)?
|
|
||||||
};
|
|
||||||
|
|
||||||
info!("Created H264 encoder: {}", encoder.codec_name());
|
info!("Created H264 encoder: {}", encoder.codec_name());
|
||||||
Box::new(H264EncoderWrapper(encoder))
|
Box::new(H264EncoderWrapper(encoder))
|
||||||
}
|
}
|
||||||
VideoEncoderType::H265 => {
|
VideoEncoderType::H265 => {
|
||||||
// Determine H265 input format based on backend and input format
|
let codec_name = if use_rkmpp_direct {
|
||||||
let encoder_config = if use_yuyv_direct {
|
get_codec_name(VideoEncoderType::H265, Some(EncoderBackend::Rkmpp)).ok_or_else(
|
||||||
H265Config::low_latency_yuyv422(config.resolution, config.bitrate_kbps())
|
|| {
|
||||||
|
AppError::VideoError(
|
||||||
|
"RKMPP backend not available for H.265".to_string(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)?
|
||||||
|
} else if let Some(ref backend) = config.encoder_backend {
|
||||||
|
get_codec_name(VideoEncoderType::H265, Some(*backend)).ok_or_else(|| {
|
||||||
|
AppError::VideoError(format!(
|
||||||
|
"Backend {:?} does not support H.265",
|
||||||
|
backend
|
||||||
|
))
|
||||||
|
})?
|
||||||
} else {
|
} else {
|
||||||
H265Config::low_latency(config.resolution, config.bitrate_kbps())
|
let (_encoder_type, detected) =
|
||||||
|
detect_best_h265_encoder(config.resolution.width, config.resolution.height);
|
||||||
|
detected.ok_or_else(|| {
|
||||||
|
AppError::VideoError("No H.265 encoder available".to_string())
|
||||||
|
})?
|
||||||
};
|
};
|
||||||
|
|
||||||
let encoder = if use_yuyv_direct {
|
let is_rkmpp = codec_name.contains("rkmpp");
|
||||||
// Force RKMPP backend for YUYV direct input
|
let direct_input_format = if is_rkmpp {
|
||||||
let codec_name = get_codec_name(VideoEncoderType::H265, Some(EncoderBackend::Rkmpp))
|
match config.input_format {
|
||||||
.ok_or_else(|| AppError::VideoError(
|
PixelFormat::Yuyv => Some(H265InputFormat::Yuyv422),
|
||||||
"RKMPP backend not available for H.265".to_string()
|
PixelFormat::Yuv420 => Some(H265InputFormat::Yuv420p),
|
||||||
))?;
|
PixelFormat::Rgb24 => Some(H265InputFormat::Rgb24),
|
||||||
info!("Creating H265 encoder with RKMPP backend for YUYV direct input (codec: {})", codec_name);
|
PixelFormat::Bgr24 => Some(H265InputFormat::Bgr24),
|
||||||
H265Encoder::with_codec(encoder_config, &codec_name)?
|
PixelFormat::Nv12 => Some(H265InputFormat::Nv12),
|
||||||
} else if let Some(ref backend) = config.encoder_backend {
|
PixelFormat::Nv16 => Some(H265InputFormat::Nv16),
|
||||||
let codec_name = get_codec_name(VideoEncoderType::H265, Some(*backend))
|
PixelFormat::Nv21 => Some(H265InputFormat::Nv21),
|
||||||
.ok_or_else(|| AppError::VideoError(format!(
|
PixelFormat::Nv24 => Some(H265InputFormat::Nv24),
|
||||||
"Backend {:?} does not support H.265", backend
|
_ => None,
|
||||||
)))?;
|
}
|
||||||
info!("Creating H265 encoder with backend {:?} (codec: {})", backend, codec_name);
|
} else if codec_name.contains("libx265") {
|
||||||
H265Encoder::with_codec(encoder_config, &codec_name)?
|
match config.input_format {
|
||||||
|
PixelFormat::Yuv420 => Some(H265InputFormat::Yuv420p),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
H265Encoder::new(encoder_config)?
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let h265_input_format = if let Some(fmt) = direct_input_format {
|
||||||
|
fmt
|
||||||
|
} else if codec_name.contains("libx265") {
|
||||||
|
H265InputFormat::Yuv420p
|
||||||
|
} else {
|
||||||
|
H265InputFormat::Nv12
|
||||||
|
};
|
||||||
|
|
||||||
|
let encoder_config = H265Config {
|
||||||
|
base: EncoderConfig {
|
||||||
|
resolution: config.resolution,
|
||||||
|
input_format: config.input_format,
|
||||||
|
quality: config.bitrate_kbps(),
|
||||||
|
fps: config.fps,
|
||||||
|
gop_size: config.gop_size(),
|
||||||
|
},
|
||||||
|
bitrate_kbps: config.bitrate_kbps(),
|
||||||
|
gop_size: config.gop_size(),
|
||||||
|
fps: config.fps,
|
||||||
|
input_format: h265_input_format,
|
||||||
|
};
|
||||||
|
|
||||||
|
if use_rkmpp_direct {
|
||||||
|
info!(
|
||||||
|
"Creating H265 encoder with RKMPP backend for {} direct input (codec: {})",
|
||||||
|
config.input_format, codec_name
|
||||||
|
);
|
||||||
|
} else if let Some(ref backend) = config.encoder_backend {
|
||||||
|
info!(
|
||||||
|
"Creating H265 encoder with backend {:?} (codec: {})",
|
||||||
|
backend, codec_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let encoder = H265Encoder::with_codec(encoder_config, &codec_name)?;
|
||||||
|
|
||||||
info!("Created H265 encoder: {}", encoder.codec_name());
|
info!("Created H265 encoder: {}", encoder.codec_name());
|
||||||
Box::new(H265EncoderWrapper(encoder))
|
Box::new(H265EncoderWrapper(encoder))
|
||||||
}
|
}
|
||||||
VideoEncoderType::VP8 => {
|
VideoEncoderType::VP8 => {
|
||||||
let encoder_config = VP8Config::low_latency(config.resolution, config.bitrate_kbps());
|
let encoder_config =
|
||||||
|
VP8Config::low_latency(config.resolution, config.bitrate_kbps());
|
||||||
|
|
||||||
let encoder = if let Some(ref backend) = config.encoder_backend {
|
let encoder = if let Some(ref backend) = config.encoder_backend {
|
||||||
let codec_name = get_codec_name(VideoEncoderType::VP8, Some(*backend))
|
let codec_name = get_codec_name(VideoEncoderType::VP8, Some(*backend))
|
||||||
.ok_or_else(|| AppError::VideoError(format!(
|
.ok_or_else(|| {
|
||||||
"Backend {:?} does not support VP8", backend
|
AppError::VideoError(format!(
|
||||||
)))?;
|
"Backend {:?} does not support VP8",
|
||||||
info!("Creating VP8 encoder with backend {:?} (codec: {})", backend, codec_name);
|
backend
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
info!(
|
||||||
|
"Creating VP8 encoder with backend {:?} (codec: {})",
|
||||||
|
backend, codec_name
|
||||||
|
);
|
||||||
VP8Encoder::with_codec(encoder_config, &codec_name)?
|
VP8Encoder::with_codec(encoder_config, &codec_name)?
|
||||||
} else {
|
} else {
|
||||||
VP8Encoder::new(encoder_config)?
|
VP8Encoder::new(encoder_config)?
|
||||||
@@ -459,14 +594,21 @@ impl SharedVideoPipeline {
|
|||||||
Box::new(VP8EncoderWrapper(encoder))
|
Box::new(VP8EncoderWrapper(encoder))
|
||||||
}
|
}
|
||||||
VideoEncoderType::VP9 => {
|
VideoEncoderType::VP9 => {
|
||||||
let encoder_config = VP9Config::low_latency(config.resolution, config.bitrate_kbps());
|
let encoder_config =
|
||||||
|
VP9Config::low_latency(config.resolution, config.bitrate_kbps());
|
||||||
|
|
||||||
let encoder = if let Some(ref backend) = config.encoder_backend {
|
let encoder = if let Some(ref backend) = config.encoder_backend {
|
||||||
let codec_name = get_codec_name(VideoEncoderType::VP9, Some(*backend))
|
let codec_name = get_codec_name(VideoEncoderType::VP9, Some(*backend))
|
||||||
.ok_or_else(|| AppError::VideoError(format!(
|
.ok_or_else(|| {
|
||||||
"Backend {:?} does not support VP9", backend
|
AppError::VideoError(format!(
|
||||||
)))?;
|
"Backend {:?} does not support VP9",
|
||||||
info!("Creating VP9 encoder with backend {:?} (codec: {})", backend, codec_name);
|
backend
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
info!(
|
||||||
|
"Creating VP9 encoder with backend {:?} (codec: {})",
|
||||||
|
backend, codec_name
|
||||||
|
);
|
||||||
VP9Encoder::with_codec(encoder_config, &codec_name)?
|
VP9Encoder::with_codec(encoder_config, &codec_name)?
|
||||||
} else {
|
} else {
|
||||||
VP9Encoder::new(encoder_config)?
|
VP9Encoder::new(encoder_config)?
|
||||||
@@ -477,25 +619,71 @@ impl SharedVideoPipeline {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine if encoder needs YUV420P (software encoders) or NV12 (hardware encoders)
|
// Determine if encoder can take direct input without conversion
|
||||||
let codec_name = encoder.codec_name();
|
let codec_name = encoder.codec_name();
|
||||||
let needs_yuv420p = codec_name.contains("libvpx") || codec_name.contains("libx265");
|
let use_direct_input = if codec_name.contains("rkmpp") {
|
||||||
|
matches!(
|
||||||
|
config.input_format,
|
||||||
|
PixelFormat::Yuyv
|
||||||
|
| PixelFormat::Yuv420
|
||||||
|
| PixelFormat::Rgb24
|
||||||
|
| PixelFormat::Bgr24
|
||||||
|
| PixelFormat::Nv12
|
||||||
|
| PixelFormat::Nv16
|
||||||
|
| PixelFormat::Nv21
|
||||||
|
| PixelFormat::Nv24
|
||||||
|
)
|
||||||
|
} else if codec_name.contains("libx264") {
|
||||||
|
matches!(
|
||||||
|
config.input_format,
|
||||||
|
PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine if encoder needs YUV420P (software encoders) or NV12 (hardware encoders)
|
||||||
|
let needs_yuv420p = if codec_name.contains("libx264") {
|
||||||
|
!matches!(
|
||||||
|
config.input_format,
|
||||||
|
PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
codec_name.contains("libvpx") || codec_name.contains("libx265")
|
||||||
|
};
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Encoder {} needs {} format",
|
"Encoder {} needs {} format",
|
||||||
codec_name,
|
codec_name,
|
||||||
if use_yuyv_direct { "YUYV422 (direct)" } else if needs_yuv420p { "YUV420P" } else { "NV12" }
|
if use_direct_input {
|
||||||
|
"direct"
|
||||||
|
} else if needs_yuv420p {
|
||||||
|
"YUV420P"
|
||||||
|
} else {
|
||||||
|
"NV12"
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create converter or decoder based on input format and encoder needs
|
// Create converter or decoder based on input format and encoder needs
|
||||||
info!("Initializing input format handler for: {} -> {}",
|
info!(
|
||||||
|
"Initializing input format handler for: {} -> {}",
|
||||||
config.input_format,
|
config.input_format,
|
||||||
if use_yuyv_direct { "YUYV422 (direct)" } else if needs_yuv420p { "YUV420P" } else { "NV12" });
|
if use_direct_input {
|
||||||
|
"direct"
|
||||||
|
} else if needs_yuv420p {
|
||||||
|
"YUV420P"
|
||||||
|
} else {
|
||||||
|
"NV12"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
let (nv12_converter, yuv420p_converter) = if use_yuyv_direct {
|
let (nv12_converter, yuv420p_converter) = if use_yuyv_direct {
|
||||||
// RKMPP with YUYV direct input - skip all conversion
|
// RKMPP with YUYV direct input - skip all conversion
|
||||||
info!("YUYV direct input enabled for RKMPP, skipping format conversion");
|
info!("YUYV direct input enabled for RKMPP, skipping format conversion");
|
||||||
(None, None)
|
(None, None)
|
||||||
|
} else if use_direct_input {
|
||||||
|
info!("Direct input enabled, skipping format conversion");
|
||||||
|
(None, None)
|
||||||
} else if needs_yuv420p {
|
} else if needs_yuv420p {
|
||||||
// Software encoder needs YUV420P
|
// Software encoder needs YUV420P
|
||||||
match config.input_format {
|
match config.input_format {
|
||||||
@@ -505,19 +693,38 @@ impl SharedVideoPipeline {
|
|||||||
}
|
}
|
||||||
PixelFormat::Yuyv => {
|
PixelFormat::Yuyv => {
|
||||||
info!("Using YUYV->YUV420P converter");
|
info!("Using YUYV->YUV420P converter");
|
||||||
(None, Some(PixelConverter::yuyv_to_yuv420p(config.resolution)))
|
(
|
||||||
|
None,
|
||||||
|
Some(PixelConverter::yuyv_to_yuv420p(config.resolution)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
PixelFormat::Nv12 => {
|
PixelFormat::Nv12 => {
|
||||||
info!("Using NV12->YUV420P converter");
|
info!("Using NV12->YUV420P converter");
|
||||||
(None, Some(PixelConverter::nv12_to_yuv420p(config.resolution)))
|
(
|
||||||
|
None,
|
||||||
|
Some(PixelConverter::nv12_to_yuv420p(config.resolution)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PixelFormat::Nv21 => {
|
||||||
|
info!("Using NV21->YUV420P converter");
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
Some(PixelConverter::nv21_to_yuv420p(config.resolution)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
PixelFormat::Rgb24 => {
|
PixelFormat::Rgb24 => {
|
||||||
info!("Using RGB24->YUV420P converter");
|
info!("Using RGB24->YUV420P converter");
|
||||||
(None, Some(PixelConverter::rgb24_to_yuv420p(config.resolution)))
|
(
|
||||||
|
None,
|
||||||
|
Some(PixelConverter::rgb24_to_yuv420p(config.resolution)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
PixelFormat::Bgr24 => {
|
PixelFormat::Bgr24 => {
|
||||||
info!("Using BGR24->YUV420P converter");
|
info!("Using BGR24->YUV420P converter");
|
||||||
(None, Some(PixelConverter::bgr24_to_yuv420p(config.resolution)))
|
(
|
||||||
|
None,
|
||||||
|
Some(PixelConverter::bgr24_to_yuv420p(config.resolution)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::VideoError(format!(
|
return Err(AppError::VideoError(format!(
|
||||||
@@ -537,6 +744,18 @@ impl SharedVideoPipeline {
|
|||||||
info!("Using YUYV->NV12 converter");
|
info!("Using YUYV->NV12 converter");
|
||||||
(Some(Nv12Converter::yuyv_to_nv12(config.resolution)), None)
|
(Some(Nv12Converter::yuyv_to_nv12(config.resolution)), None)
|
||||||
}
|
}
|
||||||
|
PixelFormat::Nv21 => {
|
||||||
|
info!("Using NV21->NV12 converter");
|
||||||
|
(Some(Nv12Converter::nv21_to_nv12(config.resolution)), None)
|
||||||
|
}
|
||||||
|
PixelFormat::Nv16 => {
|
||||||
|
info!("Using NV16->NV12 converter");
|
||||||
|
(Some(Nv12Converter::nv16_to_nv12(config.resolution)), None)
|
||||||
|
}
|
||||||
|
PixelFormat::Yuv420 => {
|
||||||
|
info!("Using YUV420P->NV12 converter");
|
||||||
|
(Some(Nv12Converter::yuv420_to_nv12(config.resolution)), None)
|
||||||
|
}
|
||||||
PixelFormat::Rgb24 => {
|
PixelFormat::Rgb24 => {
|
||||||
info!("Using RGB24->NV12 converter");
|
info!("Using RGB24->NV12 converter");
|
||||||
(Some(Nv12Converter::rgb24_to_nv12(config.resolution)), None)
|
(Some(Nv12Converter::rgb24_to_nv12(config.resolution)), None)
|
||||||
@@ -557,8 +776,9 @@ impl SharedVideoPipeline {
|
|||||||
*self.encoder.lock().await = Some(encoder);
|
*self.encoder.lock().await = Some(encoder);
|
||||||
*self.nv12_converter.lock().await = nv12_converter;
|
*self.nv12_converter.lock().await = nv12_converter;
|
||||||
*self.yuv420p_converter.lock().await = yuv420p_converter;
|
*self.yuv420p_converter.lock().await = yuv420p_converter;
|
||||||
self.encoder_needs_yuv420p.store(needs_yuv420p, Ordering::Release);
|
self.encoder_needs_yuv420p
|
||||||
self.yuyv_direct_input.store(use_yuyv_direct, Ordering::Release);
|
.store(needs_yuv420p, Ordering::Release);
|
||||||
|
self.direct_input.store(use_direct_input, Ordering::Release);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -646,7 +866,10 @@ impl SharedVideoPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start the pipeline
|
/// Start the pipeline
|
||||||
pub async fn start(self: &Arc<Self>, mut frame_rx: broadcast::Receiver<VideoFrame>) -> Result<()> {
|
pub async fn start(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
mut frame_rx: broadcast::Receiver<VideoFrame>,
|
||||||
|
) -> Result<()> {
|
||||||
if *self.running_rx.borrow() {
|
if *self.running_rx.borrow() {
|
||||||
warn!("Pipeline already running");
|
warn!("Pipeline already running");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -657,7 +880,10 @@ impl SharedVideoPipeline {
|
|||||||
|
|
||||||
let config = self.config.read().await.clone();
|
let config = self.config.read().await.clone();
|
||||||
let gop_size = config.gop_size();
|
let gop_size = config.gop_size();
|
||||||
info!("Starting {} pipeline (GOP={})", config.output_codec, gop_size);
|
info!(
|
||||||
|
"Starting {} pipeline (GOP={})",
|
||||||
|
config.output_codec, gop_size
|
||||||
|
);
|
||||||
|
|
||||||
let pipeline = self.clone();
|
let pipeline = self.clone();
|
||||||
|
|
||||||
@@ -674,7 +900,6 @@ impl SharedVideoPipeline {
|
|||||||
let mut local_errors: u64 = 0;
|
let mut local_errors: u64 = 0;
|
||||||
let mut local_dropped: u64 = 0;
|
let mut local_dropped: u64 = 0;
|
||||||
let mut local_skipped: u64 = 0;
|
let mut local_skipped: u64 = 0;
|
||||||
|
|
||||||
// Track when we last had subscribers for auto-stop feature
|
// Track when we last had subscribers for auto-stop feature
|
||||||
let mut no_subscribers_since: Option<Instant> = None;
|
let mut no_subscribers_since: Option<Instant> = None;
|
||||||
let grace_period = Duration::from_secs(AUTO_STOP_GRACE_PERIOD_SECS);
|
let grace_period = Duration::from_secs(AUTO_STOP_GRACE_PERIOD_SECS);
|
||||||
@@ -790,7 +1015,11 @@ impl SharedVideoPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Encode a single frame
|
/// Encode a single frame
|
||||||
async fn encode_frame(&self, frame: &VideoFrame, frame_count: u64) -> Result<Option<EncodedVideoFrame>> {
|
async fn encode_frame(
|
||||||
|
&self,
|
||||||
|
frame: &VideoFrame,
|
||||||
|
frame_count: u64,
|
||||||
|
) -> Result<Option<EncodedVideoFrame>> {
|
||||||
let config = self.config.read().await;
|
let config = self.config.read().await;
|
||||||
let raw_frame = frame.data();
|
let raw_frame = frame.data();
|
||||||
let fps = config.fps;
|
let fps = config.fps;
|
||||||
@@ -835,9 +1064,9 @@ impl SharedVideoPipeline {
|
|||||||
let needs_yuv420p = self.encoder_needs_yuv420p.load(Ordering::Acquire);
|
let needs_yuv420p = self.encoder_needs_yuv420p.load(Ordering::Acquire);
|
||||||
let mut encoder_guard = self.encoder.lock().await;
|
let mut encoder_guard = self.encoder.lock().await;
|
||||||
|
|
||||||
let encoder = encoder_guard.as_mut().ok_or_else(|| {
|
let encoder = encoder_guard
|
||||||
AppError::VideoError("Encoder not initialized".to_string())
|
.as_mut()
|
||||||
})?;
|
.ok_or_else(|| AppError::VideoError("Encoder not initialized".to_string()))?;
|
||||||
|
|
||||||
// Check and consume keyframe request (atomic, no lock contention)
|
// Check and consume keyframe request (atomic, no lock contention)
|
||||||
if self.keyframe_requested.swap(false, Ordering::AcqRel) {
|
if self.keyframe_requested.swap(false, Ordering::AcqRel) {
|
||||||
@@ -848,13 +1077,15 @@ impl SharedVideoPipeline {
|
|||||||
let encode_result = if needs_yuv420p && yuv420p_converter.is_some() {
|
let encode_result = if needs_yuv420p && yuv420p_converter.is_some() {
|
||||||
// Software encoder with direct input conversion to YUV420P
|
// Software encoder with direct input conversion to YUV420P
|
||||||
let conv = yuv420p_converter.as_mut().unwrap();
|
let conv = yuv420p_converter.as_mut().unwrap();
|
||||||
let yuv420p_data = conv.convert(raw_frame)
|
let yuv420p_data = conv
|
||||||
|
.convert(raw_frame)
|
||||||
.map_err(|e| AppError::VideoError(format!("YUV420P conversion failed: {}", e)))?;
|
.map_err(|e| AppError::VideoError(format!("YUV420P conversion failed: {}", e)))?;
|
||||||
encoder.encode_raw(yuv420p_data, pts_ms)
|
encoder.encode_raw(yuv420p_data, pts_ms)
|
||||||
} else if nv12_converter.is_some() {
|
} else if nv12_converter.is_some() {
|
||||||
// Hardware encoder with input conversion to NV12
|
// Hardware encoder with input conversion to NV12
|
||||||
let conv = nv12_converter.as_mut().unwrap();
|
let conv = nv12_converter.as_mut().unwrap();
|
||||||
let nv12_data = conv.convert(raw_frame)
|
let nv12_data = conv
|
||||||
|
.convert(raw_frame)
|
||||||
.map_err(|e| AppError::VideoError(format!("NV12 conversion failed: {}", e)))?;
|
.map_err(|e| AppError::VideoError(format!("NV12 conversion failed: {}", e)))?;
|
||||||
encoder.encode_raw(nv12_data, pts_ms)
|
encoder.encode_raw(nv12_data, pts_ms)
|
||||||
} else {
|
} else {
|
||||||
@@ -871,7 +1102,6 @@ impl SharedVideoPipeline {
|
|||||||
if !frames.is_empty() {
|
if !frames.is_empty() {
|
||||||
let encoded = frames.into_iter().next().unwrap();
|
let encoded = frames.into_iter().next().unwrap();
|
||||||
let is_keyframe = encoded.key == 1;
|
let is_keyframe = encoded.key == 1;
|
||||||
|
|
||||||
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1;
|
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
|
||||||
// Debug log for H265 encoded frame
|
// Debug log for H265 encoded frame
|
||||||
@@ -901,17 +1131,23 @@ impl SharedVideoPipeline {
|
|||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
if codec == VideoEncoderType::H265 {
|
if codec == VideoEncoderType::H265 {
|
||||||
warn!("[Pipeline-H265] Encoder returned no frames for frame #{}", frame_count);
|
warn!(
|
||||||
|
"[Pipeline-H265] Encoder returned no frames for frame #{}",
|
||||||
|
frame_count
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if codec == VideoEncoderType::H265 {
|
if codec == VideoEncoderType::H265 {
|
||||||
error!("[Pipeline-H265] Encode error at frame #{}: {}", frame_count, e);
|
error!(
|
||||||
|
"[Pipeline-H265] Encode error at frame #{}: {}",
|
||||||
|
frame_count, e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(e)
|
Err(e)
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -924,7 +1160,10 @@ impl SharedVideoPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set bitrate using preset
|
/// Set bitrate using preset
|
||||||
pub async fn set_bitrate_preset(&self, preset: crate::video::encoder::BitratePreset) -> Result<()> {
|
pub async fn set_bitrate_preset(
|
||||||
|
&self,
|
||||||
|
preset: crate::video::encoder::BitratePreset,
|
||||||
|
) -> Result<()> {
|
||||||
let bitrate_kbps = preset.bitrate_kbps();
|
let bitrate_kbps = preset.bitrate_kbps();
|
||||||
if let Some(ref mut encoder) = *self.encoder.lock().await {
|
if let Some(ref mut encoder) = *self.encoder.lock().await {
|
||||||
encoder.set_bitrate(bitrate_kbps)?;
|
encoder.set_bitrate(bitrate_kbps)?;
|
||||||
@@ -965,11 +1204,7 @@ fn parse_h265_nal_types(data: &[u8]) -> Vec<(u8, usize)> {
|
|||||||
&& data[i + 3] == 1
|
&& data[i + 3] == 1
|
||||||
{
|
{
|
||||||
i + 4
|
i + 4
|
||||||
} else if i + 3 <= data.len()
|
} else if i + 3 <= data.len() && data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
|
||||||
&& data[i] == 0
|
|
||||||
&& data[i + 1] == 0
|
|
||||||
&& data[i + 2] == 1
|
|
||||||
{
|
|
||||||
i + 3
|
i + 3
|
||||||
} else {
|
} else {
|
||||||
i += 1;
|
i += 1;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::config::{ConfigStore, StreamMode};
|
use crate::config::{ConfigStore, StreamMode};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
@@ -55,6 +56,17 @@ pub struct StreamManagerConfig {
|
|||||||
pub fps: u32,
|
pub fps: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Result of a mode switch request.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ModeSwitchTransaction {
|
||||||
|
/// Whether this request started a new switch.
|
||||||
|
pub accepted: bool,
|
||||||
|
/// Whether a switch is currently in progress after handling this request.
|
||||||
|
pub switching: bool,
|
||||||
|
/// Transition ID if a switch is/was in progress.
|
||||||
|
pub transition_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for StreamManagerConfig {
|
impl Default for StreamManagerConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -90,6 +102,8 @@ pub struct VideoStreamManager {
|
|||||||
config_store: RwLock<Option<ConfigStore>>,
|
config_store: RwLock<Option<ConfigStore>>,
|
||||||
/// Mode switching lock to prevent concurrent switch requests
|
/// Mode switching lock to prevent concurrent switch requests
|
||||||
switching: AtomicBool,
|
switching: AtomicBool,
|
||||||
|
/// Current mode switch transaction ID (set while switching=true)
|
||||||
|
transition_id: RwLock<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VideoStreamManager {
|
impl VideoStreamManager {
|
||||||
@@ -105,6 +119,7 @@ impl VideoStreamManager {
|
|||||||
events: RwLock::new(None),
|
events: RwLock::new(None),
|
||||||
config_store: RwLock::new(None),
|
config_store: RwLock::new(None),
|
||||||
switching: AtomicBool::new(false),
|
switching: AtomicBool::new(false),
|
||||||
|
transition_id: RwLock::new(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +128,11 @@ impl VideoStreamManager {
|
|||||||
self.switching.load(Ordering::SeqCst)
|
self.switching.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get current mode switch transition ID, if any
|
||||||
|
pub async fn current_transition_id(&self) -> Option<String> {
|
||||||
|
self.transition_id.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Set event bus for notifications
|
/// Set event bus for notifications
|
||||||
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
|
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
|
||||||
*self.events.write().await = Some(events);
|
*self.events.write().await = Some(events);
|
||||||
@@ -188,7 +208,9 @@ impl VideoStreamManager {
|
|||||||
"Reconnecting frame source to WebRTC after init: {}x{} {:?} @ {}fps (receiver_count={})",
|
"Reconnecting frame source to WebRTC after init: {}x{} {:?} @ {}fps (receiver_count={})",
|
||||||
resolution.width, resolution.height, format, fps, frame_tx.receiver_count()
|
resolution.width, resolution.height, format, fps, frame_tx.receiver_count()
|
||||||
);
|
);
|
||||||
self.webrtc_streamer.update_video_config(resolution, format, fps).await;
|
self.webrtc_streamer
|
||||||
|
.update_video_config(resolution, format, fps)
|
||||||
|
.await;
|
||||||
self.webrtc_streamer.set_video_source(frame_tx).await;
|
self.webrtc_streamer.set_video_source(frame_tx).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +226,18 @@ impl VideoStreamManager {
|
|||||||
/// 4. Start the new mode (ensuring video capture runs for WebRTC)
|
/// 4. Start the new mode (ensuring video capture runs for WebRTC)
|
||||||
/// 5. Update configuration
|
/// 5. Update configuration
|
||||||
pub async fn switch_mode(self: &Arc<Self>, new_mode: StreamMode) -> Result<()> {
|
pub async fn switch_mode(self: &Arc<Self>, new_mode: StreamMode) -> Result<()> {
|
||||||
|
let _ = self.switch_mode_transaction(new_mode).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch streaming mode with a transaction ID for correlating events
|
||||||
|
///
|
||||||
|
/// If a switch is already in progress, returns `accepted=false` with the
|
||||||
|
/// current `transition_id` (if known) and does not start a new switch.
|
||||||
|
pub async fn switch_mode_transaction(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
new_mode: StreamMode,
|
||||||
|
) -> Result<ModeSwitchTransaction> {
|
||||||
let current_mode = self.mode.read().await.clone();
|
let current_mode = self.mode.read().await.clone();
|
||||||
|
|
||||||
if current_mode == new_mode {
|
if current_mode == new_mode {
|
||||||
@@ -212,19 +246,85 @@ impl VideoStreamManager {
|
|||||||
if new_mode == StreamMode::WebRTC {
|
if new_mode == StreamMode::WebRTC {
|
||||||
self.ensure_video_capture_running().await?;
|
self.ensure_video_capture_running().await?;
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(ModeSwitchTransaction {
|
||||||
|
accepted: false,
|
||||||
|
switching: false,
|
||||||
|
transition_id: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Acquire switching lock - prevent concurrent switch requests
|
// Acquire switching lock - prevent concurrent switch requests
|
||||||
if self.switching.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
|
if self
|
||||||
|
.switching
|
||||||
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
debug!("Mode switch already in progress, ignoring duplicate request");
|
debug!("Mode switch already in progress, ignoring duplicate request");
|
||||||
return Ok(());
|
return Ok(ModeSwitchTransaction {
|
||||||
|
accepted: false,
|
||||||
|
switching: true,
|
||||||
|
transition_id: self.transition_id.read().await.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use a helper to ensure we release the lock when done
|
let transition_id = Uuid::new_v4().to_string();
|
||||||
let result = self.do_switch_mode(current_mode, new_mode.clone()).await;
|
*self.transition_id.write().await = Some(transition_id.clone());
|
||||||
self.switching.store(false, Ordering::SeqCst);
|
|
||||||
result
|
// Publish transaction start event
|
||||||
|
let from_mode_str = self.mode_to_string(¤t_mode).await;
|
||||||
|
let to_mode_str = self.mode_to_string(&new_mode).await;
|
||||||
|
self.publish_event(SystemEvent::StreamModeSwitching {
|
||||||
|
transition_id: transition_id.clone(),
|
||||||
|
to_mode: to_mode_str,
|
||||||
|
from_mode: from_mode_str,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Perform the switch asynchronously so the HTTP handler can return
|
||||||
|
// immediately and clients can reliably wait for WebSocket events.
|
||||||
|
let manager = Arc::clone(self);
|
||||||
|
let transition_id_for_task = transition_id.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = manager
|
||||||
|
.do_switch_mode(current_mode, new_mode, transition_id_for_task.clone())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
error!(
|
||||||
|
"Mode switch transaction {} failed: {}",
|
||||||
|
transition_id_for_task, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish transaction end marker with best-effort actual mode
|
||||||
|
let actual_mode = manager.mode.read().await.clone();
|
||||||
|
let actual_mode_str = manager.mode_to_string(&actual_mode).await;
|
||||||
|
manager
|
||||||
|
.publish_event(SystemEvent::StreamModeReady {
|
||||||
|
transition_id: transition_id_for_task.clone(),
|
||||||
|
mode: actual_mode_str,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
*manager.transition_id.write().await = None;
|
||||||
|
manager.switching.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(ModeSwitchTransaction {
|
||||||
|
accepted: true,
|
||||||
|
switching: true,
|
||||||
|
transition_id: Some(transition_id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mode_to_string(&self, mode: &StreamMode) -> String {
|
||||||
|
match mode {
|
||||||
|
StreamMode::Mjpeg => "mjpeg".to_string(),
|
||||||
|
StreamMode::WebRTC => {
|
||||||
|
let codec = self.webrtc_streamer.current_video_codec().await;
|
||||||
|
codec_to_string(codec)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure video capture is running (for WebRTC mode)
|
/// Ensure video capture is running (for WebRTC mode)
|
||||||
@@ -257,7 +357,9 @@ impl VideoStreamManager {
|
|||||||
"Reconnecting frame source to WebRTC: {}x{} {:?} @ {}fps",
|
"Reconnecting frame source to WebRTC: {}x{} {:?} @ {}fps",
|
||||||
resolution.width, resolution.height, format, fps
|
resolution.width, resolution.height, format, fps
|
||||||
);
|
);
|
||||||
self.webrtc_streamer.update_video_config(resolution, format, fps).await;
|
self.webrtc_streamer
|
||||||
|
.update_video_config(resolution, format, fps)
|
||||||
|
.await;
|
||||||
self.webrtc_streamer.set_video_source(frame_tx).await;
|
self.webrtc_streamer.set_video_source(frame_tx).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +367,12 @@ impl VideoStreamManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Internal implementation of mode switching (called with lock held)
|
/// Internal implementation of mode switching (called with lock held)
|
||||||
async fn do_switch_mode(self: &Arc<Self>, current_mode: StreamMode, new_mode: StreamMode) -> Result<()> {
|
async fn do_switch_mode(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
current_mode: StreamMode,
|
||||||
|
new_mode: StreamMode,
|
||||||
|
transition_id: String,
|
||||||
|
) -> Result<()> {
|
||||||
info!("Switching video mode: {:?} -> {:?}", current_mode, new_mode);
|
info!("Switching video mode: {:?} -> {:?}", current_mode, new_mode);
|
||||||
|
|
||||||
// Get the actual mode strings (with codec info for WebRTC)
|
// Get the actual mode strings (with codec info for WebRTC)
|
||||||
@@ -286,6 +393,7 @@ impl VideoStreamManager {
|
|||||||
|
|
||||||
// 1. Publish mode change event (clients should prepare to reconnect)
|
// 1. Publish mode change event (clients should prepare to reconnect)
|
||||||
self.publish_event(SystemEvent::StreamModeChanged {
|
self.publish_event(SystemEvent::StreamModeChanged {
|
||||||
|
transition_id: Some(transition_id.clone()),
|
||||||
mode: new_mode_str,
|
mode: new_mode_str,
|
||||||
previous_mode: previous_mode_str,
|
previous_mode: previous_mode_str,
|
||||||
})
|
})
|
||||||
@@ -320,15 +428,26 @@ impl VideoStreamManager {
|
|||||||
|
|
||||||
// Auto-switch to MJPEG format if device supports it
|
// Auto-switch to MJPEG format if device supports it
|
||||||
if let Some(device) = self.streamer.current_device().await {
|
if let Some(device) = self.streamer.current_device().await {
|
||||||
let (current_format, resolution, fps) = self.streamer.current_video_config().await;
|
let (current_format, resolution, fps) =
|
||||||
let available_formats: Vec<PixelFormat> = device.formats.iter().map(|f| f.format).collect();
|
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 current format is not MJPEG and device supports MJPEG, switch to it
|
||||||
if current_format != PixelFormat::Mjpeg && available_formats.contains(&PixelFormat::Mjpeg) {
|
if current_format != PixelFormat::Mjpeg
|
||||||
|
&& available_formats.contains(&PixelFormat::Mjpeg)
|
||||||
|
{
|
||||||
info!("Auto-switching to MJPEG format for MJPEG mode");
|
info!("Auto-switching to MJPEG format for MJPEG mode");
|
||||||
let device_path = device.path.to_string_lossy().to_string();
|
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 {
|
if let Err(e) = self
|
||||||
warn!("Failed to auto-switch to MJPEG format: {}, keeping current format", e);
|
.streamer
|
||||||
|
.apply_video_config(&device_path, PixelFormat::Mjpeg, resolution, fps)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
"Failed to auto-switch to MJPEG format: {}, keeping current format",
|
||||||
|
e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,21 +472,29 @@ impl VideoStreamManager {
|
|||||||
|
|
||||||
// Auto-switch to non-compressed format if current format is MJPEG/JPEG
|
// Auto-switch to non-compressed format if current format is MJPEG/JPEG
|
||||||
if let Some(device) = self.streamer.current_device().await {
|
if let Some(device) = self.streamer.current_device().await {
|
||||||
let (current_format, resolution, fps) = self.streamer.current_video_config().await;
|
let (current_format, resolution, fps) =
|
||||||
|
self.streamer.current_video_config().await;
|
||||||
|
|
||||||
if current_format.is_compressed() {
|
if current_format.is_compressed() {
|
||||||
let available_formats: Vec<PixelFormat> = device.formats.iter().map(|f| f.format).collect();
|
let available_formats: Vec<PixelFormat> =
|
||||||
|
device.formats.iter().map(|f| f.format).collect();
|
||||||
|
|
||||||
// Determine if using hardware encoding
|
// Determine if using hardware encoding
|
||||||
let is_hardware = self.webrtc_streamer.is_hardware_encoding().await;
|
let is_hardware = self.webrtc_streamer.is_hardware_encoding().await;
|
||||||
|
|
||||||
if let Some(recommended) = PixelFormat::recommended_for_encoding(&available_formats, is_hardware) {
|
if let Some(recommended) =
|
||||||
|
PixelFormat::recommended_for_encoding(&available_formats, is_hardware)
|
||||||
|
{
|
||||||
info!(
|
info!(
|
||||||
"Auto-switching from {:?} to {:?} for WebRTC encoding (hardware={})",
|
"Auto-switching from {:?} to {:?} for WebRTC encoding (hardware={})",
|
||||||
current_format, recommended, is_hardware
|
current_format, recommended, is_hardware
|
||||||
);
|
);
|
||||||
let device_path = device.path.to_string_lossy().to_string();
|
let device_path = device.path.to_string_lossy().to_string();
|
||||||
if let Err(e) = self.streamer.apply_video_config(&device_path, recommended, resolution, fps).await {
|
if let Err(e) = self
|
||||||
|
.streamer
|
||||||
|
.apply_video_config(&device_path, recommended, resolution, fps)
|
||||||
|
.await
|
||||||
|
{
|
||||||
warn!("Failed to auto-switch format for WebRTC: {}, keeping current format", e);
|
warn!("Failed to auto-switch format for WebRTC: {}, keeping current format", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,33 +521,24 @@ impl VideoStreamManager {
|
|||||||
"Connecting frame source to WebRTC pipeline: {}x{} {:?} @ {}fps",
|
"Connecting frame source to WebRTC pipeline: {}x{} {:?} @ {}fps",
|
||||||
resolution.width, resolution.height, format, fps
|
resolution.width, resolution.height, format, fps
|
||||||
);
|
);
|
||||||
self.webrtc_streamer.update_video_config(resolution, format, fps).await;
|
self.webrtc_streamer
|
||||||
self.webrtc_streamer.set_video_source(frame_tx).await;
|
.update_video_config(resolution, format, fps)
|
||||||
|
|
||||||
// Get device path for events
|
|
||||||
let device_path = self.streamer.current_device().await
|
|
||||||
.map(|d| d.path.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
// Publish StreamConfigApplied event - clients can now safely connect
|
|
||||||
self.publish_event(SystemEvent::StreamConfigApplied {
|
|
||||||
device: device_path,
|
|
||||||
resolution: (resolution.width, resolution.height),
|
|
||||||
format: format!("{:?}", format).to_lowercase(),
|
|
||||||
fps,
|
|
||||||
})
|
|
||||||
.await;
|
.await;
|
||||||
|
self.webrtc_streamer.set_video_source(frame_tx).await;
|
||||||
|
|
||||||
// Publish WebRTCReady event - frame source is now connected
|
// Publish WebRTCReady event - frame source is now connected
|
||||||
let codec = self.webrtc_streamer.current_video_codec().await;
|
let codec = self.webrtc_streamer.current_video_codec().await;
|
||||||
let is_hardware = self.webrtc_streamer.is_hardware_encoding().await;
|
let is_hardware = self.webrtc_streamer.is_hardware_encoding().await;
|
||||||
self.publish_event(SystemEvent::WebRTCReady {
|
self.publish_event(SystemEvent::WebRTCReady {
|
||||||
|
transition_id: Some(transition_id.clone()),
|
||||||
codec: codec_to_string(codec),
|
codec: codec_to_string(codec),
|
||||||
hardware: is_hardware,
|
hardware: is_hardware,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
warn!("No frame source available for WebRTC - sessions may fail to receive video");
|
warn!(
|
||||||
|
"No frame source available for WebRTC - sessions may fail to receive video"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("WebRTC mode activated (sessions created on-demand)");
|
info!("WebRTC mode activated (sessions created on-demand)");
|
||||||
@@ -483,13 +601,16 @@ impl VideoStreamManager {
|
|||||||
if let Some(frame_tx) = self.streamer.frame_sender().await {
|
if let Some(frame_tx) = self.streamer.frame_sender().await {
|
||||||
// Note: update_video_config was already called above with the requested config,
|
// Note: update_video_config was already called above with the requested config,
|
||||||
// but verify that actual capture matches
|
// but verify that actual capture matches
|
||||||
let (actual_format, actual_resolution, actual_fps) = self.streamer.current_video_config().await;
|
let (actual_format, actual_resolution, actual_fps) =
|
||||||
|
self.streamer.current_video_config().await;
|
||||||
if actual_format != format || actual_resolution != resolution || actual_fps != fps {
|
if actual_format != format || actual_resolution != resolution || actual_fps != fps {
|
||||||
info!(
|
info!(
|
||||||
"Actual capture config differs from requested, updating WebRTC: {}x{} {:?} @ {}fps",
|
"Actual capture config differs from requested, updating WebRTC: {}x{} {:?} @ {}fps",
|
||||||
actual_resolution.width, actual_resolution.height, actual_format, actual_fps
|
actual_resolution.width, actual_resolution.height, actual_format, actual_fps
|
||||||
);
|
);
|
||||||
self.webrtc_streamer.update_video_config(actual_resolution, actual_format, actual_fps).await;
|
self.webrtc_streamer
|
||||||
|
.update_video_config(actual_resolution, actual_format, actual_fps)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
info!("Reconnecting frame source to WebRTC after config change");
|
info!("Reconnecting frame source to WebRTC after config change");
|
||||||
self.webrtc_streamer.set_video_source(frame_tx).await;
|
self.webrtc_streamer.set_video_source(frame_tx).await;
|
||||||
@@ -522,7 +643,9 @@ impl VideoStreamManager {
|
|||||||
if let Some(frame_tx) = self.streamer.frame_sender().await {
|
if let Some(frame_tx) = self.streamer.frame_sender().await {
|
||||||
// Synchronize WebRTC config with actual capture format
|
// Synchronize WebRTC config with actual capture format
|
||||||
let (format, resolution, fps) = self.streamer.current_video_config().await;
|
let (format, resolution, fps) = self.streamer.current_video_config().await;
|
||||||
self.webrtc_streamer.update_video_config(resolution, format, fps).await;
|
self.webrtc_streamer
|
||||||
|
.update_video_config(resolution, format, fps)
|
||||||
|
.await;
|
||||||
self.webrtc_streamer.set_video_source(frame_tx).await;
|
self.webrtc_streamer.set_video_source(frame_tx).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -620,7 +743,9 @@ impl VideoStreamManager {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/// List available video devices
|
/// List available video devices
|
||||||
pub async fn list_devices(&self) -> crate::error::Result<Vec<crate::video::device::VideoDeviceInfo>> {
|
pub async fn list_devices(
|
||||||
|
&self,
|
||||||
|
) -> crate::error::Result<Vec<crate::video::device::VideoDeviceInfo>> {
|
||||||
self.streamer.list_devices().await
|
self.streamer.list_devices().await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +765,9 @@ impl VideoStreamManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get frame sender for video frames
|
/// Get frame sender for video frames
|
||||||
pub async fn frame_sender(&self) -> Option<tokio::sync::broadcast::Sender<crate::video::frame::VideoFrame>> {
|
pub async fn frame_sender(
|
||||||
|
&self,
|
||||||
|
) -> Option<tokio::sync::broadcast::Sender<crate::video::frame::VideoFrame>> {
|
||||||
self.streamer.frame_sender().await
|
self.streamer.frame_sender().await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,12 +781,17 @@ impl VideoStreamManager {
|
|||||||
/// Returns None if video capture cannot be started or pipeline creation fails.
|
/// Returns None if video capture cannot be started or pipeline creation fails.
|
||||||
pub async fn subscribe_encoded_frames(
|
pub async fn subscribe_encoded_frames(
|
||||||
&self,
|
&self,
|
||||||
) -> Option<tokio::sync::broadcast::Receiver<crate::video::shared_video_pipeline::EncodedVideoFrame>> {
|
) -> Option<
|
||||||
|
tokio::sync::broadcast::Receiver<crate::video::shared_video_pipeline::EncodedVideoFrame>,
|
||||||
|
> {
|
||||||
// 1. Ensure video capture is initialized
|
// 1. Ensure video capture is initialized
|
||||||
if self.streamer.state().await == StreamerState::Uninitialized {
|
if self.streamer.state().await == StreamerState::Uninitialized {
|
||||||
tracing::info!("Initializing video capture for encoded frame subscription");
|
tracing::info!("Initializing video capture for encoded frame subscription");
|
||||||
if let Err(e) = self.streamer.init_auto().await {
|
if let Err(e) = self.streamer.init_auto().await {
|
||||||
tracing::error!("Failed to initialize video capture for encoded frames: {}", e);
|
tracing::error!(
|
||||||
|
"Failed to initialize video capture for encoded frames: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -688,13 +820,22 @@ impl VideoStreamManager {
|
|||||||
let (format, resolution, fps) = self.streamer.current_video_config().await;
|
let (format, resolution, fps) = self.streamer.current_video_config().await;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Connecting encoded frame subscription: {}x{} {:?} @ {}fps",
|
"Connecting encoded frame subscription: {}x{} {:?} @ {}fps",
|
||||||
resolution.width, resolution.height, format, fps
|
resolution.width,
|
||||||
|
resolution.height,
|
||||||
|
format,
|
||||||
|
fps
|
||||||
);
|
);
|
||||||
self.webrtc_streamer.update_video_config(resolution, format, fps).await;
|
self.webrtc_streamer
|
||||||
|
.update_video_config(resolution, format, fps)
|
||||||
|
.await;
|
||||||
|
|
||||||
// 5. Use WebRtcStreamer to ensure the shared video pipeline is running
|
// 5. Use WebRtcStreamer to ensure the shared video pipeline is running
|
||||||
// This will create the pipeline if needed
|
// This will create the pipeline if needed
|
||||||
match self.webrtc_streamer.ensure_video_pipeline_for_external(frame_tx).await {
|
match self
|
||||||
|
.webrtc_streamer
|
||||||
|
.ensure_video_pipeline_for_external(frame_tx)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(pipeline) => Some(pipeline.subscribe()),
|
Ok(pipeline) => Some(pipeline.subscribe()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to start shared video pipeline: {}", e);
|
tracing::error!("Failed to start shared video pipeline: {}", e);
|
||||||
@@ -704,7 +845,9 @@ impl VideoStreamManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current video encoding configuration from the shared pipeline
|
/// Get the current video encoding configuration from the shared pipeline
|
||||||
pub async fn get_encoding_config(&self) -> Option<crate::video::shared_video_pipeline::SharedVideoPipelineConfig> {
|
pub async fn get_encoding_config(
|
||||||
|
&self,
|
||||||
|
) -> Option<crate::video::shared_video_pipeline::SharedVideoPipelineConfig> {
|
||||||
self.webrtc_streamer.get_pipeline_config().await
|
self.webrtc_streamer.get_pipeline_config().await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,7 +855,10 @@ impl VideoStreamManager {
|
|||||||
///
|
///
|
||||||
/// This allows external consumers (like RustDesk) to set the video codec
|
/// This allows external consumers (like RustDesk) to set the video codec
|
||||||
/// before subscribing to encoded frames.
|
/// before subscribing to encoded frames.
|
||||||
pub async fn set_video_codec(&self, codec: crate::video::encoder::VideoCodecType) -> crate::error::Result<()> {
|
pub async fn set_video_codec(
|
||||||
|
&self,
|
||||||
|
codec: crate::video::encoder::VideoCodecType,
|
||||||
|
) -> crate::error::Result<()> {
|
||||||
self.webrtc_streamer.set_video_codec(codec).await
|
self.webrtc_streamer.set_video_codec(codec).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,7 +866,10 @@ impl VideoStreamManager {
|
|||||||
///
|
///
|
||||||
/// This allows external consumers (like RustDesk) to adjust the video quality
|
/// This allows external consumers (like RustDesk) to adjust the video quality
|
||||||
/// based on client preferences.
|
/// based on client preferences.
|
||||||
pub async fn set_bitrate_preset(&self, preset: crate::video::encoder::BitratePreset) -> crate::error::Result<()> {
|
pub async fn set_bitrate_preset(
|
||||||
|
&self,
|
||||||
|
preset: crate::video::encoder::BitratePreset,
|
||||||
|
) -> crate::error::Result<()> {
|
||||||
self.webrtc_streamer.set_bitrate_preset(preset).await
|
self.webrtc_streamer.set_bitrate_preset(preset).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,12 @@ impl Streamer {
|
|||||||
/// Get current state as SystemEvent
|
/// Get current state as SystemEvent
|
||||||
pub async fn current_state_event(&self) -> SystemEvent {
|
pub async fn current_state_event(&self) -> SystemEvent {
|
||||||
let state = *self.state.read().await;
|
let state = *self.state.read().await;
|
||||||
let device = self.current_device.read().await.as_ref().map(|d| d.path.display().to_string());
|
let device = self
|
||||||
|
.current_device
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| d.path.display().to_string());
|
||||||
|
|
||||||
SystemEvent::StreamStateChanged {
|
SystemEvent::StreamStateChanged {
|
||||||
state: match state {
|
state: match state {
|
||||||
@@ -162,7 +167,8 @@ impl Streamer {
|
|||||||
/// Check if config is currently being changed
|
/// Check if config is currently being changed
|
||||||
/// When true, auto-start should be blocked to prevent device busy errors
|
/// When true, auto-start should be blocked to prevent device busy errors
|
||||||
pub fn is_config_changing(&self) -> bool {
|
pub fn is_config_changing(&self) -> bool {
|
||||||
self.config_changing.load(std::sync::atomic::Ordering::SeqCst)
|
self.config_changing
|
||||||
|
.load(std::sync::atomic::Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get MJPEG handler for stream endpoints
|
/// Get MJPEG handler for stream endpoints
|
||||||
@@ -209,13 +215,17 @@ impl Streamer {
|
|||||||
fps: u32,
|
fps: u32,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Set config_changing flag to prevent frontend mode sync during config change
|
// Set config_changing flag to prevent frontend mode sync during config change
|
||||||
self.config_changing.store(true, std::sync::atomic::Ordering::SeqCst);
|
self.config_changing
|
||||||
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
|
||||||
let result = self.apply_video_config_inner(device_path, format, resolution, fps).await;
|
let result = self
|
||||||
|
.apply_video_config_inner(device_path, format, resolution, fps)
|
||||||
|
.await;
|
||||||
|
|
||||||
// Clear the flag after config change is complete
|
// Clear the flag after config change is complete
|
||||||
// The stream will be started by MJPEG client connection, not here
|
// The stream will be started by MJPEG client connection, not here
|
||||||
self.config_changing.store(false, std::sync::atomic::Ordering::SeqCst);
|
self.config_changing
|
||||||
|
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -230,6 +240,7 @@ impl Streamer {
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Publish "config changing" event
|
// Publish "config changing" event
|
||||||
self.publish_event(SystemEvent::StreamConfigChanging {
|
self.publish_event(SystemEvent::StreamConfigChanging {
|
||||||
|
transition_id: None,
|
||||||
reason: "device_switch".to_string(),
|
reason: "device_switch".to_string(),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
@@ -254,7 +265,9 @@ impl Streamer {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|r| r.width == resolution.width && r.height == resolution.height)
|
.any(|r| r.width == resolution.width && r.height == resolution.height)
|
||||||
{
|
{
|
||||||
return Err(AppError::VideoError("Requested resolution not supported".to_string()));
|
return Err(AppError::VideoError(
|
||||||
|
"Requested resolution not supported".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// IMPORTANT: Disconnect all MJPEG clients FIRST before stopping capture
|
// IMPORTANT: Disconnect all MJPEG clients FIRST before stopping capture
|
||||||
@@ -277,7 +290,6 @@ impl Streamer {
|
|||||||
// Explicitly drop the capturer to release V4L2 resources
|
// Explicitly drop the capturer to release V4L2 resources
|
||||||
drop(capturer);
|
drop(capturer);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update config
|
// Update config
|
||||||
@@ -305,9 +317,12 @@ impl Streamer {
|
|||||||
*self.state.write().await = StreamerState::Ready;
|
*self.state.write().await = StreamerState::Ready;
|
||||||
|
|
||||||
// Publish "config applied" event
|
// Publish "config applied" event
|
||||||
info!("Publishing StreamConfigApplied event: {}x{} {:?} @ {}fps",
|
info!(
|
||||||
resolution.width, resolution.height, format, fps);
|
"Publishing StreamConfigApplied event: {}x{} {:?} @ {}fps",
|
||||||
|
resolution.width, resolution.height, format, fps
|
||||||
|
);
|
||||||
self.publish_event(SystemEvent::StreamConfigApplied {
|
self.publish_event(SystemEvent::StreamConfigApplied {
|
||||||
|
transition_id: None,
|
||||||
device: device_path.to_string(),
|
device: device_path.to_string(),
|
||||||
resolution: (resolution.width, resolution.height),
|
resolution: (resolution.width, resolution.height),
|
||||||
format: format!("{:?}", format),
|
format: format!("{:?}", format),
|
||||||
@@ -381,7 +396,11 @@ impl Streamer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Select best format for device
|
/// Select best format for device
|
||||||
fn select_format(&self, device: &VideoDeviceInfo, preferred: PixelFormat) -> Result<PixelFormat> {
|
fn select_format(
|
||||||
|
&self,
|
||||||
|
device: &VideoDeviceInfo,
|
||||||
|
preferred: PixelFormat,
|
||||||
|
) -> Result<PixelFormat> {
|
||||||
// Check if preferred format is available
|
// Check if preferred format is available
|
||||||
if device.formats.iter().any(|f| f.format == preferred) {
|
if device.formats.iter().any(|f| f.format == preferred) {
|
||||||
return Ok(preferred);
|
return Ok(preferred);
|
||||||
@@ -410,9 +429,10 @@ impl Streamer {
|
|||||||
|
|
||||||
// Check if preferred resolution is available
|
// Check if preferred resolution is available
|
||||||
if format_info.resolutions.is_empty()
|
if format_info.resolutions.is_empty()
|
||||||
|| format_info.resolutions.iter().any(|r| {
|
|| format_info
|
||||||
r.width == preferred.width && r.height == preferred.height
|
.resolutions
|
||||||
})
|
.iter()
|
||||||
|
.any(|r| r.width == preferred.width && r.height == preferred.height)
|
||||||
{
|
{
|
||||||
return Ok(preferred);
|
return Ok(preferred);
|
||||||
}
|
}
|
||||||
@@ -528,7 +548,10 @@ impl Streamer {
|
|||||||
// Stop the streamer
|
// Stop the streamer
|
||||||
if let Some(streamer) = state_ref.upgrade() {
|
if let Some(streamer) = state_ref.upgrade() {
|
||||||
if let Err(e) = streamer.stop().await {
|
if let Err(e) = streamer.stop().await {
|
||||||
warn!("Failed to stop streamer during idle cleanup: {}", e);
|
warn!(
|
||||||
|
"Failed to stop streamer during idle cleanup: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -609,8 +632,14 @@ impl Streamer {
|
|||||||
|
|
||||||
// Start background tasks only once per Streamer instance
|
// Start background tasks only once per Streamer instance
|
||||||
// Use compare_exchange to atomically check and set the flag
|
// Use compare_exchange to atomically check and set the flag
|
||||||
if self.background_tasks_started
|
if self
|
||||||
.compare_exchange(false, true, std::sync::atomic::Ordering::SeqCst, std::sync::atomic::Ordering::SeqCst)
|
.background_tasks_started
|
||||||
|
.compare_exchange(
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
std::sync::atomic::Ordering::SeqCst,
|
||||||
|
std::sync::atomic::Ordering::SeqCst,
|
||||||
|
)
|
||||||
.is_ok()
|
.is_ok()
|
||||||
{
|
{
|
||||||
info!("Starting background tasks (stats, cleanup, monitor)");
|
info!("Starting background tasks (stats, cleanup, monitor)");
|
||||||
@@ -626,10 +655,12 @@ impl Streamer {
|
|||||||
let clients_stat = streamer.mjpeg_handler().get_clients_stat();
|
let clients_stat = streamer.mjpeg_handler().get_clients_stat();
|
||||||
let clients = clients_stat.len() as u64;
|
let clients = clients_stat.len() as u64;
|
||||||
|
|
||||||
streamer.publish_event(SystemEvent::StreamStatsUpdate {
|
streamer
|
||||||
|
.publish_event(SystemEvent::StreamStatsUpdate {
|
||||||
clients,
|
clients,
|
||||||
clients_stat,
|
clients_stat,
|
||||||
}).await;
|
})
|
||||||
|
.await;
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -649,7 +680,9 @@ impl Streamer {
|
|||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
|
|
||||||
let Some(streamer) = monitor_ref.upgrade() else { break; };
|
let Some(streamer) = monitor_ref.upgrade() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
// Check auto-pause configuration
|
// Check auto-pause configuration
|
||||||
let config = monitor_handler.auto_pause_config();
|
let config = monitor_handler.auto_pause_config();
|
||||||
@@ -663,10 +696,16 @@ impl Streamer {
|
|||||||
if count == 0 {
|
if count == 0 {
|
||||||
if zero_since.is_none() {
|
if zero_since.is_none() {
|
||||||
zero_since = Some(std::time::Instant::now());
|
zero_since = Some(std::time::Instant::now());
|
||||||
info!("No clients connected, starting shutdown timer ({}s)", config.shutdown_delay_secs);
|
info!(
|
||||||
|
"No clients connected, starting shutdown timer ({}s)",
|
||||||
|
config.shutdown_delay_secs
|
||||||
|
);
|
||||||
} else if let Some(since) = zero_since {
|
} else if let Some(since) = zero_since {
|
||||||
if since.elapsed().as_secs() >= config.shutdown_delay_secs {
|
if since.elapsed().as_secs() >= config.shutdown_delay_secs {
|
||||||
info!("Auto-pausing stream (no clients for {}s)", config.shutdown_delay_secs);
|
info!(
|
||||||
|
"Auto-pausing stream (no clients for {}s)",
|
||||||
|
config.shutdown_delay_secs
|
||||||
|
);
|
||||||
if let Err(e) = streamer.stop().await {
|
if let Err(e) = streamer.stop().await {
|
||||||
error!("Auto-pause failed: {}", e);
|
error!("Auto-pause failed: {}", e);
|
||||||
}
|
}
|
||||||
@@ -734,8 +773,14 @@ impl Streamer {
|
|||||||
clients: self.mjpeg_handler.client_count(),
|
clients: self.mjpeg_handler.client_count(),
|
||||||
target_fps: config.fps,
|
target_fps: config.fps,
|
||||||
fps: capture_stats.as_ref().map(|s| s.current_fps).unwrap_or(0.0),
|
fps: capture_stats.as_ref().map(|s| s.current_fps).unwrap_or(0.0),
|
||||||
frames_captured: capture_stats.as_ref().map(|s| s.frames_captured).unwrap_or(0),
|
frames_captured: capture_stats
|
||||||
frames_dropped: capture_stats.as_ref().map(|s| s.frames_dropped).unwrap_or(0),
|
.as_ref()
|
||||||
|
.map(|s| s.frames_captured)
|
||||||
|
.unwrap_or(0),
|
||||||
|
frames_dropped: capture_stats
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.frames_dropped)
|
||||||
|
.unwrap_or(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,7 +821,10 @@ impl Streamer {
|
|||||||
/// until the device is recovered.
|
/// until the device is recovered.
|
||||||
async fn start_device_recovery_internal(self: &Arc<Self>) {
|
async fn start_device_recovery_internal(self: &Arc<Self>) {
|
||||||
// Check if recovery is already in progress
|
// Check if recovery is already in progress
|
||||||
if self.recovery_in_progress.swap(true, std::sync::atomic::Ordering::SeqCst) {
|
if self
|
||||||
|
.recovery_in_progress
|
||||||
|
.swap(true, std::sync::atomic::Ordering::SeqCst)
|
||||||
|
{
|
||||||
debug!("Device recovery already in progress, skipping");
|
debug!("Device recovery already in progress, skipping");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -786,7 +834,9 @@ impl Streamer {
|
|||||||
let capturer = self.capturer.read().await;
|
let capturer = self.capturer.read().await;
|
||||||
if let Some(cap) = capturer.as_ref() {
|
if let Some(cap) = capturer.as_ref() {
|
||||||
cap.last_error().unwrap_or_else(|| {
|
cap.last_error().unwrap_or_else(|| {
|
||||||
let device_path = self.current_device.blocking_read()
|
let device_path = self
|
||||||
|
.current_device
|
||||||
|
.blocking_read()
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|d| d.path.display().to_string())
|
.map(|d| d.path.display().to_string())
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
@@ -800,13 +850,15 @@ impl Streamer {
|
|||||||
// Store error info
|
// Store error info
|
||||||
*self.last_lost_device.write().await = Some(device.clone());
|
*self.last_lost_device.write().await = Some(device.clone());
|
||||||
*self.last_lost_reason.write().await = Some(reason.clone());
|
*self.last_lost_reason.write().await = Some(reason.clone());
|
||||||
self.recovery_retry_count.store(0, std::sync::atomic::Ordering::Relaxed);
|
self.recovery_retry_count
|
||||||
|
.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
|
||||||
// Publish device lost event
|
// Publish device lost event
|
||||||
self.publish_event(SystemEvent::StreamDeviceLost {
|
self.publish_event(SystemEvent::StreamDeviceLost {
|
||||||
device: device.clone(),
|
device: device.clone(),
|
||||||
reason: reason.clone(),
|
reason: reason.clone(),
|
||||||
}).await;
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
// Start recovery task
|
// Start recovery task
|
||||||
let streamer = Arc::clone(self);
|
let streamer = Arc::clone(self);
|
||||||
@@ -814,11 +866,16 @@ impl Streamer {
|
|||||||
let device_path = device.clone();
|
let device_path = device.clone();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let attempt = streamer.recovery_retry_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
let attempt = streamer
|
||||||
|
.recovery_retry_count
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||||
|
+ 1;
|
||||||
|
|
||||||
// Check if still in device lost state
|
// Check if still in device lost state
|
||||||
let current_state = *streamer.state.read().await;
|
let current_state = *streamer.state.read().await;
|
||||||
if current_state != StreamerState::DeviceLost && current_state != StreamerState::Recovering {
|
if current_state != StreamerState::DeviceLost
|
||||||
|
&& current_state != StreamerState::Recovering
|
||||||
|
{
|
||||||
info!("Stream state changed during recovery, stopping recovery task");
|
info!("Stream state changed during recovery, stopping recovery task");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -828,11 +885,16 @@ impl Streamer {
|
|||||||
|
|
||||||
// Publish reconnecting event (every 5 attempts to avoid spam)
|
// Publish reconnecting event (every 5 attempts to avoid spam)
|
||||||
if attempt == 1 || attempt % 5 == 0 {
|
if attempt == 1 || attempt % 5 == 0 {
|
||||||
streamer.publish_event(SystemEvent::StreamReconnecting {
|
streamer
|
||||||
|
.publish_event(SystemEvent::StreamReconnecting {
|
||||||
device: device_path.clone(),
|
device: device_path.clone(),
|
||||||
attempt,
|
attempt,
|
||||||
}).await;
|
})
|
||||||
info!("Attempting to recover video device {} (attempt {})", device_path, attempt);
|
.await;
|
||||||
|
info!(
|
||||||
|
"Attempting to recover video device {} (attempt {})",
|
||||||
|
device_path, attempt
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait before retry (1 second)
|
// Wait before retry (1 second)
|
||||||
@@ -848,13 +910,20 @@ impl Streamer {
|
|||||||
// Try to restart capture
|
// Try to restart capture
|
||||||
match streamer.restart_capturer().await {
|
match streamer.restart_capturer().await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
info!("Video device {} recovered after {} attempts", device_path, attempt);
|
info!(
|
||||||
streamer.recovery_in_progress.store(false, std::sync::atomic::Ordering::SeqCst);
|
"Video device {} recovered after {} attempts",
|
||||||
|
device_path, attempt
|
||||||
|
);
|
||||||
|
streamer
|
||||||
|
.recovery_in_progress
|
||||||
|
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
|
||||||
// Publish recovered event
|
// Publish recovered event
|
||||||
streamer.publish_event(SystemEvent::StreamRecovered {
|
streamer
|
||||||
|
.publish_event(SystemEvent::StreamRecovered {
|
||||||
device: device_path.clone(),
|
device: device_path.clone(),
|
||||||
}).await;
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
// Clear error info
|
// Clear error info
|
||||||
*streamer.last_lost_device.write().await = None;
|
*streamer.last_lost_device.write().await = None;
|
||||||
@@ -867,7 +936,9 @@ impl Streamer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
streamer.recovery_in_progress.store(false, std::sync::atomic::Ordering::SeqCst);
|
streamer
|
||||||
|
.recovery_in_progress
|
||||||
|
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,10 +234,7 @@ impl VideoSessionManager {
|
|||||||
let mut sessions = self.sessions.write().await;
|
let mut sessions = self.sessions.write().await;
|
||||||
sessions.insert(session_id.clone(), session);
|
sessions.insert(session_id.clone(), session);
|
||||||
|
|
||||||
info!(
|
info!("Video session created: {} (codec: {})", session_id, codec);
|
||||||
"Video session created: {} (codec: {})",
|
|
||||||
session_id, codec
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(session_id)
|
Ok(session_id)
|
||||||
}
|
}
|
||||||
@@ -428,8 +425,7 @@ impl VideoSessionManager {
|
|||||||
sessions
|
sessions
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, s)| {
|
.filter(|(_, s)| {
|
||||||
(s.state == VideoSessionState::Paused
|
(s.state == VideoSessionState::Paused || s.state == VideoSessionState::Created)
|
||||||
|| s.state == VideoSessionState::Created)
|
|
||||||
&& now.duration_since(s.last_activity) > timeout
|
&& now.duration_since(s.last_activity) > timeout
|
||||||
})
|
})
|
||||||
.map(|(id, _)| id.clone())
|
.map(|(id, _)| id.clone())
|
||||||
|
|||||||
@@ -31,15 +31,14 @@ pub async fn apply_video_config(
|
|||||||
.format
|
.format
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|f| {
|
.and_then(|f| {
|
||||||
serde_json::from_value::<crate::video::format::PixelFormat>(
|
serde_json::from_value::<crate::video::format::PixelFormat>(serde_json::Value::String(
|
||||||
serde_json::Value::String(f.clone()),
|
f.clone(),
|
||||||
)
|
))
|
||||||
.ok()
|
.ok()
|
||||||
})
|
})
|
||||||
.unwrap_or(crate::video::format::PixelFormat::Mjpeg);
|
.unwrap_or(crate::video::format::PixelFormat::Mjpeg);
|
||||||
|
|
||||||
let resolution =
|
let resolution = crate::video::format::Resolution::new(new_config.width, new_config.height);
|
||||||
crate::video::format::Resolution::new(new_config.width, new_config.height);
|
|
||||||
|
|
||||||
// Step 1: 更新 WebRTC streamer 配置(停止现有 pipeline 和 sessions)
|
// Step 1: 更新 WebRTC streamer 配置(停止现有 pipeline 和 sessions)
|
||||||
state
|
state
|
||||||
@@ -162,9 +161,16 @@ pub async fn apply_hid_config(
|
|||||||
// 如果描述符变更且当前使用 OTG 后端,需要重建 Gadget
|
// 如果描述符变更且当前使用 OTG 后端,需要重建 Gadget
|
||||||
if descriptor_changed && new_config.backend == HidBackend::Otg {
|
if descriptor_changed && new_config.backend == HidBackend::Otg {
|
||||||
tracing::info!("OTG descriptor changed, updating gadget...");
|
tracing::info!("OTG descriptor changed, updating gadget...");
|
||||||
if let Err(e) = state.otg_service.update_descriptor(&new_config.otg_descriptor).await {
|
if let Err(e) = state
|
||||||
|
.otg_service
|
||||||
|
.update_descriptor(&new_config.otg_descriptor)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::error!("Failed to update OTG descriptor: {}", e);
|
tracing::error!("Failed to update OTG descriptor: {}", e);
|
||||||
return Err(AppError::Config(format!("OTG descriptor update failed: {}", e)));
|
return Err(AppError::Config(format!(
|
||||||
|
"OTG descriptor update failed: {}",
|
||||||
|
e
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
tracing::info!("OTG descriptor updated successfully");
|
tracing::info!("OTG descriptor updated successfully");
|
||||||
}
|
}
|
||||||
@@ -197,7 +203,10 @@ pub async fn apply_hid_config(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
|
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
|
||||||
|
|
||||||
tracing::info!("HID backend reloaded successfully: {:?}", new_config.backend);
|
tracing::info!(
|
||||||
|
"HID backend reloaded successfully: {:?}",
|
||||||
|
new_config.backend
|
||||||
|
);
|
||||||
|
|
||||||
// When switching to OTG backend, automatically enable MSD if not already enabled
|
// When switching to OTG backend, automatically enable MSD if not already enabled
|
||||||
// OTG HID and MSD share the same USB gadget, so it makes sense to enable both
|
// OTG HID and MSD share the same USB gadget, so it makes sense to enable both
|
||||||
@@ -245,7 +254,11 @@ pub async fn apply_msd_config(
|
|||||||
let old_msd_enabled = old_config.enabled;
|
let old_msd_enabled = old_config.enabled;
|
||||||
let new_msd_enabled = new_config.enabled;
|
let new_msd_enabled = new_config.enabled;
|
||||||
|
|
||||||
tracing::info!("MSD enabled: old={}, new={}", old_msd_enabled, new_msd_enabled);
|
tracing::info!(
|
||||||
|
"MSD enabled: old={}, new={}",
|
||||||
|
old_msd_enabled,
|
||||||
|
new_msd_enabled
|
||||||
|
);
|
||||||
|
|
||||||
if old_msd_enabled != new_msd_enabled {
|
if old_msd_enabled != new_msd_enabled {
|
||||||
if new_msd_enabled {
|
if new_msd_enabled {
|
||||||
@@ -257,9 +270,9 @@ pub async fn apply_msd_config(
|
|||||||
&new_config.images_path,
|
&new_config.images_path,
|
||||||
&new_config.drive_path,
|
&new_config.drive_path,
|
||||||
);
|
);
|
||||||
msd.init().await.map_err(|e| {
|
msd.init()
|
||||||
AppError::Config(format!("MSD initialization failed: {}", e))
|
.await
|
||||||
})?;
|
.map_err(|e| AppError::Config(format!("MSD initialization failed: {}", e)))?;
|
||||||
|
|
||||||
// Set event bus
|
// Set event bus
|
||||||
let events = state.events.clone();
|
let events = state.events.clone();
|
||||||
@@ -429,7 +442,10 @@ pub async fn apply_rustdesk_config(
|
|||||||
if let Err(e) = service.restart(new_config.clone()).await {
|
if let Err(e) = service.restart(new_config.clone()).await {
|
||||||
tracing::error!("Failed to restart RustDesk service: {}", e);
|
tracing::error!("Failed to restart RustDesk service: {}", e);
|
||||||
} else {
|
} else {
|
||||||
tracing::info!("RustDesk service restarted with ID: {}", new_config.device_id);
|
tracing::info!(
|
||||||
|
"RustDesk service restarted with ID: {}",
|
||||||
|
new_config.device_id
|
||||||
|
);
|
||||||
// Save generated keypair and UUID to config
|
// Save generated keypair and UUID to config
|
||||||
credentials_to_save = service.save_credentials();
|
credentials_to_save = service.save_credentials();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,26 +19,26 @@
|
|||||||
pub(crate) mod apply;
|
pub(crate) mod apply;
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
pub(crate) mod video;
|
|
||||||
mod stream;
|
|
||||||
mod hid;
|
|
||||||
mod msd;
|
|
||||||
mod atx;
|
mod atx;
|
||||||
mod audio;
|
mod audio;
|
||||||
|
mod hid;
|
||||||
|
mod msd;
|
||||||
mod rustdesk;
|
mod rustdesk;
|
||||||
|
mod stream;
|
||||||
|
pub(crate) mod video;
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
// 导出 handler 函数
|
// 导出 handler 函数
|
||||||
pub use video::{get_video_config, update_video_config};
|
|
||||||
pub use stream::{get_stream_config, update_stream_config};
|
|
||||||
pub use hid::{get_hid_config, update_hid_config};
|
|
||||||
pub use msd::{get_msd_config, update_msd_config};
|
|
||||||
pub use atx::{get_atx_config, update_atx_config};
|
pub use atx::{get_atx_config, update_atx_config};
|
||||||
pub use audio::{get_audio_config, update_audio_config};
|
pub use audio::{get_audio_config, update_audio_config};
|
||||||
|
pub use hid::{get_hid_config, update_hid_config};
|
||||||
|
pub use msd::{get_msd_config, update_msd_config};
|
||||||
pub use rustdesk::{
|
pub use rustdesk::{
|
||||||
get_rustdesk_config, get_rustdesk_status, update_rustdesk_config,
|
get_device_password, get_rustdesk_config, get_rustdesk_status, regenerate_device_id,
|
||||||
regenerate_device_id, regenerate_device_password, get_device_password,
|
regenerate_device_password, update_rustdesk_config,
|
||||||
};
|
};
|
||||||
|
pub use stream::{get_stream_config, update_stream_config};
|
||||||
|
pub use video::{get_video_config, update_video_config};
|
||||||
pub use web::{get_web_config, update_web_config};
|
pub use web::{get_web_config, update_web_config};
|
||||||
|
|
||||||
// 保留全局配置查询(向后兼容)
|
// 保留全局配置查询(向后兼容)
|
||||||
|
|||||||
@@ -48,12 +48,16 @@ pub struct RustDeskStatusResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 RustDesk 配置
|
/// 获取 RustDesk 配置
|
||||||
pub async fn get_rustdesk_config(State(state): State<Arc<AppState>>) -> Json<RustDeskConfigResponse> {
|
pub async fn get_rustdesk_config(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Json<RustDeskConfigResponse> {
|
||||||
Json(RustDeskConfigResponse::from(&state.config.get().rustdesk))
|
Json(RustDeskConfigResponse::from(&state.config.get().rustdesk))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 RustDesk 完整状态(配置 + 服务状态)
|
/// 获取 RustDesk 完整状态(配置 + 服务状态)
|
||||||
pub async fn get_rustdesk_status(State(state): State<Arc<AppState>>) -> Json<RustDeskStatusResponse> {
|
pub async fn get_rustdesk_status(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Json<RustDeskStatusResponse> {
|
||||||
let config = state.config.get().rustdesk.clone();
|
let config = state.config.get().rustdesk.clone();
|
||||||
|
|
||||||
// 获取服务状态
|
// 获取服务状态
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use serde::Deserialize;
|
|
||||||
use typeshare::typeshare;
|
|
||||||
use crate::config::*;
|
use crate::config::*;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::rustdesk::config::RustDeskConfig;
|
use crate::rustdesk::config::RustDeskConfig;
|
||||||
use crate::video::encoder::BitratePreset;
|
use crate::video::encoder::BitratePreset;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use typeshare::typeshare;
|
||||||
|
|
||||||
// ===== Video Config =====
|
// ===== Video Config =====
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
@@ -21,12 +21,16 @@ impl VideoConfigUpdate {
|
|||||||
pub fn validate(&self) -> crate::error::Result<()> {
|
pub fn validate(&self) -> crate::error::Result<()> {
|
||||||
if let Some(width) = self.width {
|
if let Some(width) = self.width {
|
||||||
if !(320..=7680).contains(&width) {
|
if !(320..=7680).contains(&width) {
|
||||||
return Err(AppError::BadRequest("Invalid width: must be 320-7680".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Invalid width: must be 320-7680".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(height) = self.height {
|
if let Some(height) = self.height {
|
||||||
if !(240..=4320).contains(&height) {
|
if !(240..=4320).contains(&height) {
|
||||||
return Err(AppError::BadRequest("Invalid height: must be 240-4320".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Invalid height: must be 240-4320".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(fps) = self.fps {
|
if let Some(fps) = self.fps {
|
||||||
@@ -36,7 +40,9 @@ impl VideoConfigUpdate {
|
|||||||
}
|
}
|
||||||
if let Some(quality) = self.quality {
|
if let Some(quality) = self.quality {
|
||||||
if !(1..=100).contains(&quality) {
|
if !(1..=100).contains(&quality) {
|
||||||
return Err(AppError::BadRequest("Invalid quality: must be 1-100".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Invalid quality: must be 1-100".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -126,7 +132,8 @@ impl StreamConfigUpdate {
|
|||||||
if let Some(ref stun) = self.stun_server {
|
if let Some(ref stun) = self.stun_server {
|
||||||
if !stun.is_empty() && !stun.starts_with("stun:") {
|
if !stun.is_empty() && !stun.starts_with("stun:") {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"STUN server must start with 'stun:' (e.g., stun:stun.l.google.com:19302)".into(),
|
"STUN server must start with 'stun:' (e.g., stun:stun.l.google.com:19302)"
|
||||||
|
.into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,16 +160,32 @@ impl StreamConfigUpdate {
|
|||||||
}
|
}
|
||||||
// STUN/TURN settings - empty string means clear (use public servers), Some("value") means set custom
|
// STUN/TURN settings - empty string means clear (use public servers), Some("value") means set custom
|
||||||
if let Some(ref stun) = self.stun_server {
|
if let Some(ref stun) = self.stun_server {
|
||||||
config.stun_server = if stun.is_empty() { None } else { Some(stun.clone()) };
|
config.stun_server = if stun.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(stun.clone())
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if let Some(ref turn) = self.turn_server {
|
if let Some(ref turn) = self.turn_server {
|
||||||
config.turn_server = if turn.is_empty() { None } else { Some(turn.clone()) };
|
config.turn_server = if turn.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(turn.clone())
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if let Some(ref username) = self.turn_username {
|
if let Some(ref username) = self.turn_username {
|
||||||
config.turn_username = if username.is_empty() { None } else { Some(username.clone()) };
|
config.turn_username = if username.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(username.clone())
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if let Some(ref password) = self.turn_password {
|
if let Some(ref password) = self.turn_password {
|
||||||
config.turn_password = if password.is_empty() { None } else { Some(password.clone()) };
|
config.turn_password = if password.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(password.clone())
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,19 +208,25 @@ impl OtgDescriptorConfigUpdate {
|
|||||||
// Validate manufacturer string length
|
// Validate manufacturer string length
|
||||||
if let Some(ref s) = self.manufacturer {
|
if let Some(ref s) = self.manufacturer {
|
||||||
if s.len() > 126 {
|
if s.len() > 126 {
|
||||||
return Err(AppError::BadRequest("Manufacturer string too long (max 126 chars)".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Manufacturer string too long (max 126 chars)".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Validate product string length
|
// Validate product string length
|
||||||
if let Some(ref s) = self.product {
|
if let Some(ref s) = self.product {
|
||||||
if s.len() > 126 {
|
if s.len() > 126 {
|
||||||
return Err(AppError::BadRequest("Product string too long (max 126 chars)".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Product string too long (max 126 chars)".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Validate serial number string length
|
// Validate serial number string length
|
||||||
if let Some(ref s) = self.serial_number {
|
if let Some(ref s) = self.serial_number {
|
||||||
if s.len() > 126 {
|
if s.len() > 126 {
|
||||||
return Err(AppError::BadRequest("Serial number string too long (max 126 chars)".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Serial number string too long (max 126 chars)".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -469,7 +498,8 @@ impl RustDeskConfigUpdate {
|
|||||||
if let Some(ref server) = self.rendezvous_server {
|
if let Some(ref server) = self.rendezvous_server {
|
||||||
if !server.is_empty() && !server.contains(':') {
|
if !server.is_empty() && !server.contains(':') {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"Rendezvous server must be in format 'host:port' (e.g., rs.example.com:21116)".into(),
|
"Rendezvous server must be in format 'host:port' (e.g., rs.example.com:21116)"
|
||||||
|
.into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -477,7 +507,8 @@ impl RustDeskConfigUpdate {
|
|||||||
if let Some(ref server) = self.relay_server {
|
if let Some(ref server) = self.relay_server {
|
||||||
if !server.is_empty() && !server.contains(':') {
|
if !server.is_empty() && !server.contains(':') {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"Relay server must be in format 'host:port' (e.g., rs.example.com:21117)".into(),
|
"Relay server must be in format 'host:port' (e.g., rs.example.com:21117)"
|
||||||
|
.into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,10 +531,18 @@ impl RustDeskConfigUpdate {
|
|||||||
config.rendezvous_server = server.clone();
|
config.rendezvous_server = server.clone();
|
||||||
}
|
}
|
||||||
if let Some(ref server) = self.relay_server {
|
if let Some(ref server) = self.relay_server {
|
||||||
config.relay_server = if server.is_empty() { None } else { Some(server.clone()) };
|
config.relay_server = if server.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(server.clone())
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if let Some(ref key) = self.relay_key {
|
if let Some(ref key) = self.relay_key {
|
||||||
config.relay_key = if key.is_empty() { None } else { Some(key.clone()) };
|
config.relay_key = if key.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(key.clone())
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if let Some(ref password) = self.device_password {
|
if let Some(ref password) = self.device_password {
|
||||||
if !password.is_empty() {
|
if !password.is_empty() {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ use typeshare::typeshare;
|
|||||||
|
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::extensions::{
|
use crate::extensions::{
|
||||||
EasytierConfig, EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs,
|
EasytierConfig, EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs, ExtensionsStatus,
|
||||||
ExtensionsStatus, GostcConfig, GostcInfo, TtydConfig, TtydInfo,
|
GostcConfig, GostcInfo, TtydConfig, TtydInfo,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -108,9 +108,7 @@ pub async fn stop_extension(
|
|||||||
let mgr = &state.extensions;
|
let mgr = &state.extensions;
|
||||||
|
|
||||||
// Stop the extension
|
// Stop the extension
|
||||||
mgr.stop(ext_id)
|
mgr.stop(ext_id).await.map_err(|e| AppError::Internal(e))?;
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::Internal(e))?;
|
|
||||||
|
|
||||||
// Return updated status
|
// Return updated status
|
||||||
Ok(Json(ExtensionInfo {
|
Ok(Json(ExtensionInfo {
|
||||||
|
|||||||
@@ -124,8 +124,7 @@ pub async fn system_info(State(state): State<Arc<AppState>>) -> Json<SystemInfo>
|
|||||||
backend: if config.atx.enabled {
|
backend: if config.atx.enabled {
|
||||||
Some(format!(
|
Some(format!(
|
||||||
"power: {:?}, reset: {:?}",
|
"power: {:?}, reset: {:?}",
|
||||||
config.atx.power.driver,
|
config.atx.power.driver, config.atx.reset.driver
|
||||||
config.atx.reset.driver
|
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -208,7 +207,8 @@ fn get_cpu_model() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// CPU usage state for calculating usage between samples
|
/// CPU usage state for calculating usage between samples
|
||||||
static CPU_PREV_STATS: std::sync::OnceLock<std::sync::Mutex<(u64, u64)>> = std::sync::OnceLock::new();
|
static CPU_PREV_STATS: std::sync::OnceLock<std::sync::Mutex<(u64, u64)>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
/// Get CPU usage percentage (0.0 - 100.0)
|
/// Get CPU usage percentage (0.0 - 100.0)
|
||||||
fn get_cpu_usage() -> f32 {
|
fn get_cpu_usage() -> f32 {
|
||||||
@@ -268,7 +268,12 @@ struct MemInfo {
|
|||||||
fn get_meminfo() -> MemInfo {
|
fn get_meminfo() -> MemInfo {
|
||||||
let content = match std::fs::read_to_string("/proc/meminfo") {
|
let content = match std::fs::read_to_string("/proc/meminfo") {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => return MemInfo { total: 0, available: 0 },
|
Err(_) => {
|
||||||
|
return MemInfo {
|
||||||
|
total: 0,
|
||||||
|
available: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut total = 0u64;
|
let mut total = 0u64;
|
||||||
@@ -276,11 +281,19 @@ fn get_meminfo() -> MemInfo {
|
|||||||
|
|
||||||
for line in content.lines() {
|
for line in content.lines() {
|
||||||
if line.starts_with("MemTotal:") {
|
if line.starts_with("MemTotal:") {
|
||||||
if let Some(kb) = line.split_whitespace().nth(1).and_then(|v| v.parse::<u64>().ok()) {
|
if let Some(kb) = line
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|v| v.parse::<u64>().ok())
|
||||||
|
{
|
||||||
total = kb * 1024;
|
total = kb * 1024;
|
||||||
}
|
}
|
||||||
} else if line.starts_with("MemAvailable:") {
|
} else if line.starts_with("MemAvailable:") {
|
||||||
if let Some(kb) = line.split_whitespace().nth(1).and_then(|v| v.parse::<u64>().ok()) {
|
if let Some(kb) = line
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|v| v.parse::<u64>().ok())
|
||||||
|
{
|
||||||
available = kb * 1024;
|
available = kb * 1024;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,10 +325,7 @@ fn get_network_addresses() -> Vec<NetworkAddress> {
|
|||||||
if !ipv4_map.contains_key(&ifaddr.interface_name) {
|
if !ipv4_map.contains_key(&ifaddr.interface_name) {
|
||||||
if let Some(addr) = ifaddr.address {
|
if let Some(addr) = ifaddr.address {
|
||||||
if let Some(sockaddr_in) = addr.as_sockaddr_in() {
|
if let Some(sockaddr_in) = addr.as_sockaddr_in() {
|
||||||
ipv4_map.insert(
|
ipv4_map.insert(ifaddr.interface_name.clone(), sockaddr_in.ip().to_string());
|
||||||
ifaddr.interface_name.clone(),
|
|
||||||
sockaddr_in.ip().to_string(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -624,10 +634,7 @@ pub async fn setup_init(
|
|||||||
if new_config.extensions.ttyd.enabled {
|
if new_config.extensions.ttyd.enabled {
|
||||||
if let Err(e) = state
|
if let Err(e) = state
|
||||||
.extensions
|
.extensions
|
||||||
.start(
|
.start(crate::extensions::ExtensionId::Ttyd, &new_config.extensions)
|
||||||
crate::extensions::ExtensionId::Ttyd,
|
|
||||||
&new_config.extensions,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!("Failed to start ttyd during setup: {}", e);
|
tracing::warn!("Failed to start ttyd during setup: {}", e);
|
||||||
@@ -658,7 +665,10 @@ pub async fn setup_init(
|
|||||||
if let Err(e) = state.audio.update_config(audio_config).await {
|
if let Err(e) = state.audio.update_config(audio_config).await {
|
||||||
tracing::warn!("Failed to start audio during setup: {}", e);
|
tracing::warn!("Failed to start audio during setup: {}", e);
|
||||||
} else {
|
} else {
|
||||||
tracing::info!("Audio started during setup: device={}", new_config.audio.device);
|
tracing::info!(
|
||||||
|
"Audio started during setup: device={}",
|
||||||
|
new_config.audio.device
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Also enable WebRTC audio
|
// Also enable WebRTC audio
|
||||||
if let Err(e) = state.stream_manager.set_webrtc_audio_enabled(true).await {
|
if let Err(e) = state.stream_manager.set_webrtc_audio_enabled(true).await {
|
||||||
@@ -666,7 +676,10 @@ pub async fn setup_init(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("System initialized successfully with admin user: {}", req.username);
|
tracing::info!(
|
||||||
|
"System initialized successfully with admin user: {}",
|
||||||
|
req.username
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -798,10 +811,19 @@ pub async fn update_config(
|
|||||||
if let Some(frame_tx) = state.stream_manager.frame_sender().await {
|
if let Some(frame_tx) = state.stream_manager.frame_sender().await {
|
||||||
let receiver_count = frame_tx.receiver_count();
|
let receiver_count = frame_tx.receiver_count();
|
||||||
// Use WebRtcStreamer (new unified interface)
|
// Use WebRtcStreamer (new unified interface)
|
||||||
state.stream_manager.webrtc_streamer().set_video_source(frame_tx).await;
|
state
|
||||||
tracing::info!("WebRTC streamer frame source updated with new capturer (receiver_count={})", receiver_count);
|
.stream_manager
|
||||||
|
.webrtc_streamer()
|
||||||
|
.set_video_source(frame_tx)
|
||||||
|
.await;
|
||||||
|
tracing::info!(
|
||||||
|
"WebRTC streamer frame source updated with new capturer (receiver_count={})",
|
||||||
|
receiver_count
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("No frame source available after config change - streamer may not be running");
|
tracing::warn!(
|
||||||
|
"No frame source available after config change - streamer may not be running"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,8 +853,11 @@ pub async fn update_config(
|
|||||||
.await
|
.await
|
||||||
.ok(); // Ignore error if no active stream
|
.ok(); // Ignore error if no active stream
|
||||||
|
|
||||||
tracing::info!("Stream config applied: encoder={:?}, bitrate={}",
|
tracing::info!(
|
||||||
new_config.stream.encoder, new_config.stream.bitrate_preset);
|
"Stream config applied: encoder={:?}, bitrate={}",
|
||||||
|
new_config.stream.encoder,
|
||||||
|
new_config.stream.bitrate_preset
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// HID config processing - always reload if section was sent
|
// HID config processing - always reload if section was sent
|
||||||
@@ -860,7 +885,10 @@ pub async fn update_config(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("HID backend reloaded successfully: {:?}", new_config.hid.backend);
|
tracing::info!(
|
||||||
|
"HID backend reloaded successfully: {:?}",
|
||||||
|
new_config.hid.backend
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio config processing - always reload if section was sent
|
// Audio config processing - always reload if section was sent
|
||||||
@@ -888,7 +916,11 @@ pub async fn update_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Also update WebRTC audio enabled state
|
// Also update WebRTC audio enabled state
|
||||||
if let Err(e) = state.stream_manager.set_webrtc_audio_enabled(new_config.audio.enabled).await {
|
if let Err(e) = state
|
||||||
|
.stream_manager
|
||||||
|
.set_webrtc_audio_enabled(new_config.audio.enabled)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::warn!("Failed to update WebRTC audio state: {}", e);
|
tracing::warn!("Failed to update WebRTC audio state: {}", e);
|
||||||
} else {
|
} else {
|
||||||
tracing::info!("WebRTC audio enabled: {}", new_config.audio.enabled);
|
tracing::info!("WebRTC audio enabled: {}", new_config.audio.enabled);
|
||||||
@@ -911,7 +943,11 @@ pub async fn update_config(
|
|||||||
let old_msd_enabled = old_config.msd.enabled;
|
let old_msd_enabled = old_config.msd.enabled;
|
||||||
let new_msd_enabled = new_config.msd.enabled;
|
let new_msd_enabled = new_config.msd.enabled;
|
||||||
|
|
||||||
tracing::info!("MSD enabled: old={}, new={}", old_msd_enabled, new_msd_enabled);
|
tracing::info!(
|
||||||
|
"MSD enabled: old={}, new={}",
|
||||||
|
old_msd_enabled,
|
||||||
|
new_msd_enabled
|
||||||
|
);
|
||||||
|
|
||||||
if old_msd_enabled != new_msd_enabled {
|
if old_msd_enabled != new_msd_enabled {
|
||||||
if new_msd_enabled {
|
if new_msd_enabled {
|
||||||
@@ -953,7 +989,10 @@ pub async fn update_config(
|
|||||||
tracing::info!("MSD shutdown complete");
|
tracing::info!("MSD shutdown complete");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::info!("MSD enabled state unchanged ({}), no reload needed", new_msd_enabled);
|
tracing::info!(
|
||||||
|
"MSD enabled state unchanged ({}), no reload needed",
|
||||||
|
new_msd_enabled
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1060,7 +1099,12 @@ fn extract_usb_bus_from_bus_info(bus_info: &str) -> Option<String> {
|
|||||||
if parts.len() == 2 {
|
if parts.len() == 2 {
|
||||||
let port = parts[0];
|
let port = parts[0];
|
||||||
// Verify it looks like a USB port (starts with digit)
|
// Verify it looks like a USB port (starts with digit)
|
||||||
if port.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
|
if port
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.map(|c| c.is_ascii_digit())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
return Some(port.to_string());
|
return Some(port.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1115,7 +1159,10 @@ pub async fn list_devices(State(state): State<Arc<AppState>>) -> Json<DeviceList
|
|||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
// Check if matches any prefix
|
// Check if matches any prefix
|
||||||
if serial_prefixes.iter().any(|prefix| name.starts_with(prefix)) {
|
if serial_prefixes
|
||||||
|
.iter()
|
||||||
|
.any(|prefix| name.starts_with(prefix))
|
||||||
|
{
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if let Some(p) = path.to_str() {
|
if let Some(p) = path.to_str() {
|
||||||
serial_devices.push(SerialDevice {
|
serial_devices.push(SerialDevice {
|
||||||
@@ -1156,7 +1203,9 @@ pub async fn list_devices(State(state): State<Arc<AppState>>) -> Json<DeviceList
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Check extension availability
|
// Check extension availability
|
||||||
let ttyd_available = state.extensions.check_available(crate::extensions::ExtensionId::Ttyd);
|
let ttyd_available = state
|
||||||
|
.extensions
|
||||||
|
.check_available(crate::extensions::ExtensionId::Ttyd);
|
||||||
|
|
||||||
Json(DeviceList {
|
Json(DeviceList {
|
||||||
video: video_devices,
|
video: video_devices,
|
||||||
@@ -1174,12 +1223,12 @@ pub async fn list_devices(State(state): State<Arc<AppState>>) -> Json<DeviceList
|
|||||||
// Stream Control
|
// Stream Control
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
use crate::video::streamer::StreamerStats;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
http::{header, StatusCode},
|
http::{header, StatusCode},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use crate::video::streamer::StreamerStats;
|
|
||||||
|
|
||||||
/// Get stream state
|
/// Get stream state
|
||||||
pub async fn stream_state(State(state): State<Arc<AppState>>) -> Json<StreamerStats> {
|
pub async fn stream_state(State(state): State<Arc<AppState>>) -> Json<StreamerStats> {
|
||||||
@@ -1216,6 +1265,9 @@ pub struct SetStreamModeRequest {
|
|||||||
pub struct StreamModeResponse {
|
pub struct StreamModeResponse {
|
||||||
pub success: bool,
|
pub success: bool,
|
||||||
pub mode: String,
|
pub mode: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub transition_id: Option<String>,
|
||||||
|
pub switching: bool,
|
||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1223,12 +1275,27 @@ pub struct StreamModeResponse {
|
|||||||
pub async fn stream_mode_get(State(state): State<Arc<AppState>>) -> Json<StreamModeResponse> {
|
pub async fn stream_mode_get(State(state): State<Arc<AppState>>) -> Json<StreamModeResponse> {
|
||||||
let mode = state.stream_manager.current_mode().await;
|
let mode = state.stream_manager.current_mode().await;
|
||||||
let mode_str = match mode {
|
let mode_str = match mode {
|
||||||
StreamMode::Mjpeg => "mjpeg",
|
StreamMode::Mjpeg => "mjpeg".to_string(),
|
||||||
StreamMode::WebRTC => "webrtc",
|
StreamMode::WebRTC => {
|
||||||
|
use crate::video::encoder::VideoCodecType;
|
||||||
|
let codec = state
|
||||||
|
.stream_manager
|
||||||
|
.webrtc_streamer()
|
||||||
|
.current_video_codec()
|
||||||
|
.await;
|
||||||
|
match codec {
|
||||||
|
VideoCodecType::H264 => "h264".to_string(),
|
||||||
|
VideoCodecType::H265 => "h265".to_string(),
|
||||||
|
VideoCodecType::VP8 => "vp8".to_string(),
|
||||||
|
VideoCodecType::VP9 => "vp9".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Json(StreamModeResponse {
|
Json(StreamModeResponse {
|
||||||
success: true,
|
success: true,
|
||||||
mode: mode_str.to_string(),
|
mode: mode_str,
|
||||||
|
transition_id: state.stream_manager.current_transition_id().await,
|
||||||
|
switching: state.stream_manager.is_switching(),
|
||||||
message: None,
|
message: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1258,15 +1325,24 @@ pub async fn stream_mode_set(
|
|||||||
// Set video codec if switching to WebRTC mode with specific codec
|
// Set video codec if switching to WebRTC mode with specific codec
|
||||||
if let Some(codec) = video_codec {
|
if let Some(codec) = video_codec {
|
||||||
info!("Setting WebRTC video codec to {:?}", codec);
|
info!("Setting WebRTC video codec to {:?}", codec);
|
||||||
if let Err(e) = state.stream_manager.webrtc_streamer().set_video_codec(codec).await {
|
if let Err(e) = state
|
||||||
|
.stream_manager
|
||||||
|
.webrtc_streamer()
|
||||||
|
.set_video_codec(codec)
|
||||||
|
.await
|
||||||
|
{
|
||||||
warn!("Failed to set video codec: {}", e);
|
warn!("Failed to set video codec: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.stream_manager.switch_mode(new_mode.clone()).await?;
|
let tx = state
|
||||||
|
.stream_manager
|
||||||
|
.switch_mode_transaction(new_mode.clone())
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Return the actual codec being used
|
// Return the requested codec identifier (for UI display). The actual active mode
|
||||||
let mode_str = match (&new_mode, &video_codec) {
|
// may differ if the request was rejected due to an in-progress switch.
|
||||||
|
let requested_mode_str = match (&new_mode, &video_codec) {
|
||||||
(StreamMode::Mjpeg, _) => "mjpeg",
|
(StreamMode::Mjpeg, _) => "mjpeg",
|
||||||
(StreamMode::WebRTC, Some(VideoCodecType::H264)) => "h264",
|
(StreamMode::WebRTC, Some(VideoCodecType::H264)) => "h264",
|
||||||
(StreamMode::WebRTC, Some(VideoCodecType::H265)) => "h265",
|
(StreamMode::WebRTC, Some(VideoCodecType::H265)) => "h265",
|
||||||
@@ -1275,10 +1351,39 @@ pub async fn stream_mode_set(
|
|||||||
(StreamMode::WebRTC, None) => "webrtc",
|
(StreamMode::WebRTC, None) => "webrtc",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let active_mode_str = match state.stream_manager.current_mode().await {
|
||||||
|
StreamMode::Mjpeg => "mjpeg".to_string(),
|
||||||
|
StreamMode::WebRTC => {
|
||||||
|
let codec = state
|
||||||
|
.stream_manager
|
||||||
|
.webrtc_streamer()
|
||||||
|
.current_video_codec()
|
||||||
|
.await;
|
||||||
|
match codec {
|
||||||
|
VideoCodecType::H264 => "h264".to_string(),
|
||||||
|
VideoCodecType::H265 => "h265".to_string(),
|
||||||
|
VideoCodecType::VP8 => "vp8".to_string(),
|
||||||
|
VideoCodecType::VP9 => "vp9".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Json(StreamModeResponse {
|
Ok(Json(StreamModeResponse {
|
||||||
success: true,
|
success: tx.accepted,
|
||||||
mode: mode_str.to_string(),
|
mode: if tx.accepted {
|
||||||
message: Some(format!("Switched to {} mode", mode_str)),
|
requested_mode_str.to_string()
|
||||||
|
} else {
|
||||||
|
active_mode_str
|
||||||
|
},
|
||||||
|
transition_id: tx.transition_id,
|
||||||
|
switching: tx.switching,
|
||||||
|
message: Some(if tx.accepted {
|
||||||
|
format!("Switching to {} mode", requested_mode_str)
|
||||||
|
} else if tx.switching {
|
||||||
|
"Mode switch already in progress".to_string()
|
||||||
|
} else {
|
||||||
|
"No switch needed".to_string()
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1470,7 +1575,9 @@ pub async fn mjpeg_stream(
|
|||||||
return axum::response::Response::builder()
|
return axum::response::Response::builder()
|
||||||
.status(axum::http::StatusCode::SERVICE_UNAVAILABLE)
|
.status(axum::http::StatusCode::SERVICE_UNAVAILABLE)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(axum::body::Body::from(r#"{"error":"MJPEG mode not active. Current mode is WebRTC."}"#))
|
.body(axum::body::Body::from(
|
||||||
|
r#"{"error":"MJPEG mode not active. Current mode is WebRTC."}"#,
|
||||||
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1479,7 +1586,9 @@ pub async fn mjpeg_stream(
|
|||||||
return axum::response::Response::builder()
|
return axum::response::Response::builder()
|
||||||
.status(axum::http::StatusCode::SERVICE_UNAVAILABLE)
|
.status(axum::http::StatusCode::SERVICE_UNAVAILABLE)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(axum::body::Body::from(r#"{"error":"Video configuration is being changed. Please retry shortly."}"#))
|
.body(axum::body::Body::from(
|
||||||
|
r#"{"error":"Video configuration is being changed. Please retry shortly."}"#,
|
||||||
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1493,7 +1602,8 @@ pub async fn mjpeg_stream(
|
|||||||
let handler = state.stream_manager.mjpeg_handler();
|
let handler = state.stream_manager.mjpeg_handler();
|
||||||
|
|
||||||
// Use provided client ID or generate a new one
|
// Use provided client ID or generate a new one
|
||||||
let client_id = query.client_id
|
let client_id = query
|
||||||
|
.client_id
|
||||||
.filter(|id| !id.is_empty() && id.len() <= 64) // Validate: non-empty, max 64 chars
|
.filter(|id| !id.is_empty() && id.len() <= 64) // Validate: non-empty, max 64 chars
|
||||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||||
|
|
||||||
@@ -1538,10 +1648,8 @@ pub async fn mjpeg_stream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait for new frame notification with timeout
|
// Wait for new frame notification with timeout
|
||||||
let result = tokio::time::timeout(
|
let result =
|
||||||
std::time::Duration::from_secs(5),
|
tokio::time::timeout(std::time::Duration::from_secs(5), notify_rx.recv()).await;
|
||||||
notify_rx.recv()
|
|
||||||
).await;
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
@@ -1622,7 +1730,10 @@ pub async fn mjpeg_stream(
|
|||||||
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, "multipart/x-mixed-replace; boundary=frame")
|
.header(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
"multipart/x-mixed-replace; boundary=frame",
|
||||||
|
)
|
||||||
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
||||||
.header(header::PRAGMA, "no-cache")
|
.header(header::PRAGMA, "no-cache")
|
||||||
.header(header::EXPIRES, "0")
|
.header(header::EXPIRES, "0")
|
||||||
@@ -1636,14 +1747,12 @@ pub async fn snapshot(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
|||||||
let handler = state.stream_manager.mjpeg_handler();
|
let handler = state.stream_manager.mjpeg_handler();
|
||||||
|
|
||||||
match handler.current_frame() {
|
match handler.current_frame() {
|
||||||
Some(frame) if frame.is_valid_jpeg() => {
|
Some(frame) if frame.is_valid_jpeg() => Response::builder()
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, "image/jpeg")
|
.header(header::CONTENT_TYPE, "image/jpeg")
|
||||||
.header(header::CACHE_CONTROL, "no-cache")
|
.header(header::CACHE_CONTROL, "no-cache")
|
||||||
.body(Body::from(frame.data_bytes()))
|
.body(Body::from(frame.data_bytes()))
|
||||||
.unwrap()
|
.unwrap(),
|
||||||
}
|
|
||||||
_ => Response::builder()
|
_ => Response::builder()
|
||||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
.status(StatusCode::SERVICE_UNAVAILABLE)
|
||||||
.body(Body::from("No frame available"))
|
.body(Body::from("No frame available"))
|
||||||
@@ -1674,7 +1783,7 @@ fn create_mjpeg_part(jpeg_data: &[u8]) -> bytes::Bytes {
|
|||||||
// WebRTC
|
// WebRTC
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
use crate::webrtc::signaling::{IceCandidateRequest, OfferRequest, AnswerResponse};
|
use crate::webrtc::signaling::{AnswerResponse, IceCandidateRequest, OfferRequest};
|
||||||
|
|
||||||
/// Create WebRTC session
|
/// Create WebRTC session
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -1692,7 +1801,11 @@ pub async fn webrtc_create_session(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let session_id = state.stream_manager.webrtc_streamer().create_session().await?;
|
let session_id = state
|
||||||
|
.stream_manager
|
||||||
|
.webrtc_streamer()
|
||||||
|
.create_session()
|
||||||
|
.await?;
|
||||||
Ok(Json(CreateSessionResponse { session_id }))
|
Ok(Json(CreateSessionResponse { session_id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1986,7 +2099,9 @@ pub async fn msd_image_upload(
|
|||||||
|
|
||||||
// Use streaming upload - chunks are written directly to disk
|
// Use streaming upload - chunks are written directly to disk
|
||||||
// This avoids loading the entire file into memory
|
// This avoids loading the entire file into memory
|
||||||
let image = manager.create_from_multipart_field(&filename, field).await?;
|
let image = manager
|
||||||
|
.create_from_multipart_field(&filename, field)
|
||||||
|
.await?;
|
||||||
return Ok(Json(image));
|
return Ok(Json(image));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2033,9 +2148,7 @@ pub async fn msd_image_download(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
||||||
|
|
||||||
let progress = controller
|
let progress = controller.download_image(req.url, req.filename).await?;
|
||||||
.download_image(req.url, req.filename)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(progress))
|
Ok(Json(progress))
|
||||||
}
|
}
|
||||||
@@ -2076,9 +2189,9 @@ pub async fn msd_connect(
|
|||||||
|
|
||||||
match req.mode {
|
match req.mode {
|
||||||
MsdMode::Image => {
|
MsdMode::Image => {
|
||||||
let image_id = req
|
let image_id = req.image_id.ok_or_else(|| {
|
||||||
.image_id
|
AppError::BadRequest("image_id required for image mode".to_string())
|
||||||
.ok_or_else(|| AppError::BadRequest("image_id required for image mode".to_string()))?;
|
})?;
|
||||||
|
|
||||||
// Get image info from ImageManager
|
// Get image info from ImageManager
|
||||||
let images_path = std::path::PathBuf::from(&config.msd.images_path);
|
let images_path = std::path::PathBuf::from(&config.msd.images_path);
|
||||||
@@ -2170,9 +2283,8 @@ pub async fn msd_drive_delete(State(state): State<Arc<AppState>>) -> Result<Json
|
|||||||
// Delete the drive file
|
// Delete the drive file
|
||||||
let drive_path = std::path::PathBuf::from(&config.msd.drive_path);
|
let drive_path = std::path::PathBuf::from(&config.msd.drive_path);
|
||||||
if drive_path.exists() {
|
if drive_path.exists() {
|
||||||
std::fs::remove_file(&drive_path).map_err(|e| {
|
std::fs::remove_file(&drive_path)
|
||||||
AppError::Internal(format!("Failed to delete drive file: {}", e))
|
.map_err(|e| AppError::Internal(format!("Failed to delete drive file: {}", e)))?;
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
@@ -2227,7 +2339,9 @@ pub async fn msd_drive_upload(
|
|||||||
|
|
||||||
// Use streaming upload - chunks are written directly to disk
|
// Use streaming upload - chunks are written directly to disk
|
||||||
// This avoids loading the entire file into memory
|
// This avoids loading the entire file into memory
|
||||||
drive.write_file_from_multipart_field(&file_path, field).await?;
|
drive
|
||||||
|
.write_file_from_multipart_field(&file_path, field)
|
||||||
|
.await?;
|
||||||
|
|
||||||
return Ok(Json(LoginResponse {
|
return Ok(Json(LoginResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -2561,7 +2675,10 @@ pub async fn list_users(
|
|||||||
Extension(session): Extension<Session>,
|
Extension(session): Extension<Session>,
|
||||||
) -> Result<Json<Vec<UserResponse>>> {
|
) -> Result<Json<Vec<UserResponse>>> {
|
||||||
// Check if current user is admin
|
// Check if current user is admin
|
||||||
let current_user = state.users.get(&session.user_id).await?
|
let current_user = state
|
||||||
|
.users
|
||||||
|
.get(&session.user_id)
|
||||||
|
.await?
|
||||||
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
|
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
|
||||||
|
|
||||||
if !current_user.is_admin {
|
if !current_user.is_admin {
|
||||||
@@ -2588,7 +2705,10 @@ pub async fn create_user(
|
|||||||
Json(req): Json<CreateUserRequest>,
|
Json(req): Json<CreateUserRequest>,
|
||||||
) -> Result<Json<UserResponse>> {
|
) -> Result<Json<UserResponse>> {
|
||||||
// Check if current user is admin
|
// Check if current user is admin
|
||||||
let current_user = state.users.get(&session.user_id).await?
|
let current_user = state
|
||||||
|
.users
|
||||||
|
.get(&session.user_id)
|
||||||
|
.await?
|
||||||
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
|
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
|
||||||
|
|
||||||
if !current_user.is_admin {
|
if !current_user.is_admin {
|
||||||
@@ -2597,13 +2717,20 @@ pub async fn create_user(
|
|||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
if req.username.len() < 2 {
|
if req.username.len() < 2 {
|
||||||
return Err(AppError::BadRequest("Username must be at least 2 characters".to_string()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Username must be at least 2 characters".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if req.password.len() < 4 {
|
if req.password.len() < 4 {
|
||||||
return Err(AppError::BadRequest("Password must be at least 4 characters".to_string()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Password must be at least 4 characters".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = state.users.create(&req.username, &req.password, req.is_admin).await?;
|
let user = state
|
||||||
|
.users
|
||||||
|
.create(&req.username, &req.password, req.is_admin)
|
||||||
|
.await?;
|
||||||
info!("User created: {} (admin: {})", user.username, user.is_admin);
|
info!("User created: {} (admin: {})", user.username, user.is_admin);
|
||||||
Ok(Json(UserResponse::from(user)))
|
Ok(Json(UserResponse::from(user)))
|
||||||
}
|
}
|
||||||
@@ -2623,7 +2750,10 @@ pub async fn update_user(
|
|||||||
Json(req): Json<UpdateUserRequest>,
|
Json(req): Json<UpdateUserRequest>,
|
||||||
) -> Result<Json<UserResponse>> {
|
) -> Result<Json<UserResponse>> {
|
||||||
// Check if current user is admin
|
// Check if current user is admin
|
||||||
let current_user = state.users.get(&session.user_id).await?
|
let current_user = state
|
||||||
|
.users
|
||||||
|
.get(&session.user_id)
|
||||||
|
.await?
|
||||||
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
|
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
|
||||||
|
|
||||||
if !current_user.is_admin {
|
if !current_user.is_admin {
|
||||||
@@ -2631,13 +2761,18 @@ pub async fn update_user(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get target user
|
// Get target user
|
||||||
let mut user = state.users.get(&user_id).await?
|
let mut user = state
|
||||||
|
.users
|
||||||
|
.get(&user_id)
|
||||||
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
|
||||||
|
|
||||||
// Update fields if provided
|
// Update fields if provided
|
||||||
if let Some(username) = req.username {
|
if let Some(username) = req.username {
|
||||||
if username.len() < 2 {
|
if username.len() < 2 {
|
||||||
return Err(AppError::BadRequest("Username must be at least 2 characters".to_string()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Username must be at least 2 characters".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
user.username = username;
|
user.username = username;
|
||||||
}
|
}
|
||||||
@@ -2647,7 +2782,9 @@ pub async fn update_user(
|
|||||||
|
|
||||||
// Note: We need to add an update method to UserStore
|
// Note: We need to add an update method to UserStore
|
||||||
// For now, return error
|
// For now, return error
|
||||||
Err(AppError::Internal("User update not yet implemented".to_string()))
|
Err(AppError::Internal(
|
||||||
|
"User update not yet implemented".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete user (admin only)
|
/// Delete user (admin only)
|
||||||
@@ -2657,7 +2794,10 @@ pub async fn delete_user(
|
|||||||
Path(user_id): Path<String>,
|
Path(user_id): Path<String>,
|
||||||
) -> Result<Json<LoginResponse>> {
|
) -> Result<Json<LoginResponse>> {
|
||||||
// Check if current user is admin
|
// Check if current user is admin
|
||||||
let current_user = state.users.get(&session.user_id).await?
|
let current_user = state
|
||||||
|
.users
|
||||||
|
.get(&session.user_id)
|
||||||
|
.await?
|
||||||
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
|
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
|
||||||
|
|
||||||
if !current_user.is_admin {
|
if !current_user.is_admin {
|
||||||
@@ -2666,17 +2806,24 @@ pub async fn delete_user(
|
|||||||
|
|
||||||
// Prevent deleting self
|
// Prevent deleting self
|
||||||
if user_id == session.user_id {
|
if user_id == session.user_id {
|
||||||
return Err(AppError::BadRequest("Cannot delete your own account".to_string()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Cannot delete your own account".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is the last admin
|
// Check if this is the last admin
|
||||||
let users = state.users.list().await?;
|
let users = state.users.list().await?;
|
||||||
let admin_count = users.iter().filter(|u| u.is_admin).count();
|
let admin_count = users.iter().filter(|u| u.is_admin).count();
|
||||||
let target_user = state.users.get(&user_id).await?
|
let target_user = state
|
||||||
|
.users
|
||||||
|
.get(&user_id)
|
||||||
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
|
||||||
|
|
||||||
if target_user.is_admin && admin_count <= 1 {
|
if target_user.is_admin && admin_count <= 1 {
|
||||||
return Err(AppError::BadRequest("Cannot delete the last admin user".to_string()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Cannot delete the last admin user".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
state.users.delete(&user_id).await?;
|
state.users.delete(&user_id).await?;
|
||||||
@@ -2703,30 +2850,45 @@ pub async fn change_user_password(
|
|||||||
Json(req): Json<ChangePasswordRequest>,
|
Json(req): Json<ChangePasswordRequest>,
|
||||||
) -> Result<Json<LoginResponse>> {
|
) -> Result<Json<LoginResponse>> {
|
||||||
// Check if current user is admin or changing own password
|
// Check if current user is admin or changing own password
|
||||||
let current_user = state.users.get(&session.user_id).await?
|
let current_user = state
|
||||||
|
.users
|
||||||
|
.get(&session.user_id)
|
||||||
|
.await?
|
||||||
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
|
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
|
||||||
|
|
||||||
let is_self = user_id == session.user_id;
|
let is_self = user_id == session.user_id;
|
||||||
let is_admin = current_user.is_admin;
|
let is_admin = current_user.is_admin;
|
||||||
|
|
||||||
if !is_self && !is_admin {
|
if !is_self && !is_admin {
|
||||||
return Err(AppError::Forbidden("Cannot change other user's password".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Cannot change other user's password".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate new password
|
// Validate new password
|
||||||
if req.new_password.len() < 4 {
|
if req.new_password.len() < 4 {
|
||||||
return Err(AppError::BadRequest("Password must be at least 4 characters".to_string()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Password must be at least 4 characters".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If changing own password, verify current password
|
// If changing own password, verify current password
|
||||||
if is_self {
|
if is_self {
|
||||||
let verified = state.users.verify(¤t_user.username, &req.current_password).await?;
|
let verified = state
|
||||||
|
.users
|
||||||
|
.verify(¤t_user.username, &req.current_password)
|
||||||
|
.await?;
|
||||||
if verified.is_none() {
|
if verified.is_none() {
|
||||||
return Err(AppError::AuthError("Current password is incorrect".to_string()));
|
return Err(AppError::AuthError(
|
||||||
|
"Current password is incorrect".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.users.update_password(&user_id, &req.new_password).await?;
|
state
|
||||||
|
.users
|
||||||
|
.update_password(&user_id, &req.new_password)
|
||||||
|
.await?;
|
||||||
info!("Password changed for user ID: {}", user_id);
|
info!("Password changed for user ID: {}", user_id);
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ use std::sync::Arc;
|
|||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
use tokio_tungstenite::tungstenite::{
|
use tokio_tungstenite::tungstenite::{
|
||||||
client::IntoClientRequest,
|
client::IntoClientRequest, http::HeaderValue, Message as TungsteniteMessage,
|
||||||
http::HeaderValue,
|
|
||||||
Message as TungsteniteMessage,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@@ -60,10 +58,9 @@ async fn handle_terminal_websocket(client_ws: WebSocket, query_string: String) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
request.headers_mut().insert(
|
request
|
||||||
"Sec-WebSocket-Protocol",
|
.headers_mut()
|
||||||
HeaderValue::from_static("tty"),
|
.insert("Sec-WebSocket-Protocol", HeaderValue::from_static("tty"));
|
||||||
);
|
|
||||||
|
|
||||||
// Create WebSocket connection to ttyd
|
// Create WebSocket connection to ttyd
|
||||||
let ws_stream = match tokio_tungstenite::client_async(request, unix_stream).await {
|
let ws_stream = match tokio_tungstenite::client_async(request, unix_stream).await {
|
||||||
@@ -143,7 +140,11 @@ pub async fn terminal_proxy(
|
|||||||
|
|
||||||
// Build HTTP request to forward
|
// Build HTTP request to forward
|
||||||
let method = req.method().as_str();
|
let method = req.method().as_str();
|
||||||
let query = req.uri().query().map(|q| format!("?{}", q)).unwrap_or_default();
|
let query = req
|
||||||
|
.uri()
|
||||||
|
.query()
|
||||||
|
.map(|q| format!("?{}", q))
|
||||||
|
.unwrap_or_default();
|
||||||
let uri_path = if path_str.is_empty() {
|
let uri_path = if path_str.is_empty() {
|
||||||
format!("/api/terminal/{}", query)
|
format!("/api/terminal/{}", query)
|
||||||
} else {
|
} else {
|
||||||
@@ -203,7 +204,8 @@ pub async fn terminal_proxy(
|
|||||||
.unwrap_or(200);
|
.unwrap_or(200);
|
||||||
|
|
||||||
// Build response
|
// Build response
|
||||||
let mut builder = Response::builder().status(StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK));
|
let mut builder =
|
||||||
|
Response::builder().status(StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK));
|
||||||
|
|
||||||
// Forward response headers
|
// Forward response headers
|
||||||
for line in headers_part.lines().skip(1) {
|
for line in headers_part.lines().skip(1) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
mod audio_ws;
|
mod audio_ws;
|
||||||
mod routes;
|
|
||||||
mod handlers;
|
mod handlers;
|
||||||
|
mod routes;
|
||||||
mod static_files;
|
mod static_files;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
|
|||||||
@@ -78,9 +78,15 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/config", get(handlers::config::get_all_config))
|
.route("/config", get(handlers::config::get_all_config))
|
||||||
.route("/config", post(handlers::update_config))
|
.route("/config", post(handlers::update_config))
|
||||||
.route("/config/video", get(handlers::config::get_video_config))
|
.route("/config/video", get(handlers::config::get_video_config))
|
||||||
.route("/config/video", patch(handlers::config::update_video_config))
|
.route(
|
||||||
|
"/config/video",
|
||||||
|
patch(handlers::config::update_video_config),
|
||||||
|
)
|
||||||
.route("/config/stream", get(handlers::config::get_stream_config))
|
.route("/config/stream", get(handlers::config::get_stream_config))
|
||||||
.route("/config/stream", patch(handlers::config::update_stream_config))
|
.route(
|
||||||
|
"/config/stream",
|
||||||
|
patch(handlers::config::update_stream_config),
|
||||||
|
)
|
||||||
.route("/config/hid", get(handlers::config::get_hid_config))
|
.route("/config/hid", get(handlers::config::get_hid_config))
|
||||||
.route("/config/hid", patch(handlers::config::update_hid_config))
|
.route("/config/hid", patch(handlers::config::update_hid_config))
|
||||||
.route("/config/msd", get(handlers::config::get_msd_config))
|
.route("/config/msd", get(handlers::config::get_msd_config))
|
||||||
@@ -88,14 +94,35 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/config/atx", get(handlers::config::get_atx_config))
|
.route("/config/atx", get(handlers::config::get_atx_config))
|
||||||
.route("/config/atx", patch(handlers::config::update_atx_config))
|
.route("/config/atx", patch(handlers::config::update_atx_config))
|
||||||
.route("/config/audio", get(handlers::config::get_audio_config))
|
.route("/config/audio", get(handlers::config::get_audio_config))
|
||||||
.route("/config/audio", patch(handlers::config::update_audio_config))
|
.route(
|
||||||
|
"/config/audio",
|
||||||
|
patch(handlers::config::update_audio_config),
|
||||||
|
)
|
||||||
// RustDesk configuration endpoints
|
// RustDesk configuration endpoints
|
||||||
.route("/config/rustdesk", get(handlers::config::get_rustdesk_config))
|
.route(
|
||||||
.route("/config/rustdesk", patch(handlers::config::update_rustdesk_config))
|
"/config/rustdesk",
|
||||||
.route("/config/rustdesk/status", get(handlers::config::get_rustdesk_status))
|
get(handlers::config::get_rustdesk_config),
|
||||||
.route("/config/rustdesk/password", get(handlers::config::get_device_password))
|
)
|
||||||
.route("/config/rustdesk/regenerate-id", post(handlers::config::regenerate_device_id))
|
.route(
|
||||||
.route("/config/rustdesk/regenerate-password", post(handlers::config::regenerate_device_password))
|
"/config/rustdesk",
|
||||||
|
patch(handlers::config::update_rustdesk_config),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/rustdesk/status",
|
||||||
|
get(handlers::config::get_rustdesk_status),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/rustdesk/password",
|
||||||
|
get(handlers::config::get_device_password),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/rustdesk/regenerate-id",
|
||||||
|
post(handlers::config::regenerate_device_id),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/rustdesk/regenerate-password",
|
||||||
|
post(handlers::config::regenerate_device_password),
|
||||||
|
)
|
||||||
// Web server configuration
|
// Web server configuration
|
||||||
.route("/config/web", get(handlers::config::get_web_config))
|
.route("/config/web", get(handlers::config::get_web_config))
|
||||||
.route("/config/web", patch(handlers::config::update_web_config))
|
.route("/config/web", patch(handlers::config::update_web_config))
|
||||||
@@ -105,7 +132,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/msd/status", get(handlers::msd_status))
|
.route("/msd/status", get(handlers::msd_status))
|
||||||
.route("/msd/images", get(handlers::msd_images_list))
|
.route("/msd/images", get(handlers::msd_images_list))
|
||||||
.route("/msd/images/download", post(handlers::msd_image_download))
|
.route("/msd/images/download", post(handlers::msd_image_download))
|
||||||
.route("/msd/images/download/cancel", post(handlers::msd_image_download_cancel))
|
.route(
|
||||||
|
"/msd/images/download/cancel",
|
||||||
|
post(handlers::msd_image_download_cancel),
|
||||||
|
)
|
||||||
.route("/msd/images/{id}", get(handlers::msd_image_get))
|
.route("/msd/images/{id}", get(handlers::msd_image_get))
|
||||||
.route("/msd/images/{id}", delete(handlers::msd_image_delete))
|
.route("/msd/images/{id}", delete(handlers::msd_image_delete))
|
||||||
.route("/msd/connect", post(handlers::msd_connect))
|
.route("/msd/connect", post(handlers::msd_connect))
|
||||||
@@ -115,8 +145,14 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/msd/drive", delete(handlers::msd_drive_delete))
|
.route("/msd/drive", delete(handlers::msd_drive_delete))
|
||||||
.route("/msd/drive/init", post(handlers::msd_drive_init))
|
.route("/msd/drive/init", post(handlers::msd_drive_init))
|
||||||
.route("/msd/drive/files", get(handlers::msd_drive_files))
|
.route("/msd/drive/files", get(handlers::msd_drive_files))
|
||||||
.route("/msd/drive/files/{*path}", get(handlers::msd_drive_download))
|
.route(
|
||||||
.route("/msd/drive/files/{*path}", delete(handlers::msd_drive_file_delete))
|
"/msd/drive/files/{*path}",
|
||||||
|
get(handlers::msd_drive_download),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/msd/drive/files/{*path}",
|
||||||
|
delete(handlers::msd_drive_file_delete),
|
||||||
|
)
|
||||||
.route("/msd/drive/mkdir/{*path}", post(handlers::msd_drive_mkdir))
|
.route("/msd/drive/mkdir/{*path}", post(handlers::msd_drive_mkdir))
|
||||||
// ATX (Power Control) endpoints
|
// ATX (Power Control) endpoints
|
||||||
.route("/atx/status", get(handlers::atx_status))
|
.route("/atx/status", get(handlers::atx_status))
|
||||||
@@ -132,13 +168,34 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
// Extension management endpoints
|
// Extension management endpoints
|
||||||
.route("/extensions", get(handlers::extensions::list_extensions))
|
.route("/extensions", get(handlers::extensions::list_extensions))
|
||||||
.route("/extensions/{id}", get(handlers::extensions::get_extension))
|
.route("/extensions/{id}", get(handlers::extensions::get_extension))
|
||||||
.route("/extensions/{id}/start", post(handlers::extensions::start_extension))
|
.route(
|
||||||
.route("/extensions/{id}/stop", post(handlers::extensions::stop_extension))
|
"/extensions/{id}/start",
|
||||||
.route("/extensions/{id}/logs", get(handlers::extensions::get_extension_logs))
|
post(handlers::extensions::start_extension),
|
||||||
.route("/extensions/ttyd/config", patch(handlers::extensions::update_ttyd_config))
|
)
|
||||||
.route("/extensions/ttyd/status", get(handlers::extensions::get_ttyd_status))
|
.route(
|
||||||
.route("/extensions/gostc/config", patch(handlers::extensions::update_gostc_config))
|
"/extensions/{id}/stop",
|
||||||
.route("/extensions/easytier/config", patch(handlers::extensions::update_easytier_config))
|
post(handlers::extensions::stop_extension),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/extensions/{id}/logs",
|
||||||
|
get(handlers::extensions::get_extension_logs),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/extensions/ttyd/config",
|
||||||
|
patch(handlers::extensions::update_ttyd_config),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/extensions/ttyd/status",
|
||||||
|
get(handlers::extensions::get_ttyd_status),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/extensions/gostc/config",
|
||||||
|
patch(handlers::extensions::update_gostc_config),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/extensions/easytier/config",
|
||||||
|
patch(handlers::extensions::update_easytier_config),
|
||||||
|
)
|
||||||
// Terminal (ttyd) reverse proxy - WebSocket and HTTP
|
// Terminal (ttyd) reverse proxy - WebSocket and HTTP
|
||||||
.route("/terminal", get(handlers::terminal::terminal_index))
|
.route("/terminal", get(handlers::terminal::terminal_index))
|
||||||
.route("/terminal/", get(handlers::terminal::terminal_index))
|
.route("/terminal/", get(handlers::terminal::terminal_index))
|
||||||
@@ -148,9 +205,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
.layer(middleware::from_fn_with_state(state.clone(), require_admin));
|
.layer(middleware::from_fn_with_state(state.clone(), require_admin));
|
||||||
|
|
||||||
// Combine protected routes (user + admin)
|
// Combine protected routes (user + admin)
|
||||||
let protected_routes = Router::new()
|
let protected_routes = Router::new().merge(user_routes).merge(admin_routes);
|
||||||
.merge(user_routes)
|
|
||||||
.merge(admin_routes);
|
|
||||||
|
|
||||||
// Stream endpoints (accessible with auth, but typically embedded in pages)
|
// Stream endpoints (accessible with auth, but typically embedded in pages)
|
||||||
let stream_routes = Router::new()
|
let stream_routes = Router::new()
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ pub struct StaticAssets;
|
|||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
fn get_static_base_dir() -> PathBuf {
|
fn get_static_base_dir() -> PathBuf {
|
||||||
static BASE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
static BASE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
BASE_DIR.get_or_init(|| {
|
BASE_DIR
|
||||||
|
.get_or_init(|| {
|
||||||
// Try to get executable directory
|
// Try to get executable directory
|
||||||
if let Ok(exe_path) = std::env::current_exe() {
|
if let Ok(exe_path) = std::env::current_exe() {
|
||||||
if let Some(exe_dir) = exe_path.parent() {
|
if let Some(exe_dir) = exe_path.parent() {
|
||||||
@@ -35,7 +36,8 @@ fn get_static_base_dir() -> PathBuf {
|
|||||||
}
|
}
|
||||||
// Fallback to current directory
|
// Fallback to current directory
|
||||||
PathBuf::from("web/dist")
|
PathBuf::from("web/dist")
|
||||||
}).clone()
|
})
|
||||||
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create router for static file serving
|
/// Create router for static file serving
|
||||||
|
|||||||
@@ -228,7 +228,8 @@ impl H265Payloader {
|
|||||||
let fragment_size = remaining.min(max_fragment_size);
|
let fragment_size = remaining.min(max_fragment_size);
|
||||||
|
|
||||||
// Create FU packet
|
// Create FU packet
|
||||||
let mut packet = BytesMut::with_capacity(H265_NAL_HEADER_SIZE + H265_FU_HEADER_SIZE + fragment_size);
|
let mut packet =
|
||||||
|
BytesMut::with_capacity(H265_NAL_HEADER_SIZE + H265_FU_HEADER_SIZE + fragment_size);
|
||||||
|
|
||||||
// NAL header for FU (2 bytes)
|
// NAL header for FU (2 bytes)
|
||||||
// Preserve F bit (bit 7) and LayerID MSB (bit 0) from original, set Type to 49
|
// Preserve F bit (bit 7) and LayerID MSB (bit 0) from original, set Type to 49
|
||||||
|
|||||||
@@ -42,5 +42,7 @@ pub use rtp::{H264VideoTrack, H264VideoTrackConfig, OpusAudioTrack};
|
|||||||
pub use session::WebRtcSessionManager;
|
pub use session::WebRtcSessionManager;
|
||||||
pub use signaling::{ConnectionState, IceCandidate, SdpAnswer, SdpOffer, SignalingMessage};
|
pub use signaling::{ConnectionState, IceCandidate, SdpAnswer, SdpOffer, SignalingMessage};
|
||||||
pub use universal_session::{UniversalSession, UniversalSessionConfig, UniversalSessionInfo};
|
pub use universal_session::{UniversalSession, UniversalSessionConfig, UniversalSessionInfo};
|
||||||
pub use video_track::{UniversalVideoTrack, UniversalVideoTrackConfig, VideoCodec, VideoTrackStats};
|
pub use video_track::{
|
||||||
|
UniversalVideoTrack, UniversalVideoTrackConfig, VideoCodec, VideoTrackStats,
|
||||||
|
};
|
||||||
pub use webrtc_streamer::{SessionInfo, WebRtcStreamer, WebRtcStreamerConfig, WebRtcStreamerStats};
|
pub use webrtc_streamer::{SessionInfo, WebRtcStreamer, WebRtcStreamerConfig, WebRtcStreamerStats};
|
||||||
|
|||||||
@@ -92,10 +92,9 @@ impl PeerConnection {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create peer connection
|
// Create peer connection
|
||||||
let pc = api
|
let pc = api.new_peer_connection(rtc_config).await.map_err(|e| {
|
||||||
.new_peer_connection(rtc_config)
|
AppError::VideoError(format!("Failed to create peer connection: {}", e))
|
||||||
.await
|
})?;
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to create peer connection: {}", e)))?;
|
|
||||||
|
|
||||||
let pc = Arc::new(pc);
|
let pc = Arc::new(pc);
|
||||||
|
|
||||||
@@ -125,7 +124,8 @@ impl PeerConnection {
|
|||||||
let session_id = self.session_id.clone();
|
let session_id = self.session_id.clone();
|
||||||
|
|
||||||
// Connection state change handler
|
// Connection state change handler
|
||||||
self.pc.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| {
|
self.pc
|
||||||
|
.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| {
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let session_id = session_id.clone();
|
let session_id = session_id.clone();
|
||||||
|
|
||||||
@@ -147,14 +147,13 @@ impl PeerConnection {
|
|||||||
|
|
||||||
// ICE candidate handler
|
// ICE candidate handler
|
||||||
let ice_candidates = self.ice_candidates.clone();
|
let ice_candidates = self.ice_candidates.clone();
|
||||||
self.pc.on_ice_candidate(Box::new(move |candidate: Option<RTCIceCandidate>| {
|
self.pc
|
||||||
|
.on_ice_candidate(Box::new(move |candidate: Option<RTCIceCandidate>| {
|
||||||
let ice_candidates = ice_candidates.clone();
|
let ice_candidates = ice_candidates.clone();
|
||||||
|
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if let Some(c) = candidate {
|
if let Some(c) = candidate {
|
||||||
let candidate_str = c.to_json()
|
let candidate_str = c.to_json().map(|j| j.candidate).unwrap_or_default();
|
||||||
.map(|j| j.candidate)
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
debug!("ICE candidate: {}", candidate_str);
|
debug!("ICE candidate: {}", candidate_str);
|
||||||
|
|
||||||
@@ -171,7 +170,8 @@ impl PeerConnection {
|
|||||||
|
|
||||||
// Data channel handler - note: HID processing is done when hid_controller is set
|
// Data channel handler - note: HID processing is done when hid_controller is set
|
||||||
let data_channel = self.data_channel.clone();
|
let data_channel = self.data_channel.clone();
|
||||||
self.pc.on_data_channel(Box::new(move |dc: Arc<RTCDataChannel>| {
|
self.pc
|
||||||
|
.on_data_channel(Box::new(move |dc: Arc<RTCDataChannel>| {
|
||||||
let data_channel = data_channel.clone();
|
let data_channel = data_channel.clone();
|
||||||
|
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
@@ -206,7 +206,11 @@ impl PeerConnection {
|
|||||||
let is_hid_channel = label == "hid" || label == "hid-unreliable";
|
let is_hid_channel = label == "hid" || label == "hid-unreliable";
|
||||||
|
|
||||||
if is_hid_channel {
|
if is_hid_channel {
|
||||||
info!("HID DataChannel opened: {} (unreliable: {})", label, label == "hid-unreliable");
|
info!(
|
||||||
|
"HID DataChannel opened: {} (unreliable: {})",
|
||||||
|
label,
|
||||||
|
label == "hid-unreliable"
|
||||||
|
);
|
||||||
|
|
||||||
// Store the reliable data channel for sending responses
|
// Store the reliable data channel for sending responses
|
||||||
if label == "hid" {
|
if label == "hid" {
|
||||||
@@ -291,10 +295,9 @@ impl PeerConnection {
|
|||||||
let sdp = RTCSessionDescription::offer(offer.sdp)
|
let sdp = RTCSessionDescription::offer(offer.sdp)
|
||||||
.map_err(|e| AppError::VideoError(format!("Invalid SDP offer: {}", e)))?;
|
.map_err(|e| AppError::VideoError(format!("Invalid SDP offer: {}", e)))?;
|
||||||
|
|
||||||
self.pc
|
self.pc.set_remote_description(sdp).await.map_err(|e| {
|
||||||
.set_remote_description(sdp)
|
AppError::VideoError(format!("Failed to set remote description: {}", e))
|
||||||
.await
|
})?;
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to set remote description: {}", e)))?;
|
|
||||||
|
|
||||||
// Create answer
|
// Create answer
|
||||||
let answer = self
|
let answer = self
|
||||||
@@ -373,7 +376,11 @@ impl PeerConnection {
|
|||||||
// Reset HID state to release any held keys/buttons
|
// Reset HID state to release any held keys/buttons
|
||||||
if let Some(ref hid) = self.hid_controller {
|
if let Some(ref hid) = self.hid_controller {
|
||||||
if let Err(e) = hid.reset().await {
|
if let Err(e) = hid.reset().await {
|
||||||
tracing::warn!("Failed to reset HID on peer {} close: {}", self.session_id, e);
|
tracing::warn!(
|
||||||
|
"Failed to reset HID on peer {} close: {}",
|
||||||
|
self.session_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!("HID reset on peer {} close", self.session_id);
|
tracing::debug!("HID reset on peer {} close", self.session_id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ use tokio::sync::Mutex;
|
|||||||
use tracing::{debug, error, trace};
|
use tracing::{debug, error, trace};
|
||||||
use webrtc::media::io::h264_reader::H264Reader;
|
use webrtc::media::io::h264_reader::H264Reader;
|
||||||
use webrtc::media::Sample;
|
use webrtc::media::Sample;
|
||||||
|
use webrtc::rtp_transceiver::rtp_codec::RTCRtpCodecCapability;
|
||||||
use webrtc::track::track_local::track_local_static_sample::TrackLocalStaticSample;
|
use webrtc::track::track_local::track_local_static_sample::TrackLocalStaticSample;
|
||||||
use webrtc::track::track_local::TrackLocal;
|
use webrtc::track::track_local::TrackLocal;
|
||||||
use webrtc::rtp_transceiver::rtp_codec::RTCRtpCodecCapability;
|
|
||||||
|
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::video::format::Resolution;
|
use crate::video::format::Resolution;
|
||||||
@@ -168,7 +168,12 @@ impl H264VideoTrack {
|
|||||||
/// * `data` - H264 Annex B encoded frame data
|
/// * `data` - H264 Annex B encoded frame data
|
||||||
/// * `duration` - Frame duration (typically 1/fps seconds)
|
/// * `duration` - Frame duration (typically 1/fps seconds)
|
||||||
/// * `is_keyframe` - Whether this is a keyframe (IDR frame)
|
/// * `is_keyframe` - Whether this is a keyframe (IDR frame)
|
||||||
pub async fn write_frame(&self, data: &[u8], _duration: Duration, is_keyframe: bool) -> Result<()> {
|
pub async fn write_frame(
|
||||||
|
&self,
|
||||||
|
data: &[u8],
|
||||||
|
_duration: Duration,
|
||||||
|
is_keyframe: bool,
|
||||||
|
) -> Result<()> {
|
||||||
if data.is_empty() {
|
if data.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -324,9 +329,9 @@ impl H264VideoTrack {
|
|||||||
let mut payloader = self.payloader.lock().await;
|
let mut payloader = self.payloader.lock().await;
|
||||||
let bytes = Bytes::copy_from_slice(data);
|
let bytes = Bytes::copy_from_slice(data);
|
||||||
|
|
||||||
payloader.payload(mtu, &bytes).map_err(|e| {
|
payloader
|
||||||
AppError::VideoError(format!("H264 packetization failed: {}", e))
|
.payload(mtu, &bytes)
|
||||||
})
|
.map_err(|e| AppError::VideoError(format!("H264 packetization failed: {}", e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get configuration
|
/// Get configuration
|
||||||
@@ -423,7 +428,10 @@ impl OpusAudioTrack {
|
|||||||
let mut stats = self.stats.lock().await;
|
let mut stats = self.stats.lock().await;
|
||||||
stats.errors += 1;
|
stats.errors += 1;
|
||||||
error!("Failed to write Opus sample: {}", e);
|
error!("Failed to write Opus sample: {}", e);
|
||||||
Err(AppError::WebRtcError(format!("Failed to write audio sample: {}", e)))
|
Err(AppError::WebRtcError(format!(
|
||||||
|
"Failed to write audio sample: {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ use std::sync::Arc;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::sync::{broadcast, watch, Mutex};
|
use tokio::sync::{broadcast, watch, Mutex};
|
||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error, info};
|
||||||
|
use webrtc::rtp_transceiver::rtp_codec::RTCRtpCodecCapability;
|
||||||
use webrtc::track::track_local::track_local_static_rtp::TrackLocalStaticRTP;
|
use webrtc::track::track_local::track_local_static_rtp::TrackLocalStaticRTP;
|
||||||
use webrtc::track::track_local::TrackLocalWriter;
|
use webrtc::track::track_local::TrackLocalWriter;
|
||||||
use webrtc::rtp_transceiver::rtp_codec::RTCRtpCodecCapability;
|
|
||||||
|
|
||||||
use crate::video::frame::VideoFrame;
|
use crate::video::frame::VideoFrame;
|
||||||
|
|
||||||
@@ -56,7 +56,9 @@ impl VideoCodecType {
|
|||||||
|
|
||||||
pub fn sdp_fmtp(&self) -> &'static str {
|
pub fn sdp_fmtp(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
VideoCodecType::H264 => "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
|
VideoCodecType::H264 => {
|
||||||
|
"level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f"
|
||||||
|
}
|
||||||
VideoCodecType::VP8 => "",
|
VideoCodecType::VP8 => "",
|
||||||
VideoCodecType::VP9 => "profile-id=0",
|
VideoCodecType::VP9 => "profile-id=0",
|
||||||
}
|
}
|
||||||
@@ -156,10 +158,7 @@ impl VideoTrack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start sending frames from a broadcast receiver
|
/// Start sending frames from a broadcast receiver
|
||||||
pub async fn start_sending(
|
pub async fn start_sending(&self, mut frame_rx: broadcast::Receiver<VideoFrame>) {
|
||||||
&self,
|
|
||||||
mut frame_rx: broadcast::Receiver<VideoFrame>,
|
|
||||||
) {
|
|
||||||
let _ = self.running.send(true);
|
let _ = self.running.send(true);
|
||||||
let track = self.track.clone();
|
let track = self.track.clone();
|
||||||
let stats = self.stats.clone();
|
let stats = self.stats.clone();
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ use webrtc::peer_connection::configuration::RTCConfiguration;
|
|||||||
use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState;
|
use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState;
|
||||||
use webrtc::peer_connection::sdp::session_description::RTCSessionDescription;
|
use webrtc::peer_connection::sdp::session_description::RTCSessionDescription;
|
||||||
use webrtc::peer_connection::RTCPeerConnection;
|
use webrtc::peer_connection::RTCPeerConnection;
|
||||||
use webrtc::rtp_transceiver::rtp_codec::{RTCRtpCodecCapability, RTCRtpCodecParameters, RTPCodecType};
|
use webrtc::rtp_transceiver::rtp_codec::{
|
||||||
|
RTCRtpCodecCapability, RTCRtpCodecParameters, RTPCodecType,
|
||||||
|
};
|
||||||
use webrtc::rtp_transceiver::RTCPFeedback;
|
use webrtc::rtp_transceiver::RTCPFeedback;
|
||||||
|
|
||||||
use super::config::WebRtcConfig;
|
use super::config::WebRtcConfig;
|
||||||
@@ -192,7 +194,8 @@ impl UniversalSession {
|
|||||||
clock_rate: 90000,
|
clock_rate: 90000,
|
||||||
channels: 0,
|
channels: 0,
|
||||||
// Match browser's fmtp format for profile-id=1
|
// Match browser's fmtp format for profile-id=1
|
||||||
sdp_fmtp_line: "level-id=180;profile-id=1;tier-flag=0;tx-mode=SRST".to_owned(),
|
sdp_fmtp_line: "level-id=180;profile-id=1;tier-flag=0;tx-mode=SRST"
|
||||||
|
.to_owned(),
|
||||||
rtcp_feedback: video_rtcp_feedback.clone(),
|
rtcp_feedback: video_rtcp_feedback.clone(),
|
||||||
},
|
},
|
||||||
payload_type: 49, // Use same payload type as browser
|
payload_type: 49, // Use same payload type as browser
|
||||||
@@ -200,7 +203,9 @@ impl UniversalSession {
|
|||||||
},
|
},
|
||||||
RTPCodecType::Video,
|
RTPCodecType::Video,
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to register H.265 codec: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
AppError::VideoError(format!("Failed to register H.265 codec: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
// Also register profile-id=2 (Main 10) variant
|
// Also register profile-id=2 (Main 10) variant
|
||||||
media_engine
|
media_engine
|
||||||
@@ -210,7 +215,8 @@ impl UniversalSession {
|
|||||||
mime_type: MIME_TYPE_H265.to_owned(),
|
mime_type: MIME_TYPE_H265.to_owned(),
|
||||||
clock_rate: 90000,
|
clock_rate: 90000,
|
||||||
channels: 0,
|
channels: 0,
|
||||||
sdp_fmtp_line: "level-id=180;profile-id=2;tier-flag=0;tx-mode=SRST".to_owned(),
|
sdp_fmtp_line: "level-id=180;profile-id=2;tier-flag=0;tx-mode=SRST"
|
||||||
|
.to_owned(),
|
||||||
rtcp_feedback: video_rtcp_feedback,
|
rtcp_feedback: video_rtcp_feedback,
|
||||||
},
|
},
|
||||||
payload_type: 51,
|
payload_type: 51,
|
||||||
@@ -218,7 +224,12 @@ impl UniversalSession {
|
|||||||
},
|
},
|
||||||
RTPCodecType::Video,
|
RTPCodecType::Video,
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to register H.265 codec (profile 2): {}", e)))?;
|
.map_err(|e| {
|
||||||
|
AppError::VideoError(format!(
|
||||||
|
"Failed to register H.265 codec (profile 2): {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
info!("Registered H.265/HEVC codec for session {}", session_id);
|
info!("Registered H.265/HEVC codec for session {}", session_id);
|
||||||
}
|
}
|
||||||
@@ -269,10 +280,9 @@ impl UniversalSession {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let pc = api
|
let pc = api.new_peer_connection(rtc_config).await.map_err(|e| {
|
||||||
.new_peer_connection(rtc_config)
|
AppError::VideoError(format!("Failed to create peer connection: {}", e))
|
||||||
.await
|
})?;
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to create peer connection: {}", e)))?;
|
|
||||||
|
|
||||||
let pc = Arc::new(pc);
|
let pc = Arc::new(pc);
|
||||||
|
|
||||||
@@ -291,7 +301,10 @@ impl UniversalSession {
|
|||||||
pc.add_track(audio.as_track_local())
|
pc.add_track(audio.as_track_local())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to add audio track: {}", e)))?;
|
.map_err(|e| AppError::AudioError(format!("Failed to add audio track: {}", e)))?;
|
||||||
info!("Opus audio track added to peer connection (session {})", session_id);
|
info!(
|
||||||
|
"Opus audio track added to peer connection (session {})",
|
||||||
|
session_id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create state channel
|
// Create state channel
|
||||||
@@ -479,11 +492,13 @@ impl UniversalSession {
|
|||||||
&self,
|
&self,
|
||||||
mut frame_rx: broadcast::Receiver<EncodedVideoFrame>,
|
mut frame_rx: broadcast::Receiver<EncodedVideoFrame>,
|
||||||
on_connected: F,
|
on_connected: F,
|
||||||
)
|
) where
|
||||||
where
|
|
||||||
F: FnOnce() + Send + 'static,
|
F: FnOnce() + Send + 'static,
|
||||||
{
|
{
|
||||||
info!("Starting {} session {} with shared encoder", self.codec, self.session_id);
|
info!(
|
||||||
|
"Starting {} session {} with shared encoder",
|
||||||
|
self.codec, self.session_id
|
||||||
|
);
|
||||||
|
|
||||||
let video_track = self.video_track.clone();
|
let video_track = self.video_track.clone();
|
||||||
let mut state_rx = self.state_rx.clone();
|
let mut state_rx = self.state_rx.clone();
|
||||||
@@ -492,7 +507,10 @@ impl UniversalSession {
|
|||||||
let expected_codec = self.codec;
|
let expected_codec = self.codec;
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
info!("Video receiver waiting for connection for session {}", session_id);
|
info!(
|
||||||
|
"Video receiver waiting for connection for session {}",
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
|
||||||
// Wait for Connected state before sending frames
|
// Wait for Connected state before sending frames
|
||||||
loop {
|
loop {
|
||||||
@@ -500,7 +518,10 @@ impl UniversalSession {
|
|||||||
if current_state == ConnectionState::Connected {
|
if current_state == ConnectionState::Connected {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if matches!(current_state, ConnectionState::Closed | ConnectionState::Failed) {
|
if matches!(
|
||||||
|
current_state,
|
||||||
|
ConnectionState::Closed | ConnectionState::Failed
|
||||||
|
) {
|
||||||
info!("Session {} closed before connecting", session_id);
|
info!("Session {} closed before connecting", session_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -509,7 +530,10 @@ impl UniversalSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Video receiver started for session {} (ICE connected)", session_id);
|
info!(
|
||||||
|
"Video receiver started for session {} (ICE connected)",
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
|
||||||
// Request keyframe now that connection is established
|
// Request keyframe now that connection is established
|
||||||
on_connected();
|
on_connected();
|
||||||
@@ -592,7 +616,10 @@ impl UniversalSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Video receiver stopped for session {} (sent {} frames)", session_id, frames_sent);
|
info!(
|
||||||
|
"Video receiver stopped for session {} (sent {} frames)",
|
||||||
|
session_id, frames_sent
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
*self.video_receiver_handle.lock().await = Some(handle);
|
*self.video_receiver_handle.lock().await = Some(handle);
|
||||||
@@ -620,7 +647,10 @@ impl UniversalSession {
|
|||||||
if current_state == ConnectionState::Connected {
|
if current_state == ConnectionState::Connected {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if matches!(current_state, ConnectionState::Closed | ConnectionState::Failed) {
|
if matches!(
|
||||||
|
current_state,
|
||||||
|
ConnectionState::Closed | ConnectionState::Failed
|
||||||
|
) {
|
||||||
info!("Session {} closed before audio could start", session_id);
|
info!("Session {} closed before audio could start", session_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -629,7 +659,10 @@ impl UniversalSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Audio receiver started for session {} (ICE connected)", session_id);
|
info!(
|
||||||
|
"Audio receiver started for session {} (ICE connected)",
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
|
||||||
let mut packets_sent: u64 = 0;
|
let mut packets_sent: u64 = 0;
|
||||||
|
|
||||||
@@ -673,7 +706,10 @@ impl UniversalSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Audio receiver stopped for session {} (sent {} packets)", session_id, packets_sent);
|
info!(
|
||||||
|
"Audio receiver stopped for session {} (sent {} packets)",
|
||||||
|
session_id, packets_sent
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
*self.audio_receiver_handle.lock().await = Some(handle);
|
*self.audio_receiver_handle.lock().await = Some(handle);
|
||||||
@@ -697,8 +733,7 @@ impl UniversalSession {
|
|||||||
|| offer.sdp.to_lowercase().contains("hevc");
|
|| offer.sdp.to_lowercase().contains("hevc");
|
||||||
info!(
|
info!(
|
||||||
"[SDP] Session {} offer contains H.265: {}",
|
"[SDP] Session {} offer contains H.265: {}",
|
||||||
self.session_id,
|
self.session_id, has_h265
|
||||||
has_h265
|
|
||||||
);
|
);
|
||||||
if !has_h265 {
|
if !has_h265 {
|
||||||
warn!("[SDP] Browser offer does not include H.265 codec! Session may fail.");
|
warn!("[SDP] Browser offer does not include H.265 codec! Session may fail.");
|
||||||
@@ -708,10 +743,9 @@ impl UniversalSession {
|
|||||||
let sdp = RTCSessionDescription::offer(offer.sdp)
|
let sdp = RTCSessionDescription::offer(offer.sdp)
|
||||||
.map_err(|e| AppError::VideoError(format!("Invalid SDP offer: {}", e)))?;
|
.map_err(|e| AppError::VideoError(format!("Invalid SDP offer: {}", e)))?;
|
||||||
|
|
||||||
self.pc
|
self.pc.set_remote_description(sdp).await.map_err(|e| {
|
||||||
.set_remote_description(sdp)
|
AppError::VideoError(format!("Failed to set remote description: {}", e))
|
||||||
.await
|
})?;
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to set remote description: {}", e)))?;
|
|
||||||
|
|
||||||
let answer = self
|
let answer = self
|
||||||
.pc
|
.pc
|
||||||
@@ -725,8 +759,7 @@ impl UniversalSession {
|
|||||||
|| answer.sdp.to_lowercase().contains("hevc");
|
|| answer.sdp.to_lowercase().contains("hevc");
|
||||||
info!(
|
info!(
|
||||||
"[SDP] Session {} answer contains H.265: {}",
|
"[SDP] Session {} answer contains H.265: {}",
|
||||||
self.session_id,
|
self.session_id, has_h265
|
||||||
has_h265
|
|
||||||
);
|
);
|
||||||
if !has_h265 {
|
if !has_h265 {
|
||||||
warn!("[SDP] Answer does not include H.265! Codec negotiation may have failed.");
|
warn!("[SDP] Answer does not include H.265! Codec negotiation may have failed.");
|
||||||
@@ -821,9 +854,21 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_encoder_type_to_video_codec() {
|
fn test_encoder_type_to_video_codec() {
|
||||||
assert_eq!(encoder_type_to_video_codec(VideoEncoderType::H264), VideoCodec::H264);
|
assert_eq!(
|
||||||
assert_eq!(encoder_type_to_video_codec(VideoEncoderType::H265), VideoCodec::H265);
|
encoder_type_to_video_codec(VideoEncoderType::H264),
|
||||||
assert_eq!(encoder_type_to_video_codec(VideoEncoderType::VP8), VideoCodec::VP8);
|
VideoCodec::H264
|
||||||
assert_eq!(encoder_type_to_video_codec(VideoEncoderType::VP9), VideoCodec::VP9);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encoder_type_to_video_codec(VideoEncoderType::H265),
|
||||||
|
VideoCodec::H265
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encoder_type_to_video_codec(VideoEncoderType::VP8),
|
||||||
|
VideoCodec::VP8
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encoder_type_to_video_codec(VideoEncoderType::VP9),
|
||||||
|
VideoCodec::VP9
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,12 +41,14 @@ use crate::audio::shared_pipeline::{SharedAudioPipeline, SharedAudioPipelineConf
|
|||||||
use crate::audio::{AudioController, OpusFrame};
|
use crate::audio::{AudioController, OpusFrame};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::hid::HidController;
|
use crate::hid::HidController;
|
||||||
use crate::video::encoder::registry::VideoEncoderType;
|
|
||||||
use crate::video::encoder::registry::EncoderBackend;
|
use crate::video::encoder::registry::EncoderBackend;
|
||||||
|
use crate::video::encoder::registry::VideoEncoderType;
|
||||||
use crate::video::encoder::VideoCodecType;
|
use crate::video::encoder::VideoCodecType;
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
use crate::video::frame::VideoFrame;
|
use crate::video::frame::VideoFrame;
|
||||||
use crate::video::shared_video_pipeline::{SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats};
|
use crate::video::shared_video_pipeline::{
|
||||||
|
SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats,
|
||||||
|
};
|
||||||
|
|
||||||
use super::config::{TurnServer, WebRtcConfig};
|
use super::config::{TurnServer, WebRtcConfig};
|
||||||
use super::signaling::{ConnectionState, IceCandidate, SdpAnswer, SdpOffer};
|
use super::signaling::{ConnectionState, IceCandidate, SdpAnswer, SdpOffer};
|
||||||
@@ -489,7 +491,9 @@ impl WebRtcStreamer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
info!("No video pipeline exists yet, frame source will be used when pipeline is created");
|
info!(
|
||||||
|
"No video pipeline exists yet, frame source will be used when pipeline is created"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,24 +521,21 @@ impl WebRtcStreamer {
|
|||||||
/// Only restarts the encoding pipeline if configuration actually changed.
|
/// Only restarts the encoding pipeline if configuration actually changed.
|
||||||
/// This allows multiple consumers (WebRTC, RustDesk) to share the same pipeline
|
/// This allows multiple consumers (WebRTC, RustDesk) to share the same pipeline
|
||||||
/// without interrupting each other when they call this method with the same config.
|
/// without interrupting each other when they call this method with the same config.
|
||||||
pub async fn update_video_config(
|
pub async fn update_video_config(&self, resolution: Resolution, format: PixelFormat, fps: u32) {
|
||||||
&self,
|
|
||||||
resolution: Resolution,
|
|
||||||
format: PixelFormat,
|
|
||||||
fps: u32,
|
|
||||||
) {
|
|
||||||
// Check if configuration actually changed
|
// Check if configuration actually changed
|
||||||
let config = self.config.read().await;
|
let config = self.config.read().await;
|
||||||
let config_changed = config.resolution != resolution
|
let config_changed =
|
||||||
|| config.input_format != format
|
config.resolution != resolution || config.input_format != format || config.fps != fps;
|
||||||
|| config.fps != fps;
|
|
||||||
drop(config);
|
drop(config);
|
||||||
|
|
||||||
if !config_changed {
|
if !config_changed {
|
||||||
// Configuration unchanged, no need to restart pipeline
|
// Configuration unchanged, no need to restart pipeline
|
||||||
trace!(
|
trace!(
|
||||||
"Video config unchanged: {}x{} {:?} @ {} fps",
|
"Video config unchanged: {}x{} {:?} @ {} fps",
|
||||||
resolution.width, resolution.height, format, fps
|
resolution.width,
|
||||||
|
resolution.height,
|
||||||
|
format,
|
||||||
|
fps
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -554,7 +555,10 @@ impl WebRtcStreamer {
|
|||||||
// Close all existing sessions - they need to reconnect
|
// Close all existing sessions - they need to reconnect
|
||||||
let session_count = self.close_all_sessions().await;
|
let session_count = self.close_all_sessions().await;
|
||||||
if session_count > 0 {
|
if session_count > 0 {
|
||||||
info!("Closed {} existing sessions due to config change", session_count);
|
info!(
|
||||||
|
"Closed {} existing sessions due to config change",
|
||||||
|
session_count
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update config (preserve user-configured bitrate)
|
// Update config (preserve user-configured bitrate)
|
||||||
@@ -581,17 +585,17 @@ impl WebRtcStreamer {
|
|||||||
// Close all existing sessions - they need to reconnect with new encoder
|
// Close all existing sessions - they need to reconnect with new encoder
|
||||||
let session_count = self.close_all_sessions().await;
|
let session_count = self.close_all_sessions().await;
|
||||||
if session_count > 0 {
|
if session_count > 0 {
|
||||||
info!("Closed {} existing sessions due to encoder backend change", session_count);
|
info!(
|
||||||
|
"Closed {} existing sessions due to encoder backend change",
|
||||||
|
session_count
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update config
|
// Update config
|
||||||
let mut config = self.config.write().await;
|
let mut config = self.config.write().await;
|
||||||
config.encoder_backend = encoder_backend;
|
config.encoder_backend = encoder_backend;
|
||||||
|
|
||||||
info!(
|
info!("WebRTC encoder backend updated: {:?}", encoder_backend);
|
||||||
"WebRTC encoder backend updated: {:?}",
|
|
||||||
encoder_backend
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if current encoder configuration uses hardware encoding
|
/// Check if current encoder configuration uses hardware encoding
|
||||||
@@ -694,7 +698,11 @@ impl WebRtcStreamer {
|
|||||||
let codec = *self.video_codec.read().await;
|
let codec = *self.video_codec.read().await;
|
||||||
|
|
||||||
// Ensure video pipeline is running
|
// Ensure video pipeline is running
|
||||||
let frame_tx = self.video_frame_tx.read().await.clone()
|
let frame_tx = self
|
||||||
|
.video_frame_tx
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.clone()
|
||||||
.ok_or_else(|| AppError::VideoError("No video frame source".to_string()))?;
|
.ok_or_else(|| AppError::VideoError("No video frame source".to_string()))?;
|
||||||
let pipeline = self.ensure_video_pipeline(frame_tx).await?;
|
let pipeline = self.ensure_video_pipeline(frame_tx).await?;
|
||||||
|
|
||||||
@@ -729,15 +737,20 @@ impl WebRtcStreamer {
|
|||||||
// Request keyframe after ICE connection is established (via callback)
|
// Request keyframe after ICE connection is established (via callback)
|
||||||
let pipeline_for_callback = pipeline.clone();
|
let pipeline_for_callback = pipeline.clone();
|
||||||
let session_id_for_callback = session_id.clone();
|
let session_id_for_callback = session_id.clone();
|
||||||
session.start_from_video_pipeline(pipeline.subscribe(), move || {
|
session
|
||||||
|
.start_from_video_pipeline(pipeline.subscribe(), move || {
|
||||||
// Spawn async task to request keyframe
|
// Spawn async task to request keyframe
|
||||||
let pipeline = pipeline_for_callback;
|
let pipeline = pipeline_for_callback;
|
||||||
let sid = session_id_for_callback;
|
let sid = session_id_for_callback;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
info!("Requesting keyframe for session {} after ICE connected", sid);
|
info!(
|
||||||
|
"Requesting keyframe for session {} after ICE connected",
|
||||||
|
sid
|
||||||
|
);
|
||||||
pipeline.request_keyframe().await;
|
pipeline.request_keyframe().await;
|
||||||
});
|
});
|
||||||
}).await;
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
// Start audio if enabled
|
// Start audio if enabled
|
||||||
if session_config.audio_enabled {
|
if session_config.audio_enabled {
|
||||||
@@ -863,7 +876,9 @@ impl WebRtcStreamer {
|
|||||||
.filter(|(_, s)| {
|
.filter(|(_, s)| {
|
||||||
matches!(
|
matches!(
|
||||||
s.state(),
|
s.state(),
|
||||||
ConnectionState::Closed | ConnectionState::Failed | ConnectionState::Disconnected
|
ConnectionState::Closed
|
||||||
|
| ConnectionState::Failed
|
||||||
|
| ConnectionState::Disconnected
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.map(|(id, _)| id.clone())
|
.map(|(id, _)| id.clone())
|
||||||
@@ -967,10 +982,7 @@ impl WebRtcStreamer {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if pipeline_running {
|
if pipeline_running {
|
||||||
info!(
|
info!("Restarting video pipeline to apply new bitrate: {}", preset);
|
||||||
"Restarting video pipeline to apply new bitrate: {}",
|
|
||||||
preset
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save video_frame_tx BEFORE stopping pipeline (monitor task will clear it)
|
// Save video_frame_tx BEFORE stopping pipeline (monitor task will clear it)
|
||||||
let saved_frame_tx = self.video_frame_tx.read().await.clone();
|
let saved_frame_tx = self.video_frame_tx.read().await.clone();
|
||||||
@@ -1005,13 +1017,18 @@ impl WebRtcStreamer {
|
|||||||
info!("Reconnecting session {} to new pipeline", session_id);
|
info!("Reconnecting session {} to new pipeline", session_id);
|
||||||
let pipeline_for_callback = pipeline.clone();
|
let pipeline_for_callback = pipeline.clone();
|
||||||
let sid = session_id.clone();
|
let sid = session_id.clone();
|
||||||
session.start_from_video_pipeline(pipeline.subscribe(), move || {
|
session
|
||||||
|
.start_from_video_pipeline(pipeline.subscribe(), move || {
|
||||||
let pipeline = pipeline_for_callback;
|
let pipeline = pipeline_for_callback;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
info!("Requesting keyframe for session {} after reconnect", sid);
|
info!(
|
||||||
|
"Requesting keyframe for session {} after reconnect",
|
||||||
|
sid
|
||||||
|
);
|
||||||
pipeline.request_keyframe().await;
|
pipeline.request_keyframe().await;
|
||||||
});
|
});
|
||||||
}).await;
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user