All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m44s
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>
322 lines
8.1 KiB
Vue
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>
|