diff --git a/src/client/HtmlPreviewFrame.tsx b/src/client/HtmlPreviewFrame.tsx new file mode 100644 index 0000000..fe08a65 --- /dev/null +++ b/src/client/HtmlPreviewFrame.tsx @@ -0,0 +1,78 @@ +/** + * 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 ( +