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

@@ -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;