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();