feat(plugins): add host.saveFile bridge for plugin downloads

Plugin iframes cannot trigger downloads: WKWebView cancels
shouldPerformDownload navigations when no host download handler is
registered, so blob-anchor downloads were silent no-ops on desktop.
The bridge now accepts a transferred ArrayBuffer (512 MiB cap, base64
fallback) and persists it through the native save dialog
(plugin-dialog + plugin-fs on Tauri, top-level anchor on web hosts),
resolving null when the user cancels.
This commit is contained in:
t8y2 committed 2026-09-15 11:37:51 +08:00
1 parent f852a57673
commit c4952adb98
3 files changed
+135 -2

No files matched your search

@@ -6,6 +6,9 @@ const HOST_MESSAGE_SOURCE = "dbx-host";
const BRIDGE_VERSION = 1;
const MAX_BRIDGE_PAYLOAD_BYTES = 2 * 1024 * 1024;
const MAX_BRIDGE_BINARY_BYTES = 8 * 1024 * 1024;
// Distinct from the sidecar binary cap: saved files go straight from the
// plugin iframe to disk and never traverse plugin frames.
const MAX_BRIDGE_SAVE_BYTES = 512 * 1024 * 1024;
export interface PluginBridgeTheme {
appearance: "light" | "dark";
@@ -21,6 +24,15 @@ export interface PluginWorkbenchContext {
[key: string]: unknown;
}
export interface PluginSaveFileRequest {
fileName?: string;
contentType?: string;
}
export interface PluginSaveFileResult {
path: string;
}
export interface PluginHostBridgeApi {
invoke<T = unknown>(pluginId: string, method: string, params?: unknown, timeoutMs?: number): Promise<T>;
notify(pluginId: string, method: string, params?: unknown): Promise<void>;
@@ -29,6 +41,8 @@ export interface PluginHostBridgeApi {
openWorkbench?(pluginId: string, contributionId: string, context?: PluginWorkbenchContext): Promise<void> | void;
openFilesystem?(pluginId: string, providerId: string, context?: PluginWorkbenchContext): Promise<void> | void;
closeTab?(): Promise<void> | void;
/** Persist plugin bytes through the host's native save dialog. Resolves null when the user cancels. */
saveFile?(pluginId: string, request: PluginSaveFileRequest, data: Uint8Array): Promise<PluginSaveFileResult | null>;
}
interface PluginRequestMessage {
@@ -180,6 +194,19 @@ export class PluginHostBridge {
await this.api.openFilesystem(this.plugin.manifest.id, requireProtocolName(input.providerId, "filesystem provider"), isRecord(input.context) ? input.context : undefined);
return null;
}
if (method === "host.saveFile") {
const input = isRecord(params) ? params : {};
// The sandboxed iframe cannot trigger downloads (WKWebView cancels blob
// navigations without a host download handler), so plugins hand the bytes
// to the host, which runs the native save dialog and the disk write.
let bytes: Uint8Array;
if (binary instanceof ArrayBuffer) bytes = new Uint8Array(binary);
else if (typeof input.dataBase64 === "string") bytes = new Uint8Array(base64ToBytes(requireBase64(input.dataBase64)));
else throw new Error("host.saveFile requires transferred binary data or dataBase64");
if (bytes.byteLength > MAX_BRIDGE_SAVE_BYTES) throw new Error(`Plugin save payload exceeds ${MAX_BRIDGE_SAVE_BYTES} bytes`);
if (!this.api.saveFile) throw new Error("Host file saving is unavailable");
return this.api.saveFile(this.plugin.manifest.id, { fileName: optionalTrimmedString(input.fileName), contentType: optionalTrimmedString(input.contentType) }, bytes);
}
throw new Error(`Unsupported plugin host method '${method}'`);
}
@@ -460,6 +487,12 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
},
openWorkbench: (contributionId, childContext) => request('host.openWorkbench', { contributionId, context: childContext }),
openFilesystem: (providerId, childContext) => request('host.openFilesystem', { providerId, context: childContext }),
saveFile: (options = {}, data) => {
if (data === undefined) return request('host.saveFile', options);
if (typeof data === 'string') return request('host.saveFile', { ...(options || {}), dataBase64: data });
const bytes = data instanceof ArrayBuffer ? data : (data instanceof Uint8Array ? data.buffer : new Uint8Array(data).buffer);
return request('host.saveFile', options, { transfer: bytes });
},
onEvent: (listener) => { listeners.event.add(listener); return () => listeners.event.delete(listener); },
onBinary: (listener) => { listeners.binary.add(listener); return () => listeners.binary.delete(listener); },
onContext: (listener) => { listeners.context.add(listener); return () => listeners.context.delete(listener); },
@@ -554,6 +587,10 @@ function requireBase64(value: unknown): string {
return value;
}
function optionalTrimmedString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function requireSafeAssetPath(value: unknown): string {
if (typeof value !== "string" || !value || value.startsWith("/") || value.split("/").some((part) => !part || part === "." || part === "..")) throw new Error("Plugin asset path is invalid");
return value;