From 4530d8663d4e4852e8b0966a64ae87ae70ffd36b Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Fri, 7 Aug 2026 15:49:58 +0800 Subject: [PATCH 01/29] =?UTF-8?q?fix:=20=E5=B0=86=E7=94=B5=E6=BA=90?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=80=89=E9=A1=B9=E8=87=AA=E5=8A=A8=E9=9A=90?= =?UTF-8?q?=E8=97=8F=E4=BF=AE=E6=94=B9=E4=B8=BA=E6=89=8B=E5=8A=A8=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=EF=BC=8C=E9=81=BF=E5=85=8D=20WOL=20=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E6=98=BE=E7=A4=BA=20#289?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/ActionBar.vue | 4 ++-- web/src/composables/useFeatureVisibility.ts | 3 ++- web/src/views/ConsoleView.vue | 2 ++ web/src/views/SettingsView.vue | 21 ++++++++++++++------- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/web/src/components/ActionBar.vue b/web/src/components/ActionBar.vue index ae82bebf..c3869b26 100644 --- a/web/src/components/ActionBar.vue +++ b/web/src/components/ActionBar.vue @@ -59,12 +59,11 @@ const isCh9329Backend = computed(() => hidBackend.value.includes('ch9329')) const showMsd = computed(() => { return !!systemStore.msd?.available && !isCh9329Backend.value }) -const showAtx = computed(() => systemStore.atx?.available === true) - const props = defineProps<{ mouseMode?: 'absolute' | 'relative' videoMode?: VideoMode ttydRunning?: boolean + showPower?: boolean showTerminal?: boolean showComputerUse?: boolean showPasteText?: boolean @@ -72,6 +71,7 @@ const props = defineProps<{ scaleMode?: VideoScaleMode sourceSizeAvailable?: boolean }>() +const showAtx = computed(() => props.showPower !== false) const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg') const showPasteText = computed(() => props.showPasteText !== false) const showMic = computed(() => props.showMic === true) diff --git a/web/src/composables/useFeatureVisibility.ts b/web/src/composables/useFeatureVisibility.ts index ea968912..46ea379d 100644 --- a/web/src/composables/useFeatureVisibility.ts +++ b/web/src/composables/useFeatureVisibility.ts @@ -1,10 +1,11 @@ import { useLocalStorage } from '@vueuse/core' import type { RemovableRef } from '@vueuse/core' -export type FeatureVisibilityKey = 'webTerminal' | 'computerUse' | 'pasteText' +export type FeatureVisibilityKey = 'power' | 'webTerminal' | 'computerUse' | 'pasteText' export type FeatureVisibility = Record const DEFAULT_FEATURE_VISIBILITY: FeatureVisibility = { + power: true, webTerminal: true, computerUse: true, pasteText: true, diff --git a/web/src/views/ConsoleView.vue b/web/src/views/ConsoleView.vue index 3eaebd75..1714f7f9 100644 --- a/web/src/views/ConsoleView.vue +++ b/web/src/views/ConsoleView.vue @@ -237,6 +237,7 @@ const showTerminalDialog = ref(false) const featureVisibility = useFeatureVisibility() const { isDark, toggleTheme } = useTheme() const terminalAvailable = computed(() => ttydStatus.value?.available !== false) +const showPower = computed(() => featureVisibility.value.power) const showTerminal = computed(() => terminalAvailable.value && featureVisibility.value.webTerminal) const showComputerUse = computed(() => featureVisibility.value.computerUse) const showPasteText = computed(() => featureVisibility.value.pasteText) @@ -3273,6 +3274,7 @@ onUnmounted(() => { :mouse-mode="mouseMode" :video-mode="videoMode" :ttyd-running="ttydStatus?.running" + :show-power="showPower" :show-terminal="showTerminal" :show-computer-use="showComputerUse" :show-paste-text="showPasteText" diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 5bd5644d..5b252db9 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -2798,6 +2798,20 @@ watch(isWindows, () => { {{ t('settings.featureVisibilityDesc') }} +
+ + +
+
+ + +
-
- - -
From b8ea38b5f38afb5e763267af7ee7bfdf12f33058 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sat, 15 Aug 2026 21:26:34 +0800 Subject: [PATCH 02/29] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20s905=20?= =?UTF-8?q?=E6=99=B6=E6=99=A8=20H.264=20=E7=A1=AC=E4=BB=B6=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/video/codec/amlenc.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/video/codec/amlenc.rs b/src/video/codec/amlenc.rs index 99fa06b1..4ee9b0c5 100644 --- a/src/video/codec/amlenc.rs +++ b/src/video/codec/amlenc.rs @@ -643,10 +643,14 @@ pub fn has_parameter_sets(codec: AmlencCodec, data: &[u8]) -> bool { )] fn is_s912_gxm_compatible(compatible: &[u8]) -> bool { let compatible = String::from_utf8_lossy(compatible).to_ascii_lowercase(); + // s912 compatible.contains("amlogic,gxm") || compatible.contains("amlogic, gxm") || compatible.contains("amlogic,meson-gxm") || compatible.contains("amlogic,s912") + // s905d + || compatible.contains("amlogic,meson-gxl") + || compatible.contains("amlogic,s905d") } pub fn system_is_s912_gxm() -> Result { From 827d24fde43ae448c5f2fe4c7d2e662b247b782c Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Tue, 25 Aug 2026 14:58:09 +0800 Subject: [PATCH 03/29] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Amlogic=20?= =?UTF-8?q?=E7=A1=AC=E4=BB=B6=E7=BC=96=E7=A0=81=E7=9A=84=E5=B9=B6=E8=A1=8C?= =?UTF-8?q?=E8=A7=A3=E7=A0=81=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/video/pipeline/encoder_state.rs | 13 +++++++++---- src/video/pipeline/shared.rs | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/video/pipeline/encoder_state.rs b/src/video/pipeline/encoder_state.rs index dafbdc53..02e9a032 100644 --- a/src/video/pipeline/encoder_state.rs +++ b/src/video/pipeline/encoder_state.rs @@ -231,9 +231,9 @@ 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. +/// Amlogic hardware encoders 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!( @@ -248,7 +248,12 @@ pub(super) fn should_parallel_decode_mjpeg(config: &SharedVideoPipelineConfig) - 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) + selected.is_some_and(|encoder| { + matches!( + encoder.backend, + EncoderBackend::Amlogic | EncoderBackend::V4l2m2m + ) + }) } pub(super) fn build_encoder_state( diff --git a/src/video/pipeline/shared.rs b/src/video/pipeline/shared.rs index 81012293..8fb51921 100644 --- a/src/video/pipeline/shared.rs +++ b/src/video/pipeline/shared.rs @@ -636,7 +636,7 @@ impl SharedVideoPipeline { 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"); + info!("Using capture-thread libyuv MJPEG decode with parallel hardware encoding"); } let mut encoder_state = build_encoder_state(&encoder_config)?; let _ = self.running.send(true); From 37af369b4389c7709bedeb60d93c7e82b49b2d72 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Wed, 26 Aug 2026 10:53:08 +0800 Subject: [PATCH 04/29] =?UTF-8?q?refactor:=20=E7=AE=80=E5=8C=96=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E4=B8=8E=E6=9C=8D=E5=8A=A1=E5=8D=8F=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提取运行时生命周期管理,集中远程访问与 USB 协调,并缩小 Web 路由状态依赖。 --- src/lib.rs | 2 + src/main.rs | 832 +------------------------ src/runtime/builder.rs | 598 ++++++++++++++++++ src/runtime/config_apply.rs | 44 ++ src/runtime/mod.rs | 10 + src/runtime/remote_access.rs | 496 +++++++++++++++ src/runtime/supervisor.rs | 228 +++++++ src/runtime/usb.rs | 335 ++++++++++ src/state.rs | 70 +-- src/web/handlers/config/apply.rs | 721 +-------------------- src/web/handlers/config/hid.rs | 7 +- src/web/handlers/config/msd.rs | 7 +- src/web/handlers/config/otg.rs | 10 +- src/web/handlers/config/otg_network.rs | 12 +- src/web/handlers/config/rtsp.rs | 90 +-- src/web/handlers/config/rustdesk.rs | 110 ++-- src/web/handlers/config/stream.rs | 2 +- src/web/handlers/config/uac.rs | 8 +- src/web/handlers/config/usb_update.rs | 29 +- src/web/handlers/config/vnc.rs | 86 +-- src/web/handlers/mod.rs | 2 +- src/web/handlers/setup.rs | 32 +- src/web/mod.rs | 1 + src/web/state.rs | 55 ++ 24 files changed, 2016 insertions(+), 1771 deletions(-) create mode 100644 src/runtime/builder.rs create mode 100644 src/runtime/config_apply.rs create mode 100644 src/runtime/mod.rs create mode 100644 src/runtime/remote_access.rs create mode 100644 src/runtime/supervisor.rs create mode 100644 src/runtime/usb.rs create mode 100644 src/web/state.rs diff --git a/src/lib.rs b/src/lib.rs index d7e0509f..a8b8fa93 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,8 @@ pub mod redfish; #[cfg(feature = "desktop")] pub mod rtsp; #[cfg(feature = "desktop")] +pub mod runtime; +#[cfg(feature = "desktop")] pub mod rustdesk; #[cfg(feature = "desktop")] pub mod state; diff --git a/src/main.rs b/src/main.rs index e407bb39..cd64a246 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,43 +3,20 @@ use std::future::Future; use std::io::Write; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; -use std::sync::Arc; use axum_server::tls_rustls::RustlsConfig; use clap::{Args, Parser, Subcommand, ValueEnum}; use futures::{stream::FuturesUnordered, StreamExt}; use rustls::crypto::{ring, CryptoProvider}; -use tokio::sync::{broadcast, mpsc}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use one_kvm::atx::AtxController; -use one_kvm::audio::{AudioController, AudioControllerConfig, AudioQuality}; use one_kvm::auth::{SessionStore, TwoFactorService, UserStore}; -use one_kvm::computer_use::ComputerUseManager; -use one_kvm::config::{self, AppConfig, ConfigStore}; +use one_kvm::config; use one_kvm::db::DatabasePool; -use one_kvm::events::EventBus; -use one_kvm::extensions::ExtensionManager; -use one_kvm::hid::{HidBackendType, HidController}; -#[cfg(unix)] -use one_kvm::msd::MsdController; -#[cfg(unix)] -use one_kvm::otg::OtgService; use one_kvm::platform::PlatformCapabilities; -use one_kvm::rtsp::RtspService; -use one_kvm::rustdesk::RustDeskService; -use one_kvm::state::{AppState, ShutdownAction}; -use one_kvm::update::UpdateService; +use one_kvm::runtime::{RuntimeBuilder, WebConfigOverrides}; +use one_kvm::state::ShutdownAction; use one_kvm::utils::bind_tcp_listener; -use one_kvm::video::codec_constraints::{ - enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility, - StreamCodecConstraints, -}; -use one_kvm::video::format::{PixelFormat, Resolution}; -use one_kvm::video::{Streamer, VideoStreamManager}; -use one_kvm::vnc::VncService; -use one_kvm::web; -use one_kvm::webrtc::{WebRtcStreamer, WebRtcStreamerConfig}; #[derive(Debug, Clone, Copy, Default, ValueEnum)] enum LogLevel { @@ -147,28 +124,20 @@ async fn main() -> anyhow::Result<()> { return Ok(()); } - let (db, config_store, mut config) = load_runtime_config(&data_dir).await?; - - if let Some(addr) = args.address { - config.web.bind_address = addr.clone(); - config.web.bind_addresses = vec![addr]; - } - if let Some(port) = args.http_port { - config.web.http_port = port; - } - if let Some(port) = args.https_port { - config.web.https_port = port; - } - if args.enable_https { - config.web.https_enabled = true; - } - - if let Some(cert_path) = args.ssl_cert { - config.web.ssl_cert_path = Some(cert_path.to_string_lossy().to_string()); - } - if let Some(key_path) = args.ssl_key { - config.web.ssl_key_path = Some(key_path.to_string_lossy().to_string()); - } + let overrides = WebConfigOverrides { + address: args.address, + http_port: args.http_port, + https_port: args.https_port, + enable_https: args.enable_https, + ssl_cert: args.ssl_cert, + ssl_key: args.ssl_key, + }; + let mut runtime = RuntimeBuilder::new(data_dir.clone()) + .with_web_overrides(overrides) + .build() + .await?; + let config = runtime.config(); + let state = runtime.state().clone(); let bind_ips = resolve_bind_addresses(&config.web)?; let scheme = if config.web.https_enabled { @@ -187,500 +156,12 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Server will listen on: {}://{}", scheme, addr); } - let session_store = SessionStore::new(config.auth.session_timeout_secs as i64); - - let user_store = UserStore::new(db.clone_pool()); - let two_factor = TwoFactorService::new(db.clone_pool()); - - let (shutdown_tx, _) = broadcast::channel::(1); - - let events = Arc::new(EventBus::new()); - tracing::info!("Event bus initialized"); - - let (video_format, video_resolution) = parse_video_config(&config); - tracing::debug!( - "Parsed video config: {} @ {}x{}", - video_format, - video_resolution.width, - video_resolution.height - ); - - let streamer = Streamer::new(); - streamer.set_event_bus(events.clone()).await; - if let Some(ref device_path) = config.video.device { - if let Err(e) = streamer - .apply_video_config( - device_path, - video_format, - video_resolution, - config.video.fps, - ) - .await - { - tracing::warn!( - "Failed to initialize video with config: {}, will auto-detect", - e - ); - } else { - tracing::info!( - "Video configured: {} @ {}x{} {}", - device_path, - video_resolution.width, - video_resolution.height, - video_format - ); - } - } - - let webrtc_streamer = { - let webrtc_config = WebRtcStreamerConfig { - resolution: video_resolution, - input_format: video_format, - fps: config.video.fps, - bitrate_preset: config.stream.bitrate_preset, - encoder_backend: one_kvm::stream_encoder::encoder_type_to_backend( - config.stream.encoder.clone(), - ), - webrtc: { - let mut stun_servers = vec![]; - let mut turn_servers = vec![]; - - let has_custom_stun = config - .stream - .stun_server - .as_ref() - .map(|s| !s.is_empty()) - .unwrap_or(false); - let has_custom_turn = config - .stream - .turn_server - .as_ref() - .map(|s| !s.is_empty()) - .unwrap_or(false); - - if !has_custom_stun && !has_custom_turn { - use one_kvm::webrtc::config::public_ice; - let stun = public_ice::stun_server().to_string(); - tracing::info!("Using public STUN server: {}", stun); - stun_servers.push(stun); - } else { - if let Some(ref stun) = config.stream.stun_server { - if !stun.is_empty() { - stun_servers.push(stun.clone()); - tracing::info!("Using custom STUN server: {}", stun); - } - } - if let Some(ref turn) = config.stream.turn_server { - if !turn.is_empty() { - let username = config.stream.turn_username.clone().unwrap_or_default(); - let credential = - config.stream.turn_password.clone().unwrap_or_default(); - turn_servers.push(one_kvm::webrtc::config::TurnServer::new( - turn.clone(), - username.clone(), - credential, - )); - tracing::info!( - "Using custom TURN server: {} (user: {})", - turn, - username - ); - } - } - } - - one_kvm::webrtc::config::WebRtcConfig { - stun_servers, - turn_servers, - ..Default::default() - } - }, - ..Default::default() - }; - WebRtcStreamer::with_config(webrtc_config) - }; - tracing::info!("WebRTC streamer created"); - - #[cfg(unix)] - let otg_service = Arc::new(OtgService::new()); - #[cfg(unix)] - tracing::info!("OTG Service created"); - - #[cfg(unix)] - if let Err(e) = otg_service - .apply_config(&config.hid, &config.msd, &config.otg_network, &config.uac) - .await - { - tracing::warn!("Failed to apply OTG config: {}", e); - } - - let hid_backend = match config.hid.backend { - config::HidBackend::Otg => HidBackendType::Otg, - config::HidBackend::Ch9329 => HidBackendType::Ch9329 { - port: config.hid.ch9329_port.clone(), - baud_rate: config.hid.ch9329_baudrate, - hybrid_mouse: config.hid.ch9329_hybrid_mouse, - }, - config::HidBackend::None => HidBackendType::None, - }; - #[cfg(unix)] - let hid = Arc::new(HidController::new(hid_backend, Some(otg_service.clone()))); - #[cfg(not(unix))] - let hid = Arc::new(HidController::new(hid_backend)); - hid.set_event_bus(events.clone()).await; - if let Err(e) = hid.init().await { - tracing::warn!("Failed to initialize HID backend: {}", e); - } - - #[cfg(unix)] - let msd = if config.msd.enabled { - let ventoy_resource_dir = data_dir.join("ventoy"); - let controller = MsdController::new(otg_service.clone(), config.msd.msd_dir_path()); - if let Err(e) = controller.init(&ventoy_resource_dir).await { - tracing::warn!("Failed to initialize MSD controller: {}", e); - None - } else { - controller.set_event_bus(events.clone()).await; - Some(controller) - } - } else { - tracing::info!("MSD disabled in configuration"); - None - }; - - let atx = if config.atx.enabled { - let controller_config = config.atx.to_controller_config(); - let controller = AtxController::new(controller_config); - - if let Err(e) = controller.init().await { - tracing::warn!("Failed to initialize ATX controller: {}", e); - None - } else { - Some(controller) - } - } else { - tracing::info!("ATX disabled in configuration"); - None - }; - - let audio = { - let audio_config = AudioControllerConfig { - enabled: config.audio.enabled, - device: config.audio.device.clone(), - quality: match config.audio.quality.parse::() { - Ok(q) => q, - Err(e) => { - tracing::warn!( - "Invalid audio quality in config (value={:?}): {}, using balanced", - config.audio.quality, - e - ); - AudioQuality::Balanced - } - }, - }; - - let controller = AudioController::new(audio_config); - controller.set_event_bus(events.clone()).await; - - if config.audio.enabled { - tracing::info!( - "Audio enabled: {}, quality={}", - config.audio.device, - config.audio.quality - ); - if let Err(e) = controller.start_streaming().await { - tracing::warn!("Failed to start audio streaming: {}", e); - } - } else { - tracing::info!("Audio disabled in configuration"); - } - - Arc::new(controller) - }; - - let extensions = Arc::new(ExtensionManager::new()); - tracing::info!("Extension manager initialized"); - - webrtc_streamer.set_hid_controller(hid.clone()).await; - - webrtc_streamer.set_audio_controller(audio.clone()).await; - if config.audio.enabled { - if let Err(e) = webrtc_streamer.set_audio_enabled(true).await { - tracing::warn!("Failed to enable WebRTC audio: {}", e); - } else { - tracing::debug!("WebRTC audio enabled"); - } - } - - let (device_path, actual_resolution, actual_format, actual_fps, jpeg_quality) = - streamer.current_capture_config().await; - tracing::debug!( - "Initial video config: {}x{} {:?} @ {}fps", - actual_resolution.width, - actual_resolution.height, - actual_format, - actual_fps - ); - webrtc_streamer - .update_video_config(actual_resolution, actual_format, actual_fps) - .await; - if let Some(device_path) = device_path { - let device_info = streamer.current_device().await; - webrtc_streamer - .set_capture_device(device_path, jpeg_quality, device_info) - .await; - tracing::debug!("WebRTC streamer configured for direct capture"); - } else { - tracing::warn!("No capture device configured for WebRTC"); - } - - let stream_manager = VideoStreamManager::with_webrtc_streamer( - streamer.clone(), - webrtc_streamer.clone() as std::sync::Arc, - ); - stream_manager.set_event_bus(events.clone()).await; - stream_manager.set_config_store(config_store.clone()).await; - { - let stream_manager_weak = Arc::downgrade(&stream_manager); - audio - .set_recovered_callback(Arc::new(move || { - if let Some(stream_manager) = stream_manager_weak.upgrade() { - tokio::spawn(async move { - stream_manager.reconnect_webrtc_audio_sources().await; - }); - } - })) - .await; - } - - let initial_mode = config.stream.mode.clone(); - if let Err(e) = stream_manager.init_with_mode(initial_mode.clone()).await { - tracing::warn!( - "Failed to initialize stream manager with mode {:?}: {}", - initial_mode, - e - ); - } else { - tracing::info!( - "Video stream manager initialized with mode: {:?}", - initial_mode - ); - } - - let third_party_codec_config_valid = match validate_third_party_codec_compatibility(&config) { - Ok(()) => true, - Err(e) => { - tracing::warn!( - "Third-party access codec configuration is invalid; RustDesk/VNC/RTSP will not start: {}", - e - ); - false - } - }; - - let rustdesk = if third_party_codec_config_valid && config.rustdesk.is_valid() { - tracing::info!( - "Initializing RustDesk service: ID={} -> {}", - config.rustdesk.device_id, - config.rustdesk.rendezvous_addr() - ); - let service = RustDeskService::new( - config.rustdesk.clone(), - stream_manager.clone(), - hid.clone(), - audio.clone(), - ); - Some(Arc::new(service)) - } else { - if config.rustdesk.enabled { - tracing::warn!( - "RustDesk enabled but configuration is incomplete (missing server or credentials)" - ); - } else { - tracing::info!("RustDesk disabled in configuration"); - } - None - }; - - let rtsp = if third_party_codec_config_valid && config.rtsp.enabled { - tracing::info!( - "Initializing RTSP service: rtsp://{}:{}/{}", - config.rtsp.bind, - config.rtsp.port, - config.rtsp.path - ); - let service = RtspService::new(config.rtsp.clone(), stream_manager.clone()); - Some(Arc::new(service)) - } else { - tracing::info!("RTSP disabled in configuration"); - None - }; - - let vnc = if third_party_codec_config_valid && config.vnc.enabled { - tracing::info!( - "Initializing VNC service: {}:{} ({:?})", - config.vnc.bind, - config.vnc.port, - config.vnc.encoding - ); - Some(Arc::new(VncService::new( - config.vnc.clone(), - stream_manager.clone(), - hid.clone(), - ))) - } else { - tracing::info!("VNC disabled in configuration"); - None - }; - - let update_service = Arc::new(UpdateService::new()); - let computer_use = ComputerUseManager::new(config_store.clone(), hid.clone()); - - let state = AppState::new( - db.clone(), - config_store.clone(), - session_store, - user_store, - two_factor, - #[cfg(unix)] - otg_service, - stream_manager, - webrtc_streamer.clone(), - hid, - computer_use, - #[cfg(unix)] - msd, - atx, - audio, - rustdesk.clone(), - vnc.clone(), - rtsp.clone(), - extensions.clone(), - events.clone(), - update_service, - shutdown_tx.clone(), - data_dir.clone(), - ); - - #[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); - } - } - } - } - - if config.watchdog.enabled { - if let Err(error) = state.watchdog.enable().await { - tracing::error!( - "Configured hardware watchdog failed to start; web service will continue: {}", - error - ); - } else { - tracing::info!("Hardware watchdog started"); - } - } - - extensions.set_event_bus(events.clone()).await; - - if let Some(ref service) = rustdesk { - if let Err(e) = service.start().await { - tracing::error!("Failed to start RustDesk service: {}", e); - } else { - if let Some(updated_config) = service.save_credentials() { - if let Err(e) = config_store - .update(|cfg| { - cfg.rustdesk.public_key = updated_config.public_key.clone(); - cfg.rustdesk.private_key = updated_config.private_key.clone(); - cfg.rustdesk.signing_public_key = updated_config.signing_public_key.clone(); - cfg.rustdesk.signing_private_key = - updated_config.signing_private_key.clone(); - cfg.rustdesk.uuid = updated_config.uuid.clone(); - }) - .await - { - tracing::warn!("Failed to save RustDesk credentials: {}", e); - } - } - tracing::info!("RustDesk service started"); - } - } - if let Some(ref service) = vnc { - if let Err(e) = service.start().await { - tracing::error!("Failed to start VNC service: {}", e); - } else { - tracing::info!("VNC service started"); - } - } - - if let Some(ref service) = rtsp { - if let Err(e) = service.start().await { - tracing::error!("Failed to start RTSP service: {}", e); - } else { - tracing::info!("RTSP service started"); - } - } - - { - let runtime_config = state.runtime_third_party_config().await; - let constraints = StreamCodecConstraints::from_config(&runtime_config); - state - .stream_manager - .set_runtime_codec_constraints(constraints.clone()) - .await; - match enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await { - Ok(result) if result.changed => { - if let Some(message) = result.message { - tracing::info!("{}", message); - } - } - Ok(_) => {} - Err(e) => tracing::warn!("Failed to enforce startup codec constraints: {}", e), - } - } - - { - let ext_config = config_store.get(); - extensions.start_enabled(&ext_config.extensions).await; - } - - { - let extensions_clone = extensions.clone(); - let config_store_clone = config_store.clone(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); - loop { - interval.tick().await; - let config = config_store_clone.get(); - extensions_clone.health_check(&config.extensions).await; - } - }); - tracing::info!("Extension health check task started"); - } - - state.publish_device_info().await; - - spawn_device_info_broadcaster(state.clone(), events); - - let app = web::create_router(state.clone()); + let app = runtime.router(); let listeners = bind_tcp_listeners(&bind_ips, bind_port)?; let shutdown_signal = { + let shutdown_tx = state.shutdown_tx.clone(); let mut shutdown_rx = shutdown_tx.subscribe(); async move { tokio::select! { @@ -740,7 +221,7 @@ async fn main() -> anyhow::Result<()> { servers.push(server); } - run_servers_until_shutdown(servers, shutdown_signal, &state, "HTTPS").await + run_servers_until_shutdown(servers, shutdown_signal, "HTTPS").await } else { let servers = FuturesUnordered::new(); for listener in listeners { @@ -752,9 +233,10 @@ async fn main() -> anyhow::Result<()> { servers.push(async move { server.await }); } - run_servers_until_shutdown(servers, shutdown_signal, &state, "HTTP").await + run_servers_until_shutdown(servers, shutdown_signal, "HTTP").await }; + runtime.shutdown().await; tracing::info!("Server shutdown complete"); if let ShutdownAction::Restart { exe_path } = shutdown_action { restart_current_process(exe_path)?; @@ -840,14 +322,13 @@ async fn open_database_pool(data_dir: &Path) -> anyhow::Result { async fn run_servers_until_shutdown( mut servers: FuturesUnordered, shutdown_signal: impl Future, - state: &Arc, protocol: &'static str, ) -> ShutdownAction where F: Future> + Send, E: std::fmt::Display, { - let action = tokio::select! { + tokio::select! { action = shutdown_signal => { action } @@ -857,9 +338,7 @@ where } ShutdownAction::Exit } - }; - cleanup(state).await; - action + } } fn restart_current_process(exe_path: Option) -> anyhow::Result<()> { @@ -896,64 +375,6 @@ async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Resu } } -async fn load_runtime_config( - data_dir: &Path, -) -> anyhow::Result<(DatabasePool, ConfigStore, AppConfig)> { - tokio::fs::create_dir_all(data_dir).await?; - - let db = open_database_pool(data_dir).await?; - let config_store = ConfigStore::new(db.clone_pool())?; - config_store.load().await?; - let mut config = (*config_store.get()).clone(); - config.apply_platform_defaults(); - - prepare_linux_runtime_dirs(data_dir, &config_store, &mut config).await?; - - Ok((db, config_store, config)) -} - -#[cfg(unix)] -async fn prepare_linux_runtime_dirs( - data_dir: &Path, - config_store: &ConfigStore, - config: &mut AppConfig, -) -> anyhow::Result<()> { - let mut msd_dir_updated = false; - if config.msd.msd_dir.trim().is_empty() { - let msd_dir = data_dir.join("msd"); - config.msd.msd_dir = msd_dir.to_string_lossy().to_string(); - msd_dir_updated = true; - } else if !PathBuf::from(&config.msd.msd_dir).is_absolute() { - let msd_dir = data_dir.join(&config.msd.msd_dir); - tracing::warn!( - "MSD directory is relative, rebasing to {}", - msd_dir.display() - ); - config.msd.msd_dir = msd_dir.to_string_lossy().to_string(); - msd_dir_updated = true; - } - if msd_dir_updated { - config_store.set(config.clone()).await?; - } - let msd_dir = PathBuf::from(&config.msd.msd_dir); - if let Err(e) = tokio::fs::create_dir_all(msd_dir.join("images")).await { - tracing::warn!("Failed to create MSD images directory: {}", e); - } - if let Err(e) = tokio::fs::create_dir_all(msd_dir.join("ventoy")).await { - tracing::warn!("Failed to create MSD ventoy directory: {}", e); - } - Ok(()) -} - -#[cfg(not(unix))] -async fn prepare_linux_runtime_dirs( - _data_dir: &Path, - _config_store: &ConfigStore, - _config: &mut AppConfig, -) -> anyhow::Result<()> { - Ok(()) -} - async fn run_user_action( action: UserAction, users: &UserStore, @@ -1065,17 +486,6 @@ fn bind_tcp_listeners(addrs: &[IpAddr], port: u16) -> anyhow::Result (PixelFormat, Resolution) { - let format = config - .video - .format - .as_ref() - .and_then(|f: &String| f.parse::().ok()) - .unwrap_or(PixelFormat::Mjpeg); - let resolution = Resolution::new(config.video.width, config.video.height); - (format, resolution) -} - fn generate_self_signed_cert() -> anyhow::Result> { use rcgen::generate_simple_self_signed; @@ -1088,197 +498,3 @@ fn generate_self_signed_cert() -> anyhow::Result, events: Arc) { - use std::time::{Duration, Instant}; - - enum DeviceInfoTrigger { - Event, - Lagged { topic: &'static str, count: u64 }, - } - - const DEVICE_INFO_TOPICS: &[&str] = &[ - "stream.state_changed", - "stream.config_applied", - "stream.mode_ready", - ]; - const DEBOUNCE_MS: u64 = 100; - - let (trigger_tx, mut trigger_rx) = mpsc::unbounded_channel(); - - for topic in DEVICE_INFO_TOPICS { - let Some(mut rx) = events.subscribe_topic(topic) else { - tracing::warn!( - "DeviceInfo broadcaster missing topic subscription: {}", - topic - ); - continue; - }; - - let trigger_tx = trigger_tx.clone(); - let topic_name = *topic; - tokio::spawn(async move { - loop { - match rx.recv().await { - Ok(_) => { - if trigger_tx.send(DeviceInfoTrigger::Event).is_err() { - break; - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { - if trigger_tx - .send(DeviceInfoTrigger::Lagged { - topic: topic_name, - count, - }) - .is_err() - { - break; - } - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - }); - } - - { - let mut dirty_rx = events.subscribe_device_info_dirty(); - let trigger_tx = trigger_tx.clone(); - tokio::spawn(async move { - loop { - match dirty_rx.recv().await { - Ok(()) => { - if trigger_tx.send(DeviceInfoTrigger::Event).is_err() { - break; - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { - if trigger_tx - .send(DeviceInfoTrigger::Lagged { - topic: "device_info_dirty", - count, - }) - .is_err() - { - break; - } - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - }); - } - - tokio::spawn(async move { - let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS); - let mut pending_broadcast = false; - - loop { - let recv_result = if pending_broadcast { - let remaining = - DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64); - tokio::time::timeout(Duration::from_millis(remaining), trigger_rx.recv()).await - } else { - Ok(trigger_rx.recv().await) - }; - - match recv_result { - Ok(Some(DeviceInfoTrigger::Event)) => { - pending_broadcast = true; - } - Ok(Some(DeviceInfoTrigger::Lagged { topic, count })) => { - tracing::warn!( - "DeviceInfo broadcaster lagged by {} events on topic {}", - count, - topic - ); - pending_broadcast = true; - } - Ok(None) => { - tracing::info!("Event bus closed, stopping DeviceInfo broadcaster"); - break; - } - Err(_timeout) => {} - } - - if pending_broadcast && last_broadcast.elapsed() >= Duration::from_millis(DEBOUNCE_MS) { - state.publish_device_info().await; - tracing::trace!("Broadcasted DeviceInfo (debounced)"); - last_broadcast = Instant::now(); - pending_broadcast = false; - } - } - }); - - tracing::info!( - "DeviceInfo broadcaster task started (debounce: {}ms)", - DEBOUNCE_MS - ); -} - -async fn cleanup(state: &Arc) { - state.extensions.stop_all().await; - tracing::info!("Extensions stopped"); - - if let Some(ref service) = *state.rustdesk.read().await { - if let Err(e) = service.stop().await { - tracing::warn!("Failed to stop RustDesk service: {}", e); - } else { - tracing::info!("RustDesk service stopped"); - } - } - - if let Some(ref service) = *state.vnc.read().await { - if let Err(e) = service.stop().await { - tracing::warn!("Failed to stop VNC service: {}", e); - } else { - tracing::info!("VNC service stopped"); - } - } - - if let Some(ref service) = *state.rtsp.read().await { - if let Err(e) = service.stop().await { - tracing::warn!("Failed to stop RTSP service: {}", e); - } else { - tracing::info!("RTSP service stopped"); - } - } - - if let Err(e) = state.stream_manager.stop().await { - tracing::warn!("Failed to stop streamer: {}", e); - } - - if let Err(e) = state.hid.shutdown().await { - tracing::warn!("Failed to shutdown HID: {}", e); - } - - #[cfg(unix)] - if let Some(msd) = state.msd.write().await.as_mut() { - if let Err(e) = msd.shutdown().await { - tracing::warn!("Failed to shutdown MSD: {}", e); - } - } - - #[cfg(unix)] - if let Err(e) = state.otg_service.shutdown().await { - tracing::warn!("Failed to shutdown OTG: {}", e); - } - - if let Some(atx) = state.atx.write().await.as_mut() { - if let Err(e) = atx.shutdown().await { - tracing::warn!("Failed to shutdown ATX: {}", e); - } - } - - if let Err(e) = state.audio.shutdown().await { - tracing::warn!("Failed to shutdown audio: {}", e); - } - - if let Err(error) = state.watchdog.disable().await { - tracing::error!( - "CRITICAL: failed to disable hardware watchdog during shutdown: {}", - error - ); - } -} diff --git a/src/runtime/builder.rs b/src/runtime/builder.rs new file mode 100644 index 00000000..d576adf2 --- /dev/null +++ b/src/runtime/builder.rs @@ -0,0 +1,598 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use tokio::sync::broadcast; + +use crate::atx::AtxController; +use crate::audio::{AudioController, AudioControllerConfig, AudioQuality}; +use crate::auth::{SessionStore, TwoFactorService, UserStore}; +use crate::computer_use::ComputerUseManager; +use crate::config::{self, AppConfig, ConfigStore}; +use crate::db::DatabasePool; +use crate::events::EventBus; +use crate::extensions::ExtensionManager; +use crate::hid::{HidBackendType, HidController}; +#[cfg(unix)] +use crate::msd::MsdController; +#[cfg(unix)] +use crate::otg::OtgService; +use crate::state::{AppState, ShutdownAction}; +use crate::update::UpdateService; +use crate::video::format::{PixelFormat, Resolution}; +use crate::video::{Streamer, VideoStreamManager}; +use crate::webrtc::{WebRtcStreamer, WebRtcStreamerConfig}; + +use super::supervisor::RuntimeSupervisor; + +#[derive(Debug, Clone, Default)] +pub struct WebConfigOverrides { + pub address: Option, + pub http_port: Option, + pub https_port: Option, + pub enable_https: bool, + pub ssl_cert: Option, + pub ssl_key: Option, +} + +impl WebConfigOverrides { + fn apply(self, config: &mut AppConfig) { + if let Some(address) = self.address { + config.web.bind_address = address.clone(); + config.web.bind_addresses = vec![address]; + } + if let Some(port) = self.http_port { + config.web.http_port = port; + } + if let Some(port) = self.https_port { + config.web.https_port = port; + } + if self.enable_https { + config.web.https_enabled = true; + } + if let Some(path) = self.ssl_cert { + config.web.ssl_cert_path = Some(path.to_string_lossy().to_string()); + } + if let Some(path) = self.ssl_key { + config.web.ssl_key_path = Some(path.to_string_lossy().to_string()); + } + } +} + +pub struct RuntimeBuilder { + data_dir: PathBuf, + web_overrides: WebConfigOverrides, +} + +impl RuntimeBuilder { + pub fn new(data_dir: PathBuf) -> Self { + Self { + data_dir, + web_overrides: WebConfigOverrides::default(), + } + } + + pub fn with_web_overrides(mut self, overrides: WebConfigOverrides) -> Self { + self.web_overrides = overrides; + self + } + + pub async fn build(self) -> anyhow::Result { + let Self { + data_dir, + web_overrides, + } = self; + let (db, config_store, mut config) = load_runtime_config(&data_dir).await?; + web_overrides.apply(&mut config); + + let sessions = SessionStore::new(config.auth.session_timeout_secs as i64); + let users = UserStore::new(db.clone_pool()); + let two_factor = TwoFactorService::new(db.clone_pool()); + let (shutdown_tx, _) = broadcast::channel::(1); + + let events = Arc::new(EventBus::new()); + tracing::info!("Event bus initialized"); + + let (video_format, video_resolution) = parse_video_config(&config); + let streamer = build_streamer(&config, &events, video_format, video_resolution).await; + let webrtc = build_webrtc(&config, video_format, video_resolution); + + #[cfg(unix)] + let otg_service = build_otg(&config).await; + + let hid_backend = hid_backend_type(&config); + #[cfg(unix)] + let hid = Arc::new(HidController::new(hid_backend, Some(otg_service.clone()))); + #[cfg(not(unix))] + let hid = Arc::new(HidController::new(hid_backend)); + hid.set_event_bus(events.clone()).await; + if let Err(error) = hid.init().await { + tracing::warn!("Failed to initialize HID backend: {}", error); + } + + #[cfg(unix)] + let msd = build_msd(&config, &data_dir, &otg_service, &events).await; + let atx = build_atx(&config).await; + let audio = build_audio(&config, &events).await; + let extensions = Arc::new(ExtensionManager::new()); + tracing::info!("Extension manager initialized"); + + webrtc.set_hid_controller(hid.clone()).await; + webrtc.set_audio_controller(audio.clone()).await; + if config.audio.enabled { + if let Err(error) = webrtc.set_audio_enabled(true).await { + tracing::warn!("Failed to enable WebRTC audio: {}", error); + } else { + tracing::debug!("WebRTC audio enabled"); + } + } + + connect_capture_to_webrtc(&streamer, &webrtc).await; + + let stream_manager = VideoStreamManager::with_webrtc_streamer( + streamer.clone(), + webrtc.clone() as Arc, + ); + stream_manager.set_event_bus(events.clone()).await; + stream_manager.set_config_store(config_store.clone()).await; + connect_audio_recovery(&audio, &stream_manager).await; + + let initial_mode = config.stream.mode.clone(); + if let Err(error) = stream_manager.init_with_mode(initial_mode.clone()).await { + tracing::warn!( + "Failed to initialize stream manager with mode {:?}: {}", + initial_mode, + error + ); + } else { + tracing::info!( + "Video stream manager initialized with mode: {:?}", + initial_mode + ); + } + + let computer_use = ComputerUseManager::new(config_store.clone(), hid.clone()); + let state = AppState::new( + db, + config_store.clone(), + sessions, + users, + two_factor, + #[cfg(unix)] + otg_service, + stream_manager, + webrtc, + hid, + computer_use, + #[cfg(unix)] + msd, + atx, + audio, + extensions.clone(), + events.clone(), + Arc::new(UpdateService::new()), + shutdown_tx, + data_dir.clone(), + ); + + start_uac_playback(&state, &config).await; + start_watchdog(&state, &config).await; + extensions.set_event_bus(events.clone()).await; + state.remote_access.start_configured(&config).await; + + let extension_config = config_store.get(); + extensions.start_enabled(&extension_config.extensions).await; + + state.publish_device_info().await; + let supervisor = RuntimeSupervisor::start(state.clone(), events, extensions, config_store); + + Ok(ApplicationRuntime { + state, + config, + data_dir, + supervisor, + }) + } +} + +pub struct ApplicationRuntime { + state: Arc, + config: AppConfig, + data_dir: PathBuf, + supervisor: RuntimeSupervisor, +} + +impl ApplicationRuntime { + pub fn state(&self) -> &Arc { + &self.state + } + + pub fn config(&self) -> &AppConfig { + &self.config + } + + pub fn data_dir(&self) -> &Path { + &self.data_dir + } + + pub fn router(&self) -> axum::Router { + crate::web::create_router(self.state.clone()) + } + + pub async fn shutdown(&mut self) { + self.supervisor.shutdown(&self.state).await; + } +} + +async fn load_runtime_config( + data_dir: &Path, +) -> anyhow::Result<(DatabasePool, ConfigStore, AppConfig)> { + tokio::fs::create_dir_all(data_dir).await?; + + let db_path = data_dir.join("one-kvm.db"); + let db = DatabasePool::new(&db_path).await?; + db.init_schema().await?; + + let config_store = ConfigStore::new(db.clone_pool())?; + config_store.load().await?; + let mut config = (*config_store.get()).clone(); + config.apply_platform_defaults(); + prepare_linux_runtime_dirs(data_dir, &config_store, &mut config).await?; + + Ok((db, config_store, config)) +} + +#[cfg(unix)] +async fn prepare_linux_runtime_dirs( + data_dir: &Path, + config_store: &ConfigStore, + config: &mut AppConfig, +) -> anyhow::Result<()> { + let mut msd_dir_updated = false; + if config.msd.msd_dir.trim().is_empty() { + config.msd.msd_dir = data_dir.join("msd").to_string_lossy().to_string(); + msd_dir_updated = true; + } else if !PathBuf::from(&config.msd.msd_dir).is_absolute() { + let msd_dir = data_dir.join(&config.msd.msd_dir); + tracing::warn!( + "MSD directory is relative, rebasing to {}", + msd_dir.display() + ); + config.msd.msd_dir = msd_dir.to_string_lossy().to_string(); + msd_dir_updated = true; + } + if msd_dir_updated { + config_store.set(config.clone()).await?; + } + + let msd_dir = PathBuf::from(&config.msd.msd_dir); + if let Err(error) = tokio::fs::create_dir_all(msd_dir.join("images")).await { + tracing::warn!("Failed to create MSD images directory: {}", error); + } + if let Err(error) = tokio::fs::create_dir_all(msd_dir.join("ventoy")).await { + tracing::warn!("Failed to create MSD ventoy directory: {}", error); + } + Ok(()) +} + +#[cfg(not(unix))] +async fn prepare_linux_runtime_dirs( + _data_dir: &Path, + _config_store: &ConfigStore, + _config: &mut AppConfig, +) -> anyhow::Result<()> { + Ok(()) +} + +fn parse_video_config(config: &AppConfig) -> (PixelFormat, Resolution) { + let format = config + .video + .format + .as_ref() + .and_then(|format| format.parse::().ok()) + .unwrap_or(PixelFormat::Mjpeg); + ( + format, + Resolution::new(config.video.width, config.video.height), + ) +} + +async fn build_streamer( + config: &AppConfig, + events: &Arc, + format: PixelFormat, + resolution: Resolution, +) -> Arc { + tracing::debug!( + "Parsed video config: {} @ {}x{}", + format, + resolution.width, + resolution.height + ); + let streamer = Streamer::new(); + streamer.set_event_bus(events.clone()).await; + if let Some(device_path) = config.video.device.as_ref() { + if let Err(error) = streamer + .apply_video_config(device_path, format, resolution, config.video.fps) + .await + { + tracing::warn!( + "Failed to initialize video with config: {}, will auto-detect", + error + ); + } else { + tracing::info!( + "Video configured: {} @ {}x{} {}", + device_path, + resolution.width, + resolution.height, + format + ); + } + } + streamer +} + +fn build_webrtc( + config: &AppConfig, + input_format: PixelFormat, + resolution: Resolution, +) -> Arc { + let webrtc = WebRtcStreamer::with_config(WebRtcStreamerConfig { + resolution, + input_format, + fps: config.video.fps, + bitrate_preset: config.stream.bitrate_preset, + encoder_backend: crate::stream_encoder::encoder_type_to_backend( + config.stream.encoder.clone(), + ), + webrtc: build_ice_config(config), + ..Default::default() + }); + tracing::info!("WebRTC streamer created"); + webrtc +} + +fn build_ice_config(config: &AppConfig) -> crate::webrtc::config::WebRtcConfig { + let mut stun_servers = Vec::new(); + let mut turn_servers = Vec::new(); + let has_custom_stun = config + .stream + .stun_server + .as_ref() + .is_some_and(|server| !server.is_empty()); + let has_custom_turn = config + .stream + .turn_server + .as_ref() + .is_some_and(|server| !server.is_empty()); + + if !has_custom_stun && !has_custom_turn { + let stun = crate::webrtc::config::public_ice::stun_server().to_string(); + tracing::info!("Using public STUN server: {}", stun); + stun_servers.push(stun); + } else { + if let Some(stun) = config + .stream + .stun_server + .as_ref() + .filter(|server| !server.is_empty()) + { + tracing::info!("Using custom STUN server: {}", stun); + stun_servers.push(stun.clone()); + } + if let Some(turn) = config + .stream + .turn_server + .as_ref() + .filter(|server| !server.is_empty()) + { + let username = config.stream.turn_username.clone().unwrap_or_default(); + let credential = config.stream.turn_password.clone().unwrap_or_default(); + turn_servers.push(crate::webrtc::config::TurnServer::new( + turn.clone(), + username.clone(), + credential, + )); + tracing::info!("Using custom TURN server: {} (user: {})", turn, username); + } + } + + crate::webrtc::config::WebRtcConfig { + stun_servers, + turn_servers, + ..Default::default() + } +} + +#[cfg(unix)] +async fn build_otg(config: &AppConfig) -> Arc { + let service = Arc::new(OtgService::new()); + tracing::info!("OTG Service created"); + if let Err(error) = service + .apply_config(&config.hid, &config.msd, &config.otg_network, &config.uac) + .await + { + tracing::warn!("Failed to apply OTG config: {}", error); + } + service +} + +fn hid_backend_type(config: &AppConfig) -> HidBackendType { + match config.hid.backend { + config::HidBackend::Otg => HidBackendType::Otg, + config::HidBackend::Ch9329 => HidBackendType::Ch9329 { + port: config.hid.ch9329_port.clone(), + baud_rate: config.hid.ch9329_baudrate, + hybrid_mouse: config.hid.ch9329_hybrid_mouse, + }, + config::HidBackend::None => HidBackendType::None, + } +} + +#[cfg(unix)] +async fn build_msd( + config: &AppConfig, + data_dir: &Path, + otg: &Arc, + events: &Arc, +) -> Option { + if !config.msd.enabled { + tracing::info!("MSD disabled in configuration"); + return None; + } + + let controller = MsdController::new(otg.clone(), config.msd.msd_dir_path()); + if let Err(error) = controller.init(&data_dir.join("ventoy")).await { + tracing::warn!("Failed to initialize MSD controller: {}", error); + return None; + } + controller.set_event_bus(events.clone()).await; + Some(controller) +} + +async fn build_atx(config: &AppConfig) -> Option { + if !config.atx.enabled { + tracing::info!("ATX disabled in configuration"); + return None; + } + + let controller = AtxController::new(config.atx.to_controller_config()); + if let Err(error) = controller.init().await { + tracing::warn!("Failed to initialize ATX controller: {}", error); + return None; + } + Some(controller) +} + +async fn build_audio(config: &AppConfig, events: &Arc) -> Arc { + let quality = config + .audio + .quality + .parse::() + .unwrap_or_else(|error| { + tracing::warn!( + "Invalid audio quality in config (value={:?}): {}, using balanced", + config.audio.quality, + error + ); + AudioQuality::Balanced + }); + let controller = Arc::new(AudioController::new(AudioControllerConfig { + enabled: config.audio.enabled, + device: config.audio.device.clone(), + quality, + })); + controller.set_event_bus(events.clone()).await; + + if config.audio.enabled { + tracing::info!( + "Audio enabled: {}, quality={}", + config.audio.device, + config.audio.quality + ); + if let Err(error) = controller.start_streaming().await { + tracing::warn!("Failed to start audio streaming: {}", error); + } + } else { + tracing::info!("Audio disabled in configuration"); + } + controller +} + +async fn connect_capture_to_webrtc(streamer: &Arc, webrtc: &Arc) { + let (device_path, resolution, format, fps, jpeg_quality) = + streamer.current_capture_config().await; + tracing::debug!( + "Initial video config: {}x{} {:?} @ {}fps", + resolution.width, + resolution.height, + format, + fps + ); + webrtc.update_video_config(resolution, format, fps).await; + if let Some(device_path) = device_path { + webrtc + .set_capture_device(device_path, jpeg_quality, streamer.current_device().await) + .await; + tracing::debug!("WebRTC streamer configured for direct capture"); + } else { + tracing::warn!("No capture device configured for WebRTC"); + } +} + +async fn connect_audio_recovery( + audio: &Arc, + stream_manager: &Arc, +) { + let stream_manager = Arc::downgrade(stream_manager); + audio + .set_recovered_callback(Arc::new(move || { + if let Some(stream_manager) = stream_manager.upgrade() { + tokio::spawn(async move { + stream_manager.reconnect_webrtc_audio_sources().await; + }); + } + })) + .await; +} + +#[cfg(unix)] +async fn start_uac_playback(state: &Arc, config: &AppConfig) { + if !config.uac.enabled { + return; + } + let playback_config = crate::audio::uac::UacPlaybackConfig { + sample_rate: config.uac.sample_rate, + channels: config.uac.channels as u16, + ..Default::default() + }; + match crate::audio::uac::UacPlayback::start(playback_config) { + Ok(writer) => { + *state.uac_playback.write().await = Some(writer); + tracing::info!("UAC playback writer started"); + } + Err(error) => tracing::warn!("Failed to start UAC playback writer: {}", error), + } +} + +#[cfg(not(unix))] +async fn start_uac_playback(_state: &Arc, _config: &AppConfig) {} + +async fn start_watchdog(state: &Arc, config: &AppConfig) { + if !config.watchdog.enabled { + return; + } + if let Err(error) = state.watchdog.enable().await { + tracing::error!( + "Configured hardware watchdog failed to start; web service will continue: {}", + error + ); + } else { + tracing::info!("Hardware watchdog started"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn web_overrides_only_replace_explicit_values() { + let mut config = AppConfig::default(); + let original_https_port = config.web.https_port; + + WebConfigOverrides { + address: Some("127.0.0.1".to_string()), + http_port: Some(9000), + enable_https: true, + ..Default::default() + } + .apply(&mut config); + + assert_eq!(config.web.bind_address, "127.0.0.1"); + assert_eq!(config.web.bind_addresses, ["127.0.0.1"]); + assert_eq!(config.web.http_port, 9000); + assert_eq!(config.web.https_port, original_https_port); + assert!(config.web.https_enabled); + } +} diff --git a/src/runtime/config_apply.rs b/src/runtime/config_apply.rs new file mode 100644 index 00000000..1be6a335 --- /dev/null +++ b/src/runtime/config_apply.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use tokio::sync::{Mutex, OwnedMutexGuard}; + +use crate::error::{AppError, Result}; + +#[derive(Debug, Clone, Copy, Default)] +pub struct ConfigApplyOptions { + pub force: bool, + pub preserve_service_state: bool, + pub runtime_only: bool, +} + +impl ConfigApplyOptions { + pub const fn forced() -> Self { + Self { + force: true, + preserve_service_state: false, + runtime_only: false, + } + } + + pub const fn preserving_service_state() -> Self { + Self { + force: false, + preserve_service_state: true, + runtime_only: false, + } + } + + pub const fn runtime_only() -> Self { + Self { + force: false, + preserve_service_state: false, + runtime_only: true, + } + } +} + +pub fn try_apply_lock(lock: &Arc>, domain: &str) -> Result> { + lock.clone().try_lock_owned().map_err(|_| { + AppError::ServiceUnavailable(format!("{domain} configuration is already applying")) + }) +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs new file mode 100644 index 00000000..6f12c72b --- /dev/null +++ b/src/runtime/mod.rs @@ -0,0 +1,10 @@ +mod builder; +mod config_apply; +mod remote_access; +mod supervisor; +mod usb; + +pub use builder::{ApplicationRuntime, RuntimeBuilder, WebConfigOverrides}; +pub use config_apply::{try_apply_lock, ConfigApplyOptions}; +pub use remote_access::{RemoteAccessCoordinator, RustDeskRuntimeStatus}; +pub use usb::UsbCoordinator; diff --git a/src/runtime/remote_access.rs b/src/runtime/remote_access.rs new file mode 100644 index 00000000..d5d74605 --- /dev/null +++ b/src/runtime/remote_access.rs @@ -0,0 +1,496 @@ +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::audio::AudioController; +use crate::config::{AppConfig, ConfigStore, RtspConfig, VncConfig}; +use crate::error::{AppError, Result}; +use crate::hid::HidController; +use crate::rtsp::{RtspService, RtspServiceStatus}; +use crate::rustdesk::config::RustDeskConfig; +use crate::rustdesk::RustDeskService; +use crate::video::codec_constraints::{ + enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility, + StreamCodecConstraints, +}; +use crate::video::VideoStreamManager; +use crate::vnc::{VncService, VncServiceStatus}; + +use super::ConfigApplyOptions; + +#[derive(Debug, Clone)] +pub struct RustDeskRuntimeStatus { + pub service_status: String, + pub rendezvous_status: Option, +} + +pub struct RemoteAccessCoordinator { + config: ConfigStore, + stream_manager: Arc, + hid: Arc, + audio: Arc, + rustdesk: RwLock>>, + vnc: RwLock>>, + rtsp: RwLock>>, +} + +impl RemoteAccessCoordinator { + pub fn new( + config: ConfigStore, + stream_manager: Arc, + hid: Arc, + audio: Arc, + ) -> Arc { + Arc::new(Self { + config, + stream_manager, + hid, + audio, + rustdesk: RwLock::new(None), + vnc: RwLock::new(None), + rtsp: RwLock::new(None), + }) + } + + pub async fn start_configured(&self, config: &AppConfig) { + if let Err(error) = validate_third_party_codec_compatibility(config) { + tracing::warn!( + "Third-party access codec configuration is invalid; RustDesk/VNC/RTSP will not start: {}", + error + ); + return; + } + + if config.rustdesk.is_valid() { + if let Err(error) = self + .apply_rustdesk( + &RustDeskConfig::default(), + &config.rustdesk, + ConfigApplyOptions::default(), + ) + .await + { + tracing::error!("Failed to start RustDesk service: {}", error); + } + } else if config.rustdesk.enabled { + tracing::warn!( + "RustDesk enabled but configuration is incomplete (missing server or credentials)" + ); + } else { + tracing::info!("RustDesk disabled in configuration"); + } + + if config.vnc.enabled { + if let Err(error) = self + .apply_vnc( + &VncConfig::default(), + &config.vnc, + ConfigApplyOptions::default(), + ) + .await + { + tracing::error!("Failed to start VNC service: {}", error); + } + } else { + tracing::info!("VNC disabled in configuration"); + } + + if config.rtsp.enabled { + if let Err(error) = self + .apply_rtsp( + &RtspConfig::default(), + &config.rtsp, + ConfigApplyOptions::default(), + ) + .await + { + tracing::error!("Failed to start RTSP service: {}", error); + } + } else { + tracing::info!("RTSP disabled in configuration"); + } + + if let Err(error) = self.enforce_codec_constraints().await { + tracing::warn!("Failed to enforce startup codec constraints: {}", error); + } + } + + pub async fn runtime_config(&self) -> AppConfig { + let mut config = self.config.get().as_ref().clone(); + let rustdesk = self.rustdesk.read().await.clone(); + let vnc = self.vnc.read().await.clone(); + let rtsp = self.rtsp.read().await.clone(); + + config.rustdesk.enabled = rustdesk.is_some_and(|service| service.is_listening()); + config.vnc.enabled = match vnc { + Some(service) => matches!( + service.status().await, + VncServiceStatus::Starting | VncServiceStatus::Running + ), + None => false, + }; + config.rtsp.enabled = match rtsp { + Some(service) => matches!( + service.status().await, + RtspServiceStatus::Starting | RtspServiceStatus::Running + ), + None => false, + }; + config + } + + pub async fn rustdesk_status(&self) -> RustDeskRuntimeStatus { + let service = self.rustdesk.read().await.clone(); + match service { + Some(service) => RustDeskRuntimeStatus { + service_status: service.status().to_string(), + rendezvous_status: service.rendezvous_status().map(|status| status.to_string()), + }, + None => RustDeskRuntimeStatus { + service_status: "not_initialized".to_string(), + rendezvous_status: None, + }, + } + } + + pub async fn vnc_status(&self) -> (VncServiceStatus, usize) { + let service = self.vnc.read().await.clone(); + match service { + Some(service) => (service.status().await, service.connection_count()), + None => (VncServiceStatus::Stopped, 0), + } + } + + pub async fn rtsp_status(&self) -> RtspServiceStatus { + let service = self.rtsp.read().await.clone(); + match service { + Some(service) => service.status().await, + None => RtspServiceStatus::Stopped, + } + } + + pub async fn enforce_codec_constraints(&self) -> Result> { + let config = self.runtime_config().await; + let constraints = StreamCodecConstraints::from_config(&config); + self.stream_manager + .set_runtime_codec_constraints(constraints.clone()) + .await; + let enforcement = + enforce_constraints_with_stream_manager(&self.stream_manager, &constraints).await?; + Ok(enforcement.message) + } + + pub async fn apply_rustdesk( + &self, + old_config: &RustDeskConfig, + new_config: &RustDeskConfig, + options: ConfigApplyOptions, + ) -> Result<()> { + tracing::info!("Applying RustDesk config changes..."); + self.validate_rustdesk_candidate(new_config, options.runtime_only) + .await?; + + let need_restart = options.force + || old_config.codec != new_config.codec + || old_config.rendezvous_server != new_config.rendezvous_server + || old_config.device_id != new_config.device_id + || old_config.device_password != new_config.device_password; + let current = self.rustdesk.read().await.clone(); + let mut credentials_to_save = None; + + if !options.preserve_service_state && !new_config.enabled { + if let Some(service) = current.as_ref() { + service.stop().await.map_err(|error| { + AppError::Config(format!("Failed to stop RustDesk service: {error}")) + })?; + tracing::info!("RustDesk service stopped"); + } + *self.rustdesk.write().await = None; + } else if !options.preserve_service_state && new_config.enabled { + match current { + None => { + tracing::info!("Initializing RustDesk service..."); + let service = Arc::new(RustDeskService::new( + new_config.clone(), + self.stream_manager.clone(), + self.hid.clone(), + self.audio.clone(), + )); + *self.rustdesk.write().await = Some(service.clone()); + service.start().await.map_err(|error| { + AppError::Config(format!("Failed to start RustDesk service: {error}")) + })?; + tracing::info!("RustDesk service started with ID: {}", new_config.device_id); + credentials_to_save = service.save_credentials(); + } + Some(service) => { + if service.is_listening() { + if need_restart { + service.restart(new_config.clone()).await.map_err(|error| { + AppError::Config(format!( + "Failed to restart RustDesk service: {error}" + )) + })?; + tracing::info!( + "RustDesk service restarted with ID: {}", + new_config.device_id + ); + } + } else { + service.update_config(new_config.clone()); + service.start().await.map_err(|error| { + AppError::Config(format!("Failed to start RustDesk service: {error}")) + })?; + } + credentials_to_save = service.save_credentials(); + } + } + } else if options.preserve_service_state && need_restart { + if let Some(service) = current { + let mut runtime_config = new_config.clone(); + runtime_config.enabled = true; + service.restart(runtime_config).await.map_err(|error| { + AppError::Config(format!("Failed to restart RustDesk service: {error}")) + })?; + credentials_to_save = service.save_credentials(); + } + } + + if let Some(updated) = credentials_to_save { + tracing::info!("Saving RustDesk credentials to config store..."); + self.config + .update(|config| { + config.rustdesk.public_key = updated.public_key.clone(); + config.rustdesk.private_key = updated.private_key.clone(); + config.rustdesk.signing_public_key = updated.signing_public_key.clone(); + config.rustdesk.signing_private_key = updated.signing_private_key.clone(); + config.rustdesk.uuid = updated.uuid.clone(); + }) + .await?; + tracing::info!("RustDesk credentials saved successfully"); + } + + self.log_enforced_constraints().await?; + Ok(()) + } + + pub async fn apply_vnc( + &self, + old_config: &VncConfig, + new_config: &VncConfig, + options: ConfigApplyOptions, + ) -> Result<()> { + tracing::info!("Applying VNC config changes..."); + self.validate_vnc_candidate(new_config, options.runtime_only) + .await?; + + let runtime_config = self.runtime_config().await; + let will_run = if options.preserve_service_state { + runtime_config.vnc.enabled + } else { + new_config.enabled + }; + if will_run { + let mut candidate = runtime_config; + candidate.vnc = new_config.clone(); + candidate.vnc.enabled = true; + let constraints = StreamCodecConstraints::from_config(&candidate); + match enforce_constraints_with_stream_manager(&self.stream_manager, &constraints).await + { + Ok(result) if result.changed => { + if let Some(message) = result.message { + tracing::info!("{}", message); + } + } + Ok(_) => {} + Err(error) => tracing::warn!( + "Failed to enforce VNC stream constraints before start: {}", + error + ), + } + } + + let need_restart = options.force + || old_config.bind != new_config.bind + || old_config.port != new_config.port + || old_config.encoding != new_config.encoding + || old_config.password != new_config.password + || old_config.allow_one_client != new_config.allow_one_client; + let current = self.vnc.read().await.clone(); + + if !options.preserve_service_state && !new_config.enabled { + if let Some(service) = current.as_ref() { + service.stop().await?; + } + *self.vnc.write().await = None; + } else if !options.preserve_service_state && new_config.enabled { + match current { + None => { + let service = Arc::new(VncService::new( + new_config.clone(), + self.stream_manager.clone(), + self.hid.clone(), + )); + *self.vnc.write().await = Some(service.clone()); + service.start().await?; + tracing::info!("VNC service started"); + } + Some(service) => { + if matches!(service.status().await, VncServiceStatus::Running) { + if need_restart { + service.restart(new_config.clone()).await?; + tracing::info!("VNC service restarted"); + } + } else { + service.update_config(new_config.clone()).await; + service.start().await?; + } + } + } + } else if options.preserve_service_state && need_restart { + if let Some(service) = current { + let mut runtime_config = new_config.clone(); + runtime_config.enabled = true; + service.restart(runtime_config).await?; + } + } + + self.log_enforced_constraints().await?; + Ok(()) + } + + pub async fn apply_rtsp( + &self, + old_config: &RtspConfig, + new_config: &RtspConfig, + options: ConfigApplyOptions, + ) -> Result<()> { + tracing::info!("Applying RTSP config changes..."); + self.validate_rtsp_candidate(new_config, options.runtime_only) + .await?; + + let need_restart = options.force + || old_config.bind != new_config.bind + || old_config.port != new_config.port + || old_config.path != new_config.path + || old_config.codec != new_config.codec + || old_config.username != new_config.username + || old_config.password != new_config.password + || old_config.allow_one_client != new_config.allow_one_client; + let current = self.rtsp.read().await.clone(); + + if !options.preserve_service_state && !new_config.enabled { + if let Some(service) = current.as_ref() { + service.stop().await.map_err(|error| { + AppError::Config(format!("Failed to stop RTSP service: {error}")) + })?; + } + *self.rtsp.write().await = None; + } else if !options.preserve_service_state && new_config.enabled { + match current { + None => { + let service = Arc::new(RtspService::new( + new_config.clone(), + self.stream_manager.clone(), + )); + *self.rtsp.write().await = Some(service.clone()); + service.start().await?; + tracing::info!("RTSP service started"); + } + Some(service) => { + if matches!(service.status().await, RtspServiceStatus::Running) { + if need_restart { + service.restart(new_config.clone()).await?; + tracing::info!("RTSP service restarted"); + } + } else { + service.update_config(new_config.clone()).await; + service.start().await?; + } + } + } + } else if options.preserve_service_state && need_restart { + if let Some(service) = current { + let mut runtime_config = new_config.clone(); + runtime_config.enabled = true; + service.restart(runtime_config).await?; + } + } + + self.log_enforced_constraints().await?; + Ok(()) + } + + pub async fn shutdown(&self) { + let rustdesk = self.rustdesk.write().await.take(); + let vnc = self.vnc.write().await.take(); + let rtsp = self.rtsp.write().await.take(); + + if let Some(service) = rustdesk { + if let Err(error) = service.stop().await { + tracing::warn!("Failed to stop RustDesk service: {}", error); + } else { + tracing::info!("RustDesk service stopped"); + } + } + if let Some(service) = vnc { + if let Err(error) = service.stop().await { + tracing::warn!("Failed to stop VNC service: {}", error); + } else { + tracing::info!("VNC service stopped"); + } + } + if let Some(service) = rtsp { + if let Err(error) = service.stop().await { + tracing::warn!("Failed to stop RTSP service: {}", error); + } else { + tracing::info!("RTSP service stopped"); + } + } + } + + async fn validate_rustdesk_candidate( + &self, + new_config: &RustDeskConfig, + runtime_only: bool, + ) -> Result<()> { + let mut candidate = self.candidate_config(runtime_only).await; + candidate.rustdesk = new_config.clone(); + validate_third_party_codec_compatibility(&candidate) + } + + async fn validate_vnc_candidate( + &self, + new_config: &VncConfig, + runtime_only: bool, + ) -> Result<()> { + let mut candidate = self.candidate_config(runtime_only).await; + candidate.vnc = new_config.clone(); + validate_third_party_codec_compatibility(&candidate) + } + + async fn validate_rtsp_candidate( + &self, + new_config: &RtspConfig, + runtime_only: bool, + ) -> Result<()> { + let mut candidate = self.candidate_config(runtime_only).await; + candidate.rtsp = new_config.clone(); + validate_third_party_codec_compatibility(&candidate) + } + + async fn candidate_config(&self, runtime_only: bool) -> AppConfig { + if runtime_only { + self.runtime_config().await + } else { + self.config.get().as_ref().clone() + } + } + + async fn log_enforced_constraints(&self) -> Result<()> { + if let Some(message) = self.enforce_codec_constraints().await? { + tracing::info!("{}", message); + } + Ok(()) + } +} diff --git a/src/runtime/supervisor.rs b/src/runtime/supervisor.rs new file mode 100644 index 00000000..2b00b8b4 --- /dev/null +++ b/src/runtime/supervisor.rs @@ -0,0 +1,228 @@ +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use crate::config::ConfigStore; +use crate::events::EventBus; +use crate::extensions::ExtensionManager; +use crate::state::AppState; + +pub(super) struct RuntimeSupervisor { + tasks: Vec>, +} + +impl RuntimeSupervisor { + pub(super) fn start( + state: Arc, + events: Arc, + extensions: Arc, + config: ConfigStore, + ) -> Self { + let mut tasks = spawn_device_info_broadcaster(state, events); + tasks.push(spawn_extension_health_check(extensions, config)); + Self { tasks } + } + + pub(super) async fn shutdown(&mut self, state: &Arc) { + for task in self.tasks.drain(..) { + task.abort(); + } + cleanup(state).await; + } +} + +fn spawn_extension_health_check( + extensions: Arc, + config: ConfigStore, +) -> JoinHandle<()> { + let task = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + loop { + interval.tick().await; + let config = config.get(); + extensions.health_check(&config.extensions).await; + } + }); + tracing::info!("Extension health check task started"); + task +} + +fn spawn_device_info_broadcaster( + state: Arc, + events: Arc, +) -> Vec> { + enum DeviceInfoTrigger { + Event, + Lagged { topic: &'static str, count: u64 }, + } + + const DEVICE_INFO_TOPICS: &[&str] = &[ + "stream.state_changed", + "stream.config_applied", + "stream.mode_ready", + ]; + const DEBOUNCE_MS: u64 = 100; + + let (trigger_tx, mut trigger_rx) = mpsc::unbounded_channel(); + let mut tasks = Vec::new(); + + for topic in DEVICE_INFO_TOPICS { + let Some(mut rx) = events.subscribe_topic(topic) else { + tracing::warn!( + "DeviceInfo broadcaster missing topic subscription: {}", + topic + ); + continue; + }; + + let trigger_tx = trigger_tx.clone(); + let topic_name = *topic; + tasks.push(tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(_) => { + if trigger_tx.send(DeviceInfoTrigger::Event).is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { + if trigger_tx + .send(DeviceInfoTrigger::Lagged { + topic: topic_name, + count, + }) + .is_err() + { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + })); + } + + { + let mut dirty_rx = events.subscribe_device_info_dirty(); + let trigger_tx = trigger_tx.clone(); + tasks.push(tokio::spawn(async move { + loop { + match dirty_rx.recv().await { + Ok(()) => { + if trigger_tx.send(DeviceInfoTrigger::Event).is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { + if trigger_tx + .send(DeviceInfoTrigger::Lagged { + topic: "device_info_dirty", + count, + }) + .is_err() + { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + })); + } + + tasks.push(tokio::spawn(async move { + let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS); + let mut pending_broadcast = false; + + loop { + let recv_result = if pending_broadcast { + let remaining = + DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64); + tokio::time::timeout(Duration::from_millis(remaining), trigger_rx.recv()).await + } else { + Ok(trigger_rx.recv().await) + }; + + match recv_result { + Ok(Some(DeviceInfoTrigger::Event)) => { + pending_broadcast = true; + } + Ok(Some(DeviceInfoTrigger::Lagged { topic, count })) => { + tracing::warn!( + "DeviceInfo broadcaster lagged by {} events on topic {}", + count, + topic + ); + pending_broadcast = true; + } + Ok(None) => { + tracing::info!("Event bus closed, stopping DeviceInfo broadcaster"); + break; + } + Err(_timeout) => {} + } + + if pending_broadcast && last_broadcast.elapsed() >= Duration::from_millis(DEBOUNCE_MS) { + state.publish_device_info().await; + tracing::trace!("Broadcasted DeviceInfo (debounced)"); + last_broadcast = Instant::now(); + pending_broadcast = false; + } + } + })); + + tracing::info!( + "DeviceInfo broadcaster task started (debounce: {}ms)", + DEBOUNCE_MS + ); + tasks +} + +async fn cleanup(state: &Arc) { + state.extensions.stop_all().await; + tracing::info!("Extensions stopped"); + + state.remote_access.shutdown().await; + + if let Err(error) = state.stream_manager.stop().await { + tracing::warn!("Failed to stop streamer: {}", error); + } + + if let Err(error) = state.hid.shutdown().await { + tracing::warn!("Failed to shutdown HID: {}", error); + } + + #[cfg(unix)] + { + let msd = state.msd.write().await.take(); + if let Some(msd) = msd { + if let Err(error) = msd.shutdown().await { + tracing::warn!("Failed to shutdown MSD: {}", error); + } + } + + if let Err(error) = state.otg_service.shutdown().await { + tracing::warn!("Failed to shutdown OTG: {}", error); + } + } + + let atx = state.atx.write().await.take(); + if let Some(atx) = atx { + if let Err(error) = atx.shutdown().await { + tracing::warn!("Failed to shutdown ATX: {}", error); + } + } + + if let Err(error) = state.audio.shutdown().await { + tracing::warn!("Failed to shutdown audio: {}", error); + } + + if let Err(error) = state.watchdog.disable().await { + tracing::error!( + "CRITICAL: failed to disable hardware watchdog during shutdown: {}", + error + ); + } +} diff --git a/src/runtime/usb.rs b/src/runtime/usb.rs new file mode 100644 index 00000000..2dec7d4a --- /dev/null +++ b/src/runtime/usb.rs @@ -0,0 +1,335 @@ +use std::path::PathBuf; +use std::sync::Arc; + +#[cfg(unix)] +use tokio::sync::RwLock; + +use crate::config::{AppConfig, HidBackend, HidConfig, MsdConfig, OtgNetworkConfig, UacConfig}; +use crate::error::{AppError, Result}; +use crate::events::EventBus; +use crate::hid::{HidBackendType, HidController}; +#[cfg(unix)] +use crate::msd::MsdController; +#[cfg(unix)] +use crate::otg::OtgService; + +use super::ConfigApplyOptions; + +pub struct UsbCoordinator { + hid: Arc, + #[cfg(unix)] + otg: Arc, + #[cfg(unix)] + msd: Arc>>, + #[cfg(unix)] + uac_playback: Arc>>, + events: Arc, + data_dir: PathBuf, +} + +impl UsbCoordinator { + #[allow(clippy::too_many_arguments)] + pub fn new( + hid: Arc, + #[cfg(unix)] otg: Arc, + #[cfg(unix)] msd: Arc>>, + #[cfg(unix)] uac_playback: Arc>>, + events: Arc, + data_dir: PathBuf, + ) -> Arc { + Arc::new(Self { + hid, + #[cfg(unix)] + otg, + #[cfg(unix)] + msd, + #[cfg(unix)] + uac_playback, + events, + data_dir, + }) + } + + pub async fn apply_config(&self, old_config: &AppConfig, new_config: &AppConfig) -> Result<()> { + #[cfg(unix)] + { + let transitioning_away_from_otg = old_config.hid.backend == HidBackend::Otg + && new_config.hid.backend != HidBackend::Otg; + let hid_unchanged = old_config.hid == new_config.hid; + let 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 = + old_config.uac != new_config.uac || (new_config.uac.enabled && gadget_rebuilt); + + if restart_uac { + let playback = self.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 { + self.apply_hid( + &old_config.hid, + &new_config.hid, + &new_config.msd, + &new_config.otg_network, + &new_config.uac, + ConfigApplyOptions::default(), + ) + .await?; + } else { + self.reconcile_otg( + &new_config.hid, + &new_config.msd, + &new_config.otg_network, + &new_config.uac, + ) + .await?; + self.apply_hid( + &old_config.hid, + &new_config.hid, + &new_config.msd, + &new_config.otg_network, + &new_config.uac, + ConfigApplyOptions::default(), + ) + .await?; + } + + if hid_unchanged && gadget_rebuilt && new_config.hid.backend == HidBackend::Otg { + tracing::info!("OTG gadget rebuilt, reloading HID backend for new devices"); + self.hid + .reload(hid_backend_type(&new_config.hid)) + .await + .map_err(|error| { + AppError::Config(format!("HID reload after gadget rebuild failed: {error}")) + })?; + } + + self.apply_msd( + &old_config.msd, + &new_config.msd, + &new_config.hid, + &new_config.otg_network, + &new_config.uac, + ConfigApplyOptions::default(), + ) + .await?; + + if restart_uac && 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}")) + })?; + *self.uac_playback.write().await = Some(writer); + tracing::info!("UAC playback writer started after OTG reconcile"); + } else if restart_uac { + tracing::info!("UAC playback remains disabled"); + } + + Ok(()) + } + + #[cfg(not(unix))] + { + self.apply_hid( + &old_config.hid, + &new_config.hid, + &new_config.msd, + &new_config.otg_network, + &new_config.uac, + ConfigApplyOptions::default(), + ) + .await + } + } + + async fn apply_hid( + &self, + old_config: &HidConfig, + new_config: &HidConfig, + msd_config: &MsdConfig, + network_config: &OtgNetworkConfig, + uac_config: &UacConfig, + options: ConfigApplyOptions, + ) -> Result<()> { + new_config.validate_otg_functions()?; + + let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor; + let hid_functions_changed = + old_config.constrained_otg_functions() != new_config.constrained_otg_functions(); + let keyboard_leds_changed = + old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds(); + let ch9329_runtime_changed = + old_config.ch9329_hybrid_mouse != new_config.ch9329_hybrid_mouse; + + if old_config.backend == new_config.backend + && old_config.ch9329_port == new_config.ch9329_port + && old_config.ch9329_baudrate == new_config.ch9329_baudrate + && !ch9329_runtime_changed + && old_config.otg_udc == new_config.otg_udc + && !descriptor_changed + && !hid_functions_changed + && !keyboard_leds_changed + && !options.force + { + tracing::info!("HID config unchanged, skipping reload"); + return Ok(()); + } + + tracing::info!("Applying HID config changes..."); + let backend = hid_backend_type(new_config); + let transitioning_away_from_otg = + old_config.backend == HidBackend::Otg && new_config.backend != HidBackend::Otg; + let otg_changed = hid_otg_config_changed(old_config, new_config); + + if transitioning_away_from_otg { + self.hid + .reload(backend.clone()) + .await + .map_err(|error| AppError::Config(format!("HID reload failed: {error}")))?; + } + if otg_changed { + self.reconcile_otg(new_config, msd_config, network_config, uac_config) + .await?; + } + if !transitioning_away_from_otg { + self.hid + .reload(backend) + .await + .map_err(|error| AppError::Config(format!("HID reload failed: {error}")))?; + } + + tracing::info!( + "HID backend reloaded successfully: {:?}", + new_config.backend + ); + Ok(()) + } + + async fn reconcile_otg( + &self, + hid: &HidConfig, + msd: &MsdConfig, + network: &OtgNetworkConfig, + uac: &UacConfig, + ) -> Result<()> { + #[cfg(unix)] + { + self.otg + .apply_config(hid, msd, network, uac) + .await + .map_err(|error| AppError::Config(format!("OTG reconcile failed: {error}"))) + } + #[cfg(not(unix))] + { + let _ = (hid, msd, network, uac); + Ok(()) + } + } + + #[cfg(unix)] + async fn apply_msd( + &self, + old_config: &MsdConfig, + new_config: &MsdConfig, + hid_config: &HidConfig, + network_config: &OtgNetworkConfig, + uac_config: &UacConfig, + options: ConfigApplyOptions, + ) -> Result<()> { + let old_enabled = old_config.enabled; + let new_enabled = new_config.enabled && hid_config.backend == HidBackend::Otg; + let directory_changed = old_config.msd_dir != new_config.msd_dir; + let inquiry_changed = old_config.flash_inquiry_string != new_config.flash_inquiry_string + || old_config.cdrom_inquiry_string != new_config.cdrom_inquiry_string; + + let msd_dir = new_config.msd_dir_path(); + if let Err(error) = std::fs::create_dir_all(msd_dir.join("images")) { + tracing::warn!("Failed to create MSD images directory: {}", error); + } + if let Err(error) = std::fs::create_dir_all(msd_dir.join("ventoy")) { + tracing::warn!("Failed to create MSD ventoy directory: {}", error); + } + + if !options.force && old_enabled == new_enabled && !directory_changed && !inquiry_changed { + tracing::info!("MSD configuration unchanged, no reload needed"); + return Ok(()); + } + + if new_enabled { + tracing::info!("(Re)initializing MSD..."); + self.reconcile_otg(hid_config, new_config, network_config, uac_config) + .await?; + + let old_msd = self.msd.write().await.take(); + if let Some(msd) = old_msd { + msd.shutdown() + .await + .map_err(|error| AppError::Config(format!("MSD shutdown failed: {error}")))?; + } + + let msd = MsdController::new(self.otg.clone(), new_config.msd_dir_path()); + msd.init(&self.data_dir.join("ventoy")) + .await + .map_err(|error| AppError::Config(format!("MSD initialization failed: {error}")))?; + msd.set_event_bus(self.events.clone()).await; + *self.msd.write().await = Some(msd); + tracing::info!("MSD initialized successfully"); + } else { + tracing::info!("MSD disabled in config, shutting down..."); + let old_msd = self.msd.write().await.take(); + if let Some(msd) = old_msd { + msd.shutdown() + .await + .map_err(|error| AppError::Config(format!("MSD shutdown failed: {error}")))?; + } + tracing::info!("MSD shutdown complete"); + self.reconcile_otg(hid_config, new_config, network_config, uac_config) + .await?; + } + + if hid_config.backend == HidBackend::Otg && (options.force || old_enabled != new_enabled) { + self.hid + .reload(HidBackendType::Otg) + .await + .map_err(|error| AppError::Config(format!("OTG HID reload failed: {error}")))?; + } + Ok(()) + } +} + +fn hid_backend_type(config: &HidConfig) -> HidBackendType { + match config.backend { + HidBackend::Otg => HidBackendType::Otg, + HidBackend::Ch9329 => HidBackendType::Ch9329 { + port: config.ch9329_port.clone(), + baud_rate: config.ch9329_baudrate, + hybrid_mouse: config.ch9329_hybrid_mouse, + }, + HidBackend::None => HidBackendType::None, + } +} + +fn hid_otg_config_changed(old_config: &HidConfig, new_config: &HidConfig) -> bool { + old_config.backend == HidBackend::Otg + || new_config.backend == HidBackend::Otg + || old_config.otg_udc != new_config.otg_udc + || old_config.otg_descriptor != new_config.otg_descriptor + || old_config.constrained_otg_functions() != new_config.constrained_otg_functions() + || old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds() +} diff --git a/src/state.rs b/src/state.rs index 9afc162c..1d1e1389 100644 --- a/src/state.rs +++ b/src/state.rs @@ -19,11 +19,9 @@ use crate::hid::HidController; use crate::msd::MsdController; #[cfg(unix)] use crate::otg::OtgService; -use crate::rtsp::RtspService; -use crate::rustdesk::RustDeskService; +use crate::runtime::{RemoteAccessCoordinator, UsbCoordinator}; use crate::update::UpdateService; use crate::video::VideoStreamManager; -use crate::vnc::VncService; use crate::watchdog::WatchdogController; use crate::webrtc::WebRtcStreamer; @@ -81,9 +79,8 @@ pub struct AppState { pub audio: Arc, #[cfg(unix)] pub uac_playback: Arc>>, - pub rustdesk: Arc>>>, - pub vnc: Arc>>>, - pub rtsp: Arc>>>, + pub usb: Arc, + pub remote_access: Arc, pub extensions: Arc, pub events: Arc, device_info_tx: watch::Sender>, @@ -111,9 +108,6 @@ impl AppState { #[cfg(unix)] msd: Option, atx: Option, audio: Arc, - rustdesk: Option>, - vnc: Option>, - rtsp: Option>, extensions: Arc, events: Arc, update: Arc, @@ -122,6 +116,28 @@ impl AppState { ) -> Arc { let (device_info_tx, _device_info_rx) = watch::channel(None); + let remote_access = RemoteAccessCoordinator::new( + config.clone(), + stream_manager.clone(), + hid.clone(), + audio.clone(), + ); + #[cfg(unix)] + let msd = Arc::new(RwLock::new(msd)); + #[cfg(unix)] + let uac_playback = Arc::new(RwLock::new(None)); + let usb = UsbCoordinator::new( + hid.clone(), + #[cfg(unix)] + otg_service.clone(), + #[cfg(unix)] + msd.clone(), + #[cfg(unix)] + uac_playback.clone(), + events.clone(), + data_dir.clone(), + ); + Arc::new(Self { db, config, @@ -135,12 +151,11 @@ impl AppState { hid, computer_use, #[cfg(unix)] - msd: Arc::new(RwLock::new(msd)), + msd, atx: Arc::new(RwLock::new(atx)), audio, - rustdesk: Arc::new(RwLock::new(rustdesk)), - vnc: Arc::new(RwLock::new(vnc)), - rtsp: Arc::new(RwLock::new(rtsp)), + usb, + remote_access, extensions, events, device_info_tx, @@ -151,7 +166,7 @@ impl AppState { config_apply_locks: ConfigApplyLocks::new(), data_dir, #[cfg(unix)] - uac_playback: Arc::new(RwLock::new(None)), + uac_playback, }) } @@ -159,33 +174,6 @@ impl AppState { &self.data_dir } - pub async fn runtime_third_party_config(&self) -> crate::config::AppConfig { - let mut config = self.config.get().as_ref().clone(); - - config.rustdesk.enabled = self - .rustdesk - .read() - .await - .as_ref() - .is_some_and(|service| service.is_listening()); - config.vnc.enabled = match self.vnc.read().await.as_ref() { - Some(service) => matches!( - service.status().await, - crate::vnc::VncServiceStatus::Starting | crate::vnc::VncServiceStatus::Running - ), - None => false, - }; - config.rtsp.enabled = match self.rtsp.read().await.as_ref() { - Some(service) => matches!( - service.status().await, - crate::rtsp::RtspServiceStatus::Starting | crate::rtsp::RtspServiceStatus::Running - ), - None => false, - }; - - config - } - pub fn subscribe_device_info(&self) -> watch::Receiver> { self.device_info_tx.subscribe() } diff --git a/src/web/handlers/config/apply.rs b/src/web/handlers/config/apply.rs index be073e4c..bed1afb1 100644 --- a/src/web/handlers/config/apply.rs +++ b/src/web/handlers/config/apply.rs @@ -2,96 +2,9 @@ use std::sync::Arc; use crate::config::*; use crate::error::{AppError, Result}; -use crate::rtsp::RtspService; +pub use crate::runtime::{try_apply_lock, ConfigApplyOptions}; use crate::state::AppState; use crate::stream_encoder::encoder_type_to_backend; -use crate::video::codec_constraints::{ - enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility, - StreamCodecConstraints, -}; -use tokio::sync::{Mutex, OwnedMutexGuard}; - -#[derive(Debug, Clone, Copy, Default)] -pub struct ConfigApplyOptions { - pub force: bool, - pub preserve_service_state: bool, - pub runtime_only: bool, -} - -impl ConfigApplyOptions { - pub const fn forced() -> Self { - Self { - force: true, - preserve_service_state: false, - runtime_only: false, - } - } - - pub const fn preserving_service_state() -> Self { - Self { - force: false, - preserve_service_state: true, - runtime_only: false, - } - } - - pub const fn runtime_only() -> Self { - Self { - force: false, - preserve_service_state: false, - runtime_only: true, - } - } -} - -pub fn try_apply_lock(lock: &Arc>, domain: &str) -> Result> { - lock.clone().try_lock_owned().map_err(|_| { - AppError::ServiceUnavailable(format!("{domain} configuration is already applying")) - }) -} - -fn hid_backend_type(config: &HidConfig) -> crate::hid::HidBackendType { - match config.backend { - HidBackend::Otg => crate::hid::HidBackendType::Otg, - HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 { - port: config.ch9329_port.clone(), - baud_rate: config.ch9329_baudrate, - hybrid_mouse: config.ch9329_hybrid_mouse, - }, - HidBackend::None => crate::hid::HidBackendType::None, - } -} - -fn hid_otg_config_changed(old_config: &HidConfig, new_config: &HidConfig) -> bool { - old_config.backend == HidBackend::Otg - || new_config.backend == HidBackend::Otg - || old_config.otg_udc != new_config.otg_udc - || old_config.otg_descriptor != new_config.otg_descriptor - || old_config.constrained_otg_functions() != new_config.constrained_otg_functions() - || old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds() -} - -async fn reconcile_otg_config( - state: &Arc, - hid: &HidConfig, - msd: &MsdConfig, - network: &OtgNetworkConfig, - uac: &UacConfig, -) -> Result<()> { - #[cfg(not(unix))] - { - let _ = (state, hid, msd, network, uac); - Ok(()) - } - #[cfg(unix)] - { - state - .otg_service - .apply_config(hid, msd, network, uac) - .await - .map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e))) - } -} pub async fn apply_video_config( state: &Arc, @@ -189,305 +102,6 @@ pub async fn apply_stream_config( Ok(()) } -pub async fn apply_hid_config( - state: &Arc, - old_config: &HidConfig, - new_config: &HidConfig, - msd_config: &MsdConfig, - network_config: &OtgNetworkConfig, - uac_config: &UacConfig, - options: ConfigApplyOptions, -) -> Result<()> { - new_config.validate_otg_functions()?; - - let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor; - let old_hid_functions = old_config.constrained_otg_functions(); - let new_hid_functions = new_config.constrained_otg_functions(); - let hid_functions_changed = old_hid_functions != new_hid_functions; - let keyboard_leds_changed = - old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds(); - let ch9329_runtime_changed = old_config.ch9329_hybrid_mouse != new_config.ch9329_hybrid_mouse; - - if old_config.backend == new_config.backend - && old_config.ch9329_port == new_config.ch9329_port - && old_config.ch9329_baudrate == new_config.ch9329_baudrate - && !ch9329_runtime_changed - && old_config.otg_udc == new_config.otg_udc - && !descriptor_changed - && !hid_functions_changed - && !keyboard_leds_changed - && !options.force - { - tracing::info!("HID config unchanged, skipping reload"); - return Ok(()); - } - - tracing::info!("Applying HID config changes..."); - - let new_hid_backend = hid_backend_type(new_config); - let transitioning_away_from_otg = - old_config.backend == HidBackend::Otg && new_config.backend != HidBackend::Otg; - let otg_config_changed = hid_otg_config_changed(old_config, new_config); - - if transitioning_away_from_otg { - state - .hid - .reload(new_hid_backend.clone()) - .await - .map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?; - } - - if otg_config_changed { - reconcile_otg_config(state, new_config, msd_config, network_config, uac_config).await?; - } - - if !transitioning_away_from_otg { - state - .hid - .reload(new_hid_backend) - .await - .map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?; - } - - tracing::info!( - "HID backend reloaded successfully: {:?}", - new_config.backend - ); - - Ok(()) -} - -#[cfg(unix)] -pub async fn apply_msd_config( - state: &Arc, - old_config: &MsdConfig, - 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; - let effective_new_msd_enabled = new_config.enabled && hid_backend_is_otg; - - tracing::info!("MSD config sent, checking if reload needed..."); - tracing::debug!("Old MSD config: {:?}", old_config); - tracing::debug!("New MSD config: {:?}", new_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={}", - old_msd_enabled, - new_msd_enabled - ); - 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")) { - tracing::warn!("Failed to create MSD images directory: {}", e); - } - if let Err(e) = std::fs::create_dir_all(msd_dir.join("ventoy")) { - tracing::warn!("Failed to create MSD ventoy directory: {}", e); - } - - let needs_reload = options.force - || old_msd_enabled != new_msd_enabled - || msd_dir_changed - || inquiry_strings_changed; - if !needs_reload { - 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, uac_config).await?; - - let mut msd_guard = state.msd.write().await; - if let Some(msd) = msd_guard.as_mut() { - msd.shutdown() - .await - .map_err(|e| AppError::Config(format!("MSD shutdown failed: {e}")))?; - } - *msd_guard = None; - drop(msd_guard); - - let msd = - crate::msd::MsdController::new(state.otg_service.clone(), new_config.msd_dir_path()); - let ventoy_resource_dir = state.data_dir().join("ventoy"); - msd.init(&ventoy_resource_dir) - .await - .map_err(|e| AppError::Config(format!("MSD initialization failed: {}", e)))?; - - let events = state.events.clone(); - msd.set_event_bus(events).await; - - *state.msd.write().await = Some(msd); - tracing::info!("MSD initialized successfully"); - } else { - tracing::info!("MSD disabled in config, shutting down..."); - - let mut msd_guard = state.msd.write().await; - if let Some(msd) = msd_guard.as_mut() { - msd.shutdown() - .await - .map_err(|e| AppError::Config(format!("MSD shutdown failed: {e}")))?; - } - *msd_guard = None; - tracing::info!("MSD shutdown complete"); - - reconcile_otg_config(state, hid_config, new_config, network_config, uac_config).await?; - } - - if hid_config.backend == HidBackend::Otg - && (options.force || old_msd_enabled != new_msd_enabled) - { - state - .hid - .reload(crate::hid::HidBackendType::Otg) - .await - .map_err(|e| AppError::Config(format!("OTG HID reload failed: {}", e)))?; - } - - Ok(()) -} - -pub async fn apply_usb_config( - state: &Arc, - old_config: &AppConfig, - new_config: &AppConfig, -) -> Result<()> { - #[cfg(unix)] - { - let transitioning_away_from_otg = - 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 - || 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( - state, - &old_config.hid, - &new_config.hid, - &new_config.msd, - &new_config.otg_network, - &new_config.uac, - ConfigApplyOptions::default(), - ) - .await?; - } else { - reconcile_otg_config( - state, - &new_config.hid, - &new_config.msd, - &new_config.otg_network, - &new_config.uac, - ) - .await?; - apply_hid_config( - state, - &old_config.hid, - &new_config.hid, - &new_config.msd, - &new_config.otg_network, - &new_config.uac, - ConfigApplyOptions::default(), - ) - .await?; - } - - // When the OTG gadget was rebuilt due to MSD or network config changes - // while HID config stayed the same, the /dev/hidg* devices are new and - // the HID backend must be reloaded to reopen them. - 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)) - })?; - } - - apply_msd_config( - state, - &old_config.msd, - &new_config.msd, - &new_config.hid, - &new_config.otg_network, - &new_config.uac, - ConfigApplyOptions::default(), - ) - .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))] - { - apply_hid_config( - state, - &old_config.hid, - &new_config.hid, - &new_config.msd, - &new_config.otg_network, - &new_config.uac, - ConfigApplyOptions::default(), - ) - .await - } -} - pub async fn apply_atx_config( state: &Arc, _old_config: &AtxConfig, @@ -554,336 +168,3 @@ pub async fn apply_audio_config( Ok(()) } - -pub async fn enforce_stream_codec_constraints(state: &Arc) -> Result> { - let config = state.runtime_third_party_config().await; - let constraints = StreamCodecConstraints::from_config(&config); - state - .stream_manager - .set_runtime_codec_constraints(constraints.clone()) - .await; - let enforcement = - enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await?; - Ok(enforcement.message) -} - -async fn validate_runtime_candidate( - state: &Arc, - apply: impl FnOnce(&mut crate::config::AppConfig, T), - config: T, -) -> Result<()> { - let mut candidate = state.runtime_third_party_config().await; - apply(&mut candidate, config); - validate_third_party_codec_compatibility(&candidate) -} - -fn validate_rustdesk_candidate( - state: &Arc, - new_config: &crate::rustdesk::config::RustDeskConfig, -) -> Result<()> { - let mut candidate = state.config.get().as_ref().clone(); - candidate.rustdesk = new_config.clone(); - validate_third_party_codec_compatibility(&candidate) -} - -fn validate_vnc_candidate(state: &Arc, new_config: &VncConfig) -> Result<()> { - let mut candidate = state.config.get().as_ref().clone(); - candidate.vnc = new_config.clone(); - validate_third_party_codec_compatibility(&candidate) -} - -fn validate_rtsp_candidate(state: &Arc, new_config: &RtspConfig) -> Result<()> { - let mut candidate = state.config.get().as_ref().clone(); - candidate.rtsp = new_config.clone(); - validate_third_party_codec_compatibility(&candidate) -} - -pub async fn apply_rustdesk_config( - state: &Arc, - old_config: &crate::rustdesk::config::RustDeskConfig, - new_config: &crate::rustdesk::config::RustDeskConfig, - options: ConfigApplyOptions, -) -> Result<()> { - tracing::info!("Applying RustDesk config changes..."); - - if options.runtime_only { - validate_runtime_candidate( - state, - |candidate, config| candidate.rustdesk = config, - new_config.clone(), - ) - .await?; - } else { - validate_rustdesk_candidate(state, new_config)?; - } - - let mut rustdesk_guard = state.rustdesk.write().await; - let mut credentials_to_save = None; - let need_restart = options.force - || old_config.codec != new_config.codec - || old_config.rendezvous_server != new_config.rendezvous_server - || old_config.device_id != new_config.device_id - || old_config.device_password != new_config.device_password; - - if !options.preserve_service_state && !new_config.enabled { - if let Some(ref service) = *rustdesk_guard { - service - .stop() - .await - .map_err(|e| AppError::Config(format!("Failed to stop RustDesk service: {}", e)))?; - tracing::info!("RustDesk service stopped"); - } - *rustdesk_guard = None; - } - - if !options.preserve_service_state && new_config.enabled { - if rustdesk_guard.is_none() { - tracing::info!("Initializing RustDesk service..."); - let service = std::sync::Arc::new(crate::rustdesk::RustDeskService::new( - new_config.clone(), - state.stream_manager.clone(), - state.hid.clone(), - state.audio.clone(), - )); - *rustdesk_guard = Some(service.clone()); - service.start().await.map_err(|e| { - AppError::Config(format!("Failed to start RustDesk service: {}", e)) - })?; - tracing::info!("RustDesk service started with ID: {}", new_config.device_id); - credentials_to_save = service.save_credentials(); - } else { - if let Some(ref service) = *rustdesk_guard { - if service.is_listening() { - if need_restart { - service.restart(new_config.clone()).await.map_err(|e| { - AppError::Config(format!("Failed to restart RustDesk service: {}", e)) - })?; - tracing::info!( - "RustDesk service restarted with ID: {}", - new_config.device_id - ); - } - } else { - service.update_config(new_config.clone()); - service.start().await.map_err(|e| { - AppError::Config(format!("Failed to start RustDesk service: {}", e)) - })?; - } - credentials_to_save = service.save_credentials(); - } - } - } else if options.preserve_service_state && need_restart { - if let Some(ref service) = *rustdesk_guard { - let mut runtime_config = new_config.clone(); - runtime_config.enabled = true; - service.restart(runtime_config).await.map_err(|e| { - AppError::Config(format!("Failed to restart RustDesk service: {}", e)) - })?; - credentials_to_save = service.save_credentials(); - } - } - - drop(rustdesk_guard); - if let Some(updated_config) = credentials_to_save { - tracing::info!("Saving RustDesk credentials to config store..."); - state - .config - .update(|cfg| { - cfg.rustdesk.public_key = updated_config.public_key.clone(); - cfg.rustdesk.private_key = updated_config.private_key.clone(); - cfg.rustdesk.signing_public_key = updated_config.signing_public_key.clone(); - cfg.rustdesk.signing_private_key = updated_config.signing_private_key.clone(); - cfg.rustdesk.uuid = updated_config.uuid.clone(); - }) - .await?; - tracing::info!("RustDesk credentials saved successfully"); - } - - if let Some(message) = enforce_stream_codec_constraints(state).await? { - tracing::info!("{}", message); - } - - Ok(()) -} - -pub async fn apply_vnc_config( - state: &Arc, - old_config: &VncConfig, - new_config: &VncConfig, - options: ConfigApplyOptions, -) -> Result<()> { - tracing::info!("Applying VNC config changes..."); - - if options.runtime_only { - validate_runtime_candidate( - state, - |candidate, config| candidate.vnc = config, - new_config.clone(), - ) - .await?; - } else { - validate_vnc_candidate(state, new_config)?; - } - - let runtime_config = state.runtime_third_party_config().await; - let will_run = if options.preserve_service_state { - runtime_config.vnc.enabled - } else { - new_config.enabled - }; - if will_run { - let mut candidate = runtime_config; - candidate.vnc = new_config.clone(); - candidate.vnc.enabled = true; - let constraints = StreamCodecConstraints::from_config(&candidate); - match enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await { - Ok(result) if result.changed => { - if let Some(message) = result.message { - tracing::info!("{}", message); - } - } - Ok(_) => {} - Err(e) => tracing::warn!( - "Failed to enforce VNC stream constraints before start: {}", - e - ), - } - } - - let mut vnc_guard = state.vnc.write().await; - let need_restart = options.force - || old_config.bind != new_config.bind - || old_config.port != new_config.port - || old_config.encoding != new_config.encoding - || old_config.password != new_config.password - || old_config.allow_one_client != new_config.allow_one_client; - - if !options.preserve_service_state && !new_config.enabled { - if let Some(ref service) = *vnc_guard { - service.stop().await?; - } - *vnc_guard = None; - } - - if !options.preserve_service_state && new_config.enabled { - if vnc_guard.is_none() { - let service = Arc::new(crate::vnc::VncService::new( - new_config.clone(), - state.stream_manager.clone(), - state.hid.clone(), - )); - *vnc_guard = Some(service.clone()); - service.start().await?; - tracing::info!("VNC service started"); - } else { - if let Some(ref service) = *vnc_guard { - if matches!( - service.status().await, - crate::vnc::VncServiceStatus::Running - ) { - if need_restart { - service.restart(new_config.clone()).await?; - tracing::info!("VNC service restarted"); - } - } else { - service.update_config(new_config.clone()).await; - service.start().await?; - } - } - } - } else if options.preserve_service_state && need_restart { - if let Some(ref service) = *vnc_guard { - let mut runtime_config = new_config.clone(); - runtime_config.enabled = true; - service.restart(runtime_config).await?; - } - } - - drop(vnc_guard); - if let Some(message) = enforce_stream_codec_constraints(state).await? { - tracing::info!("{}", message); - } - - Ok(()) -} - -pub async fn apply_rtsp_config( - state: &Arc, - old_config: &RtspConfig, - new_config: &RtspConfig, - options: ConfigApplyOptions, -) -> Result<()> { - tracing::info!("Applying RTSP config changes..."); - - if options.runtime_only { - validate_runtime_candidate( - state, - |candidate, config| candidate.rtsp = config, - new_config.clone(), - ) - .await?; - } else { - validate_rtsp_candidate(state, new_config)?; - } - - let mut rtsp_guard = state.rtsp.write().await; - let need_restart = options.force - || old_config.bind != new_config.bind - || old_config.port != new_config.port - || old_config.path != new_config.path - || old_config.codec != new_config.codec - || old_config.username != new_config.username - || old_config.password != new_config.password - || old_config.allow_one_client != new_config.allow_one_client; - - if !options.preserve_service_state && !new_config.enabled { - if let Some(ref service) = *rtsp_guard { - service - .stop() - .await - .map_err(|e| AppError::Config(format!("Failed to stop RTSP service: {}", e)))?; - } - *rtsp_guard = None; - } - - if !options.preserve_service_state && new_config.enabled { - if rtsp_guard.is_none() { - let service = Arc::new(RtspService::new( - new_config.clone(), - state.stream_manager.clone(), - )); - *rtsp_guard = Some(service.clone()); - service.start().await?; - tracing::info!("RTSP service started"); - } else { - if let Some(ref service) = *rtsp_guard { - if matches!( - service.status().await, - crate::rtsp::RtspServiceStatus::Running - ) { - if need_restart { - service.restart(new_config.clone()).await?; - tracing::info!("RTSP service restarted"); - } - } else { - service.update_config(new_config.clone()).await; - service.start().await?; - } - } - } - } else if options.preserve_service_state && need_restart { - if let Some(ref service) = *rtsp_guard { - let mut runtime_config = new_config.clone(); - runtime_config.enabled = true; - service.restart(runtime_config).await?; - } - } - - drop(rtsp_guard); - - if let Some(message) = enforce_stream_codec_constraints(state).await? { - tracing::info!("{}", message); - } - - Ok(()) -} diff --git a/src/web/handlers/config/hid.rs b/src/web/handlers/config/hid.rs index 3dcf45f6..7e4d88db 100644 --- a/src/web/handlers/config/hid.rs +++ b/src/web/handlers/config/hid.rs @@ -1,19 +1,18 @@ use axum::{extract::State, Json}; -use std::sync::Arc; use crate::config::HidConfig; use crate::error::Result; -use crate::state::AppState; +use crate::web::state::UsbApiState; use super::types::HidConfigUpdate; use super::usb_update::{stage_hid_config_update, update_usb_config}; -pub async fn get_hid_config(State(state): State>) -> Json { +pub async fn get_hid_config(State(state): State) -> Json { Json(state.config.get().hid.clone()) } pub async fn update_hid_config( - State(state): State>, + State(state): State, Json(req): Json, ) -> Result> { let config = update_usb_config(&state, move |staged| { diff --git a/src/web/handlers/config/msd.rs b/src/web/handlers/config/msd.rs index 47a37431..a975a004 100644 --- a/src/web/handlers/config/msd.rs +++ b/src/web/handlers/config/msd.rs @@ -1,19 +1,18 @@ use axum::{extract::State, Json}; -use std::sync::Arc; use crate::config::MsdConfig; use crate::error::Result; -use crate::state::AppState; +use crate::web::state::UsbApiState; use super::otg::update_otg_config_inner; use super::types::{MsdConfigUpdate, OtgConfigUpdate}; -pub async fn get_msd_config(State(state): State>) -> Json { +pub async fn get_msd_config(State(state): State) -> Json { Json(state.config.get().msd.clone()) } pub async fn update_msd_config( - State(state): State>, + State(state): State, Json(req): Json, ) -> Result> { let response = update_otg_config_inner( diff --git a/src/web/handlers/config/otg.rs b/src/web/handlers/config/otg.rs index b06bee9b..9b9f4d56 100644 --- a/src/web/handlers/config/otg.rs +++ b/src/web/handlers/config/otg.rs @@ -1,5 +1,3 @@ -use std::sync::Arc; - use axum::{extract::State, Json}; use serde::Serialize; use typeshare::typeshare; @@ -7,7 +5,7 @@ use typeshare::typeshare; use crate::config::{HidConfig, MsdConfig, OtgNetworkConfig}; use crate::error::Result; use crate::otg::OtgNetworkStatus; -use crate::state::AppState; +use crate::web::state::UsbApiState; use super::types::OtgConfigUpdate; use super::usb_update::{stage_hid_config_update, update_usb_config}; @@ -22,14 +20,14 @@ pub struct OtgConfigResponse { } pub async fn update_otg_config( - State(state): State>, + State(state): State, Json(request): Json, ) -> Result> { update_otg_config_inner(&state, request).await.map(Json) } pub(super) async fn update_otg_config_inner( - state: &Arc, + state: &UsbApiState, request: OtgConfigUpdate, ) -> Result { let staged_config = update_usb_config(state, move |staged| { @@ -54,6 +52,6 @@ pub(super) async fn update_otg_config_inner( hid: staged_config.hid, msd: staged_config.msd, network: staged_config.otg_network, - status: state.otg_service.network_status().await, + status: state.otg.network_status().await, }) } diff --git a/src/web/handlers/config/otg_network.rs b/src/web/handlers/config/otg_network.rs index d9da27dc..4f0abae9 100644 --- a/src/web/handlers/config/otg_network.rs +++ b/src/web/handlers/config/otg_network.rs @@ -1,21 +1,19 @@ -use std::sync::Arc; - use axum::{extract::State, Json}; use crate::config::OtgNetworkConfig; use crate::error::Result; use crate::otg::OtgNetworkStatus; -use crate::state::AppState; +use crate::web::state::UsbApiState; use super::otg::update_otg_config_inner; use super::types::{OtgConfigUpdate, OtgNetworkConfigUpdate}; -pub async fn get_otg_network_config(State(state): State>) -> Json { +pub async fn get_otg_network_config(State(state): State) -> Json { Json(state.config.get().otg_network.clone()) } pub async fn update_otg_network_config( - State(state): State>, + State(state): State, Json(request): Json, ) -> Result> { let response = update_otg_config_inner( @@ -29,6 +27,6 @@ pub async fn update_otg_network_config( Ok(Json(response.network)) } -pub async fn get_otg_network_status(State(state): State>) -> Json { - Json(state.otg_service.network_status().await) +pub async fn get_otg_network_status(State(state): State) -> Json { + Json(state.otg.network_status().await) } diff --git a/src/web/handlers/config/rtsp.rs b/src/web/handlers/config/rtsp.rs index 7a5a5fbf..f94f03c4 100644 --- a/src/web/handlers/config/rtsp.rs +++ b/src/web/handlers/config/rtsp.rs @@ -1,20 +1,22 @@ use axum::{extract::State, Json}; -use std::sync::Arc; use crate::error::Result; -use crate::state::AppState; +use crate::web::state::RemoteAccessApiState; -use super::apply::{apply_rtsp_config, try_apply_lock, ConfigApplyOptions}; use super::types::{RtspConfigResponse, RtspConfigUpdate, RtspStatusResponse}; +use crate::runtime::{try_apply_lock, ConfigApplyOptions}; -fn validate_candidate(state: &Arc, config: &crate::config::RtspConfig) -> Result<()> { +fn validate_candidate( + state: &RemoteAccessApiState, + config: &crate::config::RtspConfig, +) -> Result<()> { let mut candidate = state.config.get().as_ref().clone(); candidate.rtsp = config.clone(); crate::video::codec_constraints::validate_third_party_codec_compatibility(&candidate) } async fn persist_and_apply( - state: &Arc, + state: &RemoteAccessApiState, old_config: crate::config::RtspConfig, new_config: crate::config::RtspConfig, ) -> Result { @@ -26,31 +28,31 @@ async fn persist_and_apply( }) .await?; let stored_config = state.config.get().rtsp.clone(); - apply_rtsp_config( - state, - &old_config, - &stored_config, - ConfigApplyOptions::preserving_service_state(), - ) - .await?; + state + .coordinator + .apply_rtsp( + &old_config, + &stored_config, + ConfigApplyOptions::preserving_service_state(), + ) + .await?; Ok(stored_config) } -async fn current_status(state: &Arc) -> crate::rtsp::RtspServiceStatus { - let guard = state.rtsp.read().await; - if let Some(ref service) = *guard { - service.status().await - } else { - crate::rtsp::RtspServiceStatus::Stopped - } +async fn current_status(state: &RemoteAccessApiState) -> crate::rtsp::RtspServiceStatus { + state.coordinator.rtsp_status().await } -pub async fn get_rtsp_config(State(state): State>) -> Json { +pub async fn get_rtsp_config( + State(state): State, +) -> Json { let config = state.config.get(); Json(RtspConfigResponse::from(&config.rtsp)) } -pub async fn get_rtsp_status(State(state): State>) -> Json { +pub async fn get_rtsp_status( + State(state): State, +) -> Json { let config = state.config.get().rtsp.clone(); let status = current_status(&state).await; @@ -58,12 +60,12 @@ pub async fn get_rtsp_status(State(state): State>) -> Json>, + State(state): State, Json(req): Json, ) -> Result> { req.validate()?; - let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?; + let _apply_guard = try_apply_lock(&state.rtsp_apply_lock, "rtsp")?; let old_config = state.config.get().rtsp.clone(); let mut merged_config = old_config.clone(); req.apply_to(&mut merged_config); @@ -73,40 +75,42 @@ pub async fn update_rtsp_config( } pub async fn start_rtsp_service( - State(state): State>, + State(state): State, ) -> Result> { - let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?; + let _apply_guard = try_apply_lock(&state.rtsp_apply_lock, "rtsp")?; let stored_config = state.config.get().rtsp.clone(); - let runtime_config = state.runtime_third_party_config().await.rtsp; + let runtime_config = state.coordinator.runtime_config().await.rtsp; let mut start_config = stored_config.clone(); start_config.enabled = true; - apply_rtsp_config( - &state, - &runtime_config, - &start_config, - ConfigApplyOptions::runtime_only(), - ) - .await?; + state + .coordinator + .apply_rtsp( + &runtime_config, + &start_config, + ConfigApplyOptions::runtime_only(), + ) + .await?; let status = current_status(&state).await; Ok(Json(RtspStatusResponse::new(&stored_config, status))) } pub async fn stop_rtsp_service( - State(state): State>, + State(state): State, ) -> Result> { - let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?; + let _apply_guard = try_apply_lock(&state.rtsp_apply_lock, "rtsp")?; let stored_config = state.config.get().rtsp.clone(); - let runtime_config = state.runtime_third_party_config().await.rtsp; + let runtime_config = state.coordinator.runtime_config().await.rtsp; let mut stop_config = stored_config.clone(); stop_config.enabled = false; - apply_rtsp_config( - &state, - &runtime_config, - &stop_config, - ConfigApplyOptions::runtime_only(), - ) - .await?; + state + .coordinator + .apply_rtsp( + &runtime_config, + &stop_config, + ConfigApplyOptions::runtime_only(), + ) + .await?; let status = current_status(&state).await; Ok(Json(RtspStatusResponse::new(&stored_config, status))) diff --git a/src/web/handlers/config/rustdesk.rs b/src/web/handlers/config/rustdesk.rs index ce08d920..7399ece0 100644 --- a/src/web/handlers/config/rustdesk.rs +++ b/src/web/handlers/config/rustdesk.rs @@ -1,21 +1,20 @@ use axum::{extract::State, Json}; -use std::sync::Arc; use crate::error::Result; use crate::rustdesk::config::RustDeskConfig; -use crate::state::AppState; +use crate::web::state::RemoteAccessApiState; -use super::apply::{apply_rustdesk_config, try_apply_lock, ConfigApplyOptions}; use super::types::RustDeskConfigUpdate; +use crate::runtime::{try_apply_lock, ConfigApplyOptions}; -fn validate_candidate(state: &Arc, config: &RustDeskConfig) -> Result<()> { +fn validate_candidate(state: &RemoteAccessApiState, config: &RustDeskConfig) -> Result<()> { let mut candidate = state.config.get().as_ref().clone(); candidate.rustdesk = config.clone(); crate::video::codec_constraints::validate_third_party_codec_compatibility(&candidate) } async fn persist_and_apply( - state: &Arc, + state: &RemoteAccessApiState, old_config: RustDeskConfig, new_config: RustDeskConfig, ) -> Result { @@ -27,32 +26,27 @@ async fn persist_and_apply( }) .await?; let stored_config = state.config.get().rustdesk.clone(); - apply_rustdesk_config( - state, - &old_config, - &stored_config, - ConfigApplyOptions::preserving_service_state(), - ) - .await?; + state + .coordinator + .apply_rustdesk( + &old_config, + &stored_config, + ConfigApplyOptions::preserving_service_state(), + ) + .await?; Ok(stored_config) } -async fn current_status(state: &Arc, config: RustDeskConfig) -> RustDeskStatusResponse { - let (service_status, rendezvous_status) = { - let guard = state.rustdesk.read().await; - if let Some(ref service) = *guard { - let status = format!("{}", service.status()); - let rv_status = service.rendezvous_status().map(|s| format!("{}", s)); - (status, rv_status) - } else { - ("not_initialized".to_string(), None) - } - }; +async fn current_status( + state: &RemoteAccessApiState, + config: RustDeskConfig, +) -> RustDeskStatusResponse { + let runtime = state.coordinator.rustdesk_status().await; RustDeskStatusResponse { config: RustDeskConfigResponse::from(&config), - service_status, - rendezvous_status, + service_status: runtime.service_status, + rendezvous_status: runtime.rendezvous_status, } } @@ -91,25 +85,25 @@ pub struct RustDeskStatusResponse { } pub async fn get_rustdesk_config( - State(state): State>, + State(state): State, ) -> Json { Json(RustDeskConfigResponse::from(&state.config.get().rustdesk)) } pub async fn get_rustdesk_status( - State(state): State>, + State(state): State, ) -> Json { let config = state.config.get().rustdesk.clone(); Json(current_status(&state, config).await) } pub async fn update_rustdesk_config( - State(state): State>, + State(state): State, Json(req): Json, ) -> Result> { req.validate()?; - let _apply_guard = try_apply_lock(&state.config_apply_locks.rustdesk, "rustdesk")?; + let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?; let old_config = state.config.get().rustdesk.clone(); let mut merged_config = old_config.clone(); req.apply_to(&mut merged_config); @@ -117,19 +111,11 @@ pub async fn update_rustdesk_config( let new_config = persist_and_apply(&state, old_config, merged_config).await?; - let constraints = state.stream_manager.codec_constraints().await; - if constraints.rustdesk_enabled || constraints.rtsp_enabled { - tracing::info!( - "Stream codec constraints active after RustDesk update: {}", - constraints.reason - ); - } - Ok(Json(RustDeskConfigResponse::from(&new_config))) } pub async fn regenerate_device_id( - State(state): State>, + State(state): State, ) -> Result> { state .config @@ -143,7 +129,7 @@ pub async fn regenerate_device_id( } pub async fn regenerate_device_password( - State(state): State>, + State(state): State, ) -> Result> { state .config @@ -156,7 +142,9 @@ pub async fn regenerate_device_password( Ok(Json(RustDeskConfigResponse::from(&new_config))) } -pub async fn get_device_password(State(state): State>) -> Json { +pub async fn get_device_password( + State(state): State, +) -> Json { let config = state.config.get().rustdesk.clone(); Json(serde_json::json!({ "device_id": config.device_id, @@ -165,38 +153,40 @@ pub async fn get_device_password(State(state): State>) -> Json>, + State(state): State, ) -> Result> { - let _apply_guard = try_apply_lock(&state.config_apply_locks.rustdesk, "rustdesk")?; + let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?; let stored_config = state.config.get().rustdesk.clone(); - let runtime_config = state.runtime_third_party_config().await.rustdesk; + let runtime_config = state.coordinator.runtime_config().await.rustdesk; let mut start_config = stored_config.clone(); start_config.enabled = true; - apply_rustdesk_config( - &state, - &runtime_config, - &start_config, - ConfigApplyOptions::runtime_only(), - ) - .await?; + state + .coordinator + .apply_rustdesk( + &runtime_config, + &start_config, + ConfigApplyOptions::runtime_only(), + ) + .await?; let stored_config = state.config.get().rustdesk.clone(); Ok(Json(current_status(&state, stored_config).await)) } pub async fn stop_rustdesk_service( - State(state): State>, + State(state): State, ) -> Result> { - let _apply_guard = try_apply_lock(&state.config_apply_locks.rustdesk, "rustdesk")?; + let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?; let stored_config = state.config.get().rustdesk.clone(); - let runtime_config = state.runtime_third_party_config().await.rustdesk; + let runtime_config = state.coordinator.runtime_config().await.rustdesk; let mut stop_config = stored_config.clone(); stop_config.enabled = false; - apply_rustdesk_config( - &state, - &runtime_config, - &stop_config, - ConfigApplyOptions::runtime_only(), - ) - .await?; + state + .coordinator + .apply_rustdesk( + &runtime_config, + &stop_config, + ConfigApplyOptions::runtime_only(), + ) + .await?; Ok(Json(current_status(&state, stored_config).await)) } diff --git a/src/web/handlers/config/stream.rs b/src/web/handlers/config/stream.rs index c4aac191..d6448b04 100644 --- a/src/web/handlers/config/stream.rs +++ b/src/web/handlers/config/stream.rs @@ -38,7 +38,7 @@ pub async fn update_stream_config( ) .await?; - super::apply::enforce_stream_codec_constraints(&state).await?; + state.remote_access.enforce_codec_constraints().await?; Ok(Json(StreamConfigResponse::from(&new_stream_config))) } diff --git a/src/web/handlers/config/uac.rs b/src/web/handlers/config/uac.rs index 90b0ec43..4e1828a8 100644 --- a/src/web/handlers/config/uac.rs +++ b/src/web/handlers/config/uac.rs @@ -1,19 +1,17 @@ -use std::sync::Arc; - use axum::{extract::State, Json}; use crate::config::UacConfig; use crate::error::Result; -use crate::state::AppState; +use crate::web::state::UsbApiState; use super::usb_update::update_usb_config; -pub async fn get_uac_config(State(state): State>) -> Json { +pub async fn get_uac_config(State(state): State) -> Json { Json(state.config.get().uac.clone()) } pub async fn update_uac_config( - State(state): State>, + State(state): State, Json(request): Json, ) -> Result> { request.validate()?; diff --git a/src/web/handlers/config/usb_update.rs b/src/web/handlers/config/usb_update.rs index bce96476..d4f4dfce 100644 --- a/src/web/handlers/config/usb_update.rs +++ b/src/web/handlers/config/usb_update.rs @@ -1,11 +1,9 @@ -use std::sync::Arc; - use crate::config::{AppConfig, Ch9329DescriptorConfig, HidBackend, HidConfig}; use crate::error::{AppError, Result}; -use crate::state::AppState; +use crate::web::state::UsbApiState; -use super::apply::{apply_usb_config, try_apply_lock}; use super::types::HidConfigUpdate; +use crate::runtime::try_apply_lock; pub(super) fn stage_hid_config_update( staged_hid: &mut HidConfig, @@ -27,14 +25,11 @@ pub(super) fn stage_hid_config_update( Ok(requested_descriptor) } -pub(super) async fn update_usb_config( - state: &Arc, - stage_update: F, -) -> Result +pub(super) async fn update_usb_config(state: &UsbApiState, stage_update: F) -> Result where F: FnOnce(&mut AppConfig) -> Result>, { - let _guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?; + let _guard = try_apply_lock(&state.apply_lock, "otg")?; let old_config = state.config.get(); let mut staged_config = old_config.as_ref().clone(); @@ -58,7 +53,11 @@ where staged_config.uac.validate()?; } - if let Err(error) = apply_usb_config(state, &old_config, &staged_config).await { + if let Err(error) = state + .coordinator + .apply_config(&old_config, &staged_config) + .await + { return Err(rollback_after_failure(state, &staged_config, &old_config, error, false).await); } @@ -114,7 +113,7 @@ where } async fn rollback_after_failure( - state: &Arc, + state: &UsbApiState, failed_config: &AppConfig, old_config: &AppConfig, primary_error: AppError, @@ -122,7 +121,11 @@ async fn rollback_after_failure( ) -> AppError { let mut rollback_errors = Vec::new(); - if let Err(error) = apply_usb_config(state, failed_config, old_config).await { + if let Err(error) = state + .coordinator + .apply_config(failed_config, old_config) + .await + { rollback_errors.push(format!("runtime rollback failed: {error}")); } if restore_descriptor && old_config.hid.backend == HidBackend::Ch9329 { @@ -141,7 +144,7 @@ async fn rollback_after_failure( let message = format!("{primary_error}; {}", rollback_errors.join("; ")); #[cfg(unix)] - state.otg_service.mark_degraded(message.clone()).await; + state.otg.mark_degraded(message.clone()).await; AppError::Config(message) } diff --git a/src/web/handlers/config/vnc.rs b/src/web/handlers/config/vnc.rs index f68a8b42..8bac9e0f 100644 --- a/src/web/handlers/config/vnc.rs +++ b/src/web/handlers/config/vnc.rs @@ -1,20 +1,22 @@ use axum::{extract::State, Json}; -use std::sync::Arc; use crate::error::Result; -use crate::state::AppState; +use crate::web::state::RemoteAccessApiState; -use super::apply::{apply_vnc_config, try_apply_lock, ConfigApplyOptions}; use super::types::{VncConfigResponse, VncConfigUpdate, VncStatusResponse}; +use crate::runtime::{try_apply_lock, ConfigApplyOptions}; -fn validate_candidate(state: &Arc, config: &crate::config::VncConfig) -> Result<()> { +fn validate_candidate( + state: &RemoteAccessApiState, + config: &crate::config::VncConfig, +) -> Result<()> { let mut candidate = state.config.get().as_ref().clone(); candidate.vnc = config.clone(); crate::video::codec_constraints::validate_third_party_codec_compatibility(&candidate) } async fn persist_and_apply( - state: &Arc, + state: &RemoteAccessApiState, old_config: crate::config::VncConfig, new_config: crate::config::VncConfig, ) -> Result { @@ -26,30 +28,26 @@ async fn persist_and_apply( }) .await?; let stored_config = state.config.get().vnc.clone(); - apply_vnc_config( - state, - &old_config, - &stored_config, - ConfigApplyOptions::preserving_service_state(), - ) - .await?; + state + .coordinator + .apply_vnc( + &old_config, + &stored_config, + ConfigApplyOptions::preserving_service_state(), + ) + .await?; Ok(stored_config) } -async fn current_status(state: &Arc) -> (crate::vnc::VncServiceStatus, usize) { - let guard = state.vnc.read().await; - if let Some(ref service) = *guard { - (service.status().await, service.connection_count()) - } else { - (crate::vnc::VncServiceStatus::Stopped, 0) - } +async fn current_status(state: &RemoteAccessApiState) -> (crate::vnc::VncServiceStatus, usize) { + state.coordinator.vnc_status().await } -pub async fn get_vnc_config(State(state): State>) -> Json { +pub async fn get_vnc_config(State(state): State) -> Json { Json(VncConfigResponse::from(&state.config.get().vnc)) } -pub async fn get_vnc_status(State(state): State>) -> Json { +pub async fn get_vnc_status(State(state): State) -> Json { let config = state.config.get().vnc.clone(); let (status, connection_count) = current_status(&state).await; @@ -57,12 +55,12 @@ pub async fn get_vnc_status(State(state): State>) -> Json>, + State(state): State, Json(req): Json, ) -> Result> { req.validate()?; - let _apply_guard = try_apply_lock(&state.config_apply_locks.vnc, "vnc")?; + let _apply_guard = try_apply_lock(&state.vnc_apply_lock, "vnc")?; let old_config = state.config.get().vnc.clone(); let mut merged_config = old_config.clone(); req.apply_to(&mut merged_config); @@ -73,23 +71,24 @@ pub async fn update_vnc_config( } pub async fn start_vnc_service( - State(state): State>, + State(state): State, ) -> Result> { - let _apply_guard = try_apply_lock(&state.config_apply_locks.vnc, "vnc")?; + let _apply_guard = try_apply_lock(&state.vnc_apply_lock, "vnc")?; let stored_config = state.config.get().vnc.clone(); - let runtime_config = state.runtime_third_party_config().await.vnc; + let runtime_config = state.coordinator.runtime_config().await.vnc; let mut start_config = stored_config.clone(); start_config.enabled = true; if start_config.password.as_deref().unwrap_or("").is_empty() { start_config.password = stored_config.password.clone(); } - apply_vnc_config( - &state, - &runtime_config, - &start_config, - ConfigApplyOptions::runtime_only(), - ) - .await?; + state + .coordinator + .apply_vnc( + &runtime_config, + &start_config, + ConfigApplyOptions::runtime_only(), + ) + .await?; let (status, connection_count) = current_status(&state).await; Ok(Json(VncStatusResponse::new( @@ -100,20 +99,21 @@ pub async fn start_vnc_service( } pub async fn stop_vnc_service( - State(state): State>, + State(state): State, ) -> Result> { - let _apply_guard = try_apply_lock(&state.config_apply_locks.vnc, "vnc")?; + let _apply_guard = try_apply_lock(&state.vnc_apply_lock, "vnc")?; let stored_config = state.config.get().vnc.clone(); - let runtime_config = state.runtime_third_party_config().await.vnc; + let runtime_config = state.coordinator.runtime_config().await.vnc; let mut stop_config = stored_config.clone(); stop_config.enabled = false; - apply_vnc_config( - &state, - &runtime_config, - &stop_config, - ConfigApplyOptions::runtime_only(), - ) - .await?; + state + .coordinator + .apply_vnc( + &runtime_config, + &stop_config, + ConfigApplyOptions::runtime_only(), + ) + .await?; Ok(Json(VncStatusResponse::new( &stored_config, diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index b34927c7..4fd088f6 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -42,12 +42,12 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::{debug, info, warn}; -use self::config::apply::ConfigApplyOptions; use crate::auth::{Session, SESSION_COOKIE}; use crate::config::StreamMode; use crate::diagnostics::{get_device_info, get_disk_space, DeviceInfo, DiskSpaceInfo}; use crate::error::{AppError, Result}; use crate::platform::PlatformCapabilities; +use crate::runtime::ConfigApplyOptions; use crate::state::AppState; use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest}; use crate::utils::list_serial_ports; diff --git a/src/web/handlers/setup.rs b/src/web/handlers/setup.rs index 80392462..9e30dfbc 100644 --- a/src/web/handlers/setup.rs +++ b/src/web/handlers/setup.rs @@ -175,7 +175,7 @@ pub async fn setup_init( // Apply the complete USB runtime configuration, including the MSD controller. let new_config = state.config.get(); - if let Err(e) = config::apply::apply_usb_config(&state, &old_config, &new_config).await { + if let Err(e) = state.usb.apply_config(&old_config, &new_config).await { tracing::warn!("Failed to apply USB config during setup: {}", e); } @@ -201,13 +201,14 @@ pub async fn setup_init( // Start RustDesk if enabled if new_config.rustdesk.enabled { let empty_config = crate::rustdesk::config::RustDeskConfig::default(); - if let Err(e) = config::apply::apply_rustdesk_config( - &state, - &empty_config, - &new_config.rustdesk, - ConfigApplyOptions::default(), - ) - .await + if let Err(e) = state + .remote_access + .apply_rustdesk( + &empty_config, + &new_config.rustdesk, + ConfigApplyOptions::default(), + ) + .await { tracing::warn!("Failed to start RustDesk during setup: {}", e); } else { @@ -218,13 +219,14 @@ pub async fn setup_init( // Start RTSP if enabled if new_config.rtsp.enabled { let empty_config = crate::config::RtspConfig::default(); - if let Err(e) = config::apply::apply_rtsp_config( - &state, - &empty_config, - &new_config.rtsp, - ConfigApplyOptions::default(), - ) - .await + if let Err(e) = state + .remote_access + .apply_rtsp( + &empty_config, + &new_config.rtsp, + ConfigApplyOptions::default(), + ) + .await { tracing::warn!("Failed to start RTSP during setup: {}", e); } else { diff --git a/src/web/mod.rs b/src/web/mod.rs index 72733b11..f657025b 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -2,6 +2,7 @@ mod audio_ws; mod error; mod handlers; mod routes; +pub(crate) mod state; mod static_files; #[cfg(unix)] mod uac_ws; diff --git a/src/web/state.rs b/src/web/state.rs new file mode 100644 index 00000000..85fcf8d3 --- /dev/null +++ b/src/web/state.rs @@ -0,0 +1,55 @@ +use std::sync::Arc; + +use axum::extract::FromRef; +use tokio::sync::Mutex; + +use crate::config::ConfigStore; +use crate::hid::HidController; +#[cfg(unix)] +use crate::otg::OtgService; +use crate::runtime::{RemoteAccessCoordinator, UsbCoordinator}; +use crate::state::AppState; + +#[derive(Clone)] +pub(crate) struct RemoteAccessApiState { + pub config: ConfigStore, + pub coordinator: Arc, + pub rustdesk_apply_lock: Arc>, + pub vnc_apply_lock: Arc>, + pub rtsp_apply_lock: Arc>, +} + +impl FromRef> for RemoteAccessApiState { + fn from_ref(state: &Arc) -> Self { + Self { + config: state.config.clone(), + coordinator: state.remote_access.clone(), + rustdesk_apply_lock: state.config_apply_locks.rustdesk.clone(), + vnc_apply_lock: state.config_apply_locks.vnc.clone(), + rtsp_apply_lock: state.config_apply_locks.rtsp.clone(), + } + } +} + +#[derive(Clone)] +pub(crate) struct UsbApiState { + pub config: ConfigStore, + pub coordinator: Arc, + pub hid: Arc, + pub apply_lock: Arc>, + #[cfg(unix)] + pub otg: Arc, +} + +impl FromRef> for UsbApiState { + fn from_ref(state: &Arc) -> Self { + Self { + config: state.config.clone(), + coordinator: state.usb.clone(), + hid: state.hid.clone(), + apply_lock: state.config_apply_locks.otg.clone(), + #[cfg(unix)] + otg: state.otg_service.clone(), + } + } +} From 486f3887c2833fc5430a929ee2dcda1547bcf5c3 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Wed, 26 Aug 2026 11:04:40 +0800 Subject: [PATCH 05/29] =?UTF-8?q?refactor:=20=E7=AE=80=E5=8C=96=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=BA=A7=E5=88=AB=E9=85=8D=E7=BD=AE=E5=B9=B6=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20verbose=20=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.rs | 34 ++++++++++------------------------ 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/src/main.rs b/src/main.rs index cd64a246..84d3fa9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,6 @@ enum LogLevel { Warn, #[default] Info, - Verbose, Debug, Trace, } @@ -70,13 +69,9 @@ struct CliArgs { #[arg(short = 'd', long, value_name = "DIR")] data_dir: Option, - /// Log level (error, warn, info, verbose, debug, trace) + /// Log level (error, warn, info, debug, trace) #[arg(short = 'l', long, value_name = "LEVEL", default_value = "info")] log_level: LogLevel, - - /// Increase verbosity (-v for verbose, -vv for debug, -vvv for trace) - #[arg(short = 'v', long, action = clap::ArgAction::Count)] - verbose: u8, } #[derive(Subcommand, Debug)] @@ -103,7 +98,7 @@ enum UserAction { async fn main() -> anyhow::Result<()> { let args = CliArgs::parse(); - init_logging(args.log_level, args.verbose); + init_logging(args.log_level); CryptoProvider::install_default(ring::default_provider()) .expect("Failed to install rustls crypto provider"); @@ -244,25 +239,16 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -fn init_logging(level: LogLevel, verbose_count: u8) { - let effective_level = match verbose_count { - 0 => level, - 1 => LogLevel::Verbose, - 2 => LogLevel::Debug, - _ => LogLevel::Trace, +fn init_logging(level: LogLevel) { + let app_level = match level { + LogLevel::Error => "error", + LogLevel::Warn => "warn", + LogLevel::Info => "info", + LogLevel::Debug => "debug", + LogLevel::Trace => "trace", }; - - let filter = match effective_level { - LogLevel::Error => "one_kvm=error,tower_http=error,webrtc_sctp=warn", - LogLevel::Warn => "one_kvm=warn,tower_http=warn,webrtc_sctp=warn", - LogLevel::Info => "one_kvm=info,tower_http=info,webrtc_sctp=warn", - LogLevel::Verbose => "one_kvm=debug,tower_http=info,webrtc_sctp=warn", - LogLevel::Debug => "one_kvm=debug,tower_http=debug,webrtc_sctp=warn", - LogLevel::Trace => "one_kvm=trace,tower_http=debug,webrtc_sctp=warn", - }; - let env_filter = - tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()); + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| app_level.into()); if let Err(err) = tracing_subscriber::registry() .with(env_filter) From 005410041439ab4c6bd9e2de3b906bc960d95b18 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Wed, 26 Aug 2026 11:37:39 +0800 Subject: [PATCH 06/29] =?UTF-8?q?refactor:=20=E7=B2=BE=E7=AE=80=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E9=87=8D=E5=A4=8D=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一服务与 CLI 的数据库初始化入口,由视频流管理器负责 WebRTC 采集源同步,并将 MSD 目录创建收敛到控制器内部。 --- src/db/mod.rs | 33 +++++++++++++++++++ src/main.rs | 12 ++----- src/msd/controller.rs | 8 ++--- src/runtime/builder.rs | 66 +++++++++++++++---------------------- src/runtime/usb.rs | 8 ----- src/video/stream_manager.rs | 24 ++++---------- 6 files changed, 70 insertions(+), 81 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index b2935c58..868805a2 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,3 +1,36 @@ mod pool; +use std::path::Path; + +use crate::error::Result; + pub use pool::DatabasePool; + +/// Open the application database stored in `data_dir` and ensure its schema exists. +pub async fn open_database_pool(data_dir: &Path) -> Result { + let db = DatabasePool::new(&data_dir.join("one-kvm.db")).await?; + db.init_schema().await?; + Ok(db) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn open_database_pool_creates_data_dir_and_initializes_schema() { + let temp_dir = tempfile::tempdir().unwrap(); + let data_dir = temp_dir.path().join("nested").join("data"); + + let db = open_database_pool(&data_dir).await.unwrap(); + + assert!(data_dir.join("one-kvm.db").is_file()); + let users_table: Option = sqlx::query_scalar( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'users'", + ) + .fetch_optional(db.pool()) + .await + .unwrap(); + assert_eq!(users_table.as_deref(), Some("users")); + } +} diff --git a/src/main.rs b/src/main.rs index 84d3fa9f..897b9f3d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use std::future::Future; use std::io::Write; use std::net::{IpAddr, SocketAddr}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use axum_server::tls_rustls::RustlsConfig; use clap::{Args, Parser, Subcommand, ValueEnum}; @@ -12,7 +12,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use one_kvm::auth::{SessionStore, TwoFactorService, UserStore}; use one_kvm::config; -use one_kvm::db::DatabasePool; +use one_kvm::db::open_database_pool; use one_kvm::platform::PlatformCapabilities; use one_kvm::runtime::{RuntimeBuilder, WebConfigOverrides}; use one_kvm::state::ShutdownAction; @@ -298,13 +298,6 @@ async fn shutdown_signal() -> anyhow::Result<()> { Ok(()) } -async fn open_database_pool(data_dir: &Path) -> anyhow::Result { - let db_path = data_dir.join("one-kvm.db"); - let db = DatabasePool::new(&db_path).await?; - db.init_schema().await?; - Ok(db) -} - async fn run_servers_until_shutdown( mut servers: FuturesUnordered, shutdown_signal: impl Future, @@ -348,7 +341,6 @@ fn restart_current_process(exe_path: Option) -> anyhow::Result<()> { } async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Result<()> { - tokio::fs::create_dir_all(&data_dir).await?; let db = open_database_pool(&data_dir).await?; let users = UserStore::new(db.clone_pool()); let two_factor = TwoFactorService::new(db.clone_pool()); diff --git a/src/msd/controller.rs b/src/msd/controller.rs index 6c7fff2b..027ca973 100644 --- a/src/msd/controller.rs +++ b/src/msd/controller.rs @@ -62,12 +62,8 @@ impl MsdController { ), } - if let Err(e) = std::fs::create_dir_all(&self.images_path) { - warn!("Failed to create images directory: {}", e); - } - if let Err(e) = std::fs::create_dir_all(&self.ventoy_dir) { - warn!("Failed to create ventoy directory: {}", e); - } + tokio::fs::create_dir_all(&self.images_path).await?; + tokio::fs::create_dir_all(&self.ventoy_dir).await?; info!("Fetching MSD function from OtgService"); let msd_func = self diff --git a/src/runtime/builder.rs b/src/runtime/builder.rs index d576adf2..95e453aa 100644 --- a/src/runtime/builder.rs +++ b/src/runtime/builder.rs @@ -8,7 +8,7 @@ use crate::audio::{AudioController, AudioControllerConfig, AudioQuality}; use crate::auth::{SessionStore, TwoFactorService, UserStore}; use crate::computer_use::ComputerUseManager; use crate::config::{self, AppConfig, ConfigStore}; -use crate::db::DatabasePool; +use crate::db::{open_database_pool, DatabasePool}; use crate::events::EventBus; use crate::extensions::ExtensionManager; use crate::hid::{HidBackendType, HidController}; @@ -126,8 +126,6 @@ impl RuntimeBuilder { } } - connect_capture_to_webrtc(&streamer, &webrtc).await; - let stream_manager = VideoStreamManager::with_webrtc_streamer( streamer.clone(), webrtc.clone() as Arc, @@ -226,23 +224,19 @@ impl ApplicationRuntime { async fn load_runtime_config( data_dir: &Path, ) -> anyhow::Result<(DatabasePool, ConfigStore, AppConfig)> { - tokio::fs::create_dir_all(data_dir).await?; - - let db_path = data_dir.join("one-kvm.db"); - let db = DatabasePool::new(&db_path).await?; - db.init_schema().await?; + let db = open_database_pool(data_dir).await?; let config_store = ConfigStore::new(db.clone_pool())?; config_store.load().await?; let mut config = (*config_store.get()).clone(); config.apply_platform_defaults(); - prepare_linux_runtime_dirs(data_dir, &config_store, &mut config).await?; + normalize_msd_config(data_dir, &config_store, &mut config).await?; Ok((db, config_store, config)) } #[cfg(unix)] -async fn prepare_linux_runtime_dirs( +async fn normalize_msd_config( data_dir: &Path, config_store: &ConfigStore, config: &mut AppConfig, @@ -263,19 +257,11 @@ async fn prepare_linux_runtime_dirs( if msd_dir_updated { config_store.set(config.clone()).await?; } - - let msd_dir = PathBuf::from(&config.msd.msd_dir); - if let Err(error) = tokio::fs::create_dir_all(msd_dir.join("images")).await { - tracing::warn!("Failed to create MSD images directory: {}", error); - } - if let Err(error) = tokio::fs::create_dir_all(msd_dir.join("ventoy")).await { - tracing::warn!("Failed to create MSD ventoy directory: {}", error); - } Ok(()) } #[cfg(not(unix))] -async fn prepare_linux_runtime_dirs( +async fn normalize_msd_config( _data_dir: &Path, _config_store: &ConfigStore, _config: &mut AppConfig, @@ -499,27 +485,6 @@ async fn build_audio(config: &AppConfig, events: &Arc) -> Arc, webrtc: &Arc) { - let (device_path, resolution, format, fps, jpeg_quality) = - streamer.current_capture_config().await; - tracing::debug!( - "Initial video config: {}x{} {:?} @ {}fps", - resolution.width, - resolution.height, - format, - fps - ); - webrtc.update_video_config(resolution, format, fps).await; - if let Some(device_path) = device_path { - webrtc - .set_capture_device(device_path, jpeg_quality, streamer.current_device().await) - .await; - tracing::debug!("WebRTC streamer configured for direct capture"); - } else { - tracing::warn!("No capture device configured for WebRTC"); - } -} - async fn connect_audio_recovery( audio: &Arc, stream_manager: &Arc, @@ -595,4 +560,25 @@ mod tests { assert_eq!(config.web.https_port, original_https_port); assert!(config.web.https_enabled); } + + #[cfg(unix)] + #[tokio::test] + async fn normalizing_disabled_msd_does_not_create_module_directories() { + let temp_dir = tempfile::tempdir().unwrap(); + let data_dir = temp_dir.path().join("data"); + let msd_dir = temp_dir.path().join("disabled-msd"); + let db = open_database_pool(&data_dir).await.unwrap(); + let config_store = ConfigStore::new(db.clone_pool()).unwrap(); + config_store.load().await.unwrap(); + let mut config = (*config_store.get()).clone(); + config.msd.enabled = false; + config.msd.msd_dir = msd_dir.to_string_lossy().into_owned(); + + normalize_msd_config(&data_dir, &config_store, &mut config) + .await + .unwrap(); + + assert!(!msd_dir.join("images").exists()); + assert!(!msd_dir.join("ventoy").exists()); + } } diff --git a/src/runtime/usb.rs b/src/runtime/usb.rs index 2dec7d4a..1c3db5c7 100644 --- a/src/runtime/usb.rs +++ b/src/runtime/usb.rs @@ -258,14 +258,6 @@ impl UsbCoordinator { let inquiry_changed = old_config.flash_inquiry_string != new_config.flash_inquiry_string || old_config.cdrom_inquiry_string != new_config.cdrom_inquiry_string; - let msd_dir = new_config.msd_dir_path(); - if let Err(error) = std::fs::create_dir_all(msd_dir.join("images")) { - tracing::warn!("Failed to create MSD images directory: {}", error); - } - if let Err(error) = std::fs::create_dir_all(msd_dir.join("ventoy")) { - tracing::warn!("Failed to create MSD ventoy directory: {}", error); - } - if !options.force && old_enabled == new_enabled && !directory_changed && !inquiry_changed { tracing::info!("MSD configuration unchanged, no reload needed"); return Ok(()); diff --git a/src/video/stream_manager.rs b/src/video/stream_manager.rs index 57343c94..761a90c6 100644 --- a/src/video/stream_manager.rs +++ b/src/video/stream_manager.rs @@ -195,25 +195,15 @@ impl VideoStreamManager { info!("Initializing video stream manager with mode: {:?}", mode); *self.mode.write().await = mode.clone(); - // Check if streamer is already initialized (capturer exists) - let needs_init = self.streamer.state().await == StreamerState::Uninitialized; + // A failed fixed-device configuration can leave the streamer in a transient + // state without a capture device. Treat that the same as an uninitialized + // streamer so the advertised auto-detection fallback actually runs. + let state = self.streamer.state().await; + let (device_path, _, _, _, _) = self.streamer.current_capture_config().await; + let needs_init = state == StreamerState::Uninitialized || device_path.is_none(); if needs_init { - match mode { - StreamMode::Mjpeg => { - // Initialize MJPEG streamer - if let Err(e) = self.streamer.init_auto().await { - warn!("Failed to auto-initialize MJPEG streamer: {}", e); - } - } - StreamMode::WebRTC => { - // WebRTC is initialized on-demand when clients connect - // But we still need to initialize the video capture - if let Err(e) = self.streamer.init_auto().await { - warn!("Failed to auto-initialize video capture for WebRTC: {}", e); - } - } - } + self.streamer.init_auto().await?; } self.sync_webrtc_capture_source("after init").await; From f101ddb918ddf437bd53528bb8dab1f2748530fe Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Wed, 26 Aug 2026 12:04:52 +0800 Subject: [PATCH 07/29] =?UTF-8?q?refactor:=20=E9=99=8D=E4=BD=8E=20hwcodec?= =?UTF-8?q?=20=E6=8E=A2=E6=B5=8B=E6=97=A5=E5=BF=97=E5=99=AA=E9=9F=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- libs/hwcodec/cpp/common/util.cpp | 12 ++++++++---- libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp | 5 ++--- libs/hwcodec/src/ffmpeg.rs | 4 ++-- libs/hwcodec/src/ffmpeg_ram/encode.rs | 11 ++++------- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/libs/hwcodec/cpp/common/util.cpp b/libs/hwcodec/cpp/common/util.cpp index 63b4e430..282e5710 100644 --- a/libs/hwcodec/cpp/common/util.cpp +++ b/libs/hwcodec/cpp/common/util.cpp @@ -120,7 +120,8 @@ bool set_lantency_free(void *priv_data, const std::string &name) { } if (name.find("amf") != std::string::npos) { if ((ret = av_opt_set(priv_data, "query_timeout", "1000", 0)) < 0) { - LOG_WARN(std::string("amf query_timeout option is unavailable, ret = ") + av_err2str(ret)); + LOG_DEBUG(std::string("amf query_timeout option is unavailable, ret = ") + + av_err2str(ret)); } } if (name.find("qsv") != std::string::npos) { @@ -139,7 +140,8 @@ bool set_lantency_free(void *priv_data, const std::string &name) { if (name.find("rkmpp") != std::string::npos) { // Set async_depth to 1 for minimal buffering (0 = synchronous, higher = more buffering) if ((ret = av_opt_set(priv_data, "async_depth", "1", 0)) < 0) { - LOG_WARN(std::string("rkmpp set async_depth failed, ret = ") + av_err2str(ret)); + LOG_DEBUG(std::string("rkmpp async_depth option is unavailable, ret = ") + + av_err2str(ret)); // Not fatal - older FFmpeg versions may not support this option } } @@ -147,11 +149,13 @@ bool set_lantency_free(void *priv_data, const std::string &name) { if (name.find("v4l2m2m") != std::string::npos) { // Minimize number of output buffers for lower latency if ((ret = av_opt_set_int(priv_data, "num_output_buffers", 4, 0)) < 0) { - LOG_WARN(std::string("v4l2m2m set num_output_buffers failed, ret = ") + av_err2str(ret)); + LOG_DEBUG(std::string("v4l2m2m num_output_buffers option is unavailable, ret = ") + + av_err2str(ret)); // Not fatal } if ((ret = av_opt_set_int(priv_data, "num_capture_buffers", 4, 0)) < 0) { - LOG_WARN(std::string("v4l2m2m set num_capture_buffers failed, ret = ") + av_err2str(ret)); + LOG_DEBUG(std::string("v4l2m2m num_capture_buffers option is unavailable, ret = ") + + av_err2str(ret)); // Not fatal } } diff --git a/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp b/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp index d96fbd0e..18f32ee6 100644 --- a/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp +++ b/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp @@ -26,7 +26,6 @@ static thread_local std::string g_encoder_last_error; static void set_encoder_last_error(const std::string &message) { g_encoder_last_error = message; - LOG_ERROR(message); } static int calculate_offset_length(int pix_fmt, int height, const int *linesize, @@ -646,8 +645,8 @@ ffmpeg_ram_new_encoder(const char *name, int width, // allowing CQP-only drivers to pass probing and normal encoder creation. if (name && std::string(name).find("vaapi") != std::string::npos && rc != RC_CQ) { - LOG_WARN(std::string("VAAPI bitrate-based rate control failed for ") + - name + ", retrying with CQP"); + LOG_DEBUG(std::string("VAAPI bitrate-based rate control failed for ") + + name + ", retrying with CQP"); encoder = try_create(RC_CQ, 0); if (encoder) { return encoder; diff --git a/libs/hwcodec/src/ffmpeg.rs b/libs/hwcodec/src/ffmpeg.rs index 73b67705..4bbff498 100644 --- a/libs/hwcodec/src/ffmpeg.rs +++ b/libs/hwcodec/src/ffmpeg.rs @@ -32,14 +32,14 @@ pub extern "C" fn hwcodec_av_log_callback(level: i32, message: *const std::os::r if let Ok(str_slice) = c_str.to_str() { let string = String::from(str_slice); if level == AV_LOG_ERROR as i32 { - log::error!("{}", string); if string.contains(could_not_find_ref_with_poc) { hwcodec_set_flag_could_not_find_ref_with_poc(); } + log::debug!("{}", string); } else if level == AV_LOG_PANIC as i32 || level == AV_LOG_FATAL as i32 { log::error!("{}", string); } else if level == AV_LOG_WARNING as i32 { - log::warn!("{}", string); + log::debug!("{}", string); } else if level == AV_LOG_INFO as i32 { log::info!("{}", string); } else if level == AV_LOG_VERBOSE as i32 || level == AV_LOG_DEBUG as i32 { diff --git a/libs/hwcodec/src/ffmpeg_ram/encode.rs b/libs/hwcodec/src/ffmpeg_ram/encode.rs index de99b9cd..aa08a26d 100644 --- a/libs/hwcodec/src/ffmpeg_ram/encode.rs +++ b/libs/hwcodec/src/ffmpeg_ram/encode.rs @@ -343,7 +343,7 @@ fn log_failed_probe_attempt( } fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> bool { - use log::{debug, warn}; + use log::debug; debug!("Testing encoder: {}", codec.name); @@ -395,7 +395,7 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo } Err(err) => { last_err = Some(err); - warn!( + debug!( "Encoder {} test attempt {} returned error: {}", codec.name, attempt_no, err ); @@ -412,10 +412,7 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo ); false } - Err(_) => { - warn!("Failed to create encoder {}", codec.name); - false - } + Err(_) => false, } } @@ -543,7 +540,7 @@ impl Encoder { if codec.is_null() { let message = encoder_last_error_message(); if !message.is_empty() { - log::error!("ffmpeg_ram_new_encoder failed: {}", message); + log::debug!("ffmpeg_ram_new_encoder failed: {}", message); } return Err(()); } From 6e1194fe4ce9661b665fe81766bd35234fd6f65e Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Wed, 26 Aug 2026 12:06:10 +0800 Subject: [PATCH 08/29] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=20AMLENC?= =?UTF-8?q?=20=E7=A7=81=E6=9C=89=E7=BC=96=E7=A0=81=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/schema/stream.rs | 3 +- src/stream_encoder.rs | 24 - src/video/codec/amlenc.rs | 989 ---------------------------- src/video/codec/h264.rs | 4 - src/video/codec/h265.rs | 4 - src/video/codec/mod.rs | 2 - src/video/codec/registry.rs | 156 ----- src/video/codec/self_check.rs | 38 +- src/video/pipeline/encoder_state.rs | 175 ++--- src/video/pipeline/shared.rs | 31 +- src/webrtc/webrtc_streamer.rs | 21 - web/src/types/generated.ts | 1 - 12 files changed, 64 insertions(+), 1384 deletions(-) delete mode 100644 src/video/codec/amlenc.rs diff --git a/src/config/schema/stream.rs b/src/config/schema/stream.rs index 25908e4c..f1acb752 100644 --- a/src/config/schema/stream.rs +++ b/src/config/schema/stream.rs @@ -102,8 +102,8 @@ pub enum EncoderType { Qsv, Amf, Rkmpp, + #[serde(alias = "amlogic")] V4l2m2m, - Amlogic, } impl EncoderType { @@ -117,7 +117,6 @@ impl EncoderType { EncoderType::Amf => "AMD AMF", EncoderType::Rkmpp => "Rockchip MPP", EncoderType::V4l2m2m => "V4L2 M2M", - EncoderType::Amlogic => "AMLENC", } } } diff --git a/src/stream_encoder.rs b/src/stream_encoder.rs index 4bae805a..ce0360f5 100644 --- a/src/stream_encoder.rs +++ b/src/stream_encoder.rs @@ -14,29 +14,5 @@ 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/video/codec/amlenc.rs b/src/video/codec/amlenc.rs deleted file mode 100644 index 4ee9b0c5..00000000 --- a/src/video/codec/amlenc.rs +++ /dev/null @@ -1,989 +0,0 @@ -//! 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(); - // s912 - compatible.contains("amlogic,gxm") - || compatible.contains("amlogic, gxm") - || compatible.contains("amlogic,meson-gxm") - || compatible.contains("amlogic,s912") - // s905d - || compatible.contains("amlogic,meson-gxl") - || compatible.contains("amlogic,s905d") -} - -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/h264.rs b/src/video/codec/h264.rs index 5ebc31e6..13d7a3a8 100644 --- a/src/video/codec/h264.rs +++ b/src/video/codec/h264.rs @@ -48,8 +48,6 @@ 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 @@ -66,7 +64,6 @@ 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"), } @@ -83,7 +80,6 @@ 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 dd5df5e7..08f3dbed 100644 --- a/src/video/codec/h265.rs +++ b/src/video/codec/h265.rs @@ -45,8 +45,6 @@ pub enum H265EncoderType { Rkmpp, /// V4L2 M2M (ARM generic) V4l2M2m, - /// Amlogic S912/GXM AMLENC - Amlogic, /// Software encoder (libx265) Software, /// No encoder available @@ -63,7 +61,6 @@ 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"), } @@ -79,7 +76,6 @@ 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 04d8823f..ba0ab378 100644 --- a/src/video/codec/mod.rs +++ b/src/video/codec/mod.rs @@ -3,7 +3,6 @@ use hwcodec::common::DataFormat; use hwcodec::ffmpeg_ram::CodecInfo; -pub mod amlenc; pub mod convert; pub mod h264; @@ -20,7 +19,6 @@ 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 3baa50c8..d12bb663 100644 --- a/src/video/codec/registry.rs +++ b/src/video/codec/registry.rs @@ -10,17 +10,11 @@ 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 { @@ -102,8 +96,6 @@ pub enum EncoderBackend { Rkmpp, /// V4L2 Memory-to-Memory (ARM) V4l2m2m, - /// Amlogic S912/GXM vendor AMLENC - Amlogic, /// Software encoding (libx264, libx265, libvpx) Software, } @@ -123,8 +115,6 @@ impl EncoderBackend { EncoderBackend::Rkmpp } else if name.contains("v4l2m2m") { EncoderBackend::V4l2m2m - } else if name.contains("amlenc") { - EncoderBackend::Amlogic } else { EncoderBackend::Software } @@ -144,7 +134,6 @@ impl EncoderBackend { EncoderBackend::Amf => "AMF", EncoderBackend::Rkmpp => "RKMPP", EncoderBackend::V4l2m2m => "V4L2 M2M", - EncoderBackend::Amlogic => "AMLENC", EncoderBackend::Software => "Software", } } @@ -159,7 +148,6 @@ 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, } @@ -286,79 +274,6 @@ 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. @@ -426,8 +341,6 @@ 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); @@ -624,14 +537,6 @@ 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] @@ -656,65 +561,4 @@ 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 8325e95a..be6eed32 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::{ - AmlencCodec, AmlencConfig, AmlencEncoder, EncoderRegistry, H264Config, H264Encoder, H265Config, - H265Encoder, VP8Config, VP8Encoder, VP9Config, VP9Encoder, VideoEncoderType, + EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder, + VP9Config, VP9Encoder, VideoEncoderType, }; use crate::error::{AppError, Result}; use crate::video::format::{PixelFormat, Resolution}; @@ -226,9 +226,6 @@ 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), @@ -237,37 +234,6 @@ 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/pipeline/encoder_state.rs b/src/video/pipeline/encoder_state.rs index 02e9a032..a7b63609 100644 --- a/src/video/pipeline/encoder_state.rs +++ b/src/video/pipeline/encoder_state.rs @@ -1,5 +1,4 @@ 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}; @@ -117,47 +116,6 @@ 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 { @@ -231,7 +189,7 @@ fn create_mjpeg_decoder(resolution: Resolution) -> Result<(MjpegDecoderKind, Pix Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12)) } -/// Amlogic hardware encoders and libjpeg-turbo use independent CPU/hardware +/// V4L2 M2M hardware encoding 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 { @@ -248,12 +206,7 @@ pub(super) fn should_parallel_decode_mjpeg(config: &SharedVideoPipelineConfig) - Some(backend) => registry.encoder_with_backend(config.output_codec, backend), None => registry.best_available_encoder(config.output_codec), }; - selected.is_some_and(|encoder| { - matches!( - encoder.backend, - EncoderBackend::Amlogic | EncoderBackend::V4l2m2m - ) - }) + selected.is_some_and(|encoder| encoder.backend == EncoderBackend::V4l2m2m) } pub(super) fn build_encoder_state( @@ -420,80 +373,70 @@ pub(super) fn build_encoder_state( let encoder: Box = match config.output_codec { VideoEncoderType::H264 => { let codec_name = selected_codec_name.clone(); - 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 - ); + 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 } + }); - create_h264_encoder(config, input_format, &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)? } VideoEncoderType::H265 => { let codec_name = selected_codec_name.clone(); - 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 - ); + 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 } + }); - 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, - }, - &codec_name, - )?; - info!("Created H265 encoder: {}", encoder.codec_name()); - Box::new(H265EncoderWrapper(encoder)) + 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, + gop_size: config.gop_size(), + }, + 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)) } VideoEncoderType::VP8 => { let codec_name = selected_codec_name.clone(); @@ -528,9 +471,7 @@ pub(super) fn build_encoder_state( }; let codec_name = encoder.codec_name(); - let use_direct_input = if codec_name.contains("amlenc") { - pipeline_input_format == PixelFormat::Nv12 - } else if codec_name.contains("rkmpp") { + let use_direct_input = if codec_name.contains("rkmpp") { matches!( pipeline_input_format, PixelFormat::Yuyv diff --git a/src/video/pipeline/shared.rs b/src/video/pipeline/shared.rs index 8fb51921..6cacd308 100644 --- a/src/video/pipeline/shared.rs +++ b/src/video/pipeline/shared.rs @@ -30,7 +30,6 @@ use super::encoder_state::{build_encoder_state, should_parallel_decode_mjpeg, En /// 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_SOFT_RESTART_THRESHOLD: u32 = 3; @@ -56,9 +55,6 @@ 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; @@ -287,9 +283,8 @@ 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. + /// Becomes true only after the synchronous encoder worker has exited and + /// dropped its encoder handles. encoder_done: watch::Sender, encoder_done_rx: watch::Receiver, h264_profile_level_id: watch::Sender>, @@ -554,17 +549,6 @@ 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; @@ -596,9 +580,6 @@ impl SharedVideoPipeline { } 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{} {:?} @ {} fps (configured {}x{} {:?} @ {} fps) — aligning encoder to source", @@ -741,8 +722,7 @@ impl SharedVideoPipeline { } pipeline.clear_cmd_tx(); - // Dropping encoder_state here releases AMLENC before a caller - // is allowed to construct a replacement pipeline. + // Release encoder resources before allowing a replacement pipeline. drop(encoder_state); let _ = pipeline.encoder_done.send(true); }); @@ -1604,11 +1584,6 @@ 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] diff --git a/src/webrtc/webrtc_streamer.rs b/src/webrtc/webrtc_streamer.rs index 27884dc4..852c403d 100644 --- a/src/webrtc/webrtc_streamer.rs +++ b/src/webrtc/webrtc_streamer.rs @@ -14,7 +14,6 @@ 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, VideoControlMode, VideoDevice, VideoDeviceInfo, VideoDeviceRecoveryHint, @@ -1314,26 +1313,6 @@ impl WebRtcStreamer { }; if pipeline_running { - 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(()); - } - } - info!("Restarting video pipeline to apply new bitrate: {}", preset); self.stop_video_pipeline_and_release().await?; diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index 890ea13d..5223b850 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -148,7 +148,6 @@ export enum EncoderType { Amf = "amf", Rkmpp = "rkmpp", V4l2m2m = "v4l2m2m", - Amlogic = "amlogic", } export type BitratePreset = From d613739b068d99fee1713d3e92097b002cc8c48f Mon Sep 17 00:00:00 2001 From: TXIuTnVsbA Date: Wed, 26 Aug 2026 15:19:41 +0800 Subject: [PATCH 09/29] =?UTF-8?q?fix(hid):=20=E5=85=BC=E5=AE=B9=20CH9329/C?= =?UTF-8?q?H9329F=20=E4=B8=B2=E5=8F=A3=E5=93=8D=E5=BA=94=20(#300)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持 CH9329/CH9329F 扩展参数响应、粘包与乱序处理,保留严格校验和及有界接收缓冲。 --- src/hid/ch9329.rs | 52 +++++++++++++------- src/hid/ch9329_proto.rs | 106 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 138 insertions(+), 20 deletions(-) diff --git a/src/hid/ch9329.rs b/src/hid/ch9329.rs index 4a1a2e18..49fe730e 100644 --- a/src/hid/ch9329.rs +++ b/src/hid/ch9329.rs @@ -44,7 +44,10 @@ const PARAM_CFG_VID_PID_OFFSET: usize = 11; const PARAM_CFG_STRING_FLAGS_OFFSET: usize = 36; const DESCRIPTOR_READ_RETRIES: usize = 3; const DESCRIPTOR_RETRY_DELAY_MS: u64 = 80; -const DESCRIPTOR_APPLY_RESET_WAIT_MS: u64 = 3000; + +// CH9329/CH9329F can take several seconds to restart after a descriptor update. +const DESCRIPTOR_APPLY_RESET_WAIT_MS: u64 = 5000; + const USB_STRING_MAX_LEN: usize = 23; const USB_STRING_FLAG_ENABLE: u8 = 0x80; const USB_STRING_FLAG_MANUFACTURER: u8 = 0x04; @@ -368,33 +371,41 @@ impl Ch9329Backend { Self::write_packet(port, address, cmd, data)?; - let mut pending = Vec::with_capacity(128); + // Keep enough room for a full parameter response and adjacent packets. + let mut pending = Vec::with_capacity(256); let deadline = Instant::now() + Duration::from_millis(RESPONSE_TIMEOUT_MS); let expected_ok = expected_response_cmd(cmd, false); let expected_err = expected_response_cmd(cmd, true); loop { - let mut chunk = [0u8; 128]; + let mut chunk = [0u8; 256]; match port.read(&mut chunk) { Ok(n) if n > 0 => { pending.extend_from_slice(&chunk[..n]); + // Drain every complete frame so adjacent/out-of-order responses + // cannot block the response for the current command. while let Some((response, consumed)) = try_extract_response(&pending) { + let current_response_cmd = response.cmd; pending.drain(..consumed); - if response.cmd == expected_ok || response.cmd == expected_err { + + if current_response_cmd == expected_ok + || current_response_cmd == expected_err + { return Ok(response); } trace!( - "CH9329 ignored out-of-order response: expected 0x{:02X}/0x{:02X}, got 0x{:02X}", + "CH9329 filtered an overlapping packet: expected 0x{:02X}/0x{:02X}, bypass 0x{:02X}", expected_ok, expected_err, - response.cmd + current_response_cmd ); } + // Bound memory use if a noisy or disconnected port keeps delivering bytes. if pending.len() > MAX_PACKET_SIZE * 4 { - let keep = MAX_PACKET_SIZE; + let keep = MAX_PACKET_SIZE * 2; pending.drain(..pending.len().saturating_sub(keep)); } } @@ -410,15 +421,19 @@ impl Ch9329Backend { if Instant::now() >= deadline { return Err(Self::backend_error( - format!("No matching response from CH9329 for cmd 0x{:02X}", cmd), + format!( + "No matching response from CH9329 for cmd 0x{:02X}. Remaining buffer: {}", + cmd, + Self::hex_bytes(&pending) + ), "no_response", )); } - thread::sleep(Duration::from_millis(1)); + // Give the serial driver a short opportunity to deliver the next chunk. + thread::sleep(Duration::from_micros(200)); } } - fn try_best_effort_reset(port: &mut dyn serialport::SerialPort, address: u8) { if let Err(err) = Self::write_packet(port, address, cmd::RESET, &[]) { trace!("CH9329 best-effort reset failed: {}", err); @@ -698,7 +713,6 @@ impl Ch9329Backend { let mut port = Self::open_port(port_path, baud_rate)?; Self::read_device_descriptor_on_port(port.as_mut(), DEFAULT_ADDR) } - fn open_ready_port( port_path: &str, baud_rate: u32, @@ -869,7 +883,7 @@ impl Ch9329Backend { match Self::open_ready_port(port_path, baud_rate, address) { Ok((port, info)) => { info!( - "CH9329 reconnected: {}, USB: {}", + "CH9329-compatible chip reconnected: {}, USB: {}", info.version, if info.usb_connected { "connected" @@ -892,7 +906,6 @@ impl Ch9329Backend { } } } - fn recover_worker_port( mut port: Box, rx: &mpsc::Receiver, @@ -1170,7 +1183,7 @@ impl HidBackend for Ch9329Backend { match init_rx.recv_timeout(Duration::from_millis(INIT_WAIT_MS)) { Ok(Ok(info)) => { info!( - "CH9329 chip detected: {}, USB: {}, LEDs: NumLock={}, CapsLock={}, ScrollLock={}", + "CH9329-compatible chip detected: {}, USB: {}, LEDs: NumLock={}, CapsLock={}, ScrollLock={}", info.version, if info.usb_connected { "connected" @@ -1189,13 +1202,13 @@ impl HidBackend for Ch9329Backend { Ok(Err(err)) => { self.record_error( format!( - "CH9329 not responding on {} @ {} baud: {}", + "CH9329-compatible chip not responding on {} @ {} baud: {}", self.port_path, self.baud_rate, err ), "init_failed", ); warn!( - "CH9329 not responding on {} @ {} baud, retrying in background: {}", + "CH9329-compatible chip not responding on {} @ {} baud, retrying in background: {}", self.port_path, self.baud_rate, err ); *self.worker_tx.lock() = Some(tx); @@ -1205,9 +1218,12 @@ impl HidBackend for Ch9329Backend { Err(_) => { let _ = tx.send(WorkerCommand::Shutdown); let _ = handle.join(); - self.record_error("Timed out waiting for CH9329 worker init", "init_timeout"); + self.record_error( + "Timed out waiting for CH9329-compatible worker init", + "init_timeout", + ); Err(AppError::Internal( - "Timed out waiting for CH9329 initialization".to_string(), + "Timed out waiting for CH9329-compatible initialization".to_string(), )) } } diff --git a/src/hid/ch9329_proto.rs b/src/hid/ch9329_proto.rs index c0594f30..28eb9202 100644 --- a/src/hid/ch9329_proto.rs +++ b/src/hid/ch9329_proto.rs @@ -10,6 +10,7 @@ pub const DEFAULT_ADDR: u8 = 0x00; pub const DEFAULT_BAUD_RATE: u32 = 9600; pub const MAX_DATA_LEN: usize = 64; pub const MAX_PACKET_SIZE: usize = 70; +const EXTENDED_PARAMETER_RESPONSE_SIZES: [usize; 2] = [72, 88]; pub mod cmd { pub const GET_INFO: u8 = 0x01; @@ -130,7 +131,8 @@ impl Response { let cmd = bytes[3]; let len = bytes[4] as usize; - if bytes.len() < 5 + len + 1 { + let expected_frame_len = 6 + len; + if bytes.len() < expected_frame_len { return None; } @@ -139,7 +141,7 @@ impl Response { .iter() .fold(0u8, |acc, &x| acc.wrapping_add(x)); if expected_checksum != calculated_checksum { - tracing::warn!( + tracing::debug!( "CH9329 checksum mismatch: expected {:02X}, got {:02X}", expected_checksum, calculated_checksum @@ -215,6 +217,11 @@ pub fn try_extract_response(buffer: &[u8]) -> Option<(Response, usize)> { } let len = buffer[offset + 4] as usize; + if len > MAX_DATA_LEN { + offset += 1; + continue; + } + let frame_len = 6 + len; if offset + frame_len > buffer.len() { return None; @@ -225,8 +232,103 @@ pub fn try_extract_response(buffer: &[u8]) -> Option<(Response, usize)> { return Some((response, offset + frame_len)); } + // Some CH9329F firmware appends reserved bytes to GET_PARA_CFG while + // retaining the protocol LEN value of 50. Locate and validate the real + // checksum, return the documented 50-byte payload, and consume the + // complete extended frame. Other commands keep strict framing. + let cmd = buffer[offset + 3]; + let data_start = offset + 5; + let parameter_payload_is_plausible = cmd == expected_response_cmd(cmd::GET_PARA_CFG, false) + && len == 50 + && matches!(buffer[data_start], 0x00..=0x03 | 0x80..=0x83) + && matches!(buffer[data_start + 1], 0x00..=0x02 | 0x80..=0x82); + if parameter_payload_is_plausible { + for extended_size in EXTENDED_PARAMETER_RESPONSE_SIZES { + let extended_end = offset + extended_size; + if buffer.len() >= extended_end { + let checksum_index = extended_end - 1; + if calculate_checksum(&buffer[offset..checksum_index]) != buffer[checksum_index] + { + continue; + } + return Some(( + Response { + cmd, + data: buffer[data_start..data_start + len].to_vec(), + is_error: false, + error_code: None, + }, + extended_end, + )); + } + } + + if buffer.len() < offset + EXTENDED_PARAMETER_RESPONSE_SIZES[1] { + return None; + } + } + offset += 1; } None } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_standard_response_and_checksum() { + let frame = build_packet(DEFAULT_ADDR, 0x81, &[0x30, 0x01, 0x00, 0, 0, 0, 0, 0]); + let response = Response::parse(&frame).expect("valid response"); + assert_eq!(response.cmd, 0x81); + assert_eq!(response.data, vec![0x30, 0x01, 0x00, 0, 0, 0, 0, 0]); + assert!(!response.is_error); + } + + #[test] + fn extracts_extended_parameter_response_with_valid_trailing_checksum() { + let payload = [ + 0x80, 0x80, 0x00, 0x00, 0x00, 0x25, 0x80, 0x08, 0x00, 0x00, 0x03, 0x86, 0x1A, 0x2A, + 0xE1, 0x00, 0x00, 0x00, 0x01, 0x00, 0x0D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]; + for reserved_len in [16, 32] { + let mut frame = vec![0x57, 0xAB, DEFAULT_ADDR, 0x88, 50]; + frame.extend_from_slice(&payload); + frame.extend_from_slice(&vec![0; reserved_len]); + frame.push(calculate_checksum(&frame)); + + assert!(Response::parse(&frame[..56]).is_none()); + let (response, consumed) = try_extract_response(&frame).expect("extended response"); + assert_eq!(response.cmd, 0x88); + assert_eq!(response.data, payload); + assert!(!response.is_error); + assert_eq!(consumed, frame.len()); + } + } + + #[test] + fn rejects_bad_checksum_for_other_commands() { + let mut frame = build_packet(DEFAULT_ADDR, 0x89, &[0x00; 50]); + *frame.last_mut().unwrap() ^= 0xFF; + assert!(Response::parse(&frame).is_none()); + assert!(try_extract_response(&frame).is_none()); + } + + #[test] + fn extracts_noise_and_adjacent_packets() { + let first = build_packet(DEFAULT_ADDR, 0x81, &[0x30, 0x01, 0, 0, 0, 0, 0, 0]); + let second = build_packet(DEFAULT_ADDR, 0x82, &[0x00]); + let mut buffer = vec![0x00, 0xFF]; + buffer.extend_from_slice(&first); + buffer.extend_from_slice(&second); + + let (_, consumed) = try_extract_response(&buffer).expect("first response"); + assert_eq!(consumed, 2 + first.len()); + let (response, _) = try_extract_response(&buffer[consumed..]).expect("second response"); + assert_eq!(response.cmd, 0x82); + } +} From 1d48d45592bfdb6436465f6ec9b412d0554dd228 Mon Sep 17 00:00:00 2001 From: a15355447898 Date: Mon, 3 Aug 2026 02:40:15 +0000 Subject: [PATCH 10/29] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=AB=98?= =?UTF-8?q?=E9=80=9A=20iris=20v4l2m2m=20=E7=A1=AC=E4=BB=B6=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增高通平台检测,避免 Amlogic v4l2m2m 检测误判 - 对高通 Iris 编码器调整 GOP 和 capture buffers - 强制关键帧仅在高通 Iris 之外启用 --- .../cpp/common/platform/linux/linux.cpp | 64 ++++++++++++++++++- libs/hwcodec/cpp/common/util.cpp | 41 ++++++++++-- libs/hwcodec/cpp/common/util.h | 3 + .../cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp | 4 +- 4 files changed, 105 insertions(+), 7 deletions(-) diff --git a/libs/hwcodec/cpp/common/platform/linux/linux.cpp b/libs/hwcodec/cpp/common/platform/linux/linux.cpp index 94ccd566..a4b160d9 100644 --- a/libs/hwcodec/cpp/common/platform/linux/linux.cpp +++ b/libs/hwcodec/cpp/common/platform/linux/linux.cpp @@ -146,6 +146,67 @@ int linux_support_v4l2m2m() { return false; }; + auto is_qcom_platform = [&]() -> bool { + const char *platform_hints[] = { + "qcom", + "qualcomm", + "venus", + "iris", + "sc7280", + "qcm6490", + "qcs6490", + }; + + const char *platform_files[] = { + "/proc/device-tree/compatible", + "/proc/device-tree/model", + "/sys/firmware/devicetree/base/compatible", + "/sys/firmware/devicetree/base/model", + }; + + for (size_t i = 0; i < sizeof(platform_files) / sizeof(platform_files[0]); i++) { + std::string value; + if (read_text_file(platform_files[i], &value) && + contains_any(to_lower(value), platform_hints, + sizeof(platform_hints) / sizeof(platform_hints[0]))) { + return true; + } + } + + const char *video_nodes[] = { + "video0", + "video1", + "video2", + "video3", + "video10", + "video11", + "video32", + }; + const char *video_hints[] = { + "qcom-iris", + "qcom,", + "venus", + "iris", + }; + + for (size_t i = 0; i < sizeof(video_nodes) / sizeof(video_nodes[0]); i++) { + std::string name; + std::string modalias; + const std::string base = std::string("/sys/class/video4linux/") + video_nodes[i]; + if (read_text_file((base + "/name").c_str(), &name) && + contains_any(to_lower(name), video_hints, sizeof(video_hints) / sizeof(video_hints[0]))) { + return true; + } + if (read_text_file((base + "/device/modalias").c_str(), &modalias) && + contains_any(to_lower(modalias), video_hints, + sizeof(video_hints) / sizeof(video_hints[0]))) { + return true; + } + } + + return false; + }; + auto is_amlogic_platform = [&]() -> bool { const char *platform_hints[] = { "amlogic", @@ -210,7 +271,8 @@ int linux_support_v4l2m2m() { return false; }; - const bool amlogic_platform = is_amlogic_platform(); + const bool qcom_platform = is_qcom_platform(); + const bool amlogic_platform = !qcom_platform && is_amlogic_platform(); if (amlogic_platform && !v4l2m2m_allowed()) { LOG_WARN(std::string( "V4L2 M2M: skipped probe on Amlogic platform; set ONE_KVM_V4L2M2M_ALLOW=1 to enable")); diff --git a/libs/hwcodec/cpp/common/util.cpp b/libs/hwcodec/cpp/common/util.cpp index 282e5710..0894f4eb 100644 --- a/libs/hwcodec/cpp/common/util.cpp +++ b/libs/hwcodec/cpp/common/util.cpp @@ -4,6 +4,9 @@ extern "C" { } #include "util.h" +#include +#include +#include #include #include #include @@ -45,17 +48,46 @@ bool is_software_hevc(const std::string &name) { return true; } +bool is_qcom_iris_driver() { + const char *driver_path = "/sys/class/video4linux/video1/name"; + std::ifstream file(driver_path); + if (!file.is_open()) return false; + + std::string value; + std::getline(file, value, '\0'); + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value.find("qcom-iris") != std::string::npos || + value.find("iris-encoder") != std::string::npos || + value.find("iris") != std::string::npos; +} + } // anonymous namespace namespace util_encode { +bool is_qcom_iris_platform() { + return is_qcom_iris_driver(); +} + +bool supports_forced_keyframe(const std::string &name) { + if (name.find("v4l2m2m") != std::string::npos && is_qcom_iris_platform()) { + return false; + } + return true; +} + void set_av_codec_ctx(AVCodecContext *c, const std::string &name, int kbs, int gop, int fps, int thread_count) { c->has_b_frames = 0; c->max_b_frames = 0; - if (gop > 0 && gop < std::numeric_limits::max()) { - c->gop_size = gop; - c->keyint_min = gop; // Match keyint_min to gop for consistent keyframe interval + const bool qcom_iris_v4l2 = + name.find("v4l2m2m") != std::string::npos && is_qcom_iris_platform(); + const int effective_gop = qcom_iris_v4l2 ? std::max(5, fps / 3) : gop; + if (effective_gop > 0 && effective_gop < std::numeric_limits::max()) { + c->gop_size = effective_gop; + c->keyint_min = effective_gop; // Match keyint_min to gop for consistent keyframe interval } else if (name.find("vaapi") != std::string::npos) { c->gop_size = fps > 0 ? fps : 30; // Default to 1 second keyframe interval c->keyint_min = c->gop_size; @@ -153,7 +185,8 @@ bool set_lantency_free(void *priv_data, const std::string &name) { av_err2str(ret)); // Not fatal } - if ((ret = av_opt_set_int(priv_data, "num_capture_buffers", 4, 0)) < 0) { + const int capture_buffers = is_qcom_iris_driver() ? 12 : 8; + if ((ret = av_opt_set_int(priv_data, "num_capture_buffers", capture_buffers, 0)) < 0) { LOG_DEBUG(std::string("v4l2m2m num_capture_buffers option is unavailable, ret = ") + av_err2str(ret)); // Not fatal diff --git a/libs/hwcodec/cpp/common/util.h b/libs/hwcodec/cpp/common/util.h index 0e5f4ee4..9a924692 100644 --- a/libs/hwcodec/cpp/common/util.h +++ b/libs/hwcodec/cpp/common/util.h @@ -9,6 +9,9 @@ extern "C" { namespace util_encode { +bool is_qcom_iris_platform(); +bool supports_forced_keyframe(const std::string &name); + void set_av_codec_ctx(AVCodecContext *c, const std::string &name, int kbs, int gop, int fps, int thread_count); bool set_lantency_free(void *priv_data, const std::string &name); diff --git a/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp b/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp index 18f32ee6..19ffd84b 100644 --- a/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp +++ b/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp @@ -379,12 +379,12 @@ private: frame->pts = ms; // Force keyframe if requested - if (force_keyframe_) { + if (force_keyframe_ && util_encode::supports_forced_keyframe(name_)) { frame->pict_type = AV_PICTURE_TYPE_I; - force_keyframe_ = false; } else { frame->pict_type = AV_PICTURE_TYPE_NONE; } + force_keyframe_ = false; ret = avcodec_send_frame(c_, frame); if (ret == AVERROR(EAGAIN)) { From db7a845d3be4e722ba454960f0a55d05f6123818 Mon Sep 17 00:00:00 2001 From: BigfootACA Date: Thu, 6 Aug 2026 10:52:13 +0800 Subject: [PATCH 11/29] fix(hwcodec): release V4L2M2M packets before delivery Copy compressed V4L2M2M output before asynchronous delivery. This releases driver capture buffers and prevents encoder stalls. Signed-off-by: BigfootACA --- libs/hwcodec/src/ffmpeg_ram/encode.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/libs/hwcodec/src/ffmpeg_ram/encode.rs b/libs/hwcodec/src/ffmpeg_ram/encode.rs index aa08a26d..7f01dff0 100644 --- a/libs/hwcodec/src/ffmpeg_ram/encode.rs +++ b/libs/hwcodec/src/ffmpeg_ram/encode.rs @@ -592,6 +592,16 @@ impl Encoder { Some(Encoder::packet_callback), ); if result == -11 || result == 0 { + if self.ctx.name.contains("v4l2m2m") { + return Ok(frames + .into_iter() + .map(|frame| EncodeBytesFrame { + data: Bytes::copy_from_slice(frame.data.as_ref()), + pts: frame.pts, + key: frame.key, + }) + .collect()); + } return Ok(frames); } Err(result) From 9c6d8a614cd665d1ec95c120331ff13712f9b8f5 Mon Sep 17 00:00:00 2001 From: BigfootACA Date: Thu, 6 Aug 2026 10:51:55 +0800 Subject: [PATCH 12/29] fix(webrtc): preserve H.26x headers for new sessions Detect random-access frames from Annex-B data and cache codec headers. Prepend missing headers to later H.264 IDR and H.265 IRAP frames. Forward standalone headers while WebRTC waits for a decodable frame. Signed-off-by: BigfootACA --- src/video/codec/h265_bitstream.rs | 112 ++++++++++++++++++++++++++++++ src/video/codec/mod.rs | 1 + src/video/pipeline/shared.rs | 104 +++++++++++++++++++++++++-- src/webrtc/universal_session.rs | 19 +++-- 4 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 src/video/codec/h265_bitstream.rs diff --git a/src/video/codec/h265_bitstream.rs b/src/video/codec/h265_bitstream.rs new file mode 100644 index 00000000..0c0b58f7 --- /dev/null +++ b/src/video/codec/h265_bitstream.rs @@ -0,0 +1,112 @@ +const VPS_NAL_TYPE: u8 = 32; +const SPS_NAL_TYPE: u8 = 33; +const PPS_NAL_TYPE: u8 = 34; + +fn find_start_code(data: &[u8], from: usize) -> Option<(usize, usize)> { + let mut offset = from; + while offset + 3 <= data.len() { + if offset + 4 <= data.len() && data[offset..offset + 4] == [0, 0, 0, 1] { + return Some((offset, 4)); + } + if data[offset..offset + 3] == [0, 0, 1] { + return Some((offset, 3)); + } + offset += 1; + } + None +} + +fn for_each_nal(data: &[u8], mut visit: impl FnMut(u8, &[u8])) { + let mut cursor = 0; + while let Some((start, start_code_len)) = find_start_code(data, cursor) { + let nal_start = start + start_code_len; + if nal_start + 2 > data.len() { + break; + } + let next_start = find_start_code(data, nal_start) + .map(|(offset, _)| offset) + .unwrap_or(data.len()); + let mut nal_end = next_start; + while nal_end > nal_start && data[nal_end - 1] == 0 { + nal_end -= 1; + } + if nal_end >= nal_start + 2 { + visit((data[nal_start] >> 1) & 0x3f, &data[nal_start..nal_end]); + } + if next_start == data.len() { + break; + } + cursor = next_start; + } +} + +pub fn is_keyframe(data: &[u8]) -> bool { + let mut keyframe = false; + for_each_nal(data, |nal_type, _| { + if (16..=23).contains(&nal_type) { + keyframe = true; + } + }); + keyframe +} + +pub fn extract_vps_sps_pps(data: &[u8]) -> (Option>, Option>, Option>) { + let mut vps = None; + let mut sps = None; + let mut pps = None; + for_each_nal(data, |nal_type, nal| match nal_type { + VPS_NAL_TYPE => vps = Some(nal.to_vec()), + SPS_NAL_TYPE => sps = Some(nal.to_vec()), + PPS_NAL_TYPE => pps = Some(nal.to_vec()), + _ => {} + }); + (vps, sps, pps) +} + +pub fn has_vps_sps_pps(data: &[u8]) -> bool { + let (vps, sps, pps) = extract_vps_sps_pps(data); + vps.is_some() && sps.is_some() && pps.is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_irap_but_not_trail_frame() { + assert!(is_keyframe(&[0, 0, 0, 1, 19 << 1, 1, 0xaa])); + assert!(is_keyframe(&[0, 0, 1, 21 << 1, 1, 0xbb])); + assert!(!is_keyframe(&[0, 0, 0, 1, 1 << 1, 1, 0xcc])); + } + + #[test] + fn extracts_parameter_sets() { + let data = [ + 0, + 0, + 0, + 1, + 32 << 1, + 1, + 0xaa, + 0, + 0, + 1, + 33 << 1, + 1, + 0xbb, + 0, + 0, + 0, + 1, + 34 << 1, + 1, + 0xcc, + ]; + let (vps, sps, pps) = extract_vps_sps_pps(&data); + assert_eq!(vps.unwrap(), [32 << 1, 1, 0xaa]); + assert_eq!(sps.unwrap(), [33 << 1, 1, 0xbb]); + assert_eq!(pps.unwrap(), [34 << 1, 1, 0xcc]); + assert!(has_vps_sps_pps(&data)); + } +} diff --git a/src/video/codec/mod.rs b/src/video/codec/mod.rs index ba0ab378..cfa663a3 100644 --- a/src/video/codec/mod.rs +++ b/src/video/codec/mod.rs @@ -8,6 +8,7 @@ pub mod convert; pub mod h264; pub mod h264_bitstream; pub mod h265; +pub mod h265_bitstream; pub mod jpeg; pub mod registry; pub mod self_check; diff --git a/src/video/pipeline/shared.rs b/src/video/pipeline/shared.rs index 6cacd308..ddfe33a0 100644 --- a/src/video/pipeline/shared.rs +++ b/src/video/pipeline/shared.rs @@ -48,9 +48,9 @@ use crate::video::capture::status::{ signal_status_from_capture_kind, CaptureIoErrorKind, }; use crate::video::capture::{BridgeContext, CaptureReadError, CaptureStream}; -use crate::video::codec::h264_bitstream; use crate::video::codec::registry::{EncoderBackend, VideoEncoderType}; use crate::video::codec::MjpegToNv12Decoder; +use crate::video::codec::{h264_bitstream, h265_bitstream}; use crate::video::device::parse_bridge_kind; use crate::video::device::VideoControlMode; use crate::video::format::{PixelFormat, Resolution}; @@ -276,6 +276,15 @@ pub struct SharedVideoPipelineStats { pub current_fps: f32, } +#[derive(Default)] +struct CachedH26xParameterSets { + h264_sps: Option>, + h264_pps: Option>, + h265_vps: Option>, + h265_sps: Option>, + h265_pps: Option>, +} + /// Universal shared video pipeline pub struct SharedVideoPipeline { config: RwLock, @@ -296,6 +305,7 @@ pub struct SharedVideoPipeline { sequence: AtomicU64, /// Atomic flag for keyframe request (avoids lock contention) keyframe_requested: AtomicBool, + parameter_sets: ParkingMutex, /// Pipeline start time for monotonic PTS calculation (microseconds from process start). /// Uses AtomicI64 instead of Mutex for lock-free access. pipeline_start_time_us: AtomicI64, @@ -335,6 +345,7 @@ impl SharedVideoPipeline { running_flag: AtomicBool::new(false), sequence: AtomicU64::new(0), keyframe_requested: AtomicBool::new(false), + parameter_sets: ParkingMutex::new(CachedH26xParameterSets::default()), pipeline_start_time_us: AtomicI64::new(0), pending_sync_geometry: ParkingMutex::new(None), device_lost_reason: ParkingMutex::new(None), @@ -507,6 +518,86 @@ impl SharedVideoPipeline { let _ = self.h264_profile_level_id.send(Some(profile_level_id)); } + fn inspect_and_parameterize_packet( + &self, + codec: VideoEncoderType, + data: Bytes, + ffmpeg_keyframe: bool, + ) -> (Bytes, bool) { + match codec { + VideoEncoderType::H264 => { + let (sps, pps) = h264_bitstream::extract_sps_pps(data.as_ref()); + let is_keyframe = ffmpeg_keyframe || h264_bitstream::is_keyframe(data.as_ref()); + let mut cache = self.parameter_sets.lock(); + if let Some(sps) = sps.as_ref() { + cache.h264_sps = Some(sps.clone()); + } + if let Some(pps) = pps.as_ref() { + cache.h264_pps = Some(pps.clone()); + } + + if !is_keyframe || (sps.is_some() && pps.is_some()) { + return (data, is_keyframe); + } + + match (&cache.h264_sps, &cache.h264_pps) { + (Some(cached_sps), Some(cached_pps)) => { + let mut output = Vec::with_capacity( + data.len() + cached_sps.len() + cached_pps.len() + 8, + ); + output.extend_from_slice(&[0, 0, 0, 1]); + output.extend_from_slice(cached_sps); + output.extend_from_slice(&[0, 0, 0, 1]); + output.extend_from_slice(cached_pps); + output.extend_from_slice(data.as_ref()); + debug!("[Pipeline] Prepended cached SPS/PPS to H264 IDR"); + (Bytes::from(output), true) + } + _ => (data, true), + } + } + VideoEncoderType::H265 => { + let (vps, sps, pps) = h265_bitstream::extract_vps_sps_pps(data.as_ref()); + let is_keyframe = ffmpeg_keyframe || h265_bitstream::is_keyframe(data.as_ref()); + let mut cache = self.parameter_sets.lock(); + if let Some(vps) = vps.as_ref() { + cache.h265_vps = Some(vps.clone()); + } + if let Some(sps) = sps.as_ref() { + cache.h265_sps = Some(sps.clone()); + } + if let Some(pps) = pps.as_ref() { + cache.h265_pps = Some(pps.clone()); + } + + if !is_keyframe || (vps.is_some() && sps.is_some() && pps.is_some()) { + return (data, is_keyframe); + } + + match (&cache.h265_vps, &cache.h265_sps, &cache.h265_pps) { + (Some(cached_vps), Some(cached_sps), Some(cached_pps)) => { + let mut output = Vec::with_capacity( + data.len() + + cached_vps.len() + + cached_sps.len() + + cached_pps.len() + + 12, + ); + for parameter_set in [cached_vps, cached_sps, cached_pps] { + output.extend_from_slice(&[0, 0, 0, 1]); + output.extend_from_slice(parameter_set); + } + output.extend_from_slice(data.as_ref()); + debug!("[Pipeline] Prepended cached VPS/SPS/PPS to H265 IRAP"); + (Bytes::from(output), true) + } + _ => (data, true), + } + } + _ => (data, ffmpeg_keyframe), + } + } + fn broadcast_encoded(&self, frame: Arc) { let subscribers = { let guard = self.subscribers.read(); @@ -1193,9 +1284,11 @@ impl SharedVideoPipeline { })?; if let Some((data, is_keyframe)) = packet { + let (data, is_keyframe) = + self.inspect_and_parameterize_packet(codec, Bytes::from(data), is_keyframe); let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1; return Ok(vec![EncodedVideoFrame { - data: Bytes::from(data), + data, pts_ms, is_keyframe, sequence, @@ -1275,14 +1368,15 @@ impl SharedVideoPipeline { let mut encoded_frames = Vec::with_capacity(frames.len()); for encoded in frames { - let is_keyframe = encoded.key == 1; + let (data, is_keyframe) = + self.inspect_and_parameterize_packet(codec, encoded.data, encoded.key == 1); let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1; if codec == VideoEncoderType::H264 { - self.update_h264_profile_level_id(&encoded.data); + self.update_h264_profile_level_id(&data); } encoded_frames.push(EncodedVideoFrame { - data: encoded.data, + data, pts_ms, is_keyframe, sequence, diff --git a/src/webrtc/universal_session.rs b/src/webrtc/universal_session.rs index 83b50a3b..6b4a8059 100644 --- a/src/webrtc/universal_session.rs +++ b/src/webrtc/universal_session.rs @@ -36,7 +36,7 @@ use crate::audio::OpusFrame; use crate::error::{AppError, Result}; use crate::hid::datachannel::{parse_hid_message, HidChannelEvent}; use crate::hid::HidController; -use crate::video::codec::h264_bitstream; +use crate::video::codec::{h264_bitstream, h265_bitstream}; use crate::video::types::{ BitratePreset, EncodedVideoFrame, PixelFormat, Resolution, VideoEncoderType, }; @@ -638,11 +638,16 @@ impl UniversalSession { next_keyframe_retry = Instant::now(); } - // Some H264 encoders output SPS/PPS in a separate non-keyframe AU - // before IDR. Keep this frame so browser can decode the next IDR. - let forward_h264_parameter_frame = waiting_for_keyframe - && expected_codec == VideoEncoderType::H264 - && h264_bitstream::has_sps_pps(encoded_frame.data.as_ref()); + let forward_parameter_frame = waiting_for_keyframe + && match expected_codec { + VideoEncoderType::H264 => h264_bitstream::has_sps_pps( + encoded_frame.data.as_ref(), + ), + VideoEncoderType::H265 => h265_bitstream::has_vps_sps_pps( + encoded_frame.data.as_ref(), + ), + _ => false, + }; let now = Instant::now(); if keyframe_requests < KEYFRAME_RETRY_LIMIT @@ -659,7 +664,7 @@ impl UniversalSession { ); } } - if !forward_h264_parameter_frame { + if !forward_parameter_frame { continue; } } From e678ec394d98c3151c4e716c96ca0402dc88058d Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Wed, 26 Aug 2026 17:40:04 +0800 Subject: [PATCH 13/29] =?UTF-8?q?feat(rustdesk):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=8B=AC=E5=8D=A0=E5=BC=8F=20IP=20=E7=9B=B4=E8=BF=9E=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/runtime/remote_access.rs | 8 +- src/rustdesk/config.rs | 58 +++++++- src/rustdesk/connection.rs | 214 ++++++++++++++++++++++++---- src/rustdesk/mod.rs | 70 +++++---- src/web/handlers/config/rustdesk.rs | 30 ++-- src/web/handlers/config/types.rs | 82 ++++++++++- web/src/api/config.ts | 4 + web/src/i18n/en-US.ts | 9 ++ web/src/i18n/zh-CN.ts | 9 ++ web/src/types/generated.ts | 9 ++ web/src/views/SettingsView.vue | 62 +++++++- 11 files changed, 464 insertions(+), 91 deletions(-) diff --git a/src/runtime/remote_access.rs b/src/runtime/remote_access.rs index d5d74605..6b468faa 100644 --- a/src/runtime/remote_access.rs +++ b/src/runtime/remote_access.rs @@ -121,7 +121,7 @@ impl RemoteAccessCoordinator { let vnc = self.vnc.read().await.clone(); let rtsp = self.rtsp.read().await.clone(); - config.rustdesk.enabled = rustdesk.is_some_and(|service| service.is_listening()); + config.rustdesk.enabled = rustdesk.is_some_and(|service| service.is_running()); config.vnc.enabled = match vnc { Some(service) => matches!( service.status().await, @@ -191,8 +191,12 @@ impl RemoteAccessCoordinator { .await?; let need_restart = options.force + || old_config.mode != new_config.mode || old_config.codec != new_config.codec + || old_config.direct_access_port != new_config.direct_access_port || old_config.rendezvous_server != new_config.rendezvous_server + || old_config.relay_server != new_config.relay_server + || old_config.relay_key != new_config.relay_key || old_config.device_id != new_config.device_id || old_config.device_password != new_config.device_password; let current = self.rustdesk.read().await.clone(); @@ -224,7 +228,7 @@ impl RemoteAccessCoordinator { credentials_to_save = service.save_credentials(); } Some(service) => { - if service.is_listening() { + if service.is_running() { if need_restart { service.restart(new_config.clone()).await.map_err(|error| { AppError::Config(format!( diff --git a/src/rustdesk/config.rs b/src/rustdesk/config.rs index 0e0868b3..12dd09d5 100644 --- a/src/rustdesk/config.rs +++ b/src/rustdesk/config.rs @@ -11,12 +11,24 @@ pub enum RustDeskCodec { H265, } +#[typeshare] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum RustDeskMode { + #[default] + Id, + DirectIp, +} + #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct RustDeskConfig { pub enabled: bool, + pub mode: RustDeskMode, pub codec: RustDeskCodec, + pub direct_access_port: u16, pub rendezvous_server: String, pub relay_server: Option, #[typeshare(skip)] @@ -40,7 +52,9 @@ impl Default for RustDeskConfig { fn default() -> Self { Self { enabled: false, + mode: RustDeskMode::Id, codec: RustDeskCodec::H264, + direct_access_port: 21118, rendezvous_server: String::new(), relay_server: None, relay_key: None, @@ -58,9 +72,12 @@ impl Default for RustDeskConfig { impl RustDeskConfig { pub fn is_valid(&self) -> bool { self.enabled - && !self.rendezvous_server.is_empty() && !self.device_id.is_empty() && !self.device_password.is_empty() + && match self.mode { + RustDeskMode::Id => !self.rendezvous_server.trim().is_empty(), + RustDeskMode::DirectIp => self.direct_access_port != 0, + } } pub fn effective_rendezvous_server(&self) -> &str { @@ -214,4 +231,43 @@ mod tests { config.rendezvous_server = String::new(); assert_eq!(config.effective_rendezvous_server(), ""); } + + #[test] + fn direct_ip_mode_is_valid_without_rendezvous_server() { + let config = RustDeskConfig { + enabled: true, + mode: RustDeskMode::DirectIp, + rendezvous_server: String::new(), + ..Default::default() + }; + + assert!(config.is_valid()); + } + + #[test] + fn id_mode_is_invalid_without_rendezvous_server() { + let config = RustDeskConfig { + enabled: true, + mode: RustDeskMode::Id, + rendezvous_server: String::new(), + ..Default::default() + }; + + assert!(!config.is_valid()); + } + + #[test] + fn legacy_config_defaults_to_id_mode() { + let config: RustDeskConfig = serde_json::from_value(serde_json::json!({ + "enabled": false, + "codec": "h264", + "rendezvous_server": "", + "device_id": "123456789", + "device_password": "password" + })) + .expect("legacy RustDesk config should deserialize"); + + assert_eq!(config.mode, RustDeskMode::Id); + assert_eq!(config.direct_access_port, 21118); + } } diff --git a/src/rustdesk/connection.rs b/src/rustdesk/connection.rs index a435e4ee..ed51cd92 100644 --- a/src/rustdesk/connection.rs +++ b/src/rustdesk/connection.rs @@ -100,6 +100,12 @@ pub enum ConnectionState { Error(String), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionMode { + Secure, + DirectIp, +} + /// Incoming connection from a RustDesk client pub struct Connection { /// Connection ID @@ -113,12 +119,18 @@ pub struct Connection { /// Connection state state: Arc>, /// Our signing keypair (Ed25519) for signing SignedId messages - signing_keypair: SigningKeyPair, + signing_keypair: Option, /// Temporary Curve25519 keypair for this connection (used for encryption) /// Generated fresh for each connection, public key goes in IdPk.pk temp_keypair: (box_::PublicKey, box_::SecretKey), /// Device password password: String, + /// Connection path determines whether the RustDesk signed-ID handshake is used. + mode: ConnectionMode, + /// Password hashing salt sent to the client. + password_salt: String, + /// Per-connection challenge prevents replaying a captured password hash. + password_challenge: String, /// HID controller for keyboard/mouse events hid: Option>, /// Audio controller for audio streaming @@ -197,7 +209,8 @@ impl Connection { pub fn new( id: u32, config: &RustDeskConfig, - signing_keypair: SigningKeyPair, + mode: ConnectionMode, + signing_keypair: Option, hid: Option>, audio: Option>, video_manager: Option>, @@ -223,6 +236,9 @@ impl Connection { signing_keypair, temp_keypair, password: config.device_password.clone(), + mode, + password_salt: config.device_id.clone(), + password_challenge: uuid::Uuid::new_v4().simple().to_string(), hid, audio, video_manager, @@ -282,14 +298,27 @@ impl Connection { let writer = Arc::new(Mutex::new(writer)); let mut shutdown_rx = self.shutdown_tx.subscribe(); - // Send our SignedId first (this is what RustDesk protocol expects) - // The SignedId contains our device ID and temporary public key - let signed_id_msg = self.create_signed_id_message(&self.device_id.clone()); - let signed_id_bytes = signed_id_msg - .write_to_bytes() - .map_err(|e| anyhow::anyhow!("Failed to encode SignedId: {}", e))?; - debug!("Sending SignedId with device_id={}", self.device_id); - self.send_framed_arc(&writer, &signed_id_bytes).await?; + match self.mode { + ConnectionMode::Secure => { + // ID-server and relay connections authenticate our ephemeral key through hbbs. + let signed_id_msg = self.create_signed_id_message(&self.device_id.clone()); + let signed_id_bytes = signed_id_msg + .write_to_bytes() + .map_err(|e| anyhow::anyhow!("Failed to encode SignedId: {}", e))?; + debug!("Sending SignedId with device_id={}", self.device_id); + self.send_framed_arc(&writer, &signed_id_bytes).await?; + } + ConnectionMode::DirectIp => { + // Standard RustDesk direct-IP clients do not perform the signed-ID handshake. + // They expect password authentication to start immediately. + let hash_msg = self.create_hash_message(); + let hash_bytes = hash_msg + .write_to_bytes() + .map_err(|e| anyhow::anyhow!("Failed to encode Hash: {}", e))?; + debug!("Sending password challenge for direct IP connection"); + self.send_framed_arc(&writer, &hash_bytes).await?; + } + } // Channel for receiving video frames to send (bounded to provide backpressure) let (video_tx, mut video_rx) = mpsc::channel::(4); @@ -842,7 +871,11 @@ impl Connection { // Sign the IdPk bytes with Ed25519 // RustDesk's sign::sign() prepends the 64-byte signature to the message - let signed_id_pk = self.signing_keypair.sign(&id_pk_bytes); + let signed_id_pk = self + .signing_keypair + .as_ref() + .expect("secure RustDesk connections require a signing keypair") + .sign(&id_pk_bytes); let mut signed_id = SignedId::new(); signed_id.id = signed_id_pk.into(); @@ -980,7 +1013,7 @@ impl Connection { /// Verify password fn verify_password(&self, provided: &[u8]) -> bool { // RustDesk password verification: - // We send Hash { salt: device_id, challenge: "" } to client + // We send a stable salt and a fresh per-connection challenge to the client. // The client calculates: SHA256(SHA256(password + salt) + challenge) // See create_hash_message() for the salt and challenge we use // @@ -993,9 +1026,11 @@ impl Connection { return false; } - // The client calculates: SHA256(SHA256(password + salt) + challenge) - // where salt is our device_id and challenge is empty - let expected_hash = crypto::hash_password_double(&self.password, &self.device_id, ""); + let expected_hash = crypto::hash_password_double( + &self.password, + &self.password_salt, + &self.password_challenge, + ); // Try comparison with double hash if provided == expected_hash.as_slice() { @@ -1003,9 +1038,10 @@ impl Connection { return true; } - // Also try single hash for compatibility - let expected_hash_single = crypto::hash_password(&self.password, &self.device_id); - if provided == expected_hash_single.as_slice() { + // Keep the legacy single-hash fallback only inside the encrypted ID-service path. + // It has no per-connection challenge and must not be accepted on direct IP access. + let expected_hash_single = crypto::hash_password(&self.password, &self.password_salt); + if self.mode == ConnectionMode::Secure && provided == expected_hash_single.as_slice() { debug!("Password verified with single hash"); return true; } @@ -1116,11 +1152,9 @@ impl Connection { /// Create Hash message for password authentication /// The client will hash the password with the salt and send it back in LoginRequest fn create_hash_message(&self) -> HbbMessage { - // Use device_id as salt for simplicity (RustDesk uses Config::get_salt()) - // The challenge field is not used for our password verification let mut hash = Hash::new(); - hash.salt = self.device_id.clone(); - hash.challenge = String::new(); + hash.salt = self.password_salt.clone(); + hash.challenge = self.password_challenge.clone(); let mut msg = HbbMessage::new(); msg.union = Some(message::Union::Hash(hash)); @@ -1397,6 +1431,10 @@ impl ConnectionManager { *self.video_manager.write() = Some(video_manager); } + pub fn update_config(&self, config: RustDeskConfig) { + *self.config.write() = config; + } + /// Set keypair pub fn set_keypair(&self, keypair: KeyPair) { *self.keypair.write() = Some(keypair); @@ -1431,6 +1469,25 @@ impl ConnectionManager { &self, stream: TcpStream, peer_addr: SocketAddr, + ) -> anyhow::Result { + self.accept_connection_with_mode(stream, peer_addr, ConnectionMode::Secure) + .await + } + + pub async fn accept_direct_connection( + &self, + stream: TcpStream, + peer_addr: SocketAddr, + ) -> anyhow::Result { + self.accept_connection_with_mode(stream, peer_addr, ConnectionMode::DirectIp) + .await + } + + async fn accept_connection_with_mode( + &self, + stream: TcpStream, + peer_addr: SocketAddr, + mode: ConnectionMode, ) -> anyhow::Result { let id = { let mut next = self.next_id.write(); @@ -1440,12 +1497,22 @@ impl ConnectionManager { }; let config = self.config.read().clone(); - let signing_keypair = self.ensure_signing_keypair(); + let signing_keypair = match mode { + ConnectionMode::Secure => Some(self.ensure_signing_keypair()), + ConnectionMode::DirectIp => None, + }; let hid = self.hid.read().clone(); let audio = self.audio.read().clone(); let video_manager = self.video_manager.read().clone(); - let (mut conn, _rx) = - Connection::new(id, &config, signing_keypair, hid, audio, video_manager); + let (mut conn, _rx) = Connection::new( + id, + &config, + mode, + signing_keypair, + hid, + audio, + video_manager, + ); // Track connection state for external access let state = conn.state.clone(); @@ -1781,3 +1848,100 @@ async fn run_audio_streaming( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn connection(mode: ConnectionMode) -> Connection { + crypto::init().expect("crypto should initialize"); + let config = RustDeskConfig { + device_id: "123456789".to_string(), + device_password: "fixed-password".to_string(), + ..Default::default() + }; + let (connection, _rx) = Connection::new( + 1, + &config, + mode, + (mode == ConnectionMode::Secure).then(SigningKeyPair::generate), + None, + None, + None, + ); + connection + } + + async fn first_server_message(mode: ConnectionMode) -> HbbMessage { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener should bind"); + let address = listener + .local_addr() + .expect("listener should have an address"); + let client = tokio::spawn(async move { + TcpStream::connect(address) + .await + .expect("client should connect") + }); + let (server_stream, peer_addr) = listener.accept().await.expect("server should accept"); + let mut client_stream = client.await.expect("client task should finish"); + let mut server_connection = connection(mode); + let server = + tokio::spawn( + async move { server_connection.handle_tcp(server_stream, peer_addr).await }, + ); + + let bytes = read_frame(&mut client_stream) + .await + .expect("client should receive the first frame"); + drop(client_stream); + let _ = tokio::time::timeout(Duration::from_secs(1), server).await; + decode_message(&bytes).expect("first frame should contain a RustDesk message") + } + + #[test] + fn direct_ip_password_challenge_is_non_empty_and_verifiable() { + let connection = connection(ConnectionMode::DirectIp); + let message = connection.create_hash_message(); + let hash = match message.union { + Some(message::Union::Hash(hash)) => hash, + _ => panic!("expected Hash message"), + }; + + assert_eq!(hash.salt, "123456789"); + assert!(!hash.challenge.is_empty()); + + let response = crypto::hash_password_double("fixed-password", &hash.salt, &hash.challenge); + assert!(connection.verify_password(&response)); + } + + #[test] + fn direct_ip_rejects_legacy_replayable_single_hash() { + let direct = connection(ConnectionMode::DirectIp); + let single_hash = crypto::hash_password("fixed-password", "123456789"); + assert!(!direct.verify_password(&single_hash)); + + let secure = connection(ConnectionMode::Secure); + assert!(secure.verify_password(&single_hash)); + } + + #[test] + fn password_challenge_changes_for_each_connection() { + let first = connection(ConnectionMode::DirectIp); + let second = connection(ConnectionMode::DirectIp); + assert_ne!(first.password_challenge, second.password_challenge); + } + + #[tokio::test] + async fn direct_ip_connection_starts_with_password_hash() { + let message = first_server_message(ConnectionMode::DirectIp).await; + assert!(matches!(message.union, Some(message::Union::Hash(_)))); + } + + #[tokio::test] + async fn secure_connection_starts_with_signed_id() { + let message = first_server_message(ConnectionMode::Secure).await; + assert!(matches!(message.union, Some(message::Union::SignedId(_)))); + } +} diff --git a/src/rustdesk/mod.rs b/src/rustdesk/mod.rs index b8be700b..ff70b042 100644 --- a/src/rustdesk/mod.rs +++ b/src/rustdesk/mod.rs @@ -26,7 +26,7 @@ use crate::hid::HidController; use crate::utils::bind_tcp_listener; use crate::video::stream_manager::VideoStreamManager; -use self::config::RustDeskConfig; +use self::config::{RustDeskConfig, RustDeskMode}; use self::connection::ConnectionManager; use self::protocol::{make_local_addr, make_relay_response, make_request_relay}; use self::rendezvous::{AddrMangle, RendezvousMediator, RendezvousStatus}; @@ -53,8 +53,6 @@ impl std::fmt::Display for ServiceStatus { } } -const DIRECT_LISTEN_PORT: u16 = 21118; - pub struct RustDeskService { config: Arc>, status: Arc>, @@ -78,6 +76,7 @@ impl RustDeskService { ) -> Self { let (shutdown_tx, _) = broadcast::channel(1); let connection_manager = Arc::new(ConnectionManager::new(config.clone())); + let direct_access_port = config.direct_access_port; Self { config: Arc::new(RwLock::new(config)), @@ -85,7 +84,7 @@ impl RustDeskService { rendezvous: Arc::new(RwLock::new(None)), rendezvous_handle: Arc::new(RwLock::new(None)), tcp_listener_handle: Arc::new(RwLock::new(None)), - listen_port: Arc::new(RwLock::new(DIRECT_LISTEN_PORT)), + listen_port: Arc::new(RwLock::new(direct_access_port)), connection_manager, video_manager, hid, @@ -107,6 +106,7 @@ impl RustDeskService { } pub fn update_config(&self, config: RustDeskConfig) { + self.connection_manager.update_config(config.clone()); *self.config.write() = config; } @@ -126,6 +126,10 @@ impl RustDeskService { self.tcp_listener_handle.read().is_some() } + pub fn is_running(&self) -> bool { + self.status() == ServiceStatus::Running + } + pub async fn start(&self) -> anyhow::Result<()> { let config = self.config.read().clone(); @@ -146,9 +150,8 @@ impl RustDeskService { *self.status.write() = ServiceStatus::Starting; info!( - "Starting RustDesk service with ID: {} -> {}", - config.device_id, - config.rendezvous_addr() + "Starting RustDesk service in {:?} mode with ID: {}", + config.mode, config.device_id, ); if let Err(e) = crypto::init() { @@ -157,6 +160,27 @@ impl RustDeskService { return Err(e.into()); } + self.connection_manager.set_hid(self.hid.clone()); + + self.connection_manager.set_audio(self.audio.clone()); + + self.connection_manager + .set_video_manager(self.video_manager.clone()); + + if config.mode == RustDeskMode::DirectIp { + let (tcp_handles, listen_port) = match self.start_tcp_listener_with_port().await { + Ok(result) => result, + Err(err) => { + *self.status.write() = ServiceStatus::Error(err.to_string()); + return Err(err); + } + }; + *self.tcp_listener_handle.write() = Some(tcp_handles); + *self.listen_port.write() = listen_port; + *self.status.write() = ServiceStatus::Running; + return Ok(()); + } + let mediator = Arc::new(RendezvousMediator::new(config.clone())); let keypair = mediator.ensure_keypair(); @@ -165,26 +189,8 @@ impl RustDeskService { let signing_keypair = mediator.ensure_signing_keypair(); self.connection_manager.set_signing_keypair(signing_keypair); - self.connection_manager.set_hid(self.hid.clone()); - - self.connection_manager.set_audio(self.audio.clone()); - - self.connection_manager - .set_video_manager(self.video_manager.clone()); - *self.rendezvous.write() = Some(mediator.clone()); - let (tcp_handles, listen_port) = match self.start_tcp_listener_with_port().await { - Ok(result) => result, - Err(err) => { - *self.status.write() = ServiceStatus::Error(err.to_string()); - return Err(err); - } - }; - *self.tcp_listener_handle.write() = Some(tcp_handles); - - mediator.set_listen_port(listen_port); - let connection_manager = self.connection_manager.clone(); let service_config = self.config.clone(); @@ -299,16 +305,8 @@ impl RustDeskService { } async fn start_tcp_listener_with_port(&self) -> anyhow::Result<(Vec>, u16)> { - let (listeners, listen_port) = match self.bind_direct_listeners(DIRECT_LISTEN_PORT) { - Ok(result) => result, - Err(err) => { - warn!( - "Failed to bind RustDesk TCP on port {}: {}, falling back to random port", - DIRECT_LISTEN_PORT, err - ); - self.bind_direct_listeners(0)? - } - }; + let direct_access_port = self.config.read().direct_access_port; + let (listeners, listen_port) = self.bind_direct_listeners(direct_access_port)?; *self.listen_port.write() = listen_port; @@ -330,7 +328,7 @@ impl RustDeskService { info!("Accepted direct connection from {}", peer_addr); let conn_mgr = conn_mgr.clone(); tokio::spawn(async move { - if let Err(e) = conn_mgr.accept_connection(stream, peer_addr).await { + if let Err(e) = conn_mgr.accept_direct_connection(stream, peer_addr).await { error!("Failed to handle direct connection from {}: {}", peer_addr, e); } }); diff --git a/src/web/handlers/config/rustdesk.rs b/src/web/handlers/config/rustdesk.rs index 7399ece0..2af7df96 100644 --- a/src/web/handlers/config/rustdesk.rs +++ b/src/web/handlers/config/rustdesk.rs @@ -53,7 +53,9 @@ async fn current_status( #[derive(Debug, serde::Serialize)] pub struct RustDeskConfigResponse { pub enabled: bool, + pub mode: crate::rustdesk::config::RustDeskMode, pub codec: crate::rustdesk::config::RustDeskCodec, + pub direct_access_port: u16, pub rendezvous_server: String, pub relay_server: Option, pub device_id: String, @@ -66,7 +68,9 @@ impl From<&RustDeskConfig> for RustDeskConfigResponse { fn from(config: &RustDeskConfig) -> Self { Self { enabled: config.enabled, + mode: config.mode, codec: config.codec, + direct_access_port: config.direct_access_port, rendezvous_server: config.rendezvous_server.clone(), relay_server: config.relay_server.clone(), device_id: config.device_id.clone(), @@ -117,28 +121,22 @@ pub async fn update_rustdesk_config( pub async fn regenerate_device_id( State(state): State, ) -> Result> { - state - .config - .update(|config| { - config.rustdesk.device_id = RustDeskConfig::generate_device_id(); - }) - .await?; - - let new_config = state.config.get().rustdesk.clone(); + let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?; + let old_config = state.config.get().rustdesk.clone(); + let mut regenerated = old_config.clone(); + regenerated.device_id = RustDeskConfig::generate_device_id(); + let new_config = persist_and_apply(&state, old_config, regenerated).await?; Ok(Json(RustDeskConfigResponse::from(&new_config))) } pub async fn regenerate_device_password( State(state): State, ) -> Result> { - state - .config - .update(|config| { - config.rustdesk.device_password = RustDeskConfig::generate_password(); - }) - .await?; - - let new_config = state.config.get().rustdesk.clone(); + let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?; + let old_config = state.config.get().rustdesk.clone(); + let mut regenerated = old_config.clone(); + regenerated.device_password = RustDeskConfig::generate_password(); + let new_config = persist_and_apply(&state, old_config, regenerated).await?; Ok(Json(RustDeskConfigResponse::from(&new_config))) } diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index 83f0d487..23bdfe3b 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -899,7 +899,9 @@ fn validate_rustdesk_relay_key(key: &str) -> Result<(), AppError> { #[derive(Debug, Deserialize)] pub struct RustDeskConfigUpdate { pub enabled: Option, + pub mode: Option, pub codec: Option, + pub direct_access_port: Option, pub rendezvous_server: Option, pub relay_server: Option, pub relay_key: Option, @@ -908,6 +910,11 @@ pub struct RustDeskConfigUpdate { impl RustDeskConfigUpdate { pub fn validate(&self) -> crate::error::Result<()> { + if self.direct_access_port == Some(0) { + return Err(AppError::BadRequest( + "RustDesk direct access port must be greater than 0".into(), + )); + } // Validate rendezvous server format (should be host:port) if let Some(ref server) = self.rendezvous_server { if !server.is_empty() && !server.contains(':') { @@ -944,10 +951,24 @@ impl RustDeskConfigUpdate { } pub fn validate_merged(&self, config: &RustDeskConfig) -> crate::error::Result<()> { - if config.enabled && config.rendezvous_server.trim().is_empty() { - return Err(AppError::BadRequest( - "RustDesk ID server is required".into(), - )); + if config.enabled { + match config.mode { + crate::rustdesk::config::RustDeskMode::Id + if config.rendezvous_server.trim().is_empty() => + { + return Err(AppError::BadRequest( + "RustDesk ID server is required in ID service mode".into(), + )); + } + crate::rustdesk::config::RustDeskMode::DirectIp + if config.direct_access_port == 0 => + { + return Err(AppError::BadRequest( + "RustDesk direct access port must be greater than 0".into(), + )); + } + _ => {} + } } Ok(()) } @@ -956,9 +977,15 @@ impl RustDeskConfigUpdate { if let Some(enabled) = self.enabled { config.enabled = enabled; } + if let Some(mode) = self.mode { + config.mode = mode; + } if let Some(codec) = self.codec { config.codec = codec; } + if let Some(port) = self.direct_access_port { + config.direct_access_port = port; + } if let Some(ref server) = self.rendezvous_server { config.rendezvous_server = server.clone(); } @@ -1466,7 +1493,9 @@ mod tests { fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() { let update = RustDeskConfigUpdate { enabled: None, + mode: None, codec: None, + direct_access_port: None, rendezvous_server: None, relay_server: None, relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()), @@ -1481,7 +1510,9 @@ mod tests { let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string(); let update = RustDeskConfigUpdate { enabled: None, + mode: None, codec: None, + direct_access_port: None, rendezvous_server: None, relay_server: None, relay_key: Some(not_32), @@ -1490,6 +1521,49 @@ mod tests { assert!(update.validate().is_err()); } + #[test] + fn rustdesk_direct_ip_mode_does_not_require_id_server() { + let mut config = RustDeskConfig::default(); + config.enabled = true; + config.mode = crate::rustdesk::config::RustDeskMode::DirectIp; + config.rendezvous_server.clear(); + + let update = RustDeskConfigUpdate { + enabled: Some(true), + mode: Some(crate::rustdesk::config::RustDeskMode::DirectIp), + codec: None, + direct_access_port: Some(21118), + rendezvous_server: Some(String::new()), + relay_server: None, + relay_key: None, + device_password: None, + }; + + assert!(update.validate().is_ok()); + assert!(update.validate_merged(&config).is_ok()); + } + + #[test] + fn rustdesk_id_mode_requires_id_server_when_enabled() { + let mut config = RustDeskConfig::default(); + config.enabled = true; + config.mode = crate::rustdesk::config::RustDeskMode::Id; + config.rendezvous_server.clear(); + + let update = RustDeskConfigUpdate { + enabled: Some(true), + mode: Some(crate::rustdesk::config::RustDeskMode::Id), + codec: None, + direct_access_port: None, + rendezvous_server: Some(String::new()), + relay_server: None, + relay_key: None, + device_password: None, + }; + + assert!(update.validate_merged(&config).is_err()); + } + #[test] fn ipv6_bind_vnc_config_accepts_ipv6_literals() { for bind in ["::", "::1", "2001:db8::1"] { diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 393217cc..a6da99b4 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -221,7 +221,9 @@ export const extensionsApi = { export interface RustDeskConfigResponse { enabled: boolean + mode: 'id' | 'direct_ip' codec: 'h264' | 'h265' + direct_access_port: number rendezvous_server: string relay_server: string | null device_id: string @@ -238,7 +240,9 @@ export interface RustDeskStatusResponse { export interface RustDeskConfigUpdate { enabled?: boolean + mode?: 'id' | 'direct_ip' codec?: 'h264' | 'h265' + direct_access_port?: number rendezvous_server?: string relay_server?: string relay_key?: string diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index cf522915..ce266394 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -1072,6 +1072,15 @@ export default { rustdesk: { title: 'RustDesk Remote', desc: 'Configure the RustDesk service; the selected codec will be locked', + mode: 'Access Mode', + modeId: 'ID Service', + modeIdDesc: 'Register with an ID server and use the configured relay when direct connections fail', + modeDirectIp: 'Direct IP', + modeDirectIpDesc: 'Listen on the device only, without connecting to an ID or relay server', + directAccessPort: 'Direct Access Port', + directAccessPortInvalid: 'The direct access port must be between 1 and 65535', + directAccessWarningTitle: 'Direct IP access is not end-to-end encrypted', + directAccessWarningDesc: 'Use it only on a trusted LAN or encrypted VPN. Do not expose this port directly to the internet.', rendezvousServer: 'ID Server', rendezvousServerPlaceholder: 'hbbs.example.com:21116', rendezvousServerRequired: 'Enter the RustDesk ID server', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index ebf91aa4..a625f869 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -1071,6 +1071,15 @@ export default { rustdesk: { title: 'RustDesk 远程', desc: '配置 RustDesk 服务,将会锁定所选编码', + mode: '接入模式', + modeId: 'ID 服务', + modeIdDesc: '通过 ID 服务器注册,并在直连失败时按配置使用中继服务器', + modeDirectIp: 'IP 直连', + modeDirectIpDesc: '仅监听设备端口,不连接 ID 或中继服务器', + directAccessPort: '直连端口', + directAccessPortInvalid: '直连端口必须在 1 到 65535 之间', + directAccessWarningTitle: 'IP 直连不提供端到端加密', + directAccessWarningDesc: '仅建议在可信局域网或加密 VPN 中使用,请勿将此端口直接暴露到公网。', rendezvousServer: 'ID 服务器', rendezvousServerPlaceholder: 'hbbs.example.com:21116', rendezvousServerRequired: '请填写 RustDesk ID 服务器', diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index 5223b850..4712034e 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -248,6 +248,11 @@ export interface ExtensionsConfig { frpc: FrpcConfig; } +export enum RustDeskMode { + Id = "id", + DirectIp = "direct_ip", +} + export enum RustDeskCodec { H264 = "h264", H265 = "h265", @@ -255,7 +260,9 @@ export enum RustDeskCodec { export interface RustDeskConfig { enabled: boolean; + mode: RustDeskMode; codec: RustDeskCodec; + direct_access_port: number; rendezvous_server: string; relay_server?: string; device_id: string; @@ -633,7 +640,9 @@ export interface RtspStatusResponse { export interface RustDeskConfigUpdate { enabled?: boolean; + mode?: RustDeskMode; codec?: RustDeskCodec; + direct_access_port?: number; rendezvous_server?: string; relay_server?: string; relay_key?: string; diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 5b252db9..8f65c6f5 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -496,16 +496,24 @@ const rustdeskCopied = ref<'id' | 'password' | null>(null) const { copy: clipboardCopy } = useClipboard() const rustdeskLocalConfig = ref({ enabled: false, + mode: 'id' as 'id' | 'direct_ip', codec: 'h264' as 'h264' | 'h265', + direct_access_port: 21118, rendezvous_server: '', relay_server: '', relay_key: '', }) const rustdeskValidationMessage = computed(() => { - if (!rustdeskLocalConfig.value.rendezvous_server?.trim()) { + if (rustdeskLocalConfig.value.mode === 'id' && !rustdeskLocalConfig.value.rendezvous_server?.trim()) { return t('extensions.rustdesk.rendezvousServerRequired') } + if ( + rustdeskLocalConfig.value.mode === 'direct_ip' + && (rustdeskLocalConfig.value.direct_access_port < 1 || rustdeskLocalConfig.value.direct_access_port > 65535) + ) { + return t('extensions.rustdesk.directAccessPortInvalid') + } return '' }) @@ -1941,7 +1949,9 @@ function applyRustdeskStatus(status: RustDeskStatusResponse) { rustdeskStatus.value = status rustdeskLocalConfig.value = { enabled: config.enabled, + mode: config.mode || 'id', codec: config.codec || 'h264', + direct_access_port: config.direct_access_port || 21118, rendezvous_server: config.rendezvous_server, relay_server: config.relay_server || '', relay_key: config.relay_key || '', @@ -2335,7 +2345,9 @@ function updateStatusBadgeText(): string { function rustdeskUpdatePayload(enabled = rustdeskLocalConfig.value.enabled) { return { enabled, + mode: rustdeskLocalConfig.value.mode, codec: rustdeskLocalConfig.value.codec, + direct_access_port: rustdeskLocalConfig.value.direct_access_port, rendezvous_server: normalizeRustdeskServer( rustdeskLocalConfig.value.rendezvous_server, 21116, @@ -2346,7 +2358,10 @@ function rustdeskUpdatePayload(enabled = rustdeskLocalConfig.value.enabled) { } async function saveRustdeskConfig() { - if (rustdeskLocalConfig.value.enabled && !validateRustdeskConfig()) return + if ( + (rustdeskLocalConfig.value.enabled || rustdeskLocalConfig.value.mode === 'direct_ip') + && !validateRustdeskConfig() + ) return loading.value = true saved.value = false @@ -5066,6 +5081,21 @@ watch(isWindows, () => { +
+ +
+ +

+ {{ rustdeskLocalConfig.mode === 'id' ? t('extensions.rustdesk.modeIdDesc') : t('extensions.rustdesk.modeDirectIpDesc') }} +

+
+
@@ -5075,7 +5105,7 @@ watch(isWindows, () => {
-
+
{

{{ rustdeskValidationMessage }}

-
+
{ />
-
+
@@ -5123,6 +5153,24 @@ watch(isWindows, () => {
+
+ +
+ +

{{ rustdeskValidationMessage }}

+
+
+ + + {{ t('extensions.rustdesk.directAccessWarningTitle') }} + {{ t('extensions.rustdesk.directAccessWarningDesc') }} +
@@ -5131,7 +5179,7 @@ watch(isWindows, () => {

{{ t('extensions.rustdesk.deviceInfo') }}

-
+
{{ rustdeskConfig?.device_id || '-' }} @@ -5177,7 +5225,7 @@ watch(isWindows, () => {
-
+
From 95a1fdf42d5b40f420294d5b0be941fd842b2dbd Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sun, 30 Aug 2026 08:18:46 +0800 Subject: [PATCH 14/29] =?UTF-8?q?feat(rustdesk):=20=E5=BC=BA=E5=8C=96?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=B5=81=E5=AA=92=E4=BD=93=E4=BC=A0=E8=BE=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/runtime/remote_access.rs | 9 + src/rustdesk/bytes_codec.rs | 93 ++++- src/rustdesk/connection.rs | 618 ++++++++++++++++++++-------- src/rustdesk/frame_adapters.rs | 6 + src/rustdesk/mod.rs | 110 +++-- src/rustdesk/rendezvous.rs | 29 +- src/web/handlers/config/rustdesk.rs | 6 + web/src/api/config.ts | 3 + web/src/i18n/en-US.ts | 3 - web/src/i18n/zh-CN.ts | 3 - web/src/views/SettingsView.vue | 7 - 11 files changed, 654 insertions(+), 233 deletions(-) diff --git a/src/runtime/remote_access.rs b/src/runtime/remote_access.rs index 6b468faa..91a598c8 100644 --- a/src/runtime/remote_access.rs +++ b/src/runtime/remote_access.rs @@ -22,6 +22,9 @@ use super::ConfigApplyOptions; pub struct RustDeskRuntimeStatus { pub service_status: String, pub rendezvous_status: Option, + pub connection_count: usize, + pub listening: bool, + pub listen_port: Option, } pub struct RemoteAccessCoordinator { @@ -145,10 +148,16 @@ impl RemoteAccessCoordinator { Some(service) => RustDeskRuntimeStatus { service_status: service.status().to_string(), rendezvous_status: service.rendezvous_status().map(|status| status.to_string()), + connection_count: service.connection_count(), + listening: service.is_listening(), + listen_port: service.is_listening().then(|| service.listen_port()), }, None => RustDeskRuntimeStatus { service_status: "not_initialized".to_string(), rendezvous_status: None, + connection_count: 0, + listening: false, + listen_port: None, }, } } diff --git a/src/rustdesk/bytes_codec.rs b/src/rustdesk/bytes_codec.rs index d00896ab..a45dc2bd 100644 --- a/src/rustdesk/bytes_codec.rs +++ b/src/rustdesk/bytes_codec.rs @@ -1,7 +1,7 @@ //! Variable-length TCP framing (RustDesk wire format). use bytes::{Buf, BufMut, Bytes, BytesMut}; -use std::io; +use std::io::{self, IoSlice}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; const MAX_PACKET_LENGTH: usize = 0x3FFFFFFF; @@ -53,6 +53,18 @@ fn decode_header(first_byte: u8, header_bytes: &[u8]) -> (usize, usize) { } pub async fn read_frame(reader: &mut R) -> io::Result { + read_frame_with_limit(reader, MAX_PACKET_LENGTH).await +} + +/// Read one framed message while enforcing a caller-selected allocation limit. +/// +/// Network-facing protocol stages should use a substantially smaller limit than +/// the wire format's theoretical maximum so an untrusted peer cannot force a +/// huge allocation by sending only a length header. +pub async fn read_frame_with_limit( + reader: &mut R, + max_packet_length: usize, +) -> io::Result { let mut first_byte = [0u8; 1]; reader.read_exact(&mut first_byte).await?; @@ -65,10 +77,10 @@ pub async fn read_frame(reader: &mut R) -> io::Result MAX_PACKET_LENGTH { + if msg_len > max_packet_length { return Err(io::Error::new( io::ErrorKind::InvalidData, - "Message too large", + format!("Message too large: {msg_len} bytes exceeds {max_packet_length}-byte limit"), )); } @@ -86,6 +98,53 @@ pub async fn write_frame(writer: &mut W, data: &[u8]) -> Ok(()) } +/// Write a frame without copying its payload into a second contiguous buffer. +/// TCP writers normally send the small header and payload in one vectored write. +pub async fn write_frame_vectored( + writer: &mut W, + data: &[u8], +) -> io::Result<()> { + let len = data.len(); + let mut header = [0u8; 4]; + let header_len = if len <= 0x3F { + header[0] = (len << 2) as u8; + 1 + } else if len <= 0x3FFF { + header[..2].copy_from_slice(&(((len << 2) as u16) | 0x1).to_le_bytes()); + 2 + } else if len <= 0x3FFFFF { + let value = ((len << 2) as u32) | 0x2; + header[0] = (value & 0xFF) as u8; + header[1] = ((value >> 8) & 0xFF) as u8; + header[2] = ((value >> 16) & 0xFF) as u8; + 3 + } else if len <= MAX_PACKET_LENGTH { + header.copy_from_slice(&(((len << 2) as u32) | 0x3).to_le_bytes()); + 4 + } else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Message too large", + )); + }; + + let slices = [IoSlice::new(&header[..header_len]), IoSlice::new(data)]; + let written = writer.write_vectored(&slices).await?; + if written == 0 { + return Err(io::Error::new( + io::ErrorKind::WriteZero, + "failed to write RustDesk frame", + )); + } + if written < header_len { + writer.write_all(&header[written..header_len]).await?; + writer.write_all(data).await?; + } else { + writer.write_all(&data[written - header_len..]).await?; + } + Ok(()) +} + pub async fn write_frame_buffered( writer: &mut W, data: &[u8], @@ -281,4 +340,32 @@ mod tests { let decoded = codec.decode(&mut buf).unwrap().unwrap(); assert_eq!(decoded.len(), 100000); } + + #[tokio::test] + async fn read_limit_rejects_length_before_allocating_payload() { + let encoded = encode_frame(&vec![0u8; 1024]).unwrap(); + let (mut writer, mut reader) = tokio::io::duplex(encoded.len()); + tokio::spawn(async move { + writer.write_all(&encoded).await.unwrap(); + }); + + let error = read_frame_with_limit(&mut reader, 128).await.unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + + #[tokio::test] + async fn vectored_writer_round_trips() { + let payload = vec![0x5a; 100_000]; + let (mut writer, mut reader) = tokio::io::duplex(payload.len() + 4); + let expected = payload.clone(); + let send = tokio::spawn(async move { + write_frame_vectored(&mut writer, &payload).await.unwrap(); + }); + + let decoded = read_frame_with_limit(&mut reader, expected.len()) + .await + .unwrap(); + send.await.unwrap(); + assert_eq!(decoded.as_ref(), expected.as_slice()); + } } diff --git a/src/rustdesk/connection.rs b/src/rustdesk/connection.rs index ed51cd92..dafffe7f 100644 --- a/src/rustdesk/connection.rs +++ b/src/rustdesk/connection.rs @@ -1,16 +1,17 @@ //! Incoming RustDesk TCP sessions (handshake, AV, input). +use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use bytes::{Bytes, BytesMut}; +use bytes::Bytes; use parking_lot::RwLock; use protobuf::Message as ProtobufMessage; use sodiumoxide::crypto::box_; use tokio::net::tcp::OwnedWriteHalf; use tokio::net::TcpStream; -use tokio::sync::{broadcast, mpsc, Mutex}; +use tokio::sync::{mpsc, watch}; use tracing::{debug, error, info, warn}; use crate::audio::AudioController; @@ -21,7 +22,7 @@ use crate::video::codec::BitratePreset; use crate::video::codec_constraints::{encoder_codec_to_id, encoder_codec_to_video_codec}; use crate::video::stream_manager::VideoStreamManager; -use super::bytes_codec::{read_frame, write_frame, write_frame_buffered}; +use super::bytes_codec::{read_frame_with_limit, write_frame_vectored}; use super::config::RustDeskConfig; use super::crypto::{self, KeyPair, SigningKeyPair}; use super::frame_adapters::{AudioFrameAdapter, VideoCodec, VideoFrameAdapter}; @@ -41,6 +42,14 @@ const DEFAULT_SCREEN_HEIGHT: u32 = 1080; /// Default mouse event throttle interval (16ms ≈ 60Hz) const DEFAULT_MOUSE_THROTTLE_MS: u64 = 16; +/// Limit work retained for unauthenticated peers. +const MAX_CONNECTIONS: usize = 8; +const MAX_UNAUTHENTICATED_PACKET_LENGTH: usize = 256 * 1024; +const MAX_AUTHENTICATED_PACKET_LENGTH: usize = 8 * 1024 * 1024; +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_PASSWORD_ATTEMPTS: u8 = 5; +const CONNECTION_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); + /// Advertised RustDesk version for client compatibility. const RUSTDESK_COMPAT_VERSION: &str = "1.4.5"; // Advertised platform for RustDesk clients. This affects which UI options are shown. @@ -140,10 +149,8 @@ pub struct Connection { /// Screen dimensions for mouse coordinate conversion screen_width: u32, screen_height: u32, - /// Message sender to connection handler - tx: mpsc::UnboundedSender, /// Shutdown signal - shutdown_tx: broadcast::Sender<()>, + shutdown_tx: watch::Sender, /// Video streaming task handle video_task: Option>, /// Audio streaming task handle @@ -152,14 +159,10 @@ pub struct Connection { session_key: Option, /// Encryption enabled flag encryption_enabled: bool, - /// Encryption sequence number (for nonce generation) - enc_seqnum: u64, /// Decryption sequence number (for nonce generation) dec_seqnum: u64, /// Negotiated video codec (after client capability exchange) negotiated_codec: Option, - /// Video frame sender for restarting video after codec switch - video_frame_tx: Option>, /// Input event throttler to prevent HID device EAGAIN errors input_throttler: InputThrottler, /// Last measured round-trip delay in milliseconds (for TestDelay responses) @@ -170,21 +173,18 @@ pub struct Connection { last_caps_lock: bool, /// Whether relative mouse mode is currently active for this connection relative_mouse_active: bool, + /// Latest throttled move; flushed on the next HID interval. + pending_mouse_move: Option, /// Server-configured RustDesk video codec. configured_codec: VideoEncoderType, + /// Failed authentication attempts on this TCP session. + password_attempts: u8, } -/// Messages sent to connection handler -#[derive(Debug)] -pub enum ConnectionMessage { - /// Send video frame - VideoFrame(Bytes), - /// Send audio frame - AudioFrame(Bytes), - /// Send cursor data - CursorData(Bytes), - /// Close connection - Close, +enum WriterCommand { + SetSessionKey(secretbox::Key), + Send { data: Bytes, encrypt: bool }, + Shutdown, } /// Messages received from client @@ -214,9 +214,8 @@ impl Connection { hid: Option>, audio: Option>, video_manager: Option>, - ) -> (Self, mpsc::UnboundedReceiver) { - let (tx, rx) = mpsc::unbounded_channel(); - let (shutdown_tx, _) = broadcast::channel(1); + ) -> Self { + let (shutdown_tx, _) = watch::channel(false); // Generate fresh Curve25519 keypair for this connection // This is used for encrypting the symmetric key exchange @@ -227,7 +226,7 @@ impl Connection { super::config::RustDeskCodec::H265 => VideoEncoderType::H265, }; - let conn = Self { + Self { id, device_id: config.device_id.clone(), peer_id: String::new(), @@ -244,25 +243,22 @@ impl Connection { video_manager, screen_width: DEFAULT_SCREEN_WIDTH, screen_height: DEFAULT_SCREEN_HEIGHT, - tx, shutdown_tx, video_task: None, audio_task: None, session_key: None, encryption_enabled: false, - enc_seqnum: 0, dec_seqnum: 0, negotiated_codec: None, - video_frame_tx: None, input_throttler: InputThrottler::new(), last_delay: 0, last_test_delay_sent: None, last_caps_lock: false, relative_mouse_active: false, + pending_mouse_move: None, configured_codec, - }; - - (conn, rx) + password_attempts: 0, + } } /// Get connection ID @@ -280,9 +276,8 @@ impl Connection { &self.peer_id } - /// Get message sender - pub fn sender(&self) -> mpsc::UnboundedSender { - self.tx.clone() + pub fn shutdown_sender(&self) -> watch::Sender { + self.shutdown_tx.clone() } /// Handle an incoming TCP connection @@ -292,11 +287,24 @@ impl Connection { peer_addr: SocketAddr, ) -> anyhow::Result<()> { info!("New connection from {}", peer_addr); + stream.set_nodelay(true)?; *self.state.write() = ConnectionState::Handshaking; let (mut reader, writer) = stream.into_split(); - let writer = Arc::new(Mutex::new(writer)); let mut shutdown_rx = self.shutdown_tx.subscribe(); + if *shutdown_rx.borrow() { + *self.state.write() = ConnectionState::Closed; + return Ok(()); + } + + // Keep socket writes out of the input loop. A congested video path must + // never prevent us from reading keyboard and mouse events. + let (control_tx, control_rx) = mpsc::channel::(32); + let (video_tx, video_rx) = mpsc::channel::(1); + let (audio_tx, audio_rx) = mpsc::channel::(8); + let mut writer_task = tokio::spawn(run_connection_writer( + writer, control_rx, video_rx, audio_rx, + )); match self.mode { ConnectionMode::Secure => { @@ -306,7 +314,7 @@ impl Connection { .write_to_bytes() .map_err(|e| anyhow::anyhow!("Failed to encode SignedId: {}", e))?; debug!("Sending SignedId with device_id={}", self.device_id); - self.send_framed_arc(&writer, &signed_id_bytes).await?; + self.send_framed(&control_tx, &signed_id_bytes).await?; } ConnectionMode::DirectIp => { // Standard RustDesk direct-IP clients do not perform the signed-ID handshake. @@ -316,34 +324,36 @@ impl Connection { .write_to_bytes() .map_err(|e| anyhow::anyhow!("Failed to encode Hash: {}", e))?; debug!("Sending password challenge for direct IP connection"); - self.send_framed_arc(&writer, &hash_bytes).await?; + self.send_framed(&control_tx, &hash_bytes).await?; } } - // Channel for receiving video frames to send (bounded to provide backpressure) - let (video_tx, mut video_rx) = mpsc::channel::(4); let mut video_streaming = false; - - // Channel for receiving audio frames to send (bounded to provide backpressure) - let (audio_tx, mut audio_rx) = mpsc::channel::(8); let mut audio_streaming = false; // Timer for sending TestDelay to measure round-trip latency // RustDesk clients display this delay information let mut test_delay_interval = tokio::time::interval(Duration::from_secs(1)); test_delay_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut mouse_flush_interval = + tokio::time::interval(Duration::from_millis(DEFAULT_MOUSE_THROTTLE_MS)); + mouse_flush_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - // Pre-allocated buffer for framing (reused across sends to reduce allocations) - // Typical H264 frame is 10-100KB, pre-allocate 128KB - let mut frame_buf = BytesMut::with_capacity(128 * 1024); + let handshake_deadline = tokio::time::sleep(HANDSHAKE_TIMEOUT); + tokio::pin!(handshake_deadline); loop { + let packet_limit = if self.state() == ConnectionState::Active { + MAX_AUTHENTICATED_PACKET_LENGTH + } else { + MAX_UNAUTHENTICATED_PACKET_LENGTH + }; tokio::select! { // Read framed message from client using RustDesk's variable-length encoding - result = read_frame(&mut reader) => { + result = read_frame_with_limit(&mut reader, packet_limit) => { match result { Ok(msg_buf) => { - if let Err(e) = self.handle_message_arc(&msg_buf, &writer, &video_tx, &mut video_streaming, &audio_tx, &mut audio_streaming).await { + if let Err(e) = self.handle_message_arc(&msg_buf, &control_tx, &video_tx, &mut video_streaming, &audio_tx, &mut audio_streaming).await { error!("Error handling message: {}", e); break; } @@ -363,61 +373,38 @@ impl Connection { } } - // Send video frames (encrypted if session key is set) - // Optimized path: inline encryption and use pre-allocated buffer - Some(frame_data) = video_rx.recv() => { - let send_result = if let Some(ref key) = self.session_key { - // Encrypt the frame - self.enc_seqnum += 1; - let nonce = Self::get_nonce(self.enc_seqnum); - let ciphertext = secretbox::seal(&frame_data, &nonce, key); - // Send using pre-allocated buffer - let mut w = writer.lock().await; - write_frame_buffered(&mut *w, &ciphertext, &mut frame_buf).await - } else { - // No encryption, send plain - let mut w = writer.lock().await; - write_frame_buffered(&mut *w, &frame_data, &mut frame_buf).await - }; - - if let Err(e) = send_result { - error!("Error sending video frame: {}", e); - break; - } - } - - // Send audio frames (encrypted if session key is set) - Some(frame_data) = audio_rx.recv() => { - let send_result = if let Some(ref key) = self.session_key { - // Encrypt the frame - self.enc_seqnum += 1; - let nonce = Self::get_nonce(self.enc_seqnum); - let ciphertext = secretbox::seal(&frame_data, &nonce, key); - let mut w = writer.lock().await; - write_frame_buffered(&mut *w, &ciphertext, &mut frame_buf).await - } else { - // No encryption, send plain - let mut w = writer.lock().await; - write_frame_buffered(&mut *w, &frame_data, &mut frame_buf).await - }; - - if let Err(e) = send_result { - error!("Error sending audio frame: {}", e); - break; - } - } - // Send TestDelay periodically to measure latency _ = test_delay_interval.tick() => { if self.state() == ConnectionState::Active && self.last_test_delay_sent.is_none() { - if let Err(e) = self.send_test_delay(&writer).await { + if let Err(e) = self.send_test_delay(&control_tx).await { warn!("Failed to send TestDelay: {}", e); } } } + _ = mouse_flush_interval.tick(), if self.pending_mouse_move.is_some() => { + if let Some(mouse_event) = self.pending_mouse_move.take() { + self.send_mouse_event_to_hid(&mouse_event).await; + self.input_throttler.mark_mouse_sent(); + } + } + + _ = &mut handshake_deadline, if self.state() != ConnectionState::Active => { + warn!("RustDesk handshake timed out for {}", peer_addr); + break; + } + + result = &mut writer_task => { + match result { + Ok(Ok(())) => debug!("RustDesk writer stopped for {}", peer_addr), + Ok(Err(error)) => warn!("RustDesk writer failed for {}: {}", peer_addr, error), + Err(error) => warn!("RustDesk writer task failed for {}: {}", peer_addr, error), + } + break; + } + // Shutdown signal - _ = shutdown_rx.recv() => { + _ = shutdown_rx.changed() => { info!("Connection shutdown requested"); break; } @@ -434,19 +421,33 @@ impl Connection { task.abort(); } + let _ = control_tx.try_send(WriterCommand::Shutdown); + if !writer_task.is_finished() { + match tokio::time::timeout(Duration::from_secs(1), &mut writer_task).await { + Ok(_) => {} + Err(_) => { + writer_task.abort(); + let _ = writer_task.await; + } + } + } + *self.state.write() = ConnectionState::Closed; Ok(()) } - /// Send framed message using Arc> with RustDesk's variable-length encoding - async fn send_framed_arc( + async fn send_framed( &self, - writer: &Arc>, + writer: &mpsc::Sender, data: &[u8], ) -> anyhow::Result<()> { - let mut w = writer.lock().await; - write_frame(&mut *w, data).await?; - Ok(()) + writer + .send(WriterCommand::Send { + data: Bytes::copy_from_slice(data), + encrypt: false, + }) + .await + .map_err(|_| anyhow::anyhow!("RustDesk writer is closed")) } /// Generate nonce from sequence number (RustDesk format) @@ -458,22 +459,18 @@ impl Connection { /// Send encrypted framed message if encryption is enabled /// RustDesk uses sequence-based nonce, NOT nonce prefix in message - async fn send_encrypted_arc( - &mut self, - writer: &Arc>, + async fn send_encrypted( + &self, + writer: &mpsc::Sender, data: &[u8], ) -> anyhow::Result<()> { - if let Some(ref key) = self.session_key { - // Increment encryption sequence number - self.enc_seqnum += 1; - let nonce = Self::get_nonce(self.enc_seqnum); - // Encrypt the message - RustDesk only sends ciphertext, no nonce prefix - let ciphertext = secretbox::seal(data, &nonce, key); - self.send_framed_arc(writer, &ciphertext).await - } else { - // No encryption, send plain - self.send_framed_arc(writer, data).await - } + writer + .send(WriterCommand::Send { + data: Bytes::copy_from_slice(data), + encrypt: self.session_key.is_some(), + }) + .await + .map_err(|_| anyhow::anyhow!("RustDesk writer is closed")) } /// Handle incoming message with Arc writer @@ -481,7 +478,7 @@ impl Connection { async fn handle_message_arc( &mut self, data: &[u8], - writer: &Arc>, + writer: &mpsc::Sender, video_tx: &mpsc::Sender, video_streaming: &mut bool, audio_tx: &mpsc::Sender, @@ -534,8 +531,6 @@ impl Connection { // Handle login and start video/audio streaming if successful if self.handle_login_request_arc(&lr, writer).await? { - // Store video_tx for potential codec switching - self.video_frame_tx = Some(video_tx.clone()); // Start video streaming if !*video_streaming { self.start_video_streaming(video_tx.clone()); @@ -605,7 +600,7 @@ impl Connection { async fn handle_login_request_arc( &mut self, lr: &LoginRequest, - writer: &Arc>, + writer: &mpsc::Sender, ) -> anyhow::Result { info!( "Login request from {} ({}), password_len={}", @@ -627,19 +622,23 @@ impl Connection { let response_bytes = error_response .write_to_bytes() .map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?; - self.send_encrypted_arc(writer, &response_bytes).await?; + self.send_encrypted(writer, &response_bytes).await?; // Don't close connection - wait for retry with password return Ok(false); } // Verify the password if !self.verify_password(&lr.password) { + self.password_attempts = self.password_attempts.saturating_add(1); warn!("Wrong password from {}", lr.my_id); let error_response = self.create_login_error_response("Wrong Password"); let response_bytes = error_response .write_to_bytes() .map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?; - self.send_encrypted_arc(writer, &response_bytes).await?; + self.send_encrypted(writer, &response_bytes).await?; + if self.password_attempts >= MAX_PASSWORD_ATTEMPTS { + anyhow::bail!("Too many failed RustDesk password attempts"); + } // Don't close connection - wait for retry with correct password return Ok(false); } @@ -647,6 +646,7 @@ impl Connection { // Password valid or no password required info!("Login successful for {}", lr.my_id); + self.password_attempts = 0; *self.state.write() = ConnectionState::Active; // Select the best available video codec @@ -659,7 +659,7 @@ impl Connection { let response_bytes = response .write_to_bytes() .map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?; - self.send_encrypted_arc(writer, &response_bytes).await?; + self.send_encrypted(writer, &response_bytes).await?; Ok(true) } @@ -702,7 +702,7 @@ impl Connection { async fn handle_misc_arc( &mut self, misc: &Misc, - _writer: &Arc>, + _writer: &mpsc::Sender, ) -> anyhow::Result<()> { match &misc.union { Some(misc::Union::SwitchDisplay(sd)) => { @@ -890,7 +890,7 @@ impl Connection { async fn handle_peer_public_key( &mut self, pk: &PublicKey, - writer: &Arc>, + writer: &mpsc::Sender, ) -> anyhow::Result<()> { // RustDesk's PublicKey message has two parts: // - asymmetric_value: The peer's temporary Curve25519 public key (32 bytes) @@ -913,6 +913,10 @@ impl Connection { ) { Ok(session_key) => { info!("Session key negotiated successfully"); + writer + .send(WriterCommand::SetSessionKey(session_key.clone())) + .await + .map_err(|_| anyhow::anyhow!("RustDesk writer is closed"))?; self.session_key = Some(session_key); self.encryption_enabled = true; } @@ -950,7 +954,7 @@ impl Connection { "Sending Hash message for password authentication (encrypted={})", self.encryption_enabled ); - self.send_encrypted_arc(writer, &hash_bytes).await?; + self.send_encrypted(writer, &hash_bytes).await?; Ok(()) } @@ -963,7 +967,7 @@ impl Connection { async fn handle_signed_id( &mut self, si: &SignedId, - writer: &Arc>, + writer: &mpsc::Sender, ) -> anyhow::Result<()> { // The SignedId contains a signed IdPk message // Try to parse the IdPk from the signed data @@ -1005,7 +1009,7 @@ impl Connection { let signed_id_bytes = signed_id_msg .write_to_bytes() .map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?; - self.send_framed_arc(writer, &signed_id_bytes).await?; + self.send_framed(writer, &signed_id_bytes).await?; Ok(()) } @@ -1033,7 +1037,9 @@ impl Connection { ); // Try comparison with double hash - if provided == expected_hash.as_slice() { + if provided.len() == expected_hash.len() + && sodiumoxide::utils::memcmp(provided, expected_hash.as_slice()) + { debug!("Password verified with double hash"); return true; } @@ -1041,7 +1047,10 @@ impl Connection { // Keep the legacy single-hash fallback only inside the encrypted ID-service path. // It has no per-connection challenge and must not be accepted on direct IP access. let expected_hash_single = crypto::hash_password(&self.password, &self.password_salt); - if self.mode == ConnectionMode::Secure && provided == expected_hash_single.as_slice() { + if self.mode == ConnectionMode::Secure + && provided.len() == expected_hash_single.len() + && sodiumoxide::utils::memcmp(provided, expected_hash_single.as_slice()) + { debug!("Password verified with single hash"); return true; } @@ -1058,7 +1067,7 @@ impl Connection { } /// Create login response with dynamically detected encoder capabilities - async fn create_login_response(&self, success: bool) -> HbbMessage { + async fn create_login_response(&mut self, success: bool) -> HbbMessage { if success { // Dynamically detect available encoders let registry = EncoderRegistry::global(); @@ -1089,6 +1098,10 @@ impl Connection { display_height = height; } } + // Use the same geometry for both the advertised display and HID + // absolute-coordinate conversion. + self.screen_width = display_width.max(1); + self.screen_height = display_height.max(1); let mut display_info = DisplayInfo::new(); display_info.x = 0; @@ -1171,7 +1184,7 @@ impl Connection { async fn handle_test_delay( &mut self, td: &TestDelay, - writer: &Arc>, + writer: &mpsc::Sender, ) -> anyhow::Result<()> { if td.from_client { // Client initiated the delay test, respond with the same time @@ -1187,7 +1200,7 @@ impl Connection { let data = response .write_to_bytes() .map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?; - self.send_encrypted_arc(writer, &data).await?; + self.send_encrypted(writer, &data).await?; debug!( "TestDelay response sent: time={}, last_delay={}ms", @@ -1214,7 +1227,10 @@ impl Connection { /// The client will echo this back, allowing us to calculate RTT. /// The measured delay is then included in future TestDelay messages /// for the client to display. - async fn send_test_delay(&mut self, writer: &Arc>) -> anyhow::Result<()> { + async fn send_test_delay( + &mut self, + writer: &mpsc::Sender, + ) -> anyhow::Result<()> { // Get current time in milliseconds since epoch let time_ms = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1233,7 +1249,7 @@ impl Connection { let data = msg .write_to_bytes() .map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?; - self.send_encrypted_arc(writer, &data).await?; + self.send_encrypted(writer, &data).await?; // Record when we sent this, so we can calculate RTT when client echoes back self.last_test_delay_sent = Some(Instant::now()); @@ -1332,46 +1348,131 @@ impl Connection { // For pure move events, apply throttling if is_pure_move && !self.input_throttler.should_send_mouse_move() { - // Skip this move event to prevent HID EAGAIN + // Coalesce moves instead of losing the final pointer position. + self.pending_mouse_move = Some(me.clone()); return Ok(()); } + // Preserve input ordering when a button/scroll event follows a move + // that was waiting for the next HID poll interval. + if !is_pure_move { + if let Some(pending) = self.pending_mouse_move.take() { + self.send_mouse_event_to_hid(&pending).await; + } + } + debug!("Mouse event: x={}, y={}, mask={}", me.x, me.y, me.mask); - // Convert RustDesk mouse event to One-KVM mouse events - let mouse_events = convert_mouse_event(me, self.screen_width, self.screen_height); - - // Send to HID controller if available - if let Some(ref hid) = self.hid { - for event in mouse_events { - if let Err(e) = hid.send_mouse(event).await { - warn!("Failed to send mouse event: {}", e); - } - } + self.send_mouse_event_to_hid(me).await; + if self.hid.is_some() { // Mark that we sent a mouse event (for non-move events) if !is_pure_move { self.input_throttler.mark_mouse_sent(); } - } else { - debug!("HID controller not available, skipping mouse event"); } Ok(()) } + async fn send_mouse_event_to_hid(&self, event: &MouseEvent) { + let mouse_events = convert_mouse_event(event, self.screen_width, self.screen_height); + if let Some(ref hid) = self.hid { + for mouse_event in mouse_events { + if let Err(error) = hid.send_mouse(mouse_event).await { + warn!("Failed to send mouse event: {}", error); + } + } + } else { + debug!("HID controller not available, skipping mouse event"); + } + } + /// Close the connection pub fn close(&self) { - let _ = self.shutdown_tx.send(()); + self.shutdown_tx.send_replace(true); *self.state.write() = ConnectionState::Closed; } } +async fn run_connection_writer( + mut writer: OwnedWriteHalf, + mut control_rx: mpsc::Receiver, + mut video_rx: mpsc::Receiver, + mut audio_rx: mpsc::Receiver, +) -> anyhow::Result<()> { + let mut session_key: Option = None; + let mut sequence = 0u64; + + loop { + let (data, encrypt) = tokio::select! { + command = control_rx.recv() => { + match command { + Some(WriterCommand::SetSessionKey(key)) => { + session_key = Some(key); + continue; + } + Some(WriterCommand::Send { data, encrypt }) => (data, encrypt), + Some(WriterCommand::Shutdown) | None => break, + } + } + frame = audio_rx.recv() => { + match frame { + Some(data) => (data, session_key.is_some()), + None => continue, + } + } + frame = video_rx.recv() => { + match frame { + Some(data) => (data, session_key.is_some()), + None => continue, + } + } + }; + + let encrypted; + let payload = if encrypt { + if let Some(key) = session_key.as_ref() { + sequence = sequence.wrapping_add(1); + let nonce = Connection::get_nonce(sequence); + encrypted = secretbox::seal(&data, &nonce, key); + encrypted.as_slice() + } else { + data.as_ref() + } + } else { + data.as_ref() + }; + + tokio::time::timeout( + Duration::from_secs(10), + write_frame_vectored(&mut writer, payload), + ) + .await + .map_err(|_| anyhow::anyhow!("RustDesk socket write timed out"))??; + } + + Ok(()) +} + /// Lightweight connection info for tracking active connections pub struct ConnectionInfo { /// Connection ID pub id: u32, /// Connection state (shared with Connection) pub state: Arc>, + shutdown_tx: watch::Sender, + abort_handle: Option, +} + +struct ConnectionCleanup { + id: u32, + connections: Arc>>, +} + +impl Drop for ConnectionCleanup { + fn drop(&mut self) { + self.connections.write().remove(&self.id); + } } impl ConnectionInfo { @@ -1384,7 +1485,7 @@ impl ConnectionInfo { /// Connection manager pub struct ConnectionManager { /// Active connection info - connections: Arc>>>>, + connections: Arc>>, /// Next connection ID next_id: Arc>, /// Configuration @@ -1405,7 +1506,7 @@ impl ConnectionManager { /// Create a new connection manager pub fn new(config: RustDeskConfig) -> Self { Self { - connections: Arc::new(RwLock::new(Vec::new())), + connections: Arc::new(RwLock::new(HashMap::new())), next_id: Arc::new(RwLock::new(1)), config: Arc::new(RwLock::new(config)), keypair: Arc::new(RwLock::new(None)), @@ -1483,12 +1584,29 @@ impl ConnectionManager { .await } + pub async fn accept_listener_connection( + &self, + stream: TcpStream, + peer_addr: SocketAddr, + ) -> anyhow::Result { + let mode = match self.config.read().mode { + super::config::RustDeskMode::Id => ConnectionMode::Secure, + super::config::RustDeskMode::DirectIp => ConnectionMode::DirectIp, + }; + self.accept_connection_with_mode(stream, peer_addr, mode) + .await + } + async fn accept_connection_with_mode( &self, stream: TcpStream, peer_addr: SocketAddr, mode: ConnectionMode, ) -> anyhow::Result { + if self.connection_count() >= MAX_CONNECTIONS { + anyhow::bail!("RustDesk connection limit ({MAX_CONNECTIONS}) reached"); + } + let id = { let mut next = self.next_id.write(); let id = *next; @@ -1504,7 +1622,7 @@ impl ConnectionManager { let hid = self.hid.read().clone(); let audio = self.audio.read().clone(); let video_manager = self.video_manager.read().clone(); - let (mut conn, _rx) = Connection::new( + let mut conn = Connection::new( id, &config, mode, @@ -1516,16 +1634,34 @@ impl ConnectionManager { // Track connection state for external access let state = conn.state.clone(); - self.connections - .write() - .push(Arc::new(RwLock::new(ConnectionInfo { id, state }))); + let shutdown_tx = conn.shutdown_sender(); + { + let mut connections = self.connections.write(); + if connections.len() >= MAX_CONNECTIONS { + anyhow::bail!("RustDesk connection limit ({MAX_CONNECTIONS}) reached"); + } + connections.insert( + id, + ConnectionInfo { + id, + state, + shutdown_tx, + abort_handle: None, + }, + ); + } // Spawn connection handler - Connection is moved, not locked - tokio::spawn(async move { + let connections = self.connections.clone(); + let task = tokio::spawn(async move { + let _cleanup = ConnectionCleanup { id, connections }; if let Err(e) = conn.handle_tcp(stream, peer_addr).await { error!("Connection {} error: {}", id, e); } }); + if let Some(connection) = self.connections.write().get_mut(&id) { + connection.abort_handle = Some(task.abort_handle()); + } Ok(id) } @@ -1535,11 +1671,47 @@ impl ConnectionManager { self.connections.read().len() } - /// Mark all connections as closed (actual connection tasks will detect this) - pub fn close_all(&self) { - let connections = self.connections.read(); - for conn_info in connections.iter() { - *conn_info.read().state.write() = ConnectionState::Closed; + /// Cancel all sessions and wait briefly for their TCP tasks to exit. + pub async fn close_all(&self) { + let senders = self + .connections + .read() + .values() + .map(|connection| connection.shutdown_tx.clone()) + .collect::>(); + for sender in senders { + sender.send_replace(true); + } + + let wait_for_empty = async { + while !self.connections.read().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }; + if tokio::time::timeout(CONNECTION_SHUTDOWN_TIMEOUT, wait_for_empty) + .await + .is_err() + { + warn!( + "Timed out waiting for {} RustDesk connection(s) to close", + self.connection_count() + ); + let abort_handles = self + .connections + .read() + .values() + .filter_map(|connection| connection.abort_handle.clone()) + .collect::>(); + for handle in abort_handles { + handle.abort(); + } + + let abort_wait = async { + while !self.connections.read().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }; + let _ = tokio::time::timeout(Duration::from_secs(1), abort_wait).await; } } } @@ -1557,7 +1729,7 @@ async fn run_video_streaming( video_manager: Arc, video_tx: mpsc::Sender, state: Arc>, - shutdown_tx: broadcast::Sender<()>, + shutdown_tx: watch::Sender, negotiated_codec: VideoEncoderType, ) -> anyhow::Result<()> { use crate::video::codec::VideoCodecType; @@ -1590,6 +1762,9 @@ async fn run_video_streaming( let mut video_adapter = VideoFrameAdapter::new(codec); let mut shutdown_rx = shutdown_tx.subscribe(); + if *shutdown_rx.borrow() { + return Ok(()); + } let mut encoded_count: u64 = 0; let mut last_log_time = Instant::now(); let mut waiting_for_keyframe = true; @@ -1652,7 +1827,7 @@ async fn run_video_streaming( tokio::select! { biased; - _ = shutdown_rx.recv() => { + _ = shutdown_rx.changed() => { debug!("Shutdown signal received, stopping video for connection {}", conn_id); break 'subscribe_loop; } @@ -1699,10 +1874,26 @@ async fn run_video_streaming( frame.pts_ms as u64, ); - // Send to connection (backpressure instead of dropping) - if video_tx.send(msg_bytes).await.is_err() { - debug!("Video channel closed for connection {}", conn_id); - break 'subscribe_loop; + // Never queue a run of stale frames behind a slow socket. + // If the one-frame queue is full, wait for a fresh keyframe + // before resuming so the decoder cannot receive a broken GOP. + match video_tx.try_send(msg_bytes) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + waiting_for_keyframe = true; + let now = Instant::now(); + if now.duration_since(last_keyframe_request) >= Duration::from_millis(200) { + if let Err(error) = video_manager.request_keyframe().await { + debug!("Failed to request recovery keyframe for connection {}: {}", conn_id, error); + } + last_keyframe_request = now; + } + continue; + } + Err(mpsc::error::TrySendError::Closed(_)) => { + debug!("Video channel closed for connection {}", conn_id); + break 'subscribe_loop; + } } last_sequence = Some(frame.sequence); @@ -1738,12 +1929,15 @@ async fn run_audio_streaming( audio_controller: Arc, audio_tx: mpsc::Sender, state: Arc>, - shutdown_tx: broadcast::Sender<()>, + shutdown_tx: watch::Sender, ) -> anyhow::Result<()> { // Audio format: 48kHz stereo Opus let mut audio_adapter = AudioFrameAdapter::new(48000, 2); let mut shutdown_rx = shutdown_tx.subscribe(); + if *shutdown_rx.borrow() { + return Ok(()); + } let mut frame_count: u64 = 0; let mut last_log_time = Instant::now(); @@ -1798,7 +1992,7 @@ async fn run_audio_streaming( tokio::select! { biased; - _ = shutdown_rx.recv() => { + _ = shutdown_rx.changed() => { debug!("Shutdown signal received, stopping audio for connection {}", conn_id); break 'subscribe_loop; } @@ -1820,10 +2014,14 @@ async fn run_audio_streaming( // Convert OpusFrame to RustDesk AudioFrame message let msg_bytes = audio_adapter.encode_opus_bytes(&opus_frame.data); - // Send to connection (blocks if channel is full, providing backpressure) - if audio_tx.send(msg_bytes).await.is_err() { - debug!("Audio channel closed for connection {}", conn_id); - break 'subscribe_loop; + // Audio is real-time data; dropping an old packet is preferable + // to accumulating seconds of latency. + match audio_tx.try_send(msg_bytes) { + Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {} + Err(mpsc::error::TrySendError::Closed(_)) => { + debug!("Audio channel closed for connection {}", conn_id); + break 'subscribe_loop; + } } frame_count += 1; @@ -1860,7 +2058,7 @@ mod tests { device_password: "fixed-password".to_string(), ..Default::default() }; - let (connection, _rx) = Connection::new( + let connection = Connection::new( 1, &config, mode, @@ -1892,7 +2090,7 @@ mod tests { async move { server_connection.handle_tcp(server_stream, peer_addr).await }, ); - let bytes = read_frame(&mut client_stream) + let bytes = read_frame_with_limit(&mut client_stream, MAX_UNAUTHENTICATED_PACKET_LENGTH) .await .expect("client should receive the first frame"); drop(client_stream); @@ -1944,4 +2142,80 @@ mod tests { let message = first_server_message(ConnectionMode::Secure).await; assert!(matches!(message.union, Some(message::Union::SignedId(_)))); } + + #[tokio::test] + async fn connection_manager_close_all_cancels_and_removes_sessions() { + let config = RustDeskConfig { + enabled: true, + mode: super::super::config::RustDeskMode::DirectIp, + ..Default::default() + }; + let manager = ConnectionManager::new(config); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let client = tokio::spawn(async move { TcpStream::connect(address).await.unwrap() }); + let (server, peer) = listener.accept().await.unwrap(); + let _client = client.await.unwrap(); + + manager + .accept_direct_connection(server, peer) + .await + .unwrap(); + assert_eq!(manager.connection_count(), 1); + manager.close_all().await; + assert_eq!(manager.connection_count(), 0); + } + + #[tokio::test] + async fn connection_manager_enforces_session_limit() { + let config = RustDeskConfig { + enabled: true, + mode: super::super::config::RustDeskMode::DirectIp, + ..Default::default() + }; + let manager = ConnectionManager::new(config); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let mut clients = Vec::new(); + + for _ in 0..MAX_CONNECTIONS { + let client = tokio::spawn(async move { TcpStream::connect(address).await.unwrap() }); + let (server, peer) = listener.accept().await.unwrap(); + clients.push(client.await.unwrap()); + manager + .accept_direct_connection(server, peer) + .await + .unwrap(); + } + let extra_client = tokio::spawn(async move { TcpStream::connect(address).await.unwrap() }); + let (extra_server, extra_peer) = listener.accept().await.unwrap(); + clients.push(extra_client.await.unwrap()); + assert!(manager + .accept_direct_connection(extra_server, extra_peer) + .await + .is_err()); + + manager.close_all().await; + assert_eq!(manager.connection_count(), 0); + } + + #[tokio::test] + async fn password_attempts_are_bounded_per_session() { + let mut connection = connection(ConnectionMode::DirectIp); + let (writer, _reader) = mpsc::channel(32); + let mut login = LoginRequest::new(); + login.my_id = "attacker".to_string(); + login.password = vec![0u8; 32].into(); + + for _ in 1..MAX_PASSWORD_ATTEMPTS { + assert!(!connection + .handle_login_request_arc(&login, &writer) + .await + .unwrap()); + } + assert!(connection + .handle_login_request_arc(&login, &writer) + .await + .is_err()); + } } diff --git a/src/rustdesk/frame_adapters.rs b/src/rustdesk/frame_adapters.rs index 93dc6f67..de57bc4c 100644 --- a/src/rustdesk/frame_adapters.rs +++ b/src/rustdesk/frame_adapters.rs @@ -91,6 +91,12 @@ impl VideoFrameAdapter { return data; } + // Parameter sets are relevant only on random-access frames. Avoid a + // full Annex-B/AVCC scan on every delta frame in every client session. + if !is_keyframe { + return data; + } + let (sps, pps) = crate::video::codec::h264_bitstream::extract_sps_pps(&data); let mut has_sps = false; let mut has_pps = false; diff --git a/src/rustdesk/mod.rs b/src/rustdesk/mod.rs index ff70b042..3e443641 100644 --- a/src/rustdesk/mod.rs +++ b/src/rustdesk/mod.rs @@ -17,7 +17,7 @@ use std::time::Duration; use parking_lot::RwLock; use protobuf::Message; use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::broadcast; +use tokio::sync::{broadcast, Semaphore}; use tokio::task::JoinHandle; use tracing::{debug, error, info, warn}; @@ -33,6 +33,7 @@ use self::rendezvous::{AddrMangle, RendezvousMediator, RendezvousStatus}; const RELAY_CONNECT_TIMEOUT_MS: u64 = 10_000; const SERVICE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_PENDING_CONNECTION_ATTEMPTS: usize = 8; #[derive(Debug, Clone, PartialEq)] pub enum ServiceStatus { @@ -59,6 +60,7 @@ pub struct RustDeskService { rendezvous: Arc>>>, rendezvous_handle: Arc>>>, tcp_listener_handle: Arc>>>>, + listener_start_lock: Arc>, listen_port: Arc>, connection_manager: Arc, video_manager: Arc, @@ -84,6 +86,7 @@ impl RustDeskService { rendezvous: Arc::new(RwLock::new(None)), rendezvous_handle: Arc::new(RwLock::new(None)), tcp_listener_handle: Arc::new(RwLock::new(None)), + listener_start_lock: Arc::new(tokio::sync::Mutex::new(())), listen_port: Arc::new(RwLock::new(direct_access_port)), connection_manager, video_manager, @@ -130,7 +133,7 @@ impl RustDeskService { self.status() == ServiceStatus::Running } - pub async fn start(&self) -> anyhow::Result<()> { + pub async fn start(self: &Arc) -> anyhow::Result<()> { let config = self.config.read().clone(); if !config.enabled { @@ -168,14 +171,16 @@ impl RustDeskService { .set_video_manager(self.video_manager.clone()); if config.mode == RustDeskMode::DirectIp { - let (tcp_handles, listen_port) = match self.start_tcp_listener_with_port().await { + let listen_port = match self + .ensure_tcp_listener(config.direct_access_port, false) + .await + { Ok(result) => result, Err(err) => { *self.status.write() = ServiceStatus::Error(err.to_string()); return Err(err); } }; - *self.tcp_listener_handle.write() = Some(tcp_handles); *self.listen_port.write() = listen_port; *self.status.write() = ServiceStatus::Running; return Ok(()); @@ -193,14 +198,23 @@ impl RustDeskService { let connection_manager = self.connection_manager.clone(); let service_config = self.config.clone(); + let connection_attempts = Arc::new(Semaphore::new(MAX_PENDING_CONNECTION_ATTEMPTS)); mediator.set_punch_callback(Arc::new({ let connection_manager = connection_manager.clone(); let service_config = service_config.clone(); + let connection_attempts = connection_attempts.clone(); move |peer_addr, rendezvous_addr, relay_server, uuid, socket_addr, device_id| { let conn_mgr = connection_manager.clone(); let config = service_config.clone(); + let attempts = connection_attempts.clone(); tokio::spawn(async move { + let Ok(_permit) = attempts.try_acquire_owned() else { + warn!( + "Dropping RustDesk punch request: too many pending connection attempts" + ); + return; + }; if let Some(addr) = peer_addr { info!("Attempting P2P direct connection to {}", addr); match punch::try_direct_connection(addr).await { @@ -238,10 +252,18 @@ impl RustDeskService { mediator.set_relay_callback(Arc::new({ let connection_manager = connection_manager.clone(); let service_config = service_config.clone(); + let connection_attempts = connection_attempts.clone(); move |rendezvous_addr, relay_server, uuid, socket_addr, device_id| { let conn_mgr = connection_manager.clone(); let config = service_config.clone(); + let attempts = connection_attempts.clone(); tokio::spawn(async move { + let Ok(_permit) = attempts.try_acquire_owned() else { + warn!( + "Dropping RustDesk relay request: too many pending connection attempts" + ); + return; + }; let relay_key = rustdesk_relay_key(&config); if let Err(e) = handle_relay_request( &rendezvous_addr, @@ -260,19 +282,39 @@ impl RustDeskService { } })); - let connection_manager2 = self.connection_manager.clone(); + let weak_service = Arc::downgrade(self); + let intranet_attempts = connection_attempts.clone(); mediator.set_intranet_callback(Arc::new( - move |rendezvous_addr, peer_socket_addr, local_addr, relay_server, device_id| { - let conn_mgr = connection_manager2.clone(); - + move |rendezvous_addr, peer_socket_addr, local_ip, relay_server, device_id| { + let weak_service = weak_service.clone(); + let attempts = intranet_attempts.clone(); tokio::spawn(async move { + let Ok(_permit) = attempts.try_acquire_owned() else { + warn!("Dropping RustDesk intranet request: too many pending connection attempts"); + return; + }; + let Some(service) = weak_service.upgrade() else { + return; + }; + let preferred_port = service.config.read().direct_access_port; + let listen_port = match service + .ensure_tcp_listener(preferred_port, true) + .await + { + Ok(port) => port, + Err(error) => { + error!("Failed to start on-demand RustDesk listener: {}", error); + return; + } + }; + let local_addr = SocketAddr::new(local_ip, listen_port); if let Err(e) = handle_intranet_request( &rendezvous_addr, &peer_socket_addr, local_addr, &relay_server, &device_id, - conn_mgr, + service.connection_manager.clone(), ) .await { @@ -304,9 +346,30 @@ impl RustDeskService { Ok(()) } - async fn start_tcp_listener_with_port(&self) -> anyhow::Result<(Vec>, u16)> { - let direct_access_port = self.config.read().direct_access_port; - let (listeners, listen_port) = self.bind_direct_listeners(direct_access_port)?; + async fn ensure_tcp_listener( + self: &Arc, + preferred_port: u16, + allow_ephemeral_fallback: bool, + ) -> anyhow::Result { + let _guard = self.listener_start_lock.lock().await; + if self.tcp_listener_handle.read().is_some() { + return Ok(*self.listen_port.read()); + } + if self.status() == ServiceStatus::Stopped { + anyhow::bail!("RustDesk service stopped before listener could start"); + } + + let (listeners, listen_port) = match self.bind_direct_listeners(preferred_port) { + Ok(result) => result, + Err(error) if allow_ephemeral_fallback => { + warn!( + "RustDesk port {} unavailable for on-demand listening: {}; using an ephemeral port", + preferred_port, error + ); + self.bind_direct_listeners(0)? + } + Err(error) => return Err(error), + }; *self.listen_port.write() = listen_port; @@ -326,15 +389,13 @@ impl RustDeskService { match result { Ok((stream, peer_addr)) => { info!("Accepted direct connection from {}", peer_addr); - let conn_mgr = conn_mgr.clone(); - tokio::spawn(async move { - if let Err(e) = conn_mgr.accept_direct_connection(stream, peer_addr).await { - error!("Failed to handle direct connection from {}: {}", peer_addr, e); - } - }); + if let Err(e) = conn_mgr.accept_listener_connection(stream, peer_addr).await { + warn!("Rejected direct connection from {}: {}", peer_addr, e); + } } Err(e) => { error!("TCP accept error: {}", e); + tokio::time::sleep(Duration::from_millis(100)).await; } } } @@ -348,7 +409,8 @@ impl RustDeskService { handles.push(handle); } - Ok((handles, listen_port)) + *self.tcp_listener_handle.write() = Some(handles); + Ok(listen_port) } fn bind_direct_listeners(&self, port: u16) -> anyhow::Result<(Vec, u16)> { @@ -382,8 +444,8 @@ impl RustDeskService { info!("Stopping RustDesk service"); let _ = self.shutdown_tx.send(()); - - self.connection_manager.close_all(); + let _listener_guard = self.listener_start_lock.lock().await; + *self.status.write() = ServiceStatus::Stopped; if let Some(mediator) = self.rendezvous.read().as_ref() { mediator.stop(); @@ -401,13 +463,15 @@ impl RustDeskService { } } + // No listener can admit a new session after this point. + self.connection_manager.close_all().await; + *self.rendezvous.write() = None; - *self.status.write() = ServiceStatus::Stopped; Ok(()) } - pub async fn restart(&self, config: RustDeskConfig) -> anyhow::Result<()> { + pub async fn restart(self: &Arc, config: RustDeskConfig) -> anyhow::Result<()> { self.stop().await?; self.update_config(config); self.start().await diff --git a/src/rustdesk/rendezvous.rs b/src/rustdesk/rendezvous.rs index 6147c535..ab5f919b 100644 --- a/src/rustdesk/rendezvous.rs +++ b/src/rustdesk/rendezvous.rs @@ -128,7 +128,7 @@ pub type RelayCallback = Arc, String) + S pub type PunchCallback = Arc, String, String, String, Vec, String) + Send + Sync>; -pub type IntranetCallback = Arc, SocketAddr, String, String) + Send + Sync>; +pub type IntranetCallback = Arc, IpAddr, String, String) + Send + Sync>; pub struct RendezvousMediator { config: Arc>, @@ -143,7 +143,6 @@ pub struct RendezvousMediator { relay_callback: Arc>>, punch_callback: Arc>>, intranet_callback: Arc>>, - listen_port: Arc>, shutdown_tx: broadcast::Sender<()>, } @@ -166,23 +165,10 @@ impl RendezvousMediator { relay_callback: Arc::new(RwLock::new(None)), punch_callback: Arc::new(RwLock::new(None)), intranet_callback: Arc::new(RwLock::new(None)), - listen_port: Arc::new(RwLock::new(21118)), shutdown_tx, } } - pub fn set_listen_port(&self, port: u16) { - let old_port = *self.listen_port.read(); - if old_port != port { - *self.listen_port.write() = port; - self.increment_serial(); - } - } - - pub fn listen_port(&self) -> u16 { - *self.listen_port.read() - } - pub fn increment_serial(&self) { let mut serial = self.serial.write(); *serial = serial.wrapping_add(1); @@ -430,7 +416,9 @@ impl RendezvousMediator { ) -> anyhow::Result<()> { let id = self.device_id(); - let local_addrs = get_local_addresses(); + let local_addrs = tokio::task::spawn_blocking(get_local_addresses) + .await + .map_err(|error| anyhow::anyhow!("Failed to inspect local addresses: {error}"))?; if local_addrs.is_empty() { debug!("No local addresses available for LocalAddr response"); return Ok(()); @@ -439,21 +427,18 @@ impl RendezvousMediator { let config = self.config.read().clone(); let rendezvous_addr = config.rendezvous_addr(); - let listen_port = self.listen_port(); - let local_ip = local_addrs[0]; - let local_sock_addr = SocketAddr::new(local_ip, listen_port); info!( - "FetchLocalAddr: calling intranet callback with local_addr={}, rendezvous={}", - local_sock_addr, rendezvous_addr + "FetchLocalAddr: requesting an on-demand listener for {}, rendezvous={}", + local_ip, rendezvous_addr ); if let Some(callback) = self.intranet_callback.read().as_ref() { callback( rendezvous_addr, peer_socket_addr.to_vec(), - local_sock_addr, + local_ip, relay_server.to_string(), id, ); diff --git a/src/web/handlers/config/rustdesk.rs b/src/web/handlers/config/rustdesk.rs index 2af7df96..6801a6c8 100644 --- a/src/web/handlers/config/rustdesk.rs +++ b/src/web/handlers/config/rustdesk.rs @@ -47,6 +47,9 @@ async fn current_status( config: RustDeskConfigResponse::from(&config), service_status: runtime.service_status, rendezvous_status: runtime.rendezvous_status, + connection_count: runtime.connection_count, + listening: runtime.listening, + listen_port: runtime.listen_port, } } @@ -86,6 +89,9 @@ pub struct RustDeskStatusResponse { pub config: RustDeskConfigResponse, pub service_status: String, pub rendezvous_status: Option, + pub connection_count: usize, + pub listening: bool, + pub listen_port: Option, } pub async fn get_rustdesk_config( diff --git a/web/src/api/config.ts b/web/src/api/config.ts index a6da99b4..f54b9628 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -236,6 +236,9 @@ export interface RustDeskStatusResponse { config: RustDeskConfigResponse service_status: string rendezvous_status: string | null + connection_count: number + listening: boolean + listen_port: number | null } export interface RustDeskConfigUpdate { diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index ce266394..1a8b5876 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -1079,8 +1079,6 @@ export default { modeDirectIpDesc: 'Listen on the device only, without connecting to an ID or relay server', directAccessPort: 'Direct Access Port', directAccessPortInvalid: 'The direct access port must be between 1 and 65535', - directAccessWarningTitle: 'Direct IP access is not end-to-end encrypted', - directAccessWarningDesc: 'Use it only on a trusted LAN or encrypted VPN. Do not expose this port directly to the internet.', rendezvousServer: 'ID Server', rendezvousServerPlaceholder: 'hbbs.example.com:21116', rendezvousServerRequired: 'Enter the RustDesk ID server', @@ -1088,7 +1086,6 @@ export default { relayServerPlaceholder: 'hbbr.example.com:21117', relayKey: 'Relay Key', codec: 'Codec', - deviceInfo: 'Device Info', deviceId: 'Device ID', devicePassword: 'Device Password', showPassword: 'Show Password', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index a625f869..37b544dd 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -1078,8 +1078,6 @@ export default { modeDirectIpDesc: '仅监听设备端口,不连接 ID 或中继服务器', directAccessPort: '直连端口', directAccessPortInvalid: '直连端口必须在 1 到 65535 之间', - directAccessWarningTitle: 'IP 直连不提供端到端加密', - directAccessWarningDesc: '仅建议在可信局域网或加密 VPN 中使用,请勿将此端口直接暴露到公网。', rendezvousServer: 'ID 服务器', rendezvousServerPlaceholder: 'hbbs.example.com:21116', rendezvousServerRequired: '请填写 RustDesk ID 服务器', @@ -1087,7 +1085,6 @@ export default { relayServerPlaceholder: 'hbbr.example.com:21117', relayKey: '中继密钥', codec: '编码格式', - deviceInfo: '设备信息', deviceId: '设备 ID', devicePassword: '设备密码', showPassword: '显示密码', diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 8f65c6f5..8562c038 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -5166,18 +5166,11 @@ watch(isWindows, () => {

{{ rustdeskValidationMessage }}

- - - {{ t('extensions.rustdesk.directAccessWarningTitle') }} - {{ t('extensions.rustdesk.directAccessWarningDesc') }} -
-

{{ t('extensions.rustdesk.deviceInfo') }}

-
From b074aa577967b69a3445802d9ffd627bf6b72b2f Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sun, 30 Aug 2026 08:50:38 +0800 Subject: [PATCH 15/29] =?UTF-8?q?fix(rustdesk):=20=E9=99=8D=E4=BD=8E?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E7=9B=B4=E8=BF=9E=E5=BB=B6=E8=BF=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/rustdesk/connection.rs | 36 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/src/rustdesk/connection.rs b/src/rustdesk/connection.rs index dafffe7f..599ab9a1 100644 --- a/src/rustdesk/connection.rs +++ b/src/rustdesk/connection.rs @@ -300,7 +300,9 @@ impl Connection { // Keep socket writes out of the input loop. A congested video path must // never prevent us from reading keyboard and mouse events. let (control_tx, control_rx) = mpsc::channel::(32); - let (video_tx, video_rx) = mpsc::channel::(1); + // Absorb short encoder/socket scheduling bursts without treating a + // momentarily busy writer as a broken inter-frame sequence. + let (video_tx, video_rx) = mpsc::channel::(4); let (audio_tx, audio_rx) = mpsc::channel::(8); let mut writer_task = tokio::spawn(run_connection_writer( writer, control_rx, video_rx, audio_rx, @@ -1405,6 +1407,8 @@ async fn run_connection_writer( loop { let (data, encrypt) = tokio::select! { + biased; + command = control_rx.recv() => { match command { Some(WriterCommand::SetSessionKey(key)) => { @@ -1415,13 +1419,13 @@ async fn run_connection_writer( Some(WriterCommand::Shutdown) | None => break, } } - frame = audio_rx.recv() => { + frame = video_rx.recv() => { match frame { Some(data) => (data, session_key.is_some()), None => continue, } } - frame = video_rx.recv() => { + frame = audio_rx.recv() => { match frame { Some(data) => (data, session_key.is_some()), None => continue, @@ -1874,26 +1878,12 @@ async fn run_video_streaming( frame.pts_ms as u64, ); - // Never queue a run of stale frames behind a slow socket. - // If the one-frame queue is full, wait for a fresh keyframe - // before resuming so the decoder cannot receive a broken GOP. - match video_tx.try_send(msg_bytes) { - Ok(()) => {} - Err(mpsc::error::TrySendError::Full(_)) => { - waiting_for_keyframe = true; - let now = Instant::now(); - if now.duration_since(last_keyframe_request) >= Duration::from_millis(200) { - if let Err(error) = video_manager.request_keyframe().await { - debug!("Failed to request recovery keyframe for connection {}: {}", conn_id, error); - } - last_keyframe_request = now; - } - continue; - } - Err(mpsc::error::TrySendError::Closed(_)) => { - debug!("Video channel closed for connection {}", conn_id); - break 'subscribe_loop; - } + // A small bounded queue absorbs transient writer jitter. + // Backpressure here cannot block input handling because the + // TCP reader and writer run independently. + if video_tx.send(msg_bytes).await.is_err() { + debug!("Video channel closed for connection {}", conn_id); + break 'subscribe_loop; } last_sequence = Some(frame.sequence); From ccf821ab222e3c9637046599091fcd07a401ecff Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sun, 30 Aug 2026 20:48:55 +0800 Subject: [PATCH 16/29] =?UTF-8?q?refactor(rustdesk):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E5=AF=B9=E7=8A=B6=E6=80=81=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/web/handlers/config/rustdesk.rs | 2 -- web/src/api/config.ts | 1 - web/src/i18n/en-US.ts | 1 - web/src/i18n/zh-CN.ts | 1 - web/src/views/SettingsView.vue | 9 --------- 5 files changed, 14 deletions(-) diff --git a/src/web/handlers/config/rustdesk.rs b/src/web/handlers/config/rustdesk.rs index 6801a6c8..659f0d0b 100644 --- a/src/web/handlers/config/rustdesk.rs +++ b/src/web/handlers/config/rustdesk.rs @@ -63,7 +63,6 @@ pub struct RustDeskConfigResponse { pub relay_server: Option, pub device_id: String, pub has_password: bool, - pub has_keypair: bool, pub relay_key: Option, } @@ -78,7 +77,6 @@ impl From<&RustDeskConfig> for RustDeskConfigResponse { relay_server: config.relay_server.clone(), device_id: config.device_id.clone(), has_password: !config.device_password.is_empty(), - has_keypair: config.public_key.is_some() && config.private_key.is_some(), relay_key: config.relay_key.clone(), } } diff --git a/web/src/api/config.ts b/web/src/api/config.ts index f54b9628..a6c01c88 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -228,7 +228,6 @@ export interface RustDeskConfigResponse { relay_server: string | null device_id: string has_password: boolean - has_keypair: boolean relay_key: string | null } diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 1a8b5876..726450bb 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -1101,7 +1101,6 @@ export default { notInitialized: 'Not Initialized', copyId: 'Copy ID', copyPassword: 'Copy Password', - keypairGenerated: 'Keypair Generated', }, rtsp: { title: 'RTSP Streaming', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 37b544dd..7f1e0188 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -1100,7 +1100,6 @@ export default { notInitialized: '未初始化', copyId: '复制 ID', copyPassword: '复制密码', - keypairGenerated: '密钥对已生成', }, rtsp: { title: 'RTSP 视频流', diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 8562c038..2b69693f 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -5217,15 +5217,6 @@ watch(isWindows, () => {
- -
- -
- - {{ rustdeskConfig?.has_keypair ? t('common.yes') : t('common.no') }} - -
-
From dbe5672df4089115aa6d7677366c909a19db3395 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sun, 30 Aug 2026 21:25:39 +0800 Subject: [PATCH 17/29] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=88=9D=E5=A7=8B=E5=8C=96=E4=B8=8E=20WOL=20?= =?UTF-8?q?=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/atx/mod.rs | 2 +- src/atx/wol.rs | 51 ---------- src/auth/two_factor.rs | 5 +- src/auth/user.rs | 32 +++--- src/config/store.rs | 14 +-- src/db/mod.rs | 2 + src/db/pool.rs | 193 +++++++++++++++++++----------------- src/db/wol_history.rs | 39 ++++++++ src/runtime/builder.rs | 4 +- src/web/handlers/atx_api.rs | 4 +- 10 files changed, 171 insertions(+), 175 deletions(-) create mode 100644 src/db/wol_history.rs diff --git a/src/atx/mod.rs b/src/atx/mod.rs index 3b51f8c4..5ab879ae 100644 --- a/src/atx/mod.rs +++ b/src/atx/mod.rs @@ -27,7 +27,7 @@ pub use types::{ ActiveLevel, AtxAction, AtxDevices, AtxDriverType, AtxInputBinding, AtxKeyConfig, AtxOutputBinding, AtxPowerRequest, AtxState, HddStatus, PowerStatus, LCUS_RELAY_MAX_CHANNEL, }; -pub use wol::{list_wol_history, record_wol_history, send_wol}; +pub use wol::send_wol; #[cfg(any(unix, test))] fn hidraw_uevent_is_usb_relay(uevent: &str) -> bool { diff --git a/src/atx/wol.rs b/src/atx/wol.rs index ab66bd2e..04a4366a 100644 --- a/src/atx/wol.rs +++ b/src/atx/wol.rs @@ -7,8 +7,6 @@ use tracing::info; use crate::error::{AppError, Result}; -const WOL_HISTORY_MAX_ENTRIES: i64 = 50; - const MAGIC_PACKET_SIZE: usize = 102; fn parse_mac_address(mac: &str) -> Result<[u8; 6]> { @@ -118,55 +116,6 @@ pub fn send_wol(mac_address: &str, interface: Option<&str>) -> Result<()> { Ok(()) } -pub async fn record_wol_history(pool: &sqlx::Pool, mac_address: &str) -> Result<()> { - sqlx::query( - r#" - INSERT INTO wol_history (mac_address, updated_at) - VALUES (?1, CAST(strftime('%s', 'now') AS INTEGER)) - ON CONFLICT(mac_address) DO UPDATE SET - updated_at = excluded.updated_at - "#, - ) - .bind(mac_address) - .execute(pool) - .await?; - - sqlx::query( - r#" - DELETE FROM wol_history - WHERE mac_address NOT IN ( - SELECT mac_address FROM wol_history - ORDER BY updated_at DESC - LIMIT ?1 - ) - "#, - ) - .bind(WOL_HISTORY_MAX_ENTRIES) - .execute(pool) - .await?; - - Ok(()) -} - -pub async fn list_wol_history( - pool: &sqlx::Pool, - limit: usize, -) -> Result> { - let rows = sqlx::query_as( - r#" - SELECT mac_address, updated_at - FROM wol_history - ORDER BY updated_at DESC - LIMIT ?1 - "#, - ) - .bind(limit as i64) - .fetch_all(pool) - .await?; - - Ok(rows) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/auth/two_factor.rs b/src/auth/two_factor.rs index 73b013bb..27d29761 100644 --- a/src/auth/two_factor.rs +++ b/src/auth/two_factor.rs @@ -227,15 +227,14 @@ impl TwoFactorService { return Err(AppError::AuthError("Invalid TOTP code".to_string())); } - let mut transaction = self.pool.begin().await?; let result = sqlx::query("INSERT INTO user_totp_credentials (user_id, secret) VALUES (?1, ?2)") .bind(user_id) .bind(secret.to_string()) - .execute(&mut *transaction) + .execute(&self.pool) .await; match result { - Ok(_) => transaction.commit().await?, + Ok(_) => {} Err(sqlx::Error::Database(error)) if error.is_unique_violation() => { return Err(AppError::Conflict("TOTP is already enabled".to_string())); } diff --git a/src/auth/user.rs b/src/auth/user.rs index e131fc6b..0333492f 100644 --- a/src/auth/user.rs +++ b/src/auth/user.rs @@ -1,7 +1,5 @@ use serde::{Deserialize, Serialize}; use sqlx::{Pool, Sqlite}; -use time::format_description::well_known::Rfc3339; -use time::OffsetDateTime; use uuid::Uuid; use super::password::{hash_password, verify_password}; @@ -112,15 +110,13 @@ impl UserStore { } let password_hash = hash_password(new_password)?; - let now = OffsetDateTime::now_utc(); - - let result = - sqlx::query("UPDATE users SET password_hash = ?1, updated_at = ?2 WHERE id = ?3") - .bind(&password_hash) - .bind(now.format(&Rfc3339).expect("RFC3339 format")) - .bind(user_id) - .execute(&self.pool) - .await?; + let result = sqlx::query( + "UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE id = ?2", + ) + .bind(&password_hash) + .bind(user_id) + .execute(&self.pool) + .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("User not found".to_string())); @@ -143,13 +139,13 @@ impl UserStore { return Ok(()); } - let now = OffsetDateTime::now_utc(); - let result = sqlx::query("UPDATE users SET username = ?1, updated_at = ?2 WHERE id = ?3") - .bind(new_username) - .bind(now.format(&Rfc3339).expect("RFC3339 format")) - .bind(user_id) - .execute(&self.pool) - .await?; + let result = sqlx::query( + "UPDATE users SET username = ?1, updated_at = datetime('now') WHERE id = ?2", + ) + .bind(new_username) + .bind(user_id) + .execute(&self.pool) + .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("User not found".to_string())); diff --git a/src/config/store.rs b/src/config/store.rs index 4a56323a..040eeeb5 100644 --- a/src/config/store.rs +++ b/src/config/store.rs @@ -17,13 +17,13 @@ pub struct ConfigStore { } impl ConfigStore { - pub fn new(pool: Pool) -> Result { - Ok(Self { + pub fn new(pool: Pool) -> Self { + Self { pool, cache: Arc::new(ArcSwap::from_pointee(AppConfig::default())), change_tx: broadcast::channel(16).0, write_lock: Arc::new(Mutex::new(())), - }) + } } pub async fn load(&self) -> Result<()> { @@ -145,7 +145,7 @@ mod tests { let db = DatabasePool::new(&db_path).await.unwrap(); db.init_schema().await.unwrap(); - let store = ConfigStore::new(db.clone_pool()).unwrap(); + let store = ConfigStore::new(db.clone_pool()); store.load().await.unwrap(); let config = store.get(); @@ -163,7 +163,7 @@ mod tests { assert!(config.initialized); assert_eq!(config.web.http_port, 9000); - let store2 = ConfigStore::new(db.clone_pool()).unwrap(); + let store2 = ConfigStore::new(db.clone_pool()); store2.load().await.unwrap(); let config = store2.get(); assert!(config.initialized); @@ -176,7 +176,7 @@ mod tests { let db_path = dir.path().join("test.db"); let db = DatabasePool::new(&db_path).await.unwrap(); db.init_schema().await.unwrap(); - let store = ConfigStore::new(db.clone_pool()).unwrap(); + let store = ConfigStore::new(db.clone_pool()); store.load().await.unwrap(); sqlx::query("DROP TABLE config") @@ -210,7 +210,7 @@ mod tests { .await .unwrap(); - let store = ConfigStore::new(db.clone_pool()).unwrap(); + let store = ConfigStore::new(db.clone_pool()); store.load().await.unwrap(); let (persisted,): (String,) = sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'") diff --git a/src/db/mod.rs b/src/db/mod.rs index 868805a2..470c4288 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,10 +1,12 @@ mod pool; +mod wol_history; use std::path::Path; use crate::error::Result; pub use pool::DatabasePool; +pub use wol_history::WolHistoryStore; /// Open the application database stored in `data_dir` and ensure its schema exists. pub async fn open_database_pool(data_dir: &Path) -> Result { diff --git a/src/db/pool.rs b/src/db/pool.rs index b06dd4bc..cd143e6d 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -1,4 +1,7 @@ -use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite}; +use sqlx::{ + sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, + Pool, Sqlite, +}; use std::path::Path; use std::time::Duration; @@ -15,114 +18,62 @@ impl DatabasePool { tokio::fs::create_dir_all(parent).await?; } - let db_url = format!("sqlite:{}?mode=rwc", db_path.display()); + let options = SqliteConnectOptions::new() + .filename(db_path) + .create_if_missing(true) + .foreign_keys(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .busy_timeout(Duration::from_secs(5)); let pool = SqlitePoolOptions::new() .max_connections(4) .acquire_timeout(Duration::from_secs(5)) .idle_timeout(Duration::from_secs(300)) - .connect(&db_url) + .connect_with(options) .await?; Ok(Self { pool }) } pub async fn init_schema(&self) -> Result<()> { - self.create_config_table().await?; - self.create_users_table().await?; - self.create_user_totp_credentials_table().await?; - self.create_api_tokens_table().await?; - self.create_wol_history_table().await?; - Ok(()) - } - - async fn create_config_table(&self) -> Result<()> { + // Keep migrations embedded in the binary so deployments do not need an + // extra migrations directory or another runtime dependency. + let mut transaction = self.pool.begin().await?; sqlx::query( r#" - CREATE TABLE IF NOT EXISTS config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) ) "#, ) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn create_users_table(&self) -> Result<()> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ) - "#, - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn create_api_tokens_table(&self) -> Result<()> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS api_tokens ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - token_hash TEXT NOT NULL, - permissions TEXT NOT NULL, - expires_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - last_used TEXT - ) - "#, - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn create_user_totp_credentials_table(&self) -> Result<()> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS user_totp_credentials ( - user_id TEXT PRIMARY KEY, - secret TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - "#, - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn create_wol_history_table(&self) -> Result<()> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS wol_history ( - mac_address TEXT PRIMARY KEY, - updated_at INTEGER NOT NULL - ) - "#, - ) - .execute(&self.pool) + .execute(&mut *transaction) .await?; - sqlx::query( - r#" - CREATE INDEX IF NOT EXISTS idx_wol_history_updated_at - ON wol_history(updated_at DESC) - "#, - ) - .execute(&self.pool) - .await?; + let current_version: i64 = + sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_migrations") + .fetch_one(&mut *transaction) + .await?; + + for (version, statements) in SCHEMA_MIGRATIONS + .iter() + .enumerate() + .map(|(index, statements)| ((index + 1) as i64, *statements)) + { + if version <= current_version { + continue; + } + for &statement in statements { + sqlx::query(statement).execute(&mut *transaction).await?; + } + sqlx::query("INSERT INTO schema_migrations (version) VALUES (?1)") + .bind(version) + .execute(&mut *transaction) + .await?; + } + + transaction.commit().await?; Ok(()) } @@ -133,4 +84,64 @@ impl DatabasePool { pub fn clone_pool(&self) -> Pool { self.pool.clone() } + + pub fn wol_history(&self) -> super::WolHistoryStore { + super::WolHistoryStore::new(self.pool.clone()) + } } + +// Each item is one version; statements within a version run atomically. +// New schema changes should be appended as a new item, never edited in place. +const SCHEMA_MIGRATIONS: &[&[&str]] = &[ + &[ + r#" + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + "#, + r#" + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + "#, + r#" + CREATE TABLE IF NOT EXISTS user_totp_credentials ( + user_id TEXT PRIMARY KEY, + secret TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + "#, + r#" + CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + permissions TEXT NOT NULL, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used TEXT + ) + "#, + r#" + CREATE TABLE IF NOT EXISTS wol_history ( + mac_address TEXT PRIMARY KEY, + updated_at INTEGER NOT NULL + ) + "#, + r#" + CREATE INDEX IF NOT EXISTS idx_wol_history_updated_at + ON wol_history(updated_at DESC) + "#, + ], + &[r#" + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_singleton + ON users ((1)) + "#], +]; diff --git a/src/db/wol_history.rs b/src/db/wol_history.rs new file mode 100644 index 00000000..0c1c20c0 --- /dev/null +++ b/src/db/wol_history.rs @@ -0,0 +1,39 @@ +use sqlx::{Pool, Sqlite}; + +use crate::error::Result; + +const MAX_ENTRIES: i64 = 50; + +#[derive(Clone)] +pub struct WolHistoryStore { + pool: Pool, +} + +impl WolHistoryStore { + pub(crate) fn new(pool: Pool) -> Self { + Self { pool } + } + + pub async fn record(&self, mac_address: &str) -> Result<()> { + let mut tx = self.pool.begin().await?; + sqlx::query("INSERT INTO wol_history (mac_address, updated_at) VALUES (?1, CAST(strftime('%s', 'now') AS INTEGER)) ON CONFLICT(mac_address) DO UPDATE SET updated_at = excluded.updated_at") + .bind(mac_address) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM wol_history WHERE mac_address NOT IN (SELECT mac_address FROM wol_history ORDER BY updated_at DESC LIMIT ?1)") + .bind(MAX_ENTRIES) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + + pub async fn list(&self, limit: usize) -> Result> { + Ok(sqlx::query_as( + "SELECT mac_address, updated_at FROM wol_history ORDER BY updated_at DESC LIMIT ?1", + ) + .bind(limit as i64) + .fetch_all(&self.pool) + .await?) + } +} diff --git a/src/runtime/builder.rs b/src/runtime/builder.rs index 95e453aa..45d4ed46 100644 --- a/src/runtime/builder.rs +++ b/src/runtime/builder.rs @@ -226,7 +226,7 @@ async fn load_runtime_config( ) -> anyhow::Result<(DatabasePool, ConfigStore, AppConfig)> { let db = open_database_pool(data_dir).await?; - let config_store = ConfigStore::new(db.clone_pool())?; + let config_store = ConfigStore::new(db.clone_pool()); config_store.load().await?; let mut config = (*config_store.get()).clone(); config.apply_platform_defaults(); @@ -568,7 +568,7 @@ mod tests { let data_dir = temp_dir.path().join("data"); let msd_dir = temp_dir.path().join("disabled-msd"); let db = open_database_pool(&data_dir).await.unwrap(); - let config_store = ConfigStore::new(db.clone_pool()).unwrap(); + let config_store = ConfigStore::new(db.clone_pool()); config_store.load().await.unwrap(); let mut config = (*config_store.get()).clone(); config.msd.enabled = false; diff --git a/src/web/handlers/atx_api.rs b/src/web/handlers/atx_api.rs index d439d0c3..5a1a6d21 100644 --- a/src/web/handlers/atx_api.rs +++ b/src/web/handlers/atx_api.rs @@ -171,7 +171,7 @@ pub async fn atx_wol( // Send WOL packet crate::atx::send_wol(&mac_address, interface)?; - if let Err(error) = crate::atx::record_wol_history(state.db.pool(), &mac_address).await { + if let Err(error) = state.db.wol_history().record(&mac_address).await { warn!("Failed to persist WOL history: {}", error); } @@ -191,7 +191,7 @@ pub async fn atx_wol_history( .unwrap_or(WOL_HISTORY_DEFAULT_LIMIT) .clamp(1, WOL_HISTORY_MAX_LIMIT); - let rows = crate::atx::list_wol_history(state.db.pool(), limit).await?; + let rows = state.db.wol_history().list(limit).await?; let history = rows .into_iter() From d67a3ab7f8e1d0fccbc8d998b39ec8f71e06f8ee Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sat, 5 Sep 2026 14:10:53 +0800 Subject: [PATCH 18/29] =?UTF-8?q?fix(build):=20=E5=9C=A8=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E8=A7=A3=E6=9E=90=E7=BC=96=E8=A7=A3=E7=A0=81=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E6=BA=90=E7=A0=81=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hwcodec 与 libyuv 构建脚本在运行时读取 CARGO_MANIFEST_DIR,避免复用或迁移构建产物后继续引用旧源码目录。 --- libs/hwcodec/build.rs | 13 +++++++------ res/vcpkg/libyuv/build.rs | 3 ++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/libs/hwcodec/build.rs b/libs/hwcodec/build.rs index 53dcc822..26aad7f3 100644 --- a/libs/hwcodec/build.rs +++ b/libs/hwcodec/build.rs @@ -5,7 +5,8 @@ use std::{ }; fn main() { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + // Read the current source path when running, since build artifacts may move. + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let cpp_dir = manifest_dir.join("cpp"); println!("cargo:rerun-if-changed=src"); println!("cargo:rerun-if-changed={}", cpp_dir.display()); @@ -17,7 +18,7 @@ fn main() { } fn build_common(builder: &mut Build) { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); let common_dir = manifest_dir.join("cpp").join("common"); @@ -367,7 +368,7 @@ mod ffmpeg { } fn ffmpeg_ffi() { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let ffmpeg_ram_dir = manifest_dir.join("cpp").join("common"); let ffi_header_path = ffmpeg_ram_dir.join("ffmpeg_ffi.h"); println!("cargo:rerun-if-changed={}", ffi_header_path.display()); @@ -381,7 +382,7 @@ mod ffmpeg { } fn build_ffmpeg_ram(builder: &mut Build) { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let ffmpeg_ram_dir = manifest_dir.join("cpp").join("ffmpeg_ram"); let ffi_header = ffmpeg_ram_dir .join("ffmpeg_ram_ffi.h") @@ -420,7 +421,7 @@ mod ffmpeg { return; } - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let capture_header = manifest_dir .join("cpp") .join("ffmpeg_capture_ffi.h") @@ -443,7 +444,7 @@ mod ffmpeg { } fn build_ffmpeg_hw(builder: &mut Build) { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let ffmpeg_hw_dir = manifest_dir.join("cpp").join("ffmpeg_hw"); let ffi_header = ffmpeg_hw_dir .join("ffmpeg_hw_ffi.h") diff --git a/res/vcpkg/libyuv/build.rs b/res/vcpkg/libyuv/build.rs index 33205e2c..466cb069 100644 --- a/res/vcpkg/libyuv/build.rs +++ b/res/vcpkg/libyuv/build.rs @@ -5,7 +5,8 @@ use std::{ }; fn main() { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + // Read the current source path when running, since build artifacts may move. + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let cpp_dir = manifest_dir.join("cpp"); println!("cargo:rerun-if-changed=cpp/yuv_ffi.h"); From b573f478fc5425c6abc847460fbf1e9e1fb1dc1b Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sat, 5 Sep 2026 14:10:53 +0800 Subject: [PATCH 19/29] =?UTF-8?q?fix(msd):=20=E5=8C=BA=E5=88=86=E8=99=9A?= =?UTF-8?q?=E6=8B=9F=E7=A3=81=E7=9B=98=E6=8C=82=E8=BD=BD=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E4=B8=8E=E6=96=87=E4=BB=B6=E8=AE=BF=E9=97=AE=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用原始文件元数据挂载虚拟磁盘,允许主机使用网页文件管理不支持的文件系统。连接期间只读取元数据;初始化和删除持有外层写锁,并同步控制器缓存。 API 的 used/free 改为可空值,新增 file_access 表示 available、unsupported、blocked_while_connected 或 unknown,需要前端配套处理。补充状态序列化、元数据校验和文件系统兼容测试。 --- src/error.rs | 8 ++- src/msd/controller.rs | 136 +++++++++++++++++++++++------------- src/msd/mod.rs | 4 +- src/msd/types.rs | 62 ++++++++++++++-- src/msd/ventoy_drive.rs | 133 ++++++++++++++++++++++++++++++----- src/web/handlers/msd_api.rs | 47 +++++++++---- 6 files changed, 300 insertions(+), 90 deletions(-) diff --git a/src/error.rs b/src/error.rs index 43d77fca..a105dc96 100644 --- a/src/error.rs +++ b/src/error.rs @@ -102,7 +102,9 @@ impl MsdErrorCode { 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::MsdDriveFilesystemUnsupported => { + "Web file management does not support this virtual drive format." + } Self::MsdDriveSizeInvalid => "The virtual drive size is invalid.", Self::MsdStorageSpaceUnavailable => { "Available virtual media storage space could not be determined." @@ -183,7 +185,7 @@ impl MsdErrorCode { "Verify the remote server and network connection, then retry." } Self::MsdDriveFilesystemUnsupported => { - "Reinitialize the virtual drive with a supported filesystem, then retry." + "Mount the drive on the controlled computer, or use a supported format for web file management." } Self::MsdStorageSpaceUnavailable => { "Verify that virtual media storage is available, then retry." @@ -386,7 +388,7 @@ mod tests { ( MsdDriveFilesystemUnsupported, "MSD_DRIVE_FILESYSTEM_UNSUPPORTED", - "The virtual drive filesystem is unsupported.", + "Web file management does not support this virtual drive format.", ), ( MsdDriveSizeInvalid, diff --git a/src/msd/controller.rs b/src/msd/controller.rs index 027ca973..c9b249f1 100644 --- a/src/msd/controller.rs +++ b/src/msd/controller.rs @@ -8,9 +8,10 @@ use tracing::{debug, info, warn}; use super::image::ImageManager; use super::monitor::MsdHealthMonitor; use super::types::{ - DiskMode, DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MountedMedia, - MountedMediaKind, MsdState, + DiskMode, DownloadProgress, DownloadStatus, DriveFileAccess, DriveInfo, ImageInfo, + MountedMedia, MountedMediaKind, MsdState, }; +use super::ventoy_drive::VentoyDrive; use crate::error::{AppError, MsdErrorCode, Result}; use crate::otg::{MsdFunction, MsdLunConfig, OtgService}; @@ -83,14 +84,9 @@ impl MsdController { state.available = true; if self.drive_path.exists() { - if let Ok(metadata) = std::fs::metadata(&self.drive_path) { - let drive_info = DriveInfo { - size: metadata.len(), - used: 0, - free: metadata.len(), - initialized: true, - path: self.drive_path.clone(), - }; + if let Ok(drive_info) = + VentoyDrive::new(self.drive_path.clone()).raw_info(DriveFileAccess::Unknown) + { state.drive_info = Some(drive_info.clone()); debug!( "Found existing virtual drive: {}", @@ -199,28 +195,6 @@ impl MsdController { self.assert_available(&state).await?; - if !self.drive_path.exists() { - self.monitor - .report_error("Virtual drive not initialized", "drive_not_found") - .await; - return Err(MsdErrorCode::MsdDriveNotInitialized.into()); - } - - let drive_info = state.drive_info.clone().or_else(|| { - std::fs::metadata(&self.drive_path) - .ok() - .map(|metadata| DriveInfo { - size: metadata.len(), - used: 0, - free: metadata.len(), - initialized: true, - path: self.drive_path.clone(), - }) - }); - if state.drive_info.is_none() { - state.drive_info = drive_info.clone(); - } - if state .mounted_media .iter() @@ -229,8 +203,22 @@ impl MsdController { return Err(MsdErrorCode::MsdMediaAlreadyMounted.into()); } - let drive_info = - drive_info.ok_or_else(|| AppError::from(MsdErrorCode::MsdDriveNotInitialized))?; + let drive_info = match self.drive_mount_info() { + Ok(info) => info, + Err(error) => { + if matches!( + &error, + AppError::Msd(msd) if msd.code() == MsdErrorCode::MsdDriveNotInitialized + ) { + self.monitor + .report_error("Virtual drive not initialized", "drive_not_found") + .await; + } + return Err(error); + } + }; + state.drive_info = Some(drive_info.clone()); + let lun = Self::lowest_free_lun(&state) .ok_or_else(|| AppError::from(MsdErrorCode::MsdMediaSlotsFull))?; @@ -240,6 +228,8 @@ impl MsdController { return Err(e); } state.mounted_media.push(media); + state.drive_info = + Some(drive_info.with_file_access(DriveFileAccess::BlockedWhileConnected)); info!( "Mounted virtual drive on LUN {}: {}", @@ -254,6 +244,15 @@ impl MsdController { Ok(()) } + fn drive_mount_info(&self) -> Result { + VentoyDrive::new(self.drive_path.clone()).raw_info(DriveFileAccess::Unknown) + } + + pub async fn set_drive_info(&self, drive_info: Option) { + self.state.write().await.drive_info = drive_info; + self.mark_device_info_dirty().await; + } + async fn assert_available(&self, state: &MsdState) -> Result<()> { if !state.available { self.monitor @@ -293,6 +292,16 @@ impl MsdController { } fn reset_mounts_for_mode(state: &mut MsdState, disk_mode: DiskMode) { + if state + .mounted_media + .iter() + .any(|media| media.kind == MountedMediaKind::Drive) + { + state.drive_info = state + .drive_info + .take() + .map(|info| info.with_file_access(DriveFileAccess::Unknown)); + } state.disk_mode = disk_mode; state.mounted_media.clear(); } @@ -397,6 +406,12 @@ impl MsdController { self.disconnect_lun(media.lun).await?; state.mounted_media.remove(index); + if media.kind == MountedMediaKind::Drive { + state.drive_info = state + .drive_info + .take() + .map(|info| info.with_file_access(DriveFileAccess::Unknown)); + } info!("Unmounted media"); drop(state); @@ -490,6 +505,16 @@ impl MsdController { disconnected.push(media.clone()); } + if state + .mounted_media + .iter() + .any(|media| media.kind == MountedMediaKind::Drive) + { + state.drive_info = state + .drive_info + .take() + .map(|info| info.with_file_access(DriveFileAccess::Unknown)); + } state.mounted_media.clear(); info!("Disconnected all mounted media"); @@ -744,6 +769,29 @@ mod tests { assert!(state.mounted_media.is_empty()); } + #[tokio::test] + async fn drive_mount_metadata_ignores_cached_drive_info() { + let temp_dir = TempDir::new().unwrap(); + let controller = MsdController::new(Arc::new(OtgService::new()), temp_dir.path()); + std::fs::create_dir_all(&controller.ventoy_dir).unwrap(); + std::fs::write(&controller.drive_path, vec![0u8; 128]).unwrap(); + controller.state.write().await.drive_info = Some(DriveInfo::from_raw( + controller.drive_path.clone(), + 64, + DriveFileAccess::Available, + )); + + std::fs::write(&controller.drive_path, vec![0u8; 256]).unwrap(); + let info = controller.drive_mount_info().unwrap(); + + assert_eq!(info.size, 256); + assert_eq!(info.used, None); + assert_eq!(info.file_access, DriveFileAccess::Unknown); + let media = MountedMedia::drive(0, &info); + let config = MsdController::media_config(&media); + assert_eq!(config.file, controller.drive_path); + } + #[test] fn single_disk_mode_only_exposes_lun_zero() { let mut state = MsdState::default(); @@ -842,13 +890,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let drive_path = temp_dir.path().join("ventoy.img"); std::fs::write(&drive_path, b"drive").unwrap(); - let drive = DriveInfo { - size: 5, - used: 0, - free: 5, - initialized: true, - path: drive_path, - }; + let drive = DriveInfo::from_raw(drive_path, 5, DriveFileAccess::Unknown); let mut state = MsdState::default(); MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi); state.mounted_media.push(MountedMedia::drive(0, &drive)); @@ -900,13 +942,11 @@ mod tests { let image_path = temp_dir.path().join("test.img"); std::fs::write(&image_path, b"img").unwrap(); let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3); - let drive = DriveInfo { - size: 5, - used: 0, - free: 5, - initialized: true, - path: temp_dir.path().join("ventoy.img"), - }; + let drive = DriveInfo::from_raw( + temp_dir.path().join("ventoy.img"), + 5, + DriveFileAccess::Unknown, + ); let mut state = MsdState::default(); state .mounted_media diff --git a/src/msd/mod.rs b/src/msd/mod.rs index 35fbf71a..be1bfa44 100644 --- a/src/msd/mod.rs +++ b/src/msd/mod.rs @@ -8,8 +8,8 @@ pub use controller::MsdController; pub use image::ImageManager; pub use monitor::MsdHealthMonitor; pub use types::{ - DiskMode, DiskModeRequest, DownloadProgress, DownloadStatus, DriveFile, DriveInfo, - DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageMountRequest, MountedMedia, + DiskMode, DiskModeRequest, DownloadProgress, DownloadStatus, DriveFile, DriveFileAccess, + DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageMountRequest, MountedMedia, MountedMediaKind, MsdState, MsdStateResponse, MULTI_DISK_MSD_LUNS, SINGLE_DISK_MSD_LUNS, }; pub use ventoy_drive::{VentoyDrive, MIN_DRIVE_SIZE_MB}; diff --git a/src/msd/types.rs b/src/msd/types.rs index f8a001e8..1bc4efa3 100644 --- a/src/msd/types.rs +++ b/src/msd/types.rs @@ -156,26 +156,44 @@ impl DiskMode { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DriveFileAccess { + Available, + Unsupported, + BlockedWhileConnected, + Unknown, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DriveInfo { pub size: u64, - pub used: u64, - pub free: u64, + pub used: Option, + pub free: Option, pub initialized: bool, + pub file_access: DriveFileAccess, #[serde(skip_serializing)] pub path: PathBuf, } impl DriveInfo { - pub fn new(path: PathBuf, size: u64) -> Self { + pub fn from_raw(path: PathBuf, size: u64, file_access: DriveFileAccess) -> Self { Self { size, - used: 0, - free: size, - initialized: false, + used: None, + free: None, + initialized: true, + file_access, path, } } + + pub fn with_file_access(mut self, file_access: DriveFileAccess) -> Self { + self.used = None; + self.free = None; + self.file_access = file_access; + self + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -267,4 +285,36 @@ mod tests { assert!(json.get("current_image").is_none()); assert!(json.get("slots").is_none()); } + + #[test] + fn drive_info_json_has_stable_nullable_space_and_file_access() { + let info = DriveInfo::from_raw( + PathBuf::from("/tmp/drive.img"), + 4096, + DriveFileAccess::Unsupported, + ); + + let value = serde_json::to_value(info).unwrap(); + assert_eq!(value["size"], 4096); + assert_eq!(value["used"], serde_json::Value::Null); + assert_eq!(value["free"], serde_json::Value::Null); + assert_eq!(value["initialized"], true); + assert_eq!(value["file_access"], "unsupported"); + assert!(value.get("path").is_none()); + } + + #[test] + fn drive_file_access_serializes_all_public_states() { + for (access, expected) in [ + (DriveFileAccess::Available, "available"), + (DriveFileAccess::Unsupported, "unsupported"), + ( + DriveFileAccess::BlockedWhileConnected, + "blocked_while_connected", + ), + (DriveFileAccess::Unknown, "unknown"), + ] { + assert_eq!(serde_json::to_value(access).unwrap(), expected); + } + } } diff --git a/src/msd/ventoy_drive.rs b/src/msd/ventoy_drive.rs index edeb4faf..d0e34468 100644 --- a/src/msd/ventoy_drive.rs +++ b/src/msd/ventoy_drive.rs @@ -5,7 +5,7 @@ use tracing::{info, warn}; use ventoy_img::{FileInfo as VentoyFileInfo, VentoyError, VentoyImage}; -use super::types::{DriveFile, DriveInfo}; +use super::types::{DriveFile, DriveFileAccess, DriveInfo}; use crate::error::{AppError, MsdErrorCode, Result}; const STREAM_CHUNK_SIZE: usize = 64 * 1024; @@ -35,11 +35,10 @@ impl VentoyDrive { &self.path } - /// Returns just the raw file size without attempting to parse the filesystem. - /// Used as a fallback when the image has been reformatted to an unsupported - /// filesystem (e.g. NTFS/exFAT) that VentoyImage cannot open. - pub fn raw_size(&self) -> Option { - std::fs::metadata(&self.path).ok().map(|m| m.len()) + /// Read and validate only the backing file metadata, without parsing its + /// partition table or filesystem. + pub fn raw_info(&self, file_access: DriveFileAccess) -> Result { + raw_drive_info(&self.path, file_access) } pub async fn init(&self, size_mb: u32) -> Result { @@ -60,9 +59,10 @@ impl VentoyDrive { Ok::(DriveInfo { size: metadata.len(), - used: 0, - free: metadata.len(), + used: Some(0), + free: Some(metadata.len()), initialized: true, + file_access: DriveFileAccess::Available, path, }) }) @@ -74,20 +74,23 @@ impl VentoyDrive { } pub async fn info(&self) -> Result { - if !self.exists() { - return Err(MsdErrorCode::MsdDriveNotInitialized.into()); - } - let path = self.path.clone(); let _lock = self.lock.read().await; tokio::task::spawn_blocking(move || { - let metadata = std::fs::metadata(&path) - .map_err(|error| drive_io_error("read drive metadata", error))?; + let raw = raw_drive_info(&path, DriveFileAccess::Unsupported)?; - let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?; + let image = match VentoyImage::open(&path) { + Ok(image) => image, + Err(error) if is_unsupported_filesystem_error(&error) => return Ok(raw), + Err(error) => return Err(ventoy_to_app_error(error)), + }; - let files = image.list_files_recursive().map_err(ventoy_to_app_error)?; + let files = match image.list_files_recursive() { + Ok(files) => files, + Err(error) if is_unsupported_filesystem_error(&error) => return Ok(raw), + Err(error) => return Err(ventoy_to_app_error(error)), + }; let used: u64 = files .iter() @@ -95,14 +98,15 @@ impl VentoyDrive { .map(|f| f.size) .sum(); - let size = metadata.len(); + let size = raw.size; let free = size.saturating_sub(used); Ok(DriveInfo { size, - used, - free, + used: Some(used), + free: Some(free), initialized: true, + file_access: DriveFileAccess::Available, path, }) }) @@ -332,6 +336,35 @@ impl VentoyDrive { } } +fn raw_drive_info(path: &Path, file_access: DriveFileAccess) -> Result { + let metadata = std::fs::metadata(path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + AppError::from(MsdErrorCode::MsdDriveNotInitialized) + } else { + drive_io_error("read drive metadata", error) + } + })?; + + if !metadata.is_file() || metadata.len() == 0 { + return Err(MsdErrorCode::MsdDriveSizeInvalid.into()); + } + + Ok(DriveInfo::from_raw( + path.to_path_buf(), + metadata.len(), + file_access, + )) +} + +fn is_unsupported_filesystem_error(error: &VentoyError) -> bool { + matches!( + error, + VentoyError::FilesystemError(_) + | VentoyError::ImageError(_) + | VentoyError::PartitionError(_) + ) +} + fn ventoy_to_app_error(err: VentoyError) -> AppError { warn!(%err, "Virtual drive filesystem operation failed"); match err { @@ -575,9 +608,71 @@ mod tests { let info = drive.init(MIN_DRIVE_SIZE_MB).await.unwrap(); assert!(info.initialized); + assert_eq!(info.file_access, DriveFileAccess::Available); + assert_eq!(info.used, Some(0)); + assert!(info.free.is_some()); assert!(drive.exists()); } + #[tokio::test] + async fn raw_bytes_are_reported_as_unsupported_with_capacity() { + let temp_dir = TempDir::new().unwrap(); + let drive_path = temp_dir.path().join("custom.img"); + std::fs::write(&drive_path, vec![0x5a; 1024 * 1024]).unwrap(); + let drive = VentoyDrive::new(drive_path); + + let info = drive.info().await.unwrap(); + assert_eq!(info.size, 1024 * 1024); + assert_eq!(info.used, None); + assert_eq!(info.free, None); + assert_eq!(info.file_access, DriveFileAccess::Unsupported); + + assert!(matches!( + drive.list_files("/").await.unwrap_err(), + AppError::Msd(error) + if error.code() == MsdErrorCode::MsdDriveFilesystemUnsupported + )); + } + + #[test] + fn raw_metadata_rejects_missing_empty_and_non_file_paths() { + let temp_dir = TempDir::new().unwrap(); + let missing = VentoyDrive::new(temp_dir.path().join("missing.img")); + assert!(matches!( + missing.raw_info(DriveFileAccess::Unknown).unwrap_err(), + AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveNotInitialized + )); + + let empty_path = temp_dir.path().join("empty.img"); + std::fs::write(&empty_path, []).unwrap(); + let empty = VentoyDrive::new(empty_path); + assert!(matches!( + empty.raw_info(DriveFileAccess::Unknown).unwrap_err(), + AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveSizeInvalid + )); + + let directory = VentoyDrive::new(temp_dir.path().to_path_buf()); + assert!(matches!( + directory.raw_info(DriveFileAccess::Unknown).unwrap_err(), + AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveSizeInvalid + )); + } + + #[tokio::test] + async fn supported_drive_info_has_space_values() { + if !ensure_resources() { + return; + } + let temp_dir = TempDir::new().unwrap(); + let drive = VentoyDrive::new(temp_dir.path().join("supported.img")); + drive.init(MIN_DRIVE_SIZE_MB).await.unwrap(); + + let info = drive.info().await.unwrap(); + assert_eq!(info.file_access, DriveFileAccess::Available); + assert!(info.used.is_some()); + assert!(info.free.is_some()); + } + #[tokio::test] async fn test_drive_mkdir() { if !ensure_resources() { diff --git a/src/web/handlers/msd_api.rs b/src/web/handlers/msd_api.rs index 05ca0c60..a29b8c56 100644 --- a/src/web/handlers/msd_api.rs +++ b/src/web/handlers/msd_api.rs @@ -2,7 +2,7 @@ use super::config::apply::try_apply_lock; use super::*; use crate::msd::{ - DiskModeRequest, DownloadProgress, DriveFile, DriveInfo, DriveInitRequest, + DiskModeRequest, DownloadProgress, DriveFile, DriveFileAccess, DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageManager, ImageMountRequest, MsdErrorCode, MsdState, MsdStateResponse, VentoyDrive, MIN_DRIVE_SIZE_MB, }; @@ -420,16 +420,26 @@ pub async fn msd_drive_info(State(state): State>) -> Result controller.is_drive_connected().await, + None => false, + }; + + // Never parse the filesystem while the USB host owns it. Metadata is safe + // to read and still lets the UI show the backing image capacity. + let info = if connected { + drive.raw_info(DriveFileAccess::BlockedWhileConnected) + } else { + drive.info().await + } + .map_err(|error| operation_failed("read virtual drive info", error))?; + + if let Some(controller) = msd_guard.as_ref() { + controller.set_drive_info(Some(info.clone())).await; } - drive - .info() - .await - .map(Json) - .map_err(|error| operation_failed("read virtual drive info", error)) + Ok(Json(info)) } /// Initialize Ventoy drive @@ -439,7 +449,6 @@ pub async fn msd_drive_init( 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(); @@ -449,6 +458,16 @@ pub async fn msd_drive_init( })?; validate_drive_init_size(req.size_mb, disk_space.available)?; + // Mount/unmount handlers also take this outer write lock. Holding it + // across image creation prevents a mount from racing the destructive + // reinitialization after the connected-state check. + let msd_guard = state.msd.write().await; + if let Some(controller) = msd_guard.as_ref() { + if controller.is_drive_connected().await { + return Err(MsdErrorCode::MsdDriveConnected.into()); + } + } + let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); @@ -456,6 +475,9 @@ pub async fn msd_drive_init( .init(req.size_mb) .await .map_err(|error| operation_failed("initialize virtual drive", error))?; + if let Some(controller) = msd_guard.as_ref() { + controller.set_drive_info(Some(info.clone())).await; + } Ok(Json(info)) } @@ -471,14 +493,15 @@ pub async fn msd_drive_delete(State(state): State>) -> Result Date: Sat, 5 Sep 2026 14:10:53 +0800 Subject: [PATCH 20/29] =?UTF-8?q?fix(rustdesk):=20=E6=8C=89=E9=94=AE?= =?UTF-8?q?=E7=9B=98=E6=A8=A1=E5=BC=8F=E5=92=8C=E7=A0=81=E7=A9=BA=E9=97=B4?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=20HID=20=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将对外兼容平台与 Windows Set-1 扫描码映射绑定,分离物理键、字符和控制键语义,拒绝未知码值跨码空间回退。 保留按下、释放、重复输入与临时 Shift 的状态语义,补充键位矩阵和事件转换回归测试,并记录输入模式及原始码值。 --- src/rustdesk/connection.rs | 85 +++- src/rustdesk/hid_adapter.rs | 778 ++++++++++++------------------- src/rustdesk/keyboard_mapping.rs | 424 +++++++++++++++++ src/rustdesk/mod.rs | 1 + 4 files changed, 790 insertions(+), 498 deletions(-) create mode 100644 src/rustdesk/keyboard_mapping.rs diff --git a/src/rustdesk/connection.rs b/src/rustdesk/connection.rs index 599ab9a1..f954f6df 100644 --- a/src/rustdesk/connection.rs +++ b/src/rustdesk/connection.rs @@ -26,7 +26,9 @@ use super::bytes_codec::{read_frame_with_limit, write_frame_vectored}; use super::config::RustDeskConfig; use super::crypto::{self, KeyPair, SigningKeyPair}; use super::frame_adapters::{AudioFrameAdapter, VideoCodec, VideoFrameAdapter}; -use super::hid_adapter::{convert_key_events, convert_mouse_event, mouse_type}; +use super::hid_adapter::{convert_key_events_in_code_space, convert_mouse_event, mouse_type}; +use super::keyboard_mapping::KeyboardCodeSpace; +use super::protocol::hbb::message::key_event as ke_union; use super::protocol::{ decode_message, login_response, message, misc, Clipboard, ControlKey, DisplayInfo, Hash, HbbMessage, IdPk, KeyEvent, LoginRequest, LoginResponse, Misc, MouseEvent, OptionMessage, @@ -50,10 +52,46 @@ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); const MAX_PASSWORD_ATTEMPTS: u8 = 5; const CONNECTION_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); -/// Advertised RustDesk version for client compatibility. -const RUSTDESK_COMPAT_VERSION: &str = "1.4.5"; -// Advertised platform for RustDesk clients. This affects which UI options are shown. -const RUSTDESK_COMPAT_PLATFORM: &str = "Windows"; +/// RustDesk-facing identity and the physical key code space implied by it. +struct RustDeskCompatibility { + version: &'static str, + platform: RustDeskCompatibilityPlatform, +} + +#[derive(Clone, Copy)] +enum RustDeskCompatibilityPlatform { + Windows, +} + +impl RustDeskCompatibilityPlatform { + const fn name(self) -> &'static str { + match self { + Self::Windows => "Windows", + } + } + + const fn keyboard_code_space(self) -> KeyboardCodeSpace { + match self { + Self::Windows => KeyboardCodeSpace::WindowsSet1, + } + } +} + +const RUSTDESK_COMPATIBILITY: RustDeskCompatibility = RustDeskCompatibility { + version: "1.4.5", + platform: RustDeskCompatibilityPlatform::Windows, +}; + +fn key_event_union_details(event: &KeyEvent) -> (&'static str, String) { + match &event.union { + Some(ke_union::Union::ControlKey(key)) => ("ControlKey", format!("0x{:X}", key.value())), + Some(ke_union::Union::Chr(code)) => ("Chr", format!("0x{code:X}")), + Some(ke_union::Union::Unicode(code)) => ("Unicode", format!("0x{code:X}")), + Some(ke_union::Union::Seq(seq)) => ("Seq", format!("{seq:?}")), + Some(ke_union::Union::Win2winHotkey(code)) => ("Win2winHotkey", format!("0x{code:X}")), + None => ("None", "none".to_string()), + } +} /// Input event throttler /// @@ -1124,11 +1162,11 @@ impl Connection { let mut peer_info = PeerInfo::new(); peer_info.username = "one-kvm".to_string(); peer_info.hostname = hostname_from_etc(); - peer_info.platform = RUSTDESK_COMPAT_PLATFORM.to_string(); + peer_info.platform = RUSTDESK_COMPATIBILITY.platform.name().to_string(); peer_info.displays.push(display_info); peer_info.current_display = 0; peer_info.sas_enabled = false; - peer_info.version = RUSTDESK_COMPAT_VERSION.to_string(); + peer_info.version = RUSTDESK_COMPATIBILITY.version.to_string(); peer_info.encoding = protobuf::MessageField::some(encoding); let mut login_response = LoginResponse::new(); @@ -1265,9 +1303,15 @@ impl Connection { /// Handle key event async fn handle_key_event(&mut self, ke: &KeyEvent) -> anyhow::Result<()> { + let (union_type, raw_value) = key_event_union_details(ke); debug!( - "Key event: down={}, press={}, chr={:?}, modifiers={:?}", - ke.down, ke.press, ke.union, ke.modifiers + mode = ke.mode.value(), + union = union_type, + raw = %raw_value, + down = ke.down, + press = ke.press, + modifiers = ?ke.modifiers, + "RustDesk key event" ); // Check for CapsLock state change in modifiers @@ -1305,15 +1349,21 @@ impl Connection { } // Convert RustDesk key event to One-KVM key events - let kb_events = convert_key_events(ke); + let kb_events = convert_key_events_in_code_space( + ke, + RUSTDESK_COMPATIBILITY.platform.keyboard_code_space(), + ); if !kb_events.is_empty() { if let Some(ref hid) = self.hid { for kb_event in kb_events { debug!( - "Converted to HID: key=0x{:02X}, event_type={:?}, modifiers={:02X}", - kb_event.key.to_hid_usage(), - kb_event.event_type, - kb_event.modifiers.to_hid_byte() + mode = ke.mode.value(), + union = union_type, + raw = %raw_value, + hid = format_args!("0x{:02X}", kb_event.key.to_hid_usage()), + event_type = ?kb_event.event_type, + modifiers = format_args!("0x{:02X}", kb_event.modifiers.to_hid_byte()), + "Converted RustDesk key event" ); if let Err(e) = hid.send_keyboard(kb_event).await { @@ -1324,7 +1374,12 @@ impl Connection { debug!("HID controller not available, skipping key event"); } } else { - warn!("Could not convert key event to HID: chr={:?}", ke.union); + debug!( + mode = ke.mode.value(), + union = union_type, + raw = %raw_value, + "RustDesk key event produced no HID event" + ); } Ok(()) diff --git a/src/rustdesk/hid_adapter.rs b/src/rustdesk/hid_adapter.rs index cfc106ac..d819aef9 100644 --- a/src/rustdesk/hid_adapter.rs +++ b/src/rustdesk/hid_adapter.rs @@ -1,10 +1,11 @@ +use super::keyboard_mapping::{self, CharacterMapping, KeyboardCodeSpace}; use super::protocol::hbb::message::key_event as ke_union; use super::protocol::{ControlKey, KeyEvent, KeyboardMode, MouseEvent}; use crate::hid::{ CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent as OneKvmMouseEvent, MouseEventType, }; -use protobuf::Enum; +use tracing::debug; pub mod mouse_type { pub const MOVE: i32 = 0; @@ -105,14 +106,23 @@ fn button_id_to_button(button_id: i32) -> Option { } } +/// Convert using the code space associated with One-KVM's compatibility platform. pub fn convert_key_events(event: &KeyEvent) -> Vec { + convert_key_events_in_code_space(event, KeyboardCodeSpace::WindowsSet1) +} + +pub(super) fn convert_key_events_in_code_space( + event: &KeyEvent, + code_space: KeyboardCodeSpace, +) -> Vec { let base_modifiers = if is_modifier_control_key(event) { KeyboardModifiers::default() } else { parse_modifiers(event) }; - let Some(mapping) = key_event_to_hid(event, base_modifiers) else { + let Some(mapping) = key_event_to_mapping(event, code_space, base_modifiers) else { + log_rejected_key_event(event, code_space); return Vec::new(); }; @@ -135,13 +145,12 @@ pub fn convert_key_events(event: &KeyEvent) -> Vec { }, ] } else { - let event_type = if event.down { - KeyEventType::Down - } else { - KeyEventType::Up - }; vec![KeyboardEvent { - event_type, + event_type: if event.down { + KeyEventType::Down + } else { + KeyEventType::Up + }, key: mapping.key, modifiers: mapping.modifiers, }] @@ -159,534 +168,337 @@ struct KeyMapping { added_shift: bool, } -fn key_event_to_hid(event: &KeyEvent, modifiers: KeyboardModifiers) -> Option { +fn key_event_to_mapping( + event: &KeyEvent, + code_space: KeyboardCodeSpace, + modifiers: KeyboardModifiers, +) -> Option { + let mode = event.mode.enum_value().ok()?; match &event.union { - Some(ke_union::Union::ControlKey(ck)) => { - let key = CanonicalKey::from_hid_usage(control_key_to_hid(ck.value())?)?; - Some(KeyMapping { - key, + Some(ke_union::Union::ControlKey(key)) => { + plain_mapping(keyboard_mapping::control_key(key.value())?, modifiers) + } + Some(ke_union::Union::Unicode(ch)) => character_mapping(*ch, modifiers), + Some(ke_union::Union::Chr(code)) => match mode { + KeyboardMode::Map | KeyboardMode::Translate => plain_mapping( + keyboard_mapping::physical_key(code_space, *code)?, modifiers, - added_shift: false, - }) - } - Some(ke_union::Union::Chr(chr)) => { - if event.mode.value() != KeyboardMode::Map.value() { - if let Some(mapping) = shifted_printable_char_to_hid(*chr, modifiers) { - return Some(mapping); - } - } - let key = CanonicalKey::from_hid_usage(keycode_to_hid(*chr)?)?; - Some(KeyMapping { - key, - modifiers, - added_shift: false, - }) - } - Some(ke_union::Union::Unicode(unicode)) => { - let mapping = printable_char_to_hid(*unicode, modifiers)?; - Some(mapping) - } - _ => None, + ), + KeyboardMode::Legacy | KeyboardMode::Auto => legacy_character_mapping(*code, modifiers), + }, + Some(ke_union::Union::Seq(_)) | Some(ke_union::Union::Win2winHotkey(_)) | None => None, + } +} + +fn plain_mapping(key: CanonicalKey, modifiers: KeyboardModifiers) -> Option { + Some(KeyMapping { + key, + modifiers, + added_shift: false, + }) +} + +fn character_mapping(ch: u32, modifiers: KeyboardModifiers) -> Option { + let CharacterMapping { key, needs_shift } = keyboard_mapping::character(ch)?; + if !needs_shift { + return plain_mapping(key, modifiers); + } + + let added_shift = !modifiers.left_shift && !modifiers.right_shift; + let mut shifted_modifiers = modifiers; + shifted_modifiers.left_shift = true; + Some(KeyMapping { + key, + modifiers: shifted_modifiers, + added_shift, + }) +} + +fn legacy_character_mapping(ch: u32, modifiers: KeyboardModifiers) -> Option { + let CharacterMapping { key, needs_shift } = keyboard_mapping::character(ch)?; + // Legacy Chr historically relied on the event's modifier list for uppercase + // letters, while synthesizing Shift for printable US-layout symbols. + if needs_shift && !(0x41..=0x5A).contains(&ch) { + character_mapping(ch, modifiers) + } else { + plain_mapping(key, modifiers) } } fn is_modifier_control_key(event: &KeyEvent) -> bool { - if let Some(ke_union::Union::ControlKey(ck)) = &event.union { - let val = ck.value(); - return val == ControlKey::Control.value() - || val == ControlKey::Shift.value() - || val == ControlKey::Alt.value() - || val == ControlKey::Meta.value() - || val == ControlKey::RControl.value() - || val == ControlKey::RShift.value() - || val == ControlKey::RAlt.value(); - } - false + let Some(ke_union::Union::ControlKey(key)) = &event.union else { + return false; + }; + matches!( + key.enum_value(), + Ok(ControlKey::Control) + | Ok(ControlKey::Shift) + | Ok(ControlKey::Alt) + | Ok(ControlKey::Meta) + | Ok(ControlKey::RControl) + | Ok(ControlKey::RShift) + | Ok(ControlKey::RAlt) + | Ok(ControlKey::RWin) + ) } fn parse_modifiers(event: &KeyEvent) -> KeyboardModifiers { let mut modifiers = KeyboardModifiers::default(); - for modifier in &event.modifiers { - let val = modifier.value(); - match val { - x if x == ControlKey::Control.value() => modifiers.left_ctrl = true, - x if x == ControlKey::Shift.value() => modifiers.left_shift = true, - x if x == ControlKey::Alt.value() => modifiers.left_alt = true, - x if x == ControlKey::Meta.value() => modifiers.left_meta = true, - x if x == ControlKey::RControl.value() => modifiers.right_ctrl = true, - x if x == ControlKey::RShift.value() => modifiers.right_shift = true, - x if x == ControlKey::RAlt.value() => modifiers.right_alt = true, + match modifier.enum_value() { + Ok(ControlKey::Control) => modifiers.left_ctrl = true, + Ok(ControlKey::Shift) => modifiers.left_shift = true, + Ok(ControlKey::Alt) => modifiers.left_alt = true, + Ok(ControlKey::Meta) => modifiers.left_meta = true, + Ok(ControlKey::RControl) => modifiers.right_ctrl = true, + Ok(ControlKey::RShift) => modifiers.right_shift = true, + Ok(ControlKey::RAlt) => modifiers.right_alt = true, + Ok(ControlKey::RWin) => modifiers.right_meta = true, _ => {} } } - modifiers } -fn with_shift(mut modifiers: KeyboardModifiers) -> KeyboardModifiers { - modifiers.left_shift = true; - modifiers -} - -fn shifted_mapping(key: CanonicalKey, modifiers: KeyboardModifiers) -> KeyMapping { - let added_shift = !modifiers.left_shift && !modifiers.right_shift; - KeyMapping { - key, - modifiers: with_shift(modifiers), - added_shift, - } -} - -fn plain_mapping(key: CanonicalKey, modifiers: KeyboardModifiers) -> KeyMapping { - KeyMapping { - key, - modifiers, - added_shift: false, - } -} - -fn shifted_printable_char_to_hid(ch: u32, modifiers: KeyboardModifiers) -> Option { - match ch { - 33 => Some(shifted_mapping(CanonicalKey::Digit1, modifiers)), - 64 => Some(shifted_mapping(CanonicalKey::Digit2, modifiers)), - 35 => Some(shifted_mapping(CanonicalKey::Digit3, modifiers)), - 36 => Some(shifted_mapping(CanonicalKey::Digit4, modifiers)), - 37 => Some(shifted_mapping(CanonicalKey::Digit5, modifiers)), - 94 => Some(shifted_mapping(CanonicalKey::Digit6, modifiers)), - 38 => Some(shifted_mapping(CanonicalKey::Digit7, modifiers)), - 42 => Some(shifted_mapping(CanonicalKey::Digit8, modifiers)), - 40 => Some(shifted_mapping(CanonicalKey::Digit9, modifiers)), - 41 => Some(shifted_mapping(CanonicalKey::Digit0, modifiers)), - 95 => Some(shifted_mapping(CanonicalKey::Minus, modifiers)), - 43 => Some(shifted_mapping(CanonicalKey::Equal, modifiers)), - 123 => Some(shifted_mapping(CanonicalKey::BracketLeft, modifiers)), - 125 => Some(shifted_mapping(CanonicalKey::BracketRight, modifiers)), - 124 => Some(shifted_mapping(CanonicalKey::Backslash, modifiers)), - 58 => Some(shifted_mapping(CanonicalKey::Semicolon, modifiers)), - 34 => Some(shifted_mapping(CanonicalKey::Quote, modifiers)), - 126 => Some(shifted_mapping(CanonicalKey::Backquote, modifiers)), - 60 => Some(shifted_mapping(CanonicalKey::Comma, modifiers)), - 62 => Some(shifted_mapping(CanonicalKey::Period, modifiers)), - 63 => Some(shifted_mapping(CanonicalKey::Slash, modifiers)), - _ => None, - } -} - -fn printable_char_to_hid(ch: u32, modifiers: KeyboardModifiers) -> Option { - match ch { - 65..=90 => Some(shifted_mapping( - CanonicalKey::from_hid_usage((ch - 65 + 0x04) as u8)?, - modifiers, - )), - 97..=122 => Some(plain_mapping( - CanonicalKey::from_hid_usage((ch - 97 + 0x04) as u8)?, - modifiers, - )), - 48 => Some(plain_mapping(CanonicalKey::Digit0, modifiers)), - 49 => Some(plain_mapping(CanonicalKey::Digit1, modifiers)), - 50 => Some(plain_mapping(CanonicalKey::Digit2, modifiers)), - 51 => Some(plain_mapping(CanonicalKey::Digit3, modifiers)), - 52 => Some(plain_mapping(CanonicalKey::Digit4, modifiers)), - 53 => Some(plain_mapping(CanonicalKey::Digit5, modifiers)), - 54 => Some(plain_mapping(CanonicalKey::Digit6, modifiers)), - 55 => Some(plain_mapping(CanonicalKey::Digit7, modifiers)), - 56 => Some(plain_mapping(CanonicalKey::Digit8, modifiers)), - 57 => Some(plain_mapping(CanonicalKey::Digit9, modifiers)), - 32 => Some(plain_mapping(CanonicalKey::Space, modifiers)), - 13 | 10 => Some(plain_mapping(CanonicalKey::Enter, modifiers)), - 9 => Some(plain_mapping(CanonicalKey::Tab, modifiers)), - 27 => Some(plain_mapping(CanonicalKey::Escape, modifiers)), - 8 => Some(plain_mapping(CanonicalKey::Backspace, modifiers)), - 127 => Some(plain_mapping(CanonicalKey::Delete, modifiers)), - 45 => Some(plain_mapping(CanonicalKey::Minus, modifiers)), - 61 => Some(plain_mapping(CanonicalKey::Equal, modifiers)), - 91 => Some(plain_mapping(CanonicalKey::BracketLeft, modifiers)), - 93 => Some(plain_mapping(CanonicalKey::BracketRight, modifiers)), - 92 => Some(plain_mapping(CanonicalKey::Backslash, modifiers)), - 59 => Some(plain_mapping(CanonicalKey::Semicolon, modifiers)), - 39 => Some(plain_mapping(CanonicalKey::Quote, modifiers)), - 96 => Some(plain_mapping(CanonicalKey::Backquote, modifiers)), - 44 => Some(plain_mapping(CanonicalKey::Comma, modifiers)), - 46 => Some(plain_mapping(CanonicalKey::Period, modifiers)), - 47 => Some(plain_mapping(CanonicalKey::Slash, modifiers)), - _ => shifted_printable_char_to_hid(ch, modifiers), - } -} - -fn control_key_to_hid(key: i32) -> Option { - match key { - x if x == ControlKey::Alt as i32 => Some(0xE2), // Left Alt - x if x == ControlKey::Backspace as i32 => Some(0x2A), - x if x == ControlKey::CapsLock as i32 => Some(0x39), - x if x == ControlKey::Control as i32 => Some(0xE0), // Left Ctrl - x if x == ControlKey::Delete as i32 => Some(0x4C), - x if x == ControlKey::DownArrow as i32 => Some(0x51), - x if x == ControlKey::End as i32 => Some(0x4D), - x if x == ControlKey::Escape as i32 => Some(0x29), - x if x == ControlKey::F1 as i32 => Some(0x3A), - x if x == ControlKey::F2 as i32 => Some(0x3B), - x if x == ControlKey::F3 as i32 => Some(0x3C), - x if x == ControlKey::F4 as i32 => Some(0x3D), - x if x == ControlKey::F5 as i32 => Some(0x3E), - x if x == ControlKey::F6 as i32 => Some(0x3F), - x if x == ControlKey::F7 as i32 => Some(0x40), - x if x == ControlKey::F8 as i32 => Some(0x41), - x if x == ControlKey::F9 as i32 => Some(0x42), - x if x == ControlKey::F10 as i32 => Some(0x43), - x if x == ControlKey::F11 as i32 => Some(0x44), - x if x == ControlKey::F12 as i32 => Some(0x45), - x if x == ControlKey::Home as i32 => Some(0x4A), - x if x == ControlKey::LeftArrow as i32 => Some(0x50), - x if x == ControlKey::Meta as i32 => Some(0xE3), // Left GUI/Windows - x if x == ControlKey::PageDown as i32 => Some(0x4E), - x if x == ControlKey::PageUp as i32 => Some(0x4B), - x if x == ControlKey::Return as i32 => Some(0x28), - x if x == ControlKey::RightArrow as i32 => Some(0x4F), - x if x == ControlKey::Shift as i32 => Some(0xE1), // Left Shift - x if x == ControlKey::Space as i32 => Some(0x2C), - x if x == ControlKey::Tab as i32 => Some(0x2B), - x if x == ControlKey::UpArrow as i32 => Some(0x52), - x if x == ControlKey::Numpad0 as i32 => Some(0x62), - x if x == ControlKey::Numpad1 as i32 => Some(0x59), - x if x == ControlKey::Numpad2 as i32 => Some(0x5A), - x if x == ControlKey::Numpad3 as i32 => Some(0x5B), - x if x == ControlKey::Numpad4 as i32 => Some(0x5C), - x if x == ControlKey::Numpad5 as i32 => Some(0x5D), - x if x == ControlKey::Numpad6 as i32 => Some(0x5E), - x if x == ControlKey::Numpad7 as i32 => Some(0x5F), - x if x == ControlKey::Numpad8 as i32 => Some(0x60), - x if x == ControlKey::Numpad9 as i32 => Some(0x61), - x if x == ControlKey::Insert as i32 => Some(0x49), - x if x == ControlKey::Pause as i32 => Some(0x48), - x if x == ControlKey::Scroll as i32 => Some(0x47), - x if x == ControlKey::NumLock as i32 => Some(0x53), - x if x == ControlKey::RShift as i32 => Some(0xE5), - x if x == ControlKey::RControl as i32 => Some(0xE4), - x if x == ControlKey::RAlt as i32 => Some(0xE6), - x if x == ControlKey::Multiply as i32 => Some(0x55), - x if x == ControlKey::Add as i32 => Some(0x57), - x if x == ControlKey::Subtract as i32 => Some(0x56), - x if x == ControlKey::Decimal as i32 => Some(0x63), - x if x == ControlKey::Divide as i32 => Some(0x54), - x if x == ControlKey::NumpadEnter as i32 => Some(0x58), - _ => None, - } -} - -fn keycode_to_hid(keycode: u32) -> Option { - if let Some(hid) = ascii_to_hid(keycode) { - return Some(hid); - } - if let Some(hid) = windows_vk_to_hid(keycode) { - return Some(hid); - } - x11_keycode_to_hid(keycode) -} - -fn ascii_to_hid(ascii: u32) -> Option { - match ascii { - 97..=122 => Some((ascii - 97 + 0x04) as u8), - 65..=90 => Some((ascii - 65 + 0x04) as u8), - 48 => Some(0x27), // 0 - 49..=57 => Some((ascii - 49 + 0x1E) as u8), // 1-9 - 32 => Some(0x2C), // Space - 13 => Some(0x28), // Enter (CR) - 10 => Some(0x28), // Enter (LF) - 9 => Some(0x2B), // Tab - 27 => Some(0x29), // Escape - 8 => Some(0x2A), // Backspace - 127 => Some(0x4C), // Delete - 45 => Some(0x2D), // - - 61 => Some(0x2E), // = - 91 => Some(0x2F), // [ - 93 => Some(0x30), // ] - 92 => Some(0x31), // \ - 59 => Some(0x33), // ; - 39 => Some(0x34), // ' - 96 => Some(0x35), // ` - 44 => Some(0x36), // , - 46 => Some(0x37), // . - 47 => Some(0x38), // / - _ => None, - } -} - -fn windows_vk_to_hid(vk: u32) -> Option { - match vk { - 0x41..=0x5A => { - let letter = (vk - 0x41) as u8; - Some(match letter { - 0 => 0x04, // A - 1 => 0x05, // B - 2 => 0x06, // C - 3 => 0x07, // D - 4 => 0x08, // E - 5 => 0x09, // F - 6 => 0x0A, // G - 7 => 0x0B, // H - 8 => 0x0C, // I - 9 => 0x0D, // J - 10 => 0x0E, // K - 11 => 0x0F, // L - 12 => 0x10, // M - 13 => 0x11, // N - 14 => 0x12, // O - 15 => 0x13, // P - 16 => 0x14, // Q - 17 => 0x15, // R - 18 => 0x16, // S - 19 => 0x17, // T - 20 => 0x18, // U - 21 => 0x19, // V - 22 => 0x1A, // W - 23 => 0x1B, // X - 24 => 0x1C, // Y - 25 => 0x1D, // Z - _ => return None, - }) +fn log_rejected_key_event(event: &KeyEvent, code_space: KeyboardCodeSpace) { + let mode = event.mode.value(); + match &event.union { + Some(ke_union::Union::ControlKey(key)) => debug!( + mode, + union = "ControlKey", + raw = format_args!("0x{:X}", key.value()), + ?code_space, + "Dropping unsupported RustDesk keyboard event" + ), + Some(ke_union::Union::Chr(code)) => debug!( + mode, + union = "Chr", + raw = format_args!("0x{code:X}"), + ?code_space, + "Dropping unsupported RustDesk keyboard event" + ), + Some(ke_union::Union::Unicode(code)) => debug!( + mode, + union = "Unicode", + raw = format_args!("0x{code:X}"), + ?code_space, + "Dropping unsupported RustDesk keyboard event" + ), + Some(ke_union::Union::Seq(seq)) => { + debug!(mode, union = "Seq", raw = ?seq, ?code_space, "Dropping unsupported RustDesk keyboard event") } - 0x30 => Some(0x27), // 0 - 0x31..=0x39 => Some((vk - 0x31 + 0x1E) as u8), // 1-9 - 0x60 => Some(0x62), // Numpad 0 - 0x61..=0x69 => Some((vk - 0x61 + 0x59) as u8), // Numpad 1-9 - 0x6A => Some(0x55), // Numpad * - 0x6B => Some(0x57), // Numpad + - 0x6D => Some(0x56), // Numpad - - 0x6E => Some(0x63), // Numpad . - 0x6F => Some(0x54), // Numpad / - 0x70..=0x7B => Some((vk - 0x70 + 0x3A) as u8), - 0x08 => Some(0x2A), // Backspace - 0x09 => Some(0x2B), // Tab - 0x0D => Some(0x28), // Enter - 0x1B => Some(0x29), // Escape - 0x20 => Some(0x2C), // Space - 0x21 => Some(0x4B), // Page Up - 0x22 => Some(0x4E), // Page Down - 0x23 => Some(0x4D), // End - 0x24 => Some(0x4A), // Home - 0x25 => Some(0x50), // Left Arrow - 0x26 => Some(0x52), // Up Arrow - 0x27 => Some(0x4F), // Right Arrow - 0x28 => Some(0x51), // Down Arrow - 0x2D => Some(0x49), // Insert - 0x2E => Some(0x4C), // Delete - 0xBA => Some(0x33), // ; : - 0xBB => Some(0x2E), // = + - 0xBC => Some(0x36), // , < - 0xBD => Some(0x2D), // - _ - 0xBE => Some(0x37), // . > - 0xBF => Some(0x38), // / ? - 0xC0 => Some(0x35), // ` ~ - 0xDB => Some(0x2F), // [ { - 0xDC => Some(0x31), // \ | - 0xDD => Some(0x30), // ] } - 0xDE => Some(0x34), // ' " - 0x14 => Some(0x39), // Caps Lock - 0x90 => Some(0x53), // Num Lock - 0x91 => Some(0x47), // Scroll Lock - 0x2C => Some(0x46), // Print Screen - 0x13 => Some(0x48), // Pause - _ => None, - } -} - -fn x11_keycode_to_hid(keycode: u32) -> Option { - match keycode { - 10..=18 => Some((keycode - 10 + 0x1E) as u8), // 1-9 - 19 => Some(0x27), // 0 - 20 => Some(0x2D), // - - 21 => Some(0x2E), // = - 34 => Some(0x2F), // [ - 35 => Some(0x30), // ] - 24 => Some(0x14), // q - 25 => Some(0x1A), // w - 26 => Some(0x08), // e - 27 => Some(0x15), // r - 28 => Some(0x17), // t - 29 => Some(0x1C), // y - 30 => Some(0x18), // u - 31 => Some(0x0C), // i - 32 => Some(0x12), // o - 33 => Some(0x13), // p - 38 => Some(0x04), // a - 39 => Some(0x16), // s - 40 => Some(0x07), // d - 41 => Some(0x09), // f - 42 => Some(0x0A), // g - 43 => Some(0x0B), // h - 44 => Some(0x0D), // j - 45 => Some(0x0E), // k - 46 => Some(0x0F), // l - 47 => Some(0x33), // ; - 48 => Some(0x34), // ' - 49 => Some(0x35), // ` - 51 => Some(0x31), // \ - 52 => Some(0x1D), // z - 53 => Some(0x1B), // x - 54 => Some(0x06), // c - 55 => Some(0x19), // v - 56 => Some(0x05), // b - 57 => Some(0x11), // n - 58 => Some(0x10), // m - 59 => Some(0x36), // , - 60 => Some(0x37), // . - 61 => Some(0x38), // / - 65 => Some(0x2C), - _ => None, + Some(ke_union::Union::Win2winHotkey(code)) => debug!( + mode, + union = "Win2winHotkey", + raw = format_args!("0x{code:X}"), + ?code_space, + "Dropping unsupported RustDesk keyboard event" + ), + None => debug!( + mode, + union = "None", + ?code_space, + "Dropping unsupported RustDesk keyboard event" + ), } } #[cfg(test)] mod tests { use super::*; + use protobuf::EnumOrUnknown; - #[test] - fn test_control_key_mapping() { - assert_eq!(control_key_to_hid(ControlKey::Escape.value()), Some(0x29)); - assert_eq!(control_key_to_hid(ControlKey::Return.value()), Some(0x28)); - assert_eq!(control_key_to_hid(ControlKey::Space.value()), Some(0x2C)); + fn key_event(mode: KeyboardMode, union: ke_union::Union, down: bool) -> KeyEvent { + let mut event = KeyEvent::new(); + event.mode = EnumOrUnknown::new(mode); + event.union = Some(union); + event.down = down; + event + } + + fn map_chr(code: u32, down: bool) -> KeyEvent { + key_event(KeyboardMode::Map, ke_union::Union::Chr(code), down) } #[test] - fn test_convert_mouse_move() { - let mut event = MouseEvent::new(); - event.x = 500; - event.y = 300; - event.mask = mouse_type::MOVE; // Pure move event - - let events = convert_mouse_event(&event, 1920, 1080); - assert!(!events.is_empty()); - assert_eq!(events[0].event_type, MouseEventType::MoveAbs); - } - - #[test] - fn test_convert_mouse_button_down() { - let mut event = MouseEvent::new(); - event.x = 500; - event.y = 300; - event.mask = (mouse_button::LEFT << 3) | mouse_type::DOWN; - - let events = convert_mouse_event(&event, 1920, 1080); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, MouseEventType::Down); - assert_eq!(events[0].button, Some(MouseButton::Left)); - } - - #[test] - fn test_convert_mouse_button_down_does_not_move() { - let mut event = MouseEvent::new(); - event.mask = (mouse_button::LEFT << 3) | mouse_type::DOWN; - - let events = convert_mouse_event(&event, 1920, 1080); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, MouseEventType::Down); - assert_eq!(events[0].button, Some(MouseButton::Left)); - } - - #[test] - fn test_convert_mouse_wheel_does_not_move() { - let mut event = MouseEvent::new(); - event.x = 500; - event.y = 1; - event.mask = mouse_type::WHEEL; - - let events = convert_mouse_event(&event, 1920, 1080); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, MouseEventType::Scroll); - assert_eq!(events[0].scroll, 1); - } - - #[test] - fn test_convert_mouse_move_relative() { + fn mouse_events_keep_existing_semantics() { let mut event = MouseEvent::new(); event.x = -12; event.y = 8; event.mask = mouse_type::MOVE_RELATIVE; - let events = convert_mouse_event(&event, 1920, 1080); - assert_eq!(events.len(), 1); assert_eq!(events[0].event_type, MouseEventType::Move); - assert_eq!(events[0].x, -12); - assert_eq!(events[0].y, 8); + assert_eq!((events[0].x, events[0].y), (-12, 8)); + + event.mask = (mouse_button::LEFT << 3) | mouse_type::DOWN; + let events = convert_mouse_event(&event, 1920, 1080); + assert_eq!(events[0].event_type, MouseEventType::Down); + assert_eq!(events[0].button, Some(MouseButton::Left)); + assert_eq!((events[0].x, events[0].y), (0, 0)); } #[test] - fn test_convert_key_event() { - use protobuf::EnumOrUnknown; - let mut key_event = KeyEvent::new(); - key_event.down = true; - key_event.press = false; - key_event.union = Some(ke_union::Union::ControlKey(EnumOrUnknown::new( - ControlKey::Return, - ))); - - let result = convert_key_event(&key_event); - assert!(result.is_some()); - - let kb_event = result.unwrap(); - assert_eq!(kb_event.event_type, KeyEventType::Down); - assert_eq!(kb_event.key, CanonicalKey::Enter); + fn map_delete_generates_only_delete_down_and_up() { + let down = convert_key_events(&map_chr(0xE053, true)); + let up = convert_key_events(&map_chr(0xE053, false)); + assert_eq!((down.len(), up.len()), (1, 1)); + assert_eq!( + (down[0].event_type, up[0].event_type), + (KeyEventType::Down, KeyEventType::Up) + ); + assert_eq!( + (down[0].key, up[0].key), + (CanonicalKey::Delete, CanonicalKey::Delete) + ); + assert_eq!( + (down[0].key.to_hid_usage(), up[0].key.to_hid_usage()), + (0x4C, 0x4C) + ); } #[test] - fn test_convert_at_press_to_shift_digit2() { - let mut key_event = KeyEvent::new(); - key_event.press = true; - key_event.union = Some(ke_union::Union::Unicode('@' as u32)); + fn map_scan_codes_do_not_become_ascii_digits() { + let alt = convert_key_events(&map_chr(0x38, true)); + let shift = convert_key_events(&map_chr(0x36, true)); + assert_eq!(alt[0].key, CanonicalKey::AltLeft); + assert_ne!(alt[0].key, CanonicalKey::Digit8); + assert_eq!(shift[0].key, CanonicalKey::ShiftRight); + assert_ne!(shift[0].key, CanonicalKey::Digit6); + } - let events = convert_key_events(&key_event); + #[test] + fn legacy_uses_character_semantics_for_same_values() { + let digit8 = key_event(KeyboardMode::Legacy, ke_union::Union::Chr(0x38), true); + let digit6 = key_event(KeyboardMode::Legacy, ke_union::Union::Chr(0x36), true); + assert_eq!(convert_key_events(&digit8)[0].key, CanonicalKey::Digit8); + assert_eq!(convert_key_events(&digit6)[0].key, CanonicalKey::Digit6); + + let uppercase = key_event(KeyboardMode::Legacy, ke_union::Union::Chr(0x41), true); + let uppercase = convert_key_events(&uppercase); + assert_eq!(uppercase[0].key, CanonicalKey::KeyA); + assert!(!uppercase[0].modifiers.left_shift); + } + + #[test] + fn translate_chr_uses_physical_fallback_semantics() { + let event = key_event(KeyboardMode::Translate, ke_union::Union::Chr(0xE053), true); + assert_eq!(convert_key_events(&event)[0].key, CanonicalKey::Delete); + } + + #[test] + fn unknown_map_codes_never_fall_back() { + for code in [0, 0x59, 0x61, 0x7F, 0xE054, 0x0101] { + assert!( + convert_key_events(&map_chr(code, true)).is_empty(), + "0x{code:X}" + ); + } + + let ascii_a_value = convert_key_events(&map_chr(0x41, true)); + assert_eq!(ascii_a_value[0].key, CanonicalKey::F7); + assert_ne!(ascii_a_value[0].key, CanonicalKey::KeyA); + } + + #[test] + fn unicode_and_control_keys_stay_independent() { + let unicode = key_event( + KeyboardMode::Map, + ke_union::Union::Unicode('@' as u32), + true, + ); + let control = key_event( + KeyboardMode::Translate, + ke_union::Union::ControlKey(EnumOrUnknown::new(ControlKey::Delete)), + true, + ); + let unicode = convert_key_events(&unicode); + assert_eq!(unicode[0].key, CanonicalKey::Digit2); + assert!(unicode[0].modifiers.left_shift); + assert_eq!(convert_key_events(&control)[0].key, CanonicalKey::Delete); + } + + #[test] + fn press_and_repeated_events_preserve_state_model() { + let mut press = map_chr(0x1E, false); + press.press = true; + press + .modifiers + .push(EnumOrUnknown::new(ControlKey::Control)); + let events = convert_key_events(&press); assert_eq!(events.len(), 2); - assert_eq!(events[0].event_type, KeyEventType::Down); - assert_eq!(events[0].key, CanonicalKey::Digit2); + assert_eq!( + (events[0].event_type, events[1].event_type), + (KeyEventType::Down, KeyEventType::Up) + ); + assert_eq!( + (events[0].key, events[1].key), + (CanonicalKey::KeyA, CanonicalKey::KeyA) + ); + assert!(events.iter().all(|event| event.modifiers.left_ctrl)); + + let down = map_chr(0x1E, true); + assert_eq!(convert_key_events(&down)[0].event_type, KeyEventType::Down); + assert_eq!(convert_key_events(&down)[0].event_type, KeyEventType::Down); + } + + #[test] + fn shifted_press_releases_only_synthetic_shift() { + let mut event = key_event( + KeyboardMode::Legacy, + ke_union::Union::Chr('@' as u32), + false, + ); + event.press = true; + let events = convert_key_events(&event); assert!(events[0].modifiers.left_shift); - assert_eq!(events[1].event_type, KeyEventType::Up); - assert_eq!(events[1].key, CanonicalKey::Digit2); assert!(!events[1].modifiers.left_shift); } #[test] - fn test_convert_shifted_chr_to_shift_digit2() { - let mut key_event = KeyEvent::new(); - key_event.down = true; - key_event.union = Some(ke_union::Union::Chr('@' as u32)); - - let events = convert_key_events(&key_event); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, KeyEventType::Down); - assert_eq!(events[0].key, CanonicalKey::Digit2); - assert!(events[0].modifiers.left_shift); + fn modifier_control_key_does_not_duplicate_state() { + let mut event = key_event( + KeyboardMode::Legacy, + ke_union::Union::ControlKey(EnumOrUnknown::new(ControlKey::RWin)), + true, + ); + event.modifiers.push(EnumOrUnknown::new(ControlKey::RWin)); + let events = convert_key_events(&event); + assert_eq!(events[0].key, CanonicalKey::MetaRight); + assert_eq!(events[0].modifiers, KeyboardModifiers::default()); } #[test] - fn test_convert_map_mode_chr_as_physical_key() { - use protobuf::EnumOrUnknown; - let mut key_event = KeyEvent::new(); - key_event.down = true; - key_event.mode = EnumOrUnknown::new(KeyboardMode::Map); - key_event.union = Some(ke_union::Union::Chr(0x41)); - - let events = convert_key_events(&key_event); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, KeyEventType::Down); - assert_eq!(events[0].key, CanonicalKey::KeyA); - assert!(!events[0].modifiers.left_shift); + fn legacy_delete_and_audited_controls_are_mapped() { + for (control, expected) in [ + (ControlKey::Delete, CanonicalKey::Delete), + (ControlKey::Snapshot, CanonicalKey::PrintScreen), + (ControlKey::RWin, CanonicalKey::MetaRight), + (ControlKey::Apps, CanonicalKey::ContextMenu), + ] { + let event = key_event( + KeyboardMode::Legacy, + ke_union::Union::ControlKey(EnumOrUnknown::new(control)), + true, + ); + assert_eq!(convert_key_events(&event)[0].key, expected); + } } #[test] - fn test_convert_press_generates_down_and_up() { - use protobuf::EnumOrUnknown; - let mut key_event = KeyEvent::new(); - key_event.press = true; - key_event.union = Some(ke_union::Union::ControlKey(EnumOrUnknown::new( - ControlKey::Return, - ))); + fn rejects_unknown_modes_and_unsupported_unions() { + let mut unknown = map_chr(0x1E, true); + unknown.mode = EnumOrUnknown::from_i32(99); + assert!(convert_key_events(&unknown).is_empty()); - let events = convert_key_events(&key_event); - assert_eq!(events.len(), 2); - assert_eq!(events[0].event_type, KeyEventType::Down); - assert_eq!(events[1].event_type, KeyEventType::Up); - assert_eq!(events[0].key, CanonicalKey::Enter); - assert_eq!(events[1].key, CanonicalKey::Enter); + let seq = key_event( + KeyboardMode::Translate, + ke_union::Union::Seq("a".to_string()), + true, + ); + assert!(convert_key_events(&seq).is_empty()); + + let mut empty = KeyEvent::new(); + empty.mode = EnumOrUnknown::new(KeyboardMode::Legacy); + assert!(convert_key_events(&empty).is_empty()); } } diff --git a/src/rustdesk/keyboard_mapping.rs b/src/rustdesk/keyboard_mapping.rs new file mode 100644 index 00000000..301e5e85 --- /dev/null +++ b/src/rustdesk/keyboard_mapping.rs @@ -0,0 +1,424 @@ +//! RustDesk keyboard code-space mappings. +//! +//! Keep physical position codes separate from character and control-key values. In +//! particular, a Windows Set-1 scan code must never fall through to ASCII, VK, or +//! X11 interpretation. + +use super::protocol::ControlKey; +use crate::hid::CanonicalKey; + +/// Physical keyboard code space selected by the platform advertised to RustDesk. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum KeyboardCodeSpace { + WindowsSet1, +} + +pub(super) fn physical_key(code_space: KeyboardCodeSpace, code: u32) -> Option { + match code_space { + KeyboardCodeSpace::WindowsSet1 => windows_set1_key(code), + } +} + +/// Convert a Windows Set-1 make code as encoded by RustDesk. +/// +/// Ordinary codes occupy the low byte. Extended codes are encoded as `0xE0xx`. +/// Other multi-byte values (including the special Pause sequence) are rejected. +pub(super) fn windows_set1_key(scan_code: u32) -> Option { + use CanonicalKey as Key; + + match scan_code { + 0x01 => Some(Key::Escape), + 0x02 => Some(Key::Digit1), + 0x03 => Some(Key::Digit2), + 0x04 => Some(Key::Digit3), + 0x05 => Some(Key::Digit4), + 0x06 => Some(Key::Digit5), + 0x07 => Some(Key::Digit6), + 0x08 => Some(Key::Digit7), + 0x09 => Some(Key::Digit8), + 0x0A => Some(Key::Digit9), + 0x0B => Some(Key::Digit0), + 0x0C => Some(Key::Minus), + 0x0D => Some(Key::Equal), + 0x0E => Some(Key::Backspace), + 0x0F => Some(Key::Tab), + 0x10 => Some(Key::KeyQ), + 0x11 => Some(Key::KeyW), + 0x12 => Some(Key::KeyE), + 0x13 => Some(Key::KeyR), + 0x14 => Some(Key::KeyT), + 0x15 => Some(Key::KeyY), + 0x16 => Some(Key::KeyU), + 0x17 => Some(Key::KeyI), + 0x18 => Some(Key::KeyO), + 0x19 => Some(Key::KeyP), + 0x1A => Some(Key::BracketLeft), + 0x1B => Some(Key::BracketRight), + 0x1C => Some(Key::Enter), + 0x1D => Some(Key::ControlLeft), + 0x1E => Some(Key::KeyA), + 0x1F => Some(Key::KeyS), + 0x20 => Some(Key::KeyD), + 0x21 => Some(Key::KeyF), + 0x22 => Some(Key::KeyG), + 0x23 => Some(Key::KeyH), + 0x24 => Some(Key::KeyJ), + 0x25 => Some(Key::KeyK), + 0x26 => Some(Key::KeyL), + 0x27 => Some(Key::Semicolon), + 0x28 => Some(Key::Quote), + 0x29 => Some(Key::Backquote), + 0x2A => Some(Key::ShiftLeft), + 0x2B => Some(Key::Backslash), + 0x2C => Some(Key::KeyZ), + 0x2D => Some(Key::KeyX), + 0x2E => Some(Key::KeyC), + 0x2F => Some(Key::KeyV), + 0x30 => Some(Key::KeyB), + 0x31 => Some(Key::KeyN), + 0x32 => Some(Key::KeyM), + 0x33 => Some(Key::Comma), + 0x34 => Some(Key::Period), + 0x35 => Some(Key::Slash), + 0x36 => Some(Key::ShiftRight), + 0x37 => Some(Key::NumpadMultiply), + 0x38 => Some(Key::AltLeft), + 0x39 => Some(Key::Space), + 0x3A => Some(Key::CapsLock), + 0x3B => Some(Key::F1), + 0x3C => Some(Key::F2), + 0x3D => Some(Key::F3), + 0x3E => Some(Key::F4), + 0x3F => Some(Key::F5), + 0x40 => Some(Key::F6), + 0x41 => Some(Key::F7), + 0x42 => Some(Key::F8), + 0x43 => Some(Key::F9), + 0x44 => Some(Key::F10), + 0x45 => Some(Key::NumLock), + 0x46 => Some(Key::ScrollLock), + 0x47 => Some(Key::Numpad7), + 0x48 => Some(Key::Numpad8), + 0x49 => Some(Key::Numpad9), + 0x4A => Some(Key::NumpadSubtract), + 0x4B => Some(Key::Numpad4), + 0x4C => Some(Key::Numpad5), + 0x4D => Some(Key::Numpad6), + 0x4E => Some(Key::NumpadAdd), + 0x4F => Some(Key::Numpad1), + 0x50 => Some(Key::Numpad2), + 0x51 => Some(Key::Numpad3), + 0x52 => Some(Key::Numpad0), + 0x53 => Some(Key::NumpadDecimal), + 0x56 => Some(Key::IntlBackslash), + 0x57 => Some(Key::F11), + 0x58 => Some(Key::F12), + + 0xE01C => Some(Key::NumpadEnter), + 0xE01D => Some(Key::ControlRight), + 0xE035 => Some(Key::NumpadDivide), + 0xE037 => Some(Key::PrintScreen), + 0xE038 => Some(Key::AltRight), + 0xE047 => Some(Key::Home), + 0xE048 => Some(Key::ArrowUp), + 0xE049 => Some(Key::PageUp), + 0xE04B => Some(Key::ArrowLeft), + 0xE04D => Some(Key::ArrowRight), + 0xE04F => Some(Key::End), + 0xE050 => Some(Key::ArrowDown), + 0xE051 => Some(Key::PageDown), + 0xE052 => Some(Key::Insert), + 0xE053 => Some(Key::Delete), + 0xE05B => Some(Key::MetaLeft), + 0xE05C => Some(Key::MetaRight), + 0xE05D => Some(Key::ContextMenu), + _ => None, + } +} + +pub(super) fn control_key(key: i32) -> Option { + use CanonicalKey as Key; + + match key { + x if x == ControlKey::Alt as i32 => Some(Key::AltLeft), + x if x == ControlKey::Backspace as i32 => Some(Key::Backspace), + x if x == ControlKey::CapsLock as i32 => Some(Key::CapsLock), + x if x == ControlKey::Control as i32 => Some(Key::ControlLeft), + x if x == ControlKey::Delete as i32 => Some(Key::Delete), + x if x == ControlKey::DownArrow as i32 => Some(Key::ArrowDown), + x if x == ControlKey::End as i32 => Some(Key::End), + x if x == ControlKey::Escape as i32 => Some(Key::Escape), + x if x == ControlKey::F1 as i32 => Some(Key::F1), + x if x == ControlKey::F2 as i32 => Some(Key::F2), + x if x == ControlKey::F3 as i32 => Some(Key::F3), + x if x == ControlKey::F4 as i32 => Some(Key::F4), + x if x == ControlKey::F5 as i32 => Some(Key::F5), + x if x == ControlKey::F6 as i32 => Some(Key::F6), + x if x == ControlKey::F7 as i32 => Some(Key::F7), + x if x == ControlKey::F8 as i32 => Some(Key::F8), + x if x == ControlKey::F9 as i32 => Some(Key::F9), + x if x == ControlKey::F10 as i32 => Some(Key::F10), + x if x == ControlKey::F11 as i32 => Some(Key::F11), + x if x == ControlKey::F12 as i32 => Some(Key::F12), + x if x == ControlKey::Home as i32 => Some(Key::Home), + x if x == ControlKey::LeftArrow as i32 => Some(Key::ArrowLeft), + x if x == ControlKey::Meta as i32 => Some(Key::MetaLeft), + x if x == ControlKey::PageDown as i32 => Some(Key::PageDown), + x if x == ControlKey::PageUp as i32 => Some(Key::PageUp), + x if x == ControlKey::Return as i32 => Some(Key::Enter), + x if x == ControlKey::RightArrow as i32 => Some(Key::ArrowRight), + x if x == ControlKey::Shift as i32 => Some(Key::ShiftLeft), + x if x == ControlKey::Space as i32 => Some(Key::Space), + x if x == ControlKey::Tab as i32 => Some(Key::Tab), + x if x == ControlKey::UpArrow as i32 => Some(Key::ArrowUp), + x if x == ControlKey::Numpad0 as i32 => Some(Key::Numpad0), + x if x == ControlKey::Numpad1 as i32 => Some(Key::Numpad1), + x if x == ControlKey::Numpad2 as i32 => Some(Key::Numpad2), + x if x == ControlKey::Numpad3 as i32 => Some(Key::Numpad3), + x if x == ControlKey::Numpad4 as i32 => Some(Key::Numpad4), + x if x == ControlKey::Numpad5 as i32 => Some(Key::Numpad5), + x if x == ControlKey::Numpad6 as i32 => Some(Key::Numpad6), + x if x == ControlKey::Numpad7 as i32 => Some(Key::Numpad7), + x if x == ControlKey::Numpad8 as i32 => Some(Key::Numpad8), + x if x == ControlKey::Numpad9 as i32 => Some(Key::Numpad9), + x if x == ControlKey::Pause as i32 => Some(Key::Pause), + x if x == ControlKey::Snapshot as i32 => Some(Key::PrintScreen), + x if x == ControlKey::Insert as i32 => Some(Key::Insert), + x if x == ControlKey::Scroll as i32 => Some(Key::ScrollLock), + x if x == ControlKey::NumLock as i32 => Some(Key::NumLock), + x if x == ControlKey::RWin as i32 => Some(Key::MetaRight), + x if x == ControlKey::Apps as i32 => Some(Key::ContextMenu), + x if x == ControlKey::Multiply as i32 => Some(Key::NumpadMultiply), + x if x == ControlKey::Add as i32 => Some(Key::NumpadAdd), + x if x == ControlKey::Subtract as i32 => Some(Key::NumpadSubtract), + x if x == ControlKey::Decimal as i32 => Some(Key::NumpadDecimal), + x if x == ControlKey::Divide as i32 => Some(Key::NumpadDivide), + x if x == ControlKey::NumpadEnter as i32 => Some(Key::NumpadEnter), + x if x == ControlKey::RShift as i32 => Some(Key::ShiftRight), + x if x == ControlKey::RControl as i32 => Some(Key::ControlRight), + x if x == ControlKey::RAlt as i32 => Some(Key::AltRight), + _ => None, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct CharacterMapping { + pub key: CanonicalKey, + pub needs_shift: bool, +} + +pub(super) fn character(ch: u32) -> Option { + use CanonicalKey as Key; + + let plain = |key| CharacterMapping { + key, + needs_shift: false, + }; + let shifted = |key| CharacterMapping { + key, + needs_shift: true, + }; + + Some(match ch { + 0x61..=0x7A => plain(CanonicalKey::from_hid_usage((ch - 0x61 + 0x04) as u8)?), + 0x41..=0x5A => shifted(CanonicalKey::from_hid_usage((ch - 0x41 + 0x04) as u8)?), + 0x30 => plain(Key::Digit0), + 0x31 => plain(Key::Digit1), + 0x32 => plain(Key::Digit2), + 0x33 => plain(Key::Digit3), + 0x34 => plain(Key::Digit4), + 0x35 => plain(Key::Digit5), + 0x36 => plain(Key::Digit6), + 0x37 => plain(Key::Digit7), + 0x38 => plain(Key::Digit8), + 0x39 => plain(Key::Digit9), + 0x20 => plain(Key::Space), + 0x0D | 0x0A => plain(Key::Enter), + 0x09 => plain(Key::Tab), + 0x1B => plain(Key::Escape), + 0x08 => plain(Key::Backspace), + 0x7F => plain(Key::Delete), + 0x2D => plain(Key::Minus), + 0x3D => plain(Key::Equal), + 0x5B => plain(Key::BracketLeft), + 0x5D => plain(Key::BracketRight), + 0x5C => plain(Key::Backslash), + 0x3B => plain(Key::Semicolon), + 0x27 => plain(Key::Quote), + 0x60 => plain(Key::Backquote), + 0x2C => plain(Key::Comma), + 0x2E => plain(Key::Period), + 0x2F => plain(Key::Slash), + 0x21 => shifted(Key::Digit1), + 0x40 => shifted(Key::Digit2), + 0x23 => shifted(Key::Digit3), + 0x24 => shifted(Key::Digit4), + 0x25 => shifted(Key::Digit5), + 0x5E => shifted(Key::Digit6), + 0x26 => shifted(Key::Digit7), + 0x2A => shifted(Key::Digit8), + 0x28 => shifted(Key::Digit9), + 0x29 => shifted(Key::Digit0), + 0x5F => shifted(Key::Minus), + 0x2B => shifted(Key::Equal), + 0x7B => shifted(Key::BracketLeft), + 0x7D => shifted(Key::BracketRight), + 0x7C => shifted(Key::Backslash), + 0x3A => shifted(Key::Semicolon), + 0x22 => shifted(Key::Quote), + 0x7E => shifted(Key::Backquote), + 0x3C => shifted(Key::Comma), + 0x3E => shifted(Key::Period), + 0x3F => shifted(Key::Slash), + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn windows_set1_matrix_is_complete() { + use CanonicalKey as Key; + + let cases = [ + (0x01, Key::Escape), + (0x02, Key::Digit1), + (0x03, Key::Digit2), + (0x04, Key::Digit3), + (0x05, Key::Digit4), + (0x06, Key::Digit5), + (0x07, Key::Digit6), + (0x08, Key::Digit7), + (0x09, Key::Digit8), + (0x0A, Key::Digit9), + (0x0B, Key::Digit0), + (0x0C, Key::Minus), + (0x0D, Key::Equal), + (0x0E, Key::Backspace), + (0x0F, Key::Tab), + (0x10, Key::KeyQ), + (0x11, Key::KeyW), + (0x12, Key::KeyE), + (0x13, Key::KeyR), + (0x14, Key::KeyT), + (0x15, Key::KeyY), + (0x16, Key::KeyU), + (0x17, Key::KeyI), + (0x18, Key::KeyO), + (0x19, Key::KeyP), + (0x1A, Key::BracketLeft), + (0x1B, Key::BracketRight), + (0x1C, Key::Enter), + (0x1D, Key::ControlLeft), + (0x1E, Key::KeyA), + (0x1F, Key::KeyS), + (0x20, Key::KeyD), + (0x21, Key::KeyF), + (0x22, Key::KeyG), + (0x23, Key::KeyH), + (0x24, Key::KeyJ), + (0x25, Key::KeyK), + (0x26, Key::KeyL), + (0x27, Key::Semicolon), + (0x28, Key::Quote), + (0x29, Key::Backquote), + (0x2A, Key::ShiftLeft), + (0x2B, Key::Backslash), + (0x2C, Key::KeyZ), + (0x2D, Key::KeyX), + (0x2E, Key::KeyC), + (0x2F, Key::KeyV), + (0x30, Key::KeyB), + (0x31, Key::KeyN), + (0x32, Key::KeyM), + (0x33, Key::Comma), + (0x34, Key::Period), + (0x35, Key::Slash), + (0x36, Key::ShiftRight), + (0x37, Key::NumpadMultiply), + (0x38, Key::AltLeft), + (0x39, Key::Space), + (0x3A, Key::CapsLock), + (0x3B, Key::F1), + (0x3C, Key::F2), + (0x3D, Key::F3), + (0x3E, Key::F4), + (0x3F, Key::F5), + (0x40, Key::F6), + (0x41, Key::F7), + (0x42, Key::F8), + (0x43, Key::F9), + (0x44, Key::F10), + (0x45, Key::NumLock), + (0x46, Key::ScrollLock), + (0x47, Key::Numpad7), + (0x48, Key::Numpad8), + (0x49, Key::Numpad9), + (0x4A, Key::NumpadSubtract), + (0x4B, Key::Numpad4), + (0x4C, Key::Numpad5), + (0x4D, Key::Numpad6), + (0x4E, Key::NumpadAdd), + (0x4F, Key::Numpad1), + (0x50, Key::Numpad2), + (0x51, Key::Numpad3), + (0x52, Key::Numpad0), + (0x53, Key::NumpadDecimal), + (0x56, Key::IntlBackslash), + (0x57, Key::F11), + (0x58, Key::F12), + (0xE01C, Key::NumpadEnter), + (0xE01D, Key::ControlRight), + (0xE035, Key::NumpadDivide), + (0xE037, Key::PrintScreen), + (0xE038, Key::AltRight), + (0xE047, Key::Home), + (0xE048, Key::ArrowUp), + (0xE049, Key::PageUp), + (0xE04B, Key::ArrowLeft), + (0xE04D, Key::ArrowRight), + (0xE04F, Key::End), + (0xE050, Key::ArrowDown), + (0xE051, Key::PageDown), + (0xE052, Key::Insert), + (0xE053, Key::Delete), + (0xE05B, Key::MetaLeft), + (0xE05C, Key::MetaRight), + (0xE05D, Key::ContextMenu), + ]; + + for (scan_code, expected) in cases { + assert_eq!( + windows_set1_key(scan_code), + Some(expected), + "0x{scan_code:04X}" + ); + } + } + + #[test] + fn windows_set1_rejects_unknown_and_invalid_multibyte_codes() { + for scan_code in [0, 0x54, 0x59, 0xD3, 0xE000, 0xE054, 0xE11D45, 0x0101] { + assert_eq!(windows_set1_key(scan_code), None, "0x{scan_code:X}"); + } + } + + #[test] + fn audited_control_keys_map_without_hid_round_trip() { + assert_eq!( + control_key(ControlKey::RWin as i32), + Some(CanonicalKey::MetaRight) + ); + assert_eq!( + control_key(ControlKey::Apps as i32), + Some(CanonicalKey::ContextMenu) + ); + assert_eq!( + control_key(ControlKey::Snapshot as i32), + Some(CanonicalKey::PrintScreen) + ); + assert_eq!(control_key(ControlKey::Power as i32), None); + } +} diff --git a/src/rustdesk/mod.rs b/src/rustdesk/mod.rs index 3e443641..035df233 100644 --- a/src/rustdesk/mod.rs +++ b/src/rustdesk/mod.rs @@ -6,6 +6,7 @@ pub mod connection; pub mod crypto; pub mod frame_adapters; pub mod hid_adapter; +mod keyboard_mapping; pub mod protocol; pub mod punch; pub mod rendezvous; From a8ee9552a07afa8fa5b1d1a0d26725a46a32863d Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sat, 5 Sep 2026 14:10:53 +0800 Subject: [PATCH 21/29] =?UTF-8?q?perf(video):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=9C=89=E7=95=8C=20VBR=20=E4=BC=98=E5=8C=96=20H264=20?= =?UTF-8?q?=E7=A0=81=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H264 编码器探测与初始化使用 VBR,将峰值码率和缓冲区限制为目标码率的两倍。动态调整码率时同步更新限制,并保留 QSV 的专用处理。 --- libs/hwcodec/cpp/common/util.cpp | 12 ++++++++++++ src/video/codec/h264.rs | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/libs/hwcodec/cpp/common/util.cpp b/libs/hwcodec/cpp/common/util.cpp index 0894f4eb..a2762aab 100644 --- a/libs/hwcodec/cpp/common/util.cpp +++ b/libs/hwcodec/cpp/common/util.cpp @@ -397,6 +397,14 @@ struct CodecOptions { bool set_rate_control(AVCodecContext *c, const std::string &name, int rc, int q) { + // Remote-desktop content is usually sparse. VBR avoids padding static + // frames up to the target bitrate while allowing short bursts for screen + // changes. Keep those bursts bounded at twice the target bitrate. + if (rc == RC_VBR && c->bit_rate > 0) { + c->rc_max_rate = c->bit_rate * 2; + c->rc_buffer_size = c->rc_max_rate; + } + if (name.find("vaapi") != std::string::npos && rc == RC_CQ) { // Used only after the normal bitrate-based VAAPI initialization fails. // Some drivers, including Intel iHD on Jasper Lake, expose CQP as their @@ -495,6 +503,10 @@ bool set_others(void *priv_data, const std::string &name) { bool change_bit_rate(AVCodecContext *c, const std::string &name, int kbs) { if (kbs > 0) { c->bit_rate = kbs * 1000; + if (c->rc_max_rate > 0 && name.find("qsv") == std::string::npos) { + c->rc_max_rate = c->bit_rate * 2; + c->rc_buffer_size = c->rc_max_rate; + } if (name.find("qsv") != std::string::npos) { c->rc_max_rate = c->bit_rate; } diff --git a/src/video/codec/h264.rs b/src/video/codec/h264.rs index 13d7a3a8..8b0ac59a 100644 --- a/src/video/codec/h264.rs +++ b/src/video/codec/h264.rs @@ -198,7 +198,7 @@ pub fn get_available_encoders(width: u32, height: u32) -> Vec { align: 1, fps: 30, gop: 30, - rc: RateControl::RC_CBR, + rc: RateControl::RC_VBR, quality: Quality::Quality_Low, // Use low quality preset for fastest encoding (ultrafast) kbs: 2000, q: 23, @@ -297,7 +297,7 @@ impl H264Encoder { align: 1, fps: config.fps as i32, gop: config.gop_size as i32, - rc: RateControl::RC_CBR, + rc: RateControl::RC_VBR, quality: Quality::Quality_Low, // Use low quality preset for fastest encoding (lowest latency) kbs: config.bitrate_kbps as i32, q: 23, From 79a4dcf2b072572cc7df8974494675263a756357 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sat, 5 Sep 2026 14:10:53 +0800 Subject: [PATCH 22/29] =?UTF-8?q?fix(video):=20=E7=BB=9F=E4=B8=80=E5=85=B3?= =?UTF-8?q?=E9=94=AE=E5=B8=A7=E5=8F=82=E6=95=B0=E9=9B=86=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=B9=B6=E7=BC=93=E5=AD=98=E5=90=AF=E5=8A=A8=E5=B8=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在共享管线中将长度前缀 H264 转为 Annex-B,要求编码器标记与 IDR/IRAP 内容一致,并补齐参数集后才标记可独立解码的关键帧。 向新订阅者提供缓存启动帧,启动管线时清理缓存,并移除 RustDesk 会话内重复的 SPS/PPS 处理。补充格式归一化、关键帧判定和订阅测试。 --- src/rustdesk/frame_adapters.rs | 44 -------- src/video/codec/h264_bitstream.rs | 66 +++++++++++- src/video/pipeline/shared.rs | 170 ++++++++++++++++++++++++++++-- 3 files changed, 222 insertions(+), 58 deletions(-) diff --git a/src/rustdesk/frame_adapters.rs b/src/rustdesk/frame_adapters.rs index de57bc4c..7a3ed733 100644 --- a/src/rustdesk/frame_adapters.rs +++ b/src/rustdesk/frame_adapters.rs @@ -31,8 +31,6 @@ pub struct VideoFrameAdapter { codec: VideoCodec, seq: u32, timestamp_base: u64, - h264_sps: Option, - h264_pps: Option, } impl VideoFrameAdapter { @@ -41,8 +39,6 @@ impl VideoFrameAdapter { codec, seq: 0, timestamp_base: 0, - h264_sps: None, - h264_pps: None, } } @@ -56,7 +52,6 @@ impl VideoFrameAdapter { is_keyframe: bool, timestamp_ms: u64, ) -> Message { - let data = self.prepare_h264_frame(data, is_keyframe); if self.seq == 0 { self.timestamp_base = timestamp_ms; } @@ -86,45 +81,6 @@ impl VideoFrameAdapter { msg } - fn prepare_h264_frame(&mut self, data: Bytes, is_keyframe: bool) -> Bytes { - if self.codec != VideoCodec::H264 { - return data; - } - - // Parameter sets are relevant only on random-access frames. Avoid a - // full Annex-B/AVCC scan on every delta frame in every client session. - if !is_keyframe { - return data; - } - - let (sps, pps) = crate::video::codec::h264_bitstream::extract_sps_pps(&data); - let mut has_sps = false; - let mut has_pps = false; - - if let Some(sps) = sps { - self.h264_sps = Some(Bytes::from(sps)); - has_sps = true; - } - if let Some(pps) = pps { - self.h264_pps = Some(Bytes::from(pps)); - has_pps = true; - } - - if is_keyframe && (!has_sps || !has_pps) { - if let (Some(sps), Some(pps)) = (self.h264_sps.as_ref(), self.h264_pps.as_ref()) { - let mut out = Vec::with_capacity(8 + sps.len() + pps.len() + data.len()); - out.extend_from_slice(&[0, 0, 0, 1]); - out.extend_from_slice(sps); - out.extend_from_slice(&[0, 0, 0, 1]); - out.extend_from_slice(pps); - out.extend_from_slice(&data); - return Bytes::from(out); - } - } - - data - } - pub fn encode_frame(&mut self, data: &[u8], is_keyframe: bool, timestamp_ms: u64) -> Message { self.encode_frame_from_bytes(Bytes::copy_from_slice(data), is_keyframe, timestamp_ms) } diff --git a/src/video/codec/h264_bitstream.rs b/src/video/codec/h264_bitstream.rs index eac14041..d47357e5 100644 --- a/src/video/codec/h264_bitstream.rs +++ b/src/video/codec/h264_bitstream.rs @@ -252,13 +252,37 @@ pub fn avcc_to_annex_b(data: &[u8]) -> Option> { } } -pub fn normalize_for_webrtc(data: &[u8]) -> Vec { - if is_annex_b(data) { - return strip_aud_nal_units(data); +/// Normalize a length-prefixed H.264 access unit to Annex-B when necessary. +/// +/// FFmpeg normally exposes elementary H.264 from hardware encoders as +/// Annex-B, but some V4L2 M2M drivers return AVCC-style packets. Consumers +/// such as RustDesk do not receive codec extradata from our protocol adapter, +/// so passing those packets through unchanged leaves the decoder unable to +/// find NAL unit boundaries. +pub fn normalize_annex_b(data: bytes::Bytes) -> bytes::Bytes { + // A four-byte start code is unambiguous for real encoder output. A + // three-byte prefix is not: an AVCC NAL of 256..511 bytes also begins + // with 00 00 01. Validate AVCC before accepting that shorter prefix. + if data.starts_with(&[0, 0, 0, 1]) { + return data; } - if let Some(annex_b) = avcc_to_annex_b(data) { - return strip_aud_nal_units(&annex_b); + if let Some(annex_b) = avcc_to_annex_b(data.as_ref()) { + return bytes::Bytes::from(annex_b); + } + + data +} + +pub fn normalize_for_webrtc(data: &[u8]) -> Vec { + if !data.starts_with(&[0, 0, 0, 1]) { + if let Some(annex_b) = avcc_to_annex_b(data) { + return strip_aud_nal_units(&annex_b); + } + } + + if is_annex_b(data) { + return strip_aud_nal_units(data); } data.to_vec() @@ -296,4 +320,36 @@ mod tests { Some("42402a".to_string()) ); } + + #[test] + fn converts_avcc_access_unit_to_annex_b() { + let avcc = [ + 0, 0, 0, 4, 0x67, 0x42, 0x40, 0x1f, // SPS + 0, 0, 0, 2, 0x68, 0xce, // PPS + 0, 0, 0, 3, 0x65, 0x88, 0x84, // IDR + ]; + + let annex_b = normalize_annex_b(bytes::Bytes::copy_from_slice(&avcc)); + assert!(is_annex_b(&annex_b)); + assert!(has_sps_pps(&annex_b)); + assert!(is_keyframe(&annex_b)); + } + + #[test] + fn leaves_annex_b_packet_unchanged() { + let annex_b = bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88, 0x84]); + let normalized = normalize_annex_b(annex_b.clone()); + assert_eq!(normalized, annex_b); + } + + #[test] + fn recognizes_avcc_length_that_looks_like_three_byte_start_code() { + let mut avcc = vec![0, 0, 1, 0]; + avcc.push(0x65); + avcc.resize(4 + 256, 0x88); + + let annex_b = normalize_annex_b(bytes::Bytes::from(avcc)); + assert_eq!(&annex_b[..5], &[0, 0, 0, 1, 0x65]); + assert!(is_keyframe(&annex_b)); + } } diff --git a/src/video/pipeline/shared.rs b/src/video/pipeline/shared.rs index ddfe33a0..52328fee 100644 --- a/src/video/pipeline/shared.rs +++ b/src/video/pipeline/shared.rs @@ -71,7 +71,10 @@ pub struct EncodedVideoFrame { pub data: Bytes, /// Presentation timestamp in milliseconds pub pts_ms: i64, - /// Whether this is a keyframe + /// Whether this frame can initialize a decoder without earlier frames. + /// + /// For H.264/H.265 this is stricter than the encoder packet flag: the + /// payload must be IDR/IRAP and include all required parameter sets. pub is_keyframe: bool, /// Frame sequence number pub sequence: u64, @@ -306,6 +309,8 @@ pub struct SharedVideoPipeline { /// Atomic flag for keyframe request (avoids lock contention) keyframe_requested: AtomicBool, parameter_sets: ParkingMutex, + /// Most recent random-access frame with all decoder parameter sets. + bootstrap_frame: ParkingRwLock>>, /// Pipeline start time for monotonic PTS calculation (microseconds from process start). /// Uses AtomicI64 instead of Mutex for lock-free access. pipeline_start_time_us: AtomicI64, @@ -346,6 +351,7 @@ impl SharedVideoPipeline { sequence: AtomicU64::new(0), keyframe_requested: AtomicBool::new(false), parameter_sets: ParkingMutex::new(CachedH26xParameterSets::default()), + bootstrap_frame: ParkingRwLock::new(None), pipeline_start_time_us: AtomicI64::new(0), pending_sync_geometry: ParkingMutex::new(None), device_lost_reason: ParkingMutex::new(None), @@ -404,6 +410,9 @@ impl SharedVideoPipeline { // 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); + if let Some(frame) = self.bootstrap_frame.read().clone() { + let _ = tx.try_send(frame); + } self.subscribers.write().push(tx); rx } @@ -526,8 +535,15 @@ impl SharedVideoPipeline { ) -> (Bytes, bool) { match codec { VideoEncoderType::H264 => { + let was_annex_b = h264_bitstream::is_annex_b(data.as_ref()); + let data = h264_bitstream::normalize_annex_b(data); + if !was_annex_b && h264_bitstream::is_annex_b(data.as_ref()) { + debug!("[Pipeline] Converted length-prefixed H264 packet to Annex-B"); + } let (sps, pps) = h264_bitstream::extract_sps_pps(data.as_ref()); - let is_keyframe = ffmpeg_keyframe || h264_bitstream::is_keyframe(data.as_ref()); + // Require metadata and payload to agree before advertising a + // decoder bootstrap frame. + let is_idr = ffmpeg_keyframe && h264_bitstream::is_keyframe(data.as_ref()); let mut cache = self.parameter_sets.lock(); if let Some(sps) = sps.as_ref() { cache.h264_sps = Some(sps.clone()); @@ -536,8 +552,11 @@ impl SharedVideoPipeline { cache.h264_pps = Some(pps.clone()); } - if !is_keyframe || (sps.is_some() && pps.is_some()) { - return (data, is_keyframe); + if !is_idr { + return (data, false); + } + if sps.is_some() && pps.is_some() { + return (data, true); } match (&cache.h264_sps, &cache.h264_pps) { @@ -553,12 +572,13 @@ impl SharedVideoPipeline { debug!("[Pipeline] Prepended cached SPS/PPS to H264 IDR"); (Bytes::from(output), true) } - _ => (data, true), + // An IDR without SPS/PPS is not a decoder bootstrap frame. + _ => (data, false), } } VideoEncoderType::H265 => { let (vps, sps, pps) = h265_bitstream::extract_vps_sps_pps(data.as_ref()); - let is_keyframe = ffmpeg_keyframe || h265_bitstream::is_keyframe(data.as_ref()); + let is_irap = ffmpeg_keyframe && h265_bitstream::is_keyframe(data.as_ref()); let mut cache = self.parameter_sets.lock(); if let Some(vps) = vps.as_ref() { cache.h265_vps = Some(vps.clone()); @@ -570,8 +590,11 @@ impl SharedVideoPipeline { cache.h265_pps = Some(pps.clone()); } - if !is_keyframe || (vps.is_some() && sps.is_some() && pps.is_some()) { - return (data, is_keyframe); + if !is_irap { + return (data, false); + } + if vps.is_some() && sps.is_some() && pps.is_some() { + return (data, true); } match (&cache.h265_vps, &cache.h265_sps, &cache.h265_pps) { @@ -591,7 +614,7 @@ impl SharedVideoPipeline { debug!("[Pipeline] Prepended cached VPS/SPS/PPS to H265 IRAP"); (Bytes::from(output), true) } - _ => (data, true), + _ => (data, false), } } _ => (data, ffmpeg_keyframe), @@ -599,6 +622,10 @@ impl SharedVideoPipeline { } fn broadcast_encoded(&self, frame: Arc) { + if frame.is_keyframe { + *self.bootstrap_frame.write() = Some(frame.clone()); + } + let subscribers = { let guard = self.subscribers.read(); if guard.is_empty() { @@ -638,6 +665,9 @@ impl SharedVideoPipeline { return Ok(()); } + *self.parameter_sets.lock() = CachedH26xParameterSets::default(); + *self.bootstrap_frame.write() = None; + let mut config = self.config.read().await.clone(); let parallel_mjpeg_decode = should_parallel_decode_mjpeg(&config); { @@ -1680,6 +1710,128 @@ mod tests { assert_eq!(h265.output_codec, VideoEncoderType::H265); } + #[test] + fn h264_keyframe_requires_idr_and_parameter_sets() { + let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264( + Resolution::HD720, + BitratePreset::Balanced, + )) + .unwrap(); + + let predicted = Bytes::from_static(&[0, 0, 0, 1, 0x41, 0xc0]); + let (_, key) = + pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, predicted, true); + assert!( + !key, + "a driver flag must not turn a P-frame into a keyframe" + ); + + let parameter_sets = + Bytes::from_static(&[0, 0, 0, 1, 0x67, 0x42, 0x40, 0x1f, 0, 0, 0, 1, 0x68, 0xce]); + let (_, key) = + pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, parameter_sets, false); + assert!(!key, "parameter sets alone are not a keyframe"); + + let idr = Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88]); + let (_, key) = pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, idr, false); + assert!(!key, "an IDR without a driver key flag is not trusted"); + + let idr = Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88]); + let (bootstrap, key) = + pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, idr, true); + assert!( + key, + "matching driver metadata and IDR payload should bootstrap" + ); + assert!(h264_bitstream::has_sps_pps(bootstrap.as_ref())); + assert!(h264_bitstream::is_keyframe(bootstrap.as_ref())); + } + + #[test] + fn h265_keyframe_requires_irap_and_parameter_sets() { + let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h265( + Resolution::HD720, + BitratePreset::Balanced, + )) + .unwrap(); + + let trail = Bytes::from_static(&[0, 0, 0, 1, 1 << 1, 1, 0xaa]); + let (_, key) = + pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, trail, true); + assert!( + !key, + "a driver flag must not turn a trailing frame into a keyframe" + ); + + let parameter_sets = Bytes::from_static(&[ + 0, + 0, + 0, + 1, + 32 << 1, + 1, + 0xaa, + 0, + 0, + 0, + 1, + 33 << 1, + 1, + 0xbb, + 0, + 0, + 0, + 1, + 34 << 1, + 1, + 0xcc, + ]); + let (_, key) = + pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, parameter_sets, false); + assert!(!key, "parameter sets alone are not a keyframe"); + + let irap = Bytes::from_static(&[0, 0, 0, 1, 19 << 1, 1, 0xdd]); + let (_, key) = + pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, irap, false); + assert!(!key, "an IRAP without a driver key flag is not trusted"); + + let irap = Bytes::from_static(&[0, 0, 0, 1, 19 << 1, 1, 0xdd]); + let (bootstrap, key) = + pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, irap, true); + assert!( + key, + "matching driver metadata and IRAP payload should bootstrap" + ); + assert!(h265_bitstream::has_vps_sps_pps(bootstrap.as_ref())); + assert!(h265_bitstream::is_keyframe(bootstrap.as_ref())); + } + + #[test] + fn new_subscriber_receives_cached_bootstrap_frame() { + let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264( + Resolution::HD720, + BitratePreset::Balanced, + )) + .unwrap(); + let bootstrap = Arc::new(EncodedVideoFrame { + data: Bytes::from_static(&[ + 0, 0, 0, 1, 0x67, 0x42, 0x40, 0x1f, 0, 0, 0, 1, 0x68, 0xce, 0, 0, 0, 1, 0x65, 0x88, + ]), + pts_ms: 0, + is_keyframe: true, + sequence: 1, + duration: Duration::from_millis(33), + codec: VideoEncoderType::H264, + }); + + pipeline.broadcast_encoded(bootstrap.clone()); + let mut subscriber = pipeline.subscribe(); + let received = subscriber + .try_recv() + .expect("cached bootstrap frame should seed the subscriber queue"); + assert!(Arc::ptr_eq(&received, &bootstrap)); + } + #[test] fn stop_request_does_not_publish_worker_exit() { let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264( From 620fe0be545928561f7f567d1871fc4285de863a Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sat, 5 Sep 2026 14:10:53 +0800 Subject: [PATCH 23/29] =?UTF-8?q?perf(video):=20=E5=B9=B6=E8=A1=8C?= =?UTF-8?q?=E8=A7=A3=E7=A0=81=20MJPEG=20=E5=B9=B6=E4=B8=A2=E5=BC=83?= =?UTF-8?q?=E7=A7=AF=E5=8E=8B=E5=B8=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按可用 CPU 数量启动独立 MJPEG 解码线程,通过无缓冲通道分发任务;线程全忙时丢弃新帧,按序列号拒绝过时解码结果。 复用帧缓冲池,在线程启动失败时回退到采集线程解码,并更新并行解码说明及工作线程数量测试。 --- src/video/pipeline/encoder_state.rs | 4 +- src/video/pipeline/shared.rs | 144 +++++++++++++++++++++++++++- 2 files changed, 143 insertions(+), 5 deletions(-) diff --git a/src/video/pipeline/encoder_state.rs b/src/video/pipeline/encoder_state.rs index a7b63609..08b1cc3c 100644 --- a/src/video/pipeline/encoder_state.rs +++ b/src/video/pipeline/encoder_state.rs @@ -190,8 +190,8 @@ fn create_mjpeg_decoder(resolution: Resolution) -> Result<(MjpegDecoderKind, Pix } /// V4L2 M2M hardware encoding 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. +/// resources. Decode MJPEG outside the encoder worker so encoding the previous +/// NV12 frame can overlap with decoding subsequent frames. pub(super) fn should_parallel_decode_mjpeg(config: &SharedVideoPipelineConfig) -> bool { if !config.input_format.is_compressed() || !matches!( diff --git a/src/video/pipeline/shared.rs b/src/video/pipeline/shared.rs index 52328fee..22d4a70e 100644 --- a/src/video/pipeline/shared.rs +++ b/src/video/pipeline/shared.rs @@ -21,6 +21,7 @@ use parking_lot::Mutex as ParkingMutex; use parking_lot::RwLock as ParkingRwLock; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; +use std::sync::mpsc::{sync_channel, SyncSender, TrySendError}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{mpsc, watch, Mutex, RwLock}; @@ -60,6 +61,92 @@ use crate::video::recovery::{wait_for_source_change, CaptureRecoveryPolicy}; use crate::video::signal::SignalStatus; const MIN_CAPTURE_FRAME_SIZE: usize = 128; +struct MjpegDecodeJob { + data: Vec, + sequence: u64, +} + +fn mjpeg_decode_worker_count(available_parallelism: usize) -> usize { + available_parallelism.max(1) +} + +fn spawn_mjpeg_decode_workers( + pipeline: &Arc, + latest_frame: &Arc>>>, + frame_seq_tx: &watch::Sender, + buffer_pool: &Arc, + resolution: Resolution, +) -> Vec> { + let available = std::thread::available_parallelism() + .map(|count| count.get()) + .unwrap_or(1); + let worker_count = mjpeg_decode_worker_count(available); + let mut senders = Vec::with_capacity(worker_count); + + for worker_id in 0..worker_count { + // A rendezvous channel deliberately has no queue. If every decoder is + // busy, capture drops the new compressed frame instead of building up + // latency behind stale frames. + let (tx, rx) = sync_channel::(0); + let worker_pipeline = pipeline.clone(); + let worker_latest_frame = latest_frame.clone(); + let worker_frame_seq_tx = frame_seq_tx.clone(); + let worker_buffer_pool = buffer_pool.clone(); + let thread_name = format!("mjpeg-decoder-{worker_id}"); + let spawn_result = std::thread::Builder::new() + .name(thread_name) + .spawn(move || { + let mut decoder = MjpegToNv12Decoder::new(resolution); + while let Ok(job) = rx.recv() { + let nv12_size = resolution.width as usize * resolution.height as usize * 3 / 2; + let mut nv12 = worker_buffer_pool.take(nv12_size); + let decode_result = decoder.decode_into(&job.data, &mut nv12); + worker_buffer_pool.put(job.data); + + if let Err(error) = decode_result { + worker_buffer_pool.put(nv12); + warn!("Dropping undecodable MJPEG frame: {}", error); + continue; + } + if !worker_pipeline.running_flag.load(Ordering::Acquire) { + worker_buffer_pool.put(nv12); + break; + } + + let frame = Arc::new(VideoFrame::from_pooled( + Arc::new(FrameBuffer::new(nv12, Some(worker_buffer_pool.clone()))), + resolution, + PixelFormat::Nv12, + resolution.width, + job.sequence, + )); + let published = { + let mut latest = worker_latest_frame.write(); + if latest + .as_ref() + .is_some_and(|current| current.sequence >= job.sequence) + { + false + } else { + *latest = Some(frame); + true + } + }; + if published { + let _ = worker_frame_seq_tx.send(job.sequence.wrapping_add(1)); + } + } + }); + + match spawn_result { + Ok(_) => senders.push(tx), + Err(error) => error!("Failed to start MJPEG decoder worker: {}", error), + } + } + + info!("Started {} parallel MJPEG decoder worker(s)", senders.len()); + senders +} #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] use hwcodec::ffmpeg_hw::last_error_message as ffmpeg_hw_last_error; @@ -738,7 +825,7 @@ impl SharedVideoPipeline { 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 hardware encoding"); + info!("Using parallel libyuv MJPEG decode with hardware encoding"); } let mut encoder_state = build_encoder_state(&encoder_config)?; let _ = self.running.send(true); @@ -864,8 +951,25 @@ impl SharedVideoPipeline { 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)); + let mut mjpeg_decode_senders = parallel_mjpeg_decode + .then(|| { + spawn_mjpeg_decode_workers( + &pipeline, + &latest_frame, + &frame_seq_tx, + &buffer_pool, + config.resolution, + ) + }) + .filter(|senders| !senders.is_empty()); + let mut next_mjpeg_decoder = 0usize; + let mut mjpeg_decoder = parallel_mjpeg_decode + .then(|| MjpegToNv12Decoder::new(config.resolution)) + .filter(|_| { + mjpeg_decode_senders + .as_ref() + .is_none_or(|senders| senders.is_empty()) + }); if let Some(s) = preopened { resolution = s.resolution(); @@ -1210,6 +1314,32 @@ impl SharedVideoPipeline { pixel_format, active_fps, )); + + if let Some(senders) = mjpeg_decode_senders.as_mut() { + let mut pending = Some(MjpegDecodeJob { + data: owned, + sequence: meta.sequence, + }); + for offset in 0..senders.len() { + let index = (next_mjpeg_decoder + offset) % senders.len(); + let job = pending.take().expect("pending MJPEG decode job"); + match senders[index].try_send(job) { + Ok(()) => { + next_mjpeg_decoder = (index + 1) % senders.len(); + break; + } + Err(TrySendError::Full(job)) + | Err(TrySendError::Disconnected(job)) => { + pending = Some(job); + } + } + } + if let Some(job) = pending { + buffer_pool.put(job.data); + } + continue; + } + let (frame_data, frame_format, frame_stride) = if let Some(decoder) = mjpeg_decoder.as_mut() { let nv12_size = @@ -1832,6 +1962,14 @@ mod tests { assert!(Arc::ptr_eq(&received, &bootstrap)); } + #[test] + fn mjpeg_workers_match_available_cpu_count() { + assert_eq!(mjpeg_decode_worker_count(1), 1); + assert_eq!(mjpeg_decode_worker_count(2), 2); + assert_eq!(mjpeg_decode_worker_count(4), 4); + assert_eq!(mjpeg_decode_worker_count(64), 64); + } + #[test] fn stop_request_does_not_publish_worker_exit() { let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264( From db9d79554a80c424dd0e68c20c11b85c593c9aba Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sat, 5 Sep 2026 20:55:30 +0800 Subject: [PATCH 24/29] =?UTF-8?q?perf(video):=20=E6=96=B0=E5=A2=9E=20RKMPP?= =?UTF-8?q?=20DMA=20=E9=87=87=E9=9B=86=E7=BC=96=E7=A0=81=E9=80=9A=E8=B7=AF?= =?UTF-8?q?=E5=B9=B6=E5=AE=8C=E5=96=84=E6=81=A2=E5=A4=8D=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持原生 HDMI 和 UVC 缓冲区导出,增加同步 RKMPP 编码及可选 MJPEG 硬件转码。 校验帧布局和缓冲区租约,在 DMA 不可用或编码失败时回退到复制通路。 保留自定义码率和 GOP 策略,重开采集时同步 HDMI 源帧率,并区分 UVC 超时状态。 验证:88 个视频测试通过(含 4 个新增回归测试);ARM64 cargo check --tests 通过。 --- libs/hwcodec/build.rs | 1 + libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dma_jpeg.h | 38 ++ libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf.cpp | 329 +++++++++++++++ libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf_ffi.h | 31 ++ libs/hwcodec/src/lib.rs | 5 + libs/hwcodec/src/rkmpp_dmabuf.rs | 193 +++++++++ src/video/capture/dmabuf_layout.rs | 201 +++++++++ src/video/capture/linux.rs | 230 +++++++++- src/video/capture/status.rs | 51 +++ src/video/pipeline/dmabuf.rs | 392 ++++++++++++++++++ src/video/pipeline/shared.rs | 247 +++++++++-- 11 files changed, 1680 insertions(+), 38 deletions(-) create mode 100644 libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dma_jpeg.h create mode 100644 libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf.cpp create mode 100644 libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf_ffi.h create mode 100644 libs/hwcodec/src/rkmpp_dmabuf.rs create mode 100644 src/video/capture/dmabuf_layout.rs create mode 100644 src/video/pipeline/dmabuf.rs diff --git a/libs/hwcodec/build.rs b/libs/hwcodec/build.rs index 26aad7f3..08441f97 100644 --- a/libs/hwcodec/build.rs +++ b/libs/hwcodec/build.rs @@ -488,6 +488,7 @@ mod ffmpeg { } } builder.file(ffmpeg_hw_dir.join("ffmpeg_hw_mjpeg_h26x.cpp")); + builder.file(ffmpeg_hw_dir.join("rkmpp_dmabuf.cpp")); } else { println!( "cargo:info=Skipping ffmpeg_hw_mjpeg_h26x.cpp (RKMPP) for arch {}", diff --git a/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dma_jpeg.h b/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dma_jpeg.h new file mode 100644 index 00000000..c7f4ad56 --- /dev/null +++ b/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dma_jpeg.h @@ -0,0 +1,38 @@ +#pragma once +#include +#include + +// Validate bounded JPEG headers before giving hardware a fixed-size output +// buffer. Only baseline 8-bit JPEG is accepted; other streams use copy fallback. +// No scan-data traversal or full-packet copy is needed. +inline bool rkmpp_dma_jpeg_header(const uint8_t *data, size_t size, int width, int height) { + if (!data || size < 4 || data[0] != 0xff || data[1] != 0xd8) return false; + size_t pos = 2; + bool sof = false; + while (pos < size) { + if (data[pos++] != 0xff) return false; + while (pos < size && data[pos] == 0xff) ++pos; + if (pos == size) return false; + const unsigned marker = data[pos++]; + if (!marker || marker == 0xd8 || marker == 0xd9 || marker == 1 || + (marker >= 0xd0 && marker <= 0xd7)) return false; + if (size - pos < 2) return false; + const size_t length = (size_t(data[pos]) << 8) | data[pos + 1]; + if (length < 2 || length > size - pos) return false; + if (marker == 0xc0) { + if (sof || length < 8 || data[pos + 2] != 8) return false; + const unsigned h = (unsigned(data[pos + 3]) << 8) | data[pos + 4]; + const unsigned w = (unsigned(data[pos + 5]) << 8) | data[pos + 6]; + const unsigned components = data[pos + 7]; + if (w != unsigned(width) || h != unsigned(height) || + (components != 1 && components != 3) || length != 8 + 3 * components) return false; + sof = true; + } else if (marker >= 0xc0 && marker <= 0xcf && marker != 0xc4 && marker != 0xcc) { + return false; // Progressive, lossless, extended or differential SOF. + } else if (marker == 0xda) { + return sof && length >= 6 && size - pos > length; + } + pos += length; + } + return false; +} diff --git a/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf.cpp b/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf.cpp new file mode 100644 index 00000000..74c13621 --- /dev/null +++ b/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf.cpp @@ -0,0 +1,329 @@ +#include "rkmpp_dmabuf_ffi.h" +#include +#include +#include +#include +#include "rkmpp_dma_jpeg.h" + +// Native MPP is already linked by the ARM FFmpeg/RKMPP build. Keep this +// optional for toolchains which only supply the FFmpeg headers. +#if defined(__linux__) && __has_include() +#define HAVE_MPP_DMA 1 +#include +#include +#include +extern "C" { +#include +#include +#include +#include +#include +#include +#include +} +#endif + +static thread_local char dma_error[192] = {}; +static int fail(const char *operation, int code) { + std::snprintf(dma_error, sizeof(dma_error), "%s (ret=%d)", operation, code); + return -1; +} + +#ifdef HAVE_MPP_DMA +struct RkmppDmaEncoder { + MppCtx ctx = nullptr; + MppApi *api = nullptr; + MppEncCfg cfg = nullptr; + MppPacket packet = nullptr; + std::array buffers{}; + std::array capacities{}; + size_t count = 0; + size_t minimum = 0; + int width = 0, height = 0, stride = 0; + MppFrameFormat format = MPP_FMT_YUV420SP; + bool jpeg = false; + MppCtx decoder = nullptr; + MppApi *dec_api = nullptr; + MppBufferGroup decoded_group = nullptr; + MppBuffer decoded_buffer = nullptr; + MppFrame decoded_frame = nullptr; + MppPacket input_packet = nullptr; + bool decoded_layout_set = false; + int decoded_stride = 0, decoded_vstride = 0; + + void close() { + if (packet) mpp_packet_deinit(&packet); + if (ctx) { + // A timeout must not expose still-in-use input to V4L2 QBUF. + api->reset(ctx); + mpp_destroy(ctx); + ctx = nullptr; + } + // On any decoder failure, end hardware access before releasing the + // input packet, exported buffers, or allowing the caller's QBUF. + if (decoder) { + dec_api->reset(decoder); + mpp_destroy(decoder); + decoder = nullptr; + } + if (input_packet) mpp_packet_deinit(&input_packet); + if (decoded_frame) mpp_frame_deinit(&decoded_frame); + if (decoded_buffer) { mpp_buffer_put(decoded_buffer); decoded_buffer = nullptr; } + if (decoded_group) { mpp_buffer_group_put(decoded_group); decoded_group = nullptr; } + for (auto &buffer : buffers) { + if (buffer) { mpp_buffer_put(buffer); buffer = nullptr; } + } + if (cfg) { mpp_enc_cfg_deinit(cfg); cfg = nullptr; } + } + ~RkmppDmaEncoder() { close(); } +}; + +static bool set_cfg(RkmppDmaEncoder *e, const char *key, int value) { + int ret = mpp_enc_cfg_set_s32(e->cfg, key, value); + if (ret) fail(key, ret); + return ret == 0; +} + +extern "C" int rkmpp_dma_reconfigure(RkmppDmaEncoder *e, int kbps, int gop) { + if (!e || !e->ctx || kbps <= 0 || kbps > 1000000 || gop <= 0) + return fail("invalid DMA encoder configuration", -1); + const int bps = kbps * 1000; + if (!set_cfg(e, "rc:bps_target", bps) || + !set_cfg(e, "rc:bps_max", bps + bps / 16) || + !set_cfg(e, "rc:bps_min", bps - bps / 16) || + !set_cfg(e, "rc:gop", gop)) return -1; + int ret = e->api->control(e->ctx, MPP_ENC_SET_CFG, e->cfg); + return ret ? fail("MPP_ENC_SET_CFG", ret) : 0; +} + +extern "C" RkmppDmaEncoder *rkmpp_dma_new( + int width, int height, int stride, int format, int codec, int fps, + int kbps, int gop, const int *fds, const size_t *sizes, size_t count) { + if (width <= 0 || height <= 0 || width > 8192 || height > 8192 || + (width & 1) || (height & 1) || format < 0 || format > 4 || + codec < 0 || codec > 1 || fps <= 0 || fps > 240 || + (format != 4 && stride < width * (format == 1 || format == 3 ? 3 : format == 2 ? 2 : 1)) || + (format == 2 && stride % 16 != 0) || !fds || !sizes || !count || count > 16) { + fail("invalid DMA frame layout", -1); return nullptr; + } + auto *e = new (std::nothrow) RkmppDmaEncoder; + if (!e) { fail("allocate DMA encoder", -1); return nullptr; } + e->width = width; e->height = height; e->stride = stride; e->count = count; + e->jpeg = format == 4; + if (e->jpeg) e->stride = stride = (width + 15) & ~15; + const int vstride = e->jpeg ? (height + 15) & ~15 : height; + e->format = format == 3 ? MPP_FMT_RGB888 : format == 2 ? MPP_FMT_YUV422_YUYV : format == 1 ? MPP_FMT_BGR888 : MPP_FMT_YUV420SP; + auto abort_init = [e](const char *op, int ret) -> RkmppDmaEncoder * { + fail(op, ret); delete e; return nullptr; + }; + int ret = mpp_create(&e->ctx, &e->api); + if (ret) return abort_init("mpp_create", ret); + RK_S64 timeout = 2000; + ret = e->api->control(e->ctx, MPP_SET_OUTPUT_TIMEOUT, &timeout); + if (ret) return abort_init("MPP_SET_OUTPUT_TIMEOUT", ret); + ret = e->api->control(e->ctx, MPP_SET_INPUT_TIMEOUT, &timeout); + if (ret) return abort_init("MPP_SET_INPUT_TIMEOUT", ret); + ret = mpp_init(e->ctx, MPP_CTX_ENC, codec ? MPP_VIDEO_CodingHEVC : MPP_VIDEO_CodingAVC); + if (ret) return abort_init("mpp_init", ret); + ret = mpp_enc_cfg_init(&e->cfg); + if (ret) return abort_init("mpp_enc_cfg_init", ret); + ret = e->api->control(e->ctx, MPP_ENC_GET_CFG, e->cfg); + if (ret) return abort_init("MPP_ENC_GET_CFG", ret); + if (!set_cfg(e, "prep:width", width) || !set_cfg(e, "prep:height", height) || + !set_cfg(e, "prep:hor_stride", stride) || !set_cfg(e, "prep:ver_stride", vstride) || + !set_cfg(e, "prep:format", e->format) || !set_cfg(e, "rc:mode", MPP_ENC_RC_MODE_CBR) || + !set_cfg(e, "rc:fps_in_flex", 0) || !set_cfg(e, "rc:fps_in_num", fps) || + !set_cfg(e, "rc:fps_in_denorm", 1) || !set_cfg(e, "rc:fps_out_flex", 0) || + !set_cfg(e, "rc:fps_out_num", fps) || !set_cfg(e, "rc:fps_out_denorm", 1) || + !set_cfg(e, "codec:type", codec ? MPP_VIDEO_CodingHEVC : MPP_VIDEO_CodingAVC)) { + delete e; return nullptr; + } + // Match the browser-friendly baseline profile used by the existing RKMPP + // byte encoder, rather than inheriting MPP's High-profile default. + const int level = int64_t(width) * height * fps <= int64_t(1920) * 1080 * 60 ? 42 : 52; + if (!codec && (!set_cfg(e, "h264:profile", 66) || !set_cfg(e, "h264:level", level) || + !set_cfg(e, "h264:cabac_en", 0) || !set_cfg(e, "h264:trans8x8", 0))) { + delete e; return nullptr; + } + if (rkmpp_dma_reconfigure(e, kbps, gop)) { delete e; return nullptr; } + MppEncHeaderMode mode = MPP_ENC_HEADER_MODE_EACH_IDR; + ret = e->api->control(e->ctx, MPP_ENC_SET_HEADER_MODE, &mode); + if (ret) return abort_init("MPP_ENC_SET_HEADER_MODE", ret); + // Reject arithmetic overflow even on 32-bit ARM; stride is supplied by a driver. + if (size_t(stride) > std::numeric_limits::max() / size_t(height)) + return abort_init("DMA buffer size overflow", -1); + size_t minimum = size_t(stride) * height; + if (format == 0) { + if (minimum > std::numeric_limits::max() / 3) + return abort_init("DMA buffer size overflow", -1); + minimum = minimum * 3 / 2; + } + if (e->jpeg) minimum = 68; // SOI/payload plus bounded hardware read headroom. + e->minimum = minimum; + for (size_t i = 0; i < count; ++i) { + if (fds[i] < 0 || sizes[i] < minimum) return abort_init("short DMA buffer", -1); + MppBufferInfo info{}; + info.type = MPP_BUFFER_TYPE_EXT_DMA; info.fd = fds[i]; + info.size = sizes[i]; info.index = static_cast(i); + ret = mpp_buffer_import(&e->buffers[i], &info); + if (ret) return abort_init("mpp_buffer_import", ret); + e->capacities[i] = sizes[i]; + } + if (e->jpeg) { + ret = mpp_create(&e->decoder, &e->dec_api); + if (ret) return abort_init("mpp_create JPEG decoder", ret); + ret = mpp_init(e->decoder, MPP_CTX_DEC, MPP_VIDEO_CodingMJPEG); + if (ret) return abort_init("mpp_init JPEG decoder", ret); + MppFrameFormat output = MPP_FMT_YUV420SP; + ret = e->dec_api->control(e->decoder, MPP_DEC_SET_OUTPUT_FORMAT, &output); + if (ret) return abort_init("JPEG NV12 output", ret); + ret = mpp_buffer_group_get_internal(&e->decoded_group, MPP_BUFFER_TYPE_DRM); + if (ret) return abort_init("JPEG output buffer group", ret); + // MPP JPEG requires aligned storage; reserve the conservative size used + // by its advanced-task decoder demo. One output reused after encode. + ret = mpp_buffer_get(e->decoded_group, &e->decoded_buffer, size_t(stride) * vstride * 4); + if (ret) return abort_init("JPEG output buffer", ret); + ret = mpp_frame_init(&e->decoded_frame); + if (ret) return abort_init("JPEG output frame", ret); + mpp_frame_set_buffer(e->decoded_frame, e->decoded_buffer); + } + return e; +} + +static int dma_read_sync(MppBuffer buffer, bool start) { + dma_buf_sync sync{}; + sync.flags = DMA_BUF_SYNC_READ | (start ? DMA_BUF_SYNC_START : DMA_BUF_SYNC_END); + int ret; + do { ret = ioctl(mpp_buffer_get_fd(buffer), DMA_BUF_IOCTL_SYNC, &sync); } + while (ret < 0 && errno == EINTR); + return ret; +} + +static int decode_jpeg(RkmppDmaEncoder *e, size_t index, size_t bytes_used) { + MppBuffer input = e->buffers[index]; + // The parser reads only header bytes with explicit DMA CPU-read ownership. + if (dma_read_sync(input, true)) return fail("JPEG DMA read sync start", errno); + const auto *data = static_cast(mpp_buffer_get_ptr(input)); + const bool valid = rkmpp_dma_jpeg_header(data, bytes_used, e->width, e->height); + if (dma_read_sync(input, false)) return fail("JPEG DMA read sync end", errno); + if (!valid) return fail("unsupported/mismatched JPEG header", -1); + + int ret = mpp_packet_init_with_buffer(&e->input_packet, input); + if (ret) return fail("JPEG input packet", ret); + mpp_packet_set_length(e->input_packet, bytes_used); + MppTask task = nullptr; + ret = e->dec_api->poll(e->decoder, MPP_PORT_INPUT, static_cast(2000)); + if (ret) return fail("JPEG input poll", ret); + ret = e->dec_api->dequeue(e->decoder, MPP_PORT_INPUT, &task); + if (ret || !task) return fail("JPEG input task", ret); + ret = mpp_task_meta_set_packet(task, KEY_INPUT_PACKET, e->input_packet); + if (ret) return fail("JPEG input metadata", ret); + ret = mpp_task_meta_set_frame(task, KEY_OUTPUT_FRAME, e->decoded_frame); + if (ret) return fail("JPEG output metadata", ret); + ret = e->dec_api->enqueue(e->decoder, MPP_PORT_INPUT, task); + if (ret) return fail("JPEG submit", ret); + task = nullptr; + ret = e->dec_api->poll(e->decoder, MPP_PORT_OUTPUT, static_cast(2000)); + if (ret) return fail("JPEG output poll", ret); + ret = e->dec_api->dequeue(e->decoder, MPP_PORT_OUTPUT, &task); + if (ret || !task) return fail("JPEG output task", ret); + MppFrame result = nullptr; + ret = mpp_task_meta_get_frame(task, KEY_OUTPUT_FRAME, &result); + if (ret || result != e->decoded_frame) return fail("JPEG output frame mismatch", ret); + ret = e->dec_api->enqueue(e->decoder, MPP_PORT_OUTPUT, task); + if (ret) return fail("JPEG return output task", ret); + ret = e->dec_api->poll(e->decoder, MPP_PORT_INPUT, static_cast(2000)); + if (ret) return fail("JPEG input completion", ret); + mpp_packet_deinit(&e->input_packet); + if (mpp_frame_get_errinfo(result) || mpp_frame_get_discard(result) || + mpp_frame_get_info_change(result) || + mpp_frame_get_width(result) != unsigned(e->width) || + mpp_frame_get_height(result) != unsigned(e->height) || + mpp_frame_get_fmt(result) != MPP_FMT_YUV420SP || + mpp_frame_get_buffer(result) != e->decoded_buffer) { + std::snprintf(dma_error, sizeof(dma_error), + "invalid JPEG decoded frame: err=%u discard=%u info_change=%u size=%ux%u fmt=%x buffer_match=%d", + mpp_frame_get_errinfo(result), mpp_frame_get_discard(result), mpp_frame_get_info_change(result), + mpp_frame_get_width(result), mpp_frame_get_height(result), unsigned(mpp_frame_get_fmt(result)), + int(mpp_frame_get_buffer(result) == e->decoded_buffer)); + return -1; + } + const int hs = mpp_frame_get_hor_stride(result), vs = mpp_frame_get_ver_stride(result); + if (hs < e->width || vs < e->height || hs > 8192 || vs > 8192 || (hs & 15) || (vs & 15) || + size_t(hs) * vs * 3 / 2 > mpp_buffer_get_size(e->decoded_buffer)) + return fail("invalid JPEG decoded stride", -1); + if (!e->decoded_layout_set) { + if (!set_cfg(e, "prep:hor_stride", hs) || !set_cfg(e, "prep:ver_stride", vs)) return -1; + ret = e->api->control(e->ctx, MPP_ENC_SET_CFG, e->cfg); + if (ret) return fail("JPEG encoder layout", ret); + e->decoded_stride = hs; e->decoded_vstride = vs; e->decoded_layout_set = true; + } else if (hs != e->decoded_stride || vs != e->decoded_vstride) { + return fail("JPEG decoded layout changed", -1); + } + return 0; +} + +extern "C" int rkmpp_dma_encode(RkmppDmaEncoder *e, size_t index, size_t bytes_used, int fresh_fd, int64_t pts_us, + int force_idr, const uint8_t **data, size_t *size) { + if (!e || !e->ctx || index >= e->count || !data || !size) + return fail("invalid DMA encode call", -1); + *data = nullptr; *size = 0; + if (e->packet) mpp_packet_deinit(&e->packet); + auto abort_encode = [e](const char *op, int ret) { + fail(op, ret); e->close(); return -1; + }; + if (bytes_used > e->capacities[index] || + (e->jpeg ? (bytes_used < 4 || e->capacities[index] - bytes_used < 64) : bytes_used != e->minimum)) + return abort_encode("invalid DMA payload length", -1); + if (fresh_fd >= 0) { + MppBuffer replacement = nullptr; + MppBufferInfo info{}; + info.type = MPP_BUFFER_TYPE_EXT_DMA; info.fd = fresh_fd; + info.size = e->capacities[index]; info.index = static_cast(index); + const int ret = mpp_buffer_import(&replacement, &info); + if (ret) return abort_encode("refresh USB DMA import", ret); + if (e->buffers[index]) mpp_buffer_put(e->buffers[index]); + e->buffers[index] = replacement; + } + if (e->jpeg && decode_jpeg(e, index, bytes_used)) { + // Preserve the detailed decoder failure while ending BOTH engines. + e->close(); return -1; + } + if (force_idr) { + int ret = e->api->control(e->ctx, MPP_ENC_SET_IDR_FRAME, nullptr); + if (ret) return abort_encode("MPP_ENC_SET_IDR_FRAME", ret); + } + MppFrame frame = e->jpeg ? e->decoded_frame : nullptr; + int ret = 0; + if (!e->jpeg) { + ret = mpp_frame_init(&frame); + if (ret) return abort_encode("mpp_frame_init", ret); + mpp_frame_set_width(frame, e->width); mpp_frame_set_height(frame, e->height); + mpp_frame_set_hor_stride(frame, e->stride); mpp_frame_set_ver_stride(frame, e->height); + mpp_frame_set_fmt(frame, e->format); + mpp_frame_set_buffer(frame, e->buffers[index]); + } + mpp_frame_set_pts(frame, pts_us); + ret = e->api->encode_put_frame(e->ctx, frame); + if (!e->jpeg) mpp_frame_deinit(&frame); + if (ret) return abort_encode("encode_put_frame", ret); + ret = e->api->encode_get_packet(e->ctx, &e->packet); + if (ret || !e->packet) return abort_encode("encode_get_packet", ret); + if (mpp_packet_is_partition(e->packet) || !mpp_packet_get_length(e->packet)) + return abort_encode("incomplete DMA encoder output", -1); + // One synchronous input, no temporal scalability/reordering/split output. + // A completed packet is the input-consumption barrier for this mode. + *data = static_cast(mpp_packet_get_pos(e->packet)); + *size = mpp_packet_get_length(e->packet); + return 0; +} +extern "C" void rkmpp_dma_free(RkmppDmaEncoder *e) { delete e; } +#else +extern "C" RkmppDmaEncoder *rkmpp_dma_new(int,int,int,int,int,int,int,int,const int*,const size_t*,size_t) { + fail("RKMPP DMA support not built", -1); return nullptr; +} +extern "C" int rkmpp_dma_encode(RkmppDmaEncoder*,size_t,size_t,int,int64_t,int,const uint8_t**,size_t*) { return -1; } +extern "C" int rkmpp_dma_reconfigure(RkmppDmaEncoder*,int,int) { return -1; } +extern "C" void rkmpp_dma_free(RkmppDmaEncoder*) {} +#endif +extern "C" const char *rkmpp_dma_error(void) { return dma_error; } diff --git a/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf_ffi.h b/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf_ffi.h new file mode 100644 index 00000000..a72ef612 --- /dev/null +++ b/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf_ffi.h @@ -0,0 +1,31 @@ +#pragma once +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct RkmppDmaEncoder RkmppDmaEncoder; +// format: 0=NV12, 1=BGR24, 2=YUYV, 3=RGB24 (byte stride), 4=MJPEG (stride ignored). +// codec: 0=H264, 1=HEVC. MJPEG is decoded to hardware NV12, then encoded. +RkmppDmaEncoder *rkmpp_dma_new(int width, int height, int stride, int format, + int codec, int fps, int kbps, int gop, + const int *fds, const size_t *sizes, size_t count); +// Synchronous input-completion boundary. On failure the encoder is destroyed +// internally BEFORE returning, so it cannot keep reading the capture buffer. +// Output is borrowed until the next call or destruction; copy before reusing it. +// bytes_used must be the actual captured payload. MJPEG needs 64 bytes of +// readable allocation headroom; never pass sizeimage as the compressed length. +// fresh_fd=-1 reuses the original import (native HDMI). UVC supplies a new +// export of this dequeued slot. Keep it open until replacement/free; the old +// export can be closed AFTER this call, including on failure. +int rkmpp_dma_encode(RkmppDmaEncoder *encoder, size_t index, size_t bytes_used, int fresh_fd, int64_t pts_us, + int force_idr, const uint8_t **data, size_t *size); +int rkmpp_dma_reconfigure(RkmppDmaEncoder *encoder, int kbps, int gop); +void rkmpp_dma_free(RkmppDmaEncoder *encoder); +const char *rkmpp_dma_error(void); + +#ifdef __cplusplus +} +#endif diff --git a/libs/hwcodec/src/lib.rs b/libs/hwcodec/src/lib.rs index 9a57fa75..b1e01c37 100644 --- a/libs/hwcodec/src/lib.rs +++ b/libs/hwcodec/src/lib.rs @@ -5,6 +5,11 @@ pub mod ffmpeg; #[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))] pub mod ffmpeg_hw; pub mod ffmpeg_ram; +#[cfg(all( + target_os = "linux", + any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp") +))] +pub mod rkmpp_dmabuf; #[no_mangle] pub extern "C" fn hwcodec_log(level: i32, message: *const std::os::raw::c_char) { diff --git a/libs/hwcodec/src/rkmpp_dmabuf.rs b/libs/hwcodec/src/rkmpp_dmabuf.rs new file mode 100644 index 00000000..58a02394 --- /dev/null +++ b/libs/hwcodec/src/rkmpp_dmabuf.rs @@ -0,0 +1,193 @@ +//! Synchronous RKMPP encoder for pre-exported V4L2 DMA buffers. +//! Unlike the byte-slice encoder this never reads raw pixels on the CPU. + +use std::ffi::{c_char, c_int, c_void, CStr}; +use std::os::fd::{AsRawFd, OwnedFd}; +use std::ptr::NonNull; + +unsafe extern "C" { + fn rkmpp_dma_new( + width: c_int, + height: c_int, + stride: c_int, + format: c_int, + codec: c_int, + fps: c_int, + kbps: c_int, + gop: c_int, + fds: *const c_int, + sizes: *const usize, + count: usize, + ) -> *mut c_void; + fn rkmpp_dma_encode( + encoder: *mut c_void, + index: usize, + bytes_used: usize, + fresh_fd: c_int, + pts_us: i64, + force_idr: c_int, + data: *mut *const u8, + size: *mut usize, + ) -> c_int; + fn rkmpp_dma_reconfigure(encoder: *mut c_void, kbps: c_int, gop: c_int) -> c_int; + fn rkmpp_dma_free(encoder: *mut c_void); + fn rkmpp_dma_error() -> *const c_char; +} + +#[derive(Debug, Clone, Copy)] +#[repr(i32)] +pub enum DmaFormat { + Nv12 = 0, + Bgr24 = 1, + Yuyv = 2, + Rgb24 = 3, + Mjpeg = 4, +} + +pub struct DmaEncoderConfig { + pub width: u32, + pub height: u32, + pub stride: u32, + pub format: DmaFormat, + pub hevc: bool, + pub fps: u32, + pub bitrate_kbps: u32, + pub gop: u32, +} + +pub struct DmaEncoder { + ctx: NonNull, + // Export FDs remain open until AFTER mpp_destroy and imported buffer release. + _buffers: Vec<(OwnedFd, usize)>, + // Keep refreshed exports alive until native replacement/release has ended + // all references to the previous import. At most one FD per capture slot. + fresh_buffers: Vec>, +} + +// Exclusive ownership: the context can move between threads but all calls are sequential. +unsafe impl Send for DmaEncoder {} + +fn last_error() -> String { + unsafe { + CStr::from_ptr(rkmpp_dma_error()) + .to_string_lossy() + .into_owned() + } +} + +impl DmaEncoder { + pub fn new(config: DmaEncoderConfig, buffers: Vec<(OwnedFd, usize)>) -> Result { + let fds: Vec<_> = buffers.iter().map(|(fd, _)| fd.as_raw_fd()).collect(); + let sizes: Vec<_> = buffers.iter().map(|(_, size)| *size).collect(); + for value in [ + config.width, + config.height, + config.stride, + config.fps, + config.bitrate_kbps, + config.gop, + ] { + if value > c_int::MAX as u32 { + return Err("DMA encoder parameter overflow".into()); + } + } + let ptr = unsafe { + rkmpp_dma_new( + config.width as _, + config.height as _, + config.stride as _, + config.format as c_int, + config.hevc as _, + config.fps as _, + config.bitrate_kbps as _, + config.gop as _, + fds.as_ptr(), + sizes.as_ptr(), + buffers.len(), + ) + }; + Ok(Self { + ctx: NonNull::new(ptr).ok_or_else(last_error)?, + fresh_buffers: (0..buffers.len()).map(|_| None).collect(), + _buffers: buffers, + }) + } + + /// # Safety + /// The indexed buffer must be dequeued and exclusively leased to this call. + /// Do not requeue/write it until this function returns. On failure native MPP + /// is synchronously destroyed before returning, ending all input access. + /// `bytes_used` is the actual DQBUF payload length, not the buffer capacity. + /// A refreshed FD, if supplied, must refer to the same leased capture slot + /// with the capacity registered at construction. Ownership is retained here. + pub unsafe fn encode( + &mut self, + index: usize, + bytes_used: usize, + fresh_fd: Option, + pts_ms: i64, + force_idr: bool, + ) -> Result, String> { + let mut data = std::ptr::null(); + let mut size = 0; + if index >= self.fresh_buffers.len() { + return Err("Invalid DMA capture index".into()); + } + let ret = unsafe { + rkmpp_dma_encode( + self.ctx.as_ptr(), + index, + bytes_used, + fresh_fd.as_ref().map_or(-1, AsRawFd::as_raw_fd), + pts_ms.saturating_mul(1000), + force_idr as _, + &mut data, + &mut size, + ) + }; + if fresh_fd.is_some() { + // Native has now released the previous import, or destroyed both + // hardware contexts on error. Only now may its old FD be closed. + self.fresh_buffers[index] = fresh_fd; + } + if ret != 0 { + return Err(last_error()); + } + if data.is_null() || size == 0 { + return Err("Empty RKMPP DMA packet".into()); + } + // Copy only the compressed output, releasing the driver's packet promptly + // regardless of how long a network subscriber retains its Bytes. + Ok(unsafe { std::slice::from_raw_parts(data, size) }.to_vec()) + } + + pub fn reconfigure(&mut self, kbps: u32, gop: u32) -> Result<(), String> { + if kbps > c_int::MAX as u32 || gop > c_int::MAX as u32 { + return Err("DMA encoder parameter overflow".into()); + } + if unsafe { rkmpp_dma_reconfigure(self.ctx.as_ptr(), kbps as _, gop as _) } != 0 { + return Err(last_error()); + } + Ok(()) + } +} + +impl Drop for DmaEncoder { + fn drop(&mut self) { + unsafe { rkmpp_dma_free(self.ctx.as_ptr()) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_discriminants_match_native_abi() { + assert_eq!(DmaFormat::Nv12 as c_int, 0); + assert_eq!(DmaFormat::Bgr24 as c_int, 1); + assert_eq!(DmaFormat::Yuyv as c_int, 2); + assert_eq!(DmaFormat::Rgb24 as c_int, 3); + assert_eq!(DmaFormat::Mjpeg as c_int, 4); + } +} diff --git a/src/video/capture/dmabuf_layout.rs b/src/video/capture/dmabuf_layout.rs new file mode 100644 index 00000000..2a2caceb --- /dev/null +++ b/src/video/capture/dmabuf_layout.rs @@ -0,0 +1,201 @@ +//! Conservative, dependency-free eligibility checks for linear RKMPP input. + +pub struct DmaCaptureLayout<'a> { + pub native_hdmi: bool, + pub driver: &'a str, + pub bus_info: &'a str, + pub configurable_usb: bool, + pub single_planar: bool, + pub fourcc: [u8; 4], + pub width: u32, + pub height: u32, + pub stride: u32, +} + +impl DmaCaptureLayout<'_> { + /// Minimum readable bytes, not the driver's page-aligned allocation size. + pub fn minimum_bytes(&self) -> Option { + if self.width == 0 + || self.height == 0 + || self.width > 8192 + || self.height > 8192 + || self.width % 2 != 0 + || self.height % 2 != 0 + { + return None; + } + let bytes_per_row = if self.native_hdmi { + match &self.fourcc { + b"NV12" => self.width, + b"BGR3" => self.width.checked_mul(3)?, + _ => return None, + } + } else if self.configurable_usb + && self.single_planar + && self.driver == "uvcvideo" + && self.bus_info.starts_with("usb-") + { + match &self.fourcc { + // Compressed frames have variable bytesused and no byte stride. + b"MJPG" => return Some(4), + b"YUYV" if self.stride % 16 == 0 => self.width.checked_mul(2)?, + b"NV12" if self.stride % 16 == 0 => self.width, + b"RGB3" if self.stride % 16 == 0 => self.width.checked_mul(3)?, + _ => return None, + } + } else { + return None; + }; + if self.stride < bytes_per_row { + return None; + } + let size = (self.stride as usize).checked_mul(self.height as usize)?; + if self.fourcc == *b"NV12" { + size.checked_mul(3)?.checked_div(2) + } else { + Some(size) + } + } +} + +/// An MJPEG DMA packet has a bounded payload, not stride * height bytes. +/// Reserve readable headroom for the MPP bitstream reader without modifying +/// capture memory. The decoder receives only `used`, never the allocation size. +pub fn valid_payload( + compressed: bool, + used: usize, + capacity: usize, + expected: Option, +) -> bool { + if compressed { + used >= 4 && used.checked_add(64).is_some_and(|end| end <= capacity) + } else { + used > 0 && Some(used) == expected && used <= capacity + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn usb() -> DmaCaptureLayout<'static> { + DmaCaptureLayout { + native_hdmi: false, + driver: "uvcvideo", + bus_info: "usb-fc880000.usb-1.1", + configurable_usb: true, + single_planar: true, + fourcc: *b"YUYV", + width: 1920, + height: 1080, + stride: 3840, + } + } + + #[test] + fn usb_yuyv_uses_byte_stride_and_supports_padding() { + let mut layout = usb(); + assert_eq!(layout.minimum_bytes(), Some(4_147_200)); + layout.width = 640; + layout.height = 480; + layout.stride = 1280; + assert_eq!(layout.minimum_bytes(), Some(614_400)); + layout.stride = 1296; + assert_eq!(layout.minimum_bytes(), Some(622_080)); + } + + #[test] + fn usb_requires_correct_driver_bus_queue_and_control_mode() { + let mut layout = usb(); + layout.driver = "rkcif"; + assert_eq!(layout.minimum_bytes(), None); + layout = usb(); + layout.bus_info = "platform:hdmi"; + assert_eq!(layout.minimum_bytes(), None); + layout = usb(); + layout.single_planar = false; + assert_eq!(layout.minimum_bytes(), None); + layout = usb(); + layout.configurable_usb = false; + assert_eq!(layout.minimum_bytes(), None); + } + + #[test] + fn unverified_usb_formats_stay_on_copy_path() { + for fourcc in [ + *b"H264", *b"NV21", *b"NV16", *b"NV24", *b"BGR3", *b"YU12", *b"UYVY", *b"YVYU", + *b"BAD!", + ] { + let mut layout = usb(); + layout.fourcc = fourcc; + assert_eq!(layout.minimum_bytes(), None, "{fourcc:?}"); + } + } + + #[test] + fn usb_nv12_rgb_and_mjpeg_layouts() { + let mut layout = usb(); + layout.fourcc = *b"NV12"; + layout.stride = 1920; + assert_eq!(layout.minimum_bytes(), Some(3_110_400)); + layout.fourcc = *b"RGB3"; + layout.stride = 5760; + assert_eq!(layout.minimum_bytes(), Some(6_220_800)); + layout.stride = 1920; + assert_eq!(layout.minimum_bytes(), None); + layout.fourcc = *b"MJPG"; + layout.stride = 0; + assert_eq!(layout.minimum_bytes(), Some(4)); + } + + #[test] + fn compressed_payload_is_bounded_and_not_allocation_size() { + assert!(valid_payload(true, 63163, 4147200, None)); + for used in [0, 3, 4147200, usize::MAX] { + assert!(!valid_payload(true, used, 4147200, None)); + } + assert!(valid_payload(true, 4, 68, None)); + assert!(!valid_payload(true, 4, 67, None)); + assert!(valid_payload(false, 614400, 614400, Some(614400))); + assert!(!valid_payload(false, 614399, 614400, Some(614400))); + assert!(!valid_payload(false, 614400, 614399, Some(614400))); + } + + #[test] + fn malformed_geometry_or_stride_is_rejected() { + for (w, h, stride) in [ + (0, 1080, 3840), + (1920, 0, 3840), + (1919, 1080, 3840), + (1920, 1079, 3840), + (8194, 1080, 16384), + (1920, 8194, 3840), + (1920, 1080, 0), + (1920, 1080, 1920), + (1920, 1080, 3841), + (u32::MAX, u32::MAX, u32::MAX), + ] { + let mut layout = usb(); + layout.width = w; + layout.height = h; + layout.stride = stride; + assert_eq!(layout.minimum_bytes(), None); + } + } + + #[test] + fn native_hdmi_formats_are_preserved_but_not_expanded() { + let mut layout = usb(); + layout.native_hdmi = true; + layout.single_planar = false; + layout.fourcc = *b"BGR3"; + layout.stride = 5760; + assert_eq!(layout.minimum_bytes(), Some(6_220_800)); + layout.fourcc = *b"NV12"; + layout.stride = 1920; + assert_eq!(layout.minimum_bytes(), Some(3_110_400)); + layout.fourcc = *b"YUYV"; + layout.stride = 3840; + assert_eq!(layout.minimum_bytes(), None); + } +} diff --git a/src/video/capture/linux.rs b/src/video/capture/linux.rs index 6ae0cc00..ad6e7d5b 100644 --- a/src/video/capture/linux.rs +++ b/src/video/capture/linux.rs @@ -3,6 +3,8 @@ use std::fs::File; use std::io; use std::os::fd::AsFd; +#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] +use std::os::fd::OwnedFd; use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -29,6 +31,10 @@ use crate::video::device::VideoControlMode; use crate::video::format::{PixelFormat, Resolution}; use crate::video::signal::SignalStatus; +#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))] +#[path = "dmabuf_layout.rs"] +mod dmabuf_layout; + /// Metadata for a captured frame. #[derive(Debug, Clone, Copy)] pub struct CaptureMeta { @@ -67,6 +73,8 @@ pub struct CaptureStream { bridge_kind: Option, native_hdmirx_state: Option, native_hdmirx_next_state_check: Option, + #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + dma_layout_bytes: Option, } fn open_capture_device(path: &Path) -> io::Result { @@ -319,6 +327,24 @@ impl CaptureStream { mappings.push(plane_maps); } + #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + let dma_layout_bytes = PixelFormat::from_v4l2r(actual_fmt.pixelformat).and_then(|format| { + dmabuf_layout::DmaCaptureLayout { + native_hdmi: is_native_hdmirx, + driver: &caps.driver, + bus_info: &caps.bus_info, + configurable_usb: !is_source_following + && bridge.kind.is_none() + && !bridge.has_subdev(), + single_planar: queue == QueueType::VideoCapture, + fourcc: format.to_fourcc(), + width: actual_resolution.width, + height: actual_resolution.height, + stride, + } + .minimum_bytes() + }); + let mut stream = Self { fd, queue, @@ -332,6 +358,8 @@ impl CaptureStream { bridge_kind: bridge.kind, native_hdmirx_state, native_hdmirx_next_state_check, + #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + dma_layout_bytes, }; stream.queue_all_buffers()?; @@ -421,10 +449,7 @@ impl CaptureStream { } } - pub fn next_into( - &mut self, - dst: &mut Vec, - ) -> std::result::Result { + fn dequeue_buffer(&mut self) -> std::result::Result { self.wait_ready()?; // Several vendor BSPs update G_FMT/DV timings without making the @@ -455,6 +480,143 @@ impl CaptureStream { }; CaptureReadError::Io(error) })?; + Ok(dqbuf) + } + + /// Native HDMI NV12/BGR24 and single-planar USB UVC YUYV/NV12/RGB24/MJPEG. + /// Actual EXPBUF/import support is probed separately; failure retains copy. + #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + pub(crate) fn supports_rkmpp_dmabuf(&self) -> bool { + self.dma_layout_bytes.is_some_and(|minimum| { + (2..=16).contains(&self.mappings.len()) + && self + .mappings + .iter() + .all(|planes| planes.len() == 1 && planes[0].len() >= minimum) + }) + } + + #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + pub(crate) fn export_dmabufs(&self) -> io::Result> { + if !self.supports_rkmpp_dmabuf() { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Unsupported RKMPP DMA capture layout", + )); + } + self.mappings + .iter() + .enumerate() + .map(|(index, planes)| { + let fd = ioctl::expbuf(&self.fd, self.queue, index, 0, ioctl::ExpbufFlags::CLOEXEC) + .map_err(|error| io::Error::other(error.to_string()))?; + Ok((fd, planes[0].len())) + }) + .collect() + } + + /// Run a synchronous consumer while a buffer is dequeued. QBUF occurs only + /// after the callback returns, including its error path. Consumers must end + /// hardware access before returning; see hwcodec::rkmpp_dmabuf::encode. + #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + pub(crate) fn with_next_dmabuf( + &mut self, + consume: impl FnOnce(usize, usize, Option) -> T, + ) -> std::result::Result<(CaptureMeta, T), CaptureReadError> { + let buffer = self.dequeue_buffer()?; + let index = buffer.as_v4l2_buffer().index as usize; + let sequence = buffer.as_v4l2_buffer().sequence as u64; + if index >= self.mappings.len() { + return Err( + io::Error::new(io::ErrorKind::InvalidData, "Invalid capture buffer index").into(), + ); + } + let expected = self.expected_capture_bytes(); + let mapped_size = self.mappings[index][0].len(); + let native_hdmi = self.native_hdmirx_state.is_some(); + let compressed = self.format == PixelFormat::Mjpeg; + let lease = BufferReturn(Some(|| { + self.queue_buffer(index as u32) + .map_err(|e| io::Error::other(e.to_string())) + })); + if buffer.as_v4l2_buffer().flags & v4l2r::bindings::V4L2_BUF_FLAG_ERROR != 0 { + // A corrupt UVC frame is not a source change or a DMA failure. + // Return it without ever letting the encoder read its payload. + lease.finish()?; + return Err(io::Error::from(io::ErrorKind::WouldBlock).into()); + } + if !native_hdmi + && buffer.as_v4l2_buffer().field != v4l2r::bindings::v4l2_field_V4L2_FIELD_NONE + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Interlaced USB DMA frames are not supported", + ) + .into()); + } + let mut planes = buffer.planes_iter(); + let plane = planes + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Missing DMA plane"))?; + if planes.next().is_some() || plane.data_offset.copied().unwrap_or(0) != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Unsupported DMA plane offset/layout", + ) + .into()); + } + let bytes_used = *plane.bytesused as usize; + if !dmabuf_layout::valid_payload(compressed, bytes_used, mapped_size, expected) { + if !native_hdmi { + // An unexpected UVC payload is not evidence of a source mode + // change. Disable DMA instead of reopening it indefinitely. + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Unexpected USB DMA payload length", + ) + .into()); + } + return Err(CaptureReadError::SourceChanged); + } + // UVC commonly fills vmalloc memory on the CPU. Older BSP exporters + // cache DMA attachments without usable per-frame CPU-access sync hooks. + // A fresh export object forces a fresh device mapping of this completed + // frame. Reuse the actual capture allocation, not a stale attachment. + let fresh_fd = if !native_hdmi { + Some( + ioctl::expbuf( + &self.fd, + self.queue, + index, + 0, + ioctl::ExpbufFlags::CLOEXEC | ioctl::ExpbufFlags::RDWR, + ) + .map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("USB DMA re-export failed: {error}"), + ) + })?, + ) + } else { + None + }; + let output = consume(index, bytes_used, fresh_fd); + lease.finish()?; + Ok(( + CaptureMeta { + bytes_used, + sequence, + }, + output, + )) + } + + pub fn next_into( + &mut self, + dst: &mut Vec, + ) -> std::result::Result { + let dqbuf = self.dequeue_buffer()?; let index = dqbuf.as_v4l2_buffer().index as usize; let sequence = dqbuf.as_v4l2_buffer().sequence as u64; @@ -664,7 +826,7 @@ impl CaptureStream { Ok(()) } - fn queue_buffer(&mut self, index: u32) -> Result<()> { + fn queue_buffer(&self, index: u32) -> Result<()> { let handle = MmapHandle; let planes = self.mappings[index as usize] .iter() @@ -682,6 +844,64 @@ impl CaptureStream { } } +#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))] +struct BufferReturn io::Result<()>>(Option); + +#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))] +impl io::Result<()>> BufferReturn { + fn finish(mut self) -> io::Result<()> { + self.0.take().expect("capture lease already returned")() + } +} + +#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))] +impl io::Result<()>> Drop for BufferReturn { + fn drop(&mut self) { + if let Some(return_buffer) = self.0.take() { + if let Err(error) = return_buffer() { + warn!("Failed to return leased capture buffer: {}", error); + } + } + } +} + +#[cfg(test)] +mod dma_lease_tests { + use super::*; + use std::cell::RefCell; + + #[test] + fn returns_buffer_once_after_consumer_and_does_not_retry_failed_qbuf() { + let operations = RefCell::new(Vec::new()); + let lease = BufferReturn(Some(|| { + operations.borrow_mut().push("qbuf"); + Err(io::Error::other("device lost")) + })); + operations.borrow_mut().push("encode completed"); + assert!(lease.finish().is_err()); + assert_eq!(*operations.borrow(), ["encode completed", "qbuf"]); + } + + #[test] + fn returns_buffer_on_validation_error_or_unwind() { + let returns = std::cell::Cell::new(0); + { + let _lease = BufferReturn(Some(|| { + returns.set(returns.get() + 1); + Ok(()) + })); + } + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _lease = BufferReturn(Some(|| { + returns.set(returns.get() + 1); + Ok(()) + })); + panic!("consumer panic"); + })); + assert_eq!(returns.get(), 2); + } +} + impl Drop for CaptureStream { fn drop(&mut self) { // Release ordering matters on rkcif: a subsequent open()/S_FMT from a diff --git a/src/video/capture/status.rs b/src/video/capture/status.rs index c6b91939..0238a8b2 100644 --- a/src/video/capture/status.rs +++ b/src/video/capture/status.rs @@ -2,8 +2,32 @@ use std::io; +#[cfg(any( + test, + all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")) +))] +use crate::video::device::VideoControlMode; use crate::video::signal::SignalStatus; +#[cfg(any( + test, + all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")) +))] +pub(crate) fn capture_recovery_status( + control_mode: VideoControlMode, + error: &io::Error, +) -> SignalStatus { + if control_mode == VideoControlMode::Configurable && error.kind() == io::ErrorKind::TimedOut { + return SignalStatus::UvcCaptureStall; + } + match classify_capture_io_error(error) { + CaptureIoErrorKind::TransientSignal { + status: Some(status), + } => status, + _ => SignalStatus::NoSignal, + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CaptureIoErrorKind { DeviceLost, @@ -52,6 +76,33 @@ pub fn capture_error_log_key(err: &io::Error) -> String { mod tests { use super::*; + #[test] + fn recovery_distinguishes_uvc_stalls_from_hdmi_signal_loss() { + let timeout = io::Error::from(io::ErrorKind::TimedOut); + assert_eq!( + capture_recovery_status(VideoControlMode::Configurable, &timeout), + SignalStatus::UvcCaptureStall + ); + assert_eq!( + capture_recovery_status(VideoControlMode::SourceFollowing, &timeout), + SignalStatus::NoSignal + ); + assert_eq!( + capture_recovery_status( + VideoControlMode::Configurable, + &io::Error::from_raw_os_error(71) + ), + SignalStatus::UvcUsbError + ); + assert_eq!( + capture_recovery_status( + VideoControlMode::SourceFollowing, + &io::Error::from_raw_os_error(5) + ), + SignalStatus::NoSignal + ); + } + #[test] fn maps_known_signal_status_strings() { assert_eq!( diff --git a/src/video/pipeline/dmabuf.rs b/src/video/pipeline/dmabuf.rs new file mode 100644 index 00000000..0fc9fba7 --- /dev/null +++ b/src/video/pipeline/dmabuf.rs @@ -0,0 +1,392 @@ +//! RKMPP-only capture/encode worker. Raw buffer ownership never crosses into a +//! latest-frame slot or a network subscriber. Other encoders use shared.rs. +use super::*; +use crate::video::capture::status::capture_recovery_status; +use crate::video::codec::registry::EncoderRegistry; +use hwcodec::rkmpp_dmabuf::{DmaEncoder, DmaEncoderConfig, DmaFormat}; + +pub(super) fn eligible(config: &SharedVideoPipelineConfig) -> bool { + if std::env::var("ONE_KVM_RKMPP_DMABUF").as_deref() == Ok("0") { + return false; + } + // The UVC per-frame mapping needed by older BSPs costs more than copying + // compressed packets in our current tests. Keep JPEG DMA opt-in; raw DMA + // remains automatic. The existing JPEG hardware transcode is the default. + if config.input_format == PixelFormat::Mjpeg + && std::env::var("ONE_KVM_RKMPP_MJPEG_DMABUF").as_deref() != Ok("1") + { + 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), + }; + rkmpp_dma_eligible( + selected.map(|e| e.backend), + config.output_codec, + config.input_format, + ) +} + +pub(super) fn prepare( + stream: &CaptureStream, + config: &SharedVideoPipelineConfig, +) -> Result { + let buffers = stream + .export_dmabufs() + .map_err(|e| AppError::VideoError(e.to_string()))?; + DmaEncoder::new( + DmaEncoderConfig { + width: config.resolution.width, + height: config.resolution.height, + stride: stream.stride(), + format: match stream.format() { + PixelFormat::Nv12 => DmaFormat::Nv12, + PixelFormat::Bgr24 => DmaFormat::Bgr24, + PixelFormat::Yuyv => DmaFormat::Yuyv, + PixelFormat::Rgb24 => DmaFormat::Rgb24, + PixelFormat::Mjpeg => DmaFormat::Mjpeg, + _ => return Err(AppError::VideoError("Unsupported DMA format".into())), + }, + hevc: config.output_codec == VideoEncoderType::H265, + fps: config.fps, + bitrate_kbps: config.bitrate_kbps(), + gop: config.gop_size().max(1), + }, + buffers, + ) + .map_err(AppError::VideoError) +} + +enum CaptureEncoder { + Dma(DmaEncoder), + Copy(Box), +} + +// Field order is intentional, including during unwinding: destroy the encoder +// and its imported FDs before STREAMOFF/unmap/REQBUFS(0). +struct ActiveCapture { + encoder: Option, + stream: CaptureStream, +} + +impl ActiveCapture { + fn fallback(&mut self, config: &SharedVideoPipelineConfig) -> Result<()> { + drop(self.encoder.take()); + self.encoder = Some(CaptureEncoder::Copy(Box::new(build_encoder_state(config)?))); + Ok(()) + } +} + +struct Completion(Arc); +impl Drop for Completion { + fn drop(&mut self) { + self.0.running_flag.store(false, Ordering::Release); + self.0.clear_cmd_tx(); + let _ = self.0.encoder_done.send(true); + let _ = self.0.running.send(false); + info!("RKMPP capture/encode worker stopped and device resources released"); + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn start( + pipeline: Arc, + stream: CaptureStream, + encoder: DmaEncoder, + config: SharedVideoPipelineConfig, + device: std::path::PathBuf, + buffer_count: u32, + bridge: BridgeContext, +) -> Result<()> { + let (tx, rx) = mpsc::unbounded_channel(); + *pipeline.cmd_tx.write() = Some(tx); + pipeline.running_flag.store(true, Ordering::Release); + let _ = pipeline.encoder_done.send(false); + let _ = pipeline.running.send(true); + let worker = pipeline.clone(); + info!( + "RKMPP DMA candidate: device={} format={:?} resolution={:?} stride={}", + device.display(), + stream.format(), + stream.resolution(), + stream.stride() + ); + let active = ActiveCapture { + encoder: Some(CaptureEncoder::Dma(encoder)), + stream, + }; + let result = std::thread::Builder::new() + .name("rkmpp-dmabuf".into()) + .spawn(move || { + let _completion = Completion(worker.clone()); + if let Err(error) = run(&worker, active, config, device, buffer_count, bridge, rx) { + error!("RKMPP DMA worker failed: {}", error); + } + }); + if let Err(error) = result { + drop(Completion(pipeline)); + return Err(AppError::VideoError(format!( + "Failed to start RKMPP DMA worker: {error}" + ))); + } + info!("RKMPP DMA capture path active: no CPU raw-frame copies (ONE_KVM_RKMPP_DMABUF=0 disables it)"); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn run( + pipeline: &Arc, + initial: ActiveCapture, + mut config: SharedVideoPipelineConfig, + device: std::path::PathBuf, + buffer_count: u32, + bridge: BridgeContext, + mut commands: mpsc::UnboundedReceiver, +) -> Result<()> { + let policy = CaptureRecoveryPolicy::new(config.control_mode); + let mut active = Some(initial); + let mut allow_dma = true; + let mut failures = 0u32; + let mut idle_since: Option = None; + let buffer_pool = Arc::new(FrameBufferPool::new(2)); // allocated only on fallback + let mut fps_frames = 0u32; + let mut fps_start = Instant::now(); + let errors = LogThrottler::with_secs(5); + + while pipeline.running_flag.load(Ordering::Acquire) { + if pipeline.subscriber_count() == 0 { + if idle_since.get_or_insert_with(Instant::now).elapsed() + >= Duration::from_secs(AUTO_STOP_GRACE_PERIOD_SECS) + { + break; + } + std::thread::sleep(Duration::from_millis(50)); + continue; + } + idle_since = None; + + while let Ok(command) = commands.try_recv() { + let PipelineCmd::SetBitrate { preset } = command; + // Preserve Custom values and preset-specific GOPs across fallback/reopen. + config.bitrate_preset = preset; + if let Some(capture) = active.as_mut() { + match capture.encoder.as_mut().expect("active encoder") { + CaptureEncoder::Dma(encoder) => { + if let Err(error) = + encoder.reconfigure(config.bitrate_kbps(), config.gop_size().max(1)) + { + warn!( + "RKMPP DMA reconfigure failed, using copy encoder: {}", + error + ); + capture.fallback(&config)?; + allow_dma = false; + pipeline.keyframe_requested.store(true, Ordering::Release); + } + } + CaptureEncoder::Copy(encoder) => { + pipeline.apply_cmd(encoder, PipelineCmd::SetBitrate { preset })? + } + } + } + } + + if active.is_none() { + match open_capture_stream_for_retry( + &device, + config.resolution, + config.input_format, + config.fps, + buffer_count.max(2), + Duration::from_secs(2), + bridge.clone(), + config.control_mode, + is_device_lost_message, + ) { + CaptureOpenResult::Opened(stream) => { + if stream.resolution() != config.resolution + || stream.format() != config.input_format + { + *pipeline.pending_sync_geometry.lock() = + Some((stream.resolution(), stream.format())); + break; + } + config.align_source_fps(stream.source_fps()); + // Update only timing: a concurrently queued bitrate command + // must retain the user's latest preset in the shared config. + pipeline.config.blocking_write().fps = config.fps; + let encoder = if allow_dma && stream.supports_rkmpp_dmabuf() { + match prepare(&stream, &config) { + Ok(encoder) => CaptureEncoder::Dma(encoder), + Err(error) => { + warn!("RKMPP DMA reopen failed, using copy encoder: {}", error); + allow_dma = false; + CaptureEncoder::Copy(Box::new(build_encoder_state(&config)?)) + } + } + } else { + CaptureEncoder::Copy(Box::new(build_encoder_state(&config)?)) + }; + active = Some(ActiveCapture { + encoder: Some(encoder), + stream, + }); + pipeline.keyframe_requested.store(true, Ordering::Release); + } + CaptureOpenResult::NoSignal(status) => { + failures = failures.saturating_add(1); + let delay = policy.retry_delay(failures); + pipeline.notify_state(PipelineStateNotification::no_signal( + status, + Some(delay.as_millis() as u64), + )); + wait_for_source_change(&bridge, delay, || { + pipeline.running_flag.load(Ordering::Acquire) + }); + continue; + } + CaptureOpenResult::DeviceLost(reason) => { + pipeline.mark_device_lost(reason); + break; + } + CaptureOpenResult::Fatal => break, + } + } + + let capture = active.as_mut().expect("opened capture"); + let result = match capture.encoder.as_mut().expect("active encoder") { + CaptureEncoder::Dma(encoder) => { + let pts = pipeline.pts_ms(); + capture + .stream + .with_next_dmabuf(|index, bytes_used, fresh_fd| { + let keyframe = pipeline.keyframe_requested.swap(false, Ordering::AcqRel); + // The callback holds the dequeue lease until native encode + // completes (or destroys MPP on error), before QBUF. + unsafe { encoder.encode(index, bytes_used, fresh_fd, pts, keyframe) } + }) + .map(|(_, packet)| { + packet + .map(|packet| { + let data = Bytes::from(packet); + let is_keyframe = match config.output_codec { + VideoEncoderType::H264 => h264_bitstream::is_keyframe(&data), + VideoEncoderType::H265 => h265_bitstream::is_keyframe(&data), + _ => false, + }; + let (data, is_keyframe) = pipeline.inspect_and_parameterize_packet( + config.output_codec, + data, + is_keyframe, + ); + if config.output_codec == VideoEncoderType::H264 { + pipeline.update_h264_profile_level_id(&data); + } + vec![EncodedVideoFrame { + data, + pts_ms: pts, + is_keyframe, + sequence: pipeline.sequence.fetch_add(1, Ordering::Relaxed) + 1, + duration: Duration::from_micros( + 1_000_000 / config.fps.max(1) as u64, + ), + codec: config.output_codec, + }] + }) + .map_err(AppError::VideoError) + }) + } + CaptureEncoder::Copy(encoder) => { + let mut raw = buffer_pool.take(0); + capture.stream.next_into(&mut raw).map(|meta| { + let frame = VideoFrame::from_pooled( + Arc::new(FrameBuffer::new(raw, Some(buffer_pool.clone()))), + config.resolution, + config.input_format, + capture.stream.stride(), + meta.sequence, + ); + pipeline.encode_frame_sync(encoder, &frame) + }) + } + }; + + match result { + Ok(Ok(frames)) => { + failures = 0; + pipeline.notify_state(PipelineStateNotification::streaming( + config.resolution, + config.input_format, + config.fps, + )); + for frame in frames { + pipeline.broadcast_encoded(Arc::new(frame)); + fps_frames += 1; + } + } + Ok(Err(error)) => { + if matches!(capture.encoder, Some(CaptureEncoder::Dma(_))) { + warn!("RKMPP DMA encode failed; disabling DMA for this pipeline and using copy encoder: {}", error); + capture.fallback(&config)?; + allow_dma = false; + pipeline.keyframe_requested.store(true, Ordering::Release); + } else if errors.should_log("copy_encode") { + error!("RKMPP copy encode failed: {}", error); + } + } + Err(CaptureReadError::Io(error)) if error.kind() == std::io::ErrorKind::WouldBlock => { + continue + } + Err(CaptureReadError::Io(error)) + if error.kind() == std::io::ErrorKind::InvalidData && allow_dma => + { + warn!( + "Unsupported RKMPP DMA frame layout, using copy encoder: {}", + error + ); + capture.fallback(&config)?; + allow_dma = false; + pipeline.keyframe_requested.store(true, Ordering::Release); + } + Err(error) => { + let mut status = SignalStatus::NoSignal; + if let CaptureReadError::Io(ref io) = error { + if classify_capture_io_error(io) == CaptureIoErrorKind::DeviceLost + || is_device_lost_message(&io.to_string()) + { + pipeline.mark_device_lost(io.to_string()); + break; + } + if errors.should_log("capture") { + warn!("RKMPP DMA capture recovery: {}", io); + } + status = capture_recovery_status(config.control_mode, io); + } + // ActiveCapture drops encoder/imports before the V4L2 stream. + drop(active.take()); + failures = failures.saturating_add(1); + let delay = policy.retry_delay(failures); + pipeline.notify_state(PipelineStateNotification::no_signal( + status, + Some(delay.as_millis() as u64), + )); + if !matches!(error, CaptureReadError::SourceChanged) { + wait_for_source_change(&bridge, delay, || { + pipeline.running_flag.load(Ordering::Acquire) + }); + } + } + } + if fps_start.elapsed() >= Duration::from_secs(1) { + pipeline.stats.blocking_lock().current_fps = + fps_frames as f32 / fps_start.elapsed().as_secs_f32(); + fps_frames = 0; + fps_start = Instant::now(); + } + } + // Explicitly release in the worker before Completion publishes stopped. + drop(active); + Ok(()) +} diff --git a/src/video/pipeline/shared.rs b/src/video/pipeline/shared.rs index 22d4a70e..1269b43d 100644 --- a/src/video/pipeline/shared.rs +++ b/src/video/pipeline/shared.rs @@ -29,6 +29,96 @@ use tracing::{debug, error, info, trace, warn}; use super::encoder_state::{build_encoder_state, should_parallel_decode_mjpeg, EncoderThreadState}; +#[cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")))] +#[path = "dmabuf.rs"] +mod dmabuf; + +#[cfg(any( + test, + all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")) +))] +fn rkmpp_dma_eligible( + backend: Option, + codec: VideoEncoderType, + format: PixelFormat, +) -> bool { + backend == Some(EncoderBackend::Rkmpp) + && matches!(codec, VideoEncoderType::H264 | VideoEncoderType::H265) + && matches!( + format, + PixelFormat::Bgr24 + | PixelFormat::Nv12 + | PixelFormat::Yuyv + | PixelFormat::Rgb24 + | PixelFormat::Mjpeg + ) +} + +#[cfg(test)] +mod dma_selection_tests { + use super::*; + #[test] + fn only_selected_rkmpp_uses_dma() { + for backend in [ + EncoderBackend::Software, + EncoderBackend::Vaapi, + EncoderBackend::Nvenc, + EncoderBackend::Qsv, + EncoderBackend::Amf, + EncoderBackend::V4l2m2m, + ] { + for codec in [VideoEncoderType::H264, VideoEncoderType::H265] { + for format in [ + PixelFormat::Bgr24, + PixelFormat::Nv12, + PixelFormat::Yuyv, + PixelFormat::Rgb24, + PixelFormat::Mjpeg, + ] { + assert!(!rkmpp_dma_eligible(Some(backend), codec, format)); + } + } + } + assert!(!rkmpp_dma_eligible( + None, + VideoEncoderType::H264, + PixelFormat::Nv12 + )); + for codec in [VideoEncoderType::H264, VideoEncoderType::H265] { + for format in [ + PixelFormat::Bgr24, + PixelFormat::Nv12, + PixelFormat::Yuyv, + PixelFormat::Rgb24, + PixelFormat::Mjpeg, + ] { + assert!(rkmpp_dma_eligible( + Some(EncoderBackend::Rkmpp), + codec, + format + )); + } + } + for format in [ + PixelFormat::Nv16, + PixelFormat::Nv21, + PixelFormat::Nv24, + PixelFormat::Yuv420, + ] { + assert!(!rkmpp_dma_eligible( + Some(EncoderBackend::Rkmpp), + VideoEncoderType::H264, + format + )); + } + assert!(!rkmpp_dma_eligible( + Some(EncoderBackend::Rkmpp), + VideoEncoderType::VP9, + PixelFormat::Nv12 + )); + } +} + /// Grace period before auto-stopping pipeline when no subscribers (in seconds) const AUTO_STOP_GRACE_PERIOD_SECS: u64 = 3; /// After this many consecutive timeouts, log a prominent warning. @@ -172,7 +262,9 @@ pub struct EncodedVideoFrame { } enum PipelineCmd { - SetBitrate { bitrate_kbps: u32, gop: u32 }, + SetBitrate { + preset: crate::video::codec::BitratePreset, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -255,6 +347,15 @@ impl Default for SharedVideoPipelineConfig { } impl SharedVideoPipelineConfig { + /// Keep encoder timing aligned with the negotiated HDMI source on every open. + fn align_source_fps(&mut self, source_fps: Option) { + if self.control_mode == VideoControlMode::SourceFollowing { + if let Some(fps) = source_fps { + self.fps = fps.round().clamp(1.0, 120.0) as u32; + } + } + } + /// Get effective bitrate in kbps pub fn bitrate_kbps(&self) -> u32 { self.bitrate_preset.bitrate_kbps() @@ -538,14 +639,13 @@ impl SharedVideoPipeline { fn apply_cmd(&self, state: &mut EncoderThreadState, cmd: PipelineCmd) -> Result<()> { match cmd { - PipelineCmd::SetBitrate { bitrate_kbps, gop } => { - #[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))] - let _ = gop; + PipelineCmd::SetBitrate { preset } => { + let bitrate_kbps = preset.bitrate_kbps(); #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] if state.ffmpeg_hw_enabled { if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline { pipeline - .reconfigure(bitrate_kbps as i32, gop as i32) + .reconfigure(bitrate_kbps as i32, preset.gop_size(state.fps) as i32) .map_err(|e| { let detail = if e.is_empty() { ffmpeg_hw_last_error() @@ -767,7 +867,8 @@ impl SharedVideoPipeline { subdev_path.clone(), parse_bridge_kind(bridge_kind.as_deref()), ); - let preopened: Option = match open_capture_stream( + #[allow(unused_mut)] + let mut preopened: Option = match open_capture_stream( &device_path, config.resolution, config.input_format, @@ -781,11 +882,7 @@ impl SharedVideoPipeline { let negotiated_res = s.resolution(); let negotiated_fmt = s.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.align_source_fps(s.source_fps()); config.resolution = negotiated_res; config.input_format = negotiated_fmt; if previous != (config.resolution, config.input_format, config.fps) { @@ -822,6 +919,32 @@ impl SharedVideoPipeline { Err(e) => return Err(e), }; + #[cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")))] + if dmabuf::eligible(&config) { + if let Some(stream) = preopened.as_ref().filter(|s| s.supports_rkmpp_dmabuf()) { + match dmabuf::prepare(stream, &config) { + Ok(encoder) => { + return dmabuf::start( + self.clone(), + preopened.take().expect("preopened DMA capture"), + encoder, + config, + device_path, + buffer_count, + BridgeContext::from_parts( + subdev_path, + parse_bridge_kind(bridge_kind.as_deref()), + ), + ); + } + Err(error) => warn!( + "RKMPP DMA unavailable; using existing copy pipeline: {}", + error + ), + } + } + } + let mut encoder_config = config.clone(); if parallel_mjpeg_decode { encoder_config.input_format = PixelFormat::Nv12; @@ -1400,23 +1523,7 @@ impl SharedVideoPipeline { let input_format = state.input_format; let raw_frame = frame.data(); - let process_start = PROCESS_START.get_or_init(Instant::now); - let current_ts_us = process_start.elapsed().as_micros() as i64; - let start_ts_us = self.pipeline_start_time_us.load(Ordering::Acquire); - let pts_ms = if start_ts_us == 0 { - let start_ts_us = match self.pipeline_start_time_us.compare_exchange( - 0, - current_ts_us, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => current_ts_us, - Err(existing) => existing, - }; - current_ts_us.saturating_sub(start_ts_us) / 1000 - } else { - current_ts_us.saturating_sub(start_ts_us) / 1000 - }; + let pts_ms = self.pts_ms(); #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] if state.ffmpeg_hw_enabled { @@ -1551,6 +1658,28 @@ impl SharedVideoPipeline { } } + fn pts_ms(&self) -> i64 { + let current_ts_us = PROCESS_START + .get_or_init(Instant::now) + .elapsed() + .as_micros() as i64; + let start_ts_us = self.pipeline_start_time_us.load(Ordering::Acquire); + let start_ts_us = if start_ts_us == 0 { + match self.pipeline_start_time_us.compare_exchange( + 0, + current_ts_us, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => current_ts_us, + Err(existing) => existing, + } + } else { + start_ts_us + }; + current_ts_us.saturating_sub(start_ts_us) / 1000 + } + /// Stop the pipeline (non-blocking, does not wait for capture thread to exit) pub fn stop(&self) { if self.running_flag.swap(false, Ordering::AcqRel) { @@ -1630,13 +1759,11 @@ impl SharedVideoPipeline { &self, preset: crate::video::codec::BitratePreset, ) -> Result<()> { - let bitrate_kbps = preset.bitrate_kbps(); - let gop = { + { let mut config = self.config.write().await; config.bitrate_preset = preset; - config.gop_size() - }; - self.send_cmd(PipelineCmd::SetBitrate { bitrate_kbps, gop }); + } + self.send_cmd(PipelineCmd::SetBitrate { preset }); Ok(()) } @@ -1831,6 +1958,60 @@ mod tests { use super::*; use crate::video::codec::BitratePreset; + #[tokio::test] + async fn bitrate_commands_preserve_custom_values_and_gop_policy() { + let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::default()).unwrap(); + let (tx, mut rx) = mpsc::unbounded_channel(); + *pipeline.cmd_tx.write() = Some(tx); + for preset in [ + BitratePreset::Custom(2500), + BitratePreset::Custom(1000), + BitratePreset::Speed, + BitratePreset::Quality, + ] { + pipeline.set_bitrate_preset(preset).await.unwrap(); + let PipelineCmd::SetBitrate { preset: received } = rx.try_recv().unwrap(); + assert_eq!(received, preset); + assert_eq!(pipeline.config().await.bitrate_preset, preset); + // Rebuilt encoders must retain the preset's policy at the new FPS. + let restored = SharedVideoPipelineConfig { + bitrate_preset: received, + fps: 60, + ..Default::default() + }; + assert_eq!(restored.bitrate_kbps(), preset.bitrate_kbps()); + assert_eq!(restored.gop_size(), preset.gop_size(60)); + } + } + + #[test] + fn source_reopen_updates_fps_and_gop_without_changing_geometry_or_bitrate() { + let mut config = SharedVideoPipelineConfig { + control_mode: VideoControlMode::SourceFollowing, + resolution: Resolution::HD1080, + fps: 60, + bitrate_preset: BitratePreset::Quality, + ..Default::default() + }; + config.align_source_fps(Some(29.97)); + assert_eq!(config.fps, 30); + assert_eq!(config.gop_size(), 60); + assert_eq!(config.resolution, Resolution::HD1080); + assert_eq!(config.bitrate_kbps(), 8000); + config.align_source_fps(None); + assert_eq!(config.fps, 30); + config.align_source_fps(Some(59.94)); + assert_eq!(config.fps, 60); + assert_eq!(config.gop_size(), 120); + } + + #[test] + fn configurable_capture_keeps_requested_fps() { + let mut config = SharedVideoPipelineConfig::default(); + config.align_source_fps(Some(60.0)); + assert_eq!(config.fps, 30); + } + #[test] fn test_pipeline_config() { let h264 = SharedVideoPipelineConfig::h264(Resolution::HD1080, BitratePreset::Balanced); From ece4f303ceb88f56caef48100de846ba23906599 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sat, 5 Sep 2026 20:59:15 +0800 Subject: [PATCH 25/29] =?UTF-8?q?feat(web):=20=E6=96=B0=E5=A2=9E=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=8F=B0=E5=B8=83=E5=B1=80=E5=88=87=E6=8D=A2=E5=B9=B6?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=8A=B6=E6=80=81=E4=B8=8E=E7=A3=81=E7=9B=98?= =?UTF-8?q?=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增经典、悬浮和侧栏布局,保存用户偏好,并调整工具栏、状态摘要及窄屏交互。 统一控制台弹层外观、焦点行为和中英文文案。 根据虚拟磁盘文件访问能力显示浏览入口、容量信息和不可用状态。 验证:npm run build 通过(vue-tsc 类型检查和 Vite 生产构建)。 --- web/src/api/index.ts | 31 +- web/src/components/ActionBar.vue | 422 ++++++++++++++---- web/src/components/AudioConfigPopover.vue | 19 +- web/src/components/ComputerUseSheet.vue | 4 + web/src/components/ConsoleHeaderActions.vue | 56 +++ web/src/components/ConsoleStatusSummary.vue | 87 ++++ web/src/components/HidConfigPopover.vue | 17 +- web/src/components/InfoBar.vue | 29 +- web/src/components/MsdDialog.vue | 100 +++-- web/src/components/StatsSheet.vue | 6 +- web/src/components/StatusCard.vue | 31 +- web/src/components/VideoConfigPopover.vue | 17 +- web/src/components/VideoDisplayControls.vue | 9 +- web/src/components/VirtualKeyboard.vue | 7 +- .../components/ui/dialog/DialogContent.vue | 3 + .../ui/dropdown-menu/DropdownMenuContent.vue | 6 +- .../dropdown-menu/DropdownMenuSubContent.vue | 3 + .../ui/hover-card/HoverCardContent.vue | 6 +- .../components/ui/popover/PopoverContent.vue | 6 +- .../components/ui/select/SelectContent.vue | 3 + web/src/components/ui/sheet/SheetContent.vue | 4 + .../components/ui/tooltip/TooltipContent.vue | 3 + web/src/composables/useConsoleAppearance.ts | 22 + web/src/composables/useConsoleLayout.ts | 32 ++ web/src/i18n/en-US.ts | 26 +- web/src/i18n/zh-CN.ts | 26 +- web/src/style.css | 76 ++++ web/src/views/ConsoleView.vue | 105 ++++- web/src/views/SettingsView.vue | 62 +++ 29 files changed, 1027 insertions(+), 191 deletions(-) create mode 100644 web/src/components/ConsoleHeaderActions.vue create mode 100644 web/src/components/ConsoleStatusSummary.vue create mode 100644 web/src/composables/useConsoleAppearance.ts create mode 100644 web/src/composables/useConsoleLayout.ts diff --git a/web/src/api/index.ts b/web/src/api/index.ts index a9d70165..f955a426 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -583,6 +583,16 @@ export interface DriveFile { size: number } +export type DriveFileAccess = 'available' | 'unsupported' | 'blocked_while_connected' | 'unknown' + +export interface DriveInfo { + size: number + used: number | null + free: number | null + initialized: boolean + file_access: DriveFileAccess +} + export type DiskMode = 'single' | 'multi' export type MountedMediaKind = 'drive' | 'image' @@ -604,12 +614,7 @@ export const msdApi = { slot_capacity: number mounted_count: number mounted_media: MountedMedia[] - drive_info: { - size: number - used: number - free: number - initialized: boolean - } | null + drive_info: DriveInfo | null usb_reenumerating: boolean } }>('/msd/status', {}, { toastOnError: false }), @@ -650,20 +655,10 @@ export const msdApi = { request<{ success: boolean }>('/msd/drive/mount', { method: 'DELETE' }, { errorTitleKey: 'msd.operations.unmountDrive' }), driveInfo: () => - request<{ - size: number - used: number - free: number - initialized: boolean - }>('/msd/drive', {}, { toastOnError: false }), + request('/msd/drive', {}, { toastOnError: false }), initDrive: (sizeMb?: number) => - request<{ - size: number - used: number - free: number - initialized: boolean - }>( + request( '/msd/drive/init', { method: 'POST', diff --git a/web/src/components/ActionBar.vue b/web/src/components/ActionBar.vue index c3869b26..d8ccaa08 100644 --- a/web/src/components/ActionBar.vue +++ b/web/src/components/ActionBar.vue @@ -39,6 +39,10 @@ import { Terminal, MoreHorizontal, Bot, + ChevronUp, + ChevronDown, + Keyboard, + Scaling, } from 'lucide-vue-next' import PasteModal from '@/components/PasteModal.vue' import AtxPopover from '@/components/AtxPopover.vue' @@ -47,6 +51,7 @@ import HidConfigPopover from '@/components/HidConfigPopover.vue' import AudioConfigPopover from '@/components/AudioConfigPopover.vue' import MsdDialog from '@/components/MsdDialog.vue' import VideoDisplayControls from '@/components/VideoDisplayControls.vue' +import type { ConsoleLayout } from '@/composables/useConsoleLayout' const { t, locale } = useI18n() const router = useRouter() @@ -60,6 +65,7 @@ const showMsd = computed(() => { return !!systemStore.msd?.available && !isCh9329Backend.value }) const props = defineProps<{ + layout?: ConsoleLayout mouseMode?: 'absolute' | 'relative' videoMode?: VideoMode ttydRunning?: boolean @@ -71,6 +77,18 @@ const props = defineProps<{ scaleMode?: VideoScaleMode sourceSizeAvailable?: boolean }>() +const isSidebarLayout = computed(() => props.layout === 'sidebar') +const isFloatingLayout = computed(() => props.layout === 'floating') +const floatingCollapsed = ref(false) +const expandButtonRef = ref | null>(null) +const collapseButtonRef = ref | null>(null) + +async function setFloatingCollapsed(collapsed: boolean) { + floatingCollapsed.value = collapsed + await nextTick() + const target = collapsed ? expandButtonRef.value : collapseButtonRef.value + target?.$el?.focus() +} const showAtx = computed(() => props.showPower !== false) const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg') const showPasteText = computed(() => props.showPasteText !== false) @@ -133,13 +151,18 @@ const openMobilePaste = () => openFromOverflow(() => { const barRef = ref(null) const measureRef = ref(null) const barWidth = ref(0) +const barHeight = ref(0) +const coreWidth = ref(0) +const coreHeight = ref(0) +const fixedHeight = ref(0) +const actionHeight = ref(36) +const minimalDisplayControls = computed(() => isFloatingLayout.value && barWidth.value < 640) const alwaysRightWidth = ref(152) let layoutResizeObserver: ResizeObserver | null = null type CollapsibleItem = - | 'video' | 'audio' | 'hid' | 'msd' | 'atx' | 'paste' - | 'stats' | 'terminal' | 'settings' + | 'stats' | 'terminal' | 'settings' | 'ai' interface ItemSpec { id: CollapsibleItem @@ -147,15 +170,13 @@ interface ItemSpec { } const ITEM_SPECS: ItemSpec[] = [ - { id: 'video', side: 'left' }, - { id: 'audio', side: 'left' }, - { id: 'hid', side: 'left' }, { id: 'msd', side: 'left' }, { id: 'atx', side: 'left' }, { id: 'paste', side: 'left' }, { id: 'stats', side: 'right' }, { id: 'terminal', side: 'right' }, { id: 'settings', side: 'right' }, + { id: 'ai', side: 'right' }, ] const measuredWidths = ref>(new Map()) @@ -167,7 +188,13 @@ const measureLayout = async () => { const measureContainer = measureRef.value if (!bar || !measureContainer) return - barWidth.value = bar.clientWidth + const style = window.getComputedStyle(bar) + barWidth.value = bar.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) + barHeight.value = bar.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom) + const core = Array.from(bar.querySelectorAll('[data-core-action]')) + coreWidth.value = core.reduce((sum, element) => sum + element.offsetWidth, 0) + coreHeight.value = core.reduce((sum, element) => sum + element.offsetHeight, 0) + actionHeight.value = core[0]?.offsetHeight || 36 const newWidths = new Map() for (const spec of ITEM_SPECS) { @@ -190,7 +217,12 @@ const measureLayout = async () => { + Number.parseFloat(style.marginLeft || '0') + Number.parseFloat(style.marginRight || '0') }, 0) - if (width > 0) alwaysRightWidth.value = Math.ceil(width) + alwaysRightWidth.value = Math.ceil(width) + fixedHeight.value = elements.reduce((sum, element) => { + const style = window.getComputedStyle(element) + return sum + element.getBoundingClientRect().height + + parseFloat(style.marginTop || '0') + parseFloat(style.marginBottom || '0') + }, 0) measurementReady.value = true } @@ -202,7 +234,7 @@ const observeLayout = async () => { void measureLayout() }) if (barRef.value) layoutResizeObserver.observe(barRef.value) - barRef.value?.querySelectorAll('[data-fixed-action]').forEach((element) => { + barRef.value?.querySelectorAll('[data-fixed-action], [data-core-action]').forEach((element) => { layoutResizeObserver?.observe(element) }) } @@ -220,7 +252,7 @@ watch(locale, () => { void measureLayout() }) -watch(() => props.showComputerUse, () => { +watch([() => props.layout, () => props.showComputerUse, floatingCollapsed, minimalDisplayControls], () => { void observeLayout() }) @@ -241,52 +273,55 @@ watch(showPasteText, (visible) => { const OVERFLOW_BUTTON_BUDGET_PX = 36 const collapsibleItems = computed(() => { - const items = ITEM_SPECS.slice(3).filter(item => { + const items = ITEM_SPECS.filter(item => { + if (isFloatingLayout.value && item.side === 'right') return false if (item.id === 'msd' && !showMsd.value) return false if (item.id === 'atx' && !showAtx.value) return false if (item.id === 'paste' && !showPasteText.value) return false if (item.id === 'stats' && !showStats.value) return false if (item.id === 'terminal' && props.showTerminal === false) return false + if (item.id === 'ai' && props.showComputerUse === false) return false return true }) return items }) const visibleSet = computed(() => { - if (!measurementReady.value) { - return new Map() - } - - const available = barWidth.value - alwaysRightWidth.value - OVERFLOW_BUTTON_BUDGET_PX - - let used = 0 - if (barRef.value) { - const leftContainer = barRef.value.querySelector('.left-buttons') as HTMLElement - if (leftContainer) { - const children = Array.from(leftContainer.children).slice(0, 3) as HTMLElement[] - used = children.reduce((sum, el) => sum + el.offsetWidth, 0) - } - } - - if (used === 0) used = 330 - const result = new Map() + if (!measurementReady.value) return result + if (isSidebarLayout.value) { + // Reserve More and a gap between the two groups before assigning vertical slots. + let available = barHeight.value - coreHeight.value - fixedHeight.value - actionHeight.value - 16 + const priority: CollapsibleItem[] = ['paste', 'settings', 'msd', 'atx', 'stats', 'terminal', 'ai'] + for (const id of priority) { + if (!collapsibleItems.value.some(item => item.id === id)) continue + if (available < actionHeight.value) break + result.set(id, 'icon') + available -= actionHeight.value + } + return result + } + + const available = barWidth.value - alwaysRightWidth.value - Math.max(OVERFLOW_BUTTON_BUDGET_PX, actionHeight.value) - (isFloatingLayout.value ? 48 : 12) + let used = coreWidth.value + // Keep actions reachable before spending the remaining space on labels. for (const item of collapsibleItems.value) { const widths = measuredWidths.value.get(item.id) - if (!widths) continue - - if (used + widths.icon <= available) { - if (used + widths.label <= available) { - result.set(item.id, 'label') - used += widths.label - } else { - result.set(item.id, 'icon') - used += widths.icon - } + if (!widths || used + widths.icon > available) continue + result.set(item.id, 'icon') + used += widths.icon + } + if (isFloatingLayout.value && barWidth.value < 640) return result + for (const item of collapsibleItems.value) { + const widths = measuredWidths.value.get(item.id) + if (!widths || !result.has(item.id)) continue + const extra = widths.label - widths.icon + if (used + extra <= available) { + result.set(item.id, 'label') + used += extra } } - return result }) @@ -304,36 +339,85 @@ const hasRightOverflow = computed(() => { + + diff --git a/web/src/components/AudioConfigPopover.vue b/web/src/components/AudioConfigPopover.vue index 045103b1..dba31ddb 100644 --- a/web/src/components/AudioConfigPopover.vue +++ b/web/src/components/AudioConfigPopover.vue @@ -1,8 +1,9 @@ + + diff --git a/web/src/components/ConsoleStatusSummary.vue b/web/src/components/ConsoleStatusSummary.vue new file mode 100644 index 00000000..2e5cab61 --- /dev/null +++ b/web/src/components/ConsoleStatusSummary.vue @@ -0,0 +1,87 @@ + + + diff --git a/web/src/components/HidConfigPopover.vue b/web/src/components/HidConfigPopover.vue index ca881b13..4f097771 100644 --- a/web/src/components/HidConfigPopover.vue +++ b/web/src/components/HidConfigPopover.vue @@ -1,4 +1,5 @@ @@ -30,6 +32,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) diff --git a/web/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue b/web/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue index 5f6da9b8..40a8422a 100644 --- a/web/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +++ b/web/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue @@ -1,4 +1,6 @@ @@ -29,6 +31,8 @@ const forwardedProps = useForwardProps(delegatedProps) +import { useConsoleAppearance } from "@/composables/useConsoleAppearance" +const consoleAppearance = useConsoleAppearance() import type { PopoverContentEmits, PopoverContentProps } from "reka-ui" import type { HTMLAttributes } from "vue" import { reactiveOmit } from "@vueuse/core" @@ -22,7 +24,7 @@ const props = withDefaults( ) const emits = defineEmits() -const delegatedProps = reactiveOmit(props, "class") +const delegatedProps = reactiveOmit(props, "class", "sideOffset") const forwarded = useForwardPropsEmits(delegatedProps, emits) @@ -31,6 +33,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) +import { useConsoleAppearance } from "@/composables/useConsoleAppearance" +const consoleAppearance = useConsoleAppearance() import type { SelectContentEmits, SelectContentProps } from "reka-ui" import type { HTMLAttributes } from "vue" import { reactiveOmit } from "@vueuse/core" @@ -32,6 +34,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) +import { useConsoleAppearance } from "@/composables/useConsoleAppearance" +const consoleAppearance = useConsoleAppearance() import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { HTMLAttributes } from "vue" import { X } from "lucide-vue-next" @@ -36,6 +38,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) +import { useConsoleAppearance } from "@/composables/useConsoleAppearance" +const consoleAppearance = useConsoleAppearance() import type { TooltipContentEmits, TooltipContentProps } from "reka-ui" import type { HTMLAttributes } from "vue" import { reactiveOmit } from "@vueuse/core" @@ -23,6 +25,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) diff --git a/web/src/composables/useConsoleAppearance.ts b/web/src/composables/useConsoleAppearance.ts new file mode 100644 index 00000000..8f3a94ba --- /dev/null +++ b/web/src/composables/useConsoleAppearance.ts @@ -0,0 +1,22 @@ +import { inject, provide, type InjectionKey, type Ref } from 'vue' +import type { ConsoleLayout } from '@/composables/useConsoleLayout' + +// Injection follows component ownership through portals, without styling settings/login. +const consoleAppearanceKey: InjectionKey>> = Symbol('consoleAppearance') + +export function provideConsoleAppearance(layout: Readonly>) { + provide(consoleAppearanceKey, layout) +} + +export function useConsoleAppearance() { + return inject(consoleAppearanceKey, undefined) +} + +// Focus the panel itself so the first help tooltip does not obscure its controls on open. +// Keyboard users can then Tab into the panel's controls in their normal order. +export function focusConsolePanel(event: Event) { + if (event.target instanceof HTMLElement) { + event.preventDefault() + event.target.focus({ preventScroll: true }) + } +} diff --git a/web/src/composables/useConsoleLayout.ts b/web/src/composables/useConsoleLayout.ts new file mode 100644 index 00000000..16c371ee --- /dev/null +++ b/web/src/composables/useConsoleLayout.ts @@ -0,0 +1,32 @@ +import { ref, watch } from 'vue' + +export type ConsoleLayout = 'current' | 'floating' | 'sidebar' + +const STORAGE_KEY = 'consoleLayout' +const VALID_LAYOUTS: ConsoleLayout[] = ['current', 'floating', 'sidebar'] + +function readStoredLayout(): ConsoleLayout { + const stored = localStorage.getItem(STORAGE_KEY) + return VALID_LAYOUTS.includes(stored as ConsoleLayout) + ? stored as ConsoleLayout + : 'current' +} + +const consoleLayout = ref(readStoredLayout()) + +watch(consoleLayout, layout => { + localStorage.setItem(STORAGE_KEY, layout) +}) + +export function useConsoleLayout() { + function setConsoleLayout(layout: ConsoleLayout) { + if (VALID_LAYOUTS.includes(layout)) { + consoleLayout.value = layout + } + } + + return { + consoleLayout, + setConsoleLayout, + } +} diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 726450bb..ae9e517b 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -96,12 +96,19 @@ export default { backToPassword: 'Back to password', }, status: { + no_signal: 'No signal', + busy: 'Device busy', connected: 'Connected', connecting: 'Connecting', disconnected: 'Disconnected', error: 'Error', }, actionbar: { + muted: 'Muted', + more: 'More actions', + collapseToolbar: 'Collapse toolbar', + expandToolbar: 'Expand toolbar', + paste: 'Paste Text', micStart: 'Start Transfer', micStop: 'Stop Transfer', @@ -190,6 +197,8 @@ export default { selectAudioDevice: 'Select device...', }, infobar: { + pointerCaptured: 'Mouse captured · Esc to release', + keys: 'Keys', pointer: 'Pointer', caps: 'Caps', @@ -439,6 +448,7 @@ export default { uploadFailed: 'File upload failed', driveUnreadable: 'Format unsupported', driveUnreadableTooltip: 'Unable to parse the exFAT filesystem. It may have been formatted with an unsupported format.', + driveFilesystemUnsupportedHint: 'Web file browsing does not support this filesystem, but you can still connect the drive to the target machine.', reinitializeDrive: 'Re-initialize', diskMode: 'Drive Mode', singleDiskMode: 'Single', @@ -484,7 +494,7 @@ export default { downloadIncomplete: 'The remote image download was incomplete.', driveNotInitialized: 'The virtual drive is not initialized.', driveConnected: 'The virtual drive is connected to the controlled computer. Disconnect it before editing files.', - driveFilesystemUnsupported: 'The virtual drive filesystem is unsupported. Reinitialize it to continue.', + driveFilesystemUnsupported: 'Web file management does not support this format, but the drive can still be mounted on the controlled computer.', driveSizeInvalid: 'The virtual drive size is invalid.', storageSpaceUnavailable: 'Available virtual media storage space could not be determined.', storageFull: 'Virtual media storage does not have enough free space.', @@ -551,6 +561,18 @@ export default { networkAddresses: 'Network Addresses', language: 'Language', theme: 'Theme', + consoleLayout: 'Console layout', + consoleLayoutDesc: 'Choose how console controls are arranged. The preference is saved in this browser.', + consoleLayoutHints: { + current: 'Full status and labeled tools for configuration and troubleshooting.', + sidebar: 'Compact status and fixed icons for frequent actions.', + floating: 'Essential floating controls that collapse to keep the video clear.', + }, + consoleLayoutOptions: { + current: 'Top bar', + floating: 'Floating bar', + sidebar: 'Sidebar', + }, lightMode: 'Light', darkMode: 'Dark', systemMode: 'System', @@ -948,6 +970,8 @@ export default { }, }, statusCard: { + connectionDetails: 'Connection details', + device: 'Device', video: 'Video', hid: 'HID', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 7f1e0188..e136c888 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -96,12 +96,19 @@ export default { backToPassword: '返回密码登录', }, status: { + no_signal: '无信号', + busy: '设备占用中', connected: '已连接', connecting: '连接中', disconnected: '已断开', error: '错误', }, actionbar: { + muted: '已静音', + more: '更多操作', + collapseToolbar: '收起工具栏', + expandToolbar: '展开工具栏', + paste: '粘贴文本', micStart: '开始传声', micStop: '停止传声', @@ -190,6 +197,8 @@ export default { selectAudioDevice: '选择设备...', }, infobar: { + pointerCaptured: '鼠标已捕获 · Esc 释放', + keys: '按键', pointer: '指针', caps: 'Caps', @@ -438,6 +447,7 @@ export default { uploadFailed: '文件上传失败', driveUnreadable: '格式不支持', driveUnreadableTooltip: '无法解析 exFAT 文件系统,可能已被格式化为不支持的格式。', + driveFilesystemUnsupportedHint: '网页暂不支持浏览此文件系统,但仍可连接到被控机使用。', reinitializeDrive: '重新初始化', diskMode: '驱动器模式', singleDiskMode: '单驱动器', @@ -483,7 +493,7 @@ export default { downloadIncomplete: '远程镜像下载不完整。', driveNotInitialized: '虚拟盘尚未初始化。', driveConnected: '虚拟盘已连接到被控机,请先断开连接再操作文件。', - driveFilesystemUnsupported: '虚拟盘文件系统不受支持,请重新初始化后再操作。', + driveFilesystemUnsupported: '网页文件管理不支持此格式,但仍可挂载到被控机使用。', driveSizeInvalid: '虚拟盘大小无效。', storageSpaceUnavailable: '无法获取虚拟媒体存储空间信息。', storageFull: '虚拟媒体存储空间不足。', @@ -550,6 +560,18 @@ export default { networkAddresses: '网络地址', language: '语言', theme: '主题', + consoleLayout: '控制台布局', + consoleLayoutDesc: '选择控制台操作区的排列方式,设置将保存在当前浏览器。', + consoleLayoutHints: { + current: '完整状态与文字工具栏,适合配置和排障。', + sidebar: '精简状态与固定图标入口,适合频繁操作。', + floating: '常用操作悬浮显示,可收起以专注画面。', + }, + consoleLayoutOptions: { + current: '顶栏', + floating: '浮动栏', + sidebar: '侧边栏', + }, lightMode: '浅色模式', darkMode: '深色模式', systemMode: '跟随系统', @@ -947,6 +969,8 @@ export default { }, }, statusCard: { + connectionDetails: '连接详情', + device: '设备', video: '视频', hid: 'HID', diff --git a/web/src/style.css b/web/src/style.css index 10cce14f..d9813034 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -185,3 +185,79 @@ body { .settings-dense [data-slot="card-footer"].border-t { padding-top: 1rem; } + +/* Console surfaces follow their owning layout, including content rendered in portals. */ +[data-console-layout="current"] { + --console-surface-radius: 8px; + --console-control-radius: 5px; + --console-menu-row: 32px; + --console-surface-shadow: 0 4px 14px rgb(0 0 0 / 12%); +} +[data-console-layout="floating"] { + --console-surface-radius: 16px; + --console-control-radius: 10px; + --console-menu-row: 38px; + --console-surface-shadow: 0 12px 36px rgb(0 0 0 / 20%); +} +[data-console-layout="sidebar"] { + --console-surface-radius: 10px; + --console-control-radius: 7px; + --console-menu-row: 36px; + --console-surface-shadow: 4px 6px 20px rgb(0 0 0 / 14%); +} +[data-console-layout]:is( + [data-slot="popover-content"], [data-slot="hover-card-content"], + [data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-sub-content"], + [data-slot="select-content"], [data-slot="dialog-content"] +) { + border-radius: var(--console-surface-radius); + box-shadow: var(--console-surface-shadow); +} +[data-console-layout][data-slot="popover-content"] { + max-height: min(var(--reka-popover-content-available-height, 80dvh), 80dvh); + overflow-y: auto; +} +[data-console-layout] :is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"], [data-slot="button"]) { + border-radius: var(--console-control-radius); +} +[data-console-layout] :is([data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-sub-trigger"], [data-slot="select-item"]) { + min-height: var(--console-menu-row); + border-radius: var(--console-control-radius); +} +[data-console-layout="floating"]:is([data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-sub-content"]) { + padding: 6px; +} +.console-config-panel[data-console-layout="floating"] { + width: min(360px, calc(100vw - 24px)); + padding: 16px; +} +.console-config-panel[data-console-layout="sidebar"] { + padding: 14px; +} +[data-console-layout="floating"][data-slot="tooltip-content"] { + border-radius: 8px; +} +[data-console-layout="floating"][data-slot="sheet-content"] { + margin: 12px; + border: 1px solid var(--border); + border-radius: 16px; +} +[data-console-layout="floating"][data-slot="sheet-content"]:is([data-edge="left"], [data-edge="right"]) { + height: calc(100% - 24px); + max-width: min(24rem, calc(100vw - 24px)); +} +[data-console-layout="sidebar"][data-slot="sheet-content"][data-edge="right"] { + border-radius: 12px 0 0 12px; +} +@media (pointer: coarse) { + [data-console-layout] :is([data-slot="dropdown-menu-item"], [data-slot="select-item"]) { + min-height: 44px; + } +} + +[data-console-layout][data-slot="computer-use-panel"] { + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--console-surface-radius); + box-shadow: var(--console-surface-shadow); +} diff --git a/web/src/views/ConsoleView.vue b/web/src/views/ConsoleView.vue index 1714f7f9..e774fbfb 100644 --- a/web/src/views/ConsoleView.vue +++ b/web/src/views/ConsoleView.vue @@ -14,6 +14,7 @@ import { useVideoScaling } from '@/composables/useVideoScaling' import { useComputerUseSocket, type ComputerUseServerMessage } from '@/composables/useComputerUseSocket' import { useFeatureVisibility } from '@/composables/useFeatureVisibility' import { useTheme } from '@/composables/useTheme' +import { useConsoleLayout } from '@/composables/useConsoleLayout' import { getUnifiedAudio } from '@/composables/useUnifiedAudio' import { getMicrophone } from '@/composables/useMicrophone' import { streamApi, hidApi, atxApi, atxConfigApi, authApi, computerUseApi, uacApi } from '@/api' @@ -30,8 +31,11 @@ import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/ import type { StreamDeviceLostEventData, StreamStateChangedEventData } from '@/types/websocket' import type { VideoMode } from '@/components/VideoConfigPopover.vue' -import StatusCard, { type StatusDetail } from '@/components/StatusCard.vue' +import StatusCard, { type StatusDetail, type ConnectionStatus } from '@/components/StatusCard.vue' import ActionBar from '@/components/ActionBar.vue' +import ConsoleHeaderActions from '@/components/ConsoleHeaderActions.vue' +import { provideConsoleAppearance } from '@/composables/useConsoleAppearance' +import ConsoleStatusSummary, { type ConsoleStatusItem } from '@/components/ConsoleStatusSummary.vue' import InfoBar from '@/components/InfoBar.vue' import VirtualKeyboard from '@/components/VirtualKeyboard.vue' import StatsSheet from '@/components/StatsSheet.vue' @@ -236,14 +240,19 @@ const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null) const showTerminalDialog = ref(false) const featureVisibility = useFeatureVisibility() const { isDark, toggleTheme } = useTheme() +const { consoleLayout } = useConsoleLayout() +provideConsoleAppearance(consoleLayout) const terminalAvailable = computed(() => ttydStatus.value?.available !== false) const showPower = computed(() => featureVisibility.value.power) const showTerminal = computed(() => terminalAvailable.value && featureVisibility.value.webTerminal) const showComputerUse = computed(() => featureVisibility.value.computerUse) const showPasteText = computed(() => featureVisibility.value.pasteText) -const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => { +const videoStatus = computed(() => { if (wsNetworkError.value) return 'connecting' + if (streamSignalState.value === 'no_signal') return 'no_signal' + if (streamSignalState.value === 'device_busy') return 'busy' + if (streamSignalState.value === 'device_lost') return 'error' if (videoError.value) return 'error' if (videoLoading.value) return 'connecting' @@ -256,6 +265,24 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro return 'disconnected' }) +// Optional idle devices are neutral; video and HID must stay visible when unavailable. +const consoleStatusItems = computed(() => { + const items: ConsoleStatusItem[] = [ + { id: 'video', title: t('statusCard.video'), status: videoStatus.value, + quickInfo: videoQuickInfo.value, details: videoDetails.value, required: true, + errorMessage: showSignalOverlay.value ? signalOverlayInfo.value.title : videoErrorMessage.value }, + { id: 'audio', title: t('statusCard.audio'), status: audioStatus.value, + quickInfo: audioQuickInfo.value, details: audioDetails.value, errorMessage: audioErrorMessage.value }, + { id: 'hid', title: t('statusCard.hid'), status: hidStatus.value, + quickInfo: hidQuickInfo.value, details: hidDetails.value, errorMessage: hidErrorMessage.value, required: true }, + ] + if (showMsdStatusCard.value) items.push({ + id: 'msd', title: t('statusCard.msd'), status: msdStatus.value, + quickInfo: msdQuickInfo.value, details: msdDetails.value, errorMessage: msdErrorMessage.value, + }) + return items +}) + function openStatsSheet() { if (showConnectionStats.value) { statsSheetOpen.value = true @@ -288,6 +315,7 @@ const isMjpegPaused = computed(() => { }) const videoQuickInfo = computed(() => { + if (videoStatus.value === 'no_signal' || videoStatus.value === 'busy') return t(`status.${videoStatus.value}`) const stream = systemStore.stream if (!stream?.resolution) return '' const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1]) @@ -306,8 +334,9 @@ const videoDetails = computed(() => { const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt} → ${outputFmt}` const targetFpsValue = formatFpsValue(stream.targetFps ?? 0) - const actualFpsValue = paused ? t('statusCard.paused') : formatFpsValue(receivedFps) - const actualStatus: StatusDetail['status'] = paused + const signalUnavailable = streamSignalState.value !== 'ok' + const actualFpsValue = signalUnavailable ? videoQuickInfo.value : paused ? t('statusCard.paused') : formatFpsValue(receivedFps) + const actualStatus: StatusDetail['status'] = signalUnavailable ? 'warning' : paused ? undefined : receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' @@ -3157,17 +3186,22 @@ onUnmounted(() => { diff --git a/web/src/components/HidWiringDiagram.vue b/web/src/components/HidWiringDiagram.vue new file mode 100644 index 00000000..07d90e5a --- /dev/null +++ b/web/src/components/HidWiringDiagram.vue @@ -0,0 +1,87 @@ + + + diff --git a/web/src/composables/useHidConnection.ts b/web/src/composables/useHidConnection.ts new file mode 100644 index 00000000..0acfeaab --- /dev/null +++ b/web/src/composables/useHidConnection.ts @@ -0,0 +1,49 @@ +import { ref, watch, onMounted, onUnmounted, onDeactivated, onActivated, type Ref } from 'vue' +import type { hidApi } from '@/api' +import { request } from '@/api/request' +import type { BluetoothStatus } from '@/types/bluetooth' +export function useHidConnection(active: Ref, backend: Ref) { + const status = ref> | null>(null) + const bluetooth = ref(null) + const error = ref('') + let timer: ReturnType | undefined + let controller: AbortController | undefined + let generation = 0, inFlight = false, mounted = false, deactivated = false + function stop() { generation++; clearTimeout(timer); controller?.abort(); status.value = null; bluetooth.value = null } + async function refresh() { + if (!mounted || deactivated || !active.value || document.hidden || inFlight) return + inFlight = true + const own = generation + controller = new AbortController() + const signal = controller.signal + const timeout = setTimeout(() => controller?.abort(), 5000) + try { + const [hidResult, btResult] = await Promise.allSettled([ + request>>('/hid/status', { signal }, { toastOnError: false }), + backend.value === 'bluetooth' ? request('/hid/bluetooth', { signal }, { toastOnError: false }) : Promise.resolve(null), + ]) + if (own !== generation) return + if (hidResult.status === 'rejected') throw hidResult.reason + if (btResult.status === 'rejected') throw btResult.reason + const hid = hidResult.value, bt = btResult.value + status.value = hid.backend === backend.value ? hid : null + bluetooth.value = hid.backend === backend.value ? bt : null + error.value = '' + } catch (e) { + if (own !== generation) return + status.value = null; bluetooth.value = null + error.value = e instanceof Error ? e.message : String(e) + } finally { + clearTimeout(timeout) + inFlight = false + if (mounted && !deactivated && active.value && !document.hidden) timer = setTimeout(refresh, 2000) + } + } + function restart() { stop(); error.value = ''; void refresh() } + watch([active, backend], restart) + onMounted(() => { mounted = true; document.addEventListener('visibilitychange', restart); restart() }) + onActivated(() => { deactivated = false; restart() }) + onDeactivated(() => { deactivated = true; stop() }) + onUnmounted(() => { mounted = false; stop(); document.removeEventListener('visibilitychange', restart) }) + return { status, bluetooth, error, restart } +} diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index ae9e517b..62a68a92 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -1,4 +1,86 @@ export default { + hidGuide: { + missingDevice: 'The previous device {device} is unavailable. Check the current selection.', + connectionTimeout: 'Connection timed out. Check the data cable and the controlled computer’s USB port. You can connect later or reconfigure.', + "deviceTitle": "Driver and device", + "features": "Feature configuration", + "saveFeatures": "Save features", + "configure": "Configure driver", + "reconfigure": "Reconfigure", + "driver": "HID driver", + "driver_otg": "USB OTG", + "driver_ch9329": "CH9329", + "driver_bluetooth": "Bluetooth HID", + "driver_none": "Disabled", + "device_otg": "UDC", + "device_ch9329": "Serial port", + "device_bluetooth": "Local Bluetooth adapter", + "host": "Controlled computer", + "hostUsb": "Host USB", + "otgPort": "One-KVM OTG USB", + "serialLink": "CH340 → Serial connection → CH9329", + "integratedCable": "CH340 + CH9329 integrated cable", + "dataCable": "USB data cable", + "wireless": "Bluetooth connection", + "wiring_otg": "One-KVM OTG USB port → USB data cable → controlled computer USB port.", + "wiring_ch9329": "Connect One-KVM to CH9329 over serial, then connect CH9329 USB to the controlled computer.", + "wiring_bluetooth": "On the controlled computer, open System Settings → Add Bluetooth device → select the advertised HID name.", + "disabledHelp": "One-KVM cannot send keyboard or mouse input while HID is disabled.", + "selectDevice": "Select a device", + "noDevices": "No devices found. Check the connection and refresh.", + "nameInvalid": "Use 1–64 UTF-8 bytes without control characters.", + "unconfigured": "HID is not configured", + "disabled": "HID is disabled", + "legacyAuto": "Automatic (legacy configuration)", + "unknown": "Unknown or unable to read status", + "ready": "HID ready", + "preparing": "Connected, preparing HID", + "paired": "Paired, waiting for connection", + "pairing": "Waiting for pairing", + "waiting": "Applied, waiting for connection", + "initializing": "Initializing", + "appliedHelp": "Configuration applied. Closing keeps the configuration and completed pairing.", + "draftHelp": "Choose a driver and device. The running configuration changes only when you apply.", + "dirty": "There are unsaved feature changes. Return to save them, or discard them to continue.", + "returnSave": "Return to save", + "discard": "Discard changes and continue", + "pairInstructions": "Add a Bluetooth device in the controlled computer’s system settings and select “{name}”. The pairing window lasts 120 seconds.", + "pairTimeout": "The pairing window ended. You can open it again.", + "reopenPairing": "Reopen pairing", + "resetWarning": "Previous One-KVM HID bonds will be cleared. Remove the old device on the controlled computer before adding it again.", + "disableUsb": "Leaving USB OTG will disable: {functions}.", + "msd": "Virtual disk", + "network": "USB network", + "audio": "USB audio", + "done": "Done", + "later": "Connect later", + "configureLater": "Configure later", + "useConfiguration": "Use this configuration", + "repair": "Pair again", + "uncertain": "The previous request has an unknown result. Check the connection first; applying again will clear bonds again.", + "checkConnection": "Check applied configuration", + "resumeRetry": "The current configuration differs from the pending selection. Review it and retry." +}, + bluetoothHid: { + paired: 'Paired; waiting for the computer to connect keyboard and mouse', + "description": "Classic Bluetooth keyboard and relative mouse.", + "adapter": "Bluetooth adapter", + "name": "Device name", + "peer": "Computer address (optional)", + "peerHelp": "Leave blank to reuse the only bonded device, or select a computer during pairing.", + "ready": "Keyboard and mouse ready", + "pairing": "Pairing open: {seconds}s", + "connected": "Connected; waiting for HID channels", + "waiting": "Waiting for the paired computer", + "unavailable": "Bluetooth unavailable or starting", + "openPairing": "Pair for 2 minutes", + "closePairing": "Close pairing", + "disconnect": "Disconnect", + "forget": "Forget computer", + "windows": "Windows 11: Settings → Bluetooth & devices → Add device → Bluetooth. After forgetting, remove the device in Windows before pairing again.", + "applyFirst": "Apply the HID settings first, then open pairing." +}, + videoInput: { format: 'Input Format', resolution: 'Resolution', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index e136c888..1fd43e7b 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -1,4 +1,86 @@ export default { + hidGuide: { + missingDevice: '原设备 {device} 已不可用,请检查当前选择。', + connectionTimeout: '等待连接超时,请检查数据线及被控机 USB 端口。可稍后连接或重新配置。', + "deviceTitle": "驱动与设备", + "features": "功能配置", + "saveFeatures": "保存功能配置", + "configure": "配置驱动", + "reconfigure": "重新配置", + "driver": "HID 驱动", + "driver_otg": "USB OTG", + "driver_ch9329": "CH9329", + "driver_bluetooth": "蓝牙 HID", + "driver_none": "禁用", + "device_otg": "UDC", + "device_ch9329": "串口", + "device_bluetooth": "本机蓝牙适配器", + "host": "被控机", + "hostUsb": "被控机 USB", + "otgPort": "One-KVM OTG USB", + "serialLink": "CH340 → 串口连接 → CH9329", + "integratedCable": "CH340 + CH9329 一体线", + "dataCable": "USB 数据线", + "wireless": "蓝牙无线连接", + "wiring_otg": "One-KVM 的 OTG USB 端口 → USB 数据线 → 被控机 USB 端口。", + "wiring_ch9329": "One-KVM 通过串口连接 CH9329,CH9329 的 USB 连接被控机。", + "wiring_bluetooth": "在被控机打开系统设置 → 添加蓝牙设备 → 选择广播的 HID 设备名称。", + "disabledHelp": "禁用后 One-KVM 无法发送键盘和鼠标输入。", + "selectDevice": "请选择具体设备", + "noDevices": "未找到可用设备,请检查连接后刷新。", + "nameInvalid": "名称须为 1–64 个 UTF-8 字节,且不能含控制字符。", + "unconfigured": "尚未配置 HID", + "disabled": "HID 已禁用", + "legacyAuto": "自动选择(旧配置)", + "unknown": "状态未知或读取失败", + "ready": "HID 可用", + "preparing": "已连接,HID 准备中", + "paired": "已配对,等待连接", + "pairing": "等待配对", + "waiting": "已应用,等待连接", + "initializing": "正在初始化", + "appliedHelp": "配置已应用。关闭引导会保留配置和已完成的配对。", + "draftHelp": "选择驱动和设备,点击应用后才会更改当前配置。", + "dirty": "功能配置有未保存的修改。请返回保存,或放弃修改后继续。", + "returnSave": "返回保存", + "discard": "放弃修改并继续", + "pairInstructions": "请在被控机的系统设置中添加蓝牙设备,选择“{name}”。配对窗口为 120 秒。", + "pairTimeout": "配对窗口已结束,可重新开启。", + "reopenPairing": "重新开启配对", + "resetWarning": "将清除原 One-KVM HID 绑定;请在被控机中删除旧设备后重新添加。", + "disableUsb": "切出 USB OTG 将关闭:{functions}。", + "msd": "虚拟磁盘", + "network": "USB 网络", + "audio": "USB 音频", + "done": "完成", + "later": "稍后连接", + "configureLater": "稍后配置", + "useConfiguration": "使用此配置", + "repair": "重新配对", + "uncertain": "上次请求结果不明。可先检查连接;再次应用将重新清除绑定。", + "checkConnection": "检查已应用配置", + "resumeRetry": "已读取当前配置,与待应用选择不一致。请检查选择后重试。" +}, + bluetoothHid: { + paired: '已配对,等待电脑建立键鼠连接', + "description": "经典蓝牙键盘和相对鼠标。", + "adapter": "蓝牙适配器", + "name": "设备名称", + "peer": "被控电脑地址(可选)", + "peerHelp": "留空时复用唯一的已绑定设备,或在配对时选择电脑。", + "ready": "键盘和鼠标已就绪", + "pairing": "配对窗口剩余 {seconds} 秒", + "connected": "已连接,等待 HID 通道建立", + "waiting": "等待已配对电脑连接", + "unavailable": "蓝牙不可用或正在启动", + "openPairing": "开启配对(2 分钟)", + "closePairing": "关闭配对", + "disconnect": "断开连接", + "forget": "忘记电脑", + "windows": "Windows 11:设置 → 蓝牙和其他设备 → 添加设备 → 蓝牙。忘记设备后,请同时在 Windows 删除设备再重新配对。", + "applyFirst": "先应用 HID 设置,再开启配对。" +}, + videoInput: { format: '输入格式', resolution: '分辨率', diff --git a/web/src/lib/hidGuide.ts b/web/src/lib/hidGuide.ts new file mode 100644 index 00000000..cfdcbc69 --- /dev/null +++ b/web/src/lib/hidGuide.ts @@ -0,0 +1,73 @@ +import type { HidConfig, HidConfigUpdate } from '../types/generated' +import type { BluetoothStatus } from '../types/bluetooth' +export type Driver = 'otg' | 'ch9329' | 'bluetooth' | 'none' +export interface HidSelection { + backend: Driver; otg_udc: string; ch9329_port: string; ch9329_baudrate: number + bluetooth: { adapter: string; name: string } +} +export function selectionFrom(hid?: HidConfig | null): HidSelection { + return { backend: hid?.backend ?? 'ch9329', otg_udc: hid?.otg_udc ?? '', + ch9329_port: hid?.ch9329_port ?? '', ch9329_baudrate: hid?.ch9329_baudrate ?? 9600, + bluetooth: { adapter: hid?.bluetooth.adapter ?? '', name: hid?.bluetooth.name ?? 'One-KVM HID' } } +} +export function selectDevice(saved: string, candidates: string[]): string { + return candidates.includes(saved) ? saved : candidates.length === 1 ? candidates[0]! : '' +} +export function validName(name: string): boolean { + const length = new TextEncoder().encode(name).length + return length > 0 && length <= 64 && !/[\u0000-\u001f\u007f-\u009f]/u.test(name) +} +export function deviceRequest(draft: HidSelection): HidConfigUpdate { + const backend = draft.backend as HidConfigUpdate['backend'] + switch (draft.backend) { + case 'otg': return { backend, otg_udc: draft.otg_udc } + case 'ch9329': return { backend, ch9329_port: draft.ch9329_port, ch9329_baudrate: draft.ch9329_baudrate } + case 'bluetooth': return { backend, bluetooth: { ...draft.bluetooth }, bluetooth_reset_pairing: true } + case 'none': return { backend } + } +} +export function matchesSelection(hid: HidConfig, draft: HidSelection): boolean { + if (hid.backend !== draft.backend) return false + switch (draft.backend) { + case 'otg': return hid.otg_udc === draft.otg_udc + case 'ch9329': return hid.ch9329_port === draft.ch9329_port && hid.ch9329_baudrate === draft.ch9329_baudrate + case 'bluetooth': return hid.bluetooth.adapter === draft.bluetooth.adapter && hid.bluetooth.name === draft.bluetooth.name + case 'none': return true + } +} +export function bluetoothStage(status: BluetoothStatus | null): string { + if (!status || status.error) return 'unknown' + if (status.ready) return 'ready' + if (status.connected) return 'preparing' + if (status.devices.some(d => d.address === status.peer && d.paired)) return 'paired' + if (status.pairing_seconds > 0) return 'pairing' + return status.initialized ? 'waiting' : 'initializing' +} +export interface HidDeviceStatus { + backend: string; online: boolean; error?: string | null; error_code?: string | null +} +export function hidDeviceError(status?: HidDeviceStatus | null): string | null { + // An unplugged OTG cable is an expected connection state, not a hardware fault. + if (status?.backend === 'otg' && status.error_code === 'udc_not_configured') return null + return status?.error ?? null +} +export function hidDeviceStage(status: HidDeviceStatus | null | undefined, bluetooth: BluetoothStatus | null, readError = ''): string { + if (!status || readError || hidDeviceError(status)) return 'unknown' + if (status.backend === 'bluetooth') return bluetoothStage(bluetooth) + if (status.backend === 'otg' && status.error_code === 'udc_not_configured') return 'waiting' + return status.online ? 'ready' : 'waiting' +} +export const pendingHidKey = 'one-kvm.pending-hid.v1' +export interface PendingHid { selection: HidSelection; phase: 'selected' | 'applying' | 'applied' } +export function readPendingHid(): PendingHid | null { + try { + const value = JSON.parse(sessionStorage.getItem(pendingHidKey) ?? 'null') + if (value && ['selected', 'applying', 'applied'].includes(value.phase) + && ['otg', 'ch9329', 'bluetooth', 'none'].includes(value.selection?.backend) + && typeof value.selection.otg_udc === 'string' && typeof value.selection.ch9329_port === 'string' + && typeof value.selection.ch9329_baudrate === 'number' + && typeof value.selection.bluetooth?.adapter === 'string' && typeof value.selection.bluetooth?.name === 'string') return value + } catch { /* Invalid or unavailable session storage is not an applied configuration. */ } + return null +} +export function writePendingHid(value: PendingHid) { sessionStorage.setItem(pendingHidKey, JSON.stringify(value)) } diff --git a/web/src/lib/hidStatus.ts b/web/src/lib/hidStatus.ts new file mode 100644 index 00000000..dc8ec432 --- /dev/null +++ b/web/src/lib/hidStatus.ts @@ -0,0 +1,41 @@ +type Status = 'connected' | 'connecting' | 'disconnected' | 'error' + +interface HidState { + available: boolean + initialized: boolean + online: boolean + backend: string + error?: string | null + errorCode?: string | null +} + +interface InputTransport { + useWebRtc: boolean + dataChannelReady: boolean + rtcConnecting: boolean + rtcConnected: boolean + wsConnected: boolean + wsNetworkError: boolean + wsHidUnavailable: boolean +} + +export function getHidStatus(hid: HidState | null, transport: InputTransport): Status { + if (hid?.errorCode === 'udc_not_configured') return 'disconnected' + if (hid?.error) return 'error' + if (!hid?.available) return 'disconnected' + + // A browser DataChannel can stay open after the controlled computer disconnects. + // Both the HID backend and the browser input transport must be ready. + if (!hid.online) { + return hid.initialized && hid.backend !== 'bluetooth' ? 'connecting' : 'disconnected' + } + + if (transport.useWebRtc) { + if (transport.dataChannelReady) return 'connected' + if (transport.rtcConnecting || transport.rtcConnected) return 'connecting' + } + + if (transport.wsNetworkError) return 'connecting' + if (!transport.wsConnected || transport.wsHidUnavailable) return 'disconnected' + return 'connected' +} diff --git a/web/src/stores/config.ts b/web/src/stores/config.ts index 0cd16ee9..81a0d6c7 100644 --- a/web/src/stores/config.ts +++ b/web/src/stores/config.ts @@ -516,8 +516,8 @@ export const useConfigStore = defineStore('config', () => { return response } - async function updateHid(update: HidConfigUpdate) { - const response = await hidConfigApi.update(update) + async function updateHid(update: HidConfigUpdate, signal?: AbortSignal) { + const response = await hidConfigApi.update(update, signal) hid.value = response return response } diff --git a/web/src/types/bluetooth.ts b/web/src/types/bluetooth.ts new file mode 100644 index 00000000..f0bc9f3a --- /dev/null +++ b/web/src/types/bluetooth.ts @@ -0,0 +1,7 @@ +export interface BluetoothAdapter { name: string; address: string; powered: boolean } +export interface BluetoothStatus { + initialized: boolean; connected: boolean; ready: boolean; adapter: string; adapter_address: string + peer?: string | null; pairing_seconds: number; control_connected: boolean; interrupt_connected: boolean + error?: string | null + devices: Array<{ address: string; name: string; paired: boolean; connected: boolean }> +} diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index 4712034e..5b47af16 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -16,9 +16,16 @@ export interface VideoConfig { quality: number; } +export interface BluetoothHidConfig { + adapter: string; + name: string; + peer?: string; +} + export enum HidBackend { Otg = "otg", Ch9329 = "ch9329", + Bluetooth = "bluetooth", None = "none", } @@ -54,6 +61,7 @@ export interface Ch9329DescriptorConfig { } export interface HidConfig { + bluetooth: BluetoothHidConfig; backend: HidBackend; otg_udc?: string; otg_descriptor?: OtgDescriptorConfig; @@ -304,6 +312,13 @@ export interface WatchdogConfig { enabled: boolean; } +/** Configuration for the USB Audio Class microphone gadget. */ +export interface UacConfig { + enabled: boolean; + sample_rate: number; + channels: number; +} + export interface AppConfig { initialized: boolean; auth: AuthConfig; @@ -322,6 +337,7 @@ export interface AppConfig { rtsp: RtspConfig; redfish: RedfishConfig; watchdog: WatchdogConfig; + uac: UacConfig; } /** Update for a single ATX output binding */ @@ -548,6 +564,8 @@ export interface OtgHidFunctionsUpdate { } export interface HidConfigUpdate { + bluetooth_reset_pairing?: boolean; + bluetooth?: BluetoothHidConfig; backend?: HidBackend; ch9329_port?: string; ch9329_baudrate?: number; diff --git a/web/src/views/ConsoleView.vue b/web/src/views/ConsoleView.vue index e774fbfb..93444222 100644 --- a/web/src/views/ConsoleView.vue +++ b/web/src/views/ConsoleView.vue @@ -25,6 +25,7 @@ import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/key import { toast } from 'vue-sonner' import { cn, generateUUID } from '@/lib/utils' import { formatFpsValue } from '@/lib/fps' +import { getHidStatus } from '@/lib/hidStatus' import { videoDebugLog } from '@/lib/debugLog' import { formatVideoDeviceLabel } from '@/lib/video-device-label' import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/lib/streamSignal' @@ -212,7 +213,7 @@ const isConsoleActive = ref(false) function syncMouseModeFromConfig() { const mouseAbsolute = configStore.hid?.mouse_absolute if (typeof mouseAbsolute !== 'boolean') return - const nextMode: 'absolute' | 'relative' = mouseAbsolute ? 'absolute' : 'relative' + const nextMode: 'absolute' | 'relative' = mouseAbsolute && configStore.hid?.backend !== 'bluetooth' ? 'absolute' : 'relative' if (mouseMode.value !== nextMode) { resetTouchInput() mouseMode.value = nextMode @@ -353,27 +354,15 @@ const videoDetails = computed(() => { return details }) -const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => { - const hid = systemStore.hid - if (hid?.errorCode === 'udc_not_configured') return 'disconnected' - if (hid?.error) return 'error' - - if (videoMode.value !== 'mjpeg') { - if (webrtc.dataChannelReady.value) return 'connected' - if (webrtc.isConnecting.value) return 'connecting' - if (webrtc.isConnected.value) return 'connecting' - } - - if (hidWs.networkError.value) return 'connecting' - - if (!hidWs.connected.value) return 'disconnected' - - if (hidWs.hidUnavailable.value) return 'disconnected' - - if (hid?.available && hid.online) return 'connected' - if (hid?.available && hid.initialized) return 'connecting' - return 'disconnected' -}) +const hidStatus = computed(() => getHidStatus(systemStore.hid, { + useWebRtc: videoMode.value !== 'mjpeg', + dataChannelReady: webrtc.dataChannelReady.value, + rtcConnecting: webrtc.isConnecting.value, + rtcConnected: webrtc.isConnected.value, + wsConnected: hidWs.connected.value, + wsNetworkError: hidWs.networkError.value, + wsHidUnavailable: hidWs.hidUnavailable.value, +})) const hidQuickInfo = computed(() => { const hid = systemStore.hid @@ -3072,7 +3061,7 @@ function handleToggleMouseMode() { exitPointerLock() } - mouseMode.value = mouseMode.value === 'absolute' ? 'relative' : 'absolute' + mouseMode.value = configStore.hid?.backend === 'bluetooth' ? 'relative' : (mouseMode.value === 'absolute' ? 'relative' : 'absolute') pendingMouseMove = null accumulatedDelta = { x: 0, y: 0 } lastMousePosition.value = { x: 0, y: 0 } diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 22b188f5..f076783d 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -1,5 +1,7 @@