Files
guru-connect/server/static/viewer.html
Mike Swanson 3292ca4275 Fix auth token localStorage key consistency
- Dashboard, viewer, and native viewer all now use guruconnect_token
- Fixed loadMachines() to include Authorization header
2026-01-01 19:18:07 +00:00

695 lines
23 KiB
HTML

<!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();
}
// Get viewer name from localStorage (same as dashboard)
const user = JSON.parse(localStorage.getItem('guruconnect_user') || 'null');
const viewerName = user?.name || user?.email || 'Technician';
// 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 token = localStorage.getItem('guruconnect_token');
if (!token) {
updateStatus('error', 'Not authenticated');
document.getElementById('overlay-text').textContent = 'Not logged in. Please log in first.';
return;
}
const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}&viewer_name=${encodeURIComponent(viewerName)}&token=${encodeURIComponent(token)}`;
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>