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?
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 }]}>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 { .. } => {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
Reference in New Issue
Block a user