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:
+1
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
Generated
+14
@@ -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
@@ -528,7 +528,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
Event::DesktopFrame { .. } => {}
|
||||
Event::VncClipboard { text, .. } => self.remote_clipboard(&text),
|
||||
Event::DesktopClipboard { text, .. } => self.remote_clipboard(&text),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -124,7 +124,7 @@ pub enum Event {
|
||||
session: String,
|
||||
},
|
||||
/// The remote desktop's clipboard changed.
|
||||
VncClipboard {
|
||||
DesktopClipboard {
|
||||
session: String,
|
||||
text: String,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user