Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Has been cancelled
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>
383 lines
8.9 KiB
Vue
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>
|