mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
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:
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user