Files
dbx/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue
T

303 lines
13 KiB
Vue

<script setup lang="ts">
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 { 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";
import { useSettingsStore } from "@/stores/settingsStore";
import { useConnectionStore } from "@/stores/connectionStore";
const props = withDefaults(
defineProps<{
plugin: InstalledPlugin;
contribution: PluginUiContribution;
context?: PluginWorkbenchContext;
}>(),
{ context: () => ({}) },
);
const emit = defineEmits<{
ready: [];
error: [message: string];
openWorkbench: [pluginId: string, contributionId: string, context?: PluginWorkbenchContext, options?: { forceNew?: boolean }];
openFilesystem: [pluginId: string, providerId: string, context?: PluginWorkbenchContext];
closeTab: [];
}>();
const { t, locale: appLocale } = useI18n();
const { isDark, themeRevision } = useTheme();
const settingsStore = useSettingsStore();
const iframe = ref<HTMLIFrameElement>();
const source = ref("");
const loading = ref(true);
// Stays false until the iframe's load event: WKWebView paints a white canvas
// for a freshly inserted iframe before the sandbox document's first styled
// frame, so the themed overlay must keep covering the frame area until then.
const frameReady = ref(false);
const error = ref("");
let bridge: PluginHostBridge | undefined;
let unsubscribeEvents: (() => void) | undefined;
let disposed = false;
let loadGeneration = 0;
const title = computed(() => `${props.plugin.manifest.name} · ${props.contribution.label}`);
/** Collect resolved DBX design tokens so the sandbox can theme itself with the same values. */
function currentBridgeTheme(): PluginBridgeTheme {
const tokens: Record<string, string> = {};
if (typeof document !== "undefined") {
const style = getComputedStyle(document.documentElement);
for (const name of style) {
if (!name.startsWith("--") || name.startsWith("--dbx-")) continue;
if (/^--(color|radius|font)/.test(name)) {
const value = style.getPropertyValue(name).trim();
if (value) tokens[name] = value;
}
}
}
return {
appearance: isDark.value ? "dark" : "light",
tokens,
editor: buildPluginEditorAppearance(settingsStore.editorSettings),
};
}
function createBridge() {
bridge?.dispose();
bridge = new PluginHostBridge(
props.plugin,
props.contribution,
props.context,
() => iframe.value?.contentWindow || null,
{
invoke: api.invokePlugin,
notify: api.notifyPlugin,
sendBinary: api.sendPluginBinary,
readAsset: api.readPluginUiAsset,
openWorkbench: async (pluginId, contributionId, context, options) => emit("openWorkbench", pluginId, contributionId, context, options),
openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context),
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,
currentBridgeTheme(),
);
// An iframe reload (F5 / webview restart) drops the plugin sidecar's
// in-memory connection registry while the host still holds the connection
// open. Re-push the connection config through the same path as a sidebar
// open before the plugin receives its fresh init, so it can reconnect
// without the user reopening the connection from the sidebar.
bridge.onReinit = async () => {
const connectionId = props.context?.connectionId;
if (!connectionId) return;
await useConnectionStore().repushPluginConnection(connectionId);
};
}
/** Keep a plugin-supplied name from smuggling path separators or traversal into the save dialog. */
function safeFileName(value: string | undefined): string {
const base = (value || "").split(/[\\/]/).pop() || "";
// eslint-disable-next-line no-control-regex -- stripping control characters is the point
const cleaned = base.replace(/[\u0000-\u001f<>:"|?*]+/g, "").trim();
return cleaned && cleaned !== "." && cleaned !== ".." ? cleaned : "download.bin";
}
/**
* Native save dialog + disk write for plugin downloads. The sandboxed iframe
* cannot trigger downloads itself (WKWebView cancels blob-anchor navigations
* when no host download handler is registered), so the bytes travel through
* the bridge and the host persists them. Resolves null when the user cancels.
*/
async function savePluginFile(request: PluginSaveFileRequest, data: Uint8Array): Promise<PluginSaveFileResult | null> {
const fileName = safeFileName(request.fileName);
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeFile } = await import("@tauri-apps/plugin-fs");
const extension = fileName.includes(".") ? (fileName.split(".").pop() as string) : "";
const path = await save({
defaultPath: fileName,
filters: extension ? [{ name: extension.toUpperCase(), extensions: [extension] }] : undefined,
});
if (!path) return null;
await writeFile(path, data);
return { path };
}
// Web host: the sandboxed iframe cannot download, but the host page can.
// Transferred buffers are plain ArrayBuffers (SharedArrayBuffer cannot cross postMessage).
const url = URL.createObjectURL(new Blob([data.buffer as ArrayBuffer], { type: request.contentType || "application/octet-stream" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
return { path: fileName };
}
function localUiAssetPath(source: string): string | undefined {
const trimmed = source.trim();
if (!trimmed || /^(?:blob:|data:|https?:|\/\/)/i.test(trimmed)) return undefined;
try {
const resolved = new URL(trimmed, "https://dbx-plugin.invalid/");
if (resolved.origin !== "https://dbx-plugin.invalid") return undefined;
const path = decodeURIComponent(resolved.pathname).replace(/^\/+/, "");
if (!path || path.split("/").some((segment) => segment === "..")) return undefined;
return path;
} catch {
return undefined;
}
}
async function inlineLocalUiAssets(html: string, pluginId: string): Promise<string> {
const document = new DOMParser().parseFromString(html, "text/html");
const resources = [...document.querySelectorAll("script[src], link[rel='stylesheet'][href]")];
for (const resource of resources) {
const source = resource.getAttribute(resource.tagName === "SCRIPT" ? "src" : "href");
const path = source ? localUiAssetPath(source) : undefined;
if (!path) continue;
const asset = await api.readPluginUiAsset(pluginId, path);
const content = new TextDecoder().decode(Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0)));
if (resource.tagName === "SCRIPT") {
const script = document.createElement("script");
for (const attribute of [...resource.attributes]) {
if (attribute.name !== "src") script.setAttribute(attribute.name, attribute.value);
}
script.textContent = content;
resource.replaceWith(script);
} else {
const style = document.createElement("style");
style.textContent = content;
resource.replaceWith(style);
}
}
return document.documentElement.outerHTML;
}
async function loadWorkbench() {
const generation = ++loadGeneration;
bridge?.dispose();
bridge = undefined;
loading.value = true;
frameReady.value = false;
error.value = "";
try {
if (!props.plugin.compatibility.compatible) throw new Error((props.plugin.compatibility.errors || []).join("; ") || t("pluginPlatform.pluginIncompatible"));
const asset = await api.readPluginUiEntry(props.plugin.manifest.id);
if (disposed || generation !== loadGeneration) return;
const bytes = Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0));
const html = await inlineLocalUiAssets(new TextDecoder().decode(bytes), props.plugin.manifest.id);
if (disposed || generation !== loadGeneration) return;
source.value = pluginSandboxDocument(html, props.plugin.manifest.permissions, currentBridgeTheme());
await nextTick();
if (disposed || generation !== loadGeneration) return;
createBridge();
} catch (cause) {
if (disposed || generation !== loadGeneration) return;
error.value = cause instanceof Error ? cause.message : String(cause);
emit("error", error.value);
} finally {
if (!disposed && generation === loadGeneration) loading.value = false;
}
}
function onMessage(event: MessageEvent) {
bridge?.handleWindowMessage(event);
}
function onFrameLoad() {
// The load event can precede the webview's first actual paint (notably on
// WKWebView); reveal after two animation frames, with a timer fallback
// because rAF stalls in occluded/background webviews. Guarded by generation
// so a stale callback from a rebuilt iframe can't lift the new overlay.
const generation = loadGeneration;
const reveal = () => {
if (!disposed && generation === loadGeneration) frameReady.value = true;
};
requestAnimationFrame(() => requestAnimationFrame(reveal));
setTimeout(reveal, 400);
bridge?.sendInit();
emit("ready");
}
onMounted(async () => {
window.addEventListener("message", onMessage);
const unsubscribe = await api.subscribePluginEvents(
(event) => bridge?.forwardEvent(event),
(event) => bridge?.forwardBinary(event),
);
if (disposed) {
unsubscribe();
return;
}
unsubscribeEvents = unsubscribe;
await loadWorkbench();
});
// Identity changes require rebuilding the sandbox document; context and locale
// changes are pushed through the bridge so plugin UI state survives them.
watch(
() => [props.plugin.manifest.id, props.plugin.manifest.version, props.contribution.id] as const,
() => void loadWorkbench(),
);
watch(
() => props.context,
(context) => bridge?.updateContext(context ?? {}),
{ deep: true },
);
watch(appLocale, (locale) => bridge?.updateLocale(locale));
// Keyed on the theme revision (bumped by applyTheme) so every theme change
// path — dark/light, palette switch, custom colors — re-pushes the resolved
// tokens; watching isDark/custom colors alone misses palette-only switches.
watch(themeRevision, () => bridge?.updateTheme(currentBridgeTheme()));
// Font families are mirrored onto root tokens by App.vue (writeRootToken) and
// reach live bridges through the themeRevision bump above. fontSize and the
// SQL editor syntax theme have no CSS-token carrier — watch them explicitly.
watch(
() => [settingsStore.editorSettings.fontSize, settingsStore.editorSettings.theme],
() => bridge?.updateTheme(currentBridgeTheme()),
);
onBeforeUnmount(() => {
disposed = true;
loadGeneration += 1;
bridge?.dispose();
bridge = undefined;
window.removeEventListener("message", onMessage);
unsubscribeEvents?.();
});
</script>
<template>
<div class="relative flex size-full min-h-40 overflow-hidden bg-background">
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-background text-sm text-muted-foreground">
<Loader2 class="mr-2 size-4 animate-spin" />
{{ t("pluginPlatform.loadingTitle", { title }) }}
</div>
<div v-else-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
<span>{{ error }}</span>
</div>
<template v-else>
<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
phase is visually identical to the host background, and faded out
instead of removed so the reveal is never a hard swap. -->
<div class="absolute inset-0 z-10 flex items-center justify-center bg-background text-sm text-muted-foreground transition-opacity duration-150 ease-out" :class="frameReady ? 'pointer-events-none opacity-0' : 'opacity-100'">
<Loader2 class="mr-2 size-4 animate-spin" />
{{ t("pluginPlatform.loadingTitle", { title }) }}
</div>
</template>
</div>
</template>