latest stages added

This commit is contained in:
felixg
2026-03-01 11:12:14 +01:00
parent 6e9956bce9
commit 7e3a1ceef4
14 changed files with 310 additions and 53 deletions

View File

@@ -35,7 +35,7 @@ FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
xvfb \
tigervnc-standalone-server \
xdotool \
xclip \
x11-utils \

View File

@@ -86,6 +86,7 @@ async fn handle_connection(
width,
height,
security,
clipboard,
}) => {
info!(
peer = %peer,
@@ -95,9 +96,10 @@ async fn handle_connection(
width,
height,
security = %security,
clipboard,
"connect request received"
);
(host, port, username, password, domain, width, height, security)
(host, port, username, password, domain, width, height, security, clipboard)
}
Ok(_) => {
warn!(peer = %peer, "first message must be a connect request");
@@ -136,14 +138,14 @@ async fn handle_connection(
}
};
let (host, port, username, password, domain, width, height, security) = connect_msg;
let (host, port, username, password, domain, width, height, security, clipboard) = connect_msg;
// Allocate a display number and register the session
let display_num = manager.allocate_display();
manager.register(display_num, host.clone(), username.clone());
// Run the session — this blocks until the session ends
Session::run(display_num, ws, host, port, username, password, domain, width, height, security).await;
Session::run(display_num, ws, host, port, username, password, domain, width, height, security, clipboard).await;
// Cleanup
manager.unregister(display_num);

View File

@@ -23,6 +23,9 @@ pub enum ClientMessage {
/// RDP security mode: "tls", "nla", "rdp", or "any". Defaults to "tls".
#[serde(default = "default_security")]
security: String,
/// Whether clipboard sharing is enabled. Defaults to true.
#[serde(default = "default_clipboard")]
clipboard: bool,
},
#[serde(rename = "mouseMove")]
MouseMove { x: i32, y: i32 },
@@ -51,6 +54,9 @@ fn default_height() -> u16 {
fn default_security() -> String {
"nla".to_string()
}
fn default_clipboard() -> bool {
true
}
// ---------------------------------------------------------------------------
// Daemon → Client messages (JSON text frames)

View File

@@ -6,13 +6,13 @@ use crate::xfreerdp;
use futures_util::stream::SplitSink;
use futures_util::{SinkExt, StreamExt};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::WebSocketStream;
use tracing::{error, info, warn};
use tracing::{debug, error, info, warn};
/// Frame capture rate — ~15 fps.
const FRAME_INTERVAL_MS: u64 = 66;
@@ -39,6 +39,7 @@ impl Session {
width: u16,
height: u16,
security: String,
clipboard: bool,
) {
let (ws_sink, mut ws_stream) = ws.split();
let sink: WsSink = Arc::new(Mutex::new(ws_sink));
@@ -49,18 +50,22 @@ impl Session {
let _ = s.send(Message::Text(msg.to_json())).await;
};
// 1. Start Xvfb
let screen = format!("{}x{}x24", width, height);
// 1. Start Xvnc (TigerVNC) — provides full RandR support for dynamic resize
let geometry = format!("{}x{}", width, height);
let display_arg = format!(":{}", display_num);
let rfb_port = format!("{}", 5900 + display_num); // VNC port (unused but required)
info!(display_num, %screen, "starting Xvfb");
let xvfb_result = tokio::process::Command::new("Xvfb")
info!(display_num, %geometry, "starting Xvnc");
let xvfb_result = tokio::process::Command::new("Xvnc")
.args([
&display_arg,
"-screen", "0", &screen,
"-ac", // disable access control
"-nolisten", "tcp",
"+extension", "XTEST",
"-geometry", &geometry,
"-depth", "24",
"-SecurityTypes", "None",
"-rfbport", &rfb_port,
"-ac", // disable access control
"-NeverShared",
"-DisconnectClients=0",
])
.stdin(Stdio::null())
.stdout(Stdio::null())
@@ -70,36 +75,36 @@ impl Session {
let mut xvfb = match xvfb_result {
Ok(child) => child,
Err(e) => {
error!(err = %e, "failed to start Xvfb");
error!(err = %e, "failed to start Xvnc");
send_msg(sink.clone(), ServerMessage::Error {
message: format!("Failed to start Xvfb: {}", e),
message: format!("Failed to start Xvnc: {}", e),
}).await;
return;
}
};
// Give Xvfb a moment to initialize
// Give Xvnc a moment to initialize
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Check Xvfb is still running
// Check Xvnc is still running
match xvfb.try_wait() {
Ok(Some(status)) => {
error!(?status, "Xvfb exited prematurely");
error!(?status, "Xvnc exited prematurely");
send_msg(sink.clone(), ServerMessage::Error {
message: format!("Xvfb exited with status: {}", status),
message: format!("Xvnc exited with status: {}", status),
}).await;
return;
}
Ok(None) => { /* still running, good */ }
Err(e) => {
error!(err = %e, "failed to check Xvfb status");
error!(err = %e, "failed to check Xvnc status");
}
}
// 2. Start xfreerdp3
info!(display_num, host = %host, port, "starting xfreerdp3");
let xfreerdp_result = xfreerdp::spawn_xfreerdp(
display_num, &host, port, &username, &password, &domain, width, height, &security,
display_num, &host, port, &username, &password, &domain, width, height, &security, clipboard,
).await;
let mut xfreerdp = match xfreerdp_result {
@@ -189,6 +194,11 @@ impl Session {
let frame_shutdown_rx = frame_shutdown.clone();
let capture_display = display_num;
// Shared resize state: packs (width << 16 | height) into AtomicU32.
// Value of 0 means no pending resize.
let pending_resize = Arc::new(AtomicU32::new(0));
let pending_resize_rx = pending_resize.clone();
let capture_handle = tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Handle::current();
@@ -198,6 +208,17 @@ impl Session {
break;
}
// Check for pending resize
let packed = pending_resize_rx.swap(0, Ordering::Relaxed);
if packed != 0 {
let new_w = (packed >> 16) as u16;
let new_h = (packed & 0xFFFF) as u16;
if new_w != capture.width() || new_h != capture.height() {
info!(width = new_w, height = new_h, display = capture_display, "applying resize");
capture.set_dimensions(new_w, new_h);
}
}
match capture.capture_frame(JPEG_QUALITY) {
Ok(Some(jpeg_bytes)) => {
let sink = frame_sink.clone();
@@ -212,6 +233,14 @@ impl Session {
// Frame unchanged, skip
}
Err(e) => {
let err_str = e.to_string();
// Match errors occur during resize when dimensions are briefly
// out of sync with the actual screen size — just skip the frame.
if err_str.contains("Match") {
warn!(display = capture_display, "frame capture size mismatch (resize in progress), skipping");
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
warn!(err = %e, display = capture_display, "frame capture error");
break;
}
@@ -221,16 +250,65 @@ impl Session {
}
});
// 5. Process incoming WebSocket messages (input events)
// 5. Spawn clipboard monitor (only if clipboard is enabled)
let clipboard_handle = if clipboard {
let clip_sink = sink.clone();
let clip_shutdown = frame_shutdown.clone();
let clip_display = display_num;
Some(tokio::spawn(async move {
let display_str = format!(":{}", clip_display);
let mut prev_hash: u64 = 0;
loop {
if clip_shutdown.load(Ordering::Relaxed) {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Read the X clipboard via xclip
let output = match tokio::process::Command::new("xclip")
.env("DISPLAY", &display_str)
.args(["-selection", "clipboard", "-o"])
.output()
.await
{
Ok(o) if o.status.success() => o.stdout,
_ => continue,
};
// Hash to detect changes (avoid sending identical content)
let hash = xxhash_rust::xxh3::xxh3_64(&output);
if hash == prev_hash || output.is_empty() {
continue;
}
prev_hash = hash;
if let Ok(text) = String::from_utf8(output) {
debug!(len = text.len(), display = clip_display, "remote clipboard changed");
let mut s = clip_sink.lock().await;
let _ = s.send(Message::Text(
ServerMessage::ClipboardRead { text }.to_json()
)).await;
}
}
}))
} else {
None
};
// 6. Process incoming WebSocket messages (input events)
let input_ref = input.clone();
let ws_display = display_num;
let resize_ref = pending_resize.clone();
while let Some(msg_result) = ws_stream.next().await {
match msg_result {
Ok(Message::Text(text)) => {
match serde_json::from_str::<ClientMessage>(&text) {
Ok(client_msg) => {
if let Err(e) = handle_input(&input_ref, ws_display, client_msg) {
if let Err(e) = handle_input(&input_ref, ws_display, client_msg, &resize_ref, clipboard).await {
warn!(err = %e, "input injection error");
}
}
@@ -255,12 +333,16 @@ impl Session {
}
}
// 6. Cleanup
// 7. Cleanup
info!(display_num, "session ending, cleaning up");
// Signal capture loop to stop
// Signal capture + clipboard loops to stop
frame_shutdown.store(true, Ordering::Relaxed);
let _ = capture_handle.await;
if let Some(handle) = clipboard_handle {
handle.abort();
let _ = handle.await;
}
// Send disconnected message
{
@@ -278,10 +360,12 @@ impl Session {
}
#[allow(non_snake_case)]
fn handle_input(
async fn handle_input(
input: &InputInjector,
display_num: u32,
msg: ClientMessage,
pending_resize: &AtomicU32,
clipboard_enabled: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match msg {
ClientMessage::MouseMove { x, y } => input.mouse_move(x, y),
@@ -290,13 +374,95 @@ fn handle_input(
ClientMessage::MouseScroll { delta, x, y } => input.mouse_scroll(delta, x, y),
ClientMessage::KeyDown { keySym } => input.key_down(keySym),
ClientMessage::KeyUp { keySym } => input.key_up(keySym),
ClientMessage::ClipboardWrite { text } => input.set_clipboard(display_num, &text),
ClientMessage::ClipboardWrite { text } => {
if clipboard_enabled {
input.set_clipboard(display_num, &text)
} else {
Ok(())
}
}
ClientMessage::Resize { width, height } => {
// TODO: implement dynamic resolution change via xrandr:
// 1. xrandr --output default --mode {width}x{height} (on the Xvfb display)
// 2. Send /size:{width}x{height} to xfreerdp3 via its control pipe
// 3. Update capture dimensions
warn!(width, height, "resize requested but not yet implemented");
// Clamp to reasonable bounds
let w = width.clamp(320, 7680);
let h = height.clamp(200, 4320);
let display_str = format!(":{}", display_num);
let mode_name = format!("{}x{}", w, h);
info!(width = w, height = h, display = display_num, "resize requested");
// Signal the capture loop to update dimensions FIRST, so it doesn't
// try to capture at the old (larger) size after the screen shrinks.
pending_resize.store(((w as u32) << 16) | (h as u32), Ordering::Relaxed);
// Xvnc supports full RandR. Add a new mode (dummy timings work fine)
// and switch to it. Errors from --newmode/--addmode are ignored since
// the mode may already exist from a previous resize.
let _ = tokio::process::Command::new("xrandr")
.env("DISPLAY", &display_str)
.args([
"--newmode", &mode_name,
"0", // dummy clock
&w.to_string(), &w.to_string(), &w.to_string(), &w.to_string(),
&h.to_string(), &h.to_string(), &h.to_string(), &h.to_string(),
])
.output()
.await;
let _ = tokio::process::Command::new("xrandr")
.env("DISPLAY", &display_str)
.args(["--addmode", "VNC-0", &mode_name])
.output()
.await;
let resize_result = tokio::process::Command::new("xrandr")
.env("DISPLAY", &display_str)
.args(["--output", "VNC-0", "--mode", &mode_name])
.output()
.await?;
if resize_result.status.success() {
info!(width = w, height = h, display = display_num, "xrandr resize succeeded");
// Resize the xfreerdp3 window to fill the new screen.
// xfreerdp3 with /dynamic-resolution detects its window resize
// and sends a Display Control Channel update to the RDP server.
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// Find the xfreerdp window and resize it
let search_result = tokio::process::Command::new("xdotool")
.env("DISPLAY", &display_str)
.args(["search", "--onlyvisible", "--name", ""])
.output()
.await;
if let Ok(output) = search_result {
let wids = String::from_utf8_lossy(&output.stdout);
for wid in wids.lines() {
let wid = wid.trim();
if wid.is_empty() { continue; }
let _ = tokio::process::Command::new("xdotool")
.env("DISPLAY", &display_str)
.args([
"windowmove", "--sync", wid, "0", "0",
])
.output()
.await;
let _ = tokio::process::Command::new("xdotool")
.env("DISPLAY", &display_str)
.args([
"windowsize", "--sync", wid,
&w.to_string(), &h.to_string(),
])
.output()
.await;
}
}
} else {
let stderr = String::from_utf8_lossy(&resize_result.stderr);
warn!(width = w, height = h, stderr = %stderr, "xrandr resize failed");
}
Ok(())
}
ClientMessage::Connect { .. } => {

View File

@@ -16,6 +16,7 @@ pub async fn spawn_xfreerdp(
width: u16,
height: u16,
security: &str,
clipboard: bool,
) -> std::io::Result<Child> {
let display_str = format!(":{}", display_num);
let geometry = format!("{}x{}", width, height);
@@ -41,6 +42,9 @@ pub async fn spawn_xfreerdp(
// Accept all certificates for lab/internal use
cmd.arg("/cert:ignore");
// Dynamic resolution — allows us to resize via xrandr
cmd.arg("/dynamic-resolution");
// Disable features we don't need
cmd.arg("-decorations");
cmd.arg("-wallpaper");
@@ -49,12 +53,14 @@ pub async fn spawn_xfreerdp(
cmd.arg("-sound");
cmd.arg("-microphone");
// Clipboard redirection — we handle it via our own protocol
// TODO: implement clipboard channel via xclip/xsel monitoring
cmd.arg("+clipboard");
// Clipboard redirection
if clipboard {
cmd.arg("+clipboard");
} else {
cmd.arg("-clipboard");
}
// TODO: audio forwarding — could use /sound:sys:pulse with a per-session PulseAudio sink
// TODO: dynamic resolution change — /dynamic-resolution flag + xrandr in the Xvfb
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());