feat(plugins): display manifest icons in dev host
This commit is contained in:
5 files changed
+129
-5
No files matched your search
@@ -42,6 +42,8 @@ When `ui_watch` is configured, its stdout must emit a standalone `DBX_UI_BUILD_S
|
||||
|
||||
Workbench contributions can be opened without a connection, including frontend-only plugins. Connection providers generate forms from declared fields, defaults, options and bindings. Port zero remains valid at the framework layer; plugins validate their own connection semantics.
|
||||
|
||||
Workbench entries, tabs and connection rows display their contribution's `icon`, falling back to the plugin-level `icon`. Icon paths are local to the plugin project (not the UI root); remote URLs and paths escaping the project are rejected. Missing or unreadable icons retain the generic host icons. Custom connection icons retain a separate connection-status dot.
|
||||
|
||||
Connection testing, connecting and disconnecting use the standard lifecycle methods. Custom RPC methods and results are forwarded without interpreting their business meaning. Workbench tabs are keyed by contribution and optional connection ID, retain their iframe when switched, and reuse an existing tab when opened again. Closing the last tab for a connection disconnects it after confirmation.
|
||||
|
||||
Lifecycle replies with `success: false` are failures, matching the real host. Browser pages have independent workbench ownership. After a page's event stream disconnects, its frames remain for a 30-second reconnect grace period, then are removed; connections still used by another page remain open. Saved connection configuration is unaffected. Manifest localization is performed only by the shell; bootstrap returns one unmodified Manifest.
|
||||
|
||||
@@ -223,6 +223,16 @@ export async function createMockHost(options) {
|
||||
}
|
||||
async function api(session, route, p) {
|
||||
if (route === "/api/bridge") return bridge(session, p);
|
||||
if (route === "/api/icon") {
|
||||
const contribution = p.contributionId === undefined ? undefined : manifest.contributions?.find((item) => item.id === p.contributionId);
|
||||
if (p.contributionId !== undefined && !contribution) throw new Error("Unknown icon contribution");
|
||||
const icon = contribution?.icon || manifest.icon;
|
||||
if (!icon) return null;
|
||||
if (typeof icon !== "string" || /[:\\\0]/.test(icon) || icon.split("/").some((part) => !part || part === "." || part === "..")) throw new Error("Invalid plugin icon path");
|
||||
const asset = await readAsset(project, icon);
|
||||
if (!asset.contentType.startsWith("image/")) throw new Error("Plugin icon is not an image");
|
||||
return asset;
|
||||
}
|
||||
if (route === "/api/frame-document") {
|
||||
const f = frameFor(session, p.frameId);
|
||||
const asset = await readAsset(uiRoot, entry);
|
||||
@@ -340,6 +350,7 @@ export async function createMockHost(options) {
|
||||
"/api/bridge",
|
||||
"/api/frame-document",
|
||||
"/api/workbenches/open",
|
||||
"/api/icon",
|
||||
"/api/connections/edit",
|
||||
"/api/connections/save",
|
||||
"/api/connections/test",
|
||||
|
||||
@@ -168,10 +168,11 @@ test("refresh cleanup does not disconnect a connection still used by another pag
|
||||
assert.equal(closed.value.connections[0].connected, false);
|
||||
});
|
||||
async function fixture(t, frontend = false, overrides = {}) {
|
||||
const { pluginManifest = manifest, ...hostOverrides } = overrides;
|
||||
const root = await mkdtemp(join(tmpdir(), "dbx-mock-test-"));
|
||||
await mkdir(join(root, "ui"));
|
||||
await writeFile(join(root, "ui/index.html"), "<html><head></head><body>Test</body></html>");
|
||||
const actual = frontend ? { ...manifest, entrypoints: { ui: manifest.entrypoints.ui }, contributions: manifest.contributions.filter((c) => c.type === "workbench") } : manifest;
|
||||
const actual = frontend ? { ...pluginManifest, entrypoints: { ui: pluginManifest.entrypoints.ui }, contributions: pluginManifest.contributions.filter((c) => c.type === "workbench") } : pluginManifest;
|
||||
await writeFile(join(root, "manifest.json"), JSON.stringify(actual));
|
||||
const host = await createMockHost({
|
||||
project: root,
|
||||
@@ -186,7 +187,7 @@ async function fixture(t, frontend = false, overrides = {}) {
|
||||
buildBackend: async () => {
|
||||
throw new Error("Intentional build failure");
|
||||
},
|
||||
...overrides,
|
||||
...hostOverrides,
|
||||
});
|
||||
t.after(async () => {
|
||||
await host.close();
|
||||
@@ -375,6 +376,69 @@ test("asset reader rejects traversal and symlinks outside the UI root", async (t
|
||||
assert.equal(Buffer.from((await readAsset(join(root, "ui"), "index.html")).dataBase64, "base64").toString(), await readFile(join(root, "ui/index.html"), "utf8"));
|
||||
});
|
||||
|
||||
test("icons use contribution overrides and plugin fallback from the project root", async (t) => {
|
||||
const pluginManifest = {
|
||||
...manifest,
|
||||
icon: "assets/plugin.svg",
|
||||
contributions: [...manifest.contributions.map((contribution) => ({ ...contribution, icon: `assets/${contribution.type}.svg` })), { type: "workbench", id: "example.fallback", label: "Fallback" }],
|
||||
};
|
||||
const { root, request } = await fixture(t, false, { pluginManifest });
|
||||
await mkdir(join(root, "assets"));
|
||||
for (const name of ["plugin", "connection-provider", "workbench"]) {
|
||||
await writeFile(join(root, `assets/${name}.svg`), `<svg xmlns="http://www.w3.org/2000/svg" aria-label="${name}"/>`);
|
||||
}
|
||||
for (const [contributionId, name] of [
|
||||
[undefined, "plugin"],
|
||||
["example.connection", "connection-provider"],
|
||||
["example.main", "workbench"],
|
||||
["example.fallback", "plugin"],
|
||||
]) {
|
||||
const result = await request("icon", { contributionId, path: "manifest.json" });
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(result.value.contentType, "image/svg+xml");
|
||||
assert.equal(Buffer.from(result.value.dataBase64, "base64").toString(), await readFile(join(root, `assets/${name}.svg`), "utf8"));
|
||||
}
|
||||
});
|
||||
|
||||
test("icons retain browser session protection and reject unknown contributions", async (t) => {
|
||||
const { request } = await fixture(t);
|
||||
assert.equal((await request("icon", {}, { Origin: "https://evil.example" })).status, 403);
|
||||
assert.equal((await request("icon", {}, { "X-Mock-Csrf": "wrong" })).status, 403);
|
||||
assert.equal((await request("icon", {}, { Cookie: "" })).status, 403);
|
||||
assert.equal((await request("icon", { contributionId: "missing" })).status, 400);
|
||||
});
|
||||
|
||||
test("plugins without icons return null without preventing workbench startup", async (t) => {
|
||||
const { request } = await fixture(t, true);
|
||||
assert.deepEqual(await request("icon"), { status: 200, value: null });
|
||||
assert.deepEqual(await request("icon", { contributionId: "example.main" }), { status: 200, value: null });
|
||||
assert.equal((await request("workbenches/open", { contributionId: "example.main" })).status, 200);
|
||||
});
|
||||
|
||||
test("icon reads reject unsafe paths, missing files, non-images, symlink escapes and oversized files", async (t) => {
|
||||
const paths = ["../outside.svg", "/outside.svg", "assets/../manifest.json", "assets\\icon.svg", "https://example.com/icon.svg", "data:image/svg+xml;base64,PHN2Zy8+", "assets/missing.svg", "manifest.json", "assets/link.svg", "assets/large.svg"];
|
||||
const pluginManifest = {
|
||||
...manifest,
|
||||
contributions: paths.map((icon, index) => ({ type: "workbench", id: `example.icon-${index}`, label: "Icon", icon })),
|
||||
};
|
||||
const { root, request, host } = await fixture(t, true, { pluginManifest });
|
||||
const outside = await mkdtemp(join(tmpdir(), "dbx-icon-outside-"));
|
||||
t.after(() => rm(outside, { recursive: true, force: true }));
|
||||
await mkdir(join(root, "assets"));
|
||||
await writeFile(join(outside, "private.svg"), "private-icon-content");
|
||||
await symlink(join(outside, "private.svg"), join(root, "assets/link.svg"));
|
||||
await writeFile(join(root, "assets/large.svg"), Buffer.alloc(8 * 1024 * 1024 + 1));
|
||||
for (const contribution of pluginManifest.contributions) {
|
||||
const result = await request("icon", { contributionId: contribution.id });
|
||||
assert.equal(result.status, 400, contribution.icon);
|
||||
assert.equal(result.value, undefined);
|
||||
assert.ok(!JSON.stringify(result).includes(root));
|
||||
assert.ok(!JSON.stringify(result).includes("private-icon-content"));
|
||||
}
|
||||
assert.equal(host.sidecar.state, "frontend");
|
||||
assert.equal((await request("workbenches/open", { contributionId: "example.icon-0" })).status, 200);
|
||||
});
|
||||
|
||||
test("UI root overrides and port collision fallback work without changing the plugin manifest", async (t) => {
|
||||
const { root, host } = await fixture(t);
|
||||
await mkdir(join(root, "alternate-ui"));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
||||
import { Plus, Upload, Plug, Unplug, Pencil, Trash2, X, RefreshCw, Sun, Moon, Terminal, Settings2, Languages } from "lucide-vue-next";
|
||||
import { hostMessage } from "./messages.js";
|
||||
import DebugPanel from "./DebugPanel.vue";
|
||||
import PluginIcon from "./PluginIcon.vue";
|
||||
import { localizeManifest, translate } from "./i18n.js";
|
||||
const rawManifest = ref(),
|
||||
connections = ref([]),
|
||||
@@ -73,6 +74,15 @@ const editor = ref(),
|
||||
draft = ref(),
|
||||
editing = ref(false);
|
||||
const windows = new Map();
|
||||
const iconRequests = new Map();
|
||||
function loadIcon(contributionId) {
|
||||
if (!iconRequests.has(contributionId))
|
||||
iconRequests.set(
|
||||
contributionId,
|
||||
api("icon", { contributionId }).catch(() => null),
|
||||
);
|
||||
return iconRequests.get(contributionId);
|
||||
}
|
||||
let csrf, stream, finishAsk;
|
||||
const providers = computed(() => manifest.value?.contributions.filter((c) => c.type === "connection-provider") || []);
|
||||
const workbenches = computed(() => manifest.value?.contributions.filter((c) => c.type === "workbench") || []);
|
||||
@@ -345,7 +355,7 @@ onBeforeUnmount(() => {
|
||||
<div v-if="workbenches.length" class="border-b border-base-300 p-2">
|
||||
<h2 class="px-1 py-2 text-xs font-semibold">{{ t("工作台") }}</h2>
|
||||
<button v-for="workbench in workbenches" :key="workbench.id" class="btn btn-sm btn-ghost w-full justify-start" :title="workbench.label" :disabled="busy" @click="openWorkbench(workbench)">
|
||||
<Terminal :size="14" /><span class="truncate">{{ workbench.label }}</span>
|
||||
<PluginIcon :contribution-id="workbench.id" :load-icon="loadIcon"><Terminal :size="14" /></PluginIcon><span class="truncate">{{ workbench.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 border-b border-base-300 p-2">
|
||||
@@ -356,7 +366,7 @@ onBeforeUnmount(() => {
|
||||
<div class="min-h-0 flex-1 overflow-auto p-2">
|
||||
<div v-for="c in connections" :key="c.id" class="mb-1 border-b border-base-300 pb-2" :data-connection="c.id">
|
||||
<button class="flex w-full min-w-0 items-center gap-2 py-2 text-left text-sm" :title="c.name" :disabled="busy || state !== 'ready'" @click="connect(c)">
|
||||
<Plug :size="15" class="shrink-0" :class="c.connected ? 'text-success' : 'text-base-content/40'" /><span class="truncate">{{ c.name }}</span>
|
||||
<PluginIcon :contribution-id="c.providerId" :load-icon="loadIcon" :connected="c.connected"><Plug :size="15" :class="c.connected ? 'text-success' : 'text-base-content/40'" /></PluginIcon><span class="truncate">{{ c.name }}</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="mr-auto truncate text-xs text-base-content/60">{{ providers.find((p) => p.id === c.providerId)?.label || c.label }}</span
|
||||
@@ -372,7 +382,8 @@ onBeforeUnmount(() => {
|
||||
<div class="flex min-h-10 items-center border-b border-base-300">
|
||||
<div class="flex min-w-0 flex-1 overflow-x-auto" role="tablist">
|
||||
<div v-for="f in frames" :key="f.id" class="flex shrink-0 items-center border-r border-base-300" :class="active === f.id ? 'bg-base-200 border-b-2 border-b-info' : ''">
|
||||
<button role="tab" class="max-w-44 truncate px-3 py-2 text-xs" :aria-selected="active === f.id" :title="frameName(f)" @click="active = f.id">{{ frameName(f) }}</button
|
||||
<button role="tab" class="flex max-w-44 items-center gap-2 px-3 py-2 text-xs" :aria-selected="active === f.id" :title="frameName(f)" @click="active = f.id">
|
||||
<PluginIcon :contribution-id="f.contributionId" :load-icon="loadIcon"><Terminal :size="14" /></PluginIcon><span class="truncate">{{ frameName(f) }}</span></button
|
||||
><button class="btn btn-xs btn-square btn-ghost mr-1" :aria-label="t('关闭') + ' ' + frameName(f)" :disabled="busy" @click="closeFrame(f)"><X :size="12" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
contributionId: String,
|
||||
loadIcon: { type: Function, required: true },
|
||||
connected: { type: Boolean, default: undefined },
|
||||
});
|
||||
const source = ref("");
|
||||
let generation = 0;
|
||||
watch(
|
||||
() => [props.contributionId, props.loadIcon],
|
||||
async ([contributionId, loadIcon]) => {
|
||||
const current = ++generation;
|
||||
source.value = "";
|
||||
try {
|
||||
const asset = await loadIcon(contributionId);
|
||||
if (current === generation && /^image\/(svg\+xml|png|jpeg|gif|webp)$/.test(asset?.contentType) && typeof asset.dataBase64 === "string") {
|
||||
source.value = `data:${asset.contentType};base64,${asset.dataBase64}`;
|
||||
}
|
||||
} catch {
|
||||
if (current === generation) source.value = "";
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
onBeforeUnmount(() => generation++);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="relative inline-flex size-3.5 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
<img v-if="source" :src="source" alt="" class="size-full object-contain" @error="source = ''" />
|
||||
<slot v-else />
|
||||
<span v-if="source && connected !== undefined" :data-connected="connected" class="absolute -bottom-0.5 -right-0.5 size-1.5 rounded-full ring-1 ring-base-200" :class="connected ? 'bg-success' : 'bg-base-content/40'" />
|
||||
</span>
|
||||
</template>
|
||||
Reference in new issue
Block a user