mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(plugins): stream dropped files and folders into plugin workbenches
This commit is contained in:
+101
-5
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 状态
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user