All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m20s
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>
253 lines
5.5 KiB
Vue
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>
|