feat: 新增 MJPEG/H.264 VNC 初步支持

This commit is contained in:
mofeng-git
2026-06-15 22:23:27 +08:00
parent 5c98aea7e3
commit c101ef1c80
34 changed files with 2270 additions and 354 deletions

View File

@@ -58,6 +58,7 @@ desktop = [
"dep:ventoy-img", "dep:ventoy-img",
"dep:protobuf", "dep:protobuf",
"dep:sodiumoxide", "dep:sodiumoxide",
"dep:des",
"dep:sha2", "dep:sha2",
"dep:typeshare", "dep:typeshare",
"dep:hwcodec", "dep:hwcodec",
@@ -104,6 +105,7 @@ android = [
"dep:serialport", "dep:serialport",
"dep:sha2", "dep:sha2",
"dep:sodiumoxide", "dep:sodiumoxide",
"dep:des",
"dep:sqlx", "dep:sqlx",
"dep:alsa", "dep:alsa",
"dep:audiopus", "dep:audiopus",
@@ -222,6 +224,7 @@ ventoy-img = { path = "libs/ventoy-img-rs", optional = true }
# RustDesk protocol support # RustDesk protocol support
protobuf = { version = "3.7", features = ["with-bytes"], optional = true } protobuf = { version = "3.7", features = ["with-bytes"], optional = true }
sodiumoxide = { version = "0.2", optional = true } sodiumoxide = { version = "0.2", optional = true }
des = { version = "0.8", optional = true }
sha2 = { version = "0.10", optional = true } sha2 = { version = "0.10", optional = true }
# TypeScript type generation # TypeScript type generation
typeshare = { version = "1.0", optional = true } typeshare = { version = "1.0", optional = true }

View File

@@ -32,6 +32,7 @@ pub struct AppConfig {
pub web: WebConfig, pub web: WebConfig,
pub extensions: ExtensionsConfig, pub extensions: ExtensionsConfig,
pub rustdesk: RustDeskConfig, pub rustdesk: RustDeskConfig,
pub vnc: VncConfig,
pub rtsp: RtspConfig, pub rtsp: RtspConfig,
pub redfish: RedfishConfig, pub redfish: RedfishConfig,
} }

View File

@@ -23,6 +23,44 @@ pub enum RtspCodec {
H265, 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<String>,
}
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] #[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]

View File

@@ -51,6 +51,8 @@ pub mod utils;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(any(feature = "android", feature = "desktop"))]
pub mod video; pub mod video;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(any(feature = "android", feature = "desktop"))]
pub mod vnc;
#[cfg(any(feature = "android", feature = "desktop"))]
pub mod web; pub mod web;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(any(feature = "android", feature = "desktop"))]
pub mod webrtc; pub mod webrtc;

View File

@@ -31,10 +31,12 @@ use one_kvm::state::{AppState, ShutdownAction};
use one_kvm::update::UpdateService; use one_kvm::update::UpdateService;
use one_kvm::utils::bind_tcp_listener; use one_kvm::utils::bind_tcp_listener;
use one_kvm::video::codec_constraints::{ 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::format::{PixelFormat, Resolution};
use one_kvm::video::{Streamer, VideoStreamManager}; use one_kvm::video::{Streamer, VideoStreamManager};
use one_kvm::vnc::VncService;
use one_kvm::web; use one_kvm::web;
use one_kvm::webrtc::{WebRtcStreamer, WebRtcStreamerConfig}; 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!( tracing::info!(
"Initializing RustDesk service: ID={} -> {}", "Initializing RustDesk service: ID={} -> {}",
config.rustdesk.device_id, config.rustdesk.device_id,
@@ -510,7 +523,7 @@ async fn main() -> anyhow::Result<()> {
None None
}; };
let rtsp = if config.rtsp.enabled { let rtsp = if third_party_codec_config_valid && config.rtsp.enabled {
tracing::info!( tracing::info!(
"Initializing RTSP service: rtsp://{}:{}/{}", "Initializing RTSP service: rtsp://{}:{}/{}",
config.rtsp.bind, config.rtsp.bind,
@@ -524,6 +537,23 @@ async fn main() -> anyhow::Result<()> {
None 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 update_service = Arc::new(UpdateService::new(data_dir.join("updates")));
let state = AppState::new( let state = AppState::new(
@@ -541,6 +571,7 @@ async fn main() -> anyhow::Result<()> {
atx, atx,
audio, audio,
rustdesk.clone(), rustdesk.clone(),
vnc.clone(),
rtsp.clone(), rtsp.clone(),
extensions.clone(), extensions.clone(),
events.clone(), events.clone(),
@@ -573,6 +604,13 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("RustDesk service started"); 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 Some(ref service) = rtsp {
if let Err(e) = service.start().await { if let Err(e) = service.start().await {
@@ -1135,6 +1173,14 @@ async fn cleanup(state: &Arc<AppState>) {
} }
} }
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 Some(ref service) = *state.rtsp.read().await {
if let Err(e) = service.stop().await { if let Err(e) = service.stop().await {
tracing::warn!("Failed to stop RTSP service: {}", e); tracing::warn!("Failed to stop RTSP service: {}", e);

View File

@@ -36,6 +36,7 @@ pub fn capabilities() -> PlatformCapabilities {
audio: FeatureCapability::available(["alsa", "opus"]) audio: FeatureCapability::available(["alsa", "opus"])
.with_selected_backend(Some("alsa".to_string())), .with_selected_backend(Some("alsa".to_string())),
rustdesk: FeatureCapability::available(["builtin"]), rustdesk: FeatureCapability::available(["builtin"]),
vnc: FeatureCapability::available(["builtin", "tight_jpeg", "h264"]),
diagnostics: FeatureCapability::available(["android_linux"]), diagnostics: FeatureCapability::available(["android_linux"]),
extensions: FeatureCapability::unsupported("unsupported on Android Amlogic v1"), extensions: FeatureCapability::unsupported("unsupported on Android Amlogic v1"),
service_installation: FeatureCapability::available(["android_foreground_service"]), service_installation: FeatureCapability::available(["android_foreground_service"]),

View File

@@ -78,6 +78,7 @@ pub struct PlatformCapabilities {
pub otg: FeatureCapability, pub otg: FeatureCapability,
pub audio: FeatureCapability, pub audio: FeatureCapability,
pub rustdesk: FeatureCapability, pub rustdesk: FeatureCapability,
pub vnc: FeatureCapability,
pub diagnostics: FeatureCapability, pub diagnostics: FeatureCapability,
pub extensions: FeatureCapability, pub extensions: FeatureCapability,
pub service_installation: FeatureCapability, pub service_installation: FeatureCapability,

View File

@@ -16,6 +16,7 @@ pub fn capabilities() -> PlatformCapabilities {
otg: FeatureCapability::available(["configfs"]), otg: FeatureCapability::available(["configfs"]),
audio: FeatureCapability::available(["alsa"]), audio: FeatureCapability::available(["alsa"]),
rustdesk: FeatureCapability::available(["builtin"]), rustdesk: FeatureCapability::available(["builtin"]),
vnc: FeatureCapability::available(["builtin", "tight_jpeg", "h264"]),
diagnostics: FeatureCapability::available(["linux"]), diagnostics: FeatureCapability::available(["linux"]),
extensions: FeatureCapability::available(["linux"]), extensions: FeatureCapability::available(["linux"]),
service_installation: FeatureCapability::available(["systemd"]), service_installation: FeatureCapability::available(["systemd"]),

View File

@@ -26,6 +26,8 @@ pub fn capabilities() -> PlatformCapabilities {
.with_selected_backend(Some("wasapi".to_string())), .with_selected_backend(Some("wasapi".to_string())),
rustdesk: FeatureCapability::available(["builtin", "tcp_direct", "relay"]) rustdesk: FeatureCapability::available(["builtin", "tcp_direct", "relay"])
.with_selected_backend(Some("builtin".to_string())), .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"]), diagnostics: FeatureCapability::available(["windows"]),
extensions: FeatureCapability::available(["windows_safe"]), extensions: FeatureCapability::available(["windows_safe"]),
service_installation: FeatureCapability::available(["windows_service"]), service_installation: FeatureCapability::available(["windows_service"]),

View File

@@ -32,10 +32,12 @@ use crate::stream_encoder::encoder_type_to_backend;
use crate::update::UpdateService; use crate::update::UpdateService;
use crate::utils::bind_tcp_listener; use crate::utils::bind_tcp_listener;
use crate::video::codec_constraints::{ 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::format::{PixelFormat, Resolution};
use crate::video::{Streamer, VideoStreamManager}; use crate::video::{Streamer, VideoStreamManager};
use crate::vnc::VncService;
use crate::web; use crate::web;
use crate::webrtc::{config::WebRtcConfig, WebRtcStreamer, WebRtcStreamerConfig}; 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); 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( Some(Arc::new(RustDeskService::new(
config.rustdesk.clone(), config.rustdesk.clone(),
stream_manager.clone(), stream_manager.clone(),
@@ -451,7 +464,7 @@ async fn build_app_state(
None None
}; };
let rtsp = if config.rtsp.enabled { let rtsp = if third_party_codec_config_valid && config.rtsp.enabled {
Some(Arc::new(RtspService::new( Some(Arc::new(RtspService::new(
config.rtsp.clone(), config.rtsp.clone(),
stream_manager.clone(), stream_manager.clone(),
@@ -459,6 +472,15 @@ async fn build_app_state(
} else { } else {
None 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 update_service = Arc::new(UpdateService::new(data_dir.join("updates")));
let state = AppState::new( let state = AppState::new(
@@ -474,6 +496,7 @@ async fn build_app_state(
atx, atx,
audio, audio,
rustdesk.clone(), rustdesk.clone(),
vnc.clone(),
rtsp.clone(), rtsp.clone(),
extensions.clone(), extensions.clone(),
events.clone(), events.clone(),
@@ -489,6 +512,11 @@ async fn build_app_state(
tracing::warn!("Failed to start Android RustDesk service: {}", err); 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 Some(service) = rtsp {
if let Err(err) = service.start().await { if let Err(err) = service.start().await {
tracing::warn!("Failed to start Android RTSP service: {}", err); tracing::warn!("Failed to start Android RTSP service: {}", err);
@@ -674,6 +702,12 @@ async fn cleanup(state: &Arc<AppState>) {
} }
} }
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 Some(service) = state.rtsp.read().await.as_ref() {
if let Err(err) = service.stop().await { if let Err(err) = service.stop().await {
tracing::warn!("Failed to stop Android RTSP service: {}", err); tracing::warn!("Failed to stop Android RTSP service: {}", err);

View File

@@ -1,11 +1,22 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use typeshare::typeshare; 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] #[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct RustDeskConfig { pub struct RustDeskConfig {
pub enabled: bool, pub enabled: bool,
pub codec: RustDeskCodec,
pub rendezvous_server: String, pub rendezvous_server: String,
pub relay_server: Option<String>, pub relay_server: Option<String>,
#[typeshare(skip)] #[typeshare(skip)]
@@ -29,6 +40,7 @@ impl Default for RustDeskConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false, enabled: false,
codec: RustDeskCodec::H264,
rendezvous_server: String::new(), rendezvous_server: String::new(),
relay_server: None, relay_server: None,
relay_key: None, relay_key: None,

View File

@@ -18,9 +18,7 @@ use crate::hid::{CanonicalKey, HidController, KeyEventType, KeyboardEvent, Keybo
use crate::utils::hostname_from_etc; use crate::utils::hostname_from_etc;
use crate::video::codec::registry::{EncoderRegistry, VideoEncoderType}; use crate::video::codec::registry::{EncoderRegistry, VideoEncoderType};
use crate::video::codec::BitratePreset; use crate::video::codec::BitratePreset;
use crate::video::codec_constraints::{ use crate::video::codec_constraints::{encoder_codec_to_id, encoder_codec_to_video_codec};
encoder_codec_to_id, encoder_codec_to_video_codec, video_codec_to_encoder_codec,
};
use crate::video::stream_manager::VideoStreamManager; use crate::video::stream_manager::VideoStreamManager;
use super::bytes_codec::{read_frame, write_frame, write_frame_buffered}; use super::bytes_codec::{read_frame, write_frame, write_frame_buffered};
@@ -160,6 +158,8 @@ pub struct Connection {
last_caps_lock: bool, last_caps_lock: bool,
/// Whether relative mouse mode is currently active for this connection /// Whether relative mouse mode is currently active for this connection
relative_mouse_active: bool, relative_mouse_active: bool,
/// Server-configured RustDesk video codec.
configured_codec: VideoEncoderType,
} }
/// Messages sent to connection handler /// Messages sent to connection handler
@@ -209,6 +209,11 @@ impl Connection {
// This is used for encrypting the symmetric key exchange // This is used for encrypting the symmetric key exchange
let temp_keypair = box_::gen_keypair(); 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 { let conn = Self {
id, id,
device_id: config.device_id.clone(), device_id: config.device_id.clone(),
@@ -238,6 +243,7 @@ impl Connection {
last_test_delay_sent: None, last_test_delay_sent: None,
last_caps_lock: false, last_caps_lock: false,
relative_mouse_active: false, relative_mouse_active: false,
configured_codec,
}; };
(conn, rx) (conn, rx)
@@ -628,43 +634,29 @@ impl Connection {
Ok(true) Ok(true)
} }
/// Negotiate video codec - select the best available encoder /// Negotiate video codec from the server-configured RustDesk codec.
/// Priority: H264 > H265 > VP8 > VP9 (H264/H265 leverage hardware encoding on embedded devices)
async fn negotiate_video_codec(&self) -> VideoEncoderType { async fn negotiate_video_codec(&self) -> VideoEncoderType {
let registry = EncoderRegistry::global(); let registry = EncoderRegistry::global();
let constraints = self.current_codec_constraints().await; let constraints = self.current_codec_constraints().await;
let configured = self.configured_codec;
// Check availability in priority order if !constraints.is_webrtc_codec_allowed(encoder_codec_to_video_codec(configured)) {
// 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;
}
// Fallback to preferred allowed codec
let preferred = constraints.preferred_webrtc_codec();
warn!( warn!(
"No allowed encoder available in priority order, falling back to {}", "Configured RustDesk codec {} is blocked by constraints: {}",
encoder_codec_to_id(video_codec_to_encoder_codec(preferred)) encoder_codec_to_id(configured),
constraints.reason
); );
video_codec_to_encoder_codec(preferred) return configured;
}
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( 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() { if let Some(supported_decoding) = opt.supported_decoding.as_ref() {
let prefer = supported_decoding.prefer.value(); let prefer = supported_decoding.prefer.value();
debug!("Client codec preference: prefer={}", prefer); 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) // Log custom_image_quality (accept but don't process)
@@ -803,31 +752,6 @@ impl Connection {
Ok(()) 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 /// Start video streaming task
fn start_video_streaming(&mut self, video_tx: mpsc::Sender<Bytes>) { fn start_video_streaming(&mut self, video_tx: mpsc::Sender<Bytes>) {
let video_manager = match &self.video_manager { let video_manager = match &self.video_manager {
@@ -1105,18 +1029,15 @@ impl Connection {
let constraints = self.current_codec_constraints().await; let constraints = self.current_codec_constraints().await;
// Check which encoders are available (include software fallback) // Check which encoders are available (include software fallback)
let h264_available = constraints let configured = self.configured_codec;
.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H264) let h264_available = configured == VideoEncoderType::H264
&& constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H264)
&& registry.is_codec_available(VideoEncoderType::H264); && registry.is_codec_available(VideoEncoderType::H264);
let h265_available = constraints let h265_available = configured == VideoEncoderType::H265
.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H265) && constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H265)
&& registry.is_codec_available(VideoEncoderType::H265); && registry.is_codec_available(VideoEncoderType::H265);
let vp8_available = constraints let vp8_available = false;
.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::VP8) let vp9_available = false;
&& 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);
info!( info!(
"Server encoding capabilities: H264={}, H265={}, VP8={}, VP9={}", "Server encoding capabilities: H264={}, H265={}, VP8={}, VP9={}",

View File

@@ -20,6 +20,7 @@ use crate::rtsp::RtspService;
use crate::rustdesk::RustDeskService; use crate::rustdesk::RustDeskService;
use crate::update::UpdateService; use crate::update::UpdateService;
use crate::video::VideoStreamManager; use crate::video::VideoStreamManager;
use crate::vnc::VncService;
use crate::webrtc::WebRtcStreamer; use crate::webrtc::WebRtcStreamer;
#[derive(Clone)] #[derive(Clone)]
@@ -30,6 +31,7 @@ pub struct ConfigApplyLocks {
pub audio: Arc<Mutex<()>>, pub audio: Arc<Mutex<()>>,
pub atx: Arc<Mutex<()>>, pub atx: Arc<Mutex<()>>,
pub rustdesk: Arc<Mutex<()>>, pub rustdesk: Arc<Mutex<()>>,
pub vnc: Arc<Mutex<()>>,
pub rtsp: Arc<Mutex<()>>, pub rtsp: Arc<Mutex<()>>,
} }
@@ -48,6 +50,7 @@ impl ConfigApplyLocks {
audio: Arc::new(Mutex::new(())), audio: Arc::new(Mutex::new(())),
atx: Arc::new(Mutex::new(())), atx: Arc::new(Mutex::new(())),
rustdesk: Arc::new(Mutex::new(())), rustdesk: Arc::new(Mutex::new(())),
vnc: Arc::new(Mutex::new(())),
rtsp: Arc::new(Mutex::new(())), rtsp: Arc::new(Mutex::new(())),
} }
} }
@@ -69,6 +72,7 @@ pub struct AppState {
pub atx: Arc<RwLock<Option<AtxController>>>, pub atx: Arc<RwLock<Option<AtxController>>>,
pub audio: Arc<AudioController>, pub audio: Arc<AudioController>,
pub rustdesk: Arc<RwLock<Option<Arc<RustDeskService>>>>, pub rustdesk: Arc<RwLock<Option<Arc<RustDeskService>>>>,
pub vnc: Arc<RwLock<Option<Arc<VncService>>>>,
pub rtsp: Arc<RwLock<Option<Arc<RtspService>>>>, pub rtsp: Arc<RwLock<Option<Arc<RtspService>>>>,
pub extensions: Arc<ExtensionManager>, pub extensions: Arc<ExtensionManager>,
pub events: Arc<EventBus>, pub events: Arc<EventBus>,
@@ -95,6 +99,7 @@ impl AppState {
atx: Option<AtxController>, atx: Option<AtxController>,
audio: Arc<AudioController>, audio: Arc<AudioController>,
rustdesk: Option<Arc<RustDeskService>>, rustdesk: Option<Arc<RustDeskService>>,
vnc: Option<Arc<VncService>>,
rtsp: Option<Arc<RtspService>>, rtsp: Option<Arc<RtspService>>,
extensions: Arc<ExtensionManager>, extensions: Arc<ExtensionManager>,
events: Arc<EventBus>, events: Arc<EventBus>,
@@ -119,6 +124,7 @@ impl AppState {
atx: Arc::new(RwLock::new(atx)), atx: Arc::new(RwLock::new(atx)),
audio, audio,
rustdesk: Arc::new(RwLock::new(rustdesk)), rustdesk: Arc::new(RwLock::new(rustdesk)),
vnc: Arc::new(RwLock::new(vnc)),
rtsp: Arc::new(RwLock::new(rtsp)), rtsp: Arc::new(RwLock::new(rtsp)),
extensions, extensions,
events, events,

View File

@@ -396,15 +396,29 @@ impl MjpegStreamHandler {
} }
pub fn disconnect_all_clients(&self) { 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 count = {
let mut clients = self.clients.write(); let mut clients = self.clients.write();
let count = clients.len(); let before = clients.len();
clients.clear(); clients.retain(|id, _| !should_disconnect(id));
count before - clients.len()
}; };
let remaining = self.client_count();
if count > 0 { 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(); self.set_offline();
} }
} }

View File

@@ -1,5 +1,6 @@
use crate::config::{AppConfig, RtspCodec, StreamMode}; use crate::config::{AppConfig, RtspCodec, StreamMode, VncEncoding};
use crate::error::Result; use crate::error::{AppError, Result};
use crate::rustdesk::config::RustDeskCodec;
use crate::video::codec::registry::VideoEncoderType; use crate::video::codec::registry::VideoEncoderType;
use crate::video::codec::VideoCodecType; use crate::video::codec::VideoCodecType;
use crate::video::VideoStreamManager; use crate::video::VideoStreamManager;
@@ -9,6 +10,7 @@ use std::sync::Arc;
pub struct StreamCodecConstraints { pub struct StreamCodecConstraints {
pub rustdesk_enabled: bool, pub rustdesk_enabled: bool,
pub rtsp_enabled: bool, pub rtsp_enabled: bool,
pub vnc_enabled: bool,
pub allowed_webrtc_codecs: Vec<VideoCodecType>, pub allowed_webrtc_codecs: Vec<VideoCodecType>,
pub allow_mjpeg: bool, pub allow_mjpeg: bool,
pub locked_codec: Option<VideoCodecType>, pub locked_codec: Option<VideoCodecType>,
@@ -21,11 +23,37 @@ pub struct ConstraintEnforcementResult {
pub message: Option<String>, pub message: Option<String>,
} }
#[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 { impl StreamCodecConstraints {
pub fn unrestricted() -> Self { pub fn unrestricted() -> Self {
Self { Self {
rustdesk_enabled: false, rustdesk_enabled: false,
rtsp_enabled: false, rtsp_enabled: false,
vnc_enabled: false,
allowed_webrtc_codecs: vec![ allowed_webrtc_codecs: vec![
VideoCodecType::H264, VideoCodecType::H264,
VideoCodecType::H265, VideoCodecType::H265,
@@ -41,42 +69,39 @@ impl StreamCodecConstraints {
pub fn from_config(config: &AppConfig) -> Self { pub fn from_config(config: &AppConfig) -> Self {
let rustdesk_enabled = config.rustdesk.enabled; let rustdesk_enabled = config.rustdesk.enabled;
let rtsp_enabled = config.rtsp.enabled; let rtsp_enabled = config.rtsp.enabled;
let vnc_enabled = config.vnc.enabled;
if rtsp_enabled { let locks = third_party_locks(config);
let locked_codec = match config.rtsp.codec { if let Some(first) = locks.first() {
RtspCodec::H264 => VideoCodecType::H264, let sources = locks
RtspCodec::H265 => VideoCodecType::H265, .iter()
}; .map(|item| item.source)
return Self { .collect::<Vec<_>>()
.join("/");
let reason = format!(
"{} enabled with codec lock ({})",
sources,
first.lock.label()
);
return match first.lock {
ThirdPartyCodecLock::H26x(codec) => Self {
rustdesk_enabled, rustdesk_enabled,
rtsp_enabled, rtsp_enabled,
allowed_webrtc_codecs: vec![locked_codec], vnc_enabled,
allowed_webrtc_codecs: vec![codec],
allow_mjpeg: false, allow_mjpeg: false,
locked_codec: Some(locked_codec), locked_codec: Some(codec),
reason: if rustdesk_enabled { reason,
format!(
"RTSP enabled with codec lock ({:?}) and RustDesk enabled",
locked_codec
)
} else {
format!("RTSP enabled with codec lock ({:?})", locked_codec)
}, },
}; ThirdPartyCodecLock::Mjpeg => Self {
}
if rustdesk_enabled {
return Self {
rustdesk_enabled, rustdesk_enabled,
rtsp_enabled, rtsp_enabled,
allowed_webrtc_codecs: vec![ vnc_enabled,
VideoCodecType::H264, allowed_webrtc_codecs: vec![],
VideoCodecType::H265, allow_mjpeg: true,
VideoCodecType::VP8,
VideoCodecType::VP9,
],
allow_mjpeg: false,
locked_codec: None, locked_codec: None,
reason: "RustDesk enabled, MJPEG disabled".to_string(), reason,
},
}; };
} }
@@ -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<VideoCodecType> {
match encoding {
VncEncoding::TightJpeg => None,
VncEncoding::H264 => Some(VideoCodecType::H264),
}
}
fn rustdesk_lock(config: &AppConfig) -> Option<ThirdPartySourceLock> {
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<ThirdPartySourceLock> {
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<ThirdPartySourceLock> {
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<ThirdPartySourceLock> {
[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( pub async fn enforce_constraints_with_stream_manager(
stream_manager: &Arc<VideoStreamManager>, stream_manager: &Arc<VideoStreamManager>,
constraints: &StreamCodecConstraints, constraints: &StreamCodecConstraints,
@@ -135,6 +241,16 @@ pub async fn enforce_constraints_with_stream_manager(
} }
if current_mode == StreamMode::WebRTC { 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; let current_codec = stream_manager.current_video_codec().await;
if !constraints.is_webrtc_codec_allowed(current_codec) { if !constraints.is_webrtc_codec_allowed(current_codec) {
let target_codec = constraints.preferred_webrtc_codec(); let target_codec = constraints.preferred_webrtc_codec();

View File

@@ -375,8 +375,8 @@ impl Streamer {
// IMPORTANT: Disconnect all MJPEG clients FIRST before stopping capture // IMPORTANT: Disconnect all MJPEG clients FIRST before stopping capture
// This prevents race conditions where clients try to reconnect and reopen the device // This prevents race conditions where clients try to reconnect and reopen the device
debug!("Disconnecting all MJPEG clients before config change..."); debug!("Disconnecting HTTP MJPEG clients before config change...");
self.mjpeg_handler.disconnect_all_clients(); self.mjpeg_handler.disconnect_non_vnc_clients();
// Give clients time to receive the disconnect signal and close their connections // Give clients time to receive the disconnect signal and close their connections
tokio::time::sleep(std::time::Duration::from_millis(100)).await; tokio::time::sleep(std::time::Duration::from_millis(100)).await;

370
src/vnc/mod.rs Normal file
View File

@@ -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<RwLock<VncConfig>>,
status: Arc<RwLock<VncServiceStatus>>,
video_manager: Arc<VideoStreamManager>,
hid: Arc<HidController>,
shutdown_tx: broadcast::Sender<()>,
server_handle: Mutex<Option<JoinHandle<()>>>,
client_handles: Arc<Mutex<Vec<JoinHandle<()>>>>,
active_clients: Arc<AtomicUsize>,
}
impl VncService {
pub fn new(
config: VncConfig,
video_manager: Arc<VideoStreamManager>,
hid: Arc<HidController>,
) -> 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<VideoStreamManager>,
hid: Arc<HidController>,
) -> 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<VideoStreamManager>,
) -> (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<VideoStreamManager>,
) -> Result<(u16, u16, tokio::sync::mpsc::Receiver<RfbFrame>)> {
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<HidController>,
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(())
}

529
src/vnc/rfb.rs Normal file
View File

@@ -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<RfbInputEvent> {
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<u8> {
let mut buf = [0u8; 1];
stream.read_exact(&mut buf).await?;
Ok(buf[0])
}
async fn read_u16(stream: &mut TcpStream) -> Result<u16> {
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<u32> {
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<i32> {
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<KeyboardEvent> {
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<CanonicalKey> {
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<MouseEvent> {
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
}

View File

@@ -6,7 +6,8 @@ use crate::rtsp::RtspService;
use crate::state::AppState; use crate::state::AppState;
use crate::stream_encoder::encoder_type_to_backend; use crate::stream_encoder::encoder_type_to_backend;
use crate::video::codec_constraints::{ 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}; use tokio::sync::{Mutex, OwnedMutexGuard};
@@ -409,6 +410,27 @@ pub async fn enforce_stream_codec_constraints(state: &Arc<AppState>) -> Result<O
Ok(enforcement.message) Ok(enforcement.message)
} }
fn validate_rustdesk_candidate(
state: &Arc<AppState>,
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<AppState>, 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<AppState>, 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( pub async fn apply_rustdesk_config(
state: &Arc<AppState>, state: &Arc<AppState>,
old_config: &crate::rustdesk::config::RustDeskConfig, old_config: &crate::rustdesk::config::RustDeskConfig,
@@ -417,6 +439,8 @@ pub async fn apply_rustdesk_config(
) -> Result<()> { ) -> Result<()> {
tracing::info!("Applying RustDesk config changes..."); tracing::info!("Applying RustDesk config changes...");
validate_rustdesk_candidate(state, new_config)?;
let mut rustdesk_guard = state.rustdesk.write().await; let mut rustdesk_guard = state.rustdesk.write().await;
let mut credentials_to_save = None; let mut credentials_to_save = None;
@@ -433,6 +457,7 @@ pub async fn apply_rustdesk_config(
if new_config.enabled { if new_config.enabled {
let need_restart = options.force let need_restart = options.force
|| old_config.codec != new_config.codec
|| old_config.rendezvous_server != new_config.rendezvous_server || old_config.rendezvous_server != new_config.rendezvous_server
|| old_config.device_id != new_config.device_id || old_config.device_id != new_config.device_id
|| old_config.device_password != new_config.device_password; || old_config.device_password != new_config.device_password;
@@ -488,6 +513,77 @@ pub async fn apply_rustdesk_config(
Ok(()) Ok(())
} }
pub async fn apply_vnc_config(
state: &Arc<AppState>,
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( pub async fn apply_rtsp_config(
state: &Arc<AppState>, state: &Arc<AppState>,
old_config: &RtspConfig, old_config: &RtspConfig,
@@ -496,6 +592,8 @@ pub async fn apply_rtsp_config(
) -> Result<()> { ) -> Result<()> {
tracing::info!("Applying RTSP config changes..."); tracing::info!("Applying RTSP config changes...");
validate_rtsp_candidate(state, new_config)?;
let mut rtsp_guard = state.rtsp.write().await; let mut rtsp_guard = state.rtsp.write().await;
if !new_config.enabled { if !new_config.enabled {

View File

@@ -12,6 +12,7 @@ mod rtsp;
mod rustdesk; mod rustdesk;
mod stream; mod stream;
pub(crate) mod video; pub(crate) mod video;
mod vnc;
mod web; mod web;
pub use atx::{get_atx_config, update_atx_config}; 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 stream::{get_stream_config, update_stream_config};
pub use video::{get_video_config, update_video_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}; pub use web::{get_web_config, update_web_config};
use axum::{extract::State, Json}; use axum::{extract::State, Json};
@@ -52,6 +56,7 @@ fn sanitize_config_for_api(config: &mut AppConfig) {
config.rustdesk.signing_private_key = None; config.rustdesk.signing_private_key = None;
config.rtsp.password = None; config.rtsp.password = None;
config.vnc.password = None;
} }
pub async fn get_all_config(State(state): State<Arc<AppState>>) -> Json<AppConfig> { pub async fn get_all_config(State(state): State<Arc<AppState>>) -> Json<AppConfig> {

View File

@@ -7,6 +7,44 @@ use crate::state::AppState;
use super::apply::{apply_rtsp_config, try_apply_lock, ConfigApplyOptions}; use super::apply::{apply_rtsp_config, try_apply_lock, ConfigApplyOptions};
use super::types::{RtspConfigResponse, RtspConfigUpdate, RtspStatusResponse}; use super::types::{RtspConfigResponse, RtspConfigUpdate, RtspStatusResponse};
fn validate_candidate(state: &Arc<AppState>, 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<AppState>,
old_config: crate::config::RtspConfig,
new_config: crate::config::RtspConfig,
) -> Result<crate::config::RtspConfig> {
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<AppState>) -> 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<Arc<AppState>>) -> Json<RtspConfigResponse> { pub async fn get_rtsp_config(State(state): State<Arc<AppState>>) -> Json<RtspConfigResponse> {
let config = state.config.get(); let config = state.config.get();
Json(RtspConfigResponse::from(&config.rtsp)) Json(RtspConfigResponse::from(&config.rtsp))
@@ -14,14 +52,7 @@ pub async fn get_rtsp_config(State(state): State<Arc<AppState>>) -> Json<RtspCon
pub async fn get_rtsp_status(State(state): State<Arc<AppState>>) -> Json<RtspStatusResponse> { pub async fn get_rtsp_status(State(state): State<Arc<AppState>>) -> Json<RtspStatusResponse> {
let config = state.config.get().rtsp.clone(); let config = state.config.get().rtsp.clone();
let status = { let status = current_status(&state).await;
let guard = state.rtsp.read().await;
if let Some(ref service) = *guard {
service.status().await
} else {
crate::rtsp::RtspServiceStatus::Stopped
}
};
Json(RtspStatusResponse::new(&config, status)) 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 _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?;
let old_config = state.config.get().rtsp.clone(); let old_config = state.config.get().rtsp.clone();
let mut merged_config = old_config.clone();
state req.apply_to(&mut merged_config);
.config let new_config = persist_and_apply(&state, old_config, merged_config).await?;
.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?;
Ok(Json(RtspConfigResponse::from(&new_config))) 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 current_config = state.config.get().rtsp.clone();
let mut start_config = current_config.clone(); let mut start_config = current_config.clone();
start_config.enabled = true; 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( Ok(Json(RtspStatusResponse::new(&stored_config, status)))
&state,
&current_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(&current_config, status)))
} }
pub async fn stop_rtsp_service( pub async fn stop_rtsp_service(
@@ -90,22 +93,8 @@ pub async fn stop_rtsp_service(
let mut stop_config = current_config.clone(); let mut stop_config = current_config.clone();
stop_config.enabled = false; stop_config.enabled = false;
apply_rtsp_config( let stored_config = persist_and_apply(&state, current_config, stop_config).await?;
&state, let status = current_status(&state).await;
&current_config,
&stop_config,
ConfigApplyOptions::forced(),
)
.await?;
let status = { Ok(Json(RtspStatusResponse::new(&stored_config, 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(&current_config, status)))
} }

View File

@@ -8,9 +8,58 @@ use crate::state::AppState;
use super::apply::{apply_rustdesk_config, try_apply_lock, ConfigApplyOptions}; use super::apply::{apply_rustdesk_config, try_apply_lock, ConfigApplyOptions};
use super::types::RustDeskConfigUpdate; use super::types::RustDeskConfigUpdate;
fn validate_candidate(state: &Arc<AppState>, 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<AppState>,
old_config: RustDeskConfig,
new_config: RustDeskConfig,
) -> Result<RustDeskConfig> {
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<AppState>, 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)] #[derive(Debug, serde::Serialize)]
pub struct RustDeskConfigResponse { pub struct RustDeskConfigResponse {
pub enabled: bool, pub enabled: bool,
pub codec: crate::rustdesk::config::RustDeskCodec,
pub rendezvous_server: String, pub rendezvous_server: String,
pub relay_server: Option<String>, pub relay_server: Option<String>,
pub device_id: String, pub device_id: String,
@@ -23,6 +72,7 @@ impl From<&RustDeskConfig> for RustDeskConfigResponse {
fn from(config: &RustDeskConfig) -> Self { fn from(config: &RustDeskConfig) -> Self {
Self { Self {
enabled: config.enabled, enabled: config.enabled,
codec: config.codec,
rendezvous_server: config.rendezvous_server.clone(), rendezvous_server: config.rendezvous_server.clone(),
relay_server: config.relay_server.clone(), relay_server: config.relay_server.clone(),
device_id: config.device_id.clone(), device_id: config.device_id.clone(),
@@ -50,23 +100,7 @@ pub async fn get_rustdesk_status(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> Json<RustDeskStatusResponse> { ) -> Json<RustDeskStatusResponse> {
let config = state.config.get().rustdesk.clone(); let config = state.config.get().rustdesk.clone();
Json(current_status(&state, config).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)
}
};
Json(RustDeskStatusResponse {
config: RustDeskConfigResponse::from(&config),
service_status,
rendezvous_status,
})
} }
pub async fn update_rustdesk_config( pub async fn update_rustdesk_config(
@@ -81,22 +115,7 @@ pub async fn update_rustdesk_config(
req.apply_to(&mut merged_config); req.apply_to(&mut merged_config);
req.validate_merged(&merged_config)?; req.validate_merged(&merged_config)?;
state let new_config = persist_and_apply(&state, old_config, merged_config).await?;
.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 constraints = state.stream_manager.codec_constraints().await; let constraints = state.stream_manager.codec_constraints().await;
if constraints.rustdesk_enabled || constraints.rtsp_enabled { 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 current_config = state.config.get().rustdesk.clone();
let mut start_config = current_config.clone(); let mut start_config = current_config.clone();
start_config.enabled = true; start_config.enabled = true;
let stored_config = persist_and_apply(&state, current_config, start_config).await?;
apply_rustdesk_config( Ok(Json(current_status(&state, stored_config).await))
&state,
&current_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(&current_config),
service_status,
rendezvous_status,
}))
} }
pub async fn stop_rustdesk_service( pub async fn stop_rustdesk_service(
@@ -187,28 +183,6 @@ pub async fn stop_rustdesk_service(
let mut stop_config = current_config.clone(); let mut stop_config = current_config.clone();
stop_config.enabled = false; stop_config.enabled = false;
apply_rustdesk_config( let stored_config = persist_and_apply(&state, current_config, stop_config).await?;
&state, Ok(Json(current_status(&state, stored_config).await))
&current_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(&current_config),
service_status,
rendezvous_status,
}))
} }

View File

@@ -2,6 +2,7 @@ use crate::config::*;
use crate::error::AppError; use crate::error::AppError;
use crate::rtsp::RtspServiceStatus; use crate::rtsp::RtspServiceStatus;
use crate::rustdesk::config::RustDeskConfig; use crate::rustdesk::config::RustDeskConfig;
use crate::vnc::VncServiceStatus;
use base64::{engine::general_purpose::STANDARD, Engine as _}; use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(unix)] #[cfg(unix)]
@@ -765,6 +766,7 @@ fn validate_rustdesk_relay_key(key: &str) -> Result<(), AppError> {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct RustDeskConfigUpdate { pub struct RustDeskConfigUpdate {
pub enabled: Option<bool>, pub enabled: Option<bool>,
pub codec: Option<crate::rustdesk::config::RustDeskCodec>,
pub rendezvous_server: Option<String>, pub rendezvous_server: Option<String>,
pub relay_server: Option<String>, pub relay_server: Option<String>,
pub relay_key: Option<String>, pub relay_key: Option<String>,
@@ -821,6 +823,9 @@ impl RustDeskConfigUpdate {
if let Some(enabled) = self.enabled { if let Some(enabled) = self.enabled {
config.enabled = enabled; config.enabled = enabled;
} }
if let Some(codec) = self.codec {
config.codec = codec;
}
if let Some(ref server) = self.rendezvous_server { if let Some(ref server) = self.rendezvous_server {
config.rendezvous_server = server.clone(); config.rendezvous_server = server.clone();
} }
@@ -904,6 +909,125 @@ pub struct RtspConfigUpdate {
pub password: Option<String>, pub password: Option<String>,
} }
#[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<bool>,
pub bind: Option<String>,
pub port: Option<u16>,
pub encoding: Option<VncEncoding>,
pub jpeg_quality: Option<u8>,
pub allow_one_client: Option<bool>,
pub password: Option<String>,
}
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::<std::net::IpAddr>().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 { impl RtspConfigUpdate {
pub fn validate(&self) -> crate::error::Result<()> { pub fn validate(&self) -> crate::error::Result<()> {
if let Some(port) = self.port { if let Some(port) = self.port {
@@ -1188,6 +1312,7 @@ mod tests {
fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() { fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() {
let update = RustDeskConfigUpdate { let update = RustDeskConfigUpdate {
enabled: None, enabled: None,
codec: None,
rendezvous_server: None, rendezvous_server: None,
relay_server: None, relay_server: None,
relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()), relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()),
@@ -1202,6 +1327,7 @@ mod tests {
let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string(); let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string();
let update = RustDeskConfigUpdate { let update = RustDeskConfigUpdate {
enabled: None, enabled: None,
codec: None,
rendezvous_server: None, rendezvous_server: None,
relay_server: None, relay_server: None,
relay_key: Some(not_32), relay_key: Some(not_32),

View File

@@ -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<AppState>, 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<AppState>,
old_config: crate::config::VncConfig,
new_config: crate::config::VncConfig,
) -> Result<crate::config::VncConfig> {
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<AppState>) -> (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<Arc<AppState>>) -> Json<VncConfigResponse> {
Json(VncConfigResponse::from(&state.config.get().vnc))
}
pub async fn get_vnc_status(State(state): State<Arc<AppState>>) -> Json<VncStatusResponse> {
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<Arc<AppState>>,
Json(req): Json<VncConfigUpdate>,
) -> Result<Json<VncConfigResponse>> {
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<Arc<AppState>>,
) -> Result<Json<VncStatusResponse>> {
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<Arc<AppState>>,
) -> Result<Json<VncStatusResponse>> {
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,
)))
}

View File

@@ -241,6 +241,7 @@ pub struct StreamConstraintsResponse {
pub struct ConstraintSources { pub struct ConstraintSources {
pub rustdesk: bool, pub rustdesk: bool,
pub rtsp: bool, pub rtsp: bool,
pub vnc: bool,
} }
/// Get stream codec constraints derived from enabled services. /// Get stream codec constraints derived from enabled services.
@@ -267,6 +268,7 @@ pub async fn stream_constraints_get(
sources: ConstraintSources { sources: ConstraintSources {
rustdesk: constraints.rustdesk_enabled, rustdesk: constraints.rustdesk_enabled,
rtsp: constraints.rtsp_enabled, rtsp: constraints.rtsp_enabled,
vnc: constraints.vnc_enabled,
}, },
reason: constraints.reason, reason: constraints.reason,
current_mode, current_mode,

View File

@@ -36,6 +36,7 @@ pub struct Capabilities {
pub atx: CapabilityInfo, pub atx: CapabilityInfo,
pub audio: CapabilityInfo, pub audio: CapabilityInfo,
pub rustdesk: CapabilityInfo, pub rustdesk: CapabilityInfo,
pub vnc: CapabilityInfo,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -106,6 +107,11 @@ pub async fn system_info(State(state): State<Arc<AppState>>) -> Json<SystemInfo>
backend: platform.rustdesk.selected_backend.clone(), backend: platform.rustdesk.selected_backend.clone(),
reason: platform.rustdesk.reason.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, disk_space,
device_info, device_info,

View File

@@ -143,6 +143,15 @@ pub fn create_router(state: Arc<AppState>) -> Router {
"/config/rustdesk/stop", "/config/rustdesk/stop",
post(handlers::config::stop_rustdesk_service), 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 // RTSP configuration endpoints
.route("/config/rtsp", get(handlers::config::get_rtsp_config)) .route("/config/rtsp", get(handlers::config::get_rtsp_config))
.route("/config/rtsp", patch(handlers::config::update_rtsp_config)) .route("/config/rtsp", patch(handlers::config::update_rtsp_config))

View File

@@ -171,6 +171,7 @@ export const extensionsApi = {
export interface RustDeskConfigResponse { export interface RustDeskConfigResponse {
enabled: boolean enabled: boolean
codec: 'h264' | 'h265'
rendezvous_server: string rendezvous_server: string
relay_server: string | null relay_server: string | null
device_id: string device_id: string
@@ -187,6 +188,7 @@ export interface RustDeskStatusResponse {
export interface RustDeskConfigUpdate { export interface RustDeskConfigUpdate {
enabled?: boolean enabled?: boolean
codec?: 'h264' | 'h265'
rendezvous_server?: string rendezvous_server?: string
relay_server?: string relay_server?: string
relay_key?: string relay_key?: string
@@ -271,6 +273,50 @@ export const rtspConfigApi = {
stop: () => request<RtspStatusResponse>('/config/rtsp/stop', { method: 'POST' }), stop: () => request<RtspStatusResponse>('/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<VncConfigResponse>('/config/vnc'),
update: (config: VncConfigUpdate) =>
request<VncConfigResponse>('/config/vnc', {
method: 'PATCH',
body: JSON.stringify(config),
}),
getStatus: () => request<VncStatusResponse>('/config/vnc/status'),
start: () => request<VncStatusResponse>('/config/vnc/start', { method: 'POST' }),
stop: () => request<VncStatusResponse>('/config/vnc/stop', { method: 'POST' }),
}
export type WebConfig = WebConfigResponse export type WebConfig = WebConfigResponse
export type { WebConfigUpdate } export type { WebConfigUpdate }

View File

@@ -67,6 +67,7 @@ export interface PlatformCapabilities {
otg: FeatureCapability otg: FeatureCapability
audio: FeatureCapability audio: FeatureCapability
rustdesk: FeatureCapability rustdesk: FeatureCapability
vnc: FeatureCapability
diagnostics: FeatureCapability diagnostics: FeatureCapability
extensions: FeatureCapability extensions: FeatureCapability
service_installation: FeatureCapability service_installation: FeatureCapability
@@ -86,6 +87,7 @@ export const systemApi = {
atx: { available: boolean; backend?: string; reason?: string } atx: { available: boolean; backend?: string; reason?: string }
audio: { available: boolean; backend?: string; reason?: string } audio: { available: boolean; backend?: string; reason?: string }
rustdesk: { available: boolean; backend?: string; reason?: string } rustdesk: { available: boolean; backend?: string; reason?: string }
vnc: { available: boolean; backend?: string; reason?: string }
} }
disk_space?: { disk_space?: {
total: number total: number
@@ -206,6 +208,7 @@ export interface StreamConstraintsResponse {
sources: { sources: {
rustdesk: boolean rustdesk: boolean
rtsp: boolean rtsp: boolean
vnc: boolean
} }
reason: string reason: string
current_mode: string current_mode: string
@@ -719,6 +722,7 @@ export {
redfishConfigApi, redfishConfigApi,
rustdeskConfigApi, rustdeskConfigApi,
rtspConfigApi, rtspConfigApi,
vncConfigApi,
webConfigApi, webConfigApi,
type RustDeskConfigResponse, type RustDeskConfigResponse,
type RustDeskStatusResponse, type RustDeskStatusResponse,
@@ -729,6 +733,10 @@ export {
type RedfishConfigUpdate, type RedfishConfigUpdate,
type RtspConfigUpdate, type RtspConfigUpdate,
type RtspStatusResponse, type RtspStatusResponse,
type VncConfigResponse,
type VncConfigUpdate,
type VncEncoding,
type VncStatusResponse,
type WebConfig, type WebConfig,
type WebConfigUpdate, type WebConfigUpdate,
} from './config' } from './config'

View File

@@ -522,8 +522,7 @@ export default {
environmentSubtitle: 'System runtime environment and USB device maintenance', environmentSubtitle: 'System runtime environment and USB device maintenance',
aboutSubtitle: 'Online upgrade, version info and hardware overview', aboutSubtitle: 'Online upgrade, version info and hardware overview',
extTtydSubtitle: 'Open a host Shell terminal in the browser', extTtydSubtitle: 'Open a host Shell terminal in the browser',
extRustdeskSubtitle: 'Remote graphical access via RustDesk', thirdPartyAccessSubtitle: 'Configure external RustDesk, VNC, and RTSP access',
extRtspSubtitle: 'Provide an RTSP video stream for external clients',
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services', extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
extFrpcSubtitle: 'NAT traversal through the FRP client', extFrpcSubtitle: 'NAT traversal through the FRP client',
aboutDesc: 'Open and Lightweight IP-KVM Solution', aboutDesc: 'Open and Lightweight IP-KVM Solution',
@@ -967,6 +966,10 @@ export default {
start: 'Start', start: 'Start',
stop: 'Stop', stop: 'Stop',
autoStart: 'Auto Start', autoStart: 'Auto Start',
thirdPartyAccess: {
title: 'Third-party Access',
desc: 'Configure RustDesk, VNC, and RTSP in one place',
},
viewLogs: 'View Logs', viewLogs: 'View Logs',
noLogs: 'No logs available', noLogs: 'No logs available',
binaryNotFound: '{path} not found, please install the required program', binaryNotFound: '{path} not found, please install the required program',
@@ -1040,6 +1043,8 @@ export default {
relayServer: 'Relay Server', relayServer: 'Relay Server',
relayServerPlaceholder: 'hbbr.example.com:21117', relayServerPlaceholder: 'hbbr.example.com:21117',
relayKey: 'Relay Key', relayKey: 'Relay Key',
codec: 'Codec',
codecHint: 'Choose H.264 or H.265 before starting RustDesk. The codec is locked while running.',
deviceInfo: 'Device Info', deviceInfo: 'Device Info',
deviceId: 'Device ID', deviceId: 'Device ID',
deviceIdHint: 'Use this ID in RustDesk client to connect', deviceIdHint: 'Use this ID in RustDesk client to connect',
@@ -1073,7 +1078,7 @@ export default {
pathPlaceholder: 'live', pathPlaceholder: 'live',
pathHint: 'Example: rtsp://device-ip:8554/live', pathHint: 'Example: rtsp://device-ip:8554/live',
codec: 'Codec', 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', allowOneClient: 'Allow One Client Only',
username: 'Username', username: 'Username',
usernamePlaceholder: 'Empty means no authentication', usernamePlaceholder: 'Empty means no authentication',
@@ -1081,6 +1086,26 @@ export default {
passwordPlaceholder: 'Enter new password', passwordPlaceholder: 'Enter new password',
urlPreview: 'RTSP URL Preview', 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: { stats: {
title: 'Connection Stats', title: 'Connection Stats',

View File

@@ -521,8 +521,7 @@ export default {
environmentSubtitle: '系统级运行环境与 USB 设备维护', environmentSubtitle: '系统级运行环境与 USB 设备维护',
aboutSubtitle: '在线升级、版本信息与设备硬件概览', aboutSubtitle: '在线升级、版本信息与设备硬件概览',
extTtydSubtitle: '在浏览器中打开本机 Shell 终端', extTtydSubtitle: '在浏览器中打开本机 Shell 终端',
extRustdeskSubtitle: '通过 RustDesk 实现远程图形访问', thirdPartyAccessSubtitle: '集中配置 RustDesk、VNC 与 RTSP 外部接入',
extRtspSubtitle: '提供 RTSP 视频流以供其他客户端拉流',
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问', extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
extFrpcSubtitle: '通过 FRP 客户端实现内网穿透', extFrpcSubtitle: '通过 FRP 客户端实现内网穿透',
aboutDesc: '开放轻量的 IP-KVM 解决方案', aboutDesc: '开放轻量的 IP-KVM 解决方案',
@@ -966,6 +965,10 @@ export default {
start: '启动', start: '启动',
stop: '停止', stop: '停止',
autoStart: '开机自启', autoStart: '开机自启',
thirdPartyAccess: {
title: '第三方接入',
desc: '集中配置 RustDesk、VNC 与 RTSP',
},
viewLogs: '查看日志', viewLogs: '查看日志',
noLogs: '暂无日志', noLogs: '暂无日志',
binaryNotFound: '未找到 {path},请先安装对应程序', binaryNotFound: '未找到 {path},请先安装对应程序',
@@ -1039,6 +1042,8 @@ export default {
relayServer: '中继服务器', relayServer: '中继服务器',
relayServerPlaceholder: 'hbbr.example.com:21117', relayServerPlaceholder: 'hbbr.example.com:21117',
relayKey: '中继密钥', relayKey: '中继密钥',
codec: '编码格式',
codecHint: 'RustDesk 启动前需选择 H.264 或 H.265;运行时会锁定编码,不允许客户端切换。',
deviceInfo: '设备信息', deviceInfo: '设备信息',
deviceId: '设备 ID', deviceId: '设备 ID',
deviceIdHint: '此 ID 用于 RustDesk 客户端连接', deviceIdHint: '此 ID 用于 RustDesk 客户端连接',
@@ -1072,7 +1077,7 @@ export default {
pathPlaceholder: 'live', pathPlaceholder: 'live',
pathHint: '访问路径,例如 rtsp://设备IP:8554/live', pathHint: '访问路径,例如 rtsp://设备IP:8554/live',
codec: '编码格式', codec: '编码格式',
codecHint: '启用 RTSP 后将锁定编码为所选项,并禁用 MJPEG。', codecHint: 'RTSP 运行时会锁定为所选编码;若 RustDesk 已运行,只能选择相同编码。',
allowOneClient: '仅允许单客户端', allowOneClient: '仅允许单客户端',
username: '用户名', username: '用户名',
usernamePlaceholder: '留空表示无需认证', usernamePlaceholder: '留空表示无需认证',
@@ -1080,6 +1085,26 @@ export default {
passwordPlaceholder: '输入新密码', passwordPlaceholder: '输入新密码',
urlPreview: 'RTSP 地址预览', 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: { stats: {
title: '连接统计', title: '连接统计',

View File

@@ -9,6 +9,7 @@ import {
rtspConfigApi, rtspConfigApi,
rustdeskConfigApi, rustdeskConfigApi,
streamConfigApi, streamConfigApi,
vncConfigApi,
videoConfigApi, videoConfigApi,
webConfigApi, webConfigApi,
} from '@/api' } from '@/api'
@@ -36,6 +37,9 @@ import type {
RustDeskConfigUpdate as ApiRustDeskConfigUpdate, RustDeskConfigUpdate as ApiRustDeskConfigUpdate,
RustDeskStatusResponse as ApiRustDeskStatusResponse, RustDeskStatusResponse as ApiRustDeskStatusResponse,
RustDeskPasswordResponse as ApiRustDeskPasswordResponse, RustDeskPasswordResponse as ApiRustDeskPasswordResponse,
VncConfigResponse as ApiVncConfigResponse,
VncConfigUpdate as ApiVncConfigUpdate,
VncStatusResponse as ApiVncStatusResponse,
WebConfig, WebConfig,
WebConfigUpdate, WebConfigUpdate,
} from '@/api' } from '@/api'
@@ -57,6 +61,8 @@ export const useConfigStore = defineStore('config', () => {
const atx = ref<AtxConfig | null>(null) const atx = ref<AtxConfig | null>(null)
const rtspConfig = ref<ApiRtspConfigResponse | null>(null) const rtspConfig = ref<ApiRtspConfigResponse | null>(null)
const rtspStatus = ref<ApiRtspStatusResponse | null>(null) const rtspStatus = ref<ApiRtspStatusResponse | null>(null)
const vncConfig = ref<ApiVncConfigResponse | null>(null)
const vncStatus = ref<ApiVncStatusResponse | null>(null)
const rustdeskConfig = ref<ApiRustDeskConfigResponse | null>(null) const rustdeskConfig = ref<ApiRustDeskConfigResponse | null>(null)
const rustdeskStatus = ref<ApiRustDeskStatusResponse | null>(null) const rustdeskStatus = ref<ApiRustDeskStatusResponse | null>(null)
const rustdeskPassword = ref<ApiRustDeskPasswordResponse | null>(null) const rustdeskPassword = ref<ApiRustDeskPasswordResponse | null>(null)
@@ -70,6 +76,7 @@ export const useConfigStore = defineStore('config', () => {
const webLoading = ref(false) const webLoading = ref(false)
const atxLoading = ref(false) const atxLoading = ref(false)
const rtspLoading = ref(false) const rtspLoading = ref(false)
const vncLoading = ref(false)
const rustdeskLoading = ref(false) const rustdeskLoading = ref(false)
const authError = ref<string | null>(null) const authError = ref<string | null>(null)
@@ -81,6 +88,7 @@ export const useConfigStore = defineStore('config', () => {
const webError = ref<string | null>(null) const webError = ref<string | null>(null)
const atxError = ref<string | null>(null) const atxError = ref<string | null>(null)
const rtspError = ref<string | null>(null) const rtspError = ref<string | null>(null)
const vncError = ref<string | null>(null)
const rustdeskError = ref<string | null>(null) const rustdeskError = ref<string | null>(null)
let authPromise: Promise<AuthConfig> | null = null let authPromise: Promise<AuthConfig> | null = null
@@ -93,6 +101,8 @@ export const useConfigStore = defineStore('config', () => {
let atxPromise: Promise<AtxConfig> | null = null let atxPromise: Promise<AtxConfig> | null = null
let rtspPromise: Promise<ApiRtspConfigResponse> | null = null let rtspPromise: Promise<ApiRtspConfigResponse> | null = null
let rtspStatusPromise: Promise<ApiRtspStatusResponse> | null = null let rtspStatusPromise: Promise<ApiRtspStatusResponse> | null = null
let vncPromise: Promise<ApiVncConfigResponse> | null = null
let vncStatusPromise: Promise<ApiVncStatusResponse> | null = null
let rustdeskPromise: Promise<ApiRustDeskConfigResponse> | null = null let rustdeskPromise: Promise<ApiRustDeskConfigResponse> | null = null
let rustdeskStatusPromise: Promise<ApiRustDeskStatusResponse> | null = null let rustdeskStatusPromise: Promise<ApiRustDeskStatusResponse> | null = null
let rustdeskPasswordPromise: Promise<ApiRustDeskPasswordResponse> | null = null let rustdeskPasswordPromise: Promise<ApiRustDeskPasswordResponse> | null = null
@@ -318,6 +328,51 @@ export const useConfigStore = defineStore('config', () => {
return request 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() { async function refreshRustdeskConfig() {
if (rustdeskLoading.value && rustdeskPromise) return rustdeskPromise if (rustdeskLoading.value && rustdeskPromise) return rustdeskPromise
rustdeskLoading.value = true rustdeskLoading.value = true
@@ -430,6 +485,11 @@ export const useConfigStore = defineStore('config', () => {
return refreshRtspConfig() return refreshRtspConfig()
} }
function ensureVncConfig() {
if (vncConfig.value) return Promise.resolve(vncConfig.value)
return refreshVncConfig()
}
function ensureRustdeskConfig() { function ensureRustdeskConfig() {
if (rustdeskConfig.value) return Promise.resolve(rustdeskConfig.value) if (rustdeskConfig.value) return Promise.resolve(rustdeskConfig.value)
return refreshRustdeskConfig() return refreshRustdeskConfig()
@@ -489,6 +549,12 @@ export const useConfigStore = defineStore('config', () => {
return response return response
} }
async function updateVnc(update: ApiVncConfigUpdate) {
const response = await vncConfigApi.update(update)
vncConfig.value = response
return response
}
async function updateRustdesk(update: ApiRustDeskConfigUpdate) { async function updateRustdesk(update: ApiRustDeskConfigUpdate) {
const response = await rustdeskConfigApi.update(update) const response = await rustdeskConfigApi.update(update)
rustdeskConfig.value = response rustdeskConfig.value = response
@@ -518,6 +584,8 @@ export const useConfigStore = defineStore('config', () => {
atx, atx,
rtspConfig, rtspConfig,
rtspStatus, rtspStatus,
vncConfig,
vncStatus,
rustdeskConfig, rustdeskConfig,
rustdeskStatus, rustdeskStatus,
rustdeskPassword, rustdeskPassword,
@@ -530,6 +598,7 @@ export const useConfigStore = defineStore('config', () => {
webLoading, webLoading,
atxLoading, atxLoading,
rtspLoading, rtspLoading,
vncLoading,
rustdeskLoading, rustdeskLoading,
authError, authError,
videoError, videoError,
@@ -540,6 +609,7 @@ export const useConfigStore = defineStore('config', () => {
webError, webError,
atxError, atxError,
rtspError, rtspError,
vncError,
rustdeskError, rustdeskError,
refreshAuth, refreshAuth,
refreshVideo, refreshVideo,
@@ -551,6 +621,8 @@ export const useConfigStore = defineStore('config', () => {
refreshAtx, refreshAtx,
refreshRtspConfig, refreshRtspConfig,
refreshRtspStatus, refreshRtspStatus,
refreshVncConfig,
refreshVncStatus,
refreshRustdeskConfig, refreshRustdeskConfig,
refreshRustdeskStatus, refreshRustdeskStatus,
refreshRustdeskPassword, refreshRustdeskPassword,
@@ -563,6 +635,7 @@ export const useConfigStore = defineStore('config', () => {
ensureWeb, ensureWeb,
ensureAtx, ensureAtx,
ensureRtspConfig, ensureRtspConfig,
ensureVncConfig,
ensureRustdeskConfig, ensureRustdeskConfig,
updateAuth, updateAuth,
updateVideo, updateVideo,
@@ -573,6 +646,7 @@ export const useConfigStore = defineStore('config', () => {
updateWeb, updateWeb,
updateAtx, updateAtx,
updateRtsp, updateRtsp,
updateVnc,
updateRustdesk, updateRustdesk,
regenerateRustdeskId, regenerateRustdeskId,
regenerateRustdeskPassword, regenerateRustdeskPassword,

View File

@@ -62,14 +62,6 @@ export interface Ch9329DescriptorConfig {
serial_number?: string; serial_number?: string;
} }
export interface Ch9329DescriptorState {
descriptor: Ch9329DescriptorConfig;
manufacturer_enabled: boolean;
product_enabled: boolean;
serial_enabled: boolean;
config_mode_available: boolean;
}
export interface HidConfig { export interface HidConfig {
backend: HidBackend; backend: HidBackend;
otg_udc?: string; otg_udc?: string;
@@ -234,11 +226,31 @@ export interface ExtensionsConfig {
export interface RustDeskConfig { export interface RustDeskConfig {
enabled: boolean; enabled: boolean;
codec: RustDeskCodec;
rendezvous_server: string; rendezvous_server: string;
relay_server?: string; relay_server?: string;
device_id: 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 { export enum RtspCodec {
H264 = "h264", H264 = "h264",
H265 = "h265", H265 = "h265",
@@ -270,6 +282,7 @@ export interface AppConfig {
web: WebConfig; web: WebConfig;
extensions: ExtensionsConfig; extensions: ExtensionsConfig;
rustdesk: RustDeskConfig; rustdesk: RustDeskConfig;
vnc: VncConfig;
rtsp: RtspConfig; rtsp: RtspConfig;
redfish: RedfishConfig; redfish: RedfishConfig;
} }
@@ -328,6 +341,14 @@ export interface Ch9329DescriptorConfigUpdate {
serial_number?: string; serial_number?: string;
} }
export interface Ch9329DescriptorState {
descriptor: Ch9329DescriptorConfig;
manufacturer_enabled: boolean;
product_enabled: boolean;
serial_enabled: boolean;
config_mode_available: boolean;
}
export interface EasytierConfigUpdate { export interface EasytierConfigUpdate {
enabled?: boolean; enabled?: boolean;
network_name?: string; network_name?: string;
@@ -480,6 +501,7 @@ export interface RtspStatusResponse {
export interface RustDeskConfigUpdate { export interface RustDeskConfigUpdate {
enabled?: boolean; enabled?: boolean;
codec?: RustDeskCodec;
rendezvous_server?: string; rendezvous_server?: string;
relay_server?: string; relay_server?: string;
relay_key?: string; relay_key?: string;
@@ -535,6 +557,32 @@ export interface VideoConfigUpdate {
quality?: number; 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`. * Web server settings returned by `GET` / `PATCH /api/config/web`.
* *

View File

@@ -20,6 +20,7 @@ import {
systemApi, systemApi,
updateApi, updateApi,
usbApi, usbApi,
vncConfigApi,
type EncoderBackendInfo, type EncoderBackendInfo,
type AuthConfig, type AuthConfig,
type RustDeskConfigResponse, type RustDeskConfigResponse,
@@ -27,6 +28,8 @@ import {
type RustDeskPasswordResponse, type RustDeskPasswordResponse,
type RtspStatusResponse, type RtspStatusResponse,
type RtspConfigUpdate, type RtspConfigUpdate,
type VncConfigUpdate,
type VncStatusResponse,
type WebConfig, type WebConfig,
type UpdateOverviewResponse, type UpdateOverviewResponse,
type UpdateStatusResponse, type UpdateStatusResponse,
@@ -105,7 +108,6 @@ import {
ExternalLink, ExternalLink,
Copy, Copy,
ScreenShare, ScreenShare,
Radio,
Globe, Globe,
Loader2, Loader2,
AlertTriangle, AlertTriangle,
@@ -136,8 +138,7 @@ const SETTINGS_SECTION_IDS = [
'atx', 'atx',
'environment', 'environment',
'ext-ttyd', 'ext-ttyd',
'ext-rustdesk', 'third-party-access',
'ext-rtsp',
'ext-remote-access', 'ext-remote-access',
'about', 'about',
] as const ] as const
@@ -167,8 +168,7 @@ const navGroups = computed(() => [
title: t('settings.extensions'), title: t('settings.extensions'),
items: [ items: [
{ id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal }, { id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal },
{ id: 'ext-rustdesk', label: t('extensions.rustdesk.title'), icon: ScreenShare }, { id: 'third-party-access', label: t('extensions.thirdPartyAccess.title'), icon: ScreenShare },
{ id: 'ext-rtsp', label: t('extensions.rtsp.title'), icon: Radio },
{ id: 'ext-remote-access', label: t('extensions.remoteAccess.title'), icon: ExternalLink }, { id: 'ext-remote-access', label: t('extensions.remoteAccess.title'), icon: ExternalLink },
] ]
}, },
@@ -200,8 +200,7 @@ const sectionMeta = computed(() => {
function sectionSubtitleKey(id: string): string { function sectionSubtitleKey(id: string): string {
switch (id) { switch (id) {
case 'ext-ttyd': return 'extTtydSubtitle' case 'ext-ttyd': return 'extTtydSubtitle'
case 'ext-rustdesk': return 'extRustdeskSubtitle' case 'third-party-access': return 'thirdPartyAccessSubtitle'
case 'ext-rtsp': return 'extRtspSubtitle'
case 'ext-remote-access': return 'extRemoteAccessSubtitle' case 'ext-remote-access': return 'extRemoteAccessSubtitle'
default: return `${id}Subtitle` default: return `${id}Subtitle`
} }
@@ -222,6 +221,7 @@ function normalizeSettingsSection(value: unknown): SettingsSectionId | null {
if (typeof value !== 'string') return null if (typeof value !== 'string') return null
if (value === 'access-control') return 'account' if (value === 'access-control') return 'account'
if (value === 'ext-frpc') return 'ext-remote-access' 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 return isSettingsSectionId(value) ? value : null
} }
@@ -272,15 +272,14 @@ async function loadSectionData(section: SettingsSectionId) {
case 'ext-remote-access': case 'ext-remote-access':
await loadExtensions() await loadExtensions()
return return
case 'ext-rustdesk': case 'third-party-access':
await Promise.all([ await Promise.all([
loadRustdeskConfig(), loadRustdeskConfig(),
loadRustdeskPassword(), loadRustdeskPassword(),
loadRtspConfig(),
loadVncConfig(),
]) ])
return return
case 'ext-rtsp':
await loadRtspConfig()
return
case 'about': case 'about':
if (isAndroid.value) return if (isAndroid.value) return
await Promise.all([ await Promise.all([
@@ -390,6 +389,7 @@ const rustdeskCopied = ref<'id' | 'password' | null>(null)
const { copy: clipboardCopy } = useClipboard() const { copy: clipboardCopy } = useClipboard()
const rustdeskLocalConfig = ref({ const rustdeskLocalConfig = ref({
enabled: false, enabled: false,
codec: 'h264' as 'h264' | 'h265',
rendezvous_server: '', rendezvous_server: '',
relay_server: '', relay_server: '',
relay_key: '', relay_key: '',
@@ -415,6 +415,18 @@ const rtspLocalConfig = ref<RtspConfigUpdate & { password?: string }>({
password: '', password: '',
}) })
const vncStatus = ref<VncStatusResponse | null>(null)
const vncLoading = ref(false)
const vncLocalConfig = ref<VncConfigUpdate & { password?: string }>({
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 { function formatHostForUrl(hostname: string): string {
if (!hostname) return '127.0.0.1' if (!hostname) return '127.0.0.1'
return hostname.includes(':') && !hostname.startsWith('[') return hostname.includes(':') && !hostname.startsWith('[')
@@ -429,6 +441,12 @@ const rtspStreamUrl = computed(() => {
return `rtsp://${host}:${port}/${path}` 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<WebConfig>({ const webServerConfig = ref<WebConfig>({
http_port: 8080, http_port: 8080,
https_port: 8443, https_port: 8443,
@@ -1839,6 +1857,7 @@ function applyRustdeskStatus(status: RustDeskStatusResponse) {
rustdeskStatus.value = status rustdeskStatus.value = status
rustdeskLocalConfig.value = { rustdeskLocalConfig.value = {
enabled: config.enabled, enabled: config.enabled,
codec: config.codec || 'h264',
rendezvous_server: config.rendezvous_server, rendezvous_server: config.rendezvous_server,
relay_server: config.relay_server || '', relay_server: config.relay_server || '',
relay_key: config.relay_key || '', relay_key: config.relay_key || '',
@@ -1901,6 +1920,17 @@ function validateRustdeskConfig(): boolean {
return !rustdeskValidationMessage.value || showValidationError(rustdeskValidationMessage.value) 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 { function normalizeRtspPath(path: string): string {
return path.trim().replace(/^\/+|\/+$/g, '') || 'live' return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
} }
@@ -2222,23 +2252,26 @@ function updateStatusBadgeText(): string {
|| updatePhaseText(updateStatus.value?.phase) || 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() { async function saveRustdeskConfig() {
if (rustdeskLocalConfig.value.enabled && !validateRustdeskConfig()) return if (rustdeskLocalConfig.value.enabled && !validateRustdeskConfig()) return
loading.value = true loading.value = true
saved.value = false saved.value = false
try { try {
const rendezvousServer = normalizeRustdeskServer( await configStore.updateRustdesk(rustdeskUpdatePayload())
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 loadRustdeskConfig() await loadRustdeskConfig()
saved.value = true saved.value = true
setTimeout(() => (saved.value = false), 2000) setTimeout(() => (saved.value = false), 2000)
@@ -2279,6 +2312,7 @@ async function startRustdesk() {
rustdeskLoading.value = true rustdeskLoading.value = true
try { try {
await configStore.updateRustdesk(rustdeskUpdatePayload(true))
const status = await rustdeskConfigApi.start() const status = await rustdeskConfigApi.start()
applyRustdeskStatus(status) applyRustdeskStatus(status)
} catch { } catch {
@@ -2373,23 +2407,24 @@ async function loadRtspConfig() {
} }
} }
async function saveRtspConfig() { function rtspUpdatePayload(enabled = !!rtspLocalConfig.value.enabled): RtspConfigUpdate {
loading.value = true return {
saved.value = false enabled,
try {
const update: RtspConfigUpdate = {
enabled: !!rtspLocalConfig.value.enabled,
bind: rtspLocalConfig.value.bind?.trim() || '0.0.0.0', bind: rtspLocalConfig.value.bind?.trim() || '0.0.0.0',
port: Number(rtspLocalConfig.value.port) || 8554, port: Number(rtspLocalConfig.value.port) || 8554,
path: normalizeRtspPath(rtspLocalConfig.value.path || 'live'), path: normalizeRtspPath(rtspLocalConfig.value.path || 'live'),
allow_one_client: !!rtspLocalConfig.value.allow_one_client, allow_one_client: !!rtspLocalConfig.value.allow_one_client,
codec: rtspLocalConfig.value.codec || 'h264', codec: rtspLocalConfig.value.codec || 'h264',
username: (rtspLocalConfig.value.username || '').trim(), username: (rtspLocalConfig.value.username || '').trim(),
password: (rtspLocalConfig.value.password || '').trim(),
} }
}
update.password = (rtspLocalConfig.value.password || '').trim() async function saveRtspConfig() {
loading.value = true
await configStore.updateRtsp(update) saved.value = false
try {
await configStore.updateRtsp(rtspUpdatePayload())
await loadRtspConfig() await loadRtspConfig()
saved.value = true saved.value = true
setTimeout(() => (saved.value = false), 2000) setTimeout(() => (saved.value = false), 2000)
@@ -2402,6 +2437,7 @@ async function saveRtspConfig() {
async function startRtsp() { async function startRtsp() {
rtspLoading.value = true rtspLoading.value = true
try { try {
await configStore.updateRtsp(rtspUpdatePayload(true))
const status = await rtspConfigApi.start() const status = await rtspConfigApi.start()
applyRtspStatus(status) applyRtspStatus(status)
} catch { } 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 { function getRtspServiceStatusText(status: string | undefined): string {
if (!status) return t('extensions.stopped') if (!status) return t('extensions.stopped')
switch (status) { switch (status) {
@@ -4506,7 +4644,7 @@ watch(isWindows, () => {
</div> </div>
<!-- RTSP Section --> <!-- RTSP Section -->
<div v-show="activeSection === 'ext-rtsp'" class="space-y-6"> <div v-show="activeSection === 'third-party-access'" class="space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -4631,8 +4769,134 @@ watch(isWindows, () => {
</div> </div>
</div> </div>
<!-- VNC Section -->
<div v-show="activeSection === 'third-party-access'" class="space-y-6">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<div class="space-y-1.5">
<CardTitle>{{ t('extensions.vnc.title') }}</CardTitle>
<CardDescription>{{ t('extensions.vnc.desc') }}</CardDescription>
</div>
<div class="flex items-center gap-2">
<Badge :variant="vncStatus?.service_status === 'running' ? 'default' : 'secondary'">
{{ getVncServiceStatusText(vncStatus?.service_status) }}
</Badge>
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.refresh')" @click="loadVncConfig" :disabled="vncLoading">
<RefreshCw :class="['h-4 w-4', vncLoading ? 'animate-spin' : '']" />
</Button>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div :class="['w-2 h-2 rounded-full', getVncStatusClass(vncStatus?.service_status)]" />
<span class="text-sm">{{ getVncServiceStatusText(vncStatus?.service_status) }}</span>
<template v-if="vncStatus?.connection_count">
<span class="text-muted-foreground">|</span>
<span class="text-sm text-muted-foreground">{{ t('extensions.vnc.clients', { count: vncStatus.connection_count }) }}</span>
</template>
</div>
<div class="flex items-center gap-2">
<Button
v-if="vncStatus?.service_status !== 'running' && vncStatus?.service_status !== 'starting'"
size="sm"
@click="startVnc"
:disabled="vncLoading || vncStatus?.service_status === 'starting'"
>
<Play class="h-4 w-4 mr-1" />
{{ t('extensions.start') }}
</Button>
<Button
v-else
size="sm"
variant="outline"
@click="stopVnc"
:disabled="vncLoading"
>
<Square class="h-4 w-4 mr-1" />
{{ t('extensions.stop') }}
</Button>
</div>
</div>
<Separator />
<div class="grid gap-4">
<div class="flex items-center justify-between">
<Label>{{ t('extensions.autoStart') }}</Label>
<Switch v-model="vncLocalConfig.enabled" :disabled="vncStatus?.service_status === 'running'" />
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.vnc.bind') }}</Label>
<Input v-model="vncLocalConfig.bind" class="sm:col-span-3" placeholder="0.0.0.0" :disabled="vncStatus?.service_status === 'running'" />
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.vnc.port') }}</Label>
<Input v-model.number="vncLocalConfig.port" class="sm:col-span-3" type="number" min="1" max="65535" :disabled="vncStatus?.service_status === 'running'" />
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.vnc.encoding') }}</Label>
<div class="sm:col-span-3 space-y-1">
<select v-model="vncLocalConfig.encoding" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="vncStatus?.service_status === 'running'">
<option value="tight_jpeg">{{ t('extensions.vnc.encodingTightJpeg') }}</option>
<option value="h264">{{ t('extensions.vnc.encodingH264') }}</option>
</select>
<p class="text-xs text-muted-foreground">{{ t('extensions.vnc.encodingHint') }}</p>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.vnc.jpegQuality') }}</Label>
<Input v-model.number="vncLocalConfig.jpeg_quality" class="sm:col-span-3" type="number" min="10" max="100" :disabled="vncStatus?.service_status === 'running' || vncLocalConfig.encoding !== 'tight_jpeg'" />
</div>
<div class="flex items-center justify-between">
<Label>{{ t('extensions.vnc.allowOneClient') }}</Label>
<Switch v-model="vncLocalConfig.allow_one_client" :disabled="vncStatus?.service_status === 'running'" />
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.vnc.password') }}</Label>
<div class="sm:col-span-3 space-y-1">
<div class="relative">
<Input
v-model="vncLocalConfig.password"
:type="showPasswords ? 'text' : 'password'"
maxlength="8"
autocomplete="off"
:placeholder="vncStatus?.config.has_password ? t('extensions.vnc.passwordPlaceholder') : t('extensions.vnc.passwordRequiredPlaceholder')"
:disabled="vncStatus?.service_status === 'running'"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
:aria-label="showPasswords ? t('extensions.rustdesk.hidePassword') : t('extensions.rustdesk.showPassword')"
@click="showPasswords = !showPasswords"
>
<Eye v-if="!showPasswords" class="h-4 w-4" />
<EyeOff v-else class="h-4 w-4" />
</button>
</div>
<p v-if="vncStatus?.config.has_password" class="text-xs text-muted-foreground">{{ t('extensions.vnc.passwordSaved') }}</p>
</div>
</div>
</div>
<Separator />
<div class="rounded-md border p-3 bg-muted/20 space-y-1">
<p class="text-sm font-medium">{{ t('extensions.vnc.urlPreview') }}</p>
<code class="font-mono text-sm break-all">{{ vncStreamUrl }}</code>
</div>
</CardContent>
</Card>
<div class="flex justify-end">
<Button :disabled="loading || vncLoading || vncStatus?.service_status === 'running'" @click="saveVncConfig">
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
</Button>
</div>
</div>
<!-- RustDesk Section --> <!-- RustDesk Section -->
<div v-show="activeSection === 'ext-rustdesk'" class="space-y-6"> <div v-show="activeSection === 'third-party-access'" class="space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -4692,6 +4956,16 @@ watch(isWindows, () => {
<Label>{{ t('extensions.autoStart') }}</Label> <Label>{{ t('extensions.autoStart') }}</Label>
<Switch v-model="rustdeskLocalConfig.enabled" :disabled="rustdeskStatus?.service_status === 'running'" /> <Switch v-model="rustdeskLocalConfig.enabled" :disabled="rustdeskStatus?.service_status === 'running'" />
</div> </div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.rustdesk.codec') }}</Label>
<div class="sm:col-span-3 space-y-1">
<select v-model="rustdeskLocalConfig.codec" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="rustdeskStatus?.service_status === 'running'">
<option value="h264">H.264</option>
<option value="h265">H.265</option>
</select>
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.codecHint') }}</p>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center"> <div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.rustdesk.rendezvousServer') }}</Label> <Label class="sm:text-right">{{ t('extensions.rustdesk.rendezvousServer') }}</Label>
<div class="sm:col-span-3 space-y-1"> <div class="sm:col-span-3 space-y-1">