/** * The one HTML-preview frame both preview surfaces render. * * Why this is not a plain `src` to the `/sidebar/html` route: on DSH Desktop * the host answers 403 `forbidden` to every request that does not carry the * Electron renderer capability (`x-dsh-desktop-renderer`), and the shell * attaches that header only to MAIN-frame and SAME-ORIGIN-subframe traffic * (`requestComesFromRendererOrigin`). A frame sandboxed WITHOUT * allow-same-origin is an opaque origin at request time, so its document * request never carries the capability and the preview shows nothing but the * bare refusal. * * So the document is fetched by the MAIN frame — which does carry it — and * handed to the frame through `srcdoc`. The `sandbox` attribute stays exactly * as before: the preview still runs in an opaque origin, and the route's own * workspace fence still bounds what can be fetched. * * Two consequences worth knowing: * - sandboxed: an opaque origin cannot make network requests either, so a * self-contained document previews completely while a multi-file one shows * its markup with the relative assets missing (inlining them is a separate * job, not this component's); * - unsandboxed (the warned per-surface unlock, or the setting): the srcdoc * frame inherits the GUI's origin — which is precisely what that escape * hatch already meant — and there its relative references resolve and load * like any other same-origin request. * * A fetch that fails hands the frame the route URL instead, so the route's * own answer (its JSON error, or the refusal) stays visible rather than being * replaced by a blank pane. */ import { useEffect, useState, type ReactNode } from 'react' import { HTML_IFRAME_SANDBOX, withPreviewBase } from './html-preview.ts' /** One preview frame. */ export function HtmlPreviewFrame(props: { /** The `/sidebar/html//` route URL of the SAVED file. */ url: string /** Whether the opaque-origin sandbox applies (the default for both surfaces). */ sandboxed: boolean className?: string title: string }): ReactNode { const { url, sandboxed, className, title } = props const [doc, setDoc] = useState(null) const [failed, setFailed] = useState(false) useEffect(() => { const controller = new AbortController() setDoc(null) setFailed(false) void fetch(url, { signal: controller.signal }) .then(async (response) => { if (!response.ok) throw new Error(String(response.status)) return response.text() }) .then((text) => { setDoc(text) }) .catch((error: unknown) => { // An abort is this effect's own cleanup, not a failure. if ((error as { name?: string }).name !== 'AbortError') setFailed(true) }) return () => { controller.abort() } }, [url]) // The delivery path depends only on what we have: the fetched markup, the // route's own answer after a failed fetch, or an empty frame while loading. const delivery = doc !== null ? { srcDoc: withPreviewBase(doc, url) } : failed ? { src: url } : { srcDoc: '' } return (