mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-18 17:51:49 +08:00
feat: 新增 MJPEG/H.264 VNC 初步支持
This commit is contained in:
@@ -58,6 +58,7 @@ desktop = [
|
|||||||
"dep:ventoy-img",
|
"dep:ventoy-img",
|
||||||
"dep:protobuf",
|
"dep:protobuf",
|
||||||
"dep:sodiumoxide",
|
"dep:sodiumoxide",
|
||||||
|
"dep:des",
|
||||||
"dep:sha2",
|
"dep:sha2",
|
||||||
"dep:typeshare",
|
"dep:typeshare",
|
||||||
"dep:hwcodec",
|
"dep:hwcodec",
|
||||||
@@ -104,6 +105,7 @@ android = [
|
|||||||
"dep:serialport",
|
"dep:serialport",
|
||||||
"dep:sha2",
|
"dep:sha2",
|
||||||
"dep:sodiumoxide",
|
"dep:sodiumoxide",
|
||||||
|
"dep:des",
|
||||||
"dep:sqlx",
|
"dep:sqlx",
|
||||||
"dep:alsa",
|
"dep:alsa",
|
||||||
"dep:audiopus",
|
"dep:audiopus",
|
||||||
@@ -222,6 +224,7 @@ ventoy-img = { path = "libs/ventoy-img-rs", optional = true }
|
|||||||
# RustDesk protocol support
|
# RustDesk protocol support
|
||||||
protobuf = { version = "3.7", features = ["with-bytes"], optional = true }
|
protobuf = { version = "3.7", features = ["with-bytes"], optional = true }
|
||||||
sodiumoxide = { version = "0.2", optional = true }
|
sodiumoxide = { version = "0.2", optional = true }
|
||||||
|
des = { version = "0.8", optional = true }
|
||||||
sha2 = { version = "0.10", optional = true }
|
sha2 = { version = "0.10", optional = true }
|
||||||
# TypeScript type generation
|
# TypeScript type generation
|
||||||
typeshare = { version = "1.0", optional = true }
|
typeshare = { version = "1.0", optional = true }
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ pub struct AppConfig {
|
|||||||
pub web: WebConfig,
|
pub web: WebConfig,
|
||||||
pub extensions: ExtensionsConfig,
|
pub extensions: ExtensionsConfig,
|
||||||
pub rustdesk: RustDeskConfig,
|
pub rustdesk: RustDeskConfig,
|
||||||
|
pub vnc: VncConfig,
|
||||||
pub rtsp: RtspConfig,
|
pub rtsp: RtspConfig,
|
||||||
pub redfish: RedfishConfig,
|
pub redfish: RedfishConfig,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,44 @@ pub enum RtspCodec {
|
|||||||
H265,
|
H265,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub enum VncEncoding {
|
||||||
|
#[default]
|
||||||
|
TightJpeg,
|
||||||
|
H264,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct VncConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub bind: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub encoding: VncEncoding,
|
||||||
|
pub jpeg_quality: u8,
|
||||||
|
pub allow_one_client: bool,
|
||||||
|
#[typeshare(skip)]
|
||||||
|
pub password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for VncConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
bind: "0.0.0.0".to_string(),
|
||||||
|
port: 5900,
|
||||||
|
encoding: VncEncoding::TightJpeg,
|
||||||
|
jpeg_quality: 80,
|
||||||
|
allow_one_client: true,
|
||||||
|
password: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ pub mod utils;
|
|||||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod video;
|
pub mod video;
|
||||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
|
pub mod vnc;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod web;
|
pub mod web;
|
||||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod webrtc;
|
pub mod webrtc;
|
||||||
|
|||||||
52
src/main.rs
52
src/main.rs
@@ -31,10 +31,12 @@ use one_kvm::state::{AppState, ShutdownAction};
|
|||||||
use one_kvm::update::UpdateService;
|
use one_kvm::update::UpdateService;
|
||||||
use one_kvm::utils::bind_tcp_listener;
|
use one_kvm::utils::bind_tcp_listener;
|
||||||
use one_kvm::video::codec_constraints::{
|
use one_kvm::video::codec_constraints::{
|
||||||
enforce_constraints_with_stream_manager, StreamCodecConstraints,
|
enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility,
|
||||||
|
StreamCodecConstraints,
|
||||||
};
|
};
|
||||||
use one_kvm::video::format::{PixelFormat, Resolution};
|
use one_kvm::video::format::{PixelFormat, Resolution};
|
||||||
use one_kvm::video::{Streamer, VideoStreamManager};
|
use one_kvm::video::{Streamer, VideoStreamManager};
|
||||||
|
use one_kvm::vnc::VncService;
|
||||||
use one_kvm::web;
|
use one_kvm::web;
|
||||||
use one_kvm::webrtc::{WebRtcStreamer, WebRtcStreamerConfig};
|
use one_kvm::webrtc::{WebRtcStreamer, WebRtcStreamerConfig};
|
||||||
|
|
||||||
@@ -486,7 +488,18 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rustdesk = if config.rustdesk.is_valid() {
|
let third_party_codec_config_valid = match validate_third_party_codec_compatibility(&config) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Third-party access codec configuration is invalid; RustDesk/VNC/RTSP will not start: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let rustdesk = if third_party_codec_config_valid && config.rustdesk.is_valid() {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Initializing RustDesk service: ID={} -> {}",
|
"Initializing RustDesk service: ID={} -> {}",
|
||||||
config.rustdesk.device_id,
|
config.rustdesk.device_id,
|
||||||
@@ -510,7 +523,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let rtsp = if config.rtsp.enabled {
|
let rtsp = if third_party_codec_config_valid && config.rtsp.enabled {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Initializing RTSP service: rtsp://{}:{}/{}",
|
"Initializing RTSP service: rtsp://{}:{}/{}",
|
||||||
config.rtsp.bind,
|
config.rtsp.bind,
|
||||||
@@ -524,6 +537,23 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let vnc = if third_party_codec_config_valid && config.vnc.enabled {
|
||||||
|
tracing::info!(
|
||||||
|
"Initializing VNC service: {}:{} ({:?})",
|
||||||
|
config.vnc.bind,
|
||||||
|
config.vnc.port,
|
||||||
|
config.vnc.encoding
|
||||||
|
);
|
||||||
|
Some(Arc::new(VncService::new(
|
||||||
|
config.vnc.clone(),
|
||||||
|
stream_manager.clone(),
|
||||||
|
hid.clone(),
|
||||||
|
)))
|
||||||
|
} else {
|
||||||
|
tracing::info!("VNC disabled in configuration");
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let update_service = Arc::new(UpdateService::new(data_dir.join("updates")));
|
let update_service = Arc::new(UpdateService::new(data_dir.join("updates")));
|
||||||
|
|
||||||
let state = AppState::new(
|
let state = AppState::new(
|
||||||
@@ -541,6 +571,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
atx,
|
atx,
|
||||||
audio,
|
audio,
|
||||||
rustdesk.clone(),
|
rustdesk.clone(),
|
||||||
|
vnc.clone(),
|
||||||
rtsp.clone(),
|
rtsp.clone(),
|
||||||
extensions.clone(),
|
extensions.clone(),
|
||||||
events.clone(),
|
events.clone(),
|
||||||
@@ -573,6 +604,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::info!("RustDesk service started");
|
tracing::info!("RustDesk service started");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(ref service) = vnc {
|
||||||
|
if let Err(e) = service.start().await {
|
||||||
|
tracing::error!("Failed to start VNC service: {}", e);
|
||||||
|
} else {
|
||||||
|
tracing::info!("VNC service started");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref service) = rtsp {
|
if let Some(ref service) = rtsp {
|
||||||
if let Err(e) = service.start().await {
|
if let Err(e) = service.start().await {
|
||||||
@@ -1135,6 +1173,14 @@ async fn cleanup(state: &Arc<AppState>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref service) = *state.vnc.read().await {
|
||||||
|
if let Err(e) = service.stop().await {
|
||||||
|
tracing::warn!("Failed to stop VNC service: {}", e);
|
||||||
|
} else {
|
||||||
|
tracing::info!("VNC service stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref service) = *state.rtsp.read().await {
|
if let Some(ref service) = *state.rtsp.read().await {
|
||||||
if let Err(e) = service.stop().await {
|
if let Err(e) = service.stop().await {
|
||||||
tracing::warn!("Failed to stop RTSP service: {}", e);
|
tracing::warn!("Failed to stop RTSP service: {}", e);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ pub fn capabilities() -> PlatformCapabilities {
|
|||||||
audio: FeatureCapability::available(["alsa", "opus"])
|
audio: FeatureCapability::available(["alsa", "opus"])
|
||||||
.with_selected_backend(Some("alsa".to_string())),
|
.with_selected_backend(Some("alsa".to_string())),
|
||||||
rustdesk: FeatureCapability::available(["builtin"]),
|
rustdesk: FeatureCapability::available(["builtin"]),
|
||||||
|
vnc: FeatureCapability::available(["builtin", "tight_jpeg", "h264"]),
|
||||||
diagnostics: FeatureCapability::available(["android_linux"]),
|
diagnostics: FeatureCapability::available(["android_linux"]),
|
||||||
extensions: FeatureCapability::unsupported("unsupported on Android Amlogic v1"),
|
extensions: FeatureCapability::unsupported("unsupported on Android Amlogic v1"),
|
||||||
service_installation: FeatureCapability::available(["android_foreground_service"]),
|
service_installation: FeatureCapability::available(["android_foreground_service"]),
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ pub struct PlatformCapabilities {
|
|||||||
pub otg: FeatureCapability,
|
pub otg: FeatureCapability,
|
||||||
pub audio: FeatureCapability,
|
pub audio: FeatureCapability,
|
||||||
pub rustdesk: FeatureCapability,
|
pub rustdesk: FeatureCapability,
|
||||||
|
pub vnc: FeatureCapability,
|
||||||
pub diagnostics: FeatureCapability,
|
pub diagnostics: FeatureCapability,
|
||||||
pub extensions: FeatureCapability,
|
pub extensions: FeatureCapability,
|
||||||
pub service_installation: FeatureCapability,
|
pub service_installation: FeatureCapability,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub fn capabilities() -> PlatformCapabilities {
|
|||||||
otg: FeatureCapability::available(["configfs"]),
|
otg: FeatureCapability::available(["configfs"]),
|
||||||
audio: FeatureCapability::available(["alsa"]),
|
audio: FeatureCapability::available(["alsa"]),
|
||||||
rustdesk: FeatureCapability::available(["builtin"]),
|
rustdesk: FeatureCapability::available(["builtin"]),
|
||||||
|
vnc: FeatureCapability::available(["builtin", "tight_jpeg", "h264"]),
|
||||||
diagnostics: FeatureCapability::available(["linux"]),
|
diagnostics: FeatureCapability::available(["linux"]),
|
||||||
extensions: FeatureCapability::available(["linux"]),
|
extensions: FeatureCapability::available(["linux"]),
|
||||||
service_installation: FeatureCapability::available(["systemd"]),
|
service_installation: FeatureCapability::available(["systemd"]),
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ pub fn capabilities() -> PlatformCapabilities {
|
|||||||
.with_selected_backend(Some("wasapi".to_string())),
|
.with_selected_backend(Some("wasapi".to_string())),
|
||||||
rustdesk: FeatureCapability::available(["builtin", "tcp_direct", "relay"])
|
rustdesk: FeatureCapability::available(["builtin", "tcp_direct", "relay"])
|
||||||
.with_selected_backend(Some("builtin".to_string())),
|
.with_selected_backend(Some("builtin".to_string())),
|
||||||
|
vnc: FeatureCapability::available(["builtin", "tight_jpeg", "h264"])
|
||||||
|
.with_selected_backend(Some("builtin".to_string())),
|
||||||
diagnostics: FeatureCapability::available(["windows"]),
|
diagnostics: FeatureCapability::available(["windows"]),
|
||||||
extensions: FeatureCapability::available(["windows_safe"]),
|
extensions: FeatureCapability::available(["windows_safe"]),
|
||||||
service_installation: FeatureCapability::available(["windows_service"]),
|
service_installation: FeatureCapability::available(["windows_service"]),
|
||||||
|
|||||||
@@ -32,10 +32,12 @@ use crate::stream_encoder::encoder_type_to_backend;
|
|||||||
use crate::update::UpdateService;
|
use crate::update::UpdateService;
|
||||||
use crate::utils::bind_tcp_listener;
|
use crate::utils::bind_tcp_listener;
|
||||||
use crate::video::codec_constraints::{
|
use crate::video::codec_constraints::{
|
||||||
enforce_constraints_with_stream_manager, StreamCodecConstraints,
|
enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility,
|
||||||
|
StreamCodecConstraints,
|
||||||
};
|
};
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
use crate::video::{Streamer, VideoStreamManager};
|
use crate::video::{Streamer, VideoStreamManager};
|
||||||
|
use crate::vnc::VncService;
|
||||||
use crate::web;
|
use crate::web;
|
||||||
use crate::webrtc::{config::WebRtcConfig, WebRtcStreamer, WebRtcStreamerConfig};
|
use crate::webrtc::{config::WebRtcConfig, WebRtcStreamer, WebRtcStreamerConfig};
|
||||||
|
|
||||||
@@ -440,7 +442,18 @@ async fn build_app_state(
|
|||||||
tracing::warn!("Failed to initialize Android stream manager: {}", err);
|
tracing::warn!("Failed to initialize Android stream manager: {}", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rustdesk = if config.rustdesk.is_valid() {
|
let third_party_codec_config_valid = match validate_third_party_codec_compatibility(&config) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Android third-party access codec configuration is invalid; RustDesk/VNC/RTSP will not start: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let rustdesk = if third_party_codec_config_valid && config.rustdesk.is_valid() {
|
||||||
Some(Arc::new(RustDeskService::new(
|
Some(Arc::new(RustDeskService::new(
|
||||||
config.rustdesk.clone(),
|
config.rustdesk.clone(),
|
||||||
stream_manager.clone(),
|
stream_manager.clone(),
|
||||||
@@ -451,7 +464,7 @@ async fn build_app_state(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let rtsp = if config.rtsp.enabled {
|
let rtsp = if third_party_codec_config_valid && config.rtsp.enabled {
|
||||||
Some(Arc::new(RtspService::new(
|
Some(Arc::new(RtspService::new(
|
||||||
config.rtsp.clone(),
|
config.rtsp.clone(),
|
||||||
stream_manager.clone(),
|
stream_manager.clone(),
|
||||||
@@ -459,6 +472,15 @@ async fn build_app_state(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let vnc = if third_party_codec_config_valid && config.vnc.enabled {
|
||||||
|
Some(Arc::new(VncService::new(
|
||||||
|
config.vnc.clone(),
|
||||||
|
stream_manager.clone(),
|
||||||
|
hid.clone(),
|
||||||
|
)))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let update_service = Arc::new(UpdateService::new(data_dir.join("updates")));
|
let update_service = Arc::new(UpdateService::new(data_dir.join("updates")));
|
||||||
let state = AppState::new(
|
let state = AppState::new(
|
||||||
@@ -474,6 +496,7 @@ async fn build_app_state(
|
|||||||
atx,
|
atx,
|
||||||
audio,
|
audio,
|
||||||
rustdesk.clone(),
|
rustdesk.clone(),
|
||||||
|
vnc.clone(),
|
||||||
rtsp.clone(),
|
rtsp.clone(),
|
||||||
extensions.clone(),
|
extensions.clone(),
|
||||||
events.clone(),
|
events.clone(),
|
||||||
@@ -489,6 +512,11 @@ async fn build_app_state(
|
|||||||
tracing::warn!("Failed to start Android RustDesk service: {}", err);
|
tracing::warn!("Failed to start Android RustDesk service: {}", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(service) = vnc {
|
||||||
|
if let Err(err) = service.start().await {
|
||||||
|
tracing::warn!("Failed to start Android VNC service: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(service) = rtsp {
|
if let Some(service) = rtsp {
|
||||||
if let Err(err) = service.start().await {
|
if let Err(err) = service.start().await {
|
||||||
tracing::warn!("Failed to start Android RTSP service: {}", err);
|
tracing::warn!("Failed to start Android RTSP service: {}", err);
|
||||||
@@ -674,6 +702,12 @@ async fn cleanup(state: &Arc<AppState>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(service) = state.vnc.read().await.as_ref() {
|
||||||
|
if let Err(err) = service.stop().await {
|
||||||
|
tracing::warn!("Failed to stop Android VNC service: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(service) = state.rtsp.read().await.as_ref() {
|
if let Some(service) = state.rtsp.read().await.as_ref() {
|
||||||
if let Err(err) = service.stop().await {
|
if let Err(err) = service.stop().await {
|
||||||
tracing::warn!("Failed to stop Android RTSP service: {}", err);
|
tracing::warn!("Failed to stop Android RTSP service: {}", err);
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub enum RustDeskCodec {
|
||||||
|
#[default]
|
||||||
|
H264,
|
||||||
|
H265,
|
||||||
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct RustDeskConfig {
|
pub struct RustDeskConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
pub codec: RustDeskCodec,
|
||||||
pub rendezvous_server: String,
|
pub rendezvous_server: String,
|
||||||
pub relay_server: Option<String>,
|
pub relay_server: Option<String>,
|
||||||
#[typeshare(skip)]
|
#[typeshare(skip)]
|
||||||
@@ -29,6 +40,7 @@ impl Default for RustDeskConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
codec: RustDeskCodec::H264,
|
||||||
rendezvous_server: String::new(),
|
rendezvous_server: String::new(),
|
||||||
relay_server: None,
|
relay_server: None,
|
||||||
relay_key: None,
|
relay_key: None,
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ use crate::hid::{CanonicalKey, HidController, KeyEventType, KeyboardEvent, Keybo
|
|||||||
use crate::utils::hostname_from_etc;
|
use crate::utils::hostname_from_etc;
|
||||||
use crate::video::codec::registry::{EncoderRegistry, VideoEncoderType};
|
use crate::video::codec::registry::{EncoderRegistry, VideoEncoderType};
|
||||||
use crate::video::codec::BitratePreset;
|
use crate::video::codec::BitratePreset;
|
||||||
use crate::video::codec_constraints::{
|
use crate::video::codec_constraints::{encoder_codec_to_id, encoder_codec_to_video_codec};
|
||||||
encoder_codec_to_id, encoder_codec_to_video_codec, video_codec_to_encoder_codec,
|
|
||||||
};
|
|
||||||
use crate::video::stream_manager::VideoStreamManager;
|
use crate::video::stream_manager::VideoStreamManager;
|
||||||
|
|
||||||
use super::bytes_codec::{read_frame, write_frame, write_frame_buffered};
|
use super::bytes_codec::{read_frame, write_frame, write_frame_buffered};
|
||||||
@@ -160,6 +158,8 @@ pub struct Connection {
|
|||||||
last_caps_lock: bool,
|
last_caps_lock: bool,
|
||||||
/// Whether relative mouse mode is currently active for this connection
|
/// Whether relative mouse mode is currently active for this connection
|
||||||
relative_mouse_active: bool,
|
relative_mouse_active: bool,
|
||||||
|
/// Server-configured RustDesk video codec.
|
||||||
|
configured_codec: VideoEncoderType,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Messages sent to connection handler
|
/// Messages sent to connection handler
|
||||||
@@ -209,6 +209,11 @@ impl Connection {
|
|||||||
// This is used for encrypting the symmetric key exchange
|
// This is used for encrypting the symmetric key exchange
|
||||||
let temp_keypair = box_::gen_keypair();
|
let temp_keypair = box_::gen_keypair();
|
||||||
|
|
||||||
|
let configured_codec = match config.codec {
|
||||||
|
super::config::RustDeskCodec::H264 => VideoEncoderType::H264,
|
||||||
|
super::config::RustDeskCodec::H265 => VideoEncoderType::H265,
|
||||||
|
};
|
||||||
|
|
||||||
let conn = Self {
|
let conn = Self {
|
||||||
id,
|
id,
|
||||||
device_id: config.device_id.clone(),
|
device_id: config.device_id.clone(),
|
||||||
@@ -238,6 +243,7 @@ impl Connection {
|
|||||||
last_test_delay_sent: None,
|
last_test_delay_sent: None,
|
||||||
last_caps_lock: false,
|
last_caps_lock: false,
|
||||||
relative_mouse_active: false,
|
relative_mouse_active: false,
|
||||||
|
configured_codec,
|
||||||
};
|
};
|
||||||
|
|
||||||
(conn, rx)
|
(conn, rx)
|
||||||
@@ -628,43 +634,29 @@ impl Connection {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Negotiate video codec - select the best available encoder
|
/// Negotiate video codec from the server-configured RustDesk codec.
|
||||||
/// Priority: H264 > H265 > VP8 > VP9 (H264/H265 leverage hardware encoding on embedded devices)
|
|
||||||
async fn negotiate_video_codec(&self) -> VideoEncoderType {
|
async fn negotiate_video_codec(&self) -> VideoEncoderType {
|
||||||
let registry = EncoderRegistry::global();
|
let registry = EncoderRegistry::global();
|
||||||
let constraints = self.current_codec_constraints().await;
|
let constraints = self.current_codec_constraints().await;
|
||||||
|
let configured = self.configured_codec;
|
||||||
|
|
||||||
// Check availability in priority order
|
if !constraints.is_webrtc_codec_allowed(encoder_codec_to_video_codec(configured)) {
|
||||||
// H264 is preferred because it has the best hardware encoder support (RKMPP, VAAPI, etc.)
|
|
||||||
// and most RustDesk clients support H264 hardware decoding
|
|
||||||
if constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H264)
|
|
||||||
&& registry.is_codec_available(VideoEncoderType::H264)
|
|
||||||
{
|
|
||||||
return VideoEncoderType::H264;
|
|
||||||
}
|
|
||||||
if constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H265)
|
|
||||||
&& registry.is_codec_available(VideoEncoderType::H265)
|
|
||||||
{
|
|
||||||
return VideoEncoderType::H265;
|
|
||||||
}
|
|
||||||
if constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::VP8)
|
|
||||||
&& registry.is_codec_available(VideoEncoderType::VP8)
|
|
||||||
{
|
|
||||||
return VideoEncoderType::VP8;
|
|
||||||
}
|
|
||||||
if constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::VP9)
|
|
||||||
&& registry.is_codec_available(VideoEncoderType::VP9)
|
|
||||||
{
|
|
||||||
return VideoEncoderType::VP9;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to preferred allowed codec
|
|
||||||
let preferred = constraints.preferred_webrtc_codec();
|
|
||||||
warn!(
|
warn!(
|
||||||
"No allowed encoder available in priority order, falling back to {}",
|
"Configured RustDesk codec {} is blocked by constraints: {}",
|
||||||
encoder_codec_to_id(video_codec_to_encoder_codec(preferred))
|
encoder_codec_to_id(configured),
|
||||||
|
constraints.reason
|
||||||
);
|
);
|
||||||
video_codec_to_encoder_codec(preferred)
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !registry.is_codec_available(configured) {
|
||||||
|
warn!(
|
||||||
|
"Configured RustDesk codec {} is not reported available; attempting to use it anyway",
|
||||||
|
encoder_codec_to_id(configured)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
configured
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn current_codec_constraints(
|
async fn current_codec_constraints(
|
||||||
@@ -740,53 +732,10 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if client sent supported_decoding with a codec preference
|
// Codec switching is locked to the server-configured RustDesk codec.
|
||||||
if let Some(supported_decoding) = opt.supported_decoding.as_ref() {
|
if let Some(supported_decoding) = opt.supported_decoding.as_ref() {
|
||||||
let prefer = supported_decoding.prefer.value();
|
let prefer = supported_decoding.prefer.value();
|
||||||
debug!("Client codec preference: prefer={}", prefer);
|
debug!("Client codec preference: prefer={}", prefer);
|
||||||
|
|
||||||
// Map RustDesk PreferCodec enum to our VideoEncoderType
|
|
||||||
// From proto: Auto=0, VP9=1, H264=2, H265=3, VP8=4, AV1=5
|
|
||||||
let requested_codec = match prefer {
|
|
||||||
1 => Some(VideoEncoderType::VP9),
|
|
||||||
2 => Some(VideoEncoderType::H264),
|
|
||||||
3 => Some(VideoEncoderType::H265),
|
|
||||||
4 => Some(VideoEncoderType::VP8),
|
|
||||||
// Auto(0) or AV1(5) or unknown: use current or negotiate
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(new_codec) = requested_codec {
|
|
||||||
// Check if this codec is different from current and available
|
|
||||||
if self.negotiated_codec != Some(new_codec) {
|
|
||||||
let constraints = self.current_codec_constraints().await;
|
|
||||||
if !constraints.is_webrtc_codec_allowed(encoder_codec_to_video_codec(new_codec))
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
"Client requested codec {:?} but it's blocked by constraints: {}",
|
|
||||||
new_codec, constraints.reason
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let registry = EncoderRegistry::global();
|
|
||||||
if registry.is_codec_available(new_codec) {
|
|
||||||
info!(
|
|
||||||
"Client requested codec switch: {:?} -> {:?}",
|
|
||||||
self.negotiated_codec, new_codec
|
|
||||||
);
|
|
||||||
// Switch codec
|
|
||||||
if let Err(e) = self.switch_video_codec(new_codec).await {
|
|
||||||
warn!("Failed to switch video codec: {}", e);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
warn!(
|
|
||||||
"Client requested codec {:?} but it's not available",
|
|
||||||
new_codec
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log custom_image_quality (accept but don't process)
|
// Log custom_image_quality (accept but don't process)
|
||||||
@@ -803,31 +752,6 @@ impl Connection {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switch video codec dynamically
|
|
||||||
/// Stops current video task, changes codec, and restarts
|
|
||||||
async fn switch_video_codec(&mut self, new_codec: VideoEncoderType) -> anyhow::Result<()> {
|
|
||||||
// Stop current video streaming task
|
|
||||||
if let Some(task) = self.video_task.take() {
|
|
||||||
info!("Stopping video task for codec switch");
|
|
||||||
task.abort();
|
|
||||||
// Wait a bit for cleanup
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update negotiated codec
|
|
||||||
self.negotiated_codec = Some(new_codec);
|
|
||||||
|
|
||||||
// Restart video streaming with new codec if we have a video_tx
|
|
||||||
if let Some(ref video_tx) = self.video_frame_tx {
|
|
||||||
info!("Restarting video streaming with codec {:?}", new_codec);
|
|
||||||
self.start_video_streaming(video_tx.clone());
|
|
||||||
} else {
|
|
||||||
warn!("No video_tx available, cannot restart video streaming");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start video streaming task
|
/// Start video streaming task
|
||||||
fn start_video_streaming(&mut self, video_tx: mpsc::Sender<Bytes>) {
|
fn start_video_streaming(&mut self, video_tx: mpsc::Sender<Bytes>) {
|
||||||
let video_manager = match &self.video_manager {
|
let video_manager = match &self.video_manager {
|
||||||
@@ -1105,18 +1029,15 @@ impl Connection {
|
|||||||
let constraints = self.current_codec_constraints().await;
|
let constraints = self.current_codec_constraints().await;
|
||||||
|
|
||||||
// Check which encoders are available (include software fallback)
|
// Check which encoders are available (include software fallback)
|
||||||
let h264_available = constraints
|
let configured = self.configured_codec;
|
||||||
.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H264)
|
let h264_available = configured == VideoEncoderType::H264
|
||||||
|
&& constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H264)
|
||||||
&& registry.is_codec_available(VideoEncoderType::H264);
|
&& registry.is_codec_available(VideoEncoderType::H264);
|
||||||
let h265_available = constraints
|
let h265_available = configured == VideoEncoderType::H265
|
||||||
.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H265)
|
&& constraints.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::H265)
|
||||||
&& registry.is_codec_available(VideoEncoderType::H265);
|
&& registry.is_codec_available(VideoEncoderType::H265);
|
||||||
let vp8_available = constraints
|
let vp8_available = false;
|
||||||
.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::VP8)
|
let vp9_available = false;
|
||||||
&& registry.is_codec_available(VideoEncoderType::VP8);
|
|
||||||
let vp9_available = constraints
|
|
||||||
.is_webrtc_codec_allowed(crate::video::codec::VideoCodecType::VP9)
|
|
||||||
&& registry.is_codec_available(VideoEncoderType::VP9);
|
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Server encoding capabilities: H264={}, H265={}, VP8={}, VP9={}",
|
"Server encoding capabilities: H264={}, H265={}, VP8={}, VP9={}",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use crate::rtsp::RtspService;
|
|||||||
use crate::rustdesk::RustDeskService;
|
use crate::rustdesk::RustDeskService;
|
||||||
use crate::update::UpdateService;
|
use crate::update::UpdateService;
|
||||||
use crate::video::VideoStreamManager;
|
use crate::video::VideoStreamManager;
|
||||||
|
use crate::vnc::VncService;
|
||||||
use crate::webrtc::WebRtcStreamer;
|
use crate::webrtc::WebRtcStreamer;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -30,6 +31,7 @@ pub struct ConfigApplyLocks {
|
|||||||
pub audio: Arc<Mutex<()>>,
|
pub audio: Arc<Mutex<()>>,
|
||||||
pub atx: Arc<Mutex<()>>,
|
pub atx: Arc<Mutex<()>>,
|
||||||
pub rustdesk: Arc<Mutex<()>>,
|
pub rustdesk: Arc<Mutex<()>>,
|
||||||
|
pub vnc: Arc<Mutex<()>>,
|
||||||
pub rtsp: Arc<Mutex<()>>,
|
pub rtsp: Arc<Mutex<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ impl ConfigApplyLocks {
|
|||||||
audio: Arc::new(Mutex::new(())),
|
audio: Arc::new(Mutex::new(())),
|
||||||
atx: Arc::new(Mutex::new(())),
|
atx: Arc::new(Mutex::new(())),
|
||||||
rustdesk: Arc::new(Mutex::new(())),
|
rustdesk: Arc::new(Mutex::new(())),
|
||||||
|
vnc: Arc::new(Mutex::new(())),
|
||||||
rtsp: Arc::new(Mutex::new(())),
|
rtsp: Arc::new(Mutex::new(())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,6 +72,7 @@ pub struct AppState {
|
|||||||
pub atx: Arc<RwLock<Option<AtxController>>>,
|
pub atx: Arc<RwLock<Option<AtxController>>>,
|
||||||
pub audio: Arc<AudioController>,
|
pub audio: Arc<AudioController>,
|
||||||
pub rustdesk: Arc<RwLock<Option<Arc<RustDeskService>>>>,
|
pub rustdesk: Arc<RwLock<Option<Arc<RustDeskService>>>>,
|
||||||
|
pub vnc: Arc<RwLock<Option<Arc<VncService>>>>,
|
||||||
pub rtsp: Arc<RwLock<Option<Arc<RtspService>>>>,
|
pub rtsp: Arc<RwLock<Option<Arc<RtspService>>>>,
|
||||||
pub extensions: Arc<ExtensionManager>,
|
pub extensions: Arc<ExtensionManager>,
|
||||||
pub events: Arc<EventBus>,
|
pub events: Arc<EventBus>,
|
||||||
@@ -95,6 +99,7 @@ impl AppState {
|
|||||||
atx: Option<AtxController>,
|
atx: Option<AtxController>,
|
||||||
audio: Arc<AudioController>,
|
audio: Arc<AudioController>,
|
||||||
rustdesk: Option<Arc<RustDeskService>>,
|
rustdesk: Option<Arc<RustDeskService>>,
|
||||||
|
vnc: Option<Arc<VncService>>,
|
||||||
rtsp: Option<Arc<RtspService>>,
|
rtsp: Option<Arc<RtspService>>,
|
||||||
extensions: Arc<ExtensionManager>,
|
extensions: Arc<ExtensionManager>,
|
||||||
events: Arc<EventBus>,
|
events: Arc<EventBus>,
|
||||||
@@ -119,6 +124,7 @@ impl AppState {
|
|||||||
atx: Arc::new(RwLock::new(atx)),
|
atx: Arc::new(RwLock::new(atx)),
|
||||||
audio,
|
audio,
|
||||||
rustdesk: Arc::new(RwLock::new(rustdesk)),
|
rustdesk: Arc::new(RwLock::new(rustdesk)),
|
||||||
|
vnc: Arc::new(RwLock::new(vnc)),
|
||||||
rtsp: Arc::new(RwLock::new(rtsp)),
|
rtsp: Arc::new(RwLock::new(rtsp)),
|
||||||
extensions,
|
extensions,
|
||||||
events,
|
events,
|
||||||
|
|||||||
@@ -396,15 +396,29 @@ impl MjpegStreamHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn disconnect_all_clients(&self) {
|
pub fn disconnect_all_clients(&self) {
|
||||||
|
self.disconnect_clients_matching(|_| true);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn disconnect_non_vnc_clients(&self) {
|
||||||
|
self.disconnect_clients_matching(|id| !id.starts_with("vnc-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disconnect_clients_matching(&self, should_disconnect: impl Fn(&str) -> bool) {
|
||||||
let count = {
|
let count = {
|
||||||
let mut clients = self.clients.write();
|
let mut clients = self.clients.write();
|
||||||
let count = clients.len();
|
let before = clients.len();
|
||||||
clients.clear();
|
clients.retain(|id, _| !should_disconnect(id));
|
||||||
count
|
before - clients.len()
|
||||||
};
|
};
|
||||||
|
let remaining = self.client_count();
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
info!("Disconnected all {} MJPEG clients for config change", count);
|
info!(
|
||||||
|
"Disconnected {} MJPEG clients for config change (remaining: {})",
|
||||||
|
count, remaining
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
// Wake all subscribers. HTTP MJPEG clients will close, while persistent
|
||||||
|
// consumers such as VNC wait for the next frame after capture restarts.
|
||||||
self.set_offline();
|
self.set_offline();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::config::{AppConfig, RtspCodec, StreamMode};
|
use crate::config::{AppConfig, RtspCodec, StreamMode, VncEncoding};
|
||||||
use crate::error::Result;
|
use crate::error::{AppError, Result};
|
||||||
|
use crate::rustdesk::config::RustDeskCodec;
|
||||||
use crate::video::codec::registry::VideoEncoderType;
|
use crate::video::codec::registry::VideoEncoderType;
|
||||||
use crate::video::codec::VideoCodecType;
|
use crate::video::codec::VideoCodecType;
|
||||||
use crate::video::VideoStreamManager;
|
use crate::video::VideoStreamManager;
|
||||||
@@ -9,6 +10,7 @@ use std::sync::Arc;
|
|||||||
pub struct StreamCodecConstraints {
|
pub struct StreamCodecConstraints {
|
||||||
pub rustdesk_enabled: bool,
|
pub rustdesk_enabled: bool,
|
||||||
pub rtsp_enabled: bool,
|
pub rtsp_enabled: bool,
|
||||||
|
pub vnc_enabled: bool,
|
||||||
pub allowed_webrtc_codecs: Vec<VideoCodecType>,
|
pub allowed_webrtc_codecs: Vec<VideoCodecType>,
|
||||||
pub allow_mjpeg: bool,
|
pub allow_mjpeg: bool,
|
||||||
pub locked_codec: Option<VideoCodecType>,
|
pub locked_codec: Option<VideoCodecType>,
|
||||||
@@ -21,11 +23,37 @@ pub struct ConstraintEnforcementResult {
|
|||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ThirdPartyCodecLock {
|
||||||
|
H26x(VideoCodecType),
|
||||||
|
Mjpeg,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThirdPartyCodecLock {
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ThirdPartyCodecLock::H26x(codec) => codec_to_id(codec),
|
||||||
|
ThirdPartyCodecLock::Mjpeg => "mjpeg",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compatible_with(self, other: Self) -> bool {
|
||||||
|
self == other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct ThirdPartySourceLock {
|
||||||
|
source: &'static str,
|
||||||
|
lock: ThirdPartyCodecLock,
|
||||||
|
}
|
||||||
|
|
||||||
impl StreamCodecConstraints {
|
impl StreamCodecConstraints {
|
||||||
pub fn unrestricted() -> Self {
|
pub fn unrestricted() -> Self {
|
||||||
Self {
|
Self {
|
||||||
rustdesk_enabled: false,
|
rustdesk_enabled: false,
|
||||||
rtsp_enabled: false,
|
rtsp_enabled: false,
|
||||||
|
vnc_enabled: false,
|
||||||
allowed_webrtc_codecs: vec![
|
allowed_webrtc_codecs: vec![
|
||||||
VideoCodecType::H264,
|
VideoCodecType::H264,
|
||||||
VideoCodecType::H265,
|
VideoCodecType::H265,
|
||||||
@@ -41,42 +69,39 @@ impl StreamCodecConstraints {
|
|||||||
pub fn from_config(config: &AppConfig) -> Self {
|
pub fn from_config(config: &AppConfig) -> Self {
|
||||||
let rustdesk_enabled = config.rustdesk.enabled;
|
let rustdesk_enabled = config.rustdesk.enabled;
|
||||||
let rtsp_enabled = config.rtsp.enabled;
|
let rtsp_enabled = config.rtsp.enabled;
|
||||||
|
let vnc_enabled = config.vnc.enabled;
|
||||||
|
|
||||||
if rtsp_enabled {
|
let locks = third_party_locks(config);
|
||||||
let locked_codec = match config.rtsp.codec {
|
if let Some(first) = locks.first() {
|
||||||
RtspCodec::H264 => VideoCodecType::H264,
|
let sources = locks
|
||||||
RtspCodec::H265 => VideoCodecType::H265,
|
.iter()
|
||||||
};
|
.map(|item| item.source)
|
||||||
return Self {
|
.collect::<Vec<_>>()
|
||||||
|
.join("/");
|
||||||
|
let reason = format!(
|
||||||
|
"{} enabled with codec lock ({})",
|
||||||
|
sources,
|
||||||
|
first.lock.label()
|
||||||
|
);
|
||||||
|
return match first.lock {
|
||||||
|
ThirdPartyCodecLock::H26x(codec) => Self {
|
||||||
rustdesk_enabled,
|
rustdesk_enabled,
|
||||||
rtsp_enabled,
|
rtsp_enabled,
|
||||||
allowed_webrtc_codecs: vec![locked_codec],
|
vnc_enabled,
|
||||||
|
allowed_webrtc_codecs: vec![codec],
|
||||||
allow_mjpeg: false,
|
allow_mjpeg: false,
|
||||||
locked_codec: Some(locked_codec),
|
locked_codec: Some(codec),
|
||||||
reason: if rustdesk_enabled {
|
reason,
|
||||||
format!(
|
|
||||||
"RTSP enabled with codec lock ({:?}) and RustDesk enabled",
|
|
||||||
locked_codec
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!("RTSP enabled with codec lock ({:?})", locked_codec)
|
|
||||||
},
|
},
|
||||||
};
|
ThirdPartyCodecLock::Mjpeg => Self {
|
||||||
}
|
|
||||||
|
|
||||||
if rustdesk_enabled {
|
|
||||||
return Self {
|
|
||||||
rustdesk_enabled,
|
rustdesk_enabled,
|
||||||
rtsp_enabled,
|
rtsp_enabled,
|
||||||
allowed_webrtc_codecs: vec![
|
vnc_enabled,
|
||||||
VideoCodecType::H264,
|
allowed_webrtc_codecs: vec![],
|
||||||
VideoCodecType::H265,
|
allow_mjpeg: true,
|
||||||
VideoCodecType::VP8,
|
|
||||||
VideoCodecType::VP9,
|
|
||||||
],
|
|
||||||
allow_mjpeg: false,
|
|
||||||
locked_codec: None,
|
locked_codec: None,
|
||||||
reason: "RustDesk enabled, MJPEG disabled".to_string(),
|
reason,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +138,87 @@ impl StreamCodecConstraints {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn rustdesk_codec_to_video(codec: RustDeskCodec) -> VideoCodecType {
|
||||||
|
match codec {
|
||||||
|
RustDeskCodec::H264 => VideoCodecType::H264,
|
||||||
|
RustDeskCodec::H265 => VideoCodecType::H265,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rtsp_codec_to_video_codec(codec: RtspCodec) -> VideoCodecType {
|
||||||
|
match codec {
|
||||||
|
RtspCodec::H264 => VideoCodecType::H264,
|
||||||
|
RtspCodec::H265 => VideoCodecType::H265,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn vnc_encoding_to_video_codec(encoding: VncEncoding) -> Option<VideoCodecType> {
|
||||||
|
match encoding {
|
||||||
|
VncEncoding::TightJpeg => None,
|
||||||
|
VncEncoding::H264 => Some(VideoCodecType::H264),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rustdesk_lock(config: &AppConfig) -> Option<ThirdPartySourceLock> {
|
||||||
|
if config.rustdesk.enabled {
|
||||||
|
return Some(ThirdPartySourceLock {
|
||||||
|
source: "RustDesk",
|
||||||
|
lock: ThirdPartyCodecLock::H26x(rustdesk_codec_to_video(config.rustdesk.codec)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rtsp_lock(config: &AppConfig) -> Option<ThirdPartySourceLock> {
|
||||||
|
if config.rtsp.enabled {
|
||||||
|
return Some(ThirdPartySourceLock {
|
||||||
|
source: "RTSP",
|
||||||
|
lock: ThirdPartyCodecLock::H26x(rtsp_codec_to_video_codec(config.rtsp.codec.clone())),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vnc_lock(config: &AppConfig) -> Option<ThirdPartySourceLock> {
|
||||||
|
if config.vnc.enabled {
|
||||||
|
let lock = match config.vnc.encoding {
|
||||||
|
VncEncoding::TightJpeg => ThirdPartyCodecLock::Mjpeg,
|
||||||
|
VncEncoding::H264 => ThirdPartyCodecLock::H26x(VideoCodecType::H264),
|
||||||
|
};
|
||||||
|
return Some(ThirdPartySourceLock {
|
||||||
|
source: "VNC",
|
||||||
|
lock,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn third_party_locks(config: &AppConfig) -> Vec<ThirdPartySourceLock> {
|
||||||
|
[rustdesk_lock(config), rtsp_lock(config), vnc_lock(config)]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_third_party_codec_compatibility(config: &AppConfig) -> Result<()> {
|
||||||
|
let locks = third_party_locks(config);
|
||||||
|
if let Some(first) = locks.first() {
|
||||||
|
for item in locks.iter().skip(1) {
|
||||||
|
if !first.lock.compatible_with(item.lock) {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"{} codec {} conflicts with {} codec {}; choose a compatible codec or stop the running service first",
|
||||||
|
item.source,
|
||||||
|
item.lock.label(),
|
||||||
|
first.source,
|
||||||
|
first.lock.label()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn enforce_constraints_with_stream_manager(
|
pub async fn enforce_constraints_with_stream_manager(
|
||||||
stream_manager: &Arc<VideoStreamManager>,
|
stream_manager: &Arc<VideoStreamManager>,
|
||||||
constraints: &StreamCodecConstraints,
|
constraints: &StreamCodecConstraints,
|
||||||
@@ -135,6 +241,16 @@ pub async fn enforce_constraints_with_stream_manager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if current_mode == StreamMode::WebRTC {
|
if current_mode == StreamMode::WebRTC {
|
||||||
|
if constraints.allow_mjpeg && constraints.allowed_webrtc_codecs.is_empty() {
|
||||||
|
let _ = stream_manager
|
||||||
|
.switch_mode_transaction(StreamMode::Mjpeg)
|
||||||
|
.await?;
|
||||||
|
return Ok(ConstraintEnforcementResult {
|
||||||
|
changed: true,
|
||||||
|
message: Some("Auto-switched from WebRTC to MJPEG due to codec lock".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let current_codec = stream_manager.current_video_codec().await;
|
let current_codec = stream_manager.current_video_codec().await;
|
||||||
if !constraints.is_webrtc_codec_allowed(current_codec) {
|
if !constraints.is_webrtc_codec_allowed(current_codec) {
|
||||||
let target_codec = constraints.preferred_webrtc_codec();
|
let target_codec = constraints.preferred_webrtc_codec();
|
||||||
|
|||||||
@@ -375,8 +375,8 @@ impl Streamer {
|
|||||||
|
|
||||||
// IMPORTANT: Disconnect all MJPEG clients FIRST before stopping capture
|
// IMPORTANT: Disconnect all MJPEG clients FIRST before stopping capture
|
||||||
// This prevents race conditions where clients try to reconnect and reopen the device
|
// This prevents race conditions where clients try to reconnect and reopen the device
|
||||||
debug!("Disconnecting all MJPEG clients before config change...");
|
debug!("Disconnecting HTTP MJPEG clients before config change...");
|
||||||
self.mjpeg_handler.disconnect_all_clients();
|
self.mjpeg_handler.disconnect_non_vnc_clients();
|
||||||
|
|
||||||
// Give clients time to receive the disconnect signal and close their connections
|
// Give clients time to receive the disconnect signal and close their connections
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|||||||
370
src/vnc/mod.rs
Normal file
370
src/vnc/mod.rs
Normal 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
529
src/vnc/rfb.rs
Normal 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
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ use crate::rtsp::RtspService;
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::stream_encoder::encoder_type_to_backend;
|
use crate::stream_encoder::encoder_type_to_backend;
|
||||||
use crate::video::codec_constraints::{
|
use crate::video::codec_constraints::{
|
||||||
enforce_constraints_with_stream_manager, StreamCodecConstraints,
|
enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility,
|
||||||
|
StreamCodecConstraints,
|
||||||
};
|
};
|
||||||
use tokio::sync::{Mutex, OwnedMutexGuard};
|
use tokio::sync::{Mutex, OwnedMutexGuard};
|
||||||
|
|
||||||
@@ -409,6 +410,27 @@ pub async fn enforce_stream_codec_constraints(state: &Arc<AppState>) -> Result<O
|
|||||||
Ok(enforcement.message)
|
Ok(enforcement.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_rustdesk_candidate(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
new_config: &crate::rustdesk::config::RustDeskConfig,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut candidate = state.config.get().as_ref().clone();
|
||||||
|
candidate.rustdesk = new_config.clone();
|
||||||
|
validate_third_party_codec_compatibility(&candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_vnc_candidate(state: &Arc<AppState>, new_config: &VncConfig) -> Result<()> {
|
||||||
|
let mut candidate = state.config.get().as_ref().clone();
|
||||||
|
candidate.vnc = new_config.clone();
|
||||||
|
validate_third_party_codec_compatibility(&candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_rtsp_candidate(state: &Arc<AppState>, new_config: &RtspConfig) -> Result<()> {
|
||||||
|
let mut candidate = state.config.get().as_ref().clone();
|
||||||
|
candidate.rtsp = new_config.clone();
|
||||||
|
validate_third_party_codec_compatibility(&candidate)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn apply_rustdesk_config(
|
pub async fn apply_rustdesk_config(
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
old_config: &crate::rustdesk::config::RustDeskConfig,
|
old_config: &crate::rustdesk::config::RustDeskConfig,
|
||||||
@@ -417,6 +439,8 @@ pub async fn apply_rustdesk_config(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
tracing::info!("Applying RustDesk config changes...");
|
tracing::info!("Applying RustDesk config changes...");
|
||||||
|
|
||||||
|
validate_rustdesk_candidate(state, new_config)?;
|
||||||
|
|
||||||
let mut rustdesk_guard = state.rustdesk.write().await;
|
let mut rustdesk_guard = state.rustdesk.write().await;
|
||||||
let mut credentials_to_save = None;
|
let mut credentials_to_save = None;
|
||||||
|
|
||||||
@@ -433,6 +457,7 @@ pub async fn apply_rustdesk_config(
|
|||||||
|
|
||||||
if new_config.enabled {
|
if new_config.enabled {
|
||||||
let need_restart = options.force
|
let need_restart = options.force
|
||||||
|
|| old_config.codec != new_config.codec
|
||||||
|| old_config.rendezvous_server != new_config.rendezvous_server
|
|| old_config.rendezvous_server != new_config.rendezvous_server
|
||||||
|| old_config.device_id != new_config.device_id
|
|| old_config.device_id != new_config.device_id
|
||||||
|| old_config.device_password != new_config.device_password;
|
|| old_config.device_password != new_config.device_password;
|
||||||
@@ -488,6 +513,77 @@ pub async fn apply_rustdesk_config(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn apply_vnc_config(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
old_config: &VncConfig,
|
||||||
|
new_config: &VncConfig,
|
||||||
|
options: ConfigApplyOptions,
|
||||||
|
) -> Result<()> {
|
||||||
|
tracing::info!("Applying VNC config changes...");
|
||||||
|
|
||||||
|
validate_vnc_candidate(state, new_config)?;
|
||||||
|
|
||||||
|
if new_config.enabled {
|
||||||
|
let mut candidate = state.config.get().as_ref().clone();
|
||||||
|
candidate.vnc = new_config.clone();
|
||||||
|
let constraints = StreamCodecConstraints::from_config(&candidate);
|
||||||
|
match enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await {
|
||||||
|
Ok(result) if result.changed => {
|
||||||
|
if let Some(message) = result.message {
|
||||||
|
tracing::info!("{}", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
"Failed to enforce VNC stream constraints before start: {}",
|
||||||
|
e
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut vnc_guard = state.vnc.write().await;
|
||||||
|
|
||||||
|
if !new_config.enabled {
|
||||||
|
if let Some(ref service) = *vnc_guard {
|
||||||
|
service.stop().await?;
|
||||||
|
}
|
||||||
|
*vnc_guard = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_config.enabled {
|
||||||
|
let need_restart = options.force
|
||||||
|
|| old_config.bind != new_config.bind
|
||||||
|
|| old_config.port != new_config.port
|
||||||
|
|| old_config.encoding != new_config.encoding
|
||||||
|
|| old_config.password != new_config.password
|
||||||
|
|| old_config.jpeg_quality != new_config.jpeg_quality
|
||||||
|
|| old_config.allow_one_client != new_config.allow_one_client;
|
||||||
|
|
||||||
|
if vnc_guard.is_none() {
|
||||||
|
let service = crate::vnc::VncService::new(
|
||||||
|
new_config.clone(),
|
||||||
|
state.stream_manager.clone(),
|
||||||
|
state.hid.clone(),
|
||||||
|
);
|
||||||
|
service.start().await?;
|
||||||
|
*vnc_guard = Some(Arc::new(service));
|
||||||
|
tracing::info!("VNC service started");
|
||||||
|
} else if need_restart {
|
||||||
|
if let Some(ref service) = *vnc_guard {
|
||||||
|
service.restart(new_config.clone()).await?;
|
||||||
|
tracing::info!("VNC service restarted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(vnc_guard);
|
||||||
|
if let Some(message) = enforce_stream_codec_constraints(state).await? {
|
||||||
|
tracing::info!("{}", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn apply_rtsp_config(
|
pub async fn apply_rtsp_config(
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
old_config: &RtspConfig,
|
old_config: &RtspConfig,
|
||||||
@@ -496,6 +592,8 @@ pub async fn apply_rtsp_config(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
tracing::info!("Applying RTSP config changes...");
|
tracing::info!("Applying RTSP config changes...");
|
||||||
|
|
||||||
|
validate_rtsp_candidate(state, new_config)?;
|
||||||
|
|
||||||
let mut rtsp_guard = state.rtsp.write().await;
|
let mut rtsp_guard = state.rtsp.write().await;
|
||||||
|
|
||||||
if !new_config.enabled {
|
if !new_config.enabled {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ mod rtsp;
|
|||||||
mod rustdesk;
|
mod rustdesk;
|
||||||
mod stream;
|
mod stream;
|
||||||
pub(crate) mod video;
|
pub(crate) mod video;
|
||||||
|
mod vnc;
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
pub use atx::{get_atx_config, update_atx_config};
|
pub use atx::{get_atx_config, update_atx_config};
|
||||||
@@ -31,6 +32,9 @@ pub use rustdesk::{
|
|||||||
};
|
};
|
||||||
pub use stream::{get_stream_config, update_stream_config};
|
pub use stream::{get_stream_config, update_stream_config};
|
||||||
pub use video::{get_video_config, update_video_config};
|
pub use video::{get_video_config, update_video_config};
|
||||||
|
pub use vnc::{
|
||||||
|
get_vnc_config, get_vnc_status, start_vnc_service, stop_vnc_service, update_vnc_config,
|
||||||
|
};
|
||||||
pub use web::{get_web_config, update_web_config};
|
pub use web::{get_web_config, update_web_config};
|
||||||
|
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
@@ -52,6 +56,7 @@ fn sanitize_config_for_api(config: &mut AppConfig) {
|
|||||||
config.rustdesk.signing_private_key = None;
|
config.rustdesk.signing_private_key = None;
|
||||||
|
|
||||||
config.rtsp.password = None;
|
config.rtsp.password = None;
|
||||||
|
config.vnc.password = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_all_config(State(state): State<Arc<AppState>>) -> Json<AppConfig> {
|
pub async fn get_all_config(State(state): State<Arc<AppState>>) -> Json<AppConfig> {
|
||||||
|
|||||||
@@ -7,6 +7,44 @@ use crate::state::AppState;
|
|||||||
use super::apply::{apply_rtsp_config, try_apply_lock, ConfigApplyOptions};
|
use super::apply::{apply_rtsp_config, try_apply_lock, ConfigApplyOptions};
|
||||||
use super::types::{RtspConfigResponse, RtspConfigUpdate, RtspStatusResponse};
|
use super::types::{RtspConfigResponse, RtspConfigUpdate, RtspStatusResponse};
|
||||||
|
|
||||||
|
fn validate_candidate(state: &Arc<AppState>, config: &crate::config::RtspConfig) -> Result<()> {
|
||||||
|
let mut candidate = state.config.get().as_ref().clone();
|
||||||
|
candidate.rtsp = config.clone();
|
||||||
|
crate::video::codec_constraints::validate_third_party_codec_compatibility(&candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_and_apply(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
old_config: crate::config::RtspConfig,
|
||||||
|
new_config: crate::config::RtspConfig,
|
||||||
|
) -> Result<crate::config::RtspConfig> {
|
||||||
|
validate_candidate(state, &new_config)?;
|
||||||
|
state
|
||||||
|
.config
|
||||||
|
.update(|config| {
|
||||||
|
config.rtsp = new_config.clone();
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let stored_config = state.config.get().rtsp.clone();
|
||||||
|
apply_rtsp_config(
|
||||||
|
state,
|
||||||
|
&old_config,
|
||||||
|
&stored_config,
|
||||||
|
ConfigApplyOptions::forced(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(stored_config)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn current_status(state: &Arc<AppState>) -> crate::rtsp::RtspServiceStatus {
|
||||||
|
let guard = state.rtsp.read().await;
|
||||||
|
if let Some(ref service) = *guard {
|
||||||
|
service.status().await
|
||||||
|
} else {
|
||||||
|
crate::rtsp::RtspServiceStatus::Stopped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_rtsp_config(State(state): State<Arc<AppState>>) -> Json<RtspConfigResponse> {
|
pub async fn get_rtsp_config(State(state): State<Arc<AppState>>) -> Json<RtspConfigResponse> {
|
||||||
let config = state.config.get();
|
let config = state.config.get();
|
||||||
Json(RtspConfigResponse::from(&config.rtsp))
|
Json(RtspConfigResponse::from(&config.rtsp))
|
||||||
@@ -14,14 +52,7 @@ pub async fn get_rtsp_config(State(state): State<Arc<AppState>>) -> Json<RtspCon
|
|||||||
|
|
||||||
pub async fn get_rtsp_status(State(state): State<Arc<AppState>>) -> Json<RtspStatusResponse> {
|
pub async fn get_rtsp_status(State(state): State<Arc<AppState>>) -> Json<RtspStatusResponse> {
|
||||||
let config = state.config.get().rtsp.clone();
|
let config = state.config.get().rtsp.clone();
|
||||||
let status = {
|
let status = current_status(&state).await;
|
||||||
let guard = state.rtsp.read().await;
|
|
||||||
if let Some(ref service) = *guard {
|
|
||||||
service.status().await
|
|
||||||
} else {
|
|
||||||
crate::rtsp::RtspServiceStatus::Stopped
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Json(RtspStatusResponse::new(&config, status))
|
Json(RtspStatusResponse::new(&config, status))
|
||||||
}
|
}
|
||||||
@@ -34,22 +65,9 @@ pub async fn update_rtsp_config(
|
|||||||
|
|
||||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?;
|
let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?;
|
||||||
let old_config = state.config.get().rtsp.clone();
|
let old_config = state.config.get().rtsp.clone();
|
||||||
|
let mut merged_config = old_config.clone();
|
||||||
state
|
req.apply_to(&mut merged_config);
|
||||||
.config
|
let new_config = persist_and_apply(&state, old_config, merged_config).await?;
|
||||||
.update(|config| {
|
|
||||||
req.apply_to(&mut config.rtsp);
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let new_config = state.config.get().rtsp.clone();
|
|
||||||
apply_rtsp_config(
|
|
||||||
&state,
|
|
||||||
&old_config,
|
|
||||||
&new_config,
|
|
||||||
ConfigApplyOptions::forced(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(RtspConfigResponse::from(&new_config)))
|
Ok(Json(RtspConfigResponse::from(&new_config)))
|
||||||
}
|
}
|
||||||
@@ -61,25 +79,10 @@ pub async fn start_rtsp_service(
|
|||||||
let current_config = state.config.get().rtsp.clone();
|
let current_config = state.config.get().rtsp.clone();
|
||||||
let mut start_config = current_config.clone();
|
let mut start_config = current_config.clone();
|
||||||
start_config.enabled = true;
|
start_config.enabled = true;
|
||||||
|
let stored_config = persist_and_apply(&state, current_config, start_config).await?;
|
||||||
|
let status = current_status(&state).await;
|
||||||
|
|
||||||
apply_rtsp_config(
|
Ok(Json(RtspStatusResponse::new(&stored_config, status)))
|
||||||
&state,
|
|
||||||
¤t_config,
|
|
||||||
&start_config,
|
|
||||||
ConfigApplyOptions::forced(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let status = {
|
|
||||||
let guard = state.rtsp.read().await;
|
|
||||||
if let Some(ref service) = *guard {
|
|
||||||
service.status().await
|
|
||||||
} else {
|
|
||||||
crate::rtsp::RtspServiceStatus::Stopped
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(RtspStatusResponse::new(¤t_config, status)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop_rtsp_service(
|
pub async fn stop_rtsp_service(
|
||||||
@@ -90,22 +93,8 @@ pub async fn stop_rtsp_service(
|
|||||||
let mut stop_config = current_config.clone();
|
let mut stop_config = current_config.clone();
|
||||||
stop_config.enabled = false;
|
stop_config.enabled = false;
|
||||||
|
|
||||||
apply_rtsp_config(
|
let stored_config = persist_and_apply(&state, current_config, stop_config).await?;
|
||||||
&state,
|
let status = current_status(&state).await;
|
||||||
¤t_config,
|
|
||||||
&stop_config,
|
|
||||||
ConfigApplyOptions::forced(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let status = {
|
Ok(Json(RtspStatusResponse::new(&stored_config, status)))
|
||||||
let guard = state.rtsp.read().await;
|
|
||||||
if let Some(ref service) = *guard {
|
|
||||||
service.status().await
|
|
||||||
} else {
|
|
||||||
crate::rtsp::RtspServiceStatus::Stopped
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(RtspStatusResponse::new(¤t_config, status)))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,58 @@ use crate::state::AppState;
|
|||||||
use super::apply::{apply_rustdesk_config, try_apply_lock, ConfigApplyOptions};
|
use super::apply::{apply_rustdesk_config, try_apply_lock, ConfigApplyOptions};
|
||||||
use super::types::RustDeskConfigUpdate;
|
use super::types::RustDeskConfigUpdate;
|
||||||
|
|
||||||
|
fn validate_candidate(state: &Arc<AppState>, config: &RustDeskConfig) -> Result<()> {
|
||||||
|
let mut candidate = state.config.get().as_ref().clone();
|
||||||
|
candidate.rustdesk = config.clone();
|
||||||
|
crate::video::codec_constraints::validate_third_party_codec_compatibility(&candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_and_apply(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
old_config: RustDeskConfig,
|
||||||
|
new_config: RustDeskConfig,
|
||||||
|
) -> Result<RustDeskConfig> {
|
||||||
|
validate_candidate(state, &new_config)?;
|
||||||
|
state
|
||||||
|
.config
|
||||||
|
.update(|config| {
|
||||||
|
config.rustdesk = new_config.clone();
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let stored_config = state.config.get().rustdesk.clone();
|
||||||
|
apply_rustdesk_config(
|
||||||
|
state,
|
||||||
|
&old_config,
|
||||||
|
&stored_config,
|
||||||
|
ConfigApplyOptions::forced(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(stored_config)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn current_status(state: &Arc<AppState>, config: RustDeskConfig) -> RustDeskStatusResponse {
|
||||||
|
let (service_status, rendezvous_status) = {
|
||||||
|
let guard = state.rustdesk.read().await;
|
||||||
|
if let Some(ref service) = *guard {
|
||||||
|
let status = format!("{}", service.status());
|
||||||
|
let rv_status = service.rendezvous_status().map(|s| format!("{}", s));
|
||||||
|
(status, rv_status)
|
||||||
|
} else {
|
||||||
|
("not_initialized".to_string(), None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
RustDeskStatusResponse {
|
||||||
|
config: RustDeskConfigResponse::from(&config),
|
||||||
|
service_status,
|
||||||
|
rendezvous_status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
pub struct RustDeskConfigResponse {
|
pub struct RustDeskConfigResponse {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
pub codec: crate::rustdesk::config::RustDeskCodec,
|
||||||
pub rendezvous_server: String,
|
pub rendezvous_server: String,
|
||||||
pub relay_server: Option<String>,
|
pub relay_server: Option<String>,
|
||||||
pub device_id: String,
|
pub device_id: String,
|
||||||
@@ -23,6 +72,7 @@ impl From<&RustDeskConfig> for RustDeskConfigResponse {
|
|||||||
fn from(config: &RustDeskConfig) -> Self {
|
fn from(config: &RustDeskConfig) -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
|
codec: config.codec,
|
||||||
rendezvous_server: config.rendezvous_server.clone(),
|
rendezvous_server: config.rendezvous_server.clone(),
|
||||||
relay_server: config.relay_server.clone(),
|
relay_server: config.relay_server.clone(),
|
||||||
device_id: config.device_id.clone(),
|
device_id: config.device_id.clone(),
|
||||||
@@ -50,23 +100,7 @@ pub async fn get_rustdesk_status(
|
|||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> Json<RustDeskStatusResponse> {
|
) -> Json<RustDeskStatusResponse> {
|
||||||
let config = state.config.get().rustdesk.clone();
|
let config = state.config.get().rustdesk.clone();
|
||||||
|
Json(current_status(&state, config).await)
|
||||||
let (service_status, rendezvous_status) = {
|
|
||||||
let guard = state.rustdesk.read().await;
|
|
||||||
if let Some(ref service) = *guard {
|
|
||||||
let status = format!("{}", service.status());
|
|
||||||
let rv_status = service.rendezvous_status().map(|s| format!("{}", s));
|
|
||||||
(status, rv_status)
|
|
||||||
} else {
|
|
||||||
("not_initialized".to_string(), None)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Json(RustDeskStatusResponse {
|
|
||||||
config: RustDeskConfigResponse::from(&config),
|
|
||||||
service_status,
|
|
||||||
rendezvous_status,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_rustdesk_config(
|
pub async fn update_rustdesk_config(
|
||||||
@@ -81,22 +115,7 @@ pub async fn update_rustdesk_config(
|
|||||||
req.apply_to(&mut merged_config);
|
req.apply_to(&mut merged_config);
|
||||||
req.validate_merged(&merged_config)?;
|
req.validate_merged(&merged_config)?;
|
||||||
|
|
||||||
state
|
let new_config = persist_and_apply(&state, old_config, merged_config).await?;
|
||||||
.config
|
|
||||||
.update(|config| {
|
|
||||||
config.rustdesk = merged_config.clone();
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let new_config = state.config.get().rustdesk.clone();
|
|
||||||
|
|
||||||
apply_rustdesk_config(
|
|
||||||
&state,
|
|
||||||
&old_config,
|
|
||||||
&new_config,
|
|
||||||
ConfigApplyOptions::forced(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let constraints = state.stream_manager.codec_constraints().await;
|
let constraints = state.stream_manager.codec_constraints().await;
|
||||||
if constraints.rustdesk_enabled || constraints.rtsp_enabled {
|
if constraints.rustdesk_enabled || constraints.rtsp_enabled {
|
||||||
@@ -152,31 +171,8 @@ pub async fn start_rustdesk_service(
|
|||||||
let current_config = state.config.get().rustdesk.clone();
|
let current_config = state.config.get().rustdesk.clone();
|
||||||
let mut start_config = current_config.clone();
|
let mut start_config = current_config.clone();
|
||||||
start_config.enabled = true;
|
start_config.enabled = true;
|
||||||
|
let stored_config = persist_and_apply(&state, current_config, start_config).await?;
|
||||||
apply_rustdesk_config(
|
Ok(Json(current_status(&state, stored_config).await))
|
||||||
&state,
|
|
||||||
¤t_config,
|
|
||||||
&start_config,
|
|
||||||
ConfigApplyOptions::forced(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let (service_status, rendezvous_status) = {
|
|
||||||
let guard = state.rustdesk.read().await;
|
|
||||||
if let Some(ref service) = *guard {
|
|
||||||
let status = format!("{}", service.status());
|
|
||||||
let rv_status = service.rendezvous_status().map(|s| format!("{}", s));
|
|
||||||
(status, rv_status)
|
|
||||||
} else {
|
|
||||||
("not_initialized".to_string(), None)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(RustDeskStatusResponse {
|
|
||||||
config: RustDeskConfigResponse::from(¤t_config),
|
|
||||||
service_status,
|
|
||||||
rendezvous_status,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop_rustdesk_service(
|
pub async fn stop_rustdesk_service(
|
||||||
@@ -187,28 +183,6 @@ pub async fn stop_rustdesk_service(
|
|||||||
let mut stop_config = current_config.clone();
|
let mut stop_config = current_config.clone();
|
||||||
stop_config.enabled = false;
|
stop_config.enabled = false;
|
||||||
|
|
||||||
apply_rustdesk_config(
|
let stored_config = persist_and_apply(&state, current_config, stop_config).await?;
|
||||||
&state,
|
Ok(Json(current_status(&state, stored_config).await))
|
||||||
¤t_config,
|
|
||||||
&stop_config,
|
|
||||||
ConfigApplyOptions::forced(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let (service_status, rendezvous_status) = {
|
|
||||||
let guard = state.rustdesk.read().await;
|
|
||||||
if let Some(ref service) = *guard {
|
|
||||||
let status = format!("{}", service.status());
|
|
||||||
let rv_status = service.rendezvous_status().map(|s| format!("{}", s));
|
|
||||||
(status, rv_status)
|
|
||||||
} else {
|
|
||||||
("not_initialized".to_string(), None)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(RustDeskStatusResponse {
|
|
||||||
config: RustDeskConfigResponse::from(¤t_config),
|
|
||||||
service_status,
|
|
||||||
rendezvous_status,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::config::*;
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::rtsp::RtspServiceStatus;
|
use crate::rtsp::RtspServiceStatus;
|
||||||
use crate::rustdesk::config::RustDeskConfig;
|
use crate::rustdesk::config::RustDeskConfig;
|
||||||
|
use crate::vnc::VncServiceStatus;
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -765,6 +766,7 @@ fn validate_rustdesk_relay_key(key: &str) -> Result<(), AppError> {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RustDeskConfigUpdate {
|
pub struct RustDeskConfigUpdate {
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
|
pub codec: Option<crate::rustdesk::config::RustDeskCodec>,
|
||||||
pub rendezvous_server: Option<String>,
|
pub rendezvous_server: Option<String>,
|
||||||
pub relay_server: Option<String>,
|
pub relay_server: Option<String>,
|
||||||
pub relay_key: Option<String>,
|
pub relay_key: Option<String>,
|
||||||
@@ -821,6 +823,9 @@ impl RustDeskConfigUpdate {
|
|||||||
if let Some(enabled) = self.enabled {
|
if let Some(enabled) = self.enabled {
|
||||||
config.enabled = enabled;
|
config.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
if let Some(codec) = self.codec {
|
||||||
|
config.codec = codec;
|
||||||
|
}
|
||||||
if let Some(ref server) = self.rendezvous_server {
|
if let Some(ref server) = self.rendezvous_server {
|
||||||
config.rendezvous_server = server.clone();
|
config.rendezvous_server = server.clone();
|
||||||
}
|
}
|
||||||
@@ -904,6 +909,125 @@ pub struct RtspConfigUpdate {
|
|||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct VncConfigResponse {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub bind: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub encoding: VncEncoding,
|
||||||
|
pub jpeg_quality: u8,
|
||||||
|
pub allow_one_client: bool,
|
||||||
|
pub has_password: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&VncConfig> for VncConfigResponse {
|
||||||
|
fn from(config: &VncConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: config.enabled,
|
||||||
|
bind: config.bind.clone(),
|
||||||
|
port: config.port,
|
||||||
|
encoding: config.encoding.clone(),
|
||||||
|
jpeg_quality: config.jpeg_quality,
|
||||||
|
allow_one_client: config.allow_one_client,
|
||||||
|
has_password: config.password.as_deref().is_some_and(|p| !p.is_empty()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct VncStatusResponse {
|
||||||
|
pub config: VncConfigResponse,
|
||||||
|
pub service_status: String,
|
||||||
|
pub connection_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VncStatusResponse {
|
||||||
|
pub fn new(config: &VncConfig, status: VncServiceStatus, connection_count: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
config: VncConfigResponse::from(config),
|
||||||
|
service_status: status.to_string(),
|
||||||
|
connection_count: connection_count as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct VncConfigUpdate {
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub bind: Option<String>,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
pub encoding: Option<VncEncoding>,
|
||||||
|
pub jpeg_quality: Option<u8>,
|
||||||
|
pub allow_one_client: Option<bool>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VncConfigUpdate {
|
||||||
|
pub fn validate(&self) -> crate::error::Result<()> {
|
||||||
|
if let Some(port) = self.port {
|
||||||
|
if port == 0 {
|
||||||
|
return Err(AppError::BadRequest("VNC port cannot be 0".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref bind) = self.bind {
|
||||||
|
if bind.parse::<std::net::IpAddr>().is_err() {
|
||||||
|
return Err(AppError::BadRequest("VNC bind must be a valid IP".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(quality) = self.jpeg_quality {
|
||||||
|
if !(10..=100).contains(&quality) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"VNC JPEG quality must be 10-100".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref password) = self.password {
|
||||||
|
if !password.is_empty() && password.len() > 8 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"VNCAuth password must be at most 8 characters".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_merged(&self, config: &VncConfig) -> crate::error::Result<()> {
|
||||||
|
if config.enabled && config.password.as_deref().unwrap_or("").is_empty() {
|
||||||
|
return Err(AppError::BadRequest("VNC password is required".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_to(&self, config: &mut VncConfig) {
|
||||||
|
if let Some(enabled) = self.enabled {
|
||||||
|
config.enabled = enabled;
|
||||||
|
}
|
||||||
|
if let Some(ref bind) = self.bind {
|
||||||
|
config.bind = bind.clone();
|
||||||
|
}
|
||||||
|
if let Some(port) = self.port {
|
||||||
|
config.port = port;
|
||||||
|
}
|
||||||
|
if let Some(ref encoding) = self.encoding {
|
||||||
|
config.encoding = encoding.clone();
|
||||||
|
}
|
||||||
|
if let Some(quality) = self.jpeg_quality {
|
||||||
|
config.jpeg_quality = quality;
|
||||||
|
}
|
||||||
|
if let Some(allow_one_client) = self.allow_one_client {
|
||||||
|
config.allow_one_client = allow_one_client;
|
||||||
|
}
|
||||||
|
if let Some(ref password) = self.password {
|
||||||
|
if !password.is_empty() {
|
||||||
|
config.password = Some(password.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl RtspConfigUpdate {
|
impl RtspConfigUpdate {
|
||||||
pub fn validate(&self) -> crate::error::Result<()> {
|
pub fn validate(&self) -> crate::error::Result<()> {
|
||||||
if let Some(port) = self.port {
|
if let Some(port) = self.port {
|
||||||
@@ -1188,6 +1312,7 @@ mod tests {
|
|||||||
fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() {
|
fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() {
|
||||||
let update = RustDeskConfigUpdate {
|
let update = RustDeskConfigUpdate {
|
||||||
enabled: None,
|
enabled: None,
|
||||||
|
codec: None,
|
||||||
rendezvous_server: None,
|
rendezvous_server: None,
|
||||||
relay_server: None,
|
relay_server: None,
|
||||||
relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()),
|
relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()),
|
||||||
@@ -1202,6 +1327,7 @@ mod tests {
|
|||||||
let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string();
|
let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string();
|
||||||
let update = RustDeskConfigUpdate {
|
let update = RustDeskConfigUpdate {
|
||||||
enabled: None,
|
enabled: None,
|
||||||
|
codec: None,
|
||||||
rendezvous_server: None,
|
rendezvous_server: None,
|
||||||
relay_server: None,
|
relay_server: None,
|
||||||
relay_key: Some(not_32),
|
relay_key: Some(not_32),
|
||||||
|
|||||||
110
src/web/handlers/config/vnc.rs
Normal file
110
src/web/handlers/config/vnc.rs
Normal 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,
|
||||||
|
)))
|
||||||
|
}
|
||||||
@@ -241,6 +241,7 @@ pub struct StreamConstraintsResponse {
|
|||||||
pub struct ConstraintSources {
|
pub struct ConstraintSources {
|
||||||
pub rustdesk: bool,
|
pub rustdesk: bool,
|
||||||
pub rtsp: bool,
|
pub rtsp: bool,
|
||||||
|
pub vnc: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get stream codec constraints derived from enabled services.
|
/// Get stream codec constraints derived from enabled services.
|
||||||
@@ -267,6 +268,7 @@ pub async fn stream_constraints_get(
|
|||||||
sources: ConstraintSources {
|
sources: ConstraintSources {
|
||||||
rustdesk: constraints.rustdesk_enabled,
|
rustdesk: constraints.rustdesk_enabled,
|
||||||
rtsp: constraints.rtsp_enabled,
|
rtsp: constraints.rtsp_enabled,
|
||||||
|
vnc: constraints.vnc_enabled,
|
||||||
},
|
},
|
||||||
reason: constraints.reason,
|
reason: constraints.reason,
|
||||||
current_mode,
|
current_mode,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ pub struct Capabilities {
|
|||||||
pub atx: CapabilityInfo,
|
pub atx: CapabilityInfo,
|
||||||
pub audio: CapabilityInfo,
|
pub audio: CapabilityInfo,
|
||||||
pub rustdesk: CapabilityInfo,
|
pub rustdesk: CapabilityInfo,
|
||||||
|
pub vnc: CapabilityInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -106,6 +107,11 @@ pub async fn system_info(State(state): State<Arc<AppState>>) -> Json<SystemInfo>
|
|||||||
backend: platform.rustdesk.selected_backend.clone(),
|
backend: platform.rustdesk.selected_backend.clone(),
|
||||||
reason: platform.rustdesk.reason.clone(),
|
reason: platform.rustdesk.reason.clone(),
|
||||||
},
|
},
|
||||||
|
vnc: CapabilityInfo {
|
||||||
|
available: config.vnc.enabled && platform.vnc.available,
|
||||||
|
backend: platform.vnc.selected_backend.clone(),
|
||||||
|
reason: platform.vnc.reason.clone(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
disk_space,
|
disk_space,
|
||||||
device_info,
|
device_info,
|
||||||
|
|||||||
@@ -143,6 +143,15 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
"/config/rustdesk/stop",
|
"/config/rustdesk/stop",
|
||||||
post(handlers::config::stop_rustdesk_service),
|
post(handlers::config::stop_rustdesk_service),
|
||||||
)
|
)
|
||||||
|
// VNC configuration endpoints
|
||||||
|
.route("/config/vnc", get(handlers::config::get_vnc_config))
|
||||||
|
.route("/config/vnc", patch(handlers::config::update_vnc_config))
|
||||||
|
.route("/config/vnc/status", get(handlers::config::get_vnc_status))
|
||||||
|
.route(
|
||||||
|
"/config/vnc/start",
|
||||||
|
post(handlers::config::start_vnc_service),
|
||||||
|
)
|
||||||
|
.route("/config/vnc/stop", post(handlers::config::stop_vnc_service))
|
||||||
// RTSP configuration endpoints
|
// RTSP configuration endpoints
|
||||||
.route("/config/rtsp", get(handlers::config::get_rtsp_config))
|
.route("/config/rtsp", get(handlers::config::get_rtsp_config))
|
||||||
.route("/config/rtsp", patch(handlers::config::update_rtsp_config))
|
.route("/config/rtsp", patch(handlers::config::update_rtsp_config))
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export const extensionsApi = {
|
|||||||
|
|
||||||
export interface RustDeskConfigResponse {
|
export interface RustDeskConfigResponse {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
codec: 'h264' | 'h265'
|
||||||
rendezvous_server: string
|
rendezvous_server: string
|
||||||
relay_server: string | null
|
relay_server: string | null
|
||||||
device_id: string
|
device_id: string
|
||||||
@@ -187,6 +188,7 @@ export interface RustDeskStatusResponse {
|
|||||||
|
|
||||||
export interface RustDeskConfigUpdate {
|
export interface RustDeskConfigUpdate {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
codec?: 'h264' | 'h265'
|
||||||
rendezvous_server?: string
|
rendezvous_server?: string
|
||||||
relay_server?: string
|
relay_server?: string
|
||||||
relay_key?: string
|
relay_key?: string
|
||||||
@@ -271,6 +273,50 @@ export const rtspConfigApi = {
|
|||||||
stop: () => request<RtspStatusResponse>('/config/rtsp/stop', { method: 'POST' }),
|
stop: () => request<RtspStatusResponse>('/config/rtsp/stop', { method: 'POST' }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VncEncoding = 'tight_jpeg' | 'h264'
|
||||||
|
|
||||||
|
export interface VncConfigResponse {
|
||||||
|
enabled: boolean
|
||||||
|
bind: string
|
||||||
|
port: number
|
||||||
|
encoding: VncEncoding
|
||||||
|
jpeg_quality: number
|
||||||
|
allow_one_client: boolean
|
||||||
|
has_password: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VncConfigUpdate {
|
||||||
|
enabled?: boolean
|
||||||
|
bind?: string
|
||||||
|
port?: number
|
||||||
|
encoding?: VncEncoding
|
||||||
|
jpeg_quality?: number
|
||||||
|
allow_one_client?: boolean
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VncStatusResponse {
|
||||||
|
config: VncConfigResponse
|
||||||
|
service_status: string
|
||||||
|
connection_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vncConfigApi = {
|
||||||
|
get: () => request<VncConfigResponse>('/config/vnc'),
|
||||||
|
|
||||||
|
update: (config: VncConfigUpdate) =>
|
||||||
|
request<VncConfigResponse>('/config/vnc', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getStatus: () => request<VncStatusResponse>('/config/vnc/status'),
|
||||||
|
|
||||||
|
start: () => request<VncStatusResponse>('/config/vnc/start', { method: 'POST' }),
|
||||||
|
|
||||||
|
stop: () => request<VncStatusResponse>('/config/vnc/stop', { method: 'POST' }),
|
||||||
|
}
|
||||||
|
|
||||||
export type WebConfig = WebConfigResponse
|
export type WebConfig = WebConfigResponse
|
||||||
|
|
||||||
export type { WebConfigUpdate }
|
export type { WebConfigUpdate }
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export interface PlatformCapabilities {
|
|||||||
otg: FeatureCapability
|
otg: FeatureCapability
|
||||||
audio: FeatureCapability
|
audio: FeatureCapability
|
||||||
rustdesk: FeatureCapability
|
rustdesk: FeatureCapability
|
||||||
|
vnc: FeatureCapability
|
||||||
diagnostics: FeatureCapability
|
diagnostics: FeatureCapability
|
||||||
extensions: FeatureCapability
|
extensions: FeatureCapability
|
||||||
service_installation: FeatureCapability
|
service_installation: FeatureCapability
|
||||||
@@ -86,6 +87,7 @@ export const systemApi = {
|
|||||||
atx: { available: boolean; backend?: string; reason?: string }
|
atx: { available: boolean; backend?: string; reason?: string }
|
||||||
audio: { available: boolean; backend?: string; reason?: string }
|
audio: { available: boolean; backend?: string; reason?: string }
|
||||||
rustdesk: { available: boolean; backend?: string; reason?: string }
|
rustdesk: { available: boolean; backend?: string; reason?: string }
|
||||||
|
vnc: { available: boolean; backend?: string; reason?: string }
|
||||||
}
|
}
|
||||||
disk_space?: {
|
disk_space?: {
|
||||||
total: number
|
total: number
|
||||||
@@ -206,6 +208,7 @@ export interface StreamConstraintsResponse {
|
|||||||
sources: {
|
sources: {
|
||||||
rustdesk: boolean
|
rustdesk: boolean
|
||||||
rtsp: boolean
|
rtsp: boolean
|
||||||
|
vnc: boolean
|
||||||
}
|
}
|
||||||
reason: string
|
reason: string
|
||||||
current_mode: string
|
current_mode: string
|
||||||
@@ -719,6 +722,7 @@ export {
|
|||||||
redfishConfigApi,
|
redfishConfigApi,
|
||||||
rustdeskConfigApi,
|
rustdeskConfigApi,
|
||||||
rtspConfigApi,
|
rtspConfigApi,
|
||||||
|
vncConfigApi,
|
||||||
webConfigApi,
|
webConfigApi,
|
||||||
type RustDeskConfigResponse,
|
type RustDeskConfigResponse,
|
||||||
type RustDeskStatusResponse,
|
type RustDeskStatusResponse,
|
||||||
@@ -729,6 +733,10 @@ export {
|
|||||||
type RedfishConfigUpdate,
|
type RedfishConfigUpdate,
|
||||||
type RtspConfigUpdate,
|
type RtspConfigUpdate,
|
||||||
type RtspStatusResponse,
|
type RtspStatusResponse,
|
||||||
|
type VncConfigResponse,
|
||||||
|
type VncConfigUpdate,
|
||||||
|
type VncEncoding,
|
||||||
|
type VncStatusResponse,
|
||||||
type WebConfig,
|
type WebConfig,
|
||||||
type WebConfigUpdate,
|
type WebConfigUpdate,
|
||||||
} from './config'
|
} from './config'
|
||||||
|
|||||||
@@ -522,8 +522,7 @@ export default {
|
|||||||
environmentSubtitle: 'System runtime environment and USB device maintenance',
|
environmentSubtitle: 'System runtime environment and USB device maintenance',
|
||||||
aboutSubtitle: 'Online upgrade, version info and hardware overview',
|
aboutSubtitle: 'Online upgrade, version info and hardware overview',
|
||||||
extTtydSubtitle: 'Open a host Shell terminal in the browser',
|
extTtydSubtitle: 'Open a host Shell terminal in the browser',
|
||||||
extRustdeskSubtitle: 'Remote graphical access via RustDesk',
|
thirdPartyAccessSubtitle: 'Configure external RustDesk, VNC, and RTSP access',
|
||||||
extRtspSubtitle: 'Provide an RTSP video stream for external clients',
|
|
||||||
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
|
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
|
||||||
extFrpcSubtitle: 'NAT traversal through the FRP client',
|
extFrpcSubtitle: 'NAT traversal through the FRP client',
|
||||||
aboutDesc: 'Open and Lightweight IP-KVM Solution',
|
aboutDesc: 'Open and Lightweight IP-KVM Solution',
|
||||||
@@ -967,6 +966,10 @@ export default {
|
|||||||
start: 'Start',
|
start: 'Start',
|
||||||
stop: 'Stop',
|
stop: 'Stop',
|
||||||
autoStart: 'Auto Start',
|
autoStart: 'Auto Start',
|
||||||
|
thirdPartyAccess: {
|
||||||
|
title: 'Third-party Access',
|
||||||
|
desc: 'Configure RustDesk, VNC, and RTSP in one place',
|
||||||
|
},
|
||||||
viewLogs: 'View Logs',
|
viewLogs: 'View Logs',
|
||||||
noLogs: 'No logs available',
|
noLogs: 'No logs available',
|
||||||
binaryNotFound: '{path} not found, please install the required program',
|
binaryNotFound: '{path} not found, please install the required program',
|
||||||
@@ -1040,6 +1043,8 @@ export default {
|
|||||||
relayServer: 'Relay Server',
|
relayServer: 'Relay Server',
|
||||||
relayServerPlaceholder: 'hbbr.example.com:21117',
|
relayServerPlaceholder: 'hbbr.example.com:21117',
|
||||||
relayKey: 'Relay Key',
|
relayKey: 'Relay Key',
|
||||||
|
codec: 'Codec',
|
||||||
|
codecHint: 'Choose H.264 or H.265 before starting RustDesk. The codec is locked while running.',
|
||||||
deviceInfo: 'Device Info',
|
deviceInfo: 'Device Info',
|
||||||
deviceId: 'Device ID',
|
deviceId: 'Device ID',
|
||||||
deviceIdHint: 'Use this ID in RustDesk client to connect',
|
deviceIdHint: 'Use this ID in RustDesk client to connect',
|
||||||
@@ -1073,7 +1078,7 @@ export default {
|
|||||||
pathPlaceholder: 'live',
|
pathPlaceholder: 'live',
|
||||||
pathHint: 'Example: rtsp://device-ip:8554/live',
|
pathHint: 'Example: rtsp://device-ip:8554/live',
|
||||||
codec: 'Codec',
|
codec: 'Codec',
|
||||||
codecHint: 'Enabling RTSP locks codec to selected value and disables MJPEG.',
|
codecHint: 'RTSP locks output to the selected codec while running. If RustDesk is running, choose the same codec.',
|
||||||
allowOneClient: 'Allow One Client Only',
|
allowOneClient: 'Allow One Client Only',
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
usernamePlaceholder: 'Empty means no authentication',
|
usernamePlaceholder: 'Empty means no authentication',
|
||||||
@@ -1081,6 +1086,26 @@ export default {
|
|||||||
passwordPlaceholder: 'Enter new password',
|
passwordPlaceholder: 'Enter new password',
|
||||||
urlPreview: 'RTSP URL Preview',
|
urlPreview: 'RTSP URL Preview',
|
||||||
},
|
},
|
||||||
|
vnc: {
|
||||||
|
title: 'VNC Remote',
|
||||||
|
desc: 'Access via TigerVNC client',
|
||||||
|
bind: 'Bind Address',
|
||||||
|
port: 'Port',
|
||||||
|
encoding: 'Video Encoding',
|
||||||
|
encodingTightJpeg: 'Tight JPEG',
|
||||||
|
encodingH264: 'H.264',
|
||||||
|
encodingHint: 'VNC locks output while running. VNC cannot start under an H.265 lock; MJPEG blocks RTSP and RustDesk.',
|
||||||
|
jpegQuality: 'JPEG Quality',
|
||||||
|
allowOneClient: 'Allow One Client Only',
|
||||||
|
password: 'Password',
|
||||||
|
passwordPlaceholder: 'Leave empty to keep current',
|
||||||
|
passwordRequiredPlaceholder: 'Up to 8 characters',
|
||||||
|
passwordRequired: 'Set a VNC password',
|
||||||
|
passwordMaxLength: 'VNC passwords are limited to 8 characters',
|
||||||
|
passwordSaved: 'Password is saved; leaving this empty keeps it unchanged.',
|
||||||
|
clients: '{count} clients',
|
||||||
|
urlPreview: 'VNC Address Preview',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
title: 'Connection Stats',
|
title: 'Connection Stats',
|
||||||
|
|||||||
@@ -521,8 +521,7 @@ export default {
|
|||||||
environmentSubtitle: '系统级运行环境与 USB 设备维护',
|
environmentSubtitle: '系统级运行环境与 USB 设备维护',
|
||||||
aboutSubtitle: '在线升级、版本信息与设备硬件概览',
|
aboutSubtitle: '在线升级、版本信息与设备硬件概览',
|
||||||
extTtydSubtitle: '在浏览器中打开本机 Shell 终端',
|
extTtydSubtitle: '在浏览器中打开本机 Shell 终端',
|
||||||
extRustdeskSubtitle: '通过 RustDesk 实现远程图形访问',
|
thirdPartyAccessSubtitle: '集中配置 RustDesk、VNC 与 RTSP 外部接入',
|
||||||
extRtspSubtitle: '提供 RTSP 视频流以供其他客户端拉流',
|
|
||||||
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
|
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
|
||||||
extFrpcSubtitle: '通过 FRP 客户端实现内网穿透',
|
extFrpcSubtitle: '通过 FRP 客户端实现内网穿透',
|
||||||
aboutDesc: '开放轻量的 IP-KVM 解决方案',
|
aboutDesc: '开放轻量的 IP-KVM 解决方案',
|
||||||
@@ -966,6 +965,10 @@ export default {
|
|||||||
start: '启动',
|
start: '启动',
|
||||||
stop: '停止',
|
stop: '停止',
|
||||||
autoStart: '开机自启',
|
autoStart: '开机自启',
|
||||||
|
thirdPartyAccess: {
|
||||||
|
title: '第三方接入',
|
||||||
|
desc: '集中配置 RustDesk、VNC 与 RTSP',
|
||||||
|
},
|
||||||
viewLogs: '查看日志',
|
viewLogs: '查看日志',
|
||||||
noLogs: '暂无日志',
|
noLogs: '暂无日志',
|
||||||
binaryNotFound: '未找到 {path},请先安装对应程序',
|
binaryNotFound: '未找到 {path},请先安装对应程序',
|
||||||
@@ -1039,6 +1042,8 @@ export default {
|
|||||||
relayServer: '中继服务器',
|
relayServer: '中继服务器',
|
||||||
relayServerPlaceholder: 'hbbr.example.com:21117',
|
relayServerPlaceholder: 'hbbr.example.com:21117',
|
||||||
relayKey: '中继密钥',
|
relayKey: '中继密钥',
|
||||||
|
codec: '编码格式',
|
||||||
|
codecHint: 'RustDesk 启动前需选择 H.264 或 H.265;运行时会锁定编码,不允许客户端切换。',
|
||||||
deviceInfo: '设备信息',
|
deviceInfo: '设备信息',
|
||||||
deviceId: '设备 ID',
|
deviceId: '设备 ID',
|
||||||
deviceIdHint: '此 ID 用于 RustDesk 客户端连接',
|
deviceIdHint: '此 ID 用于 RustDesk 客户端连接',
|
||||||
@@ -1072,7 +1077,7 @@ export default {
|
|||||||
pathPlaceholder: 'live',
|
pathPlaceholder: 'live',
|
||||||
pathHint: '访问路径,例如 rtsp://设备IP:8554/live',
|
pathHint: '访问路径,例如 rtsp://设备IP:8554/live',
|
||||||
codec: '编码格式',
|
codec: '编码格式',
|
||||||
codecHint: '启用 RTSP 后将锁定编码为所选项,并禁用 MJPEG。',
|
codecHint: 'RTSP 运行时会锁定为所选编码;若 RustDesk 已运行,只能选择相同编码。',
|
||||||
allowOneClient: '仅允许单客户端',
|
allowOneClient: '仅允许单客户端',
|
||||||
username: '用户名',
|
username: '用户名',
|
||||||
usernamePlaceholder: '留空表示无需认证',
|
usernamePlaceholder: '留空表示无需认证',
|
||||||
@@ -1080,6 +1085,26 @@ export default {
|
|||||||
passwordPlaceholder: '输入新密码',
|
passwordPlaceholder: '输入新密码',
|
||||||
urlPreview: 'RTSP 地址预览',
|
urlPreview: 'RTSP 地址预览',
|
||||||
},
|
},
|
||||||
|
vnc: {
|
||||||
|
title: 'VNC 远程',
|
||||||
|
desc: '通过 TigerVNC 客户端访问',
|
||||||
|
bind: '监听地址',
|
||||||
|
port: '端口',
|
||||||
|
encoding: '视频编码',
|
||||||
|
encodingTightJpeg: 'Tight JPEG',
|
||||||
|
encodingH264: 'H.264',
|
||||||
|
encodingHint: 'VNC 运行时会锁定编码;H.265 锁定时 VNC 无法启动,MJPEG 锁定时 RTSP 与 RustDesk 无法启动。',
|
||||||
|
jpegQuality: 'JPEG 质量',
|
||||||
|
allowOneClient: '仅允许单客户端',
|
||||||
|
password: '密码',
|
||||||
|
passwordPlaceholder: '留空表示不修改',
|
||||||
|
passwordRequiredPlaceholder: '最多 8 个字符',
|
||||||
|
passwordRequired: '请设置 VNC 密码',
|
||||||
|
passwordMaxLength: 'VNC 密码最多 8 个字符',
|
||||||
|
passwordSaved: '已保存密码;留空不会修改。',
|
||||||
|
clients: '{count} 个客户端',
|
||||||
|
urlPreview: 'VNC 地址预览',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
title: '连接统计',
|
title: '连接统计',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
rtspConfigApi,
|
rtspConfigApi,
|
||||||
rustdeskConfigApi,
|
rustdeskConfigApi,
|
||||||
streamConfigApi,
|
streamConfigApi,
|
||||||
|
vncConfigApi,
|
||||||
videoConfigApi,
|
videoConfigApi,
|
||||||
webConfigApi,
|
webConfigApi,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
@@ -36,6 +37,9 @@ import type {
|
|||||||
RustDeskConfigUpdate as ApiRustDeskConfigUpdate,
|
RustDeskConfigUpdate as ApiRustDeskConfigUpdate,
|
||||||
RustDeskStatusResponse as ApiRustDeskStatusResponse,
|
RustDeskStatusResponse as ApiRustDeskStatusResponse,
|
||||||
RustDeskPasswordResponse as ApiRustDeskPasswordResponse,
|
RustDeskPasswordResponse as ApiRustDeskPasswordResponse,
|
||||||
|
VncConfigResponse as ApiVncConfigResponse,
|
||||||
|
VncConfigUpdate as ApiVncConfigUpdate,
|
||||||
|
VncStatusResponse as ApiVncStatusResponse,
|
||||||
WebConfig,
|
WebConfig,
|
||||||
WebConfigUpdate,
|
WebConfigUpdate,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
@@ -57,6 +61,8 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
const atx = ref<AtxConfig | null>(null)
|
const atx = ref<AtxConfig | null>(null)
|
||||||
const rtspConfig = ref<ApiRtspConfigResponse | null>(null)
|
const rtspConfig = ref<ApiRtspConfigResponse | null>(null)
|
||||||
const rtspStatus = ref<ApiRtspStatusResponse | null>(null)
|
const rtspStatus = ref<ApiRtspStatusResponse | null>(null)
|
||||||
|
const vncConfig = ref<ApiVncConfigResponse | null>(null)
|
||||||
|
const vncStatus = ref<ApiVncStatusResponse | null>(null)
|
||||||
const rustdeskConfig = ref<ApiRustDeskConfigResponse | null>(null)
|
const rustdeskConfig = ref<ApiRustDeskConfigResponse | null>(null)
|
||||||
const rustdeskStatus = ref<ApiRustDeskStatusResponse | null>(null)
|
const rustdeskStatus = ref<ApiRustDeskStatusResponse | null>(null)
|
||||||
const rustdeskPassword = ref<ApiRustDeskPasswordResponse | null>(null)
|
const rustdeskPassword = ref<ApiRustDeskPasswordResponse | null>(null)
|
||||||
@@ -70,6 +76,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
const webLoading = ref(false)
|
const webLoading = ref(false)
|
||||||
const atxLoading = ref(false)
|
const atxLoading = ref(false)
|
||||||
const rtspLoading = ref(false)
|
const rtspLoading = ref(false)
|
||||||
|
const vncLoading = ref(false)
|
||||||
const rustdeskLoading = ref(false)
|
const rustdeskLoading = ref(false)
|
||||||
|
|
||||||
const authError = ref<string | null>(null)
|
const authError = ref<string | null>(null)
|
||||||
@@ -81,6 +88,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
const webError = ref<string | null>(null)
|
const webError = ref<string | null>(null)
|
||||||
const atxError = ref<string | null>(null)
|
const atxError = ref<string | null>(null)
|
||||||
const rtspError = ref<string | null>(null)
|
const rtspError = ref<string | null>(null)
|
||||||
|
const vncError = ref<string | null>(null)
|
||||||
const rustdeskError = ref<string | null>(null)
|
const rustdeskError = ref<string | null>(null)
|
||||||
|
|
||||||
let authPromise: Promise<AuthConfig> | null = null
|
let authPromise: Promise<AuthConfig> | null = null
|
||||||
@@ -93,6 +101,8 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
let atxPromise: Promise<AtxConfig> | null = null
|
let atxPromise: Promise<AtxConfig> | null = null
|
||||||
let rtspPromise: Promise<ApiRtspConfigResponse> | null = null
|
let rtspPromise: Promise<ApiRtspConfigResponse> | null = null
|
||||||
let rtspStatusPromise: Promise<ApiRtspStatusResponse> | null = null
|
let rtspStatusPromise: Promise<ApiRtspStatusResponse> | null = null
|
||||||
|
let vncPromise: Promise<ApiVncConfigResponse> | null = null
|
||||||
|
let vncStatusPromise: Promise<ApiVncStatusResponse> | null = null
|
||||||
let rustdeskPromise: Promise<ApiRustDeskConfigResponse> | null = null
|
let rustdeskPromise: Promise<ApiRustDeskConfigResponse> | null = null
|
||||||
let rustdeskStatusPromise: Promise<ApiRustDeskStatusResponse> | null = null
|
let rustdeskStatusPromise: Promise<ApiRustDeskStatusResponse> | null = null
|
||||||
let rustdeskPasswordPromise: Promise<ApiRustDeskPasswordResponse> | null = null
|
let rustdeskPasswordPromise: Promise<ApiRustDeskPasswordResponse> | null = null
|
||||||
@@ -318,6 +328,51 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshVncConfig() {
|
||||||
|
if (vncLoading.value && vncPromise) return vncPromise
|
||||||
|
vncLoading.value = true
|
||||||
|
vncError.value = null
|
||||||
|
const request = vncConfigApi.get()
|
||||||
|
.then((response) => {
|
||||||
|
vncConfig.value = response
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
vncError.value = normalizeErrorMessage(error)
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
vncLoading.value = false
|
||||||
|
vncPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
vncPromise = request
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshVncStatus() {
|
||||||
|
if (vncLoading.value && vncStatusPromise) return vncStatusPromise
|
||||||
|
vncLoading.value = true
|
||||||
|
vncError.value = null
|
||||||
|
const request = vncConfigApi.getStatus()
|
||||||
|
.then((response) => {
|
||||||
|
vncStatus.value = response
|
||||||
|
vncConfig.value = response.config
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
vncError.value = normalizeErrorMessage(error)
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
vncLoading.value = false
|
||||||
|
vncStatusPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
vncStatusPromise = request
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshRustdeskConfig() {
|
async function refreshRustdeskConfig() {
|
||||||
if (rustdeskLoading.value && rustdeskPromise) return rustdeskPromise
|
if (rustdeskLoading.value && rustdeskPromise) return rustdeskPromise
|
||||||
rustdeskLoading.value = true
|
rustdeskLoading.value = true
|
||||||
@@ -430,6 +485,11 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
return refreshRtspConfig()
|
return refreshRtspConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureVncConfig() {
|
||||||
|
if (vncConfig.value) return Promise.resolve(vncConfig.value)
|
||||||
|
return refreshVncConfig()
|
||||||
|
}
|
||||||
|
|
||||||
function ensureRustdeskConfig() {
|
function ensureRustdeskConfig() {
|
||||||
if (rustdeskConfig.value) return Promise.resolve(rustdeskConfig.value)
|
if (rustdeskConfig.value) return Promise.resolve(rustdeskConfig.value)
|
||||||
return refreshRustdeskConfig()
|
return refreshRustdeskConfig()
|
||||||
@@ -489,6 +549,12 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateVnc(update: ApiVncConfigUpdate) {
|
||||||
|
const response = await vncConfigApi.update(update)
|
||||||
|
vncConfig.value = response
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
async function updateRustdesk(update: ApiRustDeskConfigUpdate) {
|
async function updateRustdesk(update: ApiRustDeskConfigUpdate) {
|
||||||
const response = await rustdeskConfigApi.update(update)
|
const response = await rustdeskConfigApi.update(update)
|
||||||
rustdeskConfig.value = response
|
rustdeskConfig.value = response
|
||||||
@@ -518,6 +584,8 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
atx,
|
atx,
|
||||||
rtspConfig,
|
rtspConfig,
|
||||||
rtspStatus,
|
rtspStatus,
|
||||||
|
vncConfig,
|
||||||
|
vncStatus,
|
||||||
rustdeskConfig,
|
rustdeskConfig,
|
||||||
rustdeskStatus,
|
rustdeskStatus,
|
||||||
rustdeskPassword,
|
rustdeskPassword,
|
||||||
@@ -530,6 +598,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
webLoading,
|
webLoading,
|
||||||
atxLoading,
|
atxLoading,
|
||||||
rtspLoading,
|
rtspLoading,
|
||||||
|
vncLoading,
|
||||||
rustdeskLoading,
|
rustdeskLoading,
|
||||||
authError,
|
authError,
|
||||||
videoError,
|
videoError,
|
||||||
@@ -540,6 +609,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
webError,
|
webError,
|
||||||
atxError,
|
atxError,
|
||||||
rtspError,
|
rtspError,
|
||||||
|
vncError,
|
||||||
rustdeskError,
|
rustdeskError,
|
||||||
refreshAuth,
|
refreshAuth,
|
||||||
refreshVideo,
|
refreshVideo,
|
||||||
@@ -551,6 +621,8 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
refreshAtx,
|
refreshAtx,
|
||||||
refreshRtspConfig,
|
refreshRtspConfig,
|
||||||
refreshRtspStatus,
|
refreshRtspStatus,
|
||||||
|
refreshVncConfig,
|
||||||
|
refreshVncStatus,
|
||||||
refreshRustdeskConfig,
|
refreshRustdeskConfig,
|
||||||
refreshRustdeskStatus,
|
refreshRustdeskStatus,
|
||||||
refreshRustdeskPassword,
|
refreshRustdeskPassword,
|
||||||
@@ -563,6 +635,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
ensureWeb,
|
ensureWeb,
|
||||||
ensureAtx,
|
ensureAtx,
|
||||||
ensureRtspConfig,
|
ensureRtspConfig,
|
||||||
|
ensureVncConfig,
|
||||||
ensureRustdeskConfig,
|
ensureRustdeskConfig,
|
||||||
updateAuth,
|
updateAuth,
|
||||||
updateVideo,
|
updateVideo,
|
||||||
@@ -573,6 +646,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
updateWeb,
|
updateWeb,
|
||||||
updateAtx,
|
updateAtx,
|
||||||
updateRtsp,
|
updateRtsp,
|
||||||
|
updateVnc,
|
||||||
updateRustdesk,
|
updateRustdesk,
|
||||||
regenerateRustdeskId,
|
regenerateRustdeskId,
|
||||||
regenerateRustdeskPassword,
|
regenerateRustdeskPassword,
|
||||||
|
|||||||
@@ -62,14 +62,6 @@ export interface Ch9329DescriptorConfig {
|
|||||||
serial_number?: string;
|
serial_number?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Ch9329DescriptorState {
|
|
||||||
descriptor: Ch9329DescriptorConfig;
|
|
||||||
manufacturer_enabled: boolean;
|
|
||||||
product_enabled: boolean;
|
|
||||||
serial_enabled: boolean;
|
|
||||||
config_mode_available: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HidConfig {
|
export interface HidConfig {
|
||||||
backend: HidBackend;
|
backend: HidBackend;
|
||||||
otg_udc?: string;
|
otg_udc?: string;
|
||||||
@@ -234,11 +226,31 @@ export interface ExtensionsConfig {
|
|||||||
|
|
||||||
export interface RustDeskConfig {
|
export interface RustDeskConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
codec: RustDeskCodec;
|
||||||
rendezvous_server: string;
|
rendezvous_server: string;
|
||||||
relay_server?: string;
|
relay_server?: string;
|
||||||
device_id: string;
|
device_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum RustDeskCodec {
|
||||||
|
H264 = "h264",
|
||||||
|
H265 = "h265",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum VncEncoding {
|
||||||
|
TightJpeg = "tight_jpeg",
|
||||||
|
H264 = "h264",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VncConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
bind: string;
|
||||||
|
port: number;
|
||||||
|
encoding: VncEncoding;
|
||||||
|
jpeg_quality: number;
|
||||||
|
allow_one_client: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export enum RtspCodec {
|
export enum RtspCodec {
|
||||||
H264 = "h264",
|
H264 = "h264",
|
||||||
H265 = "h265",
|
H265 = "h265",
|
||||||
@@ -270,6 +282,7 @@ export interface AppConfig {
|
|||||||
web: WebConfig;
|
web: WebConfig;
|
||||||
extensions: ExtensionsConfig;
|
extensions: ExtensionsConfig;
|
||||||
rustdesk: RustDeskConfig;
|
rustdesk: RustDeskConfig;
|
||||||
|
vnc: VncConfig;
|
||||||
rtsp: RtspConfig;
|
rtsp: RtspConfig;
|
||||||
redfish: RedfishConfig;
|
redfish: RedfishConfig;
|
||||||
}
|
}
|
||||||
@@ -328,6 +341,14 @@ export interface Ch9329DescriptorConfigUpdate {
|
|||||||
serial_number?: string;
|
serial_number?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Ch9329DescriptorState {
|
||||||
|
descriptor: Ch9329DescriptorConfig;
|
||||||
|
manufacturer_enabled: boolean;
|
||||||
|
product_enabled: boolean;
|
||||||
|
serial_enabled: boolean;
|
||||||
|
config_mode_available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EasytierConfigUpdate {
|
export interface EasytierConfigUpdate {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
network_name?: string;
|
network_name?: string;
|
||||||
@@ -480,6 +501,7 @@ export interface RtspStatusResponse {
|
|||||||
|
|
||||||
export interface RustDeskConfigUpdate {
|
export interface RustDeskConfigUpdate {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
codec?: RustDeskCodec;
|
||||||
rendezvous_server?: string;
|
rendezvous_server?: string;
|
||||||
relay_server?: string;
|
relay_server?: string;
|
||||||
relay_key?: string;
|
relay_key?: string;
|
||||||
@@ -535,6 +557,32 @@ export interface VideoConfigUpdate {
|
|||||||
quality?: number;
|
quality?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VncConfigResponse {
|
||||||
|
enabled: boolean;
|
||||||
|
bind: string;
|
||||||
|
port: number;
|
||||||
|
encoding: VncEncoding;
|
||||||
|
jpeg_quality: number;
|
||||||
|
allow_one_client: boolean;
|
||||||
|
has_password: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VncConfigUpdate {
|
||||||
|
enabled?: boolean;
|
||||||
|
bind?: string;
|
||||||
|
port?: number;
|
||||||
|
encoding?: VncEncoding;
|
||||||
|
jpeg_quality?: number;
|
||||||
|
allow_one_client?: boolean;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VncStatusResponse {
|
||||||
|
config: VncConfigResponse;
|
||||||
|
service_status: string;
|
||||||
|
connection_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Web server settings returned by `GET` / `PATCH /api/config/web`.
|
* Web server settings returned by `GET` / `PATCH /api/config/web`.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
systemApi,
|
systemApi,
|
||||||
updateApi,
|
updateApi,
|
||||||
usbApi,
|
usbApi,
|
||||||
|
vncConfigApi,
|
||||||
type EncoderBackendInfo,
|
type EncoderBackendInfo,
|
||||||
type AuthConfig,
|
type AuthConfig,
|
||||||
type RustDeskConfigResponse,
|
type RustDeskConfigResponse,
|
||||||
@@ -27,6 +28,8 @@ import {
|
|||||||
type RustDeskPasswordResponse,
|
type RustDeskPasswordResponse,
|
||||||
type RtspStatusResponse,
|
type RtspStatusResponse,
|
||||||
type RtspConfigUpdate,
|
type RtspConfigUpdate,
|
||||||
|
type VncConfigUpdate,
|
||||||
|
type VncStatusResponse,
|
||||||
type WebConfig,
|
type WebConfig,
|
||||||
type UpdateOverviewResponse,
|
type UpdateOverviewResponse,
|
||||||
type UpdateStatusResponse,
|
type UpdateStatusResponse,
|
||||||
@@ -105,7 +108,6 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Copy,
|
Copy,
|
||||||
ScreenShare,
|
ScreenShare,
|
||||||
Radio,
|
|
||||||
Globe,
|
Globe,
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -136,8 +138,7 @@ const SETTINGS_SECTION_IDS = [
|
|||||||
'atx',
|
'atx',
|
||||||
'environment',
|
'environment',
|
||||||
'ext-ttyd',
|
'ext-ttyd',
|
||||||
'ext-rustdesk',
|
'third-party-access',
|
||||||
'ext-rtsp',
|
|
||||||
'ext-remote-access',
|
'ext-remote-access',
|
||||||
'about',
|
'about',
|
||||||
] as const
|
] as const
|
||||||
@@ -167,8 +168,7 @@ const navGroups = computed(() => [
|
|||||||
title: t('settings.extensions'),
|
title: t('settings.extensions'),
|
||||||
items: [
|
items: [
|
||||||
{ id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal },
|
{ id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal },
|
||||||
{ id: 'ext-rustdesk', label: t('extensions.rustdesk.title'), icon: ScreenShare },
|
{ id: 'third-party-access', label: t('extensions.thirdPartyAccess.title'), icon: ScreenShare },
|
||||||
{ id: 'ext-rtsp', label: t('extensions.rtsp.title'), icon: Radio },
|
|
||||||
{ id: 'ext-remote-access', label: t('extensions.remoteAccess.title'), icon: ExternalLink },
|
{ id: 'ext-remote-access', label: t('extensions.remoteAccess.title'), icon: ExternalLink },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -200,8 +200,7 @@ const sectionMeta = computed(() => {
|
|||||||
function sectionSubtitleKey(id: string): string {
|
function sectionSubtitleKey(id: string): string {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'ext-ttyd': return 'extTtydSubtitle'
|
case 'ext-ttyd': return 'extTtydSubtitle'
|
||||||
case 'ext-rustdesk': return 'extRustdeskSubtitle'
|
case 'third-party-access': return 'thirdPartyAccessSubtitle'
|
||||||
case 'ext-rtsp': return 'extRtspSubtitle'
|
|
||||||
case 'ext-remote-access': return 'extRemoteAccessSubtitle'
|
case 'ext-remote-access': return 'extRemoteAccessSubtitle'
|
||||||
default: return `${id}Subtitle`
|
default: return `${id}Subtitle`
|
||||||
}
|
}
|
||||||
@@ -222,6 +221,7 @@ function normalizeSettingsSection(value: unknown): SettingsSectionId | null {
|
|||||||
if (typeof value !== 'string') return null
|
if (typeof value !== 'string') return null
|
||||||
if (value === 'access-control') return 'account'
|
if (value === 'access-control') return 'account'
|
||||||
if (value === 'ext-frpc') return 'ext-remote-access'
|
if (value === 'ext-frpc') return 'ext-remote-access'
|
||||||
|
if (value === 'ext-rustdesk' || value === 'ext-vnc' || value === 'ext-rtsp') return 'third-party-access'
|
||||||
return isSettingsSectionId(value) ? value : null
|
return isSettingsSectionId(value) ? value : null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,15 +272,14 @@ async function loadSectionData(section: SettingsSectionId) {
|
|||||||
case 'ext-remote-access':
|
case 'ext-remote-access':
|
||||||
await loadExtensions()
|
await loadExtensions()
|
||||||
return
|
return
|
||||||
case 'ext-rustdesk':
|
case 'third-party-access':
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadRustdeskConfig(),
|
loadRustdeskConfig(),
|
||||||
loadRustdeskPassword(),
|
loadRustdeskPassword(),
|
||||||
|
loadRtspConfig(),
|
||||||
|
loadVncConfig(),
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
case 'ext-rtsp':
|
|
||||||
await loadRtspConfig()
|
|
||||||
return
|
|
||||||
case 'about':
|
case 'about':
|
||||||
if (isAndroid.value) return
|
if (isAndroid.value) return
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -390,6 +389,7 @@ const rustdeskCopied = ref<'id' | 'password' | null>(null)
|
|||||||
const { copy: clipboardCopy } = useClipboard()
|
const { copy: clipboardCopy } = useClipboard()
|
||||||
const rustdeskLocalConfig = ref({
|
const rustdeskLocalConfig = ref({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
codec: 'h264' as 'h264' | 'h265',
|
||||||
rendezvous_server: '',
|
rendezvous_server: '',
|
||||||
relay_server: '',
|
relay_server: '',
|
||||||
relay_key: '',
|
relay_key: '',
|
||||||
@@ -415,6 +415,18 @@ const rtspLocalConfig = ref<RtspConfigUpdate & { password?: string }>({
|
|||||||
password: '',
|
password: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const vncStatus = ref<VncStatusResponse | null>(null)
|
||||||
|
const vncLoading = ref(false)
|
||||||
|
const vncLocalConfig = ref<VncConfigUpdate & { password?: string }>({
|
||||||
|
enabled: false,
|
||||||
|
bind: '0.0.0.0',
|
||||||
|
port: 5900,
|
||||||
|
encoding: 'tight_jpeg',
|
||||||
|
jpeg_quality: 80,
|
||||||
|
allow_one_client: true,
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
|
||||||
function formatHostForUrl(hostname: string): string {
|
function formatHostForUrl(hostname: string): string {
|
||||||
if (!hostname) return '127.0.0.1'
|
if (!hostname) return '127.0.0.1'
|
||||||
return hostname.includes(':') && !hostname.startsWith('[')
|
return hostname.includes(':') && !hostname.startsWith('[')
|
||||||
@@ -429,6 +441,12 @@ const rtspStreamUrl = computed(() => {
|
|||||||
return `rtsp://${host}:${port}/${path}`
|
return `rtsp://${host}:${port}/${path}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const vncStreamUrl = computed(() => {
|
||||||
|
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
|
||||||
|
const port = Number(vncLocalConfig.value.port) || 5900
|
||||||
|
return `${host}:${port}`
|
||||||
|
})
|
||||||
|
|
||||||
const webServerConfig = ref<WebConfig>({
|
const webServerConfig = ref<WebConfig>({
|
||||||
http_port: 8080,
|
http_port: 8080,
|
||||||
https_port: 8443,
|
https_port: 8443,
|
||||||
@@ -1839,6 +1857,7 @@ function applyRustdeskStatus(status: RustDeskStatusResponse) {
|
|||||||
rustdeskStatus.value = status
|
rustdeskStatus.value = status
|
||||||
rustdeskLocalConfig.value = {
|
rustdeskLocalConfig.value = {
|
||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
|
codec: config.codec || 'h264',
|
||||||
rendezvous_server: config.rendezvous_server,
|
rendezvous_server: config.rendezvous_server,
|
||||||
relay_server: config.relay_server || '',
|
relay_server: config.relay_server || '',
|
||||||
relay_key: config.relay_key || '',
|
relay_key: config.relay_key || '',
|
||||||
@@ -1901,6 +1920,17 @@ function validateRustdeskConfig(): boolean {
|
|||||||
return !rustdeskValidationMessage.value || showValidationError(rustdeskValidationMessage.value)
|
return !rustdeskValidationMessage.value || showValidationError(rustdeskValidationMessage.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateVncConfig(enabled = vncLocalConfig.value.enabled): boolean {
|
||||||
|
const password = (vncLocalConfig.value.password || '').trim()
|
||||||
|
if (enabled && !vncStatus.value?.config.has_password && !password) {
|
||||||
|
return showValidationError(t('extensions.vnc.passwordRequired'))
|
||||||
|
}
|
||||||
|
if (password.length > 8) {
|
||||||
|
return showValidationError(t('extensions.vnc.passwordMaxLength'))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRtspPath(path: string): string {
|
function normalizeRtspPath(path: string): string {
|
||||||
return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
|
return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
|
||||||
}
|
}
|
||||||
@@ -2222,23 +2252,26 @@ function updateStatusBadgeText(): string {
|
|||||||
|| updatePhaseText(updateStatus.value?.phase)
|
|| updatePhaseText(updateStatus.value?.phase)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rustdeskUpdatePayload(enabled = rustdeskLocalConfig.value.enabled) {
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
codec: rustdeskLocalConfig.value.codec,
|
||||||
|
rendezvous_server: normalizeRustdeskServer(
|
||||||
|
rustdeskLocalConfig.value.rendezvous_server,
|
||||||
|
21116,
|
||||||
|
),
|
||||||
|
relay_server: normalizeRustdeskServer(rustdeskLocalConfig.value.relay_server, 21117),
|
||||||
|
relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveRustdeskConfig() {
|
async function saveRustdeskConfig() {
|
||||||
if (rustdeskLocalConfig.value.enabled && !validateRustdeskConfig()) return
|
if (rustdeskLocalConfig.value.enabled && !validateRustdeskConfig()) return
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
saved.value = false
|
saved.value = false
|
||||||
try {
|
try {
|
||||||
const rendezvousServer = normalizeRustdeskServer(
|
await configStore.updateRustdesk(rustdeskUpdatePayload())
|
||||||
rustdeskLocalConfig.value.rendezvous_server,
|
|
||||||
21116,
|
|
||||||
)
|
|
||||||
const relayServer = normalizeRustdeskServer(rustdeskLocalConfig.value.relay_server, 21117)
|
|
||||||
await configStore.updateRustdesk({
|
|
||||||
enabled: rustdeskLocalConfig.value.enabled,
|
|
||||||
rendezvous_server: rendezvousServer,
|
|
||||||
relay_server: relayServer,
|
|
||||||
relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key),
|
|
||||||
})
|
|
||||||
await loadRustdeskConfig()
|
await loadRustdeskConfig()
|
||||||
saved.value = true
|
saved.value = true
|
||||||
setTimeout(() => (saved.value = false), 2000)
|
setTimeout(() => (saved.value = false), 2000)
|
||||||
@@ -2279,6 +2312,7 @@ async function startRustdesk() {
|
|||||||
|
|
||||||
rustdeskLoading.value = true
|
rustdeskLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
await configStore.updateRustdesk(rustdeskUpdatePayload(true))
|
||||||
const status = await rustdeskConfigApi.start()
|
const status = await rustdeskConfigApi.start()
|
||||||
applyRustdeskStatus(status)
|
applyRustdeskStatus(status)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2373,23 +2407,24 @@ async function loadRtspConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveRtspConfig() {
|
function rtspUpdatePayload(enabled = !!rtspLocalConfig.value.enabled): RtspConfigUpdate {
|
||||||
loading.value = true
|
return {
|
||||||
saved.value = false
|
enabled,
|
||||||
try {
|
|
||||||
const update: RtspConfigUpdate = {
|
|
||||||
enabled: !!rtspLocalConfig.value.enabled,
|
|
||||||
bind: rtspLocalConfig.value.bind?.trim() || '0.0.0.0',
|
bind: rtspLocalConfig.value.bind?.trim() || '0.0.0.0',
|
||||||
port: Number(rtspLocalConfig.value.port) || 8554,
|
port: Number(rtspLocalConfig.value.port) || 8554,
|
||||||
path: normalizeRtspPath(rtspLocalConfig.value.path || 'live'),
|
path: normalizeRtspPath(rtspLocalConfig.value.path || 'live'),
|
||||||
allow_one_client: !!rtspLocalConfig.value.allow_one_client,
|
allow_one_client: !!rtspLocalConfig.value.allow_one_client,
|
||||||
codec: rtspLocalConfig.value.codec || 'h264',
|
codec: rtspLocalConfig.value.codec || 'h264',
|
||||||
username: (rtspLocalConfig.value.username || '').trim(),
|
username: (rtspLocalConfig.value.username || '').trim(),
|
||||||
|
password: (rtspLocalConfig.value.password || '').trim(),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
update.password = (rtspLocalConfig.value.password || '').trim()
|
async function saveRtspConfig() {
|
||||||
|
loading.value = true
|
||||||
await configStore.updateRtsp(update)
|
saved.value = false
|
||||||
|
try {
|
||||||
|
await configStore.updateRtsp(rtspUpdatePayload())
|
||||||
await loadRtspConfig()
|
await loadRtspConfig()
|
||||||
saved.value = true
|
saved.value = true
|
||||||
setTimeout(() => (saved.value = false), 2000)
|
setTimeout(() => (saved.value = false), 2000)
|
||||||
@@ -2402,6 +2437,7 @@ async function saveRtspConfig() {
|
|||||||
async function startRtsp() {
|
async function startRtsp() {
|
||||||
rtspLoading.value = true
|
rtspLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
await configStore.updateRtsp(rtspUpdatePayload(true))
|
||||||
const status = await rtspConfigApi.start()
|
const status = await rtspConfigApi.start()
|
||||||
applyRtspStatus(status)
|
applyRtspStatus(status)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2421,6 +2457,108 @@ async function stopRtsp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyVncStatus(status: VncStatusResponse) {
|
||||||
|
vncStatus.value = status
|
||||||
|
vncLocalConfig.value = {
|
||||||
|
enabled: status.config.enabled,
|
||||||
|
bind: status.config.bind,
|
||||||
|
port: status.config.port,
|
||||||
|
encoding: status.config.encoding,
|
||||||
|
jpeg_quality: status.config.jpeg_quality,
|
||||||
|
allow_one_client: status.config.allow_one_client,
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVncConfig() {
|
||||||
|
vncLoading.value = true
|
||||||
|
try {
|
||||||
|
const status = await configStore.refreshVncStatus()
|
||||||
|
applyVncStatus(status)
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
vncLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function vncUpdatePayload(enabled = !!vncLocalConfig.value.enabled): VncConfigUpdate {
|
||||||
|
const update: VncConfigUpdate = {
|
||||||
|
enabled,
|
||||||
|
bind: vncLocalConfig.value.bind?.trim() || '0.0.0.0',
|
||||||
|
port: Number(vncLocalConfig.value.port) || 5900,
|
||||||
|
encoding: vncLocalConfig.value.encoding || 'tight_jpeg',
|
||||||
|
jpeg_quality: Number(vncLocalConfig.value.jpeg_quality) || 80,
|
||||||
|
allow_one_client: !!vncLocalConfig.value.allow_one_client,
|
||||||
|
}
|
||||||
|
const password = (vncLocalConfig.value.password || '').trim()
|
||||||
|
if (password) update.password = password
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveVncConfig() {
|
||||||
|
if (!validateVncConfig()) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
saved.value = false
|
||||||
|
try {
|
||||||
|
await configStore.updateVnc(vncUpdatePayload())
|
||||||
|
await loadVncConfig()
|
||||||
|
saved.value = true
|
||||||
|
setTimeout(() => (saved.value = false), 2000)
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startVnc() {
|
||||||
|
if (!validateVncConfig(true)) return
|
||||||
|
|
||||||
|
vncLoading.value = true
|
||||||
|
try {
|
||||||
|
await configStore.updateVnc(vncUpdatePayload(true))
|
||||||
|
const status = await vncConfigApi.start()
|
||||||
|
applyVncStatus(status)
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
vncLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopVnc() {
|
||||||
|
vncLoading.value = true
|
||||||
|
try {
|
||||||
|
const status = await vncConfigApi.stop()
|
||||||
|
applyVncStatus(status)
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
vncLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVncServiceStatusText(status: string | undefined): string {
|
||||||
|
if (!status) return t('extensions.stopped')
|
||||||
|
switch (status) {
|
||||||
|
case 'running': return t('extensions.running')
|
||||||
|
case 'starting': return t('extensions.starting')
|
||||||
|
case 'stopped': return t('extensions.stopped')
|
||||||
|
default:
|
||||||
|
if (status.startsWith('error:')) return t('extensions.failed')
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVncStatusClass(status: string | undefined): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'running': return 'bg-green-500'
|
||||||
|
case 'starting': return 'bg-yellow-500'
|
||||||
|
case 'stopped': return 'bg-gray-400'
|
||||||
|
default:
|
||||||
|
if (status?.startsWith('error:')) return 'bg-red-500'
|
||||||
|
return 'bg-gray-400'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getRtspServiceStatusText(status: string | undefined): string {
|
function getRtspServiceStatusText(status: string | undefined): string {
|
||||||
if (!status) return t('extensions.stopped')
|
if (!status) return t('extensions.stopped')
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -4506,7 +4644,7 @@ watch(isWindows, () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RTSP Section -->
|
<!-- RTSP Section -->
|
||||||
<div v-show="activeSection === 'ext-rtsp'" class="space-y-6">
|
<div v-show="activeSection === 'third-party-access'" class="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -4631,8 +4769,134 @@ watch(isWindows, () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- VNC Section -->
|
||||||
|
<div v-show="activeSection === 'third-party-access'" class="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<CardTitle>{{ t('extensions.vnc.title') }}</CardTitle>
|
||||||
|
<CardDescription>{{ t('extensions.vnc.desc') }}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge :variant="vncStatus?.service_status === 'running' ? 'default' : 'secondary'">
|
||||||
|
{{ getVncServiceStatusText(vncStatus?.service_status) }}
|
||||||
|
</Badge>
|
||||||
|
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.refresh')" @click="loadVncConfig" :disabled="vncLoading">
|
||||||
|
<RefreshCw :class="['h-4 w-4', vncLoading ? 'animate-spin' : '']" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div :class="['w-2 h-2 rounded-full', getVncStatusClass(vncStatus?.service_status)]" />
|
||||||
|
<span class="text-sm">{{ getVncServiceStatusText(vncStatus?.service_status) }}</span>
|
||||||
|
<template v-if="vncStatus?.connection_count">
|
||||||
|
<span class="text-muted-foreground">|</span>
|
||||||
|
<span class="text-sm text-muted-foreground">{{ t('extensions.vnc.clients', { count: vncStatus.connection_count }) }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="vncStatus?.service_status !== 'running' && vncStatus?.service_status !== 'starting'"
|
||||||
|
size="sm"
|
||||||
|
@click="startVnc"
|
||||||
|
:disabled="vncLoading || vncStatus?.service_status === 'starting'"
|
||||||
|
>
|
||||||
|
<Play class="h-4 w-4 mr-1" />
|
||||||
|
{{ t('extensions.start') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
@click="stopVnc"
|
||||||
|
:disabled="vncLoading"
|
||||||
|
>
|
||||||
|
<Square class="h-4 w-4 mr-1" />
|
||||||
|
{{ t('extensions.stop') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label>{{ t('extensions.autoStart') }}</Label>
|
||||||
|
<Switch v-model="vncLocalConfig.enabled" :disabled="vncStatus?.service_status === 'running'" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.vnc.bind') }}</Label>
|
||||||
|
<Input v-model="vncLocalConfig.bind" class="sm:col-span-3" placeholder="0.0.0.0" :disabled="vncStatus?.service_status === 'running'" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.vnc.port') }}</Label>
|
||||||
|
<Input v-model.number="vncLocalConfig.port" class="sm:col-span-3" type="number" min="1" max="65535" :disabled="vncStatus?.service_status === 'running'" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.vnc.encoding') }}</Label>
|
||||||
|
<div class="sm:col-span-3 space-y-1">
|
||||||
|
<select v-model="vncLocalConfig.encoding" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="vncStatus?.service_status === 'running'">
|
||||||
|
<option value="tight_jpeg">{{ t('extensions.vnc.encodingTightJpeg') }}</option>
|
||||||
|
<option value="h264">{{ t('extensions.vnc.encodingH264') }}</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('extensions.vnc.encodingHint') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.vnc.jpegQuality') }}</Label>
|
||||||
|
<Input v-model.number="vncLocalConfig.jpeg_quality" class="sm:col-span-3" type="number" min="10" max="100" :disabled="vncStatus?.service_status === 'running' || vncLocalConfig.encoding !== 'tight_jpeg'" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label>{{ t('extensions.vnc.allowOneClient') }}</Label>
|
||||||
|
<Switch v-model="vncLocalConfig.allow_one_client" :disabled="vncStatus?.service_status === 'running'" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.vnc.password') }}</Label>
|
||||||
|
<div class="sm:col-span-3 space-y-1">
|
||||||
|
<div class="relative">
|
||||||
|
<Input
|
||||||
|
v-model="vncLocalConfig.password"
|
||||||
|
:type="showPasswords ? 'text' : 'password'"
|
||||||
|
maxlength="8"
|
||||||
|
autocomplete="off"
|
||||||
|
:placeholder="vncStatus?.config.has_password ? t('extensions.vnc.passwordPlaceholder') : t('extensions.vnc.passwordRequiredPlaceholder')"
|
||||||
|
:disabled="vncStatus?.service_status === 'running'"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||||
|
:aria-label="showPasswords ? t('extensions.rustdesk.hidePassword') : t('extensions.rustdesk.showPassword')"
|
||||||
|
@click="showPasswords = !showPasswords"
|
||||||
|
>
|
||||||
|
<Eye v-if="!showPasswords" class="h-4 w-4" />
|
||||||
|
<EyeOff v-else class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="vncStatus?.config.has_password" class="text-xs text-muted-foreground">{{ t('extensions.vnc.passwordSaved') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="rounded-md border p-3 bg-muted/20 space-y-1">
|
||||||
|
<p class="text-sm font-medium">{{ t('extensions.vnc.urlPreview') }}</p>
|
||||||
|
<code class="font-mono text-sm break-all">{{ vncStreamUrl }}</code>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button :disabled="loading || vncLoading || vncStatus?.service_status === 'running'" @click="saveVncConfig">
|
||||||
|
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- RustDesk Section -->
|
<!-- RustDesk Section -->
|
||||||
<div v-show="activeSection === 'ext-rustdesk'" class="space-y-6">
|
<div v-show="activeSection === 'third-party-access'" class="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -4692,6 +4956,16 @@ watch(isWindows, () => {
|
|||||||
<Label>{{ t('extensions.autoStart') }}</Label>
|
<Label>{{ t('extensions.autoStart') }}</Label>
|
||||||
<Switch v-model="rustdeskLocalConfig.enabled" :disabled="rustdeskStatus?.service_status === 'running'" />
|
<Switch v-model="rustdeskLocalConfig.enabled" :disabled="rustdeskStatus?.service_status === 'running'" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.rustdesk.codec') }}</Label>
|
||||||
|
<div class="sm:col-span-3 space-y-1">
|
||||||
|
<select v-model="rustdeskLocalConfig.codec" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="rustdeskStatus?.service_status === 'running'">
|
||||||
|
<option value="h264">H.264</option>
|
||||||
|
<option value="h265">H.265</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.codecHint') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
<Label class="sm:text-right">{{ t('extensions.rustdesk.rendezvousServer') }}</Label>
|
<Label class="sm:text-right">{{ t('extensions.rustdesk.rendezvousServer') }}</Label>
|
||||||
<div class="sm:col-span-3 space-y-1">
|
<div class="sm:col-span-3 space-y-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user