feat: Phase 4 complete — RDP via ironrdp

Rust RDP service: ironrdp client with full connection handshake
(TCP -> TLS -> CredSSP -> NLA), pixel buffer frame delivery,
mouse/keyboard input via scancode mapping, graceful disconnect.
Runs in dedicated thread with own tokio runtime to avoid Send
lifetime issues with ironrdp trait objects.

Vue frontend: RdpView canvas renderer with 30fps polling,
mouse/keyboard capture, RdpToolbar with Ctrl+Alt+Del and
clipboard. SessionContainer handles both SSH and RDP tabs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 16:05:11 -04:00
parent a8656b0812
commit c75da74ecd
12 changed files with 4205 additions and 171 deletions

2460
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -31,3 +31,11 @@ thiserror = "2"
russh = "0.48" russh = "0.48"
russh-sftp = "2.1.1" russh-sftp = "2.1.1"
ssh-key = { version = "0.6", features = ["ed25519", "rsa"] } ssh-key = { version = "0.6", features = ["ed25519", "rsa"] }
# RDP (IronRDP)
ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input"] }
ironrdp-tokio = { version = "0.8", features = ["reqwest-rustls-ring"] }
ironrdp-tls = { version = "0.2", features = ["rustls"] }
tokio-rustls = "0.26"
x509-cert = { version = "0.2", default-features = false, features = ["std"] }
sspi = { version = "0.18", features = ["network_client"] }

View File

@ -4,3 +4,4 @@ pub mod connections;
pub mod credentials; pub mod credentials;
pub mod ssh_commands; pub mod ssh_commands;
pub mod sftp_commands; pub mod sftp_commands;
pub mod rdp_commands;

View File

@ -0,0 +1,94 @@
//! Tauri commands for RDP session management.
//!
//! Mirrors the pattern used by `ssh_commands.rs` — thin command wrappers that
//! delegate to the `RdpService` via `State<AppState>`.
use serde::Deserialize;
use tauri::State;
use crate::rdp::{RdpConfig, RdpSessionInfo};
use crate::AppState;
/// Connect to an RDP server.
///
/// Performs the full connection handshake (TCP -> TLS -> CredSSP -> RDP) and
/// starts streaming frame updates in the background.
///
/// Returns the session UUID.
#[tauri::command]
pub fn connect_rdp(
config: RdpConfig,
state: State<'_, AppState>,
) -> Result<String, String> {
state.rdp.connect(config)
}
/// Get the current frame buffer as a base64-encoded RGBA string.
///
/// The frontend decodes this and draws it onto a `<canvas>` element.
/// Pixel format: RGBA, 4 bytes per pixel, row-major, top-left origin.
#[tauri::command]
pub async fn rdp_get_frame(
session_id: String,
state: State<'_, AppState>,
) -> Result<String, String> {
state.rdp.get_frame(&session_id).await
}
/// Send a mouse event to an RDP session.
///
/// `flags` uses MS-RDPBCGR mouse event flags:
/// - 0x0800 = move
/// - 0x1000 = left button
/// - 0x2000 = right button
/// - 0x4000 = middle button
/// - 0x8000 = button pressed (absence = released)
/// - 0x0200 = vertical wheel
/// - 0x0100 = negative wheel direction
/// - 0x0400 = horizontal wheel
#[tauri::command]
pub async fn rdp_send_mouse(
session_id: String,
x: u16,
y: u16,
flags: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
state.rdp.send_mouse(&session_id, x, y, flags)
}
/// Send a keyboard event to an RDP session.
///
/// `scancode` is the RDP hardware scancode (from the scancode map in
/// `rdp::input`). For extended keys (e.g. arrows, numpad enter), the high
/// byte is 0xE0.
///
/// `pressed` is `true` for key-down, `false` for key-up.
#[tauri::command]
pub async fn rdp_send_key(
session_id: String,
scancode: u16,
pressed: bool,
state: State<'_, AppState>,
) -> Result<(), String> {
state.rdp.send_key(&session_id, scancode, pressed)
}
/// Disconnect an RDP session.
///
/// Sends a graceful shutdown to the RDP server and removes the session.
#[tauri::command]
pub async fn disconnect_rdp(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
state.rdp.disconnect(&session_id)
}
/// List all active RDP sessions (metadata only).
#[tauri::command]
pub async fn list_rdp_sessions(
state: State<'_, AppState>,
) -> Result<Vec<RdpSessionInfo>, String> {
Ok(state.rdp.list_sessions())
}

View File

