| author | |
| committer | |
| log | 442a2007a84dd5e45dfe33380a5d483bc0c1dc29 |
| tree | 31cfe8a528b5bb43e8d8a27ad1930173f325266c |
| parent | f0fafcafe57e96510289fea484792f68abe6dcd4 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Add declarative WebRTC/NVENC streaming with noVNC fallback and the Windows guest capture channel. Correct ARM input/video/serial and Windows 11 CPU/clock profiles. Render project READMEs in the browser with sanitized Markdown, alignment, and proportional images.
Validated frontend check/build, 41 Rust tests, VM and guest-channel suites, and Windows guest builds for x86, x64, and ARM64 with cursor sanitizer tests.
Assisted-by: gpt-6.1-sol26 files changed, 1790 insertions(+), 265 deletions(-)
dashboard/src/vms.rs+21-4| ... | ... | @@ -34,12 +34,29 @@ pub async fn console(request: Request, name: &str, serial: bool) -> Result<Respo |
| 34 | 34 | .map_err(|_| Error::new(400, "Open the screen from the VM page."))? |
| 35 | 35 | .max_message_size(1024 * 1024) |
| 36 | 36 | .max_frame_size(1024 * 1024); |
| 37 | let target = json!({"operation":if serial { "vm.serial" } else { "vm.console" },"payload":{"name":name}}); | |
| 37 | let name = name.to_owned(); | |
| 38 | 38 | Ok(upgrade |
| 39 | 39 | .protocols(["binary"]) |
| 40 | 40 | .on_upgrade(move |mut socket| async move { |
| 41 | match host::open(target).await { | |
| 42 | Ok((_, stream)) => bridge(socket, stream).await, | |
| 41 | let stream = async { | |
| 42 | if serial { | |
| 43 | return host::open(json!({"operation":"vm.serial","payload":{"name":name}})) | |
| 44 | .await | |
| 45 | .map(|(_, stream)| stream); | |
| 46 | } | |
| 47 | let mut stream = tokio::net::UnixStream::connect(env( | |
| 48 | "STUDIO_VM_SCREEN_SOCKET", | |
| 49 | "/run/studio-vm-screen/screen.sock", | |
| 50 | )) | |
| 51 | .await?; | |
| 52 | stream | |
| 53 | .write_all(format!("{}\n", json!({"name":name})).as_bytes()) | |
| 54 | .await?; | |
| 55 | Ok(stream) | |
| 56 | } | |
| 57 | .await; | |
| 58 | match stream { | |
| 59 | Ok(stream) => bridge(socket, stream).await, | |
| 43 | 60 | Err(error) => { |
| 44 | 61 | let reason = if error.message.len() <= 120 { |
| 45 | 62 | error.message |
| ... | ... | @@ -89,9 +106,9 @@ async fn bridge(socket: WebSocket, stream: tokio::net::UnixStream) { |
| 89 | 106 | while let Some(Ok(message)) = receive.next().await { |
| 90 | 107 | match message { |
| 91 | 108 | Message::Binary(data) => write.write_all(&data).await?, |
| 109 | Message::Text(data) => write.write_all(data.as_bytes()).await?, | |
| 92 | 110 | Message::Close(_) => break, |
| 93 | 111 | Message::Ping(_) | Message::Pong(_) => {} |
| 94 | _ => break, | |
| 95 | 112 | } |
| 96 | 113 | } |
| 97 | 114 | Ok::<_, std::io::Error>(()) |
dashboard/vite.config.ts+3| ... | ... | @@ -1,9 +1,12 @@ |
| 1 | 1 | import { defineConfig } from "vite"; |
| 2 | 2 | import solid from "vite-plugin-solid"; |
| 3 | import { createRequire } from "node:module"; | |
| 4 | import { dirname, resolve } from "node:path"; | |
| 3 | 5 | |
| 4 | 6 | export default defineConfig({ |
| 5 | 7 | root: "web", |
| 6 | 8 | plugins: [solid()], |
| 9 | resolve: { alias: { "@novnc/keyboard": resolve(dirname(createRequire(import.meta.url).resolve("@novnc/novnc")), "input/keyboard.js") } }, | |
| 7 | 10 | build: { outDir: "../dist", emptyOutDir: true }, |
| 8 | 11 | server: { |
| 9 | 12 | host: true, |
dashboard/web/components/VMConsole.tsx+222-36| ... | ... | @@ -5,56 +5,200 @@ import Keyboard from "lucide-solid/icons/keyboard"; |
| 5 | 5 | import Clipboard from "lucide-solid/icons/clipboard"; |
| 6 | 6 | import Maximize from "lucide-solid/icons/maximize"; |
| 7 | 7 | import RefreshCw from "lucide-solid/icons/refresh-cw"; |
| 8 | import RemoteKeyboard from "@novnc/keyboard"; | |
| 8 | 9 | |
| 9 | 10 | export function VMConsole(props: { name: string; enabled: boolean; active: boolean; toolbar: JSX.Element; content: (toolbar: HTMLDivElement) => JSX.Element }) { |
| 10 | 11 | const enabled = createMemo(() => props.enabled); |
| 11 | 12 | let attempt = 0; |
| 12 | let screen!: HTMLDivElement; | |
| 13 | let video!: HTMLVideoElement; | |
| 14 | let cursorImage!: HTMLImageElement; | |
| 15 | let cursorInvert!: HTMLImageElement; | |
| 13 | 16 | let panel!: HTMLDivElement; |
| 14 | let connection: import("@novnc/novnc").default | undefined; | |
| 17 | let input: RTCDataChannel | undefined; | |
| 18 | let disconnect = () => {}; | |
| 19 | let width = 0; | |
| 20 | let height = 0; | |
| 21 | let buttons = 0; | |
| 15 | 22 | let disposed = false; |
| 23 | let cursorFrames: { image: string; invert?: string; duration: number }[] = []; | |
| 24 | let cursorFrame = 0; | |
| 25 | let cursorTimer: number | undefined; | |
| 26 | let cursorX = 0; | |
| 27 | let cursorY = 0; | |
| 28 | let pointerX = 0; | |
| 29 | let pointerY = 0; | |
| 30 | let cursorInside = false; | |
| 31 | let cursorVisible = true; | |
| 32 | const drawCursor = () => { | |
| 33 | const frame = cursorFrames[cursorFrame]; | |
| 34 | const overlay = !!frame?.invert && cursorInside && cursorVisible; | |
| 35 | cursorImage.style.display = cursorInvert.style.display = overlay ? "block" : "none"; | |
| 36 | if (!frame || !cursorVisible) { video.style.cursor = cursorVisible ? "default" : "none"; return; } | |
| 37 | video.style.cursor = frame.invert ? (cursorInside ? "none" : "default") : `url(${frame.image}) ${cursorX} ${cursorY}, default`; | |
| 38 | if (overlay) { | |
| 39 | cursorImage.src = frame.image; | |
| 40 | cursorInvert.src = frame.invert!; | |
| 41 | cursorImage.style.transform = cursorInvert.style.transform = `translate(${pointerX - cursorX}px, ${pointerY - cursorY}px)`; | |
| 42 | } | |
| 43 | }; | |
| 44 | const animateCursor = () => { | |
| 45 | const frame = cursorFrames[cursorFrame]; | |
| 46 | if (!frame) return; | |
| 47 | cursorTimer = window.setTimeout(() => { | |
| 48 | cursorFrame = (cursorFrame + 1) % cursorFrames.length; | |
| 49 | drawCursor(); | |
| 50 | animateCursor(); | |
| 51 | }, frame.duration); | |
| 52 | }; | |
| 16 | 53 | const [state, setState] = createSignal<"Connecting…" | "Connected" | "Disconnected">("Connecting…"); |
| 17 | 54 | const connected = () => state() === "Connected"; |
| 18 | 55 | const [problem, setProblem] = createSignal(""); |
| 19 | 56 | const [clipboard, setClipboard] = createSignal(""); |
| 20 | 57 | const [showClipboard, setShowClipboard] = createSignal(false); |
| 21 | 58 | const [tools, setTools] = createSignal<HTMLDivElement>(); |
| 59 | const send = (message: object) => { if (input?.readyState === "open") input.send(JSON.stringify(message)); }; | |
| 60 | const release = () => { buttons = 0; send({ type: "release" }); }; | |
| 22 | 61 | const connect = async () => { |
| 23 | 62 | const current = ++attempt; |
| 24 | const previous = connection; | |
| 25 | connection = undefined; | |
| 26 | previous?.disconnect(); | |
| 63 | disconnect(); | |
| 64 | input = undefined; | |
| 27 | 65 | setProblem(""); |
| 28 | 66 | setState("Connecting…"); |
| 29 | 67 | try { |
| 30 | const { default: RFB } = await import("@novnc/novnc"); | |
| 31 | 68 | if (disposed || !enabled() || current !== attempt) return; |
| 69 | if (!window.RTCPeerConnection) { | |
| 70 | setProblem("This browser cannot stream VM screens. Open it in a current browser."); | |
| 71 | setState("Disconnected"); | |
| 72 | return; | |
| 73 | } | |
| 32 | 74 | const url = new URL(`/api/vms/${encodeURIComponent(props.name)}/console`, location.href); |
| 33 | 75 | url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; |
| 34 | const rfb = new RFB(screen, url.href, { shared: true, wsProtocols: ["binary"] }); | |
| 35 | connection = rfb; | |
| 36 | rfb.scaleViewport = true; | |
| 37 | rfb.background = "#101014"; | |
| 38 | rfb.addEventListener("connect", () => { | |
| 39 | if (connection !== rfb) return; | |
| 40 | setState("Connected"); | |
| 41 | }); | |
| 42 | rfb.addEventListener("disconnect", () => { | |
| 43 | if (connection !== rfb || disposed) return; | |
| 76 | const socket = new WebSocket(url.href, ["binary"]); | |
| 77 | socket.binaryType = "arraybuffer"; | |
| 78 | const keyboard = new RemoteKeyboard(video); | |
| 79 | const focus = () => { if (input?.readyState === "open") keyboard.grab(); }; | |
| 80 | const blur = () => { keyboard.ungrab(); release(); }; | |
| 81 | video.addEventListener("focus", focus); | |
| 82 | video.addEventListener("blur", blur); | |
| 83 | let peer: RTCPeerConnection | undefined; | |
| 84 | let pending = ""; | |
| 85 | let messages = Promise.resolve(); | |
| 86 | const decoder = new TextDecoder(); | |
| 87 | const candidates: RTCIceCandidateInit[] = []; | |
| 88 | const signal = (message: object) => socket.send(JSON.stringify(message) + "\n"); | |
| 89 | const fail = (message: string) => { | |
| 90 | if (current !== attempt || disposed) return; | |
| 44 | 91 | setState("Disconnected"); |
| 45 | setProblem("The screen disconnected. Check that the VM is running, then reconnect."); | |
| 46 | }); | |
| 47 | rfb.addEventListener("credentialsrequired", () => { | |
| 48 | if (connection !== rfb || disposed) return; | |
| 49 | setProblem("This screen requires a VNC password. Remove it in the VM's display settings, then reconnect."); | |
| 50 | rfb.disconnect(); | |
| 51 | }); | |
| 52 | rfb.addEventListener("clipboard", (event: Event) => { | |
| 53 | if (connection !== rfb || disposed) return; | |
| 54 | setClipboard((event as CustomEvent<{ text: string }>).detail.text); | |
| 55 | }); | |
| 92 | setProblem(message); | |
| 93 | disconnect(); | |
| 94 | }; | |
| 95 | const timeout = window.setTimeout(() => fail("The screen couldn't connect. Reconnect to try again."), 20000); | |
| 96 | disconnect = () => { | |
| 97 | clearTimeout(timeout); | |
| 98 | blur(); | |
| 99 | video.removeEventListener("focus", focus); | |
| 100 | video.removeEventListener("blur", blur); | |
| 101 | socket.onclose = null; | |
| 102 | socket.close(); | |
| 103 | peer?.close(); | |
| 104 | video.onplaying = null; | |
| 105 | video.srcObject = null; | |
| 106 | clearTimeout(cursorTimer); | |
| 107 | cursorFrames = []; | |
| 108 | cursorFrame = 0; | |
| 109 | cursorVisible = true; | |
| 110 | cursorImage.style.display = cursorInvert.style.display = "none"; | |
| 111 | video.style.cursor = "default"; | |
| 112 | }; | |
| 113 | socket.onclose = () => fail("The screen disconnected. Check that the VM is running, then reconnect."); | |
| 114 | socket.onerror = () => fail("Unable to connect the screen. Reconnect to try again."); | |
| 115 | socket.onmessage = (event: MessageEvent<ArrayBuffer>) => { | |
| 116 | pending += decoder.decode(event.data, { stream: true }); | |
| 117 | if (pending.length > 262144) { fail("The screen connection stopped. Reconnect to try again."); return; } | |
| 118 | let end: number; | |
| 119 | while ((end = pending.indexOf("\n")) !== -1) { | |
| 120 | const line = pending.slice(0, end); | |
| 121 | pending = pending.slice(end + 1); | |
| 122 | messages = messages.then(async () => { | |
| 123 | if (current !== attempt || disposed) return; | |
| 124 | const message = JSON.parse(line); | |
| 125 | if (message.type === "ready") { | |
| 126 | peer = new RTCPeerConnection({ iceServers: message.iceServers }); | |
| 127 | const transceiver = peer.addTransceiver("video", { direction: "recvonly" }); | |
| 128 | const codecs = RTCRtpReceiver.getCapabilities("video")?.codecs.filter((codec) => codec.mimeType === "video/H264"); | |
| 129 | if (codecs?.length) transceiver.setCodecPreferences(codecs); | |
| 130 | input = peer.createDataChannel("input"); | |
| 131 | keyboard.onkeyevent = (key, _code, down) => send({ type: "key", key, down }); | |
| 132 | input.onopen = () => { if (document.activeElement === video) focus(); }; | |
| 133 | peer.onicecandidate = ({ candidate }) => { if (candidate) signal({ type: "candidate", ...candidate.toJSON() }); }; | |
| 134 | peer.onconnectionstatechange = () => { | |
| 135 | if (peer?.connectionState === "failed" || peer?.connectionState === "disconnected") { | |
| 136 | fail("The screen disconnected. Reconnect to try again."); | |
| 137 | } | |
| 138 | }; | |
| 139 | peer.ontrack = ({ track }) => { | |
| 140 | video.srcObject = new MediaStream([track]); | |
| 141 | void video.play().catch(() => { | |
| 142 | if (current === attempt && video.srcObject) fail("The browser couldn't play the screen. Reconnect to try again."); | |
| 143 | }); | |
| 144 | }; | |
| 145 | await peer.setLocalDescription(await peer.createOffer()); | |
| 146 | signal({ type: "offer", sdp: peer.localDescription!.sdp }); | |
| 147 | } else if (message.type === "answer" && peer) { | |
| 148 | await peer.setRemoteDescription({ type: "answer", sdp: message.sdp }); | |
| 149 | for (const candidate of candidates.splice(0)) await peer.addIceCandidate(candidate); | |
| 150 | } else if (message.type === "candidate") { | |
| 151 | if (peer?.remoteDescription) await peer.addIceCandidate(message); | |
| 152 | else candidates.push(message); | |
| 153 | } else if (message.type === "display") { | |
| 154 | width = message.width; | |
| 155 | height = message.height; | |
| 156 | video.dataset.encoder = message.encoder; | |
| 157 | video.dataset.source = message.source ?? "qemu"; | |
| 158 | } else if (message.type === "cursor") { | |
| 159 | if (message.frame === 0) { | |
| 160 | clearTimeout(cursorTimer); | |
| 161 | cursorFrames = []; | |
| 162 | cursorFrame = 0; | |
| 163 | cursorX = message.x; | |
| 164 | cursorY = message.y; | |
| 165 | if (message.source !== "guest") cursorVisible = !!message.fallback || (!!message.width && !!message.height); | |
| 166 | video.dataset.cursorSource = message.source; | |
| 167 | } | |
| 168 | if (!message.image) { drawCursor(); return; } | |
| 169 | for (const url of [message.image, message.invert].filter(Boolean)) { | |
| 170 | const image = new Image(); | |
| 171 | image.src = url; | |
| 172 | await image.decode(); | |
| 173 | } | |
| 174 | if (current !== attempt || disposed) return; | |
| 175 | cursorFrames.push({ image: message.image, invert: message.invert, duration: message.duration }); | |
| 176 | if (message.frame === 0) drawCursor(); | |
| 177 | if (cursorFrames.length === message.frames && message.frames > 1) animateCursor(); | |
| 178 | } else if (message.type === "cursor-position") { | |
| 179 | cursorVisible = message.visible; | |
| 180 | drawCursor(); | |
| 181 | } else if (message.type === "clipboard") setClipboard(message.text); | |
| 182 | else if (message.type === "error") { | |
| 183 | if (message.fatal) fail(message.message); | |
| 184 | else setProblem(message.message); | |
| 185 | } | |
| 186 | }).catch((failure) => { | |
| 187 | console.error("VM screen connection", failure); | |
| 188 | fail("The browser couldn't connect the screen. Reconnect to try again."); | |
| 189 | }); | |
| 190 | } | |
| 191 | }; | |
| 192 | video.onplaying = () => { | |
| 193 | if (current !== attempt || disposed) return; | |
| 194 | clearTimeout(timeout); | |
| 195 | setState("Connected"); | |
| 196 | if (props.active) video.focus(); | |
| 197 | }; | |
| 56 | 198 | } catch (failure) { |
| 57 | setProblem(`Unable to connect. ${reason(failure)}`); | |
| 199 | console.error("VM screen connection", failure); | |
| 200 | disconnect(); | |
| 201 | setProblem("The browser couldn't connect the screen. Reconnect to try again."); | |
| 58 | 202 | setState("Disconnected"); |
| 59 | 203 | } |
| 60 | 204 | }; |
| ... | ... | @@ -62,21 +206,38 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole |
| 62 | 206 | if (enabled()) void connect(); |
| 63 | 207 | else { |
| 64 | 208 | attempt++; |
| 65 | const previous = connection; | |
| 66 | connection = undefined; | |
| 67 | previous?.disconnect(); | |
| 209 | disconnect(); | |
| 210 | input = undefined; | |
| 68 | 211 | setState("Disconnected"); |
| 69 | 212 | setProblem(""); |
| 70 | 213 | } |
| 71 | 214 | }); |
| 72 | createEffect(() => { if (props.active && connected()) connection?.focus(); }); | |
| 73 | onCleanup(() => { disposed = true; connection?.disconnect(); }); | |
| 215 | createEffect(() => { if (props.active && connected()) video.focus(); else release(); }); | |
| 216 | onCleanup(() => { disposed = true; disconnect(); }); | |
| 217 | const pointer = (event: PointerEvent | WheelEvent, mask = buttons) => { | |
| 218 | if (!connected() || !width || !height) return; | |
| 219 | const rect = video.getBoundingClientRect(); | |
| 220 | const scale = Math.min(rect.width / width, rect.height / height); | |
| 221 | const x = Math.floor((event.clientX - rect.left - (rect.width - width * scale) / 2) / scale); | |
| 222 | const y = Math.floor((event.clientY - rect.top - (rect.height - height * scale) / 2) / scale); | |
| 223 | pointerX = event.clientX; | |
| 224 | pointerY = event.clientY; | |
| 225 | cursorInside = x >= 0 && y >= 0 && x < width && y < height; | |
| 226 | drawCursor(); | |
| 227 | if ((event.type === "pointerdown" || event.type === "wheel") && (x < 0 || y < 0 || x >= width || y >= height)) return false; | |
| 228 | send({ type: "pointer", x, y, buttons: mask }); | |
| 229 | return true; | |
| 230 | }; | |
| 231 | const ctrlAltDel = () => { | |
| 232 | for (const key of [0xffe3, 0xffe9, 0xffff]) send({ type: "key", key, down: true }); | |
| 233 | for (const key of [0xffff, 0xffe9, 0xffe3]) send({ type: "key", key, down: false }); | |
| 234 | }; | |
| 74 | 235 | return <div class="vm-console" ref={panel}> |
| 75 | 236 | <div class="vm-console-toolbar" ref={setTools}> |
| 76 | 237 | {props.toolbar} |
| 77 | 238 | <Show when={props.active}> |
| 78 | 239 | <VMMenu label="Input"> |
| 79 | <button disabled={!connected()} onClick={() => connection?.sendCtrlAltDel()}><Keyboard size={14} aria-hidden="true" />Ctrl+Alt+Del</button> | |
| 240 | <button disabled={!connected()} onClick={ctrlAltDel}><Keyboard size={14} aria-hidden="true" />Ctrl+Alt+Del</button> | |
| 80 | 241 | <button disabled={!connected()} onClick={() => setShowClipboard(!showClipboard())}><Clipboard size={14} aria-hidden="true" />Clipboard</button> |
| 81 | 242 | </VMMenu> |
| 82 | 243 | <VMMenu label="View"> |
| ... | ... | @@ -93,12 +254,37 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole |
| 93 | 254 | <label class="field">clipboard<textarea class="search" rows="3" value={clipboard()} onInput={(event) => setClipboard(event.currentTarget.value)} /></label> |
| 94 | 255 | <span class="hint">Clipboard sharing requires support in the guest</span> |
| 95 | 256 | <div class="row"> |
| 96 | <button class="button small" disabled={!connected()} onClick={() => connection?.clipboardPasteFrom(clipboard())}>send text</button> | |
| 257 | <button class="button small" disabled={!connected()} onClick={() => send({ type: "clipboard", text: clipboard() })}>send text</button> | |
| 97 | 258 | <button class="button small" onClick={() => navigator.clipboard.writeText(clipboard()).catch((failure) => setProblem(reason(failure)))}>copy text</button> |
| 98 | 259 | </div> |
| 99 | 260 | </div> |
| 100 | 261 | </Show> |
| 101 | <div class="vm-console-screen" hidden={!props.active || !props.enabled} ref={screen} /> | |
| 262 | <div class="vm-console-screen" hidden={!props.active || !props.enabled}> | |
| 263 | <video ref={video} autoplay muted playsinline tabindex="0" aria-label="VM screen" | |
| 264 | onContextMenu={(event) => event.preventDefault()} | |
| 265 | onPointerMove={pointer} | |
| 266 | onPointerLeave={() => { cursorInside = false; drawCursor(); }} | |
| 267 | onPointerDown={(event) => { | |
| 268 | event.preventDefault(); | |
| 269 | const mask = buttons | (event.button === 0 ? 1 : event.button === 1 ? 2 : event.button === 2 ? 4 : 0); | |
| 270 | if (!pointer(event, mask)) return; | |
| 271 | buttons = mask; | |
| 272 | video.focus(); video.setPointerCapture(event.pointerId); | |
| 273 | }} | |
| 274 | onPointerUp={(event) => { | |
| 275 | buttons &= ~(event.button === 0 ? 1 : event.button === 1 ? 2 : event.button === 2 ? 4 : 0); | |
| 276 | pointer(event); | |
| 277 | if (!event.buttons && video.hasPointerCapture(event.pointerId)) video.releasePointerCapture(event.pointerId); | |
| 278 | }} | |
| 279 | onPointerCancel={release} | |
| 280 | onWheel={(event) => { | |
| 281 | event.preventDefault(); | |
| 282 | const mask = event.deltaY ? (event.deltaY < 0 ? 8 : 16) : event.deltaX < 0 ? 32 : 64; | |
| 283 | pointer(event, buttons | mask); pointer(event); | |
| 284 | }} /> | |
| 285 | <img ref={cursorImage} class="vm-cursor" alt="" aria-hidden="true" /> | |
| 286 | <img ref={cursorInvert} class="vm-cursor vm-cursor-invert" alt="" aria-hidden="true" /> | |
| 287 | </div> | |
| 102 | 288 | <Show when={tools()}>{(toolbar) => props.content(toolbar())}</Show> |
| 103 | 289 | <Show when={props.active && !props.enabled}><div class="empty"><p>The VM is off. Start it from the Machine menu.</p></div></Show> |
| 104 | 290 | </div>; |
dashboard/web/pages/VMs.css+3| ... | ... | @@ -94,6 +94,9 @@ |
| 94 | 94 | .vm-password { display: grid; gap: 6px; padding: 8px 10px; color: var(--muted); font-size: 11px; } |
| 95 | 95 | .vm-password input { width: 26ch; min-width: 0; padding: 5px 6px; color: var(--text); font: 12px monospace; } |
| 96 | 96 | .vm-console-screen { flex: 1; background: #101014; min-height: 0; } |
| 97 | .vm-console-screen video { display: block; width: 100%; height: 100%; object-fit: contain; outline: none; touch-action: none; } | |
| 98 | .vm-cursor { display: none; position: fixed; left: 0; top: 0; pointer-events: none; } | |
| 99 | .vm-cursor-invert { mix-blend-mode: difference; } | |
| 97 | 100 | .vm-console-error { padding: 8px 12px; margin: 0; font-size: 12px; } |
| 98 | 101 | .vm-clipboard { display: grid; gap: 8px; padding: 12px; } |
| 99 | 102 | @media (max-width: 640px) { |
dashboard/web/types/novnc.d.ts+6-10| ... | ... | @@ -1,12 +1,8 @@ |
| 1 | declare module "@novnc/novnc" { | |
| 2 | export default class RFB extends EventTarget { | |
| 3 | constructor(target: HTMLElement, url: string, options?: { shared?: boolean; wsProtocols?: string[] }); | |
| 4 | scaleViewport: boolean; | |
| 5 | resizeSession: boolean; | |
| 6 | background: string; | |
| 7 | disconnect(): void; | |
| 8 | focus(): void; | |
| 9 | sendCtrlAltDel(): void; | |
| 10 | clipboardPasteFrom(text: string): void; | |
| 1 | declare module "@novnc/keyboard" { | |
| 2 | export default class Keyboard { | |
| 3 | constructor(target: HTMLElement); | |
| 4 | onkeyevent: (keysym: number, code: string, down: boolean) => void; | |
| 5 | grab(): void; | |
| 6 | ungrab(): void; | |
| 11 | 7 | } |
| 12 | 8 | } |
guest/.gitignore created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | build/ |
guest/DESIGN.md created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | # Guest cursor stream | |
| 2 | ||
| 3 | The executable is freestanding C99. `cursor.c` contains pixel conversion and dirty-rectangle detection; `windows.c` owns Win32 capture and transport. Zig supplies the cross compiler and Windows headers, with no Zig or C runtime in the executable. x86 and x64 target Windows 7; ARM64 targets Windows 10. PE subsystem versions and imported DLLs are checked by `python3 guest/test.py`. Runtime validation currently uses Windows 10, not a Windows 7 fixture. | |
| 4 | ||
| 5 | Build with `python3 guest/build.py --arch x86_64` (also `x86` and `aarch64`). In an elevated PowerShell session inside the logged-in administrator's desktop, run `./install.ps1 -Binary ./build/snowglobe-guest-x86_64.exe`. The installer uses Task Scheduler COM APIs available to PowerShell 2, installs into protected Program Files, and starts an elevated task at that user's logon. `-Remove` removes that task and executable. Elevation is required by the VirtIO serial driver's device ACL; capture must run in the interactive session, not session 0. The guest needs a compatible VirtIO serial driver and the channel named in `protocol.json`. A standard-user launcher requires a privileged handle broker; it is not part of this first backend. | |
| 6 | ||
| 7 | `BitBlt` copies the primary desktop into a CPU DIB without drawing the cursor. RGB comparisons ignore the unused fourth byte. Unchanged pixels produce no screen packets; changed pixels produce one bounding rectangle. Cursor polling runs every 8 ms; screen capture runs at most 30 Hz. The host's existing encoder supplies WebRTC video. Secure desktops, RDP sessions that differ from the physical console, and capture failures return the viewer to QEMU capture. | |
| 8 | ||
| 9 | `GetCursorInfo` supplies the shape, visibility, and observed position. `GetIconInfo` and black/white `DrawIconEx` renders recover RGBA and binary XOR planes, including colored XOR. Nonbinary color XOR and unsupported cursor sizes explicitly fall back to the browser's default cursor, preserving video. Animation timing uses the optional `GetCursorFrameInfo` user32 export, resolved by name. Missing or unusable animation metadata falls back to a static frame. Animation starts a browser-local clock; Windows does not expose the current animation phase through this interface. Ordinary shapes use the browser cursor; XOR shapes use local layers with difference blending. Neither follows streamed positions. | |
| 10 | ||
| 11 | ## Wire format | |
| 12 | ||
| 13 | `protocol.json` is the only home for packet IDs, field order, channel name, flags, and bounds. The build generates its private C header; the host reads that same schema. Each packet begins with the four-byte magic, a big-endian uint32 type, and a big-endian uint32 payload length. Fields listed in the schema are big-endian uint32 values. | |
| 14 | ||
| 15 | - Screen: tightly packed top-down BGRx rectangle rows following the listed fields. A connection, refresh request, resize, or resumed capture starts with a full frame. | |
| 16 | - Cursor: each frame is a uint32 duration in milliseconds, width × height RGBA pixels, then one XOR bit mask byte per pixel. The schema assigns the channel bits; each selected channel inverts the underlying browser pixel. Zero dimensions and zero frames mean unsupported shape/default cursor. | |
| 17 | - Pointer: coordinates use signed two's-complement values in the uint32 fields. These are observations, not a cursor hit map. | |
| 18 | - Suspend and heartbeat have empty payloads. A heartbeat arrives at least once per second; four seconds without a packet falls back to QEMU. | |
| 19 | - Refresh is the only host-to-guest message and has an empty payload. The agent validates the entire request. The host requests it on every connection, including reconnects that reuse an open guest port. | |
| 20 | ||
| 21 | The host broker validates a running VM's libvirt-owned channel path. One guest reader fans out to that VM's viewers. Guest bytes are untrusted and bounded before decoding; viewer buffers are bounded independently. No guest network listener or GPU is required. | |
| 22 | ||
| 23 | ## Later backends and surfaces | |
| 24 | ||
| 25 | Keep the core independent of an OS and add real platform implementations when their capture and cursor APIs can be tested. X11 cursor notifications, Wayland's compositor/portal permissions, and macOS capture permissions need separate backend decisions, not empty adapters. The next Windows experiment can enumerate HWNDs and attach full surfaces, geometry, stacking, and dirty regions to this transport; occlusion, layered windows, protected content, DPI, and secure desktops need explicit behavior. A window rectangle does not imply a uniform cursor: application hit testing can change the shape anywhere inside it. Region prediction should be based on validated application information or observations with invalidation, not fabricated bounding boxes. |
guest/build.py created+36| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | import argparse | |
| 3 | import json | |
| 4 | from pathlib import Path | |
| 5 | import re | |
| 6 | import subprocess | |
| 7 | ||
| 8 | ||
| 9 | parser = argparse.ArgumentParser() | |
| 10 | parser.add_argument("--arch", choices=["x86", "x86_64", "aarch64"], default="x86_64") | |
| 11 | parser.add_argument("--output", type=Path, default=Path(__file__).parent / "build") | |
| 12 | args = parser.parse_args() | |
| 13 | source = Path(__file__).resolve().parent | |
| 14 | args.output.mkdir(parents=True, exist_ok=True) | |
| 15 | schema = json.loads((source / "protocol.json").read_text()) | |
| 16 | header = ["#ifndef SG_PROTOCOL_H", "#define SG_PROTOCOL_H"] | |
| 17 | header += [f"#define SG_MAX_{key.upper()} {value}u" for key, value in schema["limits"].items()] | |
| 18 | header += [f"#define SG_XOR_{key.upper()} {value}u" for key, value in schema["xor_bits"].items()] | |
| 19 | for name, packet in schema["packets"].items(): | |
| 20 | header += [f"#define SG_{name.upper()} {packet['id']}u", f"#define SG_{name.upper()}_WORDS {len(packet['fields'])}u"] | |
| 21 | header += [f"#define SG_{name.upper()}_{field.upper()} {index}u" for index, field in enumerate(packet["fields"])] | |
| 22 | header += [f'#define SG_MAGIC "{schema["magic"]}"', f'#define SG_CHANNEL L"{schema["channel"]}"', "#endif"] | |
| 23 | (args.output / "protocol.h").write_text("\n".join(header) + "\n") | |
| 24 | subsystem = "10.0" if args.arch == "aarch64" else "6.1" | |
| 25 | environment = subprocess.check_output(["zig", "env"], text=True) | |
| 26 | match = re.search(r'\.lib_dir\s*=\s*"([^"]+)"', environment) | |
| 27 | library = Path(match[1] if match else json.loads(environment)["lib_dir"]) | |
| 28 | subprocess.run([ | |
| 29 | "zig", "cc", "-target", args.arch + ("-windows.win10-gnu" if args.arch == "aarch64" else "-windows.win7-gnu"), "-std=c99", "-Os", "-Wall", "-Wextra", "-Werror", | |
| 30 | "-ffreestanding", "-fno-stack-protector", "-nostdlib", "-D_WIN32_WINNT=0x0601", "-DWINVER=0x0601", | |
| 31 | "-isystem", str(library / "libc/include/any-windows-any"), | |
| 32 | "-I", str(args.output), str(source / "cursor.c"), str(source / "windows.c"), | |
| 33 | "-lkernel32", "-luser32", "-lgdi32", "-Wl,--entry,mainCRTStartup", "-Wl,--subsystem,windows", | |
| 34 | "-Wl,--major-subsystem-version," + subsystem.split(".")[0], "-Wl,--minor-subsystem-version," + subsystem.split(".")[1], | |
| 35 | "-o", str(args.output / ("snowglobe-guest-" + args.arch + ".exe")), | |
| 36 | ], check=True) |
guest/cursor.c created+70| ... | ... | @@ -0,0 +1,70 @@ |
| 1 | #include "cursor.h" | |
| 2 | ||
| 3 | void sg_word(uint8_t *bytes, uint32_t value) { | |
| 4 | bytes[0] = (uint8_t)(value >> 24); | |
| 5 | bytes[1] = (uint8_t)(value >> 16); | |
| 6 | bytes[2] = (uint8_t)(value >> 8); | |
| 7 | bytes[3] = (uint8_t)value; | |
| 8 | } | |
| 9 | ||
| 10 | int sg_difference(const uint8_t *before, const uint8_t *after, uint32_t width, uint32_t height, struct sg_rect *rect) { | |
| 11 | uint32_t x, y, left = width, top = height, right = 0, bottom = 0; | |
| 12 | if (!width || !height || width > SG_MAX_DIMENSION || height > SG_MAX_DIMENSION || width * height > SG_MAX_PIXELS) return -1; | |
| 13 | for (y = 0; y < height; ++y) { | |
| 14 | for (x = 0; x < width; ++x) { | |
| 15 | size_t pixel = ((size_t)y * width + x) * 4; | |
| 16 | if (before[pixel] == after[pixel] && before[pixel + 1] == after[pixel + 1] && before[pixel + 2] == after[pixel + 2]) continue; | |
| 17 | if (x < left) left = x; | |
| 18 | if (y < top) top = y; | |
| 19 | if (x >= right) right = x + 1; | |
| 20 | if (y >= bottom) bottom = y + 1; | |
| 21 | } | |
| 22 | } | |
| 23 | if (left == width) return 0; | |
| 24 | rect->x = left; rect->y = top; rect->width = right - left; rect->height = bottom - top; | |
| 25 | return 1; | |
| 26 | } | |
| 27 | ||
| 28 | int sg_rgba(const uint8_t *black, const uint8_t *white, uint8_t *rgba, uint8_t *invert, size_t pixels) { | |
| 29 | size_t i; | |
| 30 | for (i = 0; i < pixels; ++i) { | |
| 31 | int difference = (int)white[i * 4] - black[i * 4]; | |
| 32 | int green = (int)white[i * 4 + 1] - black[i * 4 + 1]; | |
| 33 | int red = (int)white[i * 4 + 2] - black[i * 4 + 2]; | |
| 34 | unsigned alpha; | |
| 35 | invert[i] = 0; | |
| 36 | if (difference < 0 || green < 0 || red < 0 || green - difference > 1 || difference - green > 1 || red - difference > 1 || difference - red > 1) { | |
| 37 | unsigned channel; | |
| 38 | for (channel = 0; channel < 3; ++channel) { | |
| 39 | uint8_t value = black[i * 4 + channel]; | |
| 40 | if ((value != 0 && value != 255) || (value ^ white[i * 4 + channel]) != 255) return 0; | |
| 41 | } | |
| 42 | invert[i] = (black[i * 4 + 2] ? SG_XOR_RED : 0) | (black[i * 4 + 1] ? SG_XOR_GREEN : 0) | (black[i * 4] ? SG_XOR_BLUE : 0); | |
| 43 | alpha = 0; | |
| 44 | } else alpha = 255u - (unsigned)((difference + green + red + 1) / 3); | |
| 45 | rgba[i * 4 + 3] = (uint8_t)alpha; | |
| 46 | { | |
| 47 | unsigned channel; | |
| 48 | for (channel = 0; channel < 3; ++channel) { | |
| 49 | unsigned value = alpha ? (black[i * 4 + 2 - channel] * 255u + alpha / 2) / alpha : 0; | |
| 50 | rgba[i * 4 + channel] = (uint8_t)(value > 255 ? 255 : value); | |
| 51 | } | |
| 52 | } | |
| 53 | } | |
| 54 | return 1; | |
| 55 | } | |
| 56 | ||
| 57 | void *memcpy(void *destination, const void *source, size_t length) { | |
| 58 | uint8_t *out = destination; | |
| 59 | const uint8_t *in = source; | |
| 60 | size_t i; | |
| 61 | for (i = 0; i < length; ++i) out[i] = in[i]; | |
| 62 | return destination; | |
| 63 | } | |
| 64 | ||
| 65 | void *memset(void *destination, int value, size_t length) { | |
| 66 | uint8_t *out = destination; | |
| 67 | size_t i; | |
| 68 | for (i = 0; i < length; ++i) out[i] = (uint8_t)value; | |
| 69 | return destination; | |
| 70 | } |
guest/cursor.h created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | #ifndef SG_CURSOR_H | |
| 2 | #define SG_CURSOR_H | |
| 3 | #include <stddef.h> | |
| 4 | #include <stdint.h> | |
| 5 | #include "protocol.h" | |
| 6 | ||
| 7 | struct sg_rect { uint32_t x, y, width, height; }; | |
| 8 | int sg_difference(const uint8_t *before, const uint8_t *after, uint32_t width, uint32_t height, struct sg_rect *rect); | |
| 9 | int sg_rgba(const uint8_t *black, const uint8_t *white, uint8_t *rgba, uint8_t *invert, size_t pixels); | |
| 10 | void sg_word(uint8_t *bytes, uint32_t value); | |
| 11 | #endif |
guest/install.ps1 created+38| ... | ... | @@ -0,0 +1,38 @@ |
| 1 | param([string]$Binary, [switch]$Remove) | |
| 2 | $ErrorActionPreference = 'Stop' | |
| 3 | $identity = [Security.Principal.WindowsIdentity]::GetCurrent() | |
| 4 | $principal = New-Object Security.Principal.WindowsPrincipal($identity) | |
| 5 | if (!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run this installer as administrator to access the VirtIO serial port.' } | |
| 6 | if ((Get-WmiObject Win32_ComputerSystem).UserName -ne $identity.Name) { throw 'Run this installer from the signed-in administrator account on the VM desktop.' } | |
| 7 | $directory = Join-Path $env:ProgramFiles 'Snowglobe\Guest' | |
| 8 | $destination = Join-Path $directory 'snowglobe-guest.exe' | |
| 9 | $service = New-Object -ComObject Schedule.Service | |
| 10 | $service.Connect() | |
| 11 | $folder = $service.GetFolder('\') | |
| 12 | $name = 'SnowglobeGuest-' + $identity.User.Value | |
| 13 | if ($Remove) { | |
| 14 | try { $folder.GetTask($name).Stop(0); $folder.DeleteTask($name, 0) } catch { if (Test-Path $destination) { throw } } | |
| 15 | $used = $false | |
| 16 | foreach ($task in $folder.GetTasks(1)) { | |
| 17 | foreach ($action in $task.Definition.Actions) { if ($action.Path -eq $destination) { $used = $true } } | |
| 18 | } | |
| 19 | if (!$used -and (Test-Path $destination)) { Remove-Item $destination } | |
| 20 | return | |
| 21 | } | |
| 22 | if (!$Binary) { throw 'Pass the path to the guest executable with -Binary.' } | |
| 23 | Get-WmiObject Win32_Process -Filter "name='snowglobe-guest.exe'" | Where-Object { $_.ExecutablePath -eq $destination } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force } | |
| 24 | New-Item -ItemType Directory -Path $directory -Force | Out-Null | |
| 25 | Copy-Item -LiteralPath $Binary -Destination $destination -Force | |
| 26 | $task = $service.NewTask(0) | |
| 27 | $task.Principal.UserId = $identity.User.Value | |
| 28 | $task.Principal.LogonType = 3 | |
| 29 | $task.Principal.RunLevel = 1 | |
| 30 | $task.Settings.ExecutionTimeLimit = 'PT0S' | |
| 31 | $task.Settings.DisallowStartIfOnBatteries = $false | |
| 32 | $task.Settings.StopIfGoingOnBatteries = $false | |
| 33 | $trigger = $task.Triggers.Create(9) | |
| 34 | $trigger.UserId = $identity.User.Value | |
| 35 | $action = $task.Actions.Create(0) | |
| 36 | $action.Path = $destination | |
| 37 | $registered = $folder.RegisterTaskDefinition($name, $task, 6, $identity.User.Value, $null, 3, $null) | |
| 38 | $registered.Run($null) | Out-Null |
guest/protocol.json created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | { | |
| 2 | "magic": "SGV1", | |
| 3 | "channel": "net.paperclover.snowglobe.0", | |
| 4 | "xor_bits": {"red": 1, "green": 2, "blue": 4}, | |
| 5 | "limits": {"dimension": 4096, "pixels": 8294400, "cursor_size": 128, "cursor_frames": 64, "packet_bytes": 33554456}, | |
| 6 | "packets": { | |
| 7 | "screen": {"id": 1, "fields": ["width", "height", "x", "y", "rect_width", "rect_height"]}, | |
| 8 | "cursor": {"id": 2, "fields": ["id", "width", "height", "hot_x", "hot_y", "frames"]}, | |
| 9 | "pointer": {"id": 3, "fields": ["id", "x", "y", "visible"]}, | |
| 10 | "suspend": {"id": 4, "fields": []}, | |
| 11 | "refresh": {"id": 5, "fields": []}, | |
| 12 | "heartbeat": {"id": 6, "fields": []} | |
| 13 | } | |
| 14 | } |
guest/test.py created+36| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | from pathlib import Path | |
| 3 | import struct | |
| 4 | import subprocess | |
| 5 | import tempfile | |
| 6 | ||
| 7 | source = Path(__file__).resolve().parent | |
| 8 | for arch in ['x86', 'x86_64', 'aarch64']: | |
| 9 | subprocess.run(['python3', str(source / 'build.py'), '--arch', arch], check=True) | |
| 10 | data = (source / 'build' / ('snowglobe-guest-' + arch + '.exe')).read_bytes() | |
| 11 | pe = struct.unpack_from('<I', data, 60)[0] | |
| 12 | assert data[pe:pe + 4] == b'PE\0\0' | |
| 13 | machine, sections, _, _, _, optional_size = struct.unpack_from('<HHIIIH', data, pe + 4) | |
| 14 | optional = pe + 24 | |
| 15 | assert machine == {'x86': 0x14c, 'x86_64': 0x8664, 'aarch64': 0xaa64}[arch] | |
| 16 | assert struct.unpack_from('<HH', data, optional + 48) == ((10, 0) if arch == 'aarch64' else (6, 1)) | |
| 17 | table = optional + optional_size | |
| 18 | def offset(rva): | |
| 19 | for index in range(sections): | |
| 20 | size, start, raw_size, raw = struct.unpack_from('<IIII', data, table + 40 * index + 8) | |
| 21 | if start <= rva < start + max(size, raw_size): return raw + rva - start | |
| 22 | raise AssertionError('unmapped PE address') | |
| 23 | directory = optional + (96 if arch == 'x86' else 112) | |
| 24 | imports = offset(struct.unpack_from('<I', data, directory + 8)[0]) | |
| 25 | libraries = set() | |
| 26 | while any(data[imports:imports + 20]): | |
| 27 | name = offset(struct.unpack_from('<I', data, imports + 12)[0]) | |
| 28 | libraries.add(data[name:data.index(0, name)].decode().lower()) | |
| 29 | imports += 20 | |
| 30 | assert libraries == {'kernel32.dll', 'user32.dll', 'gdi32.dll'}, libraries | |
| 31 | print(arch, len(data), 'bytes; system DLLs only; minimum subsystem verified') | |
| 32 | with tempfile.TemporaryDirectory() as temporary: | |
| 33 | binary = Path(temporary) / 'cursor-tests' | |
| 34 | subprocess.run(['cc', '-std=c99', '-Wall', '-Wextra', '-Werror', '-ffreestanding', '-fsanitize=address,undefined', '-I', str(source / 'build'), str(source / 'cursor.c'), str(source / 'tests.c'), '-o', str(binary)], check=True) | |
| 35 | subprocess.run([str(binary)], check=True) | |
| 36 | print('cursor alpha, inversion, dirty rectangles, and bounds passed') |
guest/tests.c created+32| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | #include "cursor.h" | |
| 2 | #include <assert.h> | |
| 3 | ||
| 4 | int main(void) { | |
| 5 | uint8_t before[4 * 4 * 3] = {0}, after[4 * 4 * 3] = {0}; | |
| 6 | struct sg_rect rect; | |
| 7 | uint8_t black[] = {0,0,0,0, 255,255,255,0, 0,0,0,0, 20,40,60,0}; | |
| 8 | uint8_t white[] = {255,255,255,0, 0,0,0,0, 0,0,0,0, 147,167,187,0}; | |
| 9 | uint8_t rgba[16], invert[4], word[4]; | |
| 10 | assert(sg_difference(before, after, 4, 3, &rect) == 0); | |
| 11 | after[3] = 255; | |
| 12 | assert(sg_difference(before, after, 4, 3, &rect) == 0); | |
| 13 | after[(1 * 4 + 1) * 4] = 1; | |
| 14 | after[(2 * 4 + 3) * 4 + 2] = 2; | |
| 15 | assert(sg_difference(before, after, 4, 3, &rect) == 1); | |
| 16 | assert(rect.x == 1 && rect.y == 1 && rect.width == 3 && rect.height == 2); | |
| 17 | assert(sg_difference(before, after, 0, 3, &rect) == -1); | |
| 18 | assert(sg_difference(before, after, 65535, 65535, &rect) == -1); | |
| 19 | assert(sg_rgba(black, white, rgba, invert, 4)); | |
| 20 | assert(rgba[3] == 0 && invert[0] == 0); | |
| 21 | assert(rgba[7] == 0 && invert[1] == (SG_XOR_RED | SG_XOR_GREEN | SG_XOR_BLUE)); | |
| 22 | assert(rgba[11] == 255 && rgba[8] == 0 && invert[2] == 0); | |
| 23 | assert(rgba[12] == 120 && rgba[13] == 80 && rgba[14] == 40 && rgba[15] == 128 && invert[3] == 0); | |
| 24 | black[0] = 0; black[1] = 0; black[2] = 255; | |
| 25 | white[0] = 255; white[1] = 255; white[2] = 0; | |
| 26 | assert(sg_rgba(black, white, rgba, invert, 1) && invert[0] == SG_XOR_RED && rgba[3] == 0); | |
| 27 | black[2] = 127; white[2] = 128; | |
| 28 | assert(!sg_rgba(black, white, rgba, invert, 1)); | |
| 29 | sg_word(word, 0x12345678); | |
| 30 | assert(word[0] == 0x12 && word[1] == 0x34 && word[2] == 0x56 && word[3] == 0x78); | |
| 31 | return 0; | |
| 32 | } |
guest/windows.c created+241| ... | ... | @@ -0,0 +1,241 @@ |
| 1 | #define WIN32_LEAN_AND_MEAN | |
| 2 | #define UNICODE | |
| 3 | #define _UNICODE | |
| 4 | #include <windows.h> | |
| 5 | #include "cursor.h" | |
| 6 | ||
| 7 | typedef HCURSOR (WINAPI *cursor_frame_fn)(HCURSOR, DWORD, DWORD, DWORD *, DWORD *); | |
| 8 | static cursor_frame_fn cursor_frame; | |
| 9 | static HANDLE output; | |
| 10 | static HANDLE heap; | |
| 11 | static OVERLAPPED sending; | |
| 12 | ||
| 13 | static int write_bytes(const void *data, DWORD length) { | |
| 14 | const uint8_t *bytes = data; | |
| 15 | DWORD written; | |
| 16 | while (length) { | |
| 17 | ResetEvent(sending.hEvent); | |
| 18 | if (!WriteFile(output, bytes, length, &written, &sending)) { | |
| 19 | if (GetLastError() != ERROR_IO_PENDING) return 0; | |
| 20 | if (WaitForSingleObject(sending.hEvent, 3000) != WAIT_OBJECT_0) { | |
| 21 | CancelIo(output); WaitForSingleObject(sending.hEvent, INFINITE); return 0; | |
| 22 | } | |
| 23 | } | |
| 24 | if (!GetOverlappedResult(output, &sending, &written, FALSE)) return 0; | |
| 25 | if (!written) return 0; | |
| 26 | bytes += written; length -= written; | |
| 27 | } | |
| 28 | return 1; | |
| 29 | } | |
| 30 | ||
| 31 | static int packet(uint32_t type, uint32_t length, const uint32_t *words, uint32_t count) { | |
| 32 | uint8_t header[12], number[4]; | |
| 33 | uint32_t i; | |
| 34 | header[0] = SG_MAGIC[0]; header[1] = SG_MAGIC[1]; header[2] = SG_MAGIC[2]; header[3] = SG_MAGIC[3]; | |
| 35 | sg_word(header + 4, type); sg_word(header + 8, length); | |
| 36 | if (!write_bytes(header, sizeof(header))) return 0; | |
| 37 | for (i = 0; i < count; ++i) { | |
| 38 | sg_word(number, words[i]); | |
| 39 | if (!write_bytes(number, sizeof(number))) return 0; | |
| 40 | } | |
| 41 | return 1; | |
| 42 | } | |
| 43 | ||
| 44 | static int capture_cursor(HCURSOR cursor, uint32_t id) { | |
| 45 | ICONINFO icon = {0}; | |
| 46 | BITMAP bitmap; | |
| 47 | BITMAPINFO info = {0}; | |
| 48 | HDC dc = NULL; | |
| 49 | HBITMAP dib = NULL, previous = NULL; | |
| 50 | uint8_t *bits = NULL, *black = NULL, *frames = NULL; | |
| 51 | uint32_t width, height, count = 1, step, words[SG_CURSOR_WORDS] = {0}; | |
| 52 | DWORD rate = 6, total = 1; | |
| 53 | size_t pixels, frame_bytes; | |
| 54 | int success = -1; | |
| 55 | if (!GetIconInfo(cursor, &icon)) return -1; | |
| 56 | if (!GetObjectW(icon.hbmColor ? icon.hbmColor : icon.hbmMask, sizeof(bitmap), &bitmap)) goto done; | |
| 57 | width = (uint32_t)bitmap.bmWidth; | |
| 58 | height = (uint32_t)(icon.hbmColor ? bitmap.bmHeight : bitmap.bmHeight / 2); | |
| 59 | if (!width || !height || width > SG_MAX_CURSOR_SIZE || height > SG_MAX_CURSOR_SIZE || icon.xHotspot >= width || icon.yHotspot >= height) goto done; | |
| 60 | if (cursor_frame && cursor_frame(cursor, 0, 0, &rate, &total) && total > 1 && total <= SG_MAX_CURSOR_FRAMES) count = total; | |
| 61 | pixels = (size_t)width * height; | |
| 62 | frame_bytes = 4 + pixels * 5; | |
| 63 | frames = HeapAlloc(heap, 0, frame_bytes * count); | |
| 64 | black = HeapAlloc(heap, 0, pixels * 4); | |
| 65 | dc = CreateCompatibleDC(NULL); | |
| 66 | info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); info.bmiHeader.biWidth = (LONG)width; | |
| 67 | info.bmiHeader.biHeight = -(LONG)height; info.bmiHeader.biPlanes = 1; info.bmiHeader.biBitCount = 32; | |
| 68 | dib = CreateDIBSection(dc, &info, DIB_RGB_COLORS, (void **)&bits, NULL, 0); | |
| 69 | if (!frames || !black || !dc || !dib) goto done; | |
| 70 | previous = SelectObject(dc, dib); | |
| 71 | for (step = 0; step < count; ++step) { | |
| 72 | uint8_t *frame = frames + frame_bytes * step; | |
| 73 | size_t i; | |
| 74 | HCURSOR image = cursor; | |
| 75 | rate = 6; | |
| 76 | if (count > 1) { | |
| 77 | image = cursor_frame(cursor, 0, step, &rate, &total); | |
| 78 | if (!image || total != count || !rate || rate > 3600) { image = cursor; count = 1; rate = 6; } | |
| 79 | } | |
| 80 | sg_word(frame, (rate * 1000u + 30u) / 60u); | |
| 81 | for (i = 0; i < pixels * 4; ++i) bits[i] = 0; | |
| 82 | if (!DrawIconEx(dc, 0, 0, image, (int)width, (int)height, 0, NULL, DI_NORMAL)) goto done; | |
| 83 | GdiFlush(); | |
| 84 | for (i = 0; i < pixels * 4; ++i) black[i] = bits[i]; | |
| 85 | for (i = 0; i < pixels * 4; ++i) bits[i] = 255; | |
| 86 | if (!DrawIconEx(dc, 0, 0, image, (int)width, (int)height, 0, NULL, DI_NORMAL)) goto done; | |
| 87 | GdiFlush(); | |
| 88 | if (!sg_rgba(black, bits, frame + 4, frame + 4 + pixels * 4, pixels)) goto done; | |
| 89 | } | |
| 90 | words[SG_CURSOR_ID] = id; words[SG_CURSOR_WIDTH] = width; words[SG_CURSOR_HEIGHT] = height; | |
| 91 | words[SG_CURSOR_HOT_X] = icon.xHotspot; words[SG_CURSOR_HOT_Y] = icon.yHotspot; words[SG_CURSOR_FRAMES] = count; | |
| 92 | success = packet(SG_CURSOR, SG_CURSOR_WORDS * 4 + (uint32_t)(frame_bytes * count), words, SG_CURSOR_WORDS) && write_bytes(frames, (DWORD)(frame_bytes * count)); | |
| 93 | done: | |
| 94 | if (previous) SelectObject(dc, previous); | |
| 95 | if (dib) DeleteObject(dib); | |
| 96 | if (dc) DeleteDC(dc); | |
| 97 | if (icon.hbmColor) DeleteObject(icon.hbmColor); | |
| 98 | if (icon.hbmMask) DeleteObject(icon.hbmMask); | |
| 99 | if (black) HeapFree(heap, 0, black); | |
| 100 | if (frames) HeapFree(heap, 0, frames); | |
| 101 | return success; | |
| 102 | } | |
| 103 | ||
| 104 | static void stream(DWORD session) { | |
| 105 | HDC screen = GetDC(NULL), dc = CreateCompatibleDC(screen); | |
| 106 | HBITMAP dib = NULL, previous = NULL; | |
| 107 | uint8_t *pixels = NULL, *before = NULL; | |
| 108 | uint32_t width = 0, height = 0, id = 0; | |
| 109 | HCURSOR last_cursor = NULL; | |
| 110 | POINT last_point = {-1, -1}; | |
| 111 | DWORD last_flags = ~0u, captured = GetTickCount() - 34; | |
| 112 | DWORD heartbeat = GetTickCount(), received = 0; | |
| 113 | OVERLAPPED receiving = {0}; | |
| 114 | uint8_t request[12]; | |
| 115 | int pending = 0; | |
| 116 | int suspended = 0, initial = 1; | |
| 117 | receiving.hEvent = CreateEventW(NULL, TRUE, FALSE, NULL); | |
| 118 | if (!screen || !dc || !receiving.hEvent) goto done; | |
| 119 | for (;;) { | |
| 120 | if (session != WTSGetActiveConsoleSessionId()) goto done; | |
| 121 | CURSORINFO cursor = {sizeof(CURSORINFO), 0, NULL, {0, 0}}; | |
| 122 | HDESK desktop; | |
| 123 | WCHAR name[32]; | |
| 124 | DWORD length; | |
| 125 | DWORD amount = 0; | |
| 126 | if (!pending) { | |
| 127 | ResetEvent(receiving.hEvent); | |
| 128 | if (!ReadFile(output, request + received, (DWORD)sizeof(request) - received, &amount, &receiving)) { | |
| 129 | if (GetLastError() != ERROR_IO_PENDING) goto done; | |
| 130 | pending = 1; | |
| 131 | } else if (!GetOverlappedResult(output, &receiving, &amount, FALSE)) goto done; | |
| 132 | } else if (GetOverlappedResult(output, &receiving, &amount, FALSE)) pending = 0; | |
| 133 | else if (GetLastError() != ERROR_IO_INCOMPLETE) goto done; | |
| 134 | if (!pending) { | |
| 135 | if (!amount) goto done; | |
| 136 | received += amount; | |
| 137 | if (received == sizeof(request)) { | |
| 138 | uint8_t expected[12] = {SG_MAGIC[0], SG_MAGIC[1], SG_MAGIC[2], SG_MAGIC[3], 0,0,0,0, 0,0,0,0}; | |
| 139 | uint32_t i; | |
| 140 | sg_word(expected + 4, SG_REFRESH); | |
| 141 | for (i = 0; i < sizeof(request); ++i) if (request[i] != expected[i]) goto done; | |
| 142 | received = 0; initial = 1; last_cursor = NULL; last_flags = ~0u; | |
| 143 | } | |
| 144 | } | |
| 145 | if ((DWORD)(GetTickCount() - heartbeat) >= 1000) { | |
| 146 | if (!packet(SG_HEARTBEAT, 0, NULL, 0)) goto done; | |
| 147 | heartbeat = GetTickCount(); | |
| 148 | } | |
| 149 | desktop = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS); | |
| 150 | int active = desktop && GetUserObjectInformationW(desktop, UOI_NAME, name, sizeof(name), &length) && name[0] == L'D' && name[1] == L'e' && name[2] == L'f' && name[3] == L'a' && name[4] == L'u' && name[5] == L'l' && name[6] == L't' && !name[7]; | |
| 151 | if (desktop) CloseDesktop(desktop); | |
| 152 | if (!active || !GetCursorInfo(&cursor)) { | |
| 153 | if (!suspended && !packet(SG_SUSPEND, 0, NULL, 0)) goto done; | |
| 154 | suspended = 1; initial = 1; last_cursor = NULL; last_flags = ~0u; | |
| 155 | Sleep(100); continue; | |
| 156 | } | |
| 157 | int changed = cursor.hCursor && cursor.hCursor != last_cursor; | |
| 158 | if (changed) ++id; | |
| 159 | if (changed || cursor.flags != last_flags || cursor.ptScreenPos.x != last_point.x || cursor.ptScreenPos.y != last_point.y) { | |
| 160 | uint32_t words[SG_POINTER_WORDS] = {0}; | |
| 161 | words[SG_POINTER_ID] = id; words[SG_POINTER_X] = (uint32_t)cursor.ptScreenPos.x; | |
| 162 | words[SG_POINTER_Y] = (uint32_t)cursor.ptScreenPos.y; words[SG_POINTER_VISIBLE] = (cursor.flags & CURSOR_SHOWING) ? 1 : 0; | |
| 163 | if (!packet(SG_POINTER, sizeof(words), words, SG_POINTER_WORDS)) goto done; | |
| 164 | last_flags = cursor.flags; last_point = cursor.ptScreenPos; | |
| 165 | } | |
| 166 | if (changed) { | |
| 167 | int result = capture_cursor(cursor.hCursor, id); | |
| 168 | if (!result) goto done; | |
| 169 | if (result < 0) { | |
| 170 | uint32_t words[SG_CURSOR_WORDS] = {0}; | |
| 171 | words[SG_CURSOR_ID] = id; | |
| 172 | if (!packet(SG_CURSOR, sizeof(words), words, SG_CURSOR_WORDS)) goto done; | |
| 173 | } | |
| 174 | last_cursor = cursor.hCursor; | |
| 175 | } | |
| 176 | if ((DWORD)(GetTickCount() - captured) >= 33) { | |
| 177 | uint32_t w = (uint32_t)GetSystemMetrics(SM_CXSCREEN), h = (uint32_t)GetSystemMetrics(SM_CYSCREEN), y; | |
| 178 | struct sg_rect rect; | |
| 179 | uint32_t words[SG_SCREEN_WORDS] = {0}; | |
| 180 | if (!w || !h || w > SG_MAX_DIMENSION || h > SG_MAX_DIMENSION || w * h > SG_MAX_PIXELS) goto done; | |
| 181 | if (w != width || h != height) { | |
| 182 | BITMAPINFO info = {0}; | |
| 183 | if (previous) { SelectObject(dc, previous); previous = NULL; } | |
| 184 | if (dib) DeleteObject(dib); | |
| 185 | if (before) HeapFree(heap, 0, before); | |
| 186 | before = HeapAlloc(heap, 0, (size_t)w * h * 4); | |
| 187 | info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); info.bmiHeader.biWidth = (LONG)w; | |
| 188 | info.bmiHeader.biHeight = -(LONG)h; info.bmiHeader.biPlanes = 1; info.bmiHeader.biBitCount = 32; | |
| 189 | dib = CreateDIBSection(dc, &info, DIB_RGB_COLORS, (void **)&pixels, NULL, 0); | |
| 190 | if (!before || !dib) goto done; | |
| 191 | previous = SelectObject(dc, dib); width = w; height = h; initial = 1; | |
| 192 | } | |
| 193 | if (!BitBlt(dc, 0, 0, (int)width, (int)height, screen, 0, 0, SRCCOPY | CAPTUREBLT)) { | |
| 194 | if (!suspended && !packet(SG_SUSPEND, 0, NULL, 0)) goto done; | |
| 195 | suspended = 1; initial = 1; Sleep(100); continue; | |
| 196 | } | |
| 197 | GdiFlush(); | |
| 198 | if (initial) { rect.x = 0; rect.y = 0; rect.width = width; rect.height = height; } | |
| 199 | else if (!sg_difference(before, pixels, width, height, &rect)) { captured = GetTickCount(); Sleep(8); continue; } | |
| 200 | words[SG_SCREEN_WIDTH] = width; words[SG_SCREEN_HEIGHT] = height; | |
| 201 | words[SG_SCREEN_X] = rect.x; words[SG_SCREEN_Y] = rect.y; | |
| 202 | words[SG_SCREEN_RECT_WIDTH] = rect.width; words[SG_SCREEN_RECT_HEIGHT] = rect.height; | |
| 203 | if (!packet(SG_SCREEN, sizeof(words) + rect.width * rect.height * 4, words, SG_SCREEN_WORDS)) goto done; | |
| 204 | for (y = rect.y; y < rect.y + rect.height; ++y) { | |
| 205 | size_t offset = ((size_t)y * width + rect.x) * 4, i; | |
| 206 | if (!write_bytes(pixels + offset, rect.width * 4)) goto done; | |
| 207 | for (i = 0; i < rect.width * 4; ++i) before[offset + i] = pixels[offset + i]; | |
| 208 | } | |
| 209 | initial = 0; suspended = 0; captured = GetTickCount(); | |
| 210 | } | |
| 211 | Sleep(8); | |
| 212 | } | |
| 213 | done: | |
| 214 | CancelIo(output); | |
| 215 | if (pending) WaitForSingleObject(receiving.hEvent, INFINITE); | |
| 216 | if (receiving.hEvent) CloseHandle(receiving.hEvent); | |
| 217 | if (previous) SelectObject(dc, previous); | |
| 218 | if (dib) DeleteObject(dib); | |
| 219 | if (before) HeapFree(heap, 0, before); | |
| 220 | if (dc) DeleteDC(dc); | |
| 221 | if (screen) ReleaseDC(NULL, screen); | |
| 222 | } | |
| 223 | ||
| 224 | void mainCRTStartup(void) { | |
| 225 | FARPROC symbol; | |
| 226 | DWORD session; | |
| 227 | if (!ProcessIdToSessionId(GetCurrentProcessId(), &session)) ExitProcess(1); | |
| 228 | heap = GetProcessHeap(); | |
| 229 | sending.hEvent = CreateEventW(NULL, TRUE, FALSE, NULL); | |
| 230 | if (!sending.hEvent) ExitProcess(1); | |
| 231 | SetProcessDPIAware(); | |
| 232 | symbol = GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetCursorFrameInfo"); | |
| 233 | /* This optional user32 export supplies ANI step timing; static capture uses only documented APIs. */ | |
| 234 | cursor_frame = (cursor_frame_fn)symbol; | |
| 235 | for (;;) { | |
| 236 | if (session != WTSGetActiveConsoleSessionId()) { Sleep(1000); continue; } | |
| 237 | output = CreateFileW(L"\\\\.\\Global\\" SG_CHANNEL, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL); | |
| 238 | if (output != INVALID_HANDLE_VALUE) { stream(session); CloseHandle(output); } | |
| 239 | Sleep(1000); | |
| 240 | } | |
| 241 | } |
nixos/configuration.nix+39-3| ... | ... | @@ -6,9 +6,11 @@ let |
| 6 | 6 | proxyToken = "/var/lib/studio/dashboard-proxy.token"; |
| 7 | 7 | hostTools = lib.fileset.toSource { |
| 8 | 8 | root = ../.; |
| 9 | fileset = lib.fileset.unions [ ../tools/dashboard-host.py ../tools/vms.py ../tools/dashboard-run.py ../tools/release.py ]; | |
| 9 | fileset = lib.fileset.unions [ ../tools/dashboard-host.py ../tools/vms.py ../tools/dashboard-run.py ../tools/release.py ../guest/protocol.json ]; | |
| 10 | 10 | }; |
| 11 | 11 | nativePkl = pkgs.callPackage ./pkl.nix { }; |
| 12 | screenPython = pkgs.python3.withPackages (packages: [ packages.pygobject3 packages.gst-python ]); | |
| 13 | screenPlugins = (with pkgs.gst_all_1; [ (lib.getLib gstreamer) gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly ]) ++ [ (lib.getLib pkgs.libnice) ]; | |
| 12 | 14 | secureOvmf = pkgs.OVMF.override { |
| 13 | 15 | secureBoot = true; |
| 14 | 16 | msVarsTemplate = true; |
| ... | ... | @@ -21,6 +23,7 @@ in |
| 21 | 23 | networking.firewall = { |
| 22 | 24 | enable = true; |
| 23 | 25 | allowedTCPPorts = [ 22 ]; |
| 26 | allowedUDPPortRanges = [{ from = 50000; to = 50031; }]; | |
| 24 | 27 | interfaces.podman0.allowedTCPPorts = [ 443 internalPort 10428 15432 18428 ]; |
| 25 | 28 | interfaces.podman0.allowedTCPPortRanges = [{ from = 20000; to = 32000; }]; |
| 26 | 29 | interfaces.tailscale0.allowedTCPPorts = [ 80 443 445 8081 8082 ]; |
| ... | ... | @@ -118,6 +121,38 @@ in |
| 118 | 121 | isSystemUser = true; |
| 119 | 122 | group = "studio-dashboard"; |
| 120 | 123 | }; |
| 124 | systemd.services.studio-vm-screen = { | |
| 125 | wantedBy = [ "multi-user.target" ]; | |
| 126 | wants = [ "studio-host.service" ]; | |
| 127 | after = [ "studio-host.service" ]; | |
| 128 | environment = { | |
| 129 | GST_PLUGIN_SYSTEM_PATH_1_0 = lib.makeSearchPath "lib/gstreamer-1.0" screenPlugins; | |
| 130 | GI_TYPELIB_PATH = lib.makeSearchPath "lib/girepository-1.0" (map lib.getLib ([ pkgs.gst_all_1.gstreamer ] ++ screenPlugins)); | |
| 131 | LD_LIBRARY_PATH = "/run/opengl-driver/lib"; | |
| 132 | STUDIO_VM_GUEST_PROTOCOL = toString ../guest/protocol.json; | |
| 133 | }; | |
| 134 | serviceConfig = { | |
| 135 | ExecStart = "${screenPython}/bin/python3 ${../tools/vm-screen.py}"; | |
| 136 | User = "studio-dashboard"; | |
| 137 | Group = "studio-dashboard"; | |
| 138 | RuntimeDirectory = "studio-vm-screen"; | |
| 139 | RuntimeDirectoryMode = "0700"; | |
| 140 | RuntimeDirectoryPreserve = "yes"; | |
| 141 | UMask = "0077"; | |
| 142 | ProtectSystem = "strict"; | |
| 143 | ProtectHome = true; | |
| 144 | PrivateTmp = true; | |
| 145 | NoNewPrivileges = true; | |
| 146 | CapabilityBoundingSet = ""; | |
| 147 | MemoryMax = "2G"; | |
| 148 | TasksMax = 128; | |
| 149 | DevicePolicy = "closed"; | |
| 150 | DeviceAllow = lib.optional (builtins.elem "nvidia" config.services.xserver.videoDrivers) "char-nvidia* rw"; | |
| 151 | RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" "AF_NETLINK" ]; | |
| 152 | Restart = "always"; | |
| 153 | RestartSec = 2; | |
| 154 | }; | |
| 155 | }; | |
| 121 | 156 | systemd.services.studio-host = { |
| 122 | 157 | wantedBy = [ "multi-user.target" ]; |
| 123 | 158 | wants = [ "podman.socket" ]; |
| ... | ... | @@ -181,8 +216,8 @@ in |
| 181 | 216 | in { |
| 182 | 217 | inherit environment; |
| 183 | 218 | wantedBy = [ "multi-user.target" ]; |
| 184 | wants = [ "nomad.service" "studio-router.service" "studio-host.service" ]; | |
| 185 | after = [ "nomad.service" "studio-host.service" ]; | |
| 219 | wants = [ "nomad.service" "studio-router.service" "studio-host.service" "studio-vm-screen.service" ]; | |
| 220 | after = [ "nomad.service" "studio-host.service" "studio-vm-screen.service" ]; | |
| 186 | 221 | unitConfig.ConditionPathExists = [ "/var/lib/studio/dashboard.token" "/var/lib/studio/ca-bundle.crt" "/opt/studio/current" ]; |
| 187 | 222 | path = [ pkgs.podman pkgs.coreutils pkgs.curl pkgs.openssl pkgs.systemd ]; |
| 188 | 223 | serviceConfig = { |
| ... | ... | @@ -210,6 +245,7 @@ in |
| 210 | 245 | --env=HOME=/data --env=XDG_CACHE_HOME=/data/cache \ |
| 211 | 246 | --volume=/var/lib/studio/dashboard:/data:rw \ |
| 212 | 247 | --volume=/run/studio-host:/run/studio-host:ro \ |
| 248 | --volume=/run/studio-vm-screen:/run/studio-vm-screen:ro \ | |
| 213 | 249 | --volume=${proxyToken}:/run/secrets/dashboard-proxy.token:ro \ |
| 214 | 250 | --volume=/var/lib/studio/dashboard.token:/run/secrets/dashboard-nomad.token:ro \ |
| 215 | 251 | --volume=/var/lib/studio/ca-bundle.crt:/run/secrets/ca-bundle.crt:ro \ |
readme.md-189| ... | ... | @@ -18,44 +18,6 @@ it -- It's kind of like "easy kubernetes." |
| 18 | 18 | |
| 19 | 19 | [paper clover]: https://paperclover.net |
| 20 | 20 | |
| 21 | ## deployment loop | |
| 22 | ||
| 23 | ```sh | |
| 24 | python3 tools/deploy.py stage shale # preview the working files, even without a commit message | |
| 25 | python3 tools/deploy.py stage shale # update the same URL and staging data after another edit | |
| 26 | python3 tools/deploy.py publish # upload the described, conflict-free main commit for GUI review | |
| 27 | python3 tools/deploy.py prod # upload and deploy main directly | |
| 28 | ``` | |
| 29 | ||
| 30 | Production always deploys a snapshot exported from `main`, never working files | |
| 31 | or a stage. The dashboard's **deploy main** page shows the uploaded commit and | |
| 32 | its message; deploying applies the full repository configuration and retains | |
| 33 | production data. A newer upload invalidates an older deployment confirmation. | |
| 34 | Sibling application build sources declared in `build-source.json` are bundled | |
| 35 | at upload time. Their frozen contents are part of the release digest. | |
| 36 | ||
| 37 | `main` joins the infra-2 and home-infra histories. Its tree contains infra-2; | |
| 38 | the retired configuration remains available in the home-infra parent history. | |
| 39 | ||
| 40 | ## installer | |
| 41 | ||
| 42 | After publishing main, build the prepared USB image on an x86 Linux host: | |
| 43 | ||
| 44 | ```sh | |
| 45 | nix build --extra-experimental-features 'nix-command flakes' path:/opt/studio/main#installer | |
| 46 | ``` | |
| 47 | ||
| 48 | The ISO is in `result/iso/`. It boots a live installer with this Mac's SSH key, | |
| 49 | ZFS and migration tools, the uploaded repository at `/etc/infra-2`, and a cached | |
| 50 | dashboard image. It does not install automatically. The physical installation | |
| 51 | uses `#zenith` after generating its hardware configuration; the existing data | |
| 52 | pool and service state follow the [handoff](tools/legacy-handoff.md). | |
| 53 | ||
| 54 | On Zenith, the live installer uses the same wired addresses and gateway as the | |
| 55 | installed system. Once firmware boots the USB, connect from this Mac with | |
| 56 | `ssh root@10.0.0.1`. SSH starts automatically and accepts the admin key only; | |
| 57 | no monitor, local login, or DHCP address lookup is needed after USB boot. | |
| 58 | ||
| 59 | 21 | ## filesystem layout |
| 60 | 22 | |
| 61 | 23 | The computer mounts the ZFS root dataset under `/srv`, meaning "server," loosely |
| ... | ... | @@ -94,154 +56,3 @@ Media is it's own dataset so it can be snapshotted independently of my personal |
| 94 | 56 | data (less frequent, lower retention), and all my personal files are on the same |
| 95 | 57 | dataset to allow fast move/copying between top level folders. Services use their |
| 96 | 58 | own datasets to implement copy-on-write forks. |
| 97 | ||
| 98 | ## testing domains on a Mac | |
| 99 | ||
| 100 | [tools/mac-domains.py](tools/mac-domains.py) routes `.studio.test` and its nested | |
| 101 | subdomains through the rehearsal VM's HTTPS, DNS, and login services. Start the | |
| 102 | rehearsal SSH forwards first, then run: | |
| 103 | ||
| 104 | ```sh | |
| 105 | sudo /usr/bin/python3 tools/mac-domains.py start /path/to/rehearsal-ca.crt | |
| 106 | ``` | |
| 107 | ||
| 108 | The relay binds loopback ports, drops administrator privileges, and passes TLS | |
| 109 | through to the VM. Local UDP DNS queries use the SSH tunnel's TCP DNS connection. | |
| 110 | Existing certificate trust is preserved. Mac DNS and hosts | |
| 111 | settings are backed up before editing; `sudo /usr/bin/python3 tools/mac-domains.py | |
| 112 | stop` restores them and stops the relay. Undo refuses to overwrite later edits. | |
| 113 | After a Mac restart, run stop and start again to restart the relay. | |
| 114 | ||
| 115 | The dashboard package uses `home-dashboard`. Existing state paths, environment | |
| 116 | names, service IDs, and telemetry names keep their old names for compatibility. | |
| 117 | ||
| 118 | ## dashboard boundary | |
| 119 | ||
| 120 | NixOS runs `studio-dashboard` in a non-root Podman container with a read-only | |
| 121 | root, private network, resource limits, and explicit data mounts. The small | |
| 122 | `studio-host` service authenticates the dashboard UID on its Unix socket and | |
| 123 | performs bounded ZFS, VM, deployment, host-sampling, and identity operations. | |
| 124 | Host control sockets and management credentials stay outside the container. | |
| 125 | ||
| 126 | Snowglobe and Copyparty use the Rust dashboard's accounts and host-only sessions. | |
| 127 | Account state lives in `/var/lib/studio/dashboard/accounts.sqlite`. Deployment | |
| 128 | backups include consistent SQLite copies and profile pictures; `data-restore` | |
| 129 | accepts `dashboard` and preserves a safety copy before restoring. Invitations reserve a username and groups, | |
| 130 | expire after 24 hours, and can be revoked. Setup offers optional passkey enrollment. | |
| 131 | Existing passkeys retain the `auth.paperclover.net` RP ID; that host serves related | |
| 132 | origin metadata for Snowglobe. Keycloak remains available for other services. | |
| 133 | The sign-in page shares the original Keycloak theme stylesheet and artwork. | |
| 134 | Passkeys support username-free sign-in and browser autofill; the remember-me | |
| 135 | checkbox chooses between a browser session cookie and a 30-day cookie. | |
| 136 | ||
| 137 | `tools/import-dashboard-auth.py --host root@zenith --output /private/path/accounts.json` | |
| 138 | exports account IDs, groups, password hashes and public passkey credentials without | |
| 139 | changing the source realm. Keep the export private. The dashboard imports | |
| 140 | `accounts-import.json` from its data directory at startup and removes it after | |
| 141 | success; importing the same export again is safe, while a different export is | |
| 142 | refused once accounts exist. `home-dashboard --import-accounts /private/path/accounts.json` | |
| 143 | supports an offline rehearsal with a separate `STUDIO_DATA_DIR`. | |
| 144 | ||
| 145 | The MCP tab manages separate observability, agent, and Shale catalogs through | |
| 146 | the native dashboard account. Each connection has explicit service, machine, or | |
| 147 | repository grants; Shale credentials belong to the signed-in user. Agent Relay's | |
| 148 | existing outbound client protocol connects to the Rust server. | |
| 149 | ||
| 150 | Build the image with `nix build .#dashboard-image` on Linux. Run | |
| 151 | `python3 tools/dashboard-unit-test.py --output /tmp/dashboard-checks.json` on the | |
| 152 | rehearsal VM to exercise the generated NixOS units, containment, IAM, and MCP | |
| 153 | connectors with disposable fixtures. `--relay-agent-dir` includes the existing | |
| 154 | Agent Relay client interoperability check; `--browser-ready-file` temporarily | |
| 155 | routes the public dashboard to the fixture for browser and SSO load checks. | |
| 156 | ||
| 157 | ## local agents | |
| 158 | ||
| 159 | On each Mac or Linux machine, run this from your usual terminal: | |
| 160 | ||
| 161 | ```sh | |
| 162 | curl -fsSL https://snowglobe.paperclover.net/agent/install.sh | sh | |
| 163 | ``` | |
| 164 | ||
| 165 | On Windows, use a PowerShell window without administrator privileges: | |
| 166 | ||
| 167 | ```powershell | |
| 168 | irm https://snowglobe.paperclover.net/agent/install.ps1 | iex | |
| 169 | ``` | |
| 170 | ||
| 171 | The installer downloads a private Node runtime, verifies its SHA-256 checksum, | |
| 172 | and installs the agent without npm or a repository checkout. On NixOS, it | |
| 173 | installs the runtime through Nix into the agent folder. Prompts ask for a | |
| 174 | machine name, existing project folders where new chats may start, and optional | |
| 175 | experimental Codex desktop control on macOS or Linux. No folders or desktop | |
| 176 | control are enabled on a fresh install unless selected. Codex or Claude Code | |
| 177 | must already be installed and signed in as your login user. | |
| 178 | ||
| 179 | While the installer waits, open **MCP → Settings → Local agents**, enter the printed | |
| 180 | pairing code, and click **Link machine**. Finish installation in the terminal. | |
| 181 | The machine appears **Online** when its background agent connects. Pairing | |
| 182 | belongs to the signed-in dashboard account; each machine connects outward and | |
| 183 | needs no incoming firewall port. Startup uses a systemd user service on Linux, | |
| 184 | a LaunchAgent on macOS, and a current-user scheduled task at logon on Windows. | |
| 185 | Linux needs an active systemd user session. The agent starts immediately after | |
| 186 | installation and again at login. | |
| 187 | ||
| 188 | Copy **Local agents**' endpoint, `https://snowglobe.paperclover.net/mcp/agents`, | |
| 189 | into the AI client's MCP connector settings using OAuth. Sign in to the | |
| 190 | dashboard and select the machines the client may access. The consent screen | |
| 191 | shows whether the connection requests session control. A granted machine | |
| 192 | exposes saved Codex and Claude Code chats; project folders constrain **new** | |
| 193 | chats, not saved-chat reads or the permissions of existing chats. | |
| 194 | ||
| 195 | Example requests to the connected AI client: | |
| 196 | ||
| 197 | ```text | |
| 198 | List my machines, target "Work PC", and show its recent Codex chats. | |
| 199 | Read the latest chat in that list. | |
| 200 | Start a Codex chat on "Work PC" in C:\Users\Clover\dev\site: | |
| 201 | check the build and fix the failing tests. | |
| 202 | Read that chat again to check the result. | |
| 203 | ``` | |
| 204 | ||
| 205 | Read access supports listing machines and chats and reading transcripts. | |
| 206 | Session control adds starting chats, sending messages, and interrupting turns. | |
| 207 | Writes always select one machine. Agent-owned Codex and Claude Code chats | |
| 208 | support these operations; existing Codex desktop control is experimental and | |
| 209 | requires the installer option. Existing Claude Code chats accept messages only | |
| 210 | when their local inbox supports delivery. Windows installs support saved-chat | |
| 211 | reads and agent-owned CLI chats; this installer does not enable existing | |
| 212 | desktop chat control there. A submitted message acknowledges delivery; read | |
| 213 | the chat again for its result. Commands needing local approval are refused. | |
| 214 | ||
| 215 | For an external script, create an **API key** in the MCP tab, select its | |
| 216 | machines, and enable **Allow session control** only if required. Use the key | |
| 217 | as a bearer token with `/api/v1/machines` and | |
| 218 | `/api/v1/machines/{id}/commands`. Keep it in the script's secret store. Unlinking | |
| 219 | a machine disconnects it and revokes its machine credential; revoking a client | |
| 220 | connection removes only that client's access. | |
| 221 | ||
| 222 | Rerun the install command to update the agent or change project folders. It | |
| 223 | keeps the machine identity and pairing. Leaving the first folder answer blank | |
| 224 | keeps existing folders; entering `-` clears them. A reinstall needs the existing | |
| 225 | pairing to remain active. Unlink first, then remove the saved `agent.json` if | |
| 226 | you want a new pairing or a different dashboard account. | |
| 227 | ||
| 228 | The installed `agent-relay` command accepts `status`, `start`, `stop`, and | |
| 229 | `uninstall`. Use its full path: | |
| 230 | ||
| 231 | | Platform | Command | Logs | | |
| 232 | | --- | --- | --- | | |
| 233 | | Linux | `~/.local/share/agent-relay/agent-relay status` | `journalctl --user -u agent-relay -f` | | |
| 234 | | macOS | `"$HOME/Library/Application Support/AgentRelay/agent-relay" status` | `~/Library/Application Support/AgentRelay/agent.log` | | |
| 235 | | Windows | `& "$env:LOCALAPPDATA\AgentRelay\agent-relay.cmd" status` | `%LOCALAPPDATA%\AgentRelay\agent.log` | | |
| 236 | ||
| 237 | Linux honors `XDG_DATA_HOME` and `XDG_CONFIG_HOME`. Uninstall removes startup | |
| 238 | registration and stops the agent, preserving pairing and session files. Pairing | |
| 239 | is in `~/.config/agent-relay/agent.json` on macOS/Linux, or | |
| 240 | `%LOCALAPPDATA%\AgentRelay\config\agent.json` on Windows. | |
| 241 | ||
| 242 | The local client source remains in the sibling Agent Relay project identified | |
| 243 | by `dashboard/agent/source.json`. `tools/deploy.py` bundles it into each frozen | |
| 244 | release before computing its digest. For a direct dashboard build or local | |
| 245 | preview, run `pnpm --dir dashboard install --frozen-lockfile` and | |
| 246 | `python3 tools/build-agent.py` first. The generated `relay.mjs` is a deployment | |
| 247 | artifact and is not checked into this repository. |
service/shale/readme/readme.js+67-7| ... | ... | @@ -2,31 +2,91 @@ |
| 2 | 2 | const readme = document.querySelector("#readme .markdown"); |
| 3 | 3 | if (!readme || !window.markdownit || !window.DOMPurify) return; |
| 4 | 4 | |
| 5 | const response = await fetch("/snowbound/plain/main/readme.md"); | |
| 5 | const repo = document.querySelector('meta[name="astheno.shale.repo.name"]')?.content; | |
| 6 | if (!repo) return; | |
| 7 | const tree = await fetch(`/${encodeURIComponent(repo)}/tree/-/`); | |
| 8 | if (!tree.ok) return; | |
| 9 | const page = new DOMParser().parseFromString(await tree.text(), "text/html"); | |
| 10 | const file = [...page.querySelectorAll(".objblob a[href]")] | |
| 11 | .find(link => /^readme\.md$/i.test(link.textContent.trim())); | |
| 12 | if (!file) return; | |
| 13 | const source = new URL(file.getAttribute("href"), tree.url); | |
| 14 | const linkBase = new URL("./", source); | |
| 15 | source.pathname = source.pathname.replace(`/${encodeURIComponent(repo)}/tree/`, `/${encodeURIComponent(repo)}/plain/`); | |
| 16 | const response = await fetch(source); | |
| 6 | 17 | if (!response.ok) return; |
| 7 | 18 | |
| 8 | 19 | const markdown = await response.text(); |
| 9 | 20 | const rendered = window.markdownit({ html: true }).render(markdown); |
| 10 | 21 | const fragment = window.DOMPurify.sanitize(rendered, { |
| 11 | USE_PROFILES: { html: true }, | |
| 22 | // https://github.com/gjtorikian/html-pipeline/blob/main/lib/html_pipeline/sanitization_filter.rb | |
| 23 | ALLOWED_TAGS: [ | |
| 24 | "h1", "h2", "h3", "h4", "h5", "h6", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt", | |
| 25 | "div", "ins", "del", "sup", "sub", "p", "picture", "ol", "ul", "table", "thead", "tbody", "tfoot", | |
| 26 | "blockquote", "dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", | |
| 27 | "th", "s", "strike", "summary", "details", "caption", "figure", "figcaption", "abbr", "bdo", "cite", | |
| 28 | "dfn", "mark", "small", "source", "span", "time", "wbr", | |
| 29 | ], | |
| 30 | ALLOWED_ATTR: [ | |
| 31 | "href", "src", "longdesc", "loading", "alt", "itemscope", "itemtype", "cite", "srcset", | |
| 32 | "abbr", "accept", "accept-charset", "accesskey", "action", "align", "aria-describedby", "aria-hidden", | |
| 33 | "aria-label", "aria-labelledby", "axis", "border", "char", "charoff", "charset", "checked", "clear", | |
| 34 | "cols", "colspan", "compact", "coords", "datetime", "dir", "disabled", "enctype", "for", "frame", | |
| 35 | "headers", "height", "hreflang", "hspace", "ismap", "label", "lang", "maxlength", "media", "method", | |
| 36 | "multiple", "nohref", "noshade", "nowrap", "open", "progress", "prompt", "readonly", "rel", "rev", | |
| 37 | "role", "rows", "rowspan", "rules", "scope", "selected", "shape", "size", "span", "start", "summary", | |
| 38 | "tabindex", "title", "type", "usemap", "valign", "value", "width", "itemprop", "style", | |
| 39 | ], | |
| 40 | ALLOW_DATA_ATTR: false, | |
| 41 | ALLOW_ARIA_ATTR: false, | |
| 12 | 42 | RETURN_DOM_FRAGMENT: true, |
| 13 | 43 | }); |
| 44 | for (const element of fragment.querySelectorAll("[style]")) { | |
| 45 | const ratio = element.tagName === "IMG" ? element.style.aspectRatio : ""; | |
| 46 | element.removeAttribute("style"); | |
| 47 | if (/^(?:auto\s+)?\d+(?:\.\d+)?(?:\s*\/\s*\d+(?:\.\d+)?)?$/.test(ratio)) { | |
| 48 | element.style.aspectRatio = ratio; | |
| 49 | } | |
| 50 | } | |
| 51 | for (const element of fragment.querySelectorAll("[align]")) { | |
| 52 | const align = element.getAttribute("align").toLowerCase(); | |
| 53 | if (element.tagName === "IMG") { | |
| 54 | if (["top", "middle", "bottom"].includes(align)) element.style.verticalAlign = align; | |
| 55 | if (["left", "right"].includes(align)) element.style.float = align; | |
| 56 | } else if (["left", "center", "right", "justify"].includes(align)) { | |
| 57 | element.style.textAlign = align; | |
| 58 | } | |
| 59 | } | |
| 14 | 60 | |
| 15 | const mediaBase = new URL("/snowbound/plain/main/", location.origin); | |
| 16 | const linkBase = new URL("/snowbound/tree/main/", location.origin); | |
| 61 | const mediaBase = new URL("./", source); | |
| 17 | 62 | const rebase = (value, base) => |
| 18 | 63 | value && !value.startsWith("/") && !value.startsWith("#") && !/^[a-z][a-z\d+.-]*:/i.test(value) |
| 19 | 64 | ? new URL(value, base).href |
| 20 | 65 | : value; |
| 21 | 66 | |
| 22 | for (const image of fragment.querySelectorAll("img[src]")) { | |
| 67 | await Promise.all([...fragment.querySelectorAll("img[src]")].map(async image => { | |
| 23 | 68 | image.src = rebase(image.getAttribute("src"), mediaBase); |
| 24 | 69 | for (const dimension of ["width", "height"]) { |
| 25 | 70 | const value = image.getAttribute(dimension); |
| 26 | 71 | if (value && /^\d+$/.test(value)) image.style[dimension] = `${value}px`; |
| 27 | 72 | } |
| 28 | } | |
| 29 | for (const pictureSource of fragment.querySelectorAll("source[srcset]")) { | |
| 73 | if (image.style.width) { | |
| 74 | if (!image.style.aspectRatio && image.style.height) { | |
| 75 | image.style.aspectRatio = `auto ${image.getAttribute("width")} / ${image.getAttribute("height")}`; | |
| 76 | } | |
| 77 | image.style.height = "auto"; | |
| 78 | } | |
| 79 | // Shale serves raw SVG as text/plain, which browsers reject as an image. | |
| 80 | const url = new URL(image.src); | |
| 81 | if (url.origin === location.origin && url.pathname.startsWith(mediaBase.pathname) && /\.svg$/i.test(url.pathname)) { | |
| 82 | const source = await fetch(url).then(response => response.ok ? response.text() : null).catch(() => null); | |
| 83 | if (source) { | |
| 84 | const svg = window.DOMPurify.sanitize(source, { USE_PROFILES: { svg: true, svgFilters: true } }); | |
| 85 | image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; | |
| 86 | } | |
| 87 | } | |
| 88 | })); | |
| 89 | for (const pictureSource of fragment.querySelectorAll("img[srcset], source[srcset]")) { | |
| 30 | 90 | pictureSource.srcset = pictureSource.srcset.split(",").map(candidate => { |
| 31 | 91 | const [, path, descriptor] = candidate.trim().match(/^(\S+)(.*)$/) || []; |
| 32 | 92 | return path ? rebase(path, mediaBase) + descriptor : candidate; |
service/shale/service.pkl+1-1| ... | ... | @@ -30,7 +30,7 @@ container { |
| 30 | 30 | ["/-/studio-readme/"] = "readme" |
| 31 | 31 | } |
| 32 | 32 | headHtml { |
| 33 | ["/snowbound/"] = """ | |
| 33 | ["/*"] = """ | |
| 34 | 34 | <script defer src="/-/studio-readme/markdown-it.min.js"></script><script defer src="/-/studio-readme/purify.min.js"></script><script defer src="/-/studio-readme/readme.js"></script> |
| 35 | 35 | """ |
| 36 | 36 | } |
tools/dashboard-host.py+11-4| ... | ... | @@ -33,6 +33,7 @@ TEXT_FIELDS = {"name", "compression", "mountpoint", "origin", "mounted"} |
| 33 | 33 | MAX_REQUEST = 65536 |
| 34 | 34 | MAX_RESPONSE = 16 * 1024 * 1024 |
| 35 | 35 | STREAM_SLOTS = threading.BoundedSemaphore(4) |
| 36 | GUEST_SLOTS = threading.BoundedSemaphore(4) | |
| 36 | 37 | VM_PREPARATION_SLOT = threading.BoundedSemaphore(1) |
| 37 | 38 | INDEX_SNAPSHOT = r"index-[0-9]+(?:-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})?" |
| 38 | 39 | |
| ... | ... | @@ -322,6 +323,11 @@ def stream_console(connection, host, request): |
| 322 | 323 | except BlockingIOError: |
| 323 | 324 | raise Rejected("This console is already open. Close its other console tab, then reconnect.") from None |
| 324 | 325 | tty.setraw(descriptor) |
| 326 | elif request["operation"] == "vm.guest": | |
| 327 | screen = resources.enter_context(socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)) | |
| 328 | screen.settimeout(10) | |
| 329 | screen.connect(details["path"]) | |
| 330 | screen.setblocking(False) | |
| 325 | 331 | else: |
| 326 | 332 | identity = details["uuid"] |
| 327 | 333 | deadline = time.monotonic() + 5 |
| ... | ... | @@ -427,17 +433,18 @@ def serve_connection(connection, host, allowed_uid): |
| 427 | 433 | if len(line) > MAX_REQUEST or not line.endswith(b"\n"): |
| 428 | 434 | raise Rejected("The host request is too large or incomplete.") |
| 429 | 435 | request = json.loads(line) |
| 430 | if isinstance(request, dict) and request.get("operation") in {"vm.console", "vm.serial", "vm.upload"}: | |
| 431 | if not STREAM_SLOTS.acquire(blocking=False): | |
| 436 | if isinstance(request, dict) and request.get("operation") in {"vm.console", "vm.serial", "vm.guest", "vm.upload"}: | |
| 437 | streams = GUEST_SLOTS if request["operation"] == "vm.guest" else STREAM_SLOTS | |
| 438 | if not streams.acquire(blocking=False): | |
| 432 | 439 | raise Rejected("Four VM connections or uploads are open. Close one and try again.") |
| 433 | 440 | try: |
| 434 | if request["operation"] in {"vm.console", "vm.serial"}: | |
| 441 | if request["operation"] in {"vm.console", "vm.serial", "vm.guest"}: | |
| 435 | 442 | stream_console(connection, host, request) |
| 436 | 443 | else: |
| 437 | 444 | stream_upload(connection, request) |
| 438 | 445 | return |
| 439 | 446 | finally: |
| 440 | STREAM_SLOTS.release() | |
| 447 | streams.release() | |
| 441 | 448 | response = {"value": host.handle(request)} |
| 442 | 449 | except Rejected as error: |
| 443 | 450 | response = {"error": str(error), "status": 400} |
tools/dashboard-vms-test.py+7-1| ... | ... | @@ -82,6 +82,11 @@ class VMTests(unittest.TestCase): |
| 82 | 82 | with self.subTest(name=name): |
| 83 | 83 | spec = windows if name.startswith('Windows') else linux |
| 84 | 84 | self.assertEqual(vms.installer_profile(Path(name), spec), expected) |
| 85 | self.assertEqual(vms.profile('linux-aarch64-virtio')['video'], 'virtio') | |
| 86 | self.assertEqual(vms.profile('linux-aarch64-virtio')['keyboard'], 'usb') | |
| 87 | self.assertEqual(vms.profile('linux-aarch64-virtio')['serial'], 'ttyAMA0') | |
| 88 | self.assertEqual(vms.profile('windows-q35-secure')['cpu_mode'], 'host-passthrough') | |
| 89 | self.assertEqual(vms.profile('windows-q35-secure')['clock'], 'utc') | |
| 85 | 90 | with self.assertRaisesRegex(ValueError, 'Windows ARM'): |
| 86 | 91 | vms.installer_profile(Path('Windows 11 26H2 English arm64.iso'), windows) |
| 87 | 92 | |
| ... | ... | @@ -129,7 +134,7 @@ class VMTests(unittest.TestCase): |
| 129 | 134 | patch.object(vms.subprocess, 'run', return_value=SimpleNamespace(stdout='$6$hash\n')), \ |
| 130 | 135 | patch.object(vms.secrets, 'token_urlsafe', return_value='generated-password'), \ |
| 131 | 136 | patch.object(vms.secrets, 'token_hex', side_effect=['1234abcd', 'instance']): |
| 132 | vms.linux_seed(directory, 'snow', 'NixOS') | |
| 137 | vms.linux_seed(directory, 'snow', 'NixOS', 'ttyAMA0') | |
| 133 | 138 | config = captured['config'] |
| 134 | 139 | self.assertEqual(config['bootcmd'][0], ['rm', '-f', '/etc/gdm/custom.conf']) |
| 135 | 140 | self.assertIn('/run/current-system/sw/bin/getent', config['bootcmd'][1][-1]) |
| ... | ... | @@ -140,6 +145,7 @@ class VMTests(unittest.TestCase): |
| 140 | 145 | 'content': 'snowglobe-vm-1234abcd\n', |
| 141 | 146 | }]) |
| 142 | 147 | self.assertEqual(config['runcmd'][0][-1], 'sshd') |
| 148 | self.assertEqual(config['runcmd'][1][-1], 'serial-getty@ttyAMA0.service') | |
| 143 | 149 | |
| 144 | 150 | def test_console_cannot_choose_an_arbitrary_network_target(self): |
| 145 | 151 | xml = "<domain><uuid>fixture-vm</uuid><devices><graphics type='vnc' listen='127.0.0.1' port='5901'/></devices></domain>" |
tools/guest-channel-test.py created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | import sys | |
| 3 | import unittest | |
| 4 | from unittest.mock import patch | |
| 5 | import vms | |
| 6 | ||
| 7 | ||
| 8 | class ChannelTests(unittest.TestCase): | |
| 9 | def request(self, source, state="running", channel=None): | |
| 10 | channel = vms.GUEST_CHANNEL if channel is None else channel | |
| 11 | xml = f'<domain id="9"><name>fixture</name><devices><channel type="unix"><source mode="bind" path="{source}"/><target type="virtio" name="{channel}"/></channel></devices></domain>' | |
| 12 | with patch.object(sys, "argv", ["vms.py", "guest", '{"name":"fixture"}']), patch.object(vms, "virsh", side_effect=[xml, state]): | |
| 13 | return vms.main() | |
| 14 | ||
| 15 | def test_only_the_running_domains_owned_channel_is_opened(self): | |
| 16 | path = f"/run/libvirt/qemu/channel/9-fixture/{vms.GUEST_CHANNEL}" | |
| 17 | self.assertEqual(self.request(path), {"path": path}) | |
| 18 | for source, state, channel in [("/run/studio-host/host.sock", "running", None), | |
| 19 | (path.replace("9-fixture", "8-other"), "running", None), | |
| 20 | (path, "paused", None), (path, "running", "org.qemu.guest_agent.0")]: | |
| 21 | with self.subTest(source=source, state=state, channel=channel), self.assertRaises(ValueError): | |
| 22 | self.request(source, state, channel) | |
| 23 | with self.assertRaises(ValueError): | |
| 24 | vms.validate("guest", {"name": "fixture", "path": "/run/studio-host/host.sock"}) | |
| 25 | with self.assertRaises(ValueError): | |
| 26 | vms.validate("guest", {"name": "../other"}) | |
| 27 | ||
| 28 | ||
| 29 | if __name__ == "__main__": | |
| 30 | unittest.main() |
tools/release.py+3-2| ... | ... | @@ -15,7 +15,7 @@ ROOT = Path("/opt/studio") |
| 15 | 15 | RELEASES = ROOT / "releases" |
| 16 | 16 | STATE = Path("/var/lib/studio") |
| 17 | 17 | HISTORY = STATE / "deployments.json" |
| 18 | SOURCES = ("config", "service", "tools", "nixos", "dashboard", "flake.nix", "flake.lock", "readme.md") | |
| 18 | SOURCES = ("config", "service", "tools", "nixos", "dashboard", "guest", "flake.nix", "flake.lock", "readme.md") | |
| 19 | 19 | RELEASE_ID = re.compile(r"[0-9a-f]{16}\Z") |
| 20 | 20 | STAGE_ID = re.compile(r"[a-z][a-z0-9-]*\Z") |
| 21 | 21 | |
| ... | ... | @@ -42,6 +42,7 @@ def files(root): |
| 42 | 42 | if relative.parts[:2] in ( |
| 43 | 43 | ("dashboard", "node_modules"), ("dashboard", "dist"), |
| 44 | 44 | ("dashboard", ".cache"), ("dashboard", "data"), ("dashboard", "target"), |
| 45 | ("guest", "build"), | |
| 45 | 46 | ): |
| 46 | 47 | continue |
| 47 | 48 | if any(part in {".DS_Store", "__pycache__", ".identities.lock", "identities.pending"} or part.startswith("._") or part.endswith(".pyc") for part in relative.parts): |
| ... | ... | @@ -73,7 +74,7 @@ def host_digest(root): |
| 73 | 74 | digest = hashlib.sha256() |
| 74 | 75 | paths = sorted(path for path, relative in files(root) |
| 75 | 76 | if relative.parts[0] in {"nixos", "dashboard", "config", "service"} |
| 76 | or str(relative) in {"flake.nix", "flake.lock", "tools/router.py", "tools/dashboard-host.py", "tools/dashboard-run.py", "tools/release.py", "tools/vms.py"}) | |
| 77 | or str(relative) in {"flake.nix", "flake.lock", "tools/router.py", "tools/dashboard-host.py", "tools/dashboard-run.py", "tools/release.py", "tools/vms.py", "tools/vm-screen.py", "guest/protocol.json"}) | |
| 77 | 78 | for path in paths: |
| 78 | 79 | digest.update(str(path.relative_to(root)).encode()) |
| 79 | 80 | _hash_contents(digest, path) |
tools/vm-screen-test.py created+248| ... | ... | @@ -0,0 +1,248 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | import asyncio | |
| 3 | import base64 | |
| 4 | import importlib.util | |
| 5 | import json | |
| 6 | from pathlib import Path | |
| 7 | import random | |
| 8 | import struct | |
| 9 | import threading | |
| 10 | import unittest | |
| 11 | import zlib | |
| 12 | from types import SimpleNamespace | |
| 13 | from unittest.mock import Mock | |
| 14 | ||
| 15 | ||
| 16 | spec = importlib.util.spec_from_file_location("vm_screen", Path(__file__).with_name("vm-screen.py")) | |
| 17 | screen = importlib.util.module_from_spec(spec) | |
| 18 | spec.loader.exec_module(screen) | |
| 19 | ||
| 20 | ||
| 21 | class Writer: | |
| 22 | def __init__(self): | |
| 23 | self.data = bytearray() | |
| 24 | self.closed = False | |
| 25 | self.transport = self | |
| 26 | ||
| 27 | def write(self, data): | |
| 28 | self.data.extend(data) | |
| 29 | ||
| 30 | async def drain(self): | |
| 31 | pass | |
| 32 | ||
| 33 | def get_write_buffer_size(self): | |
| 34 | return len(self.data) | |
| 35 | ||
| 36 | def is_closing(self): | |
| 37 | return self.closed | |
| 38 | ||
| 39 | def close(self): | |
| 40 | self.closed = True | |
| 41 | ||
| 42 | ||
| 43 | class DesktopTests(unittest.IsolatedAsyncioTestCase): | |
| 44 | def setUp(self): | |
| 45 | self.reader, self.writer = asyncio.StreamReader(), Writer() | |
| 46 | self.messages = [] | |
| 47 | self.desktop = screen.Desktop(self.reader, self.writer, self.messages.append) | |
| 48 | self.desktop.resize(4, 2) | |
| 49 | ||
| 50 | async def update(self, rectangles): | |
| 51 | self.reader.feed_data(struct.pack("!BBH", 0, 0, len(rectangles)) + b"".join(rectangles)) | |
| 52 | self.reader.feed_eof() | |
| 53 | self.presented = self.resized = 0 | |
| 54 | ||
| 55 | def present(): | |
| 56 | self.presented += 1 | |
| 57 | ||
| 58 | def resize(): | |
| 59 | self.resized += 1 | |
| 60 | ||
| 61 | with self.assertRaises(asyncio.IncompleteReadError): | |
| 62 | await self.desktop.read(present, resize) | |
| 63 | ||
| 64 | async def test_handshake(self): | |
| 65 | self.reader.feed_data(b"RFB 003.008\n\x01\x01\0\0\0\0" + struct.pack("!HH16sI", 4, 2, bytes(16), 4) + b"test") | |
| 66 | await self.desktop.start() | |
| 67 | self.assertEqual(self.writer.data[:14], b"RFB 003.008\n\x01\x01") | |
| 68 | self.assertEqual(self.writer.data[-10:], struct.pack("!BBHHHH", 3, 0, 0, 0, 4, 2)) | |
| 69 | ||
| 70 | async def test_raw_and_overlapping_copy_present_once(self): | |
| 71 | pixels = b"".join(bytes([value, 0, 0, 0]) for value in range(1, 9)) | |
| 72 | await self.update([ | |
| 73 | struct.pack("!HHHHi", 0, 0, 4, 2, 0) + pixels, | |
| 74 | struct.pack("!HHHHiHH", 1, 0, 3, 2, 1, 0, 0), | |
| 75 | ]) | |
| 76 | self.assertEqual(list(self.desktop.pixels[::4]), [1, 1, 2, 3, 5, 5, 6, 7]) | |
| 77 | self.assertEqual(self.presented, 1) | |
| 78 | ||
| 79 | async def test_idle_and_cursor_do_not_encode(self): | |
| 80 | await self.update([struct.pack("!HHHHi", 0, 0, 2, 1, -239) + bytes([1, 2, 3, 0, 4, 5, 6, 0, 128])]) | |
| 81 | self.assertEqual(self.presented, 0) | |
| 82 | encoded = base64.b64decode(self.messages[0]["image"].split(",")[1]) | |
| 83 | length = struct.unpack("!I", encoded[33:37])[0] | |
| 84 | self.assertEqual(zlib.decompress(encoded[41:41 + length]), bytes([0, 3, 2, 1, 255, 6, 5, 4, 0])) | |
| 85 | ||
| 86 | async def test_resize_and_out_of_bounds(self): | |
| 87 | await self.update([struct.pack("!HHHHi", 0, 0, 2, 2, -223)]) | |
| 88 | self.assertEqual((self.desktop.width, self.desktop.height, self.resized), (2, 2, 1)) | |
| 89 | self.reader = asyncio.StreamReader() | |
| 90 | self.desktop.reader = self.reader | |
| 91 | with self.assertRaisesRegex(ValueError, "outside"): | |
| 92 | await self.update([struct.pack("!HHHHi", 1, 0, 2, 1, 0)]) | |
| 93 | for width, height in [(0, 2), (4097, 1), (65535, 65535)]: | |
| 94 | with self.assertRaises(ValueError): | |
| 95 | self.desktop.resize(width, height) | |
| 96 | ||
| 97 | def test_input_validation_and_release(self): | |
| 98 | self.desktop.input({"type": "key", "key": 65507, "down": True}) | |
| 99 | self.desktop.input({"type": "pointer", "x": -1, "y": 10, "buttons": 1}) | |
| 100 | self.desktop.input({"type": "release"}) | |
| 101 | self.assertFalse(self.desktop.keys) | |
| 102 | self.assertEqual(self.writer.data[-6:], struct.pack("!BBHH", 5, 0, 0, 1)) | |
| 103 | for message in [{"type": "key", "key": True, "down": True}, {"type": "pointer", "x": 1, "y": 1, "buttons": 256}]: | |
| 104 | with self.assertRaises(ValueError): | |
| 105 | self.desktop.input(message) | |
| 106 | ||
| 107 | ||
| 108 | class GuestTests(unittest.IsolatedAsyncioTestCase): | |
| 109 | def setUp(self): | |
| 110 | self.guest = screen.GuestDesktop("fixture") | |
| 111 | self.messages = [] | |
| 112 | self.presented = self.resized = 0 | |
| 113 | self.viewer = Mock(desktop=SimpleNamespace(cursor={"type": "cursor", "source": "qemu"}), | |
| 114 | writer=Writer(), send=self.messages.append, resize=self.resize, present=self.present) | |
| 115 | self.guest.screens = {self.viewer} | |
| 116 | ||
| 117 | def present(self): | |
| 118 | self.presented += 1 | |
| 119 | ||
| 120 | def resize(self): | |
| 121 | self.resized += 1 | |
| 122 | ||
| 123 | async def packets(self, *packets): | |
| 124 | reader = asyncio.StreamReader() | |
| 125 | for name, words, data in packets: | |
| 126 | kind = screen.GUEST_PROTOCOL["packets"][name]["id"] | |
| 127 | payload = struct.pack("!" + "I" * len(words), *words) + data | |
| 128 | reader.feed_data(struct.pack("!4sII", b"SGV1", kind, len(payload)) + payload) | |
| 129 | reader.feed_eof() | |
| 130 | with self.assertRaises(asyncio.IncompleteReadError): | |
| 131 | await self.guest.read(reader) | |
| 132 | ||
| 133 | async def test_dirty_rectangles_and_cursor_do_not_reencode_video(self): | |
| 134 | await self.packets(("screen", [4, 2, 0, 0, 4, 2], bytes(32)), | |
| 135 | ("screen", [4, 2, 2, 1, 1, 1], b"\x01\x02\x03\0"), | |
| 136 | ("cursor", [7, 1, 1, 0, 0, 2], struct.pack("!I", 17) + bytes([0,0,0,0,7]) + struct.pack("!I", 33) + bytes([1,2,3,255,0])), | |
| 137 | ("pointer", [7, 0xffffffff, 9, 1], b"")) | |
| 138 | self.assertEqual(self.guest.pixels[24:28], b"\x01\x02\x03\0") | |
| 139 | self.assertEqual((self.presented, self.resized), (2, 1)) | |
| 140 | self.assertEqual([message["duration"] for message in self.messages if message["type"] == "cursor"], [17, 33]) | |
| 141 | self.assertIn("invert", self.messages[0]) | |
| 142 | self.assertEqual(self.guest.pointer["x"], -1) | |
| 143 | ||
| 144 | async def test_suspend_restores_fallback_and_requires_full_refresh(self): | |
| 145 | await self.packets(("screen", [2, 2, 0, 0, 2, 2], bytes(16)), ("suspend", [], b"")) | |
| 146 | self.assertFalse(self.guest.pixels) | |
| 147 | self.assertEqual(self.messages[-1]["source"], "qemu") | |
| 148 | with self.assertRaisesRegex(ValueError, "complete"): | |
| 149 | await self.packets(("screen", [2, 2, 0, 0, 1, 1], bytes(4))) | |
| 150 | self.viewer.desktop.cursor = None | |
| 151 | await self.packets(("screen", [1, 1, 0, 0, 1, 1], bytes(4)), ("suspend", [], b"")) | |
| 152 | self.assertTrue(self.messages[-1]["fallback"]) | |
| 153 | ||
| 154 | async def test_untrusted_guest_packets_are_bounded(self): | |
| 155 | for packet in [("screen", [4097, 1, 0, 0, 1, 1], bytes(4)), | |
| 156 | ("screen", [2, 2, 1, 1, 2, 2], bytes(16)), | |
| 157 | ("cursor", [1, 1, 1, 1, 0, 1], struct.pack("!I", 100) + bytes(5)), | |
| 158 | ("cursor", [1, 1, 1, 0, 0, 1], struct.pack("!I", 0) + bytes(5)), | |
| 159 | ("pointer", [1, 0, 0, 2], b""), ("suspend", [], b"x")]: | |
| 160 | with self.subTest(packet=packet), self.assertRaises(ValueError): | |
| 161 | await self.packets(packet) | |
| 162 | reader = asyncio.StreamReader() | |
| 163 | reader.feed_data(struct.pack("!4sII", b"SGV1", 1, 0xffffffff)) | |
| 164 | with self.assertRaises(ValueError): | |
| 165 | await self.guest.read(reader) | |
| 166 | ||
| 167 | async def test_viewer_close_during_cursor_fanout(self): | |
| 168 | await self.packets(("screen", [1, 1, 0, 0, 1, 1], bytes(4))) | |
| 169 | self.viewer.send = lambda _: self.guest.screens.discard(self.viewer) | |
| 170 | await self.packets(("cursor", [1, 1, 1, 0, 0, 2], (struct.pack("!I", 100) + bytes(5)) * 2)) | |
| 171 | self.assertFalse(self.guest.screens) | |
| 172 | self.assertEqual(len(self.guest.cursor), 2) | |
| 173 | ||
| 174 | async def test_missing_heartbeat_times_out_but_does_not_encode(self): | |
| 175 | await self.packets(("screen", [1, 1, 0, 0, 1, 1], bytes(4)), ("heartbeat", [], b"")) | |
| 176 | self.assertEqual(self.presented, 1) | |
| 177 | reader = asyncio.StreamReader() | |
| 178 | with self.assertRaises(TimeoutError): | |
| 179 | await self.guest.read(reader) | |
| 180 | ||
| 181 | ||
| 182 | class StreamTests(unittest.IsolatedAsyncioTestCase): | |
| 183 | async def asyncSetUp(self): | |
| 184 | self.writer = Writer() | |
| 185 | desktop = screen.Desktop(asyncio.StreamReader(), Writer(), lambda _: None) | |
| 186 | desktop.resize(320, 200) | |
| 187 | self.stream = screen.Screen(self.writer, desktop) | |
| 188 | ||
| 189 | async def asyncTearDown(self): | |
| 190 | self.writer.close() | |
| 191 | self.stream.close() | |
| 192 | ||
| 193 | async def test_browser_payload_and_sending_direction(self): | |
| 194 | sdp = "\r\n".join([ | |
| 195 | "v=0", "o=- 1 1 IN IP4 127.0.0.1", "s=-", "t=0 0", "a=group:BUNDLE 0", | |
| 196 | "m=video 9 UDP/TLS/RTP/SAVPF 96 103", "c=IN IP4 0.0.0.0", "a=mid:0", "a=recvonly", | |
| 197 | "a=rtcp-mux", "a=ice-ufrag:abcd", "a=ice-pwd:abcdefghijklmnopqrstuvwxyz", | |
| 198 | "a=setup:actpass", "a=fingerprint:sha-256 " + ":".join(["00"] * 32), | |
| 199 | "a=rtpmap:96 VP8/90000", "a=rtpmap:103 H264/90000", | |
| 200 | "a=fmtp:103 packetization-mode=1;profile-level-id=42e01f;level-asymmetry-allowed=1", "", | |
| 201 | ]) | |
| 202 | self.stream.signal({"type": "offer", "sdp": sdp}) | |
| 203 | async with asyncio.timeout(5): | |
| 204 | while b'"type":"answer"' not in self.writer.data: | |
| 205 | await asyncio.sleep(0.01) | |
| 206 | answer = next(json.loads(line) for line in self.writer.data.splitlines() if json.loads(line)["type"] == "answer") | |
| 207 | self.assertIn("a=sendonly", answer["sdp"]) | |
| 208 | self.assertIn("a=rtpmap:103 H264/90000", answer["sdp"]) | |
| 209 | self.assertEqual(self.stream.pipeline.get_by_name("pay").get_property("pt"), 103) | |
| 210 | ||
| 211 | async def test_stalled_guest_closes_and_ignores_queued_input(self): | |
| 212 | message = json.dumps({"type": "clipboard", "text": "a" * 65536}) | |
| 213 | for _ in range(64): | |
| 214 | self.stream.input(message) | |
| 215 | self.assertTrue(self.writer.closed) | |
| 216 | self.assertLess(len(self.stream.desktop.writer.data), 327680) | |
| 217 | ||
| 218 | async def test_rtp_packets_fit_tunneled_network(self): | |
| 219 | Gst = screen.Gst | |
| 220 | pipeline = self.stream.pipeline | |
| 221 | pipeline.set_state(Gst.State.NULL) | |
| 222 | pipeline.remove(self.stream.rtc) | |
| 223 | sink = Gst.ElementFactory.make("appsink") | |
| 224 | sink.set_property("sync", False) | |
| 225 | pipeline.add(sink) | |
| 226 | pipeline.get_by_name("codec").link(sink) | |
| 227 | pipeline.set_state(Gst.State.PLAYING) | |
| 228 | pixels = random.Random(0).randbytes(len(self.stream.desktop.pixels)) | |
| 229 | self.stream.frames.emit("push-buffer", Gst.Buffer.new_wrapped(pixels)) | |
| 230 | packets = [] | |
| 231 | async with asyncio.timeout(5): | |
| 232 | while True: | |
| 233 | sample = sink.emit("try-pull-sample", 0) | |
| 234 | if sample: | |
| 235 | buffer = sample.get_buffer() | |
| 236 | packets.append(buffer.get_size()) | |
| 237 | if buffer.extract_dup(1, 1)[0] & 0x80: | |
| 238 | break | |
| 239 | else: | |
| 240 | await asyncio.sleep(0.01) | |
| 241 | self.assertGreater(len(packets), 1) | |
| 242 | self.assertLessEqual(max(packets), 1200) | |
| 243 | ||
| 244 | ||
| 245 | if __name__ == "__main__": | |
| 246 | screen.Gst.init(None) | |
| 247 | threading.Thread(target=screen.GLib.MainLoop().run, daemon=True).start() | |
| 248 | unittest.main() |
tools/vm-screen.py created+600| ... | ... | @@ -0,0 +1,600 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | import asyncio | |
| 3 | import base64 | |
| 4 | import contextlib | |
| 5 | import json | |
| 6 | import logging | |
| 7 | import os | |
| 8 | from pathlib import Path | |
| 9 | import socket | |
| 10 | import struct | |
| 11 | import threading | |
| 12 | import zlib | |
| 13 | ||
| 14 | import gi | |
| 15 | ||
| 16 | gi.require_version("Gst", "1.0") | |
| 17 | gi.require_version("GstSdp", "1.0") | |
| 18 | gi.require_version("GstWebRTC", "1.0") | |
| 19 | from gi.repository import GLib, Gst, GstSdp, GstWebRTC | |
| 20 | ||
| 21 | ||
| 22 | MAX_PIXELS = 3840 * 2160 | |
| 23 | STUN = os.environ.get("STUDIO_VM_STUN_SERVER", "stun://stun.cloudflare.com:3478") | |
| 24 | SLOTS = asyncio.Semaphore(4) | |
| 25 | GUEST_PROTOCOL = json.loads(Path(os.environ.get("STUDIO_VM_GUEST_PROTOCOL", Path(__file__).resolve().parent.parent / "guest/protocol.json")).read_text()) | |
| 26 | GUESTS = {} | |
| 27 | ||
| 28 | ||
| 29 | def png(width, height, pixels): | |
| 30 | def chunk(kind, data): | |
| 31 | return struct.pack("!I", len(data)) + kind + data + struct.pack("!I", zlib.crc32(kind + data)) | |
| 32 | rows = b"".join(b"\0" + pixels[row * width * 4:(row + 1) * width * 4] for row in range(height)) | |
| 33 | data = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack("!IIBBBBB", width, height, 8, 6, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"") | |
| 34 | return "data:image/png;base64," + base64.b64encode(data).decode() | |
| 35 | ||
| 36 | ||
| 37 | class GuestDesktop: | |
| 38 | def __init__(self, name): | |
| 39 | self.name = name | |
| 40 | self.screens = set() | |
| 41 | self.pixels = bytearray() | |
| 42 | self.cursor = [] | |
| 43 | self.pointer = None | |
| 44 | ||
| 45 | def suspend(self): | |
| 46 | active = bool(self.pixels) | |
| 47 | self.pixels = bytearray() | |
| 48 | self.cursor.clear() | |
| 49 | self.pointer = None | |
| 50 | if active: | |
| 51 | for screen in self.screens: | |
| 52 | screen.resize() | |
| 53 | screen.send(screen.desktop.cursor or {"type": "cursor", "source": "qemu", "frame": 0, "fallback": True, "x": 0, "y": 0}) | |
| 54 | screen.present() | |
| 55 | ||
| 56 | async def read(self, reader): | |
| 57 | limits = GUEST_PROTOCOL["limits"] | |
| 58 | packets = {packet["id"]: (name, packet["fields"]) for name, packet in GUEST_PROTOCOL["packets"].items()} | |
| 59 | while True: | |
| 60 | async with asyncio.timeout(4): | |
| 61 | magic, kind, length = struct.unpack("!4sII", await reader.readexactly(12)) | |
| 62 | if magic != GUEST_PROTOCOL["magic"].encode() or kind not in packets or length > limits["packet_bytes"]: | |
| 63 | raise ValueError("Invalid guest display packet") | |
| 64 | name, fields = packets[kind] | |
| 65 | if length < len(fields) * 4: | |
| 66 | raise ValueError("Incomplete guest display packet") | |
| 67 | payload = await reader.readexactly(length) | |
| 68 | values = dict(zip(fields, struct.unpack("!" + "I" * len(fields), payload[:len(fields) * 4]))) | |
| 69 | data = payload[len(fields) * 4:] | |
| 70 | if name == "screen": | |
| 71 | width, height = values["width"], values["height"] | |
| 72 | x, y, w, h = values["x"], values["y"], values["rect_width"], values["rect_height"] | |
| 73 | if not 0 < width <= limits["dimension"] or not 0 < height <= limits["dimension"] or width * height > limits["pixels"] or not w or not h or x + w > width or y + h > height or len(data) != w * h * 4: | |
| 74 | raise ValueError("Invalid guest display rectangle") | |
| 75 | resized = not self.pixels or (width, height) != (self.width, self.height) | |
| 76 | if resized: | |
| 77 | if (x, y, w, h) != (0, 0, width, height): | |
| 78 | raise ValueError("Guest display needs a complete first frame") | |
| 79 | self.width, self.height = width, height | |
| 80 | self.pixels = bytearray(width * height * 4) | |
| 81 | for row in range(h): | |
| 82 | offset = ((y + row) * width + x) * 4 | |
| 83 | self.pixels[offset:offset + w * 4] = data[row * w * 4:(row + 1) * w * 4] | |
| 84 | for screen in tuple(self.screens): | |
| 85 | if resized: | |
| 86 | screen.resize() | |
| 87 | if self.pointer: | |
| 88 | screen.send(self.pointer) | |
| 89 | for cursor in self.cursor: | |
| 90 | screen.send(cursor) | |
| 91 | screen.present() | |
| 92 | await asyncio.sleep(0) | |
| 93 | elif name == "cursor": | |
| 94 | width, height, count = values["width"], values["height"], values["frames"] | |
| 95 | if not width and not height and not count and not data and not values["hot_x"] and not values["hot_y"]: | |
| 96 | self.cursor = [{"type": "cursor", "source": "guest", "id": values["id"], "width": 0, "height": 0, "frame": 0, "frames": 0, "fallback": True, "x": 0, "y": 0}] | |
| 97 | if self.pixels: | |
| 98 | for screen in self.screens: | |
| 99 | screen.send(self.cursor[0]) | |
| 100 | continue | |
| 101 | pixels = width * height | |
| 102 | frame_bytes = 4 + pixels * 5 | |
| 103 | if not 0 < width <= limits["cursor_size"] or not 0 < height <= limits["cursor_size"] or not 0 < count <= limits["cursor_frames"] or values["hot_x"] >= width or values["hot_y"] >= height or len(data) != frame_bytes * count: | |
| 104 | raise ValueError("Invalid guest cursor") | |
| 105 | self.cursor.clear() | |
| 106 | for index in range(count): | |
| 107 | frame = data[index * frame_bytes:(index + 1) * frame_bytes] | |
| 108 | duration = struct.unpack("!I", frame[:4])[0] | |
| 109 | if not 1 <= duration <= 60000: | |
| 110 | raise ValueError("Invalid guest cursor duration") | |
| 111 | mask = frame[4 + pixels * 4:] | |
| 112 | if any(value & ~sum(GUEST_PROTOCOL["xor_bits"].values()) for value in mask): | |
| 113 | raise ValueError("Invalid guest cursor inversion") | |
| 114 | message = {"type": "cursor", "source": "guest", "id": values["id"], "width": width, "height": height, | |
| 115 | "x": values["hot_x"], "y": values["hot_y"], "frame": index, "frames": count, | |
| 116 | "duration": duration, "image": png(width, height, frame[4:4 + pixels * 4])} | |
| 117 | if any(mask): | |
| 118 | bits = GUEST_PROTOCOL["xor_bits"] | |
| 119 | message["invert"] = png(width, height, b"".join(bytes([255 if value & bits["red"] else 0, | |
| 120 | 255 if value & bits["green"] else 0, | |
| 121 | 255 if value & bits["blue"] else 0, | |
| 122 | 255 if value else 0]) for value in mask)) | |
| 123 | self.cursor.append(message) | |
| 124 | if self.pixels: | |
| 125 | for screen in tuple(self.screens): | |
| 126 | screen.send(message) | |
| 127 | await asyncio.sleep(0) | |
| 128 | elif name == "pointer": | |
| 129 | if data or values["visible"] > 1: | |
| 130 | raise ValueError("Invalid guest pointer") | |
| 131 | self.pointer = {"type": "cursor-position", "id": values["id"], "visible": bool(values["visible"]), | |
| 132 | "x": (values["x"] ^ 0x80000000) - 0x80000000, "y": (values["y"] ^ 0x80000000) - 0x80000000} | |
| 133 | if self.pixels: | |
| 134 | for screen in self.screens: | |
| 135 | screen.send(self.pointer) | |
| 136 | elif name == "suspend": | |
| 137 | if data: | |
| 138 | raise ValueError("Invalid guest display suspension") | |
| 139 | self.suspend() | |
| 140 | elif name == "heartbeat": | |
| 141 | if data: | |
| 142 | raise ValueError("Invalid guest display heartbeat") | |
| 143 | else: | |
| 144 | raise ValueError("Unexpected guest display request") | |
| 145 | ||
| 146 | async def run(self): | |
| 147 | while True: | |
| 148 | writer = None | |
| 149 | try: | |
| 150 | reader, writer = await asyncio.wait_for(asyncio.open_unix_connection( | |
| 151 | os.environ.get("STUDIO_HOST_SOCKET", "/run/studio-host/host.sock")), 10) | |
| 152 | peer = writer.get_extra_info("socket") | |
| 153 | if struct.unpack("3i", peer.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))[1] != 0: | |
| 154 | raise ValueError("Untrusted guest display broker") | |
| 155 | writer.write(json.dumps({"operation": "vm.guest", "payload": {"name": self.name}}).encode() + b"\n") | |
| 156 | size = struct.unpack("!I", await asyncio.wait_for(reader.readexactly(4), 70))[0] | |
| 157 | if size > 65536: | |
| 158 | raise ValueError("Invalid guest display broker response") | |
| 159 | response = json.loads(await reader.readexactly(size)) | |
| 160 | if "error" not in response: | |
| 161 | writer.write(struct.pack("!4sII", GUEST_PROTOCOL["magic"].encode(), GUEST_PROTOCOL["packets"]["refresh"]["id"], 0)) | |
| 162 | await self.read(reader) | |
| 163 | except (OSError, ValueError, EOFError, asyncio.IncompleteReadError, TimeoutError) as error: | |
| 164 | logging.info("Guest display %s: %s", self.name, error) | |
| 165 | finally: | |
| 166 | self.suspend() | |
| 167 | if writer: | |
| 168 | writer.close() | |
| 169 | with contextlib.suppress(OSError, TimeoutError): | |
| 170 | await asyncio.wait_for(writer.wait_closed(), 2) | |
| 171 | await asyncio.sleep(2) | |
| 172 | ||
| 173 | ||
| 174 | class Desktop: | |
| 175 | def __init__(self, reader, writer, notify): | |
| 176 | self.reader, self.writer, self.notify = reader, writer, notify | |
| 177 | self.keys = set() | |
| 178 | self.pointer = (0, 0) | |
| 179 | self.pixels = bytearray() | |
| 180 | self.cursor = None | |
| 181 | ||
| 182 | def resize(self, width, height): | |
| 183 | if not 0 < width <= 4096 or not 0 < height <= 4096 or width * height > MAX_PIXELS: | |
| 184 | raise ValueError("The VM screen exceeds the supported 4K display size.") | |
| 185 | self.width, self.height = width, height | |
| 186 | self.pixels = bytearray(width * height * 4) | |
| 187 | ||
| 188 | async def start(self): | |
| 189 | if not (await self.reader.readexactly(12)).startswith(b"RFB 003."): | |
| 190 | raise ValueError("The VM screen did not respond. Restart the VM and reconnect.") | |
| 191 | self.writer.write(b"RFB 003.008\n") | |
| 192 | count = (await self.reader.readexactly(1))[0] | |
| 193 | security = await self.reader.readexactly(count) | |
| 194 | if 1 not in security: | |
| 195 | raise ValueError("Remove the VM's VNC password in its display settings, then reconnect.") | |
| 196 | self.writer.write(b"\x01") | |
| 197 | if await self.reader.readexactly(4) != b"\0\0\0\0": | |
| 198 | raise ValueError("The VM rejected the screen connection. Restart it and reconnect.") | |
| 199 | self.writer.write(b"\x01") | |
| 200 | width, height, _, length = struct.unpack("!HH16sI", await self.reader.readexactly(24)) | |
| 201 | if length > 65536: | |
| 202 | raise ValueError("The VM screen sent an unsupported desktop name.") | |
| 203 | await self.reader.readexactly(length) | |
| 204 | self.resize(width, height) | |
| 205 | self.writer.write(struct.pack("!B3xBBBBHHHBBB3x", 0, 32, 24, 0, 1, 255, 255, 255, 16, 8, 0)) | |
| 206 | encodings = (0, 1, -223, -224, -239) | |
| 207 | self.writer.write(struct.pack("!BBH" + "i" * len(encodings), 2, 0, len(encodings), *encodings)) | |
| 208 | self.request(False) | |
| 209 | ||
| 210 | def request(self, incremental=True): | |
| 211 | self.writer.write(struct.pack("!BBHHHH", 3, incremental, 0, 0, self.width, self.height)) | |
| 212 | ||
| 213 | def input(self, message): | |
| 214 | kind = message.get("type") | |
| 215 | if kind == "key": | |
| 216 | key, down = message.get("key"), message.get("down") | |
| 217 | if type(key) is not int or not 0 <= key <= 0xFFFFFFFF or type(down) is not bool: | |
| 218 | raise ValueError("Unsupported key event.") | |
| 219 | if down: | |
| 220 | self.keys.add(key) | |
| 221 | else: | |
| 222 | self.keys.discard(key) | |
| 223 | self.writer.write(struct.pack("!BB2xI", 4, down, key)) | |
| 224 | elif kind == "pointer": | |
| 225 | x, y, buttons = message.get("x"), message.get("y"), message.get("buttons") | |
| 226 | if any(type(value) is not int for value in (x, y, buttons)) or not 0 <= buttons <= 255: | |
| 227 | raise ValueError("Unsupported pointer event.") | |
| 228 | self.pointer = (max(0, min(x, self.width - 1)), max(0, min(y, self.height - 1))) | |
| 229 | self.writer.write(struct.pack("!BBHH", 5, buttons, *self.pointer)) | |
| 230 | elif kind == "release": | |
| 231 | for key in self.keys: | |
| 232 | self.writer.write(struct.pack("!BB2xI", 4, 0, key)) | |
| 233 | self.keys.clear() | |
| 234 | self.writer.write(struct.pack("!BBHH", 5, 0, *self.pointer)) | |
| 235 | elif kind == "clipboard": | |
| 236 | text = message.get("text") | |
| 237 | if not isinstance(text, str) or len(text) > 65536: | |
| 238 | raise ValueError("Shorten the clipboard text, then send it again.") | |
| 239 | try: | |
| 240 | data = text.encode("latin1") | |
| 241 | except UnicodeEncodeError: | |
| 242 | raise ValueError("Some clipboard characters aren't supported by this guest.") from None | |
| 243 | self.writer.write(struct.pack("!B3xI", 6, len(data)) + data) | |
| 244 | else: | |
| 245 | raise ValueError("Unsupported input event.") | |
| 246 | ||
| 247 | async def read(self, present, resized): | |
| 248 | while True: | |
| 249 | kind = (await self.reader.readexactly(1))[0] | |
| 250 | if kind == 0: | |
| 251 | _, count = struct.unpack("!BH", await self.reader.readexactly(3)) | |
| 252 | dirty = False | |
| 253 | for _ in range(count): | |
| 254 | x, y, width, height, encoding = struct.unpack("!HHHHi", await self.reader.readexactly(12)) | |
| 255 | if encoding == -224: | |
| 256 | break | |
| 257 | if encoding == -223: | |
| 258 | self.resize(width, height) | |
| 259 | resized() | |
| 260 | continue | |
| 261 | if encoding == -239: | |
| 262 | if width * height > 256 * 256: | |
| 263 | raise ValueError("The VM sent an unsupported cursor size.") | |
| 264 | pixels = bytearray(await self.reader.readexactly(width * height * 4)) | |
| 265 | mask = await self.reader.readexactly(((width + 7) // 8) * height) | |
| 266 | for row in range(height): | |
| 267 | for col in range(width): | |
| 268 | offset = (row * width + col) * 4 | |
| 269 | pixels[offset], pixels[offset + 2] = pixels[offset + 2], pixels[offset] | |
| 270 | pixels[offset + 3] = 255 if mask[row * ((width + 7) // 8) + col // 8] & (128 >> (col % 8)) else 0 | |
| 271 | self.cursor = {"type": "cursor", "source": "qemu", "width": width, "height": height, "x": x, "y": y, | |
| 272 | "frame": 0, "frames": 1, "duration": 100, | |
| 273 | "image": png(width, height, pixels) if width and height else None} | |
| 274 | self.notify(self.cursor) | |
| 275 | continue | |
| 276 | if x + width > self.width or y + height > self.height: | |
| 277 | raise ValueError("The VM sent a screen update outside its display.") | |
| 278 | if encoding == 0: | |
| 279 | data = await self.reader.readexactly(width * height * 4) | |
| 280 | elif encoding == 1: | |
| 281 | source_x, source_y = struct.unpack("!HH", await self.reader.readexactly(4)) | |
| 282 | if source_x + width > self.width or source_y + height > self.height: | |
| 283 | raise ValueError("The VM sent a screen copy outside its display.") | |
| 284 | data = b"".join(self.pixels[((source_y + row) * self.width + source_x) * 4: | |
| 285 | ((source_y + row) * self.width + source_x + width) * 4] for row in range(height)) | |
| 286 | else: | |
| 287 | raise ValueError("The VM sent an unsupported screen encoding.") | |
| 288 | for row in range(height): | |
| 289 | start = ((y + row) * self.width + x) * 4 | |
| 290 | self.pixels[start:start + width * 4] = data[row * width * 4:(row + 1) * width * 4] | |
| 291 | dirty = dirty or bool(width and height) | |
| 292 | if dirty: | |
| 293 | present() | |
| 294 | self.request() | |
| 295 | await self.writer.drain() | |
| 296 | elif kind == 2: | |
| 297 | pass | |
| 298 | elif kind == 3: | |
| 299 | _, length = struct.unpack("!3sI", await self.reader.readexactly(7)) | |
| 300 | if length > 65536: | |
| 301 | raise ValueError("The VM sent too much clipboard text.") | |
| 302 | self.notify({"type": "clipboard", "text": (await self.reader.readexactly(length)).decode("latin1")}) | |
| 303 | else: | |
| 304 | raise ValueError("The VM sent an unsupported screen message.") | |
| 305 | ||
| 306 | ||
| 307 | class Screen: | |
| 308 | def __init__(self, writer, desktop): | |
| 309 | self.loop = asyncio.get_running_loop() | |
| 310 | self.writer, self.desktop = writer, desktop | |
| 311 | self.guest = None | |
| 312 | self.channel = None | |
| 313 | hardware = Gst.ElementFactory.make("nvh264enc") | |
| 314 | encoder = "nvh264enc name=encoder preset=p4 tune=ultra-low-latency zerolatency=true bframes=0 bitrate=6000 gop-size=30" if hardware else ( | |
| 315 | "x264enc name=encoder tune=zerolatency speed-preset=ultrafast bitrate=6000 key-int-max=30 bframes=0") | |
| 316 | width, height = self.desktop.width, self.desktop.height | |
| 317 | self.pipeline = Gst.parse_launch( | |
| 318 | f"appsrc name=frames is-live=true format=time do-timestamp=true max-buffers=1 leaky-type=downstream " | |
| 319 | f"caps=video/x-raw,format=BGRx,width={width},height={height},framerate=30/1 " | |
| 320 | f"! videoconvert ! videoscale ! capsfilter name=size caps=video/x-raw,format=NV12,width={width + width % 2},height={height + height % 2} " | |
| 321 | f"! {encoder} ! video/x-h264,profile=constrained-baseline ! h264parse " | |
| 322 | "! rtph264pay name=pay pt=96 mtu=1200 config-interval=-1 aggregate-mode=zero-latency " | |
| 323 | "! capsfilter name=codec caps=application/x-rtp,media=video,encoding-name=H264,clock-rate=90000 " | |
| 324 | "! webrtcbin name=rtc bundle-policy=max-bundle") | |
| 325 | self.frames = self.pipeline.get_by_name("frames") | |
| 326 | self.rtc = self.pipeline.get_by_name("rtc") | |
| 327 | self.ice = self.rtc.get_property("ice-agent") | |
| 328 | self.ice._ref_sink() # PyGObject sinks the default agent's floating reference, which webrtcbin still owns. | |
| 329 | self.ice.set_property("min-rtp-port", 50000) | |
| 330 | self.ice.set_property("max-rtp-port", 50031) | |
| 331 | if STUN: | |
| 332 | self.rtc.set_property("stun-server", STUN) | |
| 333 | self.handlers = [ | |
| 334 | (self.rtc, self.rtc.connect("on-ice-candidate", lambda _, line, candidate: self.loop.call_soon_threadsafe( | |
| 335 | self.send, {"type": "candidate", "sdpMLineIndex": line, "candidate": candidate}))), | |
| 336 | (self.rtc, self.rtc.connect("on-data-channel", self.data_channel)), | |
| 337 | (self.rtc, self.rtc.connect("notify::connection-state", self.connection_state)), | |
| 338 | ] | |
| 339 | self.probe = self.frames.get_static_pad("src").add_probe(Gst.PadProbeType.EVENT_UPSTREAM, self.refresh) | |
| 340 | self.bus = self.pipeline.get_bus() | |
| 341 | self.bus.add_signal_watch() | |
| 342 | self.handlers.append((self.bus, self.bus.connect("message::error", self.pipeline_error))) | |
| 343 | self.pipeline.set_state(Gst.State.PLAYING) | |
| 344 | self.send({"type": "display", "width": width, "height": height, "encoder": "nvenc" if hardware else "x264"}) | |
| 345 | ||
| 346 | def close(self): | |
| 347 | self.desktop.notify = None | |
| 348 | if self.guest: | |
| 349 | self.guest.screens.discard(self) | |
| 350 | if not self.guest.screens: | |
| 351 | GUESTS.pop(self.guest.name, None) | |
| 352 | self.guest.task.cancel() | |
| 353 | for element, handler in self.handlers: | |
| 354 | element.disconnect(handler) | |
| 355 | self.handlers.clear() | |
| 356 | self.frames.get_static_pad("src").remove_probe(self.probe) | |
| 357 | self.bus.remove_signal_watch() | |
| 358 | self.pipeline.set_state(Gst.State.NULL) | |
| 359 | ||
| 360 | def send(self, message): | |
| 361 | if not self.writer.is_closing(): | |
| 362 | self.writer.write(json.dumps(message, separators=(",", ":")).encode() + b"\n") | |
| 363 | if self.writer.transport.get_write_buffer_size() > 262144: | |
| 364 | self.writer.close() | |
| 365 | ||
| 366 | def fail(self, message): | |
| 367 | self.send({"type": "error", "message": message, "fatal": True}) | |
| 368 | self.writer.close() | |
| 369 | ||
| 370 | def pipeline_error(self, _, message): | |
| 371 | error, debug = message.parse_error() | |
| 372 | logging.error("VM screen stream: %s (%s)", error, debug) | |
| 373 | self.loop.call_soon_threadsafe(self.fail, "The screen stream stopped. Reconnect to try again.") | |
| 374 | ||
| 375 | def resize(self): | |
| 376 | width, height = self.source.width, self.source.height | |
| 377 | self.frames.set_property("caps", Gst.Caps.from_string( | |
| 378 | f"video/x-raw,format=BGRx,width={width},height={height},framerate=30/1")) | |
| 379 | self.pipeline.get_by_name("size").set_property("caps", Gst.Caps.from_string( | |
| 380 | f"video/x-raw,format=NV12,width={width + width % 2},height={height + height % 2}")) | |
| 381 | self.send({"type": "display", "width": width, "height": height, | |
| 382 | "source": "guest" if self.source is self.guest else "qemu", | |
| 383 | "encoder": "nvenc" if self.pipeline.get_by_name("encoder").get_factory().get_name() == "nvh264enc" else "x264"}) | |
| 384 | ||
| 385 | def refresh(self, _, info): | |
| 386 | event = info.get_event().get_structure() | |
| 387 | if event and event.get_name() == "GstForceKeyUnit": | |
| 388 | self.loop.call_soon_threadsafe(self.present) | |
| 389 | return Gst.PadProbeReturn.OK | |
| 390 | ||
| 391 | def connection_state(self, rtc, _): | |
| 392 | state = rtc.get_property("connection-state") | |
| 393 | if state == GstWebRTC.WebRTCPeerConnectionState.CONNECTED: | |
| 394 | self.loop.call_soon_threadsafe(self.present) | |
| 395 | self.loop.call_soon_threadsafe(self.desktop.request, False) | |
| 396 | elif state in (GstWebRTC.WebRTCPeerConnectionState.FAILED, GstWebRTC.WebRTCPeerConnectionState.CLOSED): | |
| 397 | self.loop.call_soon_threadsafe(self.fail, "The screen disconnected. Reconnect to try again.") | |
| 398 | ||
| 399 | def data_channel(self, _, channel): | |
| 400 | if channel.get_property("label") != "input" or self.channel: | |
| 401 | channel.emit("close") | |
| 402 | return | |
| 403 | self.channel = channel | |
| 404 | self.handlers.append((channel, channel.connect("on-message-string", lambda _, message: self.loop.call_soon_threadsafe(self.input, message)))) | |
| 405 | ||
| 406 | def input(self, raw): | |
| 407 | if self.writer.is_closing(): | |
| 408 | return | |
| 409 | try: | |
| 410 | if len(raw) > 131072: | |
| 411 | raise ValueError("Shorten the clipboard text, then send it again.") | |
| 412 | message = json.loads(raw) | |
| 413 | if not isinstance(message, dict): | |
| 414 | raise ValueError("Unsupported input event.") | |
| 415 | self.desktop.input(message) | |
| 416 | if self.desktop.writer.transport.get_write_buffer_size() > 262144: | |
| 417 | self.fail("The VM stopped accepting input. Reconnect to try again.") | |
| 418 | except (ValueError, TypeError) as error: | |
| 419 | self.send({"type": "error", "message": str(error)}) | |
| 420 | ||
| 421 | @property | |
| 422 | def source(self): | |
| 423 | return self.guest if self.guest and self.guest.pixels else self.desktop | |
| 424 | ||
| 425 | def present(self): | |
| 426 | if self.rtc.get_property("connection-state") == GstWebRTC.WebRTCPeerConnectionState.CONNECTED: | |
| 427 | buffer = Gst.Buffer.new_wrapped(bytes(self.source.pixels)) | |
| 428 | self.frames.emit("push-buffer", buffer) | |
| 429 | ||
| 430 | def signal(self, message): | |
| 431 | if message.get("type") == "offer" and isinstance(message.get("sdp"), str): | |
| 432 | if self.rtc.get_property("remote-description"): | |
| 433 | raise ValueError("Reconnect to start a new screen connection.") | |
| 434 | result, sdp = GstSdp.SDPMessage.new_from_text(message["sdp"]) | |
| 435 | if result != GstSdp.SDPResult.OK: | |
| 436 | raise ValueError("Unable to connect the screen. Reconnect to try again.") | |
| 437 | caps = None | |
| 438 | for index in range(sdp.medias_len()): | |
| 439 | media = sdp.get_media(index) | |
| 440 | if media.get_media() != "video": | |
| 441 | continue | |
| 442 | for format_index in range(media.formats_len()): | |
| 443 | payload = int(media.get_format(format_index)) | |
| 444 | offered = media.get_caps_from_media(payload) | |
| 445 | if not offered: | |
| 446 | continue | |
| 447 | codec = offered.get_structure(0) | |
| 448 | profile = codec.get_string("profile-level-id") | |
| 449 | if codec.get_string("encoding-name") == "H264" and codec.get_string("packetization-mode") == "1" and ( | |
| 450 | not profile or profile.startswith("42")): | |
| 451 | caps = offered | |
| 452 | break | |
| 453 | if caps: | |
| 454 | break | |
| 455 | if not caps: | |
| 456 | raise ValueError("This browser doesn't support the VM's H.264 stream. Open it in a current browser.") | |
| 457 | payload = caps.get_structure(0).get_value("payload") | |
| 458 | caps = Gst.Caps.from_string( | |
| 459 | f"application/x-rtp,media=video,encoding-name=H264,clock-rate=90000,payload={payload}," | |
| 460 | "packetization-mode=(string)1,rtcp-fb-nack=(boolean)true,rtcp-fb-nack-pli=(boolean)true") | |
| 461 | self.pipeline.get_by_name("pay").set_property("pt", payload) | |
| 462 | self.pipeline.get_by_name("codec").set_property("caps", caps) | |
| 463 | transceiver = self.rtc.get_static_pad("sink_0").get_property("transceiver") | |
| 464 | transceiver.set_property("codec-preferences", caps) | |
| 465 | transceiver.set_property("direction", GstWebRTC.WebRTCRTPTransceiverDirection.SENDONLY) | |
| 466 | transceiver.set_property("do-nack", True) | |
| 467 | description = GstWebRTC.WebRTCSessionDescription.new(GstWebRTC.WebRTCSDPType.OFFER, sdp) | |
| 468 | promise = Gst.Promise.new_with_change_func(self.remote_description, None, None) | |
| 469 | self.rtc.emit("set-remote-description", description, promise) | |
| 470 | elif message.get("type") == "candidate": | |
| 471 | line, candidate = message.get("sdpMLineIndex"), message.get("candidate") | |
| 472 | if type(line) is not int or not 0 <= line <= 16 or not isinstance(candidate, str): | |
| 473 | raise ValueError("Unsupported screen connection candidate.") | |
| 474 | self.rtc.emit("add-ice-candidate", line, candidate) | |
| 475 | else: | |
| 476 | raise ValueError("Unsupported screen connection message.") | |
| 477 | ||
| 478 | def remote_description(self, promise, *_): | |
| 479 | reply = promise.get_reply() | |
| 480 | if reply and reply.has_field("error"): | |
| 481 | logging.error("VM screen offer: %s", reply.to_string()) | |
| 482 | self.loop.call_soon_threadsafe(self.fail, "Unable to connect the screen. Reconnect to try again.") | |
| 483 | return | |
| 484 | self.rtc.emit("create-answer", None, Gst.Promise.new_with_change_func(self.answer, None, None)) | |
| 485 | ||
| 486 | def answer(self, promise, *_): | |
| 487 | reply = promise.get_reply() | |
| 488 | if not reply or not reply.has_field("answer"): | |
| 489 | logging.error("VM screen answer: %s", reply.to_string() if reply else "missing reply") | |
| 490 | self.loop.call_soon_threadsafe(self.fail, "Unable to connect the screen. Reconnect to try again.") | |
| 491 | return | |
| 492 | answer = reply.get_value("answer") | |
| 493 | self.rtc.emit("set-local-description", answer, Gst.Promise.new()) | |
| 494 | self.loop.call_soon_threadsafe(self.send, {"type": "answer", "sdp": answer.sdp.as_text()}) | |
| 495 | ||
| 496 | ||
| 497 | async def serve(reader, writer): | |
| 498 | desktop = screen = task = None | |
| 499 | if SLOTS.locked(): | |
| 500 | writer.write(b'{"type":"error","fatal":true,"message":"Four VM screens are already open. Close one, then reconnect."}\n') | |
| 501 | writer.close() | |
| 502 | return | |
| 503 | async with SLOTS: | |
| 504 | try: | |
| 505 | peer = writer.get_extra_info("socket") | |
| 506 | if struct.unpack("3i", peer.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))[1] not in (0, os.getuid()): | |
| 507 | return | |
| 508 | name = json.loads(await asyncio.wait_for(reader.readline(), 10))["name"] | |
| 509 | if not isinstance(name, str) or not name or len(name) > 63: | |
| 510 | raise ValueError("Choose a VM from the list.") | |
| 511 | host_reader, host_writer = await asyncio.wait_for(asyncio.open_unix_connection( | |
| 512 | os.environ.get("STUDIO_HOST_SOCKET", "/run/studio-host/host.sock")), 10) | |
| 513 | desktop = Desktop(host_reader, host_writer, lambda message: screen.send(message) if message["type"] != "cursor" or screen.source is desktop else None) | |
| 514 | host_peer = host_writer.get_extra_info("socket") | |
| 515 | if struct.unpack("3i", host_peer.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))[1] != 0: | |
| 516 | raise ValueError("The host identity couldn't be verified. Check its configuration.") | |
| 517 | host_writer.write(json.dumps({"operation": "vm.console", "payload": {"name": name}}).encode() + b"\n") | |
| 518 | size = struct.unpack("!I", await asyncio.wait_for(host_reader.readexactly(4), 70))[0] | |
| 519 | if size > 65536: | |
| 520 | raise ValueError("The host screen response was too large.") | |
| 521 | result = json.loads(await host_reader.readexactly(size)) | |
| 522 | if "error" in result: | |
| 523 | raise ValueError(result["error"]) | |
| 524 | await asyncio.wait_for(desktop.start(), 10) | |
| 525 | screen = Screen(writer, desktop) | |
| 526 | if name not in GUESTS: | |
| 527 | GUESTS[name] = GuestDesktop(name) | |
| 528 | GUESTS[name].task = asyncio.create_task(GUESTS[name].run()) | |
| 529 | screen.guest = GUESTS[name] | |
| 530 | screen.guest.screens.add(screen) | |
| 531 | if screen.guest.pixels: | |
| 532 | screen.resize() | |
| 533 | if screen.guest.pointer: | |
| 534 | screen.send(screen.guest.pointer) | |
| 535 | for cursor in screen.guest.cursor: | |
| 536 | screen.send(cursor) | |
| 537 | screen.send({"type": "ready", "iceServers": [{"urls": STUN.replace("stun://", "stun:")}] if STUN else []}) | |
| 538 | task = asyncio.create_task(desktop.read(lambda: screen.present() if screen.source is desktop else None, | |
| 539 | lambda: screen.resize() if screen.source is desktop else None)) | |
| 540 | def captured(done): | |
| 541 | if done.cancelled(): | |
| 542 | return | |
| 543 | error = done.exception() | |
| 544 | if error: | |
| 545 | logging.error("VM screen capture: %s", error) | |
| 546 | screen.fail(str(error) if isinstance(error, ValueError) else | |
| 547 | "The VM screen stopped. Check that the VM is running, then reconnect.") | |
| 548 | task.add_done_callback(captured) | |
| 549 | async with asyncio.timeout(8 * 3600): | |
| 550 | while raw := await reader.readline(): | |
| 551 | message = json.loads(raw) | |
| 552 | if not isinstance(message, dict): | |
| 553 | raise ValueError("Unsupported screen connection message.") | |
| 554 | screen.signal(message) | |
| 555 | if task.done(): | |
| 556 | task.result() | |
| 557 | except ValueError as error: | |
| 558 | if screen: | |
| 559 | screen.send({"type": "error", "message": str(error), "fatal": True}) | |
| 560 | else: | |
| 561 | writer.write(json.dumps({"type": "error", "message": str(error), "fatal": True}).encode() + b"\n") | |
| 562 | except (OSError, EOFError, TimeoutError, GLib.Error, asyncio.IncompleteReadError): | |
| 563 | logging.exception("VM screen connection stopped") | |
| 564 | if screen: | |
| 565 | screen.send({"type": "error", "fatal": True, "message": "Unable to connect the VM screen. Reconnect to try again."}) | |
| 566 | finally: | |
| 567 | if task: | |
| 568 | task.cancel() | |
| 569 | with contextlib.suppress(asyncio.CancelledError, Exception): | |
| 570 | await task | |
| 571 | if screen: | |
| 572 | screen.close() | |
| 573 | if desktop: | |
| 574 | with contextlib.suppress(OSError): | |
| 575 | desktop.input({"type": "release"}) | |
| 576 | desktop.writer.close() | |
| 577 | try: | |
| 578 | await asyncio.wait_for(desktop.writer.wait_closed(), 2) | |
| 579 | except (OSError, TimeoutError): | |
| 580 | desktop.writer.transport.abort() | |
| 581 | writer.close() | |
| 582 | try: | |
| 583 | await asyncio.wait_for(writer.wait_closed(), 2) | |
| 584 | except (OSError, TimeoutError): | |
| 585 | writer.transport.abort() | |
| 586 | ||
| 587 | ||
| 588 | async def main(): | |
| 589 | Gst.init(None) | |
| 590 | threading.Thread(target=GLib.MainLoop().run, daemon=True).start() | |
| 591 | path = Path(os.environ.get("STUDIO_VM_SCREEN_SOCKET", "/run/studio-vm-screen/screen.sock")) | |
| 592 | path.unlink(missing_ok=True) | |
| 593 | server = await asyncio.start_unix_server(serve, path=str(path), limit=131072) | |
| 594 | path.chmod(0o600) | |
| 595 | async with server: | |
| 596 | await server.serve_forever() | |
| 597 | ||
| 598 | ||
| 599 | if __name__ == "__main__": | |
| 600 | asyncio.run(main()) |
tools/vms.py+25-8| ... | ... | @@ -20,6 +20,7 @@ PRESETS = Path(os.environ.get("STUDIO_VM_PRESETS_ROOT", str(IMAGES / "Presets")) |
| 20 | 20 | UPLOADS = Path(os.environ.get("STUDIO_VM_UPLOADS_ROOT", str(IMAGES / "Install Disks"))) |
| 21 | 21 | NS = "{https://paperclover.net/studio}" |
| 22 | 22 | NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,62}\Z") |
| 23 | GUEST_CHANNEL = json.loads((Path(__file__).resolve().parent.parent / "guest/protocol.json").read_text())["channel"] | |
| 23 | 24 | PROFILES = { |
| 24 | 25 | "linux-x86-virtio": { |
| 25 | 26 | "arch": "x86_64", "machine": "q35", "platform": "linux", "firmware": None, |
| ... | ... | @@ -30,7 +31,7 @@ PROFILES = { |
| 30 | 31 | "arch": "aarch64", "machine": "virt", "platform": "linux", "firmware": "uefi", |
| 31 | 32 | "disk": ("vda", "virtio"), "cd": ("sda", "scsi"), "seed": ("sdb", "scsi"), |
| 32 | 33 | "network": "virtio", "usb": "qemu-xhci", "clock": "utc", "emulated": True, |
| 33 | "scsi": True, | |
| 34 | "scsi": True, "video": "virtio", "keyboard": "usb", "serial": "ttyAMA0", | |
| 34 | 35 | }, |
| 35 | 36 | "windows-q35-uefi": { |
| 36 | 37 | "arch": "x86_64", "machine": "q35", "platform": "windows", "firmware": "uefi", |
| ... | ... | @@ -40,7 +41,8 @@ PROFILES = { |
| 40 | 41 | "windows-q35-secure": { |
| 41 | 42 | "arch": "x86_64", "machine": "q35", "platform": "windows", "firmware": "uefi", |
| 42 | 43 | "disk": ("sda", "sata"), "cd": ("sdb", "sata"), "network": "e1000e", |
| 43 | "usb": "qemu-xhci", "clock": "localtime", "tpm": True, "secure": True, "smm": True, | |
| 44 | "usb": "qemu-xhci", "clock": "utc", "cpu_mode": "host-passthrough", | |
| 45 | "tpm": True, "secure": True, "smm": True, | |
| 44 | 46 | }, |
| 45 | 47 | "windows-q35-bios": { |
| 46 | 48 | "arch": "x86_64", "machine": "pc-q35-10.2", "platform": "windows", "firmware": "bios", |
| ... | ... | @@ -60,7 +62,7 @@ ACTION_FIELDS = { |
| 60 | 62 | "remove": {"name", "disks"}, |
| 61 | 63 | "library": None, "media": {"name", "image"}, |
| 62 | 64 | "preset": {"name", "id", "os", "description"}, |
| 63 | "console": {"name"}, "serial": {"name"}, "owner": {"name"}, "access": {"name"}, "upload": {"volume", "size"}, | |
| 65 | "console": {"name"}, "serial": {"name"}, "guest": {"name"}, "owner": {"name"}, "access": {"name"}, "upload": {"volume", "size"}, | |
| 64 | 66 | } |
| 65 | 67 | |
| 66 | 68 | |
| ... | ... | @@ -569,7 +571,7 @@ def guest_access(directory, username, hostname): |
| 569 | 571 | return password |
| 570 | 572 | |
| 571 | 573 | |
| 572 | def linux_seed(directory, username, os_name): | |
| 574 | def linux_seed(directory, username, os_name, serial="ttyS0"): | |
| 573 | 575 | if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}", username) or username == "root": |
| 574 | 576 | raise ValueError("Use an account username suitable for a Linux guest.") |
| 575 | 577 | hostname = "snowglobe-vm-" + secrets.token_hex(4) |
| ... | ... | @@ -600,7 +602,7 @@ fi |
| 600 | 602 | "bootcmd": ([["rm", "-f", gdm_config]] if nixos else []) + [["sh", "-c", rename]], |
| 601 | 603 | "chpasswd": {"expire": False, "users": [{"name": user, "password": hashed, "type": "hash"} for user in [username, "root"]]}, |
| 602 | 604 | "write_files": files, |
| 603 | "runcmd": [["systemctl", "enable", "--now", "sshd" if fedora or nixos else "ssh"], ["systemctl", "enable", "--now", "serial-getty@ttyS0.service"]], | |
| 605 | "runcmd": [["systemctl", "enable", "--now", "sshd" if fedora or nixos else "ssh"], ["systemctl", "enable", "--now", f"serial-getty@{serial}.service"]], | |
| 604 | 606 | } |
| 605 | 607 | with tempfile.TemporaryDirectory(prefix=".seed-", dir=directory) as temporary: |
| 606 | 608 | files = Path(temporary) |
| ... | ... | @@ -723,7 +725,9 @@ def create(spec): |
| 723 | 725 | ET.SubElement(features, "apic") |
| 724 | 726 | if details_profile.get("smm"): |
| 725 | 727 | ET.SubElement(features, "smm", state="on") |
| 726 | if details_profile.get("cpu"): | |
| 728 | if cpu_mode := details_profile.get("cpu_mode"): | |
| 729 | ET.SubElement(root, "cpu", mode=cpu_mode, check="none", migratable="on") | |
| 730 | elif details_profile.get("cpu"): | |
| 727 | 731 | cpu = ET.SubElement(root, "cpu", mode="custom", match="exact", check="full") |
| 728 | 732 | ET.SubElement(cpu, "model", fallback="forbid").text = details_profile["cpu"] |
| 729 | 733 | elif root.get("type") == "qemu": |
| ... | ... | @@ -747,7 +751,7 @@ def create(spec): |
| 747 | 751 | ET.SubElement(cd, "target", dev=details_profile["cd"][0], bus=details_profile["cd"][1]) |
| 748 | 752 | ET.SubElement(cd, "readonly") |
| 749 | 753 | if preset and spec["platform"] == "linux": |
| 750 | linux_seed(directory, spec["username"], preset["os"]) | |
| 754 | linux_seed(directory, spec["username"], preset["os"], details_profile.get("serial", "ttyS0")) | |
| 751 | 755 | seed = ET.SubElement(devices, "disk", type="file", device="cdrom") |
| 752 | 756 | ET.SubElement(seed, "driver", name="qemu", type="raw") |
| 753 | 757 | ET.SubElement(seed, "source", file=str(directory / "seed.iso")) |
| ... | ... | @@ -762,8 +766,13 @@ def create(spec): |
| 762 | 766 | ET.SubElement(nic, "source", network="default") |
| 763 | 767 | ET.SubElement(nic, "model", type=details_profile["network"]) |
| 764 | 768 | ET.SubElement(devices, "graphics", type="vnc", port="-1", autoport="yes", listen="127.0.0.1") |
| 765 | ET.SubElement(ET.SubElement(devices, "video"), "model", type="vga", vram="16384", heads="1", primary="yes") | |
| 769 | video = {"type": details_profile.get("video", "vga"), "heads": "1", "primary": "yes"} | |
| 770 | if video["type"] == "vga": | |
| 771 | video["vram"] = "16384" | |
| 772 | ET.SubElement(ET.SubElement(devices, "video"), "model", **video) | |
| 766 | 773 | ET.SubElement(devices, "input", type="tablet", bus="usb") |
| 774 | if keyboard := details_profile.get("keyboard"): | |
| 775 | ET.SubElement(devices, "input", type="keyboard", bus=keyboard) | |
| 767 | 776 | ET.SubElement(devices, "controller", type="usb", model=details_profile["usb"]) |
| 768 | 777 | if details_profile.get("tpm"): |
| 769 | 778 | tpm = ET.SubElement(devices, "tpm", model="tpm-crb") |
| ... | ... | @@ -772,6 +781,7 @@ def create(spec): |
| 772 | 781 | ET.SubElement(devices, "memballoon", model=details_profile["balloon"]) |
| 773 | 782 | ET.SubElement(devices, "console", type="pty") |
| 774 | 783 | ET.SubElement(devices, "channel", type="unix").append(ET.Element("target", type="virtio", name="org.qemu.guest_agent.0")) |
| 784 | ET.SubElement(devices, "channel", type="unix").append(ET.Element("target", type="virtio", name=GUEST_CHANNEL)) | |
| 775 | 785 | xml = directory / "domain.xml" |
| 776 | 786 | xml.write_bytes(ET.tostring(root)) |
| 777 | 787 | signal.alarm(0) |
| ... | ... | @@ -817,6 +827,13 @@ def main(): |
| 817 | 827 | return library() |
| 818 | 828 | if action == "console": |
| 819 | 829 | return console_target(payload["name"]) |
| 830 | if action == "guest": | |
| 831 | root = ET.fromstring(virsh("dumpxml", payload["name"])) | |
| 832 | source = root.find(f"./devices/channel[@type='unix']/target[@name='{GUEST_CHANNEL}']/../source") | |
| 833 | expected = f"/run/libvirt/qemu/channel/{root.get('id')}-{payload['name']}/{GUEST_CHANNEL}" | |
| 834 | if virsh("domstate", payload["name"]).strip() != "running" or source is None or source.get("mode") != "bind" or source.get("path") != expected: | |
| 835 | raise ValueError("This VM has no guest display channel.") | |
| 836 | return {"path": expected} | |
| 820 | 837 | if action == "serial": |
| 821 | 838 | if virsh("domstate", payload["name"]).strip() not in {"running", "blocked"}: |
| 822 | 839 | raise ValueError("Start this VM before opening its console.") |