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:
t8y2 committed 2026-09-16 14:29:47 +08:00
1 parent b0d30e6f73
commit 048b9ed291
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
+54
View File
@@ -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); },