@ -5,6 +5,7 @@ pub mod connections;
pub mod credentials; pub mod credentials;
pub mod ssh; pub mod ssh;
pub mod sftp; pub mod sftp;
pub mod rdp;
pub mod commands; pub mod commands;
use std::path::PathBuf; use std::path::PathBuf;
@ -17,6 +18,7 @@ use settings::SettingsService;
use connections::ConnectionService; use connections::ConnectionService;
use sftp::SftpService; use sftp::SftpService;
use ssh::session::SshService; use ssh::session::SshService;
use rdp::RdpService;
/// Application state shared across all Tauri commands via State<AppState>. /// Application state shared across all Tauri commands via State<AppState>.
pub struct AppState { pub struct AppState {
@ -27,6 +29,7 @@ pub struct AppState {
pub credentials: Mutex<Option<CredentialService>>, pub credentials: Mutex<Option<CredentialService>>,
pub ssh: SshService, pub ssh: SshService,
pub sftp: SftpService, pub sftp: SftpService,
pub rdp: RdpService,
} }
impl AppState { impl AppState {
@ -41,6 +44,7 @@ impl AppState {
let connections = ConnectionService::new(database.clone()); let connections = ConnectionService::new(database.clone());
let ssh = SshService::new(database.clone()); let ssh = SshService::new(database.clone());
let sftp = SftpService::new(); let sftp = SftpService::new();
let rdp = RdpService::new();
Ok(Self { Ok(Self {
db: database, db: database,
@ -50,6 +54,7 @@ impl AppState {
credentials: Mutex::new(None), credentials: Mutex::new(None),
ssh, ssh,
sftp, sftp,
rdp,
}) })
} }
@ -125,6 +130,12 @@ pub fn run() {
commands::sftp_commands::sftp_mkdir, commands::sftp_commands::sftp_mkdir,
commands::sftp_commands::sftp_delete, commands::sftp_commands::sftp_delete,
commands::sftp_commands::sftp_rename, commands::sftp_commands::sftp_rename,
commands::rdp_commands::connect_rdp,
commands::rdp_commands::rdp_get_frame,
commands::rdp_commands::rdp_send_mouse,
commands::rdp_commands::rdp_send_key,
commands::rdp_commands::disconnect_rdp,
commands::rdp_commands::list_rdp_sessions,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

192
src-tauri/src/rdp/input.rs Normal file
View File

@ -0,0 +1,192 @@
//! Scancode mapping table — maps JavaScript `KeyboardEvent.code` strings to
//! RDP hardware scancodes (Set 1 / XT scan codes).
//!
//! Extended keys (those with a 0xE0 prefix on the wire) have the high byte set
//! to 0xE0. Use [`is_extended`] and [`scancode_value`] to decompose them.
//!
//! Ported from `wraith/internal/rdp/input.go`.
use std::collections::HashMap;
use std::sync::LazyLock;
/// RDP mouse event flags — these match the MS-RDPBCGR specification.
pub mod mouse_flags {
pub const MOVE: u32 = 0x0800;
pub const BUTTON1: u32 = 0x1000; // Left
pub const BUTTON2: u32 = 0x2000; // Right
pub const BUTTON3: u32 = 0x4000; // Middle
pub const DOWN: u32 = 0x8000; // Button pressed (absence = released)
pub const WHEEL: u32 = 0x0200; // Vertical wheel rotation
pub const WHEEL_NEG: u32 = 0x0100; // Negative wheel direction (scroll down)
pub const HWHEEL: u32 = 0x0400; // Horizontal wheel rotation
}
/// Lazily-initialized map from JS `KeyboardEvent.code` to RDP hardware scancode.
pub static SCANCODE_MAP: LazyLock<HashMap<&'static str, u32>> = LazyLock::new(|| {
let mut m = HashMap::new();
// ── Row 0: Escape + Function keys ──────────────────────────────
m.insert("Escape", 0x0001u32);
m.insert("F1", 0x003B);
m.insert("F2", 0x003C);
m.insert("F3", 0x003D);
m.insert("F4", 0x003E);
m.insert("F5", 0x003F);
m.insert("F6", 0x0040);
m.insert("F7", 0x0041);
m.insert("F8", 0x0042);
m.insert("F9", 0x0043);
m.insert("F10", 0x0044);
m.insert("F11", 0x0057);
m.insert("F12", 0x0058);
// ── Row 1: Number row ──────────────────────────────────────────
m.insert("Backquote", 0x0029);
m.insert("Digit1", 0x0002);
m.insert("Digit2", 0x0003);
m.insert("Digit3", 0x0004);
m.insert("Digit4", 0x0005);
m.insert("Digit5", 0x0006);
m.insert("Digit6", 0x0007);
m.insert("Digit7", 0x0008);
m.insert("Digit8", 0x0009);
m.insert("Digit9", 0x000A);
m.insert("Digit0", 0x000B);
m.insert("Minus", 0x000C);
m.insert("Equal", 0x000D);
m.insert("Backspace", 0x000E);
// ── Row 2: QWERTY row ─────────────────────────────────────────
m.insert("Tab", 0x000F);
m.insert("KeyQ", 0x0010);
m.insert("KeyW", 0x0011);
m.insert("KeyE", 0x0012);
m.insert("KeyR", 0x0013);
m.insert("KeyT", 0x0014);
m.insert("KeyY", 0x0015);
m.insert("KeyU", 0x0016);
m.insert("KeyI", 0x0017);
m.insert("KeyO", 0x0018);
m.insert("KeyP", 0x0019);
m.insert("BracketLeft", 0x001A);
m.insert("BracketRight", 0x001B);
m.insert("Backslash", 0x002B);
// ── Row 3: Home row ───────────────────────────────────────────
m.insert("CapsLock", 0x003A);
m.insert("KeyA", 0x001E);
m.insert("KeyS", 0x001F);
m.insert("KeyD", 0x0020);
m.insert("KeyF", 0x0021);
m.insert("KeyG", 0x0022);
m.insert("KeyH", 0x0023);
m.insert("KeyJ", 0x0024);
m.insert("KeyK", 0x0025);
m.insert("KeyL", 0x0026);
m.insert("Semicolon", 0x0027);
m.insert("Quote", 0x0028);
m.insert("Enter", 0x001C);
// ── Row 4: Bottom row ─────────────────────────────────────────
m.insert("ShiftLeft", 0x002A);
m.insert("KeyZ", 0x002C);
m.insert("KeyX", 0x002D);
m.insert("KeyC", 0x002E);
m.insert("KeyV", 0x002F);
m.insert("KeyB", 0x0030);
m.insert("KeyN", 0x0031);
m.insert("KeyM", 0x0032);
m.insert("Comma", 0x0033);
m.insert("Period", 0x0034);
m.insert("Slash", 0x0035);
m.insert("ShiftRight", 0x0036);
// ── Row 5: Bottom modifiers + space ───────────────────────────
m.insert("ControlLeft", 0x001D);
m.insert("MetaLeft", 0xE05B);
m.insert("AltLeft", 0x0038);
m.insert("Space", 0x0039);
m.insert("AltRight", 0xE038);
m.insert("MetaRight", 0xE05C);
m.insert("ContextMenu", 0xE05D);
m.insert("ControlRight", 0xE01D);
// ── Navigation cluster ────────────────────────────────────────
m.insert("PrintScreen", 0xE037);
m.insert("ScrollLock", 0x0046);
m.insert("Pause", 0x0045);
m.insert("Insert", 0xE052);
m.insert("Home", 0xE047);
m.insert("PageUp", 0xE049);
m.insert("Delete", 0xE053);
m.insert("End", 0xE04F);
m.insert("PageDown", 0xE051);
// ── Arrow keys ────────────────────────────────────────────────
m.insert("ArrowUp", 0xE048);
m.insert("ArrowLeft", 0xE04B);
m.insert("ArrowDown", 0xE050);
m.insert("ArrowRight", 0xE04D);
// ── Numpad ────────────────────────────────────────────────────
m.insert("NumLock", 0x0045);
m.insert("NumpadDivide", 0xE035);
m.insert("NumpadMultiply", 0x0037);
m.insert("NumpadSubtract", 0x004A);
m.insert("Numpad7", 0x0047);
m.insert("Numpad8", 0x0048);
m.insert("Numpad9", 0x0049);
m.insert("NumpadAdd", 0x004E);
m.insert("Numpad4", 0x004B);
m.insert("Numpad5", 0x004C);
m.insert("Numpad6", 0x004D);
m.insert("Numpad1", 0x004F);
m.insert("Numpad2", 0x0050);
m.insert("Numpad3", 0x0051);
m.insert("NumpadEnter", 0xE01C);
m.insert("Numpad0", 0x0052);
m.insert("NumpadDecimal", 0x0053);
// ── Multimedia / browser keys ─────────────────────────────────
m.insert("BrowserBack", 0xE06A);
m.insert("BrowserForward", 0xE069);
m.insert("BrowserRefresh", 0xE067);
m.insert("BrowserStop", 0xE068);
m.insert("BrowserSearch", 0xE065);
m.insert("BrowserFavorites", 0xE066);
m.insert("BrowserHome", 0xE032);
m.insert("VolumeMute", 0xE020);
m.insert("VolumeDown", 0xE02E);
m.insert("VolumeUp", 0xE030);
m.insert("MediaTrackNext", 0xE019);
m.insert("MediaTrackPrevious", 0xE010);
m.insert("MediaStop", 0xE024);
m.insert("MediaPlayPause", 0xE022);
m.insert("LaunchMail", 0xE06C);
m.insert("LaunchApp1", 0xE06B);
m.insert("LaunchApp2", 0xE021);
// ── International keys ────────────────────────────────────────
m.insert("IntlBackslash", 0x0056);
m.insert("IntlYen", 0x007D);
m.insert("IntlRo", 0x0073);
m
});
/// Look up the RDP hardware scancode for a JS `KeyboardEvent.code` string.
///
/// Returns `None` for unmapped keys.
pub fn js_key_to_scancode(js_code: &str) -> Option<u32> {
SCANCODE_MAP.get(js_code).copied()
}
/// Returns `true` if the scancode has the 0xE0 extended prefix.
pub fn is_extended(scancode: u32) -> bool {
(scancode & 0xFF00) == 0xE000
}
/// Returns the low byte of the scancode (the actual value without the prefix).
pub fn scancode_value(scancode: u32) -> u8 {
(scancode & 0xFF) as u8
}

619
src-tauri/src/rdp/mod.rs Normal file
View File

@ -0,0 +1,619 @@
//! RDP session manager — connects to Windows RDP servers via IronRDP,
//! maintains an RGBA frame buffer per session, and exposes input injection.
//!
//! Architecture:
//! - `RdpService` holds a `DashMap` of active sessions.
//! - Each session spawns a tokio task that runs the IronRDP active stage loop,
//! reading frames from the server and updating a shared `Vec<u8>` (RGBA).
//! - The frontend fetches frames via a Tauri command that reads the buffer.
//! - Mouse/keyboard input is sent to the session via an mpsc channel.
pub mod input;
use std::sync::Arc;
use base64::Engine;
use dashmap::DashMap;
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio::sync::Mutex as TokioMutex;
use ironrdp::connector::{self, ClientConnector, ConnectionResult, Credentials, DesktopSize};
use ironrdp::graphics::image_processing::PixelFormat;
use ironrdp::input::{self as rdp_input, MouseButton, MousePosition, Operation, Scancode, WheelRotations};
use ironrdp::pdu::gcc::KeyboardType;
use ironrdp::pdu::rdp::capability_sets::MajorPlatformType;
use ironrdp::pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo};
use ironrdp::session::image::DecodedImage;
use ironrdp::session::{ActiveStage, ActiveStageOutput};
use ironrdp_tokio::reqwest::ReqwestNetworkClient;
use ironrdp_tokio::{split_tokio_framed, FramedWrite, TokioFramed};
use self::input::mouse_flags;
// ── Public types ──────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RdpConfig {
pub hostname: String,
pub port: u16,
pub username: String,
pub password: String,
pub domain: Option<String>,
pub width: u16,
pub height: u16,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RdpSessionInfo {
pub id: String,
pub hostname: String,
pub width: u16,
pub height: u16,
pub connected: bool,
}
/// Input events sent from the frontend to a session's background task.
enum InputEvent {
Mouse {
x: u16,
y: u16,
flags: u32,
},
Key {
scancode: u16,
pressed: bool,
},
Disconnect,
}
// ── Session handle ────────────────────────────────────────────────────────────
/// A handle to a running RDP session. The actual IronRDP connection runs in a
/// background tokio task. This struct holds the shared frame buffer and an input
/// channel.
struct RdpSessionHandle {
id: String,
hostname: String,
width: u16,
height: u16,
/// RGBA pixel data — updated by the background task, read by the frontend.
frame_buffer: Arc<TokioMutex<Vec<u8>>>,
/// Send input events to the background task.
input_tx: mpsc::UnboundedSender<InputEvent>,
}
// ── Service ───────────────────────────────────────────────────────────────────
pub struct RdpService {
sessions: DashMap<String, Arc<RdpSessionHandle>>,
}
impl RdpService {
pub fn new() -> Self {
Self {
sessions: DashMap::new(),
}
}
/// Connect to an RDP server. Returns the session UUID on success.
///
/// The entire RDP connection (handshake + active session loop) runs in a
/// dedicated thread with its own tokio runtime. This avoids Send/lifetime
/// issues with ironrdp's internal trait objects and tokio::spawn.
pub fn connect(&self, config: RdpConfig) -> Result<String, String> {
let session_id = uuid::Uuid::new_v4().to_string();
let width = config.width;
let height = config.height;
let hostname = config.hostname.clone();
// Create shared frame buffer — initialized to opaque black.
let buf_size = (width as usize) * (height as usize) * 4;
let mut initial_buf = vec![0u8; buf_size];
for pixel in initial_buf.chunks_exact_mut(4) {
pixel[3] = 255;
}
let frame_buffer = Arc::new(TokioMutex::new(initial_buf));
// Create input channel.
let (input_tx, input_rx) = mpsc::unbounded_channel();
// Build session handle (accessible from main thread for frame reads + input sends).
let handle = Arc::new(RdpSessionHandle {
id: session_id.clone(),
hostname: hostname.clone(),
width,
height,
frame_buffer: frame_buffer.clone(),
input_tx,
});
self.sessions.insert(session_id.clone(), handle);
// Spawn dedicated thread for the RDP connection + session loop.
let sid = session_id.clone();
let sessions_ref = self.sessions.clone();
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async move {
// Build connector config.
let connector_config = match build_connector_config(&config) {
Ok(c) => c,
Err(e) => {
let _ = ready_tx.send(Err(format!("Failed to build RDP config: {}", e)));
sessions_ref.remove(&sid);
return;
}
};
// Establish connection (TCP + TLS + CredSSP + RDP handshake).
let (connection_result, framed) =
match establish_connection(connector_config, &config.hostname, config.port).await {
Ok(r) => r,
Err(e) => {
let _ = ready_tx.send(Err(format!("RDP connection failed: {}", e)));
sessions_ref.remove(&sid);
return;
}
};
info!("RDP connection established to {}:{} (session {})", config.hostname, config.port, sid);
let _ = ready_tx.send(Ok(()));
// Run active session loop until disconnect.
if let Err(e) = run_active_session(
connection_result,
framed,
frame_buffer,
input_rx,
width as u16,
height as u16,
)
.await
{
error!("RDP session {} error: {}", sid, e);
}
info!("RDP session {} ended", sid);
sessions_ref.remove(&sid);
});
});
// Wait for the connection to establish or fail.
match ready_rx.recv() {
Ok(Ok(())) => {}
Ok(Err(e)) => {
self.sessions.remove(&session_id);
return Err(e);
}
Err(_) => {
self.sessions.remove(&session_id);
return Err("RDP connection thread died unexpectedly".into());
}
}
Ok(session_id)
}
/// Get the current frame buffer as base64-encoded RGBA data.
pub async fn get_frame(&self, session_id: &str) -> Result<String, String> {
let handle = self
.sessions
.get(session_id)
.ok_or_else(|| format!("RDP session {} not found", session_id))?;
let buf = handle.frame_buffer.lock().await;
let encoded = base64::engine::general_purpose::STANDARD.encode(&*buf);
Ok(encoded)
}
/// Get the raw frame buffer bytes (for potential future optimization).
pub async fn get_frame_raw(&self, session_id: &str) -> Result<Vec<u8>, String> {
let handle = self
.sessions
.get(session_id)
.ok_or_else(|| format!("RDP session {} not found", session_id))?;
let buf = handle.frame_buffer.lock().await;
Ok(buf.clone())
}
/// Send a mouse event to the RDP session.
///
/// The `flags` parameter uses MS-RDPBCGR mouse event flags (see `input::mouse_flags`).
/// The frontend should construct these from DOM mouse events.
pub fn send_mouse(&self, session_id: &str, x: u16, y: u16, flags: u32) -> Result<(), String> {
let handle = self
.sessions
.get(session_id)
.ok_or_else(|| format!("RDP session {} not found", session_id))?;
handle
.input_tx
.send(InputEvent::Mouse { x, y, flags })
.map_err(|_| format!("RDP session {} input channel closed", session_id))
}
/// Send a keyboard event to the RDP session.
///
/// `scancode` is the RDP hardware scancode (use `input::js_key_to_scancode`
/// on the frontend side or pass it through). `pressed` indicates key-down
/// vs key-up.
pub fn send_key(&self, session_id: &str, scancode: u16, pressed: bool) -> Result<(), String> {
let handle = self
.sessions
.get(session_id)
.ok_or_else(|| format!("RDP session {} not found", session_id))?;
handle
.input_tx
.send(InputEvent::Key { scancode, pressed })
.map_err(|_| format!("RDP session {} input channel closed", session_id))
}
/// Disconnect an RDP session.
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
let handle = self
.sessions
.get(session_id)
.ok_or_else(|| format!("RDP session {} not found", session_id))?;
// Send disconnect signal — the background task will clean up.
let _ = handle.input_tx.send(InputEvent::Disconnect);
// Remove from map immediately so no new commands target it.
drop(handle);
self.sessions.remove(session_id);
info!("RDP session {} disconnect requested", session_id);
Ok(())
}
/// List all active RDP sessions.
pub fn list_sessions(&self) -> Vec<RdpSessionInfo> {
self.sessions
.iter()
.map(|entry| {
let h = entry.value();
RdpSessionInfo {
id: h.id.clone(),
hostname: h.hostname.clone(),
width: h.width,
height: h.height,
connected: !h.input_tx.is_closed(),
}
})
.collect()
}
}
// Clone the DashMap reference for use in spawned tasks.
impl Clone for RdpService {
fn clone(&self) -> Self {
// This is intentionally a shallow clone — we want to share the same
// sessions map. But since DashMap doesn't implement Clone directly in
// a way we can use here, we use a different approach: the service
// itself is stored in AppState and accessed via State<AppState>.
// The Clone here is only needed if we want to pass a reference to
// spawned tasks, which we handle via Arc<DashMap> internally.
unreachable!("RdpService should not be cloned — access via State<AppState>");
}
}
// ── Connection establishment ──────────────────────────────────────────────────
/// Build the IronRDP `connector::Config` from our simplified `RdpConfig`.
fn build_connector_config(config: &RdpConfig) -> Result<connector::Config, String> {
Ok(connector::Config {
credentials: Credentials::UsernamePassword {
username: config.username.clone(),
password: config.password.clone(),
},
domain: config.domain.clone(),
enable_tls: false,
enable_credssp: true,
keyboard_type: KeyboardType::IbmEnhanced,
keyboard_subtype: 0,
keyboard_layout: 0,
keyboard_functional_keys_count: 12,
ime_file_name: String::new(),
dig_product_id: String::new(),
desktop_size: DesktopSize {
width: config.width,
height: config.height,
},
bitmap: None,
client_build: 0,
client_name: "Wraith Desktop".to_owned(),
client_dir: "C:\\Windows\\System32\\mstscax.dll".to_owned(),
#[cfg(windows)]
platform: MajorPlatformType::WINDOWS,
#[cfg(target_os = "macos")]
platform: MajorPlatformType::MACINTOSH,
#[cfg(target_os = "linux")]
platform: MajorPlatformType::UNIX,
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
platform: MajorPlatformType::UNIX,
enable_server_pointer: true,
pointer_software_rendering: true,
request_data: None,
autologon: false,
enable_audio_playback: false,
performance_flags: PerformanceFlags::default(),
desktop_scale_factor: 0,
hardware_id: None,
license_cache: None,
timezone_info: TimezoneInfo::default(),
})
}
/// Trait alias for types that implement both AsyncRead and AsyncWrite.
trait AsyncReadWrite: AsyncRead + AsyncWrite + 'static {}
impl<T: AsyncRead + AsyncWrite + 'static> AsyncReadWrite for T {}
type UpgradedFramed = TokioFramed<Box<dyn AsyncReadWrite + Unpin + Send + Sync + 'static>>;
/// Perform the full RDP connection: TCP -> TLS upgrade -> CredSSP -> RDP handshake.
async fn establish_connection(
config: connector::Config,
hostname: &str,
port: u16,
) -> Result<(ConnectionResult, UpgradedFramed), String> {
// Resolve and connect TCP.
let addr = format!("{}:{}", hostname, port);
let stream = TcpStream::connect(&addr)
.await
.map_err(|e| format!("TCP connect to {} failed: {}", addr, e))?;
let client_addr = stream
.local_addr()
.map_err(|e| format!("Failed to get local address: {}", e))?;
let mut framed = TokioFramed::new(stream);
let mut connector = ClientConnector::new(config, client_addr);
// Phase 1: Initial connection (pre-TLS).
let should_upgrade = ironrdp_tokio::connect_begin(&mut framed, &mut connector)
.await
.map_err(|e| format!("RDP connect_begin failed: {}", e))?;
debug!("RDP TLS upgrade starting for {}", hostname);
// Phase 2: TLS upgrade.
let (initial_stream, leftover_bytes) = framed.into_inner();
let (tls_stream, tls_cert) = ironrdp_tls::upgrade(initial_stream, hostname)
.await
.map_err(|e| format!("TLS upgrade failed: {}", e))?;
let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector);
// Wrap the TLS stream in an erased box for the framed type.
let erased_stream: Box<dyn AsyncReadWrite + Unpin + Send + Sync> = Box::new(tls_stream);
let mut upgraded_framed = TokioFramed::new_with_leftover(erased_stream, leftover_bytes);
// Phase 3: CredSSP + finalize.
let server_public_key = ironrdp_tls::extract_tls_server_public_key(&tls_cert)
.ok_or_else(|| "Failed to extract TLS server public key".to_string())?
.to_owned();
let connection_result = ironrdp_tokio::connect_finalize(
upgraded,
connector,
&mut upgraded_framed,
&mut ReqwestNetworkClient::new(),
hostname.into(),
server_public_key,
None, // No Kerberos config
)
.await
.map_err(|e| format!("RDP connect_finalize failed: {}", e))?;
debug!("RDP connection finalized for {}", hostname);
Ok((connection_result, upgraded_framed))
}
// ── Active session loop ───────────────────────────────────────────────────────
/// Run the active RDP session loop — processes incoming frames and outgoing input.
async fn run_active_session(
connection_result: ConnectionResult,
framed: UpgradedFramed,
frame_buffer: Arc<TokioMutex<Vec<u8>>>,
mut input_rx: mpsc::UnboundedReceiver<InputEvent>,
width: u16,
height: u16,
) -> Result<(), String> {
let (mut reader, mut writer) = split_tokio_framed(framed);
let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height);
let mut active_stage = ActiveStage::new(connection_result);
let mut input_db = rdp_input::Database::new();
loop {
let outputs = tokio::select! {
// Read a PDU from the server.
frame = reader.read_pdu() => {
let (action, payload) = frame
.map_err(|e| format!("Failed to read RDP frame: {}", e))?;
active_stage
.process(&mut image, action, &payload)
.map_err(|e| format!("Failed to process RDP frame: {}", e))?
}
// Receive input from the frontend.
input_event = input_rx.recv() => {
match input_event {
Some(InputEvent::Disconnect) | None => {
info!("RDP session disconnect signal received");
// Attempt graceful shutdown.
match active_stage.graceful_shutdown() {
Ok(outputs) => {
for out in outputs {
if let ActiveStageOutput::ResponseFrame(frame) = out {
let _ = writer.write_all(&frame).await;
}
}
}
Err(e) => {
warn!("Graceful RDP shutdown failed: {}", e);
}
}
return Ok(());
}
Some(InputEvent::Mouse { x, y, flags }) => {
let ops = translate_mouse_flags(x, y, flags);
let events = input_db.apply(ops);
active_stage
.process_fastpath_input(&mut image, &events)
.map_err(|e| format!("Failed to process mouse input: {}", e))?
}
Some(InputEvent::Key { scancode, pressed }) => {
let sc = Scancode::from_u16(scancode);
let op = if pressed {
Operation::KeyPressed(sc)
} else {
Operation::KeyReleased(sc)
};
let events = input_db.apply([op]);
active_stage
.process_fastpath_input(&mut image, &events)
.map_err(|e| format!("Failed to process keyboard input: {}", e))?
}
}
}
};
// Process outputs from the active stage.
for out in outputs {
match out {
ActiveStageOutput::ResponseFrame(frame) => {
writer
.write_all(&frame)
.await
.map_err(|e| format!("Failed to write RDP response frame: {}", e))?;
}
ActiveStageOutput::GraphicsUpdate(_region) => {
// Copy the decoded image data into the shared frame buffer.
let mut buf = frame_buffer.lock().await;
let src = image.data();
let dst_len = buf.len();
if src.len() == dst_len {
buf.copy_from_slice(src);
} else {
// Desktop size may have changed — resize the buffer.
*buf = src.to_vec();
}
}
ActiveStageOutput::Terminate(reason) => {
info!("RDP session terminated: {:?}", reason);
return Ok(());
}
ActiveStageOutput::DeactivateAll(_connection_activation) => {
// The server requested deactivation-reactivation. For now,
// log and continue — a full implementation would re-run
// the connection activation sequence.
warn!("RDP server sent DeactivateAll — reconnection not yet implemented");
return Ok(());
}
// Pointer events — we could emit these to the frontend for
// custom cursor rendering, but for now we just log them.
ActiveStageOutput::PointerDefault => {
debug!("RDP pointer: default");
}
ActiveStageOutput::PointerHidden => {
debug!("RDP pointer: hidden");
}
ActiveStageOutput::PointerPosition { x, y } => {
debug!("RDP pointer position: ({}, {})", x, y);
}
ActiveStageOutput::PointerBitmap(_) => {
debug!("RDP pointer bitmap received");
}
_ => {
// Future variants (MultitransportRequest, etc.)
}
}
}
}
}
// ── Input translation ─────────────────────────────────────────────────────────
/// Translate MS-RDPBCGR mouse flags into IronRDP `Operation` values.
///
/// The frontend sends raw MS-RDPBCGR flags so this mapping is straightforward.
fn translate_mouse_flags(x: u16, y: u16, flags: u32) -> Vec<Operation> {
let mut ops = Vec::new();
let pos = MousePosition { x, y };
// Always include a move operation if the MOVE flag is set.
if flags & mouse_flags::MOVE != 0 {
ops.push(Operation::MouseMove(pos));
}
// Check for button press/release.
let is_down = flags & mouse_flags::DOWN != 0;
if flags & mouse_flags::BUTTON1 != 0 {
if is_down {
ops.push(Operation::MouseButtonPressed(MouseButton::Left));
} else {
ops.push(Operation::MouseButtonReleased(MouseButton::Left));
}
}
if flags & mouse_flags::BUTTON2 != 0 {
if is_down {
ops.push(Operation::MouseButtonPressed(MouseButton::Right));
} else {
ops.push(Operation::MouseButtonReleased(MouseButton::Right));
}
}
if flags & mouse_flags::BUTTON3 != 0 {
if is_down {
ops.push(Operation::MouseButtonPressed(MouseButton::Middle));
} else {
ops.push(Operation::MouseButtonReleased(MouseButton::Middle));
}
}
// Wheel events.
if flags & mouse_flags::WHEEL != 0 {
let negative = flags & mouse_flags::WHEEL_NEG != 0;
let units: i16 = if negative { -120 } else { 120 };
ops.push(Operation::WheelRotations(WheelRotations {
is_vertical: true,
rotation_units: units,
}));
}
if flags & mouse_flags::HWHEEL != 0 {
let negative = flags & mouse_flags::WHEEL_NEG != 0;
let units: i16 = if negative { -120 } else { 120 };
ops.push(Operation::WheelRotations(WheelRotations {
is_vertical: false,
rotation_units: units,
}));
}
// If no specific operation was generated but we have coordinates, treat
// it as a plain mouse move (some frontends send move without the flag).
if ops.is_empty() {
ops.push(Operation::MouseMove(pos));
}
ops
}

