wraith/src/components/rdp/RdpView.vue
Vantz Stockwell 04c140f608
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m44s
fix: RDP canvas re-measures container on tab switch
When switching from SSH back to RDP, the canvas retained the resolution
from when the copilot panel was open — even after closing the panel.
The ResizeObserver doesn't fire while the tab is hidden (v-show/display),
so the container size change goes unnoticed.

Fix: On tab activation, double-rAF waits for layout, measures the
container via getBoundingClientRect, compares with canvas.width/height,
and sends rdp_resize if they differ. This ensures the RDP session
always matches the current available space.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:58:57 -04:00

322 lines
8.1 KiB
Vue

<template>
<div class="rdp-container" ref="containerRef">
<!-- Canvas -->
<div class="rdp-canvas-wrapper" ref="canvasWrapper">
<canvas
ref="canvasRef"
class="rdp-canvas"
tabindex="0"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
@mousemove="handleMouseMove"
@wheel.prevent="handleWheel"
@contextmenu.prevent
@keydown.prevent="handleKeyDown"
@keyup.prevent="handleKeyUp"
/>
</div>
<!-- Connection status overlay visible until first frame arrives -->
<div v-if="!connected" class="rdp-overlay">
<div class="rdp-overlay-content">
<div class="rdp-spinner" />
<p>Connecting to RDP session...</p>
<p class="rdp-overlay-sub">Session: {{ sessionId }}</p>
</div>
</div>
</div>
</template>
<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<{
sessionId: string;
isActive: boolean;
width?: number;
height?: number;
}>();
const containerRef = ref<HTMLElement | null>(null);
const canvasWrapper = ref<HTMLElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const rdpWidth = computed(() => props.width ?? 1920);
const rdpHeight = computed(() => props.height ?? 1080);
const {
connected,
keyboardGrabbed,
clipboardSync,
sendMouse,
sendKey,
startFrameLoop,
stopFrameLoop,
toggleKeyboardGrab,
toggleClipboardSync,
} = useRdp();
// Expose toolbar state and controls so RdpToolbar can be a sibling component
// driven by the same composable instance via the parent (SessionContainer).
defineExpose({
keyboardGrabbed,
clipboardSync,
toggleKeyboardGrab,
toggleClipboardSync,
canvasWrapper,
});
/**
* Convert canvas-relative mouse coordinates to RDP coordinates,
* accounting for CSS scaling of the canvas element.
*/
function toRdpCoords(e: MouseEvent): { x: number; y: number } | null {
const canvas = canvasRef.value;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: Math.floor((e.clientX - rect.left) * scaleX),
y: Math.floor((e.clientY - rect.top) * scaleY),
};
}
function handleMouseDown(e: MouseEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
let buttonFlag = 0;
switch (e.button) {
case 0:
buttonFlag = MouseFlag.Button1;
break;
case 1:
buttonFlag = MouseFlag.Button3;
break; // middle
case 2:
buttonFlag = MouseFlag.Button2;
break;
}
sendMouse(props.sessionId, coords.x, coords.y, buttonFlag | MouseFlag.Down);
}
function handleMouseUp(e: MouseEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
let buttonFlag = 0;
switch (e.button) {
case 0:
buttonFlag = MouseFlag.Button1;
break;
case 1:
buttonFlag = MouseFlag.Button3;
break;
case 2:
buttonFlag = MouseFlag.Button2;
break;
}
sendMouse(props.sessionId, coords.x, coords.y, buttonFlag);
}
function handleMouseMove(e: MouseEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
sendMouse(props.sessionId, coords.x, coords.y, MouseFlag.Move);
}
function handleWheel(e: WheelEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
let flags = MouseFlag.Wheel;
if (e.deltaY > 0) {
flags |= MouseFlag.WheelNeg;
}
sendMouse(props.sessionId, coords.x, coords.y, flags);
}
function handleKeyDown(e: KeyboardEvent): void {
if (!keyboardGrabbed.value) return;
sendKey(props.sessionId, e.code, true);
}
function handleKeyUp(e: KeyboardEvent): void {
if (!keyboardGrabbed.value) return;
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(() => {
if (canvasRef.value) {
canvasRef.value.width = newW;
canvasRef.value.height = newH;
}
// Force full frame after resize so canvas gets a clean repaint
setTimeout(() => {
invoke("rdp_force_refresh", { sessionId: props.sessionId }).catch(() => {});
}, 200);
}).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, re-check dimensions, and force full frame on tab switch
watch(
() => props.isActive,
(active) => {
if (!active || !canvasRef.value) return;
// Wait for layout to settle after tab becomes visible
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const wrapper = canvasWrapper.value;
const canvas = canvasRef.value;
if (!wrapper || !canvas) return;
const { width: cw, height: ch } = wrapper.getBoundingClientRect();
const newW = Math.round(cw) & ~1;
const newH = Math.round(ch);
// If container size differs from canvas resolution, resize the RDP session
if (newW >= 200 && newH >= 200 && (newW !== canvas.width || newH !== canvas.height)) {
invoke("rdp_resize", {
sessionId: props.sessionId,
width: newW,
height: newH,
}).then(() => {
canvas.width = newW;
canvas.height = newH;
setTimeout(() => {
invoke("rdp_force_refresh", { sessionId: props.sessionId }).catch(() => {});
}, 200);
}).catch(() => {});
} else {
// Same size — just refresh the frame
invoke("rdp_force_refresh", { sessionId: props.sessionId }).catch(() => {});
}
if (keyboardGrabbed.value) canvas.focus();
});
});
},
);
</script>
<style scoped>
.rdp-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: var(--wraith-bg-primary, #0d1117);
position: relative;
}
.rdp-canvas-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
min-height: 0;
}
.rdp-canvas {
width: 100%;
height: 100%;
cursor: default;
outline: none;
image-rendering: auto;
}
.rdp-canvas:focus {
outline: 2px solid var(--wraith-accent, #58a6ff);
outline-offset: -2px;
}
.rdp-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(13, 17, 23, 0.85);
z-index: 10;
}
.rdp-overlay-content {
text-align: center;
color: var(--wraith-text-secondary, #8b949e);
}
.rdp-overlay-content p {
margin: 8px 0;
font-size: 14px;
}
.rdp-overlay-sub {
font-size: 12px !important;
color: var(--wraith-text-muted, #484f58);
}
.rdp-spinner {
width: 32px;
height: 32px;
margin: 0 auto 16px;
border: 3px solid var(--wraith-border, #30363d);
border-top-color: var(--wraith-accent, #58a6ff);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>