The preview showed nothing but the bare word `forbidden` in DSH Desktop. The cause is not this plugin's routes but the host, one layer above them: `DesktopWebServer` wraps EVERY route with `permits()`, and in the default isolated/扩展 mode `ordinaryBrowserEnabled` is false, so a request is served only when it carries the Electron renderer capability (`x-dsh-desktop-renderer`). That header is attached solely to MAIN-frame and SAME-ORIGIN-subframe traffic (`requestComesFromRendererOrigin`), and a frame sandboxed WITHOUT allow-same-origin is an opaque origin at request time — so the preview document request could never be served, while every other sidebar route (all main-frame) worked fine. The fix keeps the sandbox boundary exactly where it was and moves the delivery: the document is fetched by the MAIN frame — which the host does trust — and handed to the frame through `srcdoc`. New shared component HtmlPreviewFrame owns that path plus the sandbox tokens for BOTH surfaces (the editor viewer and the changes tab's op render preview, which had no escape hatch at all), so the two can no longer drift; a failed fetch falls back to handing the frame the route URL, leaving the route's own answer visible instead of a blank pane. `withPreviewBase` injects `<base href="<the file's route directory>">`: a srcdoc document has no URL of its own, so without it `./style.css` would resolve against the GUI page. A document that declares its own base is left alone. Two consequences, both deliberate: - sandboxed: the frame stays opaque, so a self-contained document previews completely while a multi-file one shows its markup with the relative assets missing (those requests are opaque-origin too — inlining them is a separate job); - unlocked (the warned per-surface unlock, or the setting): the srcdoc frame inherits the GUI origin — which is what that escape hatch already meant — and there relative references resolve and load like any other same-origin request, so multi-file previews work in that mode. Verified: typecheck clean; build clean; 1364 passed / 9 skipped (130 files, +13: the pure base-rewrite spec, the delivery spec, and the updated sandbox-contract assertions, which now pin srcdoc + no route src instead of the old route-src shape).
79 lines
3.3 KiB
TypeScript
79 lines
3.3 KiB
TypeScript
/**
|
|
* 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/<sessionId>/<path…>` 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<string | null>(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 (
|
|
<iframe
|
|
{...delivery}
|
|
className={className}
|
|
sandbox={sandboxed ? HTML_IFRAME_SANDBOX : undefined}
|
|
referrerPolicy="no-referrer"
|
|
allow=""
|
|
title={title}
|
|
/>
|
|
)
|
|
}
|