🎉 Add MCP plugin embedded execution (#8368)

*  Add core changes for mcp server

*  Changes to plugins-runtime to add mcp extensions

*  Changes to MCP plugin

*  Changes post-review and ci fixes
This commit is contained in:
Alonso Torres 2026-02-17 09:18:46 +01:00 committed by GitHub
parent b87d7e3de0
commit c32a336c50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 199 additions and 949 deletions

View File

@ -87,3 +87,20 @@
{:order-by [[:expires-at :asc] [:created-at :asc]] {:order-by [[:expires-at :asc] [:created-at :asc]]
:columns [:id :name :perms :type :created-at :updated-at :expires-at]}) :columns [:id :name :perms :type :created-at :updated-at :expires-at]})
(mapv decode-row))) (mapv decode-row)))
(def ^:private schema:get-current-mcp-token
[:map {:title "get-current-mcp-token"}])
(sv/defmethod ::get-current-mcp-token
{::doc/added "2.15"
::sm/params schema:get-current-mcp-token}
[{:keys [::db/pool]} {:keys [::rpc/profile-id ::rpc/request-at]}]
(->> (db/query pool :access-token
{:profile-id profile-id
:type "mcp"}
{:order-by [[:expires-at :asc] [:created-at :asc]]
:columns [:token :expires-at]})
(remove #(ct/is-after? (:expires-at %) request-at))
(map decode-row)
(first)))

View File

@ -107,4 +107,18 @@
;; (th/print-result! out) ;; (th/print-result! out)
(t/is (nil? (:error out))) (t/is (nil? (:error out)))
(let [results (:result out)] (let [results (:result out)]
(t/is (= 2 (count results)))))))) (t/is (= 2 (count results))))))
(t/testing "get mcp token"
(let [_ (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:type "mcp"
:name "token 1"
:perms ["get-profile"]})
{:keys [error result]}
(th/command! {::th/type :get-current-mcp-token
::rpc/profile-id (:id prof)})]
;; (th/print-result! result)
(t/is (nil? error))
(t/is (string? (:token result)))))))

View File

