feat(desktop): fileTransfer bridge for plugin workbench webviews

This commit is contained in:
Jinpy authored and GitHub committed 2026-09-21 02:03:12 +08:00
1 parent 9b82ce86f9
commit bc490772eb
11 files changed
+1111 -2

No files matched your search

@@ -3,6 +3,7 @@
import { createApp, nextTick, ref, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
import pluginHostSource from "./PluginWorkbenchHost.vue?raw";
const mocks = vi.hoisted(() => ({
readPluginUiEntry: vi.fn(),
@@ -10,6 +11,17 @@ const mocks = vi.hoisted(() => ({
subscribePluginEvents: vi.fn(),
repushPluginConnection: vi.fn(),
reopenPluginConnection: vi.fn(),
openPluginLocalFile: vi.fn(),
readPluginLocalFileChunk: vi.fn(),
writePluginLocalFileChunk: vi.fn(),
closePluginLocalFile: vi.fn(),
}));
vi.mock("@/lib/backend/tauri", () => ({
openPluginLocalFile: mocks.openPluginLocalFile,
readPluginLocalFileChunk: mocks.readPluginLocalFileChunk,
writePluginLocalFileChunk: mocks.writePluginLocalFileChunk,
closePluginLocalFile: mocks.closePluginLocalFile,
}));
vi.mock("@/lib/backend/api", () => ({
@@ -132,4 +144,70 @@ describe("PluginWorkbenchHost initialization", () => {
await vi.waitFor(() => expect(postMessage).toHaveBeenCalledTimes(1));
expect(postMessage.mock.calls[0]?.[0]).toMatchObject({ type: "init" });
});
it("claims OS drops over its iframe and forwards opened handles to the plugin", async () => {
const { frame, postMessage } = await mountHost();
const elementFromPoint = vi.spyOn(document, "elementFromPoint").mockReturnValue(frame);
mocks.openPluginLocalFile.mockResolvedValue({ handleId: 7, name: "a.txt", size: 3, contentType: "text/plain", write: false });
const claimed = !document.dispatchEvent(
new CustomEvent("dbx:tauri-file-drop", {
detail: { type: "drop", paths: ["/tmp/a.txt"], position: { x: 200, y: 200 } },
cancelable: true,
}),
);
expect(claimed).toBe(true);
await vi.waitFor(() => {
const posted = postMessage.mock.calls.map(([message]) => message as Record<string, unknown>);
expect(posted.some((message) => message.type === "filedrop" && (message.files as Array<Record<string, unknown>>)?.some((file) => file.handleId === "t7" && file.name === "a.txt"))).toBe(true);
});
expect(mocks.openPluginLocalFile).toHaveBeenCalledWith("sample", "/tmp/a.txt", false);
elementFromPoint.mockRestore();
});
it("leaves drops outside the iframe to the host fallback", async () => {
const { postMessage } = await mountHost();
const elementFromPoint = vi.spyOn(document, "elementFromPoint").mockReturnValue(document.body);
const claimed = !document.dispatchEvent(
new CustomEvent("dbx:tauri-file-drop", {
detail: { type: "drop", paths: ["/tmp/a.txt"], position: { x: 200, y: 200 } },
cancelable: true,
}),
);
expect(claimed).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(postMessage).not.toHaveBeenCalled();
expect(mocks.openPluginLocalFile).not.toHaveBeenCalled();
elementFromPoint.mockRestore();
});
it("reports drag enter/leave state while the pointer is over the iframe", async () => {
const { frame, postMessage } = await mountHost();
const elementFromPoint = vi.spyOn(document, "elementFromPoint").mockReturnValue(frame);
const payload = (type: "enter" | "leave") => new CustomEvent("dbx:tauri-file-drop", { detail: { type, position: { x: 10, y: 10 } }, cancelable: true });
document.dispatchEvent(payload("enter"));
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "dragstate", active: true }), "*");
document.dispatchEvent(payload("leave"));
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "dragstate", active: false }), "*");
elementFromPoint.mockRestore();
});
});
describe("PluginWorkbenchHost file-save handle tracking", () => {
const hostSource = pluginHostSource;
const beginSaveSource = hostSource.slice(hostSource.indexOf("async function beginPluginFileSave"), hostSource.indexOf("async function writePluginFileChunkById"));
it("opens the save target through openTauriPluginFile so the write handle joins openTauriHandles", () => {
// A beginSave the plugin abandons (no finish/cancel) must still be
// reclaimed by unmount's disposeLocalFileHandles; a direct
// openPluginLocalFile call would leak the handle in the shared
// 64-slot registry for the lifetime of the workbench host.
expect(beginSaveSource).toContain("await openTauriPluginFile(pluginId, path, true)");
expect(beginSaveSource).not.toContain("openPluginLocalFile");
});
});
@@ -4,7 +4,19 @@ import { AlertTriangle, Loader2 } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { copyToClipboard } from "@/lib/common/clipboard";
import { PluginHostBridge, pluginSandboxDocument, type PluginBridgeTheme, type PluginSaveFileRequest, type PluginSaveFileResult, type PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge";
import {
PluginHostBridge,
pluginSandboxDocument,
PLUGIN_SAVE_CHUNK_BYTES,
type PluginBridgeTheme,
type PluginFileHandleMeta,
type PluginFileReadChunk,
type PluginFileWriteResult,
type PluginPickFilesOptions,
type PluginSaveFileRequest,
type PluginSaveFileResult,
type PluginWorkbenchContext,
} from "@/lib/plugins/pluginHostBridge";
import { buildPluginEditorAppearance } from "@/lib/plugins/pluginAppearance";
import { downloadPluginFile, cancelPluginDownload } from "@/lib/plugins/pluginFileDownload";
import type { InstalledPlugin, PluginUiContribution } from "@/types/database";
@@ -46,6 +58,251 @@ let unsubscribeEvents: (() => void) | undefined;
let disposed = false;
let loadGeneration = 0;
// --- Plugin file-transfer bridge (native dialogs + OS file drops) ---------
// The sandboxed iframe cannot reach local files, so handles live here: Tauri
// handles wrap the plugin_file registry in Rust (`t<n>` ids); the web host
// keeps File objects and in-memory save buffers (`w<n>` ids). Only paths that
// came from a native dialog or an OS drop reach plugin_file_open — never a
// plugin-supplied string.
let webFileSequence = 0;
const webPickedFiles = new Map<string, File>();
const webSaveBuffers = new Map<string, { name: string; contentType: string; chunks: Map<number, Uint8Array> }>();
const openTauriHandles = new Set<number>();
const tauriHandlePrefix = "t";
const webHandlePrefix = "w";
function encodeBytesBase64(bytes: Uint8Array): string {
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return btoa(binary);
}
function parseHandleId(handleId: string): { source: "tauri" | "web"; numericId: number } {
if (handleId.startsWith(tauriHandlePrefix)) return { source: "tauri", numericId: Number(handleId.slice(tauriHandlePrefix.length)) };
if (handleId.startsWith(webHandlePrefix)) return { source: "web", numericId: Number(handleId.slice(webHandlePrefix.length)) };
throw new Error("Unknown file handle");
}
/** Loaded lazily: the static tauri module drags side-effectful imports (i18n boot)
* into specs that mock the whole backend layer. */
async function tauriFileApi() {
return import("@/lib/backend/tauri");
}
async function openTauriPluginFile(pluginId: string, path: string, write: boolean): Promise<PluginFileHandleMeta> {
const { openPluginLocalFile } = await tauriFileApi();
const handle = await openPluginLocalFile(pluginId, path, write);
// Track read AND write handles: unmount must reclaim both (leaked fds also
// burn the shared 64-handle registry quota).
openTauriHandles.add(handle.handleId);
return { handleId: `${tauriHandlePrefix}${handle.handleId}`, name: handle.name, size: handle.size, contentType: handle.contentType };
}
async function pickPluginFiles(pluginId: string, options: PluginPickFilesOptions): Promise<PluginFileHandleMeta[]> {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({ multiple: options.multiple === true });
const paths = Array.isArray(selected) ? selected : selected ? [selected] : [];
const files: PluginFileHandleMeta[] = [];
for (const path of paths) {
try {
files.push(await openTauriPluginFile(pluginId, path, false));
} catch (error) {
console.warn("[DBX][plugin-workbench:pick]", error);
}
}
return files;
}
// Web host: a top-document file input still works there (no sandbox).
const selection = await new Promise<FileList | null>((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.multiple = options.multiple === true;
input.style.display = "none";
// The picker fires no change event on cancel; without this the pick
// promise hangs forever and the plugin's upload waits on nothing.
input.addEventListener("cancel", () => {
input.remove();
resolve(null);
});
input.addEventListener("change", () => {
input.remove();
resolve(input.files);
});
document.body.appendChild(input);
input.click();
});
const files: PluginFileHandleMeta[] = [];
for (const file of Array.from(selection || [])) {
const handleId = `${webHandlePrefix}${++webFileSequence}`;
webPickedFiles.set(handleId, file);
files.push({ handleId, name: file.name, size: file.size, contentType: file.type || "application/octet-stream" });
}
return files;
}
async function readPluginFileChunkById(pluginId: string, handleId: string, offset: number, length?: number): Promise<PluginFileReadChunk> {
const parsed = parseHandleId(handleId);
if (parsed.source === "tauri") {
const { readPluginLocalFileChunk } = await tauriFileApi();
return readPluginLocalFileChunk(pluginId, parsed.numericId, offset, length);
}
const file = webPickedFiles.get(handleId);
if (!file) throw new Error("Unknown file handle");
const slice = file.slice(offset, offset + (length ?? PLUGIN_SAVE_CHUNK_BYTES));
const bytes = new Uint8Array(await slice.arrayBuffer());
return { dataBase64: encodeBytesBase64(bytes), length: bytes.byteLength, eof: offset + bytes.byteLength >= file.size };
}
async function beginPluginFileSave(pluginId: string, request: { name?: string; contentType?: string; size?: number }): Promise<{ handleId: string; chunkBytes: number } | null> {
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const fileName = request.name || "download.bin";
const extension = fileName.includes(".") ? fileName.split(".").pop() : "";
const path = await save({
defaultPath: fileName,
filters: extension ? [{ name: extension.toUpperCase(), extensions: [extension] }] : undefined,
});
if (!path) return null;
// Route through openTauriPluginFile so the write handle joins
// openTauriHandles: a beginSave the plugin abandons must still be
// reclaimed on unmount instead of burning the shared registry quota.
const handle = await openTauriPluginFile(pluginId, path, true);
return { handleId: handle.handleId, chunkBytes: PLUGIN_SAVE_CHUNK_BYTES };
}
const handleId = `${webHandlePrefix}${++webFileSequence}`;
webSaveBuffers.set(handleId, { name: request.name || "download.bin", contentType: request.contentType || "application/octet-stream", chunks: new Map() });
return { handleId, chunkBytes: PLUGIN_SAVE_CHUNK_BYTES };
}
async function writePluginFileChunkById(pluginId: string, handleId: string, offset: number, bytes: Uint8Array): Promise<PluginFileWriteResult> {
const parsed = parseHandleId(handleId);
if (parsed.source === "tauri") {
const { writePluginLocalFileChunk } = await tauriFileApi();
return writePluginLocalFileChunk(pluginId, parsed.numericId, offset, encodeBytesBase64(bytes));
}
const buffer = webSaveBuffers.get(handleId);
if (!buffer) throw new Error("Unknown file handle");
buffer.chunks.set(offset, bytes);
return { written: bytes.byteLength, nextOffset: offset + bytes.byteLength };
}
async function finishPluginFileSave(pluginId: string, handleId: string): Promise<void> {
const parsed = parseHandleId(handleId);
if (parsed.source === "tauri") {
const { closePluginLocalFile } = await tauriFileApi();
openTauriHandles.delete(parsed.numericId);
await closePluginLocalFile(pluginId, parsed.numericId);
return;
}
const buffer = webSaveBuffers.get(handleId);
if (!buffer) throw new Error("Unknown file handle");
webSaveBuffers.delete(handleId);
const ordered = [...buffer.chunks.entries()].sort(([left], [right]) => left - right);
const size = ordered.reduce((total, [, chunk]) => total + chunk.byteLength, 0);
const assembled = new Uint8Array(size);
let cursor = 0;
for (const [, chunk] of ordered) {
assembled.set(chunk, cursor);
cursor += chunk.byteLength;
}
const url = URL.createObjectURL(new Blob([assembled.buffer as ArrayBuffer], { type: buffer.contentType }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = buffer.name;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
async function closePluginFileHandleById(pluginId: string, handleId: string): Promise<void> {
const parsed = parseHandleId(handleId);
if (parsed.source === "tauri") {
const { closePluginLocalFile } = await tauriFileApi();
openTauriHandles.delete(parsed.numericId);
await closePluginLocalFile(pluginId, parsed.numericId);
return;
}
webPickedFiles.delete(handleId);
webSaveBuffers.delete(handleId);
}
function disposeLocalFileHandles(): void {
for (const handleId of openTauriHandles)
tauriFileApi()
.then(({ closePluginLocalFile }) => closePluginLocalFile(props.plugin.manifest.id, handleId))
.catch(() => undefined);
openTauriHandles.clear();
webPickedFiles.clear();
webSaveBuffers.clear();
}
// --- OS file-drop routing (Tauri captures drops at the webview layer) -----
// Tauri's native drag-drop pipeline hands file PATHS to the host page; HTML5
// drop events with real files never reach web content, and plugin iframes
// especially. The webview-level `dbx:tauri-file-drop` event (see useFileDrop)
// carries the physical-window position, so the workbench claims drops whose
// converted CSS point lands on its iframe: preventDefault stops the host's
// open-as-database fallback, and the paths are opened into handles that the
// plugin receives through the bridge.
interface TauriFileDropPayload {
type: "enter" | "over" | "drop" | "leave";
paths?: string[];
position?: { x: number; y: number };
}
let dropDragActive = false;
function forwardDragState(active: boolean): void {
dropDragActive = active;
bridge?.forwardDragState(active);
}
function onHostFileDrop(event: Event): void {
const payload = (event as CustomEvent<TauriFileDropPayload>).detail;
if (!payload || payload.type === "leave") {
if (dropDragActive) forwardDragState(false);
return;
}
const frame = iframe.value;
const position = payload.position;
if (!frame || !position) {
if (dropDragActive) forwardDragState(false);
return;
}
// Overlay titlebar: the webview starts at the window origin, so physical
// window coordinates divide straight into CSS pixels via devicePixelRatio.
const scale = window.devicePixelRatio || 1;
if (document.elementFromPoint(position.x / scale, position.y / scale) !== frame) {
if (dropDragActive) forwardDragState(false);
return;
}
// Claim the drop so the host fallback (open as SQL/database) does not run.
event.preventDefault();
if (payload.type !== "drop") {
if (!dropDragActive) forwardDragState(true);
return;
}
forwardDragState(false);
const paths = (payload.paths || []).filter((path) => typeof path === "string" && path);
if (!paths.length || !bridge) return;
void (async () => {
const files: PluginFileHandleMeta[] = [];
for (const path of paths) {
try {
files.push(await openTauriPluginFile(props.plugin.manifest.id, path, false));
} catch (error) {
console.error("[DBX][plugin-workbench:drop]", error);
}
}
if (files.length) bridge?.forwardFileDrop(files);
})();
}
const title = computed(() => `${props.plugin.manifest.name} · ${props.contribution.label}`);
/** Collect resolved DBX design tokens so the sandbox can theme itself with the same values. */
@@ -92,6 +349,12 @@ function createBridge() {
downloadFile: isTauriRuntime() ? downloadPluginFile : undefined,
cancelDownload: isTauriRuntime() ? cancelPluginDownload : undefined,
copyText: (_pluginId, text) => copyToClipboard(text),
pickFiles: (pluginId, options) => pickPluginFiles(pluginId, options),
readFileChunk: (pluginId, handleId, offset, length) => readPluginFileChunkById(pluginId, handleId, offset, length),
beginFileSave: (pluginId, request) => beginPluginFileSave(pluginId, request),
writeFileChunk: (pluginId, handleId, offset, bytes) => writePluginFileChunkById(pluginId, handleId, offset, bytes),
finishFileSave: (pluginId, handleId) => finishPluginFileSave(pluginId, handleId),
closeFileHandle: (pluginId, handleId) => closePluginFileHandleById(pluginId, handleId),
},
appLocale.value,
currentBridgeTheme(),
@@ -234,6 +497,7 @@ function onFrameLoad() {
onMounted(async () => {
window.addEventListener("message", onMessage);
document.addEventListener("dbx:tauri-file-drop", onHostFileDrop);
const unsubscribe = await api.subscribePluginEvents(
(event) => bridge?.forwardEvent(event),
(event) => bridge?.forwardBinary(event),
@@ -276,6 +540,8 @@ onBeforeUnmount(() => {
bridge?.dispose();
bridge = undefined;
window.removeEventListener("message", onMessage);
document.removeEventListener("dbx:tauri-file-drop", onHostFileDrop);
disposeLocalFileHandles();
unsubscribeEvents?.();
});
</script>