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