spice.css sets body, *, button and canvas for the whole document. Bundled
into the App chunk since 43ebacd (deployed as v0.1.0-188), it turned every
button into a gradient pill, shifted 15% right and 8% up with a 150px
minimum width, broke the rail and header layout, and bordered every graph
canvas. The console is already styled by the .vga rules in styles.css.
The file is removed from the vendored copy, and a new web test fails on any
stylesheet import outside an allow-list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
87 lines
4.6 KiB
TypeScript
87 lines
4.6 KiB
TypeScript
// 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<InstanceType<Spice['SpiceMainConn']> | null>(null)
|
||
const mod = useRef<Spice | null>(null)
|
||
const area = useRef<HTMLDivElement>(null)
|
||
const screen = useRef<HTMLDivElement>(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 (
|
||
<div className="modal-back vga-back" onClick={onClose}>
|
||
<div className="vga" onClick={(e) => e.stopPropagation()}>
|
||
<div className="logs-bar vga-bar">
|
||
<span className={'dot ' + (state === 'connected' ? 'ok' : state === 'error' ? 'bad' : 'warn')} />
|
||
<span className="dname">{name}</span>
|
||
<span className="muted small">{state === 'connected' ? `VGA · ${size || 'waiting for the first frame'}` : state === 'error' ? err : state === 'loading' ? 'loading the viewer…' : 'connecting…'}</span>
|
||
<span className="spacer" />
|
||
<Btn className="btn" disabled={state !== 'connected'} onClick={cad} title="send Ctrl+Alt+Del to the guest">Ctrl+Alt+Del</Btn>
|
||
<button className={'btn' + (fit ? ' on' : '')} onClick={() => setFit(!fit)} title="scale the screen to this window, or show it pixel for pixel">Fit</button>
|
||
<button className="btn" onClick={onClose}>Close</button>
|
||
</div>
|
||
<div className="vga-area" ref={area} onClick={focus}>
|
||
<div id="spice-area"><div id="spice-screen" ref={screen} /></div>
|
||
<div id="spice-message" className="spice-message vga-msg" />
|
||
</div>
|
||
<div className="muted small vga-hint">Click the screen to type into it. Keys go to the guest while it has focus; Escape with focus elsewhere closes this.</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|