From bc490772ebc593101542985257914ce30eaae547 Mon Sep 17 00:00:00 2001 From: Jinpy Date: Mon, 21 Sep 2026 02:03:12 +0800 Subject: [PATCH] feat(desktop): fileTransfer bridge for plugin workbench webviews --- .../plugins/PluginWorkbenchHost.spec.ts | 78 ++++ .../plugins/PluginWorkbenchHost.vue | 268 +++++++++++- apps/desktop/src/lib/backend/http.ts | 22 + apps/desktop/src/lib/backend/tauri.ts | 35 ++ .../src/lib/plugins/pluginHostBridge.spec.ts | 74 ++++ .../src/lib/plugins/pluginHostBridge.ts | 134 +++++- docs/content/docs/plugin-development.cn.mdx | 46 ++ docs/content/docs/plugin-development.mdx | 46 ++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/plugin_file.rs | 404 ++++++++++++++++++ src-tauri/src/lib.rs | 5 + 11 files changed, 1111 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/commands/plugin_file.rs diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchHost.spec.ts b/apps/desktop/src/components/plugins/PluginWorkbenchHost.spec.ts index 5d49e22b28..c86b8882fc 100644 --- a/apps/desktop/src/components/plugins/PluginWorkbenchHost.spec.ts +++ b/apps/desktop/src/components/plugins/PluginWorkbenchHost.spec.ts @@ -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); + expect(posted.some((message) => message.type === "filedrop" && (message.files as Array>)?.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"); + }); }); diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue index 729105e365..9b9b42d6d1 100644 --- a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue +++ b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue @@ -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` ids); the web host +// keeps File objects and in-memory save buffers (`w` 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(); +const webSaveBuffers = new Map }>(); +const openTauriHandles = new Set(); +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 { + 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 { + 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((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 { + 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 { + 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 { + 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 { + 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).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?.(); }); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 6cc27668da..afef45d58e 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -5128,3 +5128,25 @@ export async function refreshConnections(): Promise { export * from "@/lib/backend/mq-http"; export * from "@/lib/backend/mqtt-http"; + +// --------------------------------------------------------------------------- +// Plugin local file streaming (native dialogs / OS drops are Tauri-only) +// --------------------------------------------------------------------------- + +import type { PluginLocalFileChunk, PluginLocalFileHandle, PluginLocalFileWriteResult } from "./tauri"; + +export async function openPluginLocalFile(_pluginId: string, _path: string, _write: boolean): Promise { + throw new Error("Plugin local file access is not available in the web backend"); +} + +export async function readPluginLocalFileChunk(_pluginId: string, _handleId: number, _offset: number, _length?: number): Promise { + throw new Error("Plugin local file access is not available in the web backend"); +} + +export async function writePluginLocalFileChunk(_pluginId: string, _handleId: number, _offset: number, _dataBase64: string): Promise { + throw new Error("Plugin local file access is not available in the web backend"); +} + +export async function closePluginLocalFile(_pluginId: string, _handleId: number): Promise { + throw new Error("Plugin local file access is not available in the web backend"); +} diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 0a688c053d..e96fa3bdfb 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -2419,6 +2419,41 @@ export async function sendPluginBinary(pluginId: string, channel: string, dataBa return invoke("send_plugin_binary", { pluginId, channel, dataBase64 }); } +export interface PluginLocalFileHandle { + handleId: number; + name: string; + size: number; + contentType: string; + write: boolean; +} + +export interface PluginLocalFileChunk { + dataBase64: string; + length: number; + eof: boolean; +} + +export interface PluginLocalFileWriteResult { + written: number; + nextOffset: number; +} + +export async function openPluginLocalFile(pluginId: string, path: string, write: boolean): Promise { + return invoke("plugin_file_open", { pluginId, path, write }); +} + +export async function readPluginLocalFileChunk(pluginId: string, handleId: number, offset: number, length?: number): Promise { + return invoke("plugin_file_read", { pluginId, handleId, offset, length }); +} + +export async function writePluginLocalFileChunk(pluginId: string, handleId: number, offset: number, dataBase64: string): Promise { + return invoke("plugin_file_write", { pluginId, handleId, offset, dataBase64 }); +} + +export async function closePluginLocalFile(pluginId: string, handleId: number): Promise { + return invoke("plugin_file_close", { pluginId, handleId }); +} + export async function listPluginFilesystemEntries(pluginId: string, providerId: string, options: { connectionId?: string; uri?: string; cursor?: string; limit?: number } = {}): Promise { return invoke("list_plugin_filesystem_entries", { pluginId, providerId, ...options }); } diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts index fa8b1fdbe4..0c7800cd5c 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts @@ -973,4 +973,78 @@ describe("plugin SDK source", () => { // a plan mode it never asked for. expect(requests[1].params).toEqual({ connectionId: "c1", sql: "SELECT 1", mode: "estimated" }); }); + + it("forwards fileTransfer requests to the host api", async () => { + const messages: unknown[] = []; + const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; + const api = { + invoke: vi.fn(), + notify: vi.fn(), + sendBinary: vi.fn(), + readAsset: vi.fn(), + pickFiles: vi.fn().mockResolvedValue([{ handleId: "t1", name: "a.txt", size: 1, contentType: "text/plain" }]), + readFileChunk: vi.fn().mockResolvedValue({ dataBase64: "YQ==", length: 1, eof: true }), + beginFileSave: vi.fn().mockResolvedValue({ handleId: "t2", chunkBytes: 4 }), + writeFileChunk: vi.fn().mockResolvedValue({ written: 2, nextOffset: 2 }), + finishFileSave: vi.fn().mockResolvedValue(undefined), + closeFileHandle: vi.fn().mockResolvedValue(undefined), + }; + const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, api); + + const request = (id: string, method: string, params: unknown, data?: ArrayBuffer) => { + bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id, method, params, data } } as MessageEvent); + }; + request("1", "host.pickFiles", { multiple: true }); + request("2", "host.readFileChunk", { handleId: "t1", offset: 0, length: 10 }); + request("3", "host.beginFileSave", { name: "out.bin" }); + request("4", "host.writeFileChunk", { handleId: "t2", offset: 0 }, new ArrayBuffer(2)); + request("5", "host.finishFileSave", { handleId: "t2" }); + request("6", "host.closeFileHandle", { handleId: "t1" }); + + await vi.waitFor(() => expect(messages.filter((message) => (message as { type?: string }).type === "response")).toHaveLength(6)); + expect(api.pickFiles).toHaveBeenCalledWith("sample", { multiple: true }); + expect(api.readFileChunk).toHaveBeenCalledWith("sample", "t1", 0, 10); + expect(api.beginFileSave).toHaveBeenCalledWith("sample", { name: "out.bin", contentType: undefined, size: undefined }); + expect(api.writeFileChunk).toHaveBeenCalledWith("sample", "t2", 0, expect.any(Uint8Array)); + expect(api.finishFileSave).toHaveBeenCalledWith("sample", "t2"); + expect(api.closeFileHandle).toHaveBeenCalledWith("sample", "t1"); + for (const message of messages) expect((message as { error?: string }).error).toBeUndefined(); + }); + + it("resolves a cancelled beginFileSave to null", 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(), beginFileSave: vi.fn().mockResolvedValue(null) }); + + bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id: "1", method: "host.beginFileSave", params: { name: "out.bin" } } } as MessageEvent); + + await vi.waitFor(() => expect(messages.filter((message) => (message as { type?: string }).type === "response")).toHaveLength(1)); + expect(messages[0]).toMatchObject({ type: "response", id: "1", result: null }); + }); + + it("pushes drag state and opened drop handles to the plugin", () => { + 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.forwardDragState(true); + bridge.forwardFileDrop([{ handleId: "t1", name: "a.txt", size: 1, contentType: "text/plain" }]); + + expect(messages[0]).toMatchObject({ source: "dbx-host", version: 1, type: "dragstate", active: true }); + expect(messages[1]).toMatchObject({ source: "dbx-host", version: 1, type: "filedrop", files: [{ handleId: "t1", name: "a.txt" }] }); + }); + + it("sdk exposes the fileTransfer namespace and drop listeners", () => { + const source = pluginSdkSource(); + expect(source).toContain("fileTransfer"); + expect(source).toContain("host.pickFiles"); + expect(source).toContain("host.readFileChunk"); + expect(source).toContain("host.beginFileSave"); + expect(source).toContain("host.writeFileChunk"); + expect(source).toContain("host.finishFileSave"); + expect(source).toContain("onDrop"); + expect(source).toContain("onDragState"); + expect(source).toContain("type === 'filedrop'"); + expect(source).toContain("type === 'dragstate'"); + }); }); diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.ts index 8ba569581d..c9fbe54e24 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.ts @@ -52,6 +52,32 @@ export interface PluginDownloadRequest extends PluginSaveFileRequest { params: Record; } +/** An opened local-file handle handed to a plugin for streaming transfer. */ +export interface PluginFileHandleMeta { + handleId: string; + name: string; + size: number; + contentType: string; +} + +export interface PluginPickFilesOptions { + multiple?: boolean; +} + +export interface PluginFileReadChunk { + dataBase64: string; + length: number; + eof: boolean; +} + +export interface PluginFileWriteResult { + written: number; + nextOffset: number; +} + +/** Chunk size the host advertises for streamed saves; fits the bridge payload cap after base64. */ +export const PLUGIN_SAVE_CHUNK_BYTES = 1024 * 1024; + export interface PluginHostBridgeApi { invoke(pluginId: string, method: string, params?: unknown, timeoutMs?: number): Promise; notify(pluginId: string, method: string, params?: unknown): Promise; @@ -79,6 +105,18 @@ export interface PluginHostBridgeApi { cancelDownload?(pluginId: string, downloadId: string): Promise; /** Write text to the system clipboard on behalf of the sandboxed plugin iframe. */ copyText?(pluginId: string, text: string): Promise; + /** Native open dialog; resolves opened read handles (null selection → empty list). */ + pickFiles?(pluginId: string, options: PluginPickFilesOptions): Promise; + /** Stream a chunk from an opened read handle. */ + readFileChunk?(pluginId: string, handleId: string, offset: number, length?: number): Promise; + /** Native save dialog + opened write handle; resolves null when the user cancels. */ + beginFileSave?(pluginId: string, request: { name?: string; contentType?: string; size?: number }): Promise<{ handleId: string; chunkBytes: number } | null>; + /** Stream a chunk into an opened write handle. */ + writeFileChunk?(pluginId: string, handleId: string, offset: number, bytes: Uint8Array): Promise; + /** Flush and close an opened write handle. */ + finishFileSave?(pluginId: string, handleId: string): Promise; + /** Close any file handle, discarding unsaved state. */ + closeFileHandle?(pluginId: string, handleId: string): Promise; } interface PluginRequestMessage { @@ -233,6 +271,16 @@ export class PluginHostBridge { target.postMessage({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "binary", channel: event.channel, data: buffer }, "*", [buffer]); } + /** Tell the plugin whether an OS-level file drag is currently over its workbench. */ + forwardDragState(active: boolean): void { + this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "dragstate", active }); + } + + /** Hand the plugin already-opened handles for files dropped onto its workbench. */ + forwardFileDrop(files: PluginFileHandleMeta[]): void { + this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "filedrop", files }); + } + private async handleRequest(request: PluginRequestMessage, target: Window): Promise { try { enforcePayloadLimit(request.params); @@ -348,6 +396,50 @@ export class PluginHostBridge { await this.api.copyText(this.plugin.manifest.id, input.text); return { success: true }; } + if (method === "host.pickFiles") { + // Same trust level as host.saveFile: the bytes only flow after the user + // picked the files in the native dialog, so no manifest permission gate. + const input = isRecord(params) ? params : {}; + if (!this.api.pickFiles) throw new Error("Host file picking is unavailable"); + const files = await this.api.pickFiles(this.plugin.manifest.id, { multiple: input.multiple === true }); + return { files }; + } + if (method === "host.readFileChunk") { + const input = requireRecord(params, "host.readFileChunk params"); + if (!this.api.readFileChunk) throw new Error("Host file reading is unavailable"); + return this.api.readFileChunk(this.plugin.manifest.id, requireHandleId(input.handleId), requireOffset(input.offset), input.length === undefined ? undefined : requireChunkLength(input.length)); + } + if (method === "host.beginFileSave") { + const input = isRecord(params) ? params : {}; + if (!this.api.beginFileSave) throw new Error("Host file saving is unavailable"); + const target = await this.api.beginFileSave(this.plugin.manifest.id, { + name: optionalTrimmedString(input.name), + contentType: optionalTrimmedString(input.contentType), + size: input.size === undefined || typeof input.size !== "number" || !Number.isFinite(input.size) ? undefined : Math.max(0, Math.floor(input.size)), + }); + // Cancelled saves resolve null, exactly as the documented contract and + // every plugin's env.d.ts types it. + return target; + } + if (method === "host.writeFileChunk") { + const input = requireRecord(params, "host.writeFileChunk params"); + if (!this.api.writeFileChunk) throw new Error("Host file writing is unavailable"); + const bytes = binary instanceof ArrayBuffer ? new Uint8Array(binary) : new Uint8Array(base64ToBytes(requireBase64(input.dataBase64))); + if (bytes.byteLength > MAX_BRIDGE_BINARY_BYTES) throw new Error("Plugin write chunk exceeds 8 MiB"); + return this.api.writeFileChunk(this.plugin.manifest.id, requireHandleId(input.handleId), requireOffset(input.offset), bytes); + } + if (method === "host.finishFileSave") { + const input = requireRecord(params, "host.finishFileSave params"); + if (!this.api.finishFileSave) throw new Error("Host file saving is unavailable"); + await this.api.finishFileSave(this.plugin.manifest.id, requireHandleId(input.handleId)); + return null; + } + if (method === "host.closeFileHandle") { + const input = requireRecord(params, "host.closeFileHandle params"); + if (!this.api.closeFileHandle) throw new Error("Host file handling is unavailable"); + await this.api.closeFileHandle(this.plugin.manifest.id, requireHandleId(input.handleId)); + return null; + } throw new Error(`Unsupported plugin host method '${method}'`); } @@ -507,7 +599,7 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string { .replace(/\u2029/g, "\\u2029"); return `(() => { const pending = new Map(); - const listeners = { event: new Set(), binary: new Set(), init: new Set(), context: new Set() }; + const listeners = { event: new Set(), binary: new Set(), init: new Set(), context: new Set(), filedrop: new Set(), dragstate: new Set() }; let sequence = 0; let context; let locale = 'en'; @@ -644,6 +736,23 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string { return request('host.saveFile', options, { transfer: bytes }); }, copy: (text) => request('host.copy', { text }), + fileTransfer: Object.freeze({ + pick: (options) => request('host.pickFiles', options || {}), + read: (handleId, offset, length) => request('host.readFileChunk', { handleId, offset, length }), + beginSave: (options) => request('host.beginFileSave', options || {}), + write: (handleId, offset, data) => { + if (typeof data === 'string') return request('host.writeFileChunk', { handleId, offset, dataBase64: data }); + // A Uint8Array can be a view into a larger buffer — transferring + // .buffer blindly would send bytes outside the view. Copy the + // visible range into a standalone buffer first. + const bytes = data instanceof ArrayBuffer ? data : new Uint8Array(data instanceof Uint8Array ? data.slice().buffer : new Uint8Array(data).buffer); + return request('host.writeFileChunk', { handleId, offset }, { transfer: bytes }); + }, + finish: (handleId) => request('host.finishFileSave', { handleId }), + cancel: (handleId) => request('host.closeFileHandle', { handleId }), + onDragState: (listener) => { listeners.dragstate.add(listener); return () => listeners.dragstate.delete(listener); }, + onDrop: (listener) => { listeners.filedrop.add(listener); return () => listeners.filedrop.delete(listener); }, + }), 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); }, @@ -686,6 +795,14 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string { const payload = { channel: message.channel, data: message.data ? new Uint8Array(message.data) : new Uint8Array(0) }; listeners.binary.forEach((listener) => listener(payload)); document.dispatchEvent(new CustomEvent('dbx-plugin-binary', { detail: payload })); + } else if (message.type === 'filedrop') { + const files = Array.isArray(message.files) ? message.files : []; + listeners.filedrop.forEach((listener) => listener(files)); + document.dispatchEvent(new CustomEvent('dbx-plugin-filedrop', { detail: files })); + } else if (message.type === 'dragstate') { + const active = message.active === true; + listeners.dragstate.forEach((listener) => listener(active)); + document.dispatchEvent(new CustomEvent('dbx-plugin-dragstate', { detail: active })); } }); addEventListener('keydown', (event) => { @@ -771,6 +888,21 @@ function clampPluginPlanTimeout(value: unknown): number { return Math.min(MAX_PLUGIN_PLAN_TIMEOUT_MS, Math.max(1, Math.round(value))); } +function requireHandleId(value: unknown): string { + if (typeof value !== "string" || !value || value.length > 128) throw new Error("handleId is invalid"); + return value; +} + +function requireOffset(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error("offset is invalid"); + return Math.floor(value); +} + +function requireChunkLength(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error("length is invalid"); + return Math.min(8 * 1024 * 1024, Math.floor(value)); +} + function base64ToBytes(value: string): ArrayBuffer { const binary = atob(value); const bytes = new Uint8Array(binary.length); diff --git a/docs/content/docs/plugin-development.cn.mdx b/docs/content/docs/plugin-development.cn.mdx index 4cfb8818f1..7d3d726555 100644 --- a/docs/content/docs/plugin-development.cn.mdx +++ b/docs/content/docs/plugin-development.cn.mdx @@ -297,6 +297,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg"); | `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | 返回 `sql` 的估算执行计划;需要 `host.plans:read`。 | | `capabilities` | 来自 init 消息的 `{ downloadFile, planApi }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 | | `onInit(fn)` / `onEvent(fn)` | 监听初始化、环境变化或后端事件;转发后端事件需要 `host.events`。 | +| `fileTransfer` | 本地文件流式传输与系统拖放(仅桌面端);见下文。 | 调用失败会以 Promise rejection 返回,处理时应展示可理解的错误并允许重试。通过 `request` 调用的宿主方法、参数和返回值以当前 Host API 版本为准,不要调用未在协议中声明的 DBX 内部函数。 @@ -346,6 +347,51 @@ if (window.dbxPlugin.capabilities.planApi) { 计划 API 属于 Host API 1.2。无法离开它工作的插件应在 manifest 里声明下限:`"engines": { "host_api": "^1.2" }`。manifest 里的版本范围是兼容下限,`capabilities.planApi` 是运行期探测,两者都保留。 +### 文件传输与系统拖放 + +桌面端宿主通过 `window.dbxPlugin.fileTransfer` 把本地文件流式接入插件沙箱。句柄只在用户明确同意后打开:原生打开/保存对话框(`pick` / `beginSave`),或从系统拖放到本插件工作台区域内的文件(`onDrop`)。传输按分块进行,多 GB 文件也不会整体载入内存。 + +```js +const fileTransfer = window.dbxPlugin.fileTransfer; +if (!fileTransfer) { + // Web 宿主:回退到 和 sidecar 落盘。 +} + +// 上传:用户在原生对话框选择文件,插件把分块推给自己的 sidecar。 +const { files } = await fileTransfer.pick({ multiple: true }); +for (const file of files) { + let offset = 0; + for (;;) { + const chunk = await fileTransfer.read(file.handleId, offset, 256 * 1024); + await uploadChunk(file.name, offset, chunk.dataBase64); // 你自己的 RPC + if (chunk.eof) break; + offset += chunk.length; + } + await fileTransfer.cancel(file.handleId); +} + +// 下载:打开保存目标,分块写入,最后 finish 落盘。 +const target = await fileTransfer.beginSave({ name: "export.csv", size }); +await fileTransfer.write(target.handleId, 0, bytes); +await fileTransfer.finish(target.handleId); + +// 系统拖放(仅桌面端):被拖入的文件以已打开的句柄送达。 +const offDrop = fileTransfer.onDrop((files) => uploadAll(files)); +const offDrag = fileTransfer.onDragState((active) => showDropOverlay(active)); +``` + +| API | 用途 | +| --- | --- | +| `pick({ multiple })` | 原生打开对话框;返回已打开的读句柄 `{ handleId, name, size, contentType }`。 | +| `read(handleId, offset, length?)` | 从读句柄流式读取一个分块 `{ dataBase64, length, eof }`。 | +| `beginSave({ name, contentType?, size? })` | 原生保存对话框 + 写句柄;返回 `{ handleId, chunkBytes }`,用户取消时为 `null`。 | +| `write(handleId, offset, data)` | 写入一个分块(二进制 transfer 或 base64);返回 `{ written, nextOffset }`。 | +| `finish(handleId)` / `cancel(handleId)` | 冲洗并关闭写句柄 / 丢弃任意句柄。 | +| `onDrop(fn)` | 拖入本工作台的文件以已打开的读句柄送达。 | +| `onDragState(fn)` | 是否有系统拖拽悬在本工作台上方;用它驱动拖放遮罩。 | + +仅桌面端可用:Web 宿主下整个命名空间是 `undefined`,使用前请先探测。`onDrop` 只在拖放落点位于本插件工作台区域内时触发;落点在宿主其他区域时保持宿主自身的行为。 + ### 主题、布局和静态资源 DBX 将主题 tokens 注入文档根元素,并更新 `data-dbx-theme`。自定义组件库(包括 Svelte、shadcn-svelte)应使用这些变量,不要依赖父页面 CSS 自动穿透 iframe: diff --git a/docs/content/docs/plugin-development.mdx b/docs/content/docs/plugin-development.mdx index 2160aa006e..4a631818cc 100644 --- a/docs/content/docs/plugin-development.mdx +++ b/docs/content/docs/plugin-development.mdx @@ -297,6 +297,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg"); | `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | Returns the estimated plan for `sql`; requires `host.plans:read`. | | `capabilities` | `{ downloadFile, planApi }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request. | | `onInit(fn)` / `onEvent(fn)` | Observes initialization, environment changes, or backend events; forwarding backend events requires `host.events`. | +| `fileTransfer` | Streams local files and OS drops (desktop only); see below. | Failed calls reject their Promise; show a useful error and offer retry. Host methods, parameters, and results are versioned API surface—do not call undocumented DBX internals. @@ -346,6 +347,51 @@ Gate on capability rather than probing: read `dbxPlugin.capabilities.planApi` fr The plan API is Host API 1.2. A plugin that cannot work without it declares the floor in its manifest with `"engines": { "host_api": "^1.2" }`. The manifest range is a compatibility floor and `capabilities.planApi` is the runtime check; keep both. +### File Transfer and OS Drops + +Desktop hosts stream local files into plugin sandboxes through `window.dbxPlugin.fileTransfer`. Handles are opened only after explicit user consent: a native open/save dialog (`pick` / `beginSave`) or a file dropped from the OS onto this plugin's workbench area (`onDrop`). Transfers are chunked, so multi-gigabyte files never load into memory. + +```js +const fileTransfer = window.dbxPlugin.fileTransfer; +if (!fileTransfer) { + // Web host: fall back to and sidecar disk writes. +} + +// Upload: the user picks files, the plugin streams chunks to its sidecar. +const { files } = await fileTransfer.pick({ multiple: true }); +for (const file of files) { + let offset = 0; + for (;;) { + const chunk = await fileTransfer.read(file.handleId, offset, 256 * 1024); + await uploadChunk(file.name, offset, chunk.dataBase64); // your own RPC + if (chunk.eof) break; + offset += chunk.length; + } + await fileTransfer.cancel(file.handleId); +} + +// Download: open a save target, stream chunks, then flush with finish. +const target = await fileTransfer.beginSave({ name: "export.csv", size }); +await fileTransfer.write(target.handleId, 0, bytes); +await fileTransfer.finish(target.handleId); + +// OS drag & drop (desktop only): dropped files arrive as opened handles. +const offDrop = fileTransfer.onDrop((files) => uploadAll(files)); +const offDrag = fileTransfer.onDragState((active) => showDropOverlay(active)); +``` + +| API | Purpose | +| --- | --- | +| `pick({ multiple })` | Native open dialog; resolves opened read handles `{ handleId, name, size, contentType }`. | +| `read(handleId, offset, length?)` | Streams a chunk `{ dataBase64, length, eof }` from a read handle. | +| `beginSave({ name, contentType?, size? })` | Native save dialog + write handle; resolves `{ handleId, chunkBytes }`, or `null` when the user cancels. | +| `write(handleId, offset, data)` | Writes one chunk (transferred binary or base64); resolves `{ written, nextOffset }`. | +| `finish(handleId)` / `cancel(handleId)` | Flush-closes a write handle / discards any handle. | +| `onDrop(fn)` | Files dropped onto this workbench arrive as already-opened read handles. | +| `onDragState(fn)` | Whether an OS drag is currently over this workbench; drive drop overlays with it. | + +Desktop only: the whole namespace is `undefined` in the web host, so feature-detect before use. `onDrop` only fires for drops that land inside this plugin's workbench area; drops elsewhere keep the host's own behavior. + ### Themes, Layout, and Static Assets DBX injects theme tokens on the document root and updates `data-dbx-theme`. Custom libraries, including Svelte and shadcn-svelte, should consume these variables; parent-page CSS does not cross the iframe: diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 56704c3069..30de1428f2 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -40,6 +40,7 @@ pub mod mqtt_cmd; pub mod nacos_cmd; pub mod plugin_download; pub mod plugin_download_file; +pub mod plugin_file; pub mod plugins; pub mod prompt_template; pub mod query; diff --git a/src-tauri/src/commands/plugin_file.rs b/src-tauri/src/commands/plugin_file.rs new file mode 100644 index 0000000000..5f085ea204 --- /dev/null +++ b/src-tauri/src/commands/plugin_file.rs @@ -0,0 +1,404 @@ +//! Streaming local-file access for plugin workbench bridges. +//! +//! Plugin sandboxes run with an opaque origin, so they can neither read local +//! files dropped onto the window nor stream multi-gigabyte transfers through a +//! one-shot IPC. The workbench host opens handles through this registry after +//! the user picked the file in a native dialog or dropped it onto the plugin's +//! workbench area — both are explicit user consent, and every open goes through +//! the workbench-host TS layer, which is the only caller of these commands. +//! +//! Every handle is owned by the plugin that opened it: all operations carry the +//! caller's plugin id and are rejected unless it matches, and ids come from +//! uuid v4 so a malicious plugin cannot enumerate another plugin's handles. + +use std::collections::HashMap; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::Path; +use std::sync::Mutex; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use serde::Serialize; +use tauri::State; +use uuid::Uuid; + +/// Mirrors the bridge binary cap: one read/write chunk never exceeds it. +pub const MAX_CHUNK_BYTES: usize = 8 * 1024 * 1024; + +/// Chunk size advertised to plugins for `beginSave` writes: comfortably below +/// the 2 MiB bridge payload limit after base64 inflation. +pub const SAVE_CHUNK_BYTES: usize = 1024 * 1024; + +const MAX_OPEN_HANDLES: usize = 64; + +struct OpenFile { + file: File, + write: bool, + size: u64, + owner: String, +} + +#[derive(Default)] +pub struct PluginFileState { + handles: Mutex>, +} + +impl PluginFileState { + pub fn new() -> Self { + Self::default() + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginFileHandle { + handle_id: u64, + name: String, + size: u64, + content_type: String, + write: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginFileReadChunk { + data_base64: String, + length: usize, + eof: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginFileWriteResult { + written: usize, + next_offset: u64, +} + +/// Content type is best-effort from the extension; plugins only use it for +/// display and dialog filters, so unknown types stay generic. +fn guess_content_type(path: &Path) -> String { + let extension = path.extension().and_then(|value| value.to_str()).unwrap_or_default().to_ascii_lowercase(); + match extension.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "pdf" => "application/pdf", + "txt" | "log" | "md" => "text/plain", + "json" => "application/json", + "csv" => "text/csv", + "zip" => "application/zip", + "gz" | "tgz" => "application/gzip", + "tar" => "application/x-tar", + "mp3" => "audio/mpeg", + "mp4" | "m4v" => "video/mp4", + "sql" => "application/sql", + "pem" | "key" => "application/x-pem-file", + _ => "application/octet-stream", + } + .to_string() +} + +fn with_handles( + state: &PluginFileState, + body: impl FnOnce(&mut HashMap) -> Result, +) -> Result { + let mut handles = state.handles.lock().map_err(|_| "plugin file registry poisoned".to_string())?; + body(&mut handles) +} + +fn owned_handle<'a>( + handles: &'a mut HashMap, + plugin_id: &str, + handle_id: u64, +) -> Result<&'a mut OpenFile, String> { + let entry = handles.get_mut(&handle_id).ok_or_else(|| "unknown plugin file handle".to_string())?; + if entry.owner != plugin_id { + // Deliberately indistinguishable from a missing handle: a plugin must + // not learn that another plugin's handle ids exist. + return Err("unknown plugin file handle".to_string()); + } + Ok(entry) +} + +/// Open a local file for the plugin bridge. `write` handles truncate existing +/// content (the path comes from the native save dialog, where the user already +/// confirmed overwriting); they may not exist yet. +pub fn open_plugin_file( + state: &PluginFileState, + plugin_id: &str, + path: &str, + write: bool, +) -> Result { + if plugin_id.trim().is_empty() { + return Err("plugin id is required".to_string()); + } + let path = Path::new(path); + let metadata = std::fs::metadata(path); + if let Ok(metadata) = &metadata { + if !write && metadata.is_dir() { + return Err("path is a directory".to_string()); + } + } + let file = if write { + OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path) + .map_err(|error| format!("cannot open file for writing: {error}"))? + } else { + File::open(path).map_err(|error| format!("cannot open file: {error}"))? + }; + let size = match &metadata { + Ok(metadata) if !write => metadata.len(), + _ => 0, + }; + let name = path.file_name().and_then(|value| value.to_str()).unwrap_or("download.bin").to_string(); + + with_handles(state, |handles| { + if handles.len() >= MAX_OPEN_HANDLES { + return Err(format!("too many open plugin file handles (max {MAX_OPEN_HANDLES})")); + } + // Unguessable id: a plugin that can only reach its own bridge must not + // be able to walk the registry by trying sequential ids. + let bytes = Uuid::new_v4().into_bytes(); + let handle_id = u64::from_be_bytes(bytes[0..8].try_into().expect("uuid prefix")); + handles.insert(handle_id, OpenFile { file, write, size, owner: plugin_id.to_string() }); + Ok(PluginFileHandle { handle_id, name, size, content_type: guess_content_type(path), write }) + }) +} + +pub fn read_plugin_file_chunk( + state: &PluginFileState, + plugin_id: &str, + handle_id: u64, + offset: u64, + length: Option, +) -> Result { + let requested = length.unwrap_or(SAVE_CHUNK_BYTES as u32) as usize; + if requested == 0 { + return Err("read length must be positive".to_string()); + } + with_handles(state, |handles| { + let entry = owned_handle(handles, plugin_id, handle_id)?; + if entry.write { + return Err("handle is write-only".to_string()); + } + if offset > entry.size { + return Err("read offset beyond end of file".to_string()); + } + entry.file.seek(SeekFrom::Start(offset)).map_err(|error| format!("seek failed: {error}"))?; + let remaining = (entry.size - offset) as usize; + let mut buffer = vec![0u8; requested.min(remaining).clamp(1, MAX_CHUNK_BYTES)]; + let read = entry.file.read(&mut buffer).map_err(|error| format!("read failed: {error}"))?; + buffer.truncate(read); + let eof = offset + read as u64 >= entry.size; + Ok(PluginFileReadChunk { data_base64: BASE64.encode(&buffer), length: read, eof }) + }) +} + +pub fn write_plugin_file_chunk( + state: &PluginFileState, + plugin_id: &str, + handle_id: u64, + offset: u64, + data_base64: &str, +) -> Result { + if data_base64.len() > MAX_CHUNK_BYTES * 4 / 3 + 4 { + return Err(format!("write chunk exceeds {MAX_CHUNK_BYTES} bytes")); + } + let bytes = BASE64.decode(data_base64.as_bytes()).map_err(|error| format!("invalid base64 payload: {error}"))?; + with_handles(state, |handles| { + let entry = owned_handle(handles, plugin_id, handle_id)?; + if !entry.write { + return Err("handle is read-only".to_string()); + } + entry.file.seek(SeekFrom::Start(offset)).map_err(|error| format!("seek failed: {error}"))?; + entry.file.write_all(&bytes).map_err(|error| format!("write failed: {error}"))?; + Ok(PluginFileWriteResult { written: bytes.len(), next_offset: offset + bytes.len() as u64 }) + }) +} + +/// Close a handle. Write handles are flushed to disk before dropping; the +/// bytes were already handed to the OS through `write_all`, so failure here +/// means the OS could not flush — surfaced to the plugin as an error. +pub fn close_plugin_file(state: &PluginFileState, plugin_id: &str, handle_id: u64) -> Result<(), String> { + with_handles(state, |handles| { + { + let entry = owned_handle(handles, plugin_id, handle_id)?; + if entry.write { + entry.file.sync_all().map_err(|error| format!("flush failed: {error}"))?; + } + } + handles.remove(&handle_id).map(|_| ()).ok_or_else(|| "unknown plugin file handle".to_string()) + }) +} + +#[tauri::command] +pub fn plugin_file_open( + state: State<'_, PluginFileState>, + plugin_id: String, + path: String, + write: Option, +) -> Result { + open_plugin_file(&state, &plugin_id, &path, write == Some(true)) +} + +#[tauri::command] +pub fn plugin_file_read( + state: State<'_, PluginFileState>, + plugin_id: String, + handle_id: u64, + offset: u64, + length: Option, +) -> Result { + read_plugin_file_chunk(&state, &plugin_id, handle_id, offset, length) +} + +#[tauri::command] +pub fn plugin_file_write( + state: State<'_, PluginFileState>, + plugin_id: String, + handle_id: u64, + offset: u64, + data_base64: String, +) -> Result { + write_plugin_file_chunk(&state, &plugin_id, handle_id, offset, &data_base64) +} + +#[tauri::command] +pub fn plugin_file_close(state: State<'_, PluginFileState>, plugin_id: String, handle_id: u64) -> Result<(), String> { + close_plugin_file(&state, &plugin_id, handle_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + const OWNER: &str = "io.dbx.sample"; + const OTHER: &str = "io.dbx.other"; + + fn temp_file(name: &str, contents: &[u8]) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("dbx-plugin-file-{}-{name}", std::process::id())); + std::fs::write(&path, contents).expect("write temp file"); + path + } + + #[test] + fn open_read_and_close_roundtrip() { + let path = temp_file("roundtrip.bin", b"hello plugin bridge"); + let state = PluginFileState::new(); + let handle = open_plugin_file(&state, OWNER, path.to_string_lossy().as_ref(), false).expect("open"); + assert_eq!(handle.size, 19); + assert_eq!(handle.name, path.file_name().and_then(|value| value.to_str()).unwrap()); + assert!(!handle.write); + assert_eq!(handle.content_type, "application/octet-stream"); + + let chunk = read_plugin_file_chunk(&state, OWNER, handle.handle_id, 6, Some(6)).expect("read"); + assert_eq!(BASE64.decode(chunk.data_base64).unwrap(), b"plugin"); + assert!(!chunk.eof); + let tail = read_plugin_file_chunk(&state, OWNER, handle.handle_id, 13, Some(64)).expect("read tail"); + assert_eq!(BASE64.decode(tail.data_base64).unwrap(), b"bridge"); + assert!(tail.eof); + + close_plugin_file(&state, OWNER, handle.handle_id).expect("close"); + assert!(read_plugin_file_chunk(&state, OWNER, handle.handle_id, 0, None).is_err()); + let _ = std::fs::remove_file(path); + } + + #[test] + fn handles_reject_other_plugins() { + let path = temp_file("ownership.bin", b"secret"); + let state = PluginFileState::new(); + let handle = open_plugin_file(&state, OWNER, path.to_string_lossy().as_ref(), false).expect("open"); + + let foreign_read = read_plugin_file_chunk(&state, OTHER, handle.handle_id, 0, None).unwrap_err(); + assert_eq!(foreign_read, "unknown plugin file handle"); + let foreign_write = + write_plugin_file_chunk(&state, OTHER, handle.handle_id, 0, &BASE64.encode(b"x")).unwrap_err(); + assert_eq!(foreign_write, "unknown plugin file handle"); + let foreign_close = close_plugin_file(&state, OTHER, handle.handle_id).unwrap_err(); + assert_eq!(foreign_close, "unknown plugin file handle"); + assert!(read_plugin_file_chunk(&state, OWNER, handle.handle_id, 0, None).is_ok()); + + close_plugin_file(&state, OWNER, handle.handle_id).expect("close"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn write_handle_truncates_and_persists() { + let path = temp_file("write.bin", b"stale contents"); + let state = PluginFileState::new(); + let handle = open_plugin_file(&state, OWNER, path.to_string_lossy().as_ref(), true).expect("open write"); + assert!(handle.write); + assert_eq!(handle.size, 0); + assert!( + read_plugin_file_chunk(&state, OWNER, handle.handle_id, 0, None).is_err(), + "write handles reject reads" + ); + + let written = + write_plugin_file_chunk(&state, OWNER, handle.handle_id, 0, &BASE64.encode(b"fresh")).expect("write"); + assert_eq!(written.written, 5); + assert_eq!(written.next_offset, 5); + close_plugin_file(&state, OWNER, handle.handle_id).expect("close"); + + assert_eq!(std::fs::read(&path).unwrap(), b"fresh"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn write_handle_can_target_a_file_that_does_not_exist_yet() { + let path = std::env::temp_dir().join(format!("dbx-plugin-file-{}-new.bin", std::process::id())); + let _ = std::fs::remove_file(&path); + let state = PluginFileState::new(); + let handle = open_plugin_file(&state, OWNER, path.to_string_lossy().as_ref(), true).expect("open write"); + write_plugin_file_chunk(&state, OWNER, handle.handle_id, 0, &BASE64.encode(b"data")).expect("write"); + close_plugin_file(&state, OWNER, handle.handle_id).expect("close"); + assert_eq!(std::fs::read(&path).unwrap(), b"data"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn rejects_directory_unknown_handles_and_missing_plugin_id() { + let state = PluginFileState::new(); + let error = + open_plugin_file(&state, OWNER, std::env::temp_dir().to_string_lossy().as_ref(), false).unwrap_err(); + assert!(error.contains("directory")); + assert!(open_plugin_file(&state, " ", std::env::temp_dir().to_string_lossy().as_ref(), false).is_err()); + assert!(read_plugin_file_chunk(&state, OWNER, 999_999, 0, None).is_err()); + assert!(close_plugin_file(&state, OWNER, 999_999).is_err()); + } + + #[test] + fn read_rejects_offset_beyond_size() { + let path = temp_file("bounds.bin", b"abc"); + let state = PluginFileState::new(); + let handle = open_plugin_file(&state, OWNER, path.to_string_lossy().as_ref(), false).expect("open"); + assert!(read_plugin_file_chunk(&state, OWNER, handle.handle_id, 4, None).is_err()); + let at_end = read_plugin_file_chunk(&state, OWNER, handle.handle_id, 3, None).expect("read at eof"); + assert!(at_end.eof); + assert_eq!(at_end.length, 0); + close_plugin_file(&state, OWNER, handle.handle_id).expect("close"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn content_type_is_guessed_from_extension() { + let mut path = temp_file("ctype.svg", b""); + let state = PluginFileState::new(); + let handle = open_plugin_file(&state, OWNER, path.to_string_lossy().as_ref(), false).expect("open"); + assert_eq!(handle.content_type, "image/svg+xml"); + close_plugin_file(&state, OWNER, handle.handle_id).expect("close"); + let _ = std::fs::remove_file(&path); + + path.set_extension("weird"); + std::fs::write(&path, b"x").unwrap(); + let handle = open_plugin_file(&state, OWNER, path.to_string_lossy().as_ref(), false).expect("open"); + assert_eq!(handle.content_type, "application/octet-stream"); + close_plugin_file(&state, OWNER, handle.handle_id).expect("close"); + let _ = std::fs::remove_file(path); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 484098377a..2f1f6d1988 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1531,6 +1531,7 @@ pub fn run() { builder .manage(CloseBehaviorState::new()) + .manage(commands::plugin_file::PluginFileState::new()) .manage(AppLocaleState::new()) .on_page_load(|webview, payload| { if payload.event() == PageLoadEvent::Started { @@ -1873,6 +1874,10 @@ pub fn run() { commands::connection::save_table_vgroups, commands::connection::load_table_vgroups, commands::connection::delete_table_vgroups_for_connection, + commands::plugin_file::plugin_file_open, + commands::plugin_file::plugin_file_read, + commands::plugin_file::plugin_file_write, + commands::plugin_file::plugin_file_close, commands::plugins::list_plugins, commands::plugins::list_plugin_trusted_keys, commands::plugins::save_plugin_trusted_key,