feat(desktop): fileTransfer bridge for plugin workbench webviews

This commit is contained in:
Jinpy authored and GitHub committed 2026-09-21 02:03:12 +08:00
1 parent 9b82ce86f9
commit bc490772eb
11 files changed
+1111 -2

No files matched your search

+22
View File
@@ -5128,3 +5128,25 @@ export async function refreshConnections(): Promise<void> {
export * from "@/lib/backend/mq-http";
export * from "@/lib/backend/mqtt-http";
// ---------------------------------------------------------------------------
// Plugin local file streaming (native dialogs / OS drops are Tauri-only)
// ---------------------------------------------------------------------------
import type { PluginLocalFileChunk, PluginLocalFileHandle, PluginLocalFileWriteResult } from "./tauri";
export async function openPluginLocalFile(_pluginId: string, _path: string, _write: boolean): Promise<PluginLocalFileHandle> {
throw new Error("Plugin local file access is not available in the web backend");
}
export async function readPluginLocalFileChunk(_pluginId: string, _handleId: number, _offset: number, _length?: number): Promise<PluginLocalFileChunk> {
throw new Error("Plugin local file access is not available in the web backend");
}
export async function writePluginLocalFileChunk(_pluginId: string, _handleId: number, _offset: number, _dataBase64: string): Promise<PluginLocalFileWriteResult> {
throw new Error("Plugin local file access is not available in the web backend");
}
export async function closePluginLocalFile(_pluginId: string, _handleId: number): Promise<void> {
throw new Error("Plugin local file access is not available in the web backend");
}
+35
View File
@@ -2419,6 +2419,41 @@ export async function sendPluginBinary(pluginId: string, channel: string, dataBa
return invoke("send_plugin_binary", { pluginId, channel, dataBase64 });
}
export interface PluginLocalFileHandle {
handleId: number;
name: string;
size: number;
contentType: string;
write: boolean;
}
export interface PluginLocalFileChunk {
dataBase64: string;
length: number;
eof: boolean;
}
export interface PluginLocalFileWriteResult {
written: number;
nextOffset: number;
}
export async function openPluginLocalFile(pluginId: string, path: string, write: boolean): Promise<PluginLocalFileHandle> {
return invoke("plugin_file_open", { pluginId, path, write });
}
export async function readPluginLocalFileChunk(pluginId: string, handleId: number, offset: number, length?: number): Promise<PluginLocalFileChunk> {
return invoke("plugin_file_read", { pluginId, handleId, offset, length });
}
export async function writePluginLocalFileChunk(pluginId: string, handleId: number, offset: number, dataBase64: string): Promise<PluginLocalFileWriteResult> {
return invoke("plugin_file_write", { pluginId, handleId, offset, dataBase64 });
}
export async function closePluginLocalFile(pluginId: string, handleId: number): Promise<void> {
return invoke("plugin_file_close", { pluginId, handleId });
}
export async function listPluginFilesystemEntries(pluginId: string, providerId: string, options: { connectionId?: string; uri?: string; cursor?: string; limit?: number } = {}): Promise<PluginFilesystemListResult> {
return invoke("list_plugin_filesystem_entries", { pluginId, providerId, ...options });
}
+74
View File
@@ -973,4 +973,78 @@ describe("plugin SDK source", () => {
// a plan mode it never asked for.
expect(requests[1].params).toEqual({ connectionId: "c1", sql: "SELECT 1", mode: "estimated" });
});
it("forwards fileTransfer requests to the host api", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const api = {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
pickFiles: vi.fn().mockResolvedValue([{ handleId: "t1", name: "a.txt", size: 1, contentType: "text/plain" }]),
readFileChunk: vi.fn().mockResolvedValue({ dataBase64: "YQ==", length: 1, eof: true }),
beginFileSave: vi.fn().mockResolvedValue({ handleId: "t2", chunkBytes: 4 }),
writeFileChunk: vi.fn().mockResolvedValue({ written: 2, nextOffset: 2 }),
finishFileSave: vi.fn().mockResolvedValue(undefined),
closeFileHandle: vi.fn().mockResolvedValue(undefined),
};
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, api);
const request = (id: string, method: string, params: unknown, data?: ArrayBuffer) => {
bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id, method, params, data } } as MessageEvent);
};
request("1", "host.pickFiles", { multiple: true });
request("2", "host.readFileChunk", { handleId: "t1", offset: 0, length: 10 });
request("3", "host.beginFileSave", { name: "out.bin" });
request("4", "host.writeFileChunk", { handleId: "t2", offset: 0 }, new ArrayBuffer(2));
request("5", "host.finishFileSave", { handleId: "t2" });
request("6", "host.closeFileHandle", { handleId: "t1" });
await vi.waitFor(() => expect(messages.filter((message) => (message as { type?: string }).type === "response")).toHaveLength(6));
expect(api.pickFiles).toHaveBeenCalledWith("sample", { multiple: true });
expect(api.readFileChunk).toHaveBeenCalledWith("sample", "t1", 0, 10);
expect(api.beginFileSave).toHaveBeenCalledWith("sample", { name: "out.bin", contentType: undefined, size: undefined });
expect(api.writeFileChunk).toHaveBeenCalledWith("sample", "t2", 0, expect.any(Uint8Array));
expect(api.finishFileSave).toHaveBeenCalledWith("sample", "t2");
expect(api.closeFileHandle).toHaveBeenCalledWith("sample", "t1");
for (const message of messages) expect((message as { error?: string }).error).toBeUndefined();
});
it("resolves a cancelled beginFileSave to null", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, { invoke: vi.fn(), notify: vi.fn(), sendBinary: vi.fn(), readAsset: vi.fn(), beginFileSave: vi.fn().mockResolvedValue(null) });
bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id: "1", method: "host.beginFileSave", params: { name: "out.bin" } } } as MessageEvent);
await vi.waitFor(() => expect(messages.filter((message) => (message as { type?: string }).type === "response")).toHaveLength(1));
expect(messages[0]).toMatchObject({ type: "response", id: "1", result: null });
});
it("pushes drag state and opened drop handles to the plugin", () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, { invoke: vi.fn(), notify: vi.fn(), sendBinary: vi.fn(), readAsset: vi.fn() });
bridge.forwardDragState(true);
bridge.forwardFileDrop([{ handleId: "t1", name: "a.txt", size: 1, contentType: "text/plain" }]);
expect(messages[0]).toMatchObject({ source: "dbx-host", version: 1, type: "dragstate", active: true });
expect(messages[1]).toMatchObject({ source: "dbx-host", version: 1, type: "filedrop", files: [{ handleId: "t1", name: "a.txt" }] });
});
it("sdk exposes the fileTransfer namespace and drop listeners", () => {
const source = pluginSdkSource();
expect(source).toContain("fileTransfer");
expect(source).toContain("host.pickFiles");
expect(source).toContain("host.readFileChunk");
expect(source).toContain("host.beginFileSave");
expect(source).toContain("host.writeFileChunk");
expect(source).toContain("host.finishFileSave");
expect(source).toContain("onDrop");
expect(source).toContain("onDragState");
expect(source).toContain("type === 'filedrop'");
expect(source).toContain("type === 'dragstate'");
});
});
@@ -52,6 +52,32 @@ export interface PluginDownloadRequest extends PluginSaveFileRequest {
params: Record<string, unknown>;
}
/** An opened local-file handle handed to a plugin for streaming transfer. */
export interface PluginFileHandleMeta {
handleId: string;
name: string;
size: number;
contentType: string;
}
export interface PluginPickFilesOptions {
multiple?: boolean;
}
export interface PluginFileReadChunk {
dataBase64: string;
length: number;
eof: boolean;
}
export interface PluginFileWriteResult {
written: number;
nextOffset: number;
}
/** Chunk size the host advertises for streamed saves; fits the bridge payload cap after base64. */
export const PLUGIN_SAVE_CHUNK_BYTES = 1024 * 1024;
export interface PluginHostBridgeApi {
invoke<T = unknown>(pluginId: string, method: string, params?: unknown, timeoutMs?: number): Promise<T>;
notify(pluginId: string, method: string, params?: unknown): Promise<void>;
@@ -79,6 +105,18 @@ export interface PluginHostBridgeApi {
cancelDownload?(pluginId: string, downloadId: string): Promise<void>;
/** Write text to the system clipboard on behalf of the sandboxed plugin iframe. */
copyText?(pluginId: string, text: string): Promise<void>;
/** Native open dialog; resolves opened read handles (null selection → empty list). */
pickFiles?(pluginId: string, options: PluginPickFilesOptions): Promise<PluginFileHandleMeta[]>;
/** Stream a chunk from an opened read handle. */
readFileChunk?(pluginId: string, handleId: string, offset: number, length?: number): Promise<PluginFileReadChunk>;
/** Native save dialog + opened write handle; resolves null when the user cancels. */
beginFileSave?(pluginId: string, request: { name?: string; contentType?: string; size?: number }): Promise<{ handleId: string; chunkBytes: number } | null>;
/** Stream a chunk into an opened write handle. */
writeFileChunk?(pluginId: string, handleId: string, offset: number, bytes: Uint8Array): Promise<PluginFileWriteResult>;
/** Flush and close an opened write handle. */
finishFileSave?(pluginId: string, handleId: string): Promise<void>;
/** Close any file handle, discarding unsaved state. */
closeFileHandle?(pluginId: string, handleId: string): Promise<void>;
}
interface PluginRequestMessage {
@@ -233,6 +271,16 @@ export class PluginHostBridge {
target.postMessage({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "binary", channel: event.channel, data: buffer }, "*", [buffer]);
}
/** Tell the plugin whether an OS-level file drag is currently over its workbench. */
forwardDragState(active: boolean): void {
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "dragstate", active });
}
/** Hand the plugin already-opened handles for files dropped onto its workbench. */
forwardFileDrop(files: PluginFileHandleMeta[]): void {
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "filedrop", files });
}
private async handleRequest(request: PluginRequestMessage, target: Window): Promise<void> {
try {
enforcePayloadLimit(request.params);
@@ -348,6 +396,50 @@ export class PluginHostBridge {
await this.api.copyText(this.plugin.manifest.id, input.text);
return { success: true };
}
if (method === "host.pickFiles") {
// Same trust level as host.saveFile: the bytes only flow after the user
// picked the files in the native dialog, so no manifest permission gate.
const input = isRecord(params) ? params : {};
if (!this.api.pickFiles) throw new Error("Host file picking is unavailable");
const files = await this.api.pickFiles(this.plugin.manifest.id, { multiple: input.multiple === true });
return { files };
}
if (method === "host.readFileChunk") {
const input = requireRecord(params, "host.readFileChunk params");
if (!this.api.readFileChunk) throw new Error("Host file reading is unavailable");
return this.api.readFileChunk(this.plugin.manifest.id, requireHandleId(input.handleId), requireOffset(input.offset), input.length === undefined ? undefined : requireChunkLength(input.length));
}
if (method === "host.beginFileSave") {
const input = isRecord(params) ? params : {};
if (!this.api.beginFileSave) throw new Error("Host file saving is unavailable");
const target = await this.api.beginFileSave(this.plugin.manifest.id, {
name: optionalTrimmedString(input.name),
contentType: optionalTrimmedString(input.contentType),
size: input.size === undefined || typeof input.size !== "number" || !Number.isFinite(input.size) ? undefined : Math.max(0, Math.floor(input.size)),
});
// Cancelled saves resolve null, exactly as the documented contract and
// every plugin's env.d.ts types it.
return target;
}
if (method === "host.writeFileChunk") {
const input = requireRecord(params, "host.writeFileChunk params");
if (!this.api.writeFileChunk) throw new Error("Host file writing is unavailable");
const bytes = binary instanceof ArrayBuffer ? new Uint8Array(binary) : new Uint8Array(base64ToBytes(requireBase64(input.dataBase64)));
if (bytes.byteLength > MAX_BRIDGE_BINARY_BYTES) throw new Error("Plugin write chunk exceeds 8 MiB");
return this.api.writeFileChunk(this.plugin.manifest.id, requireHandleId(input.handleId), requireOffset(input.offset), bytes);
}
if (method === "host.finishFileSave") {
const input = requireRecord(params, "host.finishFileSave params");
if (!this.api.finishFileSave) throw new Error("Host file saving is unavailable");
await this.api.finishFileSave(this.plugin.manifest.id, requireHandleId(input.handleId));
return null;
}
if (method === "host.closeFileHandle") {
const input = requireRecord(params, "host.closeFileHandle params");
if (!this.api.closeFileHandle) throw new Error("Host file handling is unavailable");
await this.api.closeFileHandle(this.plugin.manifest.id, requireHandleId(input.handleId));
return null;
}
throw new Error(`Unsupported plugin host method '${method}'`);
}
@@ -507,7 +599,7 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
.replace(/\u2029/g, "\\u2029");
return `(() => {
const pending = new Map();
const listeners = { event: new Set(), binary: new Set(), init: new Set(), context: new Set() };
const listeners = { event: new Set(), binary: new Set(), init: new Set(), context: new Set(), filedrop: new Set(), dragstate: new Set() };
let sequence = 0;
let context;
let locale = 'en';
@@ -644,6 +736,23 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
return request('host.saveFile', options, { transfer: bytes });
},
copy: (text) => request('host.copy', { text }),
fileTransfer: Object.freeze({
pick: (options) => request('host.pickFiles', options || {}),
read: (handleId, offset, length) => request('host.readFileChunk', { handleId, offset, length }),
beginSave: (options) => request('host.beginFileSave', options || {}),
write: (handleId, offset, data) => {
if (typeof data === 'string') return request('host.writeFileChunk', { handleId, offset, dataBase64: data });
// A Uint8Array can be a view into a larger buffer — transferring
// .buffer blindly would send bytes outside the view. Copy the
// visible range into a standalone buffer first.
const bytes = data instanceof ArrayBuffer ? data : new Uint8Array(data instanceof Uint8Array ? data.slice().buffer : new Uint8Array(data).buffer);
return request('host.writeFileChunk', { handleId, offset }, { transfer: bytes });
},
finish: (handleId) => request('host.finishFileSave', { handleId }),
cancel: (handleId) => request('host.closeFileHandle', { handleId }),
onDragState: (listener) => { listeners.dragstate.add(listener); return () => listeners.dragstate.delete(listener); },
onDrop: (listener) => { listeners.filedrop.add(listener); return () => listeners.filedrop.delete(listener); },
}),
onEvent: (listener) => { listeners.event.add(listener); return () => listeners.event.delete(listener); },
onBinary: (listener) => { listeners.binary.add(listener); return () => listeners.binary.delete(listener); },
onContext: (listener) => { listeners.context.add(listener); return () => listeners.context.delete(listener); },
@@ -686,6 +795,14 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
const payload = { channel: message.channel, data: message.data ? new Uint8Array(message.data) : new Uint8Array(0) };
listeners.binary.forEach((listener) => listener(payload));
document.dispatchEvent(new CustomEvent('dbx-plugin-binary', { detail: payload }));
} else if (message.type === 'filedrop') {
const files = Array.isArray(message.files) ? message.files : [];
listeners.filedrop.forEach((listener) => listener(files));
document.dispatchEvent(new CustomEvent('dbx-plugin-filedrop', { detail: files }));
} else if (message.type === 'dragstate') {
const active = message.active === true;
listeners.dragstate.forEach((listener) => listener(active));
document.dispatchEvent(new CustomEvent('dbx-plugin-dragstate', { detail: active }));
}
});
addEventListener('keydown', (event) => {
@@ -771,6 +888,21 @@ function clampPluginPlanTimeout(value: unknown): number {
return Math.min(MAX_PLUGIN_PLAN_TIMEOUT_MS, Math.max(1, Math.round(value)));
}
function requireHandleId(value: unknown): string {
if (typeof value !== "string" || !value || value.length > 128) throw new Error("handleId is invalid");
return value;
}
function requireOffset(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error("offset is invalid");
return Math.floor(value);
}
function requireChunkLength(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error("length is invalid");
return Math.min(8 * 1024 * 1024, Math.floor(value));
}
function base64ToBytes(value: string): ArrayBuffer {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);