diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bca5cb58..3fe864aa 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3554,7 +3554,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f910b8dc8e7b8e001c61fd742be442e8604fb2499ded713cadf911e7645ade4" dependencies = [ "ironrdp-client", + "ironrdp-cliprdr", "ironrdp-connector", + "ironrdp-core", "ironrdp-input", "ironrdp-mstsgu", "ironrdp-pdu", @@ -3595,6 +3597,8 @@ dependencies = [ "anyhow", "futures-util", "ironrdp-cfg", + "ironrdp-cliprdr", + "ironrdp-cliprdr-native", "ironrdp-connector", "ironrdp-core", "ironrdp-displaycontrol", @@ -3617,6 +3621,31 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "ironrdp-cliprdr" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb9050999a1e032f4313788ac5a6d06e897a4f672f1de2ed98683001fc1abf56" +dependencies = [ + "bitflags 2.11.1", + "ironrdp-core", + "ironrdp-pdu", + "ironrdp-svc", + "tracing", +] + +[[package]] +name = "ironrdp-cliprdr-native" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95cc7f249eb8a5c507fba3a5ee2ba57da716d0881c46421eab79e6fab1b51ae5" +dependencies = [ + "ironrdp-cliprdr", + "ironrdp-core", + "tracing", + "windows 0.62.2", +] + [[package]] name = "ironrdp-connector" version = "0.10.0" @@ -4943,6 +4972,7 @@ dependencies = [ "window-vibrancy", "windows 0.61.3", "windows-sys 0.61.2", + "x509-cert", "zeroize", "zip 8.2.0", "zmodem2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2993ed39..d697530d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -157,10 +157,11 @@ encoding_rs = "0.8" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "gif", "webp"] } rqrr = "0.9" serialport = "4" -ironrdp = { version = "0.17", default-features = false, features = ["client", "connector", "rustls", "input", "pdu"] } +ironrdp = { version = "0.17", default-features = false, features = ["client", "client-clipboard", "cliprdr", "connector", "core", "rustls", "input", "pdu"] } smallvec = "1" strip-ansi-escapes = "0.2" arboard = { version = "3", features = ["wayland-data-control"] } +x509-cert = { version = "0.2", default-features = false, features = ["std"] } opendal = { version = "0.58", default-features = false, features = [ "executors-tokio", "reqwest-rustls-tls", diff --git a/src-tauri/src/cmd/rdp.rs b/src-tauri/src/cmd/rdp.rs index 3266bd97..a4390d8e 100644 --- a/src-tauri/src/cmd/rdp.rs +++ b/src-tauri/src/cmd/rdp.rs @@ -79,10 +79,12 @@ pub async fn close_rdp_session( #[tauri::command] pub async fn respond_rdp_certificate( - _state: tauri::State<'_, Arc>, - _request_id: String, - _accepted: bool, - _remember: bool, + state: tauri::State<'_, Arc>, + request_id: String, + accepted: bool, + remember: bool, ) -> AppResult<()> { - Ok(()) + state + .respond_certificate(&request_id, accepted, remember) + .await } diff --git a/src-tauri/src/core/rdp.rs b/src-tauri/src/core/rdp.rs index 647411b7..1ff33c3d 100644 --- a/src-tauri/src/core/rdp.rs +++ b/src-tauri/src/core/rdp.rs @@ -1,13 +1,25 @@ use crate::config::{self, ConnectionAuth, ConnectionType}; use crate::error::{AppError, AppResult}; use async_trait::async_trait; +use base64::Engine as _; use ironrdp::client::config::{ - Config as IronRdpConfig, ConfigBuilder as IronRdpConfigBuilder, - Destination as IronRdpDestination, TransportKind as IronRdpTransportKind, + ClipboardType as IronRdpClipboardType, Config as IronRdpConfig, + ConfigBuilder as IronRdpConfigBuilder, Destination as IronRdpDestination, + ServerCertificateVerifier, ServerCertificateVerifyFuture, + TransportKind as IronRdpTransportKind, }; use ironrdp::client::rdp::{ RdpClient as IronRdpClient, RdpInputEvent as IronRdpInputEvent, RdpOutputEvent, }; +use ironrdp::cliprdr::backend::{ + ClipboardMessage, ClipboardMessageProxy, CliprdrBackend, CliprdrBackendFactory, +}; +use ironrdp::cliprdr::pdu::{ + ClipboardFormat, ClipboardFormatId, ClipboardGeneralCapabilityFlags, FileContentsRequest, + FileContentsResponse, FormatDataRequest, FormatDataResponse, LockDataId, + OwnedFormatDataResponse, +}; +use ironrdp::core::impl_as_any; use ironrdp::input::{ Database as IronRdpInputDatabase, MouseButton as IronRdpMouseButton, MousePosition as IronRdpMousePosition, Operation as IronRdpInputOperation, @@ -15,17 +27,30 @@ use ironrdp::input::{ }; use ironrdp::pdu::rdp::capability_sets::MajorPlatformType; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::{HashMap, VecDeque}; +use std::fmt; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::JoinHandle; +use tauri::async_runtime::JoinHandle as TauriJoinHandle; use tauri::ipc::{Channel, InvokeResponseBody}; use tauri::{AppHandle, Emitter}; -use tokio::sync::{Mutex, mpsc}; +use tokio::sync::{Mutex, mpsc, oneshot}; use tokio::time::{Duration, sleep}; +use x509_cert::der::Decode as _; const FRAME_HEADER_BYTES: usize = 44; -const PIXEL_FORMAT_BGRA8888: u32 = 1; const PIXEL_FORMAT_RGBA8888: u32 = 2; const MAX_FRAME_QUEUE: usize = 2; +const MAX_CLIPBOARD_TEXT_BYTES: usize = 16 * 1024 * 1024; +const CLIPBOARD_POLL_INTERVAL: Duration = Duration::from_millis(750); +const CLIPBOARD_TIMEOUT: Duration = Duration::from_millis(1000); +const CERTIFICATE_PROMPT_TIMEOUT: Duration = Duration::from_secs(120); +const RDP_MIN_WIDTH: u32 = 640; +const RDP_MIN_HEIGHT: u32 = 480; +const RDP_MAX_WIDTH: u32 = 7680; +const RDP_MAX_HEIGHT: u32 = 4320; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case")] @@ -41,12 +66,61 @@ pub enum RdpSessionState { Failed, } +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RdpErrorKind { + Transport, + Tls, + Certificate, + Authentication, + Negotiation, + Session, + Clipboard, + Internal, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct RdpStateEvent { pub session_id: String, pub state: RdpSessionState, pub message: Option, + pub error_kind: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RdpCertificateVerifyEvent { + pub request_id: String, + pub session_id: String, + pub host: String, + pub port: u16, + pub fingerprint: String, + pub subject: Option, + pub issuer: Option, + pub valid_from: Option, + pub valid_to: Option, + pub known_host_status: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum RdpPointerEvent { + #[serde(rename = "default")] + Default { session_id: String }, + #[serde(rename = "hidden")] + Hidden { session_id: String }, + #[serde(rename = "position")] + Position { session_id: String, x: u16, y: u16 }, + #[serde(rename = "bitmap")] + Bitmap { + session_id: String, + width: u16, + height: u16, + hotspot_x: u16, + hotspot_y: u16, + rgba_base64: String, + }, } #[derive(Debug, Clone, Deserialize)] @@ -100,8 +174,12 @@ pub struct RdpConnectConfig { pub password: Option, pub width: u32, pub height: u32, + pub display_mode: String, pub use_nla: bool, pub certificate_policy: String, + pub clipboard_mode: String, + pub reconnect_enabled: bool, + pub reconnect_max_attempts: u32, pub color_depth: u8, } @@ -109,17 +187,43 @@ pub struct RdpSession { pub config: RdpConnectConfig, pub state: Mutex, message: Mutex>, + error_kind: Mutex>, generation: Mutex, frame_channel: Mutex>>, pending_frames: Mutex>>, input_sender: Mutex>>, input_database: Mutex, frame_sequence: Mutex, + worker: Mutex>, + reconnect_attempts: Mutex, + reconnect_task: Mutex>>, + clipboard_bridge: Mutex>>, + close_requested: AtomicBool, + pending_certificates: Arc>>, } pub struct RdpSessionManager { sessions: Mutex>>, engine: Arc, + pending_certificates: Arc>>, +} + +struct RdpWorker { + generation: u64, + input_sender: mpsc::UnboundedSender, + join_handle: Option>, +} + +struct RdpCertificatePending { + session_id: String, + generation: u64, + responder: oneshot::Sender, +} + +#[derive(Debug, Clone)] +struct RdpCertificateDecision { + accepted: bool, + remember: bool, } #[async_trait] @@ -152,6 +256,7 @@ impl RdpSessionManager { Self { sessions: Mutex::new(HashMap::new()), engine, + pending_certificates: Arc::new(Mutex::new(HashMap::new())), } } @@ -165,12 +270,19 @@ impl RdpSessionManager { config, state: Mutex::new(RdpSessionState::Connecting), message: Mutex::new(Some("Establishing RDP transport".to_string())), + error_kind: Mutex::new(None), generation: Mutex::new(0), frame_channel: Mutex::new(None), pending_frames: Mutex::new(VecDeque::with_capacity(MAX_FRAME_QUEUE)), input_sender: Mutex::new(None), input_database: Mutex::new(IronRdpInputDatabase::new()), frame_sequence: Mutex::new(0), + worker: Mutex::new(None), + reconnect_attempts: Mutex::new(0), + reconnect_task: Mutex::new(None), + clipboard_bridge: Mutex::new(None), + close_requested: AtomicBool::new(false), + pending_certificates: self.pending_certificates.clone(), }); self.sessions .lock() @@ -182,6 +294,7 @@ impl RdpSessionManager { &session_id, RdpSessionState::Connecting, Some("Establishing RDP transport".to_string()), + None, ); self.engine.connect(app, session, 0).await; Ok(session_id) @@ -213,7 +326,9 @@ impl RdpSessionManager { pub async fn resize(&self, session_id: &str, width: u32, height: u32) -> AppResult<()> { let session = self.get(session_id).await?; - if !(640..=7680).contains(&width) || !(480..=4320).contains(&height) { + if !(RDP_MIN_WIDTH..=RDP_MAX_WIDTH).contains(&width) + || !(RDP_MIN_HEIGHT..=RDP_MAX_HEIGHT).contains(&height) + { return Err(AppError::Config( "RDP resize is outside the supported range".to_string(), )); @@ -223,7 +338,7 @@ impl RdpSessionManager { pub async fn set_clipboard_text(&self, session_id: &str, text: String) -> AppResult<()> { let session = self.get(session_id).await?; - if text.len() > 16 * 1024 * 1024 { + if text.len() > MAX_CLIPBOARD_TEXT_BYTES { return Err(AppError::Config( "RDP clipboard text is too large".to_string(), )); @@ -233,11 +348,18 @@ impl RdpSessionManager { pub async fn reconnect(&self, app: AppHandle, session_id: &str) -> AppResult<()> { let session = self.get(session_id).await?; + cancel_reconnect_task(&session).await; + self.cancel_pending_certificates_for_session(session_id) + .await; + session.close_requested.store(false, Ordering::SeqCst); let generation = bump_generation(&session).await; + *session.reconnect_attempts.lock().await = 0; + shutdown_worker(&session).await; set_state( &session, RdpSessionState::Reconnecting, Some("Reconnecting RDP session".to_string()), + None, ) .await; emit_state( @@ -245,6 +367,7 @@ impl RdpSessionManager { session_id, RdpSessionState::Reconnecting, Some("Reconnecting RDP session".to_string()), + None, ); self.engine.connect(app, session, generation).await; Ok(()) @@ -253,25 +376,74 @@ impl RdpSessionManager { pub async fn close(&self, app: &AppHandle, session_id: &str) -> AppResult<()> { let removed = self.sessions.lock().await.remove(session_id); if let Some(session) = removed { + session.close_requested.store(true, Ordering::SeqCst); + cancel_reconnect_task(&session).await; + self.cancel_pending_certificates_for_session(session_id) + .await; bump_generation(&session).await; set_state( &session, RdpSessionState::Disconnected, Some("RDP session closed".to_string()), + None, ) .await; + stop_clipboard_bridge(&session).await; self.engine.close(session).await?; emit_state( app, session_id, RdpSessionState::Disconnected, Some("RDP session closed".to_string()), + None, ); let _ = app.emit("sessions-changed", ()); } Ok(()) } + pub async fn respond_certificate( + &self, + request_id: &str, + accepted: bool, + remember: bool, + ) -> AppResult<()> { + let Some(pending) = self.pending_certificates.lock().await.remove(request_id) else { + return Err(AppError::Cancelled(format!( + "No pending RDP certificate request with id '{request_id}'" + ))); + }; + + if let Ok(session) = self.get(&pending.session_id).await { + if *session.generation.lock().await != pending.generation { + return Ok(()); + } + } + + let _ = pending + .responder + .send(RdpCertificateDecision { accepted, remember }); + Ok(()) + } + + async fn cancel_pending_certificates_for_session(&self, session_id: &str) { + let mut pending = self.pending_certificates.lock().await; + let request_ids = pending + .iter() + .filter_map(|(request_id, request)| { + (request.session_id == session_id).then(|| request_id.clone()) + }) + .collect::>(); + for request_id in request_ids { + if let Some(request) = pending.remove(&request_id) { + let _ = request.responder.send(RdpCertificateDecision { + accepted: false, + remember: false, + }); + } + } + } + async fn get(&self, session_id: &str) -> AppResult> { self.sessions .lock() @@ -293,7 +465,7 @@ impl Default for RdpSessionManager { #[async_trait] impl RdpEngine for IronRdpEngine { async fn connect(&self, app: AppHandle, session: Arc, generation: u64) { - spawn_ironrdp_engine(app, session, generation).await; + spawn_ironrdp_engine(app, session, generation); } async fn send_input( @@ -348,12 +520,32 @@ impl RdpEngine for IronRdpEngine { } async fn set_clipboard_text(&self, session: Arc, text: String) -> AppResult<()> { - self.send_input(session, vec![RdpInputEvent::Unicode { text }]) + if session.config.clipboard_mode == "disabled" { + return Err(AppError::Config( + "RDP clipboard redirection is disabled".to_string(), + )); + } + + let bridge = session + .clipboard_bridge + .lock() .await + .clone() + .ok_or_else(|| { + AppError::SessionNotFound("RDP clipboard bridge is not available".to_string()) + })?; + let text_for_clipboard = text.clone(); + tokio::task::spawn_blocking(move || write_clipboard_text_blocking(text_for_clipboard)) + .await + .map_err(|error| AppError::Channel(format!("RDP clipboard task failed: {error}")))? + .map_err(AppError::Channel)?; + bridge.mark_text_written_from_remote(&text); + bridge.notify_text_available().map_err(AppError::Channel)?; + Ok(()) } async fn close(&self, session: Arc) -> AppResult<()> { - close_current_input_sender(&session).await; + shutdown_worker(&session).await; Ok(()) } } @@ -368,6 +560,8 @@ pub fn load_saved_rdp_config(app: &AppHandle, connection_id: &str) -> AppResult< domain, display, security, + clipboard, + reconnect, .. } = conn.config else { @@ -387,8 +581,12 @@ pub fn load_saved_rdp_config(app: &AppHandle, connection_id: &str) -> AppResult< password, width: display.width, height: display.height, + display_mode: display.mode, use_nla: security.use_nla, certificate_policy: security.certificate_policy, + clipboard_mode: clipboard.mode, + reconnect_enabled: reconnect.enabled, + reconnect_max_attempts: reconnect.max_attempts, color_depth: display.color_depth, }) } @@ -409,214 +607,262 @@ fn resolve_rdp_password( crate::utils::crypto::decrypt_optional(&auth.password) } -fn emit_state(app: &AppHandle, session_id: &str, state: RdpSessionState, message: Option) { +fn emit_state( + app: &AppHandle, + session_id: &str, + state: RdpSessionState, + message: Option, + error_kind: Option, +) { let payload = RdpStateEvent { session_id: session_id.to_string(), state, message, + error_kind, }; let _ = app.emit(format!("rdp-state-{session_id}").as_str(), payload); } -#[allow(dead_code)] -fn spawn_placeholder_engine(app: AppHandle, session: Arc, generation: u64) { +fn spawn_ironrdp_engine(app: AppHandle, session: Arc, generation: u64) { tauri::async_runtime::spawn(async move { - let session_id = session.config.session_id.clone(); - set_state( - &session, - RdpSessionState::Negotiating, - Some("Preparing RDP graphics pipeline".to_string()), - ) - .await; - emit_state( - &app, - &session_id, - RdpSessionState::Negotiating, - Some("Preparing RDP graphics pipeline".to_string()), - ); - sleep(Duration::from_millis(150)).await; - set_state(&session, RdpSessionState::Active, None).await; - emit_state(&app, &session_id, RdpSessionState::Active, None); - let _ = app.emit("sessions-changed", ()); + shutdown_worker(&session).await; - let mut sequence = 0_u64; - loop { + let iron_config = match build_ironrdp_config(&app, &session, generation).await { + Ok(config) => config, + Err(message) => { + fail_rdp_session( + &app, + &session, + generation, + message, + RdpErrorKind::Internal, + false, + ) + .await; + return; + } + }; + + tauri::async_runtime::spawn(async move { + let session_id = session.config.session_id.clone(); if *session.generation.lock().await != generation { - break; - } - let state = session.state.lock().await.clone(); - if !matches!( - state, - RdpSessionState::Active | RdpSessionState::Reconnecting - ) { - break; - } - let frame = build_placeholder_frame(&session.config, sequence); - sequence = sequence.wrapping_add(1); - queue_or_send_frame(&session, frame).await; - sleep(Duration::from_millis(1000)).await; - } - }); -} - -async fn spawn_ironrdp_engine(app: AppHandle, session: Arc, generation: u64) { - close_current_input_sender(&session).await; - - let iron_config = match build_ironrdp_config(&session.config) { - Ok(config) => config, - Err(message) => { - fail_rdp_session(&app, &session, generation, message).await; - return; - } - }; - - tauri::async_runtime::spawn(async move { - let session_id = session.config.session_id.clone(); - if *session.generation.lock().await != generation { - return; - } - - set_state( - &session, - RdpSessionState::Authenticating, - Some("Authenticating RDP session".to_string()), - ) - .await; - emit_state( - &app, - &session_id, - RdpSessionState::Authenticating, - Some("Authenticating RDP session".to_string()), - ); - - let (output_sender, mut output_receiver) = mpsc::channel(2); - let client = IronRdpClient::new(iron_config, output_sender); - let input_sender = client.input_sender(); - { - *session.input_sender.lock().await = Some(input_sender); - *session.input_database.lock().await = IronRdpInputDatabase::new(); - *session.frame_sequence.lock().await = 0; - } - - std::thread::spawn(move || { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build(); - match runtime { - Ok(runtime) => runtime.block_on(client.run()), - Err(error) => tracing::error!(%error, "Failed to start IronRDP runtime"), - } - }); - let mut saw_terminal_event = false; - - while let Some(event) = output_receiver.recv().await { - if *session.generation.lock().await != generation { - close_current_input_sender(&session).await; - break; + return; } - match event { - RdpOutputEvent::ImagePatch { - buffer, - desktop_width, - desktop_height, - x, - y, - width, - height, - } => { - let was_active = matches!(*session.state.lock().await, RdpSessionState::Active); - if !was_active { - set_state(&session, RdpSessionState::Active, None).await; - emit_state(&app, &session_id, RdpSessionState::Active, None); - let _ = app.emit("sessions-changed", ()); - } - let sequence = next_frame_sequence(&session).await; - let frame = build_frame_from_ironrdp_image( - &buffer, + set_state( + &session, + RdpSessionState::Authenticating, + Some("Authenticating RDP session".to_string()), + None, + ) + .await; + emit_state( + &app, + &session_id, + RdpSessionState::Authenticating, + Some("Authenticating RDP session".to_string()), + None, + ); + + let (output_sender, mut output_receiver) = mpsc::channel(2); + let client = IronRdpClient::new(iron_config, output_sender); + let input_sender = client.input_sender(); + { + *session.input_sender.lock().await = Some(input_sender.clone()); + *session.input_database.lock().await = IronRdpInputDatabase::new(); + *session.frame_sequence.lock().await = 0; + } + + let join_handle = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + match runtime { + Ok(runtime) => runtime.block_on(client.run()), + Err(error) => tracing::error!(%error, "Failed to start IronRDP runtime"), + } + }); + { + *session.worker.lock().await = Some(RdpWorker { + generation, + input_sender, + join_handle: Some(join_handle), + }); + } + let mut saw_terminal_event = false; + + while let Some(event) = output_receiver.recv().await { + if *session.generation.lock().await != generation { + shutdown_worker(&session).await; + break; + } + + match event { + RdpOutputEvent::ImagePatch { + buffer, desktop_width, desktop_height, x, y, width, height, - sequence, - ); - queue_or_send_frame(&session, frame).await; - } - RdpOutputEvent::ConnectionFailure(error) => { - saw_terminal_event = true; - let message = format!("RDP connection failed: {error:?}"); - tracing::warn!( - session_id = %session_id, - host = %session.config.host, - port = session.config.port, - error = %message, - "RDP connection failed" - ); - fail_rdp_session(&app, &session, generation, message).await; - break; - } - RdpOutputEvent::Terminated(result) => { - saw_terminal_event = true; - match result { - Ok(reason) => { - let message = format!("RDP session disconnected: {reason}"); - set_state( - &session, - RdpSessionState::Disconnected, - Some(message.clone()), - ) - .await; - emit_state( - &app, - &session_id, - RdpSessionState::Disconnected, - Some(message), - ); + } => { + let was_active = + matches!(*session.state.lock().await, RdpSessionState::Active); + if !was_active { + *session.reconnect_attempts.lock().await = 0; + set_state(&session, RdpSessionState::Active, None, None).await; + emit_state(&app, &session_id, RdpSessionState::Active, None, None); let _ = app.emit("sessions-changed", ()); } - Err(error) => { - let message = format!("RDP session error: {error:?}"); - tracing::warn!( - session_id = %session_id, - host = %session.config.host, - port = session.config.port, - error = %message, - "RDP active session failed" - ); - fail_rdp_session(&app, &session, generation, message).await; - } + let sequence = next_frame_sequence(&session).await; + let frame = build_frame_from_ironrdp_image( + &buffer, + desktop_width, + desktop_height, + x, + y, + width, + height, + sequence, + ); + queue_or_send_frame(&session, frame).await; } - break; + RdpOutputEvent::ConnectionFailure(error) => { + saw_terminal_event = true; + let (error_kind, allow_reconnect) = classify_connector_error(&error); + let message = user_facing_connector_error(&error, error_kind); + tracing::warn!( + session_id = %session_id, + host = %session.config.host, + port = session.config.port, + error = ?error, + error_kind = ?error_kind, + "RDP connection failed" + ); + fail_rdp_session( + &app, + &session, + generation, + message, + error_kind, + allow_reconnect, + ) + .await; + break; + } + RdpOutputEvent::Terminated(result) => { + saw_terminal_event = true; + match result { + Ok(reason) => { + let message = format!("RDP session disconnected: {reason}"); + set_state( + &session, + RdpSessionState::Disconnected, + Some(message.clone()), + None, + ) + .await; + emit_state( + &app, + &session_id, + RdpSessionState::Disconnected, + Some(message), + None, + ); + let _ = app.emit("sessions-changed", ()); + } + Err(error) => { + let (error_kind, allow_reconnect) = classify_session_error(&error); + let message = user_facing_session_error(&error, error_kind); + tracing::warn!( + session_id = %session_id, + host = %session.config.host, + port = session.config.port, + error = ?error, + error_kind = ?error_kind, + "RDP active session failed" + ); + fail_rdp_session( + &app, + &session, + generation, + message, + error_kind, + allow_reconnect, + ) + .await; + } + } + break; + } + RdpOutputEvent::PointerDefault => emit_pointer_event( + &app, + &session_id, + RdpPointerEvent::Default { + session_id: session_id.clone(), + }, + ), + RdpOutputEvent::PointerHidden => emit_pointer_event( + &app, + &session_id, + RdpPointerEvent::Hidden { + session_id: session_id.clone(), + }, + ), + RdpOutputEvent::PointerPosition { x, y } => emit_pointer_event( + &app, + &session_id, + RdpPointerEvent::Position { + session_id: session_id.clone(), + x, + y, + }, + ), + RdpOutputEvent::PointerBitmap(pointer) => emit_pointer_event( + &app, + &session_id, + RdpPointerEvent::Bitmap { + session_id: session_id.clone(), + width: pointer.width, + height: pointer.height, + hotspot_x: pointer.hotspot_x, + hotspot_y: pointer.hotspot_y, + rgba_base64: base64::engine::general_purpose::STANDARD + .encode(&pointer.bitmap_data), + }, + ), } - RdpOutputEvent::PointerDefault - | RdpOutputEvent::PointerHidden - | RdpOutputEvent::PointerPosition { .. } - | RdpOutputEvent::PointerBitmap(_) => {} } - } - close_current_input_sender(&session).await; - if !saw_terminal_event && *session.generation.lock().await == generation { - set_state( - &session, - RdpSessionState::Disconnected, - Some("RDP session ended".to_string()), - ) - .await; - emit_state( - &app, - &session_id, - RdpSessionState::Disconnected, - Some("RDP session ended".to_string()), - ); - let _ = app.emit("sessions-changed", ()); - } + shutdown_worker(&session).await; + if !saw_terminal_event && *session.generation.lock().await == generation { + set_state( + &session, + RdpSessionState::Disconnected, + Some("RDP session ended".to_string()), + None, + ) + .await; + emit_state( + &app, + &session_id, + RdpSessionState::Disconnected, + Some("RDP session ended".to_string()), + None, + ); + let _ = app.emit("sessions-changed", ()); + } + }); }); } -fn build_ironrdp_config(config: &RdpConnectConfig) -> Result { +async fn build_ironrdp_config( + app: &AppHandle, + session: &Arc, + generation: u64, +) -> Result { + let config = &session.config; let width = u16::try_from(config.width) .map_err(|_| "RDP display width is outside the supported range".to_string())?; let height = u16::try_from(config.height) @@ -626,7 +872,20 @@ fn build_ironrdp_config(config: &RdpConnectConfig) -> Result 32, }; - IronRdpConfigBuilder::new() + if config.display_mode == "native" { + tracing::warn!( + session_id = %config.session_id, + "RDP native display mode is not implemented; using fixed initial size" + ); + } + + let certificate_verifier = Arc::new(NyaTermRdpCertificateVerifier { + app: app.clone(), + session: session.clone(), + generation, + }); + + let mut builder = IronRdpConfigBuilder::new() .with_destination(IronRdpDestination::from_parts( config.host.clone(), config.port, @@ -648,10 +907,461 @@ fn build_ironrdp_config(config: &RdpConnectConfig) -> Result, + generation: u64, +} + +impl fmt::Debug for NyaTermRdpCertificateVerifier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("NyaTermRdpCertificateVerifier") + .field("session_id", &self.session.config.session_id) + .field("generation", &self.generation) + .finish() + } +} + +impl ServerCertificateVerifier for NyaTermRdpCertificateVerifier { + fn verify_server_certificate<'a>( + &'a self, + host: &'a str, + port: u16, + der: Vec, + ) -> ServerCertificateVerifyFuture<'a> { + Box::pin(async move { self.verify(host, port, &der).await }) + } +} + +impl NyaTermRdpCertificateVerifier { + async fn verify(&self, host: &str, port: u16, der: &[u8]) -> Result<(), String> { + if *self.session.generation.lock().await != self.generation { + return Err("certificate rejected: stale RDP connection generation".to_string()); + } + + let fingerprint = rdp_certificate_fingerprint(der); + let metadata = parse_rdp_certificate_metadata(der); + let known_host_status = crate::storage::check_rdp_known_host(host, port, &fingerprint) + .map_err(|error| format!("certificate rejected: {error}"))?; + + match certificate_policy_allows_without_prompt( + &self.session.config.certificate_policy, + known_host_status, + )? { + true => Ok(()), + false => { + self.prompt_for_certificate(host, port, fingerprint, metadata, known_host_status) + .await + } + } + } + + async fn prompt_for_certificate( + &self, + host: &str, + port: u16, + fingerprint: String, + metadata: crate::storage::RdpCertificateMetadata, + known_host_status: crate::storage::KnownHostCheck, + ) -> Result<(), String> { + let request_id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + self.session.pending_certificates.lock().await.insert( + request_id.clone(), + RdpCertificatePending { + session_id: self.session.config.session_id.clone(), + generation: self.generation, + responder: tx, + }, + ); + + set_state( + &self.session, + RdpSessionState::CertificateVerification, + Some("Verifying RDP certificate".to_string()), + None, + ) + .await; + emit_state( + &self.app, + &self.session.config.session_id, + RdpSessionState::CertificateVerification, + Some("Verifying RDP certificate".to_string()), + None, + ); + + let payload = RdpCertificateVerifyEvent { + request_id: request_id.clone(), + session_id: self.session.config.session_id.clone(), + host: host.to_string(), + port, + fingerprint: fingerprint.clone(), + subject: metadata.subject.clone(), + issuer: metadata.issuer.clone(), + valid_from: metadata.valid_from.clone(), + valid_to: metadata.valid_to.clone(), + known_host_status: known_host_status_label(known_host_status).to_string(), + }; + let _ = self.app.emit("rdp-certificate-verify", payload); + + let decision = match tokio::time::timeout(CERTIFICATE_PROMPT_TIMEOUT, rx).await { + Ok(Ok(decision)) => decision, + _ => { + self.session + .pending_certificates + .lock() + .await + .remove(&request_id); + return Err("certificate rejected".to_string()); + } + }; + + if *self.session.generation.lock().await != self.generation { + return Err("certificate rejected: stale RDP connection generation".to_string()); + } + if !decision.accepted { + return Err("certificate rejected".to_string()); + } + if decision.remember { + crate::storage::upsert_rdp_known_host(host, port, &fingerprint, metadata) + .map_err(|error| format!("certificate rejected: {error}"))?; + } + Ok(()) + } +} + +fn rdp_certificate_fingerprint(der: &[u8]) -> String { + let digest = Sha256::digest(der); + format!("SHA256:{}", hex::encode(digest)) +} + +fn parse_rdp_certificate_metadata(der: &[u8]) -> crate::storage::RdpCertificateMetadata { + let Ok(cert) = x509_cert::Certificate::from_der(der) else { + return crate::storage::RdpCertificateMetadata::default(); + }; + crate::storage::RdpCertificateMetadata { + subject: Some(cert.tbs_certificate.subject.to_string()), + issuer: Some(cert.tbs_certificate.issuer.to_string()), + valid_from: Some(cert.tbs_certificate.validity.not_before.to_string()), + valid_to: Some(cert.tbs_certificate.validity.not_after.to_string()), + } +} + +fn known_host_status_label(status: crate::storage::KnownHostCheck) -> &'static str { + match status { + crate::storage::KnownHostCheck::Match => "match", + crate::storage::KnownHostCheck::HostSeen => "changed", + crate::storage::KnownHostCheck::UnknownHost => "unknown", + } +} + +fn certificate_policy_allows_without_prompt( + policy: &str, + status: crate::storage::KnownHostCheck, +) -> Result { + match policy { + "strict" => match status { + crate::storage::KnownHostCheck::Match => Ok(true), + crate::storage::KnownHostCheck::UnknownHost => Err("unknown certificate".to_string()), + crate::storage::KnownHostCheck::HostSeen => { + Err("certificate fingerprint changed".to_string()) + } + }, + "accept-temporarily" => Ok(true), + _ if status == crate::storage::KnownHostCheck::Match => Ok(true), + _ => Ok(false), + } +} + +struct RdpClipboardBridge { + session_id: String, + shutdown: AtomicBool, + watcher_started: AtomicBool, + proxy: std::sync::Mutex>>>>, + last_text_hash: std::sync::Mutex>, +} + +impl fmt::Debug for RdpClipboardBridge { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RdpClipboardBridge") + .field("session_id", &self.session_id) + .field("shutdown", &self.shutdown.load(Ordering::SeqCst)) + .field( + "watcher_started", + &self.watcher_started.load(Ordering::SeqCst), + ) + .finish_non_exhaustive() + } +} + +impl RdpClipboardBridge { + fn new(session_id: String) -> Self { + Self { + session_id, + shutdown: AtomicBool::new(false), + watcher_started: AtomicBool::new(false), + proxy: std::sync::Mutex::new(None), + last_text_hash: std::sync::Mutex::new(None), + } + } + + fn set_proxy(&self, proxy: Arc>>) { + if let Ok(mut current) = self.proxy.lock() { + *current = Some(proxy); + } + } + + fn notify_text_available(&self) -> Result<(), String> { + let proxy = self + .proxy + .lock() + .map_err(|_| "RDP clipboard proxy lock is poisoned".to_string())? + .clone() + .ok_or_else(|| "RDP clipboard channel is not ready".to_string())?; + let proxy = proxy + .lock() + .map_err(|_| "RDP clipboard proxy lock is poisoned".to_string())?; + proxy.send_clipboard_message(ClipboardMessage::SendInitiateCopy(vec![ + ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT), + ])); + Ok(()) + } + + fn start_watcher( + self: &Arc, + proxy: Arc>>, + ) { + if self.watcher_started.swap(true, Ordering::SeqCst) { + return; + } + let bridge = self.clone(); + std::thread::spawn(move || { + while !bridge.shutdown.load(Ordering::SeqCst) { + if let Some(text) = read_clipboard_text_blocking() { + if clipboard_text_within_limit(&text) { + let hash = stable_text_hash(&text); + let changed = if let Ok(mut last) = bridge.last_text_hash.lock() { + if *last == Some(hash) { + false + } else { + *last = Some(hash); + true + } + } else { + false + }; + if changed && let Ok(proxy) = proxy.lock() { + proxy.send_clipboard_message(ClipboardMessage::SendInitiateCopy(vec![ + ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT), + ])); + } + } + } + std::thread::sleep(CLIPBOARD_POLL_INTERVAL); + } + }); + } + + fn stop(&self) { + self.shutdown.store(true, Ordering::SeqCst); + } + + fn mark_text_written_from_remote(&self, text: &str) { + if let Ok(mut last) = self.last_text_hash.lock() { + *last = Some(stable_text_hash(text)); + } + } +} + +struct RdpClipboardBackendFactory { + bridge: Arc, + proxy: Arc>>, +} + +impl RdpClipboardBackendFactory { + fn new(bridge: Arc, proxy: Box) -> Self { + let proxy = Arc::new(std::sync::Mutex::new(proxy)); + bridge.set_proxy(proxy.clone()); + Self { bridge, proxy } + } +} + +impl CliprdrBackendFactory for RdpClipboardBackendFactory { + fn build_cliprdr_backend(&self) -> Box { + Box::new(RdpClipboardBackend { + bridge: self.bridge.clone(), + proxy: self.proxy.clone(), + negotiated_capabilities: ClipboardGeneralCapabilityFlags::empty(), + }) + } +} + +#[derive(Debug)] +struct RdpClipboardBackend { + bridge: Arc, + proxy: Arc>>, + negotiated_capabilities: ClipboardGeneralCapabilityFlags, +} + +impl_as_any!(RdpClipboardBackend); + +impl RdpClipboardBackend { + fn send(&self, message: ClipboardMessage) { + if let Ok(proxy) = self.proxy.lock() { + proxy.send_clipboard_message(message); + } + } + + fn advertise_text_if_available(&self) { + if read_clipboard_text_blocking() + .filter(|text| !text.is_empty() && clipboard_text_within_limit(text)) + .is_some() + { + self.send(ClipboardMessage::SendInitiateCopy(vec![ + ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT), + ])); + } + } +} + +impl CliprdrBackend for RdpClipboardBackend { + fn temporary_directory(&self) -> &str { + ".nyaterm-rdp-cliprdr" + } + + fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags { + ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES + } + + fn on_ready(&mut self) { + self.bridge.start_watcher(self.proxy.clone()); + self.advertise_text_if_available(); + } + + fn on_request_format_list(&mut self) { + self.advertise_text_if_available(); + } + + fn on_process_negotiated_capabilities( + &mut self, + capabilities: ClipboardGeneralCapabilityFlags, + ) { + self.negotiated_capabilities = capabilities; + } + + fn on_remote_copy(&mut self, available_formats: &[ClipboardFormat]) { + if available_formats + .iter() + .any(|format| format.id == ClipboardFormatId::CF_UNICODETEXT) + { + self.send(ClipboardMessage::SendInitiatePaste( + ClipboardFormatId::CF_UNICODETEXT, + )); + } + } + + fn on_format_data_request(&mut self, request: FormatDataRequest) { + if request.format != ClipboardFormatId::CF_UNICODETEXT { + self.send(ClipboardMessage::SendFormatData( + OwnedFormatDataResponse::new_error(), + )); + return; + } + let response = match read_clipboard_text_blocking() { + Some(text) if clipboard_text_within_limit(&text) => { + OwnedFormatDataResponse::new_unicode_string(&text) + } + _ => OwnedFormatDataResponse::new_error(), + }; + self.send(ClipboardMessage::SendFormatData(response)); + } + + fn on_format_data_response(&mut self, response: FormatDataResponse<'_>) { + if response.is_error() { + return; + } + let Ok(text) = response.to_unicode_string() else { + return; + }; + if !clipboard_text_within_limit(&text) { + tracing::warn!( + session_id = %self.bridge.session_id, + "Ignoring oversized RDP clipboard text from remote" + ); + return; + } + self.bridge.mark_text_written_from_remote(&text); + std::thread::spawn(move || { + let _ = write_clipboard_text_blocking(text); + }); + } + + fn on_file_contents_request(&mut self, request: FileContentsRequest) { + self.send(ClipboardMessage::SendFileContentsResponse( + FileContentsResponse::new_error(request.stream_id), + )); + } + + fn on_file_contents_response(&mut self, _response: FileContentsResponse<'_>) {} + + fn on_lock(&mut self, _data_id: LockDataId) {} + + fn on_unlock(&mut self, _data_id: LockDataId) {} +} + +fn read_clipboard_text_blocking() -> Option { + let start = std::time::Instant::now(); + let mut clipboard = arboard::Clipboard::new().ok()?; + if start.elapsed() > CLIPBOARD_TIMEOUT { + return None; + } + clipboard.get_text().ok() +} + +fn write_clipboard_text_blocking(text: String) -> Result<(), String> { + let start = std::time::Instant::now(); + let mut clipboard = + arboard::Clipboard::new().map_err(|error| format!("failed to open clipboard: {error}"))?; + if start.elapsed() > CLIPBOARD_TIMEOUT { + return Err("clipboard write timed out".to_string()); + } + clipboard + .set_text(text) + .map_err(|error| format!("failed to write clipboard text: {error}")) +} + +fn clipboard_text_within_limit(text: &str) -> bool { + text.len() <= MAX_CLIPBOARD_TEXT_BYTES +} + +fn stable_text_hash(text: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() +} + fn rdp_input_to_operations(event: RdpInputEvent) -> Option> { match event { RdpInputEvent::KeyDown { @@ -762,16 +1472,217 @@ async fn close_current_input_sender(session: &RdpSession) { } } -async fn fail_rdp_session(app: &AppHandle, session: &RdpSession, generation: u64, message: String) { +async fn shutdown_worker(session: &RdpSession) { + close_current_input_sender(session).await; + let mut worker = session.worker.lock().await; + if let Some(mut worker) = worker.take() { + tracing::debug!( + session_id = %session.config.session_id, + generation = worker.generation, + "Shutting down RDP worker" + ); + let _ = worker.input_sender.send(IronRdpInputEvent::Close); + if let Some(handle) = worker.join_handle.take() { + if handle.is_finished() { + let _ = handle.join(); + } + } + } +} + +async fn stop_clipboard_bridge(session: &RdpSession) { + if let Some(bridge) = session.clipboard_bridge.lock().await.take() { + bridge.stop(); + } +} + +async fn cancel_reconnect_task(session: &RdpSession) { + if let Some(task) = session.reconnect_task.lock().await.take() { + task.abort(); + } +} + +async fn schedule_auto_reconnect( + app: AppHandle, + session: Arc, + failed_generation: u64, + failure_message: String, + error_kind: RdpErrorKind, +) -> bool { + if session.close_requested.load(Ordering::SeqCst) || !session.config.reconnect_enabled { + return false; + } + let mut attempts = session.reconnect_attempts.lock().await; + if *attempts >= session.config.reconnect_max_attempts { + return false; + } + *attempts += 1; + let attempt = *attempts; + drop(attempts); + + let delay = reconnect_delay(attempt); + let max_attempts = session.config.reconnect_max_attempts; + let session_id = session.config.session_id.clone(); + let message = format!("Reconnecting ({attempt}/{max_attempts})"); + + set_state( + &session, + RdpSessionState::Reconnecting, + Some(message.clone()), + Some(error_kind), + ) + .await; + emit_state( + &app, + &session_id, + RdpSessionState::Reconnecting, + Some(message), + Some(error_kind), + ); + + let task_session = session.clone(); + let task_app = app.clone(); + let task = tauri::async_runtime::spawn(async move { + sleep(delay).await; + if task_session.close_requested.load(Ordering::SeqCst) { + return; + } + if *task_session.generation.lock().await != failed_generation { + return; + } + let generation = bump_generation(&task_session).await; + spawn_ironrdp_engine(task_app, task_session, generation); + }); + *session.reconnect_task.lock().await = Some(task); + tracing::warn!( + session_id = %session_id, + attempt, + max_attempts, + delay_ms = delay.as_millis(), + error = %failure_message, + "Scheduled automatic RDP reconnect" + ); + true +} + +fn reconnect_delay(attempt: u32) -> Duration { + let base = match attempt { + 0 | 1 => 1_000, + 2 => 2_000, + 3 => 4_000, + 4 => 8_000, + 5 => 15_000, + _ => 30_000, + }; + let jitter = u64::from(rand::random::() % 250); + Duration::from_millis(base + jitter) +} + +fn emit_pointer_event(app: &AppHandle, session_id: &str, event: RdpPointerEvent) { + let _ = app.emit(format!("rdp-pointer-{session_id}").as_str(), event); +} + +fn classify_connector_error(error: &ironrdp::connector::ConnectorError) -> (RdpErrorKind, bool) { + let text = format!("{error:?}").to_ascii_lowercase(); + if text.contains("certificate") { + return (RdpErrorKind::Certificate, false); + } + if text.contains("credssp") + || text.contains("authentication") + || text.contains("password") + || text.contains("logon") + || text.contains("access denied") + { + return (RdpErrorKind::Authentication, false); + } + if text.contains("negotiation") || text.contains("x224") || text.contains("nla") { + return (RdpErrorKind::Negotiation, false); + } + if text.contains("tls") { + return (RdpErrorKind::Tls, true); + } + (RdpErrorKind::Transport, true) +} + +fn classify_session_error(error: &E) -> (RdpErrorKind, bool) { + let text = format!("{error:?}").to_ascii_lowercase(); + if text.contains("authentication") || text.contains("password") { + return (RdpErrorKind::Authentication, false); + } + if text.contains("clipboard") || text.contains("cliprdr") { + return (RdpErrorKind::Clipboard, false); + } + if text.contains("eof") + || text.contains("reset") + || text.contains("timeout") + || text.contains("broken pipe") + || text.contains("connection aborted") + || text.contains("transport") + { + return (RdpErrorKind::Transport, true); + } + (RdpErrorKind::Session, true) +} + +fn user_facing_connector_error( + error: &ironrdp::connector::ConnectorError, + kind: RdpErrorKind, +) -> String { + match kind { + RdpErrorKind::Certificate => format!("RDP certificate error: {error:?}"), + RdpErrorKind::Authentication => "RDP authentication failed".to_string(), + RdpErrorKind::Negotiation => format!("RDP negotiation failed: {error:?}"), + RdpErrorKind::Tls => format!("RDP TLS connection failed: {error:?}"), + RdpErrorKind::Transport => format!("RDP transport error: {error:?}"), + _ => format!("RDP connection failed: {error:?}"), + } +} + +fn user_facing_session_error(error: &E, kind: RdpErrorKind) -> String { + match kind { + RdpErrorKind::Authentication => "RDP authentication failed".to_string(), + RdpErrorKind::Clipboard => format!("RDP clipboard error: {error:?}"), + RdpErrorKind::Transport => format!("RDP transport interrupted: {error:?}"), + _ => format!("RDP session error: {error:?}"), + } +} + +async fn fail_rdp_session( + app: &AppHandle, + session: &Arc, + generation: u64, + message: String, + error_kind: RdpErrorKind, + allow_auto_reconnect: bool, +) { if *session.generation.lock().await != generation { return; } - set_state(session, RdpSessionState::Failed, Some(message.clone())).await; + if allow_auto_reconnect + && schedule_auto_reconnect( + app.clone(), + session.clone(), + generation, + message.clone(), + error_kind, + ) + .await + { + return; + } + set_state( + session, + RdpSessionState::Failed, + Some(message.clone()), + Some(error_kind), + ) + .await; emit_state( app, &session.config.session_id, RdpSessionState::Failed, Some(message), + Some(error_kind), ); let _ = app.emit("sessions-changed", ()); } @@ -875,15 +1786,22 @@ fn current_platform() -> MajorPlatformType { } } -async fn set_state(session: &RdpSession, state: RdpSessionState, message: Option) { +async fn set_state( + session: &RdpSession, + state: RdpSessionState, + message: Option, + error_kind: Option, +) { *session.state.lock().await = state; *session.message.lock().await = message; + *session.error_kind.lock().await = error_kind; } async fn emit_current_state(app: &AppHandle, session: &RdpSession) { let state = session.state.lock().await.clone(); let message = session.message.lock().await.clone(); - emit_state(app, &session.config.session_id, state, message); + let error_kind = *session.error_kind.lock().await; + emit_state(app, &session.config.session_id, state, message, error_kind); } async fn bump_generation(session: &RdpSession) -> u64 { @@ -893,21 +1811,22 @@ async fn bump_generation(session: &RdpSession) -> u64 { } async fn queue_or_send_frame(session: &RdpSession, frame: Vec) { - let sent = { + let frame = { let channel = session.frame_channel.lock().await; - channel - .as_ref() - .is_some_and(|sender| sender.send(InvokeResponseBody::Raw(frame.clone())).is_ok()) + if let Some(sender) = channel.as_ref() { + let _ = sender.send(InvokeResponseBody::Raw(frame)); + return; + } + frame }; - if sent { - return; - } - let mut pending = session.pending_frames.lock().await; - while pending.len() >= MAX_FRAME_QUEUE { - pending.pop_front(); + { + let mut pending = session.pending_frames.lock().await; + while pending.len() >= MAX_FRAME_QUEUE { + pending.pop_front(); + } + pending.push_back(frame); } - pending.push_back(frame); } async fn flush_pending_frames(session: &RdpSession) { @@ -923,36 +1842,6 @@ async fn flush_pending_frames(session: &RdpSession) { } } -fn build_placeholder_frame(config: &RdpConnectConfig, sequence: u64) -> Vec { - let width = config.width.max(640); - let height = config.height.max(480); - let stride = width * 4; - let payload_len = (stride * height) as usize; - let mut buffer = vec![0_u8; FRAME_HEADER_BYTES + payload_len]; - write_u64(&mut buffer, 0, sequence); - write_u32(&mut buffer, 8, width); - write_u32(&mut buffer, 12, height); - write_u32(&mut buffer, 16, 0); - write_u32(&mut buffer, 20, 0); - write_u32(&mut buffer, 24, width); - write_u32(&mut buffer, 28, height); - write_u32(&mut buffer, 32, stride); - write_u32(&mut buffer, 36, PIXEL_FORMAT_BGRA8888); - write_u32(&mut buffer, 40, payload_len as u32); - - let tick = (sequence % 255) as u8; - for y in 0..height { - for x in 0..width { - let offset = FRAME_HEADER_BYTES + ((y * stride) + x * 4) as usize; - buffer[offset] = ((x * 255 / width) as u8).saturating_add(tick / 4); - buffer[offset + 1] = ((y * 255 / height) as u8).saturating_add(32); - buffer[offset + 2] = 48_u8.saturating_add(tick); - buffer[offset + 3] = 255; - } - } - buffer -} - fn write_u32(buffer: &mut [u8], offset: usize, value: u32) { buffer[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); } @@ -977,34 +1866,35 @@ mod tests { password: None, width: 640, height: 480, + display_mode: "fit-window".to_string(), use_nla: true, certificate_policy: "prompt".to_string(), + clipboard_mode: "text-only".to_string(), + reconnect_enabled: true, + reconnect_max_attempts: 3, color_depth: 32, } } - fn test_session() -> RdpSession { - RdpSession { + fn test_session() -> Arc { + Arc::new(RdpSession { config: test_config(), state: Mutex::new(RdpSessionState::Connecting), message: Mutex::new(None), + error_kind: Mutex::new(None), generation: Mutex::new(0), frame_channel: Mutex::new(None), pending_frames: Mutex::new(VecDeque::with_capacity(MAX_FRAME_QUEUE)), input_sender: Mutex::new(None), input_database: Mutex::new(IronRdpInputDatabase::new()), frame_sequence: Mutex::new(0), - } - } - - #[test] - fn placeholder_frame_has_expected_header() { - let config = test_config(); - let frame = build_placeholder_frame(&config, 7); - assert_eq!(&frame[0..8], &7_u64.to_le_bytes()); - assert_eq!(&frame[8..12], &640_u32.to_le_bytes()); - assert_eq!(&frame[12..16], &480_u32.to_le_bytes()); - assert_eq!(&frame[36..40], &PIXEL_FORMAT_BGRA8888.to_le_bytes()); + worker: Mutex::new(None), + reconnect_attempts: Mutex::new(0), + reconnect_task: Mutex::new(None), + clipboard_bridge: Mutex::new(None), + close_requested: AtomicBool::new(false), + pending_certificates: Arc::new(Mutex::new(HashMap::new())), + }) } #[test] @@ -1044,4 +1934,129 @@ mod tests { assert_eq!(pending[0], vec![2]); assert_eq!(pending[1], vec![3]); } + + #[test] + fn certificate_policy_handles_strict_and_prompt_decisions() { + use crate::storage::KnownHostCheck; + + assert_eq!( + certificate_policy_allows_without_prompt("strict", KnownHostCheck::Match), + Ok(true) + ); + assert!( + certificate_policy_allows_without_prompt("strict", KnownHostCheck::UnknownHost) + .is_err() + ); + assert!( + certificate_policy_allows_without_prompt("strict", KnownHostCheck::HostSeen).is_err() + ); + assert_eq!( + certificate_policy_allows_without_prompt("prompt", KnownHostCheck::Match), + Ok(true) + ); + assert_eq!( + certificate_policy_allows_without_prompt("prompt", KnownHostCheck::UnknownHost), + Ok(false) + ); + assert_eq!( + certificate_policy_allows_without_prompt( + "accept-temporarily", + KnownHostCheck::HostSeen + ), + Ok(true) + ); + } + + #[test] + fn clipboard_limit_rejects_oversized_text() { + let oversized = "x".repeat(MAX_CLIPBOARD_TEXT_BYTES + 1); + assert!(clipboard_text_within_limit("")); + assert!(clipboard_text_within_limit("hello")); + assert!(!clipboard_text_within_limit(&oversized)); + } + + #[test] + fn clipboard_hash_supports_loop_prevention_tokens() { + let bridge = RdpClipboardBridge::new("s".to_string()); + bridge.mark_text_written_from_remote("same"); + + let current = bridge.last_text_hash.lock().unwrap(); + assert_eq!(*current, Some(stable_text_hash("same"))); + assert_ne!(*current, Some(stable_text_hash("different"))); + } + + #[tokio::test] + async fn stale_certificate_response_is_ignored() { + let manager = RdpSessionManager::with_engine(Arc::new(NoopRdpEngine)); + let session = test_session(); + *session.generation.lock().await = 2; + manager + .sessions + .lock() + .await + .insert("s".to_string(), session.clone()); + let (tx, mut rx) = oneshot::channel(); + manager.pending_certificates.lock().await.insert( + "req".to_string(), + RdpCertificatePending { + session_id: "s".to_string(), + generation: 1, + responder: tx, + }, + ); + + manager + .respond_certificate("req", true, true) + .await + .unwrap(); + + assert!(rx.try_recv().is_err()); + assert!(manager.pending_certificates.lock().await.is_empty()); + } + + #[test] + fn reconnect_delay_caps_after_sixth_attempt() { + let sixth = reconnect_delay(6); + let later = reconnect_delay(20); + assert!(sixth >= Duration::from_millis(30_000)); + assert!(sixth <= Duration::from_millis(30_249)); + assert!(later >= Duration::from_millis(30_000)); + assert!(later <= Duration::from_millis(30_249)); + } + + struct NoopRdpEngine; + + #[async_trait] + impl RdpEngine for NoopRdpEngine { + async fn connect(&self, _app: AppHandle, _session: Arc, _generation: u64) {} + + async fn send_input( + &self, + _session: Arc, + _events: Vec, + ) -> AppResult<()> { + Ok(()) + } + + async fn resize( + &self, + _session: Arc, + _width: u32, + _height: u32, + ) -> AppResult<()> { + Ok(()) + } + + async fn set_clipboard_text( + &self, + _session: Arc, + _text: String, + ) -> AppResult<()> { + Ok(()) + } + + async fn close(&self, _session: Arc) -> AppResult<()> { + Ok(()) + } + } } diff --git a/src-tauri/vendor/README.md b/src-tauri/vendor/README.md new file mode 100644 index 00000000..43f92c54 --- /dev/null +++ b/src-tauri/vendor/README.md @@ -0,0 +1,43 @@ +# Vendored RDP Dependencies + +This directory contains local copies of upstream crates used by NyaTerm's RDP stack. Keep patches small and documented so the crates can be refreshed without turning them into long-lived forks. + +## ironrdp-client + +- Upstream: https://github.com/Devolutions/IronRDP +- Crate/version: `ironrdp-client` `0.1.0` +- Why vendored: NyaTerm needs runtime hooks that are not exposed by the published client API. +- NyaTerm changes: + - Adds an injectable server certificate verifier called after TLS/RDCleanPath certificate extraction and before RDP finalize. + - Adds an injectable CLIPRDR backend factory so NyaTerm can provide a text-only clipboard bridge instead of the native clipboard backend. + - Exposes the clipboard module for non-Windows builds when the clipboard feature is enabled. + +## ironrdp-connector + +- Upstream: https://github.com/Devolutions/IronRDP +- Crate/version: `ironrdp-connector` `0.10.0` +- Why vendored: It must stay in lockstep with the vendored IronRDP client and the current connector API used by NyaTerm. +- NyaTerm changes: no intentional feature patches in this round; keep local edits limited to compatibility fixes required by the client. + +## picky + +- Upstream: https://github.com/Devolutions/picky-rs +- Crate/version: `picky` `7.0.0-rc.25` +- Why vendored: Required by IronRDP/SSPI auth dependencies and pinned to match the vendored connector stack. +- NyaTerm changes: no intentional RDP behavior patches in this round. + +## sspi + +- Upstream: https://github.com/Devolutions/sspi-rs +- Crate/version: `sspi` `0.21.0` +- Why vendored: CredSSP/NLA support must remain compatible with the vendored IronRDP connector and pinned `picky` version. +- NyaTerm changes: no intentional RDP behavior patches in this round. + +## Update Method + +1. Record the current NyaTerm patches with `git diff -- src-tauri/vendor/`. +2. Replace the target crate from the matching upstream tag or revision. +3. Reapply only the documented NyaTerm patches, preferring upstream APIs if they now exist. +4. Run `cargo update --manifest-path src-tauri/Cargo.toml -p ` if dependency metadata changed. +5. Run `cargo fmt`, `cargo test`, and `cargo clippy` for `src-tauri/Cargo.toml`. +6. Update this README with the new version/revision and any changed local patches. diff --git a/src-tauri/vendor/ironrdp-client/src/config.rs b/src-tauri/vendor/ironrdp-client/src/config.rs index 46778e40..f759e5c9 100644 --- a/src-tauri/vendor/ironrdp-client/src/config.rs +++ b/src-tauri/vendor/ironrdp-client/src/config.rs @@ -1,4 +1,5 @@ use core::fmt; +use core::pin::Pin; use core::str::FromStr; use core::time::Duration; #[cfg(all(windows, feature = "dvc-com-plugin"))] @@ -15,6 +16,27 @@ use url::Url; type StaticChannelFn = Arc; type DvcChannelFn = Arc; +#[cfg(feature = "clipboard")] +type CliprdrFactory = Arc< + dyn Fn( + Box, + ) -> Box + + Send + + Sync, +>; + +pub type ServerCertificateVerifyFuture<'a> = + Pin> + Send + 'a>>; + +pub trait ServerCertificateVerifier: fmt::Debug + Send + Sync { + fn verify_server_certificate<'a>( + &'a self, + host: &'a str, + port: u16, + der: Vec, + ) -> ServerCertificateVerifyFuture<'a>; +} + /// Private registry of user-supplied static and dynamic virtual channel factories. /// /// Cloneable via `Arc`; the factory closures are shared across reconnects. @@ -61,6 +83,10 @@ pub struct Config { pub(crate) kerberos_config: Option, pub(crate) fake_events_interval: Option, pub(crate) channels: ChannelConfig, + pub(crate) certificate_verifier: Option>, + + #[cfg(feature = "clipboard")] + pub(crate) cliprdr_factory: Option, /// DVC channel ↔ named-pipe proxy configuration. /// @@ -142,6 +168,15 @@ impl fmt::Debug for Config { s.field("kerberos_config", &self.kerberos_config); s.field("fake_events_interval", &self.fake_events_interval); s.field("channels", &self.channels); + s.field( + "certificate_verifier", + &self.certificate_verifier.as_ref().map(|_| ""), + ); + #[cfg(feature = "clipboard")] + s.field( + "cliprdr_factory", + &self.cliprdr_factory.as_ref().map(|_| ""), + ); #[cfg(feature = "dvc-pipe-proxy")] s.field("dvc_pipe_proxies", &self.dvc_pipe_proxies); #[cfg(all(windows, feature = "dvc-com-plugin"))] @@ -568,6 +603,9 @@ pub struct ConfigBuilder { kerberos_config: Option, fake_events_interval: Option, channels: ChannelConfig, + certificate_verifier: Option>, + #[cfg(feature = "clipboard")] + cliprdr_factory: Option, #[cfg(feature = "dvc-pipe-proxy")] dvc_pipe_proxies: Vec, #[cfg(all(windows, feature = "dvc-com-plugin"))] @@ -921,6 +959,32 @@ impl ConfigBuilder { self } + /// Set a custom CLIPRDR backend factory. + #[cfg(feature = "clipboard")] + #[must_use] + pub fn with_cliprdr_factory(mut self, factory: F) -> Self + where + F: Fn( + Box, + ) -> Box + + Send + + Sync + + 'static, + { + self.cliprdr_factory = Some(Arc::new(factory)); + self + } + + /// Set a custom server-certificate verifier called after TLS upgrade and before RDP finalize. + #[must_use] + pub fn with_server_certificate_verifier( + mut self, + verifier: Arc, + ) -> Self { + self.certificate_verifier = Some(verifier); + self + } + /// Enable or disable RDPDR (device redirection). #[cfg(feature = "rdpdr")] #[must_use] @@ -1199,6 +1263,9 @@ impl ConfigBuilder { kerberos_config, fake_events_interval: self.fake_events_interval, channels: self.channels, + certificate_verifier: self.certificate_verifier, + #[cfg(feature = "clipboard")] + cliprdr_factory: self.cliprdr_factory, #[cfg(feature = "dvc-pipe-proxy")] dvc_pipe_proxies: self.dvc_pipe_proxies, #[cfg(all(windows, feature = "dvc-com-plugin"))] diff --git a/src-tauri/vendor/ironrdp-client/src/lib.rs b/src-tauri/vendor/ironrdp-client/src/lib.rs index a567b8b7..27e69a5d 100644 --- a/src-tauri/vendor/ironrdp-client/src/lib.rs +++ b/src-tauri/vendor/ironrdp-client/src/lib.rs @@ -10,7 +10,7 @@ pub mod config; pub mod rdp; -#[cfg(all(windows, feature = "clipboard"))] +#[cfg(feature = "clipboard")] mod clipboard; mod ws; diff --git a/src-tauri/vendor/ironrdp-client/src/rdp.rs b/src-tauri/vendor/ironrdp-client/src/rdp.rs index be60a10c..a418fc01 100644 --- a/src-tauri/vendor/ironrdp-client/src/rdp.rs +++ b/src-tauri/vendor/ironrdp-client/src/rdp.rs @@ -151,6 +151,16 @@ impl RdpClient { } } ClipboardType::Enable => { + if let Some(factory) = &self.config.cliprdr_factory { + use crate::clipboard::ClientClipboardMessageProxy; + cliprdr_factory = Some(factory(Box::new(ClientClipboardMessageProxy::new( + self.input_event_sender.clone(), + )))); + #[cfg(windows)] + { + _win_clipboard = None; + } + } else { #[cfg(windows)] { use crate::clipboard::ClientClipboardMessageProxy; @@ -179,6 +189,7 @@ impl RdpClient { let stub = StubClipboard::new(); cliprdr_factory = Some(stub.backend_factory()); } + } } } } @@ -514,7 +525,7 @@ async fn connect_rdcleanpath_transport( let destination = config.destination.to_string(); let (upgraded, server_public_key) = - rdcleanpath_handshake(&mut framed, &mut connector, destination, rdcp.auth_token.clone(), None).await?; + rdcleanpath_handshake(config, &mut framed, &mut connector, destination, rdcp.auth_token.clone(), None).await?; let connection_result = ironrdp_tokio::connect_finalize( upgraded, @@ -563,6 +574,10 @@ where .ok_or_else(|| ironrdp_connector::general_err!("unable to extract tls server public key"))? .to_owned(); + verify_server_certificate(config, tls_cert) + .await + .map_err(|e| ironrdp_connector::custom_err!("server certificate verification", std::io::Error::other(e)))?; + let connection_result = ironrdp_tokio::connect_finalize( upgraded, connector, @@ -577,9 +592,24 @@ where Ok((connection_result, upgraded_framed)) } +async fn verify_server_certificate(config: &Config, cert: x509_cert::Certificate) -> Result<(), String> { + let Some(verifier) = config.certificate_verifier.as_ref() else { + return Ok(()); + }; + let der = { + use x509_cert::der::Encode as _; + cert.to_der() + .map_err(|error| format!("failed to encode server certificate: {error}"))? + }; + verifier + .verify_server_certificate(config.destination.name(), config.destination.port(), der) + .await +} + // ── RDCleanPath handshake ───────────────────────────────────────────────────── async fn rdcleanpath_handshake( + config: &Config, framed: &mut ironrdp_tokio::Framed, connector: &mut ironrdp_connector::ClientConnector, destination: String, @@ -712,6 +742,10 @@ where .ok_or_else(|| ironrdp_connector::general_err!("subject public key BIT STRING is not aligned"))? .to_owned(); + verify_server_certificate(config, cert.clone()) + .await + .map_err(|e| ironrdp_connector::custom_err!("server certificate verification", std::io::Error::other(e)))?; + let should_upgrade = ironrdp_tokio::skip_connect_begin(connector); let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, connector); diff --git a/src/App.tsx b/src/App.tsx index e4f1d586..34a1008f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import AppPanelContent from "./components/app/AppPanelContent"; import ExternalConnectionMatchDialog from "./components/dialog/connections/ExternalConnectionMatchDialog"; import type { HostKeyVerifyRequest } from "./components/dialog/connections/HostKeyVerifyDialog"; import type { OtpRequest } from "./components/dialog/connections/OtpDialog"; +import type { RdpCertificateVerifyRequest } from "./components/dialog/connections/RdpCertificateVerifyDialog"; import type { SshAuthRequest } from "./components/dialog/connections/SshAuthDialog"; import TemporarySshLinkDialog from "./components/dialog/connections/TemporarySshLinkDialog"; import type { DockerSudoPasswordRequest } from "./components/dialog/docker/DockerSudoPasswordDialog"; @@ -153,6 +154,15 @@ function getConnectionSessionType( return connection ? CONNECTION_SESSION_TYPES[connection.type] : "SSH"; } +function getRdpPaneDisplay(connection: SavedConnection | null | undefined) { + if (connection?.type !== "rdp") return undefined; + return { + remoteWidth: connection.display?.width ?? 1920, + remoteHeight: connection.display?.height ?? 1080, + scaleMode: connection.display?.mode === "fit-window" ? "fit" : "actual", + } as const; +} + function isSessionCreationCancelled(error: unknown) { return getErrorMessage(error).toLowerCase().includes("session creation cancelled"); } @@ -519,6 +529,9 @@ function App() { const [hostKeyVerifyRequest, setHostKeyVerifyRequest] = useState( null, ); + const [rdpCertificateRequests, setRdpCertificateRequests] = useState< + RdpCertificateVerifyRequest[] + >([]); const lastCloudConflictRevisionRef = useRef(null); const modalChildWindowCount = useModalChildWindows(); @@ -633,6 +646,16 @@ function App() { }), ); + unsubs.push( + listen("rdp-certificate-verify", (event) => { + if (!eventTargetsCurrentWindow(event.payload.targetWindowLabel)) return; + setRdpCertificateRequests((current) => { + if (current.some((item) => item.requestId === event.payload.requestId)) return current; + return [...current, event.payload]; + }); + }), + ); + unsubs.push( listen<{ requestId: string; @@ -698,6 +721,7 @@ function App() { name: connName, type: sessionType, connectionId, + display: getRdpPaneDisplay(conn), }); } else { const pending = addPendingTab( @@ -706,6 +730,7 @@ function App() { connectionId, undefined, anchorTabId ? { afterTabId: anchorTabId } : undefined, + { display: getRdpPaneDisplay(conn) }, ); tabId = pending.tabId; createRequestId = pending.createRequestId; @@ -742,6 +767,12 @@ function App() { createRequestId, }); break; + case "rdp": + sessionId = await invoke("create_rdp_session", { + connectionId, + createRequestId, + }); + break; default: sessionId = await invoke("create_ssh_session", { connectionId, @@ -989,6 +1020,9 @@ function App() { connection.name, getConnectionSessionType(connection), connection.id, + undefined, + undefined, + { display: getRdpPaneDisplay(connection) }, ); const { tabId, createRequestId } = pending; @@ -3425,6 +3459,11 @@ function App() { ), hostKeyVerifyRequest, onHostKeyVerifyDone: () => setHostKeyVerifyRequest(null), + rdpCertificateVerifyRequest: rdpCertificateRequests[0] ?? null, + onRdpCertificateVerifyDone: (requestId) => + setRdpCertificateRequests((current) => + current.filter((item) => item.requestId !== requestId), + ), modalChildWindowCount, locked: isLocked, hasMasterPassword: !!appSettings.security.master_password, diff --git a/src/components/app/AppLayout.tsx b/src/components/app/AppLayout.tsx index 5ff5886b..ceb765b4 100644 --- a/src/components/app/AppLayout.tsx +++ b/src/components/app/AppLayout.tsx @@ -10,6 +10,8 @@ import type { HostKeyVerifyRequest } from "@/components/dialog/connections/HostK import { HostKeyVerifyDialog } from "@/components/dialog/connections/HostKeyVerifyDialog"; import type { OtpRequest } from "@/components/dialog/connections/OtpDialog"; import { OtpDialog } from "@/components/dialog/connections/OtpDialog"; +import type { RdpCertificateVerifyRequest } from "@/components/dialog/connections/RdpCertificateVerifyDialog"; +import { RdpCertificateVerifyDialog } from "@/components/dialog/connections/RdpCertificateVerifyDialog"; import type { SshAuthRequest } from "@/components/dialog/connections/SshAuthDialog"; import { SshAuthDialog } from "@/components/dialog/connections/SshAuthDialog"; import DockerSudoPasswordDialog, { @@ -127,6 +129,8 @@ interface AppLayoutProps { onDockerSudoPasswordDone: (requestId: string) => void; hostKeyVerifyRequest: HostKeyVerifyRequest | null; onHostKeyVerifyDone: () => void; + rdpCertificateVerifyRequest: RdpCertificateVerifyRequest | null; + onRdpCertificateVerifyDone: (requestId: string) => void; modalChildWindowCount: number; locked: boolean; hasMasterPassword: boolean; @@ -519,6 +523,10 @@ export default function AppLayout({ request={dialogs.hostKeyVerifyRequest} onDone={dialogs.onHostKeyVerifyDone} /> + {dialogs.modalChildWindowCount > 0 && ( diff --git a/src/components/dialog/connections/RdpCertificateVerifyDialog.tsx b/src/components/dialog/connections/RdpCertificateVerifyDialog.tsx new file mode 100644 index 00000000..ff7f3fc1 --- /dev/null +++ b/src/components/dialog/connections/RdpCertificateVerifyDialog.tsx @@ -0,0 +1,155 @@ +import { ShieldAlert, ShieldQuestion } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { invoke } from "@/lib/invoke"; +import { logger } from "@/lib/logger"; + +export interface RdpCertificateVerifyRequest { + requestId: string; + sessionId: string; + host: string; + port: number; + fingerprint: string; + subject?: string | null; + issuer?: string | null; + validFrom?: string | null; + validTo?: string | null; + knownHostStatus: "match" | "changed" | "unknown" | string; + targetWindowLabel?: string | null; +} + +interface RdpCertificateVerifyDialogProps { + request: RdpCertificateVerifyRequest | null; + onDone: (requestId: string) => void; +} + +export function RdpCertificateVerifyDialog({ request, onDone }: RdpCertificateVerifyDialogProps) { + const { t } = useTranslation(); + const [submitting, setSubmitting] = useState(false); + const changed = request?.knownHostStatus === "changed"; + + const respond = async (accepted: boolean, remember: boolean) => { + if (!request || submitting) return; + setSubmitting(true); + try { + await invoke("respond_rdp_certificate", { + requestId: request.requestId, + accepted, + remember, + }); + logger.info({ + domain: "security.flow", + event: accepted ? "rdp_certificate.user_accepted" : "rdp_certificate.user_rejected", + message: accepted ? "User accepted RDP certificate" : "User rejected RDP certificate", + ids: { request_id: request.requestId, session_id: request.sessionId }, + }); + } catch (error) { + logger.error({ + domain: "security.flow", + event: "rdp_certificate.response_failed", + message: "Failed to send RDP certificate response", + ids: { request_id: request.requestId, session_id: request.sessionId }, + error, + }); + } + setSubmitting(false); + onDone(request.requestId); + }; + + return ( + { + if (!open) void respond(false, false); + }} + > + + + + {changed ? ( + + ) : ( + + )} + {t("settings.rdpCertificateVerifyTitle")} + + + {changed + ? t("settings.rdpCertificateVerifyChanged") + : t("settings.rdpCertificateVerifyNew")} + + + +
+
+ {t("settings.hostKeyVerifyHost")} + + {request?.host}:{request?.port} + + {t("settings.hostKeyVerifyFingerprint")} + {request?.fingerprint} + {t("settings.rdpCertificateSubject")} + + {request?.subject || t("common.unknown")} + + {t("settings.rdpCertificateIssuer")} + + {request?.issuer || t("common.unknown")} + + {t("settings.rdpCertificateValidity")} + + {request?.validFrom || t("common.unknown")} -{" "} + {request?.validTo || t("common.unknown")} + +
+ + {changed && ( +
+ {t("settings.rdpCertificateVerifyWarning")} +
+ )} +
+ + + + + + +
+
+ ); +} diff --git a/src/components/rdp/RdpPaneHost.tsx b/src/components/rdp/RdpPaneHost.tsx index 0ff59b73..80ecac62 100644 --- a/src/components/rdp/RdpPaneHost.tsx +++ b/src/components/rdp/RdpPaneHost.tsx @@ -13,8 +13,15 @@ import { } from "react"; import { Button } from "@/components/ui/button"; import { invoke } from "@/lib/invoke"; +import { + buildRdpUnicodeInput, + rdpBeforeInputText, + rdpCompositionCommitText, + shouldUsePhysicalRdpKey, +} from "@/lib/rdpIme"; import { decodeRdpFramePatch, type RdpFramePatch } from "@/lib/rdpFrame"; import { buildRdpKeyEvent, type RdpInputEvent } from "@/lib/rdpInput"; +import { decideFitWindowResize, keepDesktopSizeIfUnchanged } from "@/lib/rdpResize"; import { mapClientPointToRdpPixel } from "@/lib/rdpViewport"; import type { RdpSessionPane } from "@/types/global"; @@ -32,6 +39,29 @@ interface RdpStatePayload { sessionId: string; state: RdpSessionState; message?: string | null; + errorKind?: string | null; +} + +type RdpPointerPayload = + | { type: "default"; sessionId: string } + | { type: "hidden"; sessionId: string } + | { type: "position"; sessionId: string; x: number; y: number } + | { + type: "bitmap"; + sessionId: string; + width: number; + height: number; + hotspotX: number; + hotspotY: number; + rgbaBase64: string; + }; + +interface RemoteCursorBitmap { + src: string; + width: number; + height: number; + hotspotX: number; + hotspotY: number; } interface RdpPaneHostProps { @@ -80,6 +110,21 @@ function copyPatchToRgba(patch: RdpFramePatch, target: Uint8ClampedArray | Uint8 } } +function rgbaBase64ToDataUrl(base64: string, width: number, height: number) { + const binary = atob(base64); + const bytes = new Uint8ClampedArray(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) return ""; + ctx.putImageData(new ImageData(bytes, width, height), 0, 0); + return canvas.toDataURL("image/png"); +} + interface RdpCanvasRenderer { draw(patch: RdpFramePatch): void; dispose(): void; @@ -301,10 +346,18 @@ function RdpPaneHost({ onConnectionError, }: RdpPaneHostProps) { const canvasRef = useRef(null); + const containerRef = useRef(null); + const imeRef = useRef(null); + const cursorRef = useRef(null); const rendererRef = useRef(null); const pressedKeysRef = useRef(new Set()); + const composingRef = useRef(false); const pendingMouseMoveRef = useRef<{ x: number; y: number } | null>(null); const mouseRafRef = useRef(null); + const cursorRafRef = useRef(null); + const pendingCursorRef = useRef<{ x: number; y: number } | null>(null); + const remoteCursorBitmapRef = useRef(null); + const lastResizeRef = useRef<{ width: number; height: number } | null>(null); const [state, setState] = useState(pane.connectError ? "failed" : "connecting"); const [message, setMessage] = useState(pane.connectError ?? null); const [desktopSize, setDesktopSize] = useState({ @@ -329,7 +382,12 @@ function RdpPaneHost({ useEffect(() => { const channel = new Channel((frame) => { const patch = decodeRdpFramePatch(frame); - setDesktopSize({ width: patch.desktopWidth, height: patch.desktopHeight }); + setDesktopSize((current) => + keepDesktopSizeIfUnchanged(current, { + width: patch.desktopWidth, + height: patch.desktopHeight, + }), + ); const canvas = canvasRef.current; if (!canvas) return; rendererRef.current ??= createRdpRenderer(canvas); @@ -364,10 +422,126 @@ function RdpPaneHost({ }; }, [onConnectionError, pane.sessionId]); + const applyCursorPosition = useCallback(() => { + cursorRafRef.current = null; + const cursor = cursorRef.current; + const canvas = canvasRef.current; + const container = containerRef.current; + const position = pendingCursorRef.current; + const bitmap = remoteCursorBitmapRef.current; + if (!cursor || !canvas || !container || !position || !bitmap) return; + const rect = canvas.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const scaleX = rect.width / Math.max(1, canvas.width); + const scaleY = rect.height / Math.max(1, canvas.height); + const x = rect.left - containerRect.left + position.x * scaleX - bitmap.hotspotX * scaleX; + const y = rect.top - containerRect.top + position.y * scaleY - bitmap.hotspotY * scaleY; + cursor.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`; + }, []); + + useEffect(() => { + const unlisten = listen(`rdp-pointer-${pane.sessionId}`, (event) => { + const canvas = canvasRef.current; + const cursor = cursorRef.current; + if (!canvas || !cursor) return; + + if (event.payload.type === "default") { + remoteCursorBitmapRef.current = null; + cursor.replaceChildren(); + cursor.style.display = "none"; + canvas.style.cursor = ""; + return; + } + + canvas.style.cursor = "none"; + if (event.payload.type === "hidden") { + cursor.style.display = "none"; + return; + } + + if (event.payload.type === "bitmap") { + const bitmap = { + src: rgbaBase64ToDataUrl( + event.payload.rgbaBase64, + event.payload.width, + event.payload.height, + ), + width: event.payload.width, + height: event.payload.height, + hotspotX: event.payload.hotspotX, + hotspotY: event.payload.hotspotY, + }; + remoteCursorBitmapRef.current = bitmap; + const img = document.createElement("img"); + img.src = bitmap.src; + img.width = bitmap.width; + img.height = bitmap.height; + img.draggable = false; + cursor.replaceChildren(img); + cursor.style.display = "block"; + return; + } + + pendingCursorRef.current = { x: event.payload.x, y: event.payload.y }; + cursor.style.display = remoteCursorBitmapRef.current ? "block" : "none"; + if (cursorRafRef.current === null) { + cursorRafRef.current = requestAnimationFrame(applyCursorPosition); + } + }); + return () => { + void unlisten.then((dispose) => dispose()); + if (cursorRafRef.current !== null) cancelAnimationFrame(cursorRafRef.current); + cursorRafRef.current = null; + }; + }, [applyCursorPosition, pane.sessionId]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + if (pane.display?.scaleMode !== "fit") return; + + let timer: number | null = null; + const syncResize = () => { + if (timer !== null) window.clearTimeout(timer); + timer = window.setTimeout(() => { + const rect = container.getBoundingClientRect(); + const decision = decideFitWindowResize({ + mode: "fit-window", + visible: active && visible && state === "active" && rect.width > 0 && rect.height > 0, + containerWidth: rect.width, + containerHeight: rect.height, + lastWidth: lastResizeRef.current?.width, + lastHeight: lastResizeRef.current?.height, + }); + if (!decision.shouldResize) return; + lastResizeRef.current = { width: decision.width, height: decision.height }; + void invoke("rdp_resize", { + sessionId: pane.sessionId, + width: decision.width, + height: decision.height, + }).catch(() => {}); + }, 200); + }; + + const observer = new ResizeObserver(syncResize); + observer.observe(container); + syncResize(); + return () => { + observer.disconnect(); + if (timer !== null) window.clearTimeout(timer); + }; + }, [active, pane.display?.scaleMode, pane.sessionId, state, visible]); + useEffect(() => { if (!active || !visible) releaseAllKeys(); }, [active, releaseAllKeys, visible]); + useEffect(() => { + if (active && visible) { + imeRef.current?.focus({ preventScroll: true }); + } + }, [active, visible]); + useEffect(() => { window.addEventListener("blur", releaseAllKeys); return () => { @@ -377,7 +551,8 @@ function RdpPaneHost({ }, [releaseAllKeys]); const handleKeyDown = useCallback( - (event: ReactKeyboardEvent) => { + (event: ReactKeyboardEvent) => { + if (!shouldUsePhysicalRdpKey(event.nativeEvent)) return; const inputEvent = buildRdpKeyEvent(event.nativeEvent, "key-down"); if (!inputEvent) return; event.preventDefault(); @@ -389,7 +564,8 @@ function RdpPaneHost({ ); const handleKeyUp = useCallback( - (event: ReactKeyboardEvent) => { + (event: ReactKeyboardEvent) => { + if (!shouldUsePhysicalRdpKey(event.nativeEvent)) return; const inputEvent = buildRdpKeyEvent(event.nativeEvent, "key-up"); if (!inputEvent) return; event.preventDefault(); @@ -434,12 +610,45 @@ function RdpPaneHost({ return (
imeRef.current?.focus({ preventScroll: true })} > +