diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bcf00fcb7..c5f27e34d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -168,7 +168,15 @@ NOTE: it’s not yet clear if this crate is an API Boundary or an implementation #### [`crates/ironrdp-client`](./crates/ironrdp-client) -Portable RDP client without GPU acceleration. +Reusable client engine library: holds the `Config`/`ConfigBuilder`, the `RdpClient` runtime, +input/output event types, and the WebSocket transport. Consumed by `ironrdp-viewer` and any +other embedder (e.g. a headless agent). + +#### [`crates/ironrdp-viewer`](./crates/ironrdp-viewer) + +Portable RDP client binary without GPU acceleration. A thin wrapper around `ironrdp-client` +that adds the winit/softbuffer GUI event loop, the clap CLI, the inquire prompts and the +`.rdp` file / PropertySet plumbing. #### [`crates/ironrdp-web`](./crates/ironrdp-web) diff --git a/Cargo.lock b/Cargo.lock index 12f3af5d6..411304e01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2459,39 +2459,23 @@ name = "ironrdp-client" version = "0.1.0" dependencies = [ "anyhow", - "clap", "futures-util", - "inquire", "ironrdp", - "ironrdp-cfg", - "ironrdp-cliprdr-native", "ironrdp-core", "ironrdp-dvc-com-plugin", "ironrdp-dvc-pipe-proxy", "ironrdp-mstsgu", - "ironrdp-propertyset", "ironrdp-rdcleanpath", - "ironrdp-rdpfile", "ironrdp-rdpsnd-native", "ironrdp-tls", "ironrdp-tokio", - "proc-exit", - "raw-window-handle", - "semver", "smallvec", - "softbuffer", - "tap", "tokio", "tokio-tungstenite", "tokio-util", "tracing", - "tracing-subscriber", "transport", "url", - "uuid", - "whoami", - "windows", - "winit", "x509-cert", ] @@ -2924,6 +2908,7 @@ dependencies = [ "ironrdp-client", "ironrdp-tls", "ironrdp-tokio", + "ironrdp-viewer", "semver", "tokio", "tracing", @@ -2952,6 +2937,34 @@ dependencies = [ "url", ] +[[package]] +name = "ironrdp-viewer" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "inquire", + "ironrdp", + "ironrdp-cfg", + "ironrdp-client", + "ironrdp-cliprdr-native", + "ironrdp-mstsgu", + "ironrdp-propertyset", + "ironrdp-rdpfile", + "proc-exit", + "raw-window-handle", + "semver", + "smallvec", + "softbuffer", + "tap", + "tokio", + "tracing", + "tracing-subscriber", + "url", + "whoami", + "winit", +] + [[package]] name = "ironrdp-web" version = "0.0.0" diff --git a/README.md b/README.md index bbb1a51ec..3bc584851 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,13 @@ Supported codecs: ## Examples -### [`ironrdp-client`](https://github.com/Devolutions/IronRDP/tree/master/crates/ironrdp-client) +### [`ironrdp-viewer`](https://github.com/Devolutions/IronRDP/tree/master/crates/ironrdp-viewer) A full-fledged RDP client based on IronRDP crates suite, and implemented using non-blocking, asynchronous I/O. +It is built on top of the reusable [`ironrdp-client`](https://github.com/Devolutions/IronRDP/tree/master/crates/ironrdp-client) library crate. ```shell -cargo run --bin ironrdp-client -- --username --password +cargo run --bin ironrdp-viewer -- --username --password ``` ### [`screenshot`](https://github.com/Devolutions/IronRDP/blob/master/crates/ironrdp/examples/screenshot.rs) diff --git a/crates/ironrdp-client/Cargo.toml b/crates/ironrdp-client/Cargo.toml index 946d3a07d..4a393f577 100644 --- a/crates/ironrdp-client/Cargo.toml +++ b/crates/ironrdp-client/Cargo.toml @@ -2,7 +2,7 @@ name = "ironrdp-client" version = "0.1.0" readme = "README.md" -description = "Portable RDP client without GPU acceleration" +description = "Portable RDP client engine without GPU acceleration" edition.workspace = true license.workspace = true homepage.workspace = true @@ -10,7 +10,6 @@ repository.workspace = true authors.workspace = true keywords.workspace = true categories.workspace = true -default-run = "ironrdp-client" # Not publishing for now. publish = false @@ -19,10 +18,6 @@ publish = false doctest = false test = false -[[bin]] -name = "ironrdp-client" -test = false - [features] default = ["rustls"] rustls = ["ironrdp-tls/rustls", "tokio-tungstenite/rustls-tls-native-roots", "ironrdp-mstsgu/rustls"] @@ -46,50 +41,30 @@ ironrdp = { path = "../ironrdp", version = "0.14", features = [ "echo", ] } ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } -ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.5" } ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.5" } ironrdp-tls = { path = "../ironrdp-tls", version = "0.2" } ironrdp-mstsgu = { path = "../ironrdp-mstsgu" } ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.8", features = ["reqwest"] } ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath" ironrdp-dvc-pipe-proxy.path = "../ironrdp-dvc-pipe-proxy" -ironrdp-propertyset.path = "../ironrdp-propertyset" -ironrdp-rdpfile.path = "../ironrdp-rdpfile" -ironrdp-cfg.path = "../ironrdp-cfg" - -# Windowing and rendering -winit = { version = "0.30", features = ["rwh_06"] } -softbuffer = "0.4" - -# CLI -clap = { version = "4.6", features = ["derive", "cargo"] } -proc-exit = "2" -inquire = "0.9" # Logging tracing = { version = "0.1", features = ["log"] } -tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Async, futures -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["macros", "net", "io-util", "sync", "rt", "time"] } tokio-util = { version = "0.7" } tokio-tungstenite = "0.29" transport = { git = "https://github.com/Devolutions/devolutions-gateway", rev = "06e91dfe82751a6502eaf74b6a99663f06f0236d" } futures-util = { version = "0.3", features = ["sink"] } # Utils -whoami = "2.1" anyhow = "1" smallvec = "1.15" -tap = "1" -semver = "1" -raw-window-handle = "0.6" -uuid = { version = ">=1.16, <1.21" } # Pinned below 1.21: see ironrdp-mstsgu/Cargo.toml for rationale. -x509-cert = { version = "0.2", default-features = false, features = ["std"] } url = "2" +x509-cert = { version = "0.2", default-features = false, features = ["std"] } [target.'cfg(windows)'.dependencies] -windows = { version = "0.62", features = ["Win32_Foundation"] } ironrdp-dvc-com-plugin = { path = "../ironrdp-dvc-com-plugin" } [lints] diff --git a/crates/ironrdp-client/README.md b/crates/ironrdp-client/README.md index f909b3c5f..595900155 100644 --- a/crates/ironrdp-client/README.md +++ b/crates/ironrdp-client/README.md @@ -1,84 +1,18 @@ # IronRDP client -Portable RDP client without GPU acceleration. +Reusable RDP client engine library built on top of the IronRDP crates suite. -This is a a full-fledged RDP client based on IronRDP crates suite, and implemented using -non-blocking, asynchronous I/O. Portability is achieved by using softbuffer for rendering -and winit for windowing. +This crate is **library-only**: it exposes the `Config`, the `RdpClient` +runtime, input/output event types, the WebSocket transport, and the session driver. It is +consumed by `ironrdp-viewer` (the portable GUI client binary) and by any other embedder +(for example, a headless agent). -## Sample usage +The library is winit-agnostic. Output events are emitted on a bounded +`tokio::sync::mpsc::Sender` channel: the embedder is responsible +for consuming them and dispatching them to whatever event loop or runtime it wishes. -```shell -ironrdp-client --username --password -``` - -## `.rdp` file support - -You can load a `.rdp` file with `--rdp-file `. - -Currently supported properties: - -- `full address:s:` -- `alternate full address:s:` -- `server port:i:` -- `username:s:` -- `ClearTextPassword:s:` -- `domain:s:` -- `enablecredsspsupport:i:<0|1>` -- `gatewayhostname:s:` -- `gatewayusagemethod:i:` -- `gatewaycredentialssource:i:` -- `gatewayusername:s:` -- `GatewayPassword:s:` -- `kdcproxyurl:s:` (also `KDCProxyURL:s:`) -- `kdcproxyname:s:` -- `alternate shell:s:` -- `shell working directory:s:` -- `redirectclipboard:i:<0|1>` -- `audiomode:i:<0|1|2>` -- `desktopwidth:i:` -- `desktopheight:i:` -- `desktopscalefactor:i:` -- `compression:i:<0|1>` - -Property precedence is: - -1. CLI options -2. `.rdp` file values -3. Defaults and interactive prompts - -Unknown or unsupported `.rdp` properties are ignored and do not cause parsing failures. Parse -issues are reported to stderr. - - -The `IRONRDP_LOG` environment variable is used to set the log filter directives. - -```shell -IRONRDP_LOG="info,ironrdp_connector=trace" ironrdp-client --username --password -``` - -See [`tracing-subscriber`’s documentation][tracing-doc] for more details. - -[tracing-doc]: https://docs.rs/tracing-subscriber/0.3.17/tracing_subscriber/filter/struct.EnvFilter.html#directives - -## Support for `SSLKEYLOGFILE` - -This client supports reading the `SSLKEYLOGFILE` environment variable. -When set, the TLS encryption secrets for the session will be dumped to the file specified -by the environment variable. -This file can be read by Wireshark so that in can decrypt the packets. - -### Example - -```shell -SSLKEYLOGFILE=/tmp/tls-secrets ironrdp-client --username --password -``` - -### Usage in Wireshark - -See this [awakecoding's repository][awakecoding-repository] explaining how to use the file in wireshark. +For the end-user RDP client binary, see [`ironrdp-viewer`](../ironrdp-viewer). This crate is part of the [IronRDP] project. [IronRDP]: https://github.com/Devolutions/IronRDP -[awakecoding-repository]: https://github.com/awakecoding/wireshark-rdp#sslkeylogfile diff --git a/crates/ironrdp-client/src/config.rs b/crates/ironrdp-client/src/config.rs index 4dcdbc20a..4bcd59500 100644 --- a/crates/ironrdp-client/src/config.rs +++ b/crates/ironrdp-client/src/config.rs @@ -1,24 +1,19 @@ -#![allow(clippy::print_stdout, clippy::print_stderr)] - use core::fmt; -use core::num::ParseIntError; use core::str::FromStr; use core::time::Duration; +#[cfg(windows)] use std::path::PathBuf; use anyhow::Context as _; -use clap::Parser; -use clap::clap_derive::ValueEnum; -use ironrdp::connector::{self, Credentials}; -use ironrdp::pdu::rdp::capability_sets::{MajorPlatformType, client_codecs_capabilities}; -use ironrdp::pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; +use ironrdp::connector; use ironrdp_mstsgu::GwConnectTarget; -use tap::prelude::*; use url::Url; -const DEFAULT_WIDTH: u16 = 1920; -const DEFAULT_HEIGHT: u16 = 1080; - +/// Fully resolved client configuration. +/// +/// This is the typed surface consumed by [`crate::rdp::RdpClient`]. Producing a `Config` +/// from CLI arguments, `.rdp` files, or interactive prompts is the consumer's responsibility +/// (see the `ironrdp-viewer` crate for a reference CLI front-end). #[derive(Clone, Debug)] pub struct Config { pub log_file: Option, @@ -45,119 +40,18 @@ pub struct Config { pub dvc_plugins: Vec, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +/// Resolved clipboard backend selection. +/// +/// Platform-specific details (e.g., which native clipboard backend to use) are handled +/// internally by the library when `Enable` is selected. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ClipboardType { - Default, + /// Enable clipboard redirection (use the best available backend). + Enable, + /// Disable clipboard redirection entirely. + Disable, + /// Use a stub clipboard backend (for testing or headless usage). Stub, - #[cfg(windows)] - Windows, - None, -} - -fn apply_cli_args_to_properties(properties: &mut ironrdp_propertyset::PropertySet, args: &Args) { - if let Some(dest) = &args.destination { - // Format the host in .rdp canonical form: IPv6 gets bracketed ("[::1]"), others are plain. - let host = dest - .name() - .parse::() - .map(ironrdp_cfg::TargetHost::Ip) - .unwrap_or_else(|_| ironrdp_cfg::TargetHost::Domain(dest.name().to_owned())); - properties.insert("full address", format!("{host}:{}", dest.port())); - } - - if let Some(username) = &args.username { - properties.insert("username", username.as_str()); - } - - if let Some(password) = &args.password { - properties.insert("ClearTextPassword", password.as_str()); - } - - if let Some(domain) = &args.domain { - properties.insert("domain", domain.as_str()); - } - - if let Some(scale) = args.scale_desktop { - properties.insert("desktopscalefactor", i64::from(scale)); - } - - if let Some(width) = args.desktop_width { - properties.insert("desktopwidth", i64::from(width)); - } - - if let Some(height) = args.desktop_height { - properties.insert("desktopheight", i64::from(height)); - } - - if let Some(gw_host) = &args.gw_endpoint { - properties.insert("gatewayhostname", gw_host.as_str()); - // Ensure the gateway is treated as enabled when a host is provided explicitly. - properties.insert( - "gatewayusagemethod", - ironrdp_cfg::GatewayUsageMethod::UseAlways.as_i64(), - ); - } - - if let Some(gw_user) = &args.gw_user { - properties.insert("gatewayusername", gw_user.as_str()); - } - - if let Some(gw_pass) = &args.gw_pass { - properties.insert("GatewayPassword", gw_pass.as_str()); - } - - if args.no_credssp { - properties.insert("enablecredsspsupport", 0i64); - } - - if let Some(enabled) = args.compression_enabled { - properties.insert("compression", enabled); - } -} - -fn compression_type_from_level(level: u32) -> anyhow::Result { - use ironrdp::pdu::rdp::client_info::CompressionType; - - match level { - 0 => Ok(CompressionType::K8), - 1 => Ok(CompressionType::K64), - 2 => Ok(CompressionType::Rdp6), - 3 => Ok(CompressionType::Rdp61), - _ => anyhow::bail!("Invalid compression level. Valid values are 0, 1, 2, 3."), - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] -pub enum KeyboardType { - IbmPcXt, - OlivettiIco, - IbmPcAt, - IbmEnhanced, - Nokia1050, - Nokia9140, - Japanese, -} - -impl KeyboardType { - fn parse(keyboard_type: KeyboardType) -> ironrdp::pdu::gcc::KeyboardType { - match keyboard_type { - KeyboardType::IbmEnhanced => ironrdp::pdu::gcc::KeyboardType::IbmEnhanced, - KeyboardType::IbmPcAt => ironrdp::pdu::gcc::KeyboardType::IbmPcAt, - KeyboardType::IbmPcXt => ironrdp::pdu::gcc::KeyboardType::IbmPcXt, - KeyboardType::OlivettiIco => ironrdp::pdu::gcc::KeyboardType::OlivettiIco, - KeyboardType::Nokia1050 => ironrdp::pdu::gcc::KeyboardType::Nokia1050, - KeyboardType::Nokia9140 => ironrdp::pdu::gcc::KeyboardType::Nokia9140, - KeyboardType::Japanese => ironrdp::pdu::gcc::KeyboardType::Japanese, - } - } -} - -fn parse_hex(input: &str) -> Result { - if input.starts_with("0x") { - u32::from_str_radix(input.get(2..).unwrap_or(""), 16) - } else { - input.parse::() - } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -204,6 +98,17 @@ impl Destination { pub fn port(&self) -> u16 { self.port } + + /// Construct a `Destination` from already-validated components. + /// + /// Intended for front-ends that have already resolved the host and port from their own + /// configuration sources (CLI flags, `.rdp` files, IPC schemas). + pub fn from_parts(name: impl Into, port: u16) -> Self { + Self { + name: name.into(), + port, + } + } } impl fmt::Display for Destination { @@ -269,557 +174,3 @@ impl FromStr for DvcProxyInfo { }) } } - -/// Devolutions IronRDP client -#[derive(Parser, Debug)] -#[clap(author = "Devolutions", about = "Devolutions-IronRDP client")] -#[clap(version, long_about = None)] -struct Args { - /// A file with IronRDP client logs - #[clap(short, long, value_parser)] - log_file: Option, - - #[clap(long, value_parser)] - gw_endpoint: Option, - #[clap(long, value_parser)] - gw_user: Option, - #[clap(long, value_parser)] - gw_pass: Option, - - /// An address on which the client will connect. - destination: Option, - - /// Path to a .rdp file to read the configuration from. - #[clap(long)] - rdp_file: Option, - - /// A target RDP server user name - #[clap(short, long)] - username: Option, - - /// An optional target RDP server domain name - #[clap(short, long)] - domain: Option, - - /// A target RDP server user password - #[clap(short, long)] - password: Option, - - /// Proxy URL to connect to for the RDCleanPath - #[clap(long, requires("rdcleanpath_token"))] - rdcleanpath_url: Option, - - /// Authentication token to insert in the RDCleanPath packet - #[clap(long, requires("rdcleanpath_url"))] - rdcleanpath_token: Option, - - /// The keyboard type - #[clap(long, value_enum, default_value_t = KeyboardType::IbmEnhanced)] - keyboard_type: KeyboardType, - - /// The keyboard subtype (an original equipment manufacturer-dependent value) - #[clap(long, default_value_t = 0)] - keyboard_subtype: u32, - - /// The number of function keys on the keyboard - #[clap(long, default_value_t = 12)] - keyboard_functional_keys_count: u32, - - /// The input method editor (IME) file name associated with the active input locale - #[clap(long, default_value_t = String::from(""))] - ime_file_name: String, - - /// Contains a value that uniquely identifies the client - #[clap(long, default_value_t = String::from(""))] - dig_product_id: String, - - /// Enable thin client - #[clap(long)] - thin_client: bool, - - /// Enable small cache - #[clap(long)] - small_cache: bool, - - /// Scaling factor for desktop applications, percentage (value between 100 and 500) - #[clap(long, value_parser = clap::value_parser!(u32).range(100..=500))] - scale_desktop: Option, - - /// Desired desktop width for the RDP session - #[clap(long, value_parser = clap::value_parser!(u16).range(1..=8192))] - desktop_width: Option, - - /// Desired desktop height for the RDP session - #[clap(long, value_parser = clap::value_parser!(u16).range(1..=8192))] - desktop_height: Option, - - /// Set required color depth. Currently only 32 and 16 bit color depths are supported - #[clap(long)] - color_depth: Option, - - /// Ignore mouse pointer messages sent by the server. Increases performance when enabled, as the - /// client could skip costly software rendering of the pointer with alpha blending - #[clap(long)] - no_server_pointer: bool, - - /// Enabled capability versions. Each bit represents enabling a capability version - /// starting from V8 to V10_7 - #[clap(long, value_parser = parse_hex, default_value_t = 0)] - capabilities: u32, - - /// Automatically logon to the server by passing the INFO_AUTOLOGON flag - /// - /// This flag is ignored if CredSSP authentication is used. - /// You can use `--no-credssp` to ensure it’s not. - #[clap(long)] - autologon: bool, - - /// Disable TLS + Graphical login (legacy authentication method) - /// - /// Disabling this in order to enforce usage of CredSSP (NLA) is recommended. - #[clap(long)] - no_tls: bool, - - /// Disable TLS + Network Level Authentication (NLA) using CredSSP - /// - /// NLA is used to authenticates RDP clients and servers before sending credentials over the network. - /// It’s not recommended to disable this. - #[clap(long, alias = "no-nla")] - no_credssp: bool, - - /// The clipboard type - #[clap(long, value_enum, default_value_t = ClipboardType::Default)] - clipboard_type: ClipboardType, - - /// The bitmap codecs to use (remotefx:on, ...) - #[clap(long, num_args = 1.., value_delimiter = ',')] - codecs: Vec, - - /// Enable bulk compression support (default: true). - /// - /// When enabled, the client advertises support for bulk compression and the - /// server may send compressed PDUs. Use `--compression-enabled=false` to - /// disable. When not specified, the value from the `.rdp` file is used (if - /// present), otherwise compression is enabled by default. - #[clap(long, action = clap::ArgAction::Set)] - compression_enabled: Option, - - /// Bulk compression level to negotiate with the server. - /// - /// Valid values: - /// 0 — MPPC with 8 KB history (RDP 4.0) - /// 1 — MPPC with 64 KB history (RDP 5.0) - /// 2 — NCRUSH (RDP 6.0) - /// 3 — XCRUSH (RDP 6.1) - #[clap(long, value_parser = clap::value_parser!(u32).range(0..=3), default_value_t = 3)] - compression_level: u32, - - /// Prevents session locking by injecting fake mouse movement events when - /// the connection is idle (interval in minutes) - #[clap(long)] - prevent_session_lock: Option, - - /// Add DVC channel named pipe proxy - /// - /// The format is `=`, e.g., `ChannelName=PipeName` where `ChannelName` is the name of the channel, - /// and `PipeName` is the name of the named pipe to connect to (without OS-specific prefix). - /// `` will automatically be prefixed with `\\.\pipe\` on Windows. - #[clap(long)] - dvc_proxy: Vec, - /// Load a DVC client plugin DLL (Windows only). - /// - /// Path to a DVC plugin DLL that exports VirtualChannelGetInstance. - /// Example: C:\Windows\System32\webauthn.dll - #[cfg(windows)] - #[clap(long)] - dvc_plugin: Vec, - - /// Write the effective PropertySet (merged .rdp file and CLI overrides) to the given path and exit. - /// - /// The output is a standard `.rdp` file that can be used as a starting point for customisation - /// or passed back via `--rdp-file` on the next invocation. - #[clap(long)] - dump_rdp: Option, -} - -/// The result of phase 1 parsing: the merged PropertySet plus CLI-only settings. -/// -/// After obtaining a `PartialConfig`, callers may inspect or serialise [`PartialConfig::properties`] -/// (e.g., with the `--dump-rdp` flag) before committing to a full session. Call -/// [`PartialConfig::into_config`] to complete phase 2 (interactive prompts + strong typing). -#[derive(Debug)] -pub struct PartialConfig { - /// The merged PropertySet (`.rdp` file + CLI overrides). - pub properties: ironrdp_propertyset::PropertySet, - - // CLI-only settings that are not representable as `.rdp` file properties. - pub log_file: Option, - pub dump_rdp: Option, - pub rdcleanpath: Option, - pub keyboard_type: KeyboardType, - pub keyboard_subtype: u32, - pub keyboard_functional_keys_count: u32, - pub ime_file_name: String, - pub dig_product_id: String, - pub thin_client: bool, - pub small_cache: bool, - pub color_depth: Option, - pub no_server_pointer: bool, - pub capabilities: u32, - pub autologon: bool, - pub no_tls: bool, - pub clipboard_type: ClipboardType, - pub codecs: Vec, - pub compression_level: u32, - pub prevent_session_lock: Option, - pub dvc_pipe_proxies: Vec, - #[cfg(windows)] - pub dvc_plugins: Vec, -} - -impl PartialConfig { - pub fn parse_args() -> anyhow::Result { - Self::parse_from(std::env::args_os()) - } - - pub fn parse_from(args: I) -> anyhow::Result - where - I: IntoIterator, - T: Into + Clone, - { - let args = Args::parse_from(args); - - let mut properties = ironrdp_propertyset::PropertySet::new(); - - if let Some(rdp_file) = &args.rdp_file { - let input = - std::fs::read_to_string(rdp_file).with_context(|| format!("failed to read {}", rdp_file.display()))?; - - if let Err(errors) = ironrdp_rdpfile::load(&mut properties, &input) { - for error in &errors { - eprintln!("Warning: skipped entry in {}: {error}", rdp_file.display()); - } - } - } - - // CLI arguments take precedence: upsert them after the .rdp file is loaded. - apply_cli_args_to_properties(&mut properties, &args); - - let rdcleanpath = args - .rdcleanpath_url - .zip(args.rdcleanpath_token) - .map(|(url, auth_token)| RDCleanPathConfig { url, auth_token }); - - Ok(Self { - properties, - log_file: args.log_file, - dump_rdp: args.dump_rdp, - rdcleanpath, - keyboard_type: args.keyboard_type, - keyboard_subtype: args.keyboard_subtype, - keyboard_functional_keys_count: args.keyboard_functional_keys_count, - ime_file_name: args.ime_file_name, - dig_product_id: args.dig_product_id, - thin_client: args.thin_client, - small_cache: args.small_cache, - color_depth: args.color_depth, - no_server_pointer: args.no_server_pointer, - capabilities: args.capabilities, - autologon: args.autologon, - no_tls: args.no_tls, - clipboard_type: args.clipboard_type, - codecs: args.codecs, - compression_level: args.compression_level, - prevent_session_lock: args.prevent_session_lock, - dvc_pipe_proxies: args.dvc_proxy, - #[cfg(windows)] - dvc_plugins: args.dvc_plugin, - }) - } - - pub fn into_config(self) -> anyhow::Result { - use ironrdp_cfg::{AudioMode, PropertySetExt as _}; - - let properties = &self.properties; - - let has_gateway_host = properties.gateway_hostname().is_some(); - let use_gateway = properties - .gateway_usage_method() - .unwrap_or_else(|e| { - eprintln!("Warning: {e}, assuming no gateway"); - Some(ironrdp_cfg::GatewayUsageMethod::Direct) - }) - .map_or(has_gateway_host, ironrdp_cfg::GatewayUsageMethod::is_gateway_required); - - let mut gw: Option = - use_gateway - .then(|| properties.gateway_hostname()) - .flatten() - .map(|gw_addr| GwConnectTarget { - gw_endpoint: gw_addr.to_owned(), - gw_user: String::new(), - gw_pass: String::new(), - server: String::new(), // TODO: non-standard port? also dont use here? - }); - - if let Some(ref mut gw) = gw { - if let Ok(Some(gateway_credentials_source)) = properties.gateway_credentials_source() { - // All known credential sources fall through to username/password prompts. - // The value is available for future differentiation if needed. - let _ = gateway_credentials_source; - } - - gw.gw_user = if let Some(gw_user) = properties.gateway_username() { - gw_user.to_owned() - } else { - inquire::Text::new("Gateway username:") - .prompt() - .context("Username prompt")? - }; - - gw.gw_pass = if let Some(gw_pass) = properties.gateway_password() { - gw_pass.to_owned() - } else { - inquire::Password::new("Gateway password:") - .without_confirmation() - .prompt() - .context("Password prompt")? - }; - }; - - let target = match properties.full_address().context("invalid 'full address' property")? { - Some(addr) => Some(addr), - None => properties - .alternate_full_address() - .context("invalid 'alternate full address' property")?, - }; - - let destination = if let Some(target) = target { - const RDP_DEFAULT_PORT: u16 = 3389; - let port = match target.port { - Some(p) => p, - None => properties - .server_port() - .context("invalid 'server port' property")? - .unwrap_or(RDP_DEFAULT_PORT), - }; - let name = match target.host { - ironrdp_cfg::TargetHost::Ip(ip) => ip.to_string(), - ironrdp_cfg::TargetHost::Domain(host) => host, - }; - Destination { name, port } - } else { - inquire::Text::new("Server address:") - .prompt() - .context("Address prompt")? - .pipe(Destination::new)? - }; - - if let Some(ref mut gw) = gw { - gw.server = destination.name.clone(); // TODO - } - - let username = if let Some(username) = properties.username() { - username.to_owned() - } else { - inquire::Text::new("Username:").prompt().context("Username prompt")? - }; - - let password = if let Some(password) = properties.clear_text_password() { - password.to_owned() - } else { - inquire::Password::new("Password:") - .without_confirmation() - .prompt() - .context("Password prompt")? - }; - - let codecs: Vec<_> = self.codecs.iter().map(|s| s.as_str()).collect(); - let codecs = match client_codecs_capabilities(&codecs) { - Ok(codecs) => codecs, - Err(help) => { - print!("{help}"); - std::process::exit(0); - } - }; - let mut bitmap = connector::BitmapConfig { - color_depth: 32, - lossy_compression: true, - codecs, - }; - - if let Some(color_depth) = self.color_depth { - if color_depth != 16 && color_depth != 32 { - anyhow::bail!("Invalid color depth. Only 16 and 32 bit color depths are supported."); - } - bitmap.color_depth = color_depth; - }; - - // make a duration from cmdline argument (minutes) - let fake_events_interval = self - .prevent_session_lock - .map(|v| Duration::from_secs(u64::from(v) * 60)); - - let enable_credssp = properties.enable_credssp_support().unwrap_or(true); - - let redirect_clipboard = properties.redirect_clipboard().unwrap_or(true); - let clipboard_type = if self.clipboard_type == ClipboardType::Default { - if !redirect_clipboard { - ClipboardType::None - } else { - #[cfg(windows)] - { - ClipboardType::Windows - } - #[cfg(not(windows))] - { - ClipboardType::None - } - } - } else { - self.clipboard_type - }; - - let enable_audio_playback = match properties.audio_mode() { - Ok(None) | Ok(Some(AudioMode::RedirectToClient)) => true, - Ok(Some(AudioMode::PlayOnServer | AudioMode::Disabled)) => false, - Err(e) => { - eprintln!("Warning: {e}, defaulting to audio playback enabled"); - true - } - }; - - let compression_enabled = properties.compression().unwrap_or(true); - - let compression_type = if compression_enabled { - Some(compression_type_from_level(self.compression_level)?) - } else { - None - }; - - let desktop_width = properties - .desktop_width() - .unwrap_or_else(|_| { - eprintln!("Warning: ignored out-of-range 'desktopwidth' property"); - None - }) - .unwrap_or(DEFAULT_WIDTH); - let desktop_height = properties - .desktop_height() - .unwrap_or_else(|_| { - eprintln!("Warning: ignored out-of-range 'desktopheight' property"); - None - }) - .unwrap_or(DEFAULT_HEIGHT); - let desktop_scale_factor = properties - .desktop_scale_factor() - .unwrap_or_else(|_| { - eprintln!("Warning: ignored out-of-range 'desktopscalefactor' property"); - None - }) - .unwrap_or(0); - - let kdc_proxy_url = properties - .kdc_proxy_url() - .map(str::to_owned) - .or_else(|| properties.kdc_proxy_name().map(normalize_kdc_proxy_url_from_name)); - - let kerberos_config = kdc_proxy_url.and_then(|kdc_proxy_url| { - Url::parse(&kdc_proxy_url) - .ok() - .map(|url| connector::credssp::KerberosConfig { - kdc_proxy_url: Some(url), - // The hostname field is the client computer name used for Kerberos SPN negotiation. - hostname: whoami::hostname().unwrap_or_else(|_| "ironrdp".to_owned()), - }) - .or_else(|| { - eprintln!("Warning: ignored invalid KDC proxy URL in 'kdcproxyname'/'KDCProxyURL' property"); - None - }) - }); - - let connector = connector::Config { - credentials: Credentials::UsernamePassword { username, password }, - domain: properties.domain().map(str::to_owned), - enable_tls: !self.no_tls, - enable_credssp, - keyboard_type: KeyboardType::parse(self.keyboard_type), - keyboard_subtype: self.keyboard_subtype, - keyboard_layout: 0, // the server SHOULD use the default active input locale identifier - keyboard_functional_keys_count: self.keyboard_functional_keys_count, - ime_file_name: self.ime_file_name, - dig_product_id: self.dig_product_id, - desktop_size: connector::DesktopSize { - width: desktop_width, - height: desktop_height, - }, - desktop_scale_factor, - bitmap: Some(bitmap), - client_build: semver::Version::parse(env!("CARGO_PKG_VERSION")) - .map_or(0, |version| version.major * 100 + version.minor * 10 + version.patch) - .pipe(u32::try_from) - .context("cargo package version")?, - client_name: whoami::hostname().unwrap_or_else(|_| "ironrdp".to_owned()), - // NOTE: hardcode this value like in freerdp - // https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70 - client_dir: "C:\\Windows\\System32\\mstscax.dll".to_owned(), - platform: match whoami::platform() { - whoami::Platform::Windows => MajorPlatformType::WINDOWS, - whoami::Platform::Linux => MajorPlatformType::UNIX, - whoami::Platform::Mac => MajorPlatformType::MACINTOSH, - whoami::Platform::Ios => MajorPlatformType::IOS, - whoami::Platform::Android => MajorPlatformType::ANDROID, - _ => MajorPlatformType::UNSPECIFIED, - }, - hardware_id: None, - license_cache: None, - enable_server_pointer: !self.no_server_pointer, - autologon: self.autologon, - enable_audio_playback, - request_data: None, - pointer_software_rendering: false, - multitransport_flags: None, - compression_type, - performance_flags: PerformanceFlags::default(), - timezone_info: TimezoneInfo::default(), - alternate_shell: properties.alternate_shell().unwrap_or_default().to_owned(), - work_dir: properties.shell_working_directory().unwrap_or_default().to_owned(), - }; - - Ok(Config { - log_file: self.log_file, - gw, - kerberos_config, - destination, - connector, - clipboard_type, - rdcleanpath: self.rdcleanpath, - fake_events_interval, - dvc_pipe_proxies: self.dvc_pipe_proxies, - #[cfg(windows)] - dvc_plugins: self.dvc_plugins, - }) - } -} - -impl Config { - pub fn parse_args() -> anyhow::Result { - Self::parse_from(std::env::args_os()) - } - - pub fn parse_from(args: I) -> anyhow::Result - where - I: IntoIterator, - T: Into + Clone, - { - PartialConfig::parse_from(args)?.into_config() - } -} - -fn normalize_kdc_proxy_url_from_name(name: &str) -> String { - if name.starts_with("http://") || name.starts_with("https://") { - name.to_owned() - } else { - format!("https://{name}/KdcProxy") - } -} diff --git a/crates/ironrdp-client/src/lib.rs b/crates/ironrdp-client/src/lib.rs index de4b5276d..753d2937a 100644 --- a/crates/ironrdp-client/src/lib.rs +++ b/crates/ironrdp-client/src/lib.rs @@ -1,7 +1,5 @@ #![cfg_attr(doc, doc = include_str!("../README.md"))] #![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] -#![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary - // No need to be as strict as in production libraries #![allow(clippy::arithmetic_side_effects)] #![allow(clippy::cast_lossless)] @@ -9,8 +7,6 @@ #![allow(clippy::cast_possible_wrap)] #![allow(clippy::cast_sign_loss)] -pub mod app; -pub mod clipboard; pub mod config; pub mod rdp; diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index 9900f0331..073ddfe50 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -30,7 +30,6 @@ use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::TcpStream; use tokio::sync::mpsc; use tracing::{debug, error, info, trace, warn}; -use winit::event_loop::EventLoopProxy; use crate::config::{Config, RDCleanPathConfig}; @@ -107,7 +106,7 @@ pub type WriteDvcMessageFn = Box PduResult<()> + Send pub struct RdpClient { pub config: Config, - pub event_loop_proxy: EventLoopProxy, + pub output_event_sender: mpsc::Sender, pub input_event_receiver: mpsc::UnboundedReceiver, pub cliprdr_factory: Option>, pub dvc_pipe_proxy_factory: DvcPipeProxyFactory, @@ -127,7 +126,10 @@ impl RdpClient { { Ok(result) => result, Err(e) => { - let _ = self.event_loop_proxy.send_event(RdpOutputEvent::ConnectionFailure(e)); + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(e)) + .await; break; } } @@ -141,7 +143,10 @@ impl RdpClient { { Ok(result) => result, Err(e) => { - let _ = self.event_loop_proxy.send_event(RdpOutputEvent::ConnectionFailure(e)); + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(e)) + .await; break; } } @@ -150,7 +155,7 @@ impl RdpClient { match active_session( framed, connection_result, - &self.event_loop_proxy, + &self.output_event_sender, &mut self.input_event_receiver, ) .await @@ -160,11 +165,14 @@ impl RdpClient { self.config.connector.desktop_size.height = height; } Ok(RdpControlFlow::TerminatedGracefully(reason)) => { - let _ = self.event_loop_proxy.send_event(RdpOutputEvent::Terminated(Ok(reason))); + let _ = self + .output_event_sender + .send(RdpOutputEvent::Terminated(Ok(reason))) + .await; break; } Err(e) => { - let _ = self.event_loop_proxy.send_event(RdpOutputEvent::Terminated(Err(e))); + let _ = self.output_event_sender.send(RdpOutputEvent::Terminated(Err(e))).await; break; } } @@ -572,7 +580,7 @@ where async fn active_session( framed: UpgradedFramed, connection_result: ConnectionResult, - event_loop_proxy: &EventLoopProxy, + output_event_sender: &mpsc::Sender, input_event_receiver: &mut mpsc::UnboundedReceiver, ) -> SessionResult { let (mut reader, mut writer) = split_tokio_framed(framed); @@ -714,35 +722,40 @@ async fn active_session( }) .collect(); - event_loop_proxy - .send_event(RdpOutputEvent::Image { + output_event_sender + .send(RdpOutputEvent::Image { buffer, width: NonZeroU16::new(image.width()) .ok_or_else(|| session::general_err!("width is zero"))?, height: NonZeroU16::new(image.height()) .ok_or_else(|| session::general_err!("height is zero"))?, }) - .map_err(|e| session::custom_err!("event_loop_proxy", e))?; + .await + .map_err(|e| session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerDefault => { - event_loop_proxy - .send_event(RdpOutputEvent::PointerDefault) - .map_err(|e| session::custom_err!("event_loop_proxy", e))?; + output_event_sender + .send(RdpOutputEvent::PointerDefault) + .await + .map_err(|e| session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerHidden => { - event_loop_proxy - .send_event(RdpOutputEvent::PointerHidden) - .map_err(|e| session::custom_err!("event_loop_proxy", e))?; + output_event_sender + .send(RdpOutputEvent::PointerHidden) + .await + .map_err(|e| session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerPosition { x, y } => { - event_loop_proxy - .send_event(RdpOutputEvent::PointerPosition { x, y }) - .map_err(|e| session::custom_err!("event_loop_proxy", e))?; + output_event_sender + .send(RdpOutputEvent::PointerPosition { x, y }) + .await + .map_err(|e| session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerBitmap(pointer) => { - event_loop_proxy - .send_event(RdpOutputEvent::PointerBitmap(pointer)) - .map_err(|e| session::custom_err!("event_loop_proxy", e))?; + output_event_sender + .send(RdpOutputEvent::PointerBitmap(pointer)) + .await + .map_err(|e| session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::DeactivateAll(mut connection_activation) => { // Execute the Deactivation-Reactivation Sequence: diff --git a/crates/ironrdp-testsuite-extra/Cargo.toml b/crates/ironrdp-testsuite-extra/Cargo.toml index 6d3575701..070d6f53a 100644 --- a/crates/ironrdp-testsuite-extra/Cargo.toml +++ b/crates/ironrdp-testsuite-extra/Cargo.toml @@ -27,6 +27,7 @@ async-trait = "0.1" ironrdp = { path = "../ironrdp", features = ["server", "pdu", "connector", "session", "dvc", "echo"] } ironrdp-async.path = "../ironrdp-async" ironrdp-client.path = "../ironrdp-client" +ironrdp-viewer.path = "../ironrdp-viewer" ironrdp-tokio.path = "../ironrdp-tokio" ironrdp-tls = { path = "../ironrdp-tls", features = ["rustls"] } semver = "1.0" diff --git a/crates/ironrdp-testsuite-extra/tests/config_rdp.rs b/crates/ironrdp-testsuite-extra/tests/config_rdp.rs index 2cb2e120e..e60d12ade 100644 --- a/crates/ironrdp-testsuite-extra/tests/config_rdp.rs +++ b/crates/ironrdp-testsuite-extra/tests/config_rdp.rs @@ -1,7 +1,8 @@ use std::fs; use std::path::PathBuf; -use ironrdp_client::config::{ClipboardType, Config}; +use ironrdp_client::config::ClipboardType; +use ironrdp_viewer::config::parse_config_from; use uuid::Uuid; struct TempRdpFile { @@ -26,7 +27,7 @@ impl Drop for TempRdpFile { } } -fn parse_config_from_rdp(content: &str, extra_args: &[&str]) -> Config { +fn parse_config_from_rdp(content: &str, extra_args: &[&str]) -> ironrdp_client::config::Config { let rdp_file = TempRdpFile::new(content); let mut args = vec![ @@ -37,7 +38,7 @@ fn parse_config_from_rdp(content: &str, extra_args: &[&str]) -> Config { args.extend(extra_args.iter().map(|arg| (*arg).to_owned())); - Config::parse_from(args).expect("failed to parse client config") + parse_config_from(args).expect("failed to parse client config") } #[test] @@ -102,7 +103,7 @@ fn redirectclipboard_zero_disables_clipboard_for_default_mode() { &[], ); - assert!(matches!(config.clipboard_type, ClipboardType::None)); + assert!(matches!(config.clipboard_type, ClipboardType::Disable)); } #[test] diff --git a/crates/ironrdp-viewer/Cargo.toml b/crates/ironrdp-viewer/Cargo.toml new file mode 100644 index 000000000..f98870c1b --- /dev/null +++ b/crates/ironrdp-viewer/Cargo.toml @@ -0,0 +1,68 @@ +[package] +name = "ironrdp-viewer" +version = "0.1.0" +readme = "README.md" +description = "Portable RDP viewer (GUI binary) without GPU acceleration" +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true +default-run = "ironrdp-viewer" + +# Not publishing for now. +publish = false + +[lib] +doctest = false +test = false + +[[bin]] +name = "ironrdp-viewer" +test = false + +[features] +default = ["rustls"] +rustls = ["ironrdp-client/rustls"] +native-tls = ["ironrdp-client/native-tls"] +qoi = ["ironrdp-client/qoi"] +qoiz = ["ironrdp-client/qoiz"] + +[dependencies] +ironrdp = { path = "../ironrdp", version = "0.14", features = ["input", "pdu"] } +ironrdp-client = { path = "../ironrdp-client", version = "0.1", default-features = false } +ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.5" } +ironrdp-cfg = { path = "../ironrdp-cfg" } +ironrdp-mstsgu = { path = "../ironrdp-mstsgu" } +ironrdp-propertyset = { path = "../ironrdp-propertyset" } +ironrdp-rdpfile = { path = "../ironrdp-rdpfile" } + +# Windowing and rendering +winit = { version = "0.30", features = ["rwh_06"] } +softbuffer = "0.4" + +# CLI +clap = { version = "4.6", features = ["derive", "cargo"] } +inquire = "0.9" +proc-exit = "2" + +# Logging +tracing = { version = "0.1", features = ["log"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Async, futures +tokio = { version = "1", features = ["full"] } + +# Utils +whoami = "2.1" +anyhow = "1" +smallvec = "1.15" +tap = "1" +semver = "1" +raw-window-handle = "0.6" +url = "2" + +[lints] +workspace = true diff --git a/crates/ironrdp-viewer/README.md b/crates/ironrdp-viewer/README.md new file mode 100644 index 000000000..18710c2fb --- /dev/null +++ b/crates/ironrdp-viewer/README.md @@ -0,0 +1,84 @@ +# IronRDP Viewer + +Portable RDP client without GPU acceleration. + +This is a a full-fledged RDP client based on IronRDP crates suite, and implemented using +non-blocking, asynchronous I/O. Portability is achieved by using softbuffer for rendering +and winit for windowing. + +## Sample usage + +```shell +ironrdp-viewer --username --password +``` + +## `.rdp` file support + +You can load a `.rdp` file with `--rdp-file `. + +Currently supported properties: + +- `full address:s:` +- `alternate full address:s:` +- `server port:i:` +- `username:s:` +- `ClearTextPassword:s:` +- `domain:s:` +- `enablecredsspsupport:i:<0|1>` +- `gatewayhostname:s:` +- `gatewayusagemethod:i:` +- `gatewaycredentialssource:i:` +- `gatewayusername:s:` +- `GatewayPassword:s:` +- `kdcproxyurl:s:` (also `KDCProxyURL:s:`) +- `kdcproxyname:s:` +- `alternate shell:s:` +- `shell working directory:s:` +- `redirectclipboard:i:<0|1>` +- `audiomode:i:<0|1|2>` +- `desktopwidth:i:` +- `desktopheight:i:` +- `desktopscalefactor:i:` +- `compression:i:<0|1>` + +Property precedence is: + +1. CLI options +2. `.rdp` file values +3. Defaults and interactive prompts + +Unknown or unsupported `.rdp` properties are ignored and do not cause parsing failures. Parse +issues are reported to stderr. + + +The `IRONRDP_LOG` environment variable is used to set the log filter directives. + +```shell +IRONRDP_LOG="info,ironrdp_connector=trace" ironrdp-viewer --username --password +``` + +See [`tracing-subscriber`'s documentation][tracing-doc] for more details. + +[tracing-doc]: https://docs.rs/tracing-subscriber/0.3.17/tracing_subscriber/filter/struct.EnvFilter.html#directives + +## Support for `SSLKEYLOGFILE` + +This client supports reading the `SSLKEYLOGFILE` environment variable. +When set, the TLS encryption secrets for the session will be dumped to the file specified +by the environment variable. +This file can be read by Wireshark so that in can decrypt the packets. + +### Example + +```shell +SSLKEYLOGFILE=/tmp/tls-secrets ironrdp-viewer --username --password +``` + +### Usage in Wireshark + +See this [awakecoding's repository][awakecoding-repository] explaining how to use the file in wireshark. + +This crate is part of the [IronRDP] project. + +[IronRDP]: https://github.com/Devolutions/IronRDP +[awakecoding-repository]: https://github.com/awakecoding/wireshark-rdp#sslkeylogfile diff --git a/crates/ironrdp-client/src/app.rs b/crates/ironrdp-viewer/src/app.rs similarity index 99% rename from crates/ironrdp-client/src/app.rs rename to crates/ironrdp-viewer/src/app.rs index 9c6037a9c..952e79d09 100644 --- a/crates/ironrdp-client/src/app.rs +++ b/crates/ironrdp-viewer/src/app.rs @@ -6,8 +6,10 @@ use std::sync::Arc; use std::time::Instant; use anyhow::Context as _; +use ironrdp::pdu::input::MousePdu; use ironrdp::pdu::input::fast_path::FastPathInputEvent; -use ironrdp::pdu::input::{MousePdu, mouse::PointerFlags}; +use ironrdp::pdu::input::mouse::PointerFlags; +use ironrdp_client::rdp::{RdpInputEvent, RdpOutputEvent}; use raw_window_handle::{DisplayHandle, HasDisplayHandle as _}; use smallvec::SmallVec; use tokio::sync::mpsc; @@ -19,8 +21,6 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; use winit::platform::scancode::PhysicalKeyExtScancode as _; use winit::window::{CursorIcon, CustomCursor, Window, WindowAttributes}; -use crate::rdp::{RdpInputEvent, RdpOutputEvent}; - type WindowSurface = (Arc, softbuffer::Surface, Arc>); pub struct App { diff --git a/crates/ironrdp-client/src/clipboard.rs b/crates/ironrdp-viewer/src/clipboard.rs similarity index 94% rename from crates/ironrdp-client/src/clipboard.rs rename to crates/ironrdp-viewer/src/clipboard.rs index a58716a94..9b2855844 100644 --- a/crates/ironrdp-client/src/clipboard.rs +++ b/crates/ironrdp-viewer/src/clipboard.rs @@ -1,9 +1,8 @@ use ironrdp::cliprdr::backend::{ClipboardMessage, ClipboardMessageProxy}; +use ironrdp_client::rdp::RdpInputEvent; use tokio::sync::mpsc; use tracing::error; -use crate::rdp::RdpInputEvent; - /// Shim for sending and receiving CLIPRDR events as `RdpInputEvent` #[derive(Clone, Debug)] pub struct ClientClipboardMessageProxy { diff --git a/crates/ironrdp-viewer/src/config.rs b/crates/ironrdp-viewer/src/config.rs new file mode 100644 index 000000000..009f3b35e --- /dev/null +++ b/crates/ironrdp-viewer/src/config.rs @@ -0,0 +1,689 @@ +#![allow(clippy::print_stdout, clippy::print_stderr)] + +use core::num::ParseIntError; +use core::time::Duration; +use std::path::PathBuf; + +use anyhow::Context as _; +use clap::Parser; +use clap::clap_derive::ValueEnum; +use ironrdp::connector::{self, Credentials}; +use ironrdp::pdu::rdp::capability_sets::{MajorPlatformType, client_codecs_capabilities}; +use ironrdp::pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; +use ironrdp_client::config::{ + ClipboardType as ResolvedClipboardType, Config, Destination, DvcProxyInfo, RDCleanPathConfig, +}; +use ironrdp_mstsgu::GwConnectTarget; +use tap::prelude::*; +use url::Url; + +const DEFAULT_WIDTH: u16 = 1920; +const DEFAULT_HEIGHT: u16 = 1080; + +/// CLI selection for the clipboard backend. +/// +/// Maps directly into the library's [`ResolvedClipboardType`] when the typed [`Config`] is built. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +pub enum ClipboardType { + /// Enable clipboard redirection (use the best available backend). + Enable, + /// Disable clipboard redirection entirely. + Disable, + /// Use a stub clipboard backend (for testing or headless usage). + Stub, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +pub enum KeyboardType { + IbmPcXt, + OlivettiIco, + IbmPcAt, + IbmEnhanced, + Nokia1050, + Nokia9140, + Japanese, +} + +impl KeyboardType { + fn into_pdu(self) -> ironrdp::pdu::gcc::KeyboardType { + match self { + KeyboardType::IbmEnhanced => ironrdp::pdu::gcc::KeyboardType::IbmEnhanced, + KeyboardType::IbmPcAt => ironrdp::pdu::gcc::KeyboardType::IbmPcAt, + KeyboardType::IbmPcXt => ironrdp::pdu::gcc::KeyboardType::IbmPcXt, + KeyboardType::OlivettiIco => ironrdp::pdu::gcc::KeyboardType::OlivettiIco, + KeyboardType::Nokia1050 => ironrdp::pdu::gcc::KeyboardType::Nokia1050, + KeyboardType::Nokia9140 => ironrdp::pdu::gcc::KeyboardType::Nokia9140, + KeyboardType::Japanese => ironrdp::pdu::gcc::KeyboardType::Japanese, + } + } +} + +fn apply_cli_args_to_properties(properties: &mut ironrdp_propertyset::PropertySet, args: &Args) { + if let Some(dest) = &args.destination { + // Format the host in .rdp canonical form: IPv6 gets bracketed ("[::1]"), others are plain. + let host = dest + .name() + .parse::() + .map(ironrdp_cfg::TargetHost::Ip) + .unwrap_or_else(|_| ironrdp_cfg::TargetHost::Domain(dest.name().to_owned())); + properties.insert("full address", format!("{host}:{}", dest.port())); + } + + if let Some(username) = &args.username { + properties.insert("username", username.as_str()); + } + + if let Some(password) = &args.password { + properties.insert("ClearTextPassword", password.as_str()); + } + + if let Some(domain) = &args.domain { + properties.insert("domain", domain.as_str()); + } + + if let Some(scale) = args.scale_desktop { + properties.insert("desktopscalefactor", i64::from(scale)); + } + + if let Some(width) = args.desktop_width { + properties.insert("desktopwidth", i64::from(width)); + } + + if let Some(height) = args.desktop_height { + properties.insert("desktopheight", i64::from(height)); + } + + if let Some(gw_host) = &args.gw_endpoint { + properties.insert("gatewayhostname", gw_host.as_str()); + // Ensure the gateway is treated as enabled when a host is provided explicitly. + properties.insert( + "gatewayusagemethod", + ironrdp_cfg::GatewayUsageMethod::UseAlways.as_i64(), + ); + } + + if let Some(gw_user) = &args.gw_user { + properties.insert("gatewayusername", gw_user.as_str()); + } + + if let Some(gw_pass) = &args.gw_pass { + properties.insert("GatewayPassword", gw_pass.as_str()); + } + + if args.no_credssp { + properties.insert("enablecredsspsupport", 0i64); + } + + if let Some(enabled) = args.compression_enabled { + properties.insert("compression", enabled); + } +} + +fn compression_type_from_level(level: u32) -> anyhow::Result { + use ironrdp::pdu::rdp::client_info::CompressionType; + + match level { + 0 => Ok(CompressionType::K8), + 1 => Ok(CompressionType::K64), + 2 => Ok(CompressionType::Rdp6), + 3 => Ok(CompressionType::Rdp61), + _ => anyhow::bail!("Invalid compression level. Valid values are 0, 1, 2, 3."), + } +} + +fn parse_hex(input: &str) -> Result { + if input.starts_with("0x") { + u32::from_str_radix(input.get(2..).unwrap_or(""), 16) + } else { + input.parse::() + } +} + +/// Devolutions IronRDP viewer +#[derive(Parser, Debug)] +#[clap(author = "Devolutions", about = "Devolutions-IronRDP viewer")] +#[clap(version, long_about = None)] +struct Args { + /// A file with IronRDP viewer logs + #[clap(short, long, value_parser)] + log_file: Option, + + #[clap(long, value_parser)] + gw_endpoint: Option, + #[clap(long, value_parser)] + gw_user: Option, + #[clap(long, value_parser)] + gw_pass: Option, + + /// An address on which the client will connect. + destination: Option, + + /// Path to a .rdp file to read the configuration from. + #[clap(long)] + rdp_file: Option, + + /// A target RDP server user name + #[clap(short, long)] + username: Option, + + /// An optional target RDP server domain name + #[clap(short, long)] + domain: Option, + + /// A target RDP server user password + #[clap(short, long)] + password: Option, + + /// Proxy URL to connect to for the RDCleanPath + #[clap(long, requires("rdcleanpath_token"))] + rdcleanpath_url: Option, + + /// Authentication token to insert in the RDCleanPath packet + #[clap(long, requires("rdcleanpath_url"))] + rdcleanpath_token: Option, + + /// The keyboard type + #[clap(long, value_enum, default_value_t = KeyboardType::IbmEnhanced)] + keyboard_type: KeyboardType, + + /// The keyboard subtype (an original equipment manufacturer-dependent value) + #[clap(long, default_value_t = 0)] + keyboard_subtype: u32, + + /// The number of function keys on the keyboard + #[clap(long, default_value_t = 12)] + keyboard_functional_keys_count: u32, + + /// The input method editor (IME) file name associated with the active input locale + #[clap(long, default_value_t = String::from(""))] + ime_file_name: String, + + /// Contains a value that uniquely identifies the client + #[clap(long, default_value_t = String::from(""))] + dig_product_id: String, + + /// Enable thin client + #[clap(long)] + thin_client: bool, + + /// Enable small cache + #[clap(long)] + small_cache: bool, + + /// Scaling factor for desktop applications, percentage (value between 100 and 500) + #[clap(long, value_parser = clap::value_parser!(u32).range(100..=500))] + scale_desktop: Option, + + /// Desired desktop width for the RDP session + #[clap(long, value_parser = clap::value_parser!(u16).range(1..=8192))] + desktop_width: Option, + + /// Desired desktop height for the RDP session + #[clap(long, value_parser = clap::value_parser!(u16).range(1..=8192))] + desktop_height: Option, + + /// Set required color depth. Currently only 32 and 16 bit color depths are supported + #[clap(long)] + color_depth: Option, + + /// Ignore mouse pointer messages sent by the server. Increases performance when enabled, as the + /// client could skip costly software rendering of the pointer with alpha blending + #[clap(long)] + no_server_pointer: bool, + + /// Enabled capability versions. Each bit represents enabling a capability version + /// starting from V8 to V10_7 + #[clap(long, value_parser = parse_hex, default_value_t = 0)] + capabilities: u32, + + /// Automatically logon to the server by passing the INFO_AUTOLOGON flag + /// + /// This flag is ignored if CredSSP authentication is used. + /// You can use `--no-credssp` to ensure it’s not. + #[clap(long)] + autologon: bool, + + /// Disable TLS + Graphical login (legacy authentication method) + /// + /// Disabling this in order to enforce usage of CredSSP (NLA) is recommended. + #[clap(long)] + no_tls: bool, + + /// Disable TLS + Network Level Authentication (NLA) using CredSSP + /// + /// NLA is used to authenticates RDP clients and servers before sending credentials over the network. + /// It’s not recommended to disable this. + #[clap(long, alias = "no-nla")] + no_credssp: bool, + + /// The clipboard type + #[clap(long, value_enum, default_value_t = ClipboardType::Enable)] + clipboard_type: ClipboardType, + + /// The bitmap codecs to use (remotefx:on, ...) + #[clap(long, num_args = 1.., value_delimiter = ',')] + codecs: Vec, + + /// Enable bulk compression support (default: true). + /// + /// When enabled, the client advertises support for bulk compression and the + /// server may send compressed PDUs. Use `--compression-enabled=false` to + /// disable. When not specified, the value from the `.rdp` file is used (if + /// present), otherwise compression is enabled by default. + #[clap(long, action = clap::ArgAction::Set)] + compression_enabled: Option, + + /// Bulk compression level to negotiate with the server. + /// + /// Valid values: + /// 0 — MPPC with 8 KB history (RDP 4.0) + /// 1 — MPPC with 64 KB history (RDP 5.0) + /// 2 — NCRUSH (RDP 6.0) + /// 3 — XCRUSH (RDP 6.1) + #[clap(long, value_parser = clap::value_parser!(u32).range(0..=3), default_value_t = 3)] + compression_level: u32, + + /// Prevents session locking by injecting fake mouse movement events when + /// the connection is idle (interval in minutes) + #[clap(long)] + prevent_session_lock: Option, + + /// Add DVC channel named pipe proxy + /// + /// The format is `=`, e.g., `ChannelName=PipeName` where `ChannelName` is the name of the channel, + /// and `PipeName` is the name of the named pipe to connect to (without OS-specific prefix). + /// `` will automatically be prefixed with `\\.\pipe\` on Windows. + #[clap(long)] + dvc_proxy: Vec, + /// Load a DVC client plugin DLL (Windows only). + /// + /// Path to a DVC plugin DLL that exports VirtualChannelGetInstance. + /// Example: C:\Windows\System32\webauthn.dll + #[cfg(windows)] + #[clap(long)] + dvc_plugin: Vec, + + /// Write the effective PropertySet (merged .rdp file and CLI overrides) to the given path and exit. + /// + /// The output is a standard `.rdp` file that can be used as a starting point for customisation + /// or passed back via `--rdp-file` on the next invocation. + #[clap(long)] + dump_rdp: Option, +} + +/// The result of phase 1 parsing: the merged PropertySet plus CLI-only settings. +/// +/// After obtaining a `PartialConfig`, callers may inspect or serialise [`PartialConfig::properties`] +/// (e.g., with the `--dump-rdp` flag) before committing to a full session. Call +/// [`PartialConfig::into_config`] to complete phase 2 (interactive prompts + strong typing). +#[derive(Debug)] +pub struct PartialConfig { + /// The merged PropertySet (`.rdp` file + CLI overrides). + pub properties: ironrdp_propertyset::PropertySet, + + // CLI-only settings that are not representable as `.rdp` file properties. + pub log_file: Option, + pub dump_rdp: Option, + pub rdcleanpath: Option, + pub keyboard_type: KeyboardType, + pub keyboard_subtype: u32, + pub keyboard_functional_keys_count: u32, + pub ime_file_name: String, + pub dig_product_id: String, + pub thin_client: bool, + pub small_cache: bool, + pub color_depth: Option, + pub no_server_pointer: bool, + pub capabilities: u32, + pub autologon: bool, + pub no_tls: bool, + pub clipboard_type: ClipboardType, + pub codecs: Vec, + pub compression_level: u32, + pub prevent_session_lock: Option, + pub dvc_pipe_proxies: Vec, + #[cfg(windows)] + pub dvc_plugins: Vec, +} + +impl PartialConfig { + pub fn parse_args() -> anyhow::Result { + Self::parse_from(std::env::args_os()) + } + + pub fn parse_from(args: I) -> anyhow::Result + where + I: IntoIterator, + T: Into + Clone, + { + let args = Args::parse_from(args); + + let mut properties = ironrdp_propertyset::PropertySet::new(); + + if let Some(rdp_file) = &args.rdp_file { + let input = + std::fs::read_to_string(rdp_file).with_context(|| format!("failed to read {}", rdp_file.display()))?; + + if let Err(errors) = ironrdp_rdpfile::load(&mut properties, &input) { + for error in &errors { + eprintln!("Warning: skipped entry in {}: {error}", rdp_file.display()); + } + } + } + + // CLI arguments take precedence: upsert them after the .rdp file is loaded. + apply_cli_args_to_properties(&mut properties, &args); + + let rdcleanpath = args + .rdcleanpath_url + .zip(args.rdcleanpath_token) + .map(|(url, auth_token)| RDCleanPathConfig { url, auth_token }); + + Ok(Self { + properties, + log_file: args.log_file, + dump_rdp: args.dump_rdp, + rdcleanpath, + keyboard_type: args.keyboard_type, + keyboard_subtype: args.keyboard_subtype, + keyboard_functional_keys_count: args.keyboard_functional_keys_count, + ime_file_name: args.ime_file_name, + dig_product_id: args.dig_product_id, + thin_client: args.thin_client, + small_cache: args.small_cache, + color_depth: args.color_depth, + no_server_pointer: args.no_server_pointer, + capabilities: args.capabilities, + autologon: args.autologon, + no_tls: args.no_tls, + clipboard_type: args.clipboard_type, + codecs: args.codecs, + compression_level: args.compression_level, + prevent_session_lock: args.prevent_session_lock, + dvc_pipe_proxies: args.dvc_proxy, + #[cfg(windows)] + dvc_plugins: args.dvc_plugin, + }) + } + + pub fn into_config(self) -> anyhow::Result { + use ironrdp_cfg::{AudioMode, PropertySetExt as _}; + + let properties = &self.properties; + + let has_gateway_host = properties.gateway_hostname().is_some(); + let use_gateway = properties + .gateway_usage_method() + .unwrap_or_else(|e| { + eprintln!("Warning: {e}, assuming no gateway"); + Some(ironrdp_cfg::GatewayUsageMethod::Direct) + }) + .map_or(has_gateway_host, ironrdp_cfg::GatewayUsageMethod::is_gateway_required); + + let mut gw: Option = + use_gateway + .then(|| properties.gateway_hostname()) + .flatten() + .map(|gw_addr| GwConnectTarget { + gw_endpoint: gw_addr.to_owned(), + gw_user: String::new(), + gw_pass: String::new(), + server: String::new(), // TODO: non-standard port? also dont use here? + }); + + if let Some(ref mut gw) = gw { + if let Ok(Some(gateway_credentials_source)) = properties.gateway_credentials_source() { + // All known credential sources fall through to username/password prompts. + // The value is available for future differentiation if needed. + let _ = gateway_credentials_source; + } + + gw.gw_user = if let Some(gw_user) = properties.gateway_username() { + gw_user.to_owned() + } else { + inquire::Text::new("Gateway username:") + .prompt() + .context("Username prompt")? + }; + + gw.gw_pass = if let Some(gw_pass) = properties.gateway_password() { + gw_pass.to_owned() + } else { + inquire::Password::new("Gateway password:") + .without_confirmation() + .prompt() + .context("Password prompt")? + }; + }; + + let target = match properties.full_address().context("invalid 'full address' property")? { + Some(addr) => Some(addr), + None => properties + .alternate_full_address() + .context("invalid 'alternate full address' property")?, + }; + + let destination = if let Some(target) = target { + const RDP_DEFAULT_PORT: u16 = 3389; + let port = match target.port { + Some(p) => p, + None => properties + .server_port() + .context("invalid 'server port' property")? + .unwrap_or(RDP_DEFAULT_PORT), + }; + let name = match target.host { + ironrdp_cfg::TargetHost::Ip(ip) => ip.to_string(), + ironrdp_cfg::TargetHost::Domain(host) => host, + }; + Destination::from_parts(name, port) + } else { + inquire::Text::new("Server address:") + .prompt() + .context("Address prompt")? + .pipe(Destination::new)? + }; + + if let Some(ref mut gw) = gw { + gw.server = destination.name().to_owned(); // TODO + } + + let username = if let Some(username) = properties.username() { + username.to_owned() + } else { + inquire::Text::new("Username:").prompt().context("Username prompt")? + }; + + let password = if let Some(password) = properties.clear_text_password() { + password.to_owned() + } else { + inquire::Password::new("Password:") + .without_confirmation() + .prompt() + .context("Password prompt")? + }; + + let codecs: Vec<_> = self.codecs.iter().map(|s| s.as_str()).collect(); + let codecs = match client_codecs_capabilities(&codecs) { + Ok(codecs) => codecs, + Err(help) => { + print!("{help}"); + std::process::exit(0); + } + }; + let mut bitmap = connector::BitmapConfig { + color_depth: 32, + lossy_compression: true, + codecs, + }; + + if let Some(color_depth) = self.color_depth { + if color_depth != 16 && color_depth != 32 { + anyhow::bail!("Invalid color depth. Only 16 and 32 bit color depths are supported."); + } + bitmap.color_depth = color_depth; + }; + + // make a duration from cmdline argument (minutes) + let fake_events_interval = self + .prevent_session_lock + .map(|v| Duration::from_secs(u64::from(v) * 60)); + + let enable_credssp = properties.enable_credssp_support().unwrap_or(true); + + let redirect_clipboard = properties.redirect_clipboard().unwrap_or(true); + let clipboard_type = resolve_clipboard_type(self.clipboard_type, redirect_clipboard); + + let enable_audio_playback = match properties.audio_mode() { + Ok(None) | Ok(Some(AudioMode::RedirectToClient)) => true, + Ok(Some(AudioMode::PlayOnServer | AudioMode::Disabled)) => false, + Err(e) => { + eprintln!("Warning: {e}, defaulting to audio playback enabled"); + true + } + }; + + let compression_enabled = properties.compression().unwrap_or(true); + + let compression_type = if compression_enabled { + Some(compression_type_from_level(self.compression_level)?) + } else { + None + }; + + let desktop_width = properties + .desktop_width() + .unwrap_or_else(|_| { + eprintln!("Warning: ignored out-of-range 'desktopwidth' property"); + None + }) + .unwrap_or(DEFAULT_WIDTH); + let desktop_height = properties + .desktop_height() + .unwrap_or_else(|_| { + eprintln!("Warning: ignored out-of-range 'desktopheight' property"); + None + }) + .unwrap_or(DEFAULT_HEIGHT); + let desktop_scale_factor = properties + .desktop_scale_factor() + .unwrap_or_else(|_| { + eprintln!("Warning: ignored out-of-range 'desktopscalefactor' property"); + None + }) + .unwrap_or(0); + + let kdc_proxy_url = properties + .kdc_proxy_url() + .map(str::to_owned) + .or_else(|| properties.kdc_proxy_name().map(normalize_kdc_proxy_url_from_name)); + + let kerberos_config = kdc_proxy_url.and_then(|kdc_proxy_url| { + Url::parse(&kdc_proxy_url) + .ok() + .map(|url| connector::credssp::KerberosConfig { + kdc_proxy_url: Some(url), + // The hostname field is the client computer name used for Kerberos SPN negotiation. + hostname: whoami::hostname().unwrap_or_else(|_| "ironrdp".to_owned()), + }) + .or_else(|| { + eprintln!("Warning: ignored invalid KDC proxy URL in 'kdcproxyname'/'KDCProxyURL' property"); + None + }) + }); + + let connector = connector::Config { + credentials: Credentials::UsernamePassword { username, password }, + domain: properties.domain().map(str::to_owned), + enable_tls: !self.no_tls, + enable_credssp, + keyboard_type: self.keyboard_type.into_pdu(), + keyboard_subtype: self.keyboard_subtype, + keyboard_layout: 0, // the server SHOULD use the default active input locale identifier + keyboard_functional_keys_count: self.keyboard_functional_keys_count, + ime_file_name: self.ime_file_name, + dig_product_id: self.dig_product_id, + desktop_size: connector::DesktopSize { + width: desktop_width, + height: desktop_height, + }, + desktop_scale_factor, + bitmap: Some(bitmap), + client_build: semver::Version::parse(env!("CARGO_PKG_VERSION")) + .map_or(0, |version| version.major * 100 + version.minor * 10 + version.patch) + .pipe(u32::try_from) + .context("cargo package version")?, + client_name: whoami::hostname().unwrap_or_else(|_| "ironrdp".to_owned()), + // NOTE: hardcode this value like in freerdp + // https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70 + client_dir: "C:\\Windows\\System32\\mstscax.dll".to_owned(), + platform: match whoami::platform() { + whoami::Platform::Windows => MajorPlatformType::WINDOWS, + whoami::Platform::Linux => MajorPlatformType::UNIX, + whoami::Platform::Mac => MajorPlatformType::MACINTOSH, + whoami::Platform::Ios => MajorPlatformType::IOS, + whoami::Platform::Android => MajorPlatformType::ANDROID, + _ => MajorPlatformType::UNSPECIFIED, + }, + hardware_id: None, + license_cache: None, + enable_server_pointer: !self.no_server_pointer, + autologon: self.autologon, + enable_audio_playback, + request_data: None, + pointer_software_rendering: false, + multitransport_flags: None, + compression_type, + performance_flags: PerformanceFlags::default(), + timezone_info: TimezoneInfo::default(), + alternate_shell: properties.alternate_shell().unwrap_or_default().to_owned(), + work_dir: properties.shell_working_directory().unwrap_or_default().to_owned(), + }; + + Ok(Config { + log_file: self.log_file, + gw, + kerberos_config, + destination, + connector, + clipboard_type, + rdcleanpath: self.rdcleanpath, + fake_events_interval, + dvc_pipe_proxies: self.dvc_pipe_proxies, + #[cfg(windows)] + dvc_plugins: self.dvc_plugins, + }) + } +} + +fn resolve_clipboard_type(cli: ClipboardType, redirect_clipboard: bool) -> ResolvedClipboardType { + if !redirect_clipboard { + return ResolvedClipboardType::Disable; + } + + match cli { + ClipboardType::Enable => ResolvedClipboardType::Enable, + ClipboardType::Disable => ResolvedClipboardType::Disable, + ClipboardType::Stub => ResolvedClipboardType::Stub, + } +} + +pub fn parse_config() -> anyhow::Result { + PartialConfig::parse_args()?.into_config() +} + +pub fn parse_config_from(args: I) -> anyhow::Result +where + I: IntoIterator, + T: Into + Clone, +{ + PartialConfig::parse_from(args)?.into_config() +} + +fn normalize_kdc_proxy_url_from_name(name: &str) -> String { + if name.starts_with("http://") || name.starts_with("https://") { + name.to_owned() + } else { + format!("https://{name}/KdcProxy") + } +} diff --git a/crates/ironrdp-viewer/src/lib.rs b/crates/ironrdp-viewer/src/lib.rs new file mode 100644 index 000000000..69402deb0 --- /dev/null +++ b/crates/ironrdp-viewer/src/lib.rs @@ -0,0 +1,14 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] +#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] +#![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary + +// No need to be as strict as in production libraries +#![allow(clippy::arithmetic_side_effects)] +#![allow(clippy::cast_lossless)] +#![allow(clippy::cast_possible_truncation)] +#![allow(clippy::cast_possible_wrap)] +#![allow(clippy::cast_sign_loss)] + +pub mod app; +pub mod clipboard; +pub mod config; diff --git a/crates/ironrdp-client/src/main.rs b/crates/ironrdp-viewer/src/main.rs similarity index 69% rename from crates/ironrdp-client/src/main.rs rename to crates/ironrdp-viewer/src/main.rs index 4e85b9cb1..157ddb41f 100644 --- a/crates/ironrdp-client/src/main.rs +++ b/crates/ironrdp-viewer/src/main.rs @@ -1,10 +1,12 @@ #![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary use anyhow::Context as _; -use ironrdp_client::app::App; -use ironrdp_client::config::{ClipboardType, PartialConfig}; +use ironrdp_client::config::ClipboardType; use ironrdp_client::rdp::{DvcPipeProxyFactory, RdpClient, RdpInputEvent, RdpOutputEvent}; +use ironrdp_viewer::app::App; +use ironrdp_viewer::config::PartialConfig; use tokio::runtime; +use tokio::sync::mpsc; use tracing::debug; use winit::dpi::PhysicalSize; use winit::event_loop::EventLoop; @@ -26,6 +28,7 @@ fn main() -> anyhow::Result<()> { let event_loop = EventLoop::::with_user_event().build()?; let event_loop_proxy = event_loop.create_proxy(); let (input_event_sender, input_event_receiver) = RdpInputEvent::create_channel(); + let (output_event_sender, mut output_event_receiver) = mpsc::channel::(64); let initial_window_size = PhysicalSize::new( u32::from(config.connector.desktop_size.width), u32::from(config.connector.desktop_size.height), @@ -56,30 +59,54 @@ fn main() -> anyhow::Result<()> { let factory = cliprdr.backend_factory(); Some(factory) } - #[cfg(windows)] - ClipboardType::Windows => { - use ironrdp_client::clipboard::ClientClipboardMessageProxy; - use ironrdp_cliprdr_native::WinClipboard; - - let cliprdr = WinClipboard::new(ClientClipboardMessageProxy::new(input_event_sender.clone()))?; - - let factory = cliprdr.backend_factory(); - _win_clipboard = cliprdr; - Some(factory) + ClipboardType::Enable => { + #[cfg(windows)] + { + use ironrdp_cliprdr_native::WinClipboard; + use ironrdp_viewer::clipboard::ClientClipboardMessageProxy; + + let cliprdr = WinClipboard::new(ClientClipboardMessageProxy::new(input_event_sender.clone()))?; + + let factory = cliprdr.backend_factory(); + _win_clipboard = cliprdr; + Some(factory) + } + #[cfg(not(windows))] + { + // No native clipboard backend available on this platform; fall back to stub. + use ironrdp_cliprdr_native::StubClipboard; + + let cliprdr = StubClipboard::new(); + let factory = cliprdr.backend_factory(); + Some(factory) + } } - _ => None, + ClipboardType::Disable => None, }; let dvc_pipe_proxy_factory = DvcPipeProxyFactory::new(input_event_sender); let client = RdpClient { config, - event_loop_proxy, + output_event_sender, input_event_receiver, cliprdr_factory, dvc_pipe_proxy_factory, }; + // Forward output events from the library's mpsc channel to winit's `EventLoopProxy`. + // + // The library is winit-agnostic: it just emits `RdpOutputEvent`s on a plain + // `tokio::sync::mpsc` channel. Bridging onto the GUI event loop is the binary's job. + rt.spawn(async move { + while let Some(event) = output_event_receiver.recv().await { + if event_loop_proxy.send_event(event).is_err() { + // The event loop is gone; nothing left to forward. + break; + } + } + }); + debug!("Start RDP thread"); std::thread::spawn(move || { rt.block_on(client.run()); diff --git a/release-plz.toml b/release-plz.toml index 8a33bf6f9..c5bbd12b0 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -9,7 +9,7 @@ release_commits = "^(feat|docs|fix|build|perf)" # Flagship crate for which we push a GitHub release. [[package]] -name = "ironrdp-client" +name = "ironrdp-viewer" git_release_enable = true publish = false # TODO: enable publishing when ready.