mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(plugin): complete plugin framework workflow
This commit is contained in:
@@ -16,6 +16,16 @@ const contribution: PluginConnectionProviderContribution = {
|
||||
fields: [
|
||||
{ key: "host", label: "Host", type: "text", required: true, placeholder: "localhost" },
|
||||
{ key: "password", label: "Password", type: "password" },
|
||||
{
|
||||
key: "protocol",
|
||||
label: "Protocol",
|
||||
type: "radio",
|
||||
default: "https",
|
||||
options: [
|
||||
{ label: "HTTPS", value: "https" },
|
||||
{ label: "HTTP", value: "http" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -88,4 +98,16 @@ describe("PluginConnectionFields", () => {
|
||||
expect(fieldRow?.classList.contains("grid-cols-4")).toBe(true);
|
||||
expect(document.querySelector(".rounded-lg.border")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders radio fields and emits the selected option", async () => {
|
||||
const state = await mountFields();
|
||||
const radios = Array.from(document.querySelectorAll<HTMLInputElement>("input[type='radio']"));
|
||||
|
||||
expect(radios).toHaveLength(2);
|
||||
expect(radios[0]?.checked).toBe(true);
|
||||
radios[1]?.click();
|
||||
await nextTick();
|
||||
|
||||
expect(state.values.protocol).toBe("http");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,12 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-else-if="field.type === 'radio'" :id="fieldId(field)" class="flex min-h-9 flex-wrap items-center gap-x-4 gap-y-2" role="radiogroup" :aria-label="field.label">
|
||||
<label v-for="option in field.options || []" :key="option.value" class="inline-flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="radio" :name="fieldId(field)" :value="option.value" :checked="String(fieldValue(field) ?? '') === option.value" class="size-4 accent-primary" @change="updateField(field, option.value)" />
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="field.type === 'boolean'" class="flex h-9 items-center">
|
||||
<Switch :id="fieldId(field)" :model-value="Boolean(fieldValue(field))" size="sm" @update:model-value="updateBooleanField(field, $event)" />
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import { AlertTriangle, ArrowUp, File, FileCode2, Folder, Loader2, RefreshCw } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { pluginFilesystemParentUri, pluginFilesystemRootUri, sortPluginFilesystemEntries } from "@/lib/plugins/pluginFilesystem";
|
||||
import { pluginFilesystemParentUri, pluginFilesystemRootUri, sortPluginFilesystemEntries, uniquePluginFilesystemEntries } from "@/lib/plugins/pluginFilesystem";
|
||||
import type { PluginFilesystemEntry, PluginFilesystemProviderContribution } from "@/types/database";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
@@ -26,6 +27,8 @@ const loadingMore = ref(false);
|
||||
const error = ref("");
|
||||
const selected = ref<PluginFilesystemEntry>();
|
||||
const preview = ref("");
|
||||
const previewImageUrl = ref("");
|
||||
const previewKind = ref<"text" | "image" | "binary">("text");
|
||||
const previewContentType = ref("");
|
||||
const previewTruncated = ref(false);
|
||||
const previewLoading = ref(false);
|
||||
@@ -54,7 +57,7 @@ async function load(uri = address.value || currentUri.value || rootUri.value, ap
|
||||
if (generation !== listGeneration) return;
|
||||
currentUri.value = targetUri;
|
||||
address.value = targetUri;
|
||||
entries.value = append ? [...entries.value, ...result.entries] : result.entries;
|
||||
entries.value = append ? uniquePluginFilesystemEntries([...entries.value, ...result.entries]) : uniquePluginFilesystemEntries(result.entries);
|
||||
nextCursor.value = result.nextCursor;
|
||||
if (!append) clearPreview();
|
||||
} catch (cause) {
|
||||
@@ -84,15 +87,33 @@ async function activateEntry(entry: PluginFilesystemEntry) {
|
||||
const generation = ++previewGeneration;
|
||||
previewLoading.value = true;
|
||||
previewError.value = "";
|
||||
revokePreviewImage();
|
||||
preview.value = "";
|
||||
previewKind.value = "text";
|
||||
try {
|
||||
const contentType = (entry.contentType || "").split(";", 1)[0].toLowerCase();
|
||||
const isImage = contentType.startsWith("image/") || /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(entry.name);
|
||||
const result = await api.readPluginFilesystemFile(props.pluginId, props.provider.id, entry.uri, {
|
||||
connectionId: props.connectionId,
|
||||
maxBytes: 256 * 1024,
|
||||
});
|
||||
if (generation !== previewGeneration) return;
|
||||
const bytes = Uint8Array.from(atob(result.dataBase64), (character) => character.charCodeAt(0));
|
||||
preview.value = new TextDecoder().decode(bytes);
|
||||
previewContentType.value = result.contentType || entry.contentType || "application/octet-stream";
|
||||
const resolvedContentType = (result.contentType || entry.contentType || "application/octet-stream").split(";", 1)[0].toLowerCase();
|
||||
previewContentType.value = resolvedContentType;
|
||||
if (isImage || resolvedContentType.startsWith("image/")) {
|
||||
if (result.truncated) {
|
||||
previewError.value = t("pluginPlatform.entryCannotPreview");
|
||||
} else {
|
||||
previewImageUrl.value = URL.createObjectURL(new Blob([bytes], { type: resolvedContentType }));
|
||||
previewKind.value = "image";
|
||||
}
|
||||
} else if (isTextPreview(entry.name, resolvedContentType)) {
|
||||
preview.value = new TextDecoder().decode(bytes);
|
||||
previewKind.value = "text";
|
||||
} else {
|
||||
previewKind.value = "binary";
|
||||
}
|
||||
previewTruncated.value = result.truncated;
|
||||
} catch (cause) {
|
||||
if (generation !== previewGeneration) return;
|
||||
@@ -107,12 +128,23 @@ function clearPreview() {
|
||||
previewGeneration += 1;
|
||||
selected.value = undefined;
|
||||
preview.value = "";
|
||||
revokePreviewImage();
|
||||
previewKind.value = "text";
|
||||
previewContentType.value = "";
|
||||
previewTruncated.value = false;
|
||||
previewLoading.value = false;
|
||||
previewError.value = "";
|
||||
}
|
||||
|
||||
function revokePreviewImage() {
|
||||
if (previewImageUrl.value) URL.revokeObjectURL(previewImageUrl.value);
|
||||
previewImageUrl.value = "";
|
||||
}
|
||||
|
||||
function isTextPreview(name: string, contentType: string) {
|
||||
return contentType.startsWith("text/") || /^(application\/(json|javascript|xml|yaml)|image\/svg\+xml)$/.test(contentType) || /\.(cjs|css|csv|html?|js|json|md|mjs|toml|ts|tsx|txt|vue|xml|yaml|yml)$/i.test(name);
|
||||
}
|
||||
|
||||
function formatSize(size?: number) {
|
||||
if (size === undefined) return "";
|
||||
if (size < 1024) return `${size} B`;
|
||||
@@ -122,6 +154,9 @@ function formatSize(size?: number) {
|
||||
}
|
||||
|
||||
onMounted(() => void load(props.initialUri || rootUri.value));
|
||||
onBeforeUnmount(() => {
|
||||
revokePreviewImage();
|
||||
});
|
||||
watch(
|
||||
() => [props.pluginId, props.provider.id, props.connectionId, props.initialUri, rootUri.value] as const,
|
||||
() => void load(props.initialUri || rootUri.value),
|
||||
@@ -147,30 +182,24 @@ watch(
|
||||
</div>
|
||||
|
||||
<div v-else class="grid min-h-0 flex-1 grid-cols-[minmax(18rem,42%)_minmax(0,1fr)] divide-x">
|
||||
<div class="min-h-0 overflow-auto">
|
||||
<div class="flex min-h-0 flex-col">
|
||||
<div v-if="loading && !entries.length" class="flex h-full items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 size-4 animate-spin" />{{ t("pluginPlatform.loadingFiles") }}</div>
|
||||
<div v-else-if="!sortedEntries.length" class="flex h-full items-center justify-center text-sm text-muted-foreground">{{ t("pluginPlatform.emptyDirectory") }}</div>
|
||||
<div v-else class="min-w-[28rem] text-sm">
|
||||
<button
|
||||
v-for="entry in sortedEntries"
|
||||
:key="entry.uri"
|
||||
type="button"
|
||||
class="grid w-full grid-cols-[minmax(0,1fr)_7rem_10rem] items-center gap-3 border-b px-3 py-2 text-left hover:bg-muted/50"
|
||||
:class="selected?.uri === entry.uri ? 'bg-muted' : ''"
|
||||
@click="selected = entry"
|
||||
@dblclick="activateEntry(entry)"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<Folder v-if="entry.kind === 'directory'" class="size-4 shrink-0 text-amber-500" />
|
||||
<FileCode2 v-else-if="entry.contentType?.startsWith('text/')" class="size-4 shrink-0 text-sky-500" />
|
||||
<File v-else class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate" :title="entry.name">{{ entry.name }}</span>
|
||||
</span>
|
||||
<span class="text-right text-xs tabular-nums text-muted-foreground">{{ entry.kind === "directory" ? "" : formatSize(entry.size) }}</span>
|
||||
<span class="truncate text-xs text-muted-foreground" :title="entry.modifiedAt">{{ entry.modifiedAt || "" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="nextCursor" class="flex justify-center p-3">
|
||||
<RecycleScroller v-else class="min-h-0 flex-1 min-w-[28rem] text-sm" :items="sortedEntries" :item-size="46" :buffer="300" key-field="uri">
|
||||
<template #default="{ item: entry }">
|
||||
<button type="button" class="grid h-[46px] w-full grid-cols-[minmax(0,1fr)_7rem_10rem] items-center gap-3 border-b px-3 text-left hover:bg-muted/50" :class="selected?.uri === entry.uri ? 'bg-muted' : ''" @click="selected = entry" @dblclick="activateEntry(entry)">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<Folder v-if="entry.kind === 'directory'" class="size-4 shrink-0 text-amber-500" />
|
||||
<FileCode2 v-else-if="entry.contentType?.startsWith('text/')" class="size-4 shrink-0 text-sky-500" />
|
||||
<File v-else class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate" :title="entry.name">{{ entry.name }}</span>
|
||||
</span>
|
||||
<span class="text-right text-xs tabular-nums text-muted-foreground">{{ entry.kind === "directory" ? "" : formatSize(entry.size) }}</span>
|
||||
<span class="truncate text-xs text-muted-foreground" :title="entry.modifiedAt">{{ entry.modifiedAt || "" }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
<div v-if="nextCursor" class="flex shrink-0 justify-center p-3">
|
||||
<Button size="sm" variant="outline" :disabled="loadingMore" @click="load(currentUri, true)"> <Loader2 v-if="loadingMore" class="mr-2 size-3.5 animate-spin" />{{ t("pluginPlatform.loadMore") }} </Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,13 +214,17 @@ watch(
|
||||
<div class="min-h-0 flex-1 overflow-auto p-4">
|
||||
<div v-if="previewLoading" class="flex h-full items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 size-4 animate-spin" />{{ t("pluginPlatform.loadingPreview") }}</div>
|
||||
<div v-else-if="previewError" class="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangle class="mt-0.5 size-4 shrink-0" />{{ previewError }}</div>
|
||||
<template v-else-if="preview">
|
||||
<template v-else-if="previewKind === 'image' && previewImageUrl">
|
||||
<img :src="previewImageUrl" :alt="selected.name" class="max-h-full max-w-full rounded-md object-contain" />
|
||||
</template>
|
||||
<template v-else-if="previewKind === 'text' && preview">
|
||||
<div class="mb-2 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
|
||||
<span>{{ previewContentType }}</span>
|
||||
<span v-if="previewTruncated">{{ t("pluginPlatform.previewTruncated") }}</span>
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-3 font-mono text-xs leading-5">{{ preview }}</pre>
|
||||
</template>
|
||||
<div v-else-if="previewKind === 'binary'" class="m-auto max-w-sm text-center text-sm text-muted-foreground">{{ t("pluginPlatform.entryCannotPreview") }}</div>
|
||||
<div v-else class="text-sm text-muted-foreground">{{ t("pluginPlatform.previewSelectedFileHint") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -71,6 +71,45 @@ function createBridge() {
|
||||
);
|
||||
}
|
||||
|
||||
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 = undefined;
|
||||
@@ -81,7 +120,9 @@ async function loadWorkbench() {
|
||||
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));
|
||||
source.value = pluginSandboxDocument(new TextDecoder().decode(bytes), props.plugin.manifest.permissions);
|
||||
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);
|
||||
await nextTick();
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
createBridge();
|
||||
|
||||
@@ -1641,17 +1641,16 @@ async function openObjectBrowser(eventReadOnly = false, openEventEditor: boolean
|
||||
const eventCreateRequestId = openEventEditor === "create" && node.type === "group-events" ? ++mysqlEventCreateRequestSeq : undefined;
|
||||
const objectFilter = node.type === "event" || node.type === "group-events" ? "events" : undefined;
|
||||
|
||||
if (hasTreeNodeDatabaseContext(node)) {
|
||||
queryStore.openObjectBrowser(node.connectionId, node.database, node.schema, node.catalog, eventName, eventReadOnly, objectFilter, eventCreateRequestId);
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = connectionStore.getConfig(node.connectionId);
|
||||
if (!connection) return;
|
||||
if (connection.db_type === "plugin") {
|
||||
await queryStore.openPluginConnection(node.connectionId);
|
||||
return;
|
||||
}
|
||||
if (hasTreeNodeDatabaseContext(node)) {
|
||||
queryStore.openObjectBrowser(node.connectionId, node.database, node.schema, node.catalog, eventName, eventReadOnly, objectFilter, eventCreateRequestId);
|
||||
return;
|
||||
}
|
||||
const options = await getDatabaseOptions(node.connectionId);
|
||||
const database = resolveDefaultDatabase(connection, options);
|
||||
if (database) {
|
||||
|
||||
+14
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pluginFilesystemParentUri, pluginFilesystemRootUri, sortPluginFilesystemEntries } from "./pluginFilesystem";
|
||||
import { pluginFilesystemParentUri, pluginFilesystemRootUri, sortPluginFilesystemEntries, uniquePluginFilesystemEntries } from "./pluginFilesystem";
|
||||
|
||||
describe("plugin filesystem navigation", () => {
|
||||
it("derives a default root from the first declared scheme", () => {
|
||||
@@ -21,4 +21,17 @@ describe("plugin filesystem navigation", () => {
|
||||
]);
|
||||
expect(sorted.map((entry) => entry.name)).toEqual(["folder", "file2.txt", "file10.txt"]);
|
||||
});
|
||||
|
||||
it("removes duplicate entries by URI while preserving the latest metadata", () => {
|
||||
expect(
|
||||
uniquePluginFilesystemEntries([
|
||||
{ name: "object.txt", uri: "s3:/object.txt", kind: "file", size: 1 },
|
||||
{ name: "object.txt", uri: "s3:/object.txt", kind: "file", size: 2 },
|
||||
{ name: "other.txt", uri: "s3:/other.txt", kind: "file" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ name: "object.txt", uri: "s3:/object.txt", kind: "file", size: 2 },
|
||||
{ name: "other.txt", uri: "s3:/other.txt", kind: "file" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,3 +28,7 @@ export function sortPluginFilesystemEntries(entries: readonly PluginFilesystemEn
|
||||
return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
|
||||
export function uniquePluginFilesystemEntries(entries: readonly PluginFilesystemEntry[]): PluginFilesystemEntry[] {
|
||||
return [...new Map(entries.map((entry) => [entry.uri, entry])).values()];
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ export interface PluginDriverManifest {
|
||||
database_type?: string;
|
||||
}
|
||||
|
||||
export type PluginFormFieldType = "text" | "password" | "number" | "boolean" | "select" | "textarea";
|
||||
export type PluginFormFieldType = "text" | "password" | "number" | "boolean" | "select" | "radio" | "textarea";
|
||||
export type PluginFormFieldBinding = "config" | "secret" | "name" | "host" | "port" | "username" | "password" | "database";
|
||||
|
||||
export type PluginFormFieldValue = string | number | boolean | undefined;
|
||||
|
||||
@@ -528,6 +528,7 @@ fn validate_plugin_field_type(field: &PluginFormFieldDefinition, value: &serde_j
|
||||
PluginFormFieldType::Text
|
||||
| PluginFormFieldType::Password
|
||||
| PluginFormFieldType::Select
|
||||
| PluginFormFieldType::Radio
|
||||
| PluginFormFieldType::Textarea => value.is_string(),
|
||||
PluginFormFieldType::Number => value.is_number(),
|
||||
PluginFormFieldType::Boolean => value.is_boolean(),
|
||||
|
||||
@@ -381,25 +381,58 @@ impl PluginPackageInstaller {
|
||||
std::fs::create_dir_all(&versions_dir).map_err(|error| error.to_string())?;
|
||||
std::fs::create_dir_all(&activations_dir).map_err(|error| error.to_string())?;
|
||||
let version_dir = versions_dir.join(version.to_string());
|
||||
if version_dir.exists() {
|
||||
if version_dir.exists() && !matches!(policy, PluginInstallPolicy::LocalDevelopment) {
|
||||
return Err(format!("Plugin '{}' version {} is already installed", manifest.id, version));
|
||||
}
|
||||
let current = read_latest_activation(&container_dir)?;
|
||||
let previous_version = current.as_ref().map(|record| record.version.clone());
|
||||
std::fs::rename(&package_dir, &version_dir)
|
||||
.map_err(|error| format!("Failed to store plugin '{}' version {}: {error}", manifest.id, version))?;
|
||||
let version_string = version.to_string();
|
||||
let previous_version = current.as_ref().and_then(|record| {
|
||||
if record.version == version_string {
|
||||
record.previous_version.clone()
|
||||
} else {
|
||||
Some(record.version.clone())
|
||||
}
|
||||
});
|
||||
let replaced_version_dir = if version_dir.exists() {
|
||||
let mut backup = versions_dir.join(format!(".{version_string}.replaced"));
|
||||
let mut suffix = 0u32;
|
||||
while backup.exists() {
|
||||
suffix = suffix.saturating_add(1);
|
||||
backup = versions_dir.join(format!(".{version_string}.replaced-{suffix}"));
|
||||
}
|
||||
std::fs::rename(&version_dir, &backup).map_err(|error| {
|
||||
format!("Failed to prepare plugin '{}' version {} replacement: {error}", manifest.id, version)
|
||||
})?;
|
||||
Some(backup)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Err(error) = std::fs::rename(&package_dir, &version_dir) {
|
||||
if let Some(backup) = &replaced_version_dir {
|
||||
let _ = std::fs::rename(backup, &version_dir);
|
||||
}
|
||||
return Err(format!("Failed to store plugin '{}' version {}: {error}", manifest.id, version));
|
||||
}
|
||||
|
||||
let activation = PluginActivationRecord {
|
||||
sequence: current.as_ref().map_or(1, |record| record.sequence.saturating_add(1)),
|
||||
version: version.to_string(),
|
||||
version: version_string,
|
||||
previous_version: previous_version.clone(),
|
||||
package_sha256: package_sha256.clone(),
|
||||
activated_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
if let Err(error) = write_activation_record(&container_dir, &activation) {
|
||||
let _ = std::fs::remove_dir_all(&version_dir);
|
||||
if let Some(backup) = &replaced_version_dir {
|
||||
let _ = std::fs::rename(backup, &version_dir);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(backup) = replaced_version_dir {
|
||||
if let Err(error) = std::fs::remove_dir_all(backup) {
|
||||
log::warn!("Failed to remove replaced plugin version backup: {error}");
|
||||
}
|
||||
}
|
||||
if let Err(error) = prune_plugin_history(&container_dir, &activation) {
|
||||
log::warn!("Failed to prune plugin '{}' install history: {error}", manifest.id);
|
||||
}
|
||||
@@ -955,6 +988,22 @@ mod tests {
|
||||
assert_eq!(registry.find_plugin("sample.hello").unwrap().unwrap().manifest.version, "1.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_development_reinstalls_same_version_without_creating_a_self_rollback() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let installer =
|
||||
PluginPackageInstaller::with_trust_store(root.path().to_path_buf(), "0.5.67", PluginTrustStore::default());
|
||||
let package = package("1.0.0", None, false);
|
||||
|
||||
installer.install_bytes(&package, PluginInstallPolicy::LocalDevelopment).unwrap();
|
||||
let reinstalled = installer.install_bytes(&package, PluginInstallPolicy::LocalDevelopment).unwrap();
|
||||
|
||||
assert_eq!(reinstalled.previous_version, None);
|
||||
let registry = PluginRegistry::new_with_app_version(root.path().to_path_buf(), "0.5.67");
|
||||
assert_eq!(registry.find_plugin("sample.hello").unwrap().unwrap().manifest.version, "1.0.0");
|
||||
assert_eq!(std::fs::read_dir(root.path().join("sample.hello").join(VERSIONS_DIR)).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retains_only_active_and_rollback_versions() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -268,6 +268,7 @@ pub enum PluginFormFieldType {
|
||||
Number,
|
||||
Boolean,
|
||||
Select,
|
||||
Radio,
|
||||
Textarea,
|
||||
}
|
||||
|
||||
@@ -967,23 +968,23 @@ fn validate_form_fields(fields: &[PluginFormFieldDefinition], contribution_index
|
||||
errors,
|
||||
);
|
||||
|
||||
if field.field_type == PluginFormFieldType::Select {
|
||||
if matches!(field.field_type, PluginFormFieldType::Select | PluginFormFieldType::Radio) {
|
||||
if field.options.is_empty() {
|
||||
errors.push(format!(
|
||||
"Contribution at index {contribution_index} field {field_index} select options cannot be empty"
|
||||
"Contribution at index {contribution_index} field {field_index} choice options cannot be empty"
|
||||
));
|
||||
}
|
||||
let mut seen_values = HashSet::new();
|
||||
for option in &field.options {
|
||||
if option.label.trim().is_empty() || !seen_values.insert(&option.value) {
|
||||
errors.push(format!(
|
||||
"Contribution at index {contribution_index} field {field_index} has invalid or duplicate select options"
|
||||
"Contribution at index {contribution_index} field {field_index} has invalid or duplicate choice options"
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if !field.options.is_empty() {
|
||||
errors.push(format!(
|
||||
"Contribution at index {contribution_index} field {field_index} only supports options for select fields"
|
||||
"Contribution at index {contribution_index} field {field_index} only supports options for select or radio fields"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1007,6 +1008,7 @@ fn validate_form_fields(fields: &[PluginFormFieldDefinition], contribution_index
|
||||
PluginFormFieldType::Text
|
||||
| PluginFormFieldType::Password
|
||||
| PluginFormFieldType::Select
|
||||
| PluginFormFieldType::Radio
|
||||
| PluginFormFieldType::Textarea
|
||||
) {
|
||||
errors.push(format!(
|
||||
@@ -1019,6 +1021,7 @@ fn validate_form_fields(fields: &[PluginFormFieldDefinition], contribution_index
|
||||
PluginFormFieldType::Text
|
||||
| PluginFormFieldType::Password
|
||||
| PluginFormFieldType::Select
|
||||
| PluginFormFieldType::Radio
|
||||
| PluginFormFieldType::Textarea => default.is_string(),
|
||||
PluginFormFieldType::Number => default.is_number(),
|
||||
PluginFormFieldType::Boolean => default.is_boolean(),
|
||||
@@ -1028,11 +1031,11 @@ fn validate_form_fields(fields: &[PluginFormFieldDefinition], contribution_index
|
||||
"Contribution at index {contribution_index} field {field_index} has an invalid default value"
|
||||
));
|
||||
}
|
||||
if field.field_type == PluginFormFieldType::Select
|
||||
if matches!(field.field_type, PluginFormFieldType::Select | PluginFormFieldType::Radio)
|
||||
&& default.as_str().is_some_and(|default| !field.options.iter().any(|option| option.value == default))
|
||||
{
|
||||
errors.push(format!(
|
||||
"Contribution at index {contribution_index} field {field_index} select default is not declared in options"
|
||||
"Contribution at index {contribution_index} field {field_index} choice default is not declared in options"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1175,6 +1178,21 @@ mod tests {
|
||||
"entrypoints": {
|
||||
"backend": { "executable": "bin/example" }
|
||||
},
|
||||
"contributions": [{
|
||||
"type": "connection-provider",
|
||||
"id": "io.dbx.example.connection",
|
||||
"database_type": "example",
|
||||
"fields": [{
|
||||
"key": "protocol",
|
||||
"label": "Protocol",
|
||||
"type": "radio",
|
||||
"default": "https",
|
||||
"options": [
|
||||
{ "label": "HTTPS", "value": "https" },
|
||||
{ "label": "HTTP", "value": "http" }
|
||||
]
|
||||
}]
|
||||
}],
|
||||
"permissions": ["host.events"]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
@@ -30,7 +30,7 @@ const OFFICIAL_CATALOG_URL: &str = "https://raw.githubusercontent.com/t8y2/dbx-s
|
||||
const ADDITIONAL_OFFICIAL_TRUSTED_KEYS_JSON: Option<&str> = option_env!("DBX_PLUGIN_MARKETPLACE_TRUSTED_KEYS_JSON");
|
||||
const BUILTIN_OFFICIAL_TRUSTED_KEYS: &[(&str, &str)] = &[
|
||||
("dbx-store-preview-2026", "VRb0VscZfWwuFa7LYfeD/wEOJeyNP8wPGND9br8Icmk="),
|
||||
("dbx-store-release-2026", "9Ew1pCeGfTzg2Ltiqw7siiQlLsR+HK1MGqwsSVSC8ng="),
|
||||
("dbx-store-release-2026", "6WbMG2UDx+EZ/oauMtHjdinvSD5MuFWSgXbI0n7eL+k="),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
||||
@@ -71,11 +71,12 @@ CLI 在交互式终端中默认显示彩色输出。设置 `NO_COLOR=1` 可以
|
||||
|
||||
## 2. 选择插件模板
|
||||
|
||||
`dbx-plugin create` 提供三种模板:
|
||||
`dbx-plugin create` 提供四种模板:
|
||||
|
||||
| 模板 | 组成 | 产物 | 适用场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| `frontend` | 沙箱前端,无原生后端 | 一个 `universal.dbxp` | 纯 UI、信息面板、调用 Host API 的轻量工具 |
|
||||
| `svelte` | Svelte + Vite 沙箱前端,无原生后端 | 一个 `universal.dbxp` | 使用 Svelte 编写的自定义工作台 |
|
||||
| `rust` | 沙箱前端 + Rust Sidecar | 每个平台一个 `.dbxp` | SSH、终端、复杂协议、系统能力、高性能任务 |
|
||||
| `go` | 沙箱前端 + Go Sidecar | 每个平台一个 `.dbxp` | 已有 Go 生态、网络服务、协议客户端 |
|
||||
|
||||
@@ -87,6 +88,9 @@ CLI 在交互式终端中默认显示彩色输出。设置 `NO_COLOR=1` 可以
|
||||
|
||||
```bash
|
||||
dbx-plugin create ~/Desktop/dbx-plugin-demo --template frontend
|
||||
|
||||
# 使用 Svelte + Vite 工作台模板
|
||||
dbx-plugin create ~/Desktop/dbx-plugin-svelte --template svelte
|
||||
```
|
||||
|
||||
也可以一次性传入全部参数:
|
||||
@@ -233,6 +237,8 @@ dist/
|
||||
5. 点击“安装 `.dbxp`”,选择 `dist/` 中的文件。
|
||||
6. 安装完成后切换到“已安装”,打开插件提供的工作台或其他入口。
|
||||
|
||||
本地开发包允许用相同版本重新安装,DBX 会替换当前开发版本并重启对应插件运行时;正式签名包仍不允许覆盖同版本。
|
||||
|
||||
测试结束后建议关闭“允许安装未签名开发包”。该开关只影响手动本地安装,不会放宽官方插件商店的签名校验。
|
||||
|
||||
## 7. 需要原生后端时
|
||||
@@ -369,6 +375,9 @@ dbx-plugin keygen --help
|
||||
# 创建纯前端插件
|
||||
dbx-plugin create my-plugin --template frontend
|
||||
|
||||
# 创建 Svelte + Vite 插件
|
||||
dbx-plugin create my-svelte-plugin --template svelte
|
||||
|
||||
# 创建 Rust 插件
|
||||
dbx-plugin create my-plugin --template rust
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/identifier" },
|
||||
"label": { "type": "string", "minLength": 1 },
|
||||
"type": { "enum": ["text", "password", "number", "boolean", "select", "textarea"] },
|
||||
"type": { "enum": ["text", "password", "number", "boolean", "select", "radio", "textarea"] },
|
||||
"description": { "type": ["string", "null"] },
|
||||
"placeholder": { "type": ["string", "null"] },
|
||||
"required": { "type": "boolean" },
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": { "properties": { "type": { "const": "select" } }, "required": ["type"] },
|
||||
"if": { "properties": { "type": { "enum": ["select", "radio"] } }, "required": ["type"] },
|
||||
"then": { "required": ["options"], "properties": { "options": { "minItems": 1 } } },
|
||||
"else": { "not": { "required": ["options"] } }
|
||||
},
|
||||
@@ -171,7 +171,7 @@
|
||||
"properties": { "binding": { "enum": ["secret", "name", "host", "username", "password", "database"] } },
|
||||
"required": ["binding"]
|
||||
},
|
||||
"then": { "properties": { "type": { "enum": ["text", "password", "select", "textarea"] } } }
|
||||
"then": { "properties": { "type": { "enum": ["text", "password", "select", "radio", "textarea"] } } }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -120,9 +120,25 @@ function installBridge(channel) {
|
||||
});
|
||||
}
|
||||
|
||||
export function sandboxDocument(html, channel) {
|
||||
export function sandboxDocument(html, channel, assets = new Map()) {
|
||||
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline' blob:; img-src data: blob:; font-src data: blob:; media-src data: blob:; connect-src 'none';">`;
|
||||
const bootstrap = `<script>(${installBridge.toString()})(${JSON.stringify(channel)});</script>`;
|
||||
if (/<head\b[^>]*>/i.test(html)) return html.replace(/<head\b[^>]*>/i, (match) => match + csp + bootstrap);
|
||||
return `<!doctype html><html><head>${csp}${bootstrap}</head><body>${html}</body></html>`;
|
||||
const inlineAsset = (url) => {
|
||||
const asset = assets.get(url);
|
||||
if (!asset) return undefined;
|
||||
return Buffer.from(asset.dataBase64, "base64").toString("utf8");
|
||||
};
|
||||
const withInlineScripts = html.replace(/<script\b([^>]*?)\bsrc\s*=\s*(["'])([^"']+)\2([^>]*)>\s*<\/script>/gi, (match, before, _quote, url, after) => {
|
||||
const content = inlineAsset(url);
|
||||
return content === undefined ? match : `<script${before}${after}>${content.replace(/<\/script/gi, "<\\/script")}</script>`;
|
||||
});
|
||||
const withInlineStyles = withInlineScripts.replace(/<link\b([^>]*)>/gi, (match, attributes) => {
|
||||
if (!/\brel\s*=\s*(["'])[^"']*\bstylesheet\b[^"']*\1/i.test(attributes)) return match;
|
||||
const href = attributes.match(/\bhref\s*=\s*(["'])([^"']+)\1/i);
|
||||
if (!href) return match;
|
||||
const content = inlineAsset(href[2]);
|
||||
return content === undefined ? match : `<style>${content.replace(/<\/style/gi, "<\\/style")}</style>`;
|
||||
});
|
||||
if (/<head\b[^>]*>/i.test(withInlineStyles)) return withInlineStyles.replace(/<head\b[^>]*>/i, (match) => match + csp + bootstrap);
|
||||
return `<!doctype html><html><head>${csp}${bootstrap}</head><body>${withInlineStyles}</body></html>`;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function validateRecord(manifest, record, required = true) {
|
||||
if (f.type === "number" ? typeof value !== "number" || !Number.isFinite(value) : f.type === "boolean" ? typeof value !== "boolean" : typeof value !== "string") {
|
||||
throw new Error(`Invalid field type: ${f.key}`);
|
||||
}
|
||||
if (f.type === "select" && !f.options?.some((option) => option.value === value)) throw new Error(`Invalid option: ${f.key}`);
|
||||
if (["select", "radio"].includes(f.type) && !f.options?.some((option) => option.value === value)) throw new Error(`Invalid option: ${f.key}`);
|
||||
if (binding(f) === "port" && (!Number.isInteger(value) || value < 0 || value > 65535)) throw new Error("Port must be between 0 and 65535");
|
||||
Object.defineProperty(values, f.key, { enumerable: true, configurable: true, writable: true, value });
|
||||
}
|
||||
|
||||
@@ -25,6 +25,17 @@ function pageId(value = "legacy") {
|
||||
if (typeof value !== "string" || !/^[a-zA-Z0-9-]{1,64}$/.test(value)) throw new Error("Invalid page ID");
|
||||
return value;
|
||||
}
|
||||
function localDocumentAssetPath(url, entry) {
|
||||
if (typeof url !== "string" || /^(?:data|blob|https?):/i.test(url) || url.startsWith("//") || url.startsWith("#")) return undefined;
|
||||
try {
|
||||
const parsed = new URL(url, `http://dbx-plugin.local/${entry}`);
|
||||
if (parsed.origin !== "http://dbx-plugin.local") return undefined;
|
||||
const path = decodeURIComponent(parsed.pathname).replace(/^\/+/, "");
|
||||
return path || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMockHost(options) {
|
||||
const diagnostics = options.diagnostics || new Diagnostics();
|
||||
@@ -146,6 +157,23 @@ export async function createMockHost(options) {
|
||||
frames.set(frame.id, frame);
|
||||
return expose(frame);
|
||||
}
|
||||
async function frameDocument(html, channel) {
|
||||
const urls = new Set();
|
||||
for (const match of html.matchAll(/<script\b[^>]*?\bsrc\s*=\s*(["'])([^"']+)\1[^>]*>/gi)) urls.add(match[2]);
|
||||
for (const match of html.matchAll(/<link\b([^>]*)>/gi)) {
|
||||
if (!/\brel\s*=\s*(["'])[^"']*\bstylesheet\b[^"']*\1/i.test(match[1])) continue;
|
||||
const href = match[1].match(/\bhref\s*=\s*(["'])([^"']+)\1/i);
|
||||
if (href) urls.add(href[2]);
|
||||
}
|
||||
const assets = new Map();
|
||||
await Promise.all(
|
||||
[...urls].map(async (url) => {
|
||||
const path = localDocumentAssetPath(url, entry);
|
||||
if (path) assets.set(url, await readAsset(uiRoot, path));
|
||||
}),
|
||||
);
|
||||
return sandboxDocument(html, channel, assets);
|
||||
}
|
||||
function expose(frame) {
|
||||
const { session: _session, page: _page, ...safe } = frame;
|
||||
return structuredClone(safe);
|
||||
@@ -199,7 +227,7 @@ export async function createMockHost(options) {
|
||||
const f = frameFor(session, p.frameId);
|
||||
const asset = await readAsset(uiRoot, entry);
|
||||
f.channel = randomUUID();
|
||||
return { ...expose(f), html: sandboxDocument(Buffer.from(asset.dataBase64, "base64").toString("utf8"), f.channel) };
|
||||
return { ...expose(f), html: await frameDocument(Buffer.from(asset.dataBase64, "base64").toString("utf8"), f.channel) };
|
||||
}
|
||||
return serialize(async () => {
|
||||
switch (route) {
|
||||
|
||||
@@ -37,6 +37,35 @@ const manifest = {
|
||||
],
|
||||
};
|
||||
const values = { name: "Example connection", endpoint: "test-endpoint", count: 7, enabled: true, password: "test-password" };
|
||||
|
||||
test("radio fields accept only declared options", () => {
|
||||
const radioManifest = {
|
||||
...manifest,
|
||||
contributions: manifest.contributions.map((contribution) =>
|
||||
contribution.id === "example.connection"
|
||||
? {
|
||||
...contribution,
|
||||
fields: [
|
||||
...contribution.fields,
|
||||
{
|
||||
key: "protocol",
|
||||
type: "radio",
|
||||
binding: "config",
|
||||
default: "https",
|
||||
options: [
|
||||
{ label: "HTTPS", value: "https" },
|
||||
{ label: "HTTP", value: "http" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
: contribution,
|
||||
),
|
||||
};
|
||||
const valid = validateRecord(radioManifest, { providerId: "example.connection", values: { ...values, protocol: "http" } });
|
||||
assert.equal(valid.values.protocol, "http");
|
||||
assert.throws(() => validateRecord(radioManifest, { providerId: "example.connection", values: { ...values, protocol: "ftp" } }), /Invalid option: protocol/);
|
||||
});
|
||||
test("lifecycle failures do not report success or discard a live connection", async (t) => {
|
||||
const { host, request } = await fixture(t);
|
||||
const original = host.sidecar.request.bind(host.sidecar);
|
||||
@@ -270,6 +299,19 @@ test("a frontend-only workbench opens without a connection or sidecar", async (t
|
||||
assert.match(rpc.error.message, /no backend/);
|
||||
assert.equal((await request("frames/close", { id: f.id })).status, 200);
|
||||
});
|
||||
test("srcdoc workbench documents inline local Vite scripts and styles", async (t) => {
|
||||
const { root, request } = await fixture(t, true);
|
||||
await mkdir(join(root, "ui/assets"));
|
||||
await writeFile(join(root, "ui/assets/app.js"), "document.body.dataset.loaded = 'yes';");
|
||||
await writeFile(join(root, "ui/assets/app.css"), "body { color: red; }");
|
||||
await writeFile(join(root, "ui/index.html"), '<html><head><link rel="stylesheet" href="/assets/app.css"></head><body><script type="module" src="/assets/app.js"></script></body></html>');
|
||||
const opened = await request("workbenches/open", { contributionId: "example.main" });
|
||||
const document = (await request("frame-document", { frameId: opened.value.frame.id })).value;
|
||||
assert.match(document.html, /document\.body\.dataset\.loaded/);
|
||||
assert.match(document.html, /body \{ color: red; \}/);
|
||||
assert.doesNotMatch(document.html, /src="\/assets\/app\.js"/);
|
||||
assert.doesNotMatch(document.html, /href="\/assets\/app\.css"/);
|
||||
});
|
||||
test("save, reload, iframe isolation, generic RPC and close lifecycle", async (t) => {
|
||||
const { root, host, request } = await fixture(t);
|
||||
const saved = await request("connections/save", { providerId: "example.connection", values });
|
||||
|
||||
@@ -415,6 +415,12 @@ onBeforeUnmount(() => {
|
||||
<select v-if="field.type === 'select'" v-model="draft.values[field.key]" class="select select-sm mt-1 w-full" :aria-label="field.label" :required="field.required" :disabled="busy">
|
||||
<option v-for="option in field.options" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
<div v-else-if="field.type === 'radio'" class="mt-2 flex flex-wrap gap-x-4 gap-y-2" role="radiogroup" :aria-label="field.label">
|
||||
<label v-for="option in field.options" :key="option.value" class="flex items-center gap-2">
|
||||
<input v-model="draft.values[field.key]" type="radio" :name="field.key" :value="option.value" :disabled="busy" />
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<input v-else-if="field.type === 'boolean'" v-model="draft.values[field.key]" type="checkbox" class="checkbox checkbox-sm ml-2" :aria-label="field.label" :disabled="busy" />
|
||||
<textarea v-else-if="field.type === 'textarea'" v-model="draft.values[field.key]" class="textarea mt-1 w-full" :aria-label="field.label" :required="field.required" :disabled="busy" />
|
||||
<input v-else-if="field.type === 'number'" v-model.number="draft.values[field.key]" type="number" class="input input-sm mt-1 w-full" :aria-label="field.label" :required="field.required" :disabled="busy" />
|
||||
|
||||
Generated
+6
-6
@@ -356,22 +356,22 @@ importers:
|
||||
packages/plugin-cli:
|
||||
optionalDependencies:
|
||||
'@dbx-app/plugin-cli-darwin-arm64':
|
||||
specifier: 0.1.0
|
||||
specifier: 0.1.1
|
||||
version: link:../plugin-cli-darwin-arm64
|
||||
'@dbx-app/plugin-cli-darwin-x64':
|
||||
specifier: 0.1.0
|
||||
specifier: 0.1.1
|
||||
version: link:../plugin-cli-darwin-x64
|
||||
'@dbx-app/plugin-cli-linux-arm64-gnu':
|
||||
specifier: 0.1.0
|
||||
specifier: 0.1.1
|
||||
version: link:../plugin-cli-linux-arm64-gnu
|
||||
'@dbx-app/plugin-cli-linux-x64-gnu':
|
||||
specifier: 0.1.0
|
||||
specifier: 0.1.1
|
||||
version: link:../plugin-cli-linux-x64-gnu
|
||||
'@dbx-app/plugin-cli-win32-arm64':
|
||||
specifier: 0.1.0
|
||||
specifier: 0.1.1
|
||||
version: link:../plugin-cli-win32-arm64
|
||||
'@dbx-app/plugin-cli-win32-x64':
|
||||
specifier: 0.1.0
|
||||
specifier: 0.1.1
|
||||
version: link:../plugin-cli-win32-x64
|
||||
|
||||
packages/plugin-cli-darwin-arm64: {}
|
||||
|
||||
Reference in New Issue
Block a user