feat: RDP dynamic resize on window resize (MobaXTerm-style)
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m59s

When the Wraith window is resized, the RDP session now resizes to fill
the entire canvas area — matching MobaXTerm behavior.

Implementation:
- Enabled ironrdp displaycontrol feature for Display Control Virtual Channel
- Added Resize input event to RDP session thread
- ActiveStage::encode_resize() sends monitor layout PDU to server
- Server re-renders at new resolution and sends updated frames
- Frontend ResizeObserver on canvas wrapper, debounced 500ms
- Canvas CSS changed from max-width/max-height to width/height: 100%
- Mouse coordinate mapping uses canvas.width/height (actual resolution)
  instead of initial rdpWidth/rdpHeight
- Image and front buffer reallocated on resize to match new dimensions
- Width constrained to even numbers per RDP spec, min 200px

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-30 10:44:43 -04:00
parent 1c70eb3248
commit 09c2f1a1ff
6 changed files with 83 additions and 8 deletions

1
src-tauri/Cargo.lock generated
View File

@ -2991,6 +2991,7 @@ checksum = "47c225751e8fbfaaaac5572a80e25d0a0921e9cf408c55509526161b5609157c"
dependencies = [
"ironrdp-connector",
"ironrdp-core",
"ironrdp-displaycontrol",
"ironrdp-graphics",
"ironrdp-input",
"ironrdp-pdu",

View File

@ -65,7 +65,7 @@ ureq = "3"
png = "0.17"
# RDP (IronRDP)
ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input"] }
ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input", "displaycontrol"] }
ironrdp-tokio = { version = "0.8", features = ["reqwest-rustls-ring"] }
ironrdp-tls = { version = "0.2", features = ["rustls"] }
tokio-rustls = "0.26"

View File

@ -101,6 +101,19 @@ pub fn rdp_send_clipboard(
state.rdp.send_clipboard(&session_id, &text)
}
/// Resize the RDP session's desktop resolution.
/// Sends a Display Control Virtual Channel request to the server.
/// The server will re-render at the new resolution and send updated frames.
#[tauri::command]
pub fn rdp_resize(
session_id: String,
width: u16,
height: u16,
state: State<'_, AppState>,
) -> Result<(), String> {
state.rdp.resize(&session_id, width, height)
}
/// Disconnect an RDP session.
///
/// Sends a graceful shutdown to the RDP server and removes the session.

View File

@ -224,7 +224,7 @@ pub fn run() {
commands::credentials::list_credentials, commands::credentials::create_password, commands::credentials::create_ssh_key, commands::credentials::delete_credential, commands::credentials::decrypt_password, commands::credentials::decrypt_ssh_key,
commands::ssh_commands::connect_ssh, commands::ssh_commands::connect_ssh_with_key, commands::ssh_commands::ssh_write, commands::ssh_commands::ssh_resize, commands::ssh_commands::disconnect_ssh, commands::ssh_commands::disconnect_session, commands::ssh_commands::list_ssh_sessions,
commands::sftp_commands::sftp_list, commands::sftp_commands::sftp_read_file, commands::sftp_commands::sftp_write_file, commands::sftp_commands::sftp_mkdir, commands::sftp_commands::sftp_delete, commands::sftp_commands::sftp_rename,
commands::rdp_commands::connect_rdp, commands::rdp_commands::rdp_get_frame, commands::rdp_commands::rdp_send_mouse, commands::rdp_commands::rdp_send_key, commands::rdp_commands::rdp_send_clipboard, commands::rdp_commands::disconnect_rdp, commands::rdp_commands::list_rdp_sessions,
commands::rdp_commands::connect_rdp, commands::rdp_commands::rdp_get_frame, commands::rdp_commands::rdp_send_mouse, commands::rdp_commands::rdp_send_key, commands::rdp_commands::rdp_send_clipboard, commands::rdp_commands::rdp_resize, commands::rdp_commands::disconnect_rdp, commands::rdp_commands::list_rdp_sessions,
commands::theme_commands::list_themes, commands::theme_commands::get_theme,
commands::pty_commands::list_available_shells, commands::pty_commands::spawn_local_shell, commands::pty_commands::pty_write, commands::pty_commands::pty_resize, commands::pty_commands::disconnect_pty,
commands::mcp_commands::mcp_list_sessions, commands::mcp_commands::mcp_terminal_read, commands::mcp_commands::mcp_terminal_execute, commands::mcp_commands::mcp_get_session_context, commands::mcp_commands::mcp_bridge_path,

View File

@ -63,6 +63,7 @@ enum InputEvent {
pressed: bool,
},
Clipboard(String),
Resize { width: u16, height: u16 },
Disconnect,
}
@ -299,6 +300,11 @@ impl RdpService {
handle.input_tx.send(InputEvent::Key { scancode, pressed }).map_err(|_| format!("RDP session {} input channel closed", session_id))
}
pub fn resize(&self, session_id: &str, width: u16, height: u16) -> Result<(), String> {
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
handle.input_tx.send(InputEvent::Resize { width, height }).map_err(|_| format!("RDP session {} input channel closed", session_id))
}
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
let _ = handle.input_tx.send(InputEvent::Disconnect);
@ -386,7 +392,7 @@ async fn establish_connection(config: connector::Config, hostname: &str, port: u
Ok((connection_result, upgraded_framed))
}
async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, front_buffer: Arc<std::sync::RwLock<Vec<u8>>>, dirty_region: Arc<std::sync::Mutex<Option<DirtyRect>>>, frame_dirty: Arc<AtomicBool>, mut input_rx: mpsc::UnboundedReceiver<InputEvent>, width: u16, height: u16, app_handle: tauri::AppHandle, session_id: String) -> Result<(), String> {
async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, front_buffer: Arc<std::sync::RwLock<Vec<u8>>>, dirty_region: Arc<std::sync::Mutex<Option<DirtyRect>>>, frame_dirty: Arc<AtomicBool>, mut input_rx: mpsc::UnboundedReceiver<InputEvent>, mut width: u16, mut height: u16, app_handle: tauri::AppHandle, session_id: String) -> Result<(), String> {
let (mut reader, mut writer) = split_tokio_framed(framed);
let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height);
let mut active_stage = ActiveStage::new(connection_result);
@ -438,6 +444,24 @@ async fn run_active_session(connection_result: ConnectionResult, framed: Upgrade
}
all_outputs
}
Some(InputEvent::Resize { width: new_w, height: new_h }) => {
// Ensure dimensions are within RDP spec (200-8192, even width)
let w = (new_w.max(200).min(8192) & !1) as u32;
let h = new_h.max(200).min(8192) as u32;
if let Some(Ok(resize_frame)) = active_stage.encode_resize(w, h, None, None) {
writer.write_all(&resize_frame).await.map_err(|e| format!("Failed to send resize: {}", e))?;
// Reallocate image and front buffer for new dimensions
image = DecodedImage::new(PixelFormat::RgbA32, w as u16, h as u16);
let buf_size = w as usize * h as usize * 4;
let mut new_buf = vec![0u8; buf_size];
for pixel in new_buf.chunks_exact_mut(4) { pixel[3] = 255; }
*front_buffer.write().unwrap_or_else(|e| e.into_inner()) = new_buf;
width = w as u16;
height = h as u16;
info!("RDP session {} resized to {}x{}", session_id, width, height);
}
Vec::new()
}
}
}
};

