fix(plugins): stream dropped files and folders into plugin workbenches

This commit is contained in:
Jinpy
2026-09-30 15:08:17 +08:00
committed by GitHub
parent ce5217bd23
commit be82fdc989
13 changed files with 1717 additions and 671 deletions
@@ -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("<!doctype html><html><body></body></html>"), 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<string, unknown>);
expect(posted.some((message) => message.type === "filedrop" && (message.files as Array<Record<string, unknown>>)?.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<string, unknown>);
const filedrop = posted.find((message) => message.type === "filedrop");
expect((filedrop?.files as Array<Record<string, unknown>>)?.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<string, unknown>).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<string, unknown>).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<string, unknown>).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();
});
@@ -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<string, File>();
const webSaveBuffers = new Map<string, { name: string; contentType: string; chunks: Map<number, Uint8Array> }>();
const openTauriHandles = new Set<string>();
// 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<string, string>();
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<PluginFileHandleMeta> {
const { openPluginLocalFile } = await tauriFileApi();
const handle = await openPluginLocalFile(pluginId, path, write);
// Track read AND write handles: unmount must reclaim both (leaked fds also
// burn the shared 64-handle registry quota).
openTauriHandles.add(handle.handleId);
return { handleId: `${tauriHandlePrefix}${handle.handleId}`, name: handle.name, size: handle.size, contentType: handle.contentType };
// 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<PluginFileHandleMeta[]> {
@@ -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;
+2 -2
View File
@@ -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<PluginLocalFileHandle> {
export async function openDroppedPluginLocalFiles(_pluginId: string, _paths: string[]): Promise<PluginDroppedFilesResult> {
throw new Error("Plugin local file access is not available in the web backend");
}
+17 -6
View File
@@ -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<PluginLocalFileHandle> {
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<PluginDroppedFilesResult> {
return invoke("plugin_file_open_dropped", { pluginId, paths });
}
export async function pickPluginLocalFiles(pluginId: string, multiple: boolean): Promise<PluginLocalFileHandle[]> {
return invoke("plugin_file_pick_files", { pluginId, multiple });
}
@@ -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<void>;
/** Close any file handle, discarding unsaved state. */
closeFileHandle?(pluginId: string, handleId: string): Promise<void>;
/** 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<unknown>;
storageSet?(pluginId: string, key: string, value: unknown): Promise<void>;
@@ -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<void> {
@@ -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));
+3 -3
View File
@@ -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 状态
+3 -3
View File
@@ -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
-618
View File
@@ -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<HashMap<String, OpenFile>>,
/// 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<HashMap<String, Vec<String>>>,
}
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<String>) {
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<T>(
state: &PluginFileState,
body: impl FnOnce(&mut HashMap<String, OpenFile>) -> Result<T, String>,
) -> Result<T, String> {
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<String, OpenFile>,
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<PluginFileHandle, String> {
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<u32>,
) -> Result<PluginFileReadChunk, String> {
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<PluginFileWriteResult, String> {
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<bool>,
) -> Result<PluginFileHandle, String> {
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<PluginFileHandle, String> {
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<bool>,
) -> Result<Vec<PluginFileHandle>, 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<String>,
) -> Result<Option<PluginFileHandle>, 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<u32>,
) -> Result<PluginFileReadChunk, String> {
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<PluginFileWriteResult, String> {
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"<svg/>");
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);
}
}
+221
View File
@@ -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<CollectedDropFile> = 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<PluginFileHandle, String> {
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<CollectedDropFile>,
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<std::path::PathBuf> = 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::<Vec<_>>()
.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)));
}
}
}
+973
View File
@@ -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<HashMap<String, OpenFile>>,
/// Drop-granted files waiting to be read. Metadata only: reads open the
/// path on demand and never hold a persistent fd.
dropped_files: Mutex<HashMap<String, DroppedFile>>,
/// 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<HashMap<String, Vec<DroppedGrant>>>,
}
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<String>) {
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<PluginFileHandle>,
#[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<String>,
) -> 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::<PluginFileState>();
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<bool>,
) -> Result<Vec<PluginFileHandle>, 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<String>,
) -> Result<Option<PluginFileHandle>, 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<u32>,
) -> Result<PluginFileReadChunk, String> {
tauri::async_runtime::spawn_blocking(move || {
let state = app.state::<PluginFileState>();
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<PluginFileWriteResult, String> {
tauri::async_runtime::spawn_blocking(move || {
let state = app.state::<PluginFileState>();
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::<PluginFileState>();
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<String> = 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"<svg/>");
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);
}
}
@@ -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<T>(
state: &PluginFileState,
body: impl FnOnce(&mut HashMap<String, OpenFile>) -> Result<T, String>,
) -> Result<T, String> {
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<String, OpenFile>,
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<PluginFileHandle, String> {
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<u32>,
) -> Result<PluginFileReadChunk, String> {
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<PluginFileReadChunk, String> {
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<PluginFileWriteResult, String> {
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<String> =
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<PluginFileHandle, String> {
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)
}
+14 -1
View File
@@ -255,6 +255,7 @@ pub async fn rollback_plugin(
#[tauri::command]
pub async fn uninstall_plugin(
state: State<'_, Arc<AppState>>,
file_state: State<'_, crate::commands::plugin_file::PluginFileState>,
plugin_id: String,
) -> Result<Vec<InstalledPluginInfo>, 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<AppState>>) -> Result<Vec<
}
#[tauri::command]
pub async fn stop_plugin(state: State<'_, Arc<AppState>>, plugin_id: String) -> Result<(), String> {
pub async fn stop_plugin(
state: State<'_, Arc<AppState>>,
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(())
}
+11 -4
View File
@@ -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<String> = 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::<commands::plugin_file::PluginFileState>() {
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,