wraith/frontend/src/components/rdp/RdpView.vue
Vantz Stockwell 8a096d7f7b
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Has been cancelled
Wraith v0.1.0 — Desktop SSH + RDP + SFTP Client
Go + Wails v3 + Vue 3 + SQLite + FreeRDP3 (purego)
183 tests, 76 source files, 9,910 lines of code

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

383 lines
8.9 KiB
Vue

<template>
<div class="rdp-container" ref="containerRef">
<!-- Toolbar -->
<div class="rdp-toolbar">
<div class="rdp-toolbar-left">
<span class="rdp-toolbar-label">RDP</span>
<span class="rdp-toolbar-session">{{ sessionId }}</span>
</div>
<div class="rdp-toolbar-right">
<button
class="rdp-toolbar-btn"
:class="{ active: keyboardGrabbed }"
title="Toggle keyboard capture"
@click="handleToggleKeyboard"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M1 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4zm1 0v7h12V4H2z"/>
<path d="M3 6h2v1H3V6zm3 0h2v1H6V6zm3 0h2v1H9V6zm3 0h1v1h-1V6zM3 8h1v1H3V8zm2 0h6v1H5V8zm7 0h1v1h-1V8z"/>
</svg>
</button>
<button
class="rdp-toolbar-btn"
:class="{ active: clipboardSync }"
title="Toggle clipboard sync"
@click="handleToggleClipboard"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3z"/>
</svg>
</button>
<button
class="rdp-toolbar-btn"
title="Fullscreen"
@click="handleFullscreen"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1h-4zM10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zM.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5z"/>
</svg>
</button>
</div>
</div>
<!-- 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 -->
<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, 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 = props.width ?? 1920;
const rdpHeight = props.height ?? 1080;
const {
connected,
keyboardGrabbed,
clipboardSync,
sendMouse,
sendKey,
startFrameLoop,
stopFrameLoop,
toggleKeyboardGrab,
toggleClipboardSync,
} = useRdp();
/**
* Convert canvas-relative mouse coordinates to RDP coordinates,
* accounting for CSS scaling of the canvas.
*/
function toRdpCoords(
e: MouseEvent,
): { x: number; y: number } | null {
const canvas = canvasRef.value;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const scaleX = rdpWidth / rect.width;
const scaleY = rdpHeight / 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);
}
function handleToggleKeyboard(): void {
toggleKeyboardGrab();
// Focus canvas when grabbing keyboard
if (keyboardGrabbed.value && canvasRef.value) {
canvasRef.value.focus();
}
}
function handleToggleClipboard(): void {
toggleClipboardSync();
}
function handleFullscreen(): void {
const wrapper = canvasWrapper.value;
if (!wrapper) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
wrapper.requestFullscreen();
}
}
onMounted(() => {
if (canvasRef.value) {
startFrameLoop(
props.sessionId,
canvasRef.value,
rdpWidth,
rdpHeight,
);
}
});
onBeforeUnmount(() => {
stopFrameLoop();
});
// Focus canvas when this tab becomes active
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-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 8px;
background: var(--wraith-bg-secondary, #161b22);
border-bottom: 1px solid var(--wraith-border, #30363d);
flex-shrink: 0;
}
.rdp-toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.rdp-toolbar-label {
font-size: 11px;
font-weight: 600;
color: var(--wraith-accent, #58a6ff);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.rdp-toolbar-session {
font-size: 11px;
color: var(--wraith-text-muted, #484f58);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rdp-toolbar-right {
display: flex;
align-items: center;
gap: 2px;
}
.rdp-toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--wraith-text-secondary, #8b949e);
cursor: pointer;
transition: all 0.15s ease;
}
.rdp-toolbar-btn:hover {
background: var(--wraith-bg-tertiary, #21262d);
color: var(--wraith-text-primary, #e6edf3);
}
.rdp-toolbar-btn.active {
background: var(--wraith-accent, #58a6ff);
color: #ffffff;
}
.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>