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
2026-09-15 11:37:51 +08:00
parent f852a57673
commit c4952adb98
3 changed files with 135 additions and 2 deletions
@@ -2,7 +2,8 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { AlertTriangle, Loader2 } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import { PluginHostBridge, pluginSandboxDocument, type PluginBridgeTheme, type PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { PluginHostBridge, pluginSandboxDocument, type PluginBridgeTheme, type PluginSaveFileRequest, type PluginSaveFileResult, type PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge";
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
import { useI18n } from "vue-i18n";
import { useTheme } from "@/composables/useTheme";
@@ -71,12 +72,52 @@ function createBridge() {
openWorkbench: async (pluginId, contributionId, context) => emit("openWorkbench", pluginId, contributionId, context),
openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context),
closeTab: () => emit("closeTab"),
saveFile: (_pluginId, request, data) => savePluginFile(request, data),
},
appLocale.value,
currentBridgeTheme(),
);
}
/** Keep a plugin-supplied name from smuggling path separators or traversal into the save dialog. */
function safeFileName(value: string | undefined): string {
const base = (value || "").split(/[\\/]/).pop() || "";
// eslint-disable-next-line no-control-regex -- stripping control characters is the point
const cleaned = base.replace(/[\u0000-\u001f<>:"|?*]+/g, "").trim();
return cleaned && cleaned !== "." && cleaned !== ".." ? cleaned : "download.bin";
}
/**
* Native save dialog + disk write for plugin downloads. The sandboxed iframe
* cannot trigger downloads itself (WKWebView cancels blob-anchor navigations
* when no host download handler is registered), so the bytes travel through
* the bridge and the host persists them. Resolves null when the user cancels.
*/
async function savePluginFile(request: PluginSaveFileRequest, data: Uint8Array): Promise<PluginSaveFileResult | null> {
const fileName = safeFileName(request.fileName);
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeFile } = await import("@tauri-apps/plugin-fs");
const extension = fileName.includes(".") ? (fileName.split(".").pop() as string) : "";
const path = await save({
defaultPath: fileName,
filters: extension ? [{ name: extension.toUpperCase(), extensions: [extension] }] : undefined,
});
if (!path) return null;
await writeFile(path, data);
return { path };
}
// Web host: the sandboxed iframe cannot download, but the host page can.
// Transferred buffers are plain ArrayBuffers (SharedArrayBuffer cannot cross postMessage).
const url = URL.createObjectURL(new Blob([data.buffer as ArrayBuffer], { type: request.contentType || "application/octet-stream" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
return { path: fileName };
}
function localUiAssetPath(source: string): string | undefined {
const trimmed = source.trim();
if (!trimmed || /^(?:blob:|data:|https?:|\/\/)/i.test(trimmed)) return undefined;
+56 -1
View File
@@ -291,6 +291,7 @@ describe("PluginHostBridge", () => {
expect(document).toContain("window.dbxPlugin");
expect(document).toContain("get locale() { return locale; }");
expect(document).toContain("openFilesystem");
expect(document).toContain("saveFile");
expect(document).toContain("document.dispatchEvent(new CustomEvent('dbx-plugin-env'");
expect(document).toContain("shortcut: 'closeTab'");
expect(document).toContain("connect-src 'none'");
@@ -375,7 +376,61 @@ describe("PluginHostBridge", () => {
expect(messages[0]).toMatchObject({ type: "init", theme: { appearance: "dark", tokens: { "--color-background": "#09090b" } } });
bridge.updateTheme({ appearance: "light", tokens: {} });
expect(messages[1]).toMatchObject({ type: "env", locale: "en", theme: { appearance: "light" } });
expect(messages[1]).toMatchObject({ type: "env", locale: "en", theme: { appearance: "light", tokens: {} } });
});
it("routes host.saveFile transfers through the host save dialog and reports cancellation", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const saveFile = vi.fn().mockResolvedValueOnce({ path: "/tmp/report.csv" }).mockResolvedValueOnce(null);
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
saveFile,
});
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "save1", method: "host.saveFile", params: { fileName: "report.csv", contentType: "text/csv" }, data: new Uint8Array([1, 2, 3]).buffer },
} as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(1));
expect(saveFile).toHaveBeenCalledWith("sample", { fileName: "report.csv", contentType: "text/csv" }, new Uint8Array([1, 2, 3]));
expect(messages[0]).toMatchObject({ type: "response", id: "save1", result: { path: "/tmp/report.csv" } });
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "save2", method: "host.saveFile", params: { dataBase64: "AQID" } },
} as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(2));
expect(saveFile).toHaveBeenLastCalledWith("sample", {}, new Uint8Array([1, 2, 3]));
expect(messages[1]).toMatchObject({ type: "response", id: "save2", result: null });
});
it("rejects host.saveFile without payload or host support", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
});
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "empty", method: "host.saveFile", params: { fileName: "x.bin" } },
} as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(1));
expect(messages[0]).toMatchObject({ id: "empty", error: "host.saveFile requires transferred binary data or dataBase64" });
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "unsupported", method: "host.saveFile", params: { dataBase64: "AQID" } },
} as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(2));
expect(messages[1]).toMatchObject({ id: "unsupported", error: "Host file saving is unavailable" });
});
});
@@ -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;