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

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "connections" ADD COLUMN "clipboardEnabled" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -45,6 +45,7 @@ model Connection {
domain String? domain String?
osType String? osType String?
notes String? notes String?
clipboardEnabled Boolean @default(true)
folderId String? folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
userId String userId String

View File

@@ -17,6 +17,7 @@ interface ConnectionBody {
domain?: string; domain?: string;
osType?: string; osType?: string;
notes?: string; notes?: string;
clipboardEnabled?: boolean;
folderId?: string | null; folderId?: string | null;
} }
@@ -39,6 +40,7 @@ export async function connectionRoutes(fastify: FastifyInstance) {
domain: true, domain: true,
osType: true, osType: true,
notes: true, notes: true,
clipboardEnabled: true,
folderId: true, folderId: true,
createdAt: true, createdAt: true,
// Do NOT expose encryptedPassword or privateKey in list // Do NOT expose encryptedPassword or privateKey in list
@@ -62,6 +64,7 @@ export async function connectionRoutes(fastify: FastifyInstance) {
domain: true, domain: true,
osType: true, osType: true,
notes: true, notes: true,
clipboardEnabled: true,
folderId: true, folderId: true,
privateKey: true, privateKey: true,
createdAt: true, createdAt: true,
@@ -94,6 +97,7 @@ export async function connectionRoutes(fastify: FastifyInstance) {
domain: true, domain: true,
osType: true, osType: true,
notes: true, notes: true,
clipboardEnabled: true,
folderId: true, folderId: true,
createdAt: true, createdAt: true,
}, },
@@ -131,6 +135,7 @@ export async function connectionRoutes(fastify: FastifyInstance) {
domain: true, domain: true,
osType: true, osType: true,
notes: true, notes: true,
clipboardEnabled: true,
folderId: true, folderId: true,
createdAt: true, createdAt: true,
}, },

View File

@@ -76,6 +76,7 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
domain: conn.domain || '', domain: conn.domain || '',
width: 1280, width: 1280,
height: 720, height: 720,
clipboard: conn.clipboardEnabled !== false,
}); });
rdpd.send(connectMsg); rdpd.send(connectMsg);

View File

@@ -7,6 +7,7 @@ import {
Select, Select,
Button, Button,
Space, Space,
Switch,
Divider, Divider,
Row, Row,
Col, Col,
@@ -33,7 +34,7 @@ export const ConnectionModal: React.FC<Props> = ({
}) => { }) => {
const [form] = Form.useForm<ConnectionFormValues>(); const [form] = Form.useForm<ConnectionFormValues>();
const protocol = Form.useWatch('protocol', form); const protocol = Form.useWatch('protocol', form);
const isEdit = !!connection; const isEdit = !!connection?.id;
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -48,11 +49,12 @@ export const ConnectionModal: React.FC<Props> = ({
domain: connection.domain ?? undefined, domain: connection.domain ?? undefined,
osType: connection.osType ?? undefined, osType: connection.osType ?? undefined,
notes: connection.notes ?? undefined, notes: connection.notes ?? undefined,
clipboardEnabled: connection.clipboardEnabled !== false,
folderId: connection.folderId ?? null, folderId: connection.folderId ?? null,
}); });
} else { } else {
form.resetFields(); form.resetFields();
form.setFieldsValue({ protocol: 'ssh', port: 22 }); form.setFieldsValue({ protocol: 'ssh', port: 22, clipboardEnabled: true });
} }
} }
}, [open, connection, form]); }, [open, connection, form]);
@@ -166,6 +168,12 @@ export const ConnectionModal: React.FC<Props> = ({
</Form.Item> </Form.Item>
)} )}
{protocol === 'rdp' && (
<Form.Item label="Clipboard" name="clipboardEnabled" valuePropName="checked">
<Switch checkedChildren="Enabled" unCheckedChildren="Disabled" />
</Form.Item>
)}
<Divider style={{ margin: '8px 0' }} /> <Divider style={{ margin: '8px 0' }} />
<Form.Item label="OS Type" name="osType"> <Form.Item label="OS Type" name="osType">

View File