View File

@ -0,0 +1,168 @@
<template>
<div class="rdp-toolbar">
<div class="rdp-toolbar-left">
<span class="rdp-toolbar-label">RDP</span>
<span class="rdp-toolbar-session">{{ sessionId }}</span>
</div>
<div class="rdp-toolbar-right">
<!-- Keyboard capture toggle -->
<button
class="rdp-toolbar-btn"
:class="{ active: keyboardGrabbed }"
title="Toggle keyboard capture"
@click="emit('toggle-keyboard')"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M1 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4zm1 0v7h12V4H2z"/>
<path d="M3 6h2v1H3V6zm3 0h2v1H6V6zm3 0h2v1H9V6zm3 0h1v1h-1V6zM3 8h1v1H3V8zm2 0h6v1H5V8zm7 0h1v1h-1V8z"/>
</svg>
</button>
<!-- Clipboard sync toggle -->
<button
class="rdp-toolbar-btn"
:class="{ active: clipboardSync }"
title="Toggle clipboard sync"
@click="emit('toggle-clipboard')"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3z"/>
</svg>
</button>
<!-- Ctrl+Alt+Del -->
<button
class="rdp-toolbar-btn"
title="Send Ctrl+Alt+Del"
@click="emit('ctrl-alt-del')"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8z"/>
<path d="M6.5 5.5a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 0a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5z"/>
</svg>
</button>
<!-- Clipboard paste -->
<button
class="rdp-toolbar-btn"
title="Paste clipboard to remote"
@click="handleClipboardPaste"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M5 1.5A1.5 1.5 0 0 1 6.5 0h3A1.5 1.5 0 0 1 11 1.5v1A1.5 1.5 0 0 1 9.5 4h-3A1.5 1.5 0 0 1 5 2.5v-1zm1.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-3z"/>
<path d="M3 2.5a.5.5 0 0 1 .5-.5H5a.5.5 0 0 1 0 1h-1v11h8V3h-1a.5.5 0 0 1 0-1h1.5a.5.5 0 0 1 .5.5v12a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5v-12z"/>
<path d="M7 6.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 0 1H8v3.5a.5.5 0 0 1-1 0V7h-.5a.5.5 0 0 1-.5-.5z"/>
</svg>
</button>
<!-- Fullscreen toggle -->
<button
class="rdp-toolbar-btn"
title="Toggle fullscreen"
@click="emit('fullscreen')"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1h-4zM10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zM.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5z"/>
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
sessionId: string;
keyboardGrabbed: boolean;
clipboardSync: boolean;
}>();
const emit = defineEmits<{
"toggle-keyboard": [];
"toggle-clipboard": [];
"ctrl-alt-del": [];
"fullscreen": [];
"send-clipboard": [text: string];
}>();
/**
* Read the host clipboard and forward the text to the remote session.
* Uses the Clipboard API requires a secure context (https or localhost).
*/
async function handleClipboardPaste(): Promise<void> {
try {
const text = await navigator.clipboard.readText();
if (text) {
emit("send-clipboard", text);
}
} catch (err) {
console.warn("[RdpToolbar] Could not read clipboard:", err);
}
}
</script>
<style scoped>
.rdp-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 8px;
background: var(--wraith-bg-secondary, #161b22);
border-bottom: 1px solid var(--wraith-border, #30363d);
flex-shrink: 0;
}
.rdp-toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.rdp-toolbar-label {
font-size: 11px;
font-weight: 600;
color: var(--wraith-accent, #58a6ff);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.rdp-toolbar-session {
font-size: 11px;
color: var(--wraith-text-muted, #484f58);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rdp-toolbar-right {
display: flex;
align-items: center;
gap: 2px;
}
.rdp-toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--wraith-text-secondary, #8b949e);
cursor: pointer;
transition: all 0.15s ease;
}
.rdp-toolbar-btn:hover {
background: var(--wraith-bg-tertiary, #21262d);
color: var(--wraith-text-primary, #e6edf3);
}
.rdp-toolbar-btn.active {
background: var(--wraith-accent, #58a6ff);
color: #ffffff;
}
</style>

View File

@ -0,0 +1,252 @@
<template>
<div class="rdp-container" ref="containerRef">
<!-- Canvas -->
<div class="rdp-canvas-wrapper" ref="canvasWrapper">
<canvas
ref="canvasRef"
class="rdp-canvas"
tabindex="0"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
@mousemove="handleMouseMove"
@wheel.prevent="handleWheel"
@contextmenu.prevent
@keydown.prevent="handleKeyDown"
@keyup.prevent="handleKeyUp"
/>
</div>
<!-- Connection status overlay visible until first frame arrives -->
<div v-if="!connected" class="rdp-overlay">
<div class="rdp-overlay-content">
<div class="rdp-spinner" />
<p>Connecting to RDP session...</p>
<p class="rdp-overlay-sub">Session: {{ sessionId }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
import { useRdp, MouseFlag } from "@/composables/useRdp";
const props = defineProps<{
sessionId: string;
isActive: boolean;
width?: number;
height?: number;
}>();
const containerRef = ref<HTMLElement | null>(null);
const canvasWrapper = ref<HTMLElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const rdpWidth = props.width ?? 1920;
const rdpHeight = props.height ?? 1080;
const {
connected,
keyboardGrabbed,
clipboardSync,
sendMouse,
sendKey,
startFrameLoop,
stopFrameLoop,
toggleKeyboardGrab,
toggleClipboardSync,
} = useRdp();
// Expose toolbar state and controls so RdpToolbar can be a sibling component
// driven by the same composable instance via the parent (SessionContainer).
defineExpose({
keyboardGrabbed,
clipboardSync,
toggleKeyboardGrab,
toggleClipboardSync,
canvasWrapper,
});
/**
* Convert canvas-relative mouse coordinates to RDP coordinates,
* accounting for CSS scaling of the canvas element.
*/
function toRdpCoords(e: MouseEvent): { x: number; y: number } | null {
const canvas = canvasRef.value;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const scaleX = rdpWidth / rect.width;
const scaleY = rdpHeight / rect.height;
return {
x: Math.floor((e.clientX - rect.left) * scaleX),
y: Math.floor((e.clientY - rect.top) * scaleY),
};
}
function handleMouseDown(e: MouseEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
let buttonFlag = 0;
switch (e.button) {
case 0:
buttonFlag = MouseFlag.Button1;
break;
case 1:
buttonFlag = MouseFlag.Button3;
break; // middle
case 2:
buttonFlag = MouseFlag.Button2;
break;
}
sendMouse(props.sessionId, coords.x, coords.y, buttonFlag | MouseFlag.Down);
}
function handleMouseUp(e: MouseEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
let buttonFlag = 0;
switch (e.button) {
case 0:
buttonFlag = MouseFlag.Button1;
break;
case 1:
buttonFlag = MouseFlag.Button3;
break;
case 2:
buttonFlag = MouseFlag.Button2;
break;
}
sendMouse(props.sessionId, coords.x, coords.y, buttonFlag);
}
function handleMouseMove(e: MouseEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
sendMouse(props.sessionId, coords.x, coords.y, MouseFlag.Move);
}
function handleWheel(e: WheelEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
let flags = MouseFlag.Wheel;
if (e.deltaY > 0) {
flags |= MouseFlag.WheelNeg;
}
sendMouse(props.sessionId, coords.x, coords.y, flags);
}
function handleKeyDown(e: KeyboardEvent): void {
if (!keyboardGrabbed.value) return;
sendKey(props.sessionId, e.code, true);
}
function handleKeyUp(e: KeyboardEvent): void {
if (!keyboardGrabbed.value) return;
sendKey(props.sessionId, e.code, false);
}
onMounted(() => {
if (canvasRef.value) {
startFrameLoop(props.sessionId, canvasRef.value, rdpWidth, rdpHeight);
}
});
onBeforeUnmount(() => {
stopFrameLoop();
});
// Focus canvas when this tab becomes active and keyboard is grabbed
watch(
() => props.isActive,
(active) => {
if (active && keyboardGrabbed.value && canvasRef.value) {
setTimeout(() => {
canvasRef.value?.focus();
}, 0);
}
},
);
</script>
<style scoped>
.rdp-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: var(--wraith-bg-primary, #0d1117);
position: relative;
}
.rdp-canvas-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
min-height: 0;
}
.rdp-canvas {
max-width: 100%;
max-height: 100%;
object-fit: contain;
cursor: default;
outline: none;
image-rendering: auto;
}
.rdp-canvas:focus {
outline: 2px solid var(--wraith-accent, #58a6ff);
outline-offset: -2px;
}
.rdp-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(13, 17, 23, 0.85);
z-index: 10;
}
.rdp-overlay-content {
text-align: center;
color: var(--wraith-text-secondary, #8b949e);
}
.rdp-overlay-content p {
margin: 8px 0;
font-size: 14px;
}
.rdp-overlay-sub {
font-size: 12px !important;
color: var(--wraith-text-muted, #484f58);
}
.rdp-spinner {
width: 32px;
height: 32px;
margin: 0 auto 16px;
border: 3px solid var(--wraith-border, #30363d);
border-top-color: var(--wraith-accent, #58a6ff);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="flex-1 flex flex-col bg-[var(--wraith-bg-primary)] min-h-0 relative"> <div class="flex-1 flex flex-col bg-[var(--wraith-bg-primary)] min-h-0 relative">
<!-- Terminal views v-show keeps them alive across tab switches --> <!-- SSH terminal views v-show keeps xterm alive across tab switches -->
<div <div
v-for="session in sshSessions" v-for="session in sshSessions"
:key="session.id" :key="session.id"
@ -13,6 +13,30 @@
/> />
</div> </div>
<!-- RDP views toolbar + canvas, kept alive via v-show -->
<div
v-for="session in rdpSessions"
:key="session.id"
v-show="session.id === sessionStore.activeSessionId"
class="absolute inset-0 flex flex-col"
>
<RdpToolbar
:session-id="session.id"
:keyboard-grabbed="rdpViewRefs[session.id]?.keyboardGrabbed.value ?? false"
:clipboard-sync="rdpViewRefs[session.id]?.clipboardSync.value ?? false"
@toggle-keyboard="rdpViewRefs[session.id]?.toggleKeyboardGrab()"
@toggle-clipboard="rdpViewRefs[session.id]?.toggleClipboardSync()"
@ctrl-alt-del="sendCtrlAltDel(session.id)"
@fullscreen="toggleFullscreen(rdpViewRefs[session.id]?.canvasWrapper)"
@send-clipboard="(text) => sendClipboardToSession(session.id, text)"
/>
<RdpView
:ref="(el) => setRdpRef(session.id, el)"
:session-id="session.id"
:is-active="session.id === sessionStore.activeSessionId"
/>
</div>
<!-- No session placeholder --> <!-- No session placeholder -->
<div <div
v-if="!sessionStore.activeSession" v-if="!sessionStore.activeSession"
@ -31,13 +55,100 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed, ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useSessionStore } from "@/stores/session.store"; import { useSessionStore } from "@/stores/session.store";
import TerminalView from "@/components/terminal/TerminalView.vue"; import TerminalView from "@/components/terminal/TerminalView.vue";
import RdpView from "@/components/rdp/RdpView.vue";
import RdpToolbar from "@/components/rdp/RdpToolbar.vue";
import { ScancodeMap } from "@/composables/useRdp";
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const sshSessions = computed(() => const sshSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "ssh"), sessionStore.sessions.filter((s) => s.protocol === "ssh"),
); );
const rdpSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "rdp"),
);
/**
* Map from session ID exposed RdpView instance ref.
* The ref callback pattern (`:ref="(el) => ..."`) gives us per-session access
* to each RdpView's composable state without a single shared ref.
*/
const rdpViewRefs = ref<
Record<
string,
{
keyboardGrabbed: { value: boolean };
clipboardSync: { value: boolean };
toggleKeyboardGrab: () => void;
toggleClipboardSync: () => void;
canvasWrapper: { value: HTMLElement | null };
} | null
>
>({});
function setRdpRef(
sessionId: string,
el: unknown,
): void {
if (el) {
rdpViewRefs.value[sessionId] = el as typeof rdpViewRefs.value[string];
} else {
delete rdpViewRefs.value[sessionId];
}
}
/**
* Send Ctrl+Alt+Del to a remote RDP session by injecting the three
* scancodes in the correct order: Ctrl down, Alt down, Del down, then up.
*/
async function sendCtrlAltDel(sessionId: string): Promise<void> {
const ctrl = ScancodeMap["ControlLeft"];
const alt = ScancodeMap["AltLeft"];
const del = ScancodeMap["Delete"];
const sequence: Array<[number, boolean]> = [
[ctrl, true],
[alt, true],
[del, true],
[del, false],
[alt, false],
[ctrl, false],
];
for (const [scancode, pressed] of sequence) {
try {
await invoke("rdp_send_key", { sessionId, scancode, pressed });
} catch (err) {
console.warn("[SessionContainer] Ctrl+Alt+Del key send failed:", err);
}
}
}
/**
* Forward clipboard text from the host to the remote RDP session.
*/
function sendClipboardToSession(sessionId: string, text: string): void {
invoke("rdp_send_clipboard", { sessionId, text }).catch((err: unknown) => {
console.warn("[SessionContainer] sendClipboard failed:", err);
});
}
/**
* Toggle fullscreen on the canvas wrapper element.
*/
function toggleFullscreen(wrapperRef: { value: HTMLElement | null } | undefined): void {
const wrapper = wrapperRef?.value;
if (!wrapper) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
wrapper.requestFullscreen();
}
}
</script> </script>

365
src/composables/useRdp.ts Normal file
View File

@ -0,0 +1,365 @@
import { ref, onBeforeUnmount } from "vue";
import { invoke } from "@tauri-apps/api/core";
/**
* RDP mouse event flags match the Rust constants in src-tauri/src/rdp/input.rs
*/
export const MouseFlag = {
Move: 0x0800,
Button1: 0x1000, // Left
Button2: 0x2000, // Right
Button3: 0x4000, // Middle
Down: 0x8000,
Wheel: 0x0200,
WheelNeg: 0x0100,
HWheel: 0x0400,
} as const;
/**
* JavaScript KeyboardEvent.code RDP scancode mapping.
* Mirrors the Rust ScancodeMap in src-tauri/src/rdp/input.rs.
*/
export const ScancodeMap: Record<string, number> = {
// Row 0: Escape + Function keys
Escape: 0x0001,
F1: 0x003b,
F2: 0x003c,
F3: 0x003d,
F4: 0x003e,
F5: 0x003f,
F6: 0x0040,
F7: 0x0041,
F8: 0x0042,
F9: 0x0043,
F10: 0x0044,
F11: 0x0057,
F12: 0x0058,
// Row 1: Number row
Backquote: 0x0029,
Digit1: 0x0002,
Digit2: 0x0003,
Digit3: 0x0004,
Digit4: 0x0005,
Digit5: 0x0006,
Digit6: 0x0007,
Digit7: 0x0008,
Digit8: 0x0009,
Digit9: 0x000a,
Digit0: 0x000b,
Minus: 0x000c,
Equal: 0x000d,
Backspace: 0x000e,
// Row 2: QWERTY
Tab: 0x000f,
KeyQ: 0x0010,
KeyW: 0x0011,
KeyE: 0x0012,
KeyR: 0x0013,
KeyT: 0x0014,
KeyY: 0x0015,
KeyU: 0x0016,
KeyI: 0x0017,
KeyO: 0x0018,
KeyP: 0x0019,
BracketLeft: 0x001a,
BracketRight: 0x001b,
Backslash: 0x002b,
// Row 3: Home row
CapsLock: 0x003a,
KeyA: 0x001e,
KeyS: 0x001f,
KeyD: 0x0020,
KeyF: 0x0021,
KeyG: 0x0022,
KeyH: 0x0023,
KeyJ: 0x0024,
KeyK: 0x0025,
KeyL: 0x0026,
Semicolon: 0x0027,
Quote: 0x0028,
Enter: 0x001c,
// Row 4: Bottom row
ShiftLeft: 0x002a,
KeyZ: 0x002c,
KeyX: 0x002d,
KeyC: 0x002e,
KeyV: 0x002f,
KeyB: 0x0030,
KeyN: 0x0031,
KeyM: 0x0032,
Comma: 0x0033,
Period: 0x0034,
Slash: 0x0035,
ShiftRight: 0x0036,
// Row 5: Bottom modifiers + space
ControlLeft: 0x001d,
MetaLeft: 0xe05b,
AltLeft: 0x0038,
Space: 0x0039,
AltRight: 0xe038,
MetaRight: 0xe05c,
ContextMenu: 0xe05d,
ControlRight: 0xe01d,
// Navigation cluster
PrintScreen: 0xe037,
ScrollLock: 0x0046,
Pause: 0x0045,
Insert: 0xe052,
Home: 0xe047,
PageUp: 0xe049,
Delete: 0xe053,
End: 0xe04f,
PageDown: 0xe051,
// Arrow keys
ArrowUp: 0xe048,
ArrowLeft: 0xe04b,
ArrowDown: 0xe050,
ArrowRight: 0xe04d,
// Numpad
NumLock: 0x0045,
NumpadDivide: 0xe035,
NumpadMultiply: 0x0037,
NumpadSubtract: 0x004a,
Numpad7: 0x0047,
Numpad8: 0x0048,
Numpad9: 0x0049,
NumpadAdd: 0x004e,
Numpad4: 0x004b,
Numpad5: 0x004c,
Numpad6: 0x004d,
Numpad1: 0x004f,
Numpad2: 0x0050,
Numpad3: 0x0051,
NumpadEnter: 0xe01c,
Numpad0: 0x0052,
NumpadDecimal: 0x0053,
};
/**
* Look up the RDP scancode for a JS KeyboardEvent.code string.
*/
export function jsKeyToScancode(code: string): number | null {
return ScancodeMap[code] ?? null;
}
export interface UseRdpReturn {
/** Whether the RDP session is connected (first frame received) */
connected: ReturnType<typeof ref<boolean>>;
/** Whether keyboard capture is enabled */
keyboardGrabbed: ReturnType<typeof ref<boolean>>;
/** Whether clipboard sync is enabled */
clipboardSync: ReturnType<typeof ref<boolean>>;
/** Fetch the current frame as RGBA ImageData */
fetchFrame: (sessionId: string, width: number, height: number) => Promise<ImageData | null>;
/** Send a mouse event to the backend */
sendMouse: (sessionId: string, x: number, y: number, flags: number) => void;
/** Send a key event to the backend */
sendKey: (sessionId: string, code: string, pressed: boolean) => void;
/** Send clipboard text to the remote session */
sendClipboard: (sessionId: string, text: string) => void;
/** Start the frame rendering loop targeting ~30fps */
startFrameLoop: (
sessionId: string,
canvas: HTMLCanvasElement,
width: number,
height: number,
) => void;
/** Stop the frame rendering loop */
stopFrameLoop: () => void;
/** Toggle keyboard grab */
toggleKeyboardGrab: () => void;
/** Toggle clipboard sync */
toggleClipboardSync: () => void;
}
/**
* Composable that manages an RDP session's rendering and input.
*
* Uses Tauri's invoke() to call Rust commands:
* rdp_get_frame base64 RGBA string
* rdp_send_mouse fire-and-forget
* rdp_send_key fire-and-forget
* rdp_send_clipboard fire-and-forget
*/
export function useRdp(): UseRdpReturn {
const connected = ref(false);
const keyboardGrabbed = ref(false);
const clipboardSync = ref(false);
let animFrameId: number | null = null;
let frameCount = 0;
/**
* Fetch the current frame from the Rust RDP backend.
*
* rdp_get_frame returns raw RGBA bytes (width*height*4) serialised as a
* base64 string over Tauri's IPC bridge. We decode it to Uint8ClampedArray
* and wrap in an ImageData for putImageData().
*/
async function fetchFrame(
sessionId: string,
width: number,
height: number,
): Promise<ImageData | null> {
let raw: string;
try {
raw = await invoke<string>("rdp_get_frame", { sessionId });
} catch {
// Session may not be connected yet or backend returned an error — skip frame
return null;
}
if (!raw) return null;
// Decode base64 → binary string → Uint8ClampedArray
const binaryStr = atob(raw);
const bytes = new Uint8ClampedArray(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
// Validate: RGBA requires exactly width * height * 4 bytes
const expected = width * height * 4;
if (bytes.length !== expected) {
console.warn(
`[useRdp] Frame size mismatch: got ${bytes.length}, expected ${expected}`,
);
return null;
}
return new ImageData(bytes, width, height);
}
/**
* Send a mouse event to the remote session.
* Calls Rust rdp_send_mouse(sessionId, x, y, flags).
* Fire-and-forget mouse events are best-effort.
*/
function sendMouse(
sessionId: string,
x: number,
y: number,
flags: number,
): void {
invoke("rdp_send_mouse", { sessionId, x, y, flags }).catch(
(err: unknown) => {
console.warn("[useRdp] sendMouse failed:", err);
},
);
}
/**
* Send a key event, mapping the JS KeyboardEvent.code to an RDP scancode.
* Calls Rust rdp_send_key(sessionId, scancode, pressed).
* Unmapped keys are silently dropped not every JS key has an RDP scancode.
*/
function sendKey(sessionId: string, code: string, pressed: boolean): void {
const scancode = jsKeyToScancode(code);
if (scancode === null) return;
invoke("rdp_send_key", { sessionId, scancode, pressed }).catch(
(err: unknown) => {
console.warn("[useRdp] sendKey failed:", err);
},
);
}
/**
* Send clipboard text to the remote RDP session.
* Calls Rust rdp_send_clipboard(sessionId, text).
*/
function sendClipboard(sessionId: string, text: string): void {
invoke("rdp_send_clipboard", { sessionId, text }).catch(
(err: unknown) => {
console.warn("[useRdp] sendClipboard failed:", err);
},
);
}
/**
* Start the rendering loop. Fetches frames and draws them on the canvas.
*
* Targets ~30fps by skipping every other rAF tick (rAF fires at ~60fps).
* Sets connected = true as soon as the loop starts the overlay dismisses
* on first successful frame render.
*/
function startFrameLoop(
sessionId: string,
canvas: HTMLCanvasElement,
width: number,
height: number,
): void {
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = width;
canvas.height = height;
function renderLoop(): void {
frameCount++;
// Throttle to ~30fps by skipping odd-numbered rAF ticks
if (frameCount % 2 === 0) {
fetchFrame(sessionId, width, height).then((imageData) => {
if (imageData && ctx) {
ctx.putImageData(imageData, 0, 0);
// Mark connected on first successful frame
if (!connected.value) {
connected.value = true;
}
}
});
}
animFrameId = requestAnimationFrame(renderLoop);
}
animFrameId = requestAnimationFrame(renderLoop);
}
/**
* Stop the rendering loop and reset connected state.
*/
function stopFrameLoop(): void {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId);
animFrameId = null;
}
connected.value = false;
frameCount = 0;
}
function toggleKeyboardGrab(): void {
keyboardGrabbed.value = !keyboardGrabbed.value;
}
function toggleClipboardSync(): void {
clipboardSync.value = !clipboardSync.value;
}
onBeforeUnmount(() => {
stopFrameLoop();
});
return {
connected,
keyboardGrabbed,
clipboardSync,
fetchFrame,
sendMouse,
sendKey,
sendClipboard,
startFrameLoop,
stopFrameLoop,
toggleKeyboardGrab,
toggleClipboardSync,
};
}

View File

@ -46,9 +46,13 @@ export const useSessionStore = defineStore("session", () => {
const session = sessions.value[idx]; const session = sessions.value[idx];
// Disconnect the backend session // Disconnect the backend session using the protocol-appropriate command
try { try {
if (session.protocol === "rdp") {
await invoke("disconnect_rdp", { sessionId: session.id });
} else {
await invoke("disconnect_session", { sessionId: session.id }); await invoke("disconnect_session", { sessionId: session.id });
}
} catch (err) { } catch (err) {
console.error("Failed to disconnect session:", err); console.error("Failed to disconnect session:", err);
} }
@ -154,8 +158,89 @@ export const useSessionStore = defineStore("session", () => {
username: resolvedUsername, username: resolvedUsername,
}); });
activeSessionId.value = sessionId; activeSessionId.value = sessionId;
} else if (conn.protocol === "rdp") {
let username = "";
let password = "";
let domain = "";
// Extract stored credentials from connection options JSON if present
if (conn.options) {
try {
const opts = JSON.parse(conn.options);
if (opts?.username) username = opts.username;
if (opts?.password) password = opts.password;
if (opts?.domain) domain = opts.domain;
} catch {
// ignore malformed options
}
}
let sessionId: string;
try {
sessionId = await invoke<string>("connect_rdp", {
config: {
hostname: conn.hostname,
port: conn.port,
username,
password,
domain,
width: 1920,
height: 1080,
},
});
} catch (rdpErr: unknown) {
const errMsg =
rdpErr instanceof Error
? rdpErr.message
: typeof rdpErr === "string"
? rdpErr
: String(rdpErr);
// If credentials are missing or rejected, prompt the operator
if (
errMsg.includes("NO_CREDENTIALS") ||
errMsg.includes("authentication") ||
errMsg.includes("logon failure")
) {
const promptedUsername = prompt(
`Username for ${conn.hostname}:`,
"Administrator",
);
if (!promptedUsername) throw new Error("Connection cancelled");
const promptedPassword = prompt(
`Password for ${promptedUsername}@${conn.hostname}:`,
);
if (promptedPassword === null) throw new Error("Connection cancelled");
const promptedDomain = prompt(`Domain (leave blank if none):`, "") ?? "";
username = promptedUsername;
sessionId = await invoke<string>("connect_rdp", {
config: {
hostname: conn.hostname,
port: conn.port,
username: promptedUsername,
password: promptedPassword,
domain: promptedDomain,
width: 1920,
height: 1080,
},
});
} else {
throw rdpErr;
}
}
sessions.value.push({
id: sessionId,
connectionId,
name: disambiguatedName(conn.name, connectionId),
protocol: "rdp",
active: true,
username,
});
activeSessionId.value = sessionId;
} }
// RDP support will be added in a future phase
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err); const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
console.error("Connection failed:", msg); console.error("Connection failed:", msg);