View File

@ -29,6 +29,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useRdp, MouseFlag } from "@/composables/useRdp";
const props = defineProps<{
@ -76,8 +77,8 @@ function toRdpCoords(e: MouseEvent): { x: number; y: number } | null {
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const scaleX = rdpWidth.value / rect.width;
const scaleY = rdpHeight.value / rect.height;
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: Math.floor((e.clientX - rect.left) * scaleX),
@ -153,14 +154,51 @@ function handleKeyUp(e: KeyboardEvent): void {
sendKey(props.sessionId, e.code, false);
}
let resizeObserver: ResizeObserver | null = null;
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
onMounted(() => {
if (canvasRef.value) {
startFrameLoop(props.sessionId, canvasRef.value, rdpWidth.value, rdpHeight.value);
}
// Watch container size and request server-side RDP resize (debounced 500ms)
if (canvasWrapper.value) {
resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry || !connected.value) return;
const { width: cw, height: ch } = entry.contentRect;
if (cw < 200 || ch < 200) return;
// Round to even width (RDP spec requirement)
const newW = Math.round(cw) & ~1;
const newH = Math.round(ch);
if (resizeTimeout) clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
invoke("rdp_resize", {
sessionId: props.sessionId,
width: newW,
height: newH,
}).then(() => {
// Update canvas dimensions to match new RDP resolution
if (canvasRef.value) {
canvasRef.value.width = newW;
canvasRef.value.height = newH;
}
}).catch((err: unknown) => {
console.warn("[RdpView] resize failed:", err);
});
}, 500);
});
resizeObserver.observe(canvasWrapper.value);
}
});
onBeforeUnmount(() => {
stopFrameLoop();
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; }
if (resizeTimeout) { clearTimeout(resizeTimeout); resizeTimeout = null; }
});
// Focus canvas when this tab becomes active and keyboard is grabbed
@ -196,9 +234,8 @@ watch(
}
.rdp-canvas {
max-width: 100%;
max-height: 100%;
object-fit: contain;
width: 100%;
height: 100%;
cursor: default;
outline: none;
image-rendering: auto;