@@ -7,6 +7,7 @@ import {
Button, Button,
Tag, Tag,
Typography, Typography,
Switch,
message, message,
Divider, Divider,
Row, Row,
@@ -59,6 +60,7 @@ export const ConnectionProperties: React.FC = () => {
domain: connection.domain ?? undefined, domain: connection.domain ?? undefined,
osType: connection.osType ?? undefined, osType: connection.osType ?? undefined,
notes: connection.notes ?? undefined, notes: connection.notes ?? undefined,
clipboardEnabled: connection.clipboardEnabled !== false,
folderId: connection.folderId ?? null, folderId: connection.folderId ?? null,
}); });
} else { } else {
@@ -156,6 +158,12 @@ export const ConnectionProperties: React.FC = () => {
</Form.Item> </Form.Item>
)} )}
{protocol === 'rdp' && (
<Form.Item label="Clipboard" name="clipboardEnabled" valuePropName="checked">
<Switch checkedChildren="On" unCheckedChildren="Off" />
</Form.Item>
)}
<Divider style={{ margin: '4px 0 8px' }} /> <Divider style={{ margin: '4px 0 8px' }} />
<Form.Item label="Name" name="name" rules={[{ required: true }]}> <Form.Item label="Name" name="name" rules={[{ required: true }]}>

View File

