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