@ -42,12 +42,13 @@
"clear:shadow-cache": "rm -rf .shadow-cljs", "clear:shadow-cache": "rm -rf .shadow-cljs",
"watch": "exit 0", "watch": "exit 0",
"watch:app": "pnpm run clear:shadow-cache && pnpm run build:wasm && concurrently --kill-others-on-fail \"pnpm run watch:app:assets\" \"pnpm run watch:app:main\" \"pnpm run watch:app:libs\"", "watch:app": "pnpm run clear:shadow-cache && pnpm run build:wasm && concurrently --kill-others-on-fail \"pnpm run watch:app:assets\" \"pnpm run watch:app:main\" \"pnpm run watch:app:libs\"",
"watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\"" "watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\"",
"postinstall": "(cd ../plugins/libs/plugins-runtime; pnpm install; pnpm run build)"
}, },
"devDependencies": { "devDependencies": {
"@penpot/draft-js": "workspace:./packages/draft-js", "@penpot/draft-js": "workspace:./packages/draft-js",
"@penpot/mousetrap": "workspace:./packages/mousetrap", "@penpot/mousetrap": "workspace:./packages/mousetrap",
"@penpot/plugins-runtime": "1.4.2", "@penpot/plugins-runtime": "link:../plugins/dist/plugins-runtime",
"@penpot/svgo": "penpot/svgo#v3.2", "@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "workspace:./text-editor", "@penpot/text-editor": "workspace:./text-editor",
"@penpot/tokenscript": "workspace:./packages/tokenscript", "@penpot/tokenscript": "workspace:./packages/tokenscript",

View File

@ -20,8 +20,8 @@ importers:
specifier: workspace:./packages/mousetrap specifier: workspace:./packages/mousetrap
version: link:packages/mousetrap version: link:packages/mousetrap
'@penpot/plugins-runtime': '@penpot/plugins-runtime':
specifier: 1.4.2 specifier: link:../plugins/dist/plugins-runtime
version: 1.4.2 version: link:../plugins/dist/plugins-runtime
'@penpot/svgo': '@penpot/svgo':
specifier: penpot/svgo#v3.2 specifier: penpot/svgo#v3.2
version: svgo@https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b version: svgo@https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b
@ -581,15 +581,6 @@ packages:
'@dabh/diagnostics@2.0.8': '@dabh/diagnostics@2.0.8':
resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==}
'@endo/cache-map@1.1.0':
resolution: {integrity: sha512-owFGshs/97PDw9oguZqU/px8Lv1d0KjAUtDUiPwKHNXRVUE/jyettEbRoTbNJR1OaI8biMn6bHr9kVJsOh6dXw==}
'@endo/env-options@1.1.11':
resolution: {integrity: sha512-p9OnAPsdqoX4YJsE98e3NBVhIr2iW9gNZxHhAI2/Ul5TdRfoOViItzHzTqrgUVopw6XxA1u1uS6CykLMDUxarA==}
'@endo/immutable-arraybuffer@1.1.2':
resolution: {integrity: sha512-u+NaYB2aqEugQ3u7w3c5QNkPogf8q/xGgsPaqdY6pUiGWtYiTiFspKFcha6+oeZhWXWQ23rf0KrUq0kfuzqYyQ==}
'@esbuild/aix-ppc64@0.21.5': '@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -1258,12 +1249,6 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
'@penpot/plugin-types@1.4.2':
resolution: {integrity: sha512-O8wU6RSYE8bIVU7g8cSTYi32ppxs3R13dq7X3Nn9tmDaJjBOKOBpVLuoRPIp3fJC65fv8/7om0sdrtFoL5v19g==}
'@penpot/plugins-runtime@1.4.2':
resolution: {integrity: sha512-y9TDZOnb96JBW9E33dHKpmTMeAPXLtHDIZruUVjtM8hBJWZK7RCv+vAGDGxeoZJC/OB2YAHrCZG+mukePBzcuQ==}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -4636,9 +4621,6 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
ses@1.14.0:
resolution: {integrity: sha512-T07hNgOfVRTLZGwSS50RnhqrG3foWP+rM+Q5Du4KUQyMLFI3A8YA4RKl0jjZzhihC1ZvDGrWi/JMn4vqbgr/Jg==}
set-function-length@1.2.2: set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -5499,9 +5481,6 @@ packages:
peerDependencies: peerDependencies:
zod: ^3.25.0 || ^4.0.0 zod: ^3.25.0 || ^4.0.0
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.6: zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
@ -5775,12 +5754,6 @@ snapshots:
enabled: 2.0.0 enabled: 2.0.0
kuler: 2.0.0 kuler: 2.0.0
'@endo/cache-map@1.1.0': {}
'@endo/env-options@1.1.11': {}
'@endo/immutable-arraybuffer@1.1.2': {}
'@esbuild/aix-ppc64@0.21.5': '@esbuild/aix-ppc64@0.21.5':
optional: true optional: true
@ -6297,14 +6270,6 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6
optional: true optional: true
'@penpot/plugin-types@1.4.2': {}
'@penpot/plugins-runtime@1.4.2':
dependencies:
'@penpot/plugin-types': 1.4.2
ses: 1.14.0
zod: 3.25.76
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
@ -10000,12 +9965,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
ses@1.14.0:
dependencies:
'@endo/cache-map': 1.1.0
'@endo/env-options': 1.1.11
'@endo/immutable-arraybuffer': 1.1.2
set-function-length@1.2.2: set-function-length@1.2.2:
dependencies: dependencies:
define-data-property: 1.1.4 define-data-property: 1.1.4
@ -10974,6 +10933,4 @@ snapshots:
dependencies: dependencies:
zod: 4.3.6 zod: 4.3.6
zod@3.25.76: {}
zod@4.3.6: {} zod@4.3.6: {}

View File

@ -119,6 +119,10 @@
(normalize-uri (or (obj/get global "penpotPublicURI") (normalize-uri (or (obj/get global "penpotPublicURI")
(obj/get location "origin")))) (obj/get location "origin"))))
(def mcp-ws-uri
(or (some-> (obj/get global "penpotMcpServerURI") u/uri)
(u/join public-uri "mcp/ws")))
(def rasterizer-uri (def rasterizer-uri
(or (some-> (obj/get global "penpotRasterizerURI") normalize-uri) (or (some-> (obj/get global "penpotRasterizerURI") normalize-uri)
public-uri)) public-uri))

