Add remote desktop viewer
- Create viewer.html with canvas-based video display - Implement protobuf parsing for VideoFrame/RawFrame - Add zstd decompression using fzstd library - Convert BGRA to RGBA for canvas rendering - Add mouse event capture and encoding - Add keyboard event capture and encoding - Add Ctrl+Alt+Del special key support - Add fullscreen toggle - Update dashboard to open viewer in new window - Auto-reconnect on connection loss 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -630,8 +630,9 @@
|
||||
}
|
||||
|
||||
function joinSession(sessionId) {
|
||||
// TODO: Open viewer in new window
|
||||
alert("Viewer not yet implemented.\\n\\nSession ID: " + sessionId + "\\n\\nWebSocket: wss://connect.azcomputerguru.com/ws/viewer?session_id=" + sessionId);
|
||||
// Open viewer in new window
|
||||
const viewerUrl = "/viewer.html?session_id=" + sessionId;
|
||||
window.open(viewerUrl, "viewer_" + sessionId, "width=1280,height=800,menubar=no,toolbar=no,location=no,status=no");
|
||||
}
|
||||
|
||||
// Initial load and auto-refresh every 3 seconds
|
||||
@@ -716,8 +717,9 @@
|
||||
}
|
||||
|
||||
function connectToMachine(sessionId) {
|
||||
// TODO: Open viewer in new window
|
||||
alert("Viewer not yet implemented.\\n\\nSession ID: " + sessionId + "\\n\\nWebSocket: wss://connect.azcomputerguru.com/ws/viewer?session_id=" + sessionId);
|
||||
// Open viewer in new window
|
||||
const viewerUrl = "/viewer.html?session_id=" + sessionId;
|
||||
window.open(viewerUrl, "viewer_" + sessionId, "width=1280,height=800,menubar=no,toolbar=no,location=no,status=no");
|
||||
}
|
||||
|
||||
async function disconnectMachine(sessionId, machineName) {
|
||||
|
||||
684
server/static/viewer.html
Normal file
684
server/static/viewer.html
Normal file
@@ -0,0 +1,684 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GuruConnect Viewer</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/fzstd@0.1.1/umd/index.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
background: #16213e;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
background: #0f3460;
|
||||
color: #eee;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
background: #1a4a7a;
|
||||
}
|
||||
|
||||
.toolbar button.danger {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
.toolbar button.danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.toolbar .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbar .status {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.toolbar .status.connected {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.toolbar .status.connecting {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.toolbar .status.error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#viewer-canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overlay-content {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.overlay-content .spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #333;
|
||||
border-top-color: #4caf50;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats span {
|
||||
color: #aaa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<button class="danger" onclick="disconnect()">Disconnect</button>
|
||||
<button onclick="toggleFullscreen()">Fullscreen</button>
|
||||
<button onclick="sendCtrlAltDel()">Ctrl+Alt+Del</button>
|
||||
<div class="spacer"></div>
|
||||
<div class="stats">
|
||||
<div>FPS: <span id="fps">0</span></div>
|
||||
<div>Resolution: <span id="resolution">-</span></div>
|
||||
<div>Frames: <span id="frame-count">0</span></div>
|
||||
</div>
|
||||
<div class="status connecting" id="status">Connecting...</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-container" id="canvas-container">
|
||||
<canvas id="viewer-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="overlay" id="overlay">
|
||||
<div class="overlay-content">
|
||||
<div class="spinner"></div>
|
||||
<div id="overlay-text">Connecting to remote desktop...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Get session ID from URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const sessionId = urlParams.get('session_id');
|
||||
|
||||
if (!sessionId) {
|
||||
alert('No session ID provided');
|
||||
window.close();
|
||||
}
|
||||
|
||||
// State
|
||||
let ws = null;
|
||||
let canvas = document.getElementById('viewer-canvas');
|
||||
let ctx = canvas.getContext('2d');
|
||||
let imageData = null;
|
||||
let frameCount = 0;
|
||||
let lastFpsTime = Date.now();
|
||||
let fpsFrames = 0;
|
||||
let remoteWidth = 0;
|
||||
let remoteHeight = 0;
|
||||
|
||||
// ============================================================
|
||||
// Protobuf Parsing Utilities
|
||||
// ============================================================
|
||||
|
||||
function parseVarint(buffer, offset) {
|
||||
let result = 0;
|
||||
let shift = 0;
|
||||
while (offset < buffer.length) {
|
||||
const byte = buffer[offset++];
|
||||
result |= (byte & 0x7f) << shift;
|
||||
if ((byte & 0x80) === 0) break;
|
||||
shift += 7;
|
||||
}
|
||||
return { value: result, offset };
|
||||
}
|
||||
|
||||
function parseSignedVarint(buffer, offset) {
|
||||
const { value, offset: newOffset } = parseVarint(buffer, offset);
|
||||
// ZigZag decode
|
||||
return { value: (value >>> 1) ^ -(value & 1), offset: newOffset };
|
||||
}
|
||||
|
||||
function parseField(buffer, offset) {
|
||||
if (offset >= buffer.length) return null;
|
||||
const { value: tag, offset: newOffset } = parseVarint(buffer, offset);
|
||||
const fieldNumber = tag >>> 3;
|
||||
const wireType = tag & 0x7;
|
||||
return { fieldNumber, wireType, offset: newOffset };
|
||||
}
|
||||
|
||||
function skipField(buffer, offset, wireType) {
|
||||
switch (wireType) {
|
||||
case 0: // Varint
|
||||
while (offset < buffer.length && (buffer[offset++] & 0x80)) {}
|
||||
return offset;
|
||||
case 1: // 64-bit
|
||||
return offset + 8;
|
||||
case 2: // Length-delimited
|
||||
const { value: len, offset: newOffset } = parseVarint(buffer, offset);
|
||||
return newOffset + len;
|
||||
case 5: // 32-bit
|
||||
return offset + 4;
|
||||
default:
|
||||
throw new Error(`Unknown wire type: ${wireType}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseLengthDelimited(buffer, offset) {
|
||||
const { value: len, offset: dataStart } = parseVarint(buffer, offset);
|
||||
const data = buffer.slice(dataStart, dataStart + len);
|
||||
return { data, offset: dataStart + len };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// VideoFrame Parsing
|
||||
// ============================================================
|
||||
|
||||
function parseVideoFrame(data) {
|
||||
const buffer = new Uint8Array(data);
|
||||
let offset = 0;
|
||||
|
||||
// Parse Message wrapper
|
||||
let videoFrameData = null;
|
||||
|
||||
while (offset < buffer.length) {
|
||||
const field = parseField(buffer, offset);
|
||||
if (!field) break;
|
||||
offset = field.offset;
|
||||
|
||||
if (field.fieldNumber === 10 && field.wireType === 2) {
|
||||
// video_frame field
|
||||
const { data: vfData, offset: newOffset } = parseLengthDelimited(buffer, offset);
|
||||
videoFrameData = vfData;
|
||||
offset = newOffset;
|
||||
} else {
|
||||
offset = skipField(buffer, offset, field.wireType);
|
||||
}
|
||||
}
|
||||
|
||||
if (!videoFrameData) return null;
|
||||
|
||||
// Parse VideoFrame
|
||||
let rawFrameData = null;
|
||||
offset = 0;
|
||||
|
||||
while (offset < videoFrameData.length) {
|
||||
const field = parseField(videoFrameData, offset);
|
||||
if (!field) break;
|
||||
offset = field.offset;
|
||||
|
||||
if (field.fieldNumber === 10 && field.wireType === 2) {
|
||||
// raw frame (oneof encoding = 10)
|
||||
const { data: rfData, offset: newOffset } = parseLengthDelimited(videoFrameData, offset);
|
||||
rawFrameData = rfData;
|
||||
offset = newOffset;
|
||||
} else {
|
||||
offset = skipField(videoFrameData, offset, field.wireType);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawFrameData) return null;
|
||||
|
||||
// Parse RawFrame
|
||||
let width = 0, height = 0, compressedData = null, isKeyframe = true;
|
||||
offset = 0;
|
||||
|
||||
while (offset < rawFrameData.length) {
|
||||
const field = parseField(rawFrameData, offset);
|
||||
if (!field) break;
|
||||
offset = field.offset;
|
||||
|
||||
switch (field.fieldNumber) {
|
||||
case 1: // width
|
||||
const w = parseVarint(rawFrameData, offset);
|
||||
width = w.value;
|
||||
offset = w.offset;
|
||||
break;
|
||||
case 2: // height
|
||||
const h = parseVarint(rawFrameData, offset);
|
||||
height = h.value;
|
||||
offset = h.offset;
|
||||
break;
|
||||
case 3: // data (compressed BGRA)
|
||||
const d = parseLengthDelimited(rawFrameData, offset);
|
||||
compressedData = d.data;
|
||||
offset = d.offset;
|
||||
break;
|
||||
case 6: // is_keyframe
|
||||
const k = parseVarint(rawFrameData, offset);
|
||||
isKeyframe = k.value !== 0;
|
||||
offset = k.offset;
|
||||
break;
|
||||
default:
|
||||
offset = skipField(rawFrameData, offset, field.wireType);
|
||||
}
|
||||
}
|
||||
|
||||
return { width, height, compressedData, isKeyframe };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Frame Rendering
|
||||
// ============================================================
|
||||
|
||||
function renderFrame(frame) {
|
||||
if (!frame || !frame.compressedData || frame.width === 0 || frame.height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Decompress using fzstd
|
||||
const decompressed = fzstd.decompress(frame.compressedData);
|
||||
|
||||
// Resize canvas if needed
|
||||
if (canvas.width !== frame.width || canvas.height !== frame.height) {
|
||||
canvas.width = frame.width;
|
||||
canvas.height = frame.height;
|
||||
remoteWidth = frame.width;
|
||||
remoteHeight = frame.height;
|
||||
imageData = ctx.createImageData(frame.width, frame.height);
|
||||
document.getElementById('resolution').textContent = `${frame.width}x${frame.height}`;
|
||||
}
|
||||
|
||||
// Convert BGRA to RGBA
|
||||
const pixels = imageData.data;
|
||||
for (let i = 0; i < decompressed.length; i += 4) {
|
||||
pixels[i] = decompressed[i + 2]; // R <- B
|
||||
pixels[i + 1] = decompressed[i + 1]; // G
|
||||
pixels[i + 2] = decompressed[i]; // B <- R
|
||||
pixels[i + 3] = 255; // A (force opaque)
|
||||
}
|
||||
|
||||
// Draw to canvas
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Update stats
|
||||
frameCount++;
|
||||
fpsFrames++;
|
||||
document.getElementById('frame-count').textContent = frameCount;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastFpsTime >= 1000) {
|
||||
document.getElementById('fps').textContent = fpsFrames;
|
||||
fpsFrames = 0;
|
||||
lastFpsTime = now;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Frame render error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Input Event Encoding
|
||||
// ============================================================
|
||||
|
||||
function encodeVarint(value) {
|
||||
const bytes = [];
|
||||
while (value > 0x7f) {
|
||||
bytes.push((value & 0x7f) | 0x80);
|
||||
value >>>= 7;
|
||||
}
|
||||
bytes.push(value & 0x7f);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function encodeSignedVarint(value) {
|
||||
// ZigZag encode
|
||||
const zigzag = (value << 1) ^ (value >> 31);
|
||||
return encodeVarint(zigzag >>> 0);
|
||||
}
|
||||
|
||||
function encodeMouseEvent(x, y, buttons, eventType, wheelDeltaX = 0, wheelDeltaY = 0) {
|
||||
// Build MouseEvent message
|
||||
const mouseEvent = [];
|
||||
|
||||
// Field 1: x (varint)
|
||||
mouseEvent.push(0x08); // field 1, wire type 0
|
||||
mouseEvent.push(...encodeVarint(Math.round(x)));
|
||||
|
||||
// Field 2: y (varint)
|
||||
mouseEvent.push(0x10); // field 2, wire type 0
|
||||
mouseEvent.push(...encodeVarint(Math.round(y)));
|
||||
|
||||
// Field 3: buttons (embedded message)
|
||||
if (buttons) {
|
||||
const buttonsMsg = [];
|
||||
if (buttons.left) { buttonsMsg.push(0x08, 0x01); } // field 1 = true
|
||||
if (buttons.right) { buttonsMsg.push(0x10, 0x01); } // field 2 = true
|
||||
if (buttons.middle) { buttonsMsg.push(0x18, 0x01); } // field 3 = true
|
||||
|
||||
if (buttonsMsg.length > 0) {
|
||||
mouseEvent.push(0x1a); // field 3, wire type 2
|
||||
mouseEvent.push(...encodeVarint(buttonsMsg.length));
|
||||
mouseEvent.push(...buttonsMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Field 4: wheel_delta_x (sint32)
|
||||
if (wheelDeltaX !== 0) {
|
||||
mouseEvent.push(0x20); // field 4, wire type 0
|
||||
mouseEvent.push(...encodeSignedVarint(wheelDeltaX));
|
||||
}
|
||||
|
||||
// Field 5: wheel_delta_y (sint32)
|
||||
if (wheelDeltaY !== 0) {
|
||||
mouseEvent.push(0x28); // field 5, wire type 0
|
||||
mouseEvent.push(...encodeSignedVarint(wheelDeltaY));
|
||||
}
|
||||
|
||||
// Field 6: event_type (enum)
|
||||
mouseEvent.push(0x30); // field 6, wire type 0
|
||||
mouseEvent.push(eventType);
|
||||
|
||||
// Wrap in Message with field 20
|
||||
const message = [];
|
||||
message.push(0xa2, 0x01); // field 20, wire type 2 (20 << 3 | 2 = 162 = 0xa2, then 0x01)
|
||||
message.push(...encodeVarint(mouseEvent.length));
|
||||
message.push(...mouseEvent);
|
||||
|
||||
return new Uint8Array(message);
|
||||
}
|
||||
|
||||
function encodeKeyEvent(vkCode, down) {
|
||||
// Build KeyEvent message
|
||||
const keyEvent = [];
|
||||
|
||||
// Field 1: down (bool)
|
||||
keyEvent.push(0x08); // field 1, wire type 0
|
||||
keyEvent.push(down ? 0x01 : 0x00);
|
||||
|
||||
// Field 3: vk_code (uint32)
|
||||
keyEvent.push(0x18); // field 3, wire type 0
|
||||
keyEvent.push(...encodeVarint(vkCode));
|
||||
|
||||
// Wrap in Message with field 21
|
||||
const message = [];
|
||||
message.push(0xaa, 0x01); // field 21, wire type 2 (21 << 3 | 2 = 170 = 0xaa, then 0x01)
|
||||
message.push(...encodeVarint(keyEvent.length));
|
||||
message.push(...keyEvent);
|
||||
|
||||
return new Uint8Array(message);
|
||||
}
|
||||
|
||||
function encodeSpecialKey(keyType) {
|
||||
// Build SpecialKeyEvent message
|
||||
const specialKey = [];
|
||||
specialKey.push(0x08); // field 1, wire type 0
|
||||
specialKey.push(keyType); // 0 = CTRL_ALT_DEL
|
||||
|
||||
// Wrap in Message with field 22
|
||||
const message = [];
|
||||
message.push(0xb2, 0x01); // field 22, wire type 2
|
||||
message.push(...encodeVarint(specialKey.length));
|
||||
message.push(...specialKey);
|
||||
|
||||
return new Uint8Array(message);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Mouse/Keyboard Event Handlers
|
||||
// ============================================================
|
||||
|
||||
const MOUSE_MOVE = 0;
|
||||
const MOUSE_DOWN = 1;
|
||||
const MOUSE_UP = 2;
|
||||
const MOUSE_WHEEL = 3;
|
||||
|
||||
let lastMouseX = 0;
|
||||
let lastMouseY = 0;
|
||||
let mouseThrottle = 0;
|
||||
|
||||
function getMousePosition(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = remoteWidth / rect.width;
|
||||
const scaleY = remoteHeight / rect.height;
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY
|
||||
};
|
||||
}
|
||||
|
||||
function getButtons(e) {
|
||||
return {
|
||||
left: (e.buttons & 1) !== 0,
|
||||
right: (e.buttons & 2) !== 0,
|
||||
middle: (e.buttons & 4) !== 0
|
||||
};
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
if (remoteWidth === 0) return;
|
||||
|
||||
// Throttle to ~60 events/sec
|
||||
const now = Date.now();
|
||||
if (now - mouseThrottle < 16) return;
|
||||
mouseThrottle = now;
|
||||
|
||||
const pos = getMousePosition(e);
|
||||
const msg = encodeMouseEvent(pos.x, pos.y, getButtons(e), MOUSE_MOVE);
|
||||
ws.send(msg);
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
e.preventDefault();
|
||||
canvas.focus();
|
||||
|
||||
const pos = getMousePosition(e);
|
||||
const buttons = { left: e.button === 0, right: e.button === 2, middle: e.button === 1 };
|
||||
const msg = encodeMouseEvent(pos.x, pos.y, buttons, MOUSE_DOWN);
|
||||
ws.send(msg);
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseup', (e) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
e.preventDefault();
|
||||
|
||||
const pos = getMousePosition(e);
|
||||
const buttons = { left: e.button === 0, right: e.button === 2, middle: e.button === 1 };
|
||||
const msg = encodeMouseEvent(pos.x, pos.y, buttons, MOUSE_UP);
|
||||
ws.send(msg);
|
||||
});
|
||||
|
||||
canvas.addEventListener('wheel', (e) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
e.preventDefault();
|
||||
|
||||
const pos = getMousePosition(e);
|
||||
const msg = encodeMouseEvent(pos.x, pos.y, null, MOUSE_WHEEL,
|
||||
Math.round(-e.deltaX), Math.round(-e.deltaY));
|
||||
ws.send(msg);
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
// Keyboard events
|
||||
canvas.setAttribute('tabindex', '0');
|
||||
|
||||
canvas.addEventListener('keydown', (e) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
e.preventDefault();
|
||||
|
||||
// Use keyCode for virtual key mapping
|
||||
const vkCode = e.keyCode;
|
||||
const msg = encodeKeyEvent(vkCode, true);
|
||||
ws.send(msg);
|
||||
});
|
||||
|
||||
canvas.addEventListener('keyup', (e) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
e.preventDefault();
|
||||
|
||||
const vkCode = e.keyCode;
|
||||
const msg = encodeKeyEvent(vkCode, false);
|
||||
ws.send(msg);
|
||||
});
|
||||
|
||||
// Focus canvas on click
|
||||
canvas.addEventListener('click', () => canvas.focus());
|
||||
|
||||
// ============================================================
|
||||
// WebSocket Connection
|
||||
// ============================================================
|
||||
|
||||
function connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}`;
|
||||
|
||||
console.log('Connecting to:', wsUrl);
|
||||
updateStatus('connecting', 'Connecting...');
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
updateStatus('connected', 'Connected');
|
||||
document.getElementById('overlay').classList.add('hidden');
|
||||
canvas.focus();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const frame = parseVideoFrame(event.data);
|
||||
if (frame) {
|
||||
renderFrame(frame);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('WebSocket closed:', event.code, event.reason);
|
||||
updateStatus('error', 'Disconnected');
|
||||
document.getElementById('overlay').classList.remove('hidden');
|
||||
document.getElementById('overlay-text').textContent = 'Connection closed. Reconnecting...';
|
||||
|
||||
// Reconnect after 2 seconds
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
updateStatus('error', 'Connection error');
|
||||
};
|
||||
}
|
||||
|
||||
function updateStatus(state, text) {
|
||||
const status = document.getElementById('status');
|
||||
status.className = 'status ' + state;
|
||||
status.textContent = text;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Toolbar Actions
|
||||
// ============================================================
|
||||
|
||||
function disconnect() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
function sendCtrlAltDel() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const msg = encodeSpecialKey(0); // CTRL_ALT_DEL = 0
|
||||
ws.send(msg);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Initialization
|
||||
// ============================================================
|
||||
|
||||
// Set window title
|
||||
document.title = `GuruConnect - Session ${sessionId.substring(0, 8)}`;
|
||||
|
||||
// Connect on load
|
||||
connect();
|
||||
|
||||
// Handle window close
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (ws) ws.close();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user