// The graphical console of a VM: spice-html5 (vendored, loaded on demand) drawing the SPICE // stream that Ham Lab bridges from Incus into a canvas. Used for installers from an ISO, and // anything else with a screen rather than a serial line. import { useEffect, useRef, useState } from 'react' import { wsURLPath } from './api' import { Btn, useEscape } from './ui' // No spice.css: upstream's stylesheet is for its own demo page and sets body, *, button and // canvas for the whole document. Imported here it restyled every button in Ham Lab. The // console is styled by the .vga rules in styles.css. type Spice = typeof import('./vendor/spice-html5/src/main.js') export default function VgaConsole({ name, onClose }: { name: string; onClose: () => void }) { const [state, setState] = useState<'loading' | 'connecting' | 'connected' | 'error'>('loading') const [err, setErr] = useState('') const [fit, setFit] = useState(true) const [size, setSize] = useState('') const conn = useRef | null>(null) const mod = useRef(null) const area = useRef(null) const screen = useRef(null) useEscape(onClose) useEffect(() => { let dead = false import('./vendor/spice-html5/src/main.js').then((m) => { if (dead) return mod.current = m setState('connecting') try { conn.current = new m.SpiceMainConn({ uri: wsURLPath(`/api/virt/instances/${encodeURIComponent(name)}/vga`), screen_id: 'spice-screen', message_id: 'spice-message', onerror: (e) => { setState('error'); setErr(e instanceof Error ? e.message : (e as Event & { reason?: string })?.reason || 'the console closed') }, onsuccess: () => setState('connected'), onagent: () => { try { m.handle_resize() } catch { /* no agent in the guest yet */ } }, }) } catch (e) { setState('error'); setErr(String(e)) } }).catch((e) => { setState('error'); setErr('could not load the SPICE viewer: ' + String(e)) }) return () => { dead = true; try { conn.current?.stop() } catch { /* already gone */ } conn.current = null } }, [name]) // The guest draws at its own resolution; scale the canvas to the pane unless asked not to. useEffect(() => { const a = area.current, s = screen.current if (!a || !s) return const apply = () => { const c = s.querySelector('canvas') if (!c) return setSize(`${c.width}×${c.height}`) c.tabIndex = 1 if (!fit) { s.style.transform = ''; s.style.width = ''; s.style.height = ''; return } const k = Math.min(1, (a.clientWidth - 8) / c.width, (a.clientHeight - 8) / c.height) s.style.transformOrigin = 'top left' s.style.transform = `scale(${k})` s.style.width = `${c.width}px`; s.style.height = `${c.height}px` } apply() const ro = new ResizeObserver(apply); ro.observe(a) const mo = new MutationObserver(apply); mo.observe(s, { childList: true, attributes: true, subtree: true, attributeFilter: ['width', 'height'] }) return () => { ro.disconnect(); mo.disconnect() } }, [fit, state]) const cad = () => { if (conn.current && mod.current) mod.current.sendCtrlAltDel(conn.current) } const focus = () => screen.current?.querySelector('canvas')?.focus() return (
e.stopPropagation()}>
{name} {state === 'connected' ? `VGA · ${size || 'waiting for the first frame'}` : state === 'error' ? err : state === 'loading' ? 'loading the viewer…' : 'connecting…'} Ctrl+Alt+Del
Click the screen to type into it. Keys go to the guest while it has focus; Escape with focus elsewhere closes this.
) }