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
2026-09-21 02:49:05 +08:00
parent 31df87047e
commit 943d5ec7dd
7 changed files with 314 additions and 8 deletions
@@ -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
@@ -431,6 +431,10 @@ env 消息的 `theme` 结构为 `{ appearance, tokens, editor? }`。`appearance`
UI 必须构建为包内静态资源,不要把 Vite 开发服务器或 CDN 的脚本地址写入发行包。宿主把入口及资源转换成沙箱可加载内容;CSP 报错时检查构建产物是否存在、路径是否落在 `ui.root` 内,并使用匹配版本的 CLI 复现。用插件自己的对话框组件处理删除/重命名,不要依赖沙箱中的原生 `alert`/`confirm`/`prompt`。
#### 懒加载与代码分割
宿主会把入口脚本内联进沙箱文档,`ui/` 下的其余资源在运行时通过 `dbx-plugin` 协议保持可寻址:`dbx-plugin://localhost/<插件id>/<ui.root内相对路径>`(WebView2 上映射为 `http://dbx-plugin.localhost/<插件id>/…`)。宿主会把平台对应的形式注入为沙箱文档的 `<base>`,并放行资源类 CSP,因此打包后的插件可以正常动态 `import()` 代码分割的 chunk——前提是构建产物使用**相对**资源地址:Vite 设 `base: "./"`、webpack 设 `publicPath: "./"`,让 chunk 和 CSS 资源地址基于注入的 base 解析而不是宿主 origin。绝对路径 `/assets/…` 携带不了插件 id,会 404。该协议只读文件、限定在每个插件的 `ui.root` 内,且插件更新后不会沿用旧缓存。
## 原生 Sidecar 和 RPC
只有浏览器沙箱无法完成的能力才需要原生后端,例如 S3/SSH 协议、系统凭据、长连接或高性能计算。Sidecar 不是操作系统沙箱,会以当前用户权限运行。
+4
View File
@@ -431,6 +431,10 @@ Treat `terminal.fontSize`-style concerns as plugin-owned: the host never dictate
Build the UI as packaged static assets, not references to a Vite server or CDN scripts. The host converts the entry and assets into sandbox-loadable content. For CSP errors, verify build outputs and confinement under `ui.root`, and reproduce with the matching CLI version. Use plugin-owned dialogs for delete/rename rather than relying on native `alert`/`confirm`/`prompt` inside the sandbox.
#### Lazy loading and code splitting
The host inlines the entry script into the sandbox document; further `ui/` assets stay addressable at runtime through the `dbx-plugin` scheme — `dbx-plugin://localhost/<plugin-id>/<path-under-ui.root>` (WebView2 maps it to `http://dbx-plugin.localhost/<plugin-id>/…`). The host injects the platform-correct form as the sandbox document's `<base>` and allows it in the resource CSP, so dynamic `import()` of code-split chunks works in packaged plugins — provided the build emits **relative** asset URLs: set `base: "./"` (Vite) or `publicPath: "./"` (webpack) so chunk and CSS asset URLs resolve against the injected base instead of the host origin. Absolute `/assets/…` references cannot carry the plugin id and will 404. The scheme serves files only, confined to each plugin's `ui.root`, and responses are never cached across plugin updates.
## Native Sidecars and RPC
Use a native backend only when the browser sandbox cannot provide the capability, such as S3/SSH protocols, system credentials, long-lived connections, or CPU-heavy work. A Sidecar is not an OS sandbox and runs with the current user's privileges.
+5 -1
View File
@@ -6,6 +6,7 @@ mod macos_app_delegate;
#[cfg(target_os = "macos")]
mod macos_escape_guard;
mod models;
mod plugin_ui_protocol;
#[cfg(any(target_os = "windows", test))]
mod startup_recovery;
#[cfg(all(not(target_os = "windows"), not(test)))]
@@ -1461,7 +1462,10 @@ pub fn run() {
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init());
.plugin(tauri_plugin_fs::init())
// Plugin workbench sandbox documents lazy-load their code-split chunks
// through this scheme; see plugin_ui_protocol.rs.
.register_asynchronous_uri_scheme_protocol(plugin_ui_protocol::PLUGIN_UI_SCHEME, plugin_ui_protocol::handle);
let builder = if should_enable_single_instance(cfg!(debug_assertions)) {
builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
+214
View File
@@ -0,0 +1,214 @@
//! Serves installed plugin UI assets over the `dbx-plugin` scheme so the
//! sandboxed workbench document can lazy-load code-split chunks at runtime.
//!
//! URLs carry the plugin id as the first path segment with a fixed host
//! (`dbx-plugin://localhost/<plugin-id>/<asset-path>`). The fixed host keeps
//! the WebView2-mapped form (`http://dbx-plugin.localhost/...`) a single
//! constant origin, which the sandbox CSP can allow without wildcard hosts.
use std::sync::Arc;
use dbx_core::plugins::PluginRegistry;
use percent_encoding::percent_decode_str;
use tauri::http::{HeaderValue, Method, Response, StatusCode, Uri};
use tauri::{Manager, Runtime, UriSchemeContext, UriSchemeResponder};
use crate::commands::connection::AppState;
pub const PLUGIN_UI_SCHEME: &str = "dbx-plugin";
/// Registered on the app builder; see `lib.rs`.
pub fn handle<R: Runtime>(
ctx: UriSchemeContext<'_, R>,
request: tauri::http::Request<Vec<u8>>,
responder: UriSchemeResponder,
) {
let registry = ctx.app_handle().try_state::<Arc<AppState>>().map(|state| state.plugins.clone());
let method = request.method().clone();
let uri = request.uri().clone();
tauri::async_runtime::spawn_blocking(move || {
let response = match registry {
Some(registry) => serve_plugin_ui_asset(&registry, &method, &uri),
None => error_response(StatusCode::SERVICE_UNAVAILABLE, "Plugin registry is unavailable"),
};
responder.respond(response);
});
}
pub(crate) fn serve_plugin_ui_asset(registry: &PluginRegistry, method: &Method, uri: &Uri) -> Response<Vec<u8>> {
if method != Method::GET && method != Method::HEAD {
return error_response(StatusCode::METHOD_NOT_ALLOWED, "Plugin UI assets only support GET and HEAD");
}
let Some((plugin_id, asset_path)) = parse_plugin_ui_uri(uri) else {
return error_response(StatusCode::NOT_FOUND, "Plugin UI asset not found");
};
match registry.read_ui_asset(&plugin_id, &asset_path) {
Ok(asset) => asset_response(&asset.content_type, &asset.etag, &asset.bytes, method == Method::HEAD),
Err(error) => {
// The registry error names filesystem paths; keep it in the log, not the response.
log::warn!("[{PLUGIN_UI_SCHEME}] failed to serve {uri}: {error}");
error_response(StatusCode::NOT_FOUND, "Plugin UI asset not found")
}
}
}
/// Splits `dbx-plugin://localhost/<plugin-id>/<asset-path>` into its parts.
/// The asset path keeps its percent-decoding to the registry's traversal
/// validation, which runs on the decoded components.
pub(crate) fn parse_plugin_ui_uri(uri: &Uri) -> Option<(String, String)> {
if uri.scheme_str() != Some(PLUGIN_UI_SCHEME) {
return None;
}
let path = uri.path().strip_prefix('/')?;
let mut segments = path.split('/');
let plugin_id = segments.next()?;
if !valid_plugin_id(plugin_id) {
return None;
}
let encoded_rest = segments.collect::<Vec<_>>().join("/");
if encoded_rest.is_empty() {
return None;
}
let asset_path = percent_decode_str(&encoded_rest).decode_utf8().ok()?.into_owned();
if asset_path.is_empty() {
return None;
}
Some((plugin_id.to_string(), asset_path))
}
fn valid_plugin_id(plugin_id: &str) -> bool {
!plugin_id.is_empty()
&& plugin_id.len() <= 200
&& plugin_id.chars().all(|character| character.is_ascii_alphanumeric() || character == '.' || character == '-')
&& !plugin_id.starts_with('.')
&& !plugin_id.ends_with('.')
}
fn asset_response(content_type: &str, etag: &str, bytes: &[u8], head: bool) -> Response<Vec<u8>> {
let mut response = Response::new(if head { Vec::new() } else { bytes.to_vec() });
let headers = response.headers_mut();
// The workbench document is an opaque-origin srcdoc iframe, so module
// script fetches are CORS-mode with `Origin: null` — only `*` passes.
headers.insert("access-control-allow-origin", HeaderValue::from_static("*"));
headers.insert(
"content-type",
HeaderValue::from_str(content_type).unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
);
if let Ok(value) = HeaderValue::from_str(etag) {
headers.insert("etag", value);
}
// Plugins update in place; never let a stale chunk outlive its install.
headers.insert("cache-control", HeaderValue::from_static("no-store"));
response
}
fn error_response(status: StatusCode, message: &str) -> Response<Vec<u8>> {
let mut response = Response::new(message.as_bytes().to_vec());
*response.status_mut() = status;
response.headers_mut().insert("access-control-allow-origin", HeaderValue::from_static("*"));
response.headers_mut().insert("content-type", HeaderValue::from_static("text/plain; charset=utf-8"));
response
}
#[cfg(test)]
mod tests {
use super::*;
fn test_registry() -> PluginRegistry {
let root = tempfile::tempdir().unwrap();
let plugin_dir = root.path().join("sample");
std::fs::create_dir_all(plugin_dir.join("ui/assets")).unwrap();
std::fs::write(plugin_dir.join("ui/index.html"), "<h1>Hello</h1>").unwrap();
std::fs::write(plugin_dir.join("ui/assets/chunk.js"), "console.log('chunk')").unwrap();
std::fs::write(
plugin_dir.join("manifest.json"),
serde_json::json!({
"manifest_version": 1,
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"publisher": "dbx",
"engines": { "dbx": ">=0.1.0", "host_api": "^1.0" },
"entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } }
})
.to_string(),
)
.unwrap();
// Leak the tempdir so the registry (and the reads below) outlive it.
std::mem::forget(root);
PluginRegistry::new_with_app_version(plugin_dir.parent().unwrap().to_path_buf(), "0.5.67")
}
fn uri(value: &str) -> Uri {
value.parse().unwrap()
}
fn get(path: &str) -> Response<Vec<u8>> {
serve_plugin_ui_asset(&test_registry(), &Method::GET, &uri(path))
}
#[test]
fn serves_assets_with_cors_headers() {
let response = get("dbx-plugin://localhost/sample/assets/chunk.js");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["access-control-allow-origin"], "*");
assert_eq!(response.headers()["content-type"], "text/javascript; charset=utf-8");
assert_eq!(response.headers()["cache-control"], "no-store");
assert_eq!(response.body().as_slice(), b"console.log('chunk')");
}
#[test]
fn head_returns_headers_without_body() {
let response = serve_plugin_ui_asset(
&test_registry(),
&Method::HEAD,
&uri("dbx-plugin://localhost/sample/assets/chunk.js"),
);
assert_eq!(response.status(), StatusCode::OK);
assert!(response.body().is_empty());
assert_eq!(response.headers()["content-type"], "text/javascript; charset=utf-8");
}
#[test]
fn rejects_non_get_methods() {
let response =
serve_plugin_ui_asset(&test_registry(), &Method::POST, &uri("dbx-plugin://localhost/sample/ui/index.html"));
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[test]
fn rejects_wrong_scheme() {
assert!(parse_plugin_ui_uri(&uri("http://localhost/sample/ui/index.html")).is_none());
}
#[test]
fn rejects_paths_that_escape_the_plugin() {
for path in
["dbx-plugin://localhost/sample/../secret.txt", "dbx-plugin://localhost/sample/ui/../../manifest.json"]
{
let response = get(path);
assert_eq!(response.status(), StatusCode::NOT_FOUND, "path should be rejected: {path}");
}
}
#[test]
fn rejects_unknown_plugins_and_empty_paths() {
assert_eq!(get("dbx-plugin://localhost/other/ui/index.html").status(), StatusCode::NOT_FOUND);
assert!(parse_plugin_ui_uri(&uri("dbx-plugin://localhost/sample")).is_none());
assert!(parse_plugin_ui_uri(&uri("dbx-plugin://localhost/")).is_none());
}
#[test]
fn decodes_percent_encoded_paths() {
let response = get("dbx-plugin://localhost/sample/%69ndex.html");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.body().as_slice(), b"<h1>Hello</h1>");
}
#[test]
fn validates_plugin_ids() {
assert!(parse_plugin_ui_uri(&uri("dbx-plugin://localhost/..hidden/ui/index.html")).is_none());
assert!(parse_plugin_ui_uri(&uri("dbx-plugin://localhost/a%20b/ui/index.html")).is_none());
assert!(parse_plugin_ui_uri(&uri("dbx-plugin://localhost/ok.id-1/ui/index.html")).is_some());
}
}