wraith/src/components/rdp/RdpView.vue
Vantz Stockwell a2770d3edf
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m20s
perf: double-buffered RDP frames + frontend rAF throttling
Root cause: RDP was unresponsive due to frame pipeline bottleneck.
- get_frame() held tokio::Mutex while cloning 8.3MB, blocking the
  RDP session thread from writing new frames (mutex contention)
- Frontend fetched on every backend event with no coalescing
- Every GraphicsUpdate emitted an IPC event, flooding the frontend

Fix:
- Double-buffer: back_buffer (tokio::Mutex, write path) and
  front_buffer (std::sync::RwLock, read path) — reads never block writes
- get_frame() now synchronous, reads from front_buffer via RwLock
- Backend throttles frame events to every other GraphicsUpdate
- Frontend coalesces events via requestAnimationFrame
- RdpView props now reactive (computed) for correct resize behavior
- rdp_get_frame command no longer async (no .await needed)
- screenshot_png_base64 no longer async

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

253 lines
5.5 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 { 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 = rdpWidth.value / rect.width;
const scaleY = rdpHeight.value / 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);
}
onMounted(() => {
if (canvasRef.value) {
startFrameLoop(props.sessionId, canvasRef.value, rdpWidth.value, rdpHeight.value);
}
});
onBeforeUnmount(() => {
stopFrameLoop();
});
// Focus canvas when this tab becomes active and keyboard is grabbed
watch(
() => props.isActive,
(active) => {
if (active && keyboardGrabbed.value && canvasRef.value) {
setTimeout(() => {
canvasRef.value?.focus();
}, 0);
}
},
);
</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 {
max-width: 100%;
max-height: 100%;
object-fit: contain;
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>