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:
7 files changed
+314
-8
No files matched your search
@@ -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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
Reference in new issue
Block a user