feat(plugins): serve plugin UI assets over the dbx-plugin scheme

The workbench sandbox inlines only the entry script, so code-split
chunks had no runtime address and dynamic imports 404'd. Serve each
installed plugin's ui.root through dbx-plugin://localhost/<id>/<path>
(WebView2 maps the scheme to http://dbx-plugin.localhost), inject the
platform-correct form as the sandbox document <base>, and allow it in
the resource CSP. Reads reuse the registry's ui-asset validation
(traversal confinement, 32 MiB cap, sha256 etag) and answer CORS with
ACAO:* for the opaque-origin iframe; responses are no-store so plugin
updates land immediately.

Existing plugins are unaffected. Builds that emit relative asset URLs
(vite base "./" / webpack publicPath "./") additionally regain lazy
loading and font loading; documented for plugin authors in both
languages.
This commit is contained in:
t8y2 committed 2026-09-21 02:49:05 +08:00
1 parent 31df87047e
commit 943d5ec7dd
7 files changed
+314 -8

No files matched your search

@@ -424,13 +424,18 @@ function localUiAssetPath(source: string): string | undefined {
}
}
async function inlineLocalUiAssets(html: string, pluginId: string): Promise<string> {
async function inlineLocalUiAssets(html: string, pluginId: string): Promise<{ html: string; entryDirectory: string }> {
const document = new DOMParser().parseFromString(html, "text/html");
const resources = [...document.querySelectorAll("script[src], link[rel='stylesheet'][href]")];
// Dynamic-import chunks and CSS url() references live next to the entry
// script; its directory is the <base> the sandbox document needs to resolve
// them through the dbx-plugin scheme.
let entryDirectory = "";
for (const resource of resources) {
const source = resource.getAttribute(resource.tagName === "SCRIPT" ? "src" : "href");
const path = source ? localUiAssetPath(source) : undefined;
if (!path) continue;
if (!entryDirectory) entryDirectory = path.split("/").slice(0, -1).join("/");
const asset = await api.readPluginUiAsset(pluginId, path);
const content = new TextDecoder().decode(Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0)));
if (resource.tagName === "SCRIPT") {
@@ -446,7 +451,19 @@ async function inlineLocalUiAssets(html: string, pluginId: string): Promise<stri
resource.replaceWith(style);
}
}
return document.documentElement.outerHTML;
return { html: document.documentElement.outerHTML, entryDirectory };
}
/**
* Base URL prefix for lazy-loaded plugin UI assets. wry serves custom schemes
* natively on WKWebView/webkit2gtk but maps them onto http(s) subdomains on
* WebView2, so the host page's own protocol picks the form the webview will
* actually request. The web host has no plugin asset protocol.
*/
function pluginUiBaseUrl(pluginId: string, entryDirectory: string): string | undefined {
if (!isTauriRuntime()) return undefined;
const origin = location.protocol === "http:" || location.protocol === "https:" ? `${location.protocol}//dbx-plugin.localhost/${pluginId}/` : `dbx-plugin://localhost/${pluginId}/`;
return entryDirectory ? `${origin}${entryDirectory}/` : origin;
}
async function loadWorkbench() {
@@ -461,9 +478,11 @@ async function loadWorkbench() {
const asset = await api.readPluginUiEntry(props.plugin.manifest.id);
if (disposed || generation !== loadGeneration) return;
const bytes = Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0));
const html = await inlineLocalUiAssets(new TextDecoder().decode(bytes), props.plugin.manifest.id);
const { html, entryDirectory } = await inlineLocalUiAssets(new TextDecoder().decode(bytes), props.plugin.manifest.id);
if (disposed || generation !== loadGeneration) return;
source.value = pluginSandboxDocument(html, props.plugin.manifest.permissions, currentBridgeTheme());
source.value = pluginSandboxDocument(html, props.plugin.manifest.permissions, currentBridgeTheme(), {
baseUrl: pluginUiBaseUrl(props.plugin.manifest.id, entryDirectory),
});
await nextTick();
if (disposed || generation !== loadGeneration) return;
createBridge();
+28
View File
@@ -731,6 +731,34 @@ describe("PluginHostBridge", () => {
expect(closed).toContain("host.stream.chunk");
});
it("injects a <base> and widens resource CSP sources for the plugin asset origin", () => {
const document = pluginSandboxDocument("<html><head></head><body></body></html>", [], undefined, { baseUrl: "dbx-plugin://localhost/io.github.t8y2.s3/assets/" });
expect(document).toContain('<base href="dbx-plugin://localhost/io.github.t8y2.s3/assets/">');
expect(document).toContain("script-src 'unsafe-inline' blob: dbx-plugin:;");
expect(document).toContain("font-src data: blob: dbx-plugin:;");
// <base> leads the head injection so inlined CSS url() resolves against it.
expect(document.indexOf("<base ")).toBeLessThan(document.indexOf("<style>"));
});
it("allows the WebView2-mapped asset origin exactly", () => {
const document = pluginSandboxDocument("<html><head></head><body></body></html>", [], undefined, { baseUrl: "http://dbx-plugin.localhost/io.github.t8y2.s3/assets/" });
expect(document).toContain("script-src 'unsafe-inline' blob: http://dbx-plugin.localhost;");
expect(document).toContain('<base href="http://dbx-plugin.localhost/io.github.t8y2.s3/assets/">');
});
it("rejects malformed asset base URLs without touching the CSP", () => {
const document = pluginSandboxDocument("<html><head></head><body></body></html>", [], undefined, { baseUrl: "javascript:alert(1)" });
expect(document).not.toContain("<base ");
expect(document).toContain("script-src 'unsafe-inline' blob:;");
});
it("keeps the sandbox CSP untouched without an asset base URL", () => {
const document = pluginSandboxDocument("<html><head></head><body></body></html>");
expect(document).toContain("script-src 'unsafe-inline' blob:;");
expect(document).toContain("font-src data: blob:;");
expect(document).not.toContain("<base ");
});
it("pre-seeds the current theme as the sandbox first paint", () => {
const themed = pluginSandboxDocument("<html><head></head><body></body></html>", ["host.events"], { appearance: "dark", tokens: { "--color-background": "#131416", "--color-foreground": "rgb(215 215 219)" } });
expect(themed).toContain("color-scheme: dark");
@@ -477,10 +477,26 @@ export function pluginNetworkOrigins(permissions: readonly string[] | undefined)
return [...origins];
}
export function pluginSandboxDocument(html: string, permissions?: readonly string[], theme?: PluginBridgeTheme): string {
export interface PluginSandboxOptions {
/**
* Base URL under which the workbench document may fetch further plugin UI
* assets (code-split chunks, fonts) — `dbx-plugin://localhost/<id>/assets/`
* on native custom schemes, `http(s)://dbx-plugin.localhost/<id>/assets/`
* where WebView2 maps the scheme to an http subdomain. Injected as the
* document `<base>` and allowed in the resource CSP directives. Omit on
* hosts without the plugin asset protocol (the web host).
*/
baseUrl?: string;
}
export function pluginSandboxDocument(html: string, permissions?: readonly string[], theme?: PluginBridgeTheme, options?: PluginSandboxOptions): string {
const networkOrigins = pluginNetworkOrigins(permissions);
const connectSrc = networkOrigins.length > 0 ? `connect-src ${networkOrigins.join(" ")};` : "connect-src 'none';";
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline' blob:; img-src data: blob:; font-src data: blob:; ${connectSrc} media-src data: blob:;">`;
const assetSource = pluginAssetCspSource(options?.baseUrl);
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' blob:${assetSource}; style-src 'unsafe-inline' blob:; img-src data: blob:${assetSource}; font-src data: blob:${assetSource}; ${connectSrc} media-src data: blob:${assetSource};">`;
// <base> must precede every relative URL the document resolves (inlined CSS
// url(), dynamic import specifiers), so it leads the injection.
const base = options?.baseUrl && assetSource ? `<base href="${escapeHtmlAttribute(options.baseUrl)}">` : "";
const sdk = `<script>${pluginSdkSource(theme)}</script>`;
const uiKit = `<style>${pluginUiKitCss()}</style>`;
// Placed after the uiKit so the boot `color-scheme` wins the cascade: the
@@ -488,11 +504,28 @@ export function pluginSandboxDocument(html: string, permissions?: readonly strin
// has loaded, and the uiKit's token fallbacks would otherwise paint the
// first frame white on dark hosts.
const bootTheme = pluginBootThemeCss(theme);
const injection = `${csp}${uiKit}${bootTheme ? `<style>${bootTheme}</style>` : ""}${sdk}`;
const injection = `${csp}${base}${uiKit}${bootTheme ? `<style>${bootTheme}</style>` : ""}${sdk}`;
if (/<head(?:\s[^>]*)?>/i.test(html)) return html.replace(/<head(?:\s[^>]*)?>/i, (head) => `${head}${injection}`);
return `<!doctype html><html><head>${injection}</head><body>${html}</body></html>`;
}
/**
* CSP source expression for the plugin asset base URL: the exact origin for
* the http(s)-mapped form (WebView2), the whole scheme for the native custom
* scheme form. Only dbx-plugin shapes contribute a source — anything else is
* ignored (and suppresses the <base> too).
*/
function pluginAssetCspSource(baseUrl: string | undefined): string {
if (!baseUrl) return "";
const mapped = baseUrl.match(/^(https?:\/\/dbx-plugin\.localhost)(?:\/|$)/i);
if (mapped) return ` ${mapped[1].toLowerCase()}`;
return /^dbx-plugin:\/\//i.test(baseUrl) ? " dbx-plugin:" : "";
}
function escapeHtmlAttribute(value: string): string {
return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/**
* Pre-paint theme seed for the sandbox document. The bridge init message only
* arrives after the iframe load event, so without this style the first frame