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 = [
|
dependencies = [
|
||||||
"ironrdp-connector",
|
"ironrdp-connector",
|
||||||
"ironrdp-core",
|
"ironrdp-core",
|
||||||
|
"ironrdp-displaycontrol",
|
||||||
"ironrdp-graphics",
|
"ironrdp-graphics",
|
||||||
"ironrdp-input",
|
"ironrdp-input",
|
||||||
"ironrdp-pdu",
|
"ironrdp-pdu",
|
||||||
|
|||||||
@ -65,7 +65,7 @@ ureq = "3"
|
|||||||
png = "0.17"
|
png = "0.17"
|
||||||
|
|
||||||
# RDP (IronRDP)
|
# 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-tokio = { version = "0.8", features = ["reqwest-rustls-ring"] }
|
||||||
ironrdp-tls = { version = "0.2", features = ["rustls"] }
|
ironrdp-tls = { version = "0.2", features = ["rustls"] }
|
||||||
tokio-rustls = "0.26"
|
tokio-rustls = "0.26"
|
||||||
|
|||||||
@ -101,6 +101,19 @@ pub fn rdp_send_clipboard(
|
|||||||
state.rdp.send_clipboard(&session_id, &text)
|
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.
|
/// Disconnect an RDP session.
|
||||||
///
|
///
|
||||||
/// Sends a graceful shutdown to the RDP server and removes the 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::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::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::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::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::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,
|
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,
|
pressed: bool,
|
||||||
},
|
},
|
||||||
Clipboard(String),
|
Clipboard(String),
|
||||||
|
Resize { width: u16, height: u16 },
|
||||||
Disconnect,
|
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))
|
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> {
|
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 = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||||
let _ = handle.input_tx.send(InputEvent::Disconnect);
|
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))
|
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 reader, mut writer) = split_tokio_framed(framed);
|
||||||
let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height);
|
let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height);
|
||||||
let mut active_stage = ActiveStage::new(connection_result);
|
let mut active_stage = ActiveStage::new(connection_result);
|
||||||
@ -438,6 +444,24 @@ async fn run_active_session(connection_result: ConnectionResult, framed: Upgrade
|
|||||||
}
|
}
|
||||||
all_outputs
|
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">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useRdp, MouseFlag } from "@/composables/useRdp";
|
import { useRdp, MouseFlag } from "@/composables/useRdp";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -76,8 +77,8 @@ function toRdpCoords(e: MouseEvent): { x: number; y: number } | null {
|
|||||||
if (!canvas) return null;
|
if (!canvas) return null;
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const scaleX = rdpWidth.value / rect.width;
|
const scaleX = canvas.width / rect.width;
|
||||||
const scaleY = rdpHeight.value / rect.height;
|
const scaleY = canvas.height / rect.height;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: Math.floor((e.clientX - rect.left) * scaleX),
|
x: Math.floor((e.clientX - rect.left) * scaleX),
|
||||||
@ -153,14 +154,51 @@ function handleKeyUp(e: KeyboardEvent): void {
|
|||||||
sendKey(props.sessionId, e.code, false);
|
sendKey(props.sessionId, e.code, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (canvasRef.value) {
|
if (canvasRef.value) {
|
||||||
startFrameLoop(props.sessionId, canvasRef.value, rdpWidth.value, rdpHeight.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(() => {
|
onBeforeUnmount(() => {
|
||||||
stopFrameLoop();
|
stopFrameLoop();
|
||||||
|
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; }
|
||||||
|
if (resizeTimeout) { clearTimeout(resizeTimeout); resizeTimeout = null; }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Focus canvas when this tab becomes active and keyboard is grabbed
|
// Focus canvas when this tab becomes active and keyboard is grabbed
|
||||||
@ -196,9 +234,8 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rdp-canvas {
|
.rdp-canvas {
|
||||||
max-width: 100%;
|
width: 100%;
|
||||||
max-height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
outline: none;
|
outline: none;
|
||||||
image-rendering: auto;
|
image-rendering: auto;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user