From c101ef1c80b326849394b76356a7a4623fb948aa Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Mon, 15 Jun 2026 22:23:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20MJPEG/H.264=20VNC?= =?UTF-8?q?=20=E5=88=9D=E6=AD=A5=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 3 + src/config/schema/mod.rs | 1 + src/config/schema/stream.rs | 38 ++ src/lib.rs | 2 + src/main.rs | 52 ++- src/platform/android.rs | 1 + src/platform/capabilities.rs | 1 + src/platform/linux.rs | 1 + src/platform/windows.rs | 2 + src/runtime/android.rs | 40 ++- src/rustdesk/config.rs | 12 + src/rustdesk/connection.rs | 147 ++------ src/state.rs | 6 + src/stream/mjpeg.rs | 22 +- src/video/codec_constraints.rs | 188 ++++++++-- src/video/streamer.rs | 4 +- src/vnc/mod.rs | 370 +++++++++++++++++++ src/vnc/rfb.rs | 529 ++++++++++++++++++++++++++++ src/web/handlers/config/apply.rs | 100 +++++- src/web/handlers/config/mod.rs | 5 + src/web/handlers/config/rtsp.rs | 107 +++--- src/web/handlers/config/rustdesk.rs | 138 +++----- src/web/handlers/config/types.rs | 126 +++++++ src/web/handlers/config/vnc.rs | 110 ++++++ src/web/handlers/stream.rs | 2 + src/web/handlers/system.rs | 6 + src/web/routes.rs | 9 + web/src/api/config.ts | 46 +++ web/src/api/index.ts | 8 + web/src/i18n/en-US.ts | 31 +- web/src/i18n/zh-CN.ts | 31 +- web/src/stores/config.ts | 74 ++++ web/src/types/generated.ts | 64 +++- web/src/views/SettingsView.vue | 348 ++++++++++++++++-- 34 files changed, 2270 insertions(+), 354 deletions(-) create mode 100644 src/vnc/mod.rs create mode 100644 src/vnc/rfb.rs create mode 100644 src/web/handlers/config/vnc.rs diff --git a/Cargo.toml b/Cargo.toml index 097ee2da..ce35f652 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ desktop = [ "dep:ventoy-img", "dep:protobuf", "dep:sodiumoxide", + "dep:des", "dep:sha2", "dep:typeshare", "dep:hwcodec", @@ -104,6 +105,7 @@ android = [ "dep:serialport", "dep:sha2", "dep:sodiumoxide", + "dep:des", "dep:sqlx", "dep:alsa", "dep:audiopus", @@ -222,6 +224,7 @@ ventoy-img = { path = "libs/ventoy-img-rs", optional = true } # RustDesk protocol support protobuf = { version = "3.7", features = ["with-bytes"], optional = true } sodiumoxide = { version = "0.2", optional = true } +des = { version = "0.8", optional = true } sha2 = { version = "0.10", optional = true } # TypeScript type generation typeshare = { version = "1.0", optional = true } diff --git a/src/config/schema/mod.rs b/src/config/schema/mod.rs index c42f8f3f..69078afd 100644 --- a/src/config/schema/mod.rs +++ b/src/config/schema/mod.rs @@ -32,6 +32,7 @@ pub struct AppConfig { pub web: WebConfig, pub extensions: ExtensionsConfig, pub rustdesk: RustDeskConfig, + pub vnc: VncConfig, pub rtsp: RtspConfig, pub redfish: RedfishConfig, } diff --git a/src/config/schema/stream.rs b/src/config/schema/stream.rs index d201d829..c0a10411 100644 --- a/src/config/schema/stream.rs +++ b/src/config/schema/stream.rs @@ -23,6 +23,44 @@ pub enum RtspCodec { H265, } +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum VncEncoding { + #[default] + TightJpeg, + H264, +} + +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct VncConfig { + pub enabled: bool, + pub bind: String, + pub port: u16, + pub encoding: VncEncoding, + pub jpeg_quality: u8, + pub allow_one_client: bool, + #[typeshare(skip)] + pub password: Option, +} + +impl Default for VncConfig { + fn default() -> Self { + Self { + enabled: false, + bind: "0.0.0.0".to_string(), + port: 5900, + encoding: VncEncoding::TightJpeg, + jpeg_quality: 80, + allow_one_client: true, + password: None, + } + } +} + #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] diff --git a/src/lib.rs b/src/lib.rs index 6ab92e95..93184e9a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,6 +51,8 @@ pub mod utils; #[cfg(any(feature = "android", feature = "desktop"))] pub mod video; #[cfg(any(feature = "android", feature = "desktop"))] +pub mod vnc; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod web; #[cfg(any(feature = "android", feature = "desktop"))] pub mod webrtc; diff --git a/src/main.rs b/src/main.rs index 53f7cd32..268d2f07 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,10 +31,12 @@ use one_kvm::state::{AppState, ShutdownAction}; use one_kvm::update::UpdateService; use one_kvm::utils::bind_tcp_listener; use one_kvm::video::codec_constraints::{ - enforce_constraints_with_stream_manager, StreamCodecConstraints, + 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}; @@ -486,7 +488,18 @@ async fn main() -> anyhow::Result<()> { ); } - let rustdesk = if config.rustdesk.is_valid() { + 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, @@ -510,7 +523,7 @@ async fn main() -> anyhow::Result<()> { None }; - let rtsp = if config.rtsp.enabled { + let rtsp = if third_party_codec_config_valid && config.rtsp.enabled { tracing::info!( "Initializing RTSP service: rtsp://{}:{}/{}", config.rtsp.bind, @@ -524,6 +537,23 @@ async fn main() -> anyhow::Result<()> { 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(data_dir.join("updates"))); let state = AppState::new( @@ -541,6 +571,7 @@ async fn main() -> anyhow::Result<()> { atx, audio, rustdesk.clone(), + vnc.clone(), rtsp.clone(), extensions.clone(), events.clone(), @@ -573,6 +604,13 @@ async fn main() -> anyhow::Result<()> { 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 { @@ -1135,6 +1173,14 @@ async fn cleanup(state: &Arc) { } } + 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); diff --git a/src/platform/android.rs b/src/platform/android.rs index 03f0f1cc..fd8614d2 100644 --- a/src/platform/android.rs +++ b/src/platform/android.rs @@ -36,6 +36,7 @@ pub fn capabilities() -> PlatformCapabilities { audio: FeatureCapability::available(["alsa", "opus"]) .with_selected_backend(Some("alsa".to_string())), rustdesk: FeatureCapability::available(["builtin"]), + vnc: FeatureCapability::available(["builtin", "tight_jpeg", "h264"]), diagnostics: FeatureCapability::available(["android_linux"]), extensions: FeatureCapability::unsupported("unsupported on Android Amlogic v1"), service_installation: FeatureCapability::available(["android_foreground_service"]), diff --git a/src/platform/capabilities.rs b/src/platform/capabilities.rs index 1308fec4..e7566721 100644 --- a/src/platform/capabilities.rs +++ b/src/platform/capabilities.rs @@ -78,6 +78,7 @@ pub struct PlatformCapabilities { pub otg: FeatureCapability, pub audio: FeatureCapability, pub rustdesk: FeatureCapability, + pub vnc: FeatureCapability, pub diagnostics: FeatureCapability, pub extensions: FeatureCapability, pub service_installation: FeatureCapability, diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 0035e249..5161a942 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -16,6 +16,7 @@ pub fn capabilities() -> PlatformCapabilities { otg: FeatureCapability::available(["configfs"]), audio: FeatureCapability::available(["alsa"]), rustdesk: FeatureCapability::available(["builtin"]), + vnc: FeatureCapability::available(["builtin", "tight_jpeg", "h264"]), diagnostics: FeatureCapability::available(["linux"]), extensions: FeatureCapability::available(["linux"]), service_installation: FeatureCapability::available(["systemd"]), diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 25e91939..21e0bbfd 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -26,6 +26,8 @@ pub fn capabilities() -> PlatformCapabilities { .with_selected_backend(Some("wasapi".to_string())), rustdesk: FeatureCapability::available(["builtin", "tcp_direct", "relay"]) .with_selected_backend(Some("builtin".to_string())), + vnc: FeatureCapability::available(["builtin", "tight_jpeg", "h264"]) + .with_selected_backend(Some("builtin".to_string())), diagnostics: FeatureCapability::available(["windows"]), extensions: FeatureCapability::available(["windows_safe"]), service_installation: FeatureCapability::available(["windows_service"]), diff --git a/src/runtime/android.rs b/src/runtime/android.rs index a654a88e..226458a2 100644 --- a/src/runtime/android.rs +++ b/src/runtime/android.rs @@ -32,10 +32,12 @@ use crate::stream_encoder::encoder_type_to_backend; use crate::update::UpdateService; use crate::utils::bind_tcp_listener; use crate::video::codec_constraints::{ - enforce_constraints_with_stream_manager, StreamCodecConstraints, + enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility, + StreamCodecConstraints, }; use crate::video::format::{PixelFormat, Resolution}; use crate::video::{Streamer, VideoStreamManager}; +use crate::vnc::VncService; use crate::web; use crate::webrtc::{config::WebRtcConfig, WebRtcStreamer, WebRtcStreamerConfig}; @@ -440,7 +442,18 @@ async fn build_app_state( tracing::warn!("Failed to initialize Android stream manager: {}", err); } - let rustdesk = if config.rustdesk.is_valid() { + let third_party_codec_config_valid = match validate_third_party_codec_compatibility(&config) { + Ok(()) => true, + Err(e) => { + tracing::warn!( + "Android 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() { Some(Arc::new(RustDeskService::new( config.rustdesk.clone(), stream_manager.clone(), @@ -451,7 +464,7 @@ async fn build_app_state( None }; - let rtsp = if config.rtsp.enabled { + let rtsp = if third_party_codec_config_valid && config.rtsp.enabled { Some(Arc::new(RtspService::new( config.rtsp.clone(), stream_manager.clone(), @@ -459,6 +472,15 @@ async fn build_app_state( } else { None }; + let vnc = if third_party_codec_config_valid && config.vnc.enabled { + Some(Arc::new(VncService::new( + config.vnc.clone(), + stream_manager.clone(), + hid.clone(), + ))) + } else { + None + }; let update_service = Arc::new(UpdateService::new(data_dir.join("updates"))); let state = AppState::new( @@ -474,6 +496,7 @@ async fn build_app_state( atx, audio, rustdesk.clone(), + vnc.clone(), rtsp.clone(), extensions.clone(), events.clone(), @@ -489,6 +512,11 @@ async fn build_app_state( tracing::warn!("Failed to start Android RustDesk service: {}", err); } } + if let Some(service) = vnc { + if let Err(err) = service.start().await { + tracing::warn!("Failed to start Android VNC service: {}", err); + } + } if let Some(service) = rtsp { if let Err(err) = service.start().await { tracing::warn!("Failed to start Android RTSP service: {}", err); @@ -674,6 +702,12 @@ async fn cleanup(state: &Arc) { } } + if let Some(service) = state.vnc.read().await.as_ref() { + if let Err(err) = service.stop().await { + tracing::warn!("Failed to stop Android VNC service: {}", err); + } + } + if let Some(service) = state.rtsp.read().await.as_ref() { if let Err(err) = service.stop().await { tracing::warn!("Failed to stop Android RTSP service: {}", err); diff --git a/src/rustdesk/config.rs b/src/rustdesk/config.rs index cd954126..8c44c143 100644 --- a/src/rustdesk/config.rs +++ b/src/rustdesk/config.rs @@ -1,11 +1,22 @@ use serde::{Deserialize, Serialize}; use typeshare::typeshare; +#[typeshare] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +#[derive(Default)] +pub enum RustDeskCodec { + #[default] + H264, + H265, +} + #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct RustDeskConfig { pub enabled: bool, + pub codec: RustDeskCodec, pub rendezvous_server: String, pub relay_server: Option, #[typeshare(skip)] @@ -29,6 +40,7 @@ impl Default for RustDeskConfig { fn default() -> Self { Self { enabled: false, + codec: RustDeskCodec::H264, rendezvous_server: String::new(), relay_server: None, relay_key: None, diff --git a/src/rustdesk/connection.rs b/src/rustdesk/connection.rs index e2001ec4..a435e4ee 100644 --- a/src/rustdesk/connection.rs +++ b/src/rustdesk/connection.rs @@ -18,9 +18,7 @@ use crate::hid::{CanonicalKey, HidController, KeyEventType, KeyboardEvent, Keybo use crate::utils::hostname_from_etc; use crate::video::codec::registry::{EncoderRegistry, VideoEncoderType}; use crate::video::codec::BitratePreset; -use crate::video::codec_constraints::{ - encoder_codec_to_id, encoder_codec_to_video_codec, video_codec_to_encoder_codec, -}; +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}; @@ -160,6 +158,8 @@ pub struct Connection { last_caps_lock: bool, /// Whether relative mouse mode is currently active for this connection relative_mouse_active: bool, + /// Server-configured RustDesk video codec. + configured_codec: VideoEncoderType, } /// Messages sent to connection handler @@ -209,6 +209,11 @@ impl Connection { // This is used for encrypting the symmetric key exchange let temp_keypair = box_::gen_keypair(); + let configured_codec = match config.codec { + super::config::RustDeskCodec::H264 => VideoEncoderType::H264, + super::config::RustDeskCodec::H265 => VideoEncoderType::H265, + }; + let conn = Self { id, device_id: config.device_id.clone(), @@ -238,6 +243,7 @@ impl Connection { last_test_delay_sent: None, last_caps_lock: false, relative_mouse_active: false, + configured_codec, }; (conn, rx) @@ -628,43 +634,29 @@ impl Connection { Ok(true) } - /// Negotiate video codec - select the best available encoder - /// Priority: H264 > H265 > VP8 > VP9 (H264/H265 leverage hardware encoding on embedded devices) + /// Negotiate video codec from the server-configured RustDesk codec. async fn negotiate_video_codec(&self) -> VideoEncoderType { let registry = EncoderRegistry::global(); let constraints = self.current_codec_constraints().await; + let configured = self.configured_codec; - // Check availability in priority order - // H264 is preferred because it has the best hardware encoder support (RKMPP, VAAPI, etc.) - // and most RustDesk clients support H264 hardware decoding - if constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H264) - && registry.is_codec_available(VideoEncoderType::H264) - { - return VideoEncoderType::H264; - } - if constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H265) - && registry.is_codec_available(VideoEncoderType::H265) - { - return VideoEncoderType::H265; - } - if constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::VP8) - && registry.is_codec_available(VideoEncoderType::VP8) - { - return VideoEncoderType::VP8; - } - if constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::VP9) - && registry.is_codec_available(VideoEncoderType::VP9) - { - return VideoEncoderType::VP9; + if !constraints.is_webrtc_codec_allowed(encoder_codec_to_video_codec(configured)) { + warn!( + "Configured RustDesk codec {} is blocked by constraints: {}", + encoder_codec_to_id(configured), + constraints.reason + ); + return configured; } - // Fallback to preferred allowed codec - let preferred = constraints.preferred_webrtc_codec(); - warn!( - "No allowed encoder available in priority order, falling back to {}", - encoder_codec_to_id(video_codec_to_encoder_codec(preferred)) - ); - video_codec_to_encoder_codec(preferred) + if !registry.is_codec_available(configured) { + warn!( + "Configured RustDesk codec {} is not reported available; attempting to use it anyway", + encoder_codec_to_id(configured) + ); + } + + configured } async fn current_codec_constraints( @@ -740,53 +732,10 @@ impl Connection { } } - // Check if client sent supported_decoding with a codec preference + // Codec switching is locked to the server-configured RustDesk codec. if let Some(supported_decoding) = opt.supported_decoding.as_ref() { let prefer = supported_decoding.prefer.value(); debug!("Client codec preference: prefer={}", prefer); - - // Map RustDesk PreferCodec enum to our VideoEncoderType - // From proto: Auto=0, VP9=1, H264=2, H265=3, VP8=4, AV1=5 - let requested_codec = match prefer { - 1 => Some(VideoEncoderType::VP9), - 2 => Some(VideoEncoderType::H264), - 3 => Some(VideoEncoderType::H265), - 4 => Some(VideoEncoderType::VP8), - // Auto(0) or AV1(5) or unknown: use current or negotiate - _ => None, - }; - - if let Some(new_codec) = requested_codec { - // Check if this codec is different from current and available - if self.negotiated_codec != Some(new_codec) { - let constraints = self.current_codec_constraints().await; - if !constraints.is_webrtc_codec_allowed(encoder_codec_to_video_codec(new_codec)) - { - warn!( - "Client requested codec {:?} but it's blocked by constraints: {}", - new_codec, constraints.reason - ); - return Ok(()); - } - - let registry = EncoderRegistry::global(); - if registry.is_codec_available(new_codec) { - info!( - "Client requested codec switch: {:?} -> {:?}", - self.negotiated_codec, new_codec - ); - // Switch codec - if let Err(e) = self.switch_video_codec(new_codec).await { - warn!("Failed to switch video codec: {}", e); - } - } else { - warn!( - "Client requested codec {:?} but it's not available", - new_codec - ); - } - } - } } // Log custom_image_quality (accept but don't process) @@ -803,31 +752,6 @@ impl Connection { Ok(()) } - /// Switch video codec dynamically - /// Stops current video task, changes codec, and restarts - async fn switch_video_codec(&mut self, new_codec: VideoEncoderType) -> anyhow::Result<()> { - // Stop current video streaming task - if let Some(task) = self.video_task.take() { - info!("Stopping video task for codec switch"); - task.abort(); - // Wait a bit for cleanup - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - } - - // Update negotiated codec - self.negotiated_codec = Some(new_codec); - - // Restart video streaming with new codec if we have a video_tx - if let Some(ref video_tx) = self.video_frame_tx { - info!("Restarting video streaming with codec {:?}", new_codec); - self.start_video_streaming(video_tx.clone()); - } else { - warn!("No video_tx available, cannot restart video streaming"); - } - - Ok(()) - } - /// Start video streaming task fn start_video_streaming(&mut self, video_tx: mpsc::Sender) { let video_manager = match &self.video_manager { @@ -1105,18 +1029,15 @@ impl Connection { let constraints = self.current_codec_constraints().await; // Check which encoders are available (include software fallback) - let h264_available = constraints - .is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H264) + let configured = self.configured_codec; + let h264_available = configured == VideoEncoderType::H264 + && constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H264) && registry.is_codec_available(VideoEncoderType::H264); - let h265_available = constraints - .is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H265) + let h265_available = configured == VideoEncoderType::H265 + && constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H265) && registry.is_codec_available(VideoEncoderType::H265); - let vp8_available = constraints - .is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::VP8) - && registry.is_codec_available(VideoEncoderType::VP8); - let vp9_available = constraints - .is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::VP9) - && registry.is_codec_available(VideoEncoderType::VP9); + let vp8_available = false; + let vp9_available = false; info!( "Server encoding capabilities: H264={}, H265={}, VP8={}, VP9={}", diff --git a/src/state.rs b/src/state.rs index 6c841d30..2e09775b 100644 --- a/src/state.rs +++ b/src/state.rs @@ -20,6 +20,7 @@ use crate::rtsp::RtspService; use crate::rustdesk::RustDeskService; use crate::update::UpdateService; use crate::video::VideoStreamManager; +use crate::vnc::VncService; use crate::webrtc::WebRtcStreamer; #[derive(Clone)] @@ -30,6 +31,7 @@ pub struct ConfigApplyLocks { pub audio: Arc>, pub atx: Arc>, pub rustdesk: Arc>, + pub vnc: Arc>, pub rtsp: Arc>, } @@ -48,6 +50,7 @@ impl ConfigApplyLocks { audio: Arc::new(Mutex::new(())), atx: Arc::new(Mutex::new(())), rustdesk: Arc::new(Mutex::new(())), + vnc: Arc::new(Mutex::new(())), rtsp: Arc::new(Mutex::new(())), } } @@ -69,6 +72,7 @@ pub struct AppState { pub atx: Arc>>, pub audio: Arc, pub rustdesk: Arc>>>, + pub vnc: Arc>>>, pub rtsp: Arc>>>, pub extensions: Arc, pub events: Arc, @@ -95,6 +99,7 @@ impl AppState { atx: Option, audio: Arc, rustdesk: Option>, + vnc: Option>, rtsp: Option>, extensions: Arc, events: Arc, @@ -119,6 +124,7 @@ impl AppState { 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)), extensions, events, diff --git a/src/stream/mjpeg.rs b/src/stream/mjpeg.rs index 108d4d95..981e9211 100644 --- a/src/stream/mjpeg.rs +++ b/src/stream/mjpeg.rs @@ -396,15 +396,29 @@ impl MjpegStreamHandler { } pub fn disconnect_all_clients(&self) { + self.disconnect_clients_matching(|_| true); + } + + pub fn disconnect_non_vnc_clients(&self) { + self.disconnect_clients_matching(|id| !id.starts_with("vnc-")); + } + + fn disconnect_clients_matching(&self, should_disconnect: impl Fn(&str) -> bool) { let count = { let mut clients = self.clients.write(); - let count = clients.len(); - clients.clear(); - count + let before = clients.len(); + clients.retain(|id, _| !should_disconnect(id)); + before - clients.len() }; + let remaining = self.client_count(); if count > 0 { - info!("Disconnected all {} MJPEG clients for config change", count); + info!( + "Disconnected {} MJPEG clients for config change (remaining: {})", + count, remaining + ); } + // Wake all subscribers. HTTP MJPEG clients will close, while persistent + // consumers such as VNC wait for the next frame after capture restarts. self.set_offline(); } } diff --git a/src/video/codec_constraints.rs b/src/video/codec_constraints.rs index bde68f4a..0cde662f 100644 --- a/src/video/codec_constraints.rs +++ b/src/video/codec_constraints.rs @@ -1,5 +1,6 @@ -use crate::config::{AppConfig, RtspCodec, StreamMode}; -use crate::error::Result; +use crate::config::{AppConfig, RtspCodec, StreamMode, VncEncoding}; +use crate::error::{AppError, Result}; +use crate::rustdesk::config::RustDeskCodec; use crate::video::codec::registry::VideoEncoderType; use crate::video::codec::VideoCodecType; use crate::video::VideoStreamManager; @@ -9,6 +10,7 @@ use std::sync::Arc; pub struct StreamCodecConstraints { pub rustdesk_enabled: bool, pub rtsp_enabled: bool, + pub vnc_enabled: bool, pub allowed_webrtc_codecs: Vec, pub allow_mjpeg: bool, pub locked_codec: Option, @@ -21,11 +23,37 @@ pub struct ConstraintEnforcementResult { pub message: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThirdPartyCodecLock { + H26x(VideoCodecType), + Mjpeg, +} + +impl ThirdPartyCodecLock { + fn label(self) -> &'static str { + match self { + ThirdPartyCodecLock::H26x(codec) => codec_to_id(codec), + ThirdPartyCodecLock::Mjpeg => "mjpeg", + } + } + + fn compatible_with(self, other: Self) -> bool { + self == other + } +} + +#[derive(Debug, Clone, Copy)] +struct ThirdPartySourceLock { + source: &'static str, + lock: ThirdPartyCodecLock, +} + impl StreamCodecConstraints { pub fn unrestricted() -> Self { Self { rustdesk_enabled: false, rtsp_enabled: false, + vnc_enabled: false, allowed_webrtc_codecs: vec![ VideoCodecType::H264, VideoCodecType::H265, @@ -41,42 +69,39 @@ impl StreamCodecConstraints { pub fn from_config(config: &AppConfig) -> Self { let rustdesk_enabled = config.rustdesk.enabled; let rtsp_enabled = config.rtsp.enabled; + let vnc_enabled = config.vnc.enabled; - if rtsp_enabled { - let locked_codec = match config.rtsp.codec { - RtspCodec::H264 => VideoCodecType::H264, - RtspCodec::H265 => VideoCodecType::H265, - }; - return Self { - rustdesk_enabled, - rtsp_enabled, - allowed_webrtc_codecs: vec![locked_codec], - allow_mjpeg: false, - locked_codec: Some(locked_codec), - reason: if rustdesk_enabled { - format!( - "RTSP enabled with codec lock ({:?}) and RustDesk enabled", - locked_codec - ) - } else { - format!("RTSP enabled with codec lock ({:?})", locked_codec) + let locks = third_party_locks(config); + if let Some(first) = locks.first() { + let sources = locks + .iter() + .map(|item| item.source) + .collect::>() + .join("/"); + let reason = format!( + "{} enabled with codec lock ({})", + sources, + first.lock.label() + ); + return match first.lock { + ThirdPartyCodecLock::H26x(codec) => Self { + rustdesk_enabled, + rtsp_enabled, + vnc_enabled, + allowed_webrtc_codecs: vec![codec], + allow_mjpeg: false, + locked_codec: Some(codec), + reason, + }, + ThirdPartyCodecLock::Mjpeg => Self { + rustdesk_enabled, + rtsp_enabled, + vnc_enabled, + allowed_webrtc_codecs: vec![], + allow_mjpeg: true, + locked_codec: None, + reason, }, - }; - } - - if rustdesk_enabled { - return Self { - rustdesk_enabled, - rtsp_enabled, - allowed_webrtc_codecs: vec![ - VideoCodecType::H264, - VideoCodecType::H265, - VideoCodecType::VP8, - VideoCodecType::VP9, - ], - allow_mjpeg: false, - locked_codec: None, - reason: "RustDesk enabled, MJPEG disabled".to_string(), }; } @@ -113,6 +138,87 @@ impl StreamCodecConstraints { } } +pub fn rustdesk_codec_to_video(codec: RustDeskCodec) -> VideoCodecType { + match codec { + RustDeskCodec::H264 => VideoCodecType::H264, + RustDeskCodec::H265 => VideoCodecType::H265, + } +} + +pub fn rtsp_codec_to_video_codec(codec: RtspCodec) -> VideoCodecType { + match codec { + RtspCodec::H264 => VideoCodecType::H264, + RtspCodec::H265 => VideoCodecType::H265, + } +} + +pub fn vnc_encoding_to_video_codec(encoding: VncEncoding) -> Option { + match encoding { + VncEncoding::TightJpeg => None, + VncEncoding::H264 => Some(VideoCodecType::H264), + } +} + +fn rustdesk_lock(config: &AppConfig) -> Option { + if config.rustdesk.enabled { + return Some(ThirdPartySourceLock { + source: "RustDesk", + lock: ThirdPartyCodecLock::H26x(rustdesk_codec_to_video(config.rustdesk.codec)), + }); + } + None +} + +fn rtsp_lock(config: &AppConfig) -> Option { + if config.rtsp.enabled { + return Some(ThirdPartySourceLock { + source: "RTSP", + lock: ThirdPartyCodecLock::H26x(rtsp_codec_to_video_codec(config.rtsp.codec.clone())), + }); + } + None +} + +fn vnc_lock(config: &AppConfig) -> Option { + if config.vnc.enabled { + let lock = match config.vnc.encoding { + VncEncoding::TightJpeg => ThirdPartyCodecLock::Mjpeg, + VncEncoding::H264 => ThirdPartyCodecLock::H26x(VideoCodecType::H264), + }; + return Some(ThirdPartySourceLock { + source: "VNC", + lock, + }); + } + None +} + +fn third_party_locks(config: &AppConfig) -> Vec { + [rustdesk_lock(config), rtsp_lock(config), vnc_lock(config)] + .into_iter() + .flatten() + .collect() +} + +pub fn validate_third_party_codec_compatibility(config: &AppConfig) -> Result<()> { + let locks = third_party_locks(config); + if let Some(first) = locks.first() { + for item in locks.iter().skip(1) { + if !first.lock.compatible_with(item.lock) { + return Err(AppError::BadRequest(format!( + "{} codec {} conflicts with {} codec {}; choose a compatible codec or stop the running service first", + item.source, + item.lock.label(), + first.source, + first.lock.label() + ))); + } + } + } + + Ok(()) +} + pub async fn enforce_constraints_with_stream_manager( stream_manager: &Arc, constraints: &StreamCodecConstraints, @@ -135,6 +241,16 @@ pub async fn enforce_constraints_with_stream_manager( } if current_mode == StreamMode::WebRTC { + if constraints.allow_mjpeg && constraints.allowed_webrtc_codecs.is_empty() { + let _ = stream_manager + .switch_mode_transaction(StreamMode::Mjpeg) + .await?; + return Ok(ConstraintEnforcementResult { + changed: true, + message: Some("Auto-switched from WebRTC to MJPEG due to codec lock".to_string()), + }); + } + let current_codec = stream_manager.current_video_codec().await; if !constraints.is_webrtc_codec_allowed(current_codec) { let target_codec = constraints.preferred_webrtc_codec(); diff --git a/src/video/streamer.rs b/src/video/streamer.rs index 57176182..e0463c90 100644 --- a/src/video/streamer.rs +++ b/src/video/streamer.rs @@ -375,8 +375,8 @@ impl Streamer { // IMPORTANT: Disconnect all MJPEG clients FIRST before stopping capture // This prevents race conditions where clients try to reconnect and reopen the device - debug!("Disconnecting all MJPEG clients before config change..."); - self.mjpeg_handler.disconnect_all_clients(); + debug!("Disconnecting HTTP MJPEG clients before config change..."); + self.mjpeg_handler.disconnect_non_vnc_clients(); // Give clients time to receive the disconnect signal and close their connections tokio::time::sleep(std::time::Duration::from_millis(100)).await; diff --git a/src/vnc/mod.rs b/src/vnc/mod.rs new file mode 100644 index 00000000..690e500b --- /dev/null +++ b/src/vnc/mod.rs @@ -0,0 +1,370 @@ +//! Minimal VNC/RFB service for direct JPEG/H264 frame forwarding. + +pub mod rfb; + +use std::net::SocketAddr; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{broadcast, Mutex, RwLock}; +use tokio::task::JoinHandle; +use tracing::{info, warn}; + +use crate::config::{VncConfig, VncEncoding}; +use crate::error::{AppError, Result}; +use crate::hid::HidController; +use crate::stream::mjpeg::ClientGuard; +use crate::video::codec::{BitratePreset, VideoCodecType}; +use crate::video::stream_manager::VideoStreamManager; + +use self::rfb::{RfbClient, RfbFrame, RfbInputEvent}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VncServiceStatus { + Stopped, + Starting, + Running, + Error(String), +} + +impl std::fmt::Display for VncServiceStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Stopped => write!(f, "stopped"), + Self::Starting => write!(f, "starting"), + Self::Running => write!(f, "running"), + Self::Error(err) => write!(f, "error: {}", err), + } + } +} + +pub struct VncService { + config: Arc>, + status: Arc>, + video_manager: Arc, + hid: Arc, + shutdown_tx: broadcast::Sender<()>, + server_handle: Mutex>>, + client_handles: Arc>>>, + active_clients: Arc, +} + +impl VncService { + pub fn new( + config: VncConfig, + video_manager: Arc, + hid: Arc, + ) -> Self { + let (shutdown_tx, _) = broadcast::channel(1); + Self { + config: Arc::new(RwLock::new(config)), + status: Arc::new(RwLock::new(VncServiceStatus::Stopped)), + video_manager, + hid, + shutdown_tx, + server_handle: Mutex::new(None), + client_handles: Arc::new(Mutex::new(Vec::new())), + active_clients: Arc::new(AtomicUsize::new(0)), + } + } + + pub async fn config(&self) -> VncConfig { + self.config.read().await.clone() + } + + pub async fn update_config(&self, config: VncConfig) { + *self.config.write().await = config; + } + + pub async fn status(&self) -> VncServiceStatus { + self.status.read().await.clone() + } + + pub fn connection_count(&self) -> usize { + self.active_clients.load(Ordering::Relaxed) + } + + pub async fn start(&self) -> Result<()> { + let config = self.config.read().await.clone(); + if !config.enabled { + *self.status.write().await = VncServiceStatus::Stopped; + return Ok(()); + } + if matches!(*self.status.read().await, VncServiceStatus::Running) { + return Ok(()); + } + if config.password.as_deref().unwrap_or("").is_empty() { + let msg = "VNC password is required".to_string(); + *self.status.write().await = VncServiceStatus::Error(msg.clone()); + return Err(AppError::BadRequest(msg)); + } + + *self.status.write().await = VncServiceStatus::Starting; + if let Err(err) = self.prepare_video_pipeline(&config).await { + *self.status.write().await = VncServiceStatus::Error(err.to_string()); + return Err(err); + } + + let bind_addr: SocketAddr = format!("{}:{}", config.bind, config.port) + .parse() + .map_err(|e| AppError::BadRequest(format!("Invalid VNC bind address: {}", e)))?; + let listener = TcpListener::bind(bind_addr).await.map_err(|e| { + AppError::Io(std::io::Error::new( + e.kind(), + format!("VNC bind failed: {}", e), + )) + })?; + + let config_ref = self.config.clone(); + let video_manager = self.video_manager.clone(); + let hid = self.hid.clone(); + let status = self.status.clone(); + let client_handles = self.client_handles.clone(); + let active_clients = self.active_clients.clone(); + let mut shutdown_rx = self.shutdown_tx.subscribe(); + + *self.status.write().await = VncServiceStatus::Running; + let handle = tokio::spawn(async move { + info!("VNC service listening on {}", bind_addr); + loop { + tokio::select! { + _ = shutdown_rx.recv() => { + info!("VNC service shutdown signal received"); + break; + } + result = listener.accept() => { + match result { + Ok((stream, peer)) => { + let cfg = config_ref.read().await.clone(); + if cfg.allow_one_client && active_clients.load(Ordering::Relaxed) > 0 { + warn!("Rejecting VNC client {} because another client is active", peer); + drop(stream); + continue; + } + let vm = video_manager.clone(); + let hid = hid.clone(); + let active = active_clients.clone(); + let handle = tokio::spawn(async move { + active.fetch_add(1, Ordering::Relaxed); + let result = handle_client(stream, peer, cfg, vm, hid).await; + active.fetch_sub(1, Ordering::Relaxed); + if let Err(err) = result { + warn!("VNC client {} ended: {}", peer, err); + } + }); + let mut handles = client_handles.lock().await; + handles.retain(|task| !task.is_finished()); + handles.push(handle); + } + Err(err) => warn!("VNC accept failed: {}", err), + } + } + } + } + *status.write().await = VncServiceStatus::Stopped; + }); + + *self.server_handle.lock().await = Some(handle); + Ok(()) + } + + async fn prepare_video_pipeline(&self, config: &VncConfig) -> Result<()> { + match config.encoding { + VncEncoding::TightJpeg => { + self.video_manager + .set_bitrate_preset(BitratePreset::Balanced) + .await?; + } + VncEncoding::H264 => { + self.video_manager + .set_video_codec(VideoCodecType::H264) + .await?; + } + } + Ok(()) + } + + pub async fn stop(&self) -> Result<()> { + let _ = self.shutdown_tx.send(()); + if let Some(mut handle) = self.server_handle.lock().await.take() { + match tokio::time::timeout(Duration::from_secs(2), &mut handle).await { + Ok(Ok(())) => {} + Ok(Err(err)) if err.is_cancelled() => {} + Ok(Err(err)) => warn!("VNC server task ended with error: {}", err), + Err(_) => { + warn!("Timed out waiting for VNC server task to stop"); + handle.abort(); + let _ = handle.await; + } + } + } + let mut client_handles = self.client_handles.lock().await; + for handle in client_handles.drain(..) { + handle.abort(); + } + self.active_clients.store(0, Ordering::Relaxed); + *self.status.write().await = VncServiceStatus::Stopped; + Ok(()) + } + + pub async fn restart(&self, config: VncConfig) -> Result<()> { + self.update_config(config).await; + self.stop().await?; + self.start().await + } +} + +async fn handle_client( + stream: TcpStream, + peer: SocketAddr, + config: VncConfig, + video_manager: Arc, + hid: Arc, +) -> Result<()> { + let mut client = RfbClient::new(stream, peer, config.clone()); + let (width, height) = initial_frame_size(&config, &video_manager).await; + client.set_size(width, height); + client.handshake().await?; + let (_, _, mut frame_rx) = subscribe_frames(&config, &video_manager).await?; + let mut shutdown = client.shutdown_receiver(); + + loop { + tokio::select! { + result = client.read_input_event() => { + match result? { + RfbInputEvent::Ignored => {} + RfbInputEvent::Disconnected => break, + event => handle_input_event(event, &hid, width, height).await?, + } + } + maybe_frame = frame_rx.recv() => { + let Some(frame) = maybe_frame else { break }; + client.send_frame(frame).await?; + } + _ = shutdown.recv() => break, + } + } + Ok(()) +} + +async fn initial_frame_size( + config: &VncConfig, + video_manager: &Arc, +) -> (u16, u16) { + match config.encoding { + VncEncoding::TightJpeg => { + let (_, resolution, _, _, _) = video_manager.streamer().current_capture_config().await; + (resolution.width as u16, resolution.height as u16) + } + VncEncoding::H264 => video_manager + .get_encoding_config() + .await + .map(|cfg| (cfg.resolution.width as u16, cfg.resolution.height as u16)) + .unwrap_or((1280, 720)), + } +} + +async fn subscribe_frames( + config: &VncConfig, + video_manager: &Arc, +) -> Result<(u16, u16, tokio::sync::mpsc::Receiver)> { + let (tx, rx) = tokio::sync::mpsc::channel(4); + match config.encoding { + VncEncoding::TightJpeg => { + let handler = video_manager.mjpeg_handler(); + let client_id = format!("vnc-{}", uuid::Uuid::new_v4()); + let guard = ClientGuard::new(client_id.clone(), handler.clone()); + video_manager.streamer().start().await?; + let current = handler.current_frame(); + let (width, height) = current + .as_ref() + .map(|f| (f.width() as u16, f.height() as u16)) + .unwrap_or((800, 600)); + let mut notify = handler.subscribe(); + tokio::spawn(async move { + let _guard = guard; + loop { + if notify.recv().await.is_err() { + break; + } + let Some(frame) = handler.current_frame() else { + continue; + }; + if !frame.online || !frame.is_valid_jpeg() { + continue; + } + let _ = tx + .send(RfbFrame::Jpeg { + data: frame.data_bytes(), + width: frame.width() as u16, + height: frame.height() as u16, + }) + .await; + handler.record_frame_sent(&client_id); + } + }); + Ok((width, height, rx)) + } + VncEncoding::H264 => { + video_manager.set_video_codec(VideoCodecType::H264).await?; + let mut frames = video_manager + .subscribe_encoded_frames() + .await + .ok_or_else(|| { + AppError::VideoError("Failed to subscribe to encoded frames".to_string()) + })?; + let geometry = video_manager + .get_encoding_config() + .await + .map(|cfg| cfg.resolution) + .unwrap_or(crate::video::format::Resolution::HD720); + let width = geometry.width as u16; + let height = geometry.height as u16; + if let Err(err) = video_manager.request_keyframe().await { + warn!("Failed to request VNC H264 keyframe: {}", err); + } + tokio::spawn(async move { + while let Some(frame) = frames.recv().await { + if frame.codec != crate::video::codec::registry::VideoEncoderType::H264 { + continue; + } + let _ = tx + .send(RfbFrame::H264 { + data: Bytes::copy_from_slice(&frame.data), + width, + height, + key: frame.is_keyframe, + }) + .await; + } + }); + Ok((width, height, rx)) + } + } +} + +async fn handle_input_event( + event: RfbInputEvent, + hid: &Arc, + width: u16, + height: u16, +) -> Result<()> { + match event { + RfbInputEvent::Key(key) => { + if let Some(event) = rfb::key_event_to_hid(key) { + hid.send_keyboard(event).await?; + } + } + RfbInputEvent::Pointer(pointer) => { + for event in rfb::pointer_event_to_hid(pointer, width, height) { + hid.send_mouse(event).await?; + } + } + RfbInputEvent::Clipboard(_) => {} + RfbInputEvent::Ignored | RfbInputEvent::Disconnected => {} + } + Ok(()) +} diff --git a/src/vnc/rfb.rs b/src/vnc/rfb.rs new file mode 100644 index 00000000..eea263ba --- /dev/null +++ b/src/vnc/rfb.rs @@ -0,0 +1,529 @@ +use std::net::SocketAddr; + +use bytes::Bytes; +use des::cipher::{BlockEncrypt, KeyInit}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::broadcast; + +use crate::config::{VncConfig, VncEncoding}; +use crate::error::{AppError, Result}; +use crate::hid::{ + CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent, + MouseEventType, +}; + +const ENCODING_TIGHT: i32 = 7; +const ENCODING_H264: i32 = 50; +const ENCODING_DESKTOP_SIZE: i32 = -223; + +pub enum RfbFrame { + Jpeg { + data: Bytes, + width: u16, + height: u16, + }, + H264 { + data: Bytes, + width: u16, + height: u16, + key: bool, + }, +} + +pub enum RfbInputEvent { + Key(RfbKeyEvent), + Pointer(RfbPointerEvent), + Clipboard(String), + Ignored, + Disconnected, +} + +pub struct RfbKeyEvent { + pub down: bool, + pub keysym: u32, +} + +pub struct RfbPointerEvent { + pub x: u16, + pub y: u16, + pub button_mask: u8, + pub previous_button_mask: u8, +} + +#[derive(Default)] +struct ClientEncodings { + has_tight: bool, + tight_jpeg_quality: u8, + has_h264: bool, + has_resize: bool, +} + +pub struct RfbClient { + stream: TcpStream, + peer: SocketAddr, + config: VncConfig, + encodings: ClientEncodings, + width: u16, + height: u16, + last_buttons: u8, + h264_waiting_keyframe: bool, + shutdown_tx: broadcast::Sender<()>, +} + +impl RfbClient { + pub fn new(stream: TcpStream, peer: SocketAddr, config: VncConfig) -> Self { + let (shutdown_tx, _) = broadcast::channel(1); + Self { + stream, + peer, + config, + encodings: ClientEncodings::default(), + width: 800, + height: 600, + last_buttons: 0, + h264_waiting_keyframe: true, + shutdown_tx, + } + } + + pub fn set_size(&mut self, width: u16, height: u16) { + self.width = width.max(1); + self.height = height.max(1); + } + + pub fn shutdown_receiver(&self) -> broadcast::Receiver<()> { + self.shutdown_tx.subscribe() + } + + pub async fn handshake(&mut self) -> Result<()> { + self.stream.write_all(b"RFB 003.008\n").await?; + let mut version = [0u8; 12]; + self.stream.read_exact(&mut version).await?; + if !version.starts_with(b"RFB 003.00") { + return Err(AppError::BadRequest("Invalid RFB version".to_string())); + } + + self.stream.write_all(&[1, 2]).await?; + let sec_type = read_u8(&mut self.stream).await?; + if sec_type != 2 { + return Err(AppError::BadRequest("VNCAuth is required".to_string())); + } + self.handle_vnc_auth().await?; + + let _shared = read_u8(&mut self.stream).await?; + self.write_server_init().await?; + self.read_until_set_encodings().await?; + self.validate_encoding_policy()?; + tracing::info!( + "VNC client {} negotiated encoding {:?}", + self.peer, + self.config.encoding + ); + Ok(()) + } + + async fn handle_vnc_auth(&mut self) -> Result<()> { + let challenge: [u8; 16] = rand::random(); + self.stream.write_all(&challenge).await?; + let mut response = [0u8; 16]; + self.stream.read_exact(&mut response).await?; + let password = self.config.password.as_deref().unwrap_or(""); + let expected = encrypt_vnc_challenge(&challenge, password)?; + let ok = response == expected; + self.stream + .write_all(&(if ok { 0u32 } else { 1u32 }).to_be_bytes()) + .await?; + if !ok { + return Err(AppError::BadRequest("Invalid VNC password".to_string())); + } + Ok(()) + } + + async fn write_server_init(&mut self) -> Result<()> { + self.stream.write_all(&self.width.to_be_bytes()).await?; + self.stream.write_all(&self.height.to_be_bytes()).await?; + self.stream + .write_all(&[32, 24, 0, 1, 0, 255, 0, 255, 0, 255, 16, 8, 0, 0, 0, 0]) + .await?; + let name = b"One-KVM VNC"; + self.stream + .write_all(&(name.len() as u32).to_be_bytes()) + .await?; + self.stream.write_all(name).await?; + self.stream.flush().await?; + Ok(()) + } + + async fn read_until_set_encodings(&mut self) -> Result<()> { + loop { + let msg_type = read_u8(&mut self.stream).await?; + match msg_type { + 0 => { + let mut buf = [0u8; 19]; + self.stream.read_exact(&mut buf).await?; + } + 2 => { + let _pad = read_u8(&mut self.stream).await?; + let count = read_u16(&mut self.stream).await?; + if count == 0 || count > 1024 { + return Err(AppError::BadRequest( + "Invalid VNC encoding list".to_string(), + )); + } + let mut encodings = ClientEncodings::default(); + for _ in 0..count { + let enc = read_i32(&mut self.stream).await?; + match enc { + ENCODING_TIGHT => encodings.has_tight = true, + ENCODING_H264 => encodings.has_h264 = true, + ENCODING_DESKTOP_SIZE => encodings.has_resize = true, + -32..=-23 => { + let q = ((enc + 33) * 10).clamp(10, 100) as u8; + encodings.tight_jpeg_quality = encodings.tight_jpeg_quality.max(q); + } + _ => {} + } + } + self.encodings = encodings; + return Ok(()); + } + 3 => { + let mut buf = [0u8; 9]; + self.stream.read_exact(&mut buf).await?; + } + 4 => { + let mut buf = [0u8; 7]; + self.stream.read_exact(&mut buf).await?; + } + 5 => { + let mut buf = [0u8; 5]; + self.stream.read_exact(&mut buf).await?; + } + 6 => { + let mut hdr = [0u8; 7]; + self.stream.read_exact(&mut hdr).await?; + let len = u32::from_be_bytes([hdr[3], hdr[4], hdr[5], hdr[6]]) as usize; + let mut data = vec![0u8; len.min(1024 * 1024)]; + self.stream.read_exact(&mut data).await?; + } + _ => { + return Err(AppError::BadRequest(format!( + "Unsupported RFB message {}", + msg_type + ))) + } + } + } + } + + fn validate_encoding_policy(&self) -> Result<()> { + match self.config.encoding { + VncEncoding::TightJpeg => { + if !self.encodings.has_tight || self.encodings.tight_jpeg_quality == 0 { + return Err(AppError::BadRequest( + "VNC client must support Tight JPEG encoding".to_string(), + )); + } + } + VncEncoding::H264 => { + if !self.encodings.has_h264 { + return Err(AppError::BadRequest( + "VNC client must support Open H.264 encoding".to_string(), + )); + } + } + } + Ok(()) + } + + pub async fn read_input_event(&mut self) -> Result { + let msg_type = match read_u8(&mut self.stream).await { + Ok(v) => v, + Err(AppError::Io(err)) if err.kind() == std::io::ErrorKind::UnexpectedEof => { + return Ok(RfbInputEvent::Disconnected); + } + Err(err) => return Err(err), + }; + match msg_type { + 0 => { + let mut buf = [0u8; 19]; + self.stream.read_exact(&mut buf).await?; + Ok(RfbInputEvent::Ignored) + } + 2 => { + let _pad = read_u8(&mut self.stream).await?; + let count = read_u16(&mut self.stream).await?; + for _ in 0..count { + let _ = read_i32(&mut self.stream).await?; + } + Ok(RfbInputEvent::Ignored) + } + 3 => { + let mut buf = [0u8; 9]; + self.stream.read_exact(&mut buf).await?; + Ok(RfbInputEvent::Ignored) + } + 4 => { + let down = read_u8(&mut self.stream).await? != 0; + let mut pad = [0u8; 2]; + self.stream.read_exact(&mut pad).await?; + let keysym = read_u32(&mut self.stream).await?; + Ok(RfbInputEvent::Key(RfbKeyEvent { down, keysym })) + } + 5 => { + let button_mask = read_u8(&mut self.stream).await?; + let x = read_u16(&mut self.stream).await?; + let y = read_u16(&mut self.stream).await?; + let previous_button_mask = self.last_buttons; + self.last_buttons = button_mask; + Ok(RfbInputEvent::Pointer(RfbPointerEvent { + x, + y, + button_mask, + previous_button_mask, + })) + } + 6 => { + let mut hdr = [0u8; 7]; + self.stream.read_exact(&mut hdr).await?; + let len = u32::from_be_bytes([hdr[3], hdr[4], hdr[5], hdr[6]]) as usize; + let mut data = vec![0u8; len.min(1024 * 1024)]; + self.stream.read_exact(&mut data).await?; + Ok(RfbInputEvent::Clipboard( + String::from_utf8_lossy(&data).to_string(), + )) + } + _ => Err(AppError::BadRequest(format!( + "Unsupported RFB message {}", + msg_type + ))), + } + } + + pub async fn send_frame(&mut self, frame: RfbFrame) -> Result<()> { + match frame { + RfbFrame::Jpeg { + data, + width, + height, + } => { + self.maybe_resize(width, height).await?; + self.write_frame_header(width, height, ENCODING_TIGHT) + .await?; + write_tight_jpeg_payload(&mut self.stream, &data).await?; + } + RfbFrame::H264 { + data, + width, + height, + key, + } => { + self.maybe_resize(width, height).await?; + if self.h264_waiting_keyframe && !key { + return Ok(()); + } + self.write_frame_header(width, height, ENCODING_H264) + .await?; + self.stream + .write_all(&(data.len() as u32).to_be_bytes()) + .await?; + self.stream + .write_all(&(self.h264_waiting_keyframe as u32).to_be_bytes()) + .await?; + self.stream.write_all(&data).await?; + self.h264_waiting_keyframe = false; + } + } + self.stream.flush().await?; + Ok(()) + } + + async fn maybe_resize(&mut self, width: u16, height: u16) -> Result<()> { + if width == self.width && height == self.height { + return Ok(()); + } + if !self.encodings.has_resize { + return Err(AppError::BadRequest( + "VNC client does not support DesktopSize resize; reconnect required".to_string(), + )); + } + self.write_frame_header(width, height, ENCODING_DESKTOP_SIZE) + .await?; + self.width = width; + self.height = height; + self.h264_waiting_keyframe = true; + Ok(()) + } + + async fn write_frame_header(&mut self, width: u16, height: u16, encoding: i32) -> Result<()> { + self.stream.write_all(&[0, 0]).await?; + self.stream.write_all(&1u16.to_be_bytes()).await?; + self.stream.write_all(&0u16.to_be_bytes()).await?; + self.stream.write_all(&0u16.to_be_bytes()).await?; + self.stream.write_all(&width.to_be_bytes()).await?; + self.stream.write_all(&height.to_be_bytes()).await?; + self.stream.write_all(&encoding.to_be_bytes()).await?; + Ok(()) + } +} + +async fn write_tight_jpeg_payload(stream: &mut TcpStream, data: &[u8]) -> Result<()> { + if data.len() > 0x3f_ffff { + return Err(AppError::BadRequest( + "JPEG frame too large for Tight encoding".to_string(), + )); + } + stream.write_all(&[0b1001_1111]).await?; + write_compact_len(stream, data.len()).await?; + stream.write_all(data).await?; + Ok(()) +} + +async fn write_compact_len(stream: &mut TcpStream, len: usize) -> Result<()> { + if len <= 127 { + stream.write_all(&[(len & 0x7f) as u8]).await?; + } else if len <= 16_383 { + stream + .write_all(&[((len & 0x7f) as u8) | 0x80, ((len >> 7) & 0x7f) as u8]) + .await?; + } else { + stream + .write_all(&[ + ((len & 0x7f) as u8) | 0x80, + (((len >> 7) & 0x7f) as u8) | 0x80, + ((len >> 14) & 0xff) as u8, + ]) + .await?; + } + Ok(()) +} + +fn encrypt_vnc_challenge(challenge: &[u8; 16], password: &str) -> Result<[u8; 16]> { + let mut key = [0u8; 8]; + for (dst, src) in key.iter_mut().zip(password.as_bytes().iter().take(8)) { + *dst = reverse_bits(*src); + } + let cipher = des::Des::new_from_slice(&key) + .map_err(|_| AppError::BadRequest("Invalid VNC DES key".to_string()))?; + let mut out = *challenge; + for chunk in out.chunks_exact_mut(8) { + cipher.encrypt_block(chunk.into()); + } + Ok(out) +} + +fn reverse_bits(byte: u8) -> u8 { + byte.reverse_bits() +} + +async fn read_u8(stream: &mut TcpStream) -> Result { + let mut buf = [0u8; 1]; + stream.read_exact(&mut buf).await?; + Ok(buf[0]) +} + +async fn read_u16(stream: &mut TcpStream) -> Result { + let mut buf = [0u8; 2]; + stream.read_exact(&mut buf).await?; + Ok(u16::from_be_bytes(buf)) +} + +async fn read_u32(stream: &mut TcpStream) -> Result { + let mut buf = [0u8; 4]; + stream.read_exact(&mut buf).await?; + Ok(u32::from_be_bytes(buf)) +} + +async fn read_i32(stream: &mut TcpStream) -> Result { + let mut buf = [0u8; 4]; + stream.read_exact(&mut buf).await?; + Ok(i32::from_be_bytes(buf)) +} + +pub fn key_event_to_hid(event: RfbKeyEvent) -> Option { + let key = keysym_to_key(event.keysym)?; + Some(KeyboardEvent { + event_type: if event.down { + KeyEventType::Down + } else { + KeyEventType::Up + }, + key, + modifiers: KeyboardModifiers::default(), + }) +} + +fn keysym_to_key(keysym: u32) -> Option { + match keysym { + 0xff08 => Some(CanonicalKey::Backspace), + 0xff09 => Some(CanonicalKey::Tab), + 0xff0d => Some(CanonicalKey::Enter), + 0xff1b => Some(CanonicalKey::Escape), + 0xffff => Some(CanonicalKey::Delete), + 0xff50 => Some(CanonicalKey::Home), + 0xff51 => Some(CanonicalKey::ArrowLeft), + 0xff52 => Some(CanonicalKey::ArrowUp), + 0xff53 => Some(CanonicalKey::ArrowRight), + 0xff54 => Some(CanonicalKey::ArrowDown), + 0xff55 => Some(CanonicalKey::PageUp), + 0xff56 => Some(CanonicalKey::PageDown), + 0xff57 => Some(CanonicalKey::End), + 0xff63 => Some(CanonicalKey::Insert), + 0xffbe..=0xffc9 => CanonicalKey::from_hid_usage((keysym - 0xffbe + 0x3a) as u8), + 0x20 => Some(CanonicalKey::Space), + 0x61..=0x7a => CanonicalKey::from_hid_usage((keysym - 0x61 + 0x04) as u8), + 0x41..=0x5a => CanonicalKey::from_hid_usage((keysym - 0x41 + 0x04) as u8), + 0x31..=0x39 => CanonicalKey::from_hid_usage((keysym - 0x31 + 0x1e) as u8), + 0x30 => Some(CanonicalKey::Digit0), + 0x2d => Some(CanonicalKey::Minus), + 0x3d => Some(CanonicalKey::Equal), + 0x5b => Some(CanonicalKey::BracketLeft), + 0x5d => Some(CanonicalKey::BracketRight), + 0x5c => Some(CanonicalKey::Backslash), + 0x3b => Some(CanonicalKey::Semicolon), + 0x27 => Some(CanonicalKey::Quote), + 0x60 => Some(CanonicalKey::Backquote), + 0x2c => Some(CanonicalKey::Comma), + 0x2e => Some(CanonicalKey::Period), + 0x2f => Some(CanonicalKey::Slash), + _ => None, + } +} + +pub fn pointer_event_to_hid(event: RfbPointerEvent, width: u16, height: u16) -> Vec { + let mut out = Vec::new(); + let abs_x = ((event.x as u64 * 32767) / width.max(1) as u64) as i32; + let abs_y = ((event.y as u64 * 32767) / height.max(1) as u64) as i32; + out.push(MouseEvent { + event_type: MouseEventType::MoveAbs, + x: abs_x, + y: abs_y, + button: None, + scroll: 0, + }); + + if event.button_mask & 0x08 != 0 { + out.push(MouseEvent::scroll(1)); + } + if event.button_mask & 0x10 != 0 { + out.push(MouseEvent::scroll(-1)); + } + + for (bit, button) in [ + (0x01, MouseButton::Left), + (0x02, MouseButton::Middle), + (0x04, MouseButton::Right), + ] { + if (event.button_mask ^ event.previous_button_mask) & bit == 0 { + continue; + } + if event.button_mask & bit != 0 { + out.push(MouseEvent::button_down(button)); + } else { + out.push(MouseEvent::button_up(button)); + } + } + + out +} diff --git a/src/web/handlers/config/apply.rs b/src/web/handlers/config/apply.rs index 63374ff8..393c442a 100644 --- a/src/web/handlers/config/apply.rs +++ b/src/web/handlers/config/apply.rs @@ -6,7 +6,8 @@ use crate::rtsp::RtspService; use crate::state::AppState; use crate::stream_encoder::encoder_type_to_backend; use crate::video::codec_constraints::{ - enforce_constraints_with_stream_manager, StreamCodecConstraints, + enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility, + StreamCodecConstraints, }; use tokio::sync::{Mutex, OwnedMutexGuard}; @@ -409,6 +410,27 @@ pub async fn enforce_stream_codec_constraints(state: &Arc) -> Result, + 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, @@ -417,6 +439,8 @@ pub async fn apply_rustdesk_config( ) -> Result<()> { tracing::info!("Applying RustDesk config changes..."); + validate_rustdesk_candidate(state, new_config)?; + let mut rustdesk_guard = state.rustdesk.write().await; let mut credentials_to_save = None; @@ -433,6 +457,7 @@ pub async fn apply_rustdesk_config( if new_config.enabled { 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; @@ -488,6 +513,77 @@ pub async fn apply_rustdesk_config( Ok(()) } +pub async fn apply_vnc_config( + state: &Arc, + old_config: &VncConfig, + new_config: &VncConfig, + options: ConfigApplyOptions, +) -> Result<()> { + tracing::info!("Applying VNC config changes..."); + + validate_vnc_candidate(state, new_config)?; + + if new_config.enabled { + let mut candidate = state.config.get().as_ref().clone(); + candidate.vnc = new_config.clone(); + 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; + + if !new_config.enabled { + if let Some(ref service) = *vnc_guard { + service.stop().await?; + } + *vnc_guard = None; + } + + if new_config.enabled { + 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.jpeg_quality != new_config.jpeg_quality + || old_config.allow_one_client != new_config.allow_one_client; + + if vnc_guard.is_none() { + let service = crate::vnc::VncService::new( + new_config.clone(), + state.stream_manager.clone(), + state.hid.clone(), + ); + service.start().await?; + *vnc_guard = Some(Arc::new(service)); + tracing::info!("VNC service started"); + } else if need_restart { + if let Some(ref service) = *vnc_guard { + service.restart(new_config.clone()).await?; + tracing::info!("VNC service restarted"); + } + } + } + + 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, @@ -496,6 +592,8 @@ pub async fn apply_rtsp_config( ) -> Result<()> { tracing::info!("Applying RTSP config changes..."); + validate_rtsp_candidate(state, new_config)?; + let mut rtsp_guard = state.rtsp.write().await; if !new_config.enabled { diff --git a/src/web/handlers/config/mod.rs b/src/web/handlers/config/mod.rs index 6800a1a8..3c8fecbe 100644 --- a/src/web/handlers/config/mod.rs +++ b/src/web/handlers/config/mod.rs @@ -12,6 +12,7 @@ mod rtsp; mod rustdesk; mod stream; pub(crate) mod video; +mod vnc; mod web; pub use atx::{get_atx_config, update_atx_config}; @@ -31,6 +32,9 @@ pub use rustdesk::{ }; pub use stream::{get_stream_config, update_stream_config}; pub use video::{get_video_config, update_video_config}; +pub use vnc::{ + get_vnc_config, get_vnc_status, start_vnc_service, stop_vnc_service, update_vnc_config, +}; pub use web::{get_web_config, update_web_config}; use axum::{extract::State, Json}; @@ -52,6 +56,7 @@ fn sanitize_config_for_api(config: &mut AppConfig) { config.rustdesk.signing_private_key = None; config.rtsp.password = None; + config.vnc.password = None; } pub async fn get_all_config(State(state): State>) -> Json { diff --git a/src/web/handlers/config/rtsp.rs b/src/web/handlers/config/rtsp.rs index 7cb4f549..e2d505a0 100644 --- a/src/web/handlers/config/rtsp.rs +++ b/src/web/handlers/config/rtsp.rs @@ -7,6 +7,44 @@ use crate::state::AppState; use super::apply::{apply_rtsp_config, try_apply_lock, ConfigApplyOptions}; use super::types::{RtspConfigResponse, RtspConfigUpdate, RtspStatusResponse}; +fn validate_candidate(state: &Arc, 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, + old_config: crate::config::RtspConfig, + new_config: crate::config::RtspConfig, +) -> Result { + validate_candidate(state, &new_config)?; + state + .config + .update(|config| { + config.rtsp = new_config.clone(); + }) + .await?; + let stored_config = state.config.get().rtsp.clone(); + apply_rtsp_config( + state, + &old_config, + &stored_config, + ConfigApplyOptions::forced(), + ) + .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 + } +} + pub async fn get_rtsp_config(State(state): State>) -> Json { let config = state.config.get(); Json(RtspConfigResponse::from(&config.rtsp)) @@ -14,14 +52,7 @@ pub async fn get_rtsp_config(State(state): State>) -> Json>) -> Json { let config = state.config.get().rtsp.clone(); - let status = { - let guard = state.rtsp.read().await; - if let Some(ref service) = *guard { - service.status().await - } else { - crate::rtsp::RtspServiceStatus::Stopped - } - }; + let status = current_status(&state).await; Json(RtspStatusResponse::new(&config, status)) } @@ -34,22 +65,9 @@ pub async fn update_rtsp_config( let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?; let old_config = state.config.get().rtsp.clone(); - - state - .config - .update(|config| { - req.apply_to(&mut config.rtsp); - }) - .await?; - - let new_config = state.config.get().rtsp.clone(); - apply_rtsp_config( - &state, - &old_config, - &new_config, - ConfigApplyOptions::forced(), - ) - .await?; + let mut merged_config = old_config.clone(); + req.apply_to(&mut merged_config); + let new_config = persist_and_apply(&state, old_config, merged_config).await?; Ok(Json(RtspConfigResponse::from(&new_config))) } @@ -61,25 +79,10 @@ pub async fn start_rtsp_service( let current_config = state.config.get().rtsp.clone(); let mut start_config = current_config.clone(); start_config.enabled = true; + let stored_config = persist_and_apply(&state, current_config, start_config).await?; + let status = current_status(&state).await; - apply_rtsp_config( - &state, - ¤t_config, - &start_config, - ConfigApplyOptions::forced(), - ) - .await?; - - let status = { - let guard = state.rtsp.read().await; - if let Some(ref service) = *guard { - service.status().await - } else { - crate::rtsp::RtspServiceStatus::Stopped - } - }; - - Ok(Json(RtspStatusResponse::new(¤t_config, status))) + Ok(Json(RtspStatusResponse::new(&stored_config, status))) } pub async fn stop_rtsp_service( @@ -90,22 +93,8 @@ pub async fn stop_rtsp_service( let mut stop_config = current_config.clone(); stop_config.enabled = false; - apply_rtsp_config( - &state, - ¤t_config, - &stop_config, - ConfigApplyOptions::forced(), - ) - .await?; + let stored_config = persist_and_apply(&state, current_config, stop_config).await?; + let status = current_status(&state).await; - let status = { - let guard = state.rtsp.read().await; - if let Some(ref service) = *guard { - service.status().await - } else { - crate::rtsp::RtspServiceStatus::Stopped - } - }; - - Ok(Json(RtspStatusResponse::new(¤t_config, status))) + Ok(Json(RtspStatusResponse::new(&stored_config, status))) } diff --git a/src/web/handlers/config/rustdesk.rs b/src/web/handlers/config/rustdesk.rs index 9f99ed9e..e3ce3cff 100644 --- a/src/web/handlers/config/rustdesk.rs +++ b/src/web/handlers/config/rustdesk.rs @@ -8,9 +8,58 @@ use crate::state::AppState; use super::apply::{apply_rustdesk_config, try_apply_lock, ConfigApplyOptions}; use super::types::RustDeskConfigUpdate; +fn validate_candidate(state: &Arc, 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, + old_config: RustDeskConfig, + new_config: RustDeskConfig, +) -> Result { + validate_candidate(state, &new_config)?; + state + .config + .update(|config| { + config.rustdesk = new_config.clone(); + }) + .await?; + let stored_config = state.config.get().rustdesk.clone(); + apply_rustdesk_config( + state, + &old_config, + &stored_config, + ConfigApplyOptions::forced(), + ) + .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) + } + }; + + RustDeskStatusResponse { + config: RustDeskConfigResponse::from(&config), + service_status, + rendezvous_status, + } +} + #[derive(Debug, serde::Serialize)] pub struct RustDeskConfigResponse { pub enabled: bool, + pub codec: crate::rustdesk::config::RustDeskCodec, pub rendezvous_server: String, pub relay_server: Option, pub device_id: String, @@ -23,6 +72,7 @@ impl From<&RustDeskConfig> for RustDeskConfigResponse { fn from(config: &RustDeskConfig) -> Self { Self { enabled: config.enabled, + codec: config.codec, rendezvous_server: config.rendezvous_server.clone(), relay_server: config.relay_server.clone(), device_id: config.device_id.clone(), @@ -50,23 +100,7 @@ pub async fn get_rustdesk_status( State(state): State>, ) -> Json { let config = state.config.get().rustdesk.clone(); - - 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) - } - }; - - Json(RustDeskStatusResponse { - config: RustDeskConfigResponse::from(&config), - service_status, - rendezvous_status, - }) + Json(current_status(&state, config).await) } pub async fn update_rustdesk_config( @@ -81,22 +115,7 @@ pub async fn update_rustdesk_config( req.apply_to(&mut merged_config); req.validate_merged(&merged_config)?; - state - .config - .update(|config| { - config.rustdesk = merged_config.clone(); - }) - .await?; - - let new_config = state.config.get().rustdesk.clone(); - - apply_rustdesk_config( - &state, - &old_config, - &new_config, - ConfigApplyOptions::forced(), - ) - .await?; + 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 { @@ -152,31 +171,8 @@ pub async fn start_rustdesk_service( let current_config = state.config.get().rustdesk.clone(); let mut start_config = current_config.clone(); start_config.enabled = true; - - apply_rustdesk_config( - &state, - ¤t_config, - &start_config, - ConfigApplyOptions::forced(), - ) - .await?; - - 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) - } - }; - - Ok(Json(RustDeskStatusResponse { - config: RustDeskConfigResponse::from(¤t_config), - service_status, - rendezvous_status, - })) + let stored_config = persist_and_apply(&state, current_config, start_config).await?; + Ok(Json(current_status(&state, stored_config).await)) } pub async fn stop_rustdesk_service( @@ -187,28 +183,6 @@ pub async fn stop_rustdesk_service( let mut stop_config = current_config.clone(); stop_config.enabled = false; - apply_rustdesk_config( - &state, - ¤t_config, - &stop_config, - ConfigApplyOptions::forced(), - ) - .await?; - - 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) - } - }; - - Ok(Json(RustDeskStatusResponse { - config: RustDeskConfigResponse::from(¤t_config), - service_status, - rendezvous_status, - })) + let stored_config = persist_and_apply(&state, current_config, stop_config).await?; + Ok(Json(current_status(&state, stored_config).await)) } diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index bd4802ea..a6193650 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -2,6 +2,7 @@ use crate::config::*; use crate::error::AppError; use crate::rtsp::RtspServiceStatus; use crate::rustdesk::config::RustDeskConfig; +use crate::vnc::VncServiceStatus; use base64::{engine::general_purpose::STANDARD, Engine as _}; use serde::{Deserialize, Serialize}; #[cfg(unix)] @@ -765,6 +766,7 @@ fn validate_rustdesk_relay_key(key: &str) -> Result<(), AppError> { #[derive(Debug, Deserialize)] pub struct RustDeskConfigUpdate { pub enabled: Option, + pub codec: Option, pub rendezvous_server: Option, pub relay_server: Option, pub relay_key: Option, @@ -821,6 +823,9 @@ impl RustDeskConfigUpdate { if let Some(enabled) = self.enabled { config.enabled = enabled; } + if let Some(codec) = self.codec { + config.codec = codec; + } if let Some(ref server) = self.rendezvous_server { config.rendezvous_server = server.clone(); } @@ -904,6 +909,125 @@ pub struct RtspConfigUpdate { pub password: Option, } +#[typeshare] +#[derive(Debug, serde::Serialize)] +pub struct VncConfigResponse { + pub enabled: bool, + pub bind: String, + pub port: u16, + pub encoding: VncEncoding, + pub jpeg_quality: u8, + pub allow_one_client: bool, + pub has_password: bool, +} + +impl From<&VncConfig> for VncConfigResponse { + fn from(config: &VncConfig) -> Self { + Self { + enabled: config.enabled, + bind: config.bind.clone(), + port: config.port, + encoding: config.encoding.clone(), + jpeg_quality: config.jpeg_quality, + allow_one_client: config.allow_one_client, + has_password: config.password.as_deref().is_some_and(|p| !p.is_empty()), + } + } +} + +#[typeshare] +#[derive(Debug, serde::Serialize)] +pub struct VncStatusResponse { + pub config: VncConfigResponse, + pub service_status: String, + pub connection_count: u32, +} + +impl VncStatusResponse { + pub fn new(config: &VncConfig, status: VncServiceStatus, connection_count: usize) -> Self { + Self { + config: VncConfigResponse::from(config), + service_status: status.to_string(), + connection_count: connection_count as u32, + } + } +} + +#[typeshare] +#[derive(Debug, Deserialize)] +pub struct VncConfigUpdate { + pub enabled: Option, + pub bind: Option, + pub port: Option, + pub encoding: Option, + pub jpeg_quality: Option, + pub allow_one_client: Option, + pub password: Option, +} + +impl VncConfigUpdate { + pub fn validate(&self) -> crate::error::Result<()> { + if let Some(port) = self.port { + if port == 0 { + return Err(AppError::BadRequest("VNC port cannot be 0".into())); + } + } + if let Some(ref bind) = self.bind { + if bind.parse::().is_err() { + return Err(AppError::BadRequest("VNC bind must be a valid IP".into())); + } + } + if let Some(quality) = self.jpeg_quality { + if !(10..=100).contains(&quality) { + return Err(AppError::BadRequest( + "VNC JPEG quality must be 10-100".into(), + )); + } + } + if let Some(ref password) = self.password { + if !password.is_empty() && password.len() > 8 { + return Err(AppError::BadRequest( + "VNCAuth password must be at most 8 characters".into(), + )); + } + } + Ok(()) + } + + pub fn validate_merged(&self, config: &VncConfig) -> crate::error::Result<()> { + if config.enabled && config.password.as_deref().unwrap_or("").is_empty() { + return Err(AppError::BadRequest("VNC password is required".into())); + } + Ok(()) + } + + pub fn apply_to(&self, config: &mut VncConfig) { + if let Some(enabled) = self.enabled { + config.enabled = enabled; + } + if let Some(ref bind) = self.bind { + config.bind = bind.clone(); + } + if let Some(port) = self.port { + config.port = port; + } + if let Some(ref encoding) = self.encoding { + config.encoding = encoding.clone(); + } + if let Some(quality) = self.jpeg_quality { + config.jpeg_quality = quality; + } + if let Some(allow_one_client) = self.allow_one_client { + config.allow_one_client = allow_one_client; + } + if let Some(ref password) = self.password { + if !password.is_empty() { + config.password = Some(password.clone()); + } + } + } +} + impl RtspConfigUpdate { pub fn validate(&self) -> crate::error::Result<()> { if let Some(port) = self.port { @@ -1188,6 +1312,7 @@ mod tests { fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() { let update = RustDeskConfigUpdate { enabled: None, + codec: None, rendezvous_server: None, relay_server: None, relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()), @@ -1202,6 +1327,7 @@ mod tests { let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string(); let update = RustDeskConfigUpdate { enabled: None, + codec: None, rendezvous_server: None, relay_server: None, relay_key: Some(not_32), diff --git a/src/web/handlers/config/vnc.rs b/src/web/handlers/config/vnc.rs new file mode 100644 index 00000000..8945df7b --- /dev/null +++ b/src/web/handlers/config/vnc.rs @@ -0,0 +1,110 @@ +use axum::{extract::State, Json}; +use std::sync::Arc; + +use crate::error::Result; +use crate::state::AppState; + +use super::apply::{apply_vnc_config, try_apply_lock, ConfigApplyOptions}; +use super::types::{VncConfigResponse, VncConfigUpdate, VncStatusResponse}; + +fn validate_candidate(state: &Arc, 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, + old_config: crate::config::VncConfig, + new_config: crate::config::VncConfig, +) -> Result { + validate_candidate(state, &new_config)?; + state + .config + .update(|config| { + config.vnc = new_config.clone(); + }) + .await?; + let stored_config = state.config.get().vnc.clone(); + apply_vnc_config( + state, + &old_config, + &stored_config, + ConfigApplyOptions::forced(), + ) + .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) + } +} + +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 { + let config = state.config.get().vnc.clone(); + let (status, connection_count) = current_status(&state).await; + + Json(VncStatusResponse::new(&config, status, connection_count)) +} + +pub async fn update_vnc_config( + State(state): State>, + Json(req): Json, +) -> Result> { + req.validate()?; + + let _apply_guard = try_apply_lock(&state.config_apply_locks.vnc, "vnc")?; + let old_config = state.config.get().vnc.clone(); + let mut merged_config = old_config.clone(); + req.apply_to(&mut merged_config); + req.validate_merged(&merged_config)?; + let new_config = persist_and_apply(&state, old_config, merged_config).await?; + + Ok(Json(VncConfigResponse::from(&new_config))) +} + +pub async fn start_vnc_service( + State(state): State>, +) -> Result> { + let _apply_guard = try_apply_lock(&state.config_apply_locks.vnc, "vnc")?; + let current_config = state.config.get().vnc.clone(); + let mut start_config = current_config.clone(); + start_config.enabled = true; + if start_config.password.as_deref().unwrap_or("").is_empty() { + start_config.password = current_config.password.clone(); + } + let stored_config = persist_and_apply(&state, current_config, start_config).await?; + let (status, connection_count) = current_status(&state).await; + + Ok(Json(VncStatusResponse::new( + &stored_config, + status, + connection_count, + ))) +} + +pub async fn stop_vnc_service( + State(state): State>, +) -> Result> { + let _apply_guard = try_apply_lock(&state.config_apply_locks.vnc, "vnc")?; + let current_config = state.config.get().vnc.clone(); + let mut stop_config = current_config.clone(); + stop_config.enabled = false; + + let stored_config = persist_and_apply(&state, current_config, stop_config).await?; + + Ok(Json(VncStatusResponse::new( + &stored_config, + crate::vnc::VncServiceStatus::Stopped, + 0, + ))) +} diff --git a/src/web/handlers/stream.rs b/src/web/handlers/stream.rs index f7495058..44b85a08 100644 --- a/src/web/handlers/stream.rs +++ b/src/web/handlers/stream.rs @@ -241,6 +241,7 @@ pub struct StreamConstraintsResponse { pub struct ConstraintSources { pub rustdesk: bool, pub rtsp: bool, + pub vnc: bool, } /// Get stream codec constraints derived from enabled services. @@ -267,6 +268,7 @@ pub async fn stream_constraints_get( sources: ConstraintSources { rustdesk: constraints.rustdesk_enabled, rtsp: constraints.rtsp_enabled, + vnc: constraints.vnc_enabled, }, reason: constraints.reason, current_mode, diff --git a/src/web/handlers/system.rs b/src/web/handlers/system.rs index 77a18505..15ded06f 100644 --- a/src/web/handlers/system.rs +++ b/src/web/handlers/system.rs @@ -36,6 +36,7 @@ pub struct Capabilities { pub atx: CapabilityInfo, pub audio: CapabilityInfo, pub rustdesk: CapabilityInfo, + pub vnc: CapabilityInfo, } #[derive(Serialize)] @@ -106,6 +107,11 @@ pub async fn system_info(State(state): State>) -> Json backend: platform.rustdesk.selected_backend.clone(), reason: platform.rustdesk.reason.clone(), }, + vnc: CapabilityInfo { + available: config.vnc.enabled && platform.vnc.available, + backend: platform.vnc.selected_backend.clone(), + reason: platform.vnc.reason.clone(), + }, }, disk_space, device_info, diff --git a/src/web/routes.rs b/src/web/routes.rs index f5547ded..33e95254 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -143,6 +143,15 @@ pub fn create_router(state: Arc) -> Router { "/config/rustdesk/stop", post(handlers::config::stop_rustdesk_service), ) + // VNC configuration endpoints + .route("/config/vnc", get(handlers::config::get_vnc_config)) + .route("/config/vnc", patch(handlers::config::update_vnc_config)) + .route("/config/vnc/status", get(handlers::config::get_vnc_status)) + .route( + "/config/vnc/start", + post(handlers::config::start_vnc_service), + ) + .route("/config/vnc/stop", post(handlers::config::stop_vnc_service)) // RTSP configuration endpoints .route("/config/rtsp", get(handlers::config::get_rtsp_config)) .route("/config/rtsp", patch(handlers::config::update_rtsp_config)) diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 243a123b..ae5ff4ae 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -171,6 +171,7 @@ export const extensionsApi = { export interface RustDeskConfigResponse { enabled: boolean + codec: 'h264' | 'h265' rendezvous_server: string relay_server: string | null device_id: string @@ -187,6 +188,7 @@ export interface RustDeskStatusResponse { export interface RustDeskConfigUpdate { enabled?: boolean + codec?: 'h264' | 'h265' rendezvous_server?: string relay_server?: string relay_key?: string @@ -271,6 +273,50 @@ export const rtspConfigApi = { stop: () => request('/config/rtsp/stop', { method: 'POST' }), } +export type VncEncoding = 'tight_jpeg' | 'h264' + +export interface VncConfigResponse { + enabled: boolean + bind: string + port: number + encoding: VncEncoding + jpeg_quality: number + allow_one_client: boolean + has_password: boolean +} + +export interface VncConfigUpdate { + enabled?: boolean + bind?: string + port?: number + encoding?: VncEncoding + jpeg_quality?: number + allow_one_client?: boolean + password?: string +} + +export interface VncStatusResponse { + config: VncConfigResponse + service_status: string + connection_count: number +} + +export const vncConfigApi = { + get: () => request('/config/vnc'), + + update: (config: VncConfigUpdate) => + request('/config/vnc', { + method: 'PATCH', + body: JSON.stringify(config), + }), + + getStatus: () => request('/config/vnc/status'), + + start: () => request('/config/vnc/start', { method: 'POST' }), + + stop: () => request('/config/vnc/stop', { method: 'POST' }), +} + export type WebConfig = WebConfigResponse export type { WebConfigUpdate } diff --git a/web/src/api/index.ts b/web/src/api/index.ts index e837a756..7c49876d 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -67,6 +67,7 @@ export interface PlatformCapabilities { otg: FeatureCapability audio: FeatureCapability rustdesk: FeatureCapability + vnc: FeatureCapability diagnostics: FeatureCapability extensions: FeatureCapability service_installation: FeatureCapability @@ -86,6 +87,7 @@ export const systemApi = { atx: { available: boolean; backend?: string; reason?: string } audio: { available: boolean; backend?: string; reason?: string } rustdesk: { available: boolean; backend?: string; reason?: string } + vnc: { available: boolean; backend?: string; reason?: string } } disk_space?: { total: number @@ -206,6 +208,7 @@ export interface StreamConstraintsResponse { sources: { rustdesk: boolean rtsp: boolean + vnc: boolean } reason: string current_mode: string @@ -719,6 +722,7 @@ export { redfishConfigApi, rustdeskConfigApi, rtspConfigApi, + vncConfigApi, webConfigApi, type RustDeskConfigResponse, type RustDeskStatusResponse, @@ -729,6 +733,10 @@ export { type RedfishConfigUpdate, type RtspConfigUpdate, type RtspStatusResponse, + type VncConfigResponse, + type VncConfigUpdate, + type VncEncoding, + type VncStatusResponse, type WebConfig, type WebConfigUpdate, } from './config' diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 979df5a7..c3db1a5d 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -522,8 +522,7 @@ export default { environmentSubtitle: 'System runtime environment and USB device maintenance', aboutSubtitle: 'Online upgrade, version info and hardware overview', extTtydSubtitle: 'Open a host Shell terminal in the browser', - extRustdeskSubtitle: 'Remote graphical access via RustDesk', - extRtspSubtitle: 'Provide an RTSP video stream for external clients', + thirdPartyAccessSubtitle: 'Configure external RustDesk, VNC, and RTSP access', extRemoteAccessSubtitle: 'Remote access through NAT-traversal services', extFrpcSubtitle: 'NAT traversal through the FRP client', aboutDesc: 'Open and Lightweight IP-KVM Solution', @@ -967,6 +966,10 @@ export default { start: 'Start', stop: 'Stop', autoStart: 'Auto Start', + thirdPartyAccess: { + title: 'Third-party Access', + desc: 'Configure RustDesk, VNC, and RTSP in one place', + }, viewLogs: 'View Logs', noLogs: 'No logs available', binaryNotFound: '{path} not found, please install the required program', @@ -1040,6 +1043,8 @@ export default { relayServer: 'Relay Server', relayServerPlaceholder: 'hbbr.example.com:21117', relayKey: 'Relay Key', + codec: 'Codec', + codecHint: 'Choose H.264 or H.265 before starting RustDesk. The codec is locked while running.', deviceInfo: 'Device Info', deviceId: 'Device ID', deviceIdHint: 'Use this ID in RustDesk client to connect', @@ -1073,7 +1078,7 @@ export default { pathPlaceholder: 'live', pathHint: 'Example: rtsp://device-ip:8554/live', codec: 'Codec', - codecHint: 'Enabling RTSP locks codec to selected value and disables MJPEG.', + codecHint: 'RTSP locks output to the selected codec while running. If RustDesk is running, choose the same codec.', allowOneClient: 'Allow One Client Only', username: 'Username', usernamePlaceholder: 'Empty means no authentication', @@ -1081,6 +1086,26 @@ export default { passwordPlaceholder: 'Enter new password', urlPreview: 'RTSP URL Preview', }, + vnc: { + title: 'VNC Remote', + desc: 'Access via TigerVNC client', + bind: 'Bind Address', + port: 'Port', + encoding: 'Video Encoding', + encodingTightJpeg: 'Tight JPEG', + encodingH264: 'H.264', + encodingHint: 'VNC locks output while running. VNC cannot start under an H.265 lock; MJPEG blocks RTSP and RustDesk.', + jpegQuality: 'JPEG Quality', + allowOneClient: 'Allow One Client Only', + password: 'Password', + passwordPlaceholder: 'Leave empty to keep current', + passwordRequiredPlaceholder: 'Up to 8 characters', + passwordRequired: 'Set a VNC password', + passwordMaxLength: 'VNC passwords are limited to 8 characters', + passwordSaved: 'Password is saved; leaving this empty keeps it unchanged.', + clients: '{count} clients', + urlPreview: 'VNC Address Preview', + }, }, stats: { title: 'Connection Stats', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index d052af71..2cb7f1e2 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -521,8 +521,7 @@ export default { environmentSubtitle: '系统级运行环境与 USB 设备维护', aboutSubtitle: '在线升级、版本信息与设备硬件概览', extTtydSubtitle: '在浏览器中打开本机 Shell 终端', - extRustdeskSubtitle: '通过 RustDesk 实现远程图形访问', - extRtspSubtitle: '提供 RTSP 视频流以供其他客户端拉流', + thirdPartyAccessSubtitle: '集中配置 RustDesk、VNC 与 RTSP 外部接入', extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问', extFrpcSubtitle: '通过 FRP 客户端实现内网穿透', aboutDesc: '开放轻量的 IP-KVM 解决方案', @@ -966,6 +965,10 @@ export default { start: '启动', stop: '停止', autoStart: '开机自启', + thirdPartyAccess: { + title: '第三方接入', + desc: '集中配置 RustDesk、VNC 与 RTSP', + }, viewLogs: '查看日志', noLogs: '暂无日志', binaryNotFound: '未找到 {path},请先安装对应程序', @@ -1039,6 +1042,8 @@ export default { relayServer: '中继服务器', relayServerPlaceholder: 'hbbr.example.com:21117', relayKey: '中继密钥', + codec: '编码格式', + codecHint: 'RustDesk 启动前需选择 H.264 或 H.265;运行时会锁定编码,不允许客户端切换。', deviceInfo: '设备信息', deviceId: '设备 ID', deviceIdHint: '此 ID 用于 RustDesk 客户端连接', @@ -1072,7 +1077,7 @@ export default { pathPlaceholder: 'live', pathHint: '访问路径,例如 rtsp://设备IP:8554/live', codec: '编码格式', - codecHint: '启用 RTSP 后将锁定编码为所选项,并禁用 MJPEG。', + codecHint: 'RTSP 运行时会锁定为所选编码;若 RustDesk 已运行,只能选择相同编码。', allowOneClient: '仅允许单客户端', username: '用户名', usernamePlaceholder: '留空表示无需认证', @@ -1080,6 +1085,26 @@ export default { passwordPlaceholder: '输入新密码', urlPreview: 'RTSP 地址预览', }, + vnc: { + title: 'VNC 远程', + desc: '通过 TigerVNC 客户端访问', + bind: '监听地址', + port: '端口', + encoding: '视频编码', + encodingTightJpeg: 'Tight JPEG', + encodingH264: 'H.264', + encodingHint: 'VNC 运行时会锁定编码;H.265 锁定时 VNC 无法启动,MJPEG 锁定时 RTSP 与 RustDesk 无法启动。', + jpegQuality: 'JPEG 质量', + allowOneClient: '仅允许单客户端', + password: '密码', + passwordPlaceholder: '留空表示不修改', + passwordRequiredPlaceholder: '最多 8 个字符', + passwordRequired: '请设置 VNC 密码', + passwordMaxLength: 'VNC 密码最多 8 个字符', + passwordSaved: '已保存密码;留空不会修改。', + clients: '{count} 个客户端', + urlPreview: 'VNC 地址预览', + }, }, stats: { title: '连接统计', diff --git a/web/src/stores/config.ts b/web/src/stores/config.ts index b201d7d1..a7116aa3 100644 --- a/web/src/stores/config.ts +++ b/web/src/stores/config.ts @@ -9,6 +9,7 @@ import { rtspConfigApi, rustdeskConfigApi, streamConfigApi, + vncConfigApi, videoConfigApi, webConfigApi, } from '@/api' @@ -36,6 +37,9 @@ import type { RustDeskConfigUpdate as ApiRustDeskConfigUpdate, RustDeskStatusResponse as ApiRustDeskStatusResponse, RustDeskPasswordResponse as ApiRustDeskPasswordResponse, + VncConfigResponse as ApiVncConfigResponse, + VncConfigUpdate as ApiVncConfigUpdate, + VncStatusResponse as ApiVncStatusResponse, WebConfig, WebConfigUpdate, } from '@/api' @@ -57,6 +61,8 @@ export const useConfigStore = defineStore('config', () => { const atx = ref(null) const rtspConfig = ref(null) const rtspStatus = ref(null) + const vncConfig = ref(null) + const vncStatus = ref(null) const rustdeskConfig = ref(null) const rustdeskStatus = ref(null) const rustdeskPassword = ref(null) @@ -70,6 +76,7 @@ export const useConfigStore = defineStore('config', () => { const webLoading = ref(false) const atxLoading = ref(false) const rtspLoading = ref(false) + const vncLoading = ref(false) const rustdeskLoading = ref(false) const authError = ref(null) @@ -81,6 +88,7 @@ export const useConfigStore = defineStore('config', () => { const webError = ref(null) const atxError = ref(null) const rtspError = ref(null) + const vncError = ref(null) const rustdeskError = ref(null) let authPromise: Promise | null = null @@ -93,6 +101,8 @@ export const useConfigStore = defineStore('config', () => { let atxPromise: Promise | null = null let rtspPromise: Promise | null = null let rtspStatusPromise: Promise | null = null + let vncPromise: Promise | null = null + let vncStatusPromise: Promise | null = null let rustdeskPromise: Promise | null = null let rustdeskStatusPromise: Promise | null = null let rustdeskPasswordPromise: Promise | null = null @@ -318,6 +328,51 @@ export const useConfigStore = defineStore('config', () => { return request } + async function refreshVncConfig() { + if (vncLoading.value && vncPromise) return vncPromise + vncLoading.value = true + vncError.value = null + const request = vncConfigApi.get() + .then((response) => { + vncConfig.value = response + return response + }) + .catch((error) => { + vncError.value = normalizeErrorMessage(error) + throw error + }) + .finally(() => { + vncLoading.value = false + vncPromise = null + }) + + vncPromise = request + return request + } + + async function refreshVncStatus() { + if (vncLoading.value && vncStatusPromise) return vncStatusPromise + vncLoading.value = true + vncError.value = null + const request = vncConfigApi.getStatus() + .then((response) => { + vncStatus.value = response + vncConfig.value = response.config + return response + }) + .catch((error) => { + vncError.value = normalizeErrorMessage(error) + throw error + }) + .finally(() => { + vncLoading.value = false + vncStatusPromise = null + }) + + vncStatusPromise = request + return request + } + async function refreshRustdeskConfig() { if (rustdeskLoading.value && rustdeskPromise) return rustdeskPromise rustdeskLoading.value = true @@ -430,6 +485,11 @@ export const useConfigStore = defineStore('config', () => { return refreshRtspConfig() } + function ensureVncConfig() { + if (vncConfig.value) return Promise.resolve(vncConfig.value) + return refreshVncConfig() + } + function ensureRustdeskConfig() { if (rustdeskConfig.value) return Promise.resolve(rustdeskConfig.value) return refreshRustdeskConfig() @@ -489,6 +549,12 @@ export const useConfigStore = defineStore('config', () => { return response } + async function updateVnc(update: ApiVncConfigUpdate) { + const response = await vncConfigApi.update(update) + vncConfig.value = response + return response + } + async function updateRustdesk(update: ApiRustDeskConfigUpdate) { const response = await rustdeskConfigApi.update(update) rustdeskConfig.value = response @@ -518,6 +584,8 @@ export const useConfigStore = defineStore('config', () => { atx, rtspConfig, rtspStatus, + vncConfig, + vncStatus, rustdeskConfig, rustdeskStatus, rustdeskPassword, @@ -530,6 +598,7 @@ export const useConfigStore = defineStore('config', () => { webLoading, atxLoading, rtspLoading, + vncLoading, rustdeskLoading, authError, videoError, @@ -540,6 +609,7 @@ export const useConfigStore = defineStore('config', () => { webError, atxError, rtspError, + vncError, rustdeskError, refreshAuth, refreshVideo, @@ -551,6 +621,8 @@ export const useConfigStore = defineStore('config', () => { refreshAtx, refreshRtspConfig, refreshRtspStatus, + refreshVncConfig, + refreshVncStatus, refreshRustdeskConfig, refreshRustdeskStatus, refreshRustdeskPassword, @@ -563,6 +635,7 @@ export const useConfigStore = defineStore('config', () => { ensureWeb, ensureAtx, ensureRtspConfig, + ensureVncConfig, ensureRustdeskConfig, updateAuth, updateVideo, @@ -573,6 +646,7 @@ export const useConfigStore = defineStore('config', () => { updateWeb, updateAtx, updateRtsp, + updateVnc, updateRustdesk, regenerateRustdeskId, regenerateRustdeskPassword, diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index 3f45a473..4872aff8 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -62,14 +62,6 @@ export interface Ch9329DescriptorConfig { serial_number?: string; } -export interface Ch9329DescriptorState { - descriptor: Ch9329DescriptorConfig; - manufacturer_enabled: boolean; - product_enabled: boolean; - serial_enabled: boolean; - config_mode_available: boolean; -} - export interface HidConfig { backend: HidBackend; otg_udc?: string; @@ -234,11 +226,31 @@ export interface ExtensionsConfig { export interface RustDeskConfig { enabled: boolean; + codec: RustDeskCodec; rendezvous_server: string; relay_server?: string; device_id: string; } +export enum RustDeskCodec { + H264 = "h264", + H265 = "h265", +} + +export enum VncEncoding { + TightJpeg = "tight_jpeg", + H264 = "h264", +} + +export interface VncConfig { + enabled: boolean; + bind: string; + port: number; + encoding: VncEncoding; + jpeg_quality: number; + allow_one_client: boolean; +} + export enum RtspCodec { H264 = "h264", H265 = "h265", @@ -270,6 +282,7 @@ export interface AppConfig { web: WebConfig; extensions: ExtensionsConfig; rustdesk: RustDeskConfig; + vnc: VncConfig; rtsp: RtspConfig; redfish: RedfishConfig; } @@ -328,6 +341,14 @@ export interface Ch9329DescriptorConfigUpdate { serial_number?: string; } +export interface Ch9329DescriptorState { + descriptor: Ch9329DescriptorConfig; + manufacturer_enabled: boolean; + product_enabled: boolean; + serial_enabled: boolean; + config_mode_available: boolean; +} + export interface EasytierConfigUpdate { enabled?: boolean; network_name?: string; @@ -480,6 +501,7 @@ export interface RtspStatusResponse { export interface RustDeskConfigUpdate { enabled?: boolean; + codec?: RustDeskCodec; rendezvous_server?: string; relay_server?: string; relay_key?: string; @@ -535,6 +557,32 @@ export interface VideoConfigUpdate { quality?: number; } +export interface VncConfigResponse { + enabled: boolean; + bind: string; + port: number; + encoding: VncEncoding; + jpeg_quality: number; + allow_one_client: boolean; + has_password: boolean; +} + +export interface VncConfigUpdate { + enabled?: boolean; + bind?: string; + port?: number; + encoding?: VncEncoding; + jpeg_quality?: number; + allow_one_client?: boolean; + password?: string; +} + +export interface VncStatusResponse { + config: VncConfigResponse; + service_status: string; + connection_count: number; +} + /** * Web server settings returned by `GET` / `PATCH /api/config/web`. * diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 7adf0f77..a396f41f 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -20,6 +20,7 @@ import { systemApi, updateApi, usbApi, + vncConfigApi, type EncoderBackendInfo, type AuthConfig, type RustDeskConfigResponse, @@ -27,6 +28,8 @@ import { type RustDeskPasswordResponse, type RtspStatusResponse, type RtspConfigUpdate, + type VncConfigUpdate, + type VncStatusResponse, type WebConfig, type UpdateOverviewResponse, type UpdateStatusResponse, @@ -105,7 +108,6 @@ import { ExternalLink, Copy, ScreenShare, - Radio, Globe, Loader2, AlertTriangle, @@ -136,8 +138,7 @@ const SETTINGS_SECTION_IDS = [ 'atx', 'environment', 'ext-ttyd', - 'ext-rustdesk', - 'ext-rtsp', + 'third-party-access', 'ext-remote-access', 'about', ] as const @@ -167,8 +168,7 @@ const navGroups = computed(() => [ title: t('settings.extensions'), items: [ { id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal }, - { id: 'ext-rustdesk', label: t('extensions.rustdesk.title'), icon: ScreenShare }, - { id: 'ext-rtsp', label: t('extensions.rtsp.title'), icon: Radio }, + { id: 'third-party-access', label: t('extensions.thirdPartyAccess.title'), icon: ScreenShare }, { id: 'ext-remote-access', label: t('extensions.remoteAccess.title'), icon: ExternalLink }, ] }, @@ -200,8 +200,7 @@ const sectionMeta = computed(() => { function sectionSubtitleKey(id: string): string { switch (id) { case 'ext-ttyd': return 'extTtydSubtitle' - case 'ext-rustdesk': return 'extRustdeskSubtitle' - case 'ext-rtsp': return 'extRtspSubtitle' + case 'third-party-access': return 'thirdPartyAccessSubtitle' case 'ext-remote-access': return 'extRemoteAccessSubtitle' default: return `${id}Subtitle` } @@ -222,6 +221,7 @@ function normalizeSettingsSection(value: unknown): SettingsSectionId | null { if (typeof value !== 'string') return null if (value === 'access-control') return 'account' if (value === 'ext-frpc') return 'ext-remote-access' + if (value === 'ext-rustdesk' || value === 'ext-vnc' || value === 'ext-rtsp') return 'third-party-access' return isSettingsSectionId(value) ? value : null } @@ -272,15 +272,14 @@ async function loadSectionData(section: SettingsSectionId) { case 'ext-remote-access': await loadExtensions() return - case 'ext-rustdesk': + case 'third-party-access': await Promise.all([ loadRustdeskConfig(), loadRustdeskPassword(), + loadRtspConfig(), + loadVncConfig(), ]) return - case 'ext-rtsp': - await loadRtspConfig() - return case 'about': if (isAndroid.value) return await Promise.all([ @@ -390,6 +389,7 @@ const rustdeskCopied = ref<'id' | 'password' | null>(null) const { copy: clipboardCopy } = useClipboard() const rustdeskLocalConfig = ref({ enabled: false, + codec: 'h264' as 'h264' | 'h265', rendezvous_server: '', relay_server: '', relay_key: '', @@ -415,6 +415,18 @@ const rtspLocalConfig = ref({ password: '', }) +const vncStatus = ref(null) +const vncLoading = ref(false) +const vncLocalConfig = ref({ + enabled: false, + bind: '0.0.0.0', + port: 5900, + encoding: 'tight_jpeg', + jpeg_quality: 80, + allow_one_client: true, + password: '', +}) + function formatHostForUrl(hostname: string): string { if (!hostname) return '127.0.0.1' return hostname.includes(':') && !hostname.startsWith('[') @@ -429,6 +441,12 @@ const rtspStreamUrl = computed(() => { return `rtsp://${host}:${port}/${path}` }) +const vncStreamUrl = computed(() => { + const host = formatHostForUrl(window.location.hostname || '127.0.0.1') + const port = Number(vncLocalConfig.value.port) || 5900 + return `${host}:${port}` +}) + const webServerConfig = ref({ http_port: 8080, https_port: 8443, @@ -1839,6 +1857,7 @@ function applyRustdeskStatus(status: RustDeskStatusResponse) { rustdeskStatus.value = status rustdeskLocalConfig.value = { enabled: config.enabled, + codec: config.codec || 'h264', rendezvous_server: config.rendezvous_server, relay_server: config.relay_server || '', relay_key: config.relay_key || '', @@ -1901,6 +1920,17 @@ function validateRustdeskConfig(): boolean { return !rustdeskValidationMessage.value || showValidationError(rustdeskValidationMessage.value) } +function validateVncConfig(enabled = vncLocalConfig.value.enabled): boolean { + const password = (vncLocalConfig.value.password || '').trim() + if (enabled && !vncStatus.value?.config.has_password && !password) { + return showValidationError(t('extensions.vnc.passwordRequired')) + } + if (password.length > 8) { + return showValidationError(t('extensions.vnc.passwordMaxLength')) + } + return true +} + function normalizeRtspPath(path: string): string { return path.trim().replace(/^\/+|\/+$/g, '') || 'live' } @@ -2222,23 +2252,26 @@ function updateStatusBadgeText(): string { || updatePhaseText(updateStatus.value?.phase) } +function rustdeskUpdatePayload(enabled = rustdeskLocalConfig.value.enabled) { + return { + enabled, + codec: rustdeskLocalConfig.value.codec, + rendezvous_server: normalizeRustdeskServer( + rustdeskLocalConfig.value.rendezvous_server, + 21116, + ), + relay_server: normalizeRustdeskServer(rustdeskLocalConfig.value.relay_server, 21117), + relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key), + } +} + async function saveRustdeskConfig() { if (rustdeskLocalConfig.value.enabled && !validateRustdeskConfig()) return loading.value = true saved.value = false try { - const rendezvousServer = normalizeRustdeskServer( - rustdeskLocalConfig.value.rendezvous_server, - 21116, - ) - const relayServer = normalizeRustdeskServer(rustdeskLocalConfig.value.relay_server, 21117) - await configStore.updateRustdesk({ - enabled: rustdeskLocalConfig.value.enabled, - rendezvous_server: rendezvousServer, - relay_server: relayServer, - relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key), - }) + await configStore.updateRustdesk(rustdeskUpdatePayload()) await loadRustdeskConfig() saved.value = true setTimeout(() => (saved.value = false), 2000) @@ -2279,6 +2312,7 @@ async function startRustdesk() { rustdeskLoading.value = true try { + await configStore.updateRustdesk(rustdeskUpdatePayload(true)) const status = await rustdeskConfigApi.start() applyRustdeskStatus(status) } catch { @@ -2373,23 +2407,24 @@ async function loadRtspConfig() { } } +function rtspUpdatePayload(enabled = !!rtspLocalConfig.value.enabled): RtspConfigUpdate { + return { + enabled, + bind: rtspLocalConfig.value.bind?.trim() || '0.0.0.0', + port: Number(rtspLocalConfig.value.port) || 8554, + path: normalizeRtspPath(rtspLocalConfig.value.path || 'live'), + allow_one_client: !!rtspLocalConfig.value.allow_one_client, + codec: rtspLocalConfig.value.codec || 'h264', + username: (rtspLocalConfig.value.username || '').trim(), + password: (rtspLocalConfig.value.password || '').trim(), + } +} + async function saveRtspConfig() { loading.value = true saved.value = false try { - const update: RtspConfigUpdate = { - enabled: !!rtspLocalConfig.value.enabled, - bind: rtspLocalConfig.value.bind?.trim() || '0.0.0.0', - port: Number(rtspLocalConfig.value.port) || 8554, - path: normalizeRtspPath(rtspLocalConfig.value.path || 'live'), - allow_one_client: !!rtspLocalConfig.value.allow_one_client, - codec: rtspLocalConfig.value.codec || 'h264', - username: (rtspLocalConfig.value.username || '').trim(), - } - - update.password = (rtspLocalConfig.value.password || '').trim() - - await configStore.updateRtsp(update) + await configStore.updateRtsp(rtspUpdatePayload()) await loadRtspConfig() saved.value = true setTimeout(() => (saved.value = false), 2000) @@ -2402,6 +2437,7 @@ async function saveRtspConfig() { async function startRtsp() { rtspLoading.value = true try { + await configStore.updateRtsp(rtspUpdatePayload(true)) const status = await rtspConfigApi.start() applyRtspStatus(status) } catch { @@ -2421,6 +2457,108 @@ async function stopRtsp() { } } +function applyVncStatus(status: VncStatusResponse) { + vncStatus.value = status + vncLocalConfig.value = { + enabled: status.config.enabled, + bind: status.config.bind, + port: status.config.port, + encoding: status.config.encoding, + jpeg_quality: status.config.jpeg_quality, + allow_one_client: status.config.allow_one_client, + password: '', + } +} + +async function loadVncConfig() { + vncLoading.value = true + try { + const status = await configStore.refreshVncStatus() + applyVncStatus(status) + } catch { + } finally { + vncLoading.value = false + } +} + +function vncUpdatePayload(enabled = !!vncLocalConfig.value.enabled): VncConfigUpdate { + const update: VncConfigUpdate = { + enabled, + bind: vncLocalConfig.value.bind?.trim() || '0.0.0.0', + port: Number(vncLocalConfig.value.port) || 5900, + encoding: vncLocalConfig.value.encoding || 'tight_jpeg', + jpeg_quality: Number(vncLocalConfig.value.jpeg_quality) || 80, + allow_one_client: !!vncLocalConfig.value.allow_one_client, + } + const password = (vncLocalConfig.value.password || '').trim() + if (password) update.password = password + return update +} + +async function saveVncConfig() { + if (!validateVncConfig()) return + + loading.value = true + saved.value = false + try { + await configStore.updateVnc(vncUpdatePayload()) + await loadVncConfig() + saved.value = true + setTimeout(() => (saved.value = false), 2000) + } catch { + } finally { + loading.value = false + } +} + +async function startVnc() { + if (!validateVncConfig(true)) return + + vncLoading.value = true + try { + await configStore.updateVnc(vncUpdatePayload(true)) + const status = await vncConfigApi.start() + applyVncStatus(status) + } catch { + } finally { + vncLoading.value = false + } +} + +async function stopVnc() { + vncLoading.value = true + try { + const status = await vncConfigApi.stop() + applyVncStatus(status) + } catch { + } finally { + vncLoading.value = false + } +} + +function getVncServiceStatusText(status: string | undefined): string { + if (!status) return t('extensions.stopped') + switch (status) { + case 'running': return t('extensions.running') + case 'starting': return t('extensions.starting') + case 'stopped': return t('extensions.stopped') + default: + if (status.startsWith('error:')) return t('extensions.failed') + return status + } +} + +function getVncStatusClass(status: string | undefined): string { + switch (status) { + case 'running': return 'bg-green-500' + case 'starting': return 'bg-yellow-500' + case 'stopped': return 'bg-gray-400' + default: + if (status?.startsWith('error:')) return 'bg-red-500' + return 'bg-gray-400' + } +} + function getRtspServiceStatusText(status: string | undefined): string { if (!status) return t('extensions.stopped') switch (status) { @@ -4506,7 +4644,7 @@ watch(isWindows, () => { -
+
@@ -4631,8 +4769,134 @@ watch(isWindows, () => {
+ +
+ + +
+
+ {{ t('extensions.vnc.title') }} + {{ t('extensions.vnc.desc') }} +
+
+ + {{ getVncServiceStatusText(vncStatus?.service_status) }} + + +
+
+
+ +
+
+
+ {{ getVncServiceStatusText(vncStatus?.service_status) }} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +

{{ t('extensions.vnc.encodingHint') }}

+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+

{{ t('extensions.vnc.passwordSaved') }}

+
+
+
+ + + +
+

{{ t('extensions.vnc.urlPreview') }}

+ {{ vncStreamUrl }} +
+ + +
+ +
+
+ -
+
@@ -4692,6 +4956,16 @@ watch(isWindows, () => {
+
+ +
+ +

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

+
+