feat(plugin): complete plugin framework workflow

This commit is contained in:
t8y2
2026-09-13 01:10:18 +08:00
parent c30e8fadff
commit 6ab1d9bed7
20 changed files with 350 additions and 63 deletions
@@ -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
View File
@@ -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()];
}
+1 -1
View File
@@ -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;
+1
View File
@@ -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(),
+54 -5
View File
@@ -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();
+24 -6
View File
@@ -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();
+1 -1
View File
@@ -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)]
+10 -1
View File
@@ -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
+3 -3
View File
@@ -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"] } } }
}
]
},
+19 -3
View File
@@ -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>`;
}
+1 -1
View File
@@ -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 });
}
+29 -1
View File
@@ -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 });
+6
View File
@@ -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" />
+6 -6
View File
@@ -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: {}