diff --git a/docs/overview.md b/docs/overview.md index fbc0196..5f3294b 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -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 diff --git a/docs/user-manual.md b/docs/user-manual.md index 4607ad0..19fdda9 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -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 diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 1a43d64..9ffe73b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -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" diff --git a/rust/app/src/app.rs b/rust/app/src/app.rs index d907345..a2b4a73 100644 --- a/rust/app/src/app.rs +++ b/rust/app/src/app.rs @@ -528,7 +528,7 @@ impl App { } } Event::DesktopFrame { .. } => {} - Event::VncClipboard { text, .. } => self.remote_clipboard(&text), + Event::DesktopClipboard { text, .. } => self.remote_clipboard(&text), } } diff --git a/rust/app/src/desktop_view.rs b/rust/app/src/desktop_view.rs index c73c56c..cef9921 100644 --- a/rust/app/src/desktop_view.rs +++ b/rust/app/src/desktop_view.rs @@ -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 })); diff --git a/rust/core/Cargo.toml b/rust/core/Cargo.toml index a387113..8728703 100644 --- a/rust/core/Cargo.toml +++ b/rust/core/Cargo.toml @@ -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"] } diff --git a/rust/core/src/clients/rdp.rs b/rust/core/src/clients/rdp.rs index dcef335..8faee0c 100644 --- a/rust/core/src/clients/rdp.rs +++ b/rust/core/src/clients/rdp.rs @@ -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), 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>, tx: Mutex>>, + /// Local text offered to the remote clipboard, read by the clipboard channel on request. + clip_text: Arc>>, + clip_rx: Mutex>>, } type Tls = rustls::StreamOwned; @@ -69,7 +82,7 @@ const POLL: Duration = Duration::from_millis(15); impl Client { pub fn new(cfg: Config) -> Arc { - 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> { @@ -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::() + && 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::() 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, + text: Arc>>, + 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, 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") { diff --git a/rust/core/src/sessions/mod.rs b/rust/core/src/sessions/mod.rs index 699e801..1003ae1 100644 --- a/rust/core/src/sessions/mod.rs +++ b/rust/core/src/sessions/mod.rs @@ -124,7 +124,7 @@ pub enum Event { session: String, }, /// The remote desktop's clipboard changed. - VncClipboard { + DesktopClipboard { session: String, text: String, }, diff --git a/rust/core/src/sessions/protocols.rs b/rust/core/src/sessions/protocols.rs index bd5dedb..e649b88 100644 --- a/rust/core/src/sessions/protocols.rs +++ b/rust/core/src/sessions/protocols.rs @@ -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, });