feat(rdp): text clipboard sharing in both directions

A CLIPRDR channel with a text-only backend (CF_UNICODETEXT): text copied
on the remote desktop is fetched and goes through the app's remote-
clipboard preference like VNC and OSC 52; local text is offered to the
remote when the desktop gains focus, on Ctrl+V and with Paste Clipboard
(which no longer types the text). Backend callbacks run inside the
active stage, so they queue requests that the session loop sends.

Verified against xrdp: the remote X clipboard read back the offered
text, and a remote copy arrived through on_clipboard.
This commit is contained in:
Andy Dixon
2026-10-12 17:47:22 +01:00
parent 4256d45633
commit 6476ccff28
9 changed files with 152 additions and 21 deletions
+1 -1
View File
@@ -146,7 +146,7 @@ Use Nexus Terminal when you need to:
- Server certificate pinned by fingerprint in Known Hosts, checked before credentials are sent.
- Direct, SOCKS5, HTTP CONNECT or SSH-tunnelled connections.
- Scale-to-fit or 1:1, a fixed resolution or one that follows the tab (Display Control).
- Keyboard (scancodes for keys and shortcuts, Unicode for text), mouse and wheel; Ctrl+Alt+Del; type the clipboard into the session.
- Keyboard (scancodes for keys and shortcuts, Unicode for text), mouse and wheel; Ctrl+Alt+Del; text clipboard sharing in both directions.
- Password storage modes and domain logon.
### VNC capabilities
+1 -1
View File
@@ -173,7 +173,7 @@ Nexus Terminal logs on with Network Level Authentication (CredSSP, NTLM), so you
RDP servers usually present self-signed certificates, so the certificate is pinned like an SSH host key: its SHA-256 fingerprint is shown the first time and stored in **Known Hosts**, and a changed certificate offers Reject, Accept Once or Accept & Update. The check happens before any credentials are sent.
The viewer works like the VNC one: click the desktop to give it keyboard focus, **Ctrl+Alt+Del** sends the key combination, and **Paste Clipboard** types your clipboard's text into the remote desktop (clipboard sharing is not supported yet). With **Resize With Tab**, the remote desktop follows the tab's size on servers that support it (Windows); otherwise the picture is scaled.
The viewer works like the VNC one: click the desktop to give it keyboard focus, **Ctrl+Alt+Del** sends the key combination, and text is shared through the clipboard both ways: text you copy on the remote desktop arrives on your clipboard (subject to the remote-clipboard setting), and your clipboard is offered to the remote desktop whenever you click into it, press Ctrl+V or use **Paste Clipboard**. Files and images are not shared. With **Resize With Tab**, the remote desktop follows the tab's size on servers that support it (Windows); otherwise the picture is scaled.
### IRC
+14
View File
@@ -2767,6 +2767,7 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f910b8dc8e7b8e001c61fd742be442e8604fb2499ded713cadf911e7645ade4"
dependencies = [
"ironrdp-cliprdr",
"ironrdp-connector",
"ironrdp-core",
"ironrdp-displaycontrol",
@@ -2797,6 +2798,19 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e548d9fd162558a5a8aaed72e528556ce88e77773634e3722b8d01d6f170388"
[[package]]
name = "ironrdp-cliprdr"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb9050999a1e032f4313788ac5a6d06e897a4f672f1de2ed98683001fc1abf56"
dependencies = [
"bitflags 2.13.2",
"ironrdp-core",
"ironrdp-pdu",
"ironrdp-svc",
"tracing",
]
[[package]]
name = "ironrdp-connector"
version = "0.10.0"
+1 -1
View File
@@ -528,7 +528,7 @@ impl App {
}
}
Event::DesktopFrame { .. } => {}
Event::VncClipboard { text, .. } => self.remote_clipboard(&text),
Event::DesktopClipboard { text, .. } => self.remote_clipboard(&text),
}
}
+15 -13
View File
@@ -284,7 +284,7 @@ impl DesktopView {
self.buttons = 0;
}
/// VNC: sets the remote clipboard. RDP (no clipboard channel yet): types the text.
/// Makes `text` the remote desktop's clipboard contents.
fn paste(&self, s: &mut Shared, text: &str) {
match &self.client {
Some(Remote::Vnc(c)) => {
@@ -292,12 +292,7 @@ impl DesktopView {
s.error(format!("Clipboard: {e}"));
}
}
Some(Remote::Rdp(c)) => c.input(
text.chars()
.filter(|c| !c.is_control() || *c == '\n')
.flat_map(|ch| [Operation::UnicodeKeyPressed(ch), Operation::UnicodeKeyReleased(ch)])
.collect(),
),
Some(Remote::Rdp(c)) => c.set_clipboard(text.to_string()),
None => {}
}
}
@@ -421,12 +416,10 @@ impl DesktopView {
let _ = c.request_full_update();
}
if !self.view_only {
let tip = if self.protocol == Protocol::Rdp {
"Type the local clipboard's text into the remote desktop"
} else {
"Send the local clipboard to the remote desktop"
};
if widgets::button(ui, icon::CLIPBOARD_TEXT, "Paste Clipboard").on_hover_text(tip).clicked() {
if widgets::button(ui, icon::CLIPBOARD_TEXT, "Paste Clipboard")
.on_hover_text("Send the local clipboard to the remote desktop")
.clicked()
{
match arboard::Clipboard::new().and_then(|mut c| c.get_text()) {
Ok(text) if !text.is_empty() => {
self.paste(s, &text);
@@ -453,6 +446,15 @@ impl DesktopView {
resp.request_focus();
}
let focused = resp.has_focus();
// RDP shares the clipboard both ways: offer the local text whenever the desktop gains focus.
if focused
&& !self.had_focus
&& let Some(Remote::Rdp(c)) = &self.client
&& let Ok(text) = arboard::Clipboard::new().and_then(|mut c| c.get_text())
&& !text.is_empty()
{
c.set_clipboard(text);
}
if focused {
ui.painter().rect_stroke(img, 0.0, egui::Stroke::new(1.0, Tokens::get(ui.ctx()).accent.gamma_multiply(0.6)), egui::StrokeKind::Outside);
ui.memory_mut(|m| m.set_focus_lock_filter(id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }));
+1 -1
View File
@@ -36,7 +36,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
webpki-roots = "1"
ironrdp = { version = "0.17", features = ["connector", "session", "graphics", "pdu", "svc", "dvc", "displaycontrol", "input"] }
ironrdp = { version = "0.17", features = ["connector", "session", "graphics", "pdu", "svc", "dvc", "displaycontrol", "input", "cliprdr"] }
ironrdp-blocking = "0.10"
x509-cert = { version = "0.2", default-features = false, features = ["std"] }
+115 -2
View File
@@ -3,9 +3,15 @@
//! tunnel), and the desktop decoded into the same RGBA framebuffer the VNC client uses. Like the
//! other clients, `connect` blocks and the session runs on its own thread.
use super::vnc::{FrameFn, Framebuffer};
use super::vnc::{ClipboardFn, FrameFn, Framebuffer};
use super::{CloseFn, ErrorFn};
use crate::Result;
use ironrdp::cliprdr::CliprdrClient;
use ironrdp::cliprdr::backend::{ClipboardMessage, CliprdrBackend};
use ironrdp::cliprdr::pdu::{
ClipboardFormat, ClipboardFormatId, ClipboardGeneralCapabilityFlags, FileContentsRequest, FileContentsResponse, FormatDataRequest, FormatDataResponse,
LockDataId,
};
use ironrdp::connector::connection_activation::ConnectionActivationState;
use ironrdp::connector::{self, ClientConnector, ConnectionResult, Credentials, Sequence, ServerName};
use ironrdp::displaycontrol::client::DisplayControlClient;
@@ -46,6 +52,8 @@ pub struct Config {
pub check_cert: CertCheck,
pub connect_timeout: Duration,
pub on_frame: FrameFn,
/// Text the remote desktop copied (the app applies its remote-clipboard preference).
pub on_clipboard: ClipboardFn,
pub on_error: ErrorFn,
pub on_close: CloseFn,
}
@@ -53,6 +61,8 @@ pub struct Config {
enum Input {
Ops(Vec<Operation>),
Resize(u16, u16),
/// Offer local clipboard text to the remote desktop.
Clipboard(String),
Close,
}
@@ -60,6 +70,9 @@ pub struct Client {
cfg: Config,
fb: Arc<Mutex<Framebuffer>>,
tx: Mutex<Option<Sender<Input>>>,
/// Local text offered to the remote clipboard, read by the clipboard channel on request.
clip_text: Arc<Mutex<Option<String>>>,
clip_rx: Mutex<Option<Receiver<ClipboardMessage>>>,
}
type Tls = rustls::StreamOwned<rustls::ClientConnection, TcpStream>;
@@ -69,7 +82,7 @@ const POLL: Duration = Duration::from_millis(15);
impl Client {
pub fn new(cfg: Config) -> Arc<Client> {
Arc::new(Client { cfg, fb: Arc::default(), tx: Mutex::new(None) })
Arc::new(Client { cfg, fb: Arc::default(), tx: Mutex::new(None), clip_text: Arc::default(), clip_rx: Mutex::new(None) })
}
pub fn framebuffer(&self) -> Arc<Mutex<Framebuffer>> {
@@ -106,6 +119,11 @@ impl Client {
self.send(Input::Resize(width, height));
}
/// Makes `text` the remote clipboard's contents (the remote pastes it on demand).
pub fn set_clipboard(&self, text: String) {
self.send(Input::Clipboard(text));
}
pub fn close(&self) {
if let Some(tx) = self.tx.lock().take() {
let _ = tx.send(Input::Close);
@@ -171,6 +189,13 @@ impl Client {
let mut framed = Framed::new(tcp);
let drdynvc = DrdynvcClient::new().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new())));
let mut connector = ClientConnector::new(self.connector_config(), local).with_static_channel(drdynvc);
let (clip_tx, clip_rx) = mpsc::channel();
connector.attach_static_channel(CliprdrClient::new(Box::new(TextClipboard {
tx: clip_tx,
text: self.clip_text.clone(),
on_text: self.cfg.on_clipboard.clone(),
})));
*self.clip_rx.lock() = Some(clip_rx);
let should_upgrade = ironrdp_blocking::connect_begin(&mut framed, &mut connector).map_err(|e| format!("RDP connection failed: {e}"))?;
let tcp = framed.into_inner_no_leftover();
@@ -210,6 +235,7 @@ impl Client {
}
.build();
let mut input = Database::new();
let clip_rx = self.clip_rx.lock().take();
// A resize waits here until the Display Control channel is open (it opens after logon).
let mut want_size: Option<(u16, u16)> = None;
loop {
@@ -223,6 +249,14 @@ impl Client {
}
}
Ok(Input::Resize(w, h)) => want_size = Some((w, h)).filter(|&s| s != (image.width(), image.height())),
Ok(Input::Clipboard(text)) => {
*self.clip_text.lock() = Some(text);
if let Some(c) = stage.get_svc_processor_mut::<CliprdrClient>()
&& let Ok(msgs) = c.initiate_copy(&[ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT)])
{
outputs.push(ActiveStageOutput::ResponseFrame(stage.process_svc_processor_messages(msgs)?));
}
}
Ok(Input::Close) | Err(mpsc::TryRecvError::Disconnected) => {
for o in stage.graceful_shutdown()? {
if let ActiveStageOutput::ResponseFrame(f) = o {
@@ -234,6 +268,19 @@ impl Client {
Err(mpsc::TryRecvError::Empty) => break,
}
}
// Requests the clipboard backend queued while the stage processed server PDUs.
for msg in clip_rx.iter().flat_map(|rx| rx.try_iter()) {
let Some(c) = stage.get_svc_processor_mut::<CliprdrClient>() else { break };
let msgs = match msg {
ClipboardMessage::SendInitiateCopy(formats) => c.initiate_copy(&formats),
ClipboardMessage::SendFormatData(response) => c.submit_format_data(response),
ClipboardMessage::SendInitiatePaste(format) => c.initiate_paste(format),
_ => continue,
};
if let Ok(msgs) = msgs {
outputs.push(ActiveStageOutput::ResponseFrame(stage.process_svc_processor_messages(msgs)?));
}
}
if let Some((w, h)) = want_size {
let (w, h) = ironrdp::displaycontrol::pdu::MonitorLayoutEntry::adjust_display_size(w.into(), h.into());
if let Some(frame) = stage.encode_resize(w, h, None, None) {
@@ -292,6 +339,67 @@ impl Client {
}
}
/// Text-only clipboard sharing (CF_UNICODETEXT) over CLIPRDR. Callbacks run inside the active
/// stage, so they queue requests for the session loop instead of answering directly.
struct TextClipboard {
tx: Sender<ClipboardMessage>,
text: Arc<Mutex<Option<String>>>,
on_text: ClipboardFn,
}
impl std::fmt::Debug for TextClipboard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("TextClipboard")
}
}
impl ironrdp::core::AsAny for TextClipboard {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
}
impl CliprdrBackend for TextClipboard {
fn temporary_directory(&self) -> &str {
""
}
fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags {
ClipboardGeneralCapabilityFlags::empty()
}
fn on_ready(&mut self) {}
fn on_request_format_list(&mut self) {
let formats = if self.text.lock().is_some() { vec![ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT)] } else { vec![] };
let _ = self.tx.send(ClipboardMessage::SendInitiateCopy(formats));
}
fn on_process_negotiated_capabilities(&mut self, _: ClipboardGeneralCapabilityFlags) {}
fn on_remote_copy(&mut self, formats: &[ClipboardFormat]) {
if formats.iter().any(|f| f.id() == ClipboardFormatId::CF_UNICODETEXT) {
let _ = self.tx.send(ClipboardMessage::SendInitiatePaste(ClipboardFormatId::CF_UNICODETEXT));
}
}
fn on_format_data_request(&mut self, request: FormatDataRequest) {
let response = match &*self.text.lock() {
Some(t) if request.format == ClipboardFormatId::CF_UNICODETEXT => FormatDataResponse::new_unicode_string(t),
_ => FormatDataResponse::new_error(),
};
let _ = self.tx.send(ClipboardMessage::SendFormatData(ironrdp::core::IntoOwned::into_owned(response)));
}
fn on_format_data_response(&mut self, response: FormatDataResponse<'_>) {
if !response.is_error()
&& let Ok(text) = response.to_unicode_string()
{
(self.on_text)(text);
}
}
fn on_file_contents_request(&mut self, _: FileContentsRequest) {}
fn on_file_contents_response(&mut self, _: FileContentsResponse<'_>) {}
fn on_lock(&mut self, _: LockDataId) {}
fn on_unlock(&mut self, _: LockDataId) {}
}
/// One step of a connection sequence: read the PDU it waits for (if any), answer if it says to.
fn sequence_step(framed: &mut Framed<Tls>, seq: &mut dyn Sequence, buf: &mut ironrdp::core::WriteBuf) -> Result<()> {
buf.clear();
@@ -471,6 +579,7 @@ mod tests {
on_frame: Arc::new(move || {
f2.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}),
on_clipboard: Arc::new(|t| eprintln!("remote clipboard: {t:?}")),
on_error: Arc::new(|e| eprintln!("on_error: {e}")),
on_close: Arc::new(|| eprintln!("on_close")),
});
@@ -483,6 +592,10 @@ mod tests {
c.input(vec![Operation::MouseMove(MousePosition { x: 100, y: 100 })]);
c.input(vec![Operation::KeyPressed(Scancode::from_u8(false, 0x1e)), Operation::KeyReleased(Scancode::from_u8(false, 0x1e))]);
std::thread::sleep(Duration::from_secs(2));
if std::env::var("NEXUS_RDP_CLIP").is_ok() {
c.set_clipboard("from-nexus-123".into());
std::thread::sleep(Duration::from_secs(25));
}
let fb = c.framebuffer().lock().clone();
eprintln!("frames {} size {}x{}", frames.load(std::sync::atomic::Ordering::SeqCst), fb.width, fb.height);
if let Ok(out) = std::env::var("NEXUS_RDP_PNG") {
+1 -1
View File
@@ -124,7 +124,7 @@ pub enum Event {
session: String,
},
/// The remote desktop's clipboard changed.
VncClipboard {
DesktopClipboard {
session: String,
text: String,
},
+3 -1
View File
@@ -241,7 +241,7 @@ impl Manager {
let cb = self.terminal_callbacks(id, epoch);
let (me, sid) = (self.clone(), id.to_string());
let (me2, sid2) = (self.clone(), id.to_string());
let clip: clients::vnc::ClipboardFn = Arc::new(move |text| me2.emit(Event::VncClipboard { session: sid2.clone(), text }));
let clip: clients::vnc::ClipboardFn = Arc::new(move |text| me2.emit(Event::DesktopClipboard { session: sid2.clone(), text }));
let c = connect_vnc_client(
&host,
port,
@@ -345,6 +345,7 @@ impl Manager {
let password = self.secret_fn(id, &p.id, SecretKind::RdpPassword, rc.secret_mode, prompt)()?;
let cb = self.terminal_callbacks(id, epoch);
let (me, sid) = (self.clone(), id.to_string());
let (me2, sid2) = (self.clone(), id.to_string());
let c = clients::rdp::Client::new(clients::rdp::Config {
host,
username: p.username.clone(),
@@ -357,6 +358,7 @@ impl Manager {
check_cert,
connect_timeout: timeout,
on_frame: Arc::new(move || me.emit(Event::DesktopFrame { session: sid.clone() })),
on_clipboard: Arc::new(move |text| me2.emit(Event::DesktopClipboard { session: sid2.clone(), text })),
on_error: cb.on_error,
on_close: cb.on_close,
});