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:
parent
a8656b0812
commit
c75da74ecd
2460
src-tauri/Cargo.lock
generated
2460
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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"] }
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
94
src-tauri/src/commands/rdp_commands.rs
Normal file
94
src-tauri/src/commands/rdp_commands.rs
Normal 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())
|
||||||
|
}
|
||||||
@ -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
192
src-tauri/src/rdp/input.rs
Normal 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
619
src-tauri/src/rdp/mod.rs
Normal 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
|
||||||
|
}
|
||||||
168
src/components/rdp/RdpToolbar.vue
Normal file
168
src/components/rdp/RdpToolbar.vue
Normal 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>
|
||||||
252
src/components/rdp/RdpView.vue
Normal file
252
src/components/rdp/RdpView.vue
Normal 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>
|
||||||
@ -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
365
src/composables/useRdp.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
await invoke("disconnect_session", { sessionId: session.id });
|
if (session.protocol === "rdp") {
|
||||||
|
await invoke("disconnect_rdp", { sessionId: session.id });
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user