Safari on a Mac wants the webkit prefix; Safari on iOS has no fullscreen for an ordinary element at all, only for a <video>, so requestFullscreen was simply undefined and the tab threw. It now uses whichever spelling the browser has, and where there is none the frame fills the viewport in-page instead — the same result minus the browser's own chrome — with a Done button and Escape to come back. The button reads Expand rather than Fullscreen when that is what it will do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
10 KiB
TypeScript
188 lines
10 KiB
TypeScript
// The KVM of the machine you are looking at: the device's own web page, shown in the tab.
|
||
//
|
||
// One per machine, and it follows the machine switcher — switch to `grid` and you get
|
||
// grid's KVM. The address is held on the **hub**, not on the machine, because a KVM earns
|
||
// its keep exactly when that machine is down and cannot answer for itself; the hub proxies
|
||
// the device either way.
|
||
//
|
||
// Nothing here knows anything about any particular KVM. It is an iframe pointed at Ham
|
||
// Lab's own proxy, so whatever web UI the device serves is what you get.
|
||
//
|
||
// On the keyboard: a page cannot capture keys and forward them into an iframe, and does not
|
||
// need to. An iframe with focus receives them directly. What the browser itself swallows —
|
||
// Escape, Tab, Ctrl-W, the function keys — is what `navigator.keyboard.lock()` releases,
|
||
// and that needs fullscreen and only exists in Chromium. Everywhere else you get the plain
|
||
// version, which is most keys, and the device's own on-screen buttons for the rest.
|
||
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||
import { KVM as Device, api } from './api'
|
||
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
||
|
||
// lock() releases the keys the browser would otherwise keep for itself. Chromium only; a
|
||
// browser without it simply does not get the extra keys.
|
||
type KeyboardLock = { lock?: (keys?: string[]) => Promise<void>; unlock?: () => void }
|
||
const keyboardAPI = (): KeyboardLock | undefined => (navigator as unknown as { keyboard?: KeyboardLock }).keyboard
|
||
|
||
// Fullscreen, across the three ways browsers spell it. Safari on a Mac wants the webkit
|
||
// prefix; Safari on iOS has no fullscreen for an ordinary element at all — only for a
|
||
// <video> — so there the page expands inside the viewport instead, which is the same thing
|
||
// minus the browser's own chrome.
|
||
type FSElement = HTMLElement & { webkitRequestFullscreen?: () => Promise<void> | void }
|
||
type FSDocument = Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => Promise<void> | void }
|
||
const nativeFullscreen = () => {
|
||
if (typeof document === 'undefined') return false
|
||
const el = document.documentElement as FSElement
|
||
return typeof el.requestFullscreen === 'function' || typeof el.webkitRequestFullscreen === 'function'
|
||
}
|
||
const fullscreenElement = () => (document as FSDocument).fullscreenElement ?? (document as FSDocument).webkitFullscreenElement ?? null
|
||
async function enterFullscreen(el: FSElement) {
|
||
if (typeof el.requestFullscreen === 'function') return el.requestFullscreen()
|
||
if (typeof el.webkitRequestFullscreen === 'function') return el.webkitRequestFullscreen()
|
||
throw new Error('this browser has no fullscreen for a page element')
|
||
}
|
||
function exitFullscreen() {
|
||
const d = document as FSDocument
|
||
if (typeof d.exitFullscreen === 'function') return d.exitFullscreen()
|
||
if (typeof d.webkitExitFullscreen === 'function') return d.webkitExitFullscreen()
|
||
}
|
||
|
||
export default function KVM({ machine, title }: { machine: string | null; title: string }) {
|
||
const [kvm, setKvm] = useState<Device | null | undefined>(undefined)
|
||
const [adding, setAdding] = useState(false)
|
||
const [err, setErr] = useState('')
|
||
const [full, setFull] = useState(false) // native fullscreen is on
|
||
const [expanded, setExpanded] = useState(false) // the fallback: filling the viewport in-page
|
||
const box = useRef<HTMLDivElement>(null)
|
||
const frame = useRef<HTMLIFrameElement>(null)
|
||
const key = machine ?? '' // the hub is a machine too, and its key is the empty one
|
||
|
||
const load = useCallback(async () => {
|
||
try { setKvm((await api.kvms()).find((k) => k.machine === key) ?? null) }
|
||
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||
}, [key])
|
||
useEffect(() => { load() }, [load])
|
||
|
||
// Fullscreen plus a keyboard lock is what makes Escape and Tab reach the machine rather
|
||
// than the browser. Both are undone together when fullscreen ends, however it ends.
|
||
useEffect(() => {
|
||
const onChange = () => {
|
||
const on = !!fullscreenElement()
|
||
setFull(on)
|
||
if (!on) keyboardAPI()?.unlock?.()
|
||
}
|
||
document.addEventListener('fullscreenchange', onChange)
|
||
document.addEventListener('webkitfullscreenchange', onChange)
|
||
return () => {
|
||
document.removeEventListener('fullscreenchange', onChange)
|
||
document.removeEventListener('webkitfullscreenchange', onChange)
|
||
keyboardAPI()?.unlock?.()
|
||
}
|
||
}, [])
|
||
// Expanding is in-page, so Escape is ours to handle.
|
||
useEscape(() => setExpanded(false), expanded)
|
||
|
||
const goBig = async () => {
|
||
if (!nativeFullscreen()) { setExpanded(true); frame.current?.focus(); return }
|
||
try {
|
||
if (box.current) await enterFullscreen(box.current)
|
||
await keyboardAPI()?.lock?.()
|
||
frame.current?.focus()
|
||
} catch {
|
||
setExpanded(true) // refused (an iframe policy, a permissions policy): fill the page instead
|
||
frame.current?.focus()
|
||
}
|
||
}
|
||
const goSmall = () => { setExpanded(false); if (fullscreenElement()) exitFullscreen() }
|
||
|
||
const remove = async () => {
|
||
if (!kvm || !await askYes(`Forget ${title}'s KVM? The device itself is not touched — this only removes its address.`, 'Remove')) return
|
||
try { await api.removeKVM(kvm.id); toast('removed'); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||
}
|
||
|
||
if (err && kvm === undefined) return <div className="dash"><div className="error">{err}</div></div>
|
||
if (kvm === undefined) return <div className="dash"><Loading label="Looking…" /></div>
|
||
|
||
const locks = !!keyboardAPI()?.lock
|
||
const big = full || expanded
|
||
|
||
return (
|
||
<div className="kvm">
|
||
<div className="kvm-bar">
|
||
<span className="dname">{title}</span>
|
||
{kvm
|
||
? <span className="muted small mono kvm-url" title={kvm.url}>{kvm.name !== kvm.url ? `${kvm.name} · ` : ''}{kvm.url}</span>
|
||
: <span className="muted small">no KVM</span>}
|
||
<span className="spacer" />
|
||
{kvm && <>
|
||
<Btn className="btn" onClick={goBig} title={!nativeFullscreen() ? 'fill the screen — this browser has no true fullscreen for a page element' : locks ? 'fullscreen, and the browser stops keeping Escape, Tab and Ctrl-W for itself' : 'fullscreen — this browser will still keep some keys for itself'}>{nativeFullscreen() ? 'Fullscreen' : 'Expand'}</Btn>
|
||
<a className="btn" href={kvm.url} target="_blank" rel="noreferrer" title="the device directly, outside Ham Lab — only works on the LAN">Open ↗</a>
|
||
<Btn className="btn" onClick={() => setAdding(true)}>Change</Btn>
|
||
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
||
</>}
|
||
{!kvm && <Btn className="btn on" onClick={() => setAdding(true)}>+ Add a KVM for {title}</Btn>}
|
||
</div>
|
||
|
||
{kvm ? (
|
||
<div className={'kvm-frame' + (full ? ' full' : '') + (expanded ? ' expanded' : '')} ref={box}>
|
||
<iframe ref={frame} key={kvm.id} src={`/kvm/${kvm.id}/`} title={kvm.name}
|
||
allow="fullscreen; clipboard-read; clipboard-write" />
|
||
{expanded && <button className="btn kvm-shrink" onClick={goSmall} title="back to the console">Done</button>}
|
||
</div>
|
||
) : (
|
||
<div className="svc-empty muted kvm-none">
|
||
<div>
|
||
<p>No KVM for <b>{title}</b>.</p>
|
||
<p className="small">Add one with its address and its page appears here. The address is kept on the hub,
|
||
so it still works when {title} does not — which is when you will want it.</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{kvm && !big && (
|
||
<div className="kvm-hint muted small">
|
||
Click the picture, then type — the keys go to {title}.
|
||
{locks ? ' Fullscreen also takes Escape, Tab and Ctrl-W back from the browser.' : ' This browser keeps Escape, Tab and Ctrl-W for itself; use the device’s own on-screen keys for those, or a Chromium browser.'}
|
||
</div>
|
||
)}
|
||
{adding && <AddDialog machine={key} title={title} existing={kvm} onClose={() => setAdding(false)} onAdded={() => { setAdding(false); load() }} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function AddDialog({ machine, title, existing, onClose, onAdded }: { machine: string; title: string; existing: Device | null; onClose: () => void; onAdded: () => void }) {
|
||
const [url, setUrl] = useState(existing?.url ?? '')
|
||
const [name, setName] = useState(existing?.name ?? '')
|
||
const [err, setErr] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
useEscape(onClose)
|
||
const save = async (e: FormEvent) => {
|
||
e.preventDefault(); setErr(''); setBusy(true)
|
||
try { await api.addKVM(name.trim(), url.trim(), machine); onAdded() }
|
||
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) }
|
||
}
|
||
return (
|
||
<div className="modal-back" onClick={onClose}>
|
||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
||
<h3>{existing ? `Change ${title}'s KVM` : `A KVM for ${title}`}</h3>
|
||
<label className="muted">Address — whatever you would type into a browser
|
||
<input autoFocus className="mono" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="10.0.0.217" spellCheck={false} autoCapitalize="off" required />
|
||
</label>
|
||
<label className="muted">Name — optional; the address is used otherwise
|
||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={`${title} KVM`} maxLength={64} />
|
||
</label>
|
||
<div className="muted small">
|
||
Ham Lab serves the device from its own address, so a KVM that speaks only plain HTTP
|
||
still works here, and it is behind this login rather than needing a second one. The
|
||
address is kept on the hub, so it still reaches the device when {title} is down. The
|
||
device itself is not changed and is never reachable from outside.
|
||
{existing && <> Saving replaces the one there now — a machine has one KVM.</>}
|
||
</div>
|
||
{err && <div className="error">{err}</div>}
|
||
<div className="svc-actions">
|
||
<button type="submit" className="btn on" disabled={busy || !url.trim()}>{existing ? 'Save' : 'Add'}</button>
|
||
<span className="spacer" />
|
||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)
|
||
}
|