feat: RDP dynamic resize on window resize (MobaXTerm-style)
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m59s
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:
parent
1c70eb3248
commit
09c2f1a1ff
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@ -2991,6 +2991,7 @@ checksum = "47c225751e8fbfaaaac5572a80e25d0a0921e9cf408c55509526161b5609157c"
|
||||
dependencies = [
|
||||
"ironrdp-connector",
|
||||
"ironrdp-core",
|
||||
"ironrdp-displaycontrol",
|
||||
"ironrdp-graphics",
|
||||
"ironrdp-input",
|
||||
"ironrdp-pdu",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user