diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchHost.spec.ts b/apps/desktop/src/components/plugins/PluginWorkbenchHost.spec.ts index 521ad46107..97e71578aa 100644 --- a/apps/desktop/src/components/plugins/PluginWorkbenchHost.spec.ts +++ b/apps/desktop/src/components/plugins/PluginWorkbenchHost.spec.ts @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ subscribePluginEvents: vi.fn(), repushPluginConnection: vi.fn(), reopenPluginConnection: vi.fn(), - openPluginLocalFile: vi.fn(), + openDroppedPluginLocalFiles: vi.fn(), readPluginLocalFileChunk: vi.fn(), writePluginLocalFileChunk: vi.fn(), closePluginLocalFile: vi.fn(), @@ -18,7 +18,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/lib/backend/tauri", () => ({ - openPluginLocalFile: mocks.openPluginLocalFile, + openDroppedPluginLocalFiles: mocks.openDroppedPluginLocalFiles, readPluginLocalFileChunk: mocks.readPluginLocalFileChunk, writePluginLocalFileChunk: mocks.writePluginLocalFileChunk, closePluginLocalFile: mocks.closePluginLocalFile, @@ -77,6 +77,7 @@ describe("PluginWorkbenchHost initialization", () => { vi.stubGlobal("getComputedStyle", () => Object.assign([], { getPropertyValue: () => "" })); mocks.readPluginUiEntry.mockResolvedValue({ dataBase64: btoa(""), contentType: "text/html" }); mocks.subscribePluginEvents.mockResolvedValue(vi.fn()); + mocks.closePluginLocalFile.mockResolvedValue(undefined); mocks.repushPluginConnection.mockReset().mockResolvedValue(undefined); mocks.reopenPluginConnection.mockResolvedValue(undefined); root = document.createElement("div"); @@ -150,7 +151,7 @@ describe("PluginWorkbenchHost initialization", () => { const { frame, postMessage } = await mountHost(); const elementFromPoint = vi.spyOn(document, "elementFromPoint").mockReturnValue(frame); // The Rust registry hands out uuid strings; the `t` prefix stays opaque. - mocks.openPluginLocalFile.mockResolvedValue({ handleId: "0d9f6d26-9e0e-4b1f-8f9a-2b6d3c5a7e81", name: "a.txt", size: 3, contentType: "text/plain", write: false }); + mocks.openDroppedPluginLocalFiles.mockResolvedValue({ dropId: "drop-0001", truncated: false, files: [{ handleId: "0d9f6d26-9e0e-4b1f-8f9a-2b6d3c5a7e81", name: "a.txt", size: 3, contentType: "text/plain", write: false }] }); const claimed = !document.dispatchEvent( new CustomEvent("dbx:tauri-file-drop", { @@ -164,7 +165,102 @@ describe("PluginWorkbenchHost initialization", () => { 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 === "t0d9f6d26-9e0e-4b1f-8f9a-2b6d3c5a7e81" && file.name === "a.txt"))).toBe(true); }); - expect(mocks.openPluginLocalFile).toHaveBeenCalledWith("sample", "/tmp/a.txt", false); + expect(mocks.openDroppedPluginLocalFiles).toHaveBeenCalledWith("sample", ["/tmp/a.txt"]); + elementFromPoint.mockRestore(); + }); + + it("forwards a dropped folder as the files the Rust side expanded it to", async () => { + const { frame, postMessage } = await mountHost(); + const elementFromPoint = vi.spyOn(document, "elementFromPoint").mockReturnValue(frame); + mocks.openDroppedPluginLocalFiles.mockResolvedValue({ + dropId: "drop-0002", + truncated: true, + files: [ + { handleId: "0d9f6d26-9e0e-4b1f-8f9a-2b6d3c5a7e81", name: "a.txt", size: 1, contentType: "text/plain", write: false, relativePath: "folder/a.txt" }, + { handleId: "1d9f6d26-9e0e-4b1f-8f9a-2b6d3c5a7e81", name: "nested.csv", size: 2, contentType: "text/csv", write: false, relativePath: "folder/nested/nested.csv" }, + ], + }); + + document.dispatchEvent( + new CustomEvent("dbx:tauri-file-drop", { + detail: { type: "drop", paths: ["/tmp/folder"], position: { x: 200, y: 200 } }, + cancelable: true, + }), + ); + + await vi.waitFor(() => { + const posted = postMessage.mock.calls.map(([message]) => message as Record); + const filedrop = posted.find((message) => message.type === "filedrop"); + expect((filedrop?.files as Array>)?.map((file) => file.relativePath)).toEqual(["folder/a.txt", "folder/nested/nested.csv"]); + expect(filedrop?.dropId).toBe("drop-0002"); + expect(filedrop?.truncated).toBe(true); + }); + expect(mocks.openDroppedPluginLocalFiles).toHaveBeenCalledWith("sample", ["/tmp/folder"]); + elementFromPoint.mockRestore(); + }); + + it("does not forward a filedrop when no dropped path could be opened", async () => { + const { frame, postMessage } = await mountHost(); + const elementFromPoint = vi.spyOn(document, "elementFromPoint").mockReturnValue(frame); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.openDroppedPluginLocalFiles.mockResolvedValue({ dropId: "drop-0003", truncated: false, files: [] }); + + const claimed = !document.dispatchEvent( + new CustomEvent("dbx:tauri-file-drop", { + detail: { type: "drop", paths: ["/tmp/gone"], position: { x: 200, y: 200 } }, + cancelable: true, + }), + ); + + expect(claimed).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(postMessage.mock.calls.some(([message]) => (message as Record).type === "filedrop")).toBe(false); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + elementFromPoint.mockRestore(); + }); + + it("sweeps opened drop entries when the workbench unmounts", async () => { + const { frame, postMessage } = await mountHost(); + const elementFromPoint = vi.spyOn(document, "elementFromPoint").mockReturnValue(frame); + mocks.openDroppedPluginLocalFiles.mockResolvedValue({ dropId: "drop-0004", truncated: false, files: [{ handleId: "0d9f6d26-9e0e-4b1f-8f9a-2b6d3c5a7e81", name: "a.txt", size: 1, contentType: "text/plain", write: false }] }); + document.dispatchEvent( + new CustomEvent("dbx:tauri-file-drop", { + detail: { type: "drop", paths: ["/tmp/a.txt"], position: { x: 200, y: 200 } }, + cancelable: true, + }), + ); + await vi.waitFor(() => expect(postMessage.mock.calls.some(([message]) => (message as Record).type === "filedrop")).toBe(true)); + + app?.unmount(); + app = undefined; + // Teardown closes exactly the handles this instance opened — never an + // owner-wide sweep, which would kill a sibling workbench's handles. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mocks.closePluginLocalFile).toHaveBeenCalledWith("sample", "0d9f6d26-9e0e-4b1f-8f9a-2b6d3c5a7e81"); + elementFromPoint.mockRestore(); + }); + + it("retires an in-flight drop whose expansion lands after unmount", async () => { + const { frame, postMessage } = await mountHost(); + const elementFromPoint = vi.spyOn(document, "elementFromPoint").mockReturnValue(frame); + let resolveOpen!: (result: unknown) => void; + mocks.openDroppedPluginLocalFiles.mockReturnValue(new Promise((resolve) => (resolveOpen = resolve))); + + document.dispatchEvent( + new CustomEvent("dbx:tauri-file-drop", { + detail: { type: "drop", paths: ["/tmp/folder"], position: { x: 200, y: 200 } }, + cancelable: true, + }), + ); + // The expansion is still in flight when the workbench tears down. + app?.unmount(); + app = undefined; + resolveOpen({ dropId: "drop-late", truncated: false, files: [{ handleId: "0d9f6d26-9e0e-4b1f-8f9a-2b6d3c5a7e81", name: "late.txt", size: 1, contentType: "text/plain", write: false }] }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mocks.closePluginLocalFile).toHaveBeenCalledWith("sample", "0d9f6d26-9e0e-4b1f-8f9a-2b6d3c5a7e81"); + expect(postMessage.mock.calls.some(([message]) => (message as Record).type === "filedrop")).toBe(false); elementFromPoint.mockRestore(); }); @@ -182,7 +278,7 @@ describe("PluginWorkbenchHost initialization", () => { expect(claimed).toBe(false); await new Promise((resolve) => setTimeout(resolve, 0)); expect(postMessage).not.toHaveBeenCalled(); - expect(mocks.openPluginLocalFile).not.toHaveBeenCalled(); + expect(mocks.openDroppedPluginLocalFiles).not.toHaveBeenCalled(); elementFromPoint.mockRestore(); }); diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue index de489fc0b9..df346a37bc 100644 --- a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue +++ b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue @@ -93,12 +93,16 @@ let bootCacheHit = false; // read with "unknown plugin file handle". Every path is consented to on the // Rust side — dialogs open there (plugin_file_pick_files / plugin_file_save_as) // and drops are registered by the native drag-drop pipeline before -// plugin_file_open accepts them — never a plugin-supplied string. +// plugin_file_open_dropped accepts them — never a plugin-supplied string. let webFileSequence = 0; const webPickedFiles = new Map(); const webSaveBuffers = new Map }>(); -const openTauriHandles = new Set(); +// raw handle id -> the plugin id that opened it, recorded at open time. +// Teardown closes precisely these handles: an owner-wide sweep would also +// kill the SAME plugin's handles held by a sibling workbench instance, so it +// is reserved for Rust-side lifecycle (stop/uninstall) only. +const openTauriHandles = new Map(); const tauriHandlePrefix = "t"; const webHandlePrefix = "w"; @@ -124,15 +128,21 @@ async function tauriFileApi() { } // OS drops are the one flow where the renderer still names a path: the native -// drag-drop pipeline registered the dropped files for THIS webview, and -// plugin_file_open accepts exactly those (one open per dropped file). -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 }; +// drag-drop pipeline registered the dropped paths for THIS webview, and +// plugin_file_open_dropped accepts exactly those (one open attempt per dropped +// path). A dropped folder comes back expanded to its contained files on the +// Rust side, with `truncated` flagging any cap cutoff and a `dropId` grouping +// the entries of this one drop. +async function openDroppedPluginFiles(pluginId: string, paths: string[]): Promise<{ dropId: string; truncated: boolean; files: PluginFileHandleMeta[] }> { + const { openDroppedPluginLocalFiles } = await tauriFileApi(); + const result = await openDroppedPluginLocalFiles(pluginId, paths); + const files = result.files.map((handle) => { + openTauriHandles.set(handle.handleId, pluginId); + const meta: PluginFileHandleMeta = { handleId: `${tauriHandlePrefix}${handle.handleId}`, name: handle.name, size: handle.size, contentType: handle.contentType }; + if (handle.relativePath) meta.relativePath = handle.relativePath; + return meta; + }); + return { dropId: result.dropId, truncated: result.truncated, files }; } async function pickPluginFiles(pluginId: string, options: PluginPickFilesOptions): Promise { @@ -144,7 +154,7 @@ async function pickPluginFiles(pluginId: string, options: PluginPickFilesOptions for (const handle of await pickPluginLocalFiles(pluginId, options.multiple === true)) { // Track read AND write handles: unmount must reclaim both (leaked fds // also burn the shared 64-handle registry quota). - openTauriHandles.add(handle.handleId); + openTauriHandles.set(handle.handleId, pluginId); files.push({ handleId: `${tauriHandlePrefix}${handle.handleId}`, name: handle.name, size: handle.size, contentType: handle.contentType }); } return files; @@ -233,7 +243,7 @@ async function beginPluginFileSave(pluginId: string, request: { name?: string; c const { savePluginLocalFileAs } = await tauriFileApi(); const handle = await savePluginLocalFileAs(pluginId, request.name || "download.bin"); if (!handle) return null; - openTauriHandles.add(handle.handleId); + openTauriHandles.set(handle.handleId, pluginId); return { handleId: `${tauriHandlePrefix}${handle.handleId}`, chunkBytes: PLUGIN_SAVE_CHUNK_BYTES }; } const handleId = `${webHandlePrefix}${++webFileSequence}`; @@ -257,8 +267,11 @@ async function finishPluginFileSave(pluginId: string, handleId: string): Promise const parsed = parseHandleId(handleId); if (parsed.source === "tauri") { const { closePluginLocalFile } = await tauriFileApi(); - openTauriHandles.delete(parsed.rawId); await closePluginLocalFile(pluginId, parsed.rawId); + // Drop local tracking only after the close succeeded: a failed flush + // leaves the Rust entry alive, and the map is what unmount cleanup uses + // to retry it. + openTauriHandles.delete(parsed.rawId); return; } const buffer = webSaveBuffers.get(handleId); @@ -284,8 +297,8 @@ async function closePluginFileHandleById(pluginId: string, handleId: string): Pr const parsed = parseHandleId(handleId); if (parsed.source === "tauri") { const { closePluginLocalFile } = await tauriFileApi(); - openTauriHandles.delete(parsed.rawId); await closePluginLocalFile(pluginId, parsed.rawId); + openTauriHandles.delete(parsed.rawId); return; } webPickedFiles.delete(handleId); @@ -293,9 +306,11 @@ async function closePluginFileHandleById(pluginId: string, handleId: string): Pr } function disposeLocalFileHandles(): void { - for (const handleId of openTauriHandles) - tauriFileApi() - .then(({ closePluginLocalFile }) => closePluginLocalFile(props.plugin.manifest.id, handleId)) + // Close exactly the handles THIS instance opened, with the plugin id that + // opened them — a sibling workbench of the same plugin keeps its own. + for (const [handleId, pluginId] of openTauriHandles) + void tauriFileApi() + .then(({ closePluginLocalFile }) => closePluginLocalFile(pluginId, handleId)) .catch(() => undefined); openTauriHandles.clear(); webPickedFiles.clear(); @@ -352,16 +367,32 @@ function onHostFileDrop(event: Event): void { forwardDragState(false); const paths = (payload.paths || []).filter((path) => typeof path === "string" && path); if (!paths.length || !bridge) return; + // Pin the owner and generation before the await: the expansion runs on a + // Rust blocking thread and can land after a teardown or identity rebuild. + const pluginId = props.plugin.manifest.id; + const generation = loadGeneration; 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); + try { + // A granted folder comes back expanded to its files; a path the drop + // pipeline never granted (or that vanished) is skipped on the Rust side, + // so an empty result surfaces here instead of silently handing the + // plugin nothing while the drop looked successful. + const { dropId, truncated, files } = await openDroppedPluginFiles(pluginId, paths); + if (disposed || generation !== loadGeneration) { + // The teardown already ran while the expansion was in flight: retire + // exactly these late arrivals — a closeAll here would also kill the + // same plugin's handles in a freshly rebuilt workbench. + const rawIds = files.map((file) => file.handleId.replace(new RegExp(`^${tauriHandlePrefix}`), "")); + void tauriFileApi().then(({ closePluginLocalFile }) => { + for (const rawId of rawIds) closePluginLocalFile(pluginId, rawId).catch(() => undefined); + }); + return; } + if (files.length) bridge?.forwardFileDrop(files, { dropId, truncated }); + else console.error("[DBX][plugin-workbench:drop] no dropped files could be opened", paths); + } catch (error) { + console.error("[DBX][plugin-workbench:drop]", error); } - if (files.length) bridge?.forwardFileDrop(files); })(); } @@ -461,6 +492,9 @@ function createBridge() { writeFileChunk: (pluginId, handleId, offset, bytes) => writePluginFileChunkById(pluginId, handleId, offset, bytes), finishFileSave: (pluginId, handleId) => finishPluginFileSave(pluginId, handleId), closeFileHandle: (pluginId, handleId) => closePluginFileHandleById(pluginId, handleId), + // OS drops (with folder expansion) are a desktop-host capability; the + // bridge advertises it to plugins through capabilities.fileTransfer. + receiveOsDrops: isTauriRuntime(), storageGet: (pluginId, key) => getPluginStorage(pluginId, key), storageSet: (pluginId, key, value) => setPluginStorage(pluginId, key, value), storageDelete: (pluginId, key) => deletePluginStorage(pluginId, key), @@ -549,6 +583,9 @@ function pluginUiBaseUrl(pluginId: string, entryDirectory: string): string | und async function loadWorkbench() { const generation = ++loadGeneration; + // An identity rebuild swaps the plugin this component serves; retire the + // previous plugin's handles first or they would outlive their owner. + disposeLocalFileHandles(); bridge?.dispose(); bridge = undefined; loading.value = true; diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 792a5a8d86..6a8630edaf 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -5389,9 +5389,9 @@ export * from "@/lib/backend/mqtt-http"; // Plugin local file streaming (native dialogs / OS drops are Tauri-only) // --------------------------------------------------------------------------- -import type { PluginLocalFileChunk, PluginLocalFileHandle, PluginLocalFileWriteResult } from "./tauri"; +import type { PluginDroppedFilesResult, PluginLocalFileChunk, PluginLocalFileHandle, PluginLocalFileWriteResult } from "./tauri"; -export async function openPluginLocalFile(_pluginId: string, _path: string, _write: boolean): Promise { +export async function openDroppedPluginLocalFiles(_pluginId: string, _paths: string[]): 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 844f8a8cef..d052b9cef4 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -2736,6 +2736,8 @@ export interface PluginLocalFileHandle { size: number; contentType: string; write: boolean; + /** Only for files expanded out of a dropped folder: '/'-separated path relative to the dropped folder root. */ + relativePath?: string; } export interface PluginLocalFileChunk { @@ -2749,14 +2751,23 @@ export interface PluginLocalFileWriteResult { nextOffset: number; } -export async function openPluginLocalFile(pluginId: string, path: string, write: boolean): Promise { - return invoke("plugin_file_open", { pluginId, path, write }); -} - // The native open/save dialogs run on the Rust side: the host never passes // paths into the plugin-file registry, it only receives handles for what the -// user picked. Only the OS drop flow still goes through openPluginLocalFile, -// and the Rust command accepts exactly the paths its own drop pipeline granted. +// user picked. Only the OS drop flow goes through openDroppedPluginLocalFiles: +// the Rust command accepts exactly the paths its own drop pipeline granted to +// this webview (one open attempt per granted path); a granted folder expands +// to its contained files on the Rust side, and `truncated` flags any cap +// cutoff so partial delivery is visible to the plugin. +export interface PluginDroppedFilesResult { + dropId: string; + files: PluginLocalFileHandle[]; + truncated: boolean; +} + +export async function openDroppedPluginLocalFiles(pluginId: string, paths: string[]): Promise { + return invoke("plugin_file_open_dropped", { pluginId, paths }); +} + export async function pickPluginLocalFiles(pluginId: string, multiple: boolean): Promise { return invoke("plugin_file_pick_files", { pluginId, multiple }); } diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.ts index f08cc0e6cb..9fa8e703bb 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.ts @@ -120,6 +120,8 @@ export interface PluginFileHandleMeta { name: string; size: number; contentType: string; + /** Only for files expanded out of a dropped folder: '/'-separated path relative to the dropped folder root, so the plugin can rebuild the dragged tree. */ + relativePath?: string; } export interface PluginPickFilesOptions { @@ -250,6 +252,8 @@ export interface PluginHostBridgeApi { finishFileSave?(pluginId: string, handleId: string): Promise; /** Close any file handle, discarding unsaved state. */ closeFileHandle?(pluginId: string, handleId: string): Promise; + /** Whether this host receives OS file drops on the plugin's behalf (desktop only). */ + receiveOsDrops?: boolean; /** Persistent per-plugin key-value storage for sandboxed UIs; resolves null when the key is unset. */ storageGet?(pluginId: string, key: string): Promise; storageSet?(pluginId: string, key: string, value: unknown): Promise; @@ -460,6 +464,16 @@ export class PluginHostBridge { clipboardRead: !!this.api.clipboardRead, clipboardImageRead: !!this.api.clipboardReadImage, mediaUrl: !!this.api.openMedia && !!this.api.closeMedia, + // The namespace exists on both hosts, but what it can DO differs: + // portable plugins gate on these flags instead of probing calls. + fileTransfer: { + pick: !!this.api.pickFiles, + beginSave: !!this.api.beginFileSave && !!this.api.writeFileChunk && !!this.api.finishFileSave, + read: !!this.api.readFileChunk, + // OS drops (and their folder expansion) are desktop-only. + drop: !!this.api.receiveOsDrops, + folderExpansion: !!this.api.receiveOsDrops, + }, }, context: snapshotPluginWorkbenchContext(this.context), }); @@ -539,8 +553,8 @@ export class PluginHostBridge { } /** 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 }); + forwardFileDrop(files: PluginFileHandleMeta[], drop?: { dropId?: string; truncated?: boolean }): void { + this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "filedrop", files, dropId: drop?.dropId, truncated: drop?.truncated === true }); } private async handleRequest(request: PluginRequestMessage, target: Window): Promise { @@ -1352,8 +1366,11 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string { 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 })); + // The drop metadata rides as a second listener argument so existing + // single-parameter listeners keep working unchanged. + const drop = { dropId: typeof message.dropId === 'string' ? message.dropId : undefined, truncated: message.truncated === true }; + listeners.filedrop.forEach((listener) => listener(files, drop)); + document.dispatchEvent(new CustomEvent('dbx-plugin-filedrop', { detail: { files, drop } })); } else if (message.type === 'dragstate') { const active = message.active === true; listeners.dragstate.forEach((listener) => listener(active)); diff --git a/docs/content/docs/plugin-development.cn.mdx b/docs/content/docs/plugin-development.cn.mdx index 55fac9ce8f..4cd79ce5ce 100644 --- a/docs/content/docs/plugin-development.cn.mdx +++ b/docs/content/docs/plugin-development.cn.mdx @@ -362,7 +362,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg"); | `ai.clearRecommendations()` | 清空当前工作台的推荐问题;需要 `host.ai` 和 `capabilities.aiRecommendations`。 | | `onInit(fn)` / `onEvent(fn)` | 监听初始化、环境变化或后端事件;转发后端事件需要 `host.events`。 | | `storage` | 本插件独享的持久化键值状态;需要 `host.storage`;见下文。 | -| `fileTransfer` | 本地文件流式传输与系统拖放(仅桌面端);见下文。 | +| `fileTransfer` | 本地文件流式传输、下载与(桌面端)系统拖放;见下文。 | | `clipboard` | 沙箱内的系统剪贴板访问;见下文。 | 调用失败会以 Promise rejection 返回,处理时应展示可理解的错误并允许重试。通过 `request` 调用的宿主方法、参数和返回值以当前 Host API 版本为准,不要调用未在协议中声明的 DBX 内部函数。 @@ -574,10 +574,10 @@ const offDrag = fileTransfer.onDragState((active) => showDropOverlay(active)); | `beginSave({ name, contentType?, size? })` | 原生保存对话框 + 写句柄;返回 `{ handleId, chunkBytes }`,用户取消时为 `null`。 | | `write(handleId, offset, data)` | 写入一个分块(二进制 transfer 或 base64);返回 `{ written, nextOffset }`。 | | `finish(handleId)` / `cancel(handleId)` | 冲洗并关闭写句柄 / 丢弃任意句柄。 | -| `onDrop(fn)` | 拖入本工作台的文件以已打开的读句柄送达。 | +| `onDrop(fn)` | 拖入本工作台的文件以读句柄送达;拖入文件夹会被展开为其中包含的文件(`.git` 等点开头文件与目录会被跳过),每个文件额外携带 `relativePath`(以 `/` 分隔、以拖入文件夹自身名字开头的路径,如 `folder/nested/a.csv`),可用于还原拖入的目录结构,也能区分不同拖入根目录下的同名文件。监听器签名为 `(files, { dropId, truncated })`:`dropId` 标识本次拖入的整组条目(取消与进度记账的分组键),`truncated` 为 `true` 表示展开因 2000 文件或 8 层深度上限被截断。 | | `onDragState(fn)` | 是否有系统拖拽悬在本工作台上方;用它驱动拖放遮罩。 | -仅桌面端可用:Web 宿主下整个命名空间是 `undefined`,使用前请先探测。`onDrop` 只在拖放落点位于本插件工作台区域内时触发;落点在宿主其他区域时保持宿主自身的行为。 +桌面端与网页端的行为差异:请以 init 消息里 additive 的 `capabilities.fileTransfer` 标志位(`pick`、`beginSave`、`read`、`drop`、`folderExpansion`)做能力探测,不要靠试调用。桌面端 `pick` / `beginSave` 走原生对话框、`onDrop` 接收系统拖放(句柄由宿主惰性打开,读时分块流式,因此超大文件夹与大文件都不会占满句柄;选取文件数超过共享句柄池时自动降级为惰性条目,不会静默丢弃)。网页端 `pick` 回退到浏览器文件选择器、`beginSave` 回退为内存缓冲加浏览器下载、`onDrop` / `onDragState` 不会触发(`drop`/`folderExpansion` 为 `false`)、句柄也没有 `relativePath`。`onDrop` 只在拖放落点位于本插件工作台区域内时触发;落点在宿主其他区域时保持宿主自身的行为。 ### 持久化 UI 状态 diff --git a/docs/content/docs/plugin-development.mdx b/docs/content/docs/plugin-development.mdx index e46eff1e50..f596ecbffe 100644 --- a/docs/content/docs/plugin-development.mdx +++ b/docs/content/docs/plugin-development.mdx @@ -362,7 +362,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg"); | `ai.clearRecommendations()` | Clears recommendations for the current workbench; requires `host.ai` and `capabilities.aiRecommendations`. | | `onInit(fn)` / `onEvent(fn)` | Observes initialization, environment changes, or backend events; forwarding backend events requires `host.events`. | | `storage` | Persistent per-plugin key-value state; requires `host.storage`; see below. | -| `fileTransfer` | Streams local files and OS drops (desktop only); see below. | +| `fileTransfer` | Streams local files, downloads, and (desktop) OS drops; see below. | | `clipboard` | System clipboard access from the sandbox; 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. @@ -574,10 +574,10 @@ const offDrag = fileTransfer.onDragState((active) => showDropOverlay(active)); | `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. | +| `onDrop(fn)` | Files dropped onto this workbench arrive as read handles. A dropped folder is expanded into the regular files it contains (dotfiles and dot-directories like `.git` are skipped); each expanded file carries `relativePath` — its '/'-separated path starting with the dropped folder's own name (`folder/nested/a.csv`) — so the plugin can rebuild the dragged structure and tell same-named files under different dropped roots apart. Listeners receive `(files, { dropId, truncated })`: `dropId` groups this drop's entries (cancellation and progress bookkeeping), `truncated` is `true` when the 2000-file or 8-depth expansion cap cut the delivery short. | | `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. +Desktop and web hosts differ in what the namespace can do — gate on the additive `capabilities.fileTransfer` flags (`pick`, `beginSave`, `read`, `drop`, `folderExpansion`) instead of probing calls. On desktop, `pick` / `beginSave` use native dialogs and `onDrop` receives OS drops; dropped entries are opened lazily and streamed in chunks, so very large folders and multi-gigabyte files never exhaust handles or memory, and picking more files than the shared handle registry holds downgrades them to lazily-opened entries instead of dropping any. On the web host, `pick` falls back to a browser file input, `beginSave` buffers in memory and downloads through the browser, `onDrop` / `onDragState` never fire (`drop`/`folderExpansion` are `false` there), and handles carry no `relativePath`. `onDrop` only fires for drops that land inside this plugin's workbench area; drops elsewhere keep the host's own behavior. ### Persistent UI State diff --git a/src-tauri/src/commands/plugin_file.rs b/src-tauri/src/commands/plugin_file.rs deleted file mode 100644 index d6372d79a3..0000000000 --- a/src-tauri/src/commands/plugin_file.rs +++ /dev/null @@ -1,618 +0,0 @@ -//! 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. Handles are opened through this registry, and every path -//! enters it through a Rust-anchored consent channel — never a path string the -//! renderer merely claims: -//! - `plugin_file_pick_files` / `plugin_file_save_as` open the native dialog -//! on the Rust side and return handles for exactly what the user picked; -//! - OS drops are captured by the native drag-drop pipeline -//! ([`tauri::Builder::on_window_event`]) and registered per webview, and -//! `plugin_file_open` only accepts a path that this webview actually -//! received, consuming the grant on use. -//! -//! 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 tauri_plugin_dialog::DialogExt; -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>, - /// Local paths the native drag-drop pipeline delivered to each webview, - /// waiting to be claimed by `plugin_file_open`. Registered only by the - /// window-event hook (one grant per dropped file), consumed on open. - dropped_paths: Mutex>>, -} - -impl PluginFileState { - pub fn new() -> Self { - Self::default() - } - - /// Records the files the native drop pipeline just delivered to a webview. - pub fn register_dropped_paths(&self, webview_label: &str, paths: Vec) { - if paths.is_empty() { - return; - } - if let Ok(mut dropped) = self.dropped_paths.lock() { - dropped.entry(webview_label.to_string()).or_default().extend(paths); - } - } - - /// Claims a dropped path for opening: one drop authorizes exactly one - /// open, in the webview that received it. - fn consume_dropped_path(&self, webview_label: &str, path: &str) -> bool { - let Ok(mut dropped) = self.dropped_paths.lock() else { - return false; - }; - let Some(entries) = dropped.get_mut(webview_label) else { - return false; - }; - if let Some(position) = entries.iter().position(|granted| granted == path) { - entries.remove(position); - if entries.is_empty() { - dropped.remove(webview_label); - } - true - } else { - false - } - } -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PluginFileHandle { - /// Opaque handle id, carried as a UUID string on the wire — the same - /// convention as `downloadId`. It must never be a JSON number: ids are - /// parsed as doubles in every JS layer, and a u64 loses precision above - /// `Number.MAX_SAFE_INTEGER`, which silently corrupted ids and failed - /// every bridge read/write with "unknown plugin file handle". - handle_id: String, - 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: &str, -) -> 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 { - // Response stays indistinguishable from a missing handle: a plugin must - // not learn that another plugin's handle ids exist. The host log is the - // one place the two cases can be told apart when debugging. - log::warn!("plugin file handle belongs to another plugin (caller: {plugin_id})"); - 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. Same wire - // convention as downloadId: uuid v4, carried as a string so no JS - // layer can lose precision on it. - let handle_id = Uuid::new_v4().to_string(); - handles.insert(handle_id.clone(), 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: &str, - 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: &str, - 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: &str) -> 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>, - webview: tauri::Webview, - plugin_id: String, - path: String, - write: Option, -) -> Result { - open_dropped_plugin_file(&state, webview.label(), &plugin_id, &path, write == Some(true)) -} - -/// The command-level gate for `plugin_file_open`: after the dialog flows moved -/// to Rust, the only renderer path into this registry is the OS drop flow, so -/// a path alone is never consent — the native drag-drop pipeline must have -/// delivered it to THIS webview. Write handles are dialog-only: a path string -/// is not authorization to create or truncate a file. -fn open_dropped_plugin_file( - state: &PluginFileState, - webview_label: &str, - plugin_id: &str, - path: &str, - write: bool, -) -> Result { - if write { - return Err("write handles are only available through the host save dialog".to_string()); - } - if !state.consume_dropped_path(webview_label, path) { - // Indistinguishable from an OS error to the caller; the host log is - // where a probe attempt can be told apart from a stale drop grant. - log::warn!("plugin_file_open rejected a path the drop pipeline did not grant to webview \"{webview_label}\" (plugin: {plugin_id})"); - return Err("cannot open file: path was not provided by the host".to_string()); - } - open_plugin_file(state, plugin_id, path, false) -} - -/// Opens the native open dialog on the Rust side and returns read handles for -/// exactly the files the user picked. The renderer never sees the paths and -/// cannot pass its own: user consent happens inside this command. -#[tauri::command] -pub async fn plugin_file_pick_files( - app: tauri::AppHandle, - state: State<'_, PluginFileState>, - plugin_id: String, - multiple: Option, -) -> Result, String> { - if plugin_id.trim().is_empty() { - return Err("plugin id is required".to_string()); - } - let picked = if multiple == Some(true) { - let (sender, receiver) = tokio::sync::oneshot::channel(); - app.dialog().file().pick_files(move |paths| { - let _ = sender.send(paths); - }); - receiver.await.map_err(|error| error.to_string())? - } else { - let (sender, receiver) = tokio::sync::oneshot::channel(); - app.dialog().file().pick_file(move |path| { - let _ = sender.send(path); - }); - receiver.await.map_err(|error| error.to_string())?.map(|path| vec![path]) - }; - let mut handles = Vec::new(); - for path in picked.into_iter().flatten() { - let Ok(path) = path.into_path() else { continue }; - match open_plugin_file(&state, &plugin_id, &path.to_string_lossy(), false) { - Ok(handle) => handles.push(handle), - Err(error) => log::warn!("plugin file pick: cannot open {}: {error}", path.display()), - } - } - Ok(handles) -} - -/// Opens the native save dialog on the Rust side and returns a write handle -/// for the path the user confirmed. The renderer only controls the suggested -/// file name: the dialog itself is the consent, and the path never round-trips -/// through renderer-controlled arguments. -#[tauri::command] -pub async fn plugin_file_save_as( - app: tauri::AppHandle, - state: State<'_, PluginFileState>, - plugin_id: String, - default_file_name: Option, -) -> Result, String> { - if plugin_id.trim().is_empty() { - return Err("plugin id is required".to_string()); - } - // The name comes from the plugin; keep it a bare file name. - let file_name = default_file_name - .as_deref() - .map(str::trim) - .filter(|name| !name.is_empty()) - .map(|name| name.rsplit(['/', '\\']).next().unwrap_or("download.bin").to_string()); - let mut dialog = app.dialog().file(); - if let Some(name) = &file_name { - dialog = dialog.set_file_name(name); - } - let extension = file_name - .as_deref() - .and_then(|name| Path::new(name).extension()) - .and_then(|extension| extension.to_str()) - .map(|extension| extension.to_ascii_lowercase()); - if let Some(extension) = &extension { - dialog = dialog.add_filter(extension.to_ascii_uppercase(), &[extension.as_str()]); - } - let (sender, receiver) = tokio::sync::oneshot::channel(); - dialog.save_file(move |path| { - let _ = sender.send(path); - }); - let chosen = receiver.await.map_err(|error| error.to_string())?; - let Some(chosen) = chosen else { return Ok(None) }; - let path = chosen.into_path().map_err(|error| error.to_string())?; - open_plugin_file(&state, &plugin_id, &path.to_string_lossy(), true).map(Some) -} - -#[tauri::command] -pub fn plugin_file_read( - state: State<'_, PluginFileState>, - plugin_id: String, - handle_id: String, - 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: String, - 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: String, -) -> 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, "00000000-0000-4000-8000-000000000099", 0, None).is_err()); - assert!(close_plugin_file(&state, OWNER, "00000000-0000-4000-8000-000000000099").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 dropped_paths_gate_the_open_command() { - let path = temp_file("dropped.bin", b"dropped"); - let state = PluginFileState::new(); - // A path the renderer merely claims is rejected. - let rejected = - open_dropped_plugin_file(&state, "main", OWNER, path.to_string_lossy().as_ref(), false).unwrap_err(); - assert!(rejected.contains("not provided by the host"), "{rejected}"); - // A path granted to a DIFFERENT webview is rejected for this one. - state.register_dropped_paths("other-window", vec![path.to_string_lossy().into_owned()]); - assert!(open_dropped_plugin_file(&state, "main", OWNER, path.to_string_lossy().as_ref(), false).is_err()); - // The granting webview can open exactly once. - state.register_dropped_paths("main", vec![path.to_string_lossy().into_owned()]); - let handle = open_dropped_plugin_file(&state, "main", OWNER, path.to_string_lossy().as_ref(), false) - .expect("open dropped"); - let chunk = read_plugin_file_chunk(&state, OWNER, &handle.handle_id, 0, None).expect("read"); - assert_eq!(BASE64.decode(chunk.data_base64).unwrap(), b"dropped"); - close_plugin_file(&state, OWNER, &handle.handle_id).expect("close"); - assert!( - open_dropped_plugin_file(&state, "main", OWNER, path.to_string_lossy().as_ref(), false).is_err(), - "the drop grant is consumed after one open" - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn write_handles_are_dialog_only() { - let path = temp_file("no-write.bin", b"keep"); - let state = PluginFileState::new(); - state.register_dropped_paths("main", vec![path.to_string_lossy().into_owned()]); - let error = open_dropped_plugin_file(&state, "main", OWNER, path.to_string_lossy().as_ref(), true).unwrap_err(); - assert!(error.contains("save dialog"), "{error}"); - assert_eq!(std::fs::read(&path).unwrap(), b"keep", "a path string alone must not truncate a file"); - 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); - } - - #[test] - fn handle_id_follows_the_uuid_string_wire_convention() { - // Handle ids ride the bridge as opaque strings, same as downloadId. - // The old wire format serialized a u64 JSON number: ids above - // Number.MAX_SAFE_INTEGER were rounded by every JS layer, so the host - // looked a fresh handle up under a different key and failed every - // read/write with "unknown plugin file handle". - let path = temp_file("wire-uuid.bin", b"payload"); - let state = PluginFileState::new(); - let handle = open_plugin_file(&state, OWNER, path.to_string_lossy().as_ref(), false).expect("open"); - let wire_json = serde_json::to_string(&handle.handle_id).expect("serialize id"); - assert_eq!(wire_json, format!("\"{}\"", handle.handle_id), "the wire id is a JSON string, never a number"); - let parsed = uuid::Uuid::parse_str(&handle.handle_id).expect("id parses as uuid, like downloadId"); - assert_eq!(parsed.to_string(), handle.handle_id); - - // The exact string the host sent back must hit the same registry key. - let chunk = read_plugin_file_chunk(&state, OWNER, &handle.handle_id, 0, None).expect("read via wire id"); - assert_eq!(BASE64.decode(chunk.data_base64).unwrap(), b"payload"); - close_plugin_file(&state, OWNER, &handle.handle_id).expect("close"); - let _ = std::fs::remove_file(path); - } -} diff --git a/src-tauri/src/commands/plugin_file/drop.rs b/src-tauri/src/commands/plugin_file/drop.rs new file mode 100644 index 0000000000..a86e81e67d --- /dev/null +++ b/src-tauri/src/commands/plugin_file/drop.rs @@ -0,0 +1,221 @@ +//! OS-drop consent and expansion: grant bookkeeping (via `PluginFileState`), +//! the bounded folder walk, and lazy registration of dropped files. + +use std::path::Path; + +use uuid::Uuid; + +use super::{ + file_identity, guess_content_type, DroppedFile, DroppedFilesResult, PluginFileHandle, PluginFileState, + MAX_DROPPED_FILES, MAX_DROP_FOLDER_DEPTH, MAX_DROP_FOLDER_FILES, +}; + +/// One regular file collected from a drop: absolute path, '/'-separated path +/// relative to the dropped root, size in bytes, and platform file identity. +type CollectedDropFile = (std::path::PathBuf, String, u64, Option<(u64, u64)>); + +/// The command-level gate for `plugin_file_open_dropped`: after the dialog flows moved +/// to Rust, the only renderer path into this registry is the OS drop flow, so +/// a path alone is never consent — the native drag-drop pipeline must have +/// delivered it to THIS webview. A granted folder expands to the regular +/// files it contains (bounded walk, symlinks never followed), so the plugin +/// receives entries for files even when the user dragged a whole folder. +/// Entries are metadata only — each file is opened just-in-time for its +/// first chunk read, so a huge folder burns no fds and no registry slots. +/// Write handles are dialog-only: a path string is not authorization to +/// create or truncate a file. +/// +/// Individual paths are skipped with a host-log warning instead of failing +/// the whole call: a mixed drop (folder + file, or one stale path) still +/// delivers everything that is legitimately openable. +pub(super) fn open_dropped_plugin_files( + state: &PluginFileState, + webview_label: &str, + plugin_id: &str, + paths: &[String], +) -> DroppedFilesResult { + let drop_id = Uuid::new_v4().to_string(); + if plugin_id.trim().is_empty() { + log::warn!("plugin_file_open_dropped requires a plugin id"); + return DroppedFilesResult { drop_id, files: Vec::new(), truncated: false }; + } + let mut handles = Vec::new(); + let mut truncated = false; + for path in paths { + if !state.consume_dropped_path(webview_label, path) { + // Indistinguishable from a vanished path to the caller; the host log is + // where a probe attempt can be told apart from a stale drop grant. + log::warn!("plugin_file_open_dropped rejected a path the drop pipeline did not grant to webview \"{webview_label}\" (plugin: {plugin_id})"); + continue; + } + let path = Path::new(path); + // The relative paths of a folder's files start with the folder's own + // name, so a mixed drop of two folders stays unambiguous: two roots + // with an identically named file produce distinct paths. + let root_name = + path.file_name().and_then(|value| value.to_str()).map(|name| name.to_string()).unwrap_or_default(); + // symlink_metadata first: what the OS delivered decides policy, not + // whatever the path happens to resolve to. + let contained: Vec = match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_symlink() => { + // A dropped alias must not widen the grant to an unintended + // subtree: a symlinked folder is never expanded, a symlinked + // file stays the single bounded entry the user aimed at. + match std::fs::metadata(path) { + Ok(target) if target.is_dir() => { + log::warn!( + "dropped folder alias {} is not expanded (symlinks are never followed)", + path.display() + ); + Vec::new() + } + Ok(target) => vec![(path.to_path_buf(), String::new(), target.len(), file_identity(&target))], + Err(error) => { + log::warn!("dropped path {} vanished before it could be opened: {error}", path.display()); + Vec::new() + } + } + } + Ok(metadata) if metadata.is_dir() => { + let mut files = Vec::new(); + let mut count_truncated = false; + let mut depth_truncated = false; + collect_folder_files(path, path, 0, &mut files, &mut count_truncated, &mut depth_truncated); + if count_truncated { + log::warn!("dropped folder {} holds more than {MAX_DROP_FOLDER_FILES} files; only the first {MAX_DROP_FOLDER_FILES} were handed to plugin \"{plugin_id}\"", path.display()); + } + if depth_truncated { + log::warn!("dropped folder {} has content deeper than {MAX_DROP_FOLDER_DEPTH} levels; deeper files were not handed to plugin \"{plugin_id}\"", path.display()); + } + if count_truncated || depth_truncated { + truncated = true; + } + if files.is_empty() { + log::warn!( + "dropped folder {} contains no regular files for plugin \"{plugin_id}\"", + path.display() + ); + } + // Re-anchor the walk's root-relative paths under the dropped + // folder's own name (see root_name above). + files + .into_iter() + .map(|(file_path, relative, size, identity)| { + let prefixed = + if relative.is_empty() { root_name.clone() } else { format!("{root_name}/{relative}") }; + (file_path, prefixed, size, identity) + }) + .collect() + } + Ok(metadata) => vec![(path.to_path_buf(), String::new(), metadata.len(), file_identity(&metadata))], + Err(error) => { + log::warn!("dropped path {} vanished before it could be opened: {error}", path.display()); + Vec::new() + } + }; + for (file_path, relative_path, size, identity) in contained { + match register_dropped_file(state, plugin_id, &file_path, &relative_path, size, identity) { + Ok(handle) => handles.push(handle), + Err(error) => log::warn!("plugin drop: cannot register {}: {error}", file_path.display()), + } + } + } + DroppedFilesResult { drop_id, files: handles, truncated } +} + +/// Registers one drop-granted file as a lazily-opened metadata entry and +/// returns its wire handle. No fd is taken here; the size comes from the +/// expansion walk (or the single-path stat), so no second stat is needed. +pub(super) fn register_dropped_file( + state: &PluginFileState, + plugin_id: &str, + path: &Path, + relative_path: &str, + size: u64, + identity: Option<(u64, u64)>, +) -> Result { + let name = path.file_name().and_then(|value| value.to_str()).unwrap_or("download.bin").to_string(); + let handle_id = Uuid::new_v4().to_string(); + let content_type = guess_content_type(path); + let entry = DroppedFile { path: path.to_path_buf(), owner: plugin_id.to_string(), identity }; + let handle = PluginFileHandle { + handle_id: handle_id.clone(), + name, + size, + content_type, + write: false, + relative_path: relative_path.to_string(), + }; + let mut dropped = state.dropped_files.lock().map_err(|_| "plugin file registry poisoned".to_string())?; + if dropped.len() >= MAX_DROPPED_FILES { + return Err(format!("too many dropped file entries (max {MAX_DROPPED_FILES})")); + } + dropped.insert(handle_id, entry); + Ok(handle) +} + +/// Bounded expansion of a dropped folder to the regular files inside it. +/// Symlinks are never followed, so a dropped folder cannot pull in paths +/// outside itself, and cycles cannot hang the walk. Dotfiles and +/// dot-directories (.git, .DS_Store, ...) are host/SCM bookkeeping, not user +/// payload, and are skipped — a code repo must not burn the expansion cap on +/// .git/objects. Output is sorted so the plugin sees a stable order for the +/// same folder contents; each entry carries its path relative to the folder +/// root, its size, and its file identity. The flags report WHY collection +/// stopped: more files than the cap, or content below the depth floor — both +/// are surfaced to the host log so partial delivery is never silent. +fn collect_folder_files( + root: &Path, + dir: &Path, + depth: usize, + out: &mut Vec, + count_truncated: &mut bool, + depth_truncated: &mut bool, +) { + if out.len() >= MAX_DROP_FOLDER_FILES { + *count_truncated = true; + return; + } + if depth >= MAX_DROP_FOLDER_DEPTH { + *depth_truncated = true; + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + let mut children: Vec = entries.flatten().map(|entry| entry.path()).collect(); + children.sort(); + for child in children { + if out.len() >= MAX_DROP_FOLDER_FILES { + *count_truncated = true; + return; + } + if child.file_name().and_then(|name| name.to_str()).map(|name| name.starts_with('.')).unwrap_or(false) { + continue; + } + let Ok(metadata) = std::fs::symlink_metadata(&child) else { continue }; + if metadata.is_symlink() { + continue; + } + // '/'-separated relative path regardless of platform separator, so a + // plugin can rebuild the dragged structure portably. + let relative = child + .strip_prefix(root) + .map(|relative| { + relative + .components() + .map(|component| component.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/") + }) + .unwrap_or_default(); + if metadata.is_dir() { + collect_folder_files(root, &child, depth + 1, out, count_truncated, depth_truncated); + if *count_truncated { + return; + } + } else if metadata.is_file() { + out.push((child, relative, metadata.len(), file_identity(&metadata))); + } + } +} diff --git a/src-tauri/src/commands/plugin_file/mod.rs b/src-tauri/src/commands/plugin_file/mod.rs new file mode 100644 index 0000000000..e9180fb1ad --- /dev/null +++ b/src-tauri/src/commands/plugin_file/mod.rs @@ -0,0 +1,973 @@ +//! 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. Handles are opened through this registry, and every path +//! enters it through a Rust-anchored consent channel — never a path string the +//! renderer merely claims: +//! - `plugin_file_pick_files` / `plugin_file_save_as` open the native dialog +//! on the Rust side and return handles for exactly what the user picked; +//! - OS drops are captured by the native drag-drop pipeline +//! ([`tauri::Builder::on_window_event`]) and registered per webview, and +//! `plugin_file_open_dropped` only accepts paths that this webview actually +//! received, consuming the grant on use; a dropped folder expands to the +//! regular files it contains, so the plugin gets files, not the folder. +//! Drop entries are lazily opened: the grant registers metadata only, and +//! the fd exists just for the duration of each chunk read, so a folder of +//! any practical size cannot exhaust the handle registry. +//! +//! 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; +use std::path::Path; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use tauri::{Manager, State}; +use tauri_plugin_dialog::DialogExt; +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; + +/// A dropped folder expands to at most this many contained files. Entries are +/// metadata-only (no fd, no registry slot), so the cap only bounds the +/// synchronous expansion walk and the filedrop message size, not handles. +const MAX_DROP_FOLDER_FILES: usize = 2000; +/// Recursion guard for the folder expansion walk; deeper trees are ignored. +const MAX_DROP_FOLDER_DEPTH: usize = 8; +/// Total lazy drop entries alive at once, across plugins: metadata is cheap +/// but not free, and an unbounded session of large folder drops should fail +/// loudly at the door instead of growing forever. +const MAX_DROPPED_FILES: usize = 10_000; +/// A drop grant expires. Consent is anchored to the drop event: a path from a +/// drag that happened minutes ago must not open in a renderer that never saw +/// it. Generous enough for the immediate open flow, including slow +/// network-volume expansion. +const GRANT_TTL: Duration = Duration::from_secs(60); + +struct OpenFile { + file: File, + write: bool, + size: u64, + owner: String, +} + +/// A drop-granted file held as metadata only: opened just-in-time for each +/// chunk read, so it never occupies a slot in the 64-handle registry. +#[derive(Clone)] +struct DroppedFile { + path: std::path::PathBuf, + owner: String, + /// Platform file identity (dev, ino) captured at registration. Reads + /// fstat the opened file and refuse on mismatch, so a path that was + /// swapped out after the drop cannot be read through the grant. `None` + /// where the platform has no stable id. + identity: Option<(u64, u64)>, +} + +/// One outstanding drop grant: the OS delivered this path to the webview, and +/// the renderer may claim exactly one open attempt for it within the TTL. +struct DroppedGrant { + path: String, + granted_at: Instant, +} + +/// Identity of the file a grant points at, from resolved (follows symlinks) +/// metadata — the same resolution a later read performs. +#[cfg(unix)] +fn file_identity(metadata: &std::fs::Metadata) -> Option<(u64, u64)> { + use std::os::unix::fs::MetadataExt; + Some((metadata.dev(), metadata.ino())) +} + +#[cfg(not(unix))] +fn file_identity(_metadata: &std::fs::Metadata) -> Option<(u64, u64)> { + None +} + +#[derive(Default)] +pub struct PluginFileState { + handles: Mutex>, + /// Drop-granted files waiting to be read. Metadata only: reads open the + /// path on demand and never hold a persistent fd. + dropped_files: Mutex>, + /// Local paths the native drag-drop pipeline delivered to each webview, + /// waiting to be claimed by `plugin_file_open_dropped`. Registered only by + /// the window-event hook (one grant per dropped path — file or folder), + /// consumed on open; a claimed folder expands to its contained files. + /// Grants expire (see `GRANT_TTL`): a drop authorizes the immediate open + /// flow, not a renderer claim at any future time. + dropped_paths: Mutex>>, +} + +impl PluginFileState { + pub fn new() -> Self { + Self::default() + } + + /// Records the files the native drop pipeline just delivered to a webview. + /// Expired grants for the same webview are retired on the way in. + pub fn register_dropped_paths(&self, webview_label: &str, paths: Vec) { + if paths.is_empty() { + return; + } + let now = Instant::now(); + if let Ok(mut dropped) = self.dropped_paths.lock() { + let entries = dropped.entry(webview_label.to_string()).or_default(); + entries.retain(|grant| now.duration_since(grant.granted_at) <= GRANT_TTL); + entries.extend(paths.into_iter().map(|path| DroppedGrant { path, granted_at: now })); + } + } + + /// Claims a dropped path for opening: one dropped path authorizes exactly + /// one open attempt, in the webview that received it, within the grant + /// TTL. A folder grant expands to several handles but is still one + /// attempt. + fn consume_dropped_path(&self, webview_label: &str, path: &str) -> bool { + let Ok(mut dropped) = self.dropped_paths.lock() else { + return false; + }; + let Some(entries) = dropped.get_mut(webview_label) else { + return false; + }; + let now = Instant::now(); + if let Some(position) = + entries.iter().position(|grant| grant.path == path && now.duration_since(grant.granted_at) <= GRANT_TTL) + { + entries.remove(position); + if entries.is_empty() { + dropped.remove(webview_label); + } + true + } else { + // Opportunistically retire whatever expired here, so stale grants + // never outlive the page that earned them. + entries.retain(|grant| now.duration_since(grant.granted_at) <= GRANT_TTL); + if entries.is_empty() { + dropped.remove(webview_label); + } + false + } + } + + /// Drops every outstanding grant for a webview. Called when the window is + /// destroyed: a page that no longer exists must not leave authorization + /// behind for a future renderer to claim. + pub fn clear_dropped_paths(&self, webview_label: &str) { + if let Ok(mut dropped) = self.dropped_paths.lock() { + dropped.remove(webview_label); + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginFileHandle { + /// Opaque handle id, carried as a UUID string on the wire — the same + /// convention as `downloadId`. It must never be a JSON number: ids are + /// parsed as doubles in every JS layer, and a u64 loses precision above + /// `Number.MAX_SAFE_INTEGER`, which silently corrupted ids and failed + /// every bridge read/write with "unknown plugin file handle". + handle_id: String, + name: String, + size: u64, + content_type: String, + write: bool, + /// Only for files expanded out of a dropped folder: path relative to the + /// dropped folder root, '/'-separated. Empty otherwise (dialog picks, + /// top-level dropped files), and omitted from the JSON to keep the + /// dialog-era payload shape unchanged. + #[serde(default, skip_serializing_if = "String::is_empty")] + relative_path: String, +} + +#[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, +} + +/// The wire result of one OS drop: which files were handed over, whether the +/// delivery was cut short by an expansion cap, and an id that groups the +/// entries of this drop together. `truncated` makes partial delivery a +/// protocol-level fact the plugin can surface, not a host-log footnote; +/// `dropId` gives cancellation and progress bookkeeping a grouping key. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DroppedFilesResult { + drop_id: String, + files: Vec, + #[serde(default)] + truncated: bool, +} + +/// 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() +} + +mod drop; +mod registry; + +use drop::open_dropped_plugin_files; +pub use registry::close_all_plugin_files; +use registry::{ + close_plugin_file, open_plugin_file, open_read_handle_or_lazy, read_plugin_file_chunk, write_plugin_file_chunk, +}; + +#[tauri::command] +pub async fn plugin_file_open_dropped( + app: tauri::AppHandle, + webview: tauri::Webview, + plugin_id: String, + paths: Vec, +) -> DroppedFilesResult { + // Folder expansion reads up to thousands of directory entries — latency + // that reaches seconds on network volumes — so it must leave the main + // thread instead of freezing the whole window mid-drop. + let webview_label = webview.label().to_string(); + let expanded = tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + open_dropped_plugin_files(state.inner(), &webview_label, &plugin_id, &paths) + }) + .await; + match expanded { + Ok(result) => result, + Err(error) => { + log::error!("plugin_file_open_dropped expansion task failed: {error}"); + DroppedFilesResult { drop_id: Uuid::new_v4().to_string(), files: Vec::new(), truncated: false } + } + } +} + +/// Opens the native open dialog on the Rust side and returns read handles for +/// exactly the files the user picked. The renderer never sees the paths and +/// cannot pass its own: user consent happens inside this command. +#[tauri::command] +pub async fn plugin_file_pick_files( + app: tauri::AppHandle, + state: State<'_, PluginFileState>, + plugin_id: String, + multiple: Option, +) -> Result, String> { + if plugin_id.trim().is_empty() { + return Err("plugin id is required".to_string()); + } + let picked = if multiple == Some(true) { + let (sender, receiver) = tokio::sync::oneshot::channel(); + app.dialog().file().pick_files(move |paths| { + let _ = sender.send(paths); + }); + receiver.await.map_err(|error| error.to_string())? + } else { + let (sender, receiver) = tokio::sync::oneshot::channel(); + app.dialog().file().pick_file(move |path| { + let _ = sender.send(path); + }); + receiver.await.map_err(|error| error.to_string())?.map(|path| vec![path]) + }; + let mut handles = Vec::new(); + for path in picked.into_iter().flatten() { + let Ok(path) = path.into_path() else { continue }; + match open_read_handle_or_lazy(&state, &plugin_id, &path) { + Ok(handle) => handles.push(handle), + Err(error) => log::warn!("plugin file pick: cannot open {}: {error}", path.display()), + } + } + Ok(handles) +} + +/// Opens the native save dialog on the Rust side and returns a write handle +/// for the path the user confirmed. The renderer only controls the suggested +/// file name: the dialog itself is the consent, and the path never round-trips +/// through renderer-controlled arguments. +#[tauri::command] +pub async fn plugin_file_save_as( + app: tauri::AppHandle, + state: State<'_, PluginFileState>, + plugin_id: String, + default_file_name: Option, +) -> Result, String> { + if plugin_id.trim().is_empty() { + return Err("plugin id is required".to_string()); + } + // The name comes from the plugin; keep it a bare file name. + let file_name = default_file_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| name.rsplit(['/', '\\']).next().unwrap_or("download.bin").to_string()); + let mut dialog = app.dialog().file(); + if let Some(name) = &file_name { + dialog = dialog.set_file_name(name); + } + let extension = file_name + .as_deref() + .and_then(|name| Path::new(name).extension()) + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()); + if let Some(extension) = &extension { + dialog = dialog.add_filter(extension.to_ascii_uppercase(), &[extension.as_str()]); + } + let (sender, receiver) = tokio::sync::oneshot::channel(); + dialog.save_file(move |path| { + let _ = sender.send(path); + }); + let chosen = receiver.await.map_err(|error| error.to_string())?; + let Some(chosen) = chosen else { return Ok(None) }; + let path = chosen.into_path().map_err(|error| error.to_string())?; + open_plugin_file(&state, &plugin_id, &path.to_string_lossy(), true).map(Some) +} + +// Chunk IO (open+seek+read for lazy entries, 8 MiB reads, fsync on close) +// is disk latency, not bookkeeping — on network volumes a single chunk can +// take hundreds of milliseconds. These commands are async and run their body +// on the blocking pool so a streaming transfer never freezes the UI thread. +#[tauri::command] +pub async fn plugin_file_read( + app: tauri::AppHandle, + plugin_id: String, + handle_id: String, + offset: u64, + length: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + read_plugin_file_chunk(state.inner(), &plugin_id, &handle_id, offset, length) + }) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub async fn plugin_file_write( + app: tauri::AppHandle, + plugin_id: String, + handle_id: String, + offset: u64, + data_base64: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + write_plugin_file_chunk(state.inner(), &plugin_id, &handle_id, offset, &data_base64) + }) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub async fn plugin_file_close(app: tauri::AppHandle, plugin_id: String, handle_id: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + close_plugin_file(state.inner(), &plugin_id, &handle_id) + }) + .await + .map_err(|error| error.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; + + 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, "00000000-0000-4000-8000-000000000099", 0, None).is_err()); + assert!(close_plugin_file(&state, OWNER, "00000000-0000-4000-8000-000000000099").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 dropped_paths_gate_the_open_command() { + let path = temp_file("dropped.bin", b"dropped"); + let state = PluginFileState::new(); + // A path the renderer merely claims is rejected. + let handles = open_dropped_plugin_files(&state, "main", OWNER, &[path.to_string_lossy().into_owned()]).files; + assert!(handles.is_empty(), "renderer-claimed paths get no handles"); + // A path granted to a DIFFERENT webview is rejected for this one. + state.register_dropped_paths("other-window", vec![path.to_string_lossy().into_owned()]); + assert!(open_dropped_plugin_files(&state, "main", OWNER, &[path.to_string_lossy().into_owned()]) + .files + .is_empty()); + // The granting webview can open exactly once. + state.register_dropped_paths("main", vec![path.to_string_lossy().into_owned()]); + let handles = open_dropped_plugin_files(&state, "main", OWNER, &[path.to_string_lossy().into_owned()]).files; + assert_eq!(handles.len(), 1); + assert_eq!(handles[0].relative_path, "", "a top-level dropped file carries no folder-relative path"); + let chunk = read_plugin_file_chunk(&state, OWNER, &handles[0].handle_id, 0, None).expect("read"); + assert_eq!(BASE64.decode(chunk.data_base64).unwrap(), b"dropped"); + close_plugin_file(&state, OWNER, &handles[0].handle_id).expect("close"); + assert!( + open_dropped_plugin_files(&state, "main", OWNER, &[path.to_string_lossy().into_owned()]).files.is_empty(), + "the drop grant is consumed after one open" + ); + let _ = std::fs::remove_file(path); + } + + #[test] + fn dropped_folder_expands_to_its_files() { + let root = std::env::temp_dir().join(format!("dbx-plugin-folder-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("nested/deeper")).expect("mkdir"); + std::fs::write(root.join("b.txt"), b"second").expect("write"); + std::fs::write(root.join("a.txt"), b"first").expect("write"); + std::fs::write(root.join("nested/c.txt"), b"third").expect("write"); + std::fs::write(root.join("nested/deeper/d.txt"), b"fourth").expect("write"); + // A symlink inside the dropped folder must not widen the grant: the + // walk skips it, so nothing outside the folder is ever handed over. + #[cfg(unix)] + std::os::unix::fs::symlink("/etc/hosts", root.join("link.txt")).expect("symlink"); + + let state = PluginFileState::new(); + state.register_dropped_paths("main", vec![root.to_string_lossy().into_owned()]); + let result = open_dropped_plugin_files(&state, "main", OWNER, &[root.to_string_lossy().into_owned()]); + let handles = &result.files; + assert!(!result.truncated, "a small folder is delivered in full"); + let names: Vec<&str> = handles.iter().map(|handle| handle.name.as_str()).collect(); + assert_eq!(names, vec!["a.txt", "b.txt", "c.txt", "d.txt"], "sorted walk, symlinks skipped"); + // Nested entries carry their structure under the dropped folder's own + // name, so plugins can rebuild the dragged tree and two identically + // named files under different dropped roots stay distinguishable. + let root_name = root.file_name().and_then(|value| value.to_str()).unwrap().to_string(); + let relatives: Vec = handles.iter().map(|handle| handle.relative_path.clone()).collect(); + assert_eq!( + relatives, + vec![ + format!("{root_name}/a.txt"), + format!("{root_name}/b.txt"), + format!("{root_name}/nested/c.txt"), + format!("{root_name}/nested/deeper/d.txt") + ] + ); + let chunk = read_plugin_file_chunk(&state, OWNER, &handles[0].handle_id, 0, None).expect("read"); + assert_eq!(BASE64.decode(chunk.data_base64).unwrap(), b"first"); + for handle in handles { + close_plugin_file(&state, OWNER, &handle.handle_id).expect("close"); + } + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn dropped_entries_stream_reads_without_holding_handles() { + let root = std::env::temp_dir().join(format!("dbx-plugin-lazy-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("mkdir"); + // More files than the eager handle registry could ever hold: laziness + // is what makes a folder of this size deliverable at all. + let file_count = MAX_OPEN_HANDLES + 20; + for index in 0..file_count { + std::fs::write(root.join(format!("part{index:03}.bin")), format!("payload-{index}")).expect("write"); + } + let state = PluginFileState::new(); + state.register_dropped_paths("main", vec![root.to_string_lossy().into_owned()]); + let handles = open_dropped_plugin_files(&state, "main", OWNER, &[root.to_string_lossy().into_owned()]).files; + assert_eq!(handles.len(), file_count, "metadata-only expansion is not bounded by the handle registry"); + assert_eq!(state.handles.lock().unwrap().len(), 0, "no fd is taken until a read happens"); + + // Streaming: chunks read across separate calls land exactly where the + // offsets say, with eof only on the tail. + let target = handles.iter().find(|handle| handle.name == "part042.bin").expect("entry"); + let payload = "payload-42".to_string(); + let head = read_plugin_file_chunk(&state, OWNER, &target.handle_id, 0, Some(4)).expect("read head"); + assert_eq!(BASE64.decode(head.data_base64).unwrap(), b"payl"); + assert!(!head.eof); + let tail = read_plugin_file_chunk(&state, OWNER, &target.handle_id, 4, Some(64)).expect("read tail"); + assert_eq!(BASE64.decode(tail.data_base64).unwrap(), &payload.as_bytes()[4..]); + assert!(tail.eof, "a lazily-opened file reports eof at its true end"); + assert_eq!(state.handles.lock().unwrap().len(), 0, "the fd is released between reads"); + + close_plugin_file(&state, OWNER, &target.handle_id).expect("close"); + assert!( + read_plugin_file_chunk(&state, OWNER, &target.handle_id, 0, None).is_err(), + "closed entry rejects reads" + ); + for handle in &handles { + let _ = close_plugin_file(&state, OWNER, &handle.handle_id); + } + assert_eq!(state.dropped_files.lock().unwrap().len(), 0, "close retires every metadata entry"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn dropped_entry_reads_and_closes_reject_other_plugins() { + let path = temp_file("lazy-owned.bin", b"secret"); + let state = PluginFileState::new(); + state.register_dropped_paths("main", vec![path.to_string_lossy().into_owned()]); + let handle = open_dropped_plugin_files(&state, "main", OWNER, &[path.to_string_lossy().into_owned()]).files; + assert_eq!(handle.len(), 1); + let handle_id = handle[0].handle_id.clone(); + + let foreign_read = read_plugin_file_chunk(&state, OTHER, &handle_id, 0, None).unwrap_err(); + assert_eq!(foreign_read, "unknown plugin file handle"); + let foreign_close = close_plugin_file(&state, OTHER, &handle_id).unwrap_err(); + assert_eq!(foreign_close, "unknown plugin file handle"); + assert!( + read_plugin_file_chunk(&state, OWNER, &handle_id, 0, None).is_ok(), + "the entry survives foreign probes" + ); + + close_plugin_file(&state, OWNER, &handle_id).expect("close"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn dropped_folder_expansion_is_capped() { + let root = std::env::temp_dir().join(format!("dbx-plugin-folder-cap-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("mkdir"); + for index in 0..(MAX_DROP_FOLDER_FILES + 8) { + std::fs::write(root.join(format!("f{index:03}.txt")), b"x").expect("write"); + } + let state = PluginFileState::new(); + state.register_dropped_paths("main", vec![root.to_string_lossy().into_owned()]); + let result = open_dropped_plugin_files(&state, "main", OWNER, &[root.to_string_lossy().into_owned()]); + assert_eq!(result.files.len(), MAX_DROP_FOLDER_FILES, "expansion stops at the cap"); + assert!(result.truncated, "partial delivery is visible on the wire, not just the host log"); + for handle in &result.files { + close_plugin_file(&state, OWNER, &handle.handle_id).expect("close"); + } + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn dropped_folder_alias_is_not_expanded_but_a_file_alias_reads() { + let root = std::env::temp_dir().join(format!("dbx-plugin-alias-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("real")).expect("mkdir"); + std::fs::write(root.join("real/inside.txt"), b"inside").expect("write"); + let outside_dir = std::env::temp_dir().join(format!("dbx-plugin-alias-target-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&outside_dir); + std::fs::create_dir_all(&outside_dir).expect("mkdir"); + std::fs::write(outside_dir.join("secret.txt"), b"secret").expect("write"); + #[cfg(unix)] + std::os::unix::fs::symlink(&outside_dir, root.join("alias")).expect("symlink"); + + let state = PluginFileState::new(); + state.register_dropped_paths("main", vec![root.to_string_lossy().into_owned()]); + let handles = open_dropped_plugin_files(&state, "main", OWNER, &[root.to_string_lossy().into_owned()]).files; + // The folder-internal alias is skipped by the walk; nothing outside + // the dropped folder is ever granted, even nested one level down. + let names: Vec<&str> = handles.iter().map(|handle| handle.name.as_str()).collect(); + assert_eq!(names, vec!["inside.txt"], "internal alias never widens the grant"); + for handle in &handles { + close_plugin_file(&state, OWNER, &handle.handle_id).expect("close"); + } + + // A top-level alias to a folder is not expanded either. + let mut top_grant = vec![outside_dir.to_string_lossy().into_owned()]; + #[cfg(unix)] + { + let alias_path = root.join("alias").to_string_lossy().into_owned(); + state.register_dropped_paths("main", vec![alias_path.clone()]); + top_grant.push(alias_path); + } + let handles = open_dropped_plugin_files(&state, "main", OWNER, &top_grant).files; + assert!( + !handles.iter().any(|handle| handle.name == "secret.txt"), + "a dropped folder alias must not grant its target tree" + ); + for handle in &handles { + close_plugin_file(&state, OWNER, &handle.handle_id).expect("close"); + } + + // A top-level alias to a FILE stays one bounded entry (unix only: it + // is the platform where the alias scenario is testable). + #[cfg(unix)] + { + let file_alias = root.join("file-alias.txt"); + std::os::unix::fs::symlink(root.join("real/inside.txt"), &file_alias).expect("symlink"); + state.register_dropped_paths("main", vec![file_alias.to_string_lossy().into_owned()]); + let handles = + open_dropped_plugin_files(&state, "main", OWNER, &[file_alias.to_string_lossy().into_owned()]).files; + assert_eq!(handles.len(), 1, "a dropped file alias is one entry"); + let chunk = + read_plugin_file_chunk(&state, OWNER, &handles[0].handle_id, 0, None).expect("read through alias"); + assert_eq!(BASE64.decode(chunk.data_base64).unwrap(), b"inside"); + close_plugin_file(&state, OWNER, &handles[0].handle_id).expect("close"); + } + + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&outside_dir); + } + + #[test] + fn dropped_folder_depth_truncation_stops_the_walk() { + let root = std::env::temp_dir().join(format!("dbx-plugin-deep-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let mut level = root.clone(); + for index in 0..(MAX_DROP_FOLDER_DEPTH + 3) { + level = level.join(format!("d{index}")); + std::fs::create_dir_all(&level).expect("mkdir"); + std::fs::write(level.join("f.txt"), b"x").expect("write"); + } + let state = PluginFileState::new(); + state.register_dropped_paths("main", vec![root.to_string_lossy().into_owned()]); + let result = open_dropped_plugin_files(&state, "main", OWNER, &[root.to_string_lossy().into_owned()]); + // The walk enters directories at depths 1..=MAX_DROP_FOLDER_DEPTH, so + // MAX-1 nested files are collected and everything below is cut off — + // with the cutoff visible on the wire. + assert_eq!(result.files.len(), MAX_DROP_FOLDER_DEPTH - 1, "the walk stops at the depth floor"); + assert!(result.truncated, "depth truncation is visible on the wire too"); + for handle in &result.files { + close_plugin_file(&state, OWNER, &handle.handle_id).expect("close"); + } + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn close_all_sweeps_handles_and_drop_entries_by_owner() { + let eager = temp_file("sweep-eager.bin", b"eager"); + let dropped = temp_file("sweep-dropped.bin", b"dropped"); + let state = PluginFileState::new(); + let eager_handle = + open_plugin_file(&state, OWNER, eager.to_string_lossy().as_ref(), false).expect("open eager"); + state.register_dropped_paths("main", vec![dropped.to_string_lossy().into_owned()]); + let drop_handle = + open_dropped_plugin_files(&state, "main", OWNER, &[dropped.to_string_lossy().into_owned()]).files; + assert_eq!(drop_handle.len(), 1); + + // Another plugin's entries must survive the sweep. + let other_file = temp_file("sweep-other.bin", b"other"); + let other_handle = + open_plugin_file(&state, OTHER, other_file.to_string_lossy().as_ref(), false).expect("open other"); + + let closed = close_all_plugin_files(&state, OWNER); + assert_eq!(closed, 2, "one eager handle plus one drop entry"); + assert!(read_plugin_file_chunk(&state, OWNER, &eager_handle.handle_id, 0, None).is_err()); + assert!(read_plugin_file_chunk(&state, OWNER, &drop_handle[0].handle_id, 0, None).is_err()); + assert!( + read_plugin_file_chunk(&state, OTHER, &other_handle.handle_id, 0, None).is_ok(), + "other plugins are untouched" + ); + + assert_eq!(close_all_plugin_files(&state, OWNER), 0, "a second sweep finds nothing"); + close_plugin_file(&state, OTHER, &other_handle.handle_id).expect("close other"); + for path in [eager, dropped, other_file] { + let _ = std::fs::remove_file(path); + } + } + + #[test] + fn a_mixed_drop_delivers_every_openable_path() { + let folder = std::env::temp_dir().join(format!("dbx-plugin-mixed-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&folder); + std::fs::create_dir_all(&folder).expect("mkdir"); + std::fs::write(folder.join("in.txt"), b"in").expect("write"); + let file = temp_file("mixed.bin", b"single"); + let stale = std::env::temp_dir().join(format!("dbx-plugin-gone-{}.bin", std::process::id())); + + let state = PluginFileState::new(); + state.register_dropped_paths( + "main", + vec![ + file.to_string_lossy().into_owned(), + folder.to_string_lossy().into_owned(), + stale.to_string_lossy().into_owned(), + ], + ); + let handles = open_dropped_plugin_files( + &state, + "main", + OWNER, + &[ + file.to_string_lossy().into_owned(), + folder.to_string_lossy().into_owned(), + stale.to_string_lossy().into_owned(), + ], + ) + .files; + let names: Vec<&str> = handles.iter().map(|handle| handle.name.as_str()).collect(); + assert!( + names.len() == 2 && names[0].ends_with("mixed.bin") && names[1] == "in.txt", + "the stale path is skipped, the rest delivered: {names:?}" + ); + for handle in &handles { + close_plugin_file(&state, OWNER, &handle.handle_id).expect("close"); + } + let _ = std::fs::remove_file(file); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn pick_falls_back_to_lazy_entries_when_the_handle_registry_is_full() { + let state = PluginFileState::new(); + // Fill the shared eager registry to its cap with another plugin's + // handles: a big pick in this state must still deliver every file. + for index in 0..MAX_OPEN_HANDLES { + let filler = temp_file(&format!("filler{index}.bin"), b"f"); + let handle = + open_plugin_file(&state, OTHER, filler.to_string_lossy().as_ref(), false).expect("fill registry"); + drop(handle); + let _ = std::fs::remove_file(filler); + } + let picked = temp_file("over-quota.bin", b"picked"); + let handle = open_read_handle_or_lazy(&state, OWNER, &picked).expect("deliver despite full registry"); + assert_eq!(state.handles.lock().unwrap().len(), MAX_OPEN_HANDLES, "no eager slot was taken"); + let chunk = read_plugin_file_chunk(&state, OWNER, &handle.handle_id, 0, None).expect("read the lazy entry"); + assert_eq!(BASE64.decode(chunk.data_base64).unwrap(), b"picked"); + assert_eq!(handle.relative_path, "", "dialog picks carry no folder-relative path"); + close_plugin_file(&state, OWNER, &handle.handle_id).expect("close"); + let _ = std::fs::remove_file(picked); + } + + #[test] + fn cleared_grants_cannot_be_claimed_afterwards() { + let path = temp_file("grant-clear.bin", b"x"); + let state = PluginFileState::new(); + state.register_dropped_paths("main", vec![path.to_string_lossy().into_owned()]); + // Window destruction retires the webview's outstanding grants: a + // later renderer must not be able to claim a pre-reload drop. + state.clear_dropped_paths("main"); + let result = open_dropped_plugin_files(&state, "main", OWNER, &[path.to_string_lossy().into_owned()]); + assert!(result.files.is_empty(), "retired grants mint no handles"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn expired_grants_cannot_be_claimed() { + let path = temp_file("grant-ttl.bin", b"x"); + let state = PluginFileState::new(); + state.register_dropped_paths("main", vec![path.to_string_lossy().into_owned()]); + // Backdate the grant past the TTL, as if the drag happened a minute ago. + { + let mut dropped = state.dropped_paths.lock().unwrap(); + let entries = dropped.get_mut("main").expect("grant entries"); + for grant in entries.iter_mut() { + grant.granted_at = Instant::now() - (GRANT_TTL + Duration::from_secs(1)); + } + } + let result = open_dropped_plugin_files(&state, "main", OWNER, &[path.to_string_lossy().into_owned()]); + assert!(result.files.is_empty(), "an expired grant mints no handles"); + // The consume attempt also retired the expired entry outright. + assert!(state.dropped_paths.lock().unwrap().get("main").is_none()); + let _ = std::fs::remove_file(path); + } + + #[test] + fn a_swapped_out_file_is_refused_at_read_time() { + let path = temp_file("toctou.bin", b"original"); + let state = PluginFileState::new(); + state.register_dropped_paths("main", vec![path.to_string_lossy().into_owned()]); + let handles = open_dropped_plugin_files(&state, "main", OWNER, &[path.to_string_lossy().into_owned()]).files; + assert_eq!(handles.len(), 1); + + // Replace the file at the same path with a NEW file: the grant pinned + // the ORIGINAL file. The successor is created as a sibling while the + // original still exists and renamed over it, which pins a different + // inode — a delete-then-recreate can silently reuse the just-freed + // inode number and read as the same file. (An in-place rewrite keeps + // the inode and is correctly still readable.) + let swapped = path.with_extension("swapped"); + std::fs::write(&swapped, b"swapped").expect("write successor"); + std::fs::rename(&swapped, &path).expect("swap"); + let error = read_plugin_file_chunk(&state, OWNER, &handles[0].handle_id, 0, None).unwrap_err(); + #[cfg(unix)] + assert_eq!(error, "file changed since the drop was granted", "{error}"); + #[cfg(not(unix))] + let _ = error; + + close_plugin_file(&state, OWNER, &handles[0].handle_id).expect("close"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn dropped_folder_walk_skips_dotfiles() { + let root = std::env::temp_dir().join(format!("dbx-plugin-dotfiles-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join(".git/objects/ab")).expect("mkdir"); + std::fs::write(root.join(".git/objects/ab/cd"), b"git").expect("write"); + std::fs::write(root.join(".hidden"), b"dot").expect("write"); + std::fs::write(root.join("visible.txt"), b"seen").expect("write"); + + let state = PluginFileState::new(); + state.register_dropped_paths("main", vec![root.to_string_lossy().into_owned()]); + let handles = open_dropped_plugin_files(&state, "main", OWNER, &[root.to_string_lossy().into_owned()]).files; + let names: Vec<&str> = handles.iter().map(|handle| handle.name.as_str()).collect(); + assert_eq!(names, vec!["visible.txt"], "dotfiles and dot-directories are not user payload"); + for handle in &handles { + close_plugin_file(&state, OWNER, &handle.handle_id).expect("close"); + } + let _ = std::fs::remove_dir_all(&root); + } + + #[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); + } + + #[test] + fn handle_id_follows_the_uuid_string_wire_convention() { + // Handle ids ride the bridge as opaque strings, same as downloadId. + // The old wire format serialized a u64 JSON number: ids above + // Number.MAX_SAFE_INTEGER were rounded by every JS layer, so the host + // looked a fresh handle up under a different key and failed every + // read/write with "unknown plugin file handle". + let path = temp_file("wire-uuid.bin", b"payload"); + let state = PluginFileState::new(); + let handle = open_plugin_file(&state, OWNER, path.to_string_lossy().as_ref(), false).expect("open"); + let wire_json = serde_json::to_string(&handle.handle_id).expect("serialize id"); + assert_eq!(wire_json, format!("\"{}\"", handle.handle_id), "the wire id is a JSON string, never a number"); + let parsed = uuid::Uuid::parse_str(&handle.handle_id).expect("id parses as uuid, like downloadId"); + assert_eq!(parsed.to_string(), handle.handle_id); + + // The exact string the host sent back must hit the same registry key. + let chunk = read_plugin_file_chunk(&state, OWNER, &handle.handle_id, 0, None).expect("read via wire id"); + assert_eq!(BASE64.decode(chunk.data_base64).unwrap(), b"payload"); + close_plugin_file(&state, OWNER, &handle.handle_id).expect("close"); + let _ = std::fs::remove_file(path); + } +} diff --git a/src-tauri/src/commands/plugin_file/registry.rs b/src-tauri/src/commands/plugin_file/registry.rs new file mode 100644 index 0000000000..1bc1529d31 --- /dev/null +++ b/src-tauri/src/commands/plugin_file/registry.rs @@ -0,0 +1,289 @@ +//! Eager plugin-file handles: the shared 64-slot registry that backs native +//! dialog picks and save streams, plus the chunked read/write/close protocol +//! over both eager handles and lazily-registered drop entries. + +use std::collections::HashMap; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::Path; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use uuid::Uuid; + +use super::drop::register_dropped_file; +use super::{ + file_identity, guess_content_type, OpenFile, PluginFileHandle, PluginFileReadChunk, PluginFileState, + PluginFileWriteResult, MAX_CHUNK_BYTES, MAX_OPEN_HANDLES, SAVE_CHUNK_BYTES, +}; + +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: &str, +) -> 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 { + // Response stays indistinguishable from a missing handle: a plugin must + // not learn that another plugin's handle ids exist. The host log is the + // one place the two cases can be told apart when debugging. + log::warn!("plugin file handle belongs to another plugin (caller: {plugin_id})"); + 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(super) 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. Same wire + // convention as downloadId: uuid v4, carried as a string so no JS + // layer can lose precision on it. + let handle_id = Uuid::new_v4().to_string(); + handles.insert(handle_id.clone(), OpenFile { file, write, size, owner: plugin_id.to_string() }); + Ok(PluginFileHandle { + handle_id, + name, + size, + content_type: guess_content_type(path), + write, + relative_path: String::new(), + }) + }) +} + +pub(super) fn read_plugin_file_chunk( + state: &PluginFileState, + plugin_id: &str, + handle_id: &str, + 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()); + } + // Eager handles (dialog picks, save handles) live in the handle registry; + // drop entries live in the metadata registry and open just-in-time. + let eager = state.handles.lock().map_err(|_| "plugin file registry poisoned".to_string())?.contains_key(handle_id); + if eager { + return 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 }) + }); + } + read_dropped_file_chunk(state, plugin_id, handle_id, offset, requested) +} + +/// Streaming read of a lazily-registered drop entry: the fd exists only for +/// this chunk, so plugins can stream multi-gigabyte files while the registry +/// stays empty between reads. +fn read_dropped_file_chunk( + state: &PluginFileState, + plugin_id: &str, + handle_id: &str, + offset: u64, + requested: usize, +) -> Result { + let entry = { + let dropped = state.dropped_files.lock().map_err(|_| "plugin file registry poisoned".to_string())?; + let entry = dropped.get(handle_id).ok_or_else(|| "unknown plugin file handle".to_string())?; + if entry.owner != plugin_id { + // Same indistinguishable-from-missing convention as the eager registry. + log::warn!("plugin file entry belongs to another plugin (caller: {plugin_id})"); + return Err("unknown plugin file handle".to_string()); + } + entry.clone() + }; + let mut file = File::open(&entry.path).map_err(|error| format!("cannot open file: {error}"))?; + // fstat the OPEN fd (no path race): the file on disk must still be the + // one the drop granted, not a path that was swapped out afterwards. + let opened = file.metadata().map_err(|error| format!("cannot stat file: {error}"))?; + if let Some((dev, ino)) = entry.identity { + if file_identity(&opened) != Some((dev, ino)) { + log::warn!("plugin drop: file behind a granted entry was replaced since the drop (plugin: {plugin_id})"); + return Err("file changed since the drop was granted".to_string()); + } + } + // Size is taken at read time: the file may have changed since the drop, + // and bounds must match what is actually on disk. + let size = opened.len(); + if offset > size { + return Err("read offset beyond end of file".to_string()); + } + file.seek(SeekFrom::Start(offset)).map_err(|error| format!("seek failed: {error}"))?; + let remaining = (size - offset) as usize; + let mut buffer = vec![0u8; requested.min(remaining).clamp(1, MAX_CHUNK_BYTES)]; + let read = file.read(&mut buffer).map_err(|error| format!("read failed: {error}"))?; + buffer.truncate(read); + let eof = offset + read as u64 >= size; + Ok(PluginFileReadChunk { data_base64: BASE64.encode(&buffer), length: read, eof }) +} + +pub(super) fn write_plugin_file_chunk( + state: &PluginFileState, + plugin_id: &str, + handle_id: &str, + 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 }) + }) +} + +/// Host-side sweep: closes every eager handle and retires every drop entry +/// owned by one plugin. Workbench teardown and plugin stop/uninstall call this +/// so a crashed or sloppy renderer path cannot pin the shared 64-slot registry +/// (or accumulate drop metadata) until process exit. Returns how many entries +/// were reclaimed. +pub fn close_all_plugin_files(state: &PluginFileState, plugin_id: &str) -> usize { + let mut closed = 0; + if let Ok(mut handles) = state.handles.lock() { + let stale: Vec = + handles.iter().filter(|(_, entry)| entry.owner == plugin_id).map(|(id, _)| id.clone()).collect(); + for id in stale { + if let Some(entry) = handles.remove(&id) { + // Best-effort flush for write handles; the sweep runs at + // teardown, where a failed fsync has no one left to report to. + if entry.write { + let _ = entry.file.sync_all(); + } + closed += 1; + } + } + } + if let Ok(mut dropped) = state.dropped_files.lock() { + let before = dropped.len(); + dropped.retain(|_, entry| entry.owner != plugin_id); + closed += before - dropped.len(); + } + if closed > 0 { + log::info!("plugin file sweep reclaimed {closed} entries for plugin \"{plugin_id}\""); + } + closed +} +// No `plugin_file_close_all` command on purpose: an owner-wide sweep kills +// every workbench instance of that plugin, so it is reserved for Rust-side +// lifecycle paths (stop/uninstall). Renderer teardown closes its own handles +// precisely, by id. + +/// 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. Drop +/// entries have no fd: closing just retires the metadata entry. +pub(super) fn close_plugin_file(state: &PluginFileState, plugin_id: &str, handle_id: &str) -> Result<(), String> { + // Probe the registries by key, not by matching error strings: the string + // is the plugin-facing wire contract and must stay free to change without + // silently rerouting closes. + let has_eager = + state.handles.lock().map_err(|_| "plugin file registry poisoned".to_string())?.contains_key(handle_id); + if has_eager { + return 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()) + }); + } + let mut dropped = state.dropped_files.lock().map_err(|_| "plugin file registry poisoned".to_string())?; + match dropped.get(handle_id) { + Some(entry) if entry.owner == plugin_id => { + dropped.remove(handle_id); + Ok(()) + } + Some(_) => { + log::warn!("plugin file entry belongs to another plugin (caller: {plugin_id})"); + Err("unknown plugin file handle".to_string()) + } + None => Err("unknown plugin file handle".to_string()), + } +} + +/// Opens a picked file for reading: an eager handle while the shared registry +/// has room, otherwise a lazily-opened drop-style entry. Picking 100 photos +/// must deliver 100 handles — a full registry downgrades the mode, it never +/// silently drops files the user selected. +pub(super) fn open_read_handle_or_lazy( + state: &PluginFileState, + plugin_id: &str, + path: &Path, +) -> Result { + let registry_full = + state.handles.lock().map_err(|_| "plugin file registry poisoned".to_string())?.len() >= MAX_OPEN_HANDLES; + if registry_full { + let metadata = std::fs::metadata(path).map_err(|error| format!("cannot stat file: {error}"))?; + let size = metadata.len(); + return register_dropped_file(state, plugin_id, path, "", size, file_identity(&metadata)); + } + open_plugin_file(state, plugin_id, &path.to_string_lossy(), false) +} diff --git a/src-tauri/src/commands/plugins.rs b/src-tauri/src/commands/plugins.rs index b64a2e1cdc..f782478b9a 100644 --- a/src-tauri/src/commands/plugins.rs +++ b/src-tauri/src/commands/plugins.rs @@ -255,6 +255,7 @@ pub async fn rollback_plugin( #[tauri::command] pub async fn uninstall_plugin( state: State<'_, Arc>, + file_state: State<'_, crate::commands::plugin_file::PluginFileState>, plugin_id: String, ) -> Result, String> { let dependent_connections = state @@ -282,6 +283,11 @@ pub async fn uninstall_plugin( // Stops the runtime and uninstalls the store under one lifecycle update lease, so a plugin // call cannot re-activate the sidecar (and re-lock its container) in between. state.plugin_host.uninstall_plugin(&plugin_id).await?; + // Past every validation and the runtime teardown: retire the plugin's + // remaining file handles and drop entries — nothing else will close them. + // (Before this point an Err return must leave a still-installed plugin's + // workbenches fully functional.) + crate::commands::plugin_file::close_all_plugin_files(&file_state, &plugin_id); // A reinstall must ask for AI tool access and data grants again. if let Err(error) = state.storage.forget_plugin_permissions(&plugin_id).await { log::warn!("Failed to clear permissions of uninstalled plugin {plugin_id}: {error}"); @@ -304,7 +310,14 @@ pub async fn list_active_plugins(state: State<'_, Arc>) -> Result>, plugin_id: String) -> Result<(), String> { +pub async fn stop_plugin( + state: State<'_, Arc>, + file_state: State<'_, crate::commands::plugin_file::PluginFileState>, + plugin_id: String, +) -> Result<(), String> { + // The plugin's workbenches are going away with its runtime; without this + // sweep, handles it never closed would pin the shared registry quota. + crate::commands::plugin_file::close_all_plugin_files(&file_state, &plugin_id); state.plugin_host.stop(&plugin_id).await; Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c74470bd6a..13b4633fd7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1781,12 +1781,14 @@ pub fn run() { .on_window_event(|window, event| { // Anchor the plugin-file drop consent on the Rust side: paths are // registered for the webview that physically received the drop, - // and plugin_file_open consumes them there. Renderer-side drop - // events stay display-only; they cannot mint file access. + // and plugin_file_open_dropped consumes them there. Folders are + // granted as themselves and expand to their contained files at + // open time. Renderer-side drop events stay display-only; they + // cannot mint file access. if let tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event { let dropped: Vec = paths .iter() - .filter(|path| path.is_file()) + .filter(|path| path.is_file() || path.is_dir()) .map(|path| path.to_string_lossy().into_owned()) .collect(); if !dropped.is_empty() { @@ -1797,6 +1799,11 @@ pub fn run() { return; } if let tauri::WindowEvent::Destroyed = event { + // A webview that no longer exists must not leave drop grants + // behind for a future renderer to claim. + if let Some(state) = window.try_state::() { + state.clear_dropped_paths(window.label()); + } if let Some(tab_id) = window.label().strip_prefix("detached-tab-") { let _ = window.emit("dbx:detached-tab-lost", serde_json::json!({ "tabId": tab_id })); } @@ -1975,7 +1982,7 @@ 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_open_dropped, commands::plugin_file::plugin_file_pick_files, commands::plugin_file::plugin_file_save_as, commands::plugin_file::plugin_file_read,