- Add ViewerInfo struct to track viewer name and connection time - Update session manager to track viewers with names - Update API to return viewer list for each session - Update dashboard to display "Mike Connected (3 min)" on machine bars - Update viewer.html to pass viewer_name parameter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
689 lines
23 KiB
HTML
689 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('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 wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}&viewer_name=${encodeURIComponent(viewerName)}`;
|
|
|
|
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>
|