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?
osType String?
notes String?
clipboardEnabled Boolean @default(true)
folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
userId String

View File

@@ -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,
},

View File

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

View File

@@ -7,6 +7,7 @@ import {
Select,
Button,
Space,
Switch,
Divider,
Row,
Col,
@@ -33,7 +34,7 @@ export const ConnectionModal: React.FC<Props> = ({
}) => {
const [form] = Form.useForm<ConnectionFormValues>();
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<Props> = ({
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<Props> = ({
</Form.Item>
)}
{protocol === 'rdp' && (
<Form.Item label="Clipboard" name="clipboardEnabled" valuePropName="checked">
<Switch checkedChildren="Enabled" unCheckedChildren="Disabled" />
</Form.Item>
)}
<Divider style={{ margin: '8px 0' }} />
<Form.Item label="OS Type" name="osType">

View File

@@ -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 = () => {
</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' }} />
<Form.Item label="Name" name="name" rules={[{ required: true }]}>

View File

@@ -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: <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' },
{
key: 'delete',

View File

@@ -250,6 +250,30 @@ export const RdpTab: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<typeof setTimeout> | 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<Props> = ({ 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<Props> = ({ session }) => {
ref={canvasRef}
tabIndex={0}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
width: '100%',
height: '100%',
outline: 'none',
cursor: 'default',
}}

View File

@@ -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;
}

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());