@@ -20,6 +20,7 @@ import {
MoreOutlined, MoreOutlined,
EditOutlined, EditOutlined,
DeleteOutlined, DeleteOutlined,
CopyOutlined,
FolderAddOutlined, FolderAddOutlined,
SearchOutlined, SearchOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -233,6 +234,20 @@ export const ConnectionTree: React.FC = () => {
setModalOpen(true); setModalOpen(true);
}, },
}, },
{
key: 'duplicate',
icon: <CopyOutlined />,
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' }, { type: 'divider' },
{ {
key: 'delete', key: 'delete',

View File

@@ -250,6 +250,30 @@ export const RdpTab: React.FC<Props> = ({ session }) => {
// --- Keyboard event handlers --- // --- Keyboard event handlers ---
const onKeyDown = (e: KeyboardEvent) => { 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
const keySym = getKeySym(e); const keySym = getKeySym(e);
@@ -259,6 +283,12 @@ export const RdpTab: React.FC<Props> = ({ session }) => {
}; };
const onKeyUp = (e: KeyboardEvent) => { 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
const keySym = getKeySym(e); const keySym = getKeySym(e);
@@ -270,18 +300,24 @@ export const RdpTab: React.FC<Props> = ({ session }) => {
canvas.addEventListener('keydown', onKeyDown); canvas.addEventListener('keydown', onKeyDown);
canvas.addEventListener('keyup', onKeyUp); canvas.addEventListener('keyup', onKeyUp);
// --- Clipboard: paste handler --- // --- Resize observer: update scale + send dynamic resize to rdpd ---
const onPaste = (e: ClipboardEvent) => { let resizeTimer: ReturnType<typeof setTimeout> | null = null;
const text = e.clipboardData?.getData('text');
if (text) {
sendJson({ type: 'clipboardWrite', text });
}
};
canvas.addEventListener('paste', onPaste);
// --- Resize observer to keep scale in sync ---
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
updateScale(); 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); resizeObserver.observe(container);
@@ -293,8 +329,8 @@ export const RdpTab: React.FC<Props> = ({ session }) => {
canvas.removeEventListener('contextmenu', onContextMenu); canvas.removeEventListener('contextmenu', onContextMenu);
canvas.removeEventListener('keydown', onKeyDown); canvas.removeEventListener('keydown', onKeyDown);
canvas.removeEventListener('keyup', onKeyUp); canvas.removeEventListener('keyup', onKeyUp);
canvas.removeEventListener('paste', onPaste);
resizeObserver.disconnect(); resizeObserver.disconnect();
if (resizeTimer) clearTimeout(resizeTimer);
ws.close(); ws.close();
wsRef.current = null; wsRef.current = null;
}; };
@@ -318,9 +354,8 @@ export const RdpTab: React.FC<Props> = ({ session }) => {
ref={canvasRef} ref={canvasRef}
tabIndex={0} tabIndex={0}
style={{ style={{
maxWidth: '100%', width: '100%',
maxHeight: '100%', height: '100%',
objectFit: 'contain',
outline: 'none', outline: 'none',
cursor: 'default', cursor: 'default',
}} }}

View File

@@ -21,6 +21,7 @@ export interface Connection {
domain?: string | null; domain?: string | null;
osType?: string | null; osType?: string | null;
notes?: string | null; notes?: string | null;
clipboardEnabled?: boolean;
folderId?: string | null; folderId?: string | null;
privateKey?: string | null; privateKey?: string | null;
createdAt: string; createdAt: string;
@@ -37,6 +38,7 @@ export interface ConnectionFormValues {
domain?: string; domain?: string;
osType?: string; osType?: string;
notes?: string; notes?: string;
clipboardEnabled?: boolean;
folderId?: string | null; folderId?: string | null;
} }

View File

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

View File

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

View File

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

View File

@@ -6,13 +6,13 @@ use crate::xfreerdp;
use futures_util::stream::SplitSink; use futures_util::stream::SplitSink;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use std::process::Stdio; use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc; use std::sync::Arc;
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::WebSocketStream;
use tracing::{error, info, warn}; use tracing::{debug, error, info, warn};
/// Frame capture rate — ~15 fps. /// Frame capture rate — ~15 fps.
const FRAME_INTERVAL_MS: u64 = 66; const FRAME_INTERVAL_MS: u64 = 66;
@@ -39,6 +39,7 @@ impl Session {
width: u16, width: u16,
height: u16, height: u16,
security: String, security: String,
clipboard: bool,
) { ) {
let (ws_sink, mut ws_stream) = ws.split(); let (ws_sink, mut ws_stream) = ws.split();
let sink: WsSink = Arc::new(Mutex::new(ws_sink)); let sink: WsSink = Arc::new(Mutex::new(ws_sink));
@@ -49,18 +50,22 @@ impl Session {
let _ = s.send(Message::Text(msg.to_json())).await; let _ = s.send(Message::Text(msg.to_json())).await;
}; };
// 1. Start Xvfb // 1. Start Xvnc (TigerVNC) — provides full RandR support for dynamic resize
let screen = format!("{}x{}x24", width, height); let geometry = format!("{}x{}", width, height);
let display_arg = format!(":{}", display_num); let display_arg = format!(":{}", display_num);
let rfb_port = format!("{}", 5900 + display_num); // VNC port (unused but required)
info!(display_num, %screen, "starting Xvfb"); info!(display_num, %geometry, "starting Xvnc");
let xvfb_result = tokio::process::Command::new("Xvfb") let xvfb_result = tokio::process::Command::new("Xvnc")
.args([ .args([
&display_arg, &display_arg,
"-screen", "0", &screen, "-geometry", &geometry,
"-ac", // disable access control "-depth", "24",
"-nolisten", "tcp", "-SecurityTypes", "None",
"+extension", "XTEST", "-rfbport", &rfb_port,
"-ac", // disable access control
"-NeverShared",
"-DisconnectClients=0",
]) ])
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
@@ -70,36 +75,36 @@ impl Session {
let mut xvfb = match xvfb_result { let mut xvfb = match xvfb_result {
Ok(child) => child, Ok(child) => child,
Err(e) => { Err(e) => {
error!(err = %e, "failed to start Xvfb"); error!(err = %e, "failed to start Xvnc");
send_msg(sink.clone(), ServerMessage::Error { send_msg(sink.clone(), ServerMessage::Error {
message: format!("Failed to start Xvfb: {}", e), message: format!("Failed to start Xvnc: {}", e),
}).await; }).await;
return; return;
} }
}; };
// Give Xvfb a moment to initialize // Give Xvnc a moment to initialize
tokio::time::sleep(std::time::Duration::from_millis(500)).await; tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Check Xvfb is still running // Check Xvnc is still running
match xvfb.try_wait() { match xvfb.try_wait() {
Ok(Some(status)) => { Ok(Some(status)) => {
error!(?status, "Xvfb exited prematurely"); error!(?status, "Xvnc exited prematurely");
send_msg(sink.clone(), ServerMessage::Error { send_msg(sink.clone(), ServerMessage::Error {
message: format!("Xvfb exited with status: {}", status), message: format!("Xvnc exited with status: {}", status),
}).await; }).await;
return; return;
} }
Ok(None) => { /* still running, good */ } Ok(None) => { /* still running, good */ }
Err(e) => { Err(e) => {
error!(err = %e, "failed to check Xvfb status"); error!(err = %e, "failed to check Xvnc status");
} }
} }
// 2. Start xfreerdp3 // 2. Start xfreerdp3
info!(display_num, host = %host, port, "starting xfreerdp3"); info!(display_num, host = %host, port, "starting xfreerdp3");
let xfreerdp_result = xfreerdp::spawn_xfreerdp( 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; ).await;
let mut xfreerdp = match xfreerdp_result { let mut xfreerdp = match xfreerdp_result {
@@ -189,6 +194,11 @@ impl Session {
let frame_shutdown_rx = frame_shutdown.clone(); let frame_shutdown_rx = frame_shutdown.clone();
let capture_display = display_num; 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 capture_handle = tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Handle::current(); let rt = tokio::runtime::Handle::current();
@@ -198,6 +208,17 @@ impl Session {
break; 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) { match capture.capture_frame(JPEG_QUALITY) {
Ok(Some(jpeg_bytes)) => { Ok(Some(jpeg_bytes)) => {
let sink = frame_sink.clone(); let sink = frame_sink.clone();
@@ -212,6 +233,14 @@ impl Session {
// Frame unchanged, skip // Frame unchanged, skip
} }
Err(e) => { 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"); warn!(err = %e, display = capture_display, "frame capture error");
break; 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 input_ref = input.clone();
let ws_display = display_num; let ws_display = display_num;
let resize_ref = pending_resize.clone();
while let Some(msg_result) = ws_stream.next().await { while let Some(msg_result) = ws_stream.next().await {
match msg_result { match msg_result {
Ok(Message::Text(text)) => { Ok(Message::Text(text)) => {
match serde_json::from_str::<ClientMessage>(&text) { match serde_json::from_str::<ClientMessage>(&text) {
Ok(client_msg) => { 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"); warn!(err = %e, "input injection error");
} }
} }
@@ -255,12 +333,16 @@ impl Session {
} }
} }
// 6. Cleanup // 7. Cleanup
info!(display_num, "session ending, cleaning up"); info!(display_num, "session ending, cleaning up");
// Signal capture loop to stop // Signal capture + clipboard loops to stop
frame_shutdown.store(true, Ordering::Relaxed); frame_shutdown.store(true, Ordering::Relaxed);
let _ = capture_handle.await; let _ = capture_handle.await;
if let Some(handle) = clipboard_handle {
handle.abort();
let _ = handle.await;
}
// Send disconnected message // Send disconnected message
{ {
@@ -278,10 +360,12 @@ impl Session {
} }
#[allow(non_snake_case)] #[allow(non_snake_case)]
fn handle_input( async fn handle_input(
input: &InputInjector, input: &InputInjector,
display_num: u32, display_num: u32,
msg: ClientMessage, msg: ClientMessage,
pending_resize: &AtomicU32,
clipboard_enabled: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match msg { match msg {
ClientMessage::MouseMove { x, y } => input.mouse_move(x, y), 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::MouseScroll { delta, x, y } => input.mouse_scroll(delta, x, y),
ClientMessage::KeyDown { keySym } => input.key_down(keySym), ClientMessage::KeyDown { keySym } => input.key_down(keySym),
ClientMessage::KeyUp { keySym } => input.key_up(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 } => { ClientMessage::Resize { width, height } => {
// TODO: implement dynamic resolution change via xrandr: // Clamp to reasonable bounds
// 1. xrandr --output default --mode {width}x{height} (on the Xvfb display) let w = width.clamp(320, 7680);
// 2. Send /size:{width}x{height} to xfreerdp3 via its control pipe let h = height.clamp(200, 4320);
// 3. Update capture dimensions
warn!(width, height, "resize requested but not yet implemented"); 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(()) Ok(())
} }
ClientMessage::Connect { .. } => { ClientMessage::Connect { .. } => {

View File

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