feat(plugins): stream cancellable downloads to atomic local files

Refs: t8y2/dbx-plugin-s3#22
This commit is contained in:
t8y2 committed 2026-09-20 04:09:15 +08:00
1 parent 0759616f88
commit faa8709266
8 files changed
+324

No files matched your search

@@ -6,6 +6,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { copyToClipboard } from "@/lib/common/clipboard";
import { PluginHostBridge, pluginSandboxDocument, type PluginBridgeTheme, type PluginSaveFileRequest, type PluginSaveFileResult, type PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge";
import { buildPluginEditorAppearance } from "@/lib/plugins/pluginAppearance";
import { downloadPluginFile, cancelPluginDownload } from "@/lib/plugins/pluginFileDownload";
import type { InstalledPlugin, PluginUiContribution } from "@/types/database";
import { useI18n } from "vue-i18n";
import { useTheme } from "@/composables/useTheme";
@@ -68,6 +69,7 @@ function currentBridgeTheme(): PluginBridgeTheme {
}
function createBridge() {
bridge?.dispose();
bridge = new PluginHostBridge(
props.plugin,
props.contribution,
@@ -83,6 +85,8 @@ function createBridge() {
reopenConnection: (pluginId, connectionId) => useConnectionStore().reopenPluginConnection(connectionId, pluginId),
closeTab: () => emit("closeTab"),
saveFile: (_pluginId, request, data) => savePluginFile(request, data),
downloadFile: isTauriRuntime() ? downloadPluginFile : undefined,
cancelDownload: isTauriRuntime() ? cancelPluginDownload : undefined,
copyText: (_pluginId, text) => copyToClipboard(text),
},
appLocale.value,
@@ -0,0 +1,19 @@
import { Channel, invoke } from "@tauri-apps/api/core";
import type { PluginDownloadRequest, PluginSaveFileResult } from "./pluginHostBridge";
export async function downloadPluginFile(pluginId: string, request: PluginDownloadRequest, onProgress: (progress: unknown) => void): Promise<PluginSaveFileResult | null> {
const channel = new Channel<unknown>();
channel.onmessage = onProgress;
const path = await invoke<string | null>("download_plugin_file", {
pluginId,
downloadId: request.downloadId,
fileName: request.fileName || "download.bin",
params: request.params,
onProgress: channel,
});
return path === null ? null : { path };
}
export async function cancelPluginDownload(pluginId: string, downloadId: string): Promise<void> {
await invoke("cancel_plugin_download", { pluginId, downloadId });
}
+59
View File
@@ -14,6 +14,65 @@ const workbench: PluginWorkbenchContribution = { type: "workbench", id: "sample.
const resultView: PluginResultViewContribution = { type: "result-view", id: "sample.graph", label: "Graph" };
describe("PluginHostBridge", () => {
it("streams downloads under the owning plugin, scopes cancellation and reports native capability", async () => {
const messages: any[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
let finish!: (value: null) => void;
const downloadFile = vi.fn((_pluginId, _request, onProgress) => {
onProgress({ downloadId: "download-1", sent: 300 * 1024 * 1024 });
return new Promise<null>((resolve) => {
finish = resolve;
});
});
const cancelDownload = vi.fn().mockResolvedValue(undefined);
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
downloadFile,
cancelDownload,
});
const send = (id: string, method: string, params: unknown) => bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id, method, params } } as MessageEvent);
bridge.sendInit();
expect(messages[0].capabilities.downloadFile).toBe(true);
send("open", "host.downloadFile", { downloadId: "download-1", fileName: "report.bin", params: { uri: "s3://bucket/report.bin" }, pluginId: "other" });
await vi.waitFor(() => expect(downloadFile).toHaveBeenCalledOnce());
expect(downloadFile.mock.calls[0][0]).toBe("sample");
expect(messages.some((message) => message.method === "host.download.progress" && message.params.sent === 300 * 1024 * 1024)).toBe(true);
send("other", "host.cancelDownload", { downloadId: "someone-else" });
expect(cancelDownload).not.toHaveBeenCalled();
send("cancel", "host.cancelDownload", { downloadId: "download-1" });
await vi.waitFor(() => expect(cancelDownload).toHaveBeenCalledWith("sample", "download-1"));
finish(null);
await vi.waitFor(() => expect(messages.some((message) => message.id === "open" && message.result === null)).toBe(true));
});
it("cancels unfinished native downloads when the workbench is disposed", async () => {
const target = { postMessage: vi.fn() } as unknown as Window;
const cancelDownload = vi.fn().mockResolvedValue(undefined);
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
downloadFile: vi.fn(() => new Promise<null>(() => {})),
cancelDownload,
});
bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id: "open", method: "host.downloadFile", params: { downloadId: "owned", params: {} } } } as MessageEvent);
bridge.dispose();
expect(cancelDownload).toHaveBeenCalledWith("sample", "owned");
});
it("does not advertise native downloads on legacy or web hosts", async () => {
const messages: any[] = [];
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.sendInit();
expect(messages[0].capabilities.downloadFile).toBe(false);
bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id: "open", method: "host.downloadFile", params: {} } } as MessageEvent);
await vi.waitFor(() => expect(messages[1].error).toContain("desktop host"));
});
it("binds backend calls to the owning plugin identity", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
@@ -44,6 +44,11 @@ export interface PluginSaveFileResult {
path: string;
}
export interface PluginDownloadRequest extends PluginSaveFileRequest {
downloadId: string;
params: Record<string, unknown>;
}
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>;
@@ -56,6 +61,8 @@ export interface PluginHostBridgeApi {
closeTab?(): Promise<void> | void;
/** Persist plugin bytes through the host's native save dialog. Resolves null when the user cancels. */
saveFile?(pluginId: string, request: PluginSaveFileRequest, data: Uint8Array): Promise<PluginSaveFileResult | null>;
downloadFile?(pluginId: string, request: PluginDownloadRequest, onProgress: (progress: unknown) => void): Promise<PluginSaveFileResult | null>;
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>;
}
@@ -72,6 +79,7 @@ interface PluginRequestMessage {
}
export class PluginHostBridge {
private downloads = new Set<string>();
private context: PluginWorkbenchContext;
private locale: string;
private theme?: PluginBridgeTheme;
@@ -120,6 +128,8 @@ export class PluginHostBridge {
private requestInit(signal: "load" | "ready"): void {
if ((this.initSignals.load && this.initSignals.ready) || (signal === "load" && this.initSignals.load)) {
for (const downloadId of this.downloads) void this.api.cancelDownload?.(this.plugin.manifest.id, downloadId).catch(() => undefined);
this.downloads.clear();
this.initGeneration += 1;
this.initSignals = { load: false, ready: false };
this.initStarted = false;
@@ -148,6 +158,8 @@ export class PluginHostBridge {
/** Stop future posts (queued inits after an async reinit) for a torn-down bridge. */
dispose(): void {
this.disposed = true;
for (const downloadId of this.downloads) void this.api.cancelDownload?.(this.plugin.manifest.id, downloadId).catch(() => undefined);
this.downloads.clear();
}
private disposed = false;
@@ -165,6 +177,7 @@ export class PluginHostBridge {
locale: this.locale,
theme: this.theme ? clonePluginData(this.theme) : undefined,
permissions: [...(this.plugin.manifest.permissions || [])],
capabilities: { downloadFile: !!this.api.downloadFile },
context: snapshotPluginWorkbenchContext(this.context),
});
}
@@ -215,6 +228,27 @@ export class PluginHostBridge {
}
private async dispatch(method: string, params: unknown, binary?: ArrayBuffer): Promise<unknown> {
if (method === "host.downloadFile") {
if (!this.api.downloadFile) throw new Error("Streaming file downloads require the desktop host");
const input = requireRecord(params, "download params");
const downloadId = requireProtocolName(input.downloadId, "download ID");
if (this.downloads.size >= 2 || this.downloads.has(downloadId)) throw new Error("Too many active downloads or duplicate download ID");
const request = { downloadId, fileName: optionalTrimmedString(input.fileName), params: requireRecord(input.params, "download source") };
this.downloads.add(downloadId);
try {
return await this.api.downloadFile(this.plugin.manifest.id, request, (progress) => {
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "event", method: "host.download.progress", params: progress });
});
} finally {
this.downloads.delete(downloadId);
}
}
if (method === "host.cancelDownload") {
const input = requireRecord(params, "cancel download params");
const downloadId = requireProtocolName(input.downloadId, "download ID");
if (this.downloads.has(downloadId)) await this.api.cancelDownload?.(this.plugin.manifest.id, downloadId);
return null;
}
if (method === "host.getContext") return snapshotPluginWorkbenchContext(this.context);
if (method === "backend.invoke") {
const input = requireRecord(params, "backend.invoke params");
@@ -451,6 +485,7 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
let context;
let locale = 'en';
let theme;
let capabilities = {};
let resolveReady;
const initialTheme = ${serializedInitialTheme};
const applyTheme = (value) => {
@@ -551,6 +586,9 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
get context() { return context; },
get locale() { return locale; },
get theme() { return theme; },
get capabilities() { return capabilities; },
downloadFile: (options) => request('host.downloadFile', options),
cancelDownload: (downloadId) => request('host.cancelDownload', { downloadId }),
request,
invoke: (method, params, options = {}) => request('backend.invoke', { method, params, timeoutMs: options.timeoutMs }),
stream,
@@ -591,6 +629,7 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
pending.delete(message.id);
if (message.error) handler.reject(new Error(message.error)); else handler.resolve(message.result);
} else if (message.type === 'init') {
capabilities = message.capabilities || {};
context = message.context;
locale = typeof message.locale === 'string' ? message.locale : 'en';
applyTheme(message.theme);