diff --git a/apps/desktop/src/components/plugins/PluginConnectionFields.spec.ts b/apps/desktop/src/components/plugins/PluginConnectionFields.spec.ts index 2731752bea..b8bb52c5c5 100644 --- a/apps/desktop/src/components/plugins/PluginConnectionFields.spec.ts +++ b/apps/desktop/src/components/plugins/PluginConnectionFields.spec.ts @@ -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("input[type='radio']")); + + expect(radios).toHaveLength(2); + expect(radios[0]?.checked).toBe(true); + radios[1]?.click(); + await nextTick(); + + expect(state.values.protocol).toBe("http"); + }); }); diff --git a/apps/desktop/src/components/plugins/PluginConnectionFields.vue b/apps/desktop/src/components/plugins/PluginConnectionFields.vue index 57bc128727..b3631b3a5a 100644 --- a/apps/desktop/src/components/plugins/PluginConnectionFields.vue +++ b/apps/desktop/src/components/plugins/PluginConnectionFields.vue @@ -98,6 +98,12 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue { +
+ +
diff --git a/apps/desktop/src/components/plugins/PluginFileManager.vue b/apps/desktop/src/components/plugins/PluginFileManager.vue index cf147454e9..9efd22f48d 100644 --- a/apps/desktop/src/components/plugins/PluginFileManager.vue +++ b/apps/desktop/src/components/plugins/PluginFileManager.vue @@ -1,10 +1,11 @@ `; - if (/]*>/i.test(html)) return html.replace(/]*>/i, (match) => match + csp + bootstrap); - return `${csp}${bootstrap}${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(/]*?)\bsrc\s*=\s*(["'])([^"']+)\2([^>]*)>\s*<\/script>/gi, (match, before, _quote, url, after) => { + const content = inlineAsset(url); + return content === undefined ? match : `${content.replace(/<\/script/gi, "<\\/script")}`; + }); + const withInlineStyles = withInlineScripts.replace(/]*)>/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 : ``; + }); + if (/]*>/i.test(withInlineStyles)) return withInlineStyles.replace(/]*>/i, (match) => match + csp + bootstrap); + return `${csp}${bootstrap}${withInlineStyles}`; } diff --git a/plugins/sdk/dev-host/connections.mjs b/plugins/sdk/dev-host/connections.mjs index 0e63a53b90..6fa9eb21e0 100644 --- a/plugins/sdk/dev-host/connections.mjs +++ b/plugins/sdk/dev-host/connections.mjs @@ -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 }); } diff --git a/plugins/sdk/dev-host/server.mjs b/plugins/sdk/dev-host/server.mjs index 9cfca8f70b..ea5987ab2f 100644 --- a/plugins/sdk/dev-host/server.mjs +++ b/plugins/sdk/dev-host/server.mjs @@ -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(/]*?\bsrc\s*=\s*(["'])([^"']+)\1[^>]*>/gi)) urls.add(match[2]); + for (const match of html.matchAll(/]*)>/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) { diff --git a/plugins/sdk/dev-host/tests/server.test.mjs b/plugins/sdk/dev-host/tests/server.test.mjs index 1f9cb6a13d..612c232fea 100644 --- a/plugins/sdk/dev-host/tests/server.test.mjs +++ b/plugins/sdk/dev-host/tests/server.test.mjs @@ -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"), ''); + 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 }); diff --git a/plugins/sdk/dev-host/ui/App.vue b/plugins/sdk/dev-host/ui/App.vue index 62f6f4b0cc..0583d0bb3c 100644 --- a/plugins/sdk/dev-host/ui/App.vue +++ b/plugins/sdk/dev-host/ui/App.vue @@ -415,6 +415,12 @@ onBeforeUnmount(() => { +
+ +