feat(plugins): stream cancellable downloads to atomic local files
Refs: t8y2/dbx-plugin-s3#22
This commit is contained in:
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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user