diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue index 9b9b42d6d1..4686263c46 100644 --- a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue +++ b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue @@ -424,13 +424,18 @@ function localUiAssetPath(source: string): string | undefined { } } -async function inlineLocalUiAssets(html: string, pluginId: string): Promise { +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 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 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(); diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts index 0c7800cd5c..0679fc9eef 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts @@ -731,6 +731,34 @@ describe("PluginHostBridge", () => { expect(closed).toContain("host.stream.chunk"); }); + it("injects a and widens resource CSP sources for the plugin asset origin", () => { + const document = pluginSandboxDocument("", [], undefined, { baseUrl: "dbx-plugin://localhost/io.github.t8y2.s3/assets/" }); + expect(document).toContain(''); + expect(document).toContain("script-src 'unsafe-inline' blob: dbx-plugin:;"); + expect(document).toContain("font-src data: blob: dbx-plugin:;"); + // leads the head injection so inlined CSS url() resolves against it. + expect(document.indexOf("")); + }); + + it("allows the WebView2-mapped asset origin exactly", () => { + const document = pluginSandboxDocument("", [], 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(''); + }); + + it("rejects malformed asset base URLs without touching the CSP", () => { + const document = pluginSandboxDocument("", [], undefined, { baseUrl: "javascript:alert(1)" }); + expect(document).not.toContain(" { + const document = pluginSandboxDocument(""); + expect(document).toContain("script-src 'unsafe-inline' blob:;"); + expect(document).toContain("font-src data: blob:;"); + expect(document).not.toContain(" { const themed = pluginSandboxDocument("", ["host.events"], { appearance: "dark", tokens: { "--color-background": "#131416", "--color-foreground": "rgb(215 215 219)" } }); expect(themed).toContain("color-scheme: dark"); diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.ts index c9fbe54e24..bc51c64e85 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.ts @@ -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//assets/` + * on native custom schemes, `http(s)://dbx-plugin.localhost//assets/` + * where WebView2 maps the scheme to an http subdomain. Injected as the + * document `` 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 = ``; + const assetSource = pluginAssetCspSource(options?.baseUrl); + const csp = ``; + // must precede every relative URL the document resolves (inlined CSS + // url(), dynamic import specifiers), so it leads the injection. + const base = options?.baseUrl && assetSource ? `` : ""; const sdk = ``; const uiKit = ``; // 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 ? `` : ""}${sdk}`; + const injection = `${csp}${base}${uiKit}${bootTheme ? `` : ""}${sdk}`; if (/]*)?>/i.test(html)) return html.replace(/]*)?>/i, (head) => `${head}${injection}`); return `${injection}${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 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, ">"); +} + /** * 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 diff --git a/docs/content/docs/plugin-development.cn.mdx b/docs/content/docs/plugin-development.cn.mdx index 7d3d726555..c137d93a65 100644 --- a/docs/content/docs/plugin-development.cn.mdx +++ b/docs/content/docs/plugin-development.cn.mdx @@ -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>/`(WebView2 上映射为 `http://dbx-plugin.localhost/<插件id>/…`)。宿主会把平台对应的形式注入为沙箱文档的 ``,并放行资源类 CSP,因此打包后的插件可以正常动态 `import()` 代码分割的 chunk——前提是构建产物使用**相对**资源地址:Vite 设 `base: "./"`、webpack 设 `publicPath: "./"`,让 chunk 和 CSS 资源地址基于注入的 base 解析而不是宿主 origin。绝对路径 `/assets/…` 携带不了插件 id,会 404。该协议只读文件、限定在每个插件的 `ui.root` 内,且插件更新后不会沿用旧缓存。 + ## 原生 Sidecar 和 RPC 只有浏览器沙箱无法完成的能力才需要原生后端,例如 S3/SSH 协议、系统凭据、长连接或高性能计算。Sidecar 不是操作系统沙箱,会以当前用户权限运行。 diff --git a/docs/content/docs/plugin-development.mdx b/docs/content/docs/plugin-development.mdx index 4a631818cc..33e37559a0 100644 --- a/docs/content/docs/plugin-development.mdx +++ b/docs/content/docs/plugin-development.mdx @@ -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//` (WebView2 maps it to `http://dbx-plugin.localhost//…`). The host injects the platform-correct form as the sandbox document's `` 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. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2f1f6d1988..d867f073e4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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| { diff --git a/src-tauri/src/plugin_ui_protocol.rs b/src-tauri/src/plugin_ui_protocol.rs new file mode 100644 index 0000000000..8f2aa5c550 --- /dev/null +++ b/src-tauri/src/plugin_ui_protocol.rs @@ -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//`). 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( + ctx: UriSchemeContext<'_, R>, + request: tauri::http::Request>, + responder: UriSchemeResponder, +) { + let registry = ctx.app_handle().try_state::>().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(®istry, &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> { + 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//` 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::>().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> { + 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> { + 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"), "

Hello

").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> { + 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"

Hello

"); + } + + #[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()); + } +}