| 1 | import { VMMenu } from "./VMMenu.tsx"; |
| 2 | import { createEffect, createMemo, createSignal, type JSX, on, onCleanup, Show } from "solid-js"; |
| 3 | import { reason } from "../api.ts"; |
| 4 | import Keyboard from "lucide-solid/icons/keyboard"; |
| 5 | import Clipboard from "lucide-solid/icons/clipboard"; |
| 6 | import Maximize from "lucide-solid/icons/maximize"; |
| 7 | import RefreshCw from "lucide-solid/icons/refresh-cw"; |
| 8 | import Check from "lucide-solid/icons/check"; |
| 9 | import RemoteKeyboard from "@novnc/keyboard"; |
| 10 | import { VM_SETUP, type Domain } from "../types/vms.ts"; |
| 11 | import { StatusLabel } from "./Status.tsx"; |
| 12 | |
| 13 | type WindowSurface = { |
| 14 | id: number; x: number; y: number; width: number; height: number; atlas_x: number; atlas_y: number; |
| 15 | caption_left: number; caption_top: number; caption_right: number; caption_bottom: number; |
| 16 | }; |
| 17 | type WindowFrame = { windows: WindowSurface[]; rtpTimestamp: number }; |
| 18 | |
| 19 | export function VMConsole(props: { name: string; enabled: boolean; active: boolean; setup?: Domain["setup"]; toolbar: JSX.Element; content: (toolbar: HTMLDivElement) => JSX.Element }) { |
| 20 | const enabled = createMemo(() => props.enabled); |
| 21 | const preparing = () => !!props.setup && props.setup !== "ready"; |
| 22 | let reconnectTimer: number | undefined; |
| 23 | let attempt = 0; |
| 24 | let video!: HTMLVideoElement; |
| 25 | let canvas!: HTMLCanvasElement; |
| 26 | let cursorImage!: HTMLImageElement; |
| 27 | let cursorInvert!: HTMLImageElement; |
| 28 | let panel!: HTMLDivElement; |
| 29 | let input: RTCDataChannel | undefined; |
| 30 | let movement: RTCDataChannel | undefined; |
| 31 | let pointerSequence = 0; |
| 32 | let windowFrame: WindowFrame | undefined; |
| 33 | let pendingFrames: WindowFrame[] = []; |
| 34 | let frameTimestamp: number | undefined; |
| 35 | let drag: { id: number; startX: number; startY: number; x: number; y: number; dx: number; dy: number; released: number } | undefined; |
| 36 | let disconnect = () => {}; |
| 37 | let width = 0; |
| 38 | let height = 0; |
| 39 | let buttons = 0; |
| 40 | let disposed = false; |
| 41 | let cursorFrames: { image: string; invert?: string; duration: number }[] = []; |
| 42 | let cursorFrame = 0; |
| 43 | let cursorTimer: number | undefined; |
| 44 | let cursorX = 0; |
| 45 | let cursorY = 0; |
| 46 | let pointerX = 0; |
| 47 | let pointerY = 0; |
| 48 | let cursorInside = false; |
| 49 | let cursorVisible = true; |
| 50 | const drawCursor = () => { |
| 51 | const frame = cursorFrames[cursorFrame]; |
| 52 | const overlay = !!frame?.invert && cursorInside && cursorVisible; |
| 53 | cursorImage.style.display = cursorInvert.style.display = overlay ? "block" : "none"; |
| 54 | if (!frame || !cursorVisible) { video.style.cursor = cursorVisible ? "default" : "none"; return; } |
| 55 | video.style.cursor = frame.invert ? (cursorInside ? "none" : "default") : `url(${frame.image}) ${cursorX} ${cursorY}, default`; |
| 56 | if (overlay) { |
| 57 | cursorImage.src = frame.image; |
| 58 | cursorInvert.src = frame.invert!; |
| 59 | cursorImage.style.transform = cursorInvert.style.transform = `translate(${pointerX - cursorX}px, ${pointerY - cursorY}px)`; |
| 60 | } |
| 61 | }; |
| 62 | const animateCursor = () => { |
| 63 | const frame = cursorFrames[cursorFrame]; |
| 64 | if (!frame) return; |
| 65 | cursorTimer = window.setTimeout(() => { |
| 66 | cursorFrame = (cursorFrame + 1) % cursorFrames.length; |
| 67 | drawCursor(); |
| 68 | animateCursor(); |
| 69 | }, frame.duration); |
| 70 | }; |
| 71 | const [state, setState] = createSignal<"Connecting…" | "Connected" | "Disconnected">("Connecting…"); |
| 72 | const connected = () => state() === "Connected"; |
| 73 | const [problem, setProblem] = createSignal(""); |
| 74 | const [clipboard, setClipboard] = createSignal(""); |
| 75 | const [showClipboard, setShowClipboard] = createSignal(false); |
| 76 | const [windowsAvailable, setWindowsAvailable] = createSignal(false); |
| 77 | const [separateWindows, setSeparateWindows] = createSignal(false); |
| 78 | const [frameSync, setFrameSync] = createSignal(false); |
| 79 | const [windowCapture, setWindowCapture] = createSignal(false); |
| 80 | const [showStats, setShowStats] = createSignal(false); |
| 81 | const [stats, setStats] = createSignal<{ fps: number; bitrate: number; lost: number; dropped: number; delay: number; rtt: number }>(); |
| 82 | const [capture, setCapture] = createSignal<{ fps: number; capture: number; scan: number; write: number }>(); |
| 83 | const [tools, setTools] = createSignal<HTMLDivElement>(); |
| 84 | const send = (message: object) => { if (input?.readyState === "open") input.send(JSON.stringify(message)); }; |
| 85 | const compose = () => { |
| 86 | const frame = windowFrame; |
| 87 | const composed = !!frame?.windows.length; |
| 88 | video.classList.toggle("vm-video-composed", composed); |
| 89 | canvas.hidden = !composed; |
| 90 | if (!frame || !composed || !width || !height || video.readyState < 2) return; |
| 91 | if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } |
| 92 | const context = canvas.getContext("2d", { alpha: false }); |
| 93 | if (!context) return; |
| 94 | context.fillStyle = "#101014"; |
| 95 | context.fillRect(0, 0, width, height); |
| 96 | for (const window of [...frame.windows].reverse()) { |
| 97 | let x = window.x, y = window.y; |
| 98 | if (drag?.id === window.id) { |
| 99 | const acknowledged = Math.abs(window.x - drag.x - drag.dx) <= 3 && Math.abs(window.y - drag.y - drag.dy) <= 3; |
| 100 | if (drag.released && (acknowledged || performance.now() - drag.released > 500)) drag = undefined; |
| 101 | else { x = drag.x + drag.dx; y = drag.y + drag.dy; } |
| 102 | } |
| 103 | context.drawImage(video, window.atlas_x, window.atlas_y, window.width, window.height, x, y, window.width, window.height); |
| 104 | } |
| 105 | canvas.dataset.windows = String(frame.windows.length); |
| 106 | }; |
| 107 | const release = () => { buttons = 0; drag = undefined; compose(); send({ type: "release", sequence: ++pointerSequence }); }; |
| 108 | const connect = async () => { |
| 109 | const current = ++attempt; |
| 110 | disconnect(); |
| 111 | input = undefined; |
| 112 | setProblem(""); |
| 113 | setState("Connecting…"); |
| 114 | try { |
| 115 | if (disposed || !enabled() || current !== attempt) return; |
| 116 | if (!window.RTCPeerConnection) { |
| 117 | setProblem("This browser cannot stream VM screens. Open it in a current browser."); |
| 118 | setState("Disconnected"); |
| 119 | return; |
| 120 | } |
| 121 | const url = new URL(`/api/vms/${encodeURIComponent(props.name)}/console`, location.href); |
| 122 | url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; |
| 123 | const socket = new WebSocket(url.href, ["binary"]); |
| 124 | socket.binaryType = "arraybuffer"; |
| 125 | const keyboard = new RemoteKeyboard(video); |
| 126 | const focus = () => { if (input?.readyState === "open") keyboard.grab(); }; |
| 127 | const blur = () => { keyboard.ungrab(); release(); }; |
| 128 | const releaseKeyboard = (event: KeyboardEvent) => { |
| 129 | if (event.key !== "Escape" || !event.ctrlKey || !event.altKey) return; |
| 130 | event.preventDefault(); |
| 131 | event.stopImmediatePropagation(); |
| 132 | tools()?.querySelector<HTMLElement>("summary")?.focus(); |
| 133 | }; |
| 134 | video.addEventListener("keydown", releaseKeyboard, true); |
| 135 | video.addEventListener("focus", focus); |
| 136 | video.addEventListener("blur", blur); |
| 137 | let peer: RTCPeerConnection | undefined; |
| 138 | let frameCallback: number | undefined; |
| 139 | let statsTimer: number | undefined; |
| 140 | let previousStats: { time: number; bytes: number; frames: number } | undefined; |
| 141 | const frameReady: VideoFrameRequestCallback = (_, metadata) => { |
| 142 | if (current !== attempt || disposed) return; |
| 143 | frameTimestamp = (metadata as VideoFrameCallbackMetadata & { rtpTimestamp?: number }).rtpTimestamp; |
| 144 | setFrameSync(Number.isFinite(frameTimestamp)); |
| 145 | if (!frameSync()) { |
| 146 | frameTimestamp = undefined; windowFrame = undefined; pendingFrames = []; |
| 147 | if (separateWindows()) { setSeparateWindows(false); send({ type: "experiment", enabled: false }); } |
| 148 | } |
| 149 | while (pendingFrames[0] && frameTimestamp !== undefined && ((frameTimestamp - pendingFrames[0].rtpTimestamp) | 0) >= 0) { |
| 150 | windowFrame = pendingFrames.shift(); |
| 151 | } |
| 152 | compose(); |
| 153 | frameCallback = video.requestVideoFrameCallback(frameReady); |
| 154 | }; |
| 155 | const measure = async () => { |
| 156 | if (!peer || current !== attempt || disposed) return; |
| 157 | try { |
| 158 | const report = await peer.getStats(); |
| 159 | if (current !== attempt || disposed || peer.connectionState === "closed") return; |
| 160 | let rtt = 0; |
| 161 | report.forEach((value) => { if (value.type === "candidate-pair" && value.state === "succeeded" && value.nominated) rtt = (value.currentRoundTripTime ?? 0) * 1000; }); |
| 162 | report.forEach((value) => { |
| 163 | if (value.type !== "inbound-rtp" || value.kind !== "video") return; |
| 164 | const elapsed = previousStats ? (value.timestamp - previousStats.time) / 1000 : 0; |
| 165 | setStats({ fps: elapsed ? (value.framesDecoded - previousStats!.frames) / elapsed : 0, |
| 166 | bitrate: elapsed ? (value.bytesReceived - previousStats!.bytes) * 8 / elapsed / 1000000 : 0, |
| 167 | lost: value.packetsLost ?? 0, dropped: value.framesDropped ?? 0, |
| 168 | delay: value.jitterBufferEmittedCount ? value.jitterBufferDelay / value.jitterBufferEmittedCount * 1000 : 0, rtt }); |
| 169 | previousStats = { time: value.timestamp, bytes: value.bytesReceived, frames: value.framesDecoded }; |
| 170 | }); |
| 171 | } catch { |
| 172 | if (peer.connectionState !== "closed") return; |
| 173 | } finally { |
| 174 | if (current === attempt && !disposed && peer.connectionState !== "closed") statsTimer = window.setTimeout(measure, 1000); |
| 175 | } |
| 176 | }; |
| 177 | let pending = ""; |
| 178 | let messages = Promise.resolve(); |
| 179 | const decoder = new TextDecoder(); |
| 180 | const candidates: RTCIceCandidateInit[] = []; |
| 181 | const signal = (message: object) => socket.send(JSON.stringify(message) + "\n"); |
| 182 | const fail = (message: string) => { |
| 183 | if (current !== attempt || disposed) return; |
| 184 | setState("Disconnected"); |
| 185 | disconnect(); |
| 186 | if (preparing() && props.setup !== "failed") { |
| 187 | setProblem(""); |
| 188 | reconnectTimer = window.setTimeout(connect, 1000); |
| 189 | } else setProblem(message); |
| 190 | }; |
| 191 | const timeout = window.setTimeout(() => fail("The screen couldn't connect. Reconnect to try again."), 20000); |
| 192 | const ready = () => { |
| 193 | if (current !== attempt || disposed || video.readyState < 2 || input?.readyState !== "open") return; |
| 194 | clearTimeout(timeout); |
| 195 | setState("Connected"); |
| 196 | }; |
| 197 | disconnect = () => { |
| 198 | clearTimeout(reconnectTimer); |
| 199 | clearTimeout(timeout); |
| 200 | blur(); |
| 201 | video.removeEventListener("keydown", releaseKeyboard, true); |
| 202 | video.removeEventListener("focus", focus); |
| 203 | video.removeEventListener("blur", blur); |
| 204 | socket.onclose = null; |
| 205 | socket.close(); |
| 206 | peer?.close(); |
| 207 | clearTimeout(statsTimer); |
| 208 | if (frameCallback !== undefined) video.cancelVideoFrameCallback(frameCallback); |
| 209 | windowFrame = undefined; pendingFrames = []; frameTimestamp = undefined; drag = undefined; |
| 210 | movement = undefined; pointerSequence = 0; |
| 211 | setFrameSync(false); setWindowsAvailable(false); setWindowCapture(false); setStats(undefined); setCapture(undefined); |
| 212 | compose(); |
| 213 | video.onplaying = null; |
| 214 | video.srcObject = null; |
| 215 | clearTimeout(cursorTimer); |
| 216 | cursorFrames = []; |
| 217 | cursorFrame = 0; |
| 218 | cursorVisible = true; |
| 219 | cursorImage.style.display = cursorInvert.style.display = "none"; |
| 220 | video.style.cursor = "default"; |
| 221 | }; |
| 222 | socket.onclose = () => fail("The screen disconnected. Check that the VM is running, then reconnect."); |
| 223 | socket.onerror = () => fail("Unable to connect the screen. Reconnect to try again."); |
| 224 | socket.onmessage = (event: MessageEvent<ArrayBuffer>) => { |
| 225 | pending += decoder.decode(event.data, { stream: true }); |
| 226 | if (pending.length > 262144) { fail("The screen connection stopped. Reconnect to try again."); return; } |
| 227 | let end: number; |
| 228 | while ((end = pending.indexOf("\n")) !== -1) { |
| 229 | const line = pending.slice(0, end); |
| 230 | pending = pending.slice(end + 1); |
| 231 | messages = messages.then(async () => { |
| 232 | if (current !== attempt || disposed) return; |
| 233 | const message = JSON.parse(line); |
| 234 | if (message.type === "ready") { |
| 235 | peer = new RTCPeerConnection({ iceServers: message.iceServers }); |
| 236 | const transceiver = peer.addTransceiver("video", { direction: "recvonly" }); |
| 237 | const codecs = RTCRtpReceiver.getCapabilities("video")?.codecs.filter((codec) => codec.mimeType === "video/H264" || codec.mimeType === "video/rtx"); |
| 238 | if (codecs?.length) transceiver.setCodecPreferences(codecs); |
| 239 | input = peer.createDataChannel("input"); |
| 240 | movement = peer.createDataChannel("pointer", { ordered: false, maxRetransmits: 0 }); |
| 241 | keyboard.onkeyevent = (key, _code, down) => send({ type: "key", key, down }); |
| 242 | input.onopen = () => { |
| 243 | ready(); |
| 244 | if (document.activeElement === video) focus(); |
| 245 | if (windowsAvailable()) send({ type: "experiment", enabled: separateWindows() }); |
| 246 | }; |
| 247 | peer.onicecandidate = ({ candidate }) => { if (candidate) signal({ type: "candidate", ...candidate.toJSON() }); }; |
| 248 | peer.onconnectionstatechange = () => { |
| 249 | if (peer?.connectionState === "failed" || peer?.connectionState === "disconnected") { |
| 250 | fail("The screen disconnected. Reconnect to try again."); |
| 251 | } |
| 252 | }; |
| 253 | peer.ontrack = ({ track }) => { |
| 254 | const receiver = transceiver.receiver as RTCRtpReceiver & { jitterBufferTarget?: number }; |
| 255 | if ("jitterBufferTarget" in receiver) receiver.jitterBufferTarget = 0; |
| 256 | video.srcObject = new MediaStream([track]); |
| 257 | if (video.requestVideoFrameCallback) frameCallback = video.requestVideoFrameCallback(frameReady); |
| 258 | void measure(); |
| 259 | void video.play().catch(() => { |
| 260 | if (current === attempt && video.srcObject) fail("The browser couldn't play the screen. Reconnect to try again."); |
| 261 | }); |
| 262 | }; |
| 263 | await peer.setLocalDescription(await peer.createOffer()); |
| 264 | signal({ type: "offer", sdp: peer.localDescription!.sdp }); |
| 265 | } else if (message.type === "answer" && peer) { |
| 266 | await peer.setRemoteDescription({ type: "answer", sdp: message.sdp }); |
| 267 | for (const candidate of candidates.splice(0)) await peer.addIceCandidate(candidate); |
| 268 | } else if (message.type === "candidate") { |
| 269 | if (peer?.remoteDescription) await peer.addIceCandidate(message); |
| 270 | else candidates.push(message); |
| 271 | } else if (message.type === "display") { |
| 272 | width = message.width; |
| 273 | height = message.height; |
| 274 | video.dataset.encoder = message.encoder; |
| 275 | video.dataset.source = message.source ?? "qemu"; |
| 276 | } else if (message.type === "capabilities") { |
| 277 | setWindowsAvailable(message.windows); |
| 278 | if (message.windows) send({ type: "experiment", enabled: separateWindows() }); |
| 279 | } else if (message.type === "window-capture") { |
| 280 | setWindowCapture(message.available); |
| 281 | } else if (message.type === "windows") { |
| 282 | pendingFrames.push(message); |
| 283 | if (pendingFrames.length > 16) pendingFrames.shift(); |
| 284 | if (frameTimestamp !== undefined && ((frameTimestamp - message.rtpTimestamp) | 0) >= 0) { |
| 285 | windowFrame = message; pendingFrames = []; compose(); |
| 286 | } |
| 287 | } else if (message.type === "window-positions") { |
| 288 | if (windowFrame?.windows.length && windowFrame.windows.length === message.windows.length && |
| 289 | windowFrame.windows.every((window) => message.windows.some((next: WindowSurface) => window.id === next.id && window.width === next.width && window.height === next.height))) { |
| 290 | windowFrame = { ...windowFrame, windows: message.windows }; compose(); |
| 291 | } |
| 292 | } else if (message.type === "capture-stats") { |
| 293 | setCapture({ fps: message.frames * 1000 / message.elapsed_ms, |
| 294 | capture: message.frames ? message.capture_us / message.frames / 1000 : 0, |
| 295 | scan: message.frames ? message.scan_us / message.frames / 1000 : 0, |
| 296 | write: message.frames ? message.write_us / message.frames / 1000 : 0 }); |
| 297 | } else if (message.type === "cursor") { |
| 298 | if (message.frame === 0) { |
| 299 | clearTimeout(cursorTimer); |
| 300 | cursorFrames = []; |
| 301 | cursorFrame = 0; |
| 302 | cursorX = message.x; |
| 303 | cursorY = message.y; |
| 304 | if (message.source !== "guest") cursorVisible = !!message.fallback || (!!message.width && !!message.height); |
| 305 | video.dataset.cursorSource = message.source; |
| 306 | } |
| 307 | if (!message.image) { drawCursor(); return; } |
| 308 | for (const url of [message.image, message.invert].filter(Boolean)) { |
| 309 | const image = new Image(); |
| 310 | image.src = url; |
| 311 | await image.decode(); |
| 312 | } |
| 313 | if (current !== attempt || disposed) return; |
| 314 | cursorFrames.push({ image: message.image, invert: message.invert, duration: message.duration }); |
| 315 | if (message.frame === 0) drawCursor(); |
| 316 | if (cursorFrames.length === message.frames && message.frames > 1) animateCursor(); |
| 317 | } else if (message.type === "cursor-position") { |
| 318 | cursorVisible = message.visible; |
| 319 | drawCursor(); |
| 320 | } else if (message.type === "clipboard") setClipboard(message.text); |
| 321 | else if (message.type === "error") { |
| 322 | if (message.fatal) fail(message.message); |
| 323 | else setProblem(message.message); |
| 324 | } |
| 325 | }).catch((failure) => { |
| 326 | console.error("VM screen connection", failure); |
| 327 | fail("The browser couldn't connect the screen. Reconnect to try again."); |
| 328 | }); |
| 329 | } |
| 330 | }; |
| 331 | video.onplaying = () => { |
| 332 | if (current !== attempt || disposed) return; |
| 333 | ready(); |
| 334 | compose(); |
| 335 | }; |
| 336 | } catch (failure) { |
| 337 | console.error("VM screen connection", failure); |
| 338 | disconnect(); |
| 339 | setProblem("The browser couldn't connect the screen. Reconnect to try again."); |
| 340 | setState("Disconnected"); |
| 341 | } |
| 342 | }; |
| 343 | createEffect(() => { |
| 344 | if (enabled()) void connect(); |
| 345 | else { |
| 346 | attempt++; |
| 347 | disconnect(); |
| 348 | input = undefined; |
| 349 | setState("Disconnected"); |
| 350 | setProblem(""); |
| 351 | } |
| 352 | }); |
| 353 | createEffect(() => { if (props.active && connected() && !preparing()) video.focus(); else release(); }); |
| 354 | createEffect(on(() => props.setup, setup => { |
| 355 | if (setup === "ready" && enabled() && state() === "Disconnected") void connect(); |
| 356 | }, { defer: true })); |
| 357 | onCleanup(() => { disposed = true; disconnect(); }); |
| 358 | const pointer = (event: PointerEvent | WheelEvent, mask = buttons) => { |
| 359 | if (!connected() || !width || !height) return; |
| 360 | const rect = video.getBoundingClientRect(); |
| 361 | const scale = Math.min(rect.width / width, rect.height / height); |
| 362 | const x = Math.floor((event.clientX - rect.left - (rect.width - width * scale) / 2) / scale); |
| 363 | const y = Math.floor((event.clientY - rect.top - (rect.height - height * scale) / 2) / scale); |
| 364 | pointerX = event.clientX; |
| 365 | pointerY = event.clientY; |
| 366 | cursorInside = x >= 0 && y >= 0 && x < width && y < height; |
| 367 | drawCursor(); |
| 368 | if ((event.type === "pointerdown" || event.type === "wheel") && (x < 0 || y < 0 || x >= width || y >= height)) return false; |
| 369 | if (event.type === "pointerdown" && mask === 1) { |
| 370 | const window = windowFrame?.windows.find((window) => x >= window.x && y >= window.y && x < window.x + window.width && y < window.y + window.height); |
| 371 | if (window && x >= window.caption_left && x < window.caption_right && y >= window.caption_top && y < window.caption_bottom) { |
| 372 | drag = { id: window.id, startX: x, startY: y, x: window.x, y: window.y, dx: 0, dy: 0, released: 0 }; |
| 373 | } |
| 374 | } |
| 375 | if (drag && event.type === "pointermove" && buttons === 1) { drag.dx = x - drag.startX; drag.dy = y - drag.startY; compose(); } |
| 376 | if (drag && event.type === "pointerup" && !mask) { drag.released = performance.now(); window.setTimeout(compose, 510); } |
| 377 | const message = JSON.stringify({ type: "pointer", x, y, buttons: mask, sequence: ++pointerSequence }); |
| 378 | if (event.type === "pointermove" && !mask && movement?.readyState === "open") { |
| 379 | if (movement.bufferedAmount < 16384) movement.send(message); |
| 380 | } else if (input?.readyState === "open" && (event.type !== "pointermove" || input.bufferedAmount < 16384)) input.send(message); |
| 381 | return true; |
| 382 | }; |
| 383 | const ctrlAltDel = () => { |
| 384 | for (const key of [0xffe3, 0xffe9, 0xffff]) send({ type: "key", key, down: true }); |
| 385 | for (const key of [0xffff, 0xffe9, 0xffe3]) send({ type: "key", key, down: false }); |
| 386 | }; |
| 387 | return <div class="vm-console" ref={panel}> |
| 388 | <div class="vm-console-toolbar" ref={setTools}> |
| 389 | {props.toolbar} |
| 390 | <Show when={props.active}> |
| 391 | <VMMenu label="Input"> |
| 392 | <button disabled={!connected()} onClick={ctrlAltDel}><Keyboard size={14} aria-hidden="true" />Ctrl+Alt+Del</button> |
| 393 | <button disabled={!connected()} onClick={() => setShowClipboard(!showClipboard())}><Clipboard size={14} aria-hidden="true" />Clipboard</button> |
| 394 | </VMMenu> |
| 395 | <VMMenu label="View"> |
| 396 | <button disabled={!connected() || !windowsAvailable() || !frameSync()} aria-pressed={separateWindows()} |
| 397 | onClick={() => { const enabled = !separateWindows(); setSeparateWindows(enabled); send({ type: "experiment", enabled }); }}> |
| 398 | <Check size={14} aria-hidden="true" style={{ visibility: separateWindows() ? "visible" : "hidden" }} /> |
| 399 | Separate windows (experimental) |
| 400 | </button> |
| 401 | <button aria-pressed={showStats()} onClick={() => setShowStats(!showStats())}><Check size={14} aria-hidden="true" style={{ visibility: showStats() ? "visible" : "hidden" }} />Stream statistics</button> |
| 402 | <button onClick={() => panel.requestFullscreen().catch((failure) => setProblem(reason(failure)))}><Maximize size={14} aria-hidden="true" />Fullscreen</button> |
| 403 | <button disabled={!props.enabled || state() === "Connecting…"} onClick={connect}><RefreshCw size={14} aria-hidden="true" />Reconnect</button> |
| 404 | </VMMenu> |
| 405 | <span class="spacer" /> |
| 406 | <Show when={connected() && !preparing()}><span class="vm-console-status" id={`keyboard-release-${props.name}`}>Ctrl+Alt+Esc releases keyboard</span></Show> |
| 407 | <span class="vm-console-status" role="status">{preparing() ? "" : props.enabled ? state() : "Off"}</span> |
| 408 | </Show> |
| 409 | </div> |
| 410 | <Show when={props.active && showStats()}> |
| 411 | <div class="vm-stream-stats"> |
| 412 | <Show when={stats()}>{(value) => <><span>{value().fps.toFixed(1)} FPS</span><span>{value().bitrate.toFixed(2)} Mbps</span><span>{value().lost} packets lost</span><span>{value().dropped} frames dropped</span><span>{value().rtt.toFixed(0)} ms round trip</span><span>{value().delay.toFixed(0)} ms buffer</span></>}</Show> |
| 413 | <Show when={capture()}>{(value) => <><span>Guest {value().fps.toFixed(1)} FPS</span><span>Desktop capture {value().capture.toFixed(1)} ms</span><span>Compare {value().scan.toFixed(1)} ms</span><span>Stream work {value().write.toFixed(1)} ms</span></>}</Show> |
| 414 | <Show when={separateWindows()}><span>{windowCapture() ? "Separate windows" : "Desktop fallback"}</span></Show> |
| 415 | </div> |
| 416 | </Show> |
| 417 | <Show when={props.active && !preparing() && problem()}><p class="error vm-console-error" role="alert">{problem()}</p></Show> |
| 418 | <Show when={props.active && showClipboard()}> |
| 419 | <div class="vm-clipboard"> |
| 420 | <label class="field">clipboard<textarea class="search" rows="3" value={clipboard()} onInput={(event) => setClipboard(event.currentTarget.value)} /></label> |
| 421 | <span class="hint">Clipboard sharing requires support in the guest</span> |
| 422 | <div class="row"> |
| 423 | <button class="button small" disabled={!connected()} onClick={() => send({ type: "clipboard", text: clipboard() })}>send text</button> |
| 424 | <button class="button small" onClick={() => navigator.clipboard.writeText(clipboard()).catch((failure) => setProblem(reason(failure)))}>copy text</button> |
| 425 | </div> |
| 426 | </div> |
| 427 | </Show> |
| 428 | <Show when={props.active && props.enabled && preparing()}> |
| 429 | <div class="empty vm-setup" role={props.setup === "failed" ? "alert" : "status"}> |
| 430 | <StatusLabel health={props.setup === "failed" ? "down" : "starting"}>{VM_SETUP[props.setup!]}</StatusLabel> |
| 431 | <p>{props.setup === "failed" ? "Restart this VM from the Machine menu to retry." : "Your desktop opens here when setup finishes."}</p> |
| 432 | </div> |
| 433 | </Show> |
| 434 | <div class="vm-console-screen" hidden={!props.active || !props.enabled || preparing()}> |
| 435 | <canvas ref={canvas} class="vm-window-canvas" hidden aria-hidden="true" /> |
| 436 | <video ref={video} autoplay muted playsinline tabindex="0" aria-label="VM screen" aria-describedby={`keyboard-release-${props.name}`} |
| 437 | onContextMenu={(event) => event.preventDefault()} |
| 438 | onPointerMove={pointer} |
| 439 | onPointerLeave={() => { cursorInside = false; drawCursor(); }} |
| 440 | onPointerDown={(event) => { |
| 441 | event.preventDefault(); |
| 442 | const mask = buttons | (event.button === 0 ? 1 : event.button === 1 ? 2 : event.button === 2 ? 4 : 0); |
| 443 | if (!pointer(event)) return; |
| 444 | pointer(event, mask); |
| 445 | buttons = mask; |
| 446 | video.focus(); video.setPointerCapture(event.pointerId); |
| 447 | }} |
| 448 | onPointerUp={(event) => { |
| 449 | if (buttons) pointer(event); |
| 450 | buttons &= ~(event.button === 0 ? 1 : event.button === 1 ? 2 : event.button === 2 ? 4 : 0); |
| 451 | pointer(event); |
| 452 | if (!event.buttons && video.hasPointerCapture(event.pointerId)) video.releasePointerCapture(event.pointerId); |
| 453 | }} |
| 454 | onPointerCancel={release} |
| 455 | onWheel={(event) => { |
| 456 | event.preventDefault(); |
| 457 | const mask = event.deltaY ? (event.deltaY < 0 ? 8 : 16) : event.deltaX < 0 ? 32 : 64; |
| 458 | pointer(event, buttons | mask); pointer(event); |
| 459 | }} /> |
| 460 | <img ref={cursorImage} class="vm-cursor" alt="" aria-hidden="true" /> |
| 461 | <img ref={cursorInvert} class="vm-cursor vm-cursor-invert" alt="" aria-hidden="true" /> |
| 462 | </div> |
| 463 | <Show when={tools()}>{(toolbar) => props.content(toolbar())}</Show> |
| 464 | <Show when={props.active && !props.enabled}><div class="empty"><p>The VM is off. Start it from the Machine menu.</p></div></Show> |
| 465 | </div>; |
| 466 | } |