From 7e3a1ceef4bb4c505b3d82cb3f68541cb00220ac Mon Sep 17 00:00:00 2001 From: felixg Date: Sun, 1 Mar 2026 11:12:14 +0100 Subject: [PATCH] latest stages added --- .../migration.sql | 2 + backend/prisma/schema.prisma | 1 + backend/src/routes/connections.ts | 5 + backend/src/websocket/rdp.ts | 1 + .../src/components/Modals/ConnectionModal.tsx | 12 +- .../Sidebar/ConnectionProperties.tsx | 8 + .../src/components/Sidebar/ConnectionTree.tsx | 15 ++ frontend/src/components/Tabs/RdpTab.tsx | 63 +++-- frontend/src/types/index.ts | 2 + rdpd/Dockerfile | 2 +- rdpd/src/main.rs | 8 +- rdpd/src/protocol.rs | 6 + rdpd/src/session.rs | 224 +++++++++++++++--- rdpd/src/xfreerdp.rs | 14 +- 14 files changed, 310 insertions(+), 53 deletions(-) create mode 100644 backend/prisma/migrations/20260301000000_add_clipboard_enabled/migration.sql diff --git a/backend/prisma/migrations/20260301000000_add_clipboard_enabled/migration.sql b/backend/prisma/migrations/20260301000000_add_clipboard_enabled/migration.sql new file mode 100644 index 0000000..bc1fb05 --- /dev/null +++ b/backend/prisma/migrations/20260301000000_add_clipboard_enabled/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "connections" ADD COLUMN "clipboardEnabled" BOOLEAN NOT NULL DEFAULT true; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 3108185..b27491c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -45,6 +45,7 @@ model Connection { domain String? osType String? notes String? + clipboardEnabled Boolean @default(true) folderId String? folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) userId String diff --git a/backend/src/routes/connections.ts b/backend/src/routes/connections.ts index d4346f7..c4779c1 100644 --- a/backend/src/routes/connections.ts +++ b/backend/src/routes/connections.ts @@ -17,6 +17,7 @@ interface ConnectionBody { domain?: string; osType?: string; notes?: string; + clipboardEnabled?: boolean; folderId?: string | null; } @@ -39,6 +40,7 @@ export async function connectionRoutes(fastify: FastifyInstance) { domain: true, osType: true, notes: true, + clipboardEnabled: true, folderId: true, createdAt: true, // Do NOT expose encryptedPassword or privateKey in list @@ -62,6 +64,7 @@ export async function connectionRoutes(fastify: FastifyInstance) { domain: true, osType: true, notes: true, + clipboardEnabled: true, folderId: true, privateKey: true, createdAt: true, @@ -94,6 +97,7 @@ export async function connectionRoutes(fastify: FastifyInstance) { domain: true, osType: true, notes: true, + clipboardEnabled: true, folderId: true, createdAt: true, }, @@ -131,6 +135,7 @@ export async function connectionRoutes(fastify: FastifyInstance) { domain: true, osType: true, notes: true, + clipboardEnabled: true, folderId: true, createdAt: true, }, diff --git a/backend/src/websocket/rdp.ts b/backend/src/websocket/rdp.ts index b44fc01..6be0c9b 100644 --- a/backend/src/websocket/rdp.ts +++ b/backend/src/websocket/rdp.ts @@ -76,6 +76,7 @@ export async function rdpWebsocket(fastify: FastifyInstance) { domain: conn.domain || '', width: 1280, height: 720, + clipboard: conn.clipboardEnabled !== false, }); rdpd.send(connectMsg); diff --git a/frontend/src/components/Modals/ConnectionModal.tsx b/frontend/src/components/Modals/ConnectionModal.tsx index b8ef7b9..0b96eb2 100644 --- a/frontend/src/components/Modals/ConnectionModal.tsx +++ b/frontend/src/components/Modals/ConnectionModal.tsx @@ -7,6 +7,7 @@ import { Select, Button, Space, + Switch, Divider, Row, Col, @@ -33,7 +34,7 @@ export const ConnectionModal: React.FC = ({ }) => { const [form] = Form.useForm(); const protocol = Form.useWatch('protocol', form); - const isEdit = !!connection; + const isEdit = !!connection?.id; useEffect(() => { if (open) { @@ -48,11 +49,12 @@ export const ConnectionModal: React.FC = ({ domain: connection.domain ?? undefined, osType: connection.osType ?? undefined, notes: connection.notes ?? undefined, + clipboardEnabled: connection.clipboardEnabled !== false, folderId: connection.folderId ?? null, }); } else { form.resetFields(); - form.setFieldsValue({ protocol: 'ssh', port: 22 }); + form.setFieldsValue({ protocol: 'ssh', port: 22, clipboardEnabled: true }); } } }, [open, connection, form]); @@ -166,6 +168,12 @@ export const ConnectionModal: React.FC = ({ )} + {protocol === 'rdp' && ( + + + + )} + diff --git a/frontend/src/components/Sidebar/ConnectionProperties.tsx b/frontend/src/components/Sidebar/ConnectionProperties.tsx index a15e2af..7d32a69 100644 --- a/frontend/src/components/Sidebar/ConnectionProperties.tsx +++ b/frontend/src/components/Sidebar/ConnectionProperties.tsx @@ -7,6 +7,7 @@ import { Button, Tag, Typography, + Switch, message, Divider, Row, @@ -59,6 +60,7 @@ export const ConnectionProperties: React.FC = () => { domain: connection.domain ?? undefined, osType: connection.osType ?? undefined, notes: connection.notes ?? undefined, + clipboardEnabled: connection.clipboardEnabled !== false, folderId: connection.folderId ?? null, }); } else { @@ -156,6 +158,12 @@ export const ConnectionProperties: React.FC = () => { )} + {protocol === 'rdp' && ( + + + + )} + diff --git a/frontend/src/components/Sidebar/ConnectionTree.tsx b/frontend/src/components/Sidebar/ConnectionTree.tsx index 2da4df9..80d8589 100644 --- a/frontend/src/components/Sidebar/ConnectionTree.tsx +++ b/frontend/src/components/Sidebar/ConnectionTree.tsx @@ -20,6 +20,7 @@ import { MoreOutlined, EditOutlined, DeleteOutlined, + CopyOutlined, FolderAddOutlined, SearchOutlined, } from '@ant-design/icons'; @@ -233,6 +234,20 @@ export const ConnectionTree: React.FC = () => { setModalOpen(true); }, }, + { + key: 'duplicate', + icon: , + label: 'Duplicate', + onClick: () => { + const conn = node.itemData.connection!; + setEditingConnection({ + ...conn, + id: '', // empty id → modal treats it as create + name: `${conn.name} (Copy)`, + }); + setModalOpen(true); + }, + }, { type: 'divider' }, { key: 'delete', diff --git a/frontend/src/components/Tabs/RdpTab.tsx b/frontend/src/components/Tabs/RdpTab.tsx index c685c28..6604eaf 100644 --- a/frontend/src/components/Tabs/RdpTab.tsx +++ b/frontend/src/components/Tabs/RdpTab.tsx @@ -250,6 +250,30 @@ export const RdpTab: React.FC = ({ session }) => { // --- Keyboard event handlers --- const onKeyDown = (e: KeyboardEvent) => { + // Ctrl+V / Cmd+V: read local clipboard → send to remote → then forward keystroke + if ((e.ctrlKey || e.metaKey) && e.code === 'KeyV') { + e.preventDefault(); + e.stopPropagation(); + navigator.clipboard.readText().then((text) => { + if (text) { + sendJson({ type: 'clipboardWrite', text }); + } + // Forward Ctrl+V keystrokes after clipboard is set + sendJson({ type: 'keyDown', keySym: e.ctrlKey ? 0xffe3 : 0xffeb }); // Ctrl/Meta + sendJson({ type: 'keyDown', keySym: 0x76 }); // v + sendJson({ type: 'keyUp', keySym: 0x76 }); + sendJson({ type: 'keyUp', keySym: e.ctrlKey ? 0xffe3 : 0xffeb }); + }).catch(() => { + // Clipboard access denied — just forward the keystroke + sendJson({ type: 'keyDown', keySym: 0xffe3 }); + sendJson({ type: 'keyDown', keySym: 0x76 }); + sendJson({ type: 'keyUp', keySym: 0x76 }); + sendJson({ type: 'keyUp', keySym: 0xffe3 }); + }); + return; + } + + // Ctrl+C / Cmd+C: forward to remote, clipboard sync handled by rdpd monitor e.preventDefault(); e.stopPropagation(); const keySym = getKeySym(e); @@ -259,6 +283,12 @@ export const RdpTab: React.FC = ({ session }) => { }; const onKeyUp = (e: KeyboardEvent) => { + // Skip keyup for Ctrl+V since we sent synthetic keystrokes above + if ((e.ctrlKey || e.metaKey) && e.code === 'KeyV') { + e.preventDefault(); + e.stopPropagation(); + return; + } e.preventDefault(); e.stopPropagation(); const keySym = getKeySym(e); @@ -270,18 +300,24 @@ export const RdpTab: React.FC = ({ session }) => { canvas.addEventListener('keydown', onKeyDown); canvas.addEventListener('keyup', onKeyUp); - // --- Clipboard: paste handler --- - const onPaste = (e: ClipboardEvent) => { - const text = e.clipboardData?.getData('text'); - if (text) { - sendJson({ type: 'clipboardWrite', text }); - } - }; - canvas.addEventListener('paste', onPaste); - - // --- Resize observer to keep scale in sync --- + // --- Resize observer: update scale + send dynamic resize to rdpd --- + let resizeTimer: ReturnType | null = null; const resizeObserver = new ResizeObserver(() => { updateScale(); + + // Debounce resize messages (300ms) to avoid spamming during drag + if (resizeTimer) clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + const w = container.clientWidth; + const h = container.clientHeight; + if (w > 0 && h > 0 && ws.readyState === WebSocket.OPEN) { + // Use device pixel ratio for crisp rendering on HiDPI + const dpr = window.devicePixelRatio || 1; + const rw = Math.round(w * dpr); + const rh = Math.round(h * dpr); + ws.send(JSON.stringify({ type: 'resize', width: rw, height: rh })); + } + }, 300); }); resizeObserver.observe(container); @@ -293,8 +329,8 @@ export const RdpTab: React.FC = ({ session }) => { canvas.removeEventListener('contextmenu', onContextMenu); canvas.removeEventListener('keydown', onKeyDown); canvas.removeEventListener('keyup', onKeyUp); - canvas.removeEventListener('paste', onPaste); resizeObserver.disconnect(); + if (resizeTimer) clearTimeout(resizeTimer); ws.close(); wsRef.current = null; }; @@ -318,9 +354,8 @@ export const RdpTab: React.FC = ({ session }) => { ref={canvasRef} tabIndex={0} style={{ - maxWidth: '100%', - maxHeight: '100%', - objectFit: 'contain', + width: '100%', + height: '100%', outline: 'none', cursor: 'default', }} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4f8994f..7a0e217 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -21,6 +21,7 @@ export interface Connection { domain?: string | null; osType?: string | null; notes?: string | null; + clipboardEnabled?: boolean; folderId?: string | null; privateKey?: string | null; createdAt: string; @@ -37,6 +38,7 @@ export interface ConnectionFormValues { domain?: string; osType?: string; notes?: string; + clipboardEnabled?: boolean; folderId?: string | null; } diff --git a/rdpd/Dockerfile b/rdpd/Dockerfile index df6e34c..72a9c5c 100644 --- a/rdpd/Dockerfile +++ b/rdpd/Dockerfile @@ -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 \ diff --git a/rdpd/src/main.rs b/rdpd/src/main.rs index b593f35..b977f79 100644 --- a/rdpd/src/main.rs +++ b/rdpd/src/main.rs @@ -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); diff --git a/rdpd/src/protocol.rs b/rdpd/src/protocol.rs index f88254d..a033eff 100644 --- a/rdpd/src/protocol.rs +++ b/rdpd/src/protocol.rs @@ -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) diff --git a/rdpd/src/session.rs b/rdpd/src/session.rs index 6aac4ac..05d2585 100644 --- a/rdpd/src/session.rs +++ b/rdpd/src/session.rs @@ -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::(&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> { 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 { .. } => { diff --git a/rdpd/src/xfreerdp.rs b/rdpd/src/xfreerdp.rs index 51dd109..69b13a8 100644 --- a/rdpd/src/xfreerdp.rs +++ b/rdpd/src/xfreerdp.rs @@ -16,6 +16,7 @@ pub async fn spawn_xfreerdp( width: u16, height: u16, security: &str, + clipboard: bool, ) -> std::io::Result { 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());