feat(desktop): fileTransfer bridge for plugin workbench webviews
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user