latest stages added
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "connections" ADD COLUMN "clipboardEnabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -76,6 +76,7 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
|
||||
domain: conn.domain || '',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
clipboard: conn.clipboardEnabled !== false,
|
||||
});
|
||||
|
||||
rdpd.send(connectMsg);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }]}>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 { .. } => {
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user