Files
nexus/rust/app/src/desktop_view.rs
T
Andy Dixon 6476ccff28 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.
2026-10-12 17:47:22 +01:00

577 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Remote desktop viewer for VNC and RDP: the remote framebuffer as a texture, with mouse,
//! keyboard and clipboard input. Input is expressed in X keysyms and VNC pointer masks (VNC's own
//! terms); `Remote` translates them for RDP. Toolbar after the web build's `vnc.js` (status,
//! Ctrl+Alt+Del, clipboard), plus refresh and a scale toggle.
use crate::state::Shared;
use crate::theme::{self, Tokens};
use crate::widgets;
use egui::{Event, Key, Modifiers, Pos2, Rect, RichText, Sense, TextureHandle, Ui, vec2};
use egui_phosphor::regular as icon;
use nexus_core::clients::rdp::{self, MouseButton, MousePosition, Operation, Scancode, WheelRotations};
use nexus_core::clients::vnc::{self, keysym_for};
use nexus_core::models::{Profile, Protocol};
use std::sync::Arc;
const XK_SHIFT: u32 = 0xffe1;
const XK_CONTROL: u32 = 0xffe3;
const XK_ALT: u32 = 0xffe9;
const XK_SUPER: u32 = 0xffeb;
const XK_DELETE: u32 = 0xffff;
/// Wheel distance (points) per remote scroll notch for pixel-precise wheels.
const NOTCH: f32 = 40.0;
/// The connected client a view drives.
#[derive(Clone)]
enum Remote {
Vnc(Arc<vnc::Client>),
Rdp(Arc<rdp::Client>),
}
impl Remote {
fn framebuffer(&self) -> Arc<parking_lot::Mutex<vnc::Framebuffer>> {
match self {
Remote::Vnc(c) => c.framebuffer(),
Remote::Rdp(c) => c.framebuffer(),
}
}
/// `held` is the modifier keysyms down on the remote side (for RDP shortcuts).
fn key(&self, sym: u32, down: bool, held: &[u32]) {
match self {
Remote::Vnc(c) => {
let _ = c.key(sym, down);
}
Remote::Rdp(c) => {
if let Some(op) = rdp_key(sym, down, held.iter().any(|&k| matches!(k, XK_CONTROL | XK_ALT | XK_SUPER))) {
c.input(vec![op]);
}
}
}
}
/// Bits 0–2 left, middle, right; 3/4 a wheel notch up/down (VNC's pointer mask).
fn pointer(&self, x: u16, y: u16, mask: u8) {
match self {
Remote::Vnc(c) => {
let _ = c.pointer(x, y, mask);
}
Remote::Rdp(c) => {
let mut ops = vec![Operation::MouseMove(MousePosition { x, y })];
for (bit, b) in [(0, MouseButton::Left), (1, MouseButton::Middle), (2, MouseButton::Right)] {
ops.push(if mask & 1 << bit != 0 { Operation::MouseButtonPressed(b) } else { Operation::MouseButtonReleased(b) });
}
for (bit, units) in [(3, 120), (4, -120)] {
if mask & 1 << bit != 0 {
ops.push(Operation::WheelRotations(WheelRotations { is_vertical: true, rotation_units: units }));
}
}
c.input(ops);
}
}
}
}
/// An X keysym as an RDP key operation: named keys (and letters/digits pressed with Ctrl, Alt or
/// Win, so shortcuts work) as US-layout scancodes, everything else as a Unicode key.
fn rdp_key(sym: u32, down: bool, chord: bool) -> Option<Operation> {
let sc = |ext: bool, code: u8| {
Some(if down { Operation::KeyPressed(Scancode::from_u8(ext, code)) } else { Operation::KeyReleased(Scancode::from_u8(ext, code)) })
};
let named = match sym {
0xff1b => (false, 0x01),
0xff08 => (false, 0x0e),
0xff09 => (false, 0x0f),
0xff0d => (false, 0x1c),
0xffe1 => (false, 0x2a),
0xffe2 => (false, 0x36),
0xffe3 => (false, 0x1d),
0xffe4 => (true, 0x1d),
0xffe9 => (false, 0x38),
0xffea | 0xfe03 => (true, 0x38),
0xffeb => (true, 0x5b),
0xffec => (true, 0x5c),
0xffe5 => (false, 0x3a),
0xff50 => (true, 0x47),
0xff51 => (true, 0x4b),
0xff52 => (true, 0x48),
0xff53 => (true, 0x4d),
0xff54 => (true, 0x50),
0xff55 => (true, 0x49),
0xff56 => (true, 0x51),
0xff57 => (true, 0x4f),
0xff63 => (true, 0x52),
0xffff => (true, 0x53),
f @ 0xffbe..=0xffc7 => (false, 0x3b + (f - 0xffbe) as u8),
0xffc8 => (false, 0x57),
0xffc9 => (false, 0x58),
_ => (false, 0),
};
if named.1 != 0 {
return sc(named.0, named.1);
}
let ch = char::from_u32(if sym >= 0x0100_0000 { sym - 0x0100_0000 } else { sym })?;
if chord {
const LETTERS: &[u8; 26] = b"\x1e\x30\x2e\x20\x12\x21\x22\x23\x17\x24\x25\x26\x32\x31\x18\x19\x10\x13\x1f\x14\x16\x2f\x11\x2d\x15\x2c";
let lower = ch.to_ascii_lowercase();
match lower {
'a'..='z' => return sc(false, LETTERS[(lower as u8 - b'a') as usize]),
'1'..='9' => return sc(false, lower as u8 - b'1' + 0x02),
'0' => return sc(false, 0x0b),
' ' => return sc(false, 0x39),
_ => {}
}
}
Some(if down { Operation::UnicodeKeyPressed(ch) } else { Operation::UnicodeKeyReleased(ch) })
}
pub struct DesktopView {
pub session: String,
protocol: Protocol,
view_only: bool,
scale_to_fit: bool,
/// RDP: ask the server to match the tab's size.
dynamic_resolution: bool,
/// Last tab size reported (pixels) and when it changed, for debounced RDP resizes.
reported: ([u32; 2], [u32; 2], f64),
ready: bool,
client: Option<Remote>,
texture: Option<TextureHandle>,
fb_size: [usize; 2],
name: String,
/// Modifier keysyms currently held on the remote side.
held: Vec<u32>,
buttons: u8,
last_pos: (u16, u16),
wheel: f32,
had_focus: bool,
}
/// Keysym for a non-printing key (arrows, F-keys, Enter…); printable keys arrive as `Event::Text`.
fn named_keysym(key: Key) -> Option<u32> {
let name = key.name();
if name.chars().count() == 1 || key == Key::Space {
return None;
}
keysym_for(name, None)
}
/// Keysym for a printable key pressed with Ctrl/Alt/Cmd (egui sends no text for those).
fn chord_keysym(key: Key, shift: bool) -> Option<u32> {
if key == Key::Space {
return Some(0x20);
}
let mut chars = key.symbol_or_name().chars();
let c = chars.next()?;
if chars.next().is_some() {
return None;
}
keysym_for("", Some(if shift { c.to_ascii_uppercase() } else { c.to_ascii_lowercase() }))
}
/// Modifier keysyms that should be held for `m`.
fn modifier_syms(m: Modifiers) -> Vec<u32> {
let mut v = vec![];
if m.shift {
v.push(XK_SHIFT);
}
if m.ctrl {
v.push(XK_CONTROL);
}
if m.alt {
v.push(XK_ALT);
}
if m.mac_cmd {
v.push(XK_SUPER);
}
v
}
/// Vertical wheel movement this frame in notches (positive = up).
fn wheel_notches(events: &[Event]) -> f32 {
events
.iter()
.map(|e| match e {
Event::MouseWheel { unit, delta, .. } => match unit {
egui::MouseWheelUnit::Point => delta.y / NOTCH,
egui::MouseWheelUnit::Line => delta.y,
egui::MouseWheelUnit::Page => delta.y * 5.0,
},
_ => 0.0,
})
.sum()
}
/// Pointer button mask: bit 0 left, 1 middle, 2 right.
fn button_mask(left: bool, middle: bool, right: bool) -> u8 {
left as u8 | (middle as u8) << 1 | (right as u8) << 2
}
/// A screen position over the drawn image → framebuffer coordinates (clamped).
fn to_remote(p: Pos2, img: Rect, fb: [usize; 2]) -> (u16, u16) {
let sx = fb[0] as f32 / img.width().max(1.0);
let sy = fb[1] as f32 / img.height().max(1.0);
let x = ((p.x - img.left()) * sx).clamp(0.0, fb[0].saturating_sub(1) as f32);
let y = ((p.y - img.top()) * sy).clamp(0.0, fb[1].saturating_sub(1) as f32);
(x as u16, y as u16)
}
impl DesktopView {
pub fn new(session: &str, profile: &Profile) -> DesktopView {
let (view_only, scale_to_fit, dynamic_resolution) = match (&profile.vnc, &profile.rdp) {
(Some(v), _) if profile.protocol == Protocol::Vnc => (v.view_only, v.scale_to_fit, false),
(_, Some(r)) => (false, r.scale_to_fit, r.dynamic_resolution && r.width == 0),
_ => (false, true, false),
};
DesktopView {
session: session.into(),
protocol: profile.protocol,
view_only,
scale_to_fit,
dynamic_resolution,
reported: ([0, 0], [0, 0], 0.0),
ready: false,
client: None,
texture: None,
fb_size: [0, 0],
name: String::new(),
held: vec![],
buttons: 0,
last_pos: (u16::MAX, u16::MAX),
wheel: 0.0,
had_focus: false,
}
}
/// Called on `Event::DesktopReady` (the client is connected; the view fetches it next frame).
pub fn on_ready(&mut self) {
self.ready = true;
self.client = None;
}
fn key(&self, sym: u32, down: bool) {
if let Some(c) = &self.client {
c.key(sym, down, &self.held);
}
}
fn tap(&self, syms: &[u32]) {
syms.iter().for_each(|&k| self.key(k, true));
syms.iter().rev().for_each(|&k| self.key(k, false));
}
fn sync_modifiers(&mut self, want: Vec<u32>) {
for k in self.held.clone() {
if !want.contains(&k) {
self.key(k, false);
}
}
for &k in &want {
if !self.held.contains(&k) {
self.key(k, true);
}
}
self.held = want;
}
fn release_all(&mut self) {
self.sync_modifiers(vec![]);
if self.buttons != 0
&& let Some(c) = &self.client
{
c.pointer(self.last_pos.0, self.last_pos.1, 0);
}
self.buttons = 0;
}
/// Makes `text` the remote desktop's clipboard contents.
fn paste(&self, s: &mut Shared, text: &str) {
match &self.client {
Some(Remote::Vnc(c)) => {
if let Err(e) = c.send_clipboard(text) {
s.error(format!("Clipboard: {e}"));
}
}
Some(Remote::Rdp(c)) => c.set_clipboard(text.to_string()),
None => {}
}
}
/// RDP with dynamic resolution: report the tab's pixel size once it has settled.
fn report_size(&mut self, ui: &Ui, s: &Shared, area: Rect) {
if self.protocol != Protocol::Rdp || !self.dynamic_resolution {
return;
}
let ppp = ui.ctx().pixels_per_point();
let size = [(area.width() * ppp) as u32 & !1, (area.height() * ppp) as u32];
let now = ui.input(|i| i.time);
if size != self.reported.1 {
self.reported.1 = size;
self.reported.2 = now;
}
// ponytail: 0.4 s debounce; each resize is a server-side desktop reconfiguration.
if size != self.reported.0 && now - self.reported.2 > 0.4 && size[0] >= 200 && size[1] >= 200 {
self.reported.0 = size;
let _ = s.svc.sessions.resize(&self.session, size[0].min(8192) as u16, size[1].min(8192) as u16);
} else if size != self.reported.0 {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(450));
}
}
pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) {
if self.ready && self.client.is_none() {
self.client = match self.protocol {
Protocol::Rdp => s.svc.sessions.rdp(&self.session).ok().map(Remote::Rdp),
_ => s.svc.sessions.vnc(&self.session).ok().map(Remote::Vnc),
};
if let Some(Remote::Vnc(c)) = &self.client {
self.name = c.desktop_name();
}
}
self.upload(ui);
self.toolbar(ui, s);
let t = Tokens::get(ui.ctx());
let area = ui.available_rect_before_wrap();
self.report_size(ui, s, area);
ui.painter().rect_filled(area, 0.0, t.canvas);
let Some(tex) = self.texture.clone().filter(|_| self.ready) else {
ui.allocate_rect(area, Sense::hover());
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(area));
c.vertical_centered(|ui| {
ui.add_space(area.height() / 2.0 - 30.0);
ui.add(egui::Spinner::new().size(22.0));
ui.label(RichText::new(if self.ready { "Waiting for the first frame…" } else { "Connecting…" }).color(t.text_dim));
});
return;
};
let fb = self.fb_size;
let size = vec2(fb[0] as f32, fb[1] as f32);
if self.scale_to_fit {
ui.allocate_rect(area, Sense::hover());
let scale = (area.width() / size.x).min(area.height() / size.y);
let img = Rect::from_center_size(area.center(), size * scale);
self.draw(ui, s, &tex, img);
} else {
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(area));
egui::ScrollArea::both().id_salt(("desktop", &self.session)).auto_shrink([false, false]).show(&mut c, |ui| {
let (img, _) = ui.allocate_exact_size(size, Sense::hover());
self.draw(ui, s, &tex, img);
});
ui.allocate_rect(area, Sense::hover());
}
}
/// Uploads the framebuffer when the client marked it dirty.
fn upload(&mut self, ui: &Ui) {
let Some(c) = &self.client else { return };
let fb = c.framebuffer();
let mut fb = fb.lock();
if !fb.dirty || fb.width == 0 || fb.height == 0 {
return;
}
let size = [fb.width as usize, fb.height as usize];
// Alpha is always 255, so the buffer is already premultiplied.
let img = egui::ColorImage::from_rgba_premultiplied(size, &fb.rgba);
fb.dirty = false;
drop(fb);
self.fb_size = size;
match &mut self.texture {
Some(t) => t.set(img, egui::TextureOptions::LINEAR),
None => self.texture = Some(ui.ctx().load_texture(format!("desktop-{}", self.session), img, egui::TextureOptions::LINEAR)),
}
}
fn toolbar(&mut self, ui: &mut Ui, s: &mut Shared) {
let t = Tokens::get(ui.ctx());
egui::Frame::new().fill(t.chrome).inner_margin(egui::Margin::symmetric(8, 5)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let (dot, status) = match (self.ready, self.name.is_empty()) {
(false, _) => (t.warning, "Connecting…".to_string()),
(true, true) => (t.success, "Connected".to_string()),
(true, false) => (t.success, format!("Connected — {}", self.name)),
};
ui.label(RichText::new("●").color(dot).size(10.0));
ui.label(RichText::new(status).font(theme::semibold(12.5)));
if self.fb_size[0] > 0 {
ui.label(RichText::new(format!("{}×{}", self.fb_size[0], self.fb_size[1])).color(t.text_faint).size(11.5));
}
if self.view_only {
widgets::chip(ui, "VIEW ONLY", t.warning).on_hover_text("Input is not sent to the remote desktop");
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_enabled_ui(self.ready, |ui| {
let (g, tip) = if self.scale_to_fit {
(icon::CORNERS_IN, "Scaled to fit (click for 1:1)")
} else {
(icon::CORNERS_OUT, "1:1 (click to scale to fit)")
};
if widgets::icon_button(ui, g, tip).clicked() {
self.scale_to_fit = !self.scale_to_fit;
}
if let Some(Remote::Vnc(c)) = &self.client
&& widgets::icon_button(ui, icon::ARROW_CLOCKWISE, "Refresh the screen").clicked()
{
let _ = c.request_full_update();
}
if !self.view_only {
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);
s.info("Clipboard sent");
}
_ => s.warn("The clipboard has no text"),
}
}
if widgets::button(ui, icon::KEYBOARD, "Ctrl+Alt+Del").clicked() {
self.tap(&[XK_CONTROL, XK_ALT, XK_DELETE]);
}
}
});
});
});
});
}
fn draw(&mut self, ui: &mut Ui, s: &mut Shared, tex: &TextureHandle, img: Rect) {
ui.painter().image(tex.id(), img, Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), egui::Color32::WHITE);
let id = egui::Id::new(("desktop-input", &self.session));
let resp = ui.interact(img, id, Sense::click_and_drag());
if resp.clicked() || resp.drag_started() || resp.secondary_clicked() || resp.middle_clicked() {
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 }));
} else if self.had_focus {
self.release_all();
}
self.had_focus = focused;
if self.view_only || self.client.is_none() {
return;
}
self.pointer(ui, &resp, img);
if focused {
self.keyboard(ui, s);
}
}
fn pointer(&mut self, ui: &Ui, resp: &egui::Response, img: Rect) {
let Some(c) = self.client.clone() else { return };
let (pos, left, middle, right, scroll) = ui.input(|i| {
let p = &i.pointer;
(p.latest_pos(), p.primary_down(), p.middle_down(), p.secondary_down(), wheel_notches(&i.events))
});
let Some(pos) = pos else { return };
// Only while hovering the image, or while a drag that began on it continues.
if !resp.hovered() && !resp.dragged() && self.buttons == 0 {
return;
}
let buttons = if resp.hovered() || resp.dragged() || self.buttons != 0 { button_mask(left, middle, right) } else { 0 };
let at = to_remote(pos, img, self.fb_size);
if at != self.last_pos || buttons != self.buttons {
c.pointer(at.0, at.1, buttons);
self.last_pos = at;
self.buttons = buttons;
}
if resp.hovered() && scroll != 0.0 {
self.wheel += scroll;
while self.wheel.abs() >= 1.0 {
let bit = if self.wheel > 0.0 { 1 << 3 } else { 1 << 4 };
c.pointer(at.0, at.1, buttons | bit);
c.pointer(at.0, at.1, buttons);
self.wheel -= self.wheel.signum();
}
}
}
fn keyboard(&mut self, ui: &Ui, s: &mut Shared) {
let (events, mods) = ui.input(|i| (i.events.clone(), i.modifiers));
self.sync_modifiers(modifier_syms(mods));
for e in events {
match e {
Event::Key { key, pressed, modifiers, .. } => {
let sym = named_keysym(key)
.or_else(|| (modifiers.ctrl || modifiers.alt || modifiers.mac_cmd).then(|| chord_keysym(key, modifiers.shift)).flatten());
if let Some(sym) = sym {
self.key(sym, pressed);
}
}
Event::Text(text) => {
for ch in text.chars() {
if let Some(sym) = keysym_for("", Some(ch)) {
self.tap(&[sym]);
}
}
}
// egui turns Ctrl+C/X/V into these; forward the keystroke (and the text for paste).
Event::Copy => self.tap(&[u32::from(b'c')]),
Event::Cut => self.tap(&[u32::from(b'x')]),
Event::Paste(text) => {
self.paste(s, &text);
self.tap(&[u32::from(b'v')]);
}
_ => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_map_to_keysyms() {
assert_eq!(named_keysym(Key::Enter), Some(0xff0d));
assert_eq!(named_keysym(Key::ArrowLeft), Some(0xff51));
assert_eq!(named_keysym(Key::F5), Some(0xffc2));
assert_eq!(named_keysym(Key::A), None);
assert_eq!(named_keysym(Key::Space), None);
assert_eq!(named_keysym(Key::Comma), None);
assert_eq!(chord_keysym(Key::A, false), Some(u32::from(b'a')));
assert_eq!(chord_keysym(Key::A, true), Some(u32::from(b'A')));
assert_eq!(chord_keysym(Key::Num1, false), Some(u32::from(b'1')));
assert_eq!(chord_keysym(Key::Space, false), Some(0x20));
assert_eq!(modifier_syms(Modifiers::CTRL | Modifiers::SHIFT), vec![XK_SHIFT, XK_CONTROL]);
}
#[test]
fn keysyms_map_to_rdp_input() {
let sc = |ext, code| format!("{:?}", Some(Operation::KeyPressed(Scancode::from_u8(ext, code))));
let k = |sym, down, chord| format!("{:?}", rdp_key(sym, down, chord));
assert_eq!(k(0xff0d, true, false), sc(false, 0x1c), "Enter");
assert_eq!(k(0xff51, true, false), sc(true, 0x4b), "Left is extended");
assert_eq!(k(0xffc9, true, false), sc(false, 0x58), "F12");
assert_eq!(k(XK_DELETE, true, false), sc(true, 0x53));
assert_eq!(k(u32::from(b'c'), true, false), format!("{:?}", Some(Operation::UnicodeKeyPressed('c'))), "typed text is Unicode");
assert_eq!(k(u32::from(b'c'), true, true), sc(false, 0x2e), "Ctrl+C sends the C scancode");
assert_eq!(k(u32::from(b'1'), false, true), format!("{:?}", Some(Operation::KeyReleased(Scancode::from_u8(false, 0x02)))));
assert_eq!(k(0x0100_20ac, true, false), format!("{:?}", Some(Operation::UnicodeKeyPressed('€'))));
}
#[test]
fn pointer_mapping() {
assert_eq!(button_mask(true, false, true), 0b101);
let img = Rect::from_min_size(Pos2::new(100.0, 50.0), vec2(400.0, 300.0));
assert_eq!(to_remote(Pos2::new(100.0, 50.0), img, [800, 600]), (0, 0));
assert_eq!(to_remote(Pos2::new(300.0, 200.0), img, [800, 600]), (400, 300));
assert_eq!(to_remote(Pos2::new(900.0, 900.0), img, [800, 600]), (799, 599));
}
}