feat(plugins): add host.copy bridge for plugin clipboard writes
Plugin workbenches run in a sandbox="allow-scripts" iframe with an opaque origin, so every scripted clipboard path (async Clipboard API, offscreen textarea + execCommand) is denied there — plugins could not copy share links to the system clipboard. The bridge now exposes host.copy: the workbench host reuses copyToClipboard (Tauri clipboard-manager on desktop, Web Clipboard with a legacy fallback on web hosts), and the sandbox iframe additionally gets allow="clipboard-write" so engines that honor the delegation can write directly. The dev host matches production: its debug page answers host.copy in the top-level document and its plugin iframes carry the same allow attribute. No new plugin permission is required, so old hosts keep installing new plugins and old plugins simply never call the method.
This commit is contained in:
5 files changed
+112
-2
No files matched your search
@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
||||
import { AlertTriangle, Loader2 } from "@lucide/vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
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 type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -73,6 +74,7 @@ function createBridge() {
|
||||
openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context),
|
||||
closeTab: () => emit("closeTab"),
|
||||
saveFile: (_pluginId, request, data) => savePluginFile(request, data),
|
||||
copyText: (_pluginId, text) => copyToClipboard(text),
|
||||
},
|
||||
appLocale.value,
|
||||
currentBridgeTheme(),
|
||||
@@ -253,7 +255,7 @@ onBeforeUnmount(() => {
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<iframe ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
|
||||
<iframe ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" allow="clipboard-write" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
|
||||
<!-- Cover until the frame has actually painted: the iframe stays mounted
|
||||
underneath so its load event can fire (v-else on the overlay would
|
||||
deadlock), it just isn't visible yet. Fully opaque so the covered
|
||||
|
||||
@@ -292,6 +292,7 @@ describe("PluginHostBridge", () => {
|
||||
expect(document).toContain("get locale() { return locale; }");
|
||||
expect(document).toContain("openFilesystem");
|
||||
expect(document).toContain("saveFile");
|
||||
expect(document).toContain("copy");
|
||||
expect(document).toContain("document.dispatchEvent(new CustomEvent('dbx-plugin-env'");
|
||||
expect(document).toContain("shortcut: 'closeTab'");
|
||||
expect(document).toContain("connect-src 'none'");
|
||||
@@ -408,6 +409,59 @@ describe("PluginHostBridge", () => {
|
||||
expect(messages[1]).toMatchObject({ type: "response", id: "save2", result: null });
|
||||
});
|
||||
|
||||
it("routes host.copy through the host clipboard and rejects bad payloads", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
||||
const copyText = vi.fn().mockResolvedValue(undefined);
|
||||
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, {
|
||||
invoke: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
sendBinary: vi.fn(),
|
||||
readAsset: vi.fn(),
|
||||
copyText,
|
||||
});
|
||||
|
||||
bridge.handleWindowMessage({
|
||||
source: target,
|
||||
data: { source: "dbx-plugin", version: 1, type: "request", id: "copy1", method: "host.copy", params: { text: "https://example.com/share?sig=1" } },
|
||||
} as MessageEvent);
|
||||
await vi.waitFor(() => expect(messages).toHaveLength(1));
|
||||
expect(copyText).toHaveBeenCalledWith("sample", "https://example.com/share?sig=1");
|
||||
expect(messages[0]).toMatchObject({ type: "response", id: "copy1", result: { success: true } });
|
||||
|
||||
bridge.handleWindowMessage({
|
||||
source: target,
|
||||
data: { source: "dbx-plugin", version: 1, type: "request", id: "copy2", method: "host.copy", params: { text: "" } },
|
||||
} as MessageEvent);
|
||||
bridge.handleWindowMessage({
|
||||
source: target,
|
||||
data: { source: "dbx-plugin", version: 1, type: "request", id: "copy3", method: "host.copy", params: { text: "x".repeat(2 * 1024 * 1024 + 1) } },
|
||||
} as MessageEvent);
|
||||
await vi.waitFor(() => expect(messages).toHaveLength(3));
|
||||
const byId = new Map(messages.map((message) => [(message as { id?: string }).id, message]));
|
||||
expect(byId.get("copy2")).toMatchObject({ id: "copy2", error: "host.copy requires text" });
|
||||
// Oversized text is stopped by the generic bridge payload guard before the handler cap.
|
||||
expect(String((byId.get("copy3") as { error?: string }).error)).toMatch(/too large|exceeds/);
|
||||
});
|
||||
|
||||
it("rejects host.copy when the host has no clipboard support", 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(),
|
||||
});
|
||||
|
||||
bridge.handleWindowMessage({
|
||||
source: target,
|
||||
data: { source: "dbx-plugin", version: 1, type: "request", id: "nocopy", method: "host.copy", params: { text: "hello" } },
|
||||
} as MessageEvent);
|
||||
await vi.waitFor(() => expect(messages).toHaveLength(1));
|
||||
expect(messages[0]).toMatchObject({ id: "nocopy", error: "Host clipboard is unavailable" });
|
||||
});
|
||||
|
||||
it("rejects host.saveFile without payload or host support", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
||||
|
||||
@@ -43,6 +43,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>;
|
||||
/** Write text to the system clipboard on behalf of the sandboxed plugin iframe. */
|
||||
copyText?(pluginId: string, text: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface PluginRequestMessage {
|
||||
@@ -207,6 +209,17 @@ export class PluginHostBridge {
|
||||
if (!this.api.saveFile) throw new Error("Host file saving is unavailable");
|
||||
return this.api.saveFile(this.plugin.manifest.id, { fileName: optionalTrimmedString(input.fileName), contentType: optionalTrimmedString(input.contentType) }, bytes);
|
||||
}
|
||||
if (method === "host.copy") {
|
||||
const input = isRecord(params) ? params : {};
|
||||
// The sandboxed workbench iframe has an opaque origin and no clipboard
|
||||
// permission, so every scripted copy path is denied there; the host
|
||||
// writes the system clipboard instead.
|
||||
if (typeof input.text !== "string" || !input.text) throw new Error("host.copy requires text");
|
||||
if (input.text.length > MAX_BRIDGE_PAYLOAD_BYTES) throw new Error(`Plugin copy payload exceeds ${MAX_BRIDGE_PAYLOAD_BYTES} characters`);
|
||||
if (!this.api.copyText) throw new Error("Host clipboard is unavailable");
|
||||
await this.api.copyText(this.plugin.manifest.id, input.text);
|
||||
return { success: true };
|
||||
}
|
||||
throw new Error(`Unsupported plugin host method '${method}'`);
|
||||
}
|
||||
|
||||
@@ -493,6 +506,7 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
|
||||
const bytes = data instanceof ArrayBuffer ? data : (data instanceof Uint8Array ? data.buffer : new Uint8Array(data).buffer);
|
||||
return request('host.saveFile', options, { transfer: bytes });
|
||||
},
|
||||
copy: (text) => request('host.copy', { text }),
|
||||
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); },
|
||||
|
||||
Reference in new issue
Block a user