diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 47b26c5f..348a6bbf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,13 +34,11 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 24 - cache: npm - cache-dependency-path: web/package-lock.json - name: Build frontend working-directory: web run: | - npm ci + npm install npm run build - name: Upload frontend dist @@ -66,8 +64,16 @@ jobs: - uses: dtolnay/rust-toolchain@stable + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + # The Docker workflow builds the same targets with the same Cross images. + shared-key: linux-cross-release + key: ${{ hashFiles('Cross.toml', 'build/cross/Dockerfile.*') }} + cache-all-crates: true + - name: Install cross - run: cargo install cross --locked + run: cargo install cross --version 0.2.5 --locked - name: Build linux binary run: bash build/build-images.sh @@ -127,6 +133,12 @@ jobs: "TURBOJPEG_LIB_DIR=$env:TURBOJPEG_LIB_DIR" | Out-File -FilePath $env:GITHUB_ENV -Append "TURBOJPEG_INCLUDE_DIR=$env:TURBOJPEG_INCLUDE_DIR" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + shared-key: windows-msvc-release + key: ${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }} + - name: Build Windows exe shell: pwsh run: .\build\windows\build.ps1 -Configuration release -Package diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f71ab055..7b8f5f8d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -37,21 +37,27 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 24 - cache: npm - cache-dependency-path: web/package-lock.json - uses: dtolnay/rust-toolchain@stable + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + # Reuse dependency artifacts produced by the deb job and vice versa. + shared-key: linux-cross-release + key: ${{ hashFiles('Cross.toml', 'build/cross/Dockerfile.*') }} + cache-all-crates: true + - name: Install build dependencies run: | sudo apt-get update sudo apt-get install -y unzip xz-utils - cargo install cross --locked + cargo install cross --version 0.2.5 --locked - name: Build frontend working-directory: web run: | - npm ci + npm install npm run build - name: Set up QEMU diff --git a/Cargo.toml b/Cargo.toml index 6321eb04..2e8f47fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "one-kvm" -version = "0.2.5" +version = "0.2.6" edition = "2021" authors = ["SilentWind"] description = "A open and lightweight IP-KVM solution written in Rust" @@ -56,6 +56,7 @@ desktop = [ "dep:serialport", "dep:async-trait", "dep:libc", + "dep:libloading", "dep:ventoy-img", "dep:protobuf", "dep:sodiumoxide", @@ -156,6 +157,7 @@ sdp-types = { version = "0.1", optional = true } serialport = { version = "4", optional = true } async-trait = { version = "0.1", optional = true } libc = { version = "0.2", optional = true } +libloading = { version = "0.8", optional = true } # Ventoy bootable image support ventoy-img = { path = "libs/ventoy-img-rs", optional = true } diff --git a/build/Dockerfile.runtime b/build/Dockerfile.runtime index 45a7605c..56b6dd7f 100644 --- a/build/Dockerfile.runtime +++ b/build/Dockerfile.runtime @@ -19,6 +19,10 @@ RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \ ca-certificates \ libudev1 \ libasound2 \ + # OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus) + network-manager \ + iproute2 \ + iputils-ping \ # v4l2 is handled by kernel, minimal userspace needed libv4l-0 \ && \ diff --git a/build/Dockerfile.runtime-full b/build/Dockerfile.runtime-full index 27b7eab4..422f1d1b 100644 --- a/build/Dockerfile.runtime-full +++ b/build/Dockerfile.runtime-full @@ -19,6 +19,10 @@ RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \ ca-certificates \ libudev1 \ libasound2 \ + # OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus) + network-manager \ + iproute2 \ + iputils-ping \ # v4l2 is handled by kernel, minimal userspace needed libv4l-0 \ && \ diff --git a/res/vcpkg/libyuv/src/lib.rs b/res/vcpkg/libyuv/src/lib.rs index 327bb9e3..70d4182d 100644 --- a/res/vcpkg/libyuv/src/lib.rs +++ b/res/vcpkg/libyuv/src/lib.rs @@ -1119,25 +1119,73 @@ pub fn mjpg_size(src: &[u8]) -> Result<(i32, i32)> { /// Decode MJPEG directly to NV12. pub fn mjpg_to_nv12(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; - if dst.len() < nv12_size(w, h) { + let (y_size, output_size) = mjpg_nv12_plane_sizes(width, height)?; + if dst.len() < output_size { return Err(YuvError::BufferTooSmall); } - let y_size = w * h; let (dst_y, dst_uv) = dst.split_at_mut(y_size); + // SAFETY: the length check above guarantees writable storage for both planes. + unsafe { mjpg_to_nv12_raw(src, dst_y.as_mut_ptr(), dst_uv.as_mut_ptr(), width, height) } +} +/// Decode MJPEG directly into a reusable `Vec` without zero-filling the output first. +/// +/// `Vec::resize` must initialize every byte before libyuv immediately overwrites the +/// complete NV12 frame. This variant lets libyuv initialize spare capacity directly +/// and publishes the new length only after a successful conversion. +pub fn mjpg_to_nv12_vec(src: &[u8], dst: &mut Vec, width: i32, height: i32) -> Result<()> { + let (y_size, output_size) = mjpg_nv12_plane_sizes(width, height)?; + + dst.clear(); + dst.reserve(output_size); + + // SAFETY: reserve above guarantees writable capacity for the Y and UV planes. + // MJPGToNV12 writes the complete output on success; set_len is deliberately + // delayed until then so callers can never observe partially initialized bytes. + let result = unsafe { + let dst_y = dst.as_mut_ptr(); + mjpg_to_nv12_raw(src, dst_y, dst_y.add(y_size), width, height) + }; + result?; + + // SAFETY: a successful MJPGToNV12 call initialized exactly output_size bytes. + unsafe { dst.set_len(output_size) }; + Ok(()) +} + +#[inline] +fn mjpg_nv12_plane_sizes(width: i32, height: i32) -> Result<(usize, usize)> { + if width % 2 != 0 || height % 2 != 0 || width <= 0 || height <= 0 { + return Err(YuvError::InvalidDimensions); + } + let y_size = (width as usize) + .checked_mul(height as usize) + .ok_or(YuvError::InvalidDimensions)?; + let output_size = y_size + .checked_mul(3) + .map(|size| size / 2) + .ok_or(YuvError::InvalidDimensions)?; + Ok((y_size, output_size)) +} + +/// # Safety +/// +/// `dst_y` and `dst_uv` must point to writable planes sized for `width` x `height` NV12. +#[inline] +unsafe fn mjpg_to_nv12_raw( + src: &[u8], + dst_y: *mut u8, + dst_uv: *mut u8, + width: i32, + height: i32, +) -> Result<()> { call_yuv!(MJPGToNV12( src.as_ptr(), usize_to_size_t(src.len()), - dst_y.as_mut_ptr(), + dst_y, width, - dst_uv.as_mut_ptr(), + dst_uv, width, width, height, diff --git a/src/audio/capture.rs b/src/audio/capture.rs index 8565763d..22b958ff 100644 --- a/src/audio/capture.rs +++ b/src/audio/capture.rs @@ -1,3 +1,15 @@ +//! Platform-neutral capture lifecycle and PCM frame types. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use bytes::Bytes; +use tokio::sync::{broadcast, watch, Mutex}; +use tracing::{debug, info}; + +use crate::error::Result; +use crate::utils::LogThrottler; + #[cfg(unix)] #[path = "capture_linux.rs"] mod imp; @@ -6,4 +18,146 @@ mod imp; #[path = "capture_windows.rs"] mod imp; -pub use imp::*; +#[derive(Debug, Clone)] +pub struct AudioConfig { + pub device_name: String, + pub sample_rate: u32, + pub channels: u32, + pub buffer_frames: u32, + pub period_frames: u32, +} + +impl Default for AudioConfig { + fn default() -> Self { + Self { + device_name: String::new(), + sample_rate: 48_000, + channels: 2, + buffer_frames: 4096, + period_frames: 960, + } + } +} + +#[derive(Debug, Clone)] +pub struct AudioFrame { + pub data: Bytes, + pub sample_rate: u32, + pub channels: u32, +} + +impl AudioFrame { + pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32) -> Self { + Self { + data, + sample_rate, + channels, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CaptureState { + Stopped, + Starting, + Running, + Error, +} + +pub struct AudioCapturer { + config: AudioConfig, + state: watch::Sender, + state_rx: watch::Receiver, + frame_tx: broadcast::Sender, + stop_flag: Arc, + task: Mutex>>, + lifecycle: Mutex<()>, + log_throttler: LogThrottler, +} + +impl AudioCapturer { + pub fn new(config: AudioConfig) -> Self { + let (state, state_rx) = watch::channel(CaptureState::Stopped); + let (frame_tx, _) = broadcast::channel(16); + + Self { + config, + state, + state_rx, + frame_tx, + stop_flag: Arc::new(AtomicBool::new(false)), + task: Mutex::new(None), + lifecycle: Mutex::new(()), + log_throttler: LogThrottler::with_secs(5), + } + } + + pub fn state(&self) -> CaptureState { + *self.state_rx.borrow() + } + + pub fn state_watch(&self) -> watch::Receiver { + self.state_rx.clone() + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.frame_tx.subscribe() + } + + pub async fn start(&self) -> Result<()> { + let _lifecycle = self.lifecycle.lock().await; + if matches!(self.state(), CaptureState::Starting | CaptureState::Running) { + return Ok(()); + } + + if let Some(previous) = self.task.lock().await.take() { + let _ = previous.await; + } + + debug!( + "Starting audio capture on {} at {}Hz {}ch", + self.config.device_name, self.config.sample_rate, self.config.channels + ); + + self.stop_flag.store(false, Ordering::Release); + let _ = self.state.send(CaptureState::Starting); + + let config = self.config.clone(); + let state = self.state.clone(); + let frame_tx = self.frame_tx.clone(); + let stop_flag = Arc::clone(&self.stop_flag); + let log_throttler = self.log_throttler.clone(); + + let task = tokio::task::spawn_blocking(move || { + match imp::run_capture(&config, &state, &frame_tx, &stop_flag, &log_throttler) { + Ok(()) => { + let _ = state.send(CaptureState::Stopped); + } + Err(error) => { + crate::error_throttled!( + log_throttler, + "capture_error", + "Audio capture error: {}", + error + ); + let _ = state.send(CaptureState::Error); + } + } + }); + *self.task.lock().await = Some(task); + Ok(()) + } + + pub async fn stop(&self) -> Result<()> { + let _lifecycle = self.lifecycle.lock().await; + self.stop_flag.store(true, Ordering::Release); + + if let Some(task) = self.task.lock().await.take() { + let _ = task.await; + } + + let _ = self.state.send(CaptureState::Stopped); + info!("Audio capture stopped"); + Ok(()) + } +} diff --git a/src/audio/capture_linux.rs b/src/audio/capture_linux.rs index 89aaca03..af11b15a 100644 --- a/src/audio/capture_linux.rs +++ b/src/audio/capture_linux.rs @@ -1,271 +1,60 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::Duration; + use alsa::pcm::{Access, Format, Frames, HwParams, State, IO}; use alsa::{Direction, ValueOr, PCM}; use bytes::Bytes; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::Instant; -use tokio::sync::{broadcast, watch, Mutex}; -use tracing::{debug, info}; +use tokio::sync::{broadcast, watch}; +use tracing::debug; -use crate::audio::device::AudioDeviceInfo; +use super::{AudioConfig, AudioFrame, CaptureState}; use crate::error::{AppError, Result}; use crate::utils::LogThrottler; -use crate::{error_throttled, warn_throttled}; +use crate::warn_throttled; -#[derive(Debug, Clone)] -pub struct AudioConfig { - pub device_name: String, - pub sample_rate: u32, - pub channels: u32, - pub frame_size: u32, - pub buffer_frames: u32, - pub period_frames: u32, -} +const RETRY_DELAY: Duration = Duration::from_millis(5); +const MAX_CONSECUTIVE_READ_ERRORS: u32 = 10; -impl Default for AudioConfig { - fn default() -> Self { - Self { - device_name: String::new(), - sample_rate: 48000, - channels: 2, - frame_size: 960, - buffer_frames: 4096, - period_frames: 960, - } - } -} - -impl AudioConfig { - pub fn for_device(device: &AudioDeviceInfo) -> Self { - Self { - device_name: device.name.clone(), - ..Default::default() - } - } - - pub fn bytes_per_sample(&self) -> u32 { - 2 * self.channels - } - - pub fn bytes_per_frame(&self) -> usize { - (self.frame_size * self.bytes_per_sample()) as usize - } -} - -#[derive(Debug, Clone)] -pub struct AudioFrame { - pub data: Bytes, - pub sample_rate: u32, - pub channels: u32, - pub samples: u32, - pub sequence: u64, - pub timestamp: Instant, -} - -impl AudioFrame { - pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self { - let bps = 2 * channels; - Self { - samples: data.len() as u32 / bps, - data, - sample_rate, - channels, - sequence, - timestamp: Instant::now(), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CaptureState { - Stopped, - Running, - Error, -} - -pub struct AudioCapturer { - config: AudioConfig, - state: Arc>, - state_rx: watch::Receiver, - frame_tx: broadcast::Sender, - stop_flag: Arc, - sequence: Arc, - capture_handle: Mutex>>, - log_throttler: LogThrottler, -} - -impl AudioCapturer { - pub fn new(config: AudioConfig) -> Self { - let (state_tx, state_rx) = watch::channel(CaptureState::Stopped); - let (frame_tx, _) = broadcast::channel(16); - - Self { - config, - state: Arc::new(state_tx), - state_rx, - frame_tx, - stop_flag: Arc::new(AtomicBool::new(false)), - sequence: Arc::new(AtomicU64::new(0)), - capture_handle: Mutex::new(None), - log_throttler: LogThrottler::with_secs(5), - } - } - - pub fn state(&self) -> CaptureState { - *self.state_rx.borrow() - } - - pub fn state_watch(&self) -> watch::Receiver { - self.state_rx.clone() - } - - pub fn subscribe(&self) -> broadcast::Receiver { - self.frame_tx.subscribe() - } - - pub async fn start(&self) -> Result<()> { - if self.state() == CaptureState::Running { - return Ok(()); - } - - debug!( - "Starting audio capture on {} at {}Hz {}ch", - self.config.device_name, self.config.sample_rate, self.config.channels - ); - - self.stop_flag.store(false, Ordering::SeqCst); - - let config = self.config.clone(); - let state = self.state.clone(); - let frame_tx = self.frame_tx.clone(); - let stop_flag = self.stop_flag.clone(); - let sequence = self.sequence.clone(); - let log_throttler = self.log_throttler.clone(); - - let handle = tokio::task::spawn_blocking(move || { - let result = run_capture( - &config, - &state, - &frame_tx, - &stop_flag, - &sequence, - &log_throttler, - ); - - if let Err(e) = result { - error_throttled!(log_throttler, "capture_error", "Audio capture error: {}", e); - let _ = state.send(CaptureState::Error); - } else { - let _ = state.send(CaptureState::Stopped); - } - }); - - *self.capture_handle.lock().await = Some(handle); - Ok(()) - } - - pub async fn stop(&self) -> Result<()> { - info!("Stopping audio capture"); - self.stop_flag.store(true, Ordering::SeqCst); - - if let Some(handle) = self.capture_handle.lock().await.take() { - let _ = handle.await; - } - - let _ = self.state.send(CaptureState::Stopped); - Ok(()) - } - - pub fn is_running(&self) -> bool { - self.state() == CaptureState::Running - } -} - -fn run_capture( +pub(super) fn run_capture( config: &AudioConfig, state: &watch::Sender, frame_tx: &broadcast::Sender, stop_flag: &AtomicBool, - sequence: &AtomicU64, log_throttler: &LogThrottler, ) -> Result<()> { - let pcm = PCM::new(&config.device_name, Direction::Capture, false).map_err(|e| { + // Non-blocking mode guarantees that stop() can always join the worker. + let pcm = PCM::new(&config.device_name, Direction::Capture, true).map_err(|error| { AppError::AudioError(format!( "Failed to open audio device {}: {}", - config.device_name, e + config.device_name, error )) })?; - { - let hwp = HwParams::any(&pcm) - .map_err(|e| AppError::AudioError(format!("Failed to get HwParams: {}", e)))?; - - hwp.set_channels(config.channels) - .map_err(|e| AppError::AudioError(format!("Failed to set channels: {}", e)))?; - - hwp.set_rate(config.sample_rate, ValueOr::Nearest) - .map_err(|e| AppError::AudioError(format!("Failed to set sample rate: {}", e)))?; - - hwp.set_format(Format::s16()) - .map_err(|e| AppError::AudioError(format!("Failed to set format: {}", e)))?; - - hwp.set_access(Access::RWInterleaved) - .map_err(|e| AppError::AudioError(format!("Failed to set access: {}", e)))?; - - hwp.set_buffer_size_near(config.buffer_frames as Frames) - .map_err(|e| AppError::AudioError(format!("Failed to set buffer size: {}", e)))?; - - hwp.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest) - .map_err(|e| AppError::AudioError(format!("Failed to set period size: {}", e)))?; - - pcm.hw_params(&hwp) - .map_err(|e| AppError::AudioError(format!("Failed to apply hw params: {}", e)))?; - } - - let hw_now = pcm.hw_params_current().map_err(|e| { - AppError::AudioError(format!("Failed to read hw_params after apply: {}", e)) - })?; - let actual_rate = hw_now - .get_rate() - .map_err(|e| AppError::AudioError(format!("Failed to read sample rate: {}", e)))?; - let actual_ch = hw_now - .get_channels() - .map_err(|e| AppError::AudioError(format!("Failed to read channels: {}", e)))?; - if actual_rate != 48_000 { - return Err(AppError::AudioError(format!( - "Audio capture requires 48000 Hz; device is {} Hz", - actual_rate - ))); - } - if actual_ch != 2 { - return Err(AppError::AudioError(format!( - "Audio capture requires 2 channels (stereo); device has {}", - actual_ch - ))); - } - debug!("Audio capture: 48000 Hz, 2 ch"); - + configure_pcm(&pcm, config)?; pcm.prepare() - .map_err(|e| AppError::AudioError(format!("Failed to prepare PCM: {}", e)))?; - + .map_err(|error| AppError::AudioError(format!("Failed to prepare PCM: {error}")))?; let _ = state.send(CaptureState::Running); let period_frames = pcm .hw_params_current() .ok() - .and_then(|h| h.get_period_size().ok()) - .map(|f| f as usize) - .unwrap_or(1024) + .and_then(|params| params.get_period_size().ok()) + .map(|frames| frames as usize) + .unwrap_or(config.period_frames as usize) .max(256); - let buf_frames = period_frames.saturating_mul(4).max(2048); - let bytes_per_frame = (config.channels as usize) * 2; - let mut buffer = vec![0u8; buf_frames * bytes_per_frame]; + let mut buffer = vec![0u8; period_frames * config.channels as usize * 2]; + let io: IO = pcm.io_bytes(); + let mut consecutive_errors = 0; - while !stop_flag.load(Ordering::Relaxed) { + while !stop_flag.load(Ordering::Acquire) { match pcm.state() { State::XRun => { warn_throttled!(log_throttler, "xrun", "Audio buffer overrun, recovering"); - let _ = pcm.prepare(); + pcm.prepare().map_err(|error| { + AppError::AudioError(format!("Failed to recover audio xrun: {error}")) + })?; + consecutive_errors = 0; continue; } State::Suspended => { @@ -274,61 +63,95 @@ fn run_capture( "suspended", "Audio device suspended, recovering" ); - let _ = pcm.resume(); + if pcm.resume().is_err() { + pcm.prepare().map_err(|error| { + AppError::AudioError(format!("Failed to resume audio capture: {error}")) + })?; + } + consecutive_errors = 0; continue; } _ => {} } - // io_bytes: USB capture often lacks mmap (io_checked requires it). - let io: IO = pcm.io_bytes(); - match io.readi(&mut buffer) { + Ok(0) => thread::sleep(RETRY_DELAY), Ok(frames_read) => { - if frames_read == 0 { - continue; - } - + consecutive_errors = 0; let byte_count = frames_read * config.channels as usize * 2; - - let seq = sequence.fetch_add(1, Ordering::Relaxed); let frame = AudioFrame::new_interleaved( Bytes::copy_from_slice(&buffer[..byte_count]), config.channels, - 48_000, - seq, + config.sample_rate, ); - if frame_tx.receiver_count() > 0 { - if let Err(e) = frame_tx.send(frame) { - debug!("No audio receivers: {}", e); - } + let _ = frame_tx.send(frame); } } - Err(e) => { - let desc = e.to_string(); - if is_device_lost_error(&desc) { + Err(error) if error.errno() == libc::EAGAIN => thread::sleep(RETRY_DELAY), + Err(error) if is_device_lost_errno(error.errno()) => { + return Err(AppError::AudioError(format!( + "Audio device lost while reading {}: {}", + config.device_name, error + ))); + } + Err(error) if error.errno() == libc::EPIPE => { + warn_throttled!(log_throttler, "buffer_overrun", "Audio buffer overrun"); + pcm.prepare().map_err(|prepare_error| { + AppError::AudioError(format!( + "Failed to recover after audio overrun ({error}): {prepare_error}" + )) + })?; + consecutive_errors = 0; + } + Err(error) => { + consecutive_errors += 1; + warn_throttled!(log_throttler, "read_error", "Audio read error: {}", error); + if consecutive_errors >= MAX_CONSECUTIVE_READ_ERRORS { return Err(AppError::AudioError(format!( - "Audio device lost while reading {}: {}", - config.device_name, e + "Audio capture failed {consecutive_errors} times consecutively: {error}" ))); - } else if desc.contains("EPIPE") || desc.contains("Broken pipe") { - warn_throttled!(log_throttler, "buffer_overrun", "Audio buffer overrun"); - let _ = pcm.prepare(); - } else { - error_throttled!(log_throttler, "read_error", "Audio read error: {}", e); } + thread::sleep(RETRY_DELAY); } } } - info!("Audio capture stopped"); + debug!("ALSA capture worker stopped"); Ok(()) } -fn is_device_lost_error(desc: &str) -> bool { - desc.contains("No such device") - || desc.contains("ENODEV") - || desc.contains("ENXIO") - || desc.contains("ESHUTDOWN") +fn configure_pcm(pcm: &PCM, config: &AudioConfig) -> Result<()> { + let params = HwParams::any(pcm) + .map_err(|error| AppError::AudioError(format!("Failed to get HwParams: {error}")))?; + params + .set_channels(config.channels) + .and_then(|_| params.set_rate(config.sample_rate, ValueOr::Nearest)) + .and_then(|_| params.set_format(Format::s16())) + .and_then(|_| params.set_access(Access::RWInterleaved)) + .and_then(|_| params.set_buffer_size_near(config.buffer_frames as Frames)) + .and_then(|_| params.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest)) + .and_then(|_| pcm.hw_params(¶ms)) + .map_err(|error| AppError::AudioError(format!("Failed to configure audio PCM: {error}")))?; + + let actual = pcm + .hw_params_current() + .map_err(|error| AppError::AudioError(format!("Failed to read PCM parameters: {error}")))?; + let actual_rate = actual + .get_rate() + .map_err(|error| AppError::AudioError(format!("Failed to read sample rate: {error}")))?; + let actual_channels = actual + .get_channels() + .map_err(|error| AppError::AudioError(format!("Failed to read channels: {error}")))?; + if actual_rate != config.sample_rate || actual_channels != config.channels { + return Err(AppError::AudioError(format!( + "Audio device negotiated {actual_rate} Hz/{actual_channels} ch; expected {} Hz/{} ch", + config.sample_rate, config.channels + ))); + } + Ok(()) +} + +fn is_device_lost_errno(errno: i32) -> bool { + matches!(errno, libc::ENODEV | libc::ENXIO | libc::ESHUTDOWN) } diff --git a/src/audio/capture_windows.rs b/src/audio/capture_windows.rs index 2f059df3..260d6314 100644 --- a/src/audio/capture_windows.rs +++ b/src/audio/capture_windows.rs @@ -1,198 +1,23 @@ use bytes::Bytes; use cpal::traits::{DeviceTrait, StreamTrait}; use cpal::{BufferSize, SampleFormat, StreamConfig}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::{broadcast, watch, Mutex}; +use std::time::Duration; +use tokio::sync::{broadcast, watch}; use tracing::{debug, info}; -use crate::audio::device::{find_wasapi_device, AudioDeviceInfo}; +use super::{AudioConfig, AudioFrame, CaptureState}; +use crate::audio::device::find_wasapi_device; use crate::error::{AppError, Result}; -use crate::error_throttled; use crate::utils::LogThrottler; -#[derive(Debug, Clone)] -pub struct AudioConfig { - pub device_name: String, - pub sample_rate: u32, - pub channels: u32, - pub frame_size: u32, - pub buffer_frames: u32, - pub period_frames: u32, -} - -impl Default for AudioConfig { - fn default() -> Self { - Self { - device_name: String::new(), - sample_rate: 48000, - channels: 2, - frame_size: 960, - buffer_frames: 4096, - period_frames: 960, - } - } -} - -impl AudioConfig { - pub fn for_device(device: &AudioDeviceInfo) -> Self { - Self { - device_name: device.name.clone(), - ..Default::default() - } - } - - pub fn bytes_per_sample(&self) -> u32 { - 2 * self.channels - } - - pub fn bytes_per_frame(&self) -> usize { - (self.frame_size * self.bytes_per_sample()) as usize - } -} - -#[derive(Debug, Clone)] -pub struct AudioFrame { - pub data: Bytes, - pub sample_rate: u32, - pub channels: u32, - pub samples: u32, - pub sequence: u64, - pub timestamp: Instant, -} - -impl AudioFrame { - pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self { - let bps = 2 * channels; - Self { - samples: data.len() as u32 / bps, - data, - sample_rate, - channels, - sequence, - timestamp: Instant::now(), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CaptureState { - Stopped, - Running, - Error, -} - -pub struct AudioCapturer { - config: AudioConfig, - state: Arc>, - state_rx: watch::Receiver, - frame_tx: broadcast::Sender, - stop_flag: Arc, - sequence: Arc, - capture_handle: Mutex>>, - log_throttler: LogThrottler, -} - -impl AudioCapturer { - pub fn new(config: AudioConfig) -> Self { - let (state_tx, state_rx) = watch::channel(CaptureState::Stopped); - let (frame_tx, _) = broadcast::channel(16); - - Self { - config, - state: Arc::new(state_tx), - state_rx, - frame_tx, - stop_flag: Arc::new(AtomicBool::new(false)), - sequence: Arc::new(AtomicU64::new(0)), - capture_handle: Mutex::new(None), - log_throttler: LogThrottler::with_secs(5), - } - } - - pub fn state(&self) -> CaptureState { - *self.state_rx.borrow() - } - - pub fn state_watch(&self) -> watch::Receiver { - self.state_rx.clone() - } - - pub fn subscribe(&self) -> broadcast::Receiver { - self.frame_tx.subscribe() - } - - pub async fn start(&self) -> Result<()> { - if self.state() == CaptureState::Running { - return Ok(()); - } - - debug!( - "Starting WASAPI audio capture on {} at {}Hz {}ch", - self.config.device_name, self.config.sample_rate, self.config.channels - ); - - self.stop_flag.store(false, Ordering::SeqCst); - - let config = self.config.clone(); - let state = self.state.clone(); - let frame_tx = self.frame_tx.clone(); - let stop_flag = self.stop_flag.clone(); - let sequence = self.sequence.clone(); - let log_throttler = self.log_throttler.clone(); - - let handle = tokio::task::spawn_blocking(move || { - let result = run_capture( - &config, - &state, - &frame_tx, - &stop_flag, - &sequence, - &log_throttler, - ); - - if let Err(e) = result { - error_throttled!( - log_throttler, - "capture_error", - "WASAPI audio capture error: {}", - e - ); - let _ = state.send(CaptureState::Error); - } else { - let _ = state.send(CaptureState::Stopped); - } - }); - - *self.capture_handle.lock().await = Some(handle); - Ok(()) - } - - pub async fn stop(&self) -> Result<()> { - info!("Stopping WASAPI audio capture"); - self.stop_flag.store(true, Ordering::SeqCst); - - if let Some(handle) = self.capture_handle.lock().await.take() { - let _ = handle.await; - } - - let _ = self.state.send(CaptureState::Stopped); - Ok(()) - } - - pub fn is_running(&self) -> bool { - self.state() == CaptureState::Running - } -} - -fn run_capture( +pub(super) fn run_capture( config: &AudioConfig, state: &watch::Sender, frame_tx: &broadcast::Sender, stop_flag: &AtomicBool, - sequence: &AtomicU64, log_throttler: &LogThrottler, ) -> Result<()> { let device = find_wasapi_device(&config.device_name)?; @@ -272,12 +97,10 @@ fn run_capture( if samples.is_empty() { continue; } - let seq = sequence.fetch_add(1, Ordering::Relaxed); let frame = AudioFrame::new_interleaved( Bytes::copy_from_slice(bytemuck::cast_slice(&samples)), 2, 48_000, - seq, ); if frame_tx.receiver_count() > 0 { if let Err(e) = frame_tx.send(frame) { diff --git a/src/audio/controller.rs b/src/audio/controller.rs index 2c0e064c..1e2f1675 100644 --- a/src/audio/controller.rs +++ b/src/audio/controller.rs @@ -1,8 +1,7 @@ //! Device selection, quality presets, streaming. -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use tokio::sync::RwLock; +use tokio::sync::{Mutex, RwLock}; use tracing::{debug, info}; use super::capture::AudioConfig; @@ -22,23 +21,37 @@ pub(super) type AudioRecoveredCallback = Arc; pub struct AudioController { config: Arc>, streamer: Arc>>>, - devices: Arc>>, event_bus: Arc>>>, monitor: Arc, - recovery_in_progress: Arc, + recovery: recovery::AudioRecovery, recovered_callback: Arc>>, + operation: Arc>, } impl AudioController { pub fn new(config: AudioControllerConfig) -> Self { + let config = Arc::new(RwLock::new(config)); + let streamer = Arc::new(RwLock::new(None)); + let event_bus = Arc::new(RwLock::new(None)); + let monitor = Arc::new(AudioHealthMonitor::new()); + let recovered_callback = Arc::new(RwLock::new(None)); + let operation = Arc::new(Mutex::new(())); + let recovery = recovery::AudioRecovery::new( + config.clone(), + streamer.clone(), + event_bus.clone(), + monitor.clone(), + recovered_callback.clone(), + operation.clone(), + ); Self { - config: Arc::new(RwLock::new(config)), - streamer: Arc::new(RwLock::new(None)), - devices: Arc::new(RwLock::new(Vec::new())), - event_bus: Arc::new(RwLock::new(None)), - monitor: Arc::new(AudioHealthMonitor::new()), - recovery_in_progress: Arc::new(AtomicBool::new(false)), - recovered_callback: Arc::new(RwLock::new(None)), + config, + streamer, + event_bus, + monitor, + recovery, + recovered_callback, + operation, } } @@ -55,31 +68,6 @@ impl AudioController { bus.mark_device_info_dirty(); } } - fn spawn_recovery_task(&self, lost_device: String, reason: String) { - recovery::spawn_recovery_task( - self.config.clone(), - self.streamer.clone(), - self.event_bus.clone(), - self.monitor.clone(), - self.recovery_in_progress.clone(), - self.recovered_callback.clone(), - lost_device, - reason, - ); - } - - fn spawn_stream_monitor(&self, streamer: Arc, device: String) { - recovery::spawn_stream_monitor( - self.config.clone(), - self.streamer.clone(), - self.event_bus.clone(), - self.monitor.clone(), - self.recovery_in_progress.clone(), - self.recovered_callback.clone(), - streamer, - device, - ); - } pub async fn list_devices(&self) -> Result> { let current_device = if self.is_streaming().await { @@ -88,26 +76,19 @@ impl AudioController { None }; - let devices = enumerate_audio_devices_with_current(current_device.as_deref())?; - *self.devices.write().await = devices.clone(); - Ok(devices) - } - - pub async fn get_cached_devices(&self) -> Vec { - self.devices.read().await.clone() + enumerate_audio_devices_with_current(current_device.as_deref()) } pub async fn select_device(&self, device: &str) -> Result<()> { + let _operation = self.operation.lock().await; + self.recovery.cancel(); let devices = self.list_devices().await?; let found = devices .iter() .any(|d| d.name == device || d.description.contains(device)); if !found { - return Err(AppError::AudioError(format!( - "Audio device not found: {}", - device - ))); + return Err(AppError::NotFound(format!("audio device {device}"))); } { @@ -118,14 +99,15 @@ impl AudioController { info!("Audio device selected: {}", device); if self.is_streaming().await { - self.stop_streaming().await?; - self.start_streaming().await?; + self.stop_streaming_inner().await?; + self.start_streaming_inner().await?; } Ok(()) } pub async fn set_quality(&self, quality: AudioQuality) -> Result<()> { + let _operation = self.operation.lock().await; { let mut config = self.config.write().await; config.quality = quality; @@ -144,6 +126,12 @@ impl AudioController { } pub async fn start_streaming(&self) -> Result<()> { + let _operation = self.operation.lock().await; + self.recovery.cancel(); + self.start_streaming_inner().await + } + + async fn start_streaming_inner(&self) -> Result<()> { { let config = self.config.read().await; if !config.enabled { @@ -171,7 +159,7 @@ impl AudioController { if let Some(error_msg) = select_error { self.monitor.report_error(&error_msg, "start_failed").await; - self.spawn_recovery_task("auto".to_string(), error_msg.clone()); + self.recovery.start("auto".to_string(), error_msg.clone()); self.mark_device_info_dirty().await; return Err(AppError::AudioError(error_msg)); } @@ -194,7 +182,7 @@ impl AudioController { let error_msg = format!("Failed to start audio: {}", e); self.monitor.report_error(&error_msg, "start_failed").await; - self.spawn_recovery_task(device_name.clone(), error_msg.clone()); + self.recovery.start(device_name.clone(), error_msg.clone()); self.mark_device_info_dirty().await; @@ -203,14 +191,13 @@ impl AudioController { let streamer_for_monitor = streamer.clone(); *self.streamer.write().await = Some(streamer); - self.spawn_stream_monitor(streamer_for_monitor, device_name.clone()); + self.recovery + .monitor(streamer_for_monitor, device_name.clone()); if self.monitor.is_error().await { self.monitor.report_recovered().await; } - self.recovery_in_progress.store(false, Ordering::SeqCst); - self.mark_device_info_dirty().await; info!("Audio streaming started"); @@ -218,7 +205,12 @@ impl AudioController { } pub async fn stop_streaming(&self) -> Result<()> { - self.recovery_in_progress.store(false, Ordering::SeqCst); + let _operation = self.operation.lock().await; + self.stop_streaming_inner().await + } + + async fn stop_streaming_inner(&self) -> Result<()> { + self.recovery.cancel(); if let Some(streamer) = self.streamer.write().await.take() { streamer.stop().await?; @@ -249,7 +241,7 @@ impl AudioController { let (streaming, subscriber_count) = if let Some(ref streamer) = *self.streamer.read().await { let streaming = streamer.is_running(); - let subscriber_count = streamer.stats().subscriber_count; + let subscriber_count = streamer.subscriber_count(); (streaming, subscriber_count) } else { (false, 0) @@ -278,13 +270,15 @@ impl AudioController { } pub async fn set_enabled(&self, enabled: bool) -> Result<()> { + let _operation = self.operation.lock().await; + self.recovery.cancel(); { let mut config = self.config.write().await; config.enabled = enabled; } if !enabled && self.is_streaming().await { - self.stop_streaming().await?; + self.stop_streaming_inner().await?; } info!("Audio enabled: {}", enabled); @@ -292,16 +286,18 @@ impl AudioController { } pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> { + let _operation = self.operation.lock().await; + self.recovery.cancel(); let was_streaming = self.is_streaming().await; if was_streaming { - self.stop_streaming().await?; + self.stop_streaming_inner().await?; } *self.config.write().await = new_config.clone(); if new_config.enabled { - self.start_streaming().await?; + self.start_streaming_inner().await?; } Ok(()) diff --git a/src/audio/device.rs b/src/audio/device.rs index e8752ac2..223b57c9 100644 --- a/src/audio/device.rs +++ b/src/audio/device.rs @@ -1,3 +1,9 @@ +//! Shared device description with platform-specific enumeration backends. + +use serde::Serialize; + +use crate::error::Result; + #[cfg(unix)] #[path = "device_linux.rs"] mod imp; @@ -6,4 +12,32 @@ mod imp; #[path = "device_windows.rs"] mod imp; -pub use imp::*; +#[derive(Debug, Clone, Serialize)] +pub struct AudioDeviceInfo { + pub name: String, + pub description: String, + pub card_index: i32, + pub device_index: i32, + pub sample_rates: Vec, + pub channels: Vec, + pub is_capture: bool, + pub is_hdmi: bool, + pub usb_bus: Option, +} + +pub fn enumerate_audio_devices() -> Result> { + imp::enumerate_audio_devices_with_current(None) +} + +pub fn enumerate_audio_devices_with_current( + current_device: Option<&str>, +) -> Result> { + imp::enumerate_audio_devices_with_current(current_device) +} + +pub(crate) fn find_best_audio_device() -> Result { + imp::find_best_audio_device() +} + +#[cfg(windows)] +pub(crate) use imp::find_wasapi_device; diff --git a/src/audio/device_linux.rs b/src/audio/device_linux.rs index 66df38cc..eac4d5a4 100644 --- a/src/audio/device_linux.rs +++ b/src/audio/device_linux.rs @@ -1,23 +1,10 @@ use alsa::pcm::HwParams; use alsa::{Direction, PCM}; -use serde::Serialize; use tracing::{debug, info, warn}; +use super::AudioDeviceInfo; use crate::error::{AppError, Result}; -#[derive(Debug, Clone, Serialize)] -pub struct AudioDeviceInfo { - pub name: String, - pub description: String, - pub card_index: i32, - pub device_index: i32, - pub sample_rates: Vec, - pub channels: Vec, - pub is_capture: bool, - pub is_hdmi: bool, - pub usb_bus: Option, -} - fn get_usb_bus_info(card_index: i32) -> Option { if card_index < 0 { return None; @@ -28,26 +15,18 @@ fn get_usb_bus_info(card_index: i32) -> Option { let link_str = link_target.to_string_lossy(); for component in link_str.split('/') { - if component.contains('-') && !component.contains(':') { - if component - .chars() - .next() - .map(|c| c.is_ascii_digit()) - .unwrap_or(false) - { - return Some(component.to_string()); - } + if component.contains('-') + && !component.contains(':') + && component.chars().next().is_some_and(|c| c.is_ascii_digit()) + { + return Some(component.to_string()); } } None } -pub fn enumerate_audio_devices() -> Result> { - enumerate_audio_devices_with_current(None) -} - -pub fn enumerate_audio_devices_with_current( +pub(super) fn enumerate_audio_devices_with_current( current_device: Option<&str>, ) -> Result> { let mut devices = Vec::new(); @@ -153,8 +132,8 @@ fn query_device_caps(pcm: &PCM) -> (Vec, Vec) { (supported_rates, supported_channels) } -pub fn find_best_audio_device() -> Result { - let devices = enumerate_audio_devices()?; +pub(super) fn find_best_audio_device() -> Result { + let devices = enumerate_audio_devices_with_current(None)?; if devices.is_empty() { return Err(AppError::AudioError( @@ -194,7 +173,7 @@ mod tests { #[test] fn test_enumerate_devices() { - let result = enumerate_audio_devices(); + let result = enumerate_audio_devices_with_current(None); println!("Audio devices: {:?}", result); assert!(result.is_ok()); } diff --git a/src/audio/device_windows.rs b/src/audio/device_windows.rs index 8ea1aaba..f59e94a9 100644 --- a/src/audio/device_windows.rs +++ b/src/audio/device_windows.rs @@ -1,29 +1,12 @@ use cpal::traits::{DeviceTrait, HostTrait}; use cpal::DeviceId; -use serde::Serialize; use std::str::FromStr; use tracing::{debug, info, warn}; +use super::AudioDeviceInfo; use crate::error::{AppError, Result}; -#[derive(Debug, Clone, Serialize)] -pub struct AudioDeviceInfo { - pub name: String, - pub description: String, - pub card_index: i32, - pub device_index: i32, - pub sample_rates: Vec, - pub channels: Vec, - pub is_capture: bool, - pub is_hdmi: bool, - pub usb_bus: Option, -} - -pub fn enumerate_audio_devices() -> Result> { - enumerate_audio_devices_with_current(None) -} - -pub fn enumerate_audio_devices_with_current( +pub(super) fn enumerate_audio_devices_with_current( current_device: Option<&str>, ) -> Result> { let host = cpal::default_host(); @@ -192,8 +175,8 @@ pub(crate) fn find_wasapi_device(requested_device: &str) -> Result ))) } -pub fn find_best_audio_device() -> Result { - let devices = enumerate_audio_devices()?; +pub(super) fn find_best_audio_device() -> Result { + let devices = enumerate_audio_devices_with_current(None)?; if devices.is_empty() { return Err(AppError::AudioError( diff --git a/src/audio/encoder.rs b/src/audio/encoder.rs index 4f214677..fb908a94 100644 --- a/src/audio/encoder.rs +++ b/src/audio/encoder.rs @@ -5,7 +5,6 @@ use audiopus::{coder::Encoder, Application, Bitrate, Channels, SampleRate}; use bytes::Bytes; use tracing::debug; -use super::capture::AudioFrame; use crate::error::{AppError, Result}; #[derive(Debug, Clone)] @@ -154,11 +153,6 @@ impl OpusEncoder { }) } - pub fn encode_frame(&mut self, frame: &AudioFrame) -> Result { - let samples: &[i16] = bytemuck::cast_slice(&frame.data); - self.encode(samples) - } - pub fn config(&self) -> &OpusConfig { &self.config } diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 913f16d0..eff4004c 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -1,18 +1,18 @@ //! Platform audio capture, Opus encode, device enumeration, streaming, controller, health monitor. #[cfg(any(unix, windows))] -pub mod capture; -pub mod controller; +mod capture; +mod controller; #[cfg(any(unix, windows))] -pub mod device; +mod device; #[cfg(any(unix, windows))] -pub mod encoder; -pub mod monitor; -pub mod recovery; -pub mod streamer; -pub mod types; -pub mod uac_streamer; -pub mod uac_websocket; +mod encoder; +mod monitor; +mod recovery; +mod streamer; +mod types; +#[cfg(unix)] +pub mod uac; pub use capture::{AudioCapturer, AudioConfig, AudioFrame}; pub use controller::AudioController; diff --git a/src/audio/monitor.rs b/src/audio/monitor.rs index 35bb03e2..1930826c 100644 --- a/src/audio/monitor.rs +++ b/src/audio/monitor.rs @@ -71,14 +71,14 @@ impl AudioHealthMonitor { pub async fn report_recovered(&self) { let prev_status = self.status.read().await.clone(); + self.suppress_display.store(false, Ordering::Relaxed); if prev_status != AudioHealthStatus::Healthy { let retry_count = self.retry_count.load(Ordering::Relaxed); info!("Audio recovered after {} retries", retry_count); - self.suppress_display.store(false, Ordering::Relaxed); self.retry_count.store(0, Ordering::Relaxed); - self.throttler.clear("audio_"); + self.throttler.clear_all(); *self.last_error_code.write().await = None; *self.status.write().await = AudioHealthStatus::Healthy; } diff --git a/src/audio/recovery.rs b/src/audio/recovery.rs index 99c0b16a..59acc797 100644 --- a/src/audio/recovery.rs +++ b/src/audio/recovery.rs @@ -1,6 +1,9 @@ -use std::sync::atomic::{AtomicBool, Ordering}; +//! Audio device-loss monitoring and serialized recovery. + +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use tokio::sync::RwLock; + +use tokio::sync::{Mutex, RwLock}; use tracing::{debug, info, warn}; use super::capture::AudioConfig; @@ -9,312 +12,395 @@ use super::device::{enumerate_audio_devices, AudioDeviceInfo}; use super::monitor::AudioHealthMonitor; use super::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig}; use super::types::AudioControllerConfig; -use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent}; +use crate::events::{EventBus, StreamKind, SystemEvent}; -const AUDIO_RECOVERY_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1); +const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1); + +struct RecoveryControl { + /// Even values are idle; the following odd value is that recovery's token. + /// A single compare-exchange therefore owns both activity and generation. + state: AtomicU64, +} + +impl RecoveryControl { + fn new() -> Self { + Self { + state: AtomicU64::new(0), + } + } + + fn begin(&self) -> Option { + let idle = self.state.load(Ordering::Acquire); + if !idle.is_multiple_of(2) { + return None; + } + let token = idle.wrapping_add(1); + self.state + .compare_exchange(idle, token, Ordering::AcqRel, Ordering::Acquire) + .ok() + .map(|_| token) + } + + fn is_current(&self, token: u64) -> bool { + self.state.load(Ordering::Acquire) == token + } + + fn finish(&self, token: u64) { + let _ = self.state.compare_exchange( + token, + token.wrapping_add(1), + Ordering::AcqRel, + Ordering::Acquire, + ); + } + + fn cancel(&self) { + let token = self.state.load(Ordering::Acquire); + if !token.is_multiple_of(2) { + let _ = self.state.compare_exchange( + token, + token.wrapping_add(1), + Ordering::AcqRel, + Ordering::Acquire, + ); + } + } +} + +struct RecoveryLease { + control: Arc, + generation: u64, +} + +impl Drop for RecoveryLease { + fn drop(&mut self) { + self.control.finish(self.generation); + } +} + +struct RecoveryInner { + config: Arc>, + streamer: Arc>>>, + event_bus: Arc>>>, + monitor: Arc, + recovered_callback: Arc>>, + operation: Arc>, + control: Arc, +} + +#[derive(Clone)] +pub(super) struct AudioRecovery { + inner: Arc, +} + +impl AudioRecovery { + pub(super) fn new( + config: Arc>, + streamer: Arc>>>, + event_bus: Arc>>>, + monitor: Arc, + recovered_callback: Arc>>, + operation: Arc>, + ) -> Self { + Self { + inner: Arc::new(RecoveryInner { + config, + streamer, + event_bus, + monitor, + recovered_callback, + operation, + control: Arc::new(RecoveryControl::new()), + }), + } + } + + pub(super) fn cancel(&self) { + self.inner.control.cancel(); + } + + pub(super) fn monitor(&self, streamer: Arc, device: String) { + let recovery = self.clone(); + let mut state = streamer.state_watch(); + tokio::spawn(async move { + loop { + let current_state = *state.borrow(); + match current_state { + AudioStreamState::Error => {} + AudioStreamState::Stopped => return, + AudioStreamState::Starting | AudioStreamState::Running => { + if state.changed().await.is_err() { + return; + } + continue; + } + } + + // Serialize the ownership check with user-driven start/stop + // operations. If a stop already owns the operation lock, it + // removes the streamer before this monitor may start recovery. + let _operation = recovery.inner.operation.lock().await; + let is_current = recovery + .inner + .streamer + .read() + .await + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &streamer)); + if !is_current { + return; + } + + let reason = format!("Audio device lost: {device}"); + recovery + .inner + .monitor + .report_error(&reason, "device_lost") + .await; + recovery.start(device, reason); + return; + } + }); + } + + pub(super) fn start(&self, lost_device: String, reason: String) { + let Some(generation) = self.inner.control.begin() else { + debug!("Audio recovery already in progress"); + return; + }; + let recovery = self.clone(); + tokio::spawn(async move { + let _lease = RecoveryLease { + control: recovery.inner.control.clone(), + generation, + }; + recovery.run(generation, lost_device, reason).await; + }); + } + + async fn run(&self, generation: u64, lost_device: String, reason: String) { + warn!("Audio recovery started for {lost_device}: {reason}"); + self.publish_device_lost(&lost_device, &reason).await; + self.publish_state( + "device_lost", + Some(lost_device.clone()), + Some("audio_device_lost"), + Some(RETRY_DELAY.as_millis() as u64), + ) + .await; + + let mut attempt = 0u32; + while self.inner.control.is_current(generation) { + let config = self.inner.config.read().await.clone(); + if !config.enabled { + return; + } + if self + .inner + .streamer + .read() + .await + .as_ref() + .is_some_and(|streamer| streamer.is_running()) + { + return; + } + + attempt = attempt.saturating_add(1); + self.publish_reconnecting(&lost_device, attempt).await; + self.publish_state( + "device_lost", + Some(lost_device.clone()), + Some("audio_reconnecting"), + Some(RETRY_DELAY.as_millis() as u64), + ) + .await; + tokio::time::sleep(RETRY_DELAY).await; + if !self.inner.control.is_current(generation) { + return; + } + + let devices = match enumerate_audio_devices() { + Ok(devices) => devices, + Err(error) => { + debug!("Audio recovery enumeration attempt {attempt} failed: {error}"); + continue; + } + }; + let Some(device) = select_recovery_device(&devices, &config.device) else { + debug!("No audio device found on recovery attempt {attempt}"); + continue; + }; + let streamer = Arc::new(AudioStreamer::with_config(AudioStreamerConfig { + capture: AudioConfig { + device_name: device.name.clone(), + ..Default::default() + }, + opus: config.quality.to_opus_config(), + })); + + if let Err(error) = streamer.start().await { + debug!( + "Audio recovery attempt {attempt} failed with {}: {error}", + device.name + ); + continue; + } + + // Commit a recovered streamer under the same operation lock used by + // user-driven start/stop/config updates. Cancellation is rechecked + // after acquiring the lock so an old task cannot resurrect itself. + let _operation = self.inner.operation.lock().await; + if !self.inner.control.is_current(generation) || !self.inner.config.read().await.enabled + { + let _ = streamer.stop().await; + return; + } + + self.inner.config.write().await.device = device.name.clone(); + *self.inner.streamer.write().await = Some(streamer.clone()); + self.inner.monitor.report_recovered().await; + self.publish_recovered(&device.name).await; + if let Some(callback) = self.inner.recovered_callback.read().await.clone() { + callback(); + } + self.publish_state("streaming", Some(device.name.clone()), None, None) + .await; + info!( + "Audio recovered with {} after {} attempts", + device.name, attempt + ); + self.inner.control.finish(generation); + self.monitor(streamer, device.name); + drop(_operation); + return; + } + } + + async fn publish_state( + &self, + state: &str, + device: Option, + reason: Option<&str>, + next_retry_ms: Option, + ) { + if let Some(bus) = self.inner.event_bus.read().await.as_ref() { + bus.publish(SystemEvent::StreamStateChanged { + kind: StreamKind::Audio, + state: state.to_string(), + device, + reason: reason.map(str::to_string), + next_retry_ms, + }); + bus.mark_device_info_dirty(); + } + } + + async fn publish_device_lost(&self, device: &str, reason: &str) { + if let Some(bus) = self.inner.event_bus.read().await.as_ref() { + bus.publish(SystemEvent::StreamDeviceLost { + kind: StreamKind::Audio, + device: device.to_string(), + reason: reason.to_string(), + }); + } + } + + async fn publish_reconnecting(&self, device: &str, attempt: u32) { + if let Some(bus) = self.inner.event_bus.read().await.as_ref() { + bus.publish(SystemEvent::StreamReconnecting { + device: device.to_string(), + attempt, + }); + } + } + + async fn publish_recovered(&self, device: &str) { + if let Some(bus) = self.inner.event_bus.read().await.as_ref() { + bus.publish(SystemEvent::StreamRecovered { + device: device.to_string(), + }); + } + } +} pub(super) fn select_recovery_device( devices: &[AudioDeviceInfo], preferred: &str, ) -> Option { - if let Some(device) = devices - .iter() - .find(|d| !preferred.trim().is_empty() && d.name == preferred) - { - return Some(device.clone()); - } - devices .iter() - .find(|d| d.is_hdmi && d.sample_rates.contains(&48_000) && d.channels.contains(&2)) + .find(|device| !preferred.trim().is_empty() && device.name == preferred) .or_else(|| { - devices - .iter() - .find(|d| d.sample_rates.contains(&48_000) && d.channels.contains(&2)) + devices.iter().find(|device| { + device.is_hdmi + && device.sample_rates.contains(&48_000) + && device.channels.contains(&2) + }) + }) + .or_else(|| { + devices.iter().find(|device| { + device.sample_rates.contains(&48_000) && device.channels.contains(&2) + }) }) .or_else(|| devices.first()) .cloned() } -async fn publish_state( - event_bus: &Arc>>>, - state: &str, - device: Option, - reason: Option<&str>, - next_retry_ms: Option, -) { - if let Some(bus) = event_bus.read().await.as_ref() { - bus.publish(SystemEvent::StreamStateChanged { - state: state.to_string(), - device, - reason: reason.map(str::to_string), - next_retry_ms, - }); - bus.mark_device_info_dirty(); - } -} +#[cfg(test)] +mod tests { + use super::*; -async fn publish_device_lost( - event_bus: &Arc>>>, - device: &str, - reason: &str, -) { - if let Some(bus) = event_bus.read().await.as_ref() { - bus.publish(SystemEvent::StreamDeviceLost { - kind: StreamDeviceLostKind::Audio, - device: device.to_string(), - reason: reason.to_string(), - }); - } -} - -async fn publish_reconnecting( - event_bus: &Arc>>>, - device: &str, - attempt: u32, -) { - if let Some(bus) = event_bus.read().await.as_ref() { - bus.publish(SystemEvent::StreamReconnecting { - device: device.to_string(), - attempt, - }); - } -} - -async fn publish_recovered(event_bus: &Arc>>>, device: &str) { - if let Some(bus) = event_bus.read().await.as_ref() { - bus.publish(SystemEvent::StreamRecovered { - device: device.to_string(), - }); - } -} - -fn spawn_stream_monitor_from_parts( - config: Arc>, - streamer_slot: Arc>>>, - event_bus: Arc>>>, - monitor: Arc, - recovery_in_progress: Arc, - recovered_callback: Arc>>, - streamer: Arc, - device: String, -) { - let mut state_rx = streamer.state_watch(); - - tokio::spawn(async move { - loop { - if state_rx.changed().await.is_err() { - return; - } - - if *state_rx.borrow() != AudioStreamState::Error { - continue; - } - - { - let current = streamer_slot.read().await; - if !current - .as_ref() - .is_some_and(|current| Arc::ptr_eq(current, &streamer)) - { - return; - } - } - - let reason = format!("Audio device lost: {}", device); - monitor.report_error(&reason, "device_lost").await; - spawn_recovery_task_from_parts( - config, - streamer_slot, - event_bus, - monitor, - recovery_in_progress, - recovered_callback, - device, - reason, - ); - return; + fn device(name: &str, compatible: bool, hdmi: bool) -> AudioDeviceInfo { + AudioDeviceInfo { + name: name.to_string(), + description: name.to_string(), + card_index: 0, + device_index: 0, + sample_rates: if compatible { + vec![48_000] + } else { + vec![44_100] + }, + channels: vec![2], + is_capture: true, + is_hdmi: hdmi, + usb_bus: None, } - }); -} - -fn spawn_recovery_task_from_parts( - config: Arc>, - streamer_slot: Arc>>>, - event_bus: Arc>>>, - monitor: Arc, - recovery_in_progress: Arc, - recovered_callback: Arc>>, - lost_device: String, - reason: String, -) { - if recovery_in_progress.swap(true, Ordering::SeqCst) { - debug!("Audio recovery already in progress"); - return; } - tokio::spawn(async move { - warn!("Audio recovery started for {}: {}", lost_device, reason); - publish_device_lost(&event_bus, &lost_device, &reason).await; - publish_state( - &event_bus, - "device_lost", - Some(lost_device.clone()), - Some("audio_device_lost"), - Some(AUDIO_RECOVERY_RETRY_DELAY.as_millis() as u64), - ) - .await; + #[test] + fn stale_recovery_cannot_finish_a_new_generation() { + let control = RecoveryControl::new(); + let stale = control.begin().unwrap(); + control.cancel(); + let current = control.begin().unwrap(); - let mut attempt = 0u32; + control.finish(stale); + assert!(control.is_current(current)); + } - loop { - if !recovery_in_progress.load(Ordering::SeqCst) { - debug!("Audio recovery canceled"); - return; - } + #[test] + fn completed_recovery_cannot_finish_the_next_recovery() { + let control = RecoveryControl::new(); + let completed = control.begin().unwrap(); + control.finish(completed); + let current = control.begin().unwrap(); - if streamer_slot - .read() - .await - .as_ref() - .is_some_and(|s| s.is_running()) - { - recovery_in_progress.store(false, Ordering::SeqCst); - return; - } + control.finish(completed); + assert!(control.is_current(current)); + } - let cfg: AudioControllerConfig = config.read().await.clone(); - if !cfg.enabled { - recovery_in_progress.store(false, Ordering::SeqCst); - return; - } - - attempt = attempt.saturating_add(1); - publish_reconnecting(&event_bus, &lost_device, attempt).await; - publish_state( - &event_bus, - "device_lost", - Some(lost_device.clone()), - Some("audio_reconnecting"), - Some(AUDIO_RECOVERY_RETRY_DELAY.as_millis() as u64), - ) - .await; - - tokio::time::sleep(AUDIO_RECOVERY_RETRY_DELAY).await; - - let devices = match enumerate_audio_devices() { - Ok(devices) => devices, - Err(e) => { - debug!( - "Audio recovery enumerate failed (attempt {}): {}", - attempt, e - ); - continue; - } - }; - - let Some(device) = select_recovery_device(&devices, &cfg.device) else { - debug!("No audio devices found during recovery attempt {}", attempt); - continue; - }; - - let streamer_config = AudioStreamerConfig { - capture: AudioConfig { - device_name: device.name.clone(), - ..Default::default() - }, - opus: cfg.quality.to_opus_config(), - }; - let new_streamer = Arc::new(AudioStreamer::with_config(streamer_config)); - - match new_streamer.start().await { - Ok(()) => { - { - let mut cfg = config.write().await; - cfg.device = device.name.clone(); - } - *streamer_slot.write().await = Some(new_streamer.clone()); - monitor.report_recovered().await; - publish_recovered(&event_bus, &device.name).await; - if let Some(callback) = recovered_callback.read().await.clone() { - callback(); - } - publish_state( - &event_bus, - "streaming", - Some(device.name.clone()), - None, - None, - ) - .await; - recovery_in_progress.store(false, Ordering::SeqCst); - info!( - "Audio device recovered with {} after {} attempts", - device.name, attempt - ); - spawn_stream_monitor_from_parts( - config, - streamer_slot, - event_bus, - monitor, - recovery_in_progress, - recovered_callback, - new_streamer, - device.name, - ); - return; - } - Err(e) => { - debug!( - "Audio recovery start failed with {} (attempt {}): {}", - device.name, attempt, e - ); - } - } - } - }); -} - -pub(super) fn spawn_stream_monitor( - config: Arc>, - streamer_slot: Arc>>>, - event_bus: Arc>>>, - monitor: Arc, - recovery_in_progress: Arc, - recovered_callback: Arc>>, - streamer: Arc, - device: String, -) { - spawn_stream_monitor_from_parts( - config, - streamer_slot, - event_bus, - monitor, - recovery_in_progress, - recovered_callback, - streamer, - device, - ); -} - -pub(super) fn spawn_recovery_task( - config: Arc>, - streamer_slot: Arc>>>, - event_bus: Arc>>>, - monitor: Arc, - recovery_in_progress: Arc, - recovered_callback: Arc>>, - lost_device: String, - reason: String, -) { - spawn_recovery_task_from_parts( - config, - streamer_slot, - event_bus, - monitor, - recovery_in_progress, - recovered_callback, - lost_device, - reason, - ); + #[test] + fn recovery_prefers_requested_then_compatible_hdmi() { + let devices = vec![device("fallback", true, false), device("hdmi", true, true)]; + assert_eq!( + select_recovery_device(&devices, "fallback").unwrap().name, + "fallback" + ); + assert_eq!( + select_recovery_device(&devices, "missing").unwrap().name, + "hdmi" + ); + } } diff --git a/src/audio/streamer.rs b/src/audio/streamer.rs index bdc1a207..c44f69c8 100644 --- a/src/audio/streamer.rs +++ b/src/audio/streamer.rs @@ -2,15 +2,15 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Duration; + use tokio::sync::{broadcast, mpsc, watch, Mutex as AsyncMutex, RwLock}; +use tokio::task::JoinHandle; use tracing::{debug, error, info, warn}; -use super::capture::{AudioCapturer, AudioConfig, AudioFrame, CaptureState}; +use super::capture::{AudioCapturer, AudioConfig, CaptureState}; use super::encoder::{OpusConfig, OpusEncoder, OpusFrame}; use crate::error::{AppError, Result}; -use bytemuck; -use bytes::Bytes; -use std::time::Duration; /// 48 kHz stereo: 20 ms = 960 × 2 samples (S16LE). const OPUS_STEREO_SAMPLES: usize = 960 * 2; @@ -40,16 +40,6 @@ impl AudioStreamerConfig { opus: OpusConfig::default(), } } - - pub fn with_bitrate(mut self, bitrate: u32) -> Self { - self.opus.bitrate = bitrate; - self - } -} - -#[derive(Debug, Clone, Default)] -pub struct AudioStreamStats { - pub subscriber_count: usize, } pub struct AudioStreamer { @@ -60,6 +50,9 @@ pub struct AudioStreamer { encoder: Arc>>, opus_subscribers: Arc>>>>, stop_flag: Arc, + shutdown_generation: watch::Sender, + lifecycle: AsyncMutex<()>, + stream_task: AsyncMutex>>, } impl AudioStreamer { @@ -69,6 +62,7 @@ impl AudioStreamer { pub fn with_config(config: AudioStreamerConfig) -> Self { let (state_tx, state_rx) = watch::channel(AudioStreamState::Stopped); + let (shutdown_generation, _) = watch::channel(0); Self { config: RwLock::new(config), @@ -78,6 +72,9 @@ impl AudioStreamer { encoder: Arc::new(AsyncMutex::new(None)), opus_subscribers: Arc::new(Mutex::new(Vec::new())), stop_flag: Arc::new(AtomicBool::new(false)), + shutdown_generation, + lifecycle: AsyncMutex::new(()), + stream_task: AsyncMutex::new(None), } } @@ -90,7 +87,9 @@ impl AudioStreamer { } pub fn subscribe_opus(&self) -> mpsc::Receiver> { - let (tx, rx) = mpsc::channel::>(128); + // Keep latency bounded for real-time consumers. Slow receivers lose + // new frames instead of accumulating seconds of stale audio. + let (tx, rx) = mpsc::channel::>(4); self.opus_subscribers.lock().unwrap().push(tx); rx } @@ -104,22 +103,6 @@ impl AudioStreamer { .count() } - pub fn stats(&self) -> AudioStreamStats { - AudioStreamStats { - subscriber_count: self.subscriber_count(), - } - } - - pub async fn set_config(&self, config: AudioStreamerConfig) -> Result<()> { - if self.state() != AudioStreamState::Stopped { - return Err(AppError::AudioError( - "Cannot change config while streaming".to_string(), - )); - } - *self.config.write().await = config; - Ok(()) - } - pub async fn set_bitrate(&self, bitrate: u32) -> Result<()> { self.config.write().await.opus.bitrate = bitrate; @@ -132,10 +115,25 @@ impl AudioStreamer { } pub async fn start(&self) -> Result<()> { - if self.state() == AudioStreamState::Running { + let _lifecycle = self.lifecycle.lock().await; + if matches!( + self.state(), + AudioStreamState::Starting | AudioStreamState::Running + ) { return Ok(()); } + // Error and stopped states may still own completed task handles. Reap + // them before installing a new capture pipeline so restart is a clean + // lifecycle transition rather than an overwrite of old resources. + if let Some(capturer) = self.capturer.write().await.take() { + let _ = capturer.stop().await; + } + if let Some(task) = self.stream_task.lock().await.take() { + let _ = task.await; + } + *self.encoder.lock().await = None; + let _ = self.state.send(AudioStreamState::Starting); self.stop_flag.store(false, Ordering::SeqCst); @@ -149,13 +147,21 @@ impl AudioStreamer { config.opus.bitrate ); - let capturer = Arc::new(AudioCapturer::new(config.capture.clone())); - *self.capturer.write().await = Some(capturer.clone()); - - let encoder = OpusEncoder::new(config.opus.clone())?; + let encoder = match OpusEncoder::new(config.opus.clone()) { + Ok(encoder) => encoder, + Err(error) => { + let _ = self.state.send(AudioStreamState::Error); + return Err(error); + } + }; *self.encoder.lock().await = Some(encoder); - capturer.start().await?; + let capturer = Arc::new(AudioCapturer::new(config.capture.clone())); + *self.capturer.write().await = Some(capturer.clone()); + if let Err(error) = capturer.start().await { + self.cleanup_failed_start(&capturer).await; + return Err(error); + } let mut capture_state = capturer.state_watch(); let startup_result = tokio::time::timeout(Duration::from_secs(2), async { @@ -168,7 +174,7 @@ impl AudioStreamer { "Audio capture failed to start".to_string(), )) } - CaptureState::Stopped => { + CaptureState::Stopped | CaptureState::Starting => { if capture_state.changed().await.is_err() { return Err(AppError::AudioError( "Audio capture stopped during startup".to_string(), @@ -183,17 +189,11 @@ impl AudioStreamer { match startup_result { Ok(Ok(())) => {} Ok(Err(e)) => { - let _ = capturer.stop().await; - *self.capturer.write().await = None; - *self.encoder.lock().await = None; - let _ = self.state.send(AudioStreamState::Error); + self.cleanup_failed_start(&capturer).await; return Err(e); } Err(_) => { - let _ = capturer.stop().await; - *self.capturer.write().await = None; - *self.encoder.lock().await = None; - let _ = self.state.send(AudioStreamState::Error); + self.cleanup_failed_start(&capturer).await; return Err(AppError::AudioError( "Timed out waiting for audio capture to start".to_string(), )); @@ -205,22 +205,27 @@ impl AudioStreamer { let opus_subscribers = self.opus_subscribers.clone(); let state = self.state.clone(); let stop_flag = self.stop_flag.clone(); + let shutdown_rx = self.shutdown_generation.subscribe(); + let _ = self.state.send(AudioStreamState::Running); - tokio::spawn(async move { + let task = tokio::spawn(async move { Self::stream_task( capturer_for_task, encoder, opus_subscribers, state, stop_flag, + shutdown_rx, ) .await; }); + *self.stream_task.lock().await = Some(task); Ok(()) } pub async fn stop(&self) -> Result<()> { + let _lifecycle = self.lifecycle.lock().await; if self.state() == AudioStreamState::Stopped { return Ok(()); } @@ -228,10 +233,16 @@ impl AudioStreamer { info!("Stopping audio stream"); self.stop_flag.store(true, Ordering::SeqCst); + self.shutdown_generation.send_modify(|generation| { + *generation = generation.wrapping_add(1); + }); if let Some(ref capturer) = *self.capturer.read().await { capturer.stop().await?; } + if let Some(task) = self.stream_task.lock().await.take() { + let _ = task.await; + } *self.capturer.write().await = None; *self.encoder.lock().await = None; @@ -242,28 +253,26 @@ impl AudioStreamer { Ok(()) } + async fn cleanup_failed_start(&self, capturer: &AudioCapturer) { + let _ = capturer.stop().await; + *self.capturer.write().await = None; + *self.encoder.lock().await = None; + let _ = self.state.send(AudioStreamState::Error); + } + pub fn is_running(&self) -> bool { self.state() == AudioStreamState::Running } - async fn fanout_opus( + fn fanout_opus( subscribers: &Arc>>>>, frame: Arc, ) { - let txs: Vec<_> = { - let g = subscribers.lock().unwrap(); - if g.is_empty() { - return; - } - g.clone() - }; - for tx in &txs { - let _ = tx.send(frame.clone()).await; - } - if txs.iter().any(|tx| tx.is_closed()) { - let mut g = subscribers.lock().unwrap(); - g.retain(|tx| !tx.is_closed()); - } + let mut subscribers = subscribers.lock().unwrap(); + subscribers.retain(|subscriber| match subscriber.try_send(frame.clone()) { + Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => true, + Err(mpsc::error::TrySendError::Closed(_)) => false, + }); } async fn stream_task( @@ -272,9 +281,9 @@ impl AudioStreamer { opus_subscribers: Arc>>>>, state: watch::Sender, stop_flag: Arc, + mut shutdown_rx: watch::Receiver, ) { let mut pcm_rx = capturer.subscribe(); - let _ = state.send(AudioStreamState::Running); debug!("Audio stream task started (48 kHz stereo → Opus, mpsc fan-out)"); @@ -291,8 +300,19 @@ impl AudioStreamer { break; } - let recv_result = - tokio::time::timeout(std::time::Duration::from_secs(2), pcm_rx.recv()).await; + let recv_result = tokio::select! { + biased; + changed = shutdown_rx.changed() => { + if changed.is_ok() || stop_flag.load(Ordering::Relaxed) { + break; + } + continue; + } + result = tokio::time::timeout( + std::time::Duration::from_secs(2), + pcm_rx.recv(), + ) => result, + }; match recv_result { Ok(Ok(audio_frame)) => { @@ -316,23 +336,17 @@ impl AudioStreamer { } while pending.len() >= OPUS_STEREO_SAMPLES { - let pcm_20ms = Bytes::copy_from_slice(bytemuck::cast_slice( - &pending[..OPUS_STEREO_SAMPLES], - )); - pending.drain(..OPUS_STEREO_SAMPLES); - - let frame_48k = AudioFrame::new_interleaved(pcm_20ms, 2, 48_000, 0); - let opus_result = { let mut enc_guard = encoder.lock().await; (*enc_guard) .as_mut() - .map(|enc| enc.encode_frame(&frame_48k)) + .map(|enc| enc.encode(&pending[..OPUS_STEREO_SAMPLES])) }; + pending.drain(..OPUS_STEREO_SAMPLES); match opus_result { Some(Ok(opus_frame)) => { - Self::fanout_opus(&opus_subscribers, Arc::new(opus_frame)).await; + Self::fanout_opus(&opus_subscribers, Arc::new(opus_frame)); } Some(Err(e)) => { error!("Opus encode error: {}", e); @@ -365,6 +379,7 @@ impl AudioStreamer { let _ = state.send(AudioStreamState::Stopped); } else { opus_subscribers.lock().unwrap().clear(); + let _ = capturer.stop().await; } info!("Audio stream task ended"); } @@ -379,6 +394,7 @@ impl Default for AudioStreamer { #[cfg(test)] mod tests { use super::*; + use bytes::Bytes; #[test] fn test_streamer_config_default() { @@ -398,4 +414,42 @@ mod tests { let streamer = AudioStreamer::new(); assert_eq!(streamer.state(), AudioStreamState::Stopped); } + + #[test] + fn slow_subscriber_does_not_block_or_grow_unbounded() { + let streamer = AudioStreamer::new(); + let mut receiver = streamer.subscribe_opus(); + for sequence in 0..20 { + AudioStreamer::fanout_opus( + &streamer.opus_subscribers, + Arc::new(OpusFrame { + data: Bytes::from_static(&[1]), + duration_ms: 20, + sequence, + }), + ); + } + + let mut received = 0; + while receiver.try_recv().is_ok() { + received += 1; + } + assert_eq!(received, 4); + } + + #[test] + fn closed_subscriber_is_pruned() { + let streamer = AudioStreamer::new(); + let receiver = streamer.subscribe_opus(); + drop(receiver); + AudioStreamer::fanout_opus( + &streamer.opus_subscribers, + Arc::new(OpusFrame { + data: Bytes::from_static(&[1]), + duration_ms: 20, + sequence: 0, + }), + ); + assert_eq!(streamer.subscriber_count(), 0); + } } diff --git a/src/audio/uac/decoder.rs b/src/audio/uac/decoder.rs new file mode 100644 index 00000000..bf33ae7d --- /dev/null +++ b/src/audio/uac/decoder.rs @@ -0,0 +1,59 @@ +use audiopus::coder::Decoder; +use audiopus::{Channels, SampleRate}; + +use crate::error::{AppError, Result}; + +const CHANNELS: usize = 2; +const MAX_PACKET_BYTES: usize = 1275; +const MAX_SAMPLES_PER_CHANNEL: usize = 5760; + +pub struct UacOpusDecoder { + decoder: Decoder, + buffer: Vec, +} + +impl UacOpusDecoder { + pub fn new() -> Result { + let decoder = Decoder::new(SampleRate::Hz48000, Channels::Stereo) + .map_err(|error| AppError::AudioError(format!("Opus decoder init failed: {error}")))?; + Ok(Self { + decoder, + buffer: vec![0; MAX_SAMPLES_PER_CHANNEL * CHANNELS], + }) + } + + pub fn decode(&mut self, packet: &[u8]) -> Result<&[i16]> { + if packet.is_empty() || packet.len() > MAX_PACKET_BYTES { + return Err(AppError::BadRequest(format!( + "invalid Opus packet length {}", + packet.len() + ))); + } + + let frames = self + .decoder + .decode(Some(packet), &mut self.buffer, false) + .map_err(|error| AppError::AudioError(format!("Opus decode failed: {error}")))?; + Ok(&self.buffer[..frames * CHANNELS]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use audiopus::coder::Encoder; + use audiopus::Application; + + #[test] + fn decode_preserves_all_stereo_samples() { + let encoder = + Encoder::new(SampleRate::Hz48000, Channels::Stereo, Application::Audio).unwrap(); + let pcm = vec![0i16; 960 * CHANNELS]; + let mut packet = vec![0u8; MAX_PACKET_BYTES]; + let packet_len = encoder.encode(&pcm, &mut packet).unwrap(); + + let mut decoder = UacOpusDecoder::new().unwrap(); + let decoded = decoder.decode(&packet[..packet_len]).unwrap(); + assert_eq!(decoded.len(), pcm.len()); + } +} diff --git a/src/audio/uac/mod.rs b/src/audio/uac/mod.rs new file mode 100644 index 00000000..d7ef00ad --- /dev/null +++ b/src/audio/uac/mod.rs @@ -0,0 +1,9 @@ +//! Browser-to-USB microphone audio pipeline. + +mod decoder; +mod playback; +mod protocol; + +pub use decoder::UacOpusDecoder; +pub use playback::{UacPlayback, UacPlaybackConfig, UacPlaybackState, UacSession}; +pub use protocol::{parse_audio_packet, UacAudioPacket}; diff --git a/src/audio/uac/playback.rs b/src/audio/uac/playback.rs new file mode 100644 index 00000000..98fb9805 --- /dev/null +++ b/src/audio/uac/playback.rs @@ -0,0 +1,509 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use alsa::pcm::{Access, Format, Frames, HwParams, State}; +use alsa::{Direction, ValueOr, PCM}; +use tracing::{info, warn}; + +use crate::error::{AppError, Result}; + +const RETRY_BACKOFF: Duration = Duration::from_secs(1); +const PERIOD_FRAMES: Frames = 960; +const BUFFER_FRAMES: Frames = 4_800; +const START_THRESHOLD_PERIODS: Frames = 4; +const SINK_STALL_TIMEOUT: Duration = Duration::from_millis(200); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UacPlaybackState { + Idle, + Waiting, + Active, + Stalled, +} + +impl UacPlaybackState { + pub fn as_str(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Waiting => "waiting", + Self::Active => "active", + Self::Stalled => "stalled", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UacPlaybackConfig { + pub device_name: String, + pub sample_rate: u32, + pub channels: u16, +} + +impl Default for UacPlaybackConfig { + fn default() -> Self { + Self { + device_name: crate::otg::uac::find_uac_pcm_device() + .unwrap_or_else(crate::otg::uac::uac_pcm_device), + sample_rate: 48_000, + channels: 2, + } + } +} + +struct PlaybackInner { + config: UacPlaybackConfig, + stopped: AtomicBool, + active_session: Mutex>>>, +} + +enum SessionSink { + Closed { retry_at: Option }, + Probing { pcm: PCM, stalled: bool }, + Active { pcm: PCM, last_progress: Instant }, +} + +impl SessionSink { + fn state(&self) -> UacPlaybackState { + match self { + Self::Closed { retry_at: None } => UacPlaybackState::Waiting, + Self::Closed { retry_at: Some(_) } => UacPlaybackState::Stalled, + Self::Probing { stalled: false, .. } => UacPlaybackState::Waiting, + Self::Probing { stalled: true, .. } => UacPlaybackState::Stalled, + Self::Active { .. } => UacPlaybackState::Active, + } + } +} + +struct SessionRuntime { + sink: SessionSink, +} + +impl SessionRuntime { + fn new() -> Self { + Self { + sink: SessionSink::Closed { retry_at: None }, + } + } + + fn state(&self) -> UacPlaybackState { + self.sink.state() + } + + fn close(&mut self) { + self.sink = SessionSink::Closed { retry_at: None }; + } + + /// Advance playback only when a WebSocket frame arrives. All ALSA handles + /// are non-blocking, so a slow or absent USB host drops the current frame + /// instead of occupying a worker thread or accumulating stale speech. + fn write(&mut self, config: &UacPlaybackConfig, samples: &[i16]) -> bool { + let sink = std::mem::replace(&mut self.sink, SessionSink::Closed { retry_at: None }); + let (next_sink, accepted) = drive_sink(sink, config, samples); + self.sink = next_sink; + accepted + } +} + +#[derive(Clone)] +pub struct UacPlayback { + inner: Arc, +} + +pub struct UacSession { + playback: UacPlayback, + runtime: Arc>, +} + +impl UacPlayback { + pub fn start(config: UacPlaybackConfig) -> Result { + if config.sample_rate != 48_000 || config.channels != 2 { + return Err(AppError::BadRequest( + "UAC playback supports only 48000 Hz stereo".to_string(), + )); + } + + Ok(Self { + inner: Arc::new(PlaybackInner { + config, + stopped: AtomicBool::new(false), + active_session: Mutex::new(None), + }), + }) + } + + pub fn acquire_session(&self) -> Result { + let mut active = self.inner.active_session.lock().unwrap(); + if self.inner.stopped.load(Ordering::Acquire) { + return Err(AppError::ServiceUnavailable( + "UAC playback is stopping".to_string(), + )); + } + if active.is_some() { + return Err(AppError::ServiceUnavailable( + "another UAC microphone session is already active".to_string(), + )); + } + + let runtime = Arc::new(Mutex::new(SessionRuntime::new())); + *active = Some(Arc::clone(&runtime)); + Ok(UacSession { + playback: self.clone(), + runtime, + }) + } + + /// Stop accepting frames and synchronously close an active ALSA handle. + /// This guarantees configfs may rebuild the UAC function after this call. + pub fn stop(&self) { + if self.inner.stopped.swap(true, Ordering::AcqRel) { + return; + } + let runtime = self.inner.active_session.lock().unwrap().take(); + if let Some(runtime) = runtime { + runtime.lock().unwrap().close(); + } + } +} + +impl UacSession { + pub fn state(&self) -> UacPlaybackState { + self.runtime.lock().unwrap().state() + } + + /// Returns whether the frame was accepted and the resulting target state. + pub fn try_write(&self, pcm: &[i16]) -> Result<(bool, UacPlaybackState)> { + let channels = self.playback.inner.config.channels as usize; + if pcm.is_empty() || !pcm.len().is_multiple_of(channels) { + return Err(AppError::BadRequest( + "UAC PCM must contain complete stereo frames".to_string(), + )); + } + if self.playback.inner.stopped.load(Ordering::Acquire) { + return Err(AppError::ServiceUnavailable( + "UAC playback has stopped".to_string(), + )); + } + + let mut runtime = self.runtime.lock().unwrap(); + if self.playback.inner.stopped.load(Ordering::Acquire) { + runtime.close(); + return Err(AppError::ServiceUnavailable( + "UAC playback has stopped".to_string(), + )); + } + let accepted = runtime.write(&self.playback.inner.config, pcm); + Ok((accepted, runtime.state())) + } +} + +impl Drop for UacSession { + fn drop(&mut self) { + let mut active = self.playback.inner.active_session.lock().unwrap(); + if active + .as_ref() + .is_some_and(|session| Arc::ptr_eq(session, &self.runtime)) + { + *active = None; + } + drop(active); + self.runtime.lock().unwrap().close(); + } +} + +fn drive_sink( + sink: SessionSink, + config: &UacPlaybackConfig, + samples: &[i16], +) -> (SessionSink, bool) { + match sink { + SessionSink::Closed { retry_at } => { + if retry_at.is_some_and(|deadline| Instant::now() < deadline) { + return (SessionSink::Closed { retry_at }, false); + } + + match open_pcm(config).and_then(|pcm| { + prime_pcm_with_silence(&pcm, config.channels as usize)?; + Ok(pcm) + }) { + Ok(pcm) => drive_probe(pcm, false, config, samples), + Err(error) => { + warn!("Failed to open UAC playback device; retrying later: {error}"); + ( + SessionSink::Closed { + retry_at: Some(Instant::now() + RETRY_BACKOFF), + }, + false, + ) + } + } + } + SessionSink::Probing { pcm, stalled } => drive_probe(pcm, stalled, config, samples), + SessionSink::Active { pcm, last_progress } => { + drive_active(pcm, last_progress, config, samples) + } + } +} + +fn drive_probe( + pcm: PCM, + stalled: bool, + config: &UacPlaybackConfig, + samples: &[i16], +) -> (SessionSink, bool) { + match sink_is_consuming(&pcm) { + Ok(false) => (SessionSink::Probing { pcm, stalled }, false), + Ok(true) => { + if let Err(error) = reset_pcm_buffer(&pcm) { + warn!("Failed to activate UAC playback; retrying later: {error}"); + return retry_later(); + } + info!("UAC target started consuming microphone audio"); + drive_active(pcm, Instant::now(), config, samples) + } + Err(error) => { + warn!("Failed to probe UAC playback; retrying later: {error}"); + retry_later() + } + } +} + +fn drive_active( + pcm: PCM, + last_progress: Instant, + config: &UacPlaybackConfig, + samples: &[i16], +) -> (SessionSink, bool) { + match write_pcm_nonblocking(&pcm, samples, config.channels as usize) { + Ok(WriteOutcome::Progress) => ( + SessionSink::Active { + pcm, + last_progress: Instant::now(), + }, + true, + ), + Ok(WriteOutcome::Recovered) => ( + SessionSink::Active { + pcm, + last_progress: Instant::now(), + }, + false, + ), + Ok(WriteOutcome::Blocked) if last_progress.elapsed() < SINK_STALL_TIMEOUT => { + (SessionSink::Active { pcm, last_progress }, false) + } + Ok(WriteOutcome::Blocked) => { + if let Err(error) = reset_pcm_buffer(&pcm) + .and_then(|_| prime_pcm_with_silence(&pcm, config.channels as usize)) + { + warn!("Failed to reset stalled UAC playback: {error}"); + return retry_later(); + } + info!("UAC target stopped consuming audio; waiting for playback activity"); + (SessionSink::Probing { pcm, stalled: true }, false) + } + Err(error) => { + warn!("UAC playback write failed; retrying later: {error}"); + retry_later() + } + } +} + +fn retry_later() -> (SessionSink, bool) { + ( + SessionSink::Closed { + retry_at: Some(Instant::now() + RETRY_BACKOFF), + }, + false, + ) +} + +fn open_pcm(config: &UacPlaybackConfig) -> Result { + let pcm = PCM::new(&config.device_name, Direction::Playback, true).map_err(|error| { + AppError::AudioError(format!( + "Failed to open UAC device {}: {error}", + config.device_name + )) + })?; + { + let params = HwParams::any(&pcm) + .map_err(|error| AppError::AudioError(format!("UAC HwParams failed: {error}")))?; + params + .set_channels(config.channels as u32) + .and_then(|_| params.set_rate(config.sample_rate, ValueOr::Nearest)) + .and_then(|_| params.set_format(Format::s16())) + .and_then(|_| params.set_access(Access::RWInterleaved)) + .and_then(|_| params.set_period_size_near(PERIOD_FRAMES, ValueOr::Nearest)) + .and_then(|_| params.set_buffer_size_near(BUFFER_FRAMES)) + .and_then(|_| pcm.hw_params(¶ms)) + .map_err(|error| { + AppError::AudioError(format!("Failed to configure UAC playback: {error}")) + })?; + } + + let (buffer_frames, period_frames) = pcm.get_params().map_err(|error| { + AppError::AudioError(format!("Failed to read UAC PCM parameters: {error}")) + })?; + { + let params = pcm.sw_params_current().map_err(|error| { + AppError::AudioError(format!("Failed to read UAC SwParams: {error}")) + })?; + let start_threshold = + (period_frames as Frames * START_THRESHOLD_PERIODS).min(buffer_frames as Frames); + params + .set_start_threshold(start_threshold) + .and_then(|_| params.set_avail_min(period_frames as Frames)) + .and_then(|_| pcm.sw_params(¶ms)) + .map_err(|error| { + AppError::AudioError(format!("Failed to configure UAC SwParams: {error}")) + })?; + } + pcm.prepare().map_err(|error| { + AppError::AudioError(format!("Failed to prepare UAC playback: {error}")) + })?; + info!( + "UAC playback opened on {} (buffer={} frames, period={} frames)", + config.device_name, buffer_frames, period_frames + ); + Ok(pcm) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WriteOutcome { + Progress, + Blocked, + Recovered, +} + +fn write_pcm_nonblocking(pcm: &PCM, samples: &[i16], channels: usize) -> Result { + let total_frames = samples.len() / channels; + match pcm.avail() { + Ok(available) if available < total_frames as Frames => return Ok(WriteOutcome::Blocked), + Ok(_) => {} + Err(error) => { + recover_pcm(pcm, error)?; + return Ok(WriteOutcome::Recovered); + } + } + + let io = pcm + .io_i16() + .map_err(|error| AppError::AudioError(format!("UAC PCM I/O failed: {error}")))?; + match io.writei(samples) { + Ok(0) => Ok(WriteOutcome::Blocked), + Ok(_) => Ok(WriteOutcome::Progress), + Err(error) if error.errno() == libc::EAGAIN => Ok(WriteOutcome::Blocked), + Err(error) => { + recover_pcm(pcm, error)?; + Ok(WriteOutcome::Recovered) + } + } +} + +/// Once a full playback buffer gains at least one period of free space, the +/// USB host has enabled the UAC streaming interface and is consuming samples. +fn sink_is_consuming(pcm: &PCM) -> Result { + if pcm.state() == State::XRun { + return Ok(true); + } + + match pcm.avail() { + Ok(available) => Ok(available >= PERIOD_FRAMES), + Err(error) if error.errno() == libc::EPIPE => Ok(true), + Err(error) => Err(AppError::AudioError(format!( + "Failed to query UAC playback availability: {error}" + ))), + } +} + +fn recover_pcm(pcm: &PCM, error: alsa::Error) -> Result<()> { + let errno = error.errno(); + pcm.try_recover(error, true).map_err(|recover_error| { + AppError::AudioError(format!("Failed to recover UAC playback: {recover_error}")) + })?; + if matches!(errno, libc::EPIPE | libc::ESTRPIPE) { + warn!("Recovered UAC playback after ALSA error {errno}"); + } + Ok(()) +} + +fn reset_pcm_buffer(pcm: &PCM) -> Result<()> { + pcm.drop() + .and_then(|_| pcm.prepare()) + .map_err(|error| AppError::AudioError(format!("Failed to reset UAC PCM: {error}"))) +} + +/// Prime the non-blocking ALSA buffer with silence. Subsequent WebSocket +/// frames inspect buffer progress to detect when the USB host starts reading. +fn prime_pcm_with_silence(pcm: &PCM, channels: usize) -> Result<()> { + let silence = vec![0i16; BUFFER_FRAMES as usize * channels]; + let io = pcm + .io_i16() + .map_err(|error| AppError::AudioError(format!("UAC PCM I/O failed: {error}")))?; + let mut frame_offset = 0usize; + while frame_offset < BUFFER_FRAMES as usize { + match io.writei(&silence[frame_offset * channels..]) { + Ok(0) => break, + Ok(written) => frame_offset += written, + Err(error) if error.errno() == libc::EAGAIN => break, + Err(error) => { + return Err(AppError::AudioError(format!( + "Failed to prime UAC PCM with silence: {error}" + ))); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permits_only_one_microphone_session() { + let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap(); + let first = playback.acquire_session().unwrap(); + assert_eq!(first.state(), UacPlaybackState::Waiting); + assert!(playback.acquire_session().is_err()); + + drop(first); + assert!(playback.acquire_session().is_ok()); + playback.stop(); + } + + #[test] + fn stop_rejects_new_and_existing_session_writes() { + let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap(); + let session = playback.acquire_session().unwrap(); + + playback.stop(); + + assert!(session.try_write(&[0, 0]).is_err()); + assert!(playback.acquire_session().is_err()); + } + + #[test] + fn rejects_incomplete_stereo_frames_before_opening_alsa() { + let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap(); + let session = playback.acquire_session().unwrap(); + + assert!(session.try_write(&[0]).is_err()); + assert_eq!(session.state(), UacPlaybackState::Waiting); + } + + #[test] + fn closed_sink_state_reflects_retry_backoff() { + assert_eq!( + SessionSink::Closed { retry_at: None }.state(), + UacPlaybackState::Waiting + ); + assert_eq!( + SessionSink::Closed { + retry_at: Some(Instant::now()) + } + .state(), + UacPlaybackState::Stalled + ); + } +} diff --git a/src/audio/uac/protocol.rs b/src/audio/uac/protocol.rs new file mode 100644 index 00000000..e5100ddc --- /dev/null +++ b/src/audio/uac/protocol.rs @@ -0,0 +1,98 @@ +use crate::error::{AppError, Result}; + +const HEADER_SIZE: usize = 15; +const OPUS_MESSAGE: u8 = 0x03; +const PCM_MESSAGE: u8 = 0x04; +const CHANNELS: usize = 2; +const MAX_PCM_SAMPLES: usize = 5760 * CHANNELS; + +#[derive(Debug, PartialEq, Eq)] +pub enum UacAudioPacket<'a> { + Opus(&'a [u8]), + Pcm(&'a [u8]), +} + +impl UacAudioPacket<'_> { + pub fn pcm_samples(&self) -> Result> { + let Self::Pcm(bytes) = self else { + return Err(AppError::BadRequest("packet is not raw PCM".to_string())); + }; + if bytes.is_empty() || bytes.len() % (CHANNELS * 2) != 0 { + return Err(AppError::BadRequest(format!( + "invalid stereo PCM byte length {}", + bytes.len() + ))); + } + if bytes.len() / 2 > MAX_PCM_SAMPLES { + return Err(AppError::BadRequest("PCM frame exceeds 120 ms".to_string())); + } + + Ok(bytes + .chunks_exact(2) + .map(|sample| i16::from_le_bytes([sample[0], sample[1]])) + .collect()) + } +} + +pub fn parse_audio_packet(data: &[u8]) -> Result> { + if data.len() < HEADER_SIZE { + return Err(AppError::BadRequest( + "UAC frame is shorter than its header".to_string(), + )); + } + + let payload_len = u32::from_le_bytes([data[11], data[12], data[13], data[14]]) as usize; + let expected_len = HEADER_SIZE + .checked_add(payload_len) + .ok_or_else(|| AppError::BadRequest("UAC payload length overflow".to_string()))?; + if data.len() != expected_len { + return Err(AppError::BadRequest( + "UAC payload length does not match its header".to_string(), + )); + } + + let payload = &data[HEADER_SIZE..]; + match data[0] { + OPUS_MESSAGE => Ok(UacAudioPacket::Opus(payload)), + PCM_MESSAGE => Ok(UacAudioPacket::Pcm(payload)), + message_type => Err(AppError::BadRequest(format!( + "unsupported UAC message type 0x{message_type:02x}" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn message(message_type: u8, payload: &[u8]) -> Vec { + let mut data = vec![0; HEADER_SIZE + payload.len()]; + data[0] = message_type; + data[11..15].copy_from_slice(&(payload.len() as u32).to_le_bytes()); + data[HEADER_SIZE..].copy_from_slice(payload); + data + } + + #[test] + fn requires_exact_payload_length() { + let valid = message(OPUS_MESSAGE, &[1, 2, 3]); + assert_eq!( + parse_audio_packet(&valid).unwrap(), + UacAudioPacket::Opus(&[1, 2, 3]) + ); + + let mut trailing = valid.clone(); + trailing.push(4); + assert!(parse_audio_packet(&trailing).is_err()); + assert!(parse_audio_packet(&valid[..valid.len() - 1]).is_err()); + } + + #[test] + fn converts_little_endian_stereo_pcm() { + let data = message(PCM_MESSAGE, &[1, 0, 255, 255]); + assert_eq!( + parse_audio_packet(&data).unwrap().pcm_samples().unwrap(), + vec![1, -1] + ); + } +} diff --git a/src/audio/uac_streamer.rs b/src/audio/uac_streamer.rs deleted file mode 100644 index 7a8edc37..00000000 --- a/src/audio/uac_streamer.rs +++ /dev/null @@ -1,210 +0,0 @@ -use std::io::Write; -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use tokio::sync::{mpsc, watch}; -use tracing::{info, warn}; - -use crate::error::{AppError, Result}; - -/// Kill aplay after this much idle time (no incoming audio frames). -const IDLE_CLOSE_TIMEOUT_MS: u64 = 2000; - -/// Configuration for the UAC playback stream. -#[derive(Debug, Clone)] -pub struct UacPlaybackConfig { - pub device_name: String, - pub sample_rate: u32, - pub channels: u16, -} - -impl Default for UacPlaybackConfig { - fn default() -> Self { - Self { - device_name: crate::otg::uac::find_uac_pcm_device() - .unwrap_or_else(crate::otg::uac::uac_pcm_device), - sample_rate: 48000, - channels: 2, - } - } -} - -#[derive(Debug, Clone)] -pub struct UacPcmFrame { - pub data: Vec, - pub duration_ms: u32, -} - -/// Writes PCM to the UAC gadget via `aplay` subprocess — same -/// mechanism as the successful manual test: `ffmpeg | aplay hw:0,0`. -#[derive(Clone)] -pub struct UacPlaybackWriter { - pcm_sender: mpsc::Sender, - stop_tx: watch::Sender, -} - -impl UacPlaybackWriter { - pub fn start(config: UacPlaybackConfig) -> Result { - let (pcm_sender, pcm_receiver) = mpsc::channel::(64); - let (stop_tx, stop_rx) = watch::channel(false); - - let device = config.device_name; - let rate = config.sample_rate; - let ch = config.channels; - - let thread_device = device.clone(); - std::thread::Builder::new() - .name("uac-aplay".into()) - .spawn(move || { - Self::playback_loop(&thread_device, rate, ch, pcm_receiver, stop_rx); - info!("UAC aplay thread stopped"); - }) - .map_err(|e| AppError::Internal(format!("spawn: {e}")))?; - - info!("UAC aplay writer started on {device}"); - Ok(Self { pcm_sender, stop_tx }) - } - - pub async fn write(&self, frame: UacPcmFrame) -> Result<()> { - self.pcm_sender.send(frame).await - .map_err(|_| AppError::Internal("UAC channel closed".into())) - } - - pub fn stop(&self) { - let _ = self.stop_tx.send(true); - } - - // ── internals ────────────────────────────────────────── - - fn spawn_aplay(device: &str, rate: u32, ch: u16) -> Option<(Child, Box)> { - let mut cmd = Command::new("aplay"); - cmd.arg("-D").arg(device) - .arg("-f").arg("S16_LE") - .arg("-r").arg(rate.to_string()) - .arg("-c").arg(ch.to_string()) - .arg("-") // stdin - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::inherit()); // → journalctl - - match cmd.spawn() { - Ok(mut child) => { - let stdin = child.stdin.take()?; - info!("aplay spawned pid={}", child.id()); - Some((child, Box::new(stdin))) - } - Err(e) => { - warn!("aplay spawn failed: {e}"); - None - } - } - } - - fn kill_aplay(mut child: Child, stdin: Box) { - drop(stdin); // close pipe → EOF for aplay - let _ = child.wait(); - } - - fn playback_loop( - device: &str, - rate: u32, - ch: u16, - mut receiver: mpsc::Receiver, - mut stop_rx: watch::Receiver, - ) { - let idle_timeout = Duration::from_millis(IDLE_CLOSE_TIMEOUT_MS); - let mut aplay: Option<(Child, Box)> = None; - let mut last_write = std::time::Instant::now(); - let mut frame_count: u64 = 0; - let mut byte_count: u64 = 0; - - loop { - // ── wait for frame ────────────────────────── - let need_timeout = aplay.is_some() - && last_write.elapsed() >= idle_timeout; - let deadline = if need_timeout || aplay.is_none() { - Some(std::time::Instant::now() + Duration::from_millis(200)) - } else { - None - }; - - let frame = loop { - if *stop_rx.borrow() { break None; } - match receiver.try_recv() { - Ok(f) => break Some(f), - Err(mpsc::error::TryRecvError::Disconnected) => break None, - Err(mpsc::error::TryRecvError::Empty) => {} - } - if let Some(dl) = deadline { - if std::time::Instant::now() >= dl { - break None; - } - } - std::thread::sleep(Duration::from_millis(20)); - }; - - if *stop_rx.borrow() { - break; - } - - match frame { - Some(f) => { - last_write = std::time::Instant::now(); - - // Ensure aplay is alive - if aplay.is_none() { - aplay = Self::spawn_aplay(device, rate, ch); - } - - if let Some((child, stdin)) = aplay.as_mut() { - // Check child health - match child.try_wait() { - Ok(Some(status)) => { - warn!("aplay died: {status}"); - aplay = Self::spawn_aplay(device, rate, ch); - if aplay.is_none() { continue; } - } - Ok(None) => {} // alive - Err(e) => { - warn!("aplay wait error: {e}"); - aplay = None; - continue; - } - } - } - - if let Some((child, stdin)) = aplay.as_mut() { - match stdin.write_all(&f.data) { - Ok(()) => { - let _ = stdin.flush(); - frame_count += 1; - byte_count += f.data.len() as u64; - } - Err(e) => { - warn!("aplay write error: {e}"); - // aplay died — reap and restart - if let Some((c, s)) = aplay.take() { - Self::kill_aplay(c, s); - } - } - } - } - } - None => { - // Timeout — kill aplay - if let Some((c, s)) = aplay.take() { - Self::kill_aplay(c, s); - } - if *stop_rx.borrow() { - break; - } - } - } - } - - // Cleanup - if let Some((c, s)) = aplay.take() { - Self::kill_aplay(c, s); - } - } -} diff --git a/src/audio/uac_websocket.rs b/src/audio/uac_websocket.rs deleted file mode 100644 index 6103131e..00000000 --- a/src/audio/uac_websocket.rs +++ /dev/null @@ -1,133 +0,0 @@ -use audiopus::coder::Decoder as OpusDecoder; -use audiopus::{Channels, SampleRate}; -use axum::extract::ws::{Message, WebSocket}; -use tracing::{debug, error, info, warn}; - -use super::uac_streamer::{UacPcmFrame, UacPlaybackWriter}; -use std::sync::Arc; - -/// Binary protocol header for UAC audio input. -/// -/// 0x03 — message type (reverse audio / microphone passthrough) -/// timestamp — u32 LE (milliseconds, for future sync) -/// duration — u16 LE (frame duration in ms, typically 20) -/// sequence — u32 LE (frame counter, for loss detection) -/// data_len — u32 LE (Opus payload length in bytes) -const UAC_AUDIO_HEADER_SIZE: usize = 15; -const UAC_AUDIO_MSG_TYPE: u8 = 0x03; - -/// Accept incoming Opus audio frames over WebSocket and route them -/// to the UAC playback writer. -pub async fn handle_uac_audio_ws( - mut ws: WebSocket, - playback: Arc, -) { - // Create an Opus decoder: 48kHz stereo → PCM S16LE. - let mut decoder = match OpusDecoder::new(SampleRate::Hz48000, Channels::Stereo) { - Ok(d) => d, - Err(e) => { - error!("Failed to create Opus decoder: {}", e); - let _ = ws.send(Message::Close(None)).await; - return; - } - }; - - info!("UAC audio WebSocket connected (mic passthrough)"); - - while let Some(msg) = ws.recv().await { - let msg = match msg { - Ok(m) => m, - Err(e) => { - warn!("UAC WebSocket error: {}", e); - break; - } - }; - - match msg { - Message::Binary(data) => { - if data.len() < UAC_AUDIO_HEADER_SIZE { - warn!( - "UAC audio frame too short: {} bytes (min {})", - data.len(), - UAC_AUDIO_HEADER_SIZE - ); - continue; - } - - let msg_type = data[0]; - if msg_type == 0x04 { - // Raw PCM passthrough — no Opus decode needed. - // Useful for testing and for clients that encode locally. - let duration = u16::from_le_bytes([data[5], data[6]]); - let data_len = u32::from_le_bytes([data[11], data[12], data[13], data[14]]) as usize; - if data.len() < UAC_AUDIO_HEADER_SIZE + data_len { - warn!("UAC PCM frame truncated"); - continue; - } - let pcm_bytes = &data[UAC_AUDIO_HEADER_SIZE..UAC_AUDIO_HEADER_SIZE + data_len]; - if let Err(e) = playback - .write(super::uac_streamer::UacPcmFrame { - data: pcm_bytes.to_vec(), - duration_ms: duration as u32, - }) - .await - { - error!("Failed to send UAC PCM frame: {}", e); - break; - } - continue; - } - if msg_type != UAC_AUDIO_MSG_TYPE { - warn!("UAC unknown msg type: 0x{msg_type:02x}"); - continue; - } - - let duration = u16::from_le_bytes([data[5], data[6]]); - let data_len = u32::from_le_bytes([data[11], data[12], data[13], data[14]]) as usize; - - if data.len() < UAC_AUDIO_HEADER_SIZE + data_len { - warn!("UAC audio frame truncated"); - continue; - } - - let opus_payload = &data[UAC_AUDIO_HEADER_SIZE..UAC_AUDIO_HEADER_SIZE + data_len]; - - let frame_samples = (48000u32 * duration as u32 / 1000) as usize * 2; // 2 channels - let mut pcm_i16 = vec![0i16; frame_samples]; - - match decoder.decode(Some(opus_payload), &mut pcm_i16, false) { - Ok(decoded) => { - // Convert i16 → bytes (S16LE interleaved) - let pcm_bytes: Vec = pcm_i16[..decoded] - .iter() - .flat_map(|s| s.to_le_bytes()) - .collect(); - if let Err(e) = playback - .write(UacPcmFrame { - data: pcm_bytes, - duration_ms: duration as u32, - }) - .await - { - error!("Failed to send UAC PCM frame: {}", e); - break; - } - } - Err(e) => { - warn!("Opus decode error: {}", e); - } - } - } - Message::Ping(_) | Message::Pong(_) => {} - Message::Close(_) => { - debug!("UAC audio WebSocket closing"); - break; - } - Message::Text(_) => { - // Ignore text messages - } - } - } - - info!("UAC audio WebSocket disconnected"); -} diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index 625b67be..13885088 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -71,6 +71,7 @@ pub async fn auth_middleware( fn unauthorized_response(message: &str) -> Response { let body = ErrorResponse { success: false, + code: None, message: message.to_string(), }; (StatusCode::UNAUTHORIZED, Json(body)).into_response() @@ -92,6 +93,11 @@ fn is_public_endpoint(path: &str) -> bool { fn is_setup_public_endpoint(path: &str) -> bool { matches!( path, - "/setup" | "/setup/init" | "/devices" | "/stream/codecs" + "/setup" + | "/setup/init" + | "/devices" + | "/video/input-status" + | "/stream/codecs" + | "/video/codecs" ) } diff --git a/src/config/schema/mod.rs b/src/config/schema/mod.rs index 5dd551eb..0828a9ff 100644 --- a/src/config/schema/mod.rs +++ b/src/config/schema/mod.rs @@ -10,6 +10,7 @@ mod computer_use; mod hid; mod otg_network; mod stream; +mod uac; mod watchdog; mod web; @@ -19,6 +20,7 @@ pub use computer_use::*; pub use hid::*; pub use otg_network::*; pub use stream::*; +pub use uac::*; pub use watchdog::*; pub use web::*; @@ -44,7 +46,7 @@ pub struct AppConfig { pub rtsp: RtspConfig, pub redfish: RedfishConfig, pub watchdog: WatchdogConfig, - pub uac: crate::otg::service::UacConfig, + pub uac: UacConfig, } impl AppConfig { diff --git a/src/config/schema/stream.rs b/src/config/schema/stream.rs index 71920265..25908e4c 100644 --- a/src/config/schema/stream.rs +++ b/src/config/schema/stream.rs @@ -103,6 +103,7 @@ pub enum EncoderType { Amf, Rkmpp, V4l2m2m, + Amlogic, } impl EncoderType { @@ -116,6 +117,7 @@ impl EncoderType { EncoderType::Amf => "AMD AMF", EncoderType::Rkmpp => "Rockchip MPP", EncoderType::V4l2m2m => "V4L2 M2M", + EncoderType::Amlogic => "AMLENC", } } } diff --git a/src/config/schema/uac.rs b/src/config/schema/uac.rs new file mode 100644 index 00000000..86dbd1a8 --- /dev/null +++ b/src/config/schema/uac.rs @@ -0,0 +1,78 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::error::{AppError, Result}; + +/// Configuration for the USB Audio Class microphone gadget. +#[typeshare] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct UacConfig { + pub enabled: bool, + pub sample_rate: u32, + pub channels: u8, +} + +impl Default for UacConfig { + fn default() -> Self { + Self { + enabled: false, + sample_rate: 48_000, + channels: 2, + } + } +} + +impl UacConfig { + pub fn validate(&self) -> Result<()> { + // Older configurations stored zero-valued placeholders while UAC was + // disabled. Accept them until the feature is enabled and normalized. + if !self.enabled { + return Ok(()); + } + if self.sample_rate != 48_000 { + return Err(AppError::BadRequest(format!( + "unsupported UAC sample rate {} (expected 48000)", + self.sample_rate + ))); + } + if self.channels != 2 { + return Err(AppError::BadRequest(format!( + "unsupported UAC channel count {} (expected 2)", + self.channels + ))); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_the_audio_transport() { + let config = UacConfig::default(); + assert_eq!(config.sample_rate, 48_000); + assert_eq!(config.channels, 2); + assert!(config.validate().is_ok()); + } + + #[test] + fn rejects_formats_the_transport_cannot_convert() { + assert!(UacConfig { + enabled: true, + sample_rate: 44_100, + ..Default::default() + } + .validate() + .is_err()); + assert!(UacConfig { + enabled: true, + channels: 1, + ..Default::default() + } + .validate() + .is_err()); + } +} diff --git a/src/config/schema/web.rs b/src/config/schema/web.rs index c3795c5c..83db9248 100644 --- a/src/config/schema/web.rs +++ b/src/config/schema/web.rs @@ -49,18 +49,51 @@ impl Default for VideoConfig { pub struct MsdConfig { pub enabled: bool, pub msd_dir: String, + pub flash_inquiry_string: String, + pub cdrom_inquiry_string: String, } +pub const DEFAULT_FLASH_INQUIRY_STRING: &str = "One-KVM Virtual Flash"; +pub const DEFAULT_CDROM_INQUIRY_STRING: &str = "One-KVM Virtual CD-ROM"; +pub const MAX_INQUIRY_STRING_BYTES: usize = 28; + impl Default for MsdConfig { fn default() -> Self { Self { enabled: true, msd_dir: String::new(), + flash_inquiry_string: DEFAULT_FLASH_INQUIRY_STRING.to_string(), + cdrom_inquiry_string: DEFAULT_CDROM_INQUIRY_STRING.to_string(), } } } impl MsdConfig { + pub fn validate(&self) -> crate::error::Result<()> { + Self::validate_inquiry_string("Flash", &self.flash_inquiry_string)?; + Self::validate_inquiry_string("CD-ROM", &self.cdrom_inquiry_string) + } + + pub fn validate_inquiry_string(kind: &str, value: &str) -> crate::error::Result<()> { + let value = value.trim(); + if value.is_empty() { + return Err(crate::error::AppError::BadRequest(format!( + "MSD {kind} inquiry string cannot be empty" + ))); + } + if value.len() > MAX_INQUIRY_STRING_BYTES { + return Err(crate::error::AppError::BadRequest(format!( + "MSD {kind} inquiry string must be at most {MAX_INQUIRY_STRING_BYTES} bytes" + ))); + } + if !value.bytes().all(|byte| (0x20..=0x7e).contains(&byte)) { + return Err(crate::error::AppError::BadRequest(format!( + "MSD {kind} inquiry string must contain printable ASCII characters only" + ))); + } + Ok(()) + } + pub fn msd_dir_path(&self) -> std::path::PathBuf { std::path::PathBuf::from(&self.msd_dir) } @@ -123,3 +156,18 @@ impl Default for WebConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn msd_inquiry_strings_default_and_validate() { + assert!(MsdConfig::default().validate().is_ok()); + assert!(MsdConfig::validate_inquiry_string("Flash", " Custom Drive ").is_ok()); + assert!(MsdConfig::validate_inquiry_string("Flash", "").is_err()); + assert!(MsdConfig::validate_inquiry_string("Flash", &"x".repeat(29)).is_err()); + assert!(MsdConfig::validate_inquiry_string("CD-ROM", "虚拟光驱").is_err()); + assert!(MsdConfig::validate_inquiry_string("CD-ROM", "bad\tname").is_err()); + } +} diff --git a/src/error.rs b/src/error.rs index 390f3511..43d77fca 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,236 @@ +use serde::Serialize; +use std::fmt; use thiserror::Error; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum MsdErrorCode { + MsdUnavailable, + MsdOperationInProgress, + MsdOperationFailed, + MsdInvalidRequest, + MsdResourceNotFound, + MsdResourceAlreadyExists, + MsdMediaSlotsFull, + MsdMediaAlreadyMounted, + MsdMediaInUse, + MsdImageTooLarge, + MsdInvalidUrl, + MsdRemoteDownloadFailed, + MsdDownloadIncomplete, + MsdDriveNotInitialized, + MsdDriveConnected, + MsdDriveFilesystemUnsupported, + MsdDriveSizeInvalid, + MsdStorageSpaceUnavailable, + MsdStorageFull, + MsdStorageReadOnly, + MsdStoragePermissionDenied, + MsdMediumRemovalPrevented, + MsdDisconnectFailed, +} + +impl MsdErrorCode { + pub const ALL: [Self; 23] = [ + Self::MsdUnavailable, + Self::MsdOperationInProgress, + Self::MsdOperationFailed, + Self::MsdInvalidRequest, + Self::MsdResourceNotFound, + Self::MsdResourceAlreadyExists, + Self::MsdMediaSlotsFull, + Self::MsdMediaAlreadyMounted, + Self::MsdMediaInUse, + Self::MsdImageTooLarge, + Self::MsdInvalidUrl, + Self::MsdRemoteDownloadFailed, + Self::MsdDownloadIncomplete, + Self::MsdDriveNotInitialized, + Self::MsdDriveConnected, + Self::MsdDriveFilesystemUnsupported, + Self::MsdDriveSizeInvalid, + Self::MsdStorageSpaceUnavailable, + Self::MsdStorageFull, + Self::MsdStorageReadOnly, + Self::MsdStoragePermissionDenied, + Self::MsdMediumRemovalPrevented, + Self::MsdDisconnectFailed, + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::MsdUnavailable => "MSD_UNAVAILABLE", + Self::MsdOperationInProgress => "MSD_OPERATION_IN_PROGRESS", + Self::MsdOperationFailed => "MSD_OPERATION_FAILED", + Self::MsdInvalidRequest => "MSD_INVALID_REQUEST", + Self::MsdResourceNotFound => "MSD_RESOURCE_NOT_FOUND", + Self::MsdResourceAlreadyExists => "MSD_RESOURCE_ALREADY_EXISTS", + Self::MsdMediaSlotsFull => "MSD_MEDIA_SLOTS_FULL", + Self::MsdMediaAlreadyMounted => "MSD_MEDIA_ALREADY_MOUNTED", + Self::MsdMediaInUse => "MSD_MEDIA_IN_USE", + Self::MsdImageTooLarge => "MSD_IMAGE_TOO_LARGE", + Self::MsdInvalidUrl => "MSD_INVALID_URL", + Self::MsdRemoteDownloadFailed => "MSD_REMOTE_DOWNLOAD_FAILED", + Self::MsdDownloadIncomplete => "MSD_DOWNLOAD_INCOMPLETE", + Self::MsdDriveNotInitialized => "MSD_DRIVE_NOT_INITIALIZED", + Self::MsdDriveConnected => "MSD_DRIVE_CONNECTED", + Self::MsdDriveFilesystemUnsupported => "MSD_DRIVE_FILESYSTEM_UNSUPPORTED", + Self::MsdDriveSizeInvalid => "MSD_DRIVE_SIZE_INVALID", + Self::MsdStorageSpaceUnavailable => "MSD_STORAGE_SPACE_UNAVAILABLE", + Self::MsdStorageFull => "MSD_STORAGE_FULL", + Self::MsdStorageReadOnly => "MSD_STORAGE_READ_ONLY", + Self::MsdStoragePermissionDenied => "MSD_STORAGE_PERMISSION_DENIED", + Self::MsdMediumRemovalPrevented => "MSD_MEDIUM_REMOVAL_PREVENTED", + Self::MsdDisconnectFailed => "MSD_DISCONNECT_FAILED", + } + } + + pub const fn message(self) -> &'static str { + match self { + Self::MsdUnavailable => "Virtual media service is unavailable.", + Self::MsdOperationInProgress => "Another virtual media operation is in progress.", + Self::MsdOperationFailed => "The virtual media operation failed.", + Self::MsdInvalidRequest => "The virtual media request is invalid.", + Self::MsdResourceNotFound => "The requested virtual media resource was not found.", + Self::MsdResourceAlreadyExists => "The virtual media resource already exists.", + Self::MsdMediaSlotsFull => "All virtual media slots are in use.", + Self::MsdMediaAlreadyMounted => "The virtual medium is already mounted.", + Self::MsdMediaInUse => "The virtual medium is currently in use.", + Self::MsdImageTooLarge => "The virtual media image is too large.", + Self::MsdInvalidUrl => "The download URL is invalid.", + Self::MsdRemoteDownloadFailed => "The remote image download failed.", + Self::MsdDownloadIncomplete => "The remote image download was incomplete.", + Self::MsdDriveNotInitialized => "The virtual drive is not initialized.", + Self::MsdDriveConnected => "The virtual drive is connected to the controlled computer.", + Self::MsdDriveFilesystemUnsupported => "The virtual drive filesystem is unsupported.", + Self::MsdDriveSizeInvalid => "The virtual drive size is invalid.", + Self::MsdStorageSpaceUnavailable => { + "Available virtual media storage space could not be determined." + } + Self::MsdStorageFull => "Virtual media storage does not have enough free space.", + Self::MsdStorageReadOnly => "Virtual media storage is read-only.", + Self::MsdStoragePermissionDenied => { + "Permission to access virtual media storage was denied." + } + Self::MsdMediumRemovalPrevented => { + "The controlled computer prevented removal of the virtual medium." + } + Self::MsdDisconnectFailed => "The virtual medium could not be disconnected.", + } + } + + pub const fn redfish_key(self) -> &'static str { + match self { + Self::MsdUnavailable => "MsdUnavailable", + Self::MsdOperationInProgress => "MsdOperationInProgress", + Self::MsdOperationFailed => "MsdOperationFailed", + Self::MsdInvalidRequest => "MsdInvalidRequest", + Self::MsdResourceNotFound => "MsdResourceNotFound", + Self::MsdResourceAlreadyExists => "MsdResourceAlreadyExists", + Self::MsdMediaSlotsFull => "MsdMediaSlotsFull", + Self::MsdMediaAlreadyMounted => "MsdMediaAlreadyMounted", + Self::MsdMediaInUse => "MsdMediaInUse", + Self::MsdImageTooLarge => "MsdImageTooLarge", + Self::MsdInvalidUrl => "MsdInvalidUrl", + Self::MsdRemoteDownloadFailed => "MsdRemoteDownloadFailed", + Self::MsdDownloadIncomplete => "MsdDownloadIncomplete", + Self::MsdDriveNotInitialized => "MsdDriveNotInitialized", + Self::MsdDriveConnected => "MsdDriveConnected", + Self::MsdDriveFilesystemUnsupported => "MsdDriveFilesystemUnsupported", + Self::MsdDriveSizeInvalid => "MsdDriveSizeInvalid", + Self::MsdStorageSpaceUnavailable => "MsdStorageSpaceUnavailable", + Self::MsdStorageFull => "MsdStorageFull", + Self::MsdStorageReadOnly => "MsdStorageReadOnly", + Self::MsdStoragePermissionDenied => "MsdStoragePermissionDenied", + Self::MsdMediumRemovalPrevented => "MsdMediumRemovalPrevented", + Self::MsdDisconnectFailed => "MsdDisconnectFailed", + } + } + + pub const fn severity(self) -> &'static str { + match self { + Self::MsdUnavailable | Self::MsdOperationFailed | Self::MsdDisconnectFailed => { + "Critical" + } + _ => "Warning", + } + } + + pub const fn resolution(self) -> &'static str { + match self { + Self::MsdUnavailable => "Enable or restore the virtual media service, then retry.", + Self::MsdOperationInProgress => { + "Wait for the current virtual media operation to finish, then retry." + } + Self::MsdResourceNotFound | Self::MsdDriveNotInitialized => { + "Verify that the requested virtual media resource exists, then retry." + } + Self::MsdResourceAlreadyExists => { + "Use a different resource name or remove the existing resource, then retry." + } + Self::MsdMediaSlotsFull => "Eject an inserted virtual medium, then retry.", + Self::MsdMediaAlreadyMounted => { + "Eject the existing virtual medium before mounting it again." + } + Self::MsdMediaInUse | Self::MsdDriveConnected | Self::MsdMediumRemovalPrevented => { + "Eject or unmount the virtual medium on the controlled computer, then retry." + } + Self::MsdImageTooLarge | Self::MsdDriveSizeInvalid => { + "Use a supported image or virtual drive size, then retry." + } + Self::MsdInvalidUrl | Self::MsdInvalidRequest => "Correct the request and retry.", + Self::MsdRemoteDownloadFailed | Self::MsdDownloadIncomplete => { + "Verify the remote server and network connection, then retry." + } + Self::MsdDriveFilesystemUnsupported => { + "Reinitialize the virtual drive with a supported filesystem, then retry." + } + Self::MsdStorageSpaceUnavailable => { + "Verify that virtual media storage is available, then retry." + } + Self::MsdStorageFull => { + "Free space in virtual media storage or select a smaller image, then retry." + } + Self::MsdStorageReadOnly => "Make virtual media storage writable, then retry.", + Self::MsdStoragePermissionDenied => { + "Correct virtual media storage permissions, then retry." + } + Self::MsdOperationFailed | Self::MsdDisconnectFailed => { + "Retry the operation. If the problem persists, check the One-KVM system logs." + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MsdError { + code: MsdErrorCode, +} + +impl MsdError { + pub const fn new(code: MsdErrorCode) -> Self { + Self { code } + } + + pub const fn code(self) -> MsdErrorCode { + self.code + } +} + +impl fmt::Display for MsdError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.code.message()) + } +} + +impl std::error::Error for MsdError {} + +impl From for AppError { + fn from(code: MsdErrorCode) -> Self { + Self::Msd(MsdError::new(code)) + } +} + #[derive(Error, Debug)] pub enum AppError { #[error("Authentication failed: {0}")] @@ -26,6 +257,9 @@ pub enum AppError { #[error("Internal error: {0}")] Internal(String), + #[error(transparent)] + Msd(#[from] MsdError), + #[error("Configuration error: {0}")] Config(String), @@ -66,3 +300,135 @@ impl From for AppError { AppError::Persistence(err.to_string()) } } + +#[cfg(test)] +mod tests { + use super::MsdErrorCode::*; + + #[test] + fn msd_codes_and_messages_are_stable() { + let cases = [ + ( + MsdUnavailable, + "MSD_UNAVAILABLE", + "Virtual media service is unavailable.", + ), + ( + MsdOperationInProgress, + "MSD_OPERATION_IN_PROGRESS", + "Another virtual media operation is in progress.", + ), + ( + MsdOperationFailed, + "MSD_OPERATION_FAILED", + "The virtual media operation failed.", + ), + ( + MsdInvalidRequest, + "MSD_INVALID_REQUEST", + "The virtual media request is invalid.", + ), + ( + MsdResourceNotFound, + "MSD_RESOURCE_NOT_FOUND", + "The requested virtual media resource was not found.", + ), + ( + MsdResourceAlreadyExists, + "MSD_RESOURCE_ALREADY_EXISTS", + "The virtual media resource already exists.", + ), + ( + MsdMediaSlotsFull, + "MSD_MEDIA_SLOTS_FULL", + "All virtual media slots are in use.", + ), + ( + MsdMediaAlreadyMounted, + "MSD_MEDIA_ALREADY_MOUNTED", + "The virtual medium is already mounted.", + ), + ( + MsdMediaInUse, + "MSD_MEDIA_IN_USE", + "The virtual medium is currently in use.", + ), + ( + MsdImageTooLarge, + "MSD_IMAGE_TOO_LARGE", + "The virtual media image is too large.", + ), + ( + MsdInvalidUrl, + "MSD_INVALID_URL", + "The download URL is invalid.", + ), + ( + MsdRemoteDownloadFailed, + "MSD_REMOTE_DOWNLOAD_FAILED", + "The remote image download failed.", + ), + ( + MsdDownloadIncomplete, + "MSD_DOWNLOAD_INCOMPLETE", + "The remote image download was incomplete.", + ), + ( + MsdDriveNotInitialized, + "MSD_DRIVE_NOT_INITIALIZED", + "The virtual drive is not initialized.", + ), + ( + MsdDriveConnected, + "MSD_DRIVE_CONNECTED", + "The virtual drive is connected to the controlled computer.", + ), + ( + MsdDriveFilesystemUnsupported, + "MSD_DRIVE_FILESYSTEM_UNSUPPORTED", + "The virtual drive filesystem is unsupported.", + ), + ( + MsdDriveSizeInvalid, + "MSD_DRIVE_SIZE_INVALID", + "The virtual drive size is invalid.", + ), + ( + MsdStorageSpaceUnavailable, + "MSD_STORAGE_SPACE_UNAVAILABLE", + "Available virtual media storage space could not be determined.", + ), + ( + MsdStorageFull, + "MSD_STORAGE_FULL", + "Virtual media storage does not have enough free space.", + ), + ( + MsdStorageReadOnly, + "MSD_STORAGE_READ_ONLY", + "Virtual media storage is read-only.", + ), + ( + MsdStoragePermissionDenied, + "MSD_STORAGE_PERMISSION_DENIED", + "Permission to access virtual media storage was denied.", + ), + ( + MsdMediumRemovalPrevented, + "MSD_MEDIUM_REMOVAL_PREVENTED", + "The controlled computer prevented removal of the virtual medium.", + ), + ( + MsdDisconnectFailed, + "MSD_DISCONNECT_FAILED", + "The virtual medium could not be disconnected.", + ), + ]; + + assert_eq!(cases.len(), super::MsdErrorCode::ALL.len()); + for (code, expected_code, expected_message) in cases { + assert_eq!(code.as_str(), expected_code); + assert_eq!(code.message(), expected_message); + } + } +} diff --git a/src/events/mod.rs b/src/events/mod.rs index b657bdd2..ecd2c84f 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -6,9 +6,10 @@ use self::types::EXACT_EVENT_TOPICS; pub use types::{ AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo, - MsdDeviceMediaInfo, StreamDeviceLostKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo, + MsdDeviceMediaInfo, StreamKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo, }; +use std::sync::RwLock; use tokio::sync::broadcast; const EVENT_CHANNEL_CAPACITY: usize = 256; @@ -40,6 +41,7 @@ pub struct EventBus { exact_topics: std::collections::HashMap<&'static str, broadcast::Sender>, prefix_topics: std::collections::HashMap>, device_info_dirty_tx: broadcast::Sender<()>, + latest_video_stream_state: RwLock>, } impl EventBus { @@ -60,12 +62,26 @@ impl EventBus { exact_topics, prefix_topics, device_info_dirty_tx, + latest_video_stream_state: RwLock::new(None), } } pub fn publish(&self, event: SystemEvent) { let event_name = event.event_name(); + if matches!( + event, + SystemEvent::StreamStateChanged { + kind: StreamKind::Video, + .. + } + ) { + *self + .latest_video_stream_state + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(event.clone()); + } + if let Some(tx) = self.exact_topics.get(event_name) { let _ = tx.send(event.clone()); } @@ -103,6 +119,15 @@ impl EventBus { self.device_info_dirty_tx.subscribe() } + /// Stateful video status topics replay this value to new WebSocket + /// subscribers so a page refresh cannot miss an earlier signal-loss edge. + pub fn latest_video_stream_state(&self) -> Option { + self.latest_video_stream_state + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + pub fn subscriber_count(&self) -> usize { self.tx.receiver_count() } @@ -124,6 +149,7 @@ mod tests { let mut rx = bus.subscribe(); bus.publish(SystemEvent::StreamStateChanged { + kind: StreamKind::Video, state: "streaming".to_string(), device: Some("/dev/video0".to_string()), reason: None, @@ -132,6 +158,10 @@ mod tests { let event = rx.recv().await.unwrap(); assert!(matches!(event, SystemEvent::StreamStateChanged { .. })); + assert!(matches!( + bus.latest_video_stream_state(), + Some(SystemEvent::StreamStateChanged { state, .. }) if state == "streaming" + )); } #[tokio::test] @@ -143,6 +173,7 @@ mod tests { assert_eq!(bus.subscriber_count(), 2); bus.publish(SystemEvent::StreamStateChanged { + kind: StreamKind::Video, state: "ready".to_string(), device: Some("/dev/video0".to_string()), reason: None, @@ -162,6 +193,7 @@ mod tests { let mut rx = bus.subscribe_topic("stream.state_changed").unwrap(); bus.publish(SystemEvent::StreamStateChanged { + kind: StreamKind::Video, state: "ready".to_string(), device: None, reason: None, @@ -178,6 +210,7 @@ mod tests { let mut rx = bus.subscribe_topic("stream.*").unwrap(); bus.publish(SystemEvent::StreamStateChanged { + kind: StreamKind::Video, state: "ready".to_string(), device: None, reason: None, @@ -200,10 +233,36 @@ mod tests { assert_eq!(bus.subscriber_count(), 0); bus.publish(SystemEvent::StreamStateChanged { + kind: StreamKind::Video, state: "ready".to_string(), device: None, reason: None, next_retry_ms: None, }); } + + #[test] + fn audio_state_does_not_replace_latest_video_state() { + let bus = EventBus::new(); + bus.publish(SystemEvent::StreamStateChanged { + kind: StreamKind::Video, + state: "no_signal".to_string(), + device: Some("/dev/video0".to_string()), + reason: Some("no_sync".to_string()), + next_retry_ms: Some(500), + }); + bus.publish(SystemEvent::StreamStateChanged { + kind: StreamKind::Audio, + state: "streaming".to_string(), + device: Some("hw:0,0".to_string()), + reason: None, + next_retry_ms: None, + }); + + assert!(matches!( + bus.latest_video_stream_state(), + Some(SystemEvent::StreamStateChanged { state, reason, .. }) + if state == "no_signal" && reason.as_deref() == Some("no_sync") + )); + } } diff --git a/src/events/types.rs b/src/events/types.rs index 2cf37551..7a438354 100644 --- a/src/events/types.rs +++ b/src/events/types.rs @@ -92,10 +92,10 @@ pub struct ClientStats { pub connected_secs: u64, } -/// Video vs audio source for [`SystemEvent::StreamDeviceLost`] (WebSocket `stream.device_lost`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +/// Media subsystem that owns a stream state or device event. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum StreamDeviceLostKind { +pub enum StreamKind { Video, Audio, } @@ -114,6 +114,7 @@ pub enum SystemEvent { #[serde(rename = "stream.state_changed")] StreamStateChanged { + kind: StreamKind, state: String, device: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -141,7 +142,7 @@ pub enum SystemEvent { #[serde(rename = "stream.device_lost")] StreamDeviceLost { - kind: StreamDeviceLostKind, + kind: StreamKind, device: String, reason: String, }, @@ -204,6 +205,8 @@ pub enum SystemEvent { total_bytes: Option, progress_pct: Option, status: String, + #[serde(skip_serializing_if = "Option::is_none")] + error_code: Option, }, #[serde(rename = "system.device_info")] @@ -272,6 +275,7 @@ mod tests { #[test] fn test_event_name() { let event = SystemEvent::StreamStateChanged { + kind: StreamKind::Video, state: "streaming".to_string(), device: Some("/dev/video0".to_string()), reason: None, @@ -283,7 +287,7 @@ mod tests { #[test] fn stream_device_lost_json_snake_case_kind() { let event = SystemEvent::StreamDeviceLost { - kind: StreamDeviceLostKind::Audio, + kind: StreamKind::Audio, device: "hw:0,0".to_string(), reason: "test".to_string(), }; @@ -304,6 +308,7 @@ mod tests { from_mode: String::new(), }, SystemEvent::StreamStateChanged { + kind: StreamKind::Video, state: String::new(), device: None, reason: None, @@ -321,7 +326,7 @@ mod tests { fps: 0, }, SystemEvent::StreamDeviceLost { - kind: StreamDeviceLostKind::Video, + kind: StreamKind::Video, device: String::new(), reason: String::new(), }, @@ -372,6 +377,7 @@ mod tests { total_bytes: None, progress_pct: None, status: String::new(), + error_code: None, }, SystemEvent::DeviceInfo { video: VideoDeviceInfo { diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 89477d15..46e2dc10 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -1,15 +1,15 @@ use std::collections::{HashMap, VecDeque}; -use std::path::PathBuf; +use std::path::Path; use std::process::Stdio; use std::sync::Arc; -use tempfile::TempDir; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::{Child, Command}; use tokio::sync::RwLock; -use toml_edit::DocumentMut; +use super::protected_config::ProtectedConfigFile; use super::types::*; +use super::validation::{validate_easytier_config, validate_frpc_config, validate_gostc_config}; use crate::events::EventBus; const LOG_BUFFER_SIZE: usize = 200; @@ -27,12 +27,12 @@ const TTYD_TCP_PORT: &str = "7681"; struct ExtensionProcess { child: Child, logs: Arc>>, - _temp_dir: Option, + _config_file: Option, } struct ExtensionLaunch { args: Vec, - temp_dir: Option, + config_file: Option, } pub struct ExtensionManager { @@ -83,24 +83,12 @@ impl ExtensionManager { match id { ExtensionId::Ttyd => config.ttyd.enabled, ExtensionId::Gostc => { - config.gostc.enabled - && !config.gostc.key.is_empty() - && !config.gostc.addr.trim().is_empty() + config.gostc.enabled && validate_gostc_config(&config.gostc).is_ok() } ExtensionId::Easytier => { - config.easytier.enabled && !config.easytier.network_name.is_empty() - } - ExtensionId::Frpc => { - config.frpc.enabled - && match config.frpc.config_mode { - FrpcConfigMode::Quick => { - !config.frpc.proxy_name.trim().is_empty() - && !config.frpc.server_addr.trim().is_empty() - && !config.frpc.token.is_empty() - } - FrpcConfigMode::Full => !config.frpc.custom_toml.trim().is_empty(), - } + config.easytier.enabled && validate_easytier_config(&config.easytier).is_ok() } + ExtensionId::Frpc => config.frpc.enabled && validate_frpc_config(&config.frpc).is_ok(), } } @@ -203,7 +191,7 @@ impl ExtensionManager { ExtensionProcess { child, logs, - _temp_dir: launch.temp_dir, + _config_file: launch.config_file, }, ); drop(processes); @@ -286,12 +274,7 @@ impl ExtensionManager { ExtensionId::Gostc => { let c = &config.gostc; - if c.addr.trim().is_empty() { - return Err("GOSTC server address is required".into()); - } - if c.key.is_empty() { - return Err("GOSTC client key is required".into()); - } + validate_gostc_config(c)?; let mut args = Vec::new(); @@ -307,35 +290,7 @@ impl ExtensionManager { } ExtensionId::Easytier => { - let c = &config.easytier; - if c.network_name.is_empty() { - return Err("EasyTier network name is required".into()); - } - - let mut args = vec![ - "--network-name".to_string(), - c.network_name.clone(), - "--network-secret".to_string(), - c.network_secret.clone(), - ]; - - for peer in &c.peer_urls { - if !peer.is_empty() { - args.extend(["--peers".to_string(), peer.clone()]); - } - } - - if let Some(ref ip) = c.virtual_ip { - if !ip.is_empty() { - args.extend(["-i".to_string(), ip.clone()]); - } else { - args.push("-d".to_string()); - } - } else { - args.push("-d".to_string()); - } - - args + return Self::build_easytier_launch(&config.easytier).await; } ExtensionId::Frpc => { @@ -345,58 +300,78 @@ impl ExtensionManager { Ok(ExtensionLaunch { args, - temp_dir: None, + config_file: None, }) } + async fn build_easytier_launch(config: &EasytierConfig) -> Result { + validate_easytier_config(config)?; + + match config.config_mode { + EasytierConfigMode::Quick => Ok(ExtensionLaunch { + args: Self::build_easytier_quick_args(config), + config_file: None, + }), + EasytierConfigMode::Full => { + let config_file = ProtectedConfigFile::create( + "EasyTier", + "easytier.toml", + config.custom_toml.as_str(), + ) + .await?; + + Ok(ExtensionLaunch { + args: vec!["-c".to_string(), Self::path_to_arg(config_file.path())], + config_file: Some(config_file), + }) + } + } + } + + fn build_easytier_quick_args(config: &EasytierConfig) -> Vec { + let mut args = vec![ + "--network-name".to_string(), + config.network_name.clone(), + "--network-secret".to_string(), + config.network_secret.clone(), + ]; + + for peer in &config.peer_urls { + if !peer.is_empty() { + args.extend(["--peers".to_string(), peer.clone()]); + } + } + + if let Some(ref ip) = config.virtual_ip { + if !ip.is_empty() { + args.extend(["-i".to_string(), ip.clone()]); + } else { + args.push("-d".to_string()); + } + } else { + args.push("-d".to_string()); + } + + args + } + async fn build_frpc_launch(config: &FrpcConfig) -> Result { + validate_frpc_config(config)?; + let config_text = match config.config_mode { FrpcConfigMode::Quick => Self::build_frpc_quick_toml(config)?, - FrpcConfigMode::Full => Self::validate_frpc_full_toml(config)?.to_string(), + FrpcConfigMode::Full => config.custom_toml.clone(), }; - let temp_dir = - tempfile::tempdir().map_err(|e| format!("Failed to create FRPC config dir: {}", e))?; - let config_path = temp_dir.path().join("frpc.toml"); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(temp_dir.path(), std::fs::Permissions::from_mode(0o700)) - .map_err(|e| format!("Failed to protect FRPC config dir: {}", e))?; - } - - tokio::fs::write(&config_path, config_text) - .await - .map_err(|e| format!("Failed to write FRPC config: {}", e))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - tokio::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600)) - .await - .map_err(|e| format!("Failed to protect FRPC config: {}", e))?; - } + let config_file = + ProtectedConfigFile::create("FRPC", "frpc.toml", config_text.as_str()).await?; Ok(ExtensionLaunch { - args: vec!["-c".to_string(), Self::path_to_arg(&config_path)], - temp_dir: Some(temp_dir), + args: vec!["-c".to_string(), Self::path_to_arg(config_file.path())], + config_file: Some(config_file), }) } - fn validate_frpc_full_toml(config: &FrpcConfig) -> Result<&str, String> { - let trimmed = config.custom_toml.trim(); - if trimmed.is_empty() { - return Err("FRPC full configuration is required".into()); - } - - trimmed - .parse::() - .map_err(|e| format!("FRPC full configuration is not valid TOML: {}", e))?; - - Ok(config.custom_toml.as_str()) - } - fn build_frpc_quick_toml(config: &FrpcConfig) -> Result { if config.proxy_name.trim().is_empty() { return Err("FRPC proxy name is required".into()); @@ -480,7 +455,7 @@ impl ExtensionManager { serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()) } - fn path_to_arg(path: &PathBuf) -> String { + fn path_to_arg(path: &Path) -> String { path.to_string_lossy().to_string() } @@ -603,3 +578,107 @@ impl ExtensionManager { futures::future::join_all(stop_futures).await; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn easytier_launch_revalidates_full_configuration() { + let config = EasytierConfig { + config_mode: EasytierConfigMode::Full, + custom_toml: "instance_name = [".to_string(), + ..Default::default() + }; + + let error = ExtensionManager::build_easytier_launch(&config) + .await + .err() + .expect("invalid full configuration should fail launch validation"); + assert!(error.starts_with("EasyTier full configuration is not valid TOML:")); + } + + #[test] + fn easytier_quick_mode_keeps_command_line_arguments() { + let config = EasytierConfig { + network_name: "one-kvm".to_string(), + network_secret: "secret".to_string(), + peer_urls: vec![ + "tcp://peer-one:11010".to_string(), + String::new(), + "udp://peer-two:11010".to_string(), + ], + virtual_ip: Some("10.20.30.40/24".to_string()), + ..Default::default() + }; + + assert_eq!( + ExtensionManager::build_easytier_quick_args(&config), + vec![ + "--network-name", + "one-kvm", + "--network-secret", + "secret", + "--peers", + "tcp://peer-one:11010", + "--peers", + "udp://peer-two:11010", + "-i", + "10.20.30.40/24", + ] + ); + } + + #[tokio::test] + async fn easytier_full_mode_uses_protected_temporary_config() { + let config_text = "instance_name = \"one-kvm\"\n"; + let config = EasytierConfig { + config_mode: EasytierConfigMode::Full, + network_name: "ignored-quick-network".to_string(), + custom_toml: config_text.to_string(), + ..Default::default() + }; + + let launch = ExtensionManager::build_easytier_launch(&config) + .await + .expect("full EasyTier launch should build"); + assert_eq!(launch.args[0], "-c"); + + let config_path = std::path::PathBuf::from(&launch.args[1]); + assert_eq!( + config_path.file_name().and_then(|name| name.to_str()), + Some("easytier.toml") + ); + assert_eq!( + tokio::fs::read_to_string(&config_path).await.unwrap(), + config_text + ); + + drop(launch); + assert!(!config_path.exists()); + } + + #[test] + fn easytier_auto_start_uses_fields_for_selected_mode() { + let mut config = ExtensionsConfig::default(); + config.easytier.enabled = true; + config.easytier.network_name = "quick-network".to_string(); + assert!(ExtensionManager::is_enabled_for_config( + ExtensionId::Easytier, + &config + )); + + config.easytier.config_mode = EasytierConfigMode::Full; + assert!(!ExtensionManager::is_enabled_for_config( + ExtensionId::Easytier, + &config + )); + + config.easytier.network_name.clear(); + config.easytier.custom_toml = "instance_name = \"one-kvm\"".to_string(); + assert!(ExtensionManager::is_enabled_for_config( + ExtensionId::Easytier, + &config + )); + } +} diff --git a/src/extensions/mod.rs b/src/extensions/mod.rs index 7f599dab..19ce6845 100644 --- a/src/extensions/mod.rs +++ b/src/extensions/mod.rs @@ -1,6 +1,8 @@ mod manager; +mod protected_config; mod software; mod types; +mod validation; pub use manager::ExtensionManager; #[cfg(unix)] @@ -8,3 +10,7 @@ pub use manager::TTYD_SOCKET_PATH; #[cfg(windows)] pub use manager::TTYD_TCP_ADDR; pub use types::*; +pub(crate) use validation::{ + validate_easytier_config, validate_extension_config, validate_frpc_config, + validate_gostc_config, +}; diff --git a/src/extensions/protected_config.rs b/src/extensions/protected_config.rs new file mode 100644 index 00000000..e44972b5 --- /dev/null +++ b/src/extensions/protected_config.rs @@ -0,0 +1,95 @@ +use std::path::{Path, PathBuf}; + +use tempfile::TempDir; + +pub(crate) struct ProtectedConfigFile { + _temp_dir: TempDir, + path: PathBuf, +} + +impl ProtectedConfigFile { + pub(crate) async fn create( + extension_name: &str, + file_name: &str, + contents: &str, + ) -> Result { + let temp_dir = tempfile::tempdir().map_err(|error| { + format!("Failed to create {} config dir: {}", extension_name, error) + })?; + let path = temp_dir.path().join(file_name); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(temp_dir.path(), std::fs::Permissions::from_mode(0o700)) + .map_err(|error| { + format!("Failed to protect {} config dir: {}", extension_name, error) + })?; + } + + tokio::fs::write(&path, contents) + .await + .map_err(|error| format!("Failed to write {} config: {}", extension_name, error))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .await + .map_err(|error| { + format!("Failed to protect {} config: {}", extension_name, error) + })?; + } + + Ok(Self { + _temp_dir: temp_dir, + path, + }) + } + + pub(crate) fn path(&self) -> &Path { + &self.path + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn protects_and_cleans_up_config_file() { + let config = + ProtectedConfigFile::create("Test extension", "extension.toml", "enabled = true\n") + .await + .unwrap(); + let path = config.path().to_path_buf(); + + assert_eq!( + tokio::fs::read_to_string(&path).await.unwrap(), + "enabled = true\n" + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + assert_eq!( + std::fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + drop(config); + assert!(!path.exists()); + } +} diff --git a/src/extensions/types.rs b/src/extensions/types.rs index e0e2c9ca..45839d0f 100644 --- a/src/extensions/types.rs +++ b/src/extensions/types.rs @@ -103,11 +103,25 @@ impl Default for GostcConfig { } #[typeshare] -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EasytierConfigMode { + Quick, + Full, +} + +impl Default for EasytierConfigMode { + fn default() -> Self { + Self::Quick + } +} + +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] -#[derive(Default)] pub struct EasytierConfig { pub enabled: bool, + pub config_mode: EasytierConfigMode, pub network_name: String, #[serde(skip_serializing_if = "String::is_empty")] pub network_secret: String, @@ -115,6 +129,8 @@ pub struct EasytierConfig { pub peer_urls: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub virtual_ip: Option, + #[serde(skip_serializing_if = "String::is_empty")] + pub custom_toml: String, } #[typeshare] @@ -260,3 +276,26 @@ pub struct ExtensionLogs { pub id: ExtensionId, pub logs: Vec, } + +#[cfg(test)] +mod tests { + use super::{EasytierConfig, EasytierConfigMode}; + + #[test] + fn legacy_easytier_config_defaults_to_quick_mode() { + let config: EasytierConfig = serde_json::from_str( + r#"{ + "enabled": true, + "network_name": "legacy-network", + "network_secret": "secret", + "peer_urls": ["tcp://example.com:11010"], + "virtual_ip": "10.10.10.2/24" + }"#, + ) + .expect("legacy EasyTier config should deserialize"); + + assert_eq!(config.config_mode, EasytierConfigMode::Quick); + assert!(config.custom_toml.is_empty()); + assert_eq!(config.network_name, "legacy-network"); + } +} diff --git a/src/extensions/validation.rs b/src/extensions/validation.rs new file mode 100644 index 00000000..9da0c722 --- /dev/null +++ b/src/extensions/validation.rs @@ -0,0 +1,119 @@ +use toml_edit::DocumentMut; + +use super::types::{ + EasytierConfig, EasytierConfigMode, ExtensionId, ExtensionsConfig, FrpProxyType, FrpcConfig, + FrpcConfigMode, GostcConfig, +}; + +pub(crate) fn validate_extension_config( + id: ExtensionId, + config: &ExtensionsConfig, +) -> Result<(), String> { + match id { + ExtensionId::Ttyd => Ok(()), + ExtensionId::Gostc => validate_gostc_config(&config.gostc), + ExtensionId::Easytier => validate_easytier_config(&config.easytier), + ExtensionId::Frpc => validate_frpc_config(&config.frpc), + } +} + +pub(crate) fn validate_gostc_config(config: &GostcConfig) -> Result<(), String> { + require_non_empty(config.addr.trim(), "GOSTC server address is required")?; + require_non_empty(config.key.as_str(), "GOSTC client key is required") +} + +pub(crate) fn validate_easytier_config(config: &EasytierConfig) -> Result<(), String> { + match config.config_mode { + EasytierConfigMode::Quick => require_non_empty( + config.network_name.trim(), + "EasyTier network name is required", + ), + EasytierConfigMode::Full => validate_full_toml("EasyTier", config.custom_toml.as_str()), + } +} + +pub(crate) fn validate_frpc_config(config: &FrpcConfig) -> Result<(), String> { + match config.config_mode { + FrpcConfigMode::Quick => { + require_non_empty(config.proxy_name.trim(), "FRPC proxy name is required")?; + require_non_empty(config.server_addr.trim(), "FRPC server address is required")?; + require_non_empty(config.token.as_str(), "FRPC token is required")?; + require_non_empty(config.local_ip.trim(), "FRPC local IP is required")?; + + if matches!(config.proxy_type, FrpProxyType::Tcp | FrpProxyType::Udp) + && config.remote_port.is_none() + { + return Err("FRPC remote port is required for TCP/UDP proxies".to_string()); + } + + Ok(()) + } + FrpcConfigMode::Full => validate_full_toml("FRPC", config.custom_toml.as_str()), + } +} + +fn require_non_empty(value: &str, message: &str) -> Result<(), String> { + if value.is_empty() { + Err(message.to_string()) + } else { + Ok(()) + } +} + +fn validate_full_toml(extension_name: &str, config: &str) -> Result<(), String> { + let trimmed = config.trim(); + if trimmed.is_empty() { + return Err(format!("{} full configuration is required", extension_name)); + } + + trimmed.parse::().map_err(|error| { + format!( + "{} full configuration is not valid TOML: {}", + extension_name, error + ) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_easytier_full_configuration() { + let mut config = EasytierConfig { + config_mode: EasytierConfigMode::Full, + ..Default::default() + }; + + assert_eq!( + validate_easytier_config(&config).unwrap_err(), + "EasyTier full configuration is required" + ); + + config.custom_toml = "instance_name = [".to_string(); + assert!(validate_easytier_config(&config) + .unwrap_err() + .starts_with("EasyTier full configuration is not valid TOML:")); + + config.custom_toml = "instance_name = \"one-kvm\"".to_string(); + assert!(validate_easytier_config(&config).is_ok()); + } + + #[test] + fn validates_frpc_through_the_same_entry_point() { + let mut config = FrpcConfig { + config_mode: FrpcConfigMode::Full, + ..Default::default() + }; + + assert_eq!( + validate_frpc_config(&config).unwrap_err(), + "FRPC full configuration is required" + ); + + config.custom_toml = "serverAddr = \"frps.example.com\"".to_string(); + assert!(validate_frpc_config(&config).is_ok()); + } +} diff --git a/src/hid/otg.rs b/src/hid/otg.rs index fa4fba77..23443ea1 100644 --- a/src/hid/otg.rs +++ b/src/hid/otg.rs @@ -167,9 +167,9 @@ impl OtgBackend { if now.duration_since(*last_log).as_secs() >= 1 { let count = self.error_count.swap(0, Ordering::Relaxed); if count > 1 { - warn!("{} (repeated {} times)", msg, count); + debug!("{} (repeated {} times)", msg, count); } else { - warn!("{}", msg); + debug!("{}", msg); } *last_log = now; } else { diff --git a/src/main.rs b/src/main.rs index adf8ac97..e407bb39 100644 --- a/src/main.rs +++ b/src/main.rs @@ -426,25 +426,9 @@ async fn main() -> anyhow::Result<()> { .update_video_config(actual_resolution, actual_format, actual_fps) .await; if let Some(device_path) = device_path { - let (subdev_path, bridge_kind, v4l2_driver) = streamer - .current_device() - .await - .map(|d| { - ( - d.subdev_path.clone(), - d.bridge_kind.clone(), - Some(d.driver.clone()), - ) - }) - .unwrap_or((None, None, None)); + let device_info = streamer.current_device().await; webrtc_streamer - .set_capture_device( - device_path, - jpeg_quality, - subdev_path, - bridge_kind, - v4l2_driver, - ) + .set_capture_device(device_path, jpeg_quality, device_info) .await; tracing::debug!("WebRTC streamer configured for direct capture"); } else { @@ -550,7 +534,7 @@ async fn main() -> anyhow::Result<()> { None }; - let update_service = Arc::new(UpdateService::new(data_dir.join("updates"))); + let update_service = Arc::new(UpdateService::new()); let computer_use = ComputerUseManager::new(config_store.clone(), hid.clone()); let state = AppState::new( @@ -579,20 +563,23 @@ async fn main() -> anyhow::Result<()> { data_dir.clone(), ); - // Initialize UAC playback writer if UAC is enabled - if config.uac.enabled { - let uac_cfg = one_kvm::audio::uac_streamer::UacPlaybackConfig { - sample_rate: config.uac.sample_rate, - channels: config.uac.channels as u16, - ..Default::default() - }; - match one_kvm::audio::uac_streamer::UacPlaybackWriter::start(uac_cfg) { - Ok(writer) => { - *state.uac_playback.write().await = Some(writer); - tracing::info!("UAC playback writer started"); - } - Err(e) => { - tracing::warn!("Failed to start UAC playback writer: {}", e); + #[cfg(unix)] + { + // Initialize UAC playback writer if UAC is enabled. + if config.uac.enabled { + let uac_cfg = one_kvm::audio::uac::UacPlaybackConfig { + sample_rate: config.uac.sample_rate, + channels: config.uac.channels as u16, + ..Default::default() + }; + match one_kvm::audio::uac::UacPlayback::start(uac_cfg) { + Ok(writer) => { + *state.uac_playback.write().await = Some(writer); + tracing::info!("UAC playback writer started"); + } + Err(e) => { + tracing::warn!("Failed to start UAC playback writer: {}", e); + } } } } diff --git a/src/msd/controller.rs b/src/msd/controller.rs index bfbfcf58..6c7fff2b 100644 --- a/src/msd/controller.rs +++ b/src/msd/controller.rs @@ -11,7 +11,7 @@ use super::types::{ DiskMode, DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MountedMedia, MountedMediaKind, MsdState, }; -use crate::error::{AppError, Result}; +use crate::error::{AppError, MsdErrorCode, Result}; use crate::otg::{MsdFunction, MsdLunConfig, OtgService}; pub struct MsdController { @@ -70,9 +70,11 @@ impl MsdController { } info!("Fetching MSD function from OtgService"); - let msd_func = self.otg_service.msd_function().await.ok_or_else(|| { - AppError::Internal("MSD function is not active in OtgService".to_string()) - })?; + let msd_func = self + .otg_service + .msd_function() + .await + .ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?; *self.msd_function.write().await = Some(msd_func); @@ -148,7 +150,7 @@ impl MsdController { read_only: bool, requested_lun: Option, ) -> Result<()> { - let _op_guard = self.operation_lock.write().await; + let _op_guard = self.try_operation()?; let mut state = self.state.write().await; let previous_state = state.clone(); @@ -159,7 +161,7 @@ impl MsdController { self.monitor .report_error(&error_msg, "image_not_found") .await; - return Err(AppError::Internal(error_msg)); + return Err(MsdErrorCode::MsdResourceNotFound.into()); } if state @@ -167,7 +169,7 @@ impl MsdController { .iter() .any(|media| media.kind == MountedMediaKind::Image && media.id == image.id) { - return Err(AppError::BadRequest("Image is already mounted".to_string())); + return Err(MsdErrorCode::MsdMediaAlreadyMounted.into()); } let lun = Self::select_lun(&state, requested_lun)?; @@ -195,19 +197,17 @@ impl MsdController { } pub async fn mount_drive(&self) -> Result<()> { - let _op_guard = self.operation_lock.write().await; + let _op_guard = self.try_operation()?; let mut state = self.state.write().await; let previous_state = state.clone(); self.assert_available(&state).await?; if !self.drive_path.exists() { - let err = - AppError::Internal("Virtual drive not initialized. Call init first.".to_string()); self.monitor .report_error("Virtual drive not initialized", "drive_not_found") .await; - return Err(err); + return Err(MsdErrorCode::MsdDriveNotInitialized.into()); } let drive_info = state.drive_info.clone().or_else(|| { @@ -230,15 +230,13 @@ impl MsdController { .iter() .any(|media| media.kind == MountedMediaKind::Drive) { - return Err(AppError::BadRequest( - "Virtual drive is already mounted".to_string(), - )); + return Err(MsdErrorCode::MsdMediaAlreadyMounted.into()); } - let drive_info = drive_info - .ok_or_else(|| AppError::Internal("Virtual drive info is unavailable".to_string()))?; + let drive_info = + drive_info.ok_or_else(|| AppError::from(MsdErrorCode::MsdDriveNotInitialized))?; let lun = Self::lowest_free_lun(&state) - .ok_or_else(|| AppError::BadRequest("Media slots are full".to_string()))?; + .ok_or_else(|| AppError::from(MsdErrorCode::MsdMediaSlotsFull))?; let media = MountedMedia::drive(lun, &drive_info); if let Err(e) = self.configure_media(&media).await { @@ -265,7 +263,7 @@ impl MsdController { self.monitor .report_error("MSD not available", "not_available") .await; - return Err(AppError::Internal("MSD not available".to_string())); + return Err(MsdErrorCode::MsdUnavailable.into()); } Ok(()) } @@ -286,20 +284,14 @@ impl MsdController { fn select_lun(state: &MsdState, requested_lun: Option) -> Result { let Some(lun) = requested_lun else { return Self::lowest_free_lun(state) - .ok_or_else(|| AppError::BadRequest("Media slots are full".to_string())); + .ok_or_else(|| AppError::from(MsdErrorCode::MsdMediaSlotsFull)); }; if lun >= state.disk_mode.capacity() { - return Err(AppError::BadRequest(format!( - "Media slot {} is outside the current disk mode capacity", - lun + 1 - ))); + return Err(MsdErrorCode::MsdInvalidRequest.into()); } if state.mounted_media.iter().any(|media| media.lun == lun) { - return Err(AppError::BadRequest(format!( - "Media slot {} is already occupied", - lun + 1 - ))); + return Err(MsdErrorCode::MsdMediaSlotsFull.into()); } Ok(lun) } @@ -310,7 +302,7 @@ impl MsdController { } pub async fn set_disk_mode(&self, disk_mode: DiskMode) -> Result { - let _op_guard = self.operation_lock.write().await; + let _op_guard = self.try_operation()?; let previous_state = { let mut state = self.state.write().await; self.assert_available(&state).await?; @@ -327,9 +319,10 @@ impl MsdController { self.otg_service .set_msd_lun_capacity(disk_mode.capacity()) .await?; - self.otg_service.msd_function().await.ok_or_else(|| { - AppError::Internal("MSD function missing after OTG rebuild".to_string()) - }) + self.otg_service + .msd_function() + .await + .ok_or_else(|| AppError::from(MsdErrorCode::MsdOperationFailed)) } .await; @@ -349,7 +342,7 @@ impl MsdController { .report_error(&error_msg, "disk_mode_rollback_failed") .await; self.mark_device_info_dirty().await; - return Err(AppError::Internal(error_msg)); + return Err(MsdErrorCode::MsdOperationFailed.into()); } let mut state = self.state.write().await; @@ -360,7 +353,7 @@ impl MsdController { .report_error(&error_msg, "disk_mode_switch_failed") .await; self.mark_device_info_dirty().await; - return Err(AppError::Internal(error_msg)); + return Err(MsdErrorCode::MsdOperationFailed.into()); } }; *self.msd_function.write().await = Some(msd_function); @@ -397,7 +390,7 @@ impl MsdController { where F: Fn(&MountedMedia) -> bool, { - let _op_guard = self.operation_lock.write().await; + let _op_guard = self.try_operation()?; let mut state = self.state.write().await; let Some(index) = state.mounted_media.iter().position(predicate) else { @@ -419,25 +412,22 @@ impl MsdController { } async fn configure_media(&self, media: &MountedMedia) -> Result<()> { - let gadget_path = self.active_gadget_path().await?; - let msd_hold = self.msd_function.read().await; - let Some(ref msd) = *msd_hold else { - self.monitor - .report_error("MSD function not initialized", "not_initialized") - .await; - return Err(AppError::Internal( - "MSD function not initialized".to_string(), - )); - }; - if let Err(e) = msd - .configure_lun_async(&gadget_path, media.lun, &Self::media_config(media)) + if let Err(e) = self + .otg_service + .configure_msd_lun(media.lun, &Self::media_config(media)) .await { let error_msg = format!("Failed to configure LUN {}: {}", media.lun, e); self.monitor .report_error(&error_msg, "configfs_error") .await; - return Err(e); + return Err(match e { + AppError::Msd(error) => AppError::Msd(error), + error => { + warn!(%error, "Unclassified MSD media configuration failure"); + MsdErrorCode::MsdOperationFailed.into() + } + }); } Ok(()) } @@ -447,7 +437,7 @@ impl MsdController { let msd_hold = self.msd_function.read().await; let msd = msd_hold .as_ref() - .ok_or_else(|| AppError::Internal("MSD function not initialized".to_string()))?; + .ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?; msd.disconnect_lun_async(&gadget_path, lun).await } @@ -455,9 +445,11 @@ impl MsdController { self.otg_service .set_msd_lun_capacity(previous_state.disk_mode.capacity()) .await?; - let msd_function = self.otg_service.msd_function().await.ok_or_else(|| { - AppError::Internal("MSD function missing after OTG rollback".to_string()) - })?; + let msd_function = self + .otg_service + .msd_function() + .await + .ok_or_else(|| AppError::from(MsdErrorCode::MsdOperationFailed))?; *self.msd_function.write().await = Some(msd_function); for media in &previous_state.mounted_media { self.configure_media(media).await?; @@ -473,7 +465,7 @@ impl MsdController { } pub async fn disconnect(&self) -> Result<()> { - let _op_guard = self.operation_lock.write().await; + let _op_guard = self.try_operation()?; let mut state = self.state.write().await; if state.mounted_media.is_empty() { @@ -488,10 +480,13 @@ impl MsdController { for prior in &disconnected { if let Err(restore_error) = self.configure_media(prior).await { state.available = false; - return Err(AppError::Internal(format!( - "Failed to disconnect LUN {}: {error}; restore failed: {restore_error}", - media.lun - ))); + warn!( + lun = media.lun, + disconnect_error = %error, + %restore_error, + "Failed to restore MSD media after disconnect failure" + ); + return Err(MsdErrorCode::MsdDisconnectFailed.into()); } } return Err(error); @@ -520,16 +515,14 @@ impl MsdController { } pub async fn delete_image(&self, image_id: &str) -> Result<()> { - let _op_guard = self.operation_lock.write().await; + let _op_guard = self.try_operation()?; let state = self.state.read().await; if state .mounted_media .iter() .any(|media| media.kind == MountedMediaKind::Image && media.id == image_id) { - return Err(AppError::BadRequest( - "Cannot delete image while it is mounted".to_string(), - )); + return Err(MsdErrorCode::MsdMediaInUse.into()); } ImageManager::new(self.images_path.clone()).delete(image_id) @@ -540,6 +533,12 @@ impl MsdController { url: String, filename: Option, ) -> Result { + let parsed_url = + reqwest::Url::parse(&url).map_err(|_| AppError::from(MsdErrorCode::MsdInvalidUrl))?; + if !matches!(parsed_url.scheme(), "http" | "https") { + return Err(MsdErrorCode::MsdInvalidUrl.into()); + } + let download_id = uuid::Uuid::new_v4().to_string(); let cancel_token = CancellationToken::new(); @@ -560,7 +559,7 @@ impl MsdController { total_bytes: None, progress_pct: None, status: DownloadStatus::Started, - error: None, + error_code: None, }; self.publish_event(crate::events::SystemEvent::MsdDownloadProgress { @@ -571,6 +570,7 @@ impl MsdController { total_bytes: None, progress_pct: None, status: "started".to_string(), + error_code: None, }) .await; @@ -600,6 +600,7 @@ impl MsdController { total_bytes: total, progress_pct, status: "in_progress".to_string(), + error_code: None, }); } }; @@ -624,11 +625,16 @@ impl MsdController { total_bytes: Some(image_info.size), progress_pct: Some(100.0), status: "completed".to_string(), + error_code: None, }); } } Err(e) => { - warn!("Download failed: {}", e); + warn!(error = %e, "MSD image download failed"); + let code = match e { + AppError::Msd(error) => error.code(), + _ => MsdErrorCode::MsdOperationFailed, + }; if let Some(ref bus) = events { bus.publish(crate::events::SystemEvent::MsdDownloadProgress { download_id: download_id_clone, @@ -637,7 +643,8 @@ impl MsdController { bytes_downloaded: 0, total_bytes: None, progress_pct: None, - status: format!("failed: {}", e), + status: "failed".to_string(), + error_code: Some(code.as_str().to_string()), }); } } @@ -655,10 +662,7 @@ impl MsdController { info!("Download cancelled: {}", download_id); Ok(()) } else { - Err(AppError::NotFound(format!( - "Download not found: {}", - download_id - ))) + Err(MsdErrorCode::MsdResourceNotFound.into()) } } @@ -666,7 +670,13 @@ impl MsdController { self.otg_service .gadget_path() .await - .ok_or_else(|| AppError::Internal("OTG gadget path is not available".to_string())) + .ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable)) + } + + fn try_operation(&self) -> Result> { + self.operation_lock + .try_write() + .map_err(|_| MsdErrorCode::MsdOperationInProgress.into()) } pub async fn shutdown(&self) -> Result<()> { @@ -712,6 +722,18 @@ mod tests { assert!(controller.drive_path.ends_with("ventoy.img")); } + #[tokio::test] + async fn concurrent_operations_have_a_stable_error_code() { + let temp_dir = TempDir::new().unwrap(); + let controller = MsdController::new(Arc::new(OtgService::new()), temp_dir.path()); + let _guard = controller.operation_lock.write().await; + + assert!(matches!( + controller.try_operation().unwrap_err(), + AppError::Msd(error) if error.code() == MsdErrorCode::MsdOperationInProgress + )); + } + #[tokio::test] async fn test_state_default() { let temp_dir = TempDir::new().unwrap(); @@ -783,14 +805,14 @@ mod tests { .push(MountedMedia::image(3, &image, false, true)); assert_eq!(MsdController::select_lun(&state, Some(5)).unwrap(), 5); - assert!(MsdController::select_lun(&state, Some(3)) - .unwrap_err() - .to_string() - .contains("already occupied")); - assert!(MsdController::select_lun(&state, Some(8)) - .unwrap_err() - .to_string() - .contains("outside")); + assert!(matches!( + MsdController::select_lun(&state, Some(3)).unwrap_err(), + AppError::Msd(error) if error.code() == MsdErrorCode::MsdMediaSlotsFull + )); + assert!(matches!( + MsdController::select_lun(&state, Some(8)).unwrap_err(), + AppError::Msd(error) if error.code() == MsdErrorCode::MsdInvalidRequest + )); } #[test] diff --git a/src/msd/image.rs b/src/msd/image.rs index 630f03d9..37314707 100644 --- a/src/msd/image.rs +++ b/src/msd/image.rs @@ -6,10 +6,10 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use time::OffsetDateTime; use tokio::io::AsyncWriteExt; -use tracing::info; +use tracing::{info, warn}; use super::types::ImageInfo; -use crate::error::{AppError, Result}; +use crate::error::{AppError, MsdErrorCode, Result}; const MAX_IMAGE_SIZE: u64 = 32 * 1024 * 1024 * 1024; @@ -28,7 +28,7 @@ impl ImageManager { pub fn ensure_dir(&self) -> Result<()> { fs::create_dir_all(&self.images_path) - .map_err(|e| AppError::Internal(format!("Failed to create images directory: {}", e)))?; + .map_err(|error| storage_io_error("create images directory", error))?; Ok(()) } @@ -38,11 +38,9 @@ impl ImageManager { let mut images = Vec::new(); for entry in fs::read_dir(&self.images_path) - .map_err(|e| AppError::Internal(format!("Failed to read images directory: {}", e)))? + .map_err(|error| storage_io_error("read images directory", error))? { - let entry = entry.map_err(|e| { - AppError::Internal(format!("Failed to read directory entry: {}", e)) - })?; + let entry = entry.map_err(|error| storage_io_error("read image entry", error))?; let path = entry.path(); if path.is_file() { @@ -88,13 +86,13 @@ impl ImageManager { return Ok(image); } } - Err(AppError::NotFound(format!("Image not found: {}", id))) + Err(MsdErrorCode::MsdResourceNotFound.into()) } pub fn get_by_name(&self, name: &str) -> Result { let path = self.images_path.join(name); self.get_image_info(&path) - .ok_or_else(|| AppError::NotFound(format!("Image not found: {}", name))) + .ok_or_else(|| MsdErrorCode::MsdResourceNotFound.into()) } #[cfg(test)] @@ -103,30 +101,24 @@ impl ImageManager { let name = sanitize_filename(name); if name.is_empty() { - return Err(AppError::Internal("Invalid filename".to_string())); + return Err(MsdErrorCode::MsdInvalidRequest.into()); } if data.len() as u64 > MAX_IMAGE_SIZE { - return Err(AppError::Internal(format!( - "Image too large. Maximum size: {} GB", - MAX_IMAGE_SIZE / 1024 / 1024 / 1024 - ))); + return Err(MsdErrorCode::MsdImageTooLarge.into()); } let path = self.images_path.join(&name); if path.exists() { - return Err(AppError::Internal(format!( - "Image already exists: {}", - name - ))); + return Err(MsdErrorCode::MsdResourceAlreadyExists.into()); } - let mut file = fs::File::create(&path) - .map_err(|e| AppError::Internal(format!("Failed to create image file: {}", e)))?; + let mut file = + fs::File::create(&path).map_err(|error| storage_io_error("create image", error))?; - file.write_all(data).map_err(|e| { + file.write_all(data).map_err(|error| { let _ = fs::remove_file(&path); - AppError::Internal(format!("Failed to write image data: {}", e)) + storage_io_error("write image", error) })?; info!("Created image: {} ({} bytes)", name, data.len()); @@ -143,7 +135,7 @@ impl ImageManager { let name = sanitize_filename(name); if name.is_empty() { - return Err(AppError::Internal("Invalid filename".to_string())); + return Err(MsdErrorCode::MsdInvalidRequest.into()); } let temp_name = format!(".upload_{}", uuid::Uuid::new_v4()); @@ -151,48 +143,41 @@ impl ImageManager { let final_path = self.images_path.join(&name); if final_path.exists() { - return Err(AppError::Internal(format!( - "Image already exists: {}", - name - ))); + return Err(MsdErrorCode::MsdResourceAlreadyExists.into()); } let mut file = tokio::fs::File::create(&temp_path) .await - .map_err(|e| AppError::Internal(format!("Failed to create temp file: {}", e)))?; + .map_err(|error| storage_io_error("create image upload", error))?; let mut bytes_written: u64 = 0; - while let Some(chunk) = field - .chunk() - .await - .map_err(|e| AppError::Internal(format!("Failed to read upload chunk: {}", e)))? - { + while let Some(chunk) = field.chunk().await.map_err(|error| { + warn!(%error, "Failed to read MSD image upload chunk"); + AppError::from(MsdErrorCode::MsdOperationFailed) + })? { bytes_written += chunk.len() as u64; if bytes_written > MAX_IMAGE_SIZE { drop(file); let _ = tokio::fs::remove_file(&temp_path).await; - return Err(AppError::Internal(format!( - "Image too large. Maximum size: {} GB", - MAX_IMAGE_SIZE / 1024 / 1024 / 1024 - ))); + return Err(MsdErrorCode::MsdImageTooLarge.into()); } file.write_all(&chunk) .await - .map_err(|e| AppError::Internal(format!("Failed to write chunk: {}", e)))?; + .map_err(|error| storage_io_error("write image upload", error))?; } file.flush() .await - .map_err(|e| AppError::Internal(format!("Failed to flush file: {}", e)))?; + .map_err(|error| storage_io_error("flush image upload", error))?; drop(file); tokio::fs::rename(&temp_path, &final_path) .await - .map_err(|e| { + .map_err(|error| { let _ = std::fs::remove_file(&temp_path); - AppError::Internal(format!("Failed to rename temp file: {}", e)) + storage_io_error("commit image upload", error) })?; info!( @@ -206,8 +191,7 @@ impl ImageManager { pub fn delete(&self, id: &str) -> Result<()> { let image = self.get(id)?; - fs::remove_file(&image.path) - .map_err(|e| AppError::Internal(format!("Failed to delete image: {}", e)))?; + fs::remove_file(&image.path).map_err(|error| storage_io_error("delete image", error))?; info!("Deleted image: {}", image.name); Ok(()) @@ -224,8 +208,11 @@ impl ImageManager { { self.ensure_dir()?; - let parsed_url = reqwest::Url::parse(url) - .map_err(|e| AppError::BadRequest(format!("Invalid URL: {}", e)))?; + let parsed_url = + reqwest::Url::parse(url).map_err(|_| AppError::from(MsdErrorCode::MsdInvalidUrl))?; + if !matches!(parsed_url.scheme(), "http" | "https") { + return Err(MsdErrorCode::MsdInvalidUrl.into()); + } info!("Starting download from: {}", url); @@ -233,19 +220,17 @@ impl ImageManager { .timeout(std::time::Duration::from_secs(3600)) .connect_timeout(std::time::Duration::from_secs(30)) .build() - .map_err(|e| AppError::Internal(format!("Failed to create HTTP client: {}", e)))?; + .map_err(|error| remote_download_error("create HTTP client", error))?; let head_response = client .head(url) .send() .await - .map_err(|e| AppError::Internal(format!("Failed to connect: {}", e)))?; + .map_err(|error| remote_download_error("send HEAD request", error))?; if !head_response.status().is_success() { - return Err(AppError::Internal(format!( - "Server returned error: {}", - head_response.status() - ))); + warn!(status = %head_response.status(), "MSD image HEAD request failed"); + return Err(MsdErrorCode::MsdRemoteDownloadFailed.into()); } let total_size = head_response @@ -256,11 +241,7 @@ impl ImageManager { if let Some(size) = total_size { if size > MAX_IMAGE_SIZE { - return Err(AppError::BadRequest(format!( - "File too large: {} bytes (max {} GB)", - size, - MAX_IMAGE_SIZE / 1024 / 1024 / 1024 - ))); + return Err(MsdErrorCode::MsdImageTooLarge.into()); } } @@ -284,17 +265,12 @@ impl ImageManager { }; if final_filename.is_empty() { - return Err(AppError::BadRequest( - "Could not determine filename".to_string(), - )); + return Err(MsdErrorCode::MsdInvalidRequest.into()); } let final_path = self.images_path.join(&final_filename); if final_path.exists() { - return Err(AppError::BadRequest(format!( - "Image already exists: {}", - final_filename - ))); + return Err(MsdErrorCode::MsdResourceAlreadyExists.into()); } let temp_filename = format!(".download_{}", uuid::Uuid::new_v4()); @@ -304,13 +280,11 @@ impl ImageManager { .get(url) .send() .await - .map_err(|e| AppError::Internal(format!("Download failed: {}", e)))?; + .map_err(|error| remote_download_error("send GET request", error))?; if !response.status().is_success() { - return Err(AppError::Internal(format!( - "Download failed: HTTP {}", - response.status() - ))); + warn!(status = %response.status(), "MSD image GET request failed"); + return Err(MsdErrorCode::MsdRemoteDownloadFailed.into()); } let content_length = response @@ -322,7 +296,7 @@ impl ImageManager { let mut file = tokio::fs::File::create(&temp_path) .await - .map_err(|e| AppError::Internal(format!("Failed to create temp file: {}", e)))?; + .map_err(|error| storage_io_error("create image download", error))?; let mut stream = response.bytes_stream(); let mut downloaded: u64 = 0; @@ -334,11 +308,11 @@ impl ImageManager { while let Some(chunk_result) = stream.next().await { let chunk = - chunk_result.map_err(|e| AppError::Internal(format!("Download error: {}", e)))?; + chunk_result.map_err(|error| remote_download_error("read response body", error))?; - file.write_all(&chunk).await.map_err(|e| { + file.write_all(&chunk).await.map_err(|error| { let _ = std::fs::remove_file(&temp_path); - AppError::Internal(format!("Failed to write data: {}", e)) + storage_io_error("write image download", error) })?; downloaded += chunk.len() as u64; @@ -360,29 +334,29 @@ impl ImageManager { file.flush() .await - .map_err(|e| AppError::Internal(format!("Failed to flush file: {}", e)))?; + .map_err(|error| storage_io_error("flush image download", error))?; drop(file); let metadata = tokio::fs::metadata(&temp_path) .await - .map_err(|e| AppError::Internal(format!("Failed to read file metadata: {}", e)))?; + .map_err(|error| storage_io_error("read downloaded image metadata", error))?; if let Some(expected) = content_length { if metadata.len() != expected { let _ = tokio::fs::remove_file(&temp_path).await; - return Err(AppError::Internal(format!( - "Download incomplete: got {} bytes, expected {}", - metadata.len(), - expected - ))); + warn!( + actual = metadata.len(), + expected, "MSD image download was incomplete" + ); + return Err(MsdErrorCode::MsdDownloadIncomplete.into()); } } tokio::fs::rename(&temp_path, &final_path) .await - .map_err(|e| { + .map_err(|error| { let _ = std::fs::remove_file(&temp_path); - AppError::Internal(format!("Failed to move file: {}", e)) + storage_io_error("commit image download", error) })?; info!( @@ -395,6 +369,26 @@ impl ImageManager { } } +fn storage_io_error(operation: &'static str, error: std::io::Error) -> AppError { + warn!(operation, %error, "MSD storage operation failed"); + #[cfg(unix)] + let code = match error.raw_os_error() { + Some(libc::EFBIG) => MsdErrorCode::MsdImageTooLarge, + Some(libc::ENOSPC) => MsdErrorCode::MsdStorageFull, + Some(libc::EROFS) => MsdErrorCode::MsdStorageReadOnly, + Some(libc::EACCES | libc::EPERM) => MsdErrorCode::MsdStoragePermissionDenied, + _ => MsdErrorCode::MsdOperationFailed, + }; + #[cfg(not(unix))] + let code = MsdErrorCode::MsdOperationFailed; + code.into() +} + +fn remote_download_error(operation: &'static str, error: reqwest::Error) -> AppError { + warn!(operation, %error, "MSD remote download failed"); + MsdErrorCode::MsdRemoteDownloadFailed.into() +} + fn stable_image_id_from_filename(name: &str) -> String { let mut hash: u64 = 0; for (i, byte) in name.bytes().enumerate() { @@ -490,4 +484,18 @@ mod tests { assert!(manager.list().unwrap().is_empty()); } + + #[test] + fn classifies_storage_io_errors() { + for (errno, expected) in [ + (libc::EFBIG, MsdErrorCode::MsdImageTooLarge), + (libc::ENOSPC, MsdErrorCode::MsdStorageFull), + (libc::EROFS, MsdErrorCode::MsdStorageReadOnly), + (libc::EACCES, MsdErrorCode::MsdStoragePermissionDenied), + (libc::EPERM, MsdErrorCode::MsdStoragePermissionDenied), + ] { + let error = storage_io_error("test", std::io::Error::from_raw_os_error(errno)); + assert!(matches!(error, AppError::Msd(error) if error.code() == expected)); + } + } } diff --git a/src/msd/mod.rs b/src/msd/mod.rs index fc21f851..35fbf71a 100644 --- a/src/msd/mod.rs +++ b/src/msd/mod.rs @@ -14,4 +14,5 @@ pub use types::{ }; pub use ventoy_drive::{VentoyDrive, MIN_DRIVE_SIZE_MB}; +pub use crate::error::{MsdError, MsdErrorCode}; pub use crate::otg::{MsdFunction, MsdLunConfig}; diff --git a/src/msd/types.rs b/src/msd/types.rs index e7d6be80..f8a001e8 100644 --- a/src/msd/types.rs +++ b/src/msd/types.rs @@ -235,7 +235,7 @@ pub struct DownloadProgress { pub total_bytes: Option, pub progress_pct: Option, pub status: DownloadStatus, - pub error: Option, + pub error_code: Option, } #[cfg(test)] diff --git a/src/msd/ventoy_drive.rs b/src/msd/ventoy_drive.rs index 0a9dee2a..edeb4faf 100644 --- a/src/msd/ventoy_drive.rs +++ b/src/msd/ventoy_drive.rs @@ -1,12 +1,12 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::RwLock; -use tracing::info; +use tracing::{info, warn}; use ventoy_img::{FileInfo as VentoyFileInfo, VentoyError, VentoyImage}; use super::types::{DriveFile, DriveInfo}; -use crate::error::{AppError, Result}; +use crate::error::{AppError, MsdErrorCode, Result}; const STREAM_CHUNK_SIZE: usize = 64 * 1024; @@ -44,10 +44,7 @@ impl VentoyDrive { pub async fn init(&self, size_mb: u32) -> Result { if size_mb < MIN_DRIVE_SIZE_MB { - return Err(AppError::BadRequest(format!( - "Drive size must be at least {} MB", - MIN_DRIVE_SIZE_MB - ))); + return Err(MsdErrorCode::MsdDriveSizeInvalid.into()); } let size_str = format!("{}M", size_mb); let path = self.path.clone(); @@ -59,7 +56,7 @@ impl VentoyDrive { VentoyImage::create(&path, &size_str, DEFAULT_LABEL).map_err(drive_init_error)?; let metadata = std::fs::metadata(&path) - .map_err(|e| AppError::Internal(format!("Failed to read drive metadata: {}", e)))?; + .map_err(|error| drive_io_error("read initialized drive metadata", error))?; Ok::(DriveInfo { size: metadata.len(), @@ -70,7 +67,7 @@ impl VentoyDrive { }) }) .await - .map_err(|e| AppError::Internal(format!("Task join error: {}", e)))??; + .map_err(|error| task_error("initialize virtual drive", error))??; info!("Ventoy drive created successfully"); Ok(info) @@ -78,7 +75,7 @@ impl VentoyDrive { pub async fn info(&self) -> Result { if !self.exists() { - return Err(AppError::Internal("Drive not initialized".to_string())); + return Err(MsdErrorCode::MsdDriveNotInitialized.into()); } let path = self.path.clone(); @@ -86,7 +83,7 @@ impl VentoyDrive { tokio::task::spawn_blocking(move || { let metadata = std::fs::metadata(&path) - .map_err(|e| AppError::Internal(format!("Failed to read drive metadata: {}", e)))?; + .map_err(|error| drive_io_error("read drive metadata", error))?; let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?; @@ -110,12 +107,12 @@ impl VentoyDrive { }) }) .await - .map_err(|e| AppError::Internal(format!("Task join error: {}", e)))? + .map_err(|error| task_error("read virtual drive info", error))? } pub async fn list_files(&self, dir_path: &str) -> Result> { if !self.exists() { - return Err(AppError::Internal("Drive not initialized".to_string())); + return Err(MsdErrorCode::MsdDriveNotInitialized.into()); } let path = self.path.clone(); @@ -138,7 +135,7 @@ impl VentoyDrive { .collect()) }) .await - .map_err(|e| AppError::Internal(format!("Task join error: {}", e)))? + .map_err(|error| task_error("list virtual drive files", error))? } pub async fn write_file_from_multipart_field( @@ -147,7 +144,7 @@ impl VentoyDrive { mut field: axum::extract::multipart::Field<'_>, ) -> Result { if !self.exists() { - return Err(AppError::Internal("Drive not initialized".to_string())); + return Err(MsdErrorCode::MsdDriveNotInitialized.into()); } let temp_dir = self.path.parent().unwrap_or(Path::new("/tmp")); @@ -156,24 +153,23 @@ impl VentoyDrive { let mut temp_file = tokio::fs::File::create(&temp_path) .await - .map_err(|e| AppError::Internal(format!("Failed to create temp file: {}", e)))?; + .map_err(|error| drive_io_error("create virtual drive upload", error))?; let mut bytes_written: u64 = 0; - while let Some(chunk) = field - .chunk() - .await - .map_err(|e| AppError::Internal(format!("Failed to read upload chunk: {}", e)))? - { + while let Some(chunk) = field.chunk().await.map_err(|error| { + warn!(%error, "Failed to read virtual drive upload chunk"); + AppError::from(MsdErrorCode::MsdOperationFailed) + })? { bytes_written += chunk.len() as u64; tokio::io::AsyncWriteExt::write_all(&mut temp_file, &chunk) .await - .map_err(|e| AppError::Internal(format!("Failed to write chunk: {}", e)))?; + .map_err(|error| drive_io_error("write virtual drive upload", error))?; } tokio::io::AsyncWriteExt::flush(&mut temp_file) .await - .map_err(|e| AppError::Internal(format!("Failed to flush temp file: {}", e)))?; + .map_err(|error| drive_io_error("flush virtual drive upload", error))?; drop(temp_file); let path = self.path.clone(); @@ -191,7 +187,7 @@ impl VentoyDrive { Ok::<(), AppError>(()) }) .await - .map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?; + .map_err(|error| task_error("write virtual drive file", error))?; let _ = tokio::fs::remove_file(&temp_path).await; @@ -202,7 +198,7 @@ impl VentoyDrive { #[cfg(test)] pub async fn read_file(&self, file_path: &str) -> Result> { if !self.exists() { - return Err(AppError::Internal("Drive not initialized".to_string())); + return Err(MsdErrorCode::MsdDriveNotInitialized.into()); } let path = self.path.clone(); @@ -215,12 +211,12 @@ impl VentoyDrive { image.read_file(&file_path).map_err(ventoy_to_app_error) }) .await - .map_err(|e| AppError::Internal(format!("Task join error: {}", e)))? + .map_err(|error| task_error("read virtual drive file", error))? } pub async fn get_file_info(&self, file_path: &str) -> Result> { if !self.exists() { - return Err(AppError::Internal("Drive not initialized".to_string())); + return Err(MsdErrorCode::MsdDriveNotInitialized.into()); } let path = self.path.clone(); @@ -234,7 +230,7 @@ impl VentoyDrive { .map_err(ventoy_to_app_error) }) .await - .map_err(|e| AppError::Internal(format!("Task join error: {}", e)))??; + .map_err(|error| task_error("read virtual drive file information", error))??; Ok(info.map(|f| DriveFile { name: f.name, @@ -253,19 +249,16 @@ impl VentoyDrive { tokio::sync::mpsc::Receiver>, )> { if !self.exists() { - return Err(AppError::Internal("Drive not initialized".to_string())); + return Err(MsdErrorCode::MsdDriveNotInitialized.into()); } let file_info = self .get_file_info(file_path) .await? - .ok_or_else(|| AppError::NotFound(format!("File not found: {}", file_path)))?; + .ok_or_else(|| AppError::from(MsdErrorCode::MsdResourceNotFound))?; if file_info.is_dir { - return Err(AppError::BadRequest(format!( - "'{}' is a directory", - file_path - ))); + return Err(MsdErrorCode::MsdInvalidRequest.into()); } let file_size = file_info.size; @@ -300,7 +293,7 @@ impl VentoyDrive { pub async fn mkdir(&self, dir_path: &str) -> Result<()> { if !self.exists() { - return Err(AppError::Internal("Drive not initialized".to_string())); + return Err(MsdErrorCode::MsdDriveNotInitialized.into()); } let path = self.path.clone(); @@ -315,12 +308,12 @@ impl VentoyDrive { .map_err(ventoy_to_app_error) }) .await - .map_err(|e| AppError::Internal(format!("Task join error: {}", e)))? + .map_err(|error| task_error("create virtual drive directory", error))? } pub async fn delete(&self, path_to_delete: &str) -> Result<()> { if !self.exists() { - return Err(AppError::Internal("Drive not initialized".to_string())); + return Err(MsdErrorCode::MsdDriveNotInitialized.into()); } let path = self.path.clone(); @@ -335,22 +328,23 @@ impl VentoyDrive { .map_err(ventoy_to_app_error) }) .await - .map_err(|e| AppError::Internal(format!("Task join error: {}", e)))? + .map_err(|error| task_error("delete virtual drive resource", error))? } } fn ventoy_to_app_error(err: VentoyError) -> AppError { + warn!(%err, "Virtual drive filesystem operation failed"); match err { - VentoyError::Io(e) => AppError::Io(e), - VentoyError::InvalidSize(s) => AppError::BadRequest(format!("Invalid size: {}", s)), - VentoyError::SizeParseError(s) => AppError::BadRequest(format!("Size parse error: {}", s)), - VentoyError::FilesystemError(s) => AppError::Internal(format!("Filesystem error: {}", s)), - VentoyError::ImageError(s) => AppError::Internal(format!("Image error: {}", s)), - VentoyError::FileNotFound(s) => AppError::NotFound(format!("File not found: {}", s)), - VentoyError::ResourceNotFound(s) => { - AppError::Internal(format!("Resource not found: {}", s)) + VentoyError::Io(error) => drive_io_error("access virtual drive", error), + VentoyError::InvalidSize(_) | VentoyError::SizeParseError(_) => { + MsdErrorCode::MsdDriveSizeInvalid.into() + } + VentoyError::FilesystemError(_) + | VentoyError::ImageError(_) + | VentoyError::PartitionError(_) => MsdErrorCode::MsdDriveFilesystemUnsupported.into(), + VentoyError::FileNotFound(_) | VentoyError::ResourceNotFound(_) => { + MsdErrorCode::MsdResourceNotFound.into() } - VentoyError::PartitionError(s) => AppError::Internal(format!("Partition error: {}", s)), } } @@ -361,21 +355,35 @@ fn drive_init_error(err: VentoyError) -> AppError { #[cfg(unix)] match error.raw_os_error() { - Some(libc::EFBIG) => AppError::BadRequest( - "MSD directory filesystem does not support a virtual drive file of this size".into(), - ), - Some(libc::ENOSPC) => AppError::BadRequest( - "MSD directory does not have enough free space for the virtual drive".into(), - ), - Some(libc::EROFS) => AppError::BadRequest("MSD directory filesystem is read-only".into()), - Some(libc::EACCES | libc::EPERM) => AppError::BadRequest( - "One-KVM does not have permission to write to the MSD directory".into(), - ), - _ => AppError::Io(error), + Some(libc::EFBIG) => MsdErrorCode::MsdDriveSizeInvalid.into(), + Some(libc::ENOSPC) => MsdErrorCode::MsdStorageFull.into(), + Some(libc::EROFS) => MsdErrorCode::MsdStorageReadOnly.into(), + Some(libc::EACCES | libc::EPERM) => MsdErrorCode::MsdStoragePermissionDenied.into(), + _ => drive_io_error("initialize virtual drive", error), } #[cfg(not(unix))] - AppError::Io(error) + drive_io_error("initialize virtual drive", error) +} + +fn drive_io_error(operation: &'static str, error: std::io::Error) -> AppError { + warn!(operation, %error, "Virtual drive storage operation failed"); + #[cfg(unix)] + let code = match error.raw_os_error() { + Some(libc::EFBIG) => MsdErrorCode::MsdImageTooLarge, + Some(libc::ENOSPC) => MsdErrorCode::MsdStorageFull, + Some(libc::EROFS) => MsdErrorCode::MsdStorageReadOnly, + Some(libc::EACCES | libc::EPERM) => MsdErrorCode::MsdStoragePermissionDenied, + _ => MsdErrorCode::MsdOperationFailed, + }; + #[cfg(not(unix))] + let code = MsdErrorCode::MsdOperationFailed; + code.into() +} + +fn task_error(operation: &'static str, error: tokio::task::JoinError) -> AppError { + warn!(operation, %error, "Virtual drive task failed"); + MsdErrorCode::MsdOperationFailed.into() } fn ventoy_file_to_drive_file(info: VentoyFileInfo, parent_path: &str) -> DriveFile { @@ -470,16 +478,35 @@ mod tests { #[test] fn classifies_drive_creation_io_errors() { for (errno, expected) in [ - (libc::EFBIG, "does not support"), - (libc::ENOSPC, "enough free space"), - (libc::EROFS, "read-only"), - (libc::EACCES, "permission"), + (libc::EFBIG, MsdErrorCode::MsdDriveSizeInvalid), + (libc::ENOSPC, MsdErrorCode::MsdStorageFull), + (libc::EROFS, MsdErrorCode::MsdStorageReadOnly), + (libc::EACCES, MsdErrorCode::MsdStoragePermissionDenied), + (libc::EPERM, MsdErrorCode::MsdStoragePermissionDenied), ] { let error = drive_init_error(VentoyError::Io(std::io::Error::from_raw_os_error(errno))); - assert!(matches!(error, AppError::BadRequest(message) if message.contains(expected))); + assert!(matches!(error, AppError::Msd(error) if error.code() == expected)); } } + #[test] + fn classifies_ventoy_filesystem_and_resource_errors() { + for error in [ + VentoyError::FilesystemError("details".into()), + VentoyError::ImageError("details".into()), + VentoyError::PartitionError("details".into()), + ] { + assert!(matches!( + ventoy_to_app_error(error), + AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveFilesystemUnsupported + )); + } + assert!(matches!( + ventoy_to_app_error(VentoyError::FileNotFound("details".into())), + AppError::Msd(error) if error.code() == MsdErrorCode::MsdResourceNotFound + )); + } + fn init_ventoy_resources() -> bool { static INIT: OnceLock = OnceLock::new(); *INIT.get_or_init(|| { diff --git a/src/otg/bridge.rs b/src/otg/bridge.rs index 3c6f018a..01a496f4 100644 --- a/src/otg/bridge.rs +++ b/src/otg/bridge.rs @@ -67,8 +67,14 @@ struct BridgeJournal { existing_bridge: bool, original_connection_uuid: Option, bridge_profile_uuid: Option, + #[serde(default)] + bridge_profile_name: Option, uplink_profile_uuid: Option, + #[serde(default)] + uplink_profile_name: Option, usb_profile_uuid: String, + #[serde(default)] + usb_profile_name: Option, } #[derive(Debug)] @@ -87,11 +93,11 @@ impl TransactionProfiles { let suffix = &transaction[..12]; Self { bridge_name: format!("{PROFILE_PREFIX}-bridge-{suffix}"), - bridge_uuid: Uuid::new_v4().to_string(), + bridge_uuid: String::new(), uplink_name: format!("{PROFILE_PREFIX}-uplink-{suffix}"), - uplink_uuid: Uuid::new_v4().to_string(), + uplink_uuid: String::new(), usb_name: format!("{PROFILE_PREFIX}-usb-{suffix}"), - usb_uuid: Uuid::new_v4().to_string(), + usb_uuid: String::new(), } } } @@ -151,15 +157,18 @@ impl NetworkBridgeRuntime { .trim() .to_string(); - let profiles = TransactionProfiles::new(); - let journal = BridgeJournal { + let mut profiles = TransactionProfiles::new(); + let mut journal = BridgeJournal { version: JOURNAL_VERSION, uplink: uplink.to_string(), existing_bridge: false, original_connection_uuid: Some(original_connection_uuid.clone()), - bridge_profile_uuid: Some(profiles.bridge_uuid.clone()), - uplink_profile_uuid: Some(profiles.uplink_uuid.clone()), - usb_profile_uuid: profiles.usb_uuid.clone(), + bridge_profile_uuid: None, + bridge_profile_name: Some(profiles.bridge_name.clone()), + uplink_profile_uuid: None, + uplink_profile_name: Some(profiles.uplink_name.clone()), + usb_profile_uuid: String::new(), + usb_profile_name: Some(profiles.usb_name.clone()), }; write_journal(&journal)?; @@ -174,9 +183,10 @@ impl NetworkBridgeRuntime { BRIDGE_IF, "con-name", &profiles.bridge_name, - "connection.uuid", - &profiles.bridge_uuid, ])?; + profiles.bridge_uuid = connection_value(&profiles.bridge_name, "connection.uuid")?; + journal.bridge_profile_uuid = Some(profiles.bridge_uuid.clone()); + write_journal(&journal)?; run_nmcli(&[ "connection", "modify", @@ -220,8 +230,6 @@ impl NetworkBridgeRuntime { uplink, "con-name", &profiles.uplink_name, - "connection.uuid", - &profiles.uplink_uuid, "master", BRIDGE_IF, "slave-type", @@ -229,6 +237,9 @@ impl NetworkBridgeRuntime { "connection.autoconnect", "no", ])?; + profiles.uplink_uuid = connection_value(&profiles.uplink_name, "connection.uuid")?; + journal.uplink_profile_uuid = Some(profiles.uplink_uuid.clone()); + write_journal(&journal)?; run_nmcli(&[ "connection", "add", @@ -238,8 +249,6 @@ impl NetworkBridgeRuntime { usb_interface, "con-name", &profiles.usb_name, - "connection.uuid", - &profiles.usb_uuid, "master", BRIDGE_IF, "slave-type", @@ -247,6 +256,9 @@ impl NetworkBridgeRuntime { "connection.autoconnect", "no", ])?; + profiles.usb_uuid = connection_value(&profiles.usb_name, "connection.uuid")?; + journal.usb_profile_uuid = profiles.usb_uuid.clone(); + write_journal(&journal)?; Ok(()) })(); if let Err(error) = prepare_result { @@ -449,17 +461,27 @@ fn select_bridge_candidate<'a>( fn restore_from_journal(journal: &BridgeJournal) -> Result<()> { let mut errors = Vec::new(); - for (kind, profile_uuid) in [ - ("USB", Some(journal.usb_profile_uuid.as_str())), - ("uplink", journal.uplink_profile_uuid.as_deref()), - ("bridge", journal.bridge_profile_uuid.as_deref()), + for (kind, profile_uuid, profile_name) in [ + ( + "USB", + (!journal.usb_profile_uuid.is_empty()).then_some(journal.usb_profile_uuid.as_str()), + journal.usb_profile_name.as_deref(), + ), + ( + "uplink", + journal.uplink_profile_uuid.as_deref(), + journal.uplink_profile_name.as_deref(), + ), + ( + "bridge", + journal.bridge_profile_uuid.as_deref(), + journal.bridge_profile_name.as_deref(), + ), ] { - let Some(profile_uuid) = profile_uuid else { - continue; - }; - if let Err(error) = delete_connection(profile_uuid) { + if let Err(error) = delete_owned_connection(profile_uuid, profile_name) { + let profile = profile_uuid.or(profile_name).unwrap_or("unknown"); errors.push(format!( - "failed to remove owned {kind} profile {profile_uuid}: {error}" + "failed to remove owned {kind} profile {profile}: {error}" )); } } @@ -627,12 +649,23 @@ fn connection_uuids() -> Result> { .collect()) } -fn delete_connection(profile_uuid: &str) -> Result<()> { - if !connection_uuids()?.iter().any(|uuid| uuid == profile_uuid) { +fn delete_owned_connection(profile_uuid: Option<&str>, profile_name: Option<&str>) -> Result<()> { + if let Some(profile_uuid) = profile_uuid { + if connection_uuids()?.iter().any(|uuid| uuid == profile_uuid) { + return run_nmcli(&["connection", "delete", "uuid", profile_uuid]).map(|_| ()); + } + } + let Some(profile_name) = profile_name else { + return Ok(()); + }; + let output = run_nmcli(&["-t", "--escape", "no", "-f", "NAME", "connection", "show"])?; + if !String::from_utf8_lossy(&output.stdout) + .lines() + .any(|name| name == profile_name) + { return Ok(()); } - run_nmcli(&["connection", "delete", "uuid", profile_uuid])?; - Ok(()) + run_nmcli(&["connection", "delete", "id", profile_name]).map(|_| ()) } fn copy_connection_properties(source: &str, target: &str, properties: &[&str]) -> Result<()> { @@ -834,8 +867,11 @@ mod tests { existing_bridge: false, original_connection_uuid: Some("original-uuid".to_string()), bridge_profile_uuid: Some("bridge-uuid".to_string()), + bridge_profile_name: Some("bridge-name".to_string()), uplink_profile_uuid: Some("uplink-uuid".to_string()), + uplink_profile_name: Some("uplink-name".to_string()), usb_profile_uuid: "usb-uuid".to_string(), + usb_profile_name: Some("usb-name".to_string()), }; let value = serde_json::to_string(&journal).unwrap(); let decoded: BridgeJournal = serde_json::from_str(&value).unwrap(); @@ -844,13 +880,31 @@ mod tests { } #[test] - fn transaction_profiles_use_unique_names_and_uuids() { + fn bridge_journal_accepts_legacy_entries_without_profile_names() { + let value = r#"{ + "version": 2, + "uplink": "eth0", + "existing_bridge": false, + "original_connection_uuid": "original-uuid", + "bridge_profile_uuid": "bridge-uuid", + "uplink_profile_uuid": "uplink-uuid", + "usb_profile_uuid": "usb-uuid" + }"#; + let decoded: BridgeJournal = serde_json::from_str(value).unwrap(); + assert_eq!(decoded.bridge_profile_name, None); + assert_eq!(decoded.uplink_profile_name, None); + assert_eq!(decoded.usb_profile_name, None); + } + + #[test] + fn transaction_profiles_use_unique_names() { let first = TransactionProfiles::new(); let second = TransactionProfiles::new(); assert_ne!(first.bridge_name, second.bridge_name); - assert_ne!(first.bridge_uuid, second.bridge_uuid); assert!(first.usb_name.starts_with(PROFILE_PREFIX)); - assert!(Uuid::parse_str(&first.usb_uuid).is_ok()); + assert!(first.bridge_uuid.is_empty()); + assert!(first.uplink_uuid.is_empty()); + assert!(first.usb_uuid.is_empty()); } #[test] diff --git a/src/otg/configfs.rs b/src/otg/configfs.rs index 1e95b925..5b959a52 100644 --- a/src/otg/configfs.rs +++ b/src/otg/configfs.rs @@ -106,6 +106,16 @@ pub fn write_file(path: &Path, content: &str) -> Result<()> { Ok(()) } +/// Write an optional configfs/sysfs attribute when the running kernel exposes it. +/// This keeps newer kernel enhancements compatible with older kernels. +pub fn write_file_if_exists(path: &Path, content: &str) -> Result { + if !path.exists() { + return Ok(false); + } + write_file(path, content)?; + Ok(true) +} + pub fn write_bytes(path: &Path, data: &[u8]) -> Result<()> { let mut file = File::create(path) .map_err(|e| AppError::Internal(format!("Failed to create {}: {}", path.display(), e)))?; diff --git a/src/otg/hid.rs b/src/otg/hid.rs index fe1fbbb0..6eff23ea 100644 --- a/src/otg/hid.rs +++ b/src/otg/hid.rs @@ -3,6 +3,7 @@ use tracing::debug; use super::configfs::{ create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file, + write_file_if_exists, }; use super::function::GadgetFunction; use super::report_desc::{ @@ -143,6 +144,10 @@ impl GadgetFunction for HidFunction { self.func_type.report_desc(self.keyboard_leds), )?; + // Supported by the PiKVM HID kernel patch. Older kernels simply do + // not expose this ConfigFS attribute. + let _ = write_file_if_exists(&func_path.join("wakeup_on_write"), "1")?; + debug!( "Created HID function: {} at {}", self.name(), diff --git a/src/otg/manager.rs b/src/otg/manager.rs index 94fc65e2..147c6c7a 100644 --- a/src/otg/manager.rs +++ b/src/otg/manager.rs @@ -4,12 +4,12 @@ use tracing::{debug, error, info, warn}; use super::configfs::{ configfs_path, create_dir, create_symlink, find_udc, is_configfs_available, remove_dir, - remove_file, write_file, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID, - DEFAULT_USB_VENDOR_ID, USB_BCD_USB, + remove_file, write_file, write_file_if_exists, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, + DEFAULT_USB_PRODUCT_ID, DEFAULT_USB_VENDOR_ID, USB_BCD_USB, }; use super::function::GadgetFunction; use super::hid::HidFunction; -use super::msd::MsdFunction; +use super::msd::{MsdFunction, MsdInquiryStrings}; use super::network::NetworkFunction; use crate::config::OtgNetworkConfig; use crate::error::{AppError, Result}; @@ -134,8 +134,12 @@ impl OtgGadgetManager { Ok(device_path) } - pub fn add_msd(&mut self, lun_capacity: u8) -> Result { - let func = MsdFunction::new(self.msd_instance, lun_capacity)?; + pub fn add_msd( + &mut self, + lun_capacity: u8, + inquiry_strings: MsdInquiryStrings, + ) -> Result { + let func = MsdFunction::new(self.msd_instance, lun_capacity, inquiry_strings)?; let func_clone = func.clone(); self.add_function(Box::new(func))?; self.msd_instance += 1; @@ -196,6 +200,22 @@ impl OtgGadgetManager { func.link(&self.config_path, &self.gadget_path)?; } + // A host only enables USB remote wakeup when the configuration + // descriptor advertises it. Enable the descriptor bit only when the + // running kernel supports the HID wakeup_on_write attribute. + let hid_wakeup_supported = self.functions.iter().any(|func| { + func.name().starts_with("hid.") + && self + .gadget_path + .join("functions") + .join(func.name()) + .join("wakeup_on_write") + .exists() + }); + if hid_wakeup_supported { + let _ = write_file_if_exists(&self.config_path.join("bmAttributes"), "0xA0")?; + } + debug!("OTG USB Gadget setup complete"); Ok(()) } diff --git a/src/otg/mod.rs b/src/otg/mod.rs index 9af22aaf..1801e09f 100644 --- a/src/otg/mod.rs +++ b/src/otg/mod.rs @@ -28,7 +28,7 @@ pub use msd::{MsdFunction, MsdLunConfig}; #[cfg(unix)] pub use network::NetworkFunction; #[cfg(unix)] -pub use service::{HidDevicePaths, OtgNetworkStatus, OtgRuntimeHealth, OtgService, UacConfig}; +pub use service::{HidDevicePaths, OtgNetworkStatus, OtgRuntimeHealth, OtgService}; #[cfg(unix)] pub use uac::UacFunction; diff --git a/src/otg/msd.rs b/src/otg/msd.rs index a1256f50..15fb2c86 100644 --- a/src/otg/msd.rs +++ b/src/otg/msd.rs @@ -1,10 +1,14 @@ -use std::fs; +use std::fs::{self, OpenOptions}; +use std::io::Write; use std::path::{Path, PathBuf}; use tracing::{debug, info, warn}; use super::configfs::{create_dir, create_symlink, remove_dir, remove_file, write_file}; use super::function::GadgetFunction; -use crate::error::{AppError, Result}; +use crate::config::{MsdConfig, DEFAULT_CDROM_INQUIRY_STRING, DEFAULT_FLASH_INQUIRY_STRING}; +use crate::error::{AppError, MsdErrorCode, Result}; + +const MEDIA_TYPE_REBIND_DELAY_MS: u64 = 300; #[derive(Debug, Clone)] pub struct MsdLunConfig { @@ -53,14 +57,39 @@ impl MsdLunConfig { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MsdInquiryStrings { + pub flash: String, + pub cdrom: String, +} + +impl Default for MsdInquiryStrings { + fn default() -> Self { + Self { + flash: DEFAULT_FLASH_INQUIRY_STRING.to_string(), + cdrom: DEFAULT_CDROM_INQUIRY_STRING.to_string(), + } + } +} + +impl From<&MsdConfig> for MsdInquiryStrings { + fn from(config: &MsdConfig) -> Self { + Self { + flash: config.flash_inquiry_string.clone(), + cdrom: config.cdrom_inquiry_string.clone(), + } + } +} + #[derive(Debug, Clone)] pub struct MsdFunction { name: String, lun_capacity: u8, + inquiry_strings: MsdInquiryStrings, } impl MsdFunction { - pub fn new(instance: u8, lun_capacity: u8) -> Result { + pub fn new(instance: u8, lun_capacity: u8, inquiry_strings: MsdInquiryStrings) -> Result { if lun_capacity != 1 && lun_capacity != 8 { return Err(AppError::BadRequest(format!( "MSD LUN capacity must be 1 or 8, got {lun_capacity}" @@ -70,6 +99,7 @@ impl MsdFunction { Ok(Self { name: format!("mass_storage.usb{}", instance), lun_capacity, + inquiry_strings, }) } @@ -150,6 +180,88 @@ impl MsdFunction { ))); } + let current_cdrom = fs::read_to_string(lun_path.join("cdrom")) + .unwrap_or_default() + .trim() + .to_string(); + let rebind_required = Self::media_type_rebind_required(¤t_cdrom, config); + let udc_path = gadget_path.join("UDC"); + let bound_udc = if rebind_required && udc_path.exists() { + fs::read_to_string(&udc_path) + .map_err(|error| { + AppError::Internal(format!( + "Failed to read bound UDC before changing LUN {lun} media type: {error}" + )) + })? + .trim() + .to_string() + } else { + String::new() + }; + + if !bound_udc.is_empty() { + info!( + "LUN {} media type is changing; temporarily unbinding UDC {}", + lun, bound_udc + ); + write_file(&udc_path, "")?; + std::thread::sleep(std::time::Duration::from_millis(MEDIA_TYPE_REBIND_DELAY_MS)); + } + + let configure_result = self.configure_lun_attributes(&lun_path, lun, config); + let rebind_result = if bound_udc.is_empty() { + Ok(()) + } else { + let result = write_file(&udc_path, &bound_udc); + if result.is_ok() { + std::thread::sleep(std::time::Duration::from_millis(MEDIA_TYPE_REBIND_DELAY_MS)); + info!( + "Rebound UDC {} after changing LUN {} media type", + bound_udc, lun + ); + } + result + }; + + match (configure_result, rebind_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(configure_error), Ok(())) => Err(configure_error), + (Ok(()), Err(rebind_error)) => Err(AppError::Internal(format!( + "Configured LUN {lun}, but failed to rebind UDC {bound_udc}: {rebind_error}" + ))), + (Err(configure_error), Err(rebind_error)) => Err(AppError::Internal(format!( + "Failed to configure LUN {lun}: {configure_error}; also failed to rebind UDC {bound_udc}: {rebind_error}" + ))), + } + } + + fn media_type_rebind_required(current_cdrom: &str, config: &MsdLunConfig) -> bool { + current_cdrom != if config.cdrom { "1" } else { "0" } + } + + fn inquiry_string_path(lun_path: &Path, cdrom: bool) -> Option { + let cdrom_path = lun_path.join("inquiry_string_cdrom"); + if cdrom && cdrom_path.exists() { + return Some(cdrom_path); + } + let generic_path = lun_path.join("inquiry_string"); + generic_path.exists().then_some(generic_path) + } + + fn inquiry_string(&self, cdrom: bool) -> &str { + if cdrom { + &self.inquiry_strings.cdrom + } else { + &self.inquiry_strings.flash + } + } + + fn configure_lun_attributes( + &self, + lun_path: &Path, + lun: u8, + config: &MsdLunConfig, + ) -> Result<()> { let read_attr = |attr: &str| -> String { fs::read_to_string(lun_path.join(attr)) .unwrap_or_default() @@ -161,7 +273,6 @@ impl MsdFunction { let current_ro = read_attr("ro"); let current_removable = read_attr("removable"); let current_nofua = read_attr("nofua"); - let new_cdrom = if config.cdrom { "1" } else { "0" }; let new_ro = if config.ro { "1" } else { "0" }; let new_removable = if config.removable { "1" } else { "0" }; @@ -170,20 +281,26 @@ impl MsdFunction { let forced_eject_path = lun_path.join("forced_eject"); if forced_eject_path.exists() { debug!("Using forced_eject to clear LUN {}", lun); - let _ = write_file(&forced_eject_path, "1"); + if let Err(error) = write_file(&forced_eject_path, "1") { + warn!( + "LUN {} forced_eject failed while changing media: {}; clearing file instead", + lun, error + ); + write_file(&lun_path.join("file"), "")?; + } } else { - let _ = write_file(&lun_path.join("file"), ""); + write_file(&lun_path.join("file"), "")?; } std::thread::sleep(std::time::Duration::from_millis(50)); - let cdrom_changed = current_cdrom != new_cdrom; - if cdrom_changed { + if current_cdrom != new_cdrom { debug!( "Updating LUN {} cdrom: {} -> {}", lun, current_cdrom, new_cdrom ); write_file(&lun_path.join("cdrom"), new_cdrom)?; + self.write_inquiry_string(lun_path, config.cdrom)?; } if current_ro != new_ro { debug!("Updating LUN {} ro: {} -> {}", lun, current_ro, new_ro); @@ -204,11 +321,6 @@ impl MsdFunction { write_file(&lun_path.join("nofua"), new_nofua)?; } - if cdrom_changed { - debug!("CDROM mode changed, brief yield for USB host"); - std::thread::sleep(std::time::Duration::from_millis(50)); - } - if config.file.exists() { let file_path = config.file.to_string_lossy(); let mut last_error = None; @@ -225,10 +337,9 @@ impl MsdFunction { ); return Ok(()); } - Err(e) => { - let is_busy = e.to_string().contains("Device or resource busy") - || e.to_string().contains("os error 16"); - + Err(error) => { + let is_busy = error.to_string().contains("Device or resource busy") + || error.to_string().contains("os error 16"); if is_busy && attempt < 4 { warn!( "LUN {} file write busy, retrying (attempt {}/5)", @@ -236,17 +347,16 @@ impl MsdFunction { attempt + 1 ); std::thread::sleep(std::time::Duration::from_millis(50 << attempt)); - last_error = Some(e); + last_error = Some(error); continue; } - - return Err(e); + return Err(error); } } } - if let Some(e) = last_error { - return Err(e); + if let Some(error) = last_error { + return Err(error); } } else if !config.file.as_os_str().is_empty() { warn!("LUN {} file does not exist: {}", lun, config.file.display()); @@ -255,6 +365,26 @@ impl MsdFunction { Ok(()) } + fn write_inquiry_string(&self, lun_path: &Path, cdrom: bool) -> Result<()> { + if let Some(path) = Self::inquiry_string_path(lun_path, cdrom) { + write_file(&path, self.inquiry_string(cdrom))?; + } + Ok(()) + } + + fn write_inquiry_strings(&self, lun_path: &Path) -> Result<()> { + let generic_path = lun_path.join("inquiry_string"); + if generic_path.exists() { + write_file(&generic_path, &self.inquiry_strings.flash)?; + } + + let cdrom_path = lun_path.join("inquiry_string_cdrom"); + if cdrom_path.exists() { + write_file(&cdrom_path, &self.inquiry_strings.cdrom)?; + } + Ok(()) + } + pub async fn disconnect_lun_async(&self, gadget_path: &Path, lun: u8) -> Result<()> { let gadget_path = gadget_path.to_path_buf(); let this = self.clone(); @@ -276,6 +406,52 @@ impl MsdFunction { self.disconnect_lun_path(&lun_path, lun as u16) } + fn medium_removal_was_prevented(error: &std::io::Error) -> bool { + error.raw_os_error() == Some(libc::EBUSY) + } + + fn clear_lun_file(file_path: &Path, lun: u16) -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .open(file_path) + .map_err(|error| { + warn!( + lun, + path = %file_path.display(), + %error, + "Failed to open MSD LUN backing-file attribute while disconnecting" + ); + AppError::from(MsdErrorCode::MsdDisconnectFailed) + })?; + + // An empty configfs value is represented by a newline. Keep this as one + // write operation so EBUSY can be attributed to fsg_store_file(). + if let Err(error) = file.write_all(b"\n") { + warn!( + lun, + path = %file_path.display(), + errno = error.raw_os_error(), + %error, + "Kernel rejected MSD LUN disconnect" + ); + return if Self::medium_removal_was_prevented(&error) { + Err(MsdErrorCode::MsdMediumRemovalPrevented.into()) + } else { + Err(MsdErrorCode::MsdDisconnectFailed.into()) + }; + } + + file.flush().map_err(|error| { + warn!( + lun, + path = %file_path.display(), + %error, + "Failed to flush MSD LUN backing-file attribute while disconnecting" + ); + MsdErrorCode::MsdDisconnectFailed.into() + }) + } + fn disconnect_lun_path(&self, lun_path: &Path, lun: u16) -> Result<()> { if lun_path.exists() { let forced_eject_path = lun_path.join("forced_eject"); @@ -293,14 +469,14 @@ impl MsdFunction { ); let file_path = lun_path.join("file"); if file_path.exists() { - write_file(&file_path, "")?; + Self::clear_lun_file(&file_path, lun)?; } } } } else { let file_path = lun_path.join("file"); if file_path.exists() { - write_file(&file_path, "")?; + Self::clear_lun_file(&file_path, lun)?; } } info!("LUN {} disconnected", lun); @@ -344,6 +520,7 @@ impl GadgetFunction for MsdFunction { for lun in 0..self.lun_capacity { self.clear_lun_unbound(gadget_path, lun)?; + self.write_inquiry_strings(&self.lun_path(gadget_path, lun))?; } debug!("Created MSD function: {}", self.name()); @@ -415,6 +592,10 @@ mod tests { use super::*; use tempfile::TempDir; + fn test_msd(capacity: u8) -> MsdFunction { + MsdFunction::new(0, capacity, MsdInquiryStrings::default()).unwrap() + } + #[test] fn test_lun_config_cdrom() { let config = MsdLunConfig::cdrom(PathBuf::from("/tmp/test.iso")); @@ -431,21 +612,154 @@ mod tests { assert!(config.removable); } + #[test] + fn inquiry_string_uses_cdrom_attribute_with_stock_fallback() { + let msd = MsdFunction::new( + 0, + 1, + MsdInquiryStrings { + flash: "Custom Flash".into(), + cdrom: "Custom Optical".into(), + }, + ) + .unwrap(); + let patched = TempDir::new().unwrap(); + std::fs::write(patched.path().join("inquiry_string"), b"generic\n").unwrap(); + std::fs::write(patched.path().join("inquiry_string_cdrom"), b"cdrom\n").unwrap(); + + msd.write_inquiry_strings(patched.path()).unwrap(); + + assert_eq!( + std::fs::read_to_string(patched.path().join("inquiry_string_cdrom")) + .unwrap() + .trim(), + "Custom Optical" + ); + assert_eq!( + std::fs::read_to_string(patched.path().join("inquiry_string")) + .unwrap() + .trim(), + "Custom Flash" + ); + + let stock = TempDir::new().unwrap(); + std::fs::write(stock.path().join("inquiry_string"), b"generic\n").unwrap(); + msd.write_inquiry_string(stock.path(), true).unwrap(); + assert_eq!( + std::fs::read_to_string(stock.path().join("inquiry_string")) + .unwrap() + .trim(), + "Custom Optical" + ); + } + #[test] fn test_msd_function_name() { - let msd = MsdFunction::new(0, 1).unwrap(); + let msd = test_msd(1); assert_eq!(msd.name(), "mass_storage.usb0"); assert_eq!(msd.lun_capacity, 1); - let multi = MsdFunction::new(0, 8).unwrap(); + let multi = test_msd(8); assert_eq!(multi.lun_capacity, 8); } #[test] fn test_msd_function_rejects_invalid_capacity() { - assert!(MsdFunction::new(0, 0).is_err()); - assert!(MsdFunction::new(0, 2).is_err()); - assert!(MsdFunction::new(0, 9).is_err()); + assert!(MsdFunction::new(0, 0, MsdInquiryStrings::default()).is_err()); + assert!(MsdFunction::new(0, 2, MsdInquiryStrings::default()).is_err()); + assert!(MsdFunction::new(0, 9, MsdInquiryStrings::default()).is_err()); + } + + #[test] + fn only_ebusy_means_the_host_prevented_medium_removal() { + let busy = std::io::Error::from_raw_os_error(libc::EBUSY); + let io = std::io::Error::from_raw_os_error(libc::EIO); + + assert!(MsdFunction::medium_removal_was_prevented(&busy)); + assert!(!MsdFunction::medium_removal_was_prevented(&io)); + } + + #[test] + fn disconnect_lun_prefers_forced_eject() { + let temp_dir = TempDir::new().unwrap(); + let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0"); + std::fs::create_dir_all(&lun_path).unwrap(); + std::fs::write(lun_path.join("file"), b"backing.img\n").unwrap(); + std::fs::write(lun_path.join("forced_eject"), b"0\n").unwrap(); + let msd = test_msd(1); + + msd.disconnect_lun(temp_dir.path(), 0).unwrap(); + + assert_eq!( + std::fs::read(lun_path.join("forced_eject")).unwrap(), + b"1\n" + ); + assert_eq!( + std::fs::read(lun_path.join("file")).unwrap(), + b"backing.img\n" + ); + } + + #[test] + fn disconnect_lun_without_forced_eject_clears_file() { + let temp_dir = TempDir::new().unwrap(); + let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0"); + std::fs::create_dir_all(&lun_path).unwrap(); + std::fs::write(lun_path.join("file"), b"backing.img\n").unwrap(); + let msd = test_msd(1); + + msd.disconnect_lun(temp_dir.path(), 0).unwrap(); + + assert!(std::fs::read(lun_path.join("file")) + .unwrap() + .starts_with(b"\n")); + } + + #[test] + fn disconnect_lun_falls_back_when_forced_eject_write_fails() { + let temp_dir = TempDir::new().unwrap(); + let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0"); + std::fs::create_dir_all(lun_path.join("forced_eject")).unwrap(); + std::fs::write(lun_path.join("file"), b"backing.img\n").unwrap(); + let msd = test_msd(1); + + msd.disconnect_lun(temp_dir.path(), 0).unwrap(); + + assert!(std::fs::read(lun_path.join("file")) + .unwrap() + .starts_with(b"\n")); + } + + #[test] + fn disconnect_lun_only_changes_the_selected_lun() { + let temp_dir = TempDir::new().unwrap(); + let function_path = temp_dir.path().join("functions/mass_storage.usb0"); + for lun in 0..2 { + let lun_path = function_path.join(format!("lun.{lun}")); + std::fs::create_dir_all(&lun_path).unwrap(); + std::fs::write(lun_path.join("file"), format!("backing-{lun}.img\n")).unwrap(); + std::fs::write(lun_path.join("forced_eject"), b"0\n").unwrap(); + } + let msd = test_msd(8); + + msd.disconnect_lun(temp_dir.path(), 1).unwrap(); + + assert_eq!( + std::fs::read(function_path.join("lun.0/forced_eject")).unwrap(), + b"0\n" + ); + assert_eq!( + std::fs::read(function_path.join("lun.1/forced_eject")).unwrap(), + b"1\n" + ); + assert_eq!( + std::fs::read(function_path.join("lun.0/file")).unwrap(), + b"backing-0.img\n" + ); + assert_eq!( + std::fs::read(function_path.join("lun.1/file")).unwrap(), + b"backing-1.img\n" + ); } #[test] @@ -453,7 +767,7 @@ mod tests { for capacity in [1, 8] { let temp_dir = TempDir::new().unwrap(); std::fs::create_dir_all(temp_dir.path().join("functions")).unwrap(); - let msd = MsdFunction::new(0, capacity).unwrap(); + let msd = test_msd(capacity); msd.create(temp_dir.path()).unwrap(); @@ -475,7 +789,7 @@ mod tests { std::fs::write(temp_dir.path().join("UDC"), b"test.udc\n").unwrap(); let image_path = temp_dir.path().join("test.img"); std::fs::write(&image_path, b"image").unwrap(); - let msd = MsdFunction::new(0, 1).unwrap(); + let msd = test_msd(1); msd.configure_lun(temp_dir.path(), 0, &MsdLunConfig::disk(image_path, false)) .unwrap(); @@ -486,6 +800,81 @@ mod tests { ); } + #[test] + fn media_type_changes_require_udc_rebind() { + let iso = MsdLunConfig::cdrom(PathBuf::from("/tmp/test.iso")); + let disk = MsdLunConfig::disk(PathBuf::from("/tmp/test.img"), false); + + assert!(MsdFunction::media_type_rebind_required("0", &iso)); + assert!(!MsdFunction::media_type_rebind_required("1", &iso)); + assert!(MsdFunction::media_type_rebind_required("1", &disk)); + assert!(!MsdFunction::media_type_rebind_required("0", &disk)); + } + + #[test] + fn configure_cdrom_restores_bound_udc() { + let temp_dir = TempDir::new().unwrap(); + let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0"); + std::fs::create_dir_all(&lun_path).unwrap(); + for attr in ["file", "cdrom", "ro", "removable", "nofua"] { + std::fs::write(lun_path.join(attr), b"0\n").unwrap(); + } + std::fs::write(temp_dir.path().join("UDC"), b"test.udc\n").unwrap(); + let image_path = temp_dir.path().join("test.iso"); + std::fs::write(&image_path, b"iso").unwrap(); + let msd = test_msd(1); + + msd.configure_lun(temp_dir.path(), 0, &MsdLunConfig::cdrom(image_path.clone())) + .unwrap(); + + assert_eq!( + std::fs::read_to_string(temp_dir.path().join("UDC")) + .unwrap() + .trim(), + "test.udc" + ); + assert_eq!( + std::fs::read_to_string(lun_path.join("cdrom")) + .unwrap() + .trim(), + "1" + ); + assert_eq!( + std::fs::read_to_string(lun_path.join("ro")).unwrap().trim(), + "1" + ); + assert_eq!( + std::fs::read_to_string(lun_path.join("file")) + .unwrap() + .trim(), + image_path.to_string_lossy() + ); + } + + #[test] + fn configure_failure_still_restores_bound_udc() { + let temp_dir = TempDir::new().unwrap(); + let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0"); + std::fs::create_dir_all(lun_path.join("file")).unwrap(); + for attr in ["cdrom", "ro", "removable", "nofua"] { + std::fs::write(lun_path.join(attr), b"0\n").unwrap(); + } + std::fs::write(temp_dir.path().join("UDC"), b"test.udc\n").unwrap(); + let image_path = temp_dir.path().join("test.iso"); + std::fs::write(&image_path, b"iso").unwrap(); + let msd = test_msd(1); + + assert!(msd + .configure_lun(temp_dir.path(), 0, &MsdLunConfig::cdrom(image_path),) + .is_err()); + assert_eq!( + std::fs::read_to_string(temp_dir.path().join("UDC")) + .unwrap() + .trim(), + "test.udc" + ); + } + #[test] fn cleanup_removes_all_dynamic_luns_including_stale_capacity() { let temp_dir = TempDir::new().unwrap(); @@ -493,13 +882,37 @@ mod tests { for lun in 1..8 { std::fs::create_dir_all(func_path.join(format!("lun.{lun}"))).unwrap(); } - let msd = MsdFunction::new(0, 1).unwrap(); + let msd = test_msd(1); msd.cleanup(temp_dir.path()).unwrap(); assert!(!func_path.exists()); } + #[test] + fn cleanup_forced_ejects_every_existing_lun() { + let temp_dir = TempDir::new().unwrap(); + let func_path = temp_dir.path().join("functions/mass_storage.usb0"); + for lun in 0..3 { + let lun_path = func_path.join(format!("lun.{lun}")); + std::fs::create_dir_all(&lun_path).unwrap(); + std::fs::write(lun_path.join("file"), format!("backing-{lun}.img\n")).unwrap(); + std::fs::write(lun_path.join("forced_eject"), b"0\n").unwrap(); + } + let msd = test_msd(1); + + // Ordinary files do not disappear with configfs groups, so cleanup is + // expected to report directory-removal failures in this test fixture. + assert!(msd.cleanup(temp_dir.path()).is_err()); + + for lun in 0..3 { + assert_eq!( + std::fs::read(func_path.join(format!("lun.{lun}/forced_eject"))).unwrap(), + b"1\n" + ); + } + } + #[test] fn cleanup_reports_when_non_configfs_cannot_release_default_lun() { let temp_dir = TempDir::new().unwrap(); @@ -507,7 +920,7 @@ mod tests { for lun in 0..2 { std::fs::create_dir_all(func_path.join(format!("lun.{lun}"))).unwrap(); } - let msd = MsdFunction::new(0, 1).unwrap(); + let msd = test_msd(1); let error = msd.cleanup(temp_dir.path()).unwrap_err(); diff --git a/src/otg/service.rs b/src/otg/service.rs index 22e3224c..29d081c5 100644 --- a/src/otg/service.rs +++ b/src/otg/service.rs @@ -6,42 +6,13 @@ use typeshare::typeshare; use super::bridge::NetworkBridgeRuntime; use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager}; -use super::msd::MsdFunction; +use super::msd::{MsdFunction, MsdInquiryStrings, MsdLunConfig}; use crate::config::{ HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig, + UacConfig, }; use crate::error::{AppError, Result}; -/// Configuration for the USB Audio Class (UAC) gadget function. -#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] -#[serde(default)] -pub struct UacConfig { - /// Enable the virtual USB microphone. - pub enabled: bool, - /// Sample rate in Hz (e.g. 48000). - pub sample_rate: u32, - /// Number of channels (1=mono, 2=stereo). - pub channels: u8, -} - -impl UacConfig { - pub fn validate(&self) -> Result<()> { - if self.sample_rate != 0 && (self.sample_rate < 8000 || self.sample_rate > 384000) { - return Err(AppError::BadRequest(format!( - "UAC sample rate {} out of range (8000-384000)", - self.sample_rate - ))); - } - if self.channels != 0 && self.channels > 8 { - return Err(AppError::BadRequest(format!( - "UAC channel count {} out of range (1-8)", - self.channels - ))); - } - Ok(()) - } -} - #[derive(Debug, Clone, Default)] pub struct HidDevicePaths { pub keyboard: Option, @@ -91,8 +62,9 @@ pub(crate) struct OtgDesiredState { pub keyboard_leds: bool, pub msd_enabled: bool, pub msd_lun_capacity: u8, + pub msd_inquiry_strings: MsdInquiryStrings, pub network: OtgNetworkConfig, - pub uac_enabled: bool, + pub uac: UacConfig, } impl Default for OtgDesiredState { @@ -104,8 +76,9 @@ impl Default for OtgDesiredState { keyboard_leds: false, msd_enabled: false, msd_lun_capacity: 1, + msd_inquiry_strings: MsdInquiryStrings::default(), network: OtgNetworkConfig::default(), - uac_enabled: false, + uac: UacConfig::default(), } } } @@ -119,6 +92,7 @@ impl OtgDesiredState { ) -> Result { network.validate()?; uac.validate()?; + msd.validate()?; let hid_functions = if hid.backend == HidBackend::Otg { let functions = hid.constrained_otg_functions(); Some(functions) @@ -127,8 +101,7 @@ impl OtgDesiredState { }; hid.validate_otg_functions()?; - let needs_udc = - hid_functions.is_some() || msd.enabled || network.enabled || uac.enabled; + let needs_udc = hid_functions.is_some() || msd.enabled || network.enabled || uac.enabled; let udc = if needs_udc { hid.otg_udc .as_ref() @@ -145,8 +118,13 @@ impl OtgDesiredState { keyboard_leds: hid.effective_otg_keyboard_leds(), msd_enabled: msd.enabled, msd_lun_capacity: 1, + msd_inquiry_strings: MsdInquiryStrings::from(msd), network: network.clone(), - uac_enabled: uac.enabled, + uac: if uac.enabled { + uac.clone() + } else { + UacConfig::default() + }, }) } @@ -168,8 +146,9 @@ struct OtgServiceState { pub hid_enabled: bool, pub msd_enabled: bool, pub msd_lun_capacity: u8, + pub msd_inquiry_strings: MsdInquiryStrings, pub network: OtgNetworkConfig, - pub uac_enabled: bool, + pub uac: UacConfig, pub configured_udc: Option, pub hid_paths: Option, pub hid_functions: Option, @@ -186,8 +165,9 @@ impl Default for OtgServiceState { hid_enabled: false, msd_enabled: false, msd_lun_capacity: 1, + msd_inquiry_strings: MsdInquiryStrings::default(), network: OtgNetworkConfig::default(), - uac_enabled: false, + uac: UacConfig::default(), configured_udc: None, hid_paths: None, hid_functions: None, @@ -240,6 +220,27 @@ impl OtgService { self.desired.read().await.msd_lun_capacity } + pub async fn configure_msd_lun(&self, lun: u8, config: &MsdLunConfig) -> Result<()> { + // Keep the manager locked across a possible UDC rebind so an OTG + // reconcile cannot replace the gadget halfway through the media-type + // transition. + let manager = self.manager.lock().await; + let gadget_path = manager + .as_ref() + .map(|value| value.gadget_path().clone()) + .ok_or_else(|| AppError::Internal("OTG gadget is not active".to_string()))?; + let function = self + .msd_function + .read() + .await + .clone() + .ok_or_else(|| AppError::Internal("MSD function is not active".to_string()))?; + + function + .configure_lun_async(&gadget_path, lun, config) + .await + } + pub async fn network_status(&self) -> OtgNetworkStatus { let state = self.state.read().await; OtgNetworkStatus { @@ -349,7 +350,7 @@ impl OtgService { desired.hid_enabled(), desired.msd_enabled, desired.network_enabled(), - desired.uac_enabled, + desired.uac.enabled, desired.udc ); @@ -360,8 +361,9 @@ impl OtgService { && state.hid_enabled == desired.hid_enabled() && state.msd_enabled == desired.msd_enabled && state.msd_lun_capacity == desired.msd_lun_capacity + && state.msd_inquiry_strings == desired.msd_inquiry_strings && state.network == desired.network - && state.uac_enabled == desired.uac_enabled + && state.uac == desired.uac && state.configured_udc == desired.udc && state.hid_functions == desired.hid_functions && state.keyboard_leds_enabled == desired.keyboard_leds @@ -400,8 +402,9 @@ impl OtgService { state.hid_enabled = false; state.msd_enabled = false; state.msd_lun_capacity = 1; + state.msd_inquiry_strings = MsdInquiryStrings::default(); state.network = OtgNetworkConfig::default(); - state.uac_enabled = false; + state.uac = UacConfig::default(); state.configured_udc = None; state.hid_paths = None; state.hid_functions = None; @@ -410,7 +413,11 @@ impl OtgService { state.error = None; } - if !desired.hid_enabled() && !desired.msd_enabled && !desired.network_enabled() { + if !desired.hid_enabled() + && !desired.msd_enabled + && !desired.network_enabled() + && !desired.uac.enabled + { info!("OTG desired state is empty, gadget removed"); return Ok(()); } @@ -440,12 +447,12 @@ impl OtgService { // lower hardware endpoint number. DWC3 seems to have // trouble with isochronous transfers on higher-numbered // endpoints when they follow interrupt endpoints. - let _uac_func = if desired.uac_enabled { - let sample_rate: u32 = 48000; - let channels: u8 = 2; - Some(manager.add_uac(sample_rate, channels).map_err(|e| { - AppError::Internal(format!("Failed to add UAC function: {e}")) - })?) + let _uac_func = if desired.uac.enabled { + Some( + manager + .add_uac(desired.uac.sample_rate, desired.uac.channels) + .map_err(|e| AppError::Internal(format!("Failed to add UAC function: {e}")))?, + ) } else { None }; @@ -506,7 +513,10 @@ impl OtgService { } let msd_func = if desired.msd_enabled { - match manager.add_msd(desired.msd_lun_capacity) { + match manager.add_msd( + desired.msd_lun_capacity, + desired.msd_inquiry_strings.clone(), + ) { Ok(func) => { debug!("MSD function added to gadget"); Some(func) @@ -593,8 +603,9 @@ impl OtgService { state.hid_enabled = desired.hid_enabled(); state.msd_enabled = desired.msd_enabled; state.msd_lun_capacity = desired.msd_lun_capacity; + state.msd_inquiry_strings = desired.msd_inquiry_strings.clone(); state.network = desired.network.clone(); - state.uac_enabled = desired.uac_enabled; + state.uac = desired.uac.clone(); state.configured_udc = Some(udc); state.hid_paths = hid_paths; state.hid_functions = desired.hid_functions; @@ -716,6 +727,14 @@ mod tests { assert_ne!(single, multi); } + #[test] + fn inquiry_strings_participate_in_desired_state_equality() { + let original = OtgDesiredState::default(); + let mut changed = original.clone(); + changed.msd_inquiry_strings.flash = "Custom Flash".to_string(); + assert_ne!(original, changed); + } + #[test] fn onecloud_full_composite_is_not_rejected_before_configfs() { let hid = HidConfig { @@ -732,7 +751,8 @@ mod tests { ..OtgNetworkConfig::default() }; - let desired = OtgDesiredState::from_config(&hid, &msd, &network).unwrap(); + let desired = + OtgDesiredState::from_config(&hid, &msd, &network, &UacConfig::default()).unwrap(); assert_eq!(desired.udc.as_deref(), Some("c9040000.usb")); assert_eq!(desired.hid_functions, Some(OtgHidFunctions::full())); diff --git a/src/otg/uac.rs b/src/otg/uac.rs index 5b60de13..b64b51a0 100644 --- a/src/otg/uac.rs +++ b/src/otg/uac.rs @@ -6,7 +6,7 @@ use super::configfs::{create_dir, create_symlink, remove_dir, write_file}; use super::function::GadgetFunction; use crate::error::{AppError, Result}; -/// USB Audio Class 2.0 (UAC1) gadget function. +/// USB Audio Class 1.0 gadget function. /// /// Creates a virtual USB microphone that the USB host sees as a standard /// USB audio input device. Audio written to the PCM playback device on the @@ -60,13 +60,18 @@ impl GadgetFunction for UacFunction { let chmask: u32 = (1u32 << self.channels) - 1; write_file(&func_path.join("p_chmask"), &chmask.to_string())?; write_file(&func_path.join("p_srate"), &self.sample_rate.to_string())?; - write_file(&func_path.join("p_ssize"), "2")?; // 16-bit S16LE + // 16-bit S16LE. + write_file(&func_path.join("p_ssize"), "2")?; + // One decibel per step. The kernel default is 1/256 dB, which creates + // 25,600 control values and triggers a UAC volume-range warning. + write_file(&func_path.join("p_volume_res"), "256")?; // UAC1 does not need p_hs_bint — Windows has native built-in // UAC1 drivers and handles isochronous streaming automatically. // Only enable playback direction (gadget → host = mic). // Disabling capture saves one isochronous endpoint. write_file(&func_path.join("c_chmask"), "0")?; + write_file(&func_path.join("c_volume_present"), "0")?; // req_number=4: explicitly allocate 4 USB requests for the // isochronous endpoint. Default (0 = auto) may not be enough diff --git a/src/redfish/auth.rs b/src/redfish/auth.rs index fc4489fc..1b86ce6a 100644 --- a/src/redfish/auth.rs +++ b/src/redfish/auth.rs @@ -88,10 +88,7 @@ mod tests { #[test] fn only_service_discovery_and_session_creation_are_public() { assert!(is_redfish_public_endpoint("/v1/", &Method::GET)); - assert!(is_redfish_public_endpoint( - "/v1/$metadata", - &Method::GET - )); + assert!(is_redfish_public_endpoint("/v1/$metadata", &Method::GET)); assert!(is_redfish_public_endpoint( "/v1/SessionService/Sessions", &Method::POST diff --git a/src/redfish/routes/virtual_media.rs b/src/redfish/routes/virtual_media.rs index b7e03488..f6ecfb33 100644 --- a/src/redfish/routes/virtual_media.rs +++ b/src/redfish/routes/virtual_media.rs @@ -9,8 +9,8 @@ use std::sync::Arc; use tracing::{info, warn}; use super::super::schema::*; -use super::{empty_collection, resource_not_found, service_unavailable, validate_id}; -use crate::error::AppError; +use super::{empty_collection, resource_not_found, validate_id}; +use crate::error::{AppError, MsdErrorCode}; use crate::msd::{ImageInfo, ImageManager, MountedMedia, MountedMediaKind}; use crate::state::AppState; @@ -46,7 +46,7 @@ async fn virtual_media_collection( let capacity = { let guard = state.msd.read().await; let Some(msd) = guard.as_ref() else { - return service_unavailable("MSD not available"); + return msd_error_response(MsdErrorCode::MsdUnavailable); }; msd.state().await.disk_mode.capacity() }; @@ -81,7 +81,7 @@ async fn virtual_media_detail( let (msd_state, lun) = { let guard = state.msd.read().await; let Some(msd) = guard.as_ref() else { - return service_unavailable("MSD not available"); + return msd_error_response(MsdErrorCode::MsdUnavailable); }; let msd_state = msd.state().await; let Some(lun) = parse_slot_id(&media_id, msd_state.disk_mode.capacity()) else { @@ -164,17 +164,14 @@ async fn virtual_media_insert( let lun = { let guard = state.msd.read().await; let Some(msd) = guard.as_ref() else { - return service_unavailable("MSD not available"); + return msd_error_response(MsdErrorCode::MsdUnavailable); }; let msd_state = msd.state().await; let Some(lun) = parse_slot_id(&media_id, msd_state.disk_mode.capacity()) else { return resource_not_found(); }; if msd_state.mounted_media.iter().any(|media| media.lun == lun) { - return redfish_error( - StatusCode::CONFLICT, - "Virtual media slot is already occupied", - ); + return msd_error_response(MsdErrorCode::MsdMediaSlotsFull); } lun }; @@ -194,7 +191,7 @@ async fn virtual_media_insert( let result = { let guard = state.msd.read().await; let Some(msd) = guard.as_ref() else { - return service_unavailable("MSD not available"); + return msd_error_response(MsdErrorCode::MsdUnavailable); }; msd.mount_image_at_lun(&image, cdrom, read_only, lun).await }; @@ -222,7 +219,7 @@ async fn virtual_media_eject( let lun = { let guard = state.msd.read().await; let Some(msd) = guard.as_ref() else { - return service_unavailable("MSD not available"); + return msd_error_response(MsdErrorCode::MsdUnavailable); }; let capacity = msd.state().await.disk_mode.capacity(); let Some(lun) = parse_slot_id(&media_id, capacity) else { @@ -234,7 +231,7 @@ async fn virtual_media_eject( let result = { let guard = state.msd.read().await; let Some(msd) = guard.as_ref() else { - return service_unavailable("MSD not available"); + return msd_error_response(MsdErrorCode::MsdUnavailable); }; msd.unmount_lun(lun).await }; @@ -352,6 +349,9 @@ async fn resolve_image( } fn app_error_response(error: AppError) -> Response { + if let AppError::Msd(error) = error { + return msd_error_response(error.code()); + } let status = match &error { AppError::BadRequest(_) => StatusCode::BAD_REQUEST, AppError::NotFound(_) => StatusCode::NOT_FOUND, @@ -361,6 +361,35 @@ fn app_error_response(error: AppError) -> Response { redfish_error(status, &error.to_string()) } +fn msd_error_response(code: MsdErrorCode) -> Response { + use MsdErrorCode::*; + let status = match code { + MsdUnavailable => StatusCode::SERVICE_UNAVAILABLE, + MsdResourceNotFound | MsdDriveNotInitialized => StatusCode::NOT_FOUND, + MsdOperationInProgress + | MsdResourceAlreadyExists + | MsdMediaSlotsFull + | MsdMediaAlreadyMounted + | MsdMediaInUse + | MsdDriveConnected + | MsdMediumRemovalPrevented => StatusCode::CONFLICT, + MsdInvalidRequest + | MsdImageTooLarge + | MsdInvalidUrl + | MsdDriveFilesystemUnsupported + | MsdDriveSizeInvalid + | MsdStorageSpaceUnavailable + | MsdStorageFull + | MsdStorageReadOnly + | MsdStoragePermissionDenied => StatusCode::BAD_REQUEST, + MsdOperationFailed + | MsdRemoteDownloadFailed + | MsdDownloadIncomplete + | MsdDisconnectFailed => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, Json(RedfishError::msd(code))).into_response() +} + fn redfish_error(status: StatusCode, message: &str) -> Response { (status, Json(RedfishError::general_error(message))).into_response() } @@ -421,4 +450,45 @@ mod tests { not_inserted.inserted = Some(false); assert!(validate_insert_request(¬_inserted).is_err()); } + + #[test] + fn msd_redfish_errors_use_the_one_kvm_registry_shape() { + for code in MsdErrorCode::ALL { + let body = RedfishError::msd(code); + let expected = format!("OneKVM.1.0.{}", code.redfish_key()); + assert_eq!(body.error.code, expected); + assert_eq!(body.error.message, code.message()); + assert_eq!(body.error.extended_info.len(), 1); + let info = &body.error.extended_info[0]; + assert_eq!(info.message_id, expected); + assert_eq!(info.message, code.message()); + assert_eq!(info.severity, code.severity()); + assert_eq!(info.resolution, code.resolution()); + } + } + + #[tokio::test] + async fn msd_and_validation_errors_keep_separate_redfish_registries() { + let response = msd_error_response(MsdErrorCode::MsdStoragePermissionDenied); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + json["error"]["code"], + "OneKVM.1.0.MsdStoragePermissionDenied" + ); + assert_eq!( + json["error"]["@Message.ExtendedInfo"][0]["MessageId"], + "OneKVM.1.0.MsdStoragePermissionDenied" + ); + + let validation = app_error_response(AppError::BadRequest("invalid property".into())); + let body = axum::body::to_bytes(validation.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"]["code"], "Base.1.18.GeneralError"); + } } diff --git a/src/redfish/schema.rs b/src/redfish/schema.rs index 7663a4f1..fb8d1840 100644 --- a/src/redfish/schema.rs +++ b/src/redfish/schema.rs @@ -1,3 +1,4 @@ +use crate::error::MsdErrorCode; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -546,6 +547,23 @@ pub struct RedfishExtendedInfo { } impl RedfishError { + pub fn msd(code: MsdErrorCode) -> Self { + let message_id = format!("OneKVM.1.0.{}", code.redfish_key()); + Self { + error: RedfishErrorBody { + code: message_id.clone(), + message: code.message().to_string(), + extended_info: vec![RedfishExtendedInfo { + odata_type: "#Message.v1_2_1.Message".to_string(), + message_id, + message: code.message().to_string(), + severity: code.severity().to_string(), + resolution: code.resolution().to_string(), + }], + }, + } + } + pub fn general_error(message: &str) -> Self { Self { error: RedfishErrorBody { diff --git a/src/rustdesk/mod.rs b/src/rustdesk/mod.rs index cd17cef0..b8be700b 100644 --- a/src/rustdesk/mod.rs +++ b/src/rustdesk/mod.rs @@ -276,7 +276,6 @@ impl RustDeskService { }, )); - let status = self.status.clone(); let handle = tokio::spawn(async move { loop { match mediator.start().await { @@ -286,9 +285,7 @@ impl RustDeskService { } Err(e) => { error!("Rendezvous mediator error: {}", e); - *status.write() = ServiceStatus::Error(e.to_string()); tokio::time::sleep(std::time::Duration::from_secs(5)).await; - *status.write() = ServiceStatus::Starting; } } } diff --git a/src/rustdesk/rendezvous.rs b/src/rustdesk/rendezvous.rs index ceb03c37..6147c535 100644 --- a/src/rustdesk/rendezvous.rs +++ b/src/rustdesk/rendezvous.rs @@ -20,11 +20,13 @@ use super::protocol::{ rendezvous_message, NatType, RendezvousMessage, }; -const REG_INTERVAL_MS: u64 = 12_000; +const REG_INTERVAL: Duration = Duration::from_secs(12); -const MIN_REG_TIMEOUT_MS: u64 = 3_000; +const MIN_REG_TIMEOUT: Duration = Duration::from_secs(3); -const MAX_REG_TIMEOUT_MS: u64 = 30_000; +const MAX_REG_TIMEOUT: Duration = Duration::from_secs(30); + +const OFFLINE_AFTER_TIMEOUTS: u32 = 4; const TIMER_INTERVAL_MS: u64 = 300; @@ -32,7 +34,6 @@ const TIMER_INTERVAL_MS: u64 = 300; pub enum RendezvousStatus { Disconnected, Connecting, - Connected, Registered, Error(String), } @@ -42,13 +43,86 @@ impl std::fmt::Display for RendezvousStatus { match self { Self::Disconnected => write!(f, "disconnected"), Self::Connecting => write!(f, "connecting"), - Self::Connected => write!(f, "connected"), Self::Registered => write!(f, "registered"), Self::Error(e) => write!(f, "error: {}", e), } } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RegistrationDecision { + Wait, + Send, + Retry { consecutive_timeouts: u32 }, +} + +/// Tracks the request/response lifecycle for HBBS registration. +/// +/// UDP `connect` only selects a peer; it does not prove reachability. Registration +/// health is therefore derived exclusively from acknowledged registration requests. +#[derive(Debug)] +struct RegistrationTracker { + last_sent: Option, + last_response: Option, + response_timeout: Duration, + consecutive_timeouts: u32, +} + +impl RegistrationTracker { + fn new() -> Self { + Self { + last_sent: None, + last_response: None, + response_timeout: MIN_REG_TIMEOUT, + consecutive_timeouts: 0, + } + } + + fn poll(&mut self, now: Instant) -> RegistrationDecision { + if let Some(sent_at) = self.last_sent { + if now.saturating_duration_since(sent_at) < self.response_timeout { + return RegistrationDecision::Wait; + } + + self.consecutive_timeouts = self.consecutive_timeouts.saturating_add(1); + self.response_timeout = (self.response_timeout + MIN_REG_TIMEOUT).min(MAX_REG_TIMEOUT); + return RegistrationDecision::Retry { + consecutive_timeouts: self.consecutive_timeouts, + }; + } + + let registration_expired = self + .last_response + .map(|response_at| now.saturating_duration_since(response_at) >= REG_INTERVAL) + .unwrap_or(true); + + if registration_expired { + RegistrationDecision::Send + } else { + RegistrationDecision::Wait + } + } + + fn mark_sent(&mut self, now: Instant) { + self.last_sent = Some(now); + } + + fn mark_response(&mut self, now: Instant) { + self.last_sent = None; + self.last_response = Some(now); + self.response_timeout = MIN_REG_TIMEOUT; + self.consecutive_timeouts = 0; + } + + fn status_after_timeout(consecutive_timeouts: u32) -> RendezvousStatus { + if consecutive_timeouts >= OFFLINE_AFTER_TIMEOUTS { + RendezvousStatus::Disconnected + } else { + RendezvousStatus::Connecting + } + } +} + pub type RelayCallback = Arc, String) + Send + Sync>; pub type PunchCallback = @@ -147,6 +221,14 @@ impl RendezvousMediator { self.status.read().clone() } + fn set_status(&self, next: RendezvousStatus) { + let mut current = self.status.write(); + if *current != next { + info!("Rendezvous status changed: {} -> {}", *current, next); + *current = next; + } + } + pub fn update_config(&self, config: RustDeskConfig) { *self.config.write() = config; self.increment_serial(); @@ -209,11 +291,21 @@ impl RendezvousMediator { "Rendezvous mediator not starting: enabled={}, server='{}'", config.enabled, effective_server ); + self.set_status(RendezvousStatus::Disconnected); return Ok(()); } - *self.status.write() = RendezvousStatus::Connecting; + self.set_status(RendezvousStatus::Connecting); + let result = self.run(config).await; + match &result { + Ok(()) => self.set_status(RendezvousStatus::Disconnected), + Err(err) => self.set_status(RendezvousStatus::Error(err.to_string())), + } + result + } + + async fn run(&self, config: RustDeskConfig) -> anyhow::Result<()> { let addr = config.rendezvous_addr(); info!( "Starting rendezvous mediator for {} to {}", @@ -233,8 +325,7 @@ impl RendezvousMediator { let socket = UdpSocket::from_std(std_socket)?; socket.connect(server_addr).await?; - info!("Connected to rendezvous server at {}", server_addr); - *self.status.write() = RendezvousStatus::Connected; + info!("RustDesk UDP transport ready for {}", server_addr); self.registration_loop(socket).await } @@ -242,10 +333,7 @@ impl RendezvousMediator { async fn registration_loop(&self, socket: UdpSocket) -> anyhow::Result<()> { let mut timer = interval(Duration::from_millis(TIMER_INTERVAL_MS)); let mut recv_buf = vec![0u8; 65535]; - let mut last_register_sent: Option = None; - let mut last_register_resp: Option = None; - let mut reg_timeout = MIN_REG_TIMEOUT_MS; - let mut fails = 0; + let mut registration = RegistrationTracker::new(); let mut shutdown_rx = self.shutdown_tx.subscribe(); loop { @@ -254,39 +342,36 @@ impl RendezvousMediator { match result { Ok(len) => { if let Ok(msg) = decode_rendezvous_message(&recv_buf[..len]) { - self.handle_response(&socket, msg, &mut last_register_resp, &mut fails, &mut reg_timeout).await?; + self.handle_response(&socket, msg, &mut registration).await?; } else { debug!("Failed to decode rendezvous message"); } } Err(e) => { - error!("Failed to receive from socket: {}", e); - *self.status.write() = RendezvousStatus::Error(e.to_string()); - break; + return Err(anyhow::anyhow!("Failed to receive from socket: {}", e)); } } } _ = timer.tick() => { let now = Instant::now(); - let expired = last_register_resp - .map(|x| x.elapsed().as_millis() as u64 >= REG_INTERVAL_MS) - .unwrap_or(true); - let timeout = last_register_sent - .map(|x| x.elapsed().as_millis() as u64 >= reg_timeout) - .unwrap_or(false); - - if timeout && reg_timeout < MAX_REG_TIMEOUT_MS { - reg_timeout += MIN_REG_TIMEOUT_MS; - fails += 1; - if fails >= 4 { - warn!("Registration timeout, {} consecutive failures", fails); + match registration.poll(now) { + RegistrationDecision::Wait => {} + RegistrationDecision::Send => { + self.send_register(&socket).await?; + registration.mark_sent(now); + } + RegistrationDecision::Retry { consecutive_timeouts } => { + let next_status = + RegistrationTracker::status_after_timeout(consecutive_timeouts); + self.set_status(next_status); + warn!( + "RustDesk registration timed out ({} consecutive timeouts)", + consecutive_timeouts + ); + self.send_register(&socket).await?; + registration.mark_sent(now); } - } - - if timeout || (last_register_sent.is_none() && expired) { - self.send_register(&socket).await?; - last_register_sent = Some(now); } } @@ -297,7 +382,6 @@ impl RendezvousMediator { } } - *self.status.write() = RendezvousStatus::Disconnected; Ok(()) } @@ -384,48 +468,49 @@ impl RendezvousMediator { &self, socket: &UdpSocket, msg: RendezvousMessage, - last_resp: &mut Option, - fails: &mut i32, - reg_timeout: &mut u64, + registration: &mut RegistrationTracker, ) -> anyhow::Result<()> { - *last_resp = Some(Instant::now()); - *fails = 0; - *reg_timeout = MIN_REG_TIMEOUT_MS; - match msg.union { Some(rendezvous_message::Union::RegisterPeerResponse(rpr)) => { + registration.mark_response(Instant::now()); if rpr.request_pk { info!("Server requested public key registration"); *self.key_confirmed.write() = false; + self.set_status(RendezvousStatus::Connecting); self.send_register_pk(socket).await?; + registration.mark_sent(Instant::now()); + } else { + self.set_status(RendezvousStatus::Registered); } - *self.status.write() = RendezvousStatus::Registered; } Some(rendezvous_message::Union::RegisterPkResponse(rpr)) => { + registration.mark_response(Instant::now()); info!("Received RegisterPkResponse: result={:?}", rpr.result); match rpr.result.value() { 0 => { info!("✓ Public key registered successfully with server"); *self.key_confirmed.write() = true; self.increment_serial(); - *self.status.write() = RendezvousStatus::Registered; + self.set_status(RendezvousStatus::Registered); } 2 => { warn!("UUID mismatch, need to re-register"); *self.key_confirmed.write() = false; + self.set_status(RendezvousStatus::Connecting); } 3 => { error!("Device ID already exists on server"); - *self.status.write() = - RendezvousStatus::Error("Device ID already exists".to_string()); + self.set_status(RendezvousStatus::Error( + "Device ID already exists".to_string(), + )); } 4 => { warn!("Registration too frequent"); + self.set_status(RendezvousStatus::Connecting); } 5 => { error!("Invalid device ID format"); - *self.status.write() = - RendezvousStatus::Error("Invalid ID format".to_string()); + self.set_status(RendezvousStatus::Error("Invalid ID format".to_string())); } _ => { error!("Unknown RegisterPkResponse result: {:?}", rpr.result); @@ -797,7 +882,111 @@ fn get_local_addresses() -> Vec { #[cfg(test)] mod tests { - use super::{normalize_relay_server, select_relay_server}; + use std::time::{Duration, Instant}; + + use super::{ + normalize_relay_server, select_relay_server, RegistrationDecision, RegistrationTracker, + RendezvousStatus, REG_INTERVAL, + }; + + #[test] + fn registration_tracker_requires_an_acknowledged_response() { + let started_at = Instant::now(); + let mut tracker = RegistrationTracker::new(); + + assert_eq!(tracker.poll(started_at), RegistrationDecision::Send); + tracker.mark_sent(started_at); + assert_eq!( + tracker.poll(started_at + Duration::from_secs(2)), + RegistrationDecision::Wait + ); + assert_eq!( + tracker.poll(started_at + Duration::from_secs(3)), + RegistrationDecision::Retry { + consecutive_timeouts: 1 + } + ); + assert_eq!( + RegistrationTracker::status_after_timeout(1), + RendezvousStatus::Connecting + ); + } + + #[test] + fn registration_tracker_marks_four_timeouts_offline() { + let mut now = Instant::now(); + let mut tracker = RegistrationTracker::new(); + + assert_eq!(tracker.poll(now), RegistrationDecision::Send); + tracker.mark_sent(now); + + for (failure, timeout) in [(1, 3), (2, 6), (3, 9), (4, 12)] { + now += Duration::from_secs(timeout); + assert_eq!( + tracker.poll(now), + RegistrationDecision::Retry { + consecutive_timeouts: failure + } + ); + tracker.mark_sent(now); + } + + assert_eq!( + RegistrationTracker::status_after_timeout(4), + RendezvousStatus::Disconnected + ); + } + + #[test] + fn registration_response_clears_in_flight_retry_state() { + let started_at = Instant::now(); + let mut tracker = RegistrationTracker::new(); + + tracker.mark_sent(started_at); + let retry_at = started_at + Duration::from_secs(3); + assert_eq!( + tracker.poll(retry_at), + RegistrationDecision::Retry { + consecutive_timeouts: 1 + } + ); + tracker.mark_sent(retry_at); + + let response_at = retry_at + Duration::from_millis(100); + tracker.mark_response(response_at); + assert_eq!(tracker.last_sent, None); + assert_eq!(tracker.consecutive_timeouts, 0); + + assert_eq!( + tracker.poll(response_at + REG_INTERVAL - Duration::from_millis(1)), + RegistrationDecision::Wait + ); + assert_eq!( + tracker.poll(response_at + REG_INTERVAL), + RegistrationDecision::Send + ); + } + + #[test] + fn registration_timeout_count_continues_after_backoff_reaches_its_cap() { + let mut now = Instant::now(); + let mut tracker = RegistrationTracker::new(); + + tracker.mark_sent(now); + for failure in 1..=12 { + now += tracker.response_timeout; + assert_eq!( + tracker.poll(now), + RegistrationDecision::Retry { + consecutive_timeouts: failure + } + ); + tracker.mark_sent(now); + } + + assert_eq!(tracker.response_timeout, Duration::from_secs(30)); + assert_eq!(tracker.consecutive_timeouts, 12); + } #[test] fn test_normalize_relay_server() { diff --git a/src/state.rs b/src/state.rs index ca93141f..9afc162c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -7,9 +7,11 @@ use crate::auth::{SessionStore, TwoFactorService, UserStore}; use crate::computer_use::ComputerUseManager; use crate::config::ConfigStore; use crate::db::DatabasePool; +#[cfg(unix)] +use crate::events::MsdDeviceMediaInfo; use crate::events::{ - AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, LedState, MsdDeviceInfo, - MsdDeviceMediaInfo, SystemEvent, TtydDeviceInfo, VideoDeviceInfo, + AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, LedState, MsdDeviceInfo, SystemEvent, + TtydDeviceInfo, VideoDeviceInfo, }; use crate::extensions::{ExtensionId, ExtensionManager}; use crate::hid::HidController; @@ -77,8 +79,8 @@ pub struct AppState { pub msd: Arc>>, pub atx: Arc>>, pub audio: Arc, - pub uac_playback: Arc>>, - pub uac_config: Arc>, + #[cfg(unix)] + pub uac_playback: Arc>>, pub rustdesk: Arc>>>, pub vnc: Arc>>>, pub rtsp: Arc>>>, @@ -148,8 +150,8 @@ impl AppState { revoked_sessions: Arc::new(RwLock::new(VecDeque::new())), config_apply_locks: ConfigApplyLocks::new(), data_dir, + #[cfg(unix)] uac_playback: Arc::new(RwLock::new(None)), - uac_config: Arc::new(RwLock::new(crate::otg::service::UacConfig::default())), }) } diff --git a/src/stream_encoder.rs b/src/stream_encoder.rs index ce0360f5..4bae805a 100644 --- a/src/stream_encoder.rs +++ b/src/stream_encoder.rs @@ -14,5 +14,29 @@ pub fn encoder_type_to_backend(encoder: EncoderType) -> Option { EncoderType::Amf => Some(EncoderBackend::Amf), EncoderType::Rkmpp => Some(EncoderBackend::Rkmpp), EncoderType::V4l2m2m => Some(EncoderBackend::V4l2m2m), + EncoderType::Amlogic => Some(EncoderBackend::Amlogic), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_amlogic_config_to_backend() { + assert_eq!( + encoder_type_to_backend(EncoderType::Amlogic), + Some(EncoderBackend::Amlogic) + ); + } + + #[test] + fn amlogic_config_json_round_trip() { + let json = serde_json::to_string(&EncoderType::Amlogic).unwrap(); + assert_eq!(json, "\"amlogic\""); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + EncoderType::Amlogic + ); } } diff --git a/src/update/mod.rs b/src/update/mod.rs index 80fe69e7..ca69db40 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -110,13 +110,12 @@ pub struct UpdateStatusResponse { pub struct UpdateService { client: reqwest::Client, base_url: String, - work_dir: PathBuf, status: RwLock, upgrade_permit: Arc, } impl UpdateService { - pub fn new(work_dir: PathBuf) -> Self { + pub fn new() -> Self { let base_url = std::env::var("ONE_KVM_UPDATE_BASE_URL") .ok() .filter(|url| !url.trim().is_empty()) @@ -125,7 +124,6 @@ impl UpdateService { Self { client: reqwest::Client::new(), base_url, - work_dir, status: RwLock::new(UpdateStatusResponse { success: true, phase: UpdatePhase::Idle, @@ -289,10 +287,13 @@ impl UpdateService { ) .await; - tokio::fs::create_dir_all(&self.work_dir).await?; - let staging_path = self - .work_dir - .join(format!("one-kvm-{}-download", target_version)); + let download_dir = tempfile::Builder::new() + .prefix("one-kvm-update-") + .tempdir() + .map_err(|e| { + AppError::Internal(format!("Failed to create update temp directory: {}", e)) + })?; + let staging_path = download_dir.path().join("tmpfile"); let artifact_url = self.resolve_url(&artifact.url); self.download_and_verify(&artifact_url, &staging_path, &artifact) @@ -308,6 +309,7 @@ impl UpdateService { .await; let restart_exe = self.install_binary(&staging_path).await?; + drop(download_dir); self.set_status( UpdatePhase::Restarting, diff --git a/src/video/capture/linux.rs b/src/video/capture/linux.rs index 210153ed..6ae0cc00 100644 --- a/src/video/capture/linux.rs +++ b/src/video/capture/linux.rs @@ -3,8 +3,9 @@ use std::fs::File; use std::io; use std::os::fd::AsFd; +use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::time::{Duration, Instant}; use nix::poll::{poll, PollFd, PollFlags, PollTimeout}; use tracing::{debug, info, warn}; @@ -13,28 +14,21 @@ use v4l2r::bindings::{ V4L2_DV_BT_656_1120, }; use v4l2r::ioctl::{ - self, Capabilities, Capability as V4l2rCapability, Event as V4l2Event, EventType, - MemoryConsistency, PlaneMapping, QBufPlane, QBuffer, QueryBuffer, QueryDvTimingsError, - SubscribeEventFlags, V4l2Buffer, + self, Capabilities, Capability as V4l2rCapability, EventType, IntoErrno, MemoryConsistency, + PlaneMapping, QBufPlane, QBuffer, QueryBuffer, QueryDvTimingsError, SubscribeEventFlags, + V4l2Buffer, }; use v4l2r::memory::{MemoryType, MmapHandle}; use v4l2r::nix::errno::Errno; use v4l2r::{Format as V4l2rFormat, PixelFormat as V4l2rPixelFormat, QueueType}; +use super::CaptureReadError; use crate::error::{AppError, Result}; use crate::video::device::bridge::{self as csi_bridge, CsiBridgeKind, ProbeResult}; +use crate::video::device::VideoControlMode; use crate::video::format::{PixelFormat, Resolution}; use crate::video::signal::SignalStatus; -/// `io::Error` payload when the driver posts `V4L2_EVENT_SOURCE_CHANGE`. -pub const SOURCE_CHANGED_MARKER: &str = "v4l2_source_changed"; - -pub fn is_source_changed_error(err: &io::Error) -> bool { - err.get_ref() - .map(|inner| inner.to_string() == SOURCE_CHANGED_MARKER) - .unwrap_or(false) -} - /// Metadata for a captured frame. #[derive(Debug, Clone, Copy)] pub struct CaptureMeta { @@ -65,11 +59,22 @@ pub struct CaptureStream { queue: QueueType, resolution: Resolution, format: PixelFormat, + source_fps: Option, stride: u32, timeout: Duration, mappings: Vec>, subdev_fd: Option, bridge_kind: Option, + native_hdmirx_state: Option, + native_hdmirx_next_state_check: Option, +} + +fn open_capture_device(path: &Path) -> io::Result { + File::options() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(path) } impl CaptureStream { @@ -90,6 +95,7 @@ impl CaptureStream { buffer_count, timeout, BridgeContext::default(), + VideoControlMode::Configurable, ) } @@ -102,6 +108,7 @@ impl CaptureStream { buffer_count: u32, timeout: Duration, bridge: BridgeContext, + control_mode: VideoControlMode, ) -> Result { // Probe subdev before video open (RK628: no-signal must not reach capture STREAMON). let mut subdev_fd_opt: Option = None; @@ -143,17 +150,14 @@ impl CaptureStream { } // ── Phase 1: open the capture (video) node ───────────────────── - let mut fd = File::options() - .read(true) - .write(true) - .open(device_path.as_ref()) + let mut fd = open_capture_device(device_path.as_ref()) .map_err(|e| AppError::VideoError(format!("Failed to open device: {}", e)))?; let caps: V4l2rCapability = ioctl::querycap(&fd) .map_err(|e| AppError::VideoError(format!("Failed to query capabilities: {}", e)))?; let caps_flags = caps.device_caps(); - let driver_name = caps.driver.to_string(); - let is_csi_bridge = is_csi_bridge_driver(&driver_name); + let is_source_following = control_mode == VideoControlMode::SourceFollowing; + let is_native_hdmirx = bridge.kind == Some(CsiBridgeKind::RkHdmirx); // Prefer multi-planar capture when available, as it is required for some // devices/pixel formats (e.g. NV12 via VIDEO_CAPTURE_MPLANE). @@ -176,9 +180,14 @@ impl CaptureStream { width: mode.width, height: mode.height, fps: mode.fps, + signature: None, }) - } else if is_csi_bridge { - Some(probe_and_apply_dv_timings(&fd)?) + } else if is_source_following { + // The native RK3588 HDMI RX driver already latches detected + // timings while locking the input. S_DV_TIMINGS is unnecessary + // there and rejects some otherwise valid sources whose measured + // porches do not exactly match its CEA table. + Some(probe_dv_timings(&fd, !is_native_hdmirx)?) } else { None }; @@ -188,7 +197,7 @@ impl CaptureStream { // `v4l2-ctl --set-fmt-video=width=…,height=…`). let mut fmt: V4l2rFormat = match ( ioctl::g_fmt::(&fd, queue), - is_csi_bridge, + is_source_following, dv_mode.as_ref(), ) { (Ok(f), _, _) if f.width > 0 && f.height > 0 => f, @@ -208,19 +217,51 @@ impl CaptureStream { // Prefer the DV-timings-reported geometry for CSI bridges — the // source, not the user config, dictates what the capture hardware // will actually deliver. - let (target_w, target_h) = match dv_mode { - Some(DvTimingsMode { width, height, .. }) => (width, height), + let (target_w, target_h) = match dv_mode.as_ref() { + Some(DvTimingsMode { width, height, .. }) => (*width, *height), None => (resolution.width, resolution.height), }; fmt.width = target_w; fmt.height = target_h; - fmt.pixelformat = V4l2rPixelFormat::from(&format.to_fourcc()); + let requested_fourcc = V4l2rPixelFormat::from(&format.to_fourcc()); + if is_native_hdmirx && fmt.pixelformat != requested_fourcc { + // rk_hdmirx exposes all possible HDMI input encodings through + // ENUM_FMT but can capture only the encoding currently present on + // the wire. Follow G_FMT so a source-side RGB/YUV transition can + // recover even if the saved configuration still names the old + // FourCC. The negotiated format is returned to the caller, which + // rebuilds the encoder when it changed. + info!( + "rk_hdmirx input format changed/requested {:?}, following active {:?}", + requested_fourcc, fmt.pixelformat + ); + } else { + fmt.pixelformat = requested_fourcc; + } let actual_fmt: V4l2rFormat = ioctl::s_fmt(&mut fd, (queue, &fmt)) .map_err(|e| AppError::VideoError(format!("Failed to set device format: {}", e)))?; let actual_resolution = Resolution::new(actual_fmt.width, actual_fmt.height); - let actual_format = PixelFormat::from_v4l2r(actual_fmt.pixelformat).unwrap_or(format); + let actual_format = match PixelFormat::from_v4l2r(actual_fmt.pixelformat) { + Some(format) => format, + None if is_native_hdmirx => { + return Err(AppError::VideoError(format!( + "Native HDMI RX input format {:?} is not supported; configure the HDMI source for 8-bit RGB/YUV output", + actual_fmt.pixelformat + ))); + } + None => format, + }; + + let native_hdmirx_state = is_native_hdmirx.then(|| NativeHdmirxState { + width: actual_fmt.width, + height: actual_fmt.height, + pixelformat: actual_fmt.pixelformat, + timings: dv_mode.as_ref().and_then(|mode| mode.signature), + }); + let native_hdmirx_next_state_check = + native_hdmirx_state.map(|_| Instant::now() + Duration::from_secs(1)); let stride = actual_fmt .plane_fmt @@ -283,11 +324,14 @@ impl CaptureStream { queue, resolution: actual_resolution, format: actual_format, + source_fps: dv_mode.as_ref().and_then(|mode| mode.fps), stride, timeout, mappings, subdev_fd: subdev_fd_opt, bridge_kind: bridge.kind, + native_hdmirx_state, + native_hdmirx_next_state_check, }; stream.queue_all_buffers()?; @@ -324,6 +368,10 @@ impl CaptureStream { self.format } + pub fn source_fps(&self) -> Option { + self.source_fps + } + pub fn stride(&self) -> u32 { self.stride } @@ -373,11 +421,40 @@ impl CaptureStream { } } - pub fn next_into(&mut self, dst: &mut Vec) -> io::Result { + pub fn next_into( + &mut self, + dst: &mut Vec, + ) -> std::result::Result { self.wait_ready()?; - let dqbuf: V4l2Buffer = ioctl::dqbuf(&self.fd, self.queue, MemoryType::Mmap) - .map_err(|e| io::Error::other(format!("dqbuf failed: {}", e)))?; + // Several vendor BSPs update G_FMT/DV timings without making the + // subscribed video fd poll as POLLPRI. Check once per second so a + // genuine source mode change cannot leave us dequeuing buffers with + // stale geometry forever. Transient ioctl failures are ignored here; + // the capture timeout/error path remains responsible for recovery. + if self + .native_hdmirx_next_state_check + .is_some_and(|next| Instant::now() >= next) + { + self.native_hdmirx_next_state_check = Some(Instant::now() + Duration::from_secs(1)); + if self.native_hdmirx_state_changed() { + info!( + "Native HDMI RX active format/timings changed without a usable event; requesting stream re-open" + ); + return Err(CaptureReadError::SourceChanged); + } + } + + let dqbuf: V4l2Buffer = + ioctl::dqbuf(&self.fd, self.queue, MemoryType::Mmap).map_err(|error| { + let message = error.to_string(); + let error = if error.into_errno() == Errno::EAGAIN as i32 { + io::Error::from(io::ErrorKind::WouldBlock) + } else { + io::Error::other(format!("dqbuf failed: {}", message)) + }; + CaptureReadError::Io(error) + })?; let index = dqbuf.as_v4l2_buffer().index as usize; let sequence = dqbuf.as_v4l2_buffer().sequence as u64; @@ -427,7 +504,7 @@ impl CaptureStream { self.resolution.height, self.stride ); - return Err(io::Error::other(SOURCE_CHANGED_MARKER)); + return Err(CaptureReadError::SourceChanged); } } @@ -437,80 +514,147 @@ impl CaptureStream { }) } - fn wait_ready(&self) -> io::Result<()> { + fn wait_ready(&self) -> std::result::Result<(), CaptureReadError> { if self.timeout.is_zero() { return Ok(()); } - // Multiplex video fd (POLLIN for DQBUF, POLLPRI as fallback for - // drivers that deliver events here) and the optional subdev fd - // (POLLPRI only — SOURCE_CHANGE on RK628 / rkcif). - let mut poll_fds: Vec = Vec::with_capacity(2); - poll_fds.push(PollFd::new( - self.fd.as_fd(), - PollFlags::POLLIN | PollFlags::POLLPRI | PollFlags::POLLERR | PollFlags::POLLHUP, - )); - if let Some(subdev_fd) = self.subdev_fd.as_ref() { - poll_fds.push(PollFd::new(subdev_fd.as_fd(), PollFlags::POLLPRI)); - } - let timeout_ms = self.timeout.as_millis().min(u16::MAX as u128) as u16; - let ready = poll(&mut poll_fds, PollTimeout::from(timeout_ms))?; - if ready == 0 { - return Err(io::Error::new(io::ErrorKind::TimedOut, "capture timeout")); - } + let deadline = Instant::now() + self.timeout; + loop { + // Multiplex video fd (POLLIN for DQBUF, POLLPRI as fallback for + // drivers that deliver events here) and the optional subdev fd + // (POLLPRI only — SOURCE_CHANGE on RK628 / rkcif). + let mut poll_fds: Vec = Vec::with_capacity(2); + poll_fds.push(PollFd::new( + self.fd.as_fd(), + PollFlags::POLLIN | PollFlags::POLLPRI | PollFlags::POLLERR | PollFlags::POLLHUP, + )); + if let Some(subdev_fd) = self.subdev_fd.as_ref() { + poll_fds.push(PollFd::new(subdev_fd.as_fd(), PollFlags::POLLPRI)); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(io::Error::new(io::ErrorKind::TimedOut, "capture timeout").into()); + } + // `nix::poll` accepts a u16 millisecond timeout. Round sub-ms + // durations up, and preserve the original deadline if a very long + // timeout needs more than one poll call. + let timeout_ms = remaining.as_millis().clamp(1, u16::MAX as u128) as u16; + let ready = poll(&mut poll_fds, PollTimeout::from(timeout_ms)) + .map_err(|error| CaptureReadError::Io(error.into()))?; + if ready == 0 { + if Instant::now() >= deadline { + return Err(io::Error::new(io::ErrorKind::TimedOut, "capture timeout").into()); + } + continue; + } - // Subdev POLLPRI fires first on rkcif/RK628 when the source-side - // HDMI timings changed. Drain all pending events and bubble up - // the `source_changed` marker so the upper layer re-opens with a - // fresh DV_TIMINGS probe. - if let Some(subdev_fd) = self.subdev_fd.as_ref() { - if let Some(revents) = poll_fds.get(1).and_then(|f| f.revents()) { + // Subdev POLLPRI fires first on rkcif/RK628 when the source-side + // HDMI timings changed. Native HDMI RX uses the video node and + // is validated separately below. + if let Some(subdev_fd) = self.subdev_fd.as_ref() { + if let Some(revents) = poll_fds.get(1).and_then(|f| f.revents()) { + if revents.contains(PollFlags::POLLPRI) { + let drained = csi_bridge::drain_v4l2_events(subdev_fd); + info!( + "Subdev SOURCE_CHANGE detected (drained {} event(s)), \ + requesting stream re-open", + drained + ); + return Err(CaptureReadError::SourceChanged); + } + } + } + + if let Some(revents) = poll_fds[0].revents() { + if revents.contains(PollFlags::POLLERR) || revents.contains(PollFlags::POLLHUP) { + debug!( + "capture poll: video revents={:?} (ERR/HUP) — requesting stream re-open", + revents + ); + return Err(CaptureReadError::SourceChanged); + } if revents.contains(PollFlags::POLLPRI) { - let drained = drain_events(subdev_fd); + let drained = csi_bridge::drain_v4l2_events(&self.fd); + if self.native_hdmirx_state_unchanged() { + debug!( + "Ignoring {} spurious native HDMI RX SOURCE_CHANGE event(s): active format/timings are unchanged", + drained + ); + if revents.contains(PollFlags::POLLIN) { + return Ok(()); + } + continue; + } info!( - "Subdev SOURCE_CHANGE detected (drained {} event(s)), \ + "Video-node SOURCE_CHANGE detected (drained {} event(s)), \ requesting stream re-open", drained ); - return Err(io::Error::other(SOURCE_CHANGED_MARKER)); + return Err(CaptureReadError::SourceChanged); } + if !revents.contains(PollFlags::POLLIN) { + // rkcif + RK628: the driver may wake `poll` after internally + // invalidating queued buffers without queueing a V4L2 event. + // Treat like SOURCE_CHANGE so we STREAMOFF / re-S_FMT. + debug!( + "capture poll: ready={} video revents={:?} (no POLLIN) — requesting stream re-open", + ready, revents + ); + return Err(CaptureReadError::SourceChanged); + } + return Ok(()); } + + debug!( + "capture poll: ready={} but video revents unavailable — requesting stream re-open", + ready + ); + return Err(CaptureReadError::SourceChanged); + } + } + + fn native_hdmirx_state_unchanged(&self) -> bool { + let Some(expected) = self.native_hdmirx_state.as_ref() else { + return false; + }; + let Ok(current_fmt) = ioctl::g_fmt::(&self.fd, self.queue) else { + return false; + }; + if !expected.format_matches( + current_fmt.width, + current_fmt.height, + current_fmt.pixelformat, + ) { + return false; } - if let Some(revents) = poll_fds[0].revents() { - if revents.contains(PollFlags::POLLERR) || revents.contains(PollFlags::POLLHUP) { - debug!( - "capture poll: video revents={:?} (ERR/HUP) — requesting stream re-open", - revents - ); - return Err(io::Error::other(SOURCE_CHANGED_MARKER)); - } - if revents.contains(PollFlags::POLLPRI) { - let drained = drain_events(&self.fd); - info!( - "Video-node SOURCE_CHANGE detected (drained {} event(s)), \ - requesting stream re-open", - drained - ); - return Err(io::Error::other(SOURCE_CHANGED_MARKER)); - } - if !revents.contains(PollFlags::POLLIN) { - // rkcif + RK628: the driver may wake `poll` after internally - // invalidating queued buffers without queueing a V4L2 event. - // Treat like SOURCE_CHANGE so we STREAMOFF / re-S_FMT. - debug!( - "capture poll: ready={} video revents={:?} (no POLLIN) — requesting stream re-open", - ready, revents - ); - return Err(io::Error::other(SOURCE_CHANGED_MARKER)); - } - return Ok(()); + let observed_timings = ioctl::query_dv_timings::(&self.fd) + .ok() + .and_then(|timings| dv_timings_signature(&timings)); + expected.timings_match(observed_timings).unwrap_or(false) + } + + fn native_hdmirx_state_changed(&self) -> bool { + let Some(expected) = self.native_hdmirx_state.as_ref() else { + return false; + }; + let Ok(current_fmt) = ioctl::g_fmt::(&self.fd, self.queue) else { + return false; + }; + if !expected.format_matches( + current_fmt.width, + current_fmt.height, + current_fmt.pixelformat, + ) { + return true; } - debug!( - "capture poll: ready={} but video revents unavailable — requesting stream re-open", - ready - ); - Err(io::Error::other(SOURCE_CHANGED_MARKER)) + let observed_timings = ioctl::query_dv_timings::(&self.fd) + .ok() + .and_then(|timings| dv_timings_signature(&timings)); + expected + .timings_match(observed_timings) + .is_some_and(|matches| !matches) } fn queue_all_buffers(&mut self) -> Result<()> { @@ -570,29 +714,6 @@ impl Drop for CaptureStream { } } -/// Driver-name check for CSI/HDMI bridge devices (rk_hdmirx, rkcif, tc358743, -/// …) that expose DV timings. Kept in sync with `video::device::is_csi_hdmi_bridge` -/// but queries the raw V4L2 driver string so we don't need a full -/// `VideoDeviceInfo` at `CaptureStream::open` time. -fn is_csi_bridge_driver(driver: &str) -> bool { - let d = driver.to_ascii_lowercase(); - d == "rk_hdmirx" || d == "rkcif" || d == "tc358743" || d.starts_with("rkcif") -} - -/// Drain any pending `V4L2_EVENT_*` events on `fd`. Used after POLLPRI to -/// clear the queue so the next poll doesn't immediately wake up on stale -/// state. Capped at 16 events per call. -fn drain_events(fd: &File) -> u32 { - let mut drained = 0u32; - while let Ok(_ev) = ioctl::dqevent::(fd) { - drained = drained.saturating_add(1); - if drained >= 16 { - break; - } - } - drained -} - /// Result of a successful `VIDIOC_QUERY_DV_TIMINGS` + `VIDIOC_S_DV_TIMINGS` /// probe. Used by the CSI bridge path to override the requested resolution /// with the source-reported geometry before `S_FMT`. @@ -602,6 +723,63 @@ struct DvTimingsMode { height: u32, #[allow(dead_code)] fps: Option, + signature: Option, +} + +#[derive(Debug, Clone, Copy)] +struct NativeHdmirxState { + width: u32, + height: u32, + pixelformat: V4l2rPixelFormat, + timings: Option, +} + +impl NativeHdmirxState { + fn format_matches(self, width: u32, height: u32, pixelformat: V4l2rPixelFormat) -> bool { + self.width == width && self.height == height && self.pixelformat == pixelformat + } + + /// `None` means the expected state contains timings but the current + /// timings could not be observed. Event handling treats that uncertainty + /// conservatively as changed; periodic fallback probing ignores it so a + /// single transient ioctl failure cannot tear down a healthy stream. + fn timings_match(self, observed: Option) -> Option { + match (self.timings, observed) { + (None, _) => Some(true), + (Some(expected), Some(current)) => Some(current.matches(expected)), + (Some(_), None) => None, + } + } +} + +#[derive(Debug, Clone, Copy)] +struct DvTimingsSignature { + width: u32, + height: u32, + interlaced: bool, +} + +impl DvTimingsSignature { + fn matches(self, other: Self) -> bool { + self.width == other.width + && self.height == other.height + && self.interlaced == other.interlaced + } +} + +fn dv_timings_signature(timings: &v4l2_dv_timings) -> Option { + let timings_type = timings.type_; + if timings_type != V4L2_DV_BT_656_1120 { + return None; + } + let bt = unsafe { timings.__bindgen_anon_1.bt }; + let width = bt.width; + let height = bt.height; + Some(DvTimingsSignature { + width, + height, + interlaced: bt.interlaced != 0, + }) } /// Probe DV timings from the source and latch them into the driver. @@ -619,7 +797,7 @@ struct DvTimingsMode { /// * `ENODATA` → `NoSignal` (driver says "no DV timings support on /// this input", e.g. EDID not applied yet) /// * anything else → `NoSignal` (fallback, keeps the retry loop going) -fn probe_and_apply_dv_timings(fd: &File) -> Result { +fn probe_dv_timings(fd: &File, apply: bool) -> Result { let timings: v4l2_dv_timings = match ioctl::query_dv_timings(fd) { Ok(t) => t, Err(err) => { @@ -638,7 +816,7 @@ fn probe_and_apply_dv_timings(fd: &File) -> Result { | QueryDvTimingsError::IoctlError(Errno::ETIMEDOUT) => SignalStatus::NoSync, QueryDvTimingsError::IoctlError(_) => SignalStatus::NoSignal, }; - info!( + debug!( "VIDIOC_QUERY_DV_TIMINGS failed: {} -> SignalStatus::{:?}", err, status ); @@ -687,11 +865,13 @@ fn probe_and_apply_dv_timings(fd: &File) -> Result { // right pixel clock + blanking. Failure here is *not* fatal on some // drivers (rkcif doesn't implement S_DV_TIMINGS per-output-device, only // on the bridging subdev), so degrade to a warning and keep going. - if let Err(e) = ioctl::s_dv_timings::<_, v4l2_dv_timings>(fd, timings) { - debug!( - "VIDIOC_S_DV_TIMINGS failed ({}), continuing with queried timings for S_FMT", - e - ); + if apply { + if let Err(e) = ioctl::s_dv_timings::<_, v4l2_dv_timings>(fd, timings) { + debug!( + "VIDIOC_S_DV_TIMINGS failed ({}), continuing with queried timings for S_FMT", + e + ); + } } let fps = dv_timings_fps_from_scalars( @@ -714,6 +894,7 @@ fn probe_and_apply_dv_timings(fd: &File) -> Result { width: bt_width, height: bt_height, fps, + signature: dv_timings_signature(&timings), }) } @@ -749,3 +930,66 @@ fn set_fps(fd: &File, queue: QueueType, fps: u32) -> std::result::Result<(), ioc let _actual: v4l2_streamparm = ioctl::s_parm(fd, params)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::{open_capture_device, DvTimingsSignature, NativeHdmirxState}; + use crate::video::format::PixelFormat; + + fn timing() -> DvTimingsSignature { + DvTimingsSignature { + width: 1920, + height: 1080, + interlaced: false, + } + } + + #[test] + fn timing_match_uses_only_active_geometry_and_scan_mode() { + assert!(timing().matches(timing())); + + let mut different_width = timing(); + different_width.width = 1280; + assert!(!timing().matches(different_width)); + + let mut interlaced = timing(); + interlaced.interlaced = true; + assert!(!timing().matches(interlaced)); + } + + #[test] + fn native_hdmirx_state_distinguishes_spurious_and_real_changes() { + let bgr24 = PixelFormat::Bgr24.to_v4l2r(); + let state = NativeHdmirxState { + width: 1920, + height: 1080, + pixelformat: bgr24, + timings: Some(timing()), + }; + + assert!(state.format_matches(1920, 1080, bgr24)); + assert!(!state.format_matches(1280, 720, bgr24)); + assert!(!state.format_matches(1920, 1080, PixelFormat::Nv12.to_v4l2r())); + assert_eq!(state.timings_match(Some(timing())), Some(true)); + let mut interlaced = timing(); + interlaced.interlaced = true; + assert_eq!(state.timings_match(Some(interlaced)), Some(false)); + assert_eq!(state.timings_match(None), None); + + let no_timing_state = NativeHdmirxState { + timings: None, + ..state + }; + assert_eq!(no_timing_state.timings_match(None), Some(true)); + } + + #[test] + fn capture_device_handles_are_non_blocking() { + let temp = tempfile::NamedTempFile::new().expect("create temporary device file"); + let opened = open_capture_device(temp.path()).expect("open capture device"); + let flags = + nix::fcntl::fcntl(&opened, nix::fcntl::FcntlArg::F_GETFL).expect("read file flags"); + + assert_ne!(flags & libc::O_NONBLOCK, 0); + } +} diff --git a/src/video/capture/mod.rs b/src/video/capture/mod.rs index 82e0a7dc..106d8a1a 100644 --- a/src/video/capture/mod.rs +++ b/src/video/capture/mod.rs @@ -1,10 +1,51 @@ //! Video capture implementations and capture-state helpers. +use std::fmt; +use std::io; + pub(crate) mod runtime; pub(crate) mod status; pub const DEFAULT_CAPTURE_BUFFER_COUNT: u32 = 4; +/// Expected source changes are control flow, not stringly typed I/O errors. +#[derive(Debug)] +pub enum CaptureReadError { + SourceChanged, + Io(io::Error), +} + +impl CaptureReadError { + pub fn as_io_error(&self) -> Option<&io::Error> { + match self { + Self::SourceChanged => None, + Self::Io(error) => Some(error), + } + } +} + +impl From for CaptureReadError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl fmt::Display for CaptureReadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SourceChanged => formatter.write_str("capture source changed"), + Self::Io(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for CaptureReadError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.as_io_error() + .map(|error| error as &(dyn std::error::Error + 'static)) + } +} + #[cfg(unix)] mod linux; #[cfg(windows)] diff --git a/src/video/capture/runtime.rs b/src/video/capture/runtime.rs index a09fcdde..fd65a16c 100644 --- a/src/video/capture/runtime.rs +++ b/src/video/capture/runtime.rs @@ -3,6 +3,7 @@ use std::time::Duration; use crate::error::AppError; use crate::video::capture::status::signal_status_from_capture_kind; +use crate::video::device::VideoControlMode; use crate::video::format::{PixelFormat, Resolution}; use crate::video::signal::SignalStatus; @@ -23,6 +24,7 @@ pub fn open_capture_stream( buffer_count: u32, timeout: Duration, bridge_ctx: BridgeContext, + control_mode: VideoControlMode, ) -> Result { CaptureStream::open_with_bridge( device_path, @@ -32,6 +34,7 @@ pub fn open_capture_stream( buffer_count.max(1), timeout, bridge_ctx, + control_mode, ) } @@ -43,6 +46,7 @@ pub fn open_capture_stream_for_retry( buffer_count: u32, timeout: Duration, bridge_ctx: BridgeContext, + control_mode: VideoControlMode, is_device_lost_message: impl FnOnce(&str) -> bool, ) -> CaptureOpenResult { match open_capture_stream( @@ -53,6 +57,7 @@ pub fn open_capture_stream_for_retry( buffer_count, timeout, bridge_ctx, + control_mode, ) { Ok(stream) => CaptureOpenResult::Opened(stream), Err(AppError::CaptureNoSignal { kind }) => { diff --git a/src/video/capture/windows.rs b/src/video/capture/windows.rs index e3d2cbf5..b7c4b11b 100644 --- a/src/video/capture/windows.rs +++ b/src/video/capture/windows.rs @@ -2,19 +2,14 @@ use std::io; use std::path::{Path, PathBuf}; use std::time::Duration; +use super::CaptureReadError; use crate::error::{AppError, Result}; use crate::video::device::bridge::{CsiBridgeKind, ProbeResult}; -use crate::video::device::{directshow_display_name_from_path, normalize_windows_device_path}; +use crate::video::device::{ + directshow_display_name_from_path, normalize_windows_device_path, VideoControlMode, +}; use crate::video::format::{PixelFormat, Resolution}; -pub const SOURCE_CHANGED_MARKER: &str = "dshow_source_changed"; - -pub fn is_source_changed_error(err: &io::Error) -> bool { - err.get_ref() - .map(|inner| inner.to_string() == SOURCE_CHANGED_MARKER) - .unwrap_or(false) -} - #[derive(Debug, Clone, Copy)] pub struct CaptureMeta { pub bytes_used: usize, @@ -95,8 +90,9 @@ impl CaptureStream { buffer_count: u32, timeout: Duration, bridge: BridgeContext, + control_mode: VideoControlMode, ) -> Result { - let _ = bridge; + let _ = (bridge, control_mode); Self::open(device_path, resolution, format, fps, buffer_count, timeout) } @@ -108,11 +104,18 @@ impl CaptureStream { self.format } + pub fn source_fps(&self) -> Option { + None + } + pub fn stride(&self) -> u32 { self.stride } - pub fn next_into(&mut self, dst: &mut Vec) -> io::Result { + pub fn next_into( + &mut self, + dst: &mut Vec, + ) -> std::result::Result { match self.capture.read_packet() { Ok((packet, sequence)) => { dst.clear(); @@ -128,7 +131,7 @@ impl CaptureStream { } else { io::ErrorKind::Other }; - Err(io::Error::new(kind, err.message)) + Err(CaptureReadError::Io(io::Error::new(kind, err.message))) } } } diff --git a/src/video/codec/amlenc.rs b/src/video/codec/amlenc.rs new file mode 100644 index 00000000..99fa06b1 --- /dev/null +++ b/src/video/codec/amlenc.rs @@ -0,0 +1,985 @@ +//! Native Amlogic AMLENC bindings for the S912/GXM vendor Linux 4.9 stack. +//! +//! The vendor libraries are deliberately loaded at runtime. They must be built +//! with the One-KVM ABI v1 patch from the standalone `amlenc` repository; +//! unpatched 0.4 libraries +//! are rejected before any device access is attempted. + +use std::env; +use std::ffi::{c_int, c_long, c_uchar, c_uint, OsStr}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use libloading::Library; +use tracing::{debug, warn}; + +use crate::error::{AppError, Result}; +use crate::video::format::Resolution; + +pub const AMLENC_ABI_VERSION: c_int = 1; +pub const AMLENC_H264_CODEC_NAME: &str = "h264_amlenc"; +pub const AMLENC_H265_CODEC_NAME: &str = "hevc_amlenc"; +pub const AMLENC_H264_DEFAULT_LIBRARY: &str = "libvpcodec.so"; +pub const AMLENC_H265_DEFAULT_LIBRARY: &str = "libvphevcodec.so"; + +const AMLENC_MAX_WIDTH: u32 = 1920; +const AMLENC_MAX_HEIGHT: u32 = 1080; +const AMLENC_MAX_FPS: u32 = 60; +const MIN_OUTPUT_BUFFER_SIZE: usize = 1024 * 1024; +const OUTPUT_STALL_TIMEOUT: Duration = Duration::from_secs(1); +const CODEC_ID_H264: c_int = 4; +const CODEC_ID_H265: c_int = 5; +const IMG_FMT_NV12: c_int = 1; +const FRAME_TYPE_AUTO: c_int = 1; +const FRAME_TYPE_IDR: c_int = 2; +const H264_NV12_FORMAT: c_int = 0; +const H265_NV12_FORMAT: c_int = 1; + +type AbiVersionFn = unsafe extern "C" fn() -> c_int; +type H264InitFn = unsafe extern "C" fn(c_int, c_int, c_int, c_int, c_int, c_int, c_int) -> c_long; +type H265InitFn = unsafe extern "C" fn(c_int, c_int, c_int, c_int, c_int, c_int) -> c_long; +type H264EncodeFn = + unsafe extern "C" fn(c_long, c_int, *mut c_uchar, c_int, *mut c_uchar, c_int) -> c_int; +type H265EncodeFn = + unsafe extern "C" fn(c_long, c_int, *mut c_uchar, c_uint, *mut c_uchar, c_int) -> c_int; +type DestroyFn = unsafe extern "C" fn(c_long) -> c_int; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AmlencCodec { + H264, + H265, +} + +impl AmlencCodec { + pub fn codec_name(self) -> &'static str { + match self { + Self::H264 => AMLENC_H264_CODEC_NAME, + Self::H265 => AMLENC_H265_CODEC_NAME, + } + } + + pub fn default_library(self) -> &'static str { + match self { + Self::H264 => AMLENC_H264_DEFAULT_LIBRARY, + Self::H265 => AMLENC_H265_DEFAULT_LIBRARY, + } + } + + pub fn library_env(self) -> &'static str { + match self { + Self::H264 => "ONE_KVM_AMLENC_H264_LIB", + Self::H265 => "ONE_KVM_AMLENC_H265_LIB", + } + } + + pub fn device_node(self) -> &'static str { + match self { + Self::H264 => "/dev/amvenc_avc", + Self::H265 => "/dev/HevcEnc", + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct AmlencConfig { + pub codec: AmlencCodec, + pub resolution: Resolution, + pub fps: u32, + pub bitrate_kbps: u32, + pub gop: u32, +} + +impl AmlencConfig { + pub fn validate(self) -> Result<()> { + let width = self.resolution.width; + let height = self.resolution.height; + if width == 0 + || height == 0 + || width > AMLENC_MAX_WIDTH + || height > AMLENC_MAX_HEIGHT + || width % 16 != 0 + || height % 2 != 0 + { + return Err(AppError::VideoError(format!( + "AMLENC requires NV12 with 16-aligned width, even height, and at most 1920x1080 (got {}x{})", + width, height + ))); + } + if !(1..=AMLENC_MAX_FPS).contains(&self.fps) { + return Err(AppError::VideoError(format!( + "AMLENC supports 1-60 fps (got {})", + self.fps + ))); + } + if self.bitrate_kbps == 0 || self.bitrate_kbps > (c_int::MAX as u32 / 1000) { + return Err(AppError::VideoError(format!( + "Invalid AMLENC bitrate: {} kbps", + self.bitrate_kbps + ))); + } + if self.gop > c_int::MAX as u32 { + return Err(AppError::VideoError("AMLENC GOP is too large".to_string())); + } + nv12_frame_size(self.resolution)?; + Ok(()) + } + + fn bitrate_bps(self) -> c_int { + (self.bitrate_kbps * 1000) as c_int + } + + fn vendor_gop(self) -> c_int { + match self.codec { + // GXM's H.264 microcode can time out on a later natural IDR for + // complex 1080p pictures. The pinned vendor library defines zero + // as an infinite GOP (one IDR when the instance is created). + AmlencCodec::H264 => 0, + AmlencCodec::H265 => self.gop as c_int, + } + } +} + +pub fn nv12_frame_size(resolution: Resolution) -> Result { + let pixels = (resolution.width as usize) + .checked_mul(resolution.height as usize) + .ok_or_else(|| AppError::VideoError("AMLENC NV12 frame size overflow".to_string()))?; + pixels + .checked_mul(3) + .map(|value| value / 2) + .ok_or_else(|| AppError::VideoError("AMLENC NV12 frame size overflow".to_string())) +} + +fn validate_abi_version(version: c_int, path: &Path) -> Result<()> { + if version != AMLENC_ABI_VERSION { + return Err(AppError::VideoError(format!( + "AMLENC library {} has ABI {}, expected ABI v{}; apply the one-kvm-amlenc-abi-v1.patch from the standalone amlenc repository", + path.display(), + version, + AMLENC_ABI_VERSION + ))); + } + Ok(()) +} + +struct H264Api { + _library: Library, + init: H264InitFn, + encode: H264EncodeFn, + destroy: DestroyFn, +} + +struct H265Api { + _library: Library, + init: H265InitFn, + encode: H265EncodeFn, + destroy: DestroyFn, +} + +enum AmlencApi { + H264(H264Api), + H265(H265Api), +} + +unsafe fn required_symbol(library: &Library, name: &[u8], path: &Path) -> Result { + // SAFETY: the caller supplies the signature from the fixed upstream headers. + unsafe { library.get::(name) } + .map(|symbol| *symbol) + .map_err(|error| { + AppError::VideoError(format!( + "AMLENC library {} is missing {}: {}", + path.display(), + String::from_utf8_lossy(name).trim_end_matches('\0'), + error + )) + }) +} + +impl AmlencApi { + fn load(codec: AmlencCodec, path: &Path) -> Result { + // SAFETY: all calls are made through signatures checked against the pinned headers, + // and the Library remains owned by the API object for the lifetime of the pointers. + let library = unsafe { Library::new(path) }.map_err(|error| { + AppError::VideoError(format!( + "Failed to load AMLENC {} library {}: {}", + codec.codec_name(), + path.display(), + error + )) + })?; + let abi_version: AbiVersionFn = + unsafe { required_symbol(&library, b"one_kvm_amlenc_abi_version\0", path)? }; + // SAFETY: the ABI marker has no arguments or side effects. + validate_abi_version(unsafe { abi_version() }, path)?; + + Ok(match codec { + AmlencCodec::H264 => { + let init: H264InitFn = + unsafe { required_symbol(&library, b"vl_video_encoder_init\0", path)? }; + let encode: H264EncodeFn = + unsafe { required_symbol(&library, b"vl_video_encoder_encode\0", path)? }; + let destroy: DestroyFn = + unsafe { required_symbol(&library, b"vl_video_encoder_destory\0", path)? }; + Self::H264(H264Api { + _library: library, + init, + encode, + destroy, + }) + } + AmlencCodec::H265 => { + let init: H265InitFn = + unsafe { required_symbol(&library, b"vl_video_encoder_init\0", path)? }; + let encode: H265EncodeFn = + unsafe { required_symbol(&library, b"vl_video_encoder_encode\0", path)? }; + let destroy: DestroyFn = + unsafe { required_symbol(&library, b"vl_video_encoder_destory\0", path)? }; + Self::H265(H265Api { + _library: library, + init, + encode, + destroy, + }) + } + }) + } + + unsafe fn init(&self, config: AmlencConfig) -> c_long { + let width = config.resolution.width as c_int; + let height = config.resolution.height as c_int; + match self { + Self::H264(api) => unsafe { + (api.init)( + CODEC_ID_H264, + width, + height, + config.fps as c_int, + config.bitrate_bps(), + config.vendor_gop(), + IMG_FMT_NV12, + ) + }, + Self::H265(api) => unsafe { + (api.init)( + CODEC_ID_H265, + width, + height, + config.fps as c_int, + config.bitrate_bps(), + config.gop as c_int, + ) + }, + } + } + + unsafe fn encode( + &self, + handle: c_long, + frame_type: c_int, + input: *mut c_uchar, + output: *mut c_uchar, + output_len: usize, + ) -> c_int { + match self { + // H.264's fourth argument is documented as input length, but the pinned + // implementation uses it exclusively as output capacity. + Self::H264(api) => unsafe { + (api.encode)( + handle, + frame_type, + input, + output_len as c_int, + output, + H264_NV12_FORMAT, + ) + }, + Self::H265(api) => unsafe { + (api.encode)( + handle, + frame_type, + input, + output_len as c_uint, + output, + H265_NV12_FORMAT, + ) + }, + } + } + + unsafe fn destroy(&self, handle: c_long) { + match self { + Self::H264(api) => { + unsafe { (api.destroy)(handle) }; + } + Self::H265(api) => { + unsafe { (api.destroy)(handle) }; + } + } + } +} + +static AMLENC_INSTANCE_ACTIVE: AtomicBool = AtomicBool::new(false); + +struct ExclusiveInstance; + +impl ExclusiveInstance { + fn acquire() -> Result { + AMLENC_INSTANCE_ACTIVE + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| { + AppError::VideoError( + "AMLENC hardware is already in use by another encoder or self-check" + .to_string(), + ) + })?; + Ok(Self) + } +} + +impl Drop for ExclusiveInstance { + fn drop(&mut self) { + AMLENC_INSTANCE_ACTIVE.store(false, Ordering::Release); + } +} + +pub struct AmlencEncoder { + api: AmlencApi, + handle: c_long, + config: AmlencConfig, + output: Vec, + force_keyframe: bool, + rebuild_before_next_frame: bool, + expect_parameterized_keyframe: bool, + last_output: Instant, + _exclusive: ExclusiveInstance, +} + +impl AmlencEncoder { + pub fn new(config: AmlencConfig) -> Result { + let path = env::var_os(config.codec.library_env()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(config.codec.default_library())); + Self::with_library(config, path) + } + + pub fn with_library(config: AmlencConfig, path: impl AsRef) -> Result { + config.validate()?; + let exclusive = ExclusiveInstance::acquire()?; + let path = PathBuf::from(path.as_ref()); + let api = AmlencApi::load(config.codec, &path)?; + let frame_size = nv12_frame_size(config.resolution)?; + let output = vec![0; frame_size.max(MIN_OUTPUT_BUFFER_SIZE)]; + let mut encoder = Self { + api, + handle: 0, + config, + output, + force_keyframe: false, + rebuild_before_next_frame: false, + expect_parameterized_keyframe: true, + last_output: Instant::now(), + _exclusive: exclusive, + }; + encoder.create_handle()?; + Ok(encoder) + } + + pub fn codec_name(&self) -> &'static str { + self.config.codec.codec_name() + } + + pub fn config(&self) -> AmlencConfig { + self.config + } + + fn create_handle(&mut self) -> Result<()> { + debug!( + "Creating {} at {}x{} {} fps {} kbps", + self.codec_name(), + self.config.resolution.width, + self.config.resolution.height, + self.config.fps, + self.config.bitrate_kbps + ); + // SAFETY: config validation guarantees values accepted by ABI v1. + self.handle = unsafe { self.api.init(self.config) }; + if self.handle <= 0 { + return Err(AppError::VideoError(format!( + "AMLENC {} initialization failed; check {}, firmware, CMA, and device permissions", + self.codec_name(), + self.config.codec.device_node() + ))); + } + // The first H.264 picture is naturally an IDR. Never pass the + // in-place FORCE_IDR command to the GXM H.264 microcode: later IDRs can + // wedge it. H.265 does not share that observed defect and retains its + // ABI-v1 forced-IRAP behavior. + self.force_keyframe = self.config.codec == AmlencCodec::H265; + self.rebuild_before_next_frame = false; + self.expect_parameterized_keyframe = true; + self.last_output = Instant::now(); + Ok(()) + } + + fn destroy_handle(&mut self) { + if self.handle > 0 { + // SAFETY: the handle was returned by this API instance and is destroyed once. + unsafe { self.api.destroy(self.handle) }; + self.handle = 0; + } + } + + fn rebuild(&mut self, reason: &str) -> Result<()> { + warn!("Rebuilding {} encoder: {}", self.codec_name(), reason); + self.destroy_handle(); + self.create_handle() + } + + pub fn request_keyframe(&mut self) { + if self.config.codec == AmlencCodec::H264 { + // A fresh encoder reliably emits SPS/PPS + IDR on its first AUTO + // frame. Coalesce repeated client requests while a rebuild or + // fresh first frame is already pending. + if !self.expect_parameterized_keyframe { + self.rebuild_before_next_frame = true; + } + } else { + self.force_keyframe = true; + } + } + + pub fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> { + let mut updated = self.config; + updated.bitrate_kbps = bitrate_kbps; + updated.validate()?; + self.config = updated; + self.rebuild("bitrate changed") + } + + pub fn encode_raw(&mut self, data: &[u8]) -> Result> { + let expected = nv12_frame_size(self.config.resolution)?; + if data.len() != expected { + return Err(AppError::VideoError(format!( + "AMLENC requires contiguous NV12 data of exactly {} bytes (got {})", + expected, + data.len() + ))); + } + + if self.rebuild_before_next_frame { + self.rebuild("H.264 keyframe requested")?; + } + + match self.encode_once(data) { + Ok(frame) => Ok(frame), + Err(first_error) => { + self.rebuild(&format!("vendor encode call failed: {first_error}"))?; + self.encode_once(data).map_err(|retry_error| { + AppError::VideoError(format!( + "AMLENC encode failed after one rebuild: {}; retry: {}", + first_error, retry_error + )) + }) + } + } + } + + fn encode_once(&mut self, data: &[u8]) -> Result> { + if self.handle <= 0 { + return Err(AppError::VideoError( + "AMLENC handle is not initialized".to_string(), + )); + } + let forced = self.force_keyframe; + let require_parameterized_keyframe = self.expect_parameterized_keyframe || forced; + let frame_type = if forced { + FRAME_TYPE_IDR + } else { + FRAME_TYPE_AUTO + }; + // The vendor API takes a mutable pointer but does not modify VMALLOC input. + // SAFETY: input/output live for the call, capacities are ABI-sized and the + // output length is validated before any slice is formed. + let length = unsafe { + self.api.encode( + self.handle, + frame_type, + data.as_ptr() as *mut c_uchar, + self.output.as_mut_ptr(), + self.output.len(), + ) + }; + if length < 0 { + return Err(AppError::VideoError(format!( + "{} vendor library returned {}", + self.codec_name(), + length + ))); + } + // A keyframe request applies to one submitted frame. Repeating IDR on + // every zero-output call can trap the S912 driver in its light-reset + // loop; WebRTC will issue another request if this attempt was skipped. + if forced { + self.force_keyframe = false; + } + let length = length as usize; + if length > self.output.len() { + return Err(AppError::VideoError(format!( + "{} returned oversized output {} > {}", + self.codec_name(), + length, + self.output.len() + ))); + } + if length == 0 { + if forced { + return Err(AppError::VideoError(format!( + "{} produced no output for a forced keyframe", + self.codec_name() + ))); + } + // The vendor ABI uses zero for rate-control skips and recoverable + // hardware timeouts. Do not rebuild for a few skipped frames, but + // recover if the vendor stops producing output altogether. + if self.last_output.elapsed() >= OUTPUT_STALL_TIMEOUT { + self.rebuild("no encoded output for one second")?; + } + return Ok(None); + } + + let encoded = &self.output[..length]; + let nal_summary = inspect_annex_b(self.config.codec, encoded); + let keyframe = nal_summary.keyframe; + if require_parameterized_keyframe + && (!keyframe || !nal_summary.has_parameter_sets(self.config.codec)) + { + return Err(AppError::VideoError(format!( + "{} fresh/forced keyframe did not contain an IRAP/IDR and complete parameter sets", + self.codec_name() + ))); + } + self.force_keyframe = false; + self.expect_parameterized_keyframe = false; + self.last_output = Instant::now(); + Ok(Some((Bytes::copy_from_slice(encoded), keyframe))) + } +} + +impl Drop for AmlencEncoder { + fn drop(&mut self) { + self.destroy_handle(); + } +} + +#[derive(Default)] +struct AnnexBNalSummary { + keyframe: bool, + vps: bool, + sps: bool, + pps: bool, +} + +impl AnnexBNalSummary { + fn has_parameter_sets(&self, codec: AmlencCodec) -> bool { + match codec { + AmlencCodec::H264 => self.sps && self.pps, + AmlencCodec::H265 => self.vps && self.sps && self.pps, + } + } +} + +fn inspect_annex_b(codec: AmlencCodec, data: &[u8]) -> AnnexBNalSummary { + let mut summary = AnnexBNalSummary::default(); + let mut index = 0; + while index + 3 <= data.len() { + let start_len = if index + 4 <= data.len() && data[index..index + 4] == [0, 0, 0, 1] { + 4 + } else if data[index..index + 3] == [0, 0, 1] { + 3 + } else { + index += 1; + continue; + }; + let nal = index + start_len; + if nal >= data.len() { + break; + } + let nal_type = match codec { + AmlencCodec::H264 => data[nal] & 0x1f, + AmlencCodec::H265 => (data[nal] >> 1) & 0x3f, + }; + match codec { + AmlencCodec::H264 => match nal_type { + 5 => summary.keyframe = true, + 7 => summary.sps = true, + 8 => summary.pps = true, + _ => {} + }, + AmlencCodec::H265 => match nal_type { + 16..=23 => summary.keyframe = true, + 32 => summary.vps = true, + 33 => summary.sps = true, + 34 => summary.pps = true, + _ => {} + }, + } + index = nal + 1; + } + summary +} + +pub fn is_keyframe(codec: AmlencCodec, data: &[u8]) -> bool { + inspect_annex_b(codec, data).keyframe +} + +pub fn has_parameter_sets(codec: AmlencCodec, data: &[u8]) -> bool { + inspect_annex_b(codec, data).has_parameter_sets(codec) +} + +#[cfg_attr( + not(any(test, all(target_os = "linux", target_arch = "aarch64"))), + allow(dead_code) +)] +fn is_s912_gxm_compatible(compatible: &[u8]) -> bool { + let compatible = String::from_utf8_lossy(compatible).to_ascii_lowercase(); + compatible.contains("amlogic,gxm") + || compatible.contains("amlogic, gxm") + || compatible.contains("amlogic,meson-gxm") + || compatible.contains("amlogic,s912") +} + +pub fn system_is_s912_gxm() -> Result { + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + { + let compatible = std::fs::read("/proc/device-tree/compatible").map_err(|error| { + AppError::VideoError(format!( + "Cannot read /proc/device-tree/compatible for AMLENC detection: {}", + error + )) + })?; + return Ok(is_s912_gxm_compatible(&compatible)); + } + #[cfg(not(all(target_os = "linux", target_arch = "aarch64")))] + Ok(false) +} + +/// Perform the destructive part of backend detection: initialize and encode one +/// 640x480 NV12 frame. The caller must first check SoC compatibility and node. +pub fn smoke_test(codec: AmlencCodec) -> Result<()> { + let resolution = Resolution::new(640, 480); + let config = AmlencConfig { + codec, + resolution, + fps: 30, + bitrate_kbps: 1_000, + gop: 30, + }; + let mut encoder = AmlencEncoder::new(config)?; + let mut frame = vec![0x80; nv12_frame_size(resolution)?]; + frame[..(resolution.width * resolution.height) as usize].fill(0x10); + for _ in 0..3 { + if encoder.encode_raw(&frame)?.is_some() { + return Ok(()); + } + } + Err(AppError::VideoError(format!( + "{} produced no output during the 640x480 probe", + codec.codec_name() + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(unix)] + use std::process::Command; + #[cfg(unix)] + use std::sync::Mutex; + + #[cfg(unix)] + static TEST_INSTANCE_MUTEX: Mutex<()> = Mutex::new(()); + + #[cfg(unix)] + const H264_FIXTURE: &str = r#" + static int values[16]; + static int mode; + static int fail_pending; + int one_kvm_amlenc_abi_version(void) { return 1; } + long vl_video_encoder_init(int codec, int width, int height, int fps, + int bitrate, int gop, int image_format) { + values[0]++; values[1] = codec; values[2] = width; values[3] = height; + values[4] = fps; values[5] = bitrate; values[6] = gop; + values[7] = image_format; return 1; + } + int vl_video_encoder_encode(long handle, int frame_type, unsigned char *in, + int in_size, unsigned char *out, int format) { + (void)handle; (void)in; values[8]++; values[9] = frame_type; + values[10] = in_size; values[11] = format; + if (fail_pending) { fail_pending = 0; return -9; } + if (mode == 2) return 0; + if (mode == 3) return 2000000; + { unsigned char data[] = {0,0,1,0x67,0,0,1,0x68,0,0,1,0x65}; + for (unsigned long i = 0; i < sizeof(data); i++) out[i] = data[i]; + return sizeof(data); } + } + int vl_video_encoder_destory(long handle) { (void)handle; values[12]++; return 1; } + int test_get(int index) { return values[index]; } + void test_set_mode(int value) { mode = value; } + void test_fail_once(void) { fail_pending = 1; } + "#; + + #[cfg(unix)] + const H265_FIXTURE: &str = r#" + static int values[16]; + static int mode; + int one_kvm_amlenc_abi_version(void) { return 1; } + long vl_video_encoder_init(int codec, int width, int height, int fps, + int bitrate, int gop) { + values[0]++; values[1] = codec; values[2] = width; values[3] = height; + values[4] = fps; values[5] = bitrate; values[6] = gop; return 1; + } + int vl_video_encoder_encode(long handle, int frame_type, unsigned char *in, + unsigned int output_len, unsigned char *out, int format) { + (void)handle; (void)in; values[8]++; values[9] = frame_type; + values[10] = output_len; values[11] = format; + if (mode == 3) return output_len + 1; + { unsigned char data[] = {0,0,1,0x40,1,0,0,1,0x42,1,0,0,1,0x44,1, + 0,0,1,0x26,1}; + for (unsigned long i = 0; i < sizeof(data); i++) out[i] = data[i]; + return sizeof(data); } + } + int vl_video_encoder_destory(long handle) { (void)handle; values[12]++; return 1; } + int test_get(int index) { return values[index]; } + void test_set_mode(int value) { mode = value; } + "#; + + #[cfg(unix)] + fn build_fixture(directory: &Path, name: &str, source: &str) -> PathBuf { + let source_path = directory.join(format!("{name}.c")); + let library_path = directory.join(format!("lib{name}.so")); + std::fs::write(&source_path, source).unwrap(); + let status = Command::new("cc") + .args(["-shared", "-fPIC"]) + .arg(&source_path) + .arg("-o") + .arg(&library_path) + .status() + .unwrap(); + assert!(status.success()); + library_path + } + + #[test] + fn validates_geometry_fps_and_nv12_size() { + let valid = AmlencConfig { + codec: AmlencCodec::H264, + resolution: Resolution::new(1920, 1080), + fps: 60, + bitrate_kbps: 8_000, + gop: 60, + }; + assert!(valid.validate().is_ok()); + assert_eq!(nv12_frame_size(valid.resolution).unwrap(), 3_110_400); + + for invalid in [ + AmlencConfig { + resolution: Resolution::new(1919, 1080), + ..valid + }, + AmlencConfig { + resolution: Resolution::new(1920, 1079), + ..valid + }, + AmlencConfig { + resolution: Resolution::new(2560, 1440), + ..valid + }, + AmlencConfig { fps: 61, ..valid }, + ] { + assert!(invalid.validate().is_err()); + } + } + + #[test] + fn recognizes_vendor_and_mainline_gxm_compatibles() { + assert!(is_s912_gxm_compatible(b"amlogic, Gxm\0khadas,kvim2")); + assert!(is_s912_gxm_compatible( + b"amlogic,q200\0amlogic,s912\0amlogic,meson-gxm" + )); + assert!(!is_s912_gxm_compatible(b"rockchip,rk3588")); + } + + #[test] + fn validates_abi_marker() { + let path = Path::new("libvpcodec.so"); + assert!(validate_abi_version(AMLENC_ABI_VERSION, path).is_ok()); + assert!(validate_abi_version(0, path).is_err()); + } + + #[test] + fn parses_h264_idr_and_parameter_sets() { + let data = [0, 0, 0, 1, 0x67, 1, 0, 0, 1, 0x68, 2, 0, 0, 0, 1, 0x65, 3]; + assert!(is_keyframe(AmlencCodec::H264, &data)); + assert!(has_parameter_sets(AmlencCodec::H264, &data)); + assert!(!is_keyframe(AmlencCodec::H264, &[0, 0, 1, 0x41])); + } + + #[test] + fn parses_h265_irap_and_parameter_sets() { + let data = [ + 0, + 0, + 1, + 32 << 1, + 1, + 0, + 0, + 1, + 33 << 1, + 1, + 0, + 0, + 1, + 34 << 1, + 1, + 0, + 0, + 1, + 19 << 1, + 1, + ]; + assert!(is_keyframe(AmlencCodec::H265, &data)); + assert!(has_parameter_sets(AmlencCodec::H265, &data)); + assert!(!is_keyframe(AmlencCodec::H265, &[0, 0, 1, 1 << 1, 1])); + } + + #[test] + #[cfg(unix)] + fn loads_symbols_maps_both_abis_and_recovers() { + let _test_instance = TEST_INSTANCE_MUTEX.lock().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let h264_path = build_fixture(directory.path(), "amlenc_h264", H264_FIXTURE); + let h265_path = build_fixture(directory.path(), "amlenc_h265", H265_FIXTURE); + + type GetFn = unsafe extern "C" fn(c_int) -> c_int; + type SetModeFn = unsafe extern "C" fn(c_int); + type FailOnceFn = unsafe extern "C" fn(); + + // Keep this second dlopen alive so the fixture's counters remain available. + let h264_control = unsafe { Library::new(&h264_path) }.unwrap(); + let h264_get: GetFn = unsafe { *h264_control.get(b"test_get\0").unwrap() }; + let h264_set_mode: SetModeFn = unsafe { *h264_control.get(b"test_set_mode\0").unwrap() }; + let h264_fail_once: FailOnceFn = unsafe { *h264_control.get(b"test_fail_once\0").unwrap() }; + + let resolution = Resolution::new(640, 480); + let frame = vec![0x80; nv12_frame_size(resolution).unwrap()]; + { + let mut encoder = AmlencEncoder::with_library( + AmlencConfig { + codec: AmlencCodec::H264, + resolution, + fps: 60, + bitrate_kbps: 2_000, + gop: 60, + }, + &h264_path, + ) + .unwrap(); + assert!(encoder.encode_raw(&frame).unwrap().unwrap().1); + // SAFETY: indices and fixture signatures are fixed above. + unsafe { + assert_eq!(h264_get(1), CODEC_ID_H264); + assert_eq!(h264_get(4), 60); + assert_eq!(h264_get(5), 2_000_000); + assert_eq!(h264_get(6), 0); + assert_eq!(h264_get(7), IMG_FMT_NV12); + assert_eq!(h264_get(9), FRAME_TYPE_AUTO); + assert_eq!(h264_get(10), MIN_OUTPUT_BUFFER_SIZE as c_int); + assert_eq!(h264_get(11), H264_NV12_FORMAT); + + h264_fail_once(); + } + assert!(encoder.encode_raw(&frame).unwrap().is_some()); + unsafe { assert_eq!(h264_get(0), 2) }; + + unsafe { h264_set_mode(2) }; + encoder.request_keyframe(); + assert!(encoder.encode_raw(&frame).unwrap().is_none()); + unsafe { assert_eq!(h264_get(9), FRAME_TYPE_AUTO) }; + unsafe { assert_eq!(h264_get(0), 3) }; + assert!(encoder.encode_raw(&frame).unwrap().is_none()); + unsafe { assert_eq!(h264_get(9), FRAME_TYPE_AUTO) }; + assert!(encoder.encode_raw(&frame).unwrap().is_none()); + unsafe { assert_eq!(h264_get(0), 3) }; + + encoder.last_output = Instant::now() - OUTPUT_STALL_TIMEOUT; + assert!(encoder.encode_raw(&frame).unwrap().is_none()); + unsafe { assert_eq!(h264_get(0), 4) }; + + unsafe { h264_set_mode(0) }; + encoder.set_bitrate(3_000).unwrap(); + assert!(encoder.encode_raw(&frame).unwrap().unwrap().1); + unsafe { + assert_eq!(h264_get(5), 3_000_000); + assert_eq!(h264_get(9), FRAME_TYPE_AUTO); + assert_eq!(h264_get(0), 5); + } + } + + let h265_control = unsafe { Library::new(&h265_path) }.unwrap(); + let h265_get: GetFn = unsafe { *h265_control.get(b"test_get\0").unwrap() }; + let h265_set_mode: SetModeFn = unsafe { *h265_control.get(b"test_set_mode\0").unwrap() }; + { + let mut encoder = AmlencEncoder::with_library( + AmlencConfig { + codec: AmlencCodec::H265, + resolution, + fps: 30, + bitrate_kbps: 1_500, + gop: 30, + }, + &h265_path, + ) + .unwrap(); + assert!(encoder.encode_raw(&frame).unwrap().unwrap().1); + unsafe { + assert_eq!(h265_get(1), CODEC_ID_H265); + assert_eq!(h265_get(4), 30); + assert_eq!(h265_get(5), 1_500_000); + assert_eq!(h265_get(9), FRAME_TYPE_IDR); + assert_eq!(h265_get(10), MIN_OUTPUT_BUFFER_SIZE as c_int); + assert_eq!(h265_get(11), H265_NV12_FORMAT); + h265_set_mode(3); + } + let error = encoder.encode_raw(&frame).unwrap_err().to_string(); + assert!(error.contains("oversized output")); + } + } + + #[test] + #[cfg(unix)] + fn rejects_unpatched_library_without_abi_symbol() { + let _test_instance = TEST_INSTANCE_MUTEX.lock().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let path = build_fixture( + directory.path(), + "unpatched_amlenc", + "long vl_video_encoder_init(void) { return 1; }", + ); + let error = AmlencEncoder::with_library( + AmlencConfig { + codec: AmlencCodec::H264, + resolution: Resolution::new(640, 480), + fps: 30, + bitrate_kbps: 1_000, + gop: 30, + }, + path, + ) + .err() + .expect("unpatched library must be rejected") + .to_string(); + assert!(error.contains("one_kvm_amlenc_abi_version")); + } +} diff --git a/src/video/codec/convert.rs b/src/video/codec/convert.rs index f362f064..305edd7e 100644 --- a/src/video/codec/convert.rs +++ b/src/video/codec/convert.rs @@ -558,27 +558,43 @@ impl MjpegToNv12Decoder { } pub fn decode(&mut self, input: &[u8]) -> Result<&[u8]> { + self.check_size(input)?; let width = self.resolution.width as i32; let height = self.resolution.height as i32; - if !self.size_checked { - let (src_width, src_height) = libyuv::mjpg_size(input).map_err(|e| { - AppError::VideoError(format!("libyuv MJPEG header read failed: {}", e)) - })?; - if src_width != width || src_height != height { - return Err(AppError::VideoError(format!( - "libyuv MJPEG size mismatch: {}x{} (expected {}x{})", - src_width, src_height, width, height - ))); - } - self.size_checked = true; - } - libyuv::mjpg_to_nv12(input, self.output_buffer.as_bytes_mut(), width, height) .map_err(|e| AppError::VideoError(format!("libyuv MJPEG->NV12 failed: {}", e)))?; Ok(self.output_buffer.as_bytes()) } + + /// Decode into caller-owned storage so capture and encoding can run on + /// separate threads without copying a full NV12 frame. + pub fn decode_into(&mut self, input: &[u8], output: &mut Vec) -> Result<()> { + self.check_size(input)?; + let width = self.resolution.width as i32; + let height = self.resolution.height as i32; + libyuv::mjpg_to_nv12_vec(input, output, width, height) + .map_err(|e| AppError::VideoError(format!("libyuv MJPEG->NV12 failed: {}", e))) + } + + fn check_size(&mut self, input: &[u8]) -> Result<()> { + if self.size_checked { + return Ok(()); + } + let width = self.resolution.width as i32; + let height = self.resolution.height as i32; + let (src_width, src_height) = libyuv::mjpg_size(input) + .map_err(|e| AppError::VideoError(format!("libyuv MJPEG header read failed: {}", e)))?; + if src_width != width || src_height != height { + return Err(AppError::VideoError(format!( + "libyuv MJPEG size mismatch: {}x{} (expected {}x{})", + src_width, src_height, width, height + ))); + } + self.size_checked = true; + Ok(()) + } } impl Nv12Converter { @@ -862,4 +878,34 @@ mod tests { let result = converter.convert(&yuyv).unwrap(); assert_eq!(result.len(), 24); // 4*4 + 2*2 + 2*2 = 24 bytes } + + #[test] + fn test_mjpeg_decode_into_reuses_output_allocation() { + let resolution = Resolution::new(16, 16); + let pixels = vec![0x80; 16 * 16 * 3]; + let image = turbojpeg::Image { + pixels: pixels.as_slice(), + width: 16, + pitch: 16 * 3, + height: 16, + format: turbojpeg::PixelFormat::RGB, + }; + let mut compressor = turbojpeg::Compressor::new().unwrap(); + compressor.set_quality(80).unwrap(); + compressor.set_subsamp(turbojpeg::Subsamp::Sub2x2).unwrap(); + let jpeg = compressor.compress_to_vec(image).unwrap(); + + let output_size = 16 * 16 * 3 / 2; + let mut output = Vec::with_capacity(output_size); + let allocation = output.as_ptr(); + let mut decoder = MjpegToNv12Decoder::new(resolution); + + decoder.decode_into(&jpeg, &mut output).unwrap(); + assert_eq!(output.len(), output_size); + assert_eq!(output.as_ptr(), allocation); + + decoder.decode_into(&jpeg, &mut output).unwrap(); + assert_eq!(output.len(), output_size); + assert_eq!(output.as_ptr(), allocation); + } } diff --git a/src/video/codec/h264.rs b/src/video/codec/h264.rs index 13d7a3a8..5ebc31e6 100644 --- a/src/video/codec/h264.rs +++ b/src/video/codec/h264.rs @@ -48,6 +48,8 @@ pub enum H264EncoderType { Rkmpp, /// V4L2 M2M (ARM generic) - requires hwcodec extension V4l2M2m, + /// Amlogic S912/GXM AMLENC + Amlogic, /// Software encoding (libx264/openh264) Software, /// No encoder available @@ -64,6 +66,7 @@ impl std::fmt::Display for H264EncoderType { H264EncoderType::Vaapi => write!(f, "VAAPI"), H264EncoderType::Rkmpp => write!(f, "RKMPP"), H264EncoderType::V4l2M2m => write!(f, "V4L2 M2M"), + H264EncoderType::Amlogic => write!(f, "AMLENC"), H264EncoderType::Software => write!(f, "Software"), H264EncoderType::None => write!(f, "None"), } @@ -80,6 +83,7 @@ impl From for H264EncoderType { EncoderBackend::Vaapi => H264EncoderType::Vaapi, EncoderBackend::Rkmpp => H264EncoderType::Rkmpp, EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m, + EncoderBackend::Amlogic => H264EncoderType::Amlogic, EncoderBackend::Software => H264EncoderType::Software, } } diff --git a/src/video/codec/h265.rs b/src/video/codec/h265.rs index 08f3dbed..dd5df5e7 100644 --- a/src/video/codec/h265.rs +++ b/src/video/codec/h265.rs @@ -45,6 +45,8 @@ pub enum H265EncoderType { Rkmpp, /// V4L2 M2M (ARM generic) V4l2M2m, + /// Amlogic S912/GXM AMLENC + Amlogic, /// Software encoder (libx265) Software, /// No encoder available @@ -61,6 +63,7 @@ impl std::fmt::Display for H265EncoderType { H265EncoderType::Vaapi => write!(f, "VAAPI"), H265EncoderType::Rkmpp => write!(f, "RKMPP"), H265EncoderType::V4l2M2m => write!(f, "V4L2 M2M"), + H265EncoderType::Amlogic => write!(f, "AMLENC"), H265EncoderType::Software => write!(f, "Software"), H265EncoderType::None => write!(f, "None"), } @@ -76,6 +79,7 @@ impl From for H265EncoderType { EncoderBackend::Vaapi => H265EncoderType::Vaapi, EncoderBackend::Rkmpp => H265EncoderType::Rkmpp, EncoderBackend::V4l2m2m => H265EncoderType::V4l2M2m, + EncoderBackend::Amlogic => H265EncoderType::Amlogic, EncoderBackend::Software => H265EncoderType::Software, } } diff --git a/src/video/codec/mod.rs b/src/video/codec/mod.rs index ba0ab378..04d8823f 100644 --- a/src/video/codec/mod.rs +++ b/src/video/codec/mod.rs @@ -3,6 +3,7 @@ use hwcodec::common::DataFormat; use hwcodec::ffmpeg_ram::CodecInfo; +pub mod amlenc; pub mod convert; pub mod h264; @@ -19,6 +20,7 @@ pub mod vp9; #[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))] pub mod mjpeg_rkmpp; +pub use amlenc::{AmlencCodec, AmlencConfig, AmlencEncoder}; pub use convert::{MjpegToNv12Decoder, PixelConverter, Yuv420pBuffer}; pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat}; pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat}; diff --git a/src/video/codec/registry.rs b/src/video/codec/registry.rs index d12bb663..3baa50c8 100644 --- a/src/video/codec/registry.rs +++ b/src/video/codec/registry.rs @@ -10,11 +10,17 @@ use std::sync::OnceLock; use std::time::Duration; use tracing::{debug, info, warn}; +use super::amlenc::{self, AmlencCodec, AMLENC_H264_CODEC_NAME, AMLENC_H265_CODEC_NAME}; + use hwcodec::common::{DataFormat, Quality, RateControl}; use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat}; use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder}; use hwcodec::ffmpeg_ram::CodecInfo; +// Keep native AMLENC behind the highest-priority desktop GPU backends while +// ensuring it is selected before hwcodec's software priority (3). +const AMLENC_PRIORITY: i32 = 2; + /// Video encoder format type #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum VideoEncoderType { @@ -96,6 +102,8 @@ pub enum EncoderBackend { Rkmpp, /// V4L2 Memory-to-Memory (ARM) V4l2m2m, + /// Amlogic S912/GXM vendor AMLENC + Amlogic, /// Software encoding (libx264, libx265, libvpx) Software, } @@ -115,6 +123,8 @@ impl EncoderBackend { EncoderBackend::Rkmpp } else if name.contains("v4l2m2m") { EncoderBackend::V4l2m2m + } else if name.contains("amlenc") { + EncoderBackend::Amlogic } else { EncoderBackend::Software } @@ -134,6 +144,7 @@ impl EncoderBackend { EncoderBackend::Amf => "AMF", EncoderBackend::Rkmpp => "RKMPP", EncoderBackend::V4l2m2m => "V4L2 M2M", + EncoderBackend::Amlogic => "AMLENC", EncoderBackend::Software => "Software", } } @@ -148,6 +159,7 @@ impl EncoderBackend { "amf" => Some(EncoderBackend::Amf), "rkmpp" => Some(EncoderBackend::Rkmpp), "v4l2m2m" | "v4l2" => Some(EncoderBackend::V4l2m2m), + "amlogic" | "amlenc" => Some(EncoderBackend::Amlogic), "software" | "cpu" => Some(EncoderBackend::Software), _ => None, } @@ -274,6 +286,79 @@ impl EncoderRegistry { } } + fn detect_amlenc(&mut self) { + match amlenc::system_is_s912_gxm() { + Ok(true) => {} + Ok(false) => { + debug!("AMLENC skipped: host is not Linux/aarch64 S912/GXM"); + return; + } + Err(error) => { + warn!("AMLENC skipped: {}", error); + return; + } + } + + self.detect_amlenc_candidates( + true, + |codec| std::path::Path::new(codec.device_node()).exists(), + amlenc::smoke_test, + ); + } + + fn detect_amlenc_candidates( + &mut self, + compatible: bool, + mut node_exists: NodeExists, + mut smoke_test: SmokeTest, + ) where + NodeExists: FnMut(AmlencCodec) -> bool, + SmokeTest: FnMut(AmlencCodec) -> crate::error::Result<()>, + { + if !compatible { + return; + } + + for (codec, format, codec_name) in [ + ( + AmlencCodec::H264, + VideoEncoderType::H264, + AMLENC_H264_CODEC_NAME, + ), + ( + AmlencCodec::H265, + VideoEncoderType::H265, + AMLENC_H265_CODEC_NAME, + ), + ] { + let node = codec.device_node(); + if !node_exists(codec) { + warn!( + "AMLENC {} unavailable: device node {} is missing", + format, node + ); + continue; + } + + match smoke_test(codec) { + Ok(()) => { + self.encoders + .entry(format) + .or_default() + .push(AvailableEncoder { + format, + codec_name: codec_name.to_string(), + backend: EncoderBackend::Amlogic, + priority: AMLENC_PRIORITY, + is_hardware: true, + }); + info!("Registered native AMLENC encoder: {}", codec_name); + } + Err(error) => warn!("AMLENC {} unavailable ({}): {}", format, node, error), + } + } + } + /// Get the global registry instance /// /// The registry is initialized lazily on first access with 1280x720 detection. @@ -341,6 +426,8 @@ impl EncoderRegistry { } } + self.detect_amlenc(); + // Sort encoders by priority (lower is better) for encoders in self.encoders.values_mut() { encoders.sort_by_key(|e| e.priority); @@ -537,6 +624,14 @@ mod tests { EncoderBackend::from_codec_name("libx264"), EncoderBackend::Software ); + assert_eq!( + EncoderBackend::from_codec_name("h264_amlenc"), + EncoderBackend::Amlogic + ); + assert_eq!( + EncoderBackend::from_str("amlogic"), + Some(EncoderBackend::Amlogic) + ); } #[test] @@ -561,4 +656,65 @@ mod tests { println!("Available formats: {:?}", registry.available_formats(false)); println!("Selectable formats: {:?}", registry.selectable_formats()); } + + #[test] + fn test_amlenc_registration_prerequisite_matrix() { + let ok = |_codec| Ok(()); + + let mut incompatible = EncoderRegistry::new(); + incompatible.detect_amlenc_candidates(false, |_| true, ok); + assert!(incompatible.encoders.is_empty()); + + let mut no_nodes = EncoderRegistry::new(); + no_nodes.detect_amlenc_candidates(true, |_| false, ok); + assert!(no_nodes.encoders.is_empty()); + + for reason in ["library missing", "ABI marker missing"] { + let mut rejected = EncoderRegistry::new(); + rejected.detect_amlenc_candidates( + true, + |_| true, + |_| Err(crate::error::AppError::VideoError(reason.to_string())), + ); + assert!(rejected.encoders.is_empty()); + } + + let mut h264_only = EncoderRegistry::new(); + h264_only.detect_amlenc_candidates(true, |codec| codec == AmlencCodec::H264, ok); + assert!(h264_only + .encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Amlogic) + .is_some()); + assert!(h264_only + .encoder_with_backend(VideoEncoderType::H265, EncoderBackend::Amlogic) + .is_none()); + + let mut both = EncoderRegistry::new(); + both.detect_amlenc_candidates(true, |_| true, ok); + assert!(both + .encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Amlogic) + .is_some()); + assert!(both + .encoder_with_backend(VideoEncoderType::H265, EncoderBackend::Amlogic) + .is_some()); + + both.encoders + .entry(VideoEncoderType::H264) + .or_default() + .push(AvailableEncoder { + format: VideoEncoderType::H264, + codec_name: "libx264".to_string(), + backend: EncoderBackend::Software, + priority: 3, + is_hardware: false, + }); + both.encoders + .get_mut(&VideoEncoderType::H264) + .unwrap() + .sort_by_key(|encoder| encoder.priority); + assert_eq!( + both.best_available_encoder(VideoEncoderType::H264) + .map(|encoder| encoder.backend), + Some(EncoderBackend::Amlogic) + ); + } } diff --git a/src/video/codec/self_check.rs b/src/video/codec/self_check.rs index be6eed32..8325e95a 100644 --- a/src/video/codec/self_check.rs +++ b/src/video/codec/self_check.rs @@ -3,8 +3,8 @@ use std::sync::mpsc; use std::time::{Duration, Instant}; use super::{ - EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder, - VP9Config, VP9Encoder, VideoEncoderType, + AmlencCodec, AmlencConfig, AmlencEncoder, EncoderRegistry, H264Config, H264Encoder, H265Config, + H265Encoder, VP8Config, VP8Encoder, VP9Config, VP9Encoder, VideoEncoderType, }; use crate::error::{AppError, Result}; use crate::video::format::{PixelFormat, Resolution}; @@ -226,6 +226,9 @@ fn run_smoke_test( resolution: Resolution, codec_name_ffmpeg: &str, ) -> Result<()> { + if codec_name_ffmpeg.contains("amlenc") { + return run_amlenc_smoke_test(codec, resolution); + } match codec { VideoEncoderType::H264 => run_h264_smoke_test(resolution, codec_name_ffmpeg), VideoEncoderType::H265 => run_h265_smoke_test(resolution, codec_name_ffmpeg), @@ -234,6 +237,37 @@ fn run_smoke_test( } } +fn run_amlenc_smoke_test(codec: VideoEncoderType, resolution: Resolution) -> Result<()> { + let amlenc_codec = match codec { + VideoEncoderType::H264 => AmlencCodec::H264, + VideoEncoderType::H265 => AmlencCodec::H265, + _ => { + return Err(AppError::VideoError( + "AMLENC only supports H.264 and H.265".to_string(), + )) + } + }; + let mut encoder = AmlencEncoder::new(AmlencConfig { + codec: amlenc_codec, + resolution, + fps: 30, + bitrate_kbps: bitrate_kbps_for_resolution(resolution), + gop: 30, + })?; + let frame_len = PixelFormat::Nv12.frame_size(resolution).ok_or_else(|| { + AppError::VideoError("Cannot calculate AMLENC NV12 self-check size".to_string()) + })?; + let frame = build_nv12_test_frame(resolution, frame_len); + for _ in 0..SELF_CHECK_FRAME_ATTEMPTS { + if encoder.encode_raw(&frame)?.is_some() { + return Ok(()); + } + } + Err(AppError::VideoError( + "AMLENC produced no output after multiple frames".to_string(), + )) +} + fn run_h264_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> { let mut encoder = H264Encoder::with_codec( H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)), diff --git a/src/video/device/bridge.rs b/src/video/device/bridge.rs index 174688c8..c855b245 100644 --- a/src/video/device/bridge.rs +++ b/src/video/device/bridge.rs @@ -3,6 +3,7 @@ use std::fs::File; use std::io; use std::os::fd::{AsFd, AsRawFd, FromRawFd}; +use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::sync::mpsc; use std::thread; @@ -14,7 +15,9 @@ use tracing::{debug, info, warn}; use v4l2r::bindings::{ v4l2_bt_timings, v4l2_dv_timings, V4L2_DV_BT_656_1120, V4L2_DV_FL_HAS_CEA861_VIC, }; -use v4l2r::ioctl::{self, Event as V4l2Event, EventType, QueryDvTimingsError, SubscribeEventFlags}; +use v4l2r::ioctl::{ + self, Event as V4l2Event, EventType, IntoErrno, QueryDvTimingsError, SubscribeEventFlags, +}; use v4l2r::nix::errno::Errno; use crate::video::signal::SignalStatus; @@ -53,6 +56,7 @@ pub enum ProbeResult { NoSync, OutOfRange, NoSignal, + Unavailable, } impl ProbeResult { @@ -63,6 +67,7 @@ impl ProbeResult { ProbeResult::NoSync => Some(SignalStatus::NoSync), ProbeResult::OutOfRange => Some(SignalStatus::OutOfRange), ProbeResult::NoSignal => Some(SignalStatus::NoSignal), + ProbeResult::Unavailable => None, } } @@ -132,19 +137,58 @@ fn read_sysfs_name(subdev_sysfs: &Path) -> Option { } pub fn open_subdev(path: &Path) -> io::Result { - File::options().read(true).write(true).open(path) + File::options() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(path) +} + +/// Drain a non-blocking V4L2 event queue until the driver reports that it is +/// empty. Both video nodes and subdevices use the same event ioctl contract. +pub fn drain_v4l2_events(fd: &File) -> u32 { + let mut drained = 0u32; + loop { + match ioctl::dqevent::(fd) { + Ok(_event) => { + drained = drained.saturating_add(1); + if drained >= 16 { + break; + } + } + Err(error) => { + let message = error.to_string(); + let errno = error.into_errno(); + if errno != Errno::EAGAIN as i32 && errno != Errno::ENOENT as i32 { + debug!("Failed to drain V4L2 event queue: {}", message); + } + break; + } + } + } + drained } pub fn probe_signal(subdev_fd: &impl AsRawFd, kind: CsiBridgeKind) -> ProbeResult { match ioctl::query_dv_timings::(subdev_fd) { Ok(timings) => classify_timings(timings, kind), - Err(QueryDvTimingsError::NoLink) => ProbeResult::NoCable, - Err(QueryDvTimingsError::UnstableSignal) => ProbeResult::NoSync, - Err(QueryDvTimingsError::IoctlError(Errno::ERANGE)) => ProbeResult::OutOfRange, - Err(QueryDvTimingsError::IoctlError(Errno::EIO | Errno::EREMOTEIO | Errno::ETIMEDOUT)) => { + Err(error) => classify_query_error(&error, kind), + } +} + +fn classify_query_error(error: &QueryDvTimingsError, kind: CsiBridgeKind) -> ProbeResult { + match error { + QueryDvTimingsError::NoLink => ProbeResult::NoCable, + QueryDvTimingsError::UnstableSignal => ProbeResult::NoSync, + QueryDvTimingsError::IoctlError(Errno::ERANGE) => ProbeResult::OutOfRange, + QueryDvTimingsError::IoctlError(Errno::EIO | Errno::EREMOTEIO | Errno::ETIMEDOUT) => { ProbeResult::NoSync } - Err(QueryDvTimingsError::Unsupported) | Err(QueryDvTimingsError::IoctlError(_)) => { + QueryDvTimingsError::Unsupported + | QueryDvTimingsError::IoctlError( + Errno::ENOTTY | Errno::EINVAL | Errno::ENOSYS | Errno::EOPNOTSUPP, + ) if kind == CsiBridgeKind::Unknown => ProbeResult::Unavailable, + QueryDvTimingsError::Unsupported | QueryDvTimingsError::IoctlError(_) => { ProbeResult::NoSignal } } @@ -179,7 +223,7 @@ pub fn probe_signal_thread_timeout( Some(r) } Err(mpsc::RecvTimeoutError::Timeout) => { - warn!( + debug!( "QUERY_DV_TIMINGS exceeded {:?} (RK628 HDMI mode change?) — abandoning probe thread", limit ); @@ -286,13 +330,7 @@ pub fn wait_source_change(subdev_fd: &File, timeout: Duration) -> io::Result(subdev_fd) { - drained = drained.saturating_add(1); - if drained >= 16 { - break; - } - } + let drained = drain_v4l2_events(subdev_fd); debug!("subdev source_change drained {} event(s)", drained); Ok(true) } @@ -301,6 +339,15 @@ pub fn wait_source_change(subdev_fd: &File, timeout: Duration) -> io::Result= 0); + assert_ne!(flags & libc::O_NONBLOCK, 0); + } + #[test] fn rk628_fingerprint_matches_vga() { let mut bt: v4l2_bt_timings = unsafe { std::mem::zeroed() }; @@ -352,4 +399,27 @@ mod tests { ); assert_eq!(CsiBridgeKind::from_subdev_name("mystery"), None); } + + #[test] + fn query_errno_mapping_distinguishes_signal_loss_from_unsupported_nodes() { + assert!(matches!( + classify_query_error(&QueryDvTimingsError::NoLink, CsiBridgeKind::RkHdmirx), + ProbeResult::NoCable + )); + assert!(matches!( + classify_query_error( + &QueryDvTimingsError::UnstableSignal, + CsiBridgeKind::RkHdmirx + ), + ProbeResult::NoSync + )); + assert!(matches!( + classify_query_error(&QueryDvTimingsError::Unsupported, CsiBridgeKind::RkHdmirx), + ProbeResult::NoSignal + )); + assert!(matches!( + classify_query_error(&QueryDvTimingsError::Unsupported, CsiBridgeKind::Unknown), + ProbeResult::Unavailable + )); + } } diff --git a/src/video/device/disabled_bridge.rs b/src/video/device/disabled_bridge.rs index 6da93963..0059c767 100644 --- a/src/video/device/disabled_bridge.rs +++ b/src/video/device/disabled_bridge.rs @@ -22,6 +22,7 @@ pub enum ProbeResult { NoSync, OutOfRange, NoSignal, + Unavailable, } impl ProbeResult { @@ -32,6 +33,7 @@ impl ProbeResult { ProbeResult::NoSync => Some(SignalStatus::NoSync), ProbeResult::OutOfRange => Some(SignalStatus::OutOfRange), ProbeResult::NoSignal => Some(SignalStatus::NoSignal), + ProbeResult::Unavailable => None, } } diff --git a/src/video/device/linux.rs b/src/video/device/linux.rs index 4cd4adf1..8c2551ce 100644 --- a/src/video/device/linux.rs +++ b/src/video/device/linux.rs @@ -17,7 +17,9 @@ use v4l2r::nix::errno::Errno; use v4l2r::{Format as V4l2rFormat, QueueType}; use super::bridge as csi_bridge; -use super::{is_rk_hdmirx_driver, is_rkcif_driver}; +use super::{ + control_mode, is_rk_hdmirx_driver, is_rkcif_driver, VideoControlMode, VideoInputStatus, +}; use crate::error::{AppError, Result}; use crate::video::format::{PixelFormat, Resolution}; @@ -48,6 +50,8 @@ pub struct VideoDeviceInfo { /// Whether an HDMI signal is currently detected (CSI/HDMI bridge devices only; /// always `true` for USB capture cards). pub has_signal: bool, + pub control_mode: VideoControlMode, + pub input_status: VideoInputStatus, /// Path of the bridge subdev (`/dev/v4l-subdevN`) paired with this /// capture node, if any. On Rockchip boards that wire an RK628 / /// TC358746 / RK-HDMIRX through `rkcif`, `QUERY_DV_TIMINGS`, @@ -129,6 +133,16 @@ pub struct VideoDevice { fd: File, } +struct LiveInputProbe { + control_mode: VideoControlMode, + input_status: VideoInputStatus, + has_signal: bool, + hdmi_mode: Option<(u32, u32, Option)>, + hdmi_fps: Option, + subdev_path: Option, + bridge_kind: Option, +} + impl VideoDevice { /// Open a video device by path pub fn open(path: impl AsRef) -> Result { @@ -173,6 +187,106 @@ impl VideoDevice { }) } + pub fn input_status(&self) -> Result { + let caps: V4l2rCapability = ioctl::querycap(&self.fd) + .map_err(|e| AppError::VideoError(format!("Failed to query capabilities: {}", e)))?; + Ok(self.probe_live_input(&caps).input_status) + } + + fn probe_live_input(&self, caps: &V4l2rCapability) -> LiveInputProbe { + let control_mode = control_mode(&caps.driver, &caps.card); + if control_mode == VideoControlMode::Configurable { + let input_status = self + .get_format() + .ok() + .and_then(|fmt| { + PixelFormat::from_v4l2r(fmt.pixelformat) + .map(|format| (format, fmt.width, fmt.height)) + }) + .map(|(format, width, height)| { + VideoInputStatus::locked( + format, + width, + height, + self.current_parm_fps().unwrap_or(0.0), + ) + }) + .unwrap_or_else(VideoInputStatus::unavailable); + return LiveInputProbe { + control_mode, + input_status, + has_signal: true, + hdmi_mode: None, + hdmi_fps: None, + subdev_path: None, + bridge_kind: None, + }; + } + + let (subdev_path, bridge_kind) = match csi_bridge::discover_subdev_for_video(&self.path) { + Some((path, kind)) => (Some(path), Some(format!("{:?}", kind).to_lowercase())), + None if is_rk_hdmirx_driver(&caps.driver, &caps.card) => { + (None, Some("rkhdmirx".to_string())) + } + None => (None, None), + }; + + let probe = if let Some(path) = subdev_path.as_ref() { + match csi_bridge::open_subdev(path) { + Ok(fd) => { + let kind = parse_bridge_kind(bridge_kind.as_deref()) + .unwrap_or(csi_bridge::CsiBridgeKind::Unknown); + csi_bridge::probe_signal_thread_timeout( + &fd, + kind, + csi_bridge::RK628_SUBDEV_PROBE_TIMEOUT, + ) + } + Err(error) => { + warn!("Failed to open subdev {:?}: {}", path, error); + None + } + } + } else { + let kind = if is_rk_hdmirx_driver(&caps.driver, &caps.card) { + csi_bridge::CsiBridgeKind::RkHdmirx + } else { + csi_bridge::CsiBridgeKind::Unknown + }; + Some(csi_bridge::probe_signal(&self.fd, kind)) + }; + + let (input_status, hdmi_mode, hdmi_fps, has_signal) = match probe { + Some(csi_bridge::ProbeResult::Locked(mode)) if mode.width > 64 && mode.height > 64 => { + let fps = mode.fps.or_else(|| self.current_parm_fps()); + let hdmi_mode = Some((mode.width, mode.height, fps)); + let status = VideoInputStatus::locked_with_optional_fps( + self.current_active_format(), + mode.width, + mode.height, + fps, + ); + (status, hdmi_mode, fps, true) + } + Some(csi_bridge::ProbeResult::Unavailable) => { + (VideoInputStatus::unavailable(), None, None, false) + } + Some(_) => (VideoInputStatus::no_signal(), None, None, false), + None if subdev_path.is_some() => (VideoInputStatus::unavailable(), None, None, false), + None => (VideoInputStatus::unavailable(), None, None, false), + }; + + LiveInputProbe { + control_mode, + input_status, + has_signal, + hdmi_mode, + hdmi_fps, + subdev_path, + bridge_kind, + } + } + /// Get detailed device information pub fn info(&self) -> Result { let caps: V4l2rCapability = ioctl::querycap(&self.fd) @@ -186,102 +300,26 @@ impl VideoDevice { read_write: flags.contains(Capabilities::READWRITE), }; - // For CSI/HDMI bridges, try to locate the paired subdev *before* - // the signal check: RK628 + rkcif places QUERY_DV_TIMINGS on the - // subdev (the video node returns ENOTTY). Tc358743 and rk_hdmirx - // typically expose DV ioctls on the video node itself, but having - // the subdev handle for EDID/event subscription doesn't hurt. - let (subdev_path, bridge_kind) = - if is_rkcif_driver(&caps.driver) || is_rk_hdmirx_driver(&caps.driver, &caps.card) { - match csi_bridge::discover_subdev_for_video(&self.path) { - Some((path, kind)) => (Some(path), Some(format!("{:?}", kind).to_lowercase())), - None => (None, None), - } - } else { - (None, None) - }; + let live = self.probe_live_input(&caps); + let subdev_hdmi_mode = live.hdmi_mode; + let hdmi_fps = live.hdmi_fps; + let has_signal = live.has_signal; - // Probe the HDMI source for both signal presence *and* the live - // frame-rate. rkcif's `VIDIOC_ENUM_FRAMEINTERVALS` returns a - // meaningless `1.0..30.0` StepWise range, so the only trustworthy - // fps for rkcif + RK628 / rk_hdmirx boards comes from the bridge - // subdev's DV timings (pixelclock / total_width / total_height). - // - // Preference order: - // 1. Bridge subdev — on rkcif boards this is the *only* node - // where QUERY_DV_TIMINGS works, and it lets the RK628 - // fingerprint filter kick in before we return has_signal=true. - // 2. Video node fallback — for rk_hdmirx / tc358743 where DV - // timings are exposed on the capture node directly. - // 3. USB UVC — always true (no signal concept), no hdmi_fps. - // Subdev-reported HDMI source mode (width, height, fps). On rkcif + - // RK628 boards this is the *only* place DV timings work; the video - // node itself returns ENOTTY for QUERY/G_DV_TIMINGS, so without - // threading this through to `enumerate_bridge_formats` the format - // list ends up with zero resolutions and `select_resolution` falls - // back to the user's preferred value (e.g. 4K) even when the real - // source is 1080p. - let mut subdev_hdmi_mode: Option<(u32, u32, Option)> = None; - - let (has_signal, hdmi_fps) = if let Some(subdev_path) = subdev_path.as_ref() { - match csi_bridge::open_subdev(subdev_path) { - Ok(subdev_fd) => { - let kind = parse_bridge_kind(bridge_kind.as_deref()) - .unwrap_or(csi_bridge::CsiBridgeKind::Unknown); - let probe = csi_bridge::probe_signal(&subdev_fd, kind); - debug!( - "has_signal via subdev {:?} ({:?}): {:?}", - subdev_path, kind, probe - ); - let fps = match &probe { - csi_bridge::ProbeResult::Locked(mode) => { - subdev_hdmi_mode = Some((mode.width, mode.height, mode.fps)); - mode.fps - } - _ => None, - }; - (probe.is_locked(), fps) - } - Err(e) => { - warn!("Failed to open subdev {:?}: {}", subdev_path, e); - (false, None) - } - } - } else if is_rk_hdmirx_driver(&caps.driver, &caps.card) || is_rkcif_driver(&caps.driver) { - let dv = self.current_dv_timings_mode(); - debug!( - "has_signal via video node {:?} (driver={}): dv_timings={:?}", - self.path, caps.driver, dv - ); - let has_signal = dv - .as_ref() - .map(|(w, h, _)| *w > 64 && *h > 64) - .unwrap_or(false); - let fps = if has_signal { - dv.and_then(|(_, _, f)| f) - } else { - None - }; - (has_signal, fps) + let native_hdmirx = is_rk_hdmirx_driver(&caps.driver, &caps.card); + let mut formats = if native_hdmirx || is_rkcif_driver(&caps.driver) { + // CSI/HDMI bridge drivers (rk_hdmirx, rkcif) expose multiple pixel + // formats via ENUM_FMT (e.g. rk_hdmirx: BGR3/NV24/NV16/NV12) but + // `ENUM_FRAMESIZES` is fiction for these drivers (rkcif reports a + // degenerate `64x64 StepWise 8/8` that only describes its DMA + // engine, rk_hdmirx returns ENOTTY). The only authoritative + // resolution is whatever the bridge subdev's DV timings report, + // so we treat the HDMI source mode as the single allowed + // resolution for every pixel format. + self.enumerate_bridge_formats(subdev_hdmi_mode, native_hdmirx)? } else { - (true, None) + self.enumerate_formats()? }; - let mut formats = - if is_rk_hdmirx_driver(&caps.driver, &caps.card) || is_rkcif_driver(&caps.driver) { - // CSI/HDMI bridge drivers (rk_hdmirx, rkcif) expose multiple pixel - // formats via ENUM_FMT (e.g. rk_hdmirx: BGR3/NV24/NV16/NV12) but - // `ENUM_FRAMESIZES` is fiction for these drivers (rkcif reports a - // degenerate `64x64 StepWise 8/8` that only describes its DMA - // engine, rk_hdmirx returns ENOTTY). The only authoritative - // resolution is whatever the bridge subdev's DV timings report, - // so we treat the HDMI source mode as the single allowed - // resolution for every pixel format. - self.enumerate_bridge_formats(subdev_hdmi_mode)? - } else { - self.enumerate_formats()? - }; - // For CSI/HDMI bridges, the driver-enumerated fps list is fiction // (rkcif: always `1..30`; rk_hdmirx: typically `ENOTTY`). Replace // it with the live HDMI source fps derived from the bridge DV @@ -299,7 +337,7 @@ impl VideoDevice { debug!( "Device {:?}: {} formats, priority={}, has_signal={}, hdmi_fps={:?}, is_capture_card={}, subdev={:?}", - self.path, formats.len(), priority, has_signal, hdmi_fps, is_capture_card, subdev_path + self.path, formats.len(), priority, has_signal, hdmi_fps, is_capture_card, live.subdev_path ); Ok(VideoDeviceInfo { @@ -313,8 +351,10 @@ impl VideoDevice { is_capture_card, priority, has_signal, - subdev_path, - bridge_kind, + control_mode: live.control_mode, + input_status: live.input_status, + subdev_path: live.subdev_path, + bridge_kind: live.bridge_kind, }) } @@ -373,12 +413,13 @@ impl VideoDevice { /// HDMI source mode. /// /// Returned formats are sorted by `PixelFormat::priority()` so the - /// higher-level `select_format` picks a sensible default (NV12 > YUYV on - /// rkcif / rk_hdmirx) instead of whatever the driver happens to - /// have stuck as the current active format. + /// higher-level `select_format` picks a sensible default for conversion- + /// capable rkcif paths. Native HDMI RX is reduced to its single current + /// wire format before sorting. fn enumerate_bridge_formats( &self, subdev_hdmi_mode: Option<(u32, u32, Option)>, + current_format_only: bool, ) -> Result> { let queue = self.capture_queue_type()?; let current_fmt = self.get_format().ok(); @@ -432,6 +473,31 @@ impl VideoDevice { continue; }; + // Native RK3588 HDMI RX does not perform pixel-format conversion. + // ENUM_FMT reports every input encoding the controller can receive, + // but TRY_FMT/S_FMT accept only the FourCC corresponding to the + // source's current AVI InfoFrame (RGB -> BGR3, YUV444 -> NV24, + // YUV422 -> NV16, YUV420 -> NV12). Advertising the full ENUM_FMT + // list makes the higher layer prefer NV12 even for an RGB source, + // and capture then fails with EINVAL. G_FMT is the driver's + // authoritative current-input format. + if current_format_only { + let Some(active) = current_fmt.as_ref() else { + debug!( + "enumerate_bridge_formats: skipping native HDMI RX format {:?} because G_FMT is unavailable", + desc.pixelformat + ); + continue; + }; + if active.pixelformat != desc.pixelformat { + debug!( + "enumerate_bridge_formats: skipping inactive rk_hdmirx format {:?}; current is {:?}", + desc.pixelformat, active.pixelformat + ); + continue; + } + } + let resolutions = hdmi_mode.clone().into_iter().collect(); formats.push(FormatInfo { @@ -685,6 +751,7 @@ impl VideoDevice { "uvc", "rkcif", "rk_hdmirx", + "snps_hdmirx", ]; // Check card/driver names @@ -783,9 +850,7 @@ impl VideoDevice { } fn current_dv_timings_mode(&self) -> Option<(u32, u32, Option)> { - let timings = ioctl::query_dv_timings::(&self.fd) - .or_else(|_| ioctl::g_dv_timings::(&self.fd)) - .ok()?; + let timings = ioctl::query_dv_timings::(&self.fd).ok()?; if timings.type_ != V4L2_DV_BT_656_1120 { return None; @@ -1145,7 +1210,34 @@ fn sysfs_maybe_capture(path: &Path) -> bool { .to_lowercase(); let driver = extract_uevent_value(&uevent, "driver"); - let mut maybe_capture = false; + sysfs_identity_maybe_capture(&sysfs_name, driver.as_deref()) +} + +fn sysfs_identity_maybe_capture(sysfs_name: &str, driver: Option<&str>) -> bool { + // rkisp mainpath/selfpath are real frame-capture queues even though their + // names contain the generic "isp" skip hint below. Keep the allow-list + // narrow: rkisp also registers metadata and raw read/write nodes that + // must not be probed as ordinary frame-capture devices. + let is_rkisp_capture = sysfs_name.contains("rkisp") + && (sysfs_name.contains("mainpath") || sysfs_name.contains("selfpath")); + let is_rkisp_non_capture = [ + "rkisp1_stats", + "rkisp1_params", + "rkisp_stats", + "rkisp_params", + "rkisp-statistics", + "rkisp-input-params", + "rkisp_rawrd", + "rkisp_rawwr", + ] + .iter() + .any(|hint| sysfs_name.contains(hint)); + + if is_rkisp_non_capture { + return false; + } + + let mut maybe_capture = is_rkisp_capture; let capture_hints = [ "capture", "hdmi", @@ -1158,15 +1250,17 @@ fn sysfs_maybe_capture(path: &Path) -> bool { "grabber", "rkcif", "rk_hdmirx", + "snps_hdmirx", ]; if capture_hints.iter().any(|hint| sysfs_name.contains(hint)) { maybe_capture = true; } - if let Some(driver) = &driver { + if let Some(driver) = driver { if driver.contains("uvcvideo") || driver.contains("tc358743") || driver.contains("rkcif") || driver.contains("rk_hdmirx") + || driver.contains("snps_hdmirx") { maybe_capture = true; } @@ -1185,28 +1279,15 @@ fn sysfs_maybe_capture(path: &Path) -> bool { "mpp_", "rockchip-vpu", ]; - if let Some(driver) = &driver { + if let Some(driver) = driver { if driver_skip.iter().any(|hint| driver.contains(hint)) { return false; } } let skip_hints = [ - "codec", - "decoder", - "encoder", - "isp", - "mem2mem", - "m2m", - "vbi", - "radio", - "metadata", + "codec", "decoder", "encoder", "isp", "mem2mem", "m2m", "vbi", "radio", "metadata", "output", - // rkisp sub-nodes that are not video capture queues - "rkisp-statistics", - "rkisp-input-params", - "rkisp_rawrd", - "rkisp_rawwr", ]; if skip_hints.iter().any(|hint| sysfs_name.contains(hint)) && !maybe_capture { return false; @@ -1329,6 +1410,8 @@ mod tests { is_capture_card, priority, has_signal: true, + control_mode: control_mode(driver, card), + input_status: VideoInputStatus::unavailable(), subdev_path: None, bridge_kind: None, } @@ -1350,6 +1433,39 @@ mod tests { assert!(res.is_valid()); } + #[test] + fn sysfs_filter_keeps_rkisp_frame_capture_nodes() { + assert!(sysfs_identity_maybe_capture( + "rkisp1_mainpath", + Some("rkisp1") + )); + assert!(sysfs_identity_maybe_capture( + "rkisp1_selfpath", + Some("rkisp1") + )); + assert!(sysfs_identity_maybe_capture( + "rkisp_mainpath", + Some("rkisp1_v0") + )); + } + + #[test] + fn sysfs_filter_rejects_rkisp_non_frame_nodes() { + for name in [ + "rkisp1_stats", + "rkisp1_params", + "rkisp-statistics", + "rkisp-input-params", + "rkisp_rawrd0", + "rkisp_rawwr0", + ] { + assert!( + !sysfs_identity_maybe_capture(name, Some("rkisp1")), + "{name} must not be treated as a frame-capture node" + ); + } + } + #[test] fn recovery_selection_prefers_original_path() { let original = test_device( diff --git a/src/video/device/mod.rs b/src/video/device/mod.rs index 4461b8a2..10b10fa3 100644 --- a/src/video/device/mod.rs +++ b/src/video/device/mod.rs @@ -13,6 +13,82 @@ pub use linux::{ #[cfg(windows)] pub use windows::*; +use serde::{Deserialize, Serialize}; + +use crate::video::format::{PixelFormat, Resolution}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VideoControlMode { + Configurable, + SourceFollowing, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VideoInputState { + Locked, + NoSignal, + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VideoInputStatus { + pub state: VideoInputState, + pub format: Option, + pub width: Option, + pub height: Option, + pub fps: Option, +} + +impl VideoInputStatus { + pub fn locked(format: PixelFormat, width: u32, height: u32, fps: f64) -> Self { + Self::locked_with_optional_fps(Some(format), width, height, Some(fps)) + } + + pub fn locked_with_optional_fps( + format: Option, + width: u32, + height: u32, + fps: Option, + ) -> Self { + Self { + state: VideoInputState::Locked, + format: format.map(|format| format.to_string()), + width: Some(width), + height: Some(height), + fps, + } + } + + pub const fn no_signal() -> Self { + Self { + state: VideoInputState::NoSignal, + format: None, + width: None, + height: None, + fps: None, + } + } + + pub const fn unavailable() -> Self { + Self { + state: VideoInputState::Unavailable, + format: None, + width: None, + height: None, + fps: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ResolvedVideoInputConfig { + pub format: PixelFormat, + pub resolution: Resolution, + pub fps: u32, +} + #[cfg(unix)] pub mod bridge; #[cfg(windows)] @@ -20,21 +96,168 @@ pub mod bridge; pub mod bridge; pub(crate) fn is_rk_hdmirx_driver(driver: &str, card: &str) -> bool { - driver.eq_ignore_ascii_case("rk_hdmirx") || card.eq_ignore_ascii_case("rk_hdmirx") -} - -pub(crate) fn is_rk_hdmirx_device(device: &VideoDeviceInfo) -> bool { - is_rk_hdmirx_driver(&device.driver, &device.card) + [driver, card].iter().any(|name| { + name.eq_ignore_ascii_case("rk_hdmirx") || name.eq_ignore_ascii_case("snps_hdmirx") + }) } pub(crate) fn is_rkcif_driver(driver: &str) -> bool { - driver.eq_ignore_ascii_case("rkcif") + driver.to_ascii_lowercase().starts_with("rkcif") +} + +pub fn control_mode(driver: &str, card: &str) -> VideoControlMode { + if is_rkcif_driver(driver) || is_rk_hdmirx_driver(driver, card) { + VideoControlMode::SourceFollowing + } else { + VideoControlMode::Configurable + } } /// Unified check for CSI/HDMI bridge devices (rk_hdmirx, rkcif, etc.) /// that require special enumeration and format-selection logic. pub(crate) fn is_csi_hdmi_bridge(device: &VideoDeviceInfo) -> bool { - is_rk_hdmirx_device(device) || is_rkcif_driver(&device.driver) + device.control_mode == VideoControlMode::SourceFollowing +} + +pub fn resolve_video_input_config( + device: &VideoDeviceInfo, + requested_format: PixelFormat, + requested_resolution: Resolution, + requested_fps: u32, +) -> ResolvedVideoInputConfig { + if device.control_mode == VideoControlMode::SourceFollowing { + if let VideoInputStatus { + state: VideoInputState::Locked, + format: Some(format), + width: Some(width), + height: Some(height), + fps: Some(fps), + } = &device.input_status + { + if let Ok(format) = format.parse::() { + return ResolvedVideoInputConfig { + format, + resolution: Resolution::new(*width, *height), + fps: fps.round().clamp(1.0, 120.0) as u32, + }; + } + } + } + + ResolvedVideoInputConfig { + format: requested_format, + resolution: requested_resolution, + fps: requested_fps, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + fn device(control_mode: VideoControlMode, input_status: VideoInputStatus) -> VideoDeviceInfo { + VideoDeviceInfo { + path: "/dev/video0".into(), + name: "test".into(), + driver: "test".into(), + bus_info: "test".into(), + card: "test".into(), + formats: Vec::new(), + capabilities: Default::default(), + is_capture_card: true, + priority: 0, + has_signal: input_status.state == VideoInputState::Locked, + control_mode, + input_status, + subdev_path: None, + bridge_kind: None, + } + } + + #[test] + fn recognizes_vendor_and_upstream_native_hdmirx_names() { + assert!(is_rk_hdmirx_driver("rk_hdmirx", "rk_hdmirx")); + assert!(is_rk_hdmirx_driver("snps_hdmirx", "Synopsys HDMI RX")); + assert!(is_rk_hdmirx_driver("other", "SNPS_HDMIRX")); + assert!(!is_rk_hdmirx_driver("rkcif", "stream_cif_mipi_id0")); + } + + #[test] + fn classifies_source_following_drivers_in_one_place() { + assert_eq!( + control_mode("rkcif", "stream_cif_mipi_id0"), + VideoControlMode::SourceFollowing + ); + assert_eq!( + control_mode("rkcif-mipi", "capture"), + VideoControlMode::SourceFollowing + ); + assert_eq!( + control_mode("rk_hdmirx", "capture"), + VideoControlMode::SourceFollowing + ); + assert_eq!( + control_mode("uvcvideo", "USB Capture"), + VideoControlMode::Configurable + ); + } + + #[cfg(unix)] + #[test] + fn source_following_uses_locked_hardware_mode_and_exact_fps_rounding() { + let device = device( + VideoControlMode::SourceFollowing, + VideoInputStatus::locked(PixelFormat::Nv12, 1920, 1080, 59.94), + ); + let resolved = resolve_video_input_config( + &device, + PixelFormat::Mjpeg, + Resolution::new(3840, 2160), + 15, + ); + assert_eq!(resolved.format, PixelFormat::Nv12); + assert_eq!(resolved.resolution, Resolution::new(1920, 1080)); + assert_eq!(resolved.fps, 60); + } + + #[cfg(unix)] + #[test] + fn no_signal_keeps_fallback_and_configurable_keeps_request() { + for (mode, status) in [ + ( + VideoControlMode::SourceFollowing, + VideoInputStatus::no_signal(), + ), + ( + VideoControlMode::Configurable, + VideoInputStatus::unavailable(), + ), + ] { + let resolved = resolve_video_input_config( + &device(mode, status), + PixelFormat::Yuyv, + Resolution::new(1280, 720), + 30, + ); + assert_eq!(resolved.format, PixelFormat::Yuyv); + assert_eq!(resolved.resolution, Resolution::new(1280, 720)); + assert_eq!(resolved.fps, 30); + } + } + + #[test] + fn no_signal_and_unavailable_never_expose_stale_mode_fields() { + for status in [ + VideoInputStatus::no_signal(), + VideoInputStatus::unavailable(), + ] { + assert!(status.format.is_none()); + assert!(status.width.is_none()); + assert!(status.height.is_none()); + assert!(status.fps.is_none()); + } + } } #[cfg(unix)] diff --git a/src/video/device/windows.rs b/src/video/device/windows.rs index 60d67210..b01d19e9 100644 --- a/src/video/device/windows.rs +++ b/src/video/device/windows.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +use super::{VideoControlMode, VideoInputStatus}; use crate::error::{AppError, Result}; use crate::video::format::{PixelFormat, Resolution}; @@ -16,6 +17,8 @@ pub struct VideoDeviceInfo { pub is_capture_card: bool, pub priority: u32, pub has_signal: bool, + pub control_mode: VideoControlMode, + pub input_status: VideoInputStatus, pub subdev_path: Option, pub bridge_kind: Option, } @@ -113,6 +116,10 @@ impl VideoDevice { )) }) } + + pub fn input_status(&self) -> Result { + Ok(self.info()?.input_status) + } } pub(crate) fn normalize_windows_device_path(path: impl AsRef) -> PathBuf { @@ -198,6 +205,8 @@ fn directshow_device_from_name(index: usize, name: String) -> VideoDeviceInfo { is_capture_card: true, priority, has_signal: true, + control_mode: VideoControlMode::Configurable, + input_status: VideoInputStatus::unavailable(), subdev_path: None, bridge_kind: None, } diff --git a/src/video/mod.rs b/src/video/mod.rs index acdd38f4..67d435b9 100644 --- a/src/video/mod.rs +++ b/src/video/mod.rs @@ -10,6 +10,7 @@ pub mod format; pub mod frame; #[cfg(feature = "desktop")] pub mod pipeline; +pub mod recovery; pub mod signal; #[cfg(feature = "desktop")] pub mod stream_manager; diff --git a/src/video/pipeline/encoder_state.rs b/src/video/pipeline/encoder_state.rs index 158edf1c..dafbdc53 100644 --- a/src/video/pipeline/encoder_state.rs +++ b/src/video/pipeline/encoder_state.rs @@ -1,4 +1,5 @@ use crate::error::{AppError, Result}; +use crate::video::codec::amlenc::{AmlencCodec, AmlencConfig, AmlencEncoder}; use crate::video::codec::convert::{MjpegToNv12Decoder, Nv12Converter, PixelConverter}; use crate::video::codec::h264::{H264Config, H264Encoder, H264InputFormat}; use crate::video::codec::h265::{H265Config, H265Encoder, H265InputFormat}; @@ -116,6 +117,47 @@ impl VideoEncoderTrait for H265EncoderWrapper { } } +struct AmlencEncoderWrapper(AmlencEncoder); + +impl VideoEncoderTrait for AmlencEncoderWrapper { + fn encode_raw(&mut self, data: &[u8], _pts_ms: i64) -> Result> { + Ok(match self.0.encode_raw(data)? { + Some((data, keyframe)) => vec![EncodedFrame { + data, + key: i32::from(keyframe), + }], + None => Vec::new(), + }) + } + + fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> { + self.0.set_bitrate(bitrate_kbps) + } + + fn codec_name(&self) -> &str { + self.0.codec_name() + } + + fn request_keyframe(&mut self) { + self.0.request_keyframe() + } +} + +fn create_amlenc_encoder( + config: &SharedVideoPipelineConfig, + codec: AmlencCodec, +) -> Result> { + let encoder = AmlencEncoder::new(AmlencConfig { + codec, + resolution: config.resolution, + fps: config.fps, + bitrate_kbps: config.bitrate_kbps(), + gop: config.gop_size(), + })?; + info!("Created native AMLENC encoder: {}", encoder.codec_name()); + Ok(Box::new(AmlencEncoderWrapper(encoder))) +} + struct VP8EncoderWrapper(VP8Encoder); impl VideoEncoderTrait for VP8EncoderWrapper { @@ -189,6 +231,26 @@ fn create_mjpeg_decoder(resolution: Resolution) -> Result<(MjpegDecoderKind, Pix Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12)) } +/// AMLENC and libjpeg-turbo use independent CPU/hardware resources. Decode +/// MJPEG in the capture worker so encoding the previous NV12 frame can overlap +/// with decoding the next frame. +pub(super) fn should_parallel_decode_mjpeg(config: &SharedVideoPipelineConfig) -> bool { + if !config.input_format.is_compressed() + || !matches!( + config.output_codec, + VideoEncoderType::H264 | VideoEncoderType::H265 + ) + { + return false; + } + let registry = EncoderRegistry::global(); + let selected = match config.encoder_backend { + Some(backend) => registry.encoder_with_backend(config.output_codec, backend), + None => registry.best_available_encoder(config.output_codec), + }; + selected.is_some_and(|encoder| encoder.backend == EncoderBackend::Amlogic) +} + pub(super) fn build_encoder_state( config: &SharedVideoPipelineConfig, ) -> Result { @@ -210,9 +272,14 @@ pub(super) fn build_encoder_state( let is_rkmpp_available = registry .encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Rkmpp) .is_some(); - let use_yuyv_direct = - is_rkmpp_available && !needs_mjpeg_decode && config.input_format == PixelFormat::Yuyv; + let rkmpp_is_allowed = + config.encoder_backend.is_none() || config.encoder_backend == Some(EncoderBackend::Rkmpp); + let use_yuyv_direct = is_rkmpp_available + && rkmpp_is_allowed + && !needs_mjpeg_decode + && config.input_format == PixelFormat::Yuyv; let use_rkmpp_direct = is_rkmpp_available + && rkmpp_is_allowed && !needs_mjpeg_decode && matches!( config.input_format, @@ -348,70 +415,80 @@ pub(super) fn build_encoder_state( let encoder: Box = match config.output_codec { VideoEncoderType::H264 => { let codec_name = selected_codec_name.clone(); - let direct_input_format = h264_direct_input_format(&codec_name, pipeline_input_format); - let input_format = direct_input_format.unwrap_or_else(|| { - if codec_name.contains("libx264") { - H264InputFormat::Yuv420p - } else { - H264InputFormat::Nv12 + if codec_name == crate::video::codec::amlenc::AMLENC_H264_CODEC_NAME { + create_amlenc_encoder(config, AmlencCodec::H264)? + } else { + let direct_input_format = + h264_direct_input_format(&codec_name, pipeline_input_format); + let input_format = direct_input_format.unwrap_or_else(|| { + if codec_name.contains("libx264") { + H264InputFormat::Yuv420p + } else { + H264InputFormat::Nv12 + } + }); + + if use_rkmpp_direct { + info!( + "Creating H264 encoder with RKMPP backend for {} direct input (codec: {})", + config.input_format, codec_name + ); + } else if let Some(ref backend) = config.encoder_backend { + info!( + "Creating H264 encoder with backend {:?} (codec: {})", + backend, codec_name + ); } - }); - if use_rkmpp_direct { - info!( - "Creating H264 encoder with RKMPP backend for {} direct input (codec: {})", - config.input_format, codec_name - ); - } else if let Some(ref backend) = config.encoder_backend { - info!( - "Creating H264 encoder with backend {:?} (codec: {})", - backend, codec_name - ); + create_h264_encoder(config, input_format, &codec_name)? } - - create_h264_encoder(config, input_format, &codec_name)? } VideoEncoderType::H265 => { let codec_name = selected_codec_name.clone(); - let direct_input_format = h265_direct_input_format(&codec_name, pipeline_input_format); - let input_format = direct_input_format.unwrap_or_else(|| { - if codec_name.contains("libx265") { - H265InputFormat::Yuv420p - } else { - H265InputFormat::Nv12 + if codec_name == crate::video::codec::amlenc::AMLENC_H265_CODEC_NAME { + create_amlenc_encoder(config, AmlencCodec::H265)? + } else { + let direct_input_format = + h265_direct_input_format(&codec_name, pipeline_input_format); + let input_format = direct_input_format.unwrap_or_else(|| { + if codec_name.contains("libx265") { + H265InputFormat::Yuv420p + } else { + H265InputFormat::Nv12 + } + }); + + 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 + ); } - }); - 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( - H265Config { - base: EncoderConfig { - resolution: config.resolution, - input_format: config.input_format, - quality: config.bitrate_kbps(), - fps: config.fps, + let encoder = H265Encoder::with_codec( + 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, }, - bitrate_kbps: config.bitrate_kbps(), - gop_size: config.gop_size(), - fps: config.fps, - input_format, - }, - &codec_name, - )?; - info!("Created H265 encoder: {}", encoder.codec_name()); - Box::new(H265EncoderWrapper(encoder)) + &codec_name, + )?; + info!("Created H265 encoder: {}", encoder.codec_name()); + Box::new(H265EncoderWrapper(encoder)) + } } VideoEncoderType::VP8 => { let codec_name = selected_codec_name.clone(); @@ -446,7 +523,9 @@ pub(super) fn build_encoder_state( }; let codec_name = encoder.codec_name(); - let use_direct_input = if codec_name.contains("rkmpp") { + let use_direct_input = if codec_name.contains("amlenc") { + pipeline_input_format == PixelFormat::Nv12 + } else if codec_name.contains("rkmpp") { matches!( pipeline_input_format, PixelFormat::Yuyv diff --git a/src/video/pipeline/mod.rs b/src/video/pipeline/mod.rs index 9a43bbd4..e12e6e51 100644 --- a/src/video/pipeline/mod.rs +++ b/src/video/pipeline/mod.rs @@ -4,6 +4,6 @@ mod encoder_state; mod shared; pub use shared::{ - EncodedVideoFrame, PipelineStateNotification, SharedVideoPipeline, SharedVideoPipelineConfig, - SharedVideoPipelineStats, + EncodedVideoFrame, PipelineAppliedConfig, PipelineLifecycle, PipelineStateNotification, + SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats, }; diff --git a/src/video/pipeline/shared.rs b/src/video/pipeline/shared.rs index 77300af8..81012293 100644 --- a/src/video/pipeline/shared.rs +++ b/src/video/pipeline/shared.rs @@ -26,16 +26,14 @@ use std::time::{Duration, Instant}; use tokio::sync::{mpsc, watch, Mutex, RwLock}; use tracing::{debug, error, info, trace, warn}; -use super::encoder_state::{build_encoder_state, EncoderThreadState}; +use super::encoder_state::{build_encoder_state, should_parallel_decode_mjpeg, EncoderThreadState}; /// Grace period before auto-stopping pipeline when no subscribers (in seconds) const AUTO_STOP_GRACE_PERIOD_SECS: u64 = 3; +const AMLENC_MAX_FPS: u32 = 60; /// After this many consecutive timeouts, log a prominent warning. const CAPTURE_TIMEOUT_RESTART_THRESHOLD: u32 = 5; -const CAPTURE_TIMEOUT_STOP_THRESHOLD: u32 = 60; const CAPTURE_TIMEOUT_SOFT_RESTART_THRESHOLD: u32 = 3; -const CSI_BRIDGE_NOSIGNAL_INTERVAL_MS: u64 = 500; -const NOSIGNAL_POLL_MAX: Duration = Duration::from_secs(20); /// Throttle repeated encoding errors to avoid log flooding const ENCODE_ERROR_THROTTLE_SECS: u64 = 5; @@ -50,13 +48,19 @@ use crate::video::capture::status::{ capture_error_log_key, classify_capture_io_error, is_device_lost_message, signal_status_from_capture_kind, CaptureIoErrorKind, }; -use crate::video::capture::{is_source_changed_error, BridgeContext, CaptureStream}; +use crate::video::capture::{BridgeContext, CaptureReadError, CaptureStream}; use crate::video::codec::h264_bitstream; use crate::video::codec::registry::{EncoderBackend, VideoEncoderType}; -use crate::video::device::bridge::{self as csi_bridge, ProbeResult}; +use crate::video::codec::MjpegToNv12Decoder; use crate::video::device::parse_bridge_kind; +use crate::video::device::VideoControlMode; use crate::video::format::{PixelFormat, Resolution}; + +fn amlenc_supported_fps(requested_fps: u32) -> u32 { + requested_fps.min(AMLENC_MAX_FPS) +} use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame}; +use crate::video::recovery::{wait_for_source_change, CaptureRecoveryPolicy}; use crate::video::signal::SignalStatus; const MIN_CAPTURE_FRAME_SIZE: usize = 128; @@ -90,14 +94,34 @@ pub struct PipelineStateNotification { pub state: &'static str, pub reason: Option<&'static str>, pub next_retry_ms: Option, + pub applied_config: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PipelineAppliedConfig { + pub resolution: Resolution, + pub format: PixelFormat, + pub fps: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PipelineLifecycle { + Running, + Stopping, + Stopped, } impl PipelineStateNotification { - fn streaming() -> Self { + fn streaming(resolution: Resolution, format: PixelFormat, fps: u32) -> Self { Self { state: "streaming", reason: None, next_retry_ms: None, + applied_config: Some(PipelineAppliedConfig { + resolution, + format, + fps, + }), } } @@ -106,14 +130,7 @@ impl PipelineStateNotification { state: "no_signal", reason: Some(status.as_str()), next_retry_ms, - } - } - - fn device_busy(reason: &'static str) -> Self { - Self { - state: "device_busy", - reason: Some(reason), - next_retry_ms: None, + applied_config: None, } } } @@ -121,6 +138,8 @@ impl PipelineStateNotification { /// Shared video pipeline configuration #[derive(Debug, Clone)] pub struct SharedVideoPipelineConfig { + /// Whether the capture mode is configured by the client or follows HDMI. + pub control_mode: VideoControlMode, /// Input resolution pub resolution: Resolution, /// Input pixel format @@ -138,6 +157,7 @@ pub struct SharedVideoPipelineConfig { impl Default for SharedVideoPipelineConfig { fn default() -> Self { Self { + control_mode: VideoControlMode::Configurable, resolution: Resolution::HD720, input_format: PixelFormat::Yuyv, output_codec: VideoEncoderType::H264, @@ -267,6 +287,11 @@ pub struct SharedVideoPipeline { stats: Mutex, running: watch::Sender, running_rx: watch::Receiver, + /// Becomes true only after the synchronous encoder worker has dropped its + /// vendor handles. Capture teardown alone is not sufficient for AMLENC: + /// a blocked dequeue/encode can otherwise overlap the next pipeline. + encoder_done: watch::Sender, + encoder_done_rx: watch::Receiver, h264_profile_level_id: watch::Sender>, h264_profile_level_id_rx: watch::Receiver>, cmd_tx: ParkingRwLock>>, @@ -285,81 +310,6 @@ pub struct SharedVideoPipeline { last_state_notification: ParkingMutex>, } -fn poll_bridge_subdev_after_no_signal(bridge_ctx: &BridgeContext, pipeline: &SharedVideoPipeline) { - let Some(subdev_path) = bridge_ctx.subdev_path.as_ref() else { - return; - }; - let kind = bridge_ctx - .kind - .unwrap_or(csi_bridge::CsiBridgeKind::Unknown); - let deadline = Instant::now() + NOSIGNAL_POLL_MAX; - let mut poll_count: u32 = 0; - info!( - "No-signal poll: scanning subdev {:?} every {} ms (max {:?})", - subdev_path, CSI_BRIDGE_NOSIGNAL_INTERVAL_MS, NOSIGNAL_POLL_MAX - ); - loop { - if !pipeline.running_flag.load(Ordering::Acquire) { - return; - } - if Instant::now() >= deadline { - info!( - "No-signal poll: stopped after {:?} ({} attempts)", - NOSIGNAL_POLL_MAX, poll_count - ); - return; - } - let fd = match csi_bridge::open_subdev(subdev_path) { - Ok(f) => f, - Err(e) => { - debug!( - "No-signal poll: open subdev {:?} failed: {}", - subdev_path, e - ); - std::thread::sleep(Duration::from_millis(CSI_BRIDGE_NOSIGNAL_INTERVAL_MS)); - continue; - } - }; - match csi_bridge::probe_signal_thread_timeout( - &fd, - kind, - csi_bridge::RK628_SUBDEV_PROBE_TIMEOUT, - ) { - Some(ProbeResult::Locked(mode)) => { - info!( - "No-signal poll: locked {}x{} @ {} Hz — proceeding to capture re-open", - mode.width, mode.height, mode.pixelclock - ); - return; - } - Some(other) => { - poll_count = poll_count.saturating_add(1); - if poll_count == 1 || poll_count.is_multiple_of(8) { - debug!( - "No-signal poll: attempt {} — still {:?}", - poll_count, - other.as_status() - ); - } - if let Some(st) = other.as_status() { - pipeline.notify_state(PipelineStateNotification::no_signal( - st, - Some(CSI_BRIDGE_NOSIGNAL_INTERVAL_MS.saturating_add(50)), - )); - } - } - None => { - poll_count = poll_count.saturating_add(1); - debug!( - "No-signal poll: attempt {} — probe ioctl timed out", - poll_count - ); - } - } - std::thread::sleep(Duration::from_millis(CSI_BRIDGE_NOSIGNAL_INTERVAL_MS)); - } -} - impl SharedVideoPipeline { /// Create a new shared video pipeline pub fn new(config: SharedVideoPipelineConfig) -> Result> { @@ -373,6 +323,7 @@ impl SharedVideoPipeline { ); let (running_tx, running_rx) = watch::channel(false); + let (encoder_done_tx, encoder_done_rx) = watch::channel(true); let (h264_profile_tx, h264_profile_rx) = watch::channel(None); let pipeline = Arc::new(Self { @@ -381,6 +332,8 @@ impl SharedVideoPipeline { stats: Mutex::new(SharedVideoPipelineStats::default()), running: running_tx, running_rx, + encoder_done: encoder_done_tx, + encoder_done_rx, h264_profile_level_id: h264_profile_tx, h264_profile_level_id_rx: h264_profile_rx, cmd_tx: ParkingRwLock::new(None), @@ -441,7 +394,10 @@ impl SharedVideoPipeline { /// Subscribe to encoded frames pub fn subscribe(&self) -> mpsc::Receiver> { - let (tx, rx) = mpsc::channel(4); + // A queued video frame is already stale when the next frame is ready. + // Keep at most one pending frame so a slow WebRTC writer cannot make + // the encoder wait or accumulate seconds of latency. + let (tx, rx) = mpsc::channel(1); self.subscribers.write().push(tx); rx } @@ -521,6 +477,19 @@ impl SharedVideoPipeline { *self.running_rx.borrow() } + /// Lifecycle state derived from the stop-request flag and the capture + /// thread's completion signal. A stopping pipeline must never receive a + /// new subscriber or be replaced before it releases the V4L2 device. + pub fn lifecycle(&self) -> PipelineLifecycle { + if self.running_flag.load(Ordering::Acquire) { + PipelineLifecycle::Running + } else if *self.running_rx.borrow() { + PipelineLifecycle::Stopping + } else { + PipelineLifecycle::Stopped + } + } + /// Subscribe to running state changes /// /// Returns a watch receiver that can be used to detect when the pipeline stops. @@ -543,7 +512,7 @@ impl SharedVideoPipeline { let _ = self.h264_profile_level_id.send(Some(profile_level_id)); } - async fn broadcast_encoded(&self, frame: Arc) { + fn broadcast_encoded(&self, frame: Arc) { let subscribers = { let guard = self.subscribers.read(); if guard.is_empty() { @@ -553,9 +522,11 @@ impl SharedVideoPipeline { }; for tx in &subscribers { - if tx.send(frame.clone()).await.is_err() { - // Receiver dropped; cleanup happens below. - } + // Never await a consumer. A full one-slot queue means the + // consumer is behind; dropping this frame preserves bounded + // latency and the receiver's sequence-gap logic requests a fresh + // keyframe when necessary. + let _ = tx.try_send(frame.clone()); } if subscribers.iter().any(|tx| tx.is_closed()) { @@ -575,7 +546,6 @@ impl SharedVideoPipeline { _jpeg_quality: u8, subdev_path: Option, bridge_kind: Option, - _v4l2_driver: Option, ) -> Result<()> { if *self.running_rx.borrow() { warn!("Pipeline already running"); @@ -583,6 +553,18 @@ impl SharedVideoPipeline { } let mut config = self.config.read().await.clone(); + let parallel_mjpeg_decode = should_parallel_decode_mjpeg(&config); + if parallel_mjpeg_decode { + let stable_fps = amlenc_supported_fps(config.fps); + if stable_fps != config.fps { + warn!( + "Limiting S912 AMLENC capture at {}x{} from {} to {} fps (hardware limit)", + config.resolution.width, config.resolution.height, config.fps, stable_fps + ); + config.fps = stable_fps; + *self.config.write().await = config.clone(); + } + } { let mut last = self.last_state_notification.lock(); *last = None; @@ -601,24 +583,36 @@ impl SharedVideoPipeline { buffer_count.max(1), Duration::from_secs(2), bridge_ctx_probe, + config.control_mode, ) { Ok(s) => { let negotiated_res = s.resolution(); let negotiated_fmt = s.format(); - if negotiated_res != config.resolution || negotiated_fmt != config.input_format { + let previous = (config.resolution, config.input_format, config.fps); + if config.control_mode == VideoControlMode::SourceFollowing { + if let Some(source_fps) = s.source_fps() { + config.fps = source_fps.round().clamp(1.0, 120.0) as u32; + } + } + config.resolution = negotiated_res; + config.input_format = negotiated_fmt; + if parallel_mjpeg_decode { + config.fps = amlenc_supported_fps(config.fps); + } + if previous != (config.resolution, config.input_format, config.fps) { info!( - "Negotiated capture {}x{} {:?} (configured {}x{} {:?}) — aligning encoder to source", + "Negotiated capture {}x{} {:?} @ {} fps (configured {}x{} {:?} @ {} fps) — aligning encoder to source", negotiated_res.width, negotiated_res.height, negotiated_fmt, - config.resolution.width, - config.resolution.height, - config.input_format + config.fps, + previous.0.width, + previous.0.height, + previous.1, + previous.2, ); - config.resolution = negotiated_res; - config.input_format = negotiated_fmt; - *self.config.write().await = config.clone(); } + *self.config.write().await = config.clone(); Some(s) } Err(AppError::CaptureNoSignal { kind }) => { @@ -628,15 +622,25 @@ impl SharedVideoPipeline { let status = signal_status_from_capture_kind(&kind); self.notify_state(PipelineStateNotification::no_signal( status, - Some(Duration::from_secs(2).as_millis() as u64), + Some( + CaptureRecoveryPolicy::new(config.control_mode) + .retry_delay(1) + .as_millis() as u64, + ), )); None } Err(e) => return Err(e), }; - let mut encoder_state = build_encoder_state(&config)?; + let mut encoder_config = config.clone(); + if parallel_mjpeg_decode { + encoder_config.input_format = PixelFormat::Nv12; + info!("Using capture-thread libyuv MJPEG decode with parallel AMLENC encoding"); + } + let mut encoder_state = build_encoder_state(&encoder_config)?; let _ = self.running.send(true); + let _ = self.encoder_done.send(false); self.running_flag.store(true, Ordering::Release); let pipeline = self.clone(); @@ -699,12 +703,11 @@ impl SharedVideoPipeline { input_frame_count = input_frame_count.wrapping_add(1); - match pipeline.encode_frame_sync(&mut encoder_state, &frame, input_frame_count) - { + match pipeline.encode_frame_sync(&mut encoder_state, &frame) { Ok(encoded_frames) => { for encoded_frame in encoded_frames { let encoded_arc = Arc::new(encoded_frame); - handle.block_on(pipeline.broadcast_encoded(encoded_arc)); + pipeline.broadcast_encoded(encoded_arc); encoded_frame_count = encoded_frame_count.wrapping_add(1); fps_frame_count += 1; @@ -738,6 +741,10 @@ impl SharedVideoPipeline { } pipeline.clear_cmd_tx(); + // Dropping encoder_state here releases AMLENC before a caller + // is allowed to construct a replacement pipeline. + drop(encoder_state); + let _ = pipeline.encoder_done.send(true); }); } @@ -754,74 +761,21 @@ impl SharedVideoPipeline { let mut initial_geometry: Option<(Resolution, PixelFormat)> = None; let mut resolution = config.resolution; let mut pixel_format = config.input_format; + let mut active_fps = config.fps; let mut stride: u32 = 0; + let mut mjpeg_decoder = + parallel_mjpeg_decode.then(|| MjpegToNv12Decoder::new(config.resolution)); - match preopened { - Some(s) => { - resolution = s.resolution(); - pixel_format = s.format(); - stride = s.stride(); - initial_geometry = Some((resolution, pixel_format)); - stream = Some(s); - } - None => { - match open_capture_stream( - &device_path, - config.resolution, - config.input_format, - config.fps, - buffer_count.max(1), - Duration::from_secs(2), - bridge_ctx.clone(), - ) { - Ok(s) => { - resolution = s.resolution(); - pixel_format = s.format(); - stride = s.stride(); - if resolution != config.resolution - || pixel_format != config.input_format - { - info!( - "First capture open negotiated {}x{} {:?} but encoder expects {}x{} {:?} — stopping for dimension resync", - resolution.width, - resolution.height, - pixel_format, - config.resolution.width, - config.resolution.height, - config.input_format - ); - pipeline.notify_state(PipelineStateNotification::device_busy( - "config_changing", - )); - *pipeline.pending_sync_geometry.lock() = - Some((resolution, pixel_format)); - let _ = pipeline.running.send(false); - pipeline.running_flag.store(false, Ordering::Release); - let _ = frame_seq_tx.send(1); - return; - } - initial_geometry = Some((resolution, pixel_format)); - stream = Some(s); - } - Err(AppError::CaptureNoSignal { kind }) => { - warn!( - "Capture stream open reports no signal ({}) — pipeline will retry", - kind - ); - pipeline.notify_state(PipelineStateNotification::no_signal( - signal_status_from_capture_kind(&kind), - Some(CSI_BRIDGE_NOSIGNAL_INTERVAL_MS), - )); - } - Err(e) => { - error!("Failed to open capture stream: {}", e); - let _ = pipeline.running.send(false); - pipeline.running_flag.store(false, Ordering::Release); - let _ = frame_seq_tx.send(1); - return; - } - } - } + if let Some(s) = preopened { + resolution = s.resolution(); + pixel_format = s.format(); + active_fps = s + .source_fps() + .map(|fps| fps.round().clamp(1.0, 120.0) as u32) + .unwrap_or(config.fps); + stride = s.stride(); + initial_geometry = Some((resolution, pixel_format)); + stream = Some(s); } fn open_or_retry( @@ -838,6 +792,7 @@ impl SharedVideoPipeline { buffer_count.max(1), Duration::from_secs(2), bridge_ctx, + config.control_mode, is_device_lost_message, ) { CaptureOpenResult::NoSignal(status) => { @@ -860,6 +815,7 @@ impl SharedVideoPipeline { let grace_period = Duration::from_secs(AUTO_STOP_GRACE_PERIOD_SECS); let mut sequence: u64 = 0; let mut consecutive_timeouts: u32 = 0; + let recovery_policy = CaptureRecoveryPolicy::new(config.control_mode); let capture_error_throttler = LogThrottler::with_secs(5); let mut suppressed_capture_errors: HashMap = HashMap::new(); @@ -877,9 +833,7 @@ impl SharedVideoPipeline { "No subscribers for {}s, auto-stopping video pipeline", grace_period.as_secs() ); - let _ = pipeline.running.send(false); pipeline.running_flag.store(false, Ordering::Release); - let _ = frame_seq_tx.send(sequence.wrapping_add(1)); break; } } @@ -899,6 +853,10 @@ impl SharedVideoPipeline { let new_res = new_stream.resolution(); let new_fmt = new_stream.format(); let new_stride = new_stream.stride(); + let new_fps = new_stream + .source_fps() + .map(|fps| fps.round().clamp(1.0, 120.0) as u32) + .unwrap_or(config.fps); // Pre-probe was skipped (no signal at pipeline start) but the // encoder was sized to saved settings — if DV timings now @@ -916,14 +874,13 @@ impl SharedVideoPipeline { config.resolution.height, config.input_format ); - pipeline.notify_state(PipelineStateNotification::device_busy( - "config_changing", + pipeline.notify_state(PipelineStateNotification::no_signal( + SignalStatus::NoSignal, + Some(recovery_policy.retry_delay(1).as_millis() as u64), )); *pipeline.pending_sync_geometry.lock() = Some((new_res, new_fmt)); - let _ = pipeline.running.send(false); pipeline.running_flag.store(false, Ordering::Release); - let _ = frame_seq_tx.send(sequence.wrapping_add(1)); break; } @@ -944,15 +901,15 @@ impl SharedVideoPipeline { orig_res, orig_fmt, new_res, new_fmt ); pipeline.notify_state( - PipelineStateNotification::device_busy( - "config_changing", + PipelineStateNotification::no_signal( + SignalStatus::NoSignal, + Some(recovery_policy.retry_delay(1).as_millis() + as u64), ), ); *pipeline.pending_sync_geometry.lock() = Some((new_res, new_fmt)); - let _ = pipeline.running.send(false); pipeline.running_flag.store(false, Ordering::Release); - let _ = frame_seq_tx.send(sequence.wrapping_add(1)); break; } _ => {} @@ -963,6 +920,7 @@ impl SharedVideoPipeline { } resolution = new_res; pixel_format = new_fmt; + active_fps = new_fps; stride = new_stride; stream = Some(new_stream); consecutive_timeouts = 0; @@ -973,36 +931,34 @@ impl SharedVideoPipeline { } CaptureOpenResult::NoSignal(status) => { consecutive_timeouts = consecutive_timeouts.saturating_add(1); - if consecutive_timeouts >= CAPTURE_TIMEOUT_STOP_THRESHOLD { + if !recovery_policy.should_retry(consecutive_timeouts) { warn!( "Capture soft-restart gave up after {} attempts, \ stopping pipeline", consecutive_timeouts ); - let _ = pipeline.running.send(false); pipeline.running_flag.store(false, Ordering::Release); - let _ = frame_seq_tx.send(sequence.wrapping_add(1)); break; } - let wait_ms = CSI_BRIDGE_NOSIGNAL_INTERVAL_MS; + let delay = recovery_policy.retry_delay(consecutive_timeouts); pipeline.notify_state(PipelineStateNotification::no_signal( status, - Some(wait_ms), + Some(delay.as_millis() as u64), )); - std::thread::sleep(Duration::from_millis(wait_ms)); + if wait_for_source_change(&bridge_ctx, delay, || { + pipeline.running_flag.load(Ordering::Acquire) + }) { + info!("SOURCE_CHANGE woke capture retry"); + } continue; } CaptureOpenResult::DeviceLost(reason) => { pipeline.mark_device_lost(reason); - let _ = pipeline.running.send(false); pipeline.running_flag.store(false, Ordering::Release); - let _ = frame_seq_tx.send(sequence.wrapping_add(1)); break; } CaptureOpenResult::Fatal => { - let _ = pipeline.running.send(false); pipeline.running_flag.store(false, Ordering::Release); - let _ = frame_seq_tx.send(sequence.wrapping_add(1)); break; } } @@ -1018,106 +974,44 @@ impl SharedVideoPipeline { consecutive_timeouts = 0; meta } - Err(e) => { + Err(CaptureReadError::SourceChanged) => { // V4L2 driver reported V4L2_EVENT_SOURCE_CHANGE. // The current capture is effectively invalidated: // drop the stream so the next iteration re-opens // via a fresh DV_TIMINGS probe. This is the fast // path for source-side resolution switches on - // RK628 / rkcif — sub-second recovery vs. the ~8 s - // timeout fallback. - if is_source_changed_error(&e) { - info!( - "Capture reported SOURCE_CHANGE — \ - dropping stream for immediate re-open" - ); - consecutive_timeouts = 0; - stream = None; + // RK628 / rkcif; the retry policy is only a fallback + // when a driver does not provide usable events. + info!( + "Capture reported SOURCE_CHANGE — \ + dropping stream for immediate re-open" + ); + if recovery_policy.control_mode() == VideoControlMode::SourceFollowing { + pipeline.notify_state(PipelineStateNotification::no_signal( + SignalStatus::NoSignal, + Some(recovery_policy.retry_delay(1).as_millis() as u64), + )); + } + consecutive_timeouts = 0; + stream = None; + continue; + } + Err(CaptureReadError::Io(e)) => { + if e.kind() == std::io::ErrorKind::WouldBlock { continue; } if e.kind() == std::io::ErrorKind::TimedOut { consecutive_timeouts = consecutive_timeouts.saturating_add(1); - let probe_result = { - let sr = stream.as_mut().expect("stream is Some above"); - sr.probe_bridge_signal_with_timeout( - csi_bridge::RK628_SUBDEV_PROBE_TIMEOUT, - ) - }; - match probe_result { - Some(ProbeResult::Locked(mode)) => { - let probed_resolution = - Resolution::new(mode.width, mode.height); - if probed_resolution == resolution { - info!( - "Capture timeout but bridge is locked at {}x{} — soft-restarting capture without encoder rebuild", - probed_resolution.width, - probed_resolution.height - ); - } else { - info!( - "Capture timeout probe detected geometry change {}x{} -> {}x{} — soft-restarting capture for encoder rebuild", - resolution.width, - resolution.height, - probed_resolution.width, - probed_resolution.height - ); - pipeline.notify_state( - PipelineStateNotification::device_busy( - "config_changing", - ), - ); - } - consecutive_timeouts = 0; - stream = None; - continue; - } - Some(other) => { - let status = - other.as_status().unwrap_or(SignalStatus::NoSignal); - warn!( - "Capture timeout probe reports no signal ({})", - status.as_str() - ); - pipeline.notify_state( - PipelineStateNotification::no_signal( - status, - Some(Duration::from_secs(2).as_millis() as u64), - ), - ); - // Drop capture so RK628 / rkcif can release the queue, - // then poll subdev on a fresh fd until timings lock (or - // timeout). Avoids sitting on DQBUF 2s × N with a dead - // stream while `v4l2-ctl --query-dv-timings` already shows - // a real mode. - stream = None; - consecutive_timeouts = 0; - if bridge_ctx.has_subdev() - && matches!( - other, - ProbeResult::NoSignal - | ProbeResult::NoSync - | ProbeResult::OutOfRange - ) - { - poll_bridge_subdev_after_no_signal( - &bridge_ctx, - &pipeline, - ); - } - continue; - } - None if bridge_ctx.has_subdev() => { - warn!( - "DV-timings probe timed out or failed — forcing stream re-open (RK628 / rkcif)" - ); - consecutive_timeouts = 0; - stream = None; - poll_bridge_subdev_after_no_signal(&bridge_ctx, &pipeline); - continue; - } - None => { - warn!("Capture timeout - no signal?"); - } + if recovery_policy.control_mode() + == VideoControlMode::SourceFollowing + { + let delay = recovery_policy.retry_delay(consecutive_timeouts); + pipeline.notify_state(PipelineStateNotification::no_signal( + SignalStatus::NoSignal, + Some(delay.as_millis() as u64), + )); + stream = None; + continue; } if consecutive_timeouts >= CAPTURE_TIMEOUT_SOFT_RESTART_THRESHOLD { @@ -1145,17 +1039,6 @@ impl SharedVideoPipeline { consecutive_timeouts ); } - - if consecutive_timeouts >= CAPTURE_TIMEOUT_STOP_THRESHOLD { - warn!( - "Capture timed out {} consecutive times, stopping video pipeline", - consecutive_timeouts - ); - let _ = pipeline.running.send(false); - pipeline.running_flag.store(false, Ordering::Release); - let _ = frame_seq_tx.send(sequence.wrapping_add(1)); - break; - } } else { consecutive_timeouts = 0; // EIO (5) / EPIPE (32) / EPROTO (71) in next_into generally @@ -1187,9 +1070,7 @@ impl SharedVideoPipeline { CaptureIoErrorKind::DeviceLost => { error!("Capture device lost: {}", e); pipeline.mark_device_lost(e.to_string()); - let _ = pipeline.running.send(false); pipeline.running_flag.store(false, Ordering::Release); - let _ = frame_seq_tx.send(sequence.wrapping_add(1)); break; } CaptureIoErrorKind::Other => {} @@ -1223,12 +1104,35 @@ impl SharedVideoPipeline { owned.truncate(frame_size); // Notify streaming only after the short-frame guard passes. - pipeline.notify_state(PipelineStateNotification::streaming()); - let frame = Arc::new(VideoFrame::from_pooled( - Arc::new(FrameBuffer::new(owned, Some(buffer_pool.clone()))), + pipeline.notify_state(PipelineStateNotification::streaming( resolution, pixel_format, - stride, + active_fps, + )); + let (frame_data, frame_format, frame_stride) = + if let Some(decoder) = mjpeg_decoder.as_mut() { + let nv12_size = + resolution.width as usize * resolution.height as usize * 3 / 2; + let mut nv12 = buffer_pool.take(nv12_size); + if let Err(error) = decoder.decode_into(&owned, &mut nv12) { + buffer_pool.put(owned); + buffer_pool.put(nv12); + let key = "capture_mjpeg_decode"; + if capture_error_throttler.should_log(key) { + error!("Dropping undecodable MJPEG frame: {}", error); + } + continue; + } + buffer_pool.put(owned); + (nv12, PixelFormat::Nv12, resolution.width) + } else { + (owned, pixel_format, stride) + }; + let frame = Arc::new(VideoFrame::from_pooled( + Arc::new(FrameBuffer::new(frame_data, Some(buffer_pool.clone()))), + resolution, + frame_format, + frame_stride, meta.sequence, )); sequence = meta.sequence.wrapping_add(1); @@ -1240,10 +1144,14 @@ impl SharedVideoPipeline { let _ = frame_seq_tx.send(sequence); } + // `running` represents completed lifecycle state, not a stop request. + // Drop the V4L2 stream first so STREAMOFF, buffer teardown and FD close + // have all completed before another consumer is told the device is free. + drop(stream); pipeline.running_flag.store(false, Ordering::Release); - let _ = pipeline.running.send(false); let _ = frame_seq_tx.send(sequence.wrapping_add(1)); - info!("Video pipeline stopped"); + let _ = pipeline.running.send(false); + info!("Video pipeline stopped and capture device released"); }); } @@ -1255,7 +1163,6 @@ impl SharedVideoPipeline { &self, state: &mut EncoderThreadState, frame: &VideoFrame, - frame_count: u64, ) -> Result> { let fps = state.fps; let codec = state.codec; @@ -1346,16 +1253,6 @@ impl SharedVideoPipeline { .or(compacted_buf.as_deref()) .unwrap_or(raw_frame); - // Debug log for H265 - if codec == VideoEncoderType::H265 && frame_count % 30 == 1 { - debug!( - "[Pipeline-H265] Processing frame #{}: input_size={}, pts_ms={}", - frame_count, - raw_frame.len(), - pts_ms - ); - } - let needs_yuv420p = state.encoder_needs_yuv420p; let encoder = state .encoder @@ -1392,18 +1289,7 @@ impl SharedVideoPipeline { match encode_result { Ok(frames) => { if frames.is_empty() { - if codec == VideoEncoderType::H265 { - warn!( - "[Pipeline-H265] Encoder returned no frames for frame #{}", - frame_count - ); - } else { - trace!( - "Encoder returned no frames for input frame #{} ({})", - frame_count, - codec - ); - } + trace!("Encoder returned no frame ({})", codec); return Ok(Vec::new()); } @@ -1415,23 +1301,6 @@ impl SharedVideoPipeline { self.update_h264_profile_level_id(&encoded.data); } - // Debug log for H265 encoded frame - if codec == VideoEncoderType::H265 && (is_keyframe || frame_count % 30 == 1) { - debug!( - "[Pipeline-H265] Encoded frame #{}: output_size={}, keyframe={}, sequence={}", - frame_count, - encoded.data.len(), - is_keyframe, - sequence - ); - - // Log H265 NAL unit types in the encoded data - if is_keyframe { - let nal_types = parse_h265_nal_types(&encoded.data); - debug!("[Pipeline-H265] Keyframe NAL types: {:?}", nal_types); - } - } - encoded_frames.push(EncodedVideoFrame { data: encoded.data, pts_ms, @@ -1444,23 +1313,13 @@ impl SharedVideoPipeline { Ok(encoded_frames) } - Err(e) => { - if codec == VideoEncoderType::H265 { - error!( - "[Pipeline-H265] Encode error at frame #{}: {}", - frame_count, e - ); - } - Err(e) - } + Err(e) => Err(e), } } /// Stop the pipeline (non-blocking, does not wait for capture thread to exit) pub fn stop(&self) { - if *self.running_rx.borrow() { - let _ = self.running.send(false); - self.running_flag.store(false, Ordering::Release); + if self.running_flag.swap(false, Ordering::AcqRel) { self.clear_cmd_tx(); info!("Stopping video pipeline"); } @@ -1471,32 +1330,65 @@ impl SharedVideoPipeline { /// This ensures the V4L2 device is released before returning, which is /// necessary when another consumer (e.g. MJPEG streamer) needs to open /// the same device immediately after. - pub async fn stop_and_wait(&self, timeout: std::time::Duration) { + pub async fn stop_and_wait(&self, timeout: std::time::Duration) -> Result<()> { self.stop(); let mut rx = self.running_watch(); - if !*rx.borrow() { - // Capture thread may still be running from a previous `stop()` call. - // Wait for the "Video pipeline stopped" log (thread sets running=false - // at exit), unless it already happened. - } + let mut encoder_rx = self.encoder_done_rx.clone(); let deadline = tokio::time::Instant::now() + timeout; - loop { - if !self.running_flag.load(Ordering::Acquire) { - // Flag is cleared, but the capture thread may still be unwinding - // (dropping the V4L2 stream). Give it a brief moment. - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - break; - } + + while *rx.borrow() { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { - warn!( - "Timed out waiting for video pipeline to stop after {:?}", + return Err(AppError::VideoError(format!( + "Timed out waiting {:?} for video pipeline to release capture device", timeout - ); - break; + ))); + } + match tokio::time::timeout(remaining, rx.changed()).await { + Ok(Ok(())) => {} + Ok(Err(_)) if !*rx.borrow() => break, + Ok(Err(_)) => { + return Err(AppError::VideoError( + "Video pipeline lifecycle channel closed before capture device release" + .to_string(), + )); + } + Err(_) => { + return Err(AppError::VideoError(format!( + "Timed out waiting {:?} for video pipeline to release capture device", + timeout + ))); + } } - let _ = tokio::time::timeout(remaining, rx.changed()).await; } + + while !*encoder_rx.borrow() { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(AppError::VideoError(format!( + "Timed out waiting {:?} for video encoder to release vendor session", + timeout + ))); + } + match tokio::time::timeout(remaining, encoder_rx.changed()).await { + Ok(Ok(())) => {} + Ok(Err(_)) if *encoder_rx.borrow() => break, + Ok(Err(_)) => { + return Err(AppError::VideoError( + "Video encoder lifecycle channel closed before vendor session release" + .to_string(), + )); + } + Err(_) => { + return Err(AppError::VideoError(format!( + "Timed out waiting {:?} for video encoder to release vendor session", + timeout + ))); + } + } + } + + Ok(()) } /// Set bitrate using preset @@ -1696,62 +1588,10 @@ fn copy_rows( impl Drop for SharedVideoPipeline { fn drop(&mut self) { - let _ = self.running.send(false); + self.running_flag.store(false, Ordering::Release); } } -/// Parse H265 NAL unit types from Annex B data -fn parse_h265_nal_types(data: &[u8]) -> Vec<(u8, usize)> { - let mut nal_types = Vec::new(); - let mut i = 0; - - while i < data.len() { - // Find start code - let nal_start = if i + 4 <= data.len() - && data[i] == 0 - && data[i + 1] == 0 - && data[i + 2] == 0 - && data[i + 3] == 1 - { - i + 4 - } else if i + 3 <= data.len() && data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 { - i + 3 - } else { - i += 1; - continue; - }; - - if nal_start >= data.len() { - break; - } - - // Find next start code to get NAL size - let mut nal_end = data.len(); - let mut j = nal_start + 1; - while j + 3 <= data.len() { - if (data[j] == 0 && data[j + 1] == 0 && data[j + 2] == 1) - || (j + 4 <= data.len() - && data[j] == 0 - && data[j + 1] == 0 - && data[j + 2] == 0 - && data[j + 3] == 1) - { - nal_end = j; - break; - } - j += 1; - } - - // H265 NAL type is in bits 1-6 of first byte - let nal_type = (data[nal_start] >> 1) & 0x3F; - let nal_size = nal_end - nal_start; - nal_types.push((nal_type, nal_size)); - i = nal_end; - } - - nal_types -} - #[cfg(test)] mod tests { use super::*; @@ -1764,5 +1604,60 @@ mod tests { let h265 = SharedVideoPipelineConfig::h265(Resolution::HD720, BitratePreset::Speed); assert_eq!(h265.output_codec, VideoEncoderType::H265); + + assert_eq!(amlenc_supported_fps(30), 30); + assert_eq!(amlenc_supported_fps(50), 50); + assert_eq!(amlenc_supported_fps(60), 60); + assert_eq!(amlenc_supported_fps(120), 60); + } + + #[test] + fn stop_request_does_not_publish_worker_exit() { + let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264( + Resolution::HD720, + BitratePreset::Balanced, + )) + .unwrap(); + let _ = pipeline.running.send(true); + pipeline.running_flag.store(true, Ordering::Release); + + pipeline.stop(); + + assert!(!pipeline.running_flag.load(Ordering::Acquire)); + assert!(pipeline.is_running()); + assert_eq!(pipeline.lifecycle(), PipelineLifecycle::Stopping); + + // Simulate the capture thread's common cleanup tail. + let _ = pipeline.running.send(false); + assert!(!pipeline.is_running()); + assert_eq!(pipeline.lifecycle(), PipelineLifecycle::Stopped); + } + + #[tokio::test] + async fn stop_and_wait_observes_completed_worker_cleanup() { + let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264( + Resolution::HD720, + BitratePreset::Balanced, + )) + .unwrap(); + let _ = pipeline.running.send(true); + let _ = pipeline.encoder_done.send(false); + pipeline.running_flag.store(true, Ordering::Release); + + let worker = pipeline.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(30)).await; + let _ = worker.running.send(false); + tokio::time::sleep(Duration::from_millis(30)).await; + let _ = worker.encoder_done.send(true); + }); + + let started = Instant::now(); + pipeline + .stop_and_wait(Duration::from_secs(1)) + .await + .unwrap(); + assert!(started.elapsed() >= Duration::from_millis(50)); + assert!(!pipeline.is_running()); } } diff --git a/src/video/recovery.rs b/src/video/recovery.rs new file mode 100644 index 00000000..f72f4889 --- /dev/null +++ b/src/video/recovery.rs @@ -0,0 +1,134 @@ +//! Shared capture recovery policy. +//! +//! Device discovery decides whether an input follows an external source. The +//! capture layers consume that decision; they must not infer it again from a +//! driver name because doing so makes MJPEG and WebRTC recover differently. + +use std::time::Duration; + +use super::capture::BridgeContext; +use super::device::VideoControlMode; + +const SOURCE_FOLLOWING_RETRY_DELAYS: [Duration; 3] = [ + Duration::from_millis(500), + Duration::from_secs(1), + Duration::from_secs(2), +]; +const CONFIGURABLE_RETRY_DELAY: Duration = Duration::from_millis(500); +const CONFIGURABLE_RETRY_LIMIT: u32 = 60; + +#[derive(Debug, Clone, Copy)] +pub struct CaptureRecoveryPolicy { + control_mode: VideoControlMode, +} + +/// Wait for a source-change edge, falling back to the policy delay when the +/// driver does not expose events. The short slices keep shutdown responsive. +#[cfg(unix)] +pub fn wait_for_source_change( + bridge: &BridgeContext, + delay: Duration, + should_continue: impl Fn() -> bool, +) -> bool { + use std::time::Instant; + + use super::device::bridge; + + let Some(path) = bridge.subdev_path.as_ref() else { + return interruptible_sleep(delay, should_continue); + }; + let Ok(fd) = bridge::open_subdev(path) else { + return interruptible_sleep(delay, should_continue); + }; + if bridge::subscribe_source_change(&fd).is_err() { + return interruptible_sleep(delay, should_continue); + } + + let deadline = Instant::now() + delay; + while should_continue() && Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + match bridge::wait_source_change(&fd, remaining.min(Duration::from_millis(250))) { + Ok(true) => return true, + Ok(false) => {} + Err(_) => return false, + } + } + false +} + +#[cfg(windows)] +pub fn wait_for_source_change( + _bridge: &BridgeContext, + delay: Duration, + should_continue: impl Fn() -> bool, +) -> bool { + interruptible_sleep(delay, should_continue) +} + +fn interruptible_sleep(delay: Duration, should_continue: impl Fn() -> bool) -> bool { + use std::time::Instant; + + let deadline = Instant::now() + delay; + while should_continue() && Instant::now() < deadline { + std::thread::sleep( + deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_millis(100)), + ); + } + false +} + +impl CaptureRecoveryPolicy { + pub const fn new(control_mode: VideoControlMode) -> Self { + Self { control_mode } + } + + pub const fn control_mode(self) -> VideoControlMode { + self.control_mode + } + + /// Delay after `failed_attempts` consecutive attempts (one-based). + pub fn retry_delay(self, failed_attempts: u32) -> Duration { + match self.control_mode { + VideoControlMode::SourceFollowing => { + let index = failed_attempts.saturating_sub(1).min(2) as usize; + SOURCE_FOLLOWING_RETRY_DELAYS[index] + } + VideoControlMode::Configurable => CONFIGURABLE_RETRY_DELAY, + } + } + + /// Source-following inputs keep probing for as long as they have a + /// consumer. Configurable/UVC inputs retain the pre-existing finite policy. + pub const fn should_retry(self, failed_attempts: u32) -> bool { + match self.control_mode { + VideoControlMode::SourceFollowing => true, + VideoControlMode::Configurable => failed_attempts < CONFIGURABLE_RETRY_LIMIT, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_following_uses_capped_backoff_and_never_expires() { + let policy = CaptureRecoveryPolicy::new(VideoControlMode::SourceFollowing); + assert_eq!(policy.retry_delay(1), Duration::from_millis(500)); + assert_eq!(policy.retry_delay(2), Duration::from_secs(1)); + assert_eq!(policy.retry_delay(3), Duration::from_secs(2)); + assert_eq!(policy.retry_delay(10_000), Duration::from_secs(2)); + assert!(policy.should_retry(61)); + assert!(policy.should_retry(9_000)); // five hours at the capped delay + } + + #[test] + fn configurable_inputs_keep_the_finite_retry_policy() { + let policy = CaptureRecoveryPolicy::new(VideoControlMode::Configurable); + assert_eq!(policy.retry_delay(1), Duration::from_millis(500)); + assert!(policy.should_retry(59)); + assert!(!policy.should_retry(60)); + } +} diff --git a/src/video/stream_manager.rs b/src/video/stream_manager.rs index d60e4b7d..57343c94 100644 --- a/src/video/stream_manager.rs +++ b/src/video/stream_manager.rs @@ -358,29 +358,9 @@ impl VideoStreamManager { .update_video_config(resolution, format, fps) .await; if let Some(device_path) = device_path { - // Resolve the paired subdev so the WebRTC pipeline can run the - // RK628 STREAMON gate + SOURCE_CHANGE polling identically to the - // MJPEG path. See `csi_bridge::discover_subdev_for_video`. - let (subdev_path, bridge_kind, v4l2_driver) = self - .streamer - .current_device() - .await - .map(|d| { - ( - d.subdev_path.clone(), - d.bridge_kind.clone(), - Some(d.driver.clone()), - ) - }) - .unwrap_or((None, None, None)); + let device_info = self.streamer.current_device().await; self.webrtc_streamer - .set_capture_device( - device_path, - jpeg_quality, - subdev_path, - bridge_kind, - v4l2_driver, - ) + .set_capture_device(device_path, jpeg_quality, device_info) .await; } else { warn!("No capture device configured while syncing WebRTC capture source"); @@ -434,7 +414,7 @@ impl VideoStreamManager { let closed = self .webrtc_streamer .close_all_sessions_and_release_device() - .await; + .await?; if closed > 0 { info!("Closed {} WebRTC sessions", closed); } @@ -549,26 +529,9 @@ impl VideoStreamManager { } if let Some(device_path) = device_path { info!("Configuring direct capture for WebRTC after config change"); - let (subdev_path, bridge_kind, v4l2_driver) = self - .streamer - .current_device() - .await - .map(|d| { - ( - d.subdev_path.clone(), - d.bridge_kind.clone(), - Some(d.driver.clone()), - ) - }) - .unwrap_or((None, None, None)); + let device_info = self.streamer.current_device().await; self.webrtc_streamer - .set_capture_device( - device_path, - jpeg_quality, - subdev_path, - bridge_kind, - v4l2_driver, - ) + .set_capture_device(device_path, jpeg_quality, device_info) .await; } else { warn!("No capture device configured for WebRTC after config change"); diff --git a/src/video/streamer.rs b/src/video/streamer.rs index 31c57233..14e144d4 100644 --- a/src/video/streamer.rs +++ b/src/video/streamer.rs @@ -13,13 +13,13 @@ use tracing::{debug, error, info, trace, warn}; use super::device::{ bridge as csi_bridge, enumerate_devices, find_best_device, is_csi_hdmi_bridge, - parse_bridge_kind, select_recovery_device, VideoDevice, VideoDeviceInfo, - VideoDeviceRecoveryHint, + parse_bridge_kind, resolve_video_input_config, select_recovery_device, VideoControlMode, + VideoDevice, VideoDeviceInfo, VideoDeviceRecoveryHint, }; use super::format::{PixelFormat, Resolution}; use super::frame::{FrameBuffer, FrameBufferPool, VideoFrame}; use crate::error::{AppError, Result}; -use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent}; +use crate::events::{EventBus, StreamKind, SystemEvent}; use crate::stream::MjpegStreamHandler; use crate::utils::LogThrottler; use crate::video::capture::runtime::open_capture_stream; @@ -28,8 +28,9 @@ use crate::video::capture::status::{ CaptureIoErrorKind, }; use crate::video::capture::{ - is_source_changed_error, BridgeContext, CaptureStream, DEFAULT_CAPTURE_BUFFER_COUNT, + BridgeContext, CaptureReadError, CaptureStream, DEFAULT_CAPTURE_BUFFER_COUNT, }; +use crate::video::recovery::{wait_for_source_change, CaptureRecoveryPolicy}; const MIN_CAPTURE_FRAME_SIZE: usize = 128; @@ -251,6 +252,7 @@ impl Streamer { let next = self.next_retry_ms.load(Ordering::Relaxed); SystemEvent::StreamStateChanged { + kind: StreamKind::Video, state: external.to_string(), device, reason: reason.map(|s| s.to_string()), @@ -373,7 +375,10 @@ impl Streamer { .ok_or_else(|| AppError::VideoError("Video device not found".to_string()))? }; - let (format, resolution) = self.resolve_capture_config(&device, format, resolution)?; + let resolved = self.resolve_capture_config(&device, format, resolution, fps)?; + let format = resolved.format; + let resolution = resolved.resolution; + let fps = resolved.fps; // IMPORTANT: Disconnect all MJPEG clients FIRST before stopping capture // This prevents race conditions where clients try to reconnect and reopen the device @@ -442,19 +447,18 @@ impl Streamer { device.path.display() ); - // Determine best format for this device let config = self.config.read().await; - let format = self.select_format(&device, config.format)?; - let resolution = self.select_resolution(&device, &format, config.resolution)?; - + let resolved = + self.resolve_capture_config(&device, config.format, config.resolution, config.fps)?; drop(config); // Update config with actual values { let mut config = self.config.write().await; config.device_path = Some(device.path.clone()); - config.format = format; - config.resolution = resolution; + config.format = resolved.format; + config.resolution = resolved.resolution; + config.fps = resolved.fps; } // Store device info @@ -462,7 +466,10 @@ impl Streamer { *self.state.write().await = StreamerState::Ready; - info!("Streamer initialized: {} @ {}", format, resolution); + info!( + "Streamer initialized: {} @ {} {} fps", + resolved.format, resolved.resolution, resolved.fps + ); Ok(()) } @@ -574,10 +581,20 @@ impl Streamer { device: &VideoDeviceInfo, requested_format: PixelFormat, requested_resolution: Resolution, - ) -> Result<(PixelFormat, Resolution)> { - let format = self.select_format(device, requested_format)?; - let resolution = self.select_resolution(device, &format, requested_resolution)?; - Ok((format, resolution)) + requested_fps: u32, + ) -> Result { + let mut resolved = resolve_video_input_config( + device, + requested_format, + requested_resolution, + requested_fps, + ); + if device.control_mode == VideoControlMode::Configurable { + resolved.format = self.select_format(device, resolved.format)?; + resolved.resolution = + self.select_resolution(device, &resolved.format, resolved.resolution)?; + } + Ok(resolved) } /// Restart capture for recovery (direct capture path) @@ -616,6 +633,17 @@ impl Streamer { return Ok(()); } + // A no-signal/source-change recovery keeps the existing capture thread + // alive while it closes and re-opens the V4L2 stream. HTTP clients may + // reconnect while that thread is still probing. Do not spawn a second + // capture thread here: it would contend for the same video node and + // overwrite `direct_handle`, making the original thread impossible to + // join from `stop()`. + if self.direct_active.load(Ordering::SeqCst) { + debug!("Capture thread is already active; waiting for its recovery loop"); + return Ok(()); + } + if state == StreamerState::Uninitialized { // Auto-initialize if not done self.init_auto().await?; @@ -772,26 +800,9 @@ impl Streamer { const RETRY_DELAY_MS: u64 = 200; const IDLE_STOP_DELAY_SECS: u64 = 5; const BUFFER_COUNT: u32 = DEFAULT_CAPTURE_BUFFER_COUNT; - /// Initial back-off after signal loss before the first soft restart. - /// - /// PiKVM/ustreamer drops to sub-second recovery because it subscribes to - /// `V4L2_EVENT_SOURCE_CHANGE`; lacking that (for now), we bound how long - /// the user has to stare at a placeholder after a source-side resolution - /// change by driving a soft-restart at 1 s, then 2 s, 4 s, …, 8 s. - const NOSIGNAL_SOFT_RESTART_INITIAL_SECS: u64 = 1; - const NOSIGNAL_SOFT_RESTART_MAX_SECS: u64 = 8; - let handle = tokio::runtime::Handle::current(); let mut last_state = StreamerState::Streaming; - // Compute the current soft-restart back-off window (in seconds) - // for the exponential ladder 1 s → 2 s → 4 s → 8 s (capped). - let backoff_secs = |count: u32| -> u64 { - NOSIGNAL_SOFT_RESTART_INITIAL_SECS - .saturating_mul(2u64.pow(count.min(3))) - .min(NOSIGNAL_SOFT_RESTART_MAX_SECS) - }; - let mut set_state = |new_state: StreamerState| { if new_state != last_state { handle.block_on(async { @@ -818,20 +829,23 @@ impl Streamer { self.next_retry_ms.store(ms, Ordering::Relaxed); }; - // How many soft-restart cycles have been attempted (for exponential back-off). + // Consecutive recovery attempts, shared with the common retry policy. let mut no_signal_restart_count: u32 = 0; - - // Last (resolution, format, fps) combination for which we emitted a - // `StreamConfigApplied` event. Used to de-duplicate the event across - // soft-restarts that produce the exact same geometry (e.g. a spurious - // single-frame timeout on a stable source) — the frontend would - // otherwise re-layout the `` on every glitch. - let mut last_applied: Option<(u32, u32, PixelFormat, u32)> = None; + let mut no_consumers_since: Option = None; 'session: loop { if self.direct_stop.load(Ordering::Relaxed) { break 'session; } + if self.mjpeg_handler.client_count() == 0 { + let since = no_consumers_since.get_or_insert_with(std::time::Instant::now); + if since.elapsed() >= Duration::from_secs(IDLE_STOP_DELAY_SECS) { + info!("No MJPEG consumers during recovery; stopping capture"); + break 'session; + } + } else { + no_consumers_since = None; + } // Re-read config at the start of each session so that a re_init_device() // call (from a previous soft-restart or recovery) is reflected here. @@ -844,52 +858,44 @@ impl Streamer { // `VideoDeviceInfo` during enumeration; we re-read it here // rather than caching on Streamer so a hot-plug recovery picks // up a possibly-different subdev path. - let bridge_ctx = handle.block_on(async { + let (bridge_ctx, control_mode) = handle.block_on(async { self.current_device .read() .await .as_ref() .map(|info| { - BridgeContext::from_parts( - info.subdev_path.clone(), - parse_bridge_kind(info.bridge_kind.as_deref()), + ( + BridgeContext::from_parts( + info.subdev_path.clone(), + parse_bridge_kind(info.bridge_kind.as_deref()), + ), + info.control_mode, ) }) - .unwrap_or_default() + .unwrap_or((BridgeContext::default(), VideoControlMode::Configurable)) }); + let recovery_policy = CaptureRecoveryPolicy::new(control_mode); // ── STREAMON gate: for CSI bridges with a subdev, refuse to // open the video node when the subdev reports no signal. // On RK628 this prevents a kernel null-pointer deref. if let Some(subdev_path) = bridge_ctx.subdev_path.as_ref() { - match probe_subdev_signal(subdev_path, bridge_ctx.kind) { - Some(crate::video::signal::SignalStatus::NoCable) - | Some(crate::video::signal::SignalStatus::NoSync) - | Some(crate::video::signal::SignalStatus::NoSignal) - | Some(crate::video::signal::SignalStatus::OutOfRange) => { - let status = probe_subdev_signal(subdev_path, bridge_ctx.kind) - .unwrap_or(crate::video::signal::SignalStatus::NoSignal); - let wait_secs = backoff_secs(no_signal_restart_count); - debug!( - "Pre-STREAMON gate: subdev {:?} reports {:?} — \ - waiting for SOURCE_CHANGE (<= {}s) before opening {:?}", - subdev_path, status, wait_secs, device_path - ); - set_retry(wait_secs.saturating_mul(1000)); - go_offline(); - set_state(status.into()); - // Wait for SOURCE_CHANGE or timeout before retrying. - // Opens the subdev just for the poll — cheap and - // does NOT touch the video node. - wait_subdev_for_source_change( - subdev_path, - &self.direct_stop, - Duration::from_secs(wait_secs), - ); - no_signal_restart_count = no_signal_restart_count.saturating_add(1); - continue 'session; - } - _ => {} // Locked (None from as_status) or unknown — proceed + if let Some(status) = probe_subdev_signal(subdev_path, bridge_ctx.kind) { + let delay = + recovery_policy.retry_delay(no_signal_restart_count.saturating_add(1)); + debug!( + "Pre-STREAMON gate: subdev {:?} reports {:?} — \ + waiting for SOURCE_CHANGE (<= {:?}) before opening {:?}", + subdev_path, status, delay, device_path + ); + set_retry(delay.as_millis() as u64); + go_offline(); + set_state(status.into()); + wait_for_source_change(&bridge_ctx, delay, || { + !self.direct_stop.load(Ordering::Relaxed) + }); + no_signal_restart_count = no_signal_restart_count.saturating_add(1); + continue 'session; } } @@ -911,6 +917,7 @@ impl Streamer { BUFFER_COUNT, Duration::from_secs(2), bridge_ctx.clone(), + control_mode, ) { Ok(stream) => { stream_opt = Some(stream); @@ -927,7 +934,9 @@ impl Streamer { "CSI open probe reports no signal ({:?}), will soft-restart", status ); - set_retry(backoff_secs(no_signal_restart_count).saturating_mul(1000)); + let delay = + recovery_policy.retry_delay(no_signal_restart_count.saturating_add(1)); + set_retry(delay.as_millis() as u64); go_offline(); set_state(status.into()); last_error = Some(format!("CaptureNoSignal({})", kind)); @@ -976,9 +985,16 @@ impl Streamer { } debug!("Open failed in NoSignal-like state, backing off before soft-restart"); - let wait = backoff_secs(no_signal_restart_count); - set_retry(wait.saturating_mul(1000)); - std::thread::sleep(Duration::from_secs(wait)); + if !recovery_policy.should_retry(no_signal_restart_count.saturating_add(1)) { + set_state(StreamerState::Error); + break 'session; + } + let delay = + recovery_policy.retry_delay(no_signal_restart_count.saturating_add(1)); + set_retry(delay.as_millis() as u64); + wait_for_source_change(&bridge_ctx, delay, || { + !self.direct_stop.load(Ordering::Relaxed) + }); no_signal_restart_count = no_signal_restart_count.saturating_add(1); continue 'session; } @@ -986,15 +1002,34 @@ impl Streamer { let resolution = stream.resolution(); let pixel_format = stream.format(); + let source_fps = stream + .source_fps() + .map(|fps| fps.round().clamp(1.0, 120.0) as u32) + .unwrap_or(config.fps); let stride = stream.stride(); + if control_mode == VideoControlMode::SourceFollowing { + handle.block_on(async { + let mut current = self.config.write().await; + current.resolution = resolution; + current.format = pixel_format; + current.fps = source_fps; + }); + } + info!( "Capture format: {}x{} {:?} stride={}", resolution.width, resolution.height, pixel_format, stride ); let buffer_pool = Arc::new(FrameBufferPool::new(BUFFER_COUNT.max(4) as usize)); - let mut signal_present = true; + // Preserve the no-signal state across an outer-loop re-open. This + // makes the first recovered frame transition the handler back + // online and publish Streaming instead of silently inheriting the + // previous offline state. + let mut signal_present = !handle + .block_on(async { self.state().await }) + .is_no_signal_like(); let mut idle_since: Option = None; let mut fps_frame_count: u64 = 0; @@ -1033,20 +1068,26 @@ impl Streamer { let mut owned = buffer_pool.take(MIN_CAPTURE_FRAME_SIZE); let meta = match stream.next_into(&mut owned) { Ok(meta) => meta, - Err(e) => { - if is_source_changed_error(&e) { - info!("Capture SOURCE_CHANGE — soft-restart for DV re-probe"); - set_retry(backoff_secs(no_signal_restart_count).saturating_mul(1000)); - go_offline(); - set_state(StreamerState::NoSignal); - need_soft_restart = true; - break 'capture; + Err(CaptureReadError::SourceChanged) => { + info!("Capture SOURCE_CHANGE — soft-restart for DV re-probe"); + let delay = + recovery_policy.retry_delay(no_signal_restart_count.saturating_add(1)); + set_retry(delay.as_millis() as u64); + go_offline(); + set_state(StreamerState::NoSignal); + need_soft_restart = true; + break 'capture; + } + Err(CaptureReadError::Io(e)) => { + if e.kind() == std::io::ErrorKind::WouldBlock { + continue 'capture; } if e.kind() == std::io::ErrorKind::TimedOut { if signal_present { signal_present = false; - let wait = backoff_secs(no_signal_restart_count); - set_retry(wait.saturating_mul(1000)); + let delay = recovery_policy + .retry_delay(no_signal_restart_count.saturating_add(1)); + set_retry(delay.as_millis() as u64); go_offline(); set_state(StreamerState::NoSignal); no_signal_since = Some(std::time::Instant::now()); @@ -1054,11 +1095,12 @@ impl Streamer { fps_frame_count = 0; last_fps_time = std::time::Instant::now(); } else if let Some(since) = no_signal_since { - let wait = backoff_secs(no_signal_restart_count); - if since.elapsed().as_secs() >= wait { + let delay = recovery_policy + .retry_delay(no_signal_restart_count.saturating_add(1)); + if since.elapsed() >= delay { info!( - "NoSignal for {}s, attempting soft restart (attempt {})", - wait, + "NoSignal for {:?}, attempting soft restart (attempt {})", + delay, no_signal_restart_count + 1 ); need_soft_restart = true; @@ -1105,12 +1147,7 @@ impl Streamer { "Capture transient error (EPROTO/-71, often UVC USB): {}", e ); - let is_uvc = handle.block_on(async { - self.current_device.read().await.as_ref().is_some_and(|d| { - d.driver.eq_ignore_ascii_case("uvcvideo") - }) - }); - if is_uvc { + if control_mode == VideoControlMode::Configurable { go_offline(); set_state(StreamerState::UvcUsbError); need_soft_restart = true; @@ -1122,9 +1159,9 @@ impl Streamer { e ); } - set_retry( - backoff_secs(no_signal_restart_count).saturating_mul(1000), - ); + let delay = recovery_policy + .retry_delay(no_signal_restart_count.saturating_add(1)); + set_retry(delay.as_millis() as u64); go_offline(); set_state(StreamerState::NoSignal); need_soft_restart = true; @@ -1168,27 +1205,35 @@ impl Streamer { no_signal_since = None; no_signal_restart_count = 0; set_retry(0); + // Signal-loss handling marks the MJPEG handler offline so + // stale HTTP responses close cleanly. Re-enable it on the + // first recovered frame so a reconnect can remain attached + // to this (still single) capture thread. + self.mjpeg_handler.set_online(); set_state(StreamerState::Streaming); - let fps_val = config.fps; - let current = (resolution.width, resolution.height, pixel_format, fps_val); - if last_applied != Some(current) { - last_applied = Some(current); - let dp = device_path.display().to_string(); - let fmt = format!("{:?}", pixel_format); - let w = resolution.width; - let h = resolution.height; - handle.block_on(async { - self.publish_event(SystemEvent::StreamConfigApplied { - transition_id: None, - device: dp, - resolution: (w, h), - format: fmt, - fps: fps_val, - }) - .await; - }); - } + let fps_val = source_fps; + let recovered_device = device_path.display().to_string(); + handle.block_on(async { + self.publish_event(SystemEvent::StreamRecovered { + device: recovered_device, + }) + .await; + }); + let dp = device_path.display().to_string(); + let fmt = pixel_format.to_string(); + let w = resolution.width; + let h = resolution.height; + handle.block_on(async { + self.publish_event(SystemEvent::StreamConfigApplied { + transition_id: None, + device: dp, + resolution: (w, h), + format: fmt, + fps: fps_val, + }) + .await; + }); } self.mjpeg_handler.update_frame(frame); @@ -1218,65 +1263,8 @@ impl Streamer { } no_signal_restart_count = no_signal_restart_count.saturating_add(1); - - match VideoDevice::open_readonly(&device_path).and_then(|d| d.info()) { - Ok(device_info) => { - // Skip re-open while rkcif still reports placeholder (≤64²) geometry. - let probed_res = device_info - .formats - .first() - .and_then(|f| f.resolutions.first()) - .map(|r| (r.width, r.height)); - - if matches!(probed_res, Some((w, h)) if w <= 64 || h <= 64) - || probed_res.is_none() - { - warn!( - "Soft restart: probed resolution too small ({:?}), still no signal", - probed_res - ); - set_retry(2_000); - go_offline(); - std::thread::sleep(Duration::from_secs(2)); - continue 'session; - } - - handle.block_on(async { - let fmt; - let res; - { - let cfg = self.config.read().await; - fmt = self - .select_format(&device_info, cfg.format) - .unwrap_or(cfg.format); - res = self - .select_resolution(&device_info, &fmt, cfg.resolution) - .unwrap_or(cfg.resolution); - } - { - let mut cfg = self.config.write().await; - cfg.format = fmt; - cfg.resolution = res; - } - *self.current_device.write().await = Some(device_info); - info!( - "Soft restart: re-probed device → {}x{} {:?}", - res.width, res.height, fmt - ); - }); - } - Err(e) => { - warn!("Soft restart: failed to re-probe device: {}", e); - // Brief wait before retrying to avoid spinning. - let wait = 2u64.pow(no_signal_restart_count.min(3)); - std::thread::sleep(Duration::from_secs(wait)); - } - } - - // Reset no_signal_since so the back-off timer is fresh for the new session. - // no_signal_since will be re-set if the new session immediately times out. - - // Continue 'session → re-open CaptureStream with updated config. + // Continue 'session: the single open path performs QUERY_DV_TIMINGS, + // applies the source mode, and owns the retry delay. } // 'session self.direct_active.store(false, Ordering::SeqCst); @@ -1294,28 +1282,28 @@ impl Streamer { .map_err(|e| AppError::VideoError(format!("Cannot open device for re-init: {}", e)))?; let device_info = device.info()?; - let (format, resolution) = { + let resolved = { let config = self.config.read().await; - let fmt = self - .select_format(&device_info, config.format) - .unwrap_or(config.format); - let res = self - .select_resolution(&device_info, &fmt, config.resolution) - .unwrap_or(config.resolution); - (fmt, res) + self.resolve_capture_config(&device_info, config.format, config.resolution, config.fps) + .unwrap_or(super::device::ResolvedVideoInputConfig { + format: config.format, + resolution: config.resolution, + fps: config.fps, + }) }; { let mut cfg = self.config.write().await; cfg.device_path = Some(device_info.path.clone()); - cfg.format = format; - cfg.resolution = resolution; + cfg.format = resolved.format; + cfg.resolution = resolved.resolution; + cfg.fps = resolved.fps; } *self.current_device.write().await = Some(device_info); info!( "Device re-initialized: {}x{} {:?}", - resolution.width, resolution.height, format + resolved.resolution.width, resolved.resolution.height, resolved.format ); Ok(()) } @@ -1324,9 +1312,11 @@ impl Streamer { pub async fn stats(&self) -> StreamerStats { let config = self.config.read().await; let fps = self.current_fps.load(Ordering::Relaxed) as f32 / 100.0; + let (state, reason) = self.state().await.external_state(); StreamerStats { - state: self.state().await, + state: state.to_string(), + reason: reason.map(str::to_string), device: self.current_device().await.map(|d| d.name), format: Some(config.format.to_string()), resolution: Some((config.resolution.width, config.resolution.height)), @@ -1418,7 +1408,7 @@ impl Streamer { // Publish device lost event self.publish_event(SystemEvent::StreamDeviceLost { - kind: StreamDeviceLostKind::Video, + kind: StreamKind::Video, device: device.clone(), reason: reason.clone(), }) @@ -1567,7 +1557,9 @@ impl Default for Streamer { /// Streamer statistics #[derive(Debug, Clone, serde::Serialize)] pub struct StreamerStats { - pub state: StreamerState, + pub state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, pub device: Option, pub format: Option, pub resolution: Option<(u32, u32)>, @@ -1597,50 +1589,6 @@ fn probe_subdev_signal( probe.as_status() } -fn wait_subdev_for_source_change( - subdev_path: &std::path::Path, - direct_stop: &AtomicBool, - max_wait: Duration, -) { - let fd = match csi_bridge::open_subdev(subdev_path) { - Ok(f) => f, - Err(e) => { - debug!( - "wait_subdev_for_source_change: failed to open {:?}: {}", - subdev_path, e - ); - std::thread::sleep(max_wait.min(Duration::from_secs(1))); - return; - } - }; - if let Err(e) = csi_bridge::subscribe_source_change(&fd) { - debug!( - "wait_subdev_for_source_change: subscribe failed on {:?}: {}", - subdev_path, e - ); - } - let slice = Duration::from_millis(250); - let deadline = std::time::Instant::now() + max_wait; - while std::time::Instant::now() < deadline { - if direct_stop.load(Ordering::Relaxed) { - return; - } - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - let wait = remaining.min(slice); - match csi_bridge::wait_source_change(&fd, wait) { - Ok(true) => { - info!("Subdev SOURCE_CHANGE during no-signal wait, retrying open immediately"); - return; - } - Ok(false) => continue, - Err(e) => { - debug!("wait_source_change error on {:?}: {}", subdev_path, e); - return; - } - } - } -} - impl serde::Serialize for StreamerState { fn serialize(&self, serializer: S) -> std::result::Result where diff --git a/src/video/traits.rs b/src/video/traits.rs index 715c5dc4..3ab62e9c 100644 --- a/src/video/traits.rs +++ b/src/video/traits.rs @@ -10,6 +10,7 @@ use super::types::{ use crate::error::Result; use crate::events::EventBus; use crate::hid::HidController; +use crate::video::device::VideoDeviceInfo; /// Trait for video output consumers that receive encoded video frames. /// @@ -24,14 +25,12 @@ pub trait VideoOutput: Send + Sync { &self, device_path: PathBuf, jpeg_quality: u8, - subdev_path: Option, - bridge_kind: Option, - v4l2_driver: Option, + device_info: Option, ); async fn current_video_codec(&self) -> VideoCodecType; async fn is_hardware_encoding(&self) -> bool; async fn close_all_sessions(&self); - async fn close_all_sessions_and_release_device(&self) -> usize; + async fn close_all_sessions_and_release_device(&self) -> Result; async fn session_count(&self) -> usize; async fn set_hid_controller(&self, hid: Arc); async fn set_audio_enabled(&self, enabled: bool) -> Result<()>; diff --git a/src/video/types.rs b/src/video/types.rs index 56ad9f9f..31ba49f8 100644 --- a/src/video/types.rs +++ b/src/video/types.rs @@ -17,6 +17,6 @@ pub use super::codec::registry::{EncoderBackend, VideoEncoderType}; // From video::pipeline pub use super::pipeline::{ - EncodedVideoFrame, PipelineStateNotification, SharedVideoPipeline, SharedVideoPipelineConfig, - SharedVideoPipelineStats, + EncodedVideoFrame, PipelineAppliedConfig, PipelineLifecycle, PipelineStateNotification, + SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats, }; diff --git a/src/web/error.rs b/src/web/error.rs index 41edda1c..abf809c1 100644 --- a/src/web/error.rs +++ b/src/web/error.rs @@ -9,6 +9,8 @@ use serde::Serialize; #[derive(Serialize)] pub struct ErrorResponse { pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option<&'static str>, pub message: String, } @@ -17,7 +19,8 @@ impl IntoResponse for AppError { let status = status_code(&self); let body = ErrorResponse { success: false, - message: self.to_string(), + code: error_code(&self), + message: public_message(&self), }; tracing::error!( @@ -38,10 +41,53 @@ fn status_code(error: &AppError) -> StatusCode { AppError::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS, AppError::NotFound(_) => StatusCode::NOT_FOUND, AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, + AppError::Msd(error) => msd_status_code(error.code()), _ => StatusCode::INTERNAL_SERVER_ERROR, } } +fn error_code(error: &AppError) -> Option<&'static str> { + match error { + AppError::Msd(error) => Some(error.code().as_str()), + _ => None, + } +} + +fn public_message(error: &AppError) -> String { + match error { + AppError::Msd(error) => error.code().message().to_string(), + _ => error.to_string(), + } +} + +pub(crate) fn msd_status_code(code: crate::error::MsdErrorCode) -> StatusCode { + use crate::error::MsdErrorCode::*; + match code { + MsdUnavailable => StatusCode::SERVICE_UNAVAILABLE, + MsdResourceNotFound | MsdDriveNotInitialized => StatusCode::NOT_FOUND, + MsdOperationInProgress + | MsdResourceAlreadyExists + | MsdMediaSlotsFull + | MsdMediaAlreadyMounted + | MsdMediaInUse + | MsdDriveConnected + | MsdMediumRemovalPrevented => StatusCode::CONFLICT, + MsdInvalidRequest + | MsdImageTooLarge + | MsdInvalidUrl + | MsdDriveFilesystemUnsupported + | MsdDriveSizeInvalid + | MsdStorageSpaceUnavailable + | MsdStorageFull + | MsdStorageReadOnly + | MsdStoragePermissionDenied => StatusCode::BAD_REQUEST, + MsdOperationFailed + | MsdRemoteDownloadFailed + | MsdDownloadIncomplete + | MsdDisconnectFailed => StatusCode::INTERNAL_SERVER_ERROR, + } +} + #[cfg(test)] mod tests { use super::*; @@ -72,6 +118,18 @@ mod tests { status_code(&AppError::RateLimited("limited".to_string())), StatusCode::TOO_MANY_REQUESTS ); + assert_eq!( + status_code(&AppError::from( + crate::error::MsdErrorCode::MsdMediumRemovalPrevented + )), + StatusCode::CONFLICT + ); + assert_eq!( + error_code(&AppError::from( + crate::error::MsdErrorCode::MsdMediumRemovalPrevented + )), + Some("MSD_MEDIUM_REMOVAL_PREVENTED") + ); } #[test] @@ -81,4 +139,60 @@ mod tests { StatusCode::INTERNAL_SERVER_ERROR ); } + + #[test] + fn every_msd_error_has_a_stable_code_message_and_status() { + use crate::error::MsdErrorCode::*; + let cases = [ + (MsdUnavailable, StatusCode::SERVICE_UNAVAILABLE), + (MsdOperationInProgress, StatusCode::CONFLICT), + (MsdOperationFailed, StatusCode::INTERNAL_SERVER_ERROR), + (MsdInvalidRequest, StatusCode::BAD_REQUEST), + (MsdResourceNotFound, StatusCode::NOT_FOUND), + (MsdResourceAlreadyExists, StatusCode::CONFLICT), + (MsdMediaSlotsFull, StatusCode::CONFLICT), + (MsdMediaAlreadyMounted, StatusCode::CONFLICT), + (MsdMediaInUse, StatusCode::CONFLICT), + (MsdImageTooLarge, StatusCode::BAD_REQUEST), + (MsdInvalidUrl, StatusCode::BAD_REQUEST), + (MsdRemoteDownloadFailed, StatusCode::INTERNAL_SERVER_ERROR), + (MsdDownloadIncomplete, StatusCode::INTERNAL_SERVER_ERROR), + (MsdDriveNotInitialized, StatusCode::NOT_FOUND), + (MsdDriveConnected, StatusCode::CONFLICT), + (MsdDriveFilesystemUnsupported, StatusCode::BAD_REQUEST), + (MsdDriveSizeInvalid, StatusCode::BAD_REQUEST), + (MsdStorageSpaceUnavailable, StatusCode::BAD_REQUEST), + (MsdStorageFull, StatusCode::BAD_REQUEST), + (MsdStorageReadOnly, StatusCode::BAD_REQUEST), + (MsdStoragePermissionDenied, StatusCode::BAD_REQUEST), + (MsdMediumRemovalPrevented, StatusCode::CONFLICT), + (MsdDisconnectFailed, StatusCode::INTERNAL_SERVER_ERROR), + ]; + assert_eq!(cases.len(), crate::error::MsdErrorCode::ALL.len()); + for (code, expected_status) in cases { + let error = AppError::from(code); + assert_eq!(error_code(&error), Some(code.as_str())); + assert_eq!(public_message(&error), code.message()); + assert!(!code.message().contains('/')); + assert_eq!(msd_status_code(code), expected_status); + } + } + + #[tokio::test] + async fn msd_response_contains_only_the_public_error_contract() { + let response = + AppError::from(crate::error::MsdErrorCode::MsdOperationFailed).into_response(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(json["success"], false); + assert_eq!(json["code"], "MSD_OPERATION_FAILED"); + assert_eq!( + json["message"], + crate::error::MsdErrorCode::MsdOperationFailed.message() + ); + assert_eq!(json.as_object().unwrap().len(), 3); + } } diff --git a/src/web/handlers/config/apply.rs b/src/web/handlers/config/apply.rs index 4a0f6f2b..be073e4c 100644 --- a/src/web/handlers/config/apply.rs +++ b/src/web/handlers/config/apply.rs @@ -76,7 +76,7 @@ async fn reconcile_otg_config( hid: &HidConfig, msd: &MsdConfig, network: &OtgNetworkConfig, - uac: &crate::otg::service::UacConfig, + uac: &UacConfig, ) -> Result<()> { #[cfg(not(unix))] { @@ -195,6 +195,7 @@ pub async fn apply_hid_config( new_config: &HidConfig, msd_config: &MsdConfig, network_config: &OtgNetworkConfig, + uac_config: &UacConfig, options: ConfigApplyOptions, ) -> Result<()> { new_config.validate_otg_functions()?; @@ -237,7 +238,7 @@ pub async fn apply_hid_config( } if otg_config_changed { - reconcile_otg_config(state, new_config, msd_config, network_config, &state.config.get().uac).await?; + reconcile_otg_config(state, new_config, msd_config, network_config, uac_config).await?; } if !transitioning_away_from_otg { @@ -263,6 +264,7 @@ pub async fn apply_msd_config( new_config: &MsdConfig, hid_config: &HidConfig, network_config: &OtgNetworkConfig, + uac_config: &UacConfig, options: ConfigApplyOptions, ) -> Result<()> { let hid_backend_is_otg = hid_config.backend == HidBackend::Otg; @@ -275,6 +277,9 @@ pub async fn apply_msd_config( let old_msd_enabled = old_config.enabled; let new_msd_enabled = effective_new_msd_enabled; let msd_dir_changed = old_config.msd_dir != new_config.msd_dir; + let inquiry_strings_changed = old_config.flash_inquiry_string + != new_config.flash_inquiry_string + || old_config.cdrom_inquiry_string != new_config.cdrom_inquiry_string; tracing::info!( "MSD enabled: old={}, new={}", @@ -284,6 +289,9 @@ pub async fn apply_msd_config( if msd_dir_changed { tracing::info!("MSD directory changed: {}", new_config.msd_dir); } + if inquiry_strings_changed { + tracing::info!("MSD inquiry strings changed"); + } let msd_dir = new_config.msd_dir_path(); if let Err(e) = std::fs::create_dir_all(msd_dir.join("images")) { @@ -293,19 +301,19 @@ pub async fn apply_msd_config( tracing::warn!("Failed to create MSD ventoy directory: {}", e); } - let needs_reload = options.force || old_msd_enabled != new_msd_enabled || msd_dir_changed; + let needs_reload = options.force + || old_msd_enabled != new_msd_enabled + || msd_dir_changed + || inquiry_strings_changed; if !needs_reload { - tracing::info!( - "MSD enabled state unchanged ({}) and directory unchanged, no reload needed", - new_msd_enabled - ); + tracing::info!("MSD configuration unchanged, no reload needed"); return Ok(()); } if new_msd_enabled { tracing::info!("(Re)initializing MSD..."); - reconcile_otg_config(state, hid_config, new_config, network_config, &state.config.get().uac).await?; + reconcile_otg_config(state, hid_config, new_config, network_config, uac_config).await?; let mut msd_guard = state.msd.write().await; if let Some(msd) = msd_guard.as_mut() { @@ -340,7 +348,7 @@ pub async fn apply_msd_config( *msd_guard = None; tracing::info!("MSD shutdown complete"); - reconcile_otg_config(state, hid_config, new_config, network_config, &state.config.get().uac).await?; + reconcile_otg_config(state, hid_config, new_config, network_config, uac_config).await?; } if hid_config.backend == HidBackend::Otg @@ -367,10 +375,29 @@ pub async fn apply_usb_config( old_config.hid.backend == HidBackend::Otg && new_config.hid.backend != HidBackend::Otg; let hid_unchanged = old_config.hid == new_config.hid; - let otg_gadget_rebuilt = - old_config.msd != new_config.msd - || old_config.otg_network != new_config.otg_network - || old_config.uac != new_config.uac; + let otg_gadget_rebuilt = old_config.msd != new_config.msd + || old_config.otg_network != new_config.otg_network + || old_config.uac != new_config.uac + || old_config.hid.otg_udc != new_config.hid.otg_udc + || old_config.hid.otg_descriptor != new_config.hid.otg_descriptor + || old_config.hid.backend != new_config.hid.backend + || old_config.hid.constrained_otg_functions() + != new_config.hid.constrained_otg_functions() + || old_config.hid.effective_otg_keyboard_leds() + != new_config.hid.effective_otg_keyboard_leds(); + let restart_uac_playback = + old_config.uac != new_config.uac || (new_config.uac.enabled && otg_gadget_rebuilt); + + // A bound ALSA handle refers to the old configfs function. Stop it + // before any gadget teardown so the worker cannot write through a + // disappearing PCM node. It is restarted only after every reconcile. + if restart_uac_playback { + let playback = state.uac_playback.write().await.take(); + if let Some(playback) = playback { + playback.stop(); + tracing::info!("UAC playback writer stopped before OTG reconcile"); + } + } if transitioning_away_from_otg { apply_hid_config( @@ -379,6 +406,7 @@ pub async fn apply_usb_config( &new_config.hid, &new_config.msd, &new_config.otg_network, + &new_config.uac, ConfigApplyOptions::default(), ) .await?; @@ -397,6 +425,7 @@ pub async fn apply_usb_config( &new_config.hid, &new_config.msd, &new_config.otg_network, + &new_config.uac, ConfigApplyOptions::default(), ) .await?; @@ -408,37 +437,9 @@ pub async fn apply_usb_config( if hid_unchanged && otg_gadget_rebuilt && new_config.hid.backend == HidBackend::Otg { tracing::info!("OTG gadget rebuilt, reloading HID backend for new devices"); let hid_backend = hid_backend_type(&new_config.hid); - state - .hid - .reload(hid_backend) - .await - .map_err(|e| AppError::Config(format!("HID reload after gadget rebuild failed: {}", e)))?; - } - - // UAC playback writer lifecycle - if old_config.uac.enabled != new_config.uac.enabled { - let mut guard = state.uac_playback.write().await; - if new_config.uac.enabled { - let config = crate::audio::uac_streamer::UacPlaybackConfig { - sample_rate: new_config.uac.sample_rate, - channels: new_config.uac.channels as u16, - ..Default::default() - }; - match crate::audio::uac_streamer::UacPlaybackWriter::start(config) { - Ok(writer) => { - tracing::info!("UAC playback writer started"); - *guard = Some(writer); - } - Err(e) => { - tracing::warn!("Failed to start UAC playback writer: {}", e); - } - } - } else { - if let Some(writer) = guard.take() { - writer.stop(); - tracing::info!("UAC playback writer stopped"); - } - } + state.hid.reload(hid_backend).await.map_err(|e| { + AppError::Config(format!("HID reload after gadget rebuild failed: {}", e)) + })?; } apply_msd_config( @@ -447,9 +448,29 @@ pub async fn apply_usb_config( &new_config.msd, &new_config.hid, &new_config.otg_network, + &new_config.uac, ConfigApplyOptions::default(), ) - .await + .await?; + + // apply_msd_config may perform a second gadget reconcile. Resolve the + // new ALSA card only after that final rebuild, then publish the worker. + if restart_uac_playback && new_config.uac.enabled { + let config = crate::audio::uac::UacPlaybackConfig { + sample_rate: new_config.uac.sample_rate, + channels: new_config.uac.channels as u16, + ..Default::default() + }; + let writer = crate::audio::uac::UacPlayback::start(config).map_err(|error| { + AppError::Config(format!("Failed to start UAC playback: {error}")) + })?; + *state.uac_playback.write().await = Some(writer); + tracing::info!("UAC playback writer started after OTG reconcile"); + } else if restart_uac_playback { + tracing::info!("UAC playback remains disabled"); + } + + Ok(()) } #[cfg(not(unix))] @@ -460,6 +481,7 @@ pub async fn apply_usb_config( &new_config.hid, &new_config.msd, &new_config.otg_network, + &new_config.uac, ConfigApplyOptions::default(), ) .await diff --git a/src/web/handlers/config/mod.rs b/src/web/handlers/config/mod.rs index 5852224e..8dbfbcf8 100644 --- a/src/web/handlers/config/mod.rs +++ b/src/web/handlers/config/mod.rs @@ -42,9 +42,9 @@ pub use rustdesk::{ regenerate_device_password, start_rustdesk_service, stop_rustdesk_service, update_rustdesk_config, }; +pub use stream::{get_stream_config, update_stream_config}; #[cfg(unix)] pub use uac::{get_uac_config, update_uac_config}; -pub use stream::{get_stream_config, update_stream_config}; pub use video::{get_video_config, update_video_config}; pub use vnc::{ get_vnc_config, get_vnc_status, start_vnc_service, stop_vnc_service, update_vnc_config, diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index 5565397e..83f0d487 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -54,6 +54,22 @@ pub struct VideoConfigUpdate { } impl VideoConfigUpdate { + pub fn ignore_source_following_parameters(&mut self) { + if self.format.is_some() + || self.width.is_some() + || self.height.is_some() + || self.fps.is_some() + { + tracing::debug!( + "Ignoring client-supplied format, resolution, and FPS for source-following video input" + ); + } + self.format = None; + self.width = None; + self.height = None; + self.fps = None; + } + pub fn validate(&self) -> crate::error::Result<()> { if let Some(width) = self.width { if !(320..=7680).contains(&width) { @@ -106,6 +122,32 @@ impl VideoConfigUpdate { } } +#[cfg(test)] +mod video_config_update_tests { + use super::VideoConfigUpdate; + + #[test] + fn source_following_parameters_are_silently_discarded() { + let mut update = VideoConfigUpdate { + device: Some("/dev/video0".to_string()), + format: Some("MJPEG".to_string()), + width: Some(7680), + height: Some(4320), + fps: Some(120), + quality: Some(90), + }; + update.ignore_source_following_parameters(); + + assert_eq!(update.device.as_deref(), Some("/dev/video0")); + assert!(update.format.is_none()); + assert!(update.width.is_none()); + assert!(update.height.is_none()); + assert!(update.fps.is_none()); + assert_eq!(update.quality, Some(90)); + assert!(update.validate().is_ok()); + } +} + /// Stream configuration response #[typeshare] #[derive(Debug, serde::Serialize)] @@ -476,6 +518,8 @@ impl OtgNetworkConfigUpdate { pub struct MsdConfigUpdate { pub enabled: Option, pub msd_dir: Option, + pub flash_inquiry_string: Option, + pub cdrom_inquiry_string: Option, } #[cfg(unix)] @@ -492,6 +536,12 @@ impl MsdConfigUpdate { )); } } + if let Some(ref value) = self.flash_inquiry_string { + MsdConfig::validate_inquiry_string("Flash", value)?; + } + if let Some(ref value) = self.cdrom_inquiry_string { + MsdConfig::validate_inquiry_string("CD-ROM", value)?; + } Ok(()) } @@ -502,6 +552,12 @@ impl MsdConfigUpdate { if let Some(ref dir) = self.msd_dir { config.msd_dir = dir.trim().to_string(); } + if let Some(ref value) = self.flash_inquiry_string { + config.flash_inquiry_string = value.trim().to_string(); + } + if let Some(ref value) = self.cdrom_inquiry_string { + config.cdrom_inquiry_string = value.trim().to_string(); + } } } diff --git a/src/web/handlers/config/uac.rs b/src/web/handlers/config/uac.rs index d7a7b044..90b0ec43 100644 --- a/src/web/handlers/config/uac.rs +++ b/src/web/handlers/config/uac.rs @@ -2,11 +2,11 @@ use std::sync::Arc; use axum::{extract::State, Json}; +use crate::config::UacConfig; use crate::error::Result; -use crate::otg::service::UacConfig; use crate::state::AppState; -use super::apply::try_apply_lock; +use super::usb_update::update_usb_config; pub async fn get_uac_config(State(state): State>) -> Json { Json(state.config.get().uac.clone()) @@ -14,23 +14,13 @@ pub async fn get_uac_config(State(state): State>) -> Json>, - Json(req): Json, + Json(request): Json, ) -> Result> { - req.validate()?; - let _guard = try_apply_lock(&state.config_apply_locks.otg, "uac")?; - - let old_config = (*state.config.get()).clone(); - let mut new_config = old_config.clone(); - new_config.uac = req; - - state - .config - .update(|config| { - config.uac = new_config.uac.clone(); - }) - .await?; - - super::apply::apply_usb_config(&state, &old_config, &new_config).await?; - - Ok(Json(state.config.get().uac.clone())) + request.validate()?; + let config = update_usb_config(&state, move |staged| { + staged.uac = request; + Ok(None) + }) + .await?; + Ok(Json(config.uac)) } diff --git a/src/web/handlers/config/usb_update.rs b/src/web/handlers/config/usb_update.rs index 4ed19d2a..bce96476 100644 --- a/src/web/handlers/config/usb_update.rs +++ b/src/web/handlers/config/usb_update.rs @@ -55,6 +55,7 @@ where staged_config.otg_network.host_mac = host_mac; } staged_config.otg_network.validate()?; + staged_config.uac.validate()?; } if let Err(error) = apply_usb_config(state, &old_config, &staged_config).await { @@ -92,6 +93,7 @@ where config.hid = staged_config.hid.clone(); config.msd = staged_config.msd.clone(); config.otg_network = staged_config.otg_network.clone(); + config.uac = staged_config.uac.clone(); config.enforce_invariants(); }) .await diff --git a/src/web/handlers/config/video.rs b/src/web/handlers/config/video.rs index 865da9d5..d1e3b039 100644 --- a/src/web/handlers/config/video.rs +++ b/src/web/handlers/config/video.rs @@ -14,8 +14,30 @@ pub async fn get_video_config(State(state): State>) -> Json>, - Json(req): Json, + Json(mut req): Json, ) -> Result> { + let selected_path = req + .device + .clone() + .or_else(|| state.config.get().video.device.clone()); + if let Some(path) = selected_path { + let source_following = state + .stream_manager + .list_devices() + .await + .ok() + .and_then(|devices| { + devices + .into_iter() + .find(|device| device.path.to_string_lossy() == path) + }) + .is_some_and(|device| { + device.control_mode == crate::video::device::VideoControlMode::SourceFollowing + }); + if source_following { + req.ignore_source_following_parameters(); + } + } req.validate()?; let _apply_guard = try_apply_lock(&state.config_apply_locks.video, "video")?; diff --git a/src/web/handlers/extensions.rs b/src/web/handlers/extensions.rs index 963364f7..6b4e9019 100644 --- a/src/web/handlers/extensions.rs +++ b/src/web/handlers/extensions.rs @@ -4,76 +4,19 @@ use axum::{ }; use serde::Deserialize; use std::sync::Arc; -use toml_edit::DocumentMut; use typeshare::typeshare; use crate::error::{AppError, Result}; use crate::extensions::{ - EasytierConfig, EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs, ExtensionsStatus, - FrpProxyType, FrpcConfig, FrpcConfigMode, FrpcInfo, GostcConfig, GostcInfo, TtydConfig, - TtydInfo, + validate_easytier_config, validate_extension_config, validate_frpc_config, + validate_gostc_config, EasytierConfig, EasytierConfigMode, EasytierInfo, ExtensionId, + ExtensionInfo, ExtensionLogs, ExtensionsStatus, FrpProxyType, FrpcConfig, FrpcConfigMode, + FrpcInfo, GostcConfig, GostcInfo, TtydConfig, TtydInfo, }; use crate::state::AppState; -fn validate_gostc_enabled(config: &GostcConfig) -> Result<()> { - if config.addr.trim().is_empty() { - return Err(AppError::BadRequest( - "GOSTC server address is required".into(), - )); - } - if config.key.is_empty() { - return Err(AppError::BadRequest("GOSTC client key is required".into())); - } - Ok(()) -} - -fn validate_easytier_enabled(config: &EasytierConfig) -> Result<()> { - if config.network_name.trim().is_empty() { - return Err(AppError::BadRequest( - "EasyTier network name is required".into(), - )); - } - Ok(()) -} - -fn validate_frpc_enabled(config: &FrpcConfig) -> Result<()> { - match config.config_mode { - FrpcConfigMode::Quick => { - if config.proxy_name.trim().is_empty() { - return Err(AppError::BadRequest("FRPC proxy name is required".into())); - } - if config.server_addr.trim().is_empty() { - return Err(AppError::BadRequest( - "FRPC server address is required".into(), - )); - } - if config.token.is_empty() { - return Err(AppError::BadRequest("FRPC token is required".into())); - } - if config.local_ip.trim().is_empty() { - return Err(AppError::BadRequest("FRPC local IP is required".into())); - } - if matches!(config.proxy_type, FrpProxyType::Tcp | FrpProxyType::Udp) - && config.remote_port.is_none() - { - return Err(AppError::BadRequest( - "FRPC remote port is required for TCP/UDP proxies".into(), - )); - } - } - FrpcConfigMode::Full => { - let toml = config.custom_toml.trim(); - if toml.is_empty() { - return Err(AppError::BadRequest( - "FRPC full configuration is required".into(), - )); - } - toml.parse::().map_err(|e| { - AppError::BadRequest(format!("FRPC full configuration is not valid TOML: {}", e)) - })?; - } - } - Ok(()) +fn bad_request(validation: std::result::Result<(), String>) -> Result<()> { + validation.map_err(AppError::BadRequest) } pub async fn list_extensions(State(state): State>) -> Json { @@ -131,6 +74,8 @@ pub async fn start_extension( let config = state.config.get(); let mgr = &state.extensions; + bad_request(validate_extension_config(ext_id, &config.extensions))?; + mgr.start(ext_id, &config.extensions) .await .map_err(AppError::Internal)?; @@ -200,10 +145,12 @@ pub struct GostcConfigUpdate { #[derive(Debug, Deserialize)] pub struct EasytierConfigUpdate { pub enabled: Option, + pub config_mode: Option, pub network_name: Option, pub network_secret: Option, pub peer_urls: Option>, pub virtual_ip: Option, + pub custom_toml: Option, } #[typeshare] @@ -284,7 +231,7 @@ pub async fn update_gostc_config( } if next_gostc.enabled { - validate_gostc_enabled(&next_gostc)?; + bad_request(validate_gostc_config(&next_gostc))?; } state @@ -321,6 +268,9 @@ pub async fn update_easytier_config( if let Some(enabled) = req.enabled { next_easytier.enabled = enabled; } + if let Some(config_mode) = req.config_mode { + next_easytier.config_mode = config_mode; + } if let Some(ref name) = req.network_name { next_easytier.network_name = name.clone(); } @@ -333,9 +283,12 @@ pub async fn update_easytier_config( if req.virtual_ip.is_some() { next_easytier.virtual_ip = req.virtual_ip.clone(); } + if let Some(ref custom_toml) = req.custom_toml { + next_easytier.custom_toml = custom_toml.clone(); + } - if next_easytier.enabled { - validate_easytier_enabled(&next_easytier)?; + if next_easytier.enabled || matches!(next_easytier.config_mode, EasytierConfigMode::Full) { + bad_request(validate_easytier_config(&next_easytier))?; } state @@ -414,7 +367,7 @@ pub async fn update_frpc_config( } if next_frpc.enabled || matches!(next_frpc.config_mode, FrpcConfigMode::Full) { - validate_frpc_enabled(&next_frpc)?; + bad_request(validate_frpc_config(&next_frpc))?; } state diff --git a/src/web/handlers/inventory.rs b/src/web/handlers/inventory.rs index 5908d1ed..25082a19 100644 --- a/src/web/handlers/inventory.rs +++ b/src/web/handlers/inventory.rs @@ -23,6 +23,13 @@ pub struct VideoDevice { pub formats: Vec, pub usb_bus: Option, pub has_signal: bool, + pub control_mode: crate::video::device::VideoControlMode, + pub input_status: crate::video::device::VideoInputStatus, +} + +#[derive(Deserialize)] +pub struct VideoInputStatusQuery { + pub device: String, } #[derive(Serialize)] @@ -121,6 +128,8 @@ pub async fn list_devices(State(state): State>) -> Json>) -> Json Option { + let path = std::path::Path::new(path); + let name = path.file_name()?.to_str()?; + if path.parent() != Some(std::path::Path::new("/dev")) + || !name.starts_with("video") + || name.len() == "video".len() + || !name["video".len()..].chars().all(|c| c.is_ascii_digit()) + || !sysfs_root.join(name).exists() + { + return None; + } + Some(path.to_path_buf()) +} + +pub async fn video_input_status( + Query(query): Query, +) -> Result> { + #[cfg(unix)] + let path = validated_video_node( + &query.device, + std::path::Path::new("/sys/class/video4linux"), + ) + .ok_or_else(|| AppError::BadRequest("Invalid video device".to_string()))?; + + #[cfg(windows)] + let path = crate::video::device::enumerate_devices()? + .into_iter() + .find(|device| device.path.to_string_lossy() == query.device) + .map(|device| device.path) + .ok_or_else(|| AppError::BadRequest("Invalid video device".to_string()))?; + + let probe_path = path.clone(); + let status = tokio::task::spawn_blocking(move || { + crate::video::device::VideoDevice::open_readonly(&probe_path) + .and_then(|device| device.input_status()) + }) + .await + .ok() + .and_then(|result| result.ok()) + .unwrap_or_else(|| { + debug!(device = %path.display(), "Unable to read video input status"); + crate::video::device::VideoInputStatus::unavailable() + }); + + Ok(Json(status)) +} + +#[cfg(all(test, unix))] +mod tests { + use super::validated_video_node; + + #[test] + fn only_accepts_dev_video_nodes_present_in_sysfs() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("video7")).unwrap(); + + assert_eq!( + validated_video_node("/dev/video7", root.path()).unwrap(), + std::path::PathBuf::from("/dev/video7") + ); + assert!(validated_video_node("/dev/video8", root.path()).is_none()); + assert!(validated_video_node("/tmp/video7", root.path()).is_none()); + assert!(validated_video_node("/dev/video7/../mem", root.path()).is_none()); + assert!(validated_video_node("/dev/video", root.path()).is_none()); + } +} diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index 521861b8..b34927c7 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -40,7 +40,7 @@ use axum::{ use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; use self::config::apply::ConfigApplyOptions; use crate::auth::{Session, SESSION_COOKIE}; diff --git a/src/web/handlers/msd_api.rs b/src/web/handlers/msd_api.rs index 9f0e7943..05ca0c60 100644 --- a/src/web/handlers/msd_api.rs +++ b/src/web/handlers/msd_api.rs @@ -3,12 +3,14 @@ use super::*; use crate::msd::{ DiskModeRequest, DownloadProgress, DriveFile, DriveInfo, DriveInitRequest, - ImageDownloadRequest, ImageInfo, ImageManager, ImageMountRequest, MsdState, MsdStateResponse, - VentoyDrive, MIN_DRIVE_SIZE_MB, + ImageDownloadRequest, ImageInfo, ImageManager, ImageMountRequest, MsdErrorCode, MsdState, + MsdStateResponse, VentoyDrive, MIN_DRIVE_SIZE_MB, }; #[cfg(unix)] use axum::body::Body; #[cfg(unix)] +use axum::extract::{multipart::MultipartRejection, rejection::JsonRejection}; +#[cfg(unix)] use axum::extract::{Multipart, Path as AxumPath}; #[cfg(unix)] use axum::http::{header, StatusCode}; @@ -29,10 +31,7 @@ async fn assert_drive_not_connected(state: &Arc) -> Result<()> { let msd_guard = state.msd.read().await; if let Some(controller) = msd_guard.as_ref() { if controller.is_drive_connected().await { - return Err(AppError::BadRequest( - "Virtual drive is connected to the USB host; disconnect it before modifying files" - .to_string(), - )); + return Err(MsdErrorCode::MsdDriveConnected.into()); } } Ok(()) @@ -42,35 +41,61 @@ async fn assert_drive_not_connected(state: &Arc) -> Result<()> { fn validate_drive_init_size(size_mb: u32, available_bytes: u64) -> Result<()> { let requested_bytes = size_mb as u64 * MIB; if size_mb < MIN_DRIVE_SIZE_MB { - return Err(AppError::BadRequest(format!( - "Virtual drive size must be at least {} MB", - MIN_DRIVE_SIZE_MB - ))); + return Err(MsdErrorCode::MsdDriveSizeInvalid.into()); } if requested_bytes > available_bytes { - return Err(AppError::BadRequest(format!( - "Virtual drive size cannot exceed available space on the MSD directory filesystem (available {} MB, requested {} MB)", - available_bytes / MIB, - size_mb - ))); + return Err(MsdErrorCode::MsdStorageFull.into()); } Ok(()) } #[cfg(unix)] -fn is_unsupported_drive_filesystem(error: &str) -> bool { - error.contains("Filesystem error") - || error.contains("Image error") - || error.contains("Partition error") +fn msd_controller<'a>( + guard: &'a tokio::sync::RwLockReadGuard<'_, Option>, +) -> Result<&'a crate::msd::MsdController> { + guard + .as_ref() + .ok_or_else(|| MsdErrorCode::MsdUnavailable.into()) } #[cfg(unix)] -fn unsupported_drive_filesystem_error(error: &str) -> AppError { - tracing::warn!( - error = %error, - "Virtual drive filesystem is not supported" - ); - AppError::BadRequest("Unsupported drive filesystem".to_string()) +fn classify_storage_error(operation: &'static str, error: std::io::Error) -> AppError { + tracing::warn!(operation, %error, "MSD storage operation failed"); + match error.raw_os_error() { + Some(libc::ENOSPC) => MsdErrorCode::MsdStorageFull.into(), + Some(libc::EROFS) => MsdErrorCode::MsdStorageReadOnly.into(), + Some(libc::EACCES | libc::EPERM) => MsdErrorCode::MsdStoragePermissionDenied.into(), + _ => MsdErrorCode::MsdOperationFailed.into(), + } +} + +#[cfg(unix)] +fn operation_failed(operation: &'static str, error: AppError) -> AppError { + match error { + AppError::Msd(error) => AppError::Msd(error), + error => { + tracing::warn!(operation, %error, "Unclassified MSD operation failed"); + MsdErrorCode::MsdOperationFailed.into() + } + } +} + +#[cfg(unix)] +fn parse_msd_json(payload: std::result::Result, JsonRejection>) -> Result { + payload.map(|Json(value)| value).map_err(|error| { + tracing::warn!(%error, "Failed to parse MSD JSON request"); + MsdErrorCode::MsdInvalidRequest.into() + }) +} + +#[cfg(unix)] +fn parse_msd_multipart( + payload: std::result::Result, +) -> Result { + payload.map_err(|error| { + tracing::warn!(%error, "Failed to parse MSD multipart request"); + MsdErrorCode::MsdInvalidRequest.into() + }) } /// MSD status response @@ -115,22 +140,22 @@ pub async fn msd_images_list(State(state): State>) -> Result>, - mut multipart: Multipart, + multipart: std::result::Result, ) -> Result> { + let mut multipart = parse_msd_multipart(multipart)?; let config = state.config.get(); let images_path = config.msd.images_dir(); let manager = ImageManager::new(images_path); - while let Some(field) = multipart - .next_field() - .await - .map_err(|e| AppError::Internal(format!("Multipart error: {}", e)))? - { + while let Some(field) = multipart.next_field().await.map_err(|error| { + tracing::warn!(%error, "Failed to parse MSD image upload"); + AppError::from(MsdErrorCode::MsdInvalidRequest) + })? { let name = field.name().unwrap_or("file").to_string(); if name == "file" { let filename = field .file_name() - .ok_or_else(|| AppError::BadRequest("Missing filename".to_string()))? + .ok_or_else(|| AppError::from(MsdErrorCode::MsdInvalidRequest))? .to_string(); // Use streaming upload - chunks are written directly to disk @@ -142,7 +167,7 @@ pub async fn msd_image_upload( } } - Err(AppError::BadRequest("No file provided".to_string())) + Err(MsdErrorCode::MsdInvalidRequest.into()) } /// Get image by ID @@ -166,10 +191,11 @@ pub async fn msd_image_delete( AxumPath(id): AxumPath, ) -> Result> { let msd_guard = state.msd.read().await; - let controller = msd_guard - .as_ref() - .ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?; - controller.delete_image(&id).await?; + let controller = msd_controller(&msd_guard)?; + controller + .delete_image(&id) + .await + .map_err(|error| operation_failed("delete image", error))?; Ok(Json(LoginResponse { success: true, message: Some("Image deleted".to_string()), @@ -180,14 +206,16 @@ pub async fn msd_image_delete( #[cfg(unix)] pub async fn msd_image_download( State(state): State>, - Json(req): Json, + payload: std::result::Result, JsonRejection>, ) -> Result> { + let req = parse_msd_json(payload)?; let msd_guard = state.msd.read().await; - let controller = msd_guard - .as_ref() - .ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?; + let controller = msd_controller(&msd_guard)?; - let progress = controller.download_image(req.url, req.filename).await?; + let progress = controller + .download_image(req.url, req.filename) + .await + .map_err(|error| operation_failed("start image download", error))?; Ok(Json(progress)) } @@ -202,14 +230,16 @@ pub struct CancelDownloadRequest { #[cfg(unix)] pub async fn msd_image_download_cancel( State(state): State>, - Json(req): Json, + payload: std::result::Result, JsonRejection>, ) -> Result> { + let req = parse_msd_json(payload)?; let msd_guard = state.msd.read().await; - let controller = msd_guard - .as_ref() - .ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?; + let controller = msd_controller(&msd_guard)?; - controller.cancel_download(&req.download_id).await?; + controller + .cancel_download(&req.download_id) + .await + .map_err(|error| operation_failed("cancel image download", error))?; Ok(Json(LoginResponse { success: true, @@ -221,14 +251,16 @@ pub async fn msd_image_download_cancel( #[cfg(unix)] pub async fn msd_disk_mode_put( State(state): State>, - Json(req): Json, + payload: std::result::Result, JsonRejection>, ) -> Result> { - let _otg_guard = try_apply_lock(&state.config_apply_locks.otg, "OTG")?; + let req = parse_msd_json(payload)?; + let _otg_guard = try_apply_lock(&state.config_apply_locks.otg, "OTG").map_err(|error| { + tracing::warn!(%error, "MSD disk mode change is blocked by another OTG operation"); + AppError::from(MsdErrorCode::MsdOperationInProgress) + })?; let current_mode = { let msd_guard = state.msd.read().await; - let controller = msd_guard - .as_ref() - .ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?; + let controller = msd_controller(&msd_guard)?; controller.state().await.disk_mode }; if current_mode == req.disk_mode { @@ -248,14 +280,14 @@ pub async fn msd_disk_mode_put( .hid .prepare_otg_rebuild() .await - .map_err(|e| AppError::Config(format!("Failed to prepare OTG HID for rebuild: {e}")))?; + .map_err(|error| operation_failed("prepare HID for disk mode switch", error))?; } let switch_result = { let mut msd_guard = state.msd.write().await; let controller = msd_guard .as_mut() - .ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?; + .ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?; controller.set_disk_mode(req.disk_mode).await }; @@ -271,12 +303,18 @@ pub async fn msd_disk_mode_put( match (switch_result, hid_reload_result) { (Err(switch_error), Err(hid_error)) => { - return Err(AppError::Internal(format!( - "MSD disk mode switch failed: {switch_error}; HID recovery failed: {hid_error}" - ))); + tracing::warn!(%switch_error, %hid_error, "MSD mode switch and HID recovery failed"); + return Err(MsdErrorCode::MsdOperationFailed.into()); + } + (Err(switch_error), Ok(())) => { + return Err(operation_failed("switch disk mode", switch_error)) + } + (Ok(_), Err(hid_error)) => { + return Err(operation_failed( + "recover HID after disk mode switch", + hid_error, + )) } - (Err(switch_error), Ok(())) => return Err(switch_error), - (Ok(_), Err(hid_error)) => return Err(hid_error), (Ok(_), Ok(())) => {} } @@ -291,13 +329,14 @@ pub async fn msd_disk_mode_put( pub async fn msd_image_mount( State(state): State>, AxumPath(id): AxumPath, - Json(req): Json, + payload: std::result::Result, JsonRejection>, ) -> Result> { + let req = parse_msd_json(payload)?; let config = state.config.get(); let mut msd_guard = state.msd.write().await; let controller = msd_guard .as_mut() - .ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?; + .ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?; let images_path = config.msd.images_dir(); let manager = ImageManager::new(images_path); @@ -305,7 +344,8 @@ pub async fn msd_image_mount( controller .mount_image(&image, req.cdrom, req.read_only) - .await?; + .await + .map_err(|error| operation_failed("mount image", error))?; Ok(Json(LoginResponse { success: true, @@ -322,9 +362,12 @@ pub async fn msd_image_unmount( let mut msd_guard = state.msd.write().await; let controller = msd_guard .as_mut() - .ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?; + .ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?; - controller.unmount_image(&id).await?; + controller + .unmount_image(&id) + .await + .map_err(|error| operation_failed("unmount image", error))?; Ok(Json(LoginResponse { success: true, @@ -338,9 +381,12 @@ pub async fn msd_drive_mount(State(state): State>) -> Result>) -> Result>) -> Result Ok(Json(info)), - Err(e) => { - let msg = e.to_string(); - // Detect filesystem-level failures (unrecognized format, bad partition table, etc.) - // These mean the drive FILE exists but was formatted to an unsupported type - // (e.g. the controlled machine reformatted it as NTFS/exFAT). - // Return 400 so the frontend can distinguish this from 404 (file missing). - if is_unsupported_drive_filesystem(&msg) { - return Err(unsupported_drive_filesystem_error(&msg)); - } - Err(e) - } - } + drive + .info() + .await + .map(Json) + .map_err(|error| operation_failed("read virtual drive info", error)) } /// Initialize Ventoy drive #[cfg(unix)] pub async fn msd_drive_init( State(state): State>, - Json(req): Json, + payload: std::result::Result, JsonRejection>, ) -> Result> { + let req = parse_msd_json(payload)?; + assert_drive_not_connected(&state).await?; let config = state.config.get(); let msd_dir = config.msd.msd_dir_path(); - let disk_space = get_disk_space(&msd_dir).map_err(|e| { - AppError::BadRequest(format!( - "Failed to read available space for the MSD directory filesystem: {}", - e - )) + let disk_space = get_disk_space(&msd_dir).map_err(|error| { + tracing::warn!(%error, "Failed to read MSD storage space"); + AppError::from(MsdErrorCode::MsdStorageSpaceUnavailable) })?; validate_drive_init_size(req.size_mb, disk_space.available)?; let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); - let info = drive.init(req.size_mb).await?; + let info = drive + .init(req.size_mb) + .await + .map_err(|error| operation_failed("initialize virtual drive", error))?; Ok(Json(info)) } @@ -425,9 +468,7 @@ pub async fn msd_drive_delete(State(state): State>) -> Result>) -> Result>, Query(params): Query>, - mut multipart: Multipart, + multipart: std::result::Result, ) -> Result> { + let mut multipart = parse_msd_multipart(multipart)?; // Block when connected: writing to image while USB host has it mounted // causes filesystem corruption (Windows error 0x80070570) assert_drive_not_connected(&state).await?; @@ -489,16 +525,15 @@ pub async fn msd_drive_upload( let target_dir = params.get("path").map(|s| s.as_str()).unwrap_or("/"); - while let Some(field) = multipart - .next_field() - .await - .map_err(|e| AppError::Internal(format!("Multipart error: {}", e)))? - { + while let Some(field) = multipart.next_field().await.map_err(|error| { + tracing::warn!(%error, "Failed to parse virtual drive file upload"); + AppError::from(MsdErrorCode::MsdInvalidRequest) + })? { let name = field.name().unwrap_or("file").to_string(); if name == "file" { let filename = field .file_name() - .ok_or_else(|| AppError::BadRequest("Missing filename".to_string()))? + .ok_or_else(|| AppError::from(MsdErrorCode::MsdInvalidRequest))? .to_string(); let file_path = if target_dir == "/" { @@ -511,7 +546,8 @@ pub async fn msd_drive_upload( // This avoids loading the entire file into memory drive .write_file_from_multipart_field(&file_path, field) - .await?; + .await + .map_err(|error| operation_failed("upload virtual drive file", error))?; return Ok(Json(LoginResponse { success: true, @@ -520,7 +556,7 @@ pub async fn msd_drive_upload( } } - Err(AppError::BadRequest("No file provided".to_string())) + Err(MsdErrorCode::MsdInvalidRequest.into()) } /// Download file from drive (streaming for large files) @@ -538,7 +574,10 @@ pub async fn msd_drive_download( let drive = VentoyDrive::new(drive_path); // Get file stream (returns file size and channel receiver) - let (file_size, mut rx) = drive.read_file_stream(&file_path).await?; + let (file_size, mut rx) = drive + .read_file_stream(&file_path) + .await + .map_err(|error| operation_failed("download virtual drive file", error))?; // Extract filename for Content-Disposition let filename = file_path.split('/').next_back().unwrap_or("download"); @@ -576,7 +615,10 @@ pub async fn msd_drive_file_delete( let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); - drive.delete(&file_path).await?; + drive + .delete(&file_path) + .await + .map_err(|error| operation_failed("delete virtual drive file", error))?; Ok(Json(LoginResponse { success: true, @@ -598,7 +640,10 @@ pub async fn msd_drive_mkdir( let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); - drive.mkdir(&dir_path).await?; + drive + .mkdir(&dir_path) + .await + .map_err(|error| operation_failed("create virtual drive directory", error))?; Ok(Json(LoginResponse { success: true, @@ -618,25 +663,24 @@ mod tests { #[test] fn validate_drive_init_size_rejects_below_64mb() { let err = validate_drive_init_size(MIN_DRIVE_SIZE_MB - 1, 1024 * MIB).unwrap_err(); - assert!(err.to_string().contains("at least 64 MB")); + assert!( + matches!(err, AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveSizeInvalid) + ); } #[test] fn validate_drive_init_size_rejects_available_space_overflow() { let err = validate_drive_init_size(65, 64 * MIB).unwrap_err(); - assert!(err.to_string().contains("cannot exceed available space")); + assert!( + matches!(err, AppError::Msd(error) if error.code() == MsdErrorCode::MsdStorageFull) + ); } #[test] - fn detects_unsupported_drive_filesystem_errors() { - assert!(is_unsupported_drive_filesystem( - "Internal error: Filesystem error: Invalid exFAT signature" - )); - assert!(is_unsupported_drive_filesystem( - "Internal error: Partition error: invalid partition table" - )); - assert!(!is_unsupported_drive_filesystem( - "IO error: permission denied" - )); + fn classifies_storage_permissions_without_exposing_the_io_error() { + let error = classify_storage_error("test", std::io::Error::from_raw_os_error(libc::EACCES)); + assert!( + matches!(error, AppError::Msd(error) if error.code() == MsdErrorCode::MsdStoragePermissionDenied) + ); } } diff --git a/src/web/handlers/setup.rs b/src/web/handlers/setup.rs index b469405b..80392462 100644 --- a/src/web/handlers/setup.rs +++ b/src/web/handlers/setup.rs @@ -44,7 +44,7 @@ pub struct SetupRequest { pub async fn setup_init( State(state): State>, - Json(req): Json, + Json(mut req): Json, ) -> Result> { // Check if already initialized if state.config.is_initialized() { @@ -65,6 +65,39 @@ pub async fn setup_init( )); } + if let Some(path) = req.video_device.as_deref() { + let source_following = state + .stream_manager + .list_devices() + .await + .ok() + .and_then(|devices| { + devices + .into_iter() + .find(|device| device.path.to_string_lossy() == path) + }) + .is_some_and(|device| { + device.control_mode == crate::video::device::VideoControlMode::SourceFollowing + }); + if source_following { + if req.video_format.is_some() + || req.video_width.is_some() + || req.video_height.is_some() + || req.video_fps.is_some() + { + tracing::debug!( + "Ignoring setup-supplied format, resolution, and FPS for source-following video input" + ); + } + req.video_format = None; + req.video_width = None; + req.video_height = None; + req.video_fps = None; + } + } + + let old_config = state.config.get(); + // Create single system user state .users @@ -140,18 +173,10 @@ pub async fn setup_init( }) .await?; - // Get updated config for HID reload + // Apply the complete USB runtime configuration, including the MSD controller. let new_config = state.config.get(); - - #[cfg(unix)] - { - if let Err(e) = state - .otg_service - .apply_config(&new_config.hid, &new_config.msd, &new_config.otg_network, &crate::otg::service::UacConfig::default()) - .await - { - tracing::warn!("Failed to apply OTG config during setup: {}", e); - } + if let Err(e) = config::apply::apply_usb_config(&state, &old_config, &new_config).await { + tracing::warn!("Failed to apply USB config during setup: {}", e); } tracing::info!( @@ -160,25 +185,6 @@ pub async fn setup_init( new_config.rustdesk.enabled ); - // Initialize HID backend with new config - let new_hid_backend = match new_config.hid.backend { - crate::config::HidBackend::Otg => crate::hid::HidBackendType::Otg, - crate::config::HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 { - port: new_config.hid.ch9329_port.clone(), - baud_rate: new_config.hid.ch9329_baudrate, - hybrid_mouse: new_config.hid.ch9329_hybrid_mouse, - }, - crate::config::HidBackend::None => crate::hid::HidBackendType::None, - }; - - // Reload HID backend - if let Err(e) = state.hid.reload(new_hid_backend).await { - tracing::warn!("Failed to initialize HID backend during setup: {}", e); - // Don't fail setup, just warn - } else { - tracing::info!("HID backend initialized: {:?}", new_config.hid.backend); - } - // Start extensions if enabled if new_config.extensions.ttyd.enabled { if let Err(e) = state diff --git a/src/web/handlers/stream.rs b/src/web/handlers/stream.rs index 44b85a08..6f1743de 100644 --- a/src/web/handlers/stream.rs +++ b/src/web/handlers/stream.rs @@ -1,5 +1,6 @@ use super::*; +use crate::events::SystemEvent; use crate::video::streamer::StreamerStats; use axum::{ body::Body, @@ -16,7 +17,17 @@ fn stream_mode_label(mode: StreamMode, codec: crate::video::codec::VideoCodecTyp /// Get stream state pub async fn stream_state(State(state): State>) -> Json { - Json(state.stream_manager.stats().await) + let mut stats = state.stream_manager.stats().await; + if let Some(SystemEvent::StreamStateChanged { + state: event_state, + reason, + .. + }) = state.events.latest_video_stream_state() + { + stats.state = event_state; + stats.reason = reason; + } + Json(stats) } /// Start streaming diff --git a/src/web/mod.rs b/src/web/mod.rs index 79998a9d..72733b11 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -3,6 +3,7 @@ mod error; mod handlers; mod routes; mod static_files; +#[cfg(unix)] mod uac_ws; mod ws; @@ -11,5 +12,6 @@ pub use error::ErrorResponse; pub use routes::create_router; #[cfg(not(debug_assertions))] pub use static_files::StaticAssets; +#[cfg(unix)] pub use uac_ws::uac_audio_ws_handler; pub use ws::ws_handler; diff --git a/src/web/routes.rs b/src/web/routes.rs index 1db95172..29f7eb32 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -1,8 +1,11 @@ #[cfg(unix)] -use axum::{extract::DefaultBodyLimit, routing::delete}; +use axum::{ + extract::DefaultBodyLimit, + routing::{delete, put}, +}; use axum::{ middleware, - routing::{any, get, patch, post, put}, + routing::{any, get, patch, post}, Router, }; use std::sync::Arc; @@ -13,6 +16,7 @@ use tower_http::{ use super::audio_ws::audio_ws_handler; use super::handlers; +#[cfg(unix)] use super::uac_ws::uac_audio_ws_handler; use super::ws::ws_handler; use crate::auth::auth_middleware; @@ -61,6 +65,7 @@ pub fn create_router(state: Arc) -> Router { ) .route("/auth/totp/disable", post(handlers::disable_totp)) .route("/devices", get(handlers::list_devices)) + .route("/video/input-status", get(handlers::video_input_status)) // WebSocket endpoint for real-time events .route("/ws", any(ws_handler)) // Stream control endpoints @@ -71,6 +76,7 @@ pub fn create_router(state: Arc) -> Router { .route("/stream/mode", post(handlers::stream_mode_set)) .route("/stream/bitrate", post(handlers::stream_set_bitrate)) .route("/stream/codecs", get(handlers::stream_codecs_list)) + .route("/video/codecs", get(handlers::stream_codecs_list)) .route("/stream/constraints", get(handlers::stream_constraints_get)) .route( "/video/encoder/self-check", @@ -101,7 +107,6 @@ pub fn create_router(state: Arc) -> Router { .route("/audio/devices", get(handlers::list_audio_devices)) // Audio WebSocket endpoint .route("/ws/audio", any(audio_ws_handler)) - .route("/ws/uac-audio", any(uac_audio_ws_handler)) // Configuration management (domain-separated endpoints) .route("/config", get(handlers::config::get_all_config)) .route("/config/video", get(handlers::config::get_video_config)) @@ -264,6 +269,7 @@ pub fn create_router(state: Arc) -> Router { #[cfg(unix)] let user_routes = { user_routes + .route("/ws/uac-audio", any(uac_audio_ws_handler)) .route("/hid/otg/self-check", get(handlers::hid_otg_self_check)) .route("/config/msd", get(handlers::config::get_msd_config)) .route("/config/msd", patch(handlers::config::update_msd_config)) @@ -280,14 +286,8 @@ pub fn create_router(state: Arc) -> Router { "/otg/network/status", get(handlers::config::get_otg_network_status), ) - .route( - "/config/uac", - get(handlers::config::get_uac_config), - ) - .route( - "/config/uac", - patch(handlers::config::update_uac_config), - ) + .route("/config/uac", get(handlers::config::get_uac_config)) + .route("/config/uac", patch(handlers::config::update_uac_config)) .route("/msd/status", get(handlers::msd_status)) .route("/msd/images", get(handlers::msd_images_list)) .route("/msd/images/download", post(handlers::msd_image_download)) diff --git a/src/web/uac_ws.rs b/src/web/uac_ws.rs index ea32e950..718f8bb7 100644 --- a/src/web/uac_ws.rs +++ b/src/web/uac_ws.rs @@ -1,33 +1,135 @@ -use axum::extract::ws::WebSocketUpgrade; +use std::borrow::Cow; +use std::sync::Arc; + +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::State; use axum::http::StatusCode; use axum::response::IntoResponse; -use std::sync::Arc; -use tracing::warn; +use tracing::{debug, info, warn}; +use crate::audio::uac::{ + parse_audio_packet, UacAudioPacket, UacOpusDecoder, UacPlaybackState, UacSession, +}; use crate::state::AppState; -/// WebSocket endpoint for UAC microphone passthrough audio input. -/// -/// Accepts Opus-encoded audio frames (same binary protocol as audio -/// output, message type 0x03) and routes decoded PCM to the UAC -/// playback device on the USB gadget side. pub async fn uac_audio_ws_handler( ws: WebSocketUpgrade, State(state): State>, ) -> impl IntoResponse { - let playback = { - let guard = state.uac_playback.read().await; - match guard.as_ref() { - Some(p) => Arc::new(p.clone()), - None => { - warn!("UAC audio WS rejected: playback not initialized"); - return (StatusCode::SERVICE_UNAVAILABLE, "UAC playback not initialized").into_response(); + let session = { + let playback = state.uac_playback.read().await; + let Some(playback) = playback.as_ref() else { + return (StatusCode::SERVICE_UNAVAILABLE, "UAC playback is disabled").into_response(); + }; + match playback.acquire_session() { + Ok(session) => session, + Err(error) => { + return (StatusCode::CONFLICT, error.to_string()).into_response(); } } }; - ws.on_upgrade(move |socket| { - crate::audio::uac_websocket::handle_uac_audio_ws(socket, playback) - }) + ws.on_upgrade(move |socket| handle_uac_audio(socket, session)) +} + +async fn handle_uac_audio(mut socket: WebSocket, session: UacSession) { + let mut decoder = match UacOpusDecoder::new() { + Ok(decoder) => decoder, + Err(error) => { + warn!("Unable to initialize UAC Opus decoder: {error}"); + let _ = socket.send(Message::Close(None)).await; + return; + } + }; + let mut dropped_frames = 0u64; + info!("UAC microphone WebSocket connected"); + + let mut playback_state = session.state(); + if socket + .send(playback_state_message(playback_state)) + .await + .is_err() + { + return; + } + + while let Some(message) = socket.recv().await { + let message = match message { + Ok(message) => message, + Err(error) => { + warn!("UAC microphone WebSocket failed: {error}"); + break; + } + }; + + match message { + Message::Binary(data) => { + let packet = match parse_audio_packet(&data) { + Ok(packet) => packet, + Err(error) => { + warn!("Rejected UAC audio packet: {error}"); + continue; + } + }; + let pcm: Cow<'_, [i16]> = match packet { + UacAudioPacket::Opus(payload) => match decoder.decode(payload) { + Ok(pcm) => Cow::Borrowed(pcm), + Err(error) => { + warn!("Rejected UAC Opus packet: {error}"); + continue; + } + }, + packet @ UacAudioPacket::Pcm(_) => match packet.pcm_samples() { + Ok(pcm) => Cow::Owned(pcm), + Err(error) => { + warn!("Rejected UAC PCM packet: {error}"); + continue; + } + }, + }; + + let (accepted, current_state) = match session.try_write(pcm.as_ref()) { + Ok(result) => result, + Err(error) => { + warn!("UAC playback stopped: {error}"); + break; + } + }; + if !accepted { + dropped_frames += 1; + if dropped_frames == 1 || dropped_frames.is_multiple_of(250) { + debug!( + "Dropped {dropped_frames} UAC audio frames while the target was unavailable" + ); + } + } + + if current_state != playback_state + && socket + .send(playback_state_message(current_state)) + .await + .is_err() + { + break; + } + playback_state = current_state; + } + Message::Close(_) => break, + Message::Ping(_) | Message::Pong(_) => {} + Message::Text(_) => debug!("Ignoring text message on UAC audio WebSocket"), + } + } + + info!("UAC microphone WebSocket disconnected; dropped_frames={dropped_frames}"); +} + +fn playback_state_message(state: UacPlaybackState) -> Message { + Message::Text( + serde_json::json!({ + "type": "uac_status", + "state": state.as_str(), + }) + .to_string() + .into(), + ) } diff --git a/src/web/ws.rs b/src/web/ws.rs index 3dd8e4b4..cc51d520 100644 --- a/src/web/ws.rs +++ b/src/web/ws.rs @@ -49,9 +49,14 @@ fn is_device_info_topic(topic: &str) -> bool { matches!(topic, "*" | "system.*" | "system.device_info") } +fn is_stream_state_topic(topic: &str) -> bool { + matches!(topic, "*" | "stream.*" | "stream.state_changed") +} + fn rebuild_event_tasks( state: &Arc, topics: &[String], + replay_stream_state: bool, event_tx: &mpsc::UnboundedSender, event_tasks: &mut Vec>, ) { @@ -61,7 +66,17 @@ fn rebuild_event_tasks( let topics = normalize_topics(topics); let mut device_info_task_added = false; + let mut stream_state_snapshot_added = false; for topic in topics { + if replay_stream_state && is_stream_state_topic(&topic) && !stream_state_snapshot_added { + if let Some(snapshot) = state.events.latest_video_stream_state() { + if event_tx.send(BusMessage::Event(snapshot)).is_err() { + return; + } + } + stream_state_snapshot_added = true; + } + if is_device_info_topic(&topic) && !device_info_task_added { let state = state.clone(); let mut rx = state.subscribe_device_info(); @@ -157,12 +172,19 @@ async fn handle_socket(socket: WebSocket, state: Arc) { msg = receiver.next() => { match msg { Some(Ok(Message::Text(text))) => { + let had_stream_state = normalize_topics(&subscribed_topics) + .iter() + .any(|topic| is_stream_state_topic(topic)); if let Err(e) = handle_client_message(&text, &mut subscribed_topics).await { warn!("Failed to handle client message: {}", e); } else { + let has_stream_state = normalize_topics(&subscribed_topics) + .iter() + .any(|topic| is_stream_state_topic(topic)); rebuild_event_tasks( &state, &subscribed_topics, + !had_stream_state && has_stream_state, &event_tx, &mut event_tasks, ); @@ -308,4 +330,13 @@ mod tests { assert!(is_device_info_topic("*")); assert!(!is_device_info_topic("stream.*")); } + + #[test] + fn test_is_stream_state_topic_matches_stateful_subscriptions() { + assert!(is_stream_state_topic("*")); + assert!(is_stream_state_topic("stream.*")); + assert!(is_stream_state_topic("stream.state_changed")); + assert!(!is_stream_state_topic("stream.stats_update")); + assert!(!is_stream_state_topic("system.device_info")); + } } diff --git a/src/webrtc/universal_session.rs b/src/webrtc/universal_session.rs index 90f9adc9..83b50a3b 100644 --- a/src/webrtc/universal_session.rs +++ b/src/webrtc/universal_session.rs @@ -3,7 +3,7 @@ use std::net::IpAddr; use std::sync::Arc; use std::time::{Duration, Instant}; -use tokio::sync::{watch, Mutex, RwLock}; +use tokio::sync::{broadcast, watch, Mutex, RwLock}; use tracing::{debug, info, warn}; use webrtc::api::interceptor_registry::register_default_interceptors; use webrtc::api::media_engine::MediaEngine; @@ -20,6 +20,8 @@ use webrtc::peer_connection::configuration::RTCConfiguration; use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState; use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; use webrtc::peer_connection::RTCPeerConnection; +use webrtc::rtcp::payload_feedbacks::full_intra_request::FullIntraRequest; +use webrtc::rtcp::payload_feedbacks::picture_loss_indication::PictureLossIndication; use webrtc::rtp_transceiver::rtp_codec::{ RTCRtpCodecCapability, RTCRtpCodecParameters, RTPCodecType, }; @@ -38,9 +40,10 @@ use crate::video::codec::h264_bitstream; use crate::video::types::{ BitratePreset, EncodedVideoFrame, PixelFormat, Resolution, VideoEncoderType, }; -use std::sync::atomic::AtomicBool; const MIME_TYPE_H265: &str = "video/H265"; +const KEYFRAME_RETRY_LIMIT: u8 = 3; +const KEYFRAME_RETRY_BASE_DELAY: Duration = Duration::from_secs(1); fn is_allowed_ice_ip(ip: IpAddr) -> bool { match ip { @@ -110,9 +113,9 @@ pub struct UniversalSession { state_rx: watch::Receiver, ice_candidates: Arc>>, hid_controller: Option>, + keyframe_feedback: broadcast::Sender<()>, video_receiver_handle: Mutex>>, audio_receiver_handle: Mutex>>, - fps: u32, } impl UniversalSession { @@ -277,10 +280,45 @@ impl UniversalSession { let pc = Arc::new(pc); - pc.add_track(video_track.as_track_local()) + let video_sender = pc + .add_track(video_track.as_track_local()) .await .map_err(|e| AppError::VideoError(format!("Failed to add video track: {}", e)))?; + // RTCP feedback is advertised in SDP, but it only reaches the + // application while the sender is actively drained. Forward PLI/FIR + // to the shared encoder so a client that missed an IDR can recover. + let (keyframe_feedback, _) = broadcast::channel(8); + let keyframe_feedback_tx = keyframe_feedback.clone(); + let rtcp_session_id = session_id.clone(); + tokio::spawn(async move { + loop { + let (packets, _) = match video_sender.read_rtcp().await { + Ok(value) => value, + Err(error) => { + debug!( + "RTCP reader stopped for session {}: {}", + rtcp_session_id, error + ); + break; + } + }; + if packets.iter().any(|packet| { + packet + .as_any() + .downcast_ref::() + .is_some() + || packet.as_any().downcast_ref::().is_some() + }) { + info!( + "RTCP PLI/FIR requested a keyframe for session {}", + rtcp_session_id + ); + let _ = keyframe_feedback_tx.send(()); + } + } + }); + info!( "{} video track added to peer connection (session {})", config.codec, session_id @@ -309,9 +347,9 @@ impl UniversalSession { state_rx, ice_candidates: Arc::new(Mutex::new(vec![])), hid_controller: None, + keyframe_feedback, video_receiver_handle: Mutex::new(None), audio_receiver_handle: Mutex::new(None), - fps: config.fps, }; session.setup_event_handlers().await; @@ -503,9 +541,8 @@ impl UniversalSession { let video_track = self.video_track.clone(); let mut state_rx = self.state_rx.clone(); let session_id = self.session_id.clone(); - let _fps = self.fps; let expected_codec = self.codec; - let send_in_flight = Arc::new(AtomicBool::new(false)); + let mut keyframe_feedback = self.keyframe_feedback.subscribe(); let handle = tokio::spawn(async move { info!( @@ -538,7 +575,8 @@ impl UniversalSession { request_keyframe(); let mut waiting_for_keyframe = true; let mut last_sequence: Option = None; - let mut last_keyframe_request = Instant::now() - Duration::from_secs(1); + let mut keyframe_requests = 1u8; + let mut next_keyframe_retry = Instant::now() + KEYFRAME_RETRY_BASE_DELAY; let mut frames_sent: u64 = 0; @@ -557,6 +595,16 @@ impl UniversalSession { } } + feedback = keyframe_feedback.recv() => { + if feedback.is_ok() { + request_keyframe(); + waiting_for_keyframe = true; + keyframe_requests = 1; + next_keyframe_retry = + Instant::now() + KEYFRAME_RETRY_BASE_DELAY; + } + } + result = frame_rx.recv() => { let encoded_frame = match result { Some(frame) => frame, @@ -572,17 +620,6 @@ impl UniversalSession { continue; } - if expected_codec == VideoEncoderType::H265 - && (encoded_frame.is_keyframe || frames_sent.is_multiple_of(30)) { - debug!( - "[Session-H265] Received frame #{}: size={}, keyframe={}, seq={}", - frames_sent, - encoded_frame.data.len(), - encoded_frame.is_keyframe, - encoded_frame.sequence - ); - } - let mut gap_detected = false; if let Some(prev) = last_sequence { if encoded_frame.sequence > prev.saturating_add(1) { @@ -593,9 +630,12 @@ impl UniversalSession { if waiting_for_keyframe || gap_detected { if encoded_frame.is_keyframe { waiting_for_keyframe = false; + keyframe_requests = 0; } else { - if gap_detected { + if gap_detected && !waiting_for_keyframe { waiting_for_keyframe = true; + keyframe_requests = 0; + next_keyframe_retry = Instant::now(); } // Some H264 encoders output SPS/PPS in a separate non-keyframe AU @@ -605,11 +645,19 @@ impl UniversalSession { && h264_bitstream::has_sps_pps(encoded_frame.data.as_ref()); let now = Instant::now(); - if now.duration_since(last_keyframe_request) - >= Duration::from_millis(200) + if keyframe_requests < KEYFRAME_RETRY_LIMIT + && now >= next_keyframe_retry { request_keyframe(); - last_keyframe_request = now; + keyframe_requests += 1; + let backoff = 1u32 << (keyframe_requests - 1); + next_keyframe_retry = now + KEYFRAME_RETRY_BASE_DELAY * backoff; + if keyframe_requests == KEYFRAME_RETRY_LIMIT { + warn!( + "Session {} exhausted keyframe retry budget; waiting for the encoder's next natural keyframe", + session_id + ); + } } if !forward_h264_parameter_frame { continue; @@ -617,16 +665,12 @@ impl UniversalSession { } } - let _ = send_in_flight; - let send_result = video_track .write_frame_bytes( encoded_frame.data.clone(), encoded_frame.is_keyframe, ) .await; - let _ = send_in_flight; - match send_result { Ok(()) => { frames_sent += 1; diff --git a/src/webrtc/webrtc_streamer.rs b/src/webrtc/webrtc_streamer.rs index 8a1fd2c6..27884dc4 100644 --- a/src/webrtc/webrtc_streamer.rs +++ b/src/webrtc/webrtc_streamer.rs @@ -10,17 +10,19 @@ use tracing::{debug, info, trace, warn}; use crate::audio::{AudioController, OpusFrame}; use crate::error::{AppError, Result}; -use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent}; +use crate::events::{EventBus, StreamKind, SystemEvent}; use crate::hid::HidController; use crate::video::capture::DEFAULT_CAPTURE_BUFFER_COUNT; use crate::video::codec::h264_bitstream; +use crate::video::codec::EncoderRegistry; use crate::video::device::{ - enumerate_devices, select_recovery_device, VideoDevice, VideoDeviceRecoveryHint, + enumerate_devices, select_recovery_device, VideoControlMode, VideoDevice, VideoDeviceInfo, + VideoDeviceRecoveryHint, }; use crate::video::types::{ - BitratePreset, EncoderBackend, PipelineStateNotification, PixelFormat, Resolution, - SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats, VideoCodecType, - VideoEncoderType, + BitratePreset, EncoderBackend, PipelineLifecycle, PipelineStateNotification, PixelFormat, + Resolution, SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats, + VideoCodecType, VideoEncoderType, }; use super::config::{TurnServer, WebRtcConfig}; @@ -28,6 +30,18 @@ use super::signaling::{ConnectionState, IceCandidate, SdpAnswer, SdpOffer}; use super::universal_session::{UniversalSession, UniversalSessionConfig}; const H264_PROFILE_DETECT_TIMEOUT: Duration = Duration::from_millis(500); +const PIPELINE_RELEASE_TIMEOUT: Duration = Duration::from_secs(5); + +fn update_signal_recovery_edge(pending: &AtomicBool, state: &str) -> bool { + match state { + "no_signal" => { + pending.store(true, Ordering::Release); + false + } + "streaming" => pending.swap(false, Ordering::AcqRel), + _ => false, + } +} #[derive(Debug, Clone)] pub struct WebRtcStreamerConfig { @@ -63,8 +77,7 @@ pub struct CaptureDeviceConfig { pub jpeg_quality: u8, pub subdev_path: Option, pub bridge_kind: Option, - /// V4L2 driver name (e.g. `uvcvideo`) for UVC-specific recovery hints. - pub v4l2_driver: Option, + pub control_mode: VideoControlMode, pub recovery_hint: VideoDeviceRecoveryHint, } @@ -99,6 +112,7 @@ pub struct WebRtcStreamer { hid_controller: RwLock>>, events: RwLock>>, recovery_in_progress: AtomicBool, + signal_recovery_pending: Arc, self_weak: StdRwLock>>, } @@ -119,6 +133,7 @@ impl WebRtcStreamer { hid_controller: RwLock::new(None), events: RwLock::new(None), recovery_in_progress: AtomicBool::new(false), + signal_recovery_pending: Arc::new(AtomicBool::new(false)), self_weak: StdRwLock::new(None), }); let weak = Arc::downgrade(&streamer); @@ -154,11 +169,7 @@ impl WebRtcStreamer { // Close all existing sessions self.close_all_sessions().await; - // Stop current pipeline - if let Some(ref pipeline) = *self.video_pipeline.read().await { - pipeline.stop(); - } - *self.video_pipeline.write().await = None; + self.stop_video_pipeline_and_release().await?; // Update codec *self.video_codec.write().await = codec; @@ -231,18 +242,49 @@ impl WebRtcStreamer { } } + /// Serialize pipeline teardown with creation and return only after V4L2 + /// STREAMOFF, buffer teardown and FD close have completed. + async fn stop_video_pipeline_and_release(&self) -> Result<()> { + let mut pipeline_guard = self.video_pipeline.write().await; + let Some(pipeline) = pipeline_guard.as_ref().cloned() else { + return Ok(()); + }; + + pipeline.stop_and_wait(PIPELINE_RELEASE_TIMEOUT).await?; + *pipeline_guard = None; + Ok(()) + } + fn build_pipeline_state_notifier( device: String, events: Option>, + recovery_pending: Arc, ) -> Option> { events.map(|events| { Arc::new(move |notification: PipelineStateNotification| { + let recovered = update_signal_recovery_edge(&recovery_pending, notification.state); events.publish(SystemEvent::StreamStateChanged { + kind: StreamKind::Video, state: notification.state.to_string(), device: Some(device.clone()), reason: notification.reason.map(|reason| reason.to_string()), next_retry_ms: notification.next_retry_ms, }); + if recovered { + events.publish(SystemEvent::StreamRecovered { + device: device.clone(), + }); + if let Some(applied) = notification.applied_config { + events.publish(SystemEvent::StreamConfigApplied { + transition_id: None, + device: device.clone(), + resolution: (applied.resolution.width, applied.resolution.height), + format: applied.format.to_string(), + fps: applied.fps, + }); + } + events.mark_device_info_dirty(); + } }) as Arc }) } @@ -330,7 +372,7 @@ impl WebRtcStreamer { jpeg_quality, subdev_path: device.subdev_path.clone(), bridge_kind: device.bridge_kind.clone(), - v4l2_driver: Some(device.driver.clone()), + control_mode: device.control_mode, recovery_hint: VideoDeviceRecoveryHint::from(&device), }; @@ -347,6 +389,7 @@ impl WebRtcStreamer { debug!("WebRTC video recovery already in progress"); return; } + self.signal_recovery_pending.store(true, Ordering::Release); let streamer = self.clone(); tokio::spawn(async move { @@ -357,7 +400,7 @@ impl WebRtcStreamer { ); streamer .publish_stream_event(SystemEvent::StreamDeviceLost { - kind: StreamDeviceLostKind::Video, + kind: StreamKind::Video, device: original_device.clone(), reason: reason.clone(), }) @@ -374,6 +417,7 @@ impl WebRtcStreamer { .await; streamer .publish_stream_event(SystemEvent::StreamStateChanged { + kind: StreamKind::Video, state: "device_lost".to_string(), device: Some(original_device.clone()), reason: Some("recovering".to_string()), @@ -412,24 +456,11 @@ impl WebRtcStreamer { { Ok(reconnected) => { info!( - "WebRTC video recovered with {} after {} attempts, reconnected {} sessions", + "WebRTC capture reopened with {} after {} attempts; reconnected {} sessions and waiting for first frame", device.path.display(), attempt, reconnected ); - streamer - .publish_stream_event(SystemEvent::StreamRecovered { - device: device.path.display().to_string(), - }) - .await; - streamer - .publish_stream_event(SystemEvent::StreamStateChanged { - state: "streaming".to_string(), - device: Some(device.path.display().to_string()), - reason: None, - next_retry_ms: None, - }) - .await; streamer.recovery_in_progress.store(false, Ordering::SeqCst); return; } @@ -450,9 +481,17 @@ impl WebRtcStreamer { async fn ensure_video_pipeline(&self) -> Result> { let mut pipeline_guard = self.video_pipeline.write().await; - if let Some(ref pipeline) = *pipeline_guard { - if pipeline.is_running() { - return Ok(pipeline.clone()); + if let Some(pipeline) = pipeline_guard.as_ref().cloned() { + match pipeline.lifecycle() { + PipelineLifecycle::Running => return Ok(pipeline), + PipelineLifecycle::Stopping => { + info!("Waiting for stopping video pipeline to release capture device"); + pipeline.stop_and_wait(PIPELINE_RELEASE_TIMEOUT).await?; + *pipeline_guard = None; + } + PipelineLifecycle::Stopped => { + *pipeline_guard = None; + } } } @@ -460,6 +499,13 @@ impl WebRtcStreamer { let pipeline_config = { let config = self.config.read().await; SharedVideoPipelineConfig { + control_mode: self + .capture_device + .read() + .await + .as_ref() + .map(|capture| capture.control_mode) + .unwrap_or(VideoControlMode::Configurable), resolution: config.resolution, input_format: config.input_format, output_codec: Self::codec_type_to_encoder_type(codec), @@ -476,6 +522,7 @@ impl WebRtcStreamer { pipeline.set_state_notifier(Self::build_pipeline_state_notifier( device.device_path.display().to_string(), self.events.read().await.clone(), + self.signal_recovery_pending.clone(), )); pipeline .start_with_device( @@ -484,7 +531,6 @@ impl WebRtcStreamer { device.jpeg_quality, device.subdev_path, device.bridge_kind, - device.v4l2_driver, ) .await?; } else { @@ -532,7 +578,8 @@ impl WebRtcStreamer { let should_reconnect = pending_geometry.is_some(); if let Some((r, f)) = pending_geometry { - streamer.sync_video_geometry_from_negotiated(r, f).await; + let fps = streamer.config.read().await.fps; + streamer.sync_video_input_from_negotiated(r, f, fps).await; } if should_reconnect { let streamer_for_reconnect = streamer.clone(); @@ -570,9 +617,10 @@ impl WebRtcStreamer { }); let pipeline_cfg = pipeline.config().await; - self.sync_video_geometry_from_negotiated( + self.sync_video_input_from_negotiated( pipeline_cfg.resolution, pipeline_cfg.input_format, + pipeline_cfg.fps, ) .await; @@ -725,32 +773,41 @@ impl WebRtcStreamer { &self, device_path: PathBuf, jpeg_quality: u8, - subdev_path: Option, - bridge_kind: Option, - v4l2_driver: Option, + device_info: Option, ) { + let (subdev_path, bridge_kind, control_mode, recovery_hint) = match device_info { + Some(info) => ( + info.subdev_path.clone(), + info.bridge_kind.clone(), + info.control_mode, + VideoDeviceRecoveryHint::from(&info), + ), + None => { + let recovery_hint = VideoDevice::open_readonly(&device_path) + .and_then(|device| device.info()) + .map(|info| VideoDeviceRecoveryHint::from(&info)) + .unwrap_or_else(|_| VideoDeviceRecoveryHint { + path: device_path.clone(), + name: String::new(), + driver: String::new(), + bus_info: String::new(), + card: String::new(), + is_capture_card: true, + }); + (None, None, VideoControlMode::Configurable, recovery_hint) + } + }; debug!( - "Setting direct capture device for WebRTC: {:?} (subdev={:?}, kind={:?}, driver={:?})", - device_path, subdev_path, bridge_kind, v4l2_driver + "Setting direct capture device for WebRTC: {:?} (subdev={:?}, kind={:?}, mode={:?})", + device_path, subdev_path, bridge_kind, control_mode ); - let recovery_hint = VideoDevice::open_readonly(&device_path) - .and_then(|device| device.info()) - .map(|info| VideoDeviceRecoveryHint::from(&info)) - .unwrap_or_else(|_| VideoDeviceRecoveryHint { - path: device_path.clone(), - name: String::new(), - driver: v4l2_driver.clone().unwrap_or_default(), - bus_info: String::new(), - card: String::new(), - is_capture_card: true, - }); *self.capture_device.write().await = Some(CaptureDeviceConfig { device_path, buffer_count: DEFAULT_CAPTURE_BUFFER_COUNT, jpeg_quality, subdev_path, bridge_kind, - v4l2_driver, + control_mode, recovery_hint, }); } @@ -764,12 +821,13 @@ impl WebRtcStreamer { /// /// This stops the encoding pipeline and closes all sessions. pub async fn prepare_for_config_change(&self) { - // Stop pipeline and close sessions - will be recreated on next session - if let Some(ref pipeline) = *self.video_pipeline.read().await { - pipeline.stop(); - } - *self.video_pipeline.write().await = None; self.close_all_sessions().await; + if let Err(error) = self.stop_video_pipeline_and_release().await { + warn!( + "Failed to release video pipeline for config change: {}", + error + ); + } } // === Configuration === @@ -804,12 +862,6 @@ impl WebRtcStreamer { resolution.width, resolution.height, format, fps ); - // Stop existing pipeline - if let Some(ref pipeline) = *self.video_pipeline.read().await { - pipeline.stop(); - } - *self.video_pipeline.write().await = None; - // Close all existing sessions - they need to reconnect let session_count = self.close_all_sessions().await; if session_count > 0 { @@ -818,6 +870,13 @@ impl WebRtcStreamer { session_count ); } + if let Err(error) = self.stop_video_pipeline_and_release().await { + warn!( + "Failed to release video pipeline for config change: {}", + error + ); + return; + } // Update config (preserve user-configured bitrate) { @@ -836,31 +895,36 @@ impl WebRtcStreamer { self.notify_device_info_dirty().await; } - /// Update resolution/format to match DV-negotiated capture without stopping + /// Update the input mode to match DV-negotiated capture without stopping /// the pipeline or closing sessions. Used when hardware timing differs from /// saved settings (e.g. RK628 `S_FMT` follows source while SQLite still has /// a user-chosen preset). - pub async fn sync_video_geometry_from_negotiated( + pub async fn sync_video_input_from_negotiated( &self, resolution: Resolution, format: PixelFormat, + fps: u32, ) { { let mut config = self.config.write().await; - if config.resolution == resolution && config.input_format == format { + if config.resolution == resolution && config.input_format == format && config.fps == fps + { return; } info!( - "WebRTC geometry aligned to negotiated capture: {}x{} {:?} (was {}x{} {:?})", + "WebRTC input aligned to negotiated capture: {}x{} {:?} @ {} fps (was {}x{} {:?} @ {} fps)", resolution.width, resolution.height, format, + fps, config.resolution.width, config.resolution.height, - config.input_format + config.input_format, + config.fps, ); config.resolution = resolution; config.input_format = format; + config.fps = fps; } self.notify_device_info_dirty().await; @@ -868,12 +932,6 @@ impl WebRtcStreamer { /// Update encoder backend (software/hardware selection) pub async fn update_encoder_backend(&self, encoder_backend: Option) { - // Stop existing pipeline - if let Some(ref pipeline) = *self.video_pipeline.read().await { - pipeline.stop(); - } - *self.video_pipeline.write().await = None; - // Close all existing sessions - they need to reconnect with new encoder let session_count = self.close_all_sessions().await; if session_count > 0 { @@ -882,6 +940,13 @@ impl WebRtcStreamer { session_count ); } + if let Err(error) = self.stop_video_pipeline_and_release().await { + warn!( + "Failed to release video pipeline for encoder backend change: {}", + error + ); + return; + } // Update config let mut config = self.config.write().await; @@ -1123,17 +1188,12 @@ impl WebRtcStreamer { /// Close all sessions and wait for the video pipeline to fully release the /// capture device. Use this when the caller needs the V4L2 device immediately /// afterwards (e.g. switching to MJPEG mode). - pub async fn close_all_sessions_and_release_device(&self) -> usize { + pub async fn close_all_sessions_and_release_device(&self) -> Result { let count = self.close_all_sessions().await; - if let Some(ref pipeline) = *self.video_pipeline.read().await { - pipeline - .stop_and_wait(std::time::Duration::from_secs(3)) - .await; - } - *self.video_pipeline.write().await = None; + self.stop_video_pipeline_and_release().await?; - count + Ok(count) } /// Get session count @@ -1254,18 +1314,29 @@ impl WebRtcStreamer { }; if pipeline_running { - info!("Restarting video pipeline to apply new bitrate: {}", preset); - - // Stop existing pipeline - if let Some(ref pipeline) = *self.video_pipeline.read().await { - pipeline.stop(); + let pipeline = self.video_pipeline.read().await.clone(); + if let Some(pipeline) = pipeline { + let pipeline_config = pipeline.config().await; + let selected_backend = pipeline_config.encoder_backend.or_else(|| { + EncoderRegistry::global() + .best_available_encoder(pipeline_config.output_codec) + .map(|encoder| encoder.backend) + }); + if pipeline_config.input_format == PixelFormat::Mjpeg + && selected_backend == Some(EncoderBackend::Amlogic) + { + info!( + "Applying AMLENC bitrate {} in the encoder worker without restarting MJPEG decode", + preset + ); + pipeline.set_bitrate_preset(preset).await?; + return Ok(()); + } } - // Wait for pipeline to stop - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + info!("Restarting video pipeline to apply new bitrate: {}", preset); - // Clear pipeline reference - will be recreated - *self.video_pipeline.write().await = None; + self.stop_video_pipeline_and_release().await?; let has_source = self.capture_device.read().await.is_some(); if !has_source { @@ -1306,18 +1377,10 @@ impl crate::video::traits::VideoOutput for WebRtcStreamer { &self, device_path: PathBuf, jpeg_quality: u8, - subdev_path: Option, - bridge_kind: Option, - v4l2_driver: Option, + device_info: Option, ) { - self.set_capture_device( - device_path, - jpeg_quality, - subdev_path, - bridge_kind, - v4l2_driver, - ) - .await; + self.set_capture_device(device_path, jpeg_quality, device_info) + .await; } async fn current_video_codec(&self) -> VideoCodecType { @@ -1332,7 +1395,7 @@ impl crate::video::traits::VideoOutput for WebRtcStreamer { self.close_all_sessions().await; } - async fn close_all_sessions_and_release_device(&self) -> usize { + async fn close_all_sessions_and_release_device(&self) -> Result { self.close_all_sessions_and_release_device().await } @@ -1398,6 +1461,7 @@ impl Default for WebRtcStreamer { hid_controller: RwLock::new(None), events: RwLock::new(None), recovery_in_progress: AtomicBool::new(false), + signal_recovery_pending: Arc::new(AtomicBool::new(false)), self_weak: StdRwLock::new(None), } } @@ -1431,4 +1495,14 @@ mod tests { assert!(!WebRtcStreamer::should_stop_pipeline(0, 1)); assert!(!WebRtcStreamer::should_stop_pipeline(2, 3)); } + + #[test] + fn recovery_edge_is_emitted_once_after_first_streaming_frame() { + let pending = AtomicBool::new(false); + assert!(!update_signal_recovery_edge(&pending, "streaming")); + assert!(!update_signal_recovery_edge(&pending, "no_signal")); + assert!(!update_signal_recovery_edge(&pending, "no_signal")); + assert!(update_signal_recovery_edge(&pending, "streaming")); + assert!(!update_signal_recovery_edge(&pending, "streaming")); + } } diff --git a/test/okvm-test/README.md b/test/okvm-test/README.md index b26f19ca..236db491 100644 --- a/test/okvm-test/README.md +++ b/test/okvm-test/README.md @@ -76,8 +76,8 @@ python okvm_testctl.py run ` 控制端会先通过 SSH 执行 `lsusb -t`,再结合 `/api/devices` 自动选择视频输入: -- USB2.0 采集卡:测试 `1080p30 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率最高帧率;如果没有 1080p YUYV,才退到不超过 1080p 的最高分辨率。 -- USB3.0 采集卡:测试 `1080p60 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率最高帧率;如果没有 1080p YUYV,才退到不超过 1080p 的最高分辨率。 +- USB2.0 采集卡(MS2131 测试档位):严格测试 `1080p50 MJPEG` 和 `1080p10 YUYV`;任一格式、分辨率或帧率未申报时,立即将 `video_input_select` 标记为 `FAIL` 并中止测试,不回退到其他档位。 +- USB3.0 采集卡:测试 `1080p60 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率申报的最高帧率;如果没有 1080p YUYV,立即将 `video_input_select` 标记为 `FAIL` 并中止测试,不再回退到较低分辨率。 - CSI/MIPI:只测试一套 `1080p60 NV12`,不做输入格式切换。 每个输入配置都会跑三种输出: @@ -87,6 +87,7 @@ python okvm_testctl.py run ` - H.265 WebRTC 默认每个视频输出模式采样 30 秒;可通过 `--sample-seconds <秒数>` 覆盖。 +每次应用视频输入配置后默认先空转 3 秒,稳定后再开始统计;可通过 `--video-config-settle-seconds <秒数>` 调整。 MJPEG/HTTP 测试时,控制端会让 Windows agent 输出默认 60fps 的全屏动态画面,避免静态画面触发 MJPEG “无变化不发帧”策略导致 fps 误判;可通过 `--mjpeg-motion-fps ` 覆盖。 diff --git a/test/okvm-test/agent/main.go b/test/okvm-test/agent/main.go index f9f05127..a6cac13f 100644 --- a/test/okvm-test/agent/main.go +++ b/test/okvm-test/agent/main.go @@ -42,7 +42,6 @@ const ( wmChar = 0x0102 wmSysKeyDown = 0x0104 wmSysKeyUp = 0x0105 - wmTimer = 0x0113 wmMouseMove = 0x0200 wmLButtonDown = 0x0201 wmLButtonUp = 0x0202 @@ -75,10 +74,11 @@ const ( wmAppFocus = wmApp + 4 wmAppDynamicStart = wmApp + 5 wmAppDynamicStop = wmApp + 6 + wmAppDynamicFrame = wmApp + 7 - dynamicTimerID = 1 - - colorWindow = 5 + colorWindow = 5 + dynamicBackgroundColor = 0x00101010 + dynamicPatchSize = 384 driveUnknown = 0 driveNoRootDir = 1 @@ -111,16 +111,17 @@ var ( procShowWindow = user32.NewProc("ShowWindow") procSetForegroundWindow = user32.NewProc("SetForegroundWindow") procGetSystemMetrics = user32.NewProc("GetSystemMetrics") + procGetDC = user32.NewProc("GetDC") + procReleaseDC = user32.NewProc("ReleaseDC") procInvalidateRect = user32.NewProc("InvalidateRect") procUpdateWindow = user32.NewProc("UpdateWindow") - procSetTimer = user32.NewProc("SetTimer") - procKillTimer = user32.NewProc("KillTimer") procGetKeyState = user32.NewProc("GetKeyState") procBeginPaint = user32.NewProc("BeginPaint") procEndPaint = user32.NewProc("EndPaint") procFillRect = user32.NewProc("FillRect") procCreateSolidBrush = gdi32.NewProc("CreateSolidBrush") procDeleteObject = gdi32.NewProc("DeleteObject") + procGetDeviceCaps = gdi32.NewProc("GetDeviceCaps") procImmAssociateContext = imm32.NewProc("ImmAssociateContext") procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW") procQueryPerformanceCount = kernel32.NewProc("QueryPerformanceCounter") @@ -192,6 +193,10 @@ type appState struct { dynamicActive bool dynamicFPS int dynamicFrame int64 + dynamicGeneration uint64 + dynamicFramePending bool + dynamicStop chan struct{} + dynamicStarted time.Time events []hidEvent } @@ -519,20 +524,22 @@ func windowProc(hwnd uintptr, message uintptr, wParam, lParam uintptr) uintptr { hdc, _, _ := procBeginPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps))) state.mu.Lock() color := state.bgColor + dynamic := state.dynamicActive state.mu.Unlock() - brush, _, _ := procCreateSolidBrush.Call(uintptr(color)) - r := rect{Left: 0, Top: 0, Right: int32(screenWidth()), Bottom: int32(screenHeight())} - procFillRect.Call(hdc, uintptr(unsafe.Pointer(&r)), brush) - procDeleteObject.Call(brush) + paintRect := ps.RcPaint + if paintRect.Right <= paintRect.Left || paintRect.Bottom <= paintRect.Top { + paintRect = rect{Left: 0, Top: 0, Right: int32(screenWidth()), Bottom: int32(screenHeight())} + } + if dynamic { + fillRect(hdc, paintRect, dynamicBackgroundColor) + if patch, ok := intersectRects(paintRect, dynamicPatchRect()); ok { + fillRect(hdc, patch, color) + } + } else { + fillRect(hdc, paintRect, color) + } procEndPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps))) return 0 - case wmTimer: - if wParam == dynamicTimerID { - advanceDynamicFrame(hwnd) - return 0 - } - ret, _, _ := procDefWindowProcW.Call(hwnd, message, wParam, lParam) - return ret case wmAppInvalidate: invalidateWindowNow(hwnd) return 0 @@ -547,16 +554,12 @@ func windowProc(hwnd uintptr, message uintptr, wParam, lParam uintptr) uintptr { procSetForegroundWindow.Call(hwnd) return 0 case wmAppDynamicStart: - procKillTimer.Call(hwnd, dynamicTimerID) - interval := wParam - if interval == 0 { - interval = 16 - } - procSetTimer.Call(hwnd, dynamicTimerID, interval, 0) invalidateWindowNow(hwnd) return 0 + case wmAppDynamicFrame: + advanceDynamicFrame(hwnd, uint64(wParam)) + return 0 case wmAppDynamicStop: - procKillTimer.Call(hwnd, dynamicTimerID) invalidateWindowNow(hwnd) return 0 case wmKeyDown: @@ -590,7 +593,6 @@ func windowProc(hwnd uintptr, message uintptr, wParam, lParam uintptr) uintptr { procShowWindow.Call(hwnd, swHide) return 0 case wmDestroy: - procKillTimer.Call(hwnd, dynamicTimerID) procPostQuitMessage.Call(0) return 0 default: @@ -704,13 +706,20 @@ func startDynamic(fps int) map[string]interface{} { fps = 120 } stopDynamic() + stop := make(chan struct{}) state.mu.Lock() state.dynamicActive = true state.dynamicFPS = fps state.dynamicFrame = 0 + state.dynamicGeneration++ + generation := state.dynamicGeneration + state.dynamicFramePending = false + state.dynamicStop = stop + state.dynamicStarted = time.Now() display := setColorStateLocked(dynamicFrameColor(0)) state.mu.Unlock() - postUIMessage(wmAppDynamicStart, uintptr(dynamicTimerIntervalMS(fps)), 0) + postUIMessage(wmAppDynamicStart, uintptr(generation), 0) + go runDynamicFrames(fps, generation, stop) display["dynamic"] = true display["fps"] = fps return display @@ -719,24 +728,51 @@ func startDynamic(fps int) map[string]interface{} { func stopDynamic() { state.mu.Lock() active := state.dynamicActive + stop := state.dynamicStop state.dynamicActive = false state.dynamicFPS = 0 state.dynamicFrame = 0 + state.dynamicFramePending = false + state.dynamicStop = nil + state.dynamicStarted = time.Time{} state.mu.Unlock() + if stop != nil { + close(stop) + } if active { postUIMessage(wmAppDynamicStop, 0, 0) } } -func dynamicTimerIntervalMS(fps int) int { - if fps < 1 { - fps = 60 +func runDynamicFrames(fps int, generation uint64, stop <-chan struct{}) { + interval := time.Second / time.Duration(fps) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + state.mu.Lock() + if !state.dynamicActive || state.dynamicGeneration != generation { + state.mu.Unlock() + return + } + if state.dynamicFramePending { + state.mu.Unlock() + continue + } + state.dynamicFramePending = true + state.mu.Unlock() + if !postUIMessage(wmAppDynamicFrame, uintptr(generation), 0) { + state.mu.Lock() + if state.dynamicGeneration == generation { + state.dynamicFramePending = false + } + state.mu.Unlock() + } + } } - interval := 1000 / fps - if interval < 1 { - return 1 - } - return interval } func dynamicFrameColor(frame int64) string { @@ -746,16 +782,17 @@ func dynamicFrameColor(frame int64) string { return fmt.Sprintf("#%02x%02x%02x", r, g, b) } -func advanceDynamicFrame(hwnd uintptr) { +func advanceDynamicFrame(hwnd uintptr, generation uint64) { state.mu.Lock() - if !state.dynamicActive { + if !state.dynamicActive || state.dynamicGeneration != generation { state.mu.Unlock() return } + state.dynamicFramePending = false state.dynamicFrame++ setColorStateLocked(dynamicFrameColor(state.dynamicFrame)) state.mu.Unlock() - invalidateWindowNow(hwnd) + invalidateDynamicPatchNow(hwnd) } func setColorStateLocked(colorHex string) map[string]interface{} { @@ -781,6 +818,13 @@ func setColorStateLocked(colorHex string) map[string]interface{} { func currentDisplayState() map[string]interface{} { state.mu.Lock() defer state.mu.Unlock() + dynamicActualFPS := 0.0 + if state.dynamicActive && !state.dynamicStarted.IsZero() { + elapsed := time.Since(state.dynamicStarted).Seconds() + if elapsed > 0 { + dynamicActualFPS = float64(state.dynamicFrame) / elapsed + } + } return map[string]interface{}{ "color": state.colorHex, "last_change_qpc": state.lastColorChangeQpc, @@ -788,6 +832,9 @@ func currentDisplayState() map[string]interface{} { "sequence": state.colorSequence, "dynamic": state.dynamicActive, "dynamic_fps": state.dynamicFPS, + "dynamic_frame": state.dynamicFrame, + "dynamic_actual_fps": dynamicActualFPS, + "display_refresh_hz": screenRefreshHz(), "qpc": qpcNow(), "unix_nano": time.Now().UnixNano(), } @@ -816,13 +863,15 @@ func focusWindow() { } } -func postUIMessage(message uint32, wParam uintptr, lParam uintptr) { +func postUIMessage(message uint32, wParam uintptr, lParam uintptr) bool { state.mu.Lock() hwnd := state.hwnd state.mu.Unlock() - if hwnd != 0 { - procPostMessageW.Call(hwnd, uintptr(message), wParam, lParam) + if hwnd == 0 { + return false } + result, _, _ := procPostMessageW.Call(hwnd, uintptr(message), wParam, lParam) + return result != 0 } func invalidateWindowNow(hwnd uintptr) { @@ -830,6 +879,60 @@ func invalidateWindowNow(hwnd uintptr) { procUpdateWindow.Call(hwnd) } +func invalidateDynamicPatchNow(hwnd uintptr) { + patch := dynamicPatchRect() + procInvalidateRect.Call(hwnd, uintptr(unsafe.Pointer(&patch)), 0) + procUpdateWindow.Call(hwnd) +} + +func dynamicPatchRect() rect { + width := int32(screenWidth()) + height := int32(screenHeight()) + size := int32(dynamicPatchSize) + if size > width { + size = width + } + if size > height { + size = height + } + left := (width - size) / 2 + top := (height - size) / 2 + return rect{Left: left, Top: top, Right: left + size, Bottom: top + size} +} + +func intersectRects(a, b rect) (rect, bool) { + intersection := rect{ + Left: maxInt32(a.Left, b.Left), + Top: maxInt32(a.Top, b.Top), + Right: minInt32(a.Right, b.Right), + Bottom: minInt32(a.Bottom, b.Bottom), + } + return intersection, intersection.Right > intersection.Left && intersection.Bottom > intersection.Top +} + +func fillRect(hdc uintptr, area rect, color uint32) { + brush, _, _ := procCreateSolidBrush.Call(uintptr(color)) + if brush == 0 { + return + } + procFillRect.Call(hdc, uintptr(unsafe.Pointer(&area)), brush) + procDeleteObject.Call(brush) +} + +func minInt32(a, b int32) int32 { + if a < b { + return a + } + return b +} + +func maxInt32(a, b int32) int32 { + if a > b { + return a + } + return b +} + func disableIME(hwnd uintptr) { if hwnd != 0 { procImmAssociateContext.Call(hwnd, 0) @@ -870,7 +973,18 @@ func screenSize() (int, int) { func screenInfo() map[string]int { width, height := screenSize() - return map[string]int{"width": width, "height": height} + return map[string]int{"width": width, "height": height, "refresh_hz": screenRefreshHz()} +} + +func screenRefreshHz() int { + const vRefresh = 116 + hdc, _, _ := procGetDC.Call(0) + if hdc == 0 { + return 0 + } + defer procReleaseDC.Call(0, hdc) + refresh, _, _ := procGetDeviceCaps.Call(hdc, vRefresh) + return int(refresh) } func screenWidth() int { diff --git a/test/okvm-test/okvm_report.py b/test/okvm-test/okvm_report.py index cb9e50f7..b421693d 100644 --- a/test/okvm-test/okvm_report.py +++ b/test/okvm-test/okvm_report.py @@ -163,9 +163,9 @@ class Reporter: "", f"- 运行编号:`{run_id}`", "- 测试设备:", - f"- 视频设备:{markdown_inline(self.user_video_device())}", - f"- HID设备:{markdown_inline(self.user_hid_backend())}", - f"- 网络延迟:{markdown_inline(self.user_network_latency())}", + f"- 视频设备:{self.user_video_device()}", + f"- HID 设备:{markdown_inline(self.user_hid_backend())}", + f"- HTTP 延迟:{markdown_inline(self.user_http_latency())}", "", "## 视频性能", "", @@ -192,7 +192,7 @@ class Reporter: lines.extend( [ "", - "## HID性能", + "## HID 性能", "", "| 输入方式 | 延迟统计(中位数 p50 / 95%分位 p95 / 最大值 max) |", "| --- | --- |", @@ -208,7 +208,7 @@ class Reporter: lines.extend( [ "", - "## MSD性能", + "## MSD 性能", "", "| 操作 | 数据 |", "| --- | --- |", @@ -229,14 +229,14 @@ class Reporter: return result return None - def user_network_latency(self) -> str: + def user_http_latency(self) -> str: result = self.find_result("network_latency") if not result: return "无数据" - tcp = result.data.get("tcp_connect") or {} - if tcp.get("samples"): - return format_latency_values(tcp) - return result.message or "无数据" + http = result.data.get("http_health") or {} + if http.get("samples"): + return format_latency_values(http) + return "无数据" def user_video_device(self) -> str: result = self.find_result("video_input_select") @@ -249,7 +249,9 @@ class Reporter: continue device = str(case.get("device") or "未知设备") by_device.setdefault(device, []).append(format_video_case(case)) - return ";".join(f"{device}:{', '.join(items)}" for device, items in by_device.items()) or "无数据" + return ";".join( + f"{markdown_code(device)}({'、'.join(items)})" for device, items in by_device.items() + ) or "无数据" def user_hid_backend(self) -> str: config = self.find_result("hid_msd_config") @@ -317,11 +319,11 @@ class Reporter: verify = result.data.get("verify") or {} rows: list[tuple[str, str]] = [] if "write_mib_s" in verify: - rows.append(("写", f"{float(verify.get('write_mib_s') or 0):.2f}MiB/s")) + rows.append(("写", f"{float(verify.get('write_mib_s') or 0):.2f} MiB/s")) if "read_mib_s" in verify: - rows.append(("读", f"{float(verify.get('read_mib_s') or 0):.2f}MiB/s")) + rows.append(("读", f"{float(verify.get('read_mib_s') or 0):.2f} MiB/s")) elif "cached_read_mib_s" in verify: - rows.append(("读(缓存,仅校验)", f"{float(verify.get('cached_read_mib_s') or 0):.2f}MiB/s")) + rows.append(("读(缓存,仅校验)", f"{float(verify.get('cached_read_mib_s') or 0):.2f} MiB/s")) if rows: return rows return [("MSD", f"{STATUS_TEXT.get(result.status, result.status)}:{result.message}" if result.message else STATUS_TEXT.get(result.status, result.status))] @@ -589,19 +591,30 @@ def display_name(name: str) -> str: def format_video_case(case: dict[str, Any]) -> str: - fmt = str(case.get("fmt") or "").lower() + fmt = format_video_codec(case.get("fmt")) resolution = format_resolution(case) fps = format_fps_value(case.get("fps")) return " ".join(part for part in (f"{resolution}@{fps}" if resolution and fps else resolution or fps, fmt) if part) def format_video_latency_params(case: dict[str, Any], output_mode: str) -> str: - fmt = str(case.get("fmt") or "").lower() - output = output_mode.lower() if output_mode else "unknown" + fmt = format_video_codec(case.get("fmt")) + output = format_video_codec(output_mode) if output_mode else "UNKNOWN" resolution = format_resolution(case) fps = format_fps_value(case.get("fps")) input_part = " ".join(part for part in (f"{resolution}@{fps}" if resolution and fps else resolution or fps, fmt) if part) - return f"{input_part}-->{output}" if input_part else output + return f"{input_part} → {output}" if input_part else output + + +def format_video_codec(value: Any) -> str: + codec = str(value or "").strip() + normalized = codec.lower().replace(".", "").replace("-", "").replace("_", "") + names = { + "h264": "H.264", + "h265": "H.265", + "mjpeg": "MJPEG", + } + return names.get(normalized, codec.upper()) def format_resolution(case: dict[str, Any]) -> str: @@ -659,6 +672,10 @@ def markdown_inline(value: str) -> str: return str(value).replace("\n", " | ").replace("`", "'") +def markdown_code(value: str) -> str: + return f"`{markdown_inline(value)}`" + + def markdown_table_cell(value: str) -> str: return markdown_inline(value).replace("|", "\\|") diff --git a/test/okvm-test/okvm_testctl.py b/test/okvm-test/okvm_testctl.py index 440e0d64..b285eab6 100755 --- a/test/okvm-test/okvm_testctl.py +++ b/test/okvm-test/okvm_testctl.py @@ -275,6 +275,24 @@ class DeviceSelector: return width, height, best return None + @staticmethod + def _pick_required_mode( + fmt: dict[str, Any] | None, + width: int, + height: int, + fps: float, + ) -> tuple[int, int, float] | None: + if not fmt: + return None + for res in fmt.get("resolutions", []): + if int(res.get("width", 0)) != width or int(res.get("height", 0)) != height: + continue + for advertised_fps in res.get("fps", []): + value = float(advertised_fps) + if abs(value - fps) <= 0.05: + return width, height, value + return None + @staticmethod def _pick_highest_1080(fmt: dict[str, Any]) -> tuple[int, int, float] | None: candidates: list[tuple[int, int, float]] = [] @@ -289,6 +307,22 @@ class DeviceSelector: return None return max(candidates, key=lambda x: (x[0] * x[1], x[2])) + @staticmethod + def _pick_highest_fps_at_resolution( + fmt: dict[str, Any] | None, + width: int, + height: int, + ) -> tuple[int, int, float] | None: + if not fmt: + return None + candidates: list[float] = [] + for res in fmt.get("resolutions", []): + if int(res.get("width", 0)) == width and int(res.get("height", 0)) == height: + candidates.extend(float(value) for value in res.get("fps", [])) + if not candidates: + return None + return width, height, max(candidates) + def select(self) -> list[VideoInputCase]: video_devices = self.devices.get("video", []) if not video_devices: @@ -310,17 +344,36 @@ class DeviceSelector: mjpeg = self._find_format(device, "MJPEG") yuyv = self._find_format(device, "YUYV") - target_fps = 60 if input_class == "usb3" else 30 + if input_class == "usb2": + required_modes = ( + ("MJPEG", mjpeg, 50.0, "usb2_mjpeg"), + ("YUYV", yuyv, 10.0, "usb2_yuyv"), + ) + missing: list[str] = [] + for fmt_name, fmt_info, required_fps, label in required_modes: + picked = self._pick_required_mode(fmt_info, 1920, 1080, required_fps) + if not picked: + missing.append(f"{fmt_name} 1920x1080@{required_fps:g}fps") + continue + width, height, fps = picked + cases.append(VideoInputCase(label, input_class, path, fmt_name, width, height, fps)) + if missing: + raise RuntimeError( + "USB 2.0 capture card is missing required mode(s): " + ", ".join(missing) + ) + return cases + + target_fps = 60 if mjpeg: picked = self._pick_exact(mjpeg, 1920, 1080, target_fps) or self._pick_highest_1080(mjpeg) if picked: w, h, f = picked cases.append(VideoInputCase(f"{input_class}_mjpeg", input_class, path, "MJPEG", w, h, f)) - if yuyv: - picked = self._pick_highest_1080(yuyv) - if picked: - w, h, f = picked - cases.append(VideoInputCase(f"{input_class}_yuyv", input_class, path, "YUYV", w, h, f)) + picked = self._pick_highest_fps_at_resolution(yuyv, 1920, 1080) + if not picked: + raise RuntimeError("USB 3.0 capture card is missing required mode: YUYV 1920x1080") + w, h, f = picked + cases.append(VideoInputCase("usb3_yuyv", input_class, path, "YUYV", w, h, f)) return cases @@ -597,7 +650,16 @@ echo "$BACKUP" def select_video_cases(self, devices: dict[str, Any]) -> list[VideoInputCase]: selector = DeviceSelector(self.lsusb_tree, devices) - cases = selector.select() + try: + cases = selector.select() + except RuntimeError as exc: + self.reporter.add( + "video_input_select", + "FAIL", + str(exc), + devices=devices.get("video", []), + ) + raise if not cases: self.reporter.add("video_input_select", "FAIL", "no suitable video input case found", devices=devices.get("video", [])) return [] @@ -692,7 +754,6 @@ echo "$BACKUP" ) continue try: - self.apply_video_case(case) if output_mode == "mjpeg": motion_started = await self.start_mjpeg_motion() try: @@ -796,6 +857,9 @@ echo "$BACKUP" "quality": self.args.jpeg_quality, }, ) + settle_seconds = max(0.0, float(self.args.video_config_settle_seconds)) + if settle_seconds > 0: + time.sleep(settle_seconds) def configure_video_case(self, case: VideoInputCase) -> None: self.apply_video_case(case) @@ -818,7 +882,7 @@ echo "$BACKUP" frame_count = 0 byte_count = 0 first_frame_s: float | None = None - for frame, frame_time, _ in self.iter_mjpeg_frames(client_id, timeout=self.args.sample_seconds, video_case=case): + for frame, frame_time, _ in self.iter_mjpeg_frames(client_id, timeout=self.args.sample_seconds): now = time.monotonic() if now >= deadline: break @@ -855,6 +919,7 @@ echo "$BACKUP" async def measure_webrtc(self, case: VideoInputCase, codec: str) -> dict[str, Any]: self.set_stream_mode(codec) + self.apply_video_case(case) try: from playwright.async_api import async_playwright except ImportError: @@ -885,7 +950,13 @@ echo "$BACKUP" try: page = await context.new_page() await page.goto(self.api.base) - result = await page.evaluate(js, {"seconds": self.args.sample_seconds}) + result = await page.evaluate( + js, + { + "seconds": self.args.sample_seconds, + "settleSeconds": max(0.0, float(self.args.video_config_settle_seconds)), + }, + ) except Exception as exc: text = str(exc) if codec == "h265" and is_webrtc_codec_unsupported_error(text): @@ -988,7 +1059,6 @@ echo "$BACKUP" ) continue try: - self.apply_video_case(latency_case) if output_mode == "mjpeg": await self.run_mjpeg_latency_test(latency_case, check_name=check_name, save_evidence=False) else: @@ -1010,17 +1080,8 @@ echo "$BACKUP" return cases[0] def configure_hdmi_probe_case(self, case: VideoInputCase) -> None: - self.api.patch( - "/config/video", - { - "device": case.device, - "format": case.fmt, - "width": case.width, - "height": case.height, - "fps": int(round(case.fps)), - "quality": self.args.jpeg_quality, - }, - ) + self.set_stream_mode("mjpeg") + self.apply_video_case(case) self.reporter.add( "config_video_hdmi_probe", "PASS", @@ -1044,7 +1105,6 @@ echo "$BACKUP" evidence_title=f"HDMI 纯色采集帧 {name}", expected_rgb=expected, threshold=self.args.hdmi_color_fail, - video_case=case, ) err = rgb_error(expected, stats["mean_rgb"]) closest_name, closest_error = closest_hdmi_color(stats["mean_rgb"]) @@ -1113,6 +1173,9 @@ echo "$BACKUP" if self.args.hdmi_latency_trials <= 0: self.reporter.add(check_name, "SKIP", "video latency trials disabled", video_case=case.__dict__, output_mode="mjpeg") return + self.set_stream_mode("mjpeg") + self.apply_video_case(case) + self.api.post("/stream/start", {}) offset_ns, sync = await self.sync_agent_clock(f"{check_name}_agent_clock_sync_rtt") trials: list[dict[str, Any]] = [] colors = [("#ff0000", "#00ff00"), ("#00ff00", "#0000ff"), ("#0000ff", "#ff0000")] @@ -1128,7 +1191,6 @@ echo "$BACKUP" timeout=detect_timeout, threshold=self.args.hdmi_color_fail, evidence_title=f"HDMI 延迟命中帧 {case.label} #{i + 1}" if save_evidence else None, - video_case=case, ) display = await self.agent.command("display_state", {}, timeout=5) actual_agent_ns = int(display.get("last_change_unix_nano") or 0) @@ -1209,6 +1271,9 @@ echo "$BACKUP" setup = await page.evaluate(WEBRTC_LATENCY_SETUP_JS, {"timeoutMs": 15000}) if not setup.get("connected"): raise RuntimeError(f"{output_mode} WebRTC did not connect: {setup}") + settle_seconds = max(0.0, float(self.args.video_config_settle_seconds)) + if settle_seconds > 0: + await page.wait_for_timeout(settle_seconds * 1000) for i in range(self.args.hdmi_latency_trials): source, target = colors[i % len(colors)] await self.agent.command("show", {"color": source, "full": True}, timeout=5) @@ -1313,7 +1378,6 @@ echo "$BACKUP" evidence_title: str | None = None, expected_rgb: tuple[int, int, int] | None = None, threshold: float | None = None, - video_case: VideoInputCase | None = None, ) -> dict[str, Any]: threshold = self.args.hdmi_color_fail if threshold is None else threshold required_matches = max(1, int(self.args.hdmi_match_frames)) @@ -1324,7 +1388,7 @@ echo "$BACKUP" consecutive_matches = 0 frames_seen = 0 - for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout, video_case=video_case): + for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout): frames_seen += 1 stats = jpeg_rgb_stats(frame) stats["wall_ns"] = wall_ns @@ -1370,12 +1434,11 @@ echo "$BACKUP" timeout: float, threshold: float, evidence_title: str | None = None, - video_case: VideoInputCase | None = None, ) -> dict[str, Any]: last: dict[str, Any] | None = None last_frame: bytes | None = None frames_seen = 0 - for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout, video_case=video_case): + for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout): frames_seen += 1 stats = jpeg_rgb_stats(frame) err = rgb_error(expected_rgb, stats["mean_rgb"]) @@ -1400,10 +1463,8 @@ echo "$BACKUP" path.write_bytes(frame) self.reporter.add_evidence("HDMI 采集帧", title, path) - def iter_mjpeg_frames(self, client_label: str, timeout: float, video_case: VideoInputCase | None = None): + def iter_mjpeg_frames(self, client_label: str, timeout: float): self.set_stream_mode("mjpeg") - if video_case is not None: - self.apply_video_case(video_case) self.api.post("/stream/start", {}) client_id = f"test-{self.run_id}-{client_label}" url = f"{self.api.base}/api/stream/mjpeg?client_id={client_id}" @@ -1441,8 +1502,6 @@ echo "$BACKUP" raise time.sleep(0.5) self.set_stream_mode("mjpeg", timeout=10) - if video_case is not None: - self.apply_video_case(video_case) self.api.post("/stream/start", {}) async def run_hid_test(self) -> None: @@ -1834,7 +1893,7 @@ VIDEO_SCREENSHOT_READY_JS = r""" WEBRTC_MEASURE_JS = r""" -async ({seconds}) => { +async ({seconds, settleSeconds}) => { const api = async (path, opts = {}) => { const response = await fetch('/api' + path, { credentials: 'include', @@ -1895,6 +1954,12 @@ async ({seconds}) => { await new Promise(r => setTimeout(r, 100)); } + // Let capture, encoding, decoding, and browser rendering reach steady state. + // Samples collected during this interval are intentionally discarded. + if (settleSeconds > 0) { + await new Promise(r => setTimeout(r, settleSeconds * 1000)); + } + const samples = []; const endAt = performance.now() + seconds * 1000; while (performance.now() < endAt) { @@ -2252,6 +2317,7 @@ def build_parser() -> argparse.ArgumentParser: run.add_argument("--network-latency-samples", type=int, default=7, help="controller-to-target network latency samples collected during setup") run.add_argument("--network-latency-timeout", type=float, default=3.0, help="per-sample TCP/HTTP latency timeout in seconds") run.add_argument("--sample-seconds", type=int, default=30) + run.add_argument("--video-config-settle-seconds", type=float, default=3.0, help="idle time after applying video configuration before collecting samples") run.add_argument("--jpeg-quality", type=int, default=80) run.add_argument("--mjpeg-motion-fps", type=int, default=60, help="Windows agent dynamic source fps used during MJPEG/HTTP tests") run.add_argument("--agent-host", default=None, help="Windows agent IP/hostname; omit to skip Windows-side checks") diff --git a/vcpkg.json b/vcpkg.json index 86b19aed..c8c617fa 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", "name": "one-kvm", - "version-string": "0.2.5", + "version-string": "0.2.6", "dependencies": [ { "name": "ffmpeg", diff --git a/web/package-lock.json b/web/package-lock.json deleted file mode 100644 index dcfd4ddb..00000000 --- a/web/package-lock.json +++ /dev/null @@ -1,3053 +0,0 @@ -{ - "name": "web", - "version": "0.2.5", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "web", - "version": "0.2.5", - "dependencies": { - "@vueuse/core": "^14.3.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "lucide-vue-next": "^0.556.0", - "opus-decoder": "^0.7.11", - "opus-media-recorder": "^0.8.0", - "pinia": "^3.0.4", - "qrcode.vue": "^3.10.0", - "reka-ui": "^2.10.1", - "simple-keyboard": "^3.8.163", - "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0", - "uplot": "^1.6.32", - "vue": "^3.5.40", - "vue-i18n": "^9.14.5", - "vue-router": "^4.6.4", - "vue-sonner": "^2.0.9" - }, - "devDependencies": { - "@tailwindcss/forms": "^0.5.11", - "@tailwindcss/typography": "^0.5.20", - "@tailwindcss/vite": "^4.3.3", - "@types/node": "^24.13.3", - "@vitejs/plugin-vue": "^6.0.8", - "@vue/tsconfig": "^0.8.1", - "autoprefixer": "^10.5.4", - "postcss": "^8.5.19", - "tailwindcss": "^4.3.3", - "typescript": "~5.9.3", - "vite": "^7.3.6", - "vue-tsc": "^3.3.7" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eshaz/web-worker": { - "version": "1.2.2", - "resolved": "https://registry.npmmirror.com/@eshaz/web-worker/-/web-worker-1.2.2.tgz", - "integrity": "sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==", - "license": "Apache-2.0" - }, - "node_modules/@floating-ui/core": { - "version": "1.8.0", - "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", - "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.12" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.8.0", - "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz", - "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.8.0", - "@floating-ui/utils": "^0.2.12" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.12", - "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz", - "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", - "license": "MIT" - }, - "node_modules/@floating-ui/vue": { - "version": "1.1.11", - "resolved": "https://registry.npmmirror.com/@floating-ui/vue/-/vue-1.1.11.tgz", - "integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.6", - "@floating-ui/utils": "^0.2.11", - "vue-demi": ">=0.13.0" - } - }, - "node_modules/@floating-ui/vue/node_modules/vue-demi": { - "version": "0.14.10", - "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", - "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, - "node_modules/@internationalized/date": { - "version": "3.12.2", - "resolved": "https://registry.npmmirror.com/@internationalized/date/-/date-3.12.2.tgz", - "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@internationalized/number": { - "version": "3.6.7", - "resolved": "https://registry.npmmirror.com/@internationalized/number/-/number-3.6.7.tgz", - "integrity": "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@intlify/core-base": { - "version": "9.14.5", - "resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.14.5.tgz", - "integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==", - "license": "MIT", - "dependencies": { - "@intlify/message-compiler": "9.14.5", - "@intlify/shared": "9.14.5" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - } - }, - "node_modules/@intlify/message-compiler": { - "version": "9.14.5", - "resolved": "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.14.5.tgz", - "integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==", - "license": "MIT", - "dependencies": { - "@intlify/shared": "9.14.5", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - } - }, - "node_modules/@intlify/shared": { - "version": "9.14.5", - "resolved": "https://registry.npmmirror.com/@intlify/shared/-/shared-9.14.5.tgz", - "integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@swc/helpers": { - "version": "0.5.23", - "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.23.tgz", - "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@tailwindcss/forms": { - "version": "0.5.11", - "resolved": "https://registry.npmmirror.com/@tailwindcss/forms/-/forms-0.5.11.tgz", - "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mini-svg-data-uri": "^1.2.3" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.3.tgz", - "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.24.1", - "jiti": "^2.7.0", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.3" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.3.3.tgz", - "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-x64": "4.3.3", - "@tailwindcss/oxide-freebsd-x64": "4.3.3", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-x64-musl": "4.3.3", - "@tailwindcss/oxide-wasm32-wasi": "4.3.3", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", - "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", - "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", - "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", - "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", - "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", - "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", - "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", - "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", - "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", - "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.11.1", - "@emnapi/runtime": "^1.11.1", - "@emnapi/wasi-threads": "^1.2.2", - "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.2", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", - "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", - "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/typography": { - "version": "0.5.20", - "resolved": "https://registry.npmmirror.com/@tailwindcss/typography/-/typography-0.5.20.tgz", - "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "6.0.10" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.3.3.tgz", - "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.3.3", - "@tailwindcss/oxide": "4.3.3", - "tailwindcss": "4.3.3" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@tanstack/virtual-core": { - "version": "3.17.4", - "resolved": "https://registry.npmmirror.com/@tanstack/virtual-core/-/virtual-core-3.17.4.tgz", - "integrity": "sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/vue-virtual": { - "version": "3.13.32", - "resolved": "https://registry.npmmirror.com/@tanstack/vue-virtual/-/vue-virtual-3.13.32.tgz", - "integrity": "sha512-E8OCutx7QnwZdvpJijz0Q2PHsYDWBWjnGr3TvgWiqxTU35jB1kVhtkd93scRV7tTFuId2tg3x2iFiw+IE4evjQ==", - "license": "MIT", - "dependencies": { - "@tanstack/virtual-core": "3.17.4" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "vue": "^2.7.0 || ^3.0.0" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/web-bluetooth": { - "version": "0.0.21", - "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", - "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", - "license": "MIT" - }, - "node_modules/@vitejs/plugin-vue": { - "version": "6.0.8", - "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", - "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@volar/language-core": { - "version": "2.4.28", - "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz", - "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/source-map": "2.4.28" - } - }, - "node_modules/@volar/source-map": { - "version": "2.4.28", - "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz", - "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@volar/typescript": { - "version": "2.4.28", - "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz", - "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.28", - "path-browserify": "^1.0.1", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.40", - "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.40.tgz", - "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@vue/shared": "3.5.40", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.40", - "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", - "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.40", - "@vue/shared": "3.5.40" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.40", - "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", - "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@vue/compiler-core": "3.5.40", - "@vue/compiler-dom": "3.5.40", - "@vue/compiler-ssr": "3.5.40", - "@vue/shared": "3.5.40", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.19", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.40", - "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", - "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.40", - "@vue/shared": "3.5.40" - } - }, - "node_modules/@vue/devtools-api": { - "version": "7.7.10", - "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.10.tgz", - "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", - "license": "MIT", - "dependencies": { - "@vue/devtools-kit": "^7.7.10" - } - }, - "node_modules/@vue/devtools-kit": { - "version": "7.7.10", - "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", - "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", - "license": "MIT", - "dependencies": { - "@vue/devtools-shared": "^7.7.10", - "birpc": "^2.3.0", - "hookable": "^5.5.3", - "mitt": "^3.0.1", - "perfect-debounce": "^1.0.0", - "speakingurl": "^14.0.1", - "superjson": "^2.2.2" - } - }, - "node_modules/@vue/devtools-shared": { - "version": "7.7.10", - "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", - "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", - "license": "MIT", - "dependencies": { - "rfdc": "^1.4.1" - } - }, - "node_modules/@vue/language-core": { - "version": "3.3.7", - "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.7.tgz", - "integrity": "sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.28", - "@vue/compiler-dom": "^3.5.0", - "@vue/shared": "^3.5.0", - "alien-signals": "^3.2.1", - "muggle-string": "^0.4.1", - "path-browserify": "^1.0.1", - "picomatch": "^4.0.4" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.40", - "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.40.tgz", - "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.40" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.40", - "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.40.tgz", - "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.40", - "@vue/shared": "3.5.40" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.40", - "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", - "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.40", - "@vue/runtime-core": "3.5.40", - "@vue/shared": "3.5.40", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.40", - "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.40.tgz", - "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.40", - "@vue/runtime-dom": "3.5.40", - "@vue/shared": "3.5.40" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.40", - "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.40.tgz", - "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", - "license": "MIT" - }, - "node_modules/@vue/tsconfig": { - "version": "0.8.1", - "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.8.1.tgz", - "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "typescript": "5.x", - "vue": "^3.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "vue": { - "optional": true - } - } - }, - "node_modules/@vueuse/core": { - "version": "14.3.0", - "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", - "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", - "license": "MIT", - "dependencies": { - "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "14.3.0", - "@vueuse/shared": "14.3.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vue": "^3.5.0" - } - }, - "node_modules/@vueuse/metadata": { - "version": "14.3.0", - "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", - "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/shared": { - "version": "14.3.0", - "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", - "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vue": "^3.5.0" - } - }, - "node_modules/@wasm-audio-decoders/common": { - "version": "9.0.7", - "resolved": "https://registry.npmmirror.com/@wasm-audio-decoders/common/-/common-9.0.7.tgz", - "integrity": "sha512-WRaUuWSKV7pkttBygml/a6dIEpatq2nnZGFIoPTc5yPLkxL6Wk4YaslPM98OPQvWacvNZ+Py9xROGDtrFBDzag==", - "license": "MIT", - "dependencies": { - "@eshaz/web-worker": "1.2.2", - "simple-yenc": "^1.0.4" - } - }, - "node_modules/alien-signals": { - "version": "3.2.1", - "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz", - "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", - "dev": true, - "license": "MIT" - }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/autoprefixer": { - "version": "10.5.4", - "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.5.4.tgz", - "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.6", - "caniuse-lite": "^1.0.30001806", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.43", - "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", - "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/birpc": { - "version": "2.9.0", - "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", - "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/browserslist": { - "version": "4.28.6", - "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.6.tgz", - "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001803", - "electron-to-chromium": "^1.5.389", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "license": "Apache-2.0", - "dependencies": { - "clsx": "^2.1.1" - }, - "funding": { - "url": "https://polar.sh/cva" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/copy-anything": { - "version": "4.0.5", - "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz", - "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", - "license": "MIT", - "dependencies": { - "is-what": "^5.2.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "license": "MIT" - }, - "node_modules/detect-browser": { - "version": "4.8.0", - "resolved": "https://registry.npmmirror.com/detect-browser/-/detect-browser-4.8.0.tgz", - "integrity": "sha512-f4h2dFgzHUIpjpBLjhnDIteXv8VQiUm8XzAuzQtYUqECX/eKh67ykuiVoyb7Db7a0PUSmJa3OGXStG0CbQFUVw==", - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.392", - "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", - "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==", - "dev": true, - "license": "ISC" - }, - "node_modules/enhanced-resolve": { - "version": "5.24.2", - "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", - "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/event-target-shim": { - "version": "3.0.2", - "resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-3.0.2.tgz", - "integrity": "sha512-HK5GhnEAkm7fLy249GtF7DIuYmjLm85Ft6ssj7DhVl8Tx/z9+v0W6aiIVUdT4AXWGYy5Fc+s6gqBI49Bf0LejQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/hookable": { - "version": "5.5.3", - "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "license": "MIT" - }, - "node_modules/is-what": { - "version": "5.5.0", - "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", - "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lucide-vue-next": { - "version": "0.556.0", - "resolved": "https://registry.npmmirror.com/lucide-vue-next/-/lucide-vue-next-0.556.0.tgz", - "integrity": "sha512-JvdCM2smkWrMDhkfD/FpZiWekkbWD6MZLstIFx/FOVZgULrnMr5hegCB9LlTdgllEFnQYQs8hhHC1WYcAV9HTA==", - "license": "ISC", - "peerDependencies": { - "vue": ">=3.0.1" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mini-svg-data-uri": { - "version": "1.4.4", - "resolved": "https://registry.npmmirror.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", - "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", - "dev": true, - "license": "MIT", - "bin": { - "mini-svg-data-uri": "cli.js" - } - }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" - }, - "node_modules/muggle-string": { - "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", - "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/ohash": { - "version": "2.0.11", - "resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz", - "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "license": "MIT" - }, - "node_modules/opus-decoder": { - "version": "0.7.11", - "resolved": "https://registry.npmmirror.com/opus-decoder/-/opus-decoder-0.7.11.tgz", - "integrity": "sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==", - "license": "MIT", - "dependencies": { - "@wasm-audio-decoders/common": "9.0.7" - }, - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/eshaz" - } - }, - "node_modules/opus-media-recorder": { - "version": "0.8.0", - "resolved": "https://registry.npmmirror.com/opus-media-recorder/-/opus-media-recorder-0.8.0.tgz", - "integrity": "sha512-AIvJMpnJqZ18dFAU7Amtt5cZZp8oPzDoAOtobdTcLzwVNm/j815+GJmBupBzBZGBa4L940TEulm7Uu4tGOYDGQ==", - "license": "MIT", - "dependencies": { - "detect-browser": "^4.1.0", - "event-target-shim": "^3.0.2" - } - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true, - "license": "MIT" - }, - "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pinia": { - "version": "3.0.4", - "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz", - "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", - "license": "MIT", - "dependencies": { - "@vue/devtools-api": "^7.7.7" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "typescript": ">=4.5.0", - "vue": "^3.5.11" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/postcss": { - "version": "8.5.19", - "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.19.tgz", - "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/qrcode.vue": { - "version": "3.10.0", - "resolved": "https://registry.npmmirror.com/qrcode.vue/-/qrcode.vue-3.10.0.tgz", - "integrity": "sha512-1bjeBds9hRKMszZuBuYQZor9HdJYWtb0S44HG1JofZ9uicpJpdF+TtDvqo3+2ZlH0k80WVIkmiKB4hq0/N6/rA==", - "license": "MIT", - "peerDependencies": { - "vue": "^3.0.0" - } - }, - "node_modules/reka-ui": { - "version": "2.10.1", - "resolved": "https://registry.npmmirror.com/reka-ui/-/reka-ui-2.10.1.tgz", - "integrity": "sha512-drcOQ4rQtDYAcGCsyQBqQg8QQ+H3B+zDaMJU0h8KPEPMa7g9BHu3zcOi4OB39XJSWizceFoNO0Z9tctSGLOXqg==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.6.13", - "@floating-ui/vue": "^1.1.6", - "@internationalized/date": "^3.5.0", - "@internationalized/number": "^3.5.0", - "@tanstack/vue-virtual": "^3.12.0", - "@vueuse/core": "^14.1.0", - "@vueuse/shared": "^14.1.0", - "aria-hidden": "^1.2.4", - "defu": "^6.1.5", - "ohash": "^2.0.11" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/zernonia" - }, - "peerDependencies": { - "vue": ">= 3.4.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/simple-keyboard": { - "version": "3.8.163", - "resolved": "https://registry.npmmirror.com/simple-keyboard/-/simple-keyboard-3.8.163.tgz", - "integrity": "sha512-nPneUpP8U4mY2sPM+B6P49vb2NIqPPEo3/9iqiw7ZS1LE4YtzmEHAXw0DFQ5/Aob12msgKiVkTfx7XYPDJyMLA==", - "license": "MIT" - }, - "node_modules/simple-yenc": { - "version": "1.0.4", - "resolved": "https://registry.npmmirror.com/simple-yenc/-/simple-yenc-1.0.4.tgz", - "integrity": "sha512-5gvxpSd79e9a3V4QDYUqnqxeD4HGlhCakVpb6gMnDD7lexJggSBJRBO5h52y/iJrdXRilX9UCuDaIJhSWm5OWw==", - "license": "MIT", - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/eshaz" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/speakingurl": { - "version": "14.0.1", - "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/superjson": { - "version": "2.2.6", - "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", - "license": "MIT", - "dependencies": { - "copy-anything": "^4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tailwind-merge": { - "version": "3.6.0", - "resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.6.0.tgz", - "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/tailwindcss": { - "version": "4.3.3", - "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.3.tgz", - "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tw-animate-css": { - "version": "1.4.0", - "resolved": "https://registry.npmmirror.com/tw-animate-css/-/tw-animate-css-1.4.0.tgz", - "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Wombosvideo" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uplot": { - "version": "1.6.32", - "resolved": "https://registry.npmmirror.com/uplot/-/uplot-1.6.32.tgz", - "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", - "license": "MIT" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vue": { - "version": "3.5.40", - "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.40.tgz", - "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.40", - "@vue/compiler-sfc": "3.5.40", - "@vue/runtime-dom": "3.5.40", - "@vue/server-renderer": "3.5.40", - "@vue/shared": "3.5.40" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vue-i18n": { - "version": "9.14.5", - "resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz", - "integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==", - "deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html", - "license": "MIT", - "dependencies": { - "@intlify/core-base": "9.14.5", - "@intlify/shared": "9.14.5", - "@vue/devtools-api": "^6.5.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - }, - "peerDependencies": { - "vue": "^3.0.0" - } - }, - "node_modules/vue-i18n/node_modules/@vue/devtools-api": { - "version": "6.6.4", - "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", - "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", - "license": "MIT" - }, - "node_modules/vue-router": { - "version": "4.6.4", - "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", - "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", - "license": "MIT", - "dependencies": { - "@vue/devtools-api": "^6.6.4" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "vue": "^3.5.0" - } - }, - "node_modules/vue-router/node_modules/@vue/devtools-api": { - "version": "6.6.4", - "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", - "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", - "license": "MIT" - }, - "node_modules/vue-sonner": { - "version": "2.0.9", - "resolved": "https://registry.npmmirror.com/vue-sonner/-/vue-sonner-2.0.9.tgz", - "integrity": "sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw==", - "license": "MIT", - "peerDependencies": { - "@nuxt/kit": "^4.0.3", - "@nuxt/schema": "^4.0.3", - "nuxt": "^4.0.3" - }, - "peerDependenciesMeta": { - "@nuxt/kit": { - "optional": true - }, - "@nuxt/schema": { - "optional": true - }, - "nuxt": { - "optional": true - } - } - }, - "node_modules/vue-tsc": { - "version": "3.3.7", - "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.7.tgz", - "integrity": "sha512-+C+rgD49wAQ5bUTl2sp5a8Bzg4YoldMNXM+g7CFe604MYcQ8PrZPMQhIjJSzKXtPBCa+C5ayMipqjbA7splekQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/typescript": "2.4.28", - "@vue/language-core": "3.3.7" - }, - "bin": { - "vue-tsc": "bin/vue-tsc.js" - }, - "peerDependencies": { - "typescript": ">=5.0.0" - } - } - } -} diff --git a/web/package.json b/web/package.json index 38c39bca..6de175de 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "0.2.5", + "version": "0.2.6", "type": "module", "scripts": { "dev": "vite", @@ -14,7 +14,6 @@ "clsx": "^2.1.1", "lucide-vue-next": "^0.556.0", "opus-decoder": "^0.7.11", - "opus-media-recorder": "^0.8.0", "pinia": "^3.0.4", "qrcode.vue": "^3.10.0", "reka-ui": "^2.10.1", diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 8e0fca9b..393217cc 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -115,7 +115,7 @@ export const otgNetworkApi = { status: () => request('/otg/network/status'), - interfaces: () => request('/devices/network'), + interfaces: () => request('/devices/network', {}, { toastOnError: false }), } export const uacApi = { diff --git a/web/src/api/index.ts b/web/src/api/index.ts index f795039d..a9d70165 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -1,4 +1,4 @@ -import { request, ApiError } from './request' +import { request, uploadRequest, ApiError } from './request' import type { CanonicalKey, Ch9329DescriptorState, @@ -10,6 +10,7 @@ import type { ComputerUseStartRequest, } from '@/types/generated' import { useHidWebSocket, type HidKeyboardEvent, type HidMouseEvent } from '@/composables/useHidWebSocket' +import type { StreamSignalReason, StreamState } from '@/types/websocket' const API_BASE = '/api' @@ -299,18 +300,8 @@ export interface VideoEncoderSelfCheckResponse { export const streamApi = { status: () => request<{ - state: - | 'uninitialized' - | 'ready' - | 'streaming' - | 'no_signal' - | 'no_cable' - | 'no_sync' - | 'out_of_range' - | 'device_lost' - | 'recovering' - | 'device_busy' - | 'error' + state: StreamState + reason?: StreamSignalReason | null device: string | null format: string | null resolution: [number, number] | null @@ -374,11 +365,12 @@ export const webrtcApi = { createSession: () => request<{ session_id: string }>('/webrtc/session', { method: 'POST' }), - offer: (sdp: string) => + offer: (sdp: string, signal?: AbortSignal) => request<{ sdp: string; session_id: string; ice_candidates: IceCandidate[] }>('/webrtc/offer', { method: 'POST', body: JSON.stringify({ sdp }), - }), + signal, + }, { toastOnError: false }), addIceCandidate: (sessionId: string, candidate: IceCandidate) => request<{ success: boolean }>('/webrtc/ice', { @@ -620,61 +612,42 @@ export const msdApi = { } | null usb_reenumerating: boolean } - }>('/msd/status'), + }>('/msd/status', {}, { toastOnError: false }), - listImages: () => request('/msd/images'), + listImages: () => request('/msd/images', {}, { toastOnError: false }), uploadImage: async (file: File, onProgress?: (progress: number) => void) => { const formData = new FormData() formData.append('file', file) - const xhr = new XMLHttpRequest() - xhr.open('POST', `${API_BASE}/msd/images`) - xhr.withCredentials = true - - return new Promise((resolve, reject) => { - xhr.upload.onprogress = (e) => { - if (e.lengthComputable && onProgress) { - onProgress((e.loaded / e.total) * 100) - } - } - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(JSON.parse(xhr.responseText)) - } else { - reject(new ApiError(xhr.status, 'Upload failed')) - } - } - - xhr.onerror = () => reject(new ApiError(0, 'Network error')) - xhr.send(formData) + return uploadRequest('/msd/images', formData, onProgress, { + errorTitleKey: 'msd.operations.uploadImage', }) }, deleteImage: (id: string) => - request<{ success: boolean }>(`/msd/images/${id}`, { method: 'DELETE' }), + request<{ success: boolean }>(`/msd/images/${id}`, { method: 'DELETE' }, { errorTitleKey: 'msd.operations.deleteImage' }), setDiskMode: (diskMode: DiskMode) => request<{ success: boolean }>('/msd/disk-mode', { method: 'PUT', body: JSON.stringify({ disk_mode: diskMode }), - }), + }, { errorTitleKey: 'msd.operations.changeMode' }), mountImage: (id: string, cdrom: boolean, readOnly: boolean) => request<{ success: boolean }>(`/msd/images/${id}/mount`, { method: 'POST', body: JSON.stringify({ cdrom, read_only: readOnly }), - }), + }, { errorTitleKey: 'msd.operations.mountImage' }), unmountImage: (id: string) => - request<{ success: boolean }>(`/msd/images/${id}/mount`, { method: 'DELETE' }), + request<{ success: boolean }>(`/msd/images/${id}/mount`, { method: 'DELETE' }, { errorTitleKey: 'msd.operations.unmountImage' }), mountDrive: () => - request<{ success: boolean }>('/msd/drive/mount', { method: 'POST' }), + request<{ success: boolean }>('/msd/drive/mount', { method: 'POST' }, { errorTitleKey: 'msd.operations.mountDrive' }), unmountDrive: () => - request<{ success: boolean }>('/msd/drive/mount', { method: 'DELETE' }), + request<{ success: boolean }>('/msd/drive/mount', { method: 'DELETE' }, { errorTitleKey: 'msd.operations.unmountDrive' }), driveInfo: () => request<{ @@ -696,11 +669,11 @@ export const msdApi = { method: 'POST', body: JSON.stringify({ size_mb: sizeMb }), }, - { toastOnError: false }, + { errorTitleKey: 'msd.operations.initializeDrive' }, ), deleteDrive: () => - request<{ success: boolean }>('/msd/drive', { method: 'DELETE' }), + request<{ success: boolean }>('/msd/drive', { method: 'DELETE' }, { errorTitleKey: 'msd.operations.deleteDrive' }), listDriveFiles: (path = '/') => request( @@ -713,28 +686,12 @@ export const msdApi = { const formData = new FormData() formData.append('file', file) - const xhr = new XMLHttpRequest() - xhr.open('POST', `${API_BASE}/msd/drive/files?path=${encodeURIComponent(targetPath)}`) - xhr.withCredentials = true - - return new Promise<{ success: boolean; message?: string }>((resolve, reject) => { - xhr.upload.onprogress = (e) => { - if (e.lengthComputable && onProgress) { - onProgress((e.loaded / e.total) * 100) - } - } - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(JSON.parse(xhr.responseText)) - } else { - reject(new ApiError(xhr.status, 'Upload failed')) - } - } - - xhr.onerror = () => reject(new ApiError(0, 'Network error')) - xhr.send(formData) - }) + return uploadRequest<{ success: boolean; message?: string }>( + `/msd/drive/files?path=${encodeURIComponent(targetPath)}`, + formData, + onProgress, + { errorTitleKey: 'msd.operations.uploadDriveFile' }, + ) }, downloadDriveFile: (path: string) => @@ -743,12 +700,12 @@ export const msdApi = { deleteDriveFile: (path: string) => request<{ success: boolean }>(`/msd/drive/files${encodeDrivePath(path)}`, { method: 'DELETE', - }), + }, { errorTitleKey: 'msd.operations.deleteDriveFile' }), createDirectory: (path: string) => request<{ success: boolean }>(`/msd/drive/mkdir${encodeDrivePath(path)}`, { method: 'POST', - }), + }, { errorTitleKey: 'msd.operations.createDirectory' }), downloadFromUrl: (url: string, filename?: string) => request<{ @@ -759,17 +716,17 @@ export const msdApi = { total_bytes: number | null progress_pct: number | null status: string - error: string | null + error_code: string | null }>('/msd/images/download', { method: 'POST', body: JSON.stringify({ url, filename }), - }), + }, { errorTitleKey: 'msd.operations.startDownload' }), cancelDownload: (downloadId: string) => request<{ success: boolean }>('/msd/images/download/cancel', { method: 'POST', body: JSON.stringify({ download_id: downloadId }), - }), + }, { errorTitleKey: 'msd.operations.cancelDownload' }), } interface SerialDeviceOption { @@ -777,6 +734,56 @@ interface SerialDeviceOption { name: string } +export type VideoControlMode = 'configurable' | 'source_following' +export type VideoInputState = 'locked' | 'no_signal' | 'unavailable' + +export interface VideoInputStatus { + state: VideoInputState + format: string | null + width: number | null + height: number | null + fps: number | null +} + +export interface VideoResolution { + width: number + height: number + fps: number[] +} + +export interface VideoFormat { + format: string + description: string + resolutions: VideoResolution[] +} + +export interface VideoDevice { + path: string + name: string + driver: string + formats: VideoFormat[] + usb_bus: string | null + has_signal: boolean + control_mode: VideoControlMode + input_status: VideoInputStatus +} + +export interface DeviceList { + video: VideoDevice[] + serial: Array<{ path: string; name: string }> + audio: Array<{ + name: string + description: string + is_hdmi: boolean + usb_bus: string | null + }> + udc: Array<{ name: string }> + extensions: { + ttyd_available: boolean + rustdesk_available: boolean + } +} + function encodeDrivePath(path: string): string { if (path === '' || path === '/') { return '/' @@ -809,42 +816,20 @@ function sortSerialDevices(serialDevices: SerialDeviceOption[]): SerialDeviceOpt export const configApi = { listDevices: async () => { - const result = await request<{ - video: Array<{ - path: string - name: string - driver: string - formats: Array<{ - format: string - description: string - resolutions: Array<{ - width: number - height: number - fps: number[] - }> - }> - usb_bus: string | null - has_signal: boolean - }> - serial: Array<{ path: string; name: string }> - audio: Array<{ - name: string - description: string - is_hdmi: boolean - usb_bus: string | null - }> - udc: Array<{ name: string }> - extensions: { - ttyd_available: boolean - rustdesk_available: boolean - } - }>('/devices') + const result = await request('/devices') return { ...result, serial: sortSerialDevices(result.serial), } }, + + getVideoInputStatus: (device: string) => + request( + `/video/input-status?device=${encodeURIComponent(device)}`, + {}, + { toastOnError: false }, + ), } export { diff --git a/web/src/api/request.ts b/web/src/api/request.ts index dd409398..f32fc44b 100644 --- a/web/src/api/request.ts +++ b/web/src/api/request.ts @@ -29,11 +29,13 @@ function hasTranslation(key: string): boolean { export class ApiError extends Error { status: number + code?: string - constructor(status: number, message: string) { + constructor(status: number, message: string, code?: string) { super(message) this.name = 'ApiError' this.status = status + this.code = code } } @@ -47,18 +49,69 @@ export interface ApiRequestConfig { * Toast debounce key. Defaults to `error_${endpoint}`. */ toastKey?: string + /** Translation key used as the error toast title. */ + errorTitleKey?: string } function getToastKey(endpoint: string, config?: ApiRequestConfig): string { return config?.toastKey ?? `error_${endpoint}` } -function getErrorMessage(data: unknown, fallback: string): string { +function isAuthenticationIssue(status: number, message: string): boolean { + const normalized = message.toLowerCase() + return status === 401 && ( + normalized.includes('not authenticated') + || normalized.includes('session expired') + || normalized.includes('logged in elsewhere') + ) +} + +const msdErrorKeys: Record = { + MSD_UNAVAILABLE: 'msd.errors.unavailable', + MSD_OPERATION_IN_PROGRESS: 'msd.errors.operationInProgress', + MSD_OPERATION_FAILED: 'msd.errors.operationFailed', + MSD_INVALID_REQUEST: 'msd.errors.invalidRequest', + MSD_RESOURCE_NOT_FOUND: 'msd.errors.resourceNotFound', + MSD_RESOURCE_ALREADY_EXISTS: 'msd.errors.resourceAlreadyExists', + MSD_MEDIA_SLOTS_FULL: 'msd.errors.mediaSlotsFull', + MSD_MEDIA_ALREADY_MOUNTED: 'msd.errors.mediaAlreadyMounted', + MSD_MEDIA_IN_USE: 'msd.errors.mediaInUse', + MSD_IMAGE_TOO_LARGE: 'msd.errors.imageTooLarge', + MSD_INVALID_URL: 'msd.errors.invalidUrl', + MSD_REMOTE_DOWNLOAD_FAILED: 'msd.errors.remoteDownloadFailed', + MSD_DOWNLOAD_INCOMPLETE: 'msd.errors.downloadIncomplete', + MSD_DRIVE_NOT_INITIALIZED: 'msd.errors.driveNotInitialized', + MSD_DRIVE_CONNECTED: 'msd.errors.driveConnected', + MSD_DRIVE_FILESYSTEM_UNSUPPORTED: 'msd.errors.driveFilesystemUnsupported', + MSD_DRIVE_SIZE_INVALID: 'msd.errors.driveSizeInvalid', + MSD_STORAGE_SPACE_UNAVAILABLE: 'msd.errors.storageSpaceUnavailable', + MSD_STORAGE_FULL: 'msd.errors.storageFull', + MSD_STORAGE_READ_ONLY: 'msd.errors.storageReadOnly', + MSD_STORAGE_PERMISSION_DENIED: 'msd.errors.storagePermissionDenied', + MSD_MEDIUM_REMOVAL_PREVENTED: 'msd.errors.mediumRemovalPrevented', + MSD_DISCONNECT_FAILED: 'msd.errors.disconnectFailed', +} + +export function localizeMsdErrorCode(code?: string, fallback?: string): string { + const key = code ? msdErrorKeys[code] : undefined + if (key && hasTranslation(key)) return t(key) + return fallback ? localizeBackendErrorMessage(fallback) : t('msd.errors.operationFailed') +} + +function getErrorDetails(data: unknown, fallback: string): { message: string; code?: string } { if (data && typeof data === 'object') { + const code = (data as any).code + const normalizedCode = typeof code === 'string' ? code : undefined + if (normalizedCode && msdErrorKeys[normalizedCode]) { + return { message: localizeMsdErrorCode(normalizedCode), code: normalizedCode } + } + const message = (data as any).message - if (typeof message === 'string' && message.trim()) return localizeBackendErrorMessage(message) + if (typeof message === 'string' && message.trim()) { + return { message: localizeBackendErrorMessage(message), code: normalizedCode } + } } - return localizeBackendErrorMessage(fallback) + return { message: localizeBackendErrorMessage(fallback) } } function extractCh9329Command(reason: string): string { @@ -133,6 +186,7 @@ export async function request( const url = `${API_BASE}${endpoint}` const toastOnError = config.toastOnError !== false const toastKey = getToastKey(endpoint, config) + const errorTitle = t(config.errorTitleKey ?? 'api.operationFailed') try { const response = await fetch(url, { @@ -148,40 +202,35 @@ export async function request( // Handle HTTP errors (in case backend returns non-2xx) if (!response.ok) { - const message = getErrorMessage(data, `HTTP ${response.status}`) - const normalized = message.toLowerCase() - const isNotAuthenticated = normalized.includes('not authenticated') - const isSessionExpired = normalized.includes('session expired') - const isLoggedInElsewhere = normalized.includes('logged in elsewhere') - const isAuthIssue = response.status === 401 && (isNotAuthenticated || isSessionExpired || isLoggedInElsewhere) - if (toastOnError && shouldShowToast(toastKey) && !isAuthIssue) { - toast.error(t('api.operationFailed'), { + const { message, code } = getErrorDetails(data, `HTTP ${response.status}`) + if (toastOnError && shouldShowToast(toastKey) && !isAuthenticationIssue(response.status, message)) { + toast.error(errorTitle, { description: message, duration: 4000, }) } - throw new ApiError(response.status, message) + throw new ApiError(response.status, message, code) } // Handle backend "success=false" convention (even when HTTP is 200) if (data && typeof (data as any).success === 'boolean' && !(data as any).success) { - const message = getErrorMessage(data, t('api.operationFailedDesc')) + const { message, code } = getErrorDetails(data, t('api.operationFailedDesc')) if (toastOnError && shouldShowToast(toastKey)) { - toast.error(t('api.operationFailed'), { + toast.error(errorTitle, { description: message, duration: 4000, }) } - throw new ApiError(response.status, message) + throw new ApiError(response.status, message, code) } // If response body isn't JSON (or empty), treat as failure for callers expecting JSON. if (data === null) { const message = t('api.parseResponseFailed') if (toastOnError && shouldShowToast(toastKey)) { - toast.error(t('api.operationFailed'), { + toast.error(errorTitle, { description: message, duration: 4000, }) @@ -203,3 +252,55 @@ export async function request( throw new ApiError(0, t('api.networkError')) } } + +export function uploadRequest( + endpoint: string, + formData: FormData, + onProgress?: (progress: number) => void, + config: ApiRequestConfig = {}, +): Promise { + const xhr = new XMLHttpRequest() + xhr.open('POST', `${API_BASE}${endpoint}`) + xhr.withCredentials = true + + return new Promise((resolve, reject) => { + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && onProgress) onProgress((event.loaded / event.total) * 100) + } + + xhr.onload = () => { + const data: unknown = (() => { + try { return JSON.parse(xhr.responseText) } catch { return null } + })() + if (xhr.status >= 200 && xhr.status < 300 && data !== null) { + resolve(data as T) + return + } + + const { message, code } = getErrorDetails(data, `HTTP ${xhr.status}`) + const error = new ApiError(xhr.status, message, code) + if ( + config.toastOnError !== false + && shouldShowToast(getToastKey(endpoint, config)) + && !isAuthenticationIssue(xhr.status, message) + ) { + toast.error(t(config.errorTitleKey ?? 'api.operationFailed'), { + description: message, + duration: 4000, + }) + } + reject(error) + } + + xhr.onerror = () => { + if (config.toastOnError !== false && shouldShowToast('network_error')) { + toast.error(t('api.networkError'), { + description: t('api.networkErrorDesc'), + duration: 4000, + }) + } + reject(new ApiError(0, t('api.networkError'))) + } + xhr.send(formData) + }) +} diff --git a/web/src/components/ActionBar.vue b/web/src/components/ActionBar.vue index 04b84371..ae82bebf 100644 --- a/web/src/components/ActionBar.vue +++ b/web/src/components/ActionBar.vue @@ -3,7 +3,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import { useSystemStore } from '@/stores/system' -import { getMicrophone } from '@/composables/useMicrophone' +import type { VideoScaleMode } from '@/composables/useVideoScaling' import { Button } from '@/components/ui/button' import { ButtonGroup } from '@/components/ui/button-group' import { @@ -32,11 +32,8 @@ import { } from '@/components/ui/sheet' import { ClipboardPaste, - Mic, HardDrive, - Keyboard, Settings, - Maximize, Power, BarChart3, Terminal, @@ -49,6 +46,7 @@ import VideoConfigPopover, { type VideoMode } from '@/components/VideoConfigPopo import HidConfigPopover from '@/components/HidConfigPopover.vue' import AudioConfigPopover from '@/components/AudioConfigPopover.vue' import MsdDialog from '@/components/MsdDialog.vue' +import VideoDisplayControls from '@/components/VideoDisplayControls.vue' const { t, locale } = useI18n() const router = useRouter() @@ -71,15 +69,17 @@ const props = defineProps<{ showComputerUse?: boolean showPasteText?: boolean showMic?: boolean + scaleMode?: VideoScaleMode + sourceSizeAvailable?: boolean }>() const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg') const showPasteText = computed(() => props.showPasteText !== false) const showMic = computed(() => props.showMic === true) -const mic = getMicrophone() const emit = defineEmits<{ (e: 'toggleFullscreen'): void + (e: 'update:scaleMode', mode: VideoScaleMode): void (e: 'toggleStats'): void (e: 'toggleVirtualKeyboard'): void (e: 'toggleMouseMode'): void @@ -133,7 +133,8 @@ const openMobilePaste = () => openFromOverflow(() => { const barRef = ref(null) const measureRef = ref(null) const barWidth = ref(0) -let resizeObserver: ResizeObserver | null = null +const alwaysRightWidth = ref(152) +let layoutResizeObserver: ResizeObserver | null = null type CollapsibleItem = | 'video' | 'audio' | 'hid' @@ -160,16 +161,18 @@ const ITEM_SPECS: ItemSpec[] = [ const measuredWidths = ref>(new Map()) const measurementReady = ref(false) -const measureButtonWidths = async () => { +const measureLayout = async () => { await nextTick() - if (!measureRef.value) return + const bar = barRef.value + const measureContainer = measureRef.value + if (!bar || !measureContainer) return + + barWidth.value = bar.clientWidth const newWidths = new Map() - for (const spec of ITEM_SPECS) { - const iconEl = measureRef.value.querySelector(`[data-measure="${spec.id}-icon"]`) as HTMLElement - const labelEl = measureRef.value.querySelector(`[data-measure="${spec.id}-label"]`) as HTMLElement - + const iconEl = measureContainer.querySelector(`[data-measure="${spec.id}-icon"]`) as HTMLElement + const labelEl = measureContainer.querySelector(`[data-measure="${spec.id}-label"]`) as HTMLElement if (iconEl && labelEl) { newWidths.set(spec.id, { icon: Math.ceil(iconEl.offsetWidth) + 8, @@ -177,31 +180,48 @@ const measureButtonWidths = async () => { }) } } - measuredWidths.value = newWidths + + const elements = Array.from(bar.querySelectorAll('[data-fixed-action]')) as HTMLElement[] + const width = elements.reduce((sum, element) => { + const style = window.getComputedStyle(element) + return sum + + element.getBoundingClientRect().width + + Number.parseFloat(style.marginLeft || '0') + + Number.parseFloat(style.marginRight || '0') + }, 0) + if (width > 0) alwaysRightWidth.value = Math.ceil(width) + measurementReady.value = true } +const observeLayout = async () => { + await measureLayout() + layoutResizeObserver?.disconnect() + layoutResizeObserver = new ResizeObserver(() => { + void measureLayout() + }) + if (barRef.value) layoutResizeObserver.observe(barRef.value) + barRef.value?.querySelectorAll('[data-fixed-action]').forEach((element) => { + layoutResizeObserver?.observe(element) + }) +} + onMounted(() => { - if (barRef.value) { - resizeObserver = new ResizeObserver((entries) => { - const entry = entries[0] - if (entry) barWidth.value = entry.contentRect.width - }) - resizeObserver.observe(barRef.value) - barWidth.value = barRef.value.clientWidth - } - - measureButtonWidths() + void observeLayout() }) -onUnmounted(() => { - resizeObserver?.disconnect() +onUnmounted(() => { + layoutResizeObserver?.disconnect() }) watch(locale, () => { measurementReady.value = false - measureButtonWidths() + void measureLayout() +}) + +watch(() => props.showComputerUse, () => { + void observeLayout() }) watch(showAtx, (visible) => { @@ -218,7 +238,7 @@ watch(showPasteText, (visible) => { } }) -const RIGHT_FIXED_PX = 120 +const OVERFLOW_BUTTON_BUDGET_PX = 36 const collapsibleItems = computed(() => { const items = ITEM_SPECS.slice(3).filter(item => { @@ -237,7 +257,7 @@ const visibleSet = computed(() => { return new Map() } - const available = barWidth.value - RIGHT_FIXED_PX + const available = barWidth.value - alwaysRightWidth.value - OVERFLOW_BUTTON_BUDGET_PX let used = 0 if (barRef.value) { @@ -280,6 +300,7 @@ const hasLeftOverflow = computed(() => { const hasRightOverflow = computed(() => { return collapsibleItems.value.some(i => i.side === 'right' && !visibleSet.value.has(i.id)) }) +