View File

@ -65,8 +65,23 @@
(update [_ state] (update [_ state]
(update-in state [:workspace-local :open-plugins] (fnil disj #{}) id)))) (update-in state [:workspace-local :open-plugins] (fnil disj #{}) id))))
(defn start-plugin!
[{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions]
(.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id
:name name
:version version
:description description
:host host
:code code
:allowBackground (boolean allow-background)
:permissions (apply array permissions)}
nil
extensions))
(defn- load-plugin! (defn- load-plugin!
[{:keys [plugin-id name description host code icon permissions]}] [{:keys [plugin-id name description host code icon permissions] :as params}]
(try (try
(st/emit! (save-current-plugin plugin-id) (st/emit! (save-current-plugin plugin-id)
(reset-plugin-flags plugin-id)) (reset-plugin-flags plugin-id))

View File

@ -52,6 +52,7 @@
[app.main.data.workspace.layers :as dwly] [app.main.data.workspace.layers :as dwly]
[app.main.data.workspace.layout :as layout] [app.main.data.workspace.layout :as layout]
[app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.mcp :as mcp]
[app.main.data.workspace.notifications :as dwn] [app.main.data.workspace.notifications :as dwn]
[app.main.data.workspace.pages :as dwpg] [app.main.data.workspace.pages :as dwpg]
[app.main.data.workspace.path :as dwdp] [app.main.data.workspace.path :as dwdp]
@ -212,7 +213,8 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ _ _] (watch [_ _ _]
(rx/of (dp/check-open-plugin) (rx/of (dp/check-open-plugin)
(fdf/fix-deleted-fonts-for-local-library file-id))))) (fdf/fix-deleted-fonts-for-local-library file-id)
(mcp/init-mcp-connexion)))))
(defn- bundle-fetched (defn- bundle-fetched
[{:keys [file file-id thumbnails] :as bundle}] [{:keys [file file-id thumbnails] :as bundle}]

View File

@ -0,0 +1,58 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.data.workspace.mcp
(:require
[app.common.logging :as log]
[app.common.uri :as u]
[app.config :as cf]
[app.main.data.plugins :as dp]
[app.main.repo :as rp]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(log/set-level! :info)
(def ^:private default-manifest
{:code "plugin.js"
:name "Penpot MCP Plugin"
:version 2
:plugin-id "96dfa740-005d-8020-8007-55ede24a2bae"
:description "This plugin enables interaction with the Penpot MCP server"
:allow-background true
:permissions
#{"library:read" "library:write" "comment:read" "content:write" "comment:write"
"content:read"}})
(defn init-mcp!
[]
(->> (rp/cmd! :get-current-mcp-token)
(rx/subs!
(fn [{:keys [token]}]
(when token
(dp/start-plugin!
(assoc default-manifest
:url (str (u/join cf/public-uri "plugins/mcp/manifest.json"))
:host (str (u/join cf/public-uri "plugins/mcp/")))
;; API extension for MCP server
#js {:mcp
#js
{:getToken (constantly token)
:getServerUrl #(str cf/mcp-ws-uri)
:setMcpStatus
(fn [status]
;; TODO: Visual feedback
(log/info :hint "MCP STATUS" :status status))}}))))))
(defn init-mcp-connexion
[]
(ptk/reify ::init-mcp-connexion
ptk/EffectEvent
(effect [_ state _]
(when (and (contains? cf/flags :mcp)
(-> state :profile :props :mcp-status))
(init-mcp!)))))

11
mcp/packages/plugin/src/index.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
interface McpOptions {
getToken(): string;
getServerUrl(): string;
setMcpStatus(status: string);
}
declare global {
const mcp: undefined | McpOptions;
}
export {};

View File

@ -19,12 +19,19 @@ const statusElement = document.getElementById("connection-status");
* @param isConnectedState - whether the connection is in a connected state (affects color) * @param isConnectedState - whether the connection is in a connected state (affects color)
* @param message - optional additional message to append to the status * @param message - optional additional message to append to the status
*/ */
function updateConnectionStatus(status: string, isConnectedState: boolean, message?: string): void { function updateConnectionStatus(code: string, status: string, isConnectedState: boolean, message?: string): void {
if (statusElement) { if (statusElement) {
const displayText = message ? `${status}: ${message}` : status; const displayText = message ? `${status}: ${message}` : status;
statusElement.textContent = displayText; statusElement.textContent = displayText;
statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)"; statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)";
} }
parent.postMessage(
{
type: "update-connection-status",
status: code,
},
"*"
);
} }
/** /**
@ -44,25 +51,24 @@ function sendTaskResponse(response: any): void {
/** /**
* Establishes a WebSocket connection to the MCP server. * Establishes a WebSocket connection to the MCP server.
*/ */
function connectToMcpServer(): void { function connectToMcpServer(baseUrl?: string, token?: string): void {
if (ws?.readyState === WebSocket.OPEN) { if (ws?.readyState === WebSocket.OPEN) {
updateConnectionStatus("Already connected", true); updateConnectionStatus("connected", "Already connected", true);
return; return;
} }
try { try {
let wsUrl = PENPOT_MCP_WEBSOCKET_URL; let wsUrl = baseUrl || PENPOT_MCP_WEBSOCKET_URL;
if (isMultiUserMode) { if (isMultiUserMode && token) {
// TODO obtain proper userToken from penpot wsUrl += `?userToken=${encodeURIComponent(token)}`;
const userToken = "dummyToken";
wsUrl += `?userToken=${encodeURIComponent(userToken)}`;
} }
ws = new WebSocket(wsUrl); ws = new WebSocket(wsUrl);
updateConnectionStatus("Connecting...", false); updateConnectionStatus("connecting", "Connecting...", false);
ws.onopen = () => { ws.onopen = () => {
console.log("Connected to MCP server"); console.log("Connected to MCP server");
updateConnectionStatus("Connected to MCP server", true); updateConnectionStatus("connected", "Connected to MCP server", true);
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@ -79,19 +85,19 @@ function connectToMcpServer(): void {
ws.onclose = (event: CloseEvent) => { ws.onclose = (event: CloseEvent) => {
console.log("Disconnected from MCP server"); console.log("Disconnected from MCP server");
const message = event.reason || undefined; const message = event.reason || undefined;
updateConnectionStatus("Disconnected", false, message); updateConnectionStatus("disconnected", "Disconnected", false, message);
ws = null; ws = null;
}; };
ws.onerror = (error) => { ws.onerror = (error) => {
console.error("WebSocket error:", error); console.error("WebSocket error:", error);
// note: WebSocket error events typically don't contain detailed error messages // note: WebSocket error events typically don't contain detailed error messages
updateConnectionStatus("Connection error", false); updateConnectionStatus("error", "Connection error", false);
}; };
} catch (error) { } catch (error) {
console.error("Failed to connect to MCP server:", error); console.error("Failed to connect to MCP server:", error);
const message = error instanceof Error ? error.message : undefined; const message = error instanceof Error ? error.message : undefined;
updateConnectionStatus("Connection failed", false, message); updateConnectionStatus("error", "Connection failed", false, message);
} }
} }
@ -101,10 +107,14 @@ document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click"
// Listen plugin.ts messages // Listen plugin.ts messages
window.addEventListener("message", (event) => { window.addEventListener("message", (event) => {
if (event.data.source === "penpot") { if (event.data.type === "start-server") {
connectToMcpServer(event.data.url, event.data.token);
} else if (event.data.source === "penpot") {
document.body.dataset.theme = event.data.theme; document.body.dataset.theme = event.data.theme;
} else if (event.data.type === "task-response") { } else if (event.data.type === "task-response") {
// Forward task response back to MCP server // Forward task response back to MCP server
sendTaskResponse(event.data.response); sendTaskResponse(event.data.response);
} }
}); });
parent.postMessage({ type: "ui-initialized" }, "*");

View File

@ -1,6 +1,8 @@
import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler"; import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler";
import { Task, TaskHandler } from "./TaskHandler"; import { Task, TaskHandler } from "./TaskHandler";
mcp?.setMcpStatus("connecting");
/** /**
* Registry of all available task handlers. * Registry of all available task handlers.
*/ */
@ -11,12 +13,24 @@ declare const IS_MULTI_USER_MODE: boolean;
const isMultiUserMode = typeof IS_MULTI_USER_MODE !== "undefined" ? IS_MULTI_USER_MODE : false; const isMultiUserMode = typeof IS_MULTI_USER_MODE !== "undefined" ? IS_MULTI_USER_MODE : false;
// Open the plugin UI (main.ts) // Open the plugin UI (main.ts)
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}&multiUser=${isMultiUserMode}`, { width: 158, height: 200 }); penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}&multiUser=${isMultiUserMode}`, {
width: 158,
height: 200,
hidden: !!mcp,
} as any);
// Handle messages // Handle messages
penpot.ui.onMessage<string | { id: string; task: string; params: any }>((message) => { penpot.ui.onMessage<string | { id: string; type?: string; status?: string; task: string; params: any }>((message) => {
// Handle plugin task requests // Handle plugin task requests
if (typeof message === "object" && message.task && message.id) { if (mcp && typeof message === "object" && message.type === "ui-initialized") {
penpot.ui.sendMessage({
type: "start-server",
url: mcp?.getServerUrl(),
token: mcp?.getToken(),
});
} else if (typeof message === "object" && message.type === "update-connection-status") {
mcp?.setMcpStatus(message.status || "unknown");
} else if (typeof message === "object" && message.task && message.id) {
handlePluginTaskRequest(message).catch((error) => { handlePluginTaskRequest(message).catch((error) => {
console.error("Error in handlePluginTaskRequest:", error); console.error("Error in handlePluginTaskRequest:", error);
}); });

View File

@ -4,7 +4,6 @@ import baseConfig from "./vite.config";
export default mergeConfig( export default mergeConfig(
baseConfig, baseConfig,
defineConfig({ defineConfig({
base: "./",
plugins: [], plugins: [],
}) })
); );

View File

@ -23,7 +23,7 @@ export interface Penpot extends Omit<
open: ( open: (
name: string, name: string,
url: string, url: string,
options?: { width: number; height: number }, options?: { width: number; height: number; hidden: boolean },
) => void; ) => void;
size: { size: {

View File

@ -6,8 +6,8 @@
"ses": "^1.1.0", "ses": "^1.1.0",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"module": "./index.mjs", "module": "./dist/index.js",
"typings": "./index.d.ts", "typings": "./dist/index.d.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",

View File

@ -24,6 +24,10 @@ export function createModal(
inlineStart: window.innerWidth - width - 290, inlineStart: window.innerWidth - width - 290,
}; };
if (options?.hidden) {
modal.style.setProperty('display', 'none');
}
modal.style.setProperty( modal.style.setProperty(
'--modal-block-start', '--modal-block-start',
`${initialPosition.blockStart}px`, `${initialPosition.blockStart}px`,

View File

@ -83,7 +83,7 @@ describe('createPlugin', () => {
expect.any(Function), expect.any(Function),
expect.any(Function), expect.any(Function),
); );
expect(createSandbox).toHaveBeenCalledWith(mockPluginManager); expect(createSandbox).toHaveBeenCalledWith(mockPluginManager, undefined);
expect(mockSandbox.evaluate).toHaveBeenCalled(); expect(mockSandbox.evaluate).toHaveBeenCalled();
expect(result).toEqual({ expect(result).toEqual({
plugin: mockPluginManager, plugin: mockPluginManager,

View File

@ -7,6 +7,7 @@ export async function createPlugin(
context: Context, context: Context,
manifest: Manifest, manifest: Manifest,
onCloseCallback: () => void, onCloseCallback: () => void,
apiExtensions?: object,
) { ) {
const evaluateSandbox = async () => { const evaluateSandbox = async () => {
try { try {
@ -30,7 +31,7 @@ export async function createPlugin(
}, },
); );
const sandbox = createSandbox(plugin); const sandbox = createSandbox(plugin, apiExtensions);
evaluateSandbox(); evaluateSandbox();

View File

@ -2,8 +2,10 @@ import type { Penpot } from '@penpot/plugin-types';
import type { createPluginManager } from './plugin-manager'; import type { createPluginManager } from './plugin-manager';
import { createApi } from './api'; import { createApi } from './api';
import { ses } from './ses.js'; import { ses } from './ses.js';
export function createSandbox( export function createSandbox(
plugin: Awaited<ReturnType<typeof createPluginManager>>, plugin: Awaited<ReturnType<typeof createPluginManager>>,
apiExtensions?: object,
) { ) {
ses.hardenIntrinsics(); ses.hardenIntrinsics();
@ -51,7 +53,7 @@ export function createSandbox(
}); });
}; };
const publicPluginApi = { let publicPluginApi = {
penpot: proxyApi, penpot: proxyApi,
fetch: ses.harden(safeFetch), fetch: ses.harden(safeFetch),
setTimeout: ses.harden( setTimeout: ses.harden(
@ -123,6 +125,10 @@ export function createSandbox(
structuredClone: ses.harden(window.structuredClone), structuredClone: ses.harden(window.structuredClone),
}; };
if (apiExtensions) {
publicPluginApi = Object.assign(publicPluginApi, apiExtensions);
}
const compartment = ses.createCompartment(publicPluginApi); const compartment = ses.createCompartment(publicPluginApi);
return { return {

View File

@ -78,6 +78,7 @@ describe('plugin-loader', () => {
mockContext, mockContext,
manifest, manifest,
expect.any(Function), expect.any(Function),
undefined,
); );
expect(mockPluginApi.plugin.close).not.toHaveBeenCalled(); expect(mockPluginApi.plugin.close).not.toHaveBeenCalled();
expect(getPlugins()).toHaveLength(1); expect(getPlugins()).toHaveLength(1);
@ -130,6 +131,7 @@ describe('plugin-loader', () => {
mockContext, mockContext,
manifest, manifest,
expect.any(Function), expect.any(Function),
undefined,
); );
}); });
@ -144,6 +146,7 @@ describe('plugin-loader', () => {
mockContext, mockContext,
manifest, manifest,
expect.any(Function), expect.any(Function),
undefined,
); );
}); });
}); });

View File

@ -19,7 +19,10 @@ export const getPlugins = () => plugins;
const closeAllPlugins = () => { const closeAllPlugins = () => {
plugins.forEach((pluginApi) => { plugins.forEach((pluginApi) => {
pluginApi.plugin.close(); /* eslint-disable @typescript-eslint/no-explicit-any */
if (!(pluginApi.manifest as any)?.allowBackground) {
pluginApi.plugin.close();
}
}); });
plugins = []; plugins = [];
@ -38,6 +41,7 @@ window.addEventListener('message', (event) => {
export const loadPlugin = async function ( export const loadPlugin = async function (
manifest: Manifest, manifest: Manifest,
closeCallback?: () => void, closeCallback?: () => void,
apiExtensions?: object,
) { ) {
try { try {
const context = contextBuilder && contextBuilder(manifest.pluginId); const context = contextBuilder && contextBuilder(manifest.pluginId);
@ -58,6 +62,7 @@ export const loadPlugin = async function (
closeCallback(); closeCallback();
} }
}, },
apiExtensions,
); );
plugins.push(plugin); plugins.push(plugin);
@ -70,8 +75,9 @@ export const loadPlugin = async function (
export const ɵloadPlugin = async function ( export const ɵloadPlugin = async function (
manifest: Manifest, manifest: Manifest,
closeCallback?: () => void, closeCallback?: () => void,
apiExtensions?: object,
) { ) {
loadPlugin(manifest, closeCallback); loadPlugin(manifest, closeCallback, apiExtensions);
}; };
export const ɵloadPluginByUrl = async function (manifestUrl: string) { export const ɵloadPluginByUrl = async function (manifestUrl: string) {

View File

@ -3,4 +3,5 @@ import { z } from 'zod';
export const openUISchema = z.object({ export const openUISchema = z.object({
width: z.number().positive(), width: z.number().positive(),
height: z.number().positive(), height: z.number().positive(),
hidden: z.boolean().optional(),
}); });

873
plugins/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff