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

View File

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

View File

@@ -23,6 +23,44 @@ pub enum RtspCodec {
H265,
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum VncEncoding {
#[default]
TightJpeg,
H264,
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VncConfig {
pub enabled: bool,
pub bind: String,
pub port: u16,
pub encoding: VncEncoding,
pub jpeg_quality: u8,
pub allow_one_client: bool,
#[typeshare(skip)]
pub password: Option<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]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]

View File

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

View File

@@ -31,10 +31,12 @@ use one_kvm::state::{AppState, ShutdownAction};
use one_kvm::update::UpdateService;
use one_kvm::utils::bind_tcp_listener;
use one_kvm::video::codec_constraints::{
enforce_constraints_with_stream_manager, StreamCodecConstraints,
enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility,
StreamCodecConstraints,
};
use one_kvm::video::format::{PixelFormat, Resolution};
use one_kvm::video::{Streamer, VideoStreamManager};
use one_kvm::vnc::VncService;
use one_kvm::web;
use one_kvm::webrtc::{WebRtcStreamer, WebRtcStreamerConfig};
@@ -486,7 +488,18 @@ async fn main() -> anyhow::Result<()> {
);
}
let rustdesk = if config.rustdesk.is_valid() {
let third_party_codec_config_valid = match validate_third_party_codec_compatibility(&config) {
Ok(()) => true,
Err(e) => {
tracing::warn!(
"Third-party access codec configuration is invalid; RustDesk/VNC/RTSP will not start: {}",
e
);
false
}
};
let rustdesk = if third_party_codec_config_valid && config.rustdesk.is_valid() {
tracing::info!(
"Initializing RustDesk service: ID={} -> {}",
config.rustdesk.device_id,
@@ -510,7 +523,7 @@ async fn main() -> anyhow::Result<()> {
None
};
let rtsp = if config.rtsp.enabled {
let rtsp = if third_party_codec_config_valid && config.rtsp.enabled {
tracing::info!(
"Initializing RTSP service: rtsp://{}:{}/{}",
config.rtsp.bind,
@@ -524,6 +537,23 @@ async fn main() -> anyhow::Result<()> {
None
};
let vnc = if third_party_codec_config_valid && config.vnc.enabled {
tracing::info!(
"Initializing VNC service: {}:{} ({:?})",
config.vnc.bind,
config.vnc.port,
config.vnc.encoding
);
Some(Arc::new(VncService::new(
config.vnc.clone(),
stream_manager.clone(),
hid.clone(),
)))
} else {
tracing::info!("VNC disabled in configuration");
None
};
let update_service = Arc::new(UpdateService::new(data_dir.join("updates")));
let state = AppState::new(
@@ -541,6 +571,7 @@ async fn main() -> anyhow::Result<()> {
atx,
audio,
rustdesk.clone(),
vnc.clone(),
rtsp.clone(),
extensions.clone(),
events.clone(),
@@ -573,6 +604,13 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("RustDesk service started");
}
}
if let Some(ref service) = vnc {
if let Err(e) = service.start().await {
tracing::error!("Failed to start VNC service: {}", e);
} else {
tracing::info!("VNC service started");
}
}
if let Some(ref service) = rtsp {
if let Err(e) = service.start().await {
@@ -1135,6 +1173,14 @@ async fn cleanup(state: &Arc<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 Err(e) = service.stop().await {
tracing::warn!("Failed to stop RTSP service: {}", e);

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,10 +32,12 @@ use crate::stream_encoder::encoder_type_to_backend;
use crate::update::UpdateService;
use crate::utils::bind_tcp_listener;
use crate::video::codec_constraints::{
enforce_constraints_with_stream_manager, StreamCodecConstraints,
enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility,
StreamCodecConstraints,
};
use crate::video::format::{PixelFormat, Resolution};
use crate::video::{Streamer, VideoStreamManager};
use crate::vnc::VncService;
use crate::web;
use crate::webrtc::{config::WebRtcConfig, WebRtcStreamer, WebRtcStreamerConfig};
@@ -440,7 +442,18 @@ async fn build_app_state(
tracing::warn!("Failed to initialize Android stream manager: {}", err);
}
let rustdesk = if config.rustdesk.is_valid() {
let third_party_codec_config_valid = match validate_third_party_codec_compatibility(&config) {
Ok(()) => true,
Err(e) => {
tracing::warn!(
"Android third-party access codec configuration is invalid; RustDesk/VNC/RTSP will not start: {}",
e
);
false
}
};
let rustdesk = if third_party_codec_config_valid && config.rustdesk.is_valid() {
Some(Arc::new(RustDeskService::new(
config.rustdesk.clone(),
stream_manager.clone(),
@@ -451,7 +464,7 @@ async fn build_app_state(
None
};
let rtsp = if config.rtsp.enabled {
let rtsp = if third_party_codec_config_valid && config.rtsp.enabled {
Some(Arc::new(RtspService::new(
config.rtsp.clone(),
stream_manager.clone(),
@@ -459,6 +472,15 @@ async fn build_app_state(
} else {
None
};
let vnc = if third_party_codec_config_valid && config.vnc.enabled {
Some(Arc::new(VncService::new(
config.vnc.clone(),
stream_manager.clone(),
hid.clone(),
)))
} else {
None
};
let update_service = Arc::new(UpdateService::new(data_dir.join("updates")));
let state = AppState::new(
@@ -474,6 +496,7 @@ async fn build_app_state(
atx,
audio,
rustdesk.clone(),
vnc.clone(),
rtsp.clone(),
extensions.clone(),
events.clone(),
@@ -489,6 +512,11 @@ async fn build_app_state(
tracing::warn!("Failed to start Android RustDesk service: {}", err);
}
}
if let Some(service) = vnc {
if let Err(err) = service.start().await {
tracing::warn!("Failed to start Android VNC service: {}", err);
}
}
if let Some(service) = rtsp {
if let Err(err) = service.start().await {
tracing::warn!("Failed to start Android RTSP service: {}", err);
@@ -674,6 +702,12 @@ async fn cleanup(state: &Arc<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 Err(err) = service.stop().await {
tracing::warn!("Failed to stop Android RTSP service: {}", err);

View File

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

View File

@@ -18,9 +18,7 @@ use crate::hid::{CanonicalKey, HidController, KeyEventType, KeyboardEvent, Keybo
use crate::utils::hostname_from_etc;
use crate::video::codec::registry::{EncoderRegistry, VideoEncoderType};
use crate::video::codec::BitratePreset;
use crate::video::codec_constraints::{
encoder_codec_to_id, encoder_codec_to_video_codec, video_codec_to_encoder_codec,
};
use crate::video::codec_constraints::{encoder_codec_to_id, encoder_codec_to_video_codec};
use crate::video::stream_manager::VideoStreamManager;
use super::bytes_codec::{read_frame, write_frame, write_frame_buffered};
@@ -160,6 +158,8 @@ pub struct Connection {
last_caps_lock: bool,
/// Whether relative mouse mode is currently active for this connection
relative_mouse_active: bool,
/// Server-configured RustDesk video codec.
configured_codec: VideoEncoderType,
}
/// Messages sent to connection handler
@@ -209,6 +209,11 @@ impl Connection {
// This is used for encrypting the symmetric key exchange
let temp_keypair = box_::gen_keypair();
let configured_codec = match config.codec {
super::config::RustDeskCodec::H264 => VideoEncoderType::H264,
super::config::RustDeskCodec::H265 => VideoEncoderType::H265,
};
let conn = Self {
id,
device_id: config.device_id.clone(),
@@ -238,6 +243,7 @@ impl Connection {
last_test_delay_sent: None,
last_caps_lock: false,
relative_mouse_active: false,
configured_codec,
};
(conn, rx)
@@ -628,43 +634,29 @@ impl Connection {
Ok(true)
}
/// Negotiate video codec - select the best available encoder
/// Priority: H264 > H265 > VP8 > VP9 (H264/H265 leverage hardware encoding on embedded devices)
/// Negotiate video codec from the server-configured RustDesk codec.
async fn negotiate_video_codec(&self) -> VideoEncoderType {
let registry = EncoderRegistry::global();
let constraints = self.current_codec_constraints().await;
let configured = self.configured_codec;
// Check availability in priority order
// H264 is preferred because it has the best hardware encoder support (RKMPP, VAAPI, etc.)
// and most RustDesk clients support H264 hardware decoding
if constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H264)
&& registry.is_codec_available(VideoEncoderType::H264)
{
return VideoEncoderType::H264;
}
if constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H265)
&& registry.is_codec_available(VideoEncoderType::H265)
{
return VideoEncoderType::H265;
}
if constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::VP8)
&& registry.is_codec_available(VideoEncoderType::VP8)
{
return VideoEncoderType::VP8;
}
if constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::VP9)
&& registry.is_codec_available(VideoEncoderType::VP9)
{
return VideoEncoderType::VP9;
}
// Fallback to preferred allowed codec
let preferred = constraints.preferred_webrtc_codec();
if !constraints.is_webrtc_codec_allowed(encoder_codec_to_video_codec(configured)) {
warn!(
"No allowed encoder available in priority order, falling back to {}",
encoder_codec_to_id(video_codec_to_encoder_codec(preferred))
"Configured RustDesk codec {} is blocked by constraints: {}",
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(
@@ -740,53 +732,10 @@ impl Connection {
}
}
// Check if client sent supported_decoding with a codec preference
// Codec switching is locked to the server-configured RustDesk codec.
if let Some(supported_decoding) = opt.supported_decoding.as_ref() {
let prefer = supported_decoding.prefer.value();
debug!("Client codec preference: prefer={}", prefer);
// Map RustDesk PreferCodec enum to our VideoEncoderType
// From proto: Auto=0, VP9=1, H264=2, H265=3, VP8=4, AV1=5
let requested_codec = match prefer {
1 => Some(VideoEncoderType::VP9),
2 => Some(VideoEncoderType::H264),
3 => Some(VideoEncoderType::H265),
4 => Some(VideoEncoderType::VP8),
// Auto(0) or AV1(5) or unknown: use current or negotiate
_ => None,
};
if let Some(new_codec) = requested_codec {
// Check if this codec is different from current and available
if self.negotiated_codec != Some(new_codec) {
let constraints = self.current_codec_constraints().await;
if !constraints.is_webrtc_codec_allowed(encoder_codec_to_video_codec(new_codec))
{
warn!(
"Client requested codec {:?} but it's blocked by constraints: {}",
new_codec, constraints.reason
);
return Ok(());
}
let registry = EncoderRegistry::global();
if registry.is_codec_available(new_codec) {
info!(
"Client requested codec switch: {:?} -> {:?}",
self.negotiated_codec, new_codec
);
// Switch codec
if let Err(e) = self.switch_video_codec(new_codec).await {
warn!("Failed to switch video codec: {}", e);
}
} else {
warn!(
"Client requested codec {:?} but it's not available",
new_codec
);
}
}
}
}
// Log custom_image_quality (accept but don't process)
@@ -803,31 +752,6 @@ impl Connection {
Ok(())
}
/// Switch video codec dynamically
/// Stops current video task, changes codec, and restarts
async fn switch_video_codec(&mut self, new_codec: VideoEncoderType) -> anyhow::Result<()> {
// Stop current video streaming task
if let Some(task) = self.video_task.take() {
info!("Stopping video task for codec switch");
task.abort();
// Wait a bit for cleanup
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
// Update negotiated codec
self.negotiated_codec = Some(new_codec);
// Restart video streaming with new codec if we have a video_tx
if let Some(ref video_tx) = self.video_frame_tx {
info!("Restarting video streaming with codec {:?}", new_codec);
self.start_video_streaming(video_tx.clone());
} else {
warn!("No video_tx available, cannot restart video streaming");
}
Ok(())
}
/// Start video streaming task
fn start_video_streaming(&mut self, video_tx: mpsc::Sender<Bytes>) {
let video_manager = match &self.video_manager {
@@ -1105,18 +1029,15 @@ impl Connection {
let constraints = self.current_codec_constraints().await;
// Check which encoders are available (include software fallback)
let h264_available = constraints
.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H264)
let configured = self.configured_codec;
let h264_available = configured == VideoEncoderType::H264
&& constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H264)
&& registry.is_codec_available(VideoEncoderType::H264);
let h265_available = constraints
.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H265)
let h265_available = configured == VideoEncoderType::H265
&& constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H265)
&& registry.is_codec_available(VideoEncoderType::H265);
let vp8_available = constraints
.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::VP8)
&& registry.is_codec_available(VideoEncoderType::VP8);
let vp9_available = constraints
.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::VP9)
&& registry.is_codec_available(VideoEncoderType::VP9);
let vp8_available = false;
let vp9_available = false;
info!(
"Server encoding capabilities: H264={}, H265={}, VP8={}, VP9={}",

View File

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

View File

@@ -396,15 +396,29 @@ impl MjpegStreamHandler {
}
pub fn disconnect_all_clients(&self) {
self.disconnect_clients_matching(|_| true);
}
pub fn disconnect_non_vnc_clients(&self) {
self.disconnect_clients_matching(|id| !id.starts_with("vnc-"));
}
fn disconnect_clients_matching(&self, should_disconnect: impl Fn(&str) -> bool) {
let count = {
let mut clients = self.clients.write();
let count = clients.len();
clients.clear();
count
let before = clients.len();
clients.retain(|id, _| !should_disconnect(id));
before - clients.len()
};
let remaining = self.client_count();
if count > 0 {
info!("Disconnected all {} MJPEG clients for config change", count);
info!(
"Disconnected {} MJPEG clients for config change (remaining: {})",
count, remaining
);
}
// Wake all subscribers. HTTP MJPEG clients will close, while persistent
// consumers such as VNC wait for the next frame after capture restarts.
self.set_offline();
}
}

View File

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

View File

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

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

View File

@@ -12,6 +12,7 @@ mod rtsp;
mod rustdesk;
mod stream;
pub(crate) mod video;
mod vnc;
mod web;
pub use atx::{get_atx_config, update_atx_config};
@@ -31,6 +32,9 @@ pub use rustdesk::{
};
pub use stream::{get_stream_config, update_stream_config};
pub use video::{get_video_config, update_video_config};
pub use vnc::{
get_vnc_config, get_vnc_status, start_vnc_service, stop_vnc_service, update_vnc_config,
};
pub use web::{get_web_config, update_web_config};
use axum::{extract::State, Json};
@@ -52,6 +56,7 @@ fn sanitize_config_for_api(config: &mut AppConfig) {
config.rustdesk.signing_private_key = None;
config.rtsp.password = None;
config.vnc.password = None;
}
pub async fn get_all_config(State(state): State<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::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> {
let config = state.config.get();
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> {
let config = state.config.get().rtsp.clone();
let status = {
let guard = state.rtsp.read().await;
if let Some(ref service) = *guard {
service.status().await
} else {
crate::rtsp::RtspServiceStatus::Stopped
}
};
let status = current_status(&state).await;
Json(RtspStatusResponse::new(&config, status))
}
@@ -34,22 +65,9 @@ pub async fn update_rtsp_config(
let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?;
let old_config = state.config.get().rtsp.clone();
state
.config
.update(|config| {
req.apply_to(&mut config.rtsp);
})
.await?;
let new_config = state.config.get().rtsp.clone();
apply_rtsp_config(
&state,
&old_config,
&new_config,
ConfigApplyOptions::forced(),
)
.await?;
let mut merged_config = old_config.clone();
req.apply_to(&mut merged_config);
let new_config = persist_and_apply(&state, old_config, merged_config).await?;
Ok(Json(RtspConfigResponse::from(&new_config)))
}
@@ -61,25 +79,10 @@ pub async fn start_rtsp_service(
let current_config = state.config.get().rtsp.clone();
let mut start_config = current_config.clone();
start_config.enabled = true;
let stored_config = persist_and_apply(&state, current_config, start_config).await?;
let status = current_status(&state).await;
apply_rtsp_config(
&state,
&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)))
Ok(Json(RtspStatusResponse::new(&stored_config, status)))
}
pub async fn stop_rtsp_service(
@@ -90,22 +93,8 @@ pub async fn stop_rtsp_service(
let mut stop_config = current_config.clone();
stop_config.enabled = false;
apply_rtsp_config(
&state,
&current_config,
&stop_config,
ConfigApplyOptions::forced(),
)
.await?;
let stored_config = persist_and_apply(&state, current_config, stop_config).await?;
let status = current_status(&state).await;
let status = {
let guard = state.rtsp.read().await;
if let Some(ref service) = *guard {
service.status().await
} else {
crate::rtsp::RtspServiceStatus::Stopped
}
};
Ok(Json(RtspStatusResponse::new(&current_config, status)))
Ok(Json(RtspStatusResponse::new(&stored_config, status)))
}

View File

@@ -8,9 +8,58 @@ use crate::state::AppState;
use super::apply::{apply_rustdesk_config, try_apply_lock, ConfigApplyOptions};
use super::types::RustDeskConfigUpdate;
fn validate_candidate(state: &Arc<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)]
pub struct RustDeskConfigResponse {
pub enabled: bool,
pub codec: crate::rustdesk::config::RustDeskCodec,
pub rendezvous_server: String,
pub relay_server: Option<String>,
pub device_id: String,
@@ -23,6 +72,7 @@ impl From<&RustDeskConfig> for RustDeskConfigResponse {
fn from(config: &RustDeskConfig) -> Self {
Self {
enabled: config.enabled,
codec: config.codec,
rendezvous_server: config.rendezvous_server.clone(),
relay_server: config.relay_server.clone(),
device_id: config.device_id.clone(),
@@ -50,23 +100,7 @@ pub async fn get_rustdesk_status(
State(state): State<Arc<AppState>>,
) -> Json<RustDeskStatusResponse> {
let config = state.config.get().rustdesk.clone();
let (service_status, rendezvous_status) = {
let guard = state.rustdesk.read().await;
if let Some(ref service) = *guard {
let status = format!("{}", service.status());
let rv_status = service.rendezvous_status().map(|s| format!("{}", s));
(status, rv_status)
} else {
("not_initialized".to_string(), None)
}
};
Json(RustDeskStatusResponse {
config: RustDeskConfigResponse::from(&config),
service_status,
rendezvous_status,
})
Json(current_status(&state, config).await)
}
pub async fn update_rustdesk_config(
@@ -81,22 +115,7 @@ pub async fn update_rustdesk_config(
req.apply_to(&mut merged_config);
req.validate_merged(&merged_config)?;
state
.config
.update(|config| {
config.rustdesk = merged_config.clone();
})
.await?;
let new_config = state.config.get().rustdesk.clone();
apply_rustdesk_config(
&state,
&old_config,
&new_config,
ConfigApplyOptions::forced(),
)
.await?;
let new_config = persist_and_apply(&state, old_config, merged_config).await?;
let constraints = state.stream_manager.codec_constraints().await;
if constraints.rustdesk_enabled || constraints.rtsp_enabled {
@@ -152,31 +171,8 @@ pub async fn start_rustdesk_service(
let current_config = state.config.get().rustdesk.clone();
let mut start_config = current_config.clone();
start_config.enabled = true;
apply_rustdesk_config(
&state,
&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,
}))
let stored_config = persist_and_apply(&state, current_config, start_config).await?;
Ok(Json(current_status(&state, stored_config).await))
}
pub async fn stop_rustdesk_service(
@@ -187,28 +183,6 @@ pub async fn stop_rustdesk_service(
let mut stop_config = current_config.clone();
stop_config.enabled = false;
apply_rustdesk_config(
&state,
&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,
}))
let stored_config = persist_and_apply(&state, current_config, stop_config).await?;
Ok(Json(current_status(&state, stored_config).await))
}

View File

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

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

View File

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

View File

@@ -143,6 +143,15 @@ pub fn create_router(state: Arc<AppState>) -> Router {
"/config/rustdesk/stop",
post(handlers::config::stop_rustdesk_service),
)
// VNC configuration endpoints
.route("/config/vnc", get(handlers::config::get_vnc_config))
.route("/config/vnc", patch(handlers::config::update_vnc_config))
.route("/config/vnc/status", get(handlers::config::get_vnc_status))
.route(
"/config/vnc/start",
post(handlers::config::start_vnc_service),
)
.route("/config/vnc/stop", post(handlers::config::stop_vnc_service))
// RTSP configuration endpoints
.route("/config/rtsp", get(handlers::config::get_rtsp_config))
.route("/config/rtsp", patch(handlers::config::update_rtsp_config))

View File

@@ -171,6 +171,7 @@ export const extensionsApi = {
export interface RustDeskConfigResponse {
enabled: boolean
codec: 'h264' | 'h265'
rendezvous_server: string
relay_server: string | null
device_id: string
@@ -187,6 +188,7 @@ export interface RustDeskStatusResponse {
export interface RustDeskConfigUpdate {
enabled?: boolean
codec?: 'h264' | 'h265'
rendezvous_server?: string
relay_server?: string
relay_key?: string
@@ -271,6 +273,50 @@ export const rtspConfigApi = {
stop: () => request<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 { WebConfigUpdate }

View File

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

View File

@@ -522,8 +522,7 @@ export default {
environmentSubtitle: 'System runtime environment and USB device maintenance',
aboutSubtitle: 'Online upgrade, version info and hardware overview',
extTtydSubtitle: 'Open a host Shell terminal in the browser',
extRustdeskSubtitle: 'Remote graphical access via RustDesk',
extRtspSubtitle: 'Provide an RTSP video stream for external clients',
thirdPartyAccessSubtitle: 'Configure external RustDesk, VNC, and RTSP access',
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
extFrpcSubtitle: 'NAT traversal through the FRP client',
aboutDesc: 'Open and Lightweight IP-KVM Solution',
@@ -967,6 +966,10 @@ export default {
start: 'Start',
stop: 'Stop',
autoStart: 'Auto Start',
thirdPartyAccess: {
title: 'Third-party Access',
desc: 'Configure RustDesk, VNC, and RTSP in one place',
},
viewLogs: 'View Logs',
noLogs: 'No logs available',
binaryNotFound: '{path} not found, please install the required program',
@@ -1040,6 +1043,8 @@ export default {
relayServer: 'Relay Server',
relayServerPlaceholder: 'hbbr.example.com:21117',
relayKey: 'Relay Key',
codec: 'Codec',
codecHint: 'Choose H.264 or H.265 before starting RustDesk. The codec is locked while running.',
deviceInfo: 'Device Info',
deviceId: 'Device ID',
deviceIdHint: 'Use this ID in RustDesk client to connect',
@@ -1073,7 +1078,7 @@ export default {
pathPlaceholder: 'live',
pathHint: 'Example: rtsp://device-ip:8554/live',
codec: 'Codec',
codecHint: 'Enabling RTSP locks codec to selected value and disables MJPEG.',
codecHint: 'RTSP locks output to the selected codec while running. If RustDesk is running, choose the same codec.',
allowOneClient: 'Allow One Client Only',
username: 'Username',
usernamePlaceholder: 'Empty means no authentication',
@@ -1081,6 +1086,26 @@ export default {
passwordPlaceholder: 'Enter new password',
urlPreview: 'RTSP URL Preview',
},
vnc: {
title: 'VNC Remote',
desc: 'Access via TigerVNC client',
bind: 'Bind Address',
port: 'Port',
encoding: 'Video Encoding',
encodingTightJpeg: 'Tight JPEG',
encodingH264: 'H.264',
encodingHint: 'VNC locks output while running. VNC cannot start under an H.265 lock; MJPEG blocks RTSP and RustDesk.',
jpegQuality: 'JPEG Quality',
allowOneClient: 'Allow One Client Only',
password: 'Password',
passwordPlaceholder: 'Leave empty to keep current',
passwordRequiredPlaceholder: 'Up to 8 characters',
passwordRequired: 'Set a VNC password',
passwordMaxLength: 'VNC passwords are limited to 8 characters',
passwordSaved: 'Password is saved; leaving this empty keeps it unchanged.',
clients: '{count} clients',
urlPreview: 'VNC Address Preview',
},
},
stats: {
title: 'Connection Stats',

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ import {
systemApi,
updateApi,
usbApi,
vncConfigApi,
type EncoderBackendInfo,
type AuthConfig,
type RustDeskConfigResponse,
@@ -27,6 +28,8 @@ import {
type RustDeskPasswordResponse,
type RtspStatusResponse,
type RtspConfigUpdate,
type VncConfigUpdate,
type VncStatusResponse,
type WebConfig,
type UpdateOverviewResponse,
type UpdateStatusResponse,
@@ -105,7 +108,6 @@ import {
ExternalLink,
Copy,
ScreenShare,
Radio,
Globe,
Loader2,
AlertTriangle,
@@ -136,8 +138,7 @@ const SETTINGS_SECTION_IDS = [
'atx',
'environment',
'ext-ttyd',
'ext-rustdesk',
'ext-rtsp',
'third-party-access',
'ext-remote-access',
'about',
] as const
@@ -167,8 +168,7 @@ const navGroups = computed(() => [
title: t('settings.extensions'),
items: [
{ id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal },
{ id: 'ext-rustdesk', label: t('extensions.rustdesk.title'), icon: ScreenShare },
{ id: 'ext-rtsp', label: t('extensions.rtsp.title'), icon: Radio },
{ id: 'third-party-access', label: t('extensions.thirdPartyAccess.title'), icon: ScreenShare },
{ id: 'ext-remote-access', label: t('extensions.remoteAccess.title'), icon: ExternalLink },
]
},
@@ -200,8 +200,7 @@ const sectionMeta = computed(() => {
function sectionSubtitleKey(id: string): string {
switch (id) {
case 'ext-ttyd': return 'extTtydSubtitle'
case 'ext-rustdesk': return 'extRustdeskSubtitle'
case 'ext-rtsp': return 'extRtspSubtitle'
case 'third-party-access': return 'thirdPartyAccessSubtitle'
case 'ext-remote-access': return 'extRemoteAccessSubtitle'
default: return `${id}Subtitle`
}
@@ -222,6 +221,7 @@ function normalizeSettingsSection(value: unknown): SettingsSectionId | null {
if (typeof value !== 'string') return null
if (value === 'access-control') return 'account'
if (value === 'ext-frpc') return 'ext-remote-access'
if (value === 'ext-rustdesk' || value === 'ext-vnc' || value === 'ext-rtsp') return 'third-party-access'
return isSettingsSectionId(value) ? value : null
}
@@ -272,15 +272,14 @@ async function loadSectionData(section: SettingsSectionId) {
case 'ext-remote-access':
await loadExtensions()
return
case 'ext-rustdesk':
case 'third-party-access':
await Promise.all([
loadRustdeskConfig(),
loadRustdeskPassword(),
loadRtspConfig(),
loadVncConfig(),
])
return
case 'ext-rtsp':
await loadRtspConfig()
return
case 'about':
if (isAndroid.value) return
await Promise.all([
@@ -390,6 +389,7 @@ const rustdeskCopied = ref<'id' | 'password' | null>(null)
const { copy: clipboardCopy } = useClipboard()
const rustdeskLocalConfig = ref({
enabled: false,
codec: 'h264' as 'h264' | 'h265',
rendezvous_server: '',
relay_server: '',
relay_key: '',
@@ -415,6 +415,18 @@ const rtspLocalConfig = ref<RtspConfigUpdate & { password?: string }>({
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 {
if (!hostname) return '127.0.0.1'
return hostname.includes(':') && !hostname.startsWith('[')
@@ -429,6 +441,12 @@ const rtspStreamUrl = computed(() => {
return `rtsp://${host}:${port}/${path}`
})
const vncStreamUrl = computed(() => {
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
const port = Number(vncLocalConfig.value.port) || 5900
return `${host}:${port}`
})
const webServerConfig = ref<WebConfig>({
http_port: 8080,
https_port: 8443,
@@ -1839,6 +1857,7 @@ function applyRustdeskStatus(status: RustDeskStatusResponse) {
rustdeskStatus.value = status
rustdeskLocalConfig.value = {
enabled: config.enabled,
codec: config.codec || 'h264',
rendezvous_server: config.rendezvous_server,
relay_server: config.relay_server || '',
relay_key: config.relay_key || '',
@@ -1901,6 +1920,17 @@ function validateRustdeskConfig(): boolean {
return !rustdeskValidationMessage.value || showValidationError(rustdeskValidationMessage.value)
}
function validateVncConfig(enabled = vncLocalConfig.value.enabled): boolean {
const password = (vncLocalConfig.value.password || '').trim()
if (enabled && !vncStatus.value?.config.has_password && !password) {
return showValidationError(t('extensions.vnc.passwordRequired'))
}
if (password.length > 8) {
return showValidationError(t('extensions.vnc.passwordMaxLength'))
}
return true
}
function normalizeRtspPath(path: string): string {
return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
}
@@ -2222,23 +2252,26 @@ function updateStatusBadgeText(): string {
|| updatePhaseText(updateStatus.value?.phase)
}
function rustdeskUpdatePayload(enabled = rustdeskLocalConfig.value.enabled) {
return {
enabled,
codec: rustdeskLocalConfig.value.codec,
rendezvous_server: normalizeRustdeskServer(
rustdeskLocalConfig.value.rendezvous_server,
21116,
),
relay_server: normalizeRustdeskServer(rustdeskLocalConfig.value.relay_server, 21117),
relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key),
}
}
async function saveRustdeskConfig() {
if (rustdeskLocalConfig.value.enabled && !validateRustdeskConfig()) return
loading.value = true
saved.value = false
try {
const rendezvousServer = normalizeRustdeskServer(
rustdeskLocalConfig.value.rendezvous_server,
21116,
)
const relayServer = normalizeRustdeskServer(rustdeskLocalConfig.value.relay_server, 21117)
await configStore.updateRustdesk({
enabled: rustdeskLocalConfig.value.enabled,
rendezvous_server: rendezvousServer,
relay_server: relayServer,
relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key),
})
await configStore.updateRustdesk(rustdeskUpdatePayload())
await loadRustdeskConfig()
saved.value = true
setTimeout(() => (saved.value = false), 2000)
@@ -2279,6 +2312,7 @@ async function startRustdesk() {
rustdeskLoading.value = true
try {
await configStore.updateRustdesk(rustdeskUpdatePayload(true))
const status = await rustdeskConfigApi.start()
applyRustdeskStatus(status)
} catch {
@@ -2373,23 +2407,24 @@ async function loadRtspConfig() {
}
}
async function saveRtspConfig() {
loading.value = true
saved.value = false
try {
const update: RtspConfigUpdate = {
enabled: !!rtspLocalConfig.value.enabled,
function rtspUpdatePayload(enabled = !!rtspLocalConfig.value.enabled): RtspConfigUpdate {
return {
enabled,
bind: rtspLocalConfig.value.bind?.trim() || '0.0.0.0',
port: Number(rtspLocalConfig.value.port) || 8554,
path: normalizeRtspPath(rtspLocalConfig.value.path || 'live'),
allow_one_client: !!rtspLocalConfig.value.allow_one_client,
codec: rtspLocalConfig.value.codec || 'h264',
username: (rtspLocalConfig.value.username || '').trim(),
password: (rtspLocalConfig.value.password || '').trim(),
}
}
update.password = (rtspLocalConfig.value.password || '').trim()
await configStore.updateRtsp(update)
async function saveRtspConfig() {
loading.value = true
saved.value = false
try {
await configStore.updateRtsp(rtspUpdatePayload())
await loadRtspConfig()
saved.value = true
setTimeout(() => (saved.value = false), 2000)
@@ -2402,6 +2437,7 @@ async function saveRtspConfig() {
async function startRtsp() {
rtspLoading.value = true
try {
await configStore.updateRtsp(rtspUpdatePayload(true))
const status = await rtspConfigApi.start()
applyRtspStatus(status)
} catch {
@@ -2421,6 +2457,108 @@ async function stopRtsp() {
}
}
function applyVncStatus(status: VncStatusResponse) {
vncStatus.value = status
vncLocalConfig.value = {
enabled: status.config.enabled,
bind: status.config.bind,
port: status.config.port,
encoding: status.config.encoding,
jpeg_quality: status.config.jpeg_quality,
allow_one_client: status.config.allow_one_client,
password: '',
}
}
async function loadVncConfig() {
vncLoading.value = true
try {
const status = await configStore.refreshVncStatus()
applyVncStatus(status)
} catch {
} finally {
vncLoading.value = false
}
}
function vncUpdatePayload(enabled = !!vncLocalConfig.value.enabled): VncConfigUpdate {
const update: VncConfigUpdate = {
enabled,
bind: vncLocalConfig.value.bind?.trim() || '0.0.0.0',
port: Number(vncLocalConfig.value.port) || 5900,
encoding: vncLocalConfig.value.encoding || 'tight_jpeg',
jpeg_quality: Number(vncLocalConfig.value.jpeg_quality) || 80,
allow_one_client: !!vncLocalConfig.value.allow_one_client,
}
const password = (vncLocalConfig.value.password || '').trim()
if (password) update.password = password
return update
}
async function saveVncConfig() {
if (!validateVncConfig()) return
loading.value = true
saved.value = false
try {
await configStore.updateVnc(vncUpdatePayload())
await loadVncConfig()
saved.value = true
setTimeout(() => (saved.value = false), 2000)
} catch {
} finally {
loading.value = false
}
}
async function startVnc() {
if (!validateVncConfig(true)) return
vncLoading.value = true
try {
await configStore.updateVnc(vncUpdatePayload(true))
const status = await vncConfigApi.start()
applyVncStatus(status)
} catch {
} finally {
vncLoading.value = false
}
}
async function stopVnc() {
vncLoading.value = true
try {
const status = await vncConfigApi.stop()
applyVncStatus(status)
} catch {
} finally {
vncLoading.value = false
}
}
function getVncServiceStatusText(status: string | undefined): string {
if (!status) return t('extensions.stopped')
switch (status) {
case 'running': return t('extensions.running')
case 'starting': return t('extensions.starting')
case 'stopped': return t('extensions.stopped')
default:
if (status.startsWith('error:')) return t('extensions.failed')
return status
}
}
function getVncStatusClass(status: string | undefined): string {
switch (status) {
case 'running': return 'bg-green-500'
case 'starting': return 'bg-yellow-500'
case 'stopped': return 'bg-gray-400'
default:
if (status?.startsWith('error:')) return 'bg-red-500'
return 'bg-gray-400'
}
}
function getRtspServiceStatusText(status: string | undefined): string {
if (!status) return t('extensions.stopped')
switch (status) {
@@ -4506,7 +4644,7 @@ watch(isWindows, () => {
</div>
<!-- RTSP Section -->
<div v-show="activeSection === 'ext-rtsp'" class="space-y-6">
<div v-show="activeSection === 'third-party-access'" class="space-y-6">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
@@ -4631,8 +4769,134 @@ watch(isWindows, () => {
</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 -->
<div v-show="activeSection === 'ext-rustdesk'" class="space-y-6">
<div v-show="activeSection === 'third-party-access'" class="space-y-6">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
@@ -4692,6 +4956,16 @@ watch(isWindows, () => {
<Label>{{ t('extensions.autoStart') }}</Label>
<Switch v-model="rustdeskLocalConfig.enabled" :disabled="rustdeskStatus?.service_status === 'running'" />
</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">
<Label class="sm:text-right">{{ t('extensions.rustdesk.rendezvousServer') }}</Label>
<div class="sm:col-span-3 space-y-1">