Merge pull request #12090 from oraios/mcp-global-plugin

🎉 Add global plugins and enable MCP across workspaces
This commit is contained in:
Alonso Torres 2026-10-07 15:38:17 +02:00 committed by GitHub
commit f62f4024be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 2245 additions and 353 deletions

View File

@ -47,6 +47,17 @@
(t/is (= [plugin-id-1] (:ids plugins)))
(t/is (= valid-plugin (get-in plugins [:data plugin-id-1]))))))
(t/deftest add-profile-plugin-accepts-global-plugins
(let [profile (th/create-profile* 1)
plugin (assoc valid-plugin
:scope "global"
:permissions #{"content:read" "allow:global"})
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin})]
(t/is (nil? (:error out)))
(t/is (= plugin (:result out)))))
(t/deftest add-profile-plugin-rejects-invalid-permissions
(let [profile (th/create-profile* 1)
plugin (assoc valid-plugin :permissions #{"content:read" "admin:delete"})

View File

@ -35,11 +35,12 @@
"clipboard:read" "clipboard:write"
"user:read"
"allow:downloads"
"allow:localstorage"})
"allow:localstorage"
"allow:global"})
(def schema:permissions
"Schema for plugin permissions - a set of valid permission strings."
[:set {:gen/max 11} (into [:enum] (sort valid-permissions))])
[:set {:gen/max 12} (into [:enum] (sort valid-permissions))])
(def max-plugins
"Maximum number of plugins a profile can hold."
@ -58,6 +59,7 @@
[:map
[:plugin-id :string]
[:version {:optional true} :int]
[:scope {:optional true} [:enum "workspace" "global"]]
[:name [:string {:max (:name registry-entry-max-lengths)}]]
[:description {:optional true} [:string {:max (:description registry-entry-max-lengths)}]]
[:host [:string {:max (:host registry-entry-max-lengths)}]]

View File

@ -74,16 +74,17 @@ There are three key pieces:
Some important concepts for users:
* **Integrations page**: MCP is configured under **Your account → Integrations → MCP Server**. Here you enable or disable MCP, get the server URL and manage the MCP key.
* **MCP key**: a personal, non-recoverable token that authenticates your AI client with the MCP server. Only one key can exist per user at a time. This is used by the remote MCP setup.
* **MCP session**: a connection from a Penpot tab to the MCP server, identified by a short session ID. Several tabs can connect independently, including tabs showing the same file.
* **MCP session**: a connection from a Penpot tab to the MCP server, identified by a short session ID derived from the tab's user session. Several tabs can connect independently, including tabs showing the same file.
* **Current page**: the page open in the selected session. Switching browser focus to another tab does not change which session the agent uses.
### Working with multiple files
Connect the tabs you want to use:
* **Remote MCP**: open each file and choose **MCP → Connect** in the toolbar. Use **Disconnect** in the same menu to disconnect that tab.
* **Remote MCP**: choose **MCP → Connect** in the dashboard header or a file's toolbar. Use **Disconnect** in the same place to disconnect that tab.
* **Local MCP**: run the plugin in each file and connect it to your local server. Each plugin connection has its own session ID.
* Connecting or disconnecting one tab does not disconnect the others.
* A connected tab stays connected while navigating between the dashboard and files. Its session ID stays the same; requests operate on the currently open file.
Choose a session for your agent:
@ -176,7 +177,7 @@ When this happens, MCP fails fast instead of waiting for a long task timeout:
To recover:
* If the tab was only suspended, focus it, wait until MCP reconnects, and retry the prompt.
* If the tab was unloaded or reloaded, open the file and connect again. Copy the new session ID if your agent was using the previous one.
* If the tab was unloaded or reloaded, connect again from the dashboard or a file. Copy the new session ID if your agent was using the previous one.
To reduce the chances of the browser putting Penpot to sleep during long MCP sessions:
@ -271,7 +272,7 @@ Remote MCP is the easiest way to start using AI agents with Penpot. It's hosted
1. Open **Your account → Integrations**.
2. In the **MCP Server** section, read the short description to confirm that feature is available for your account.
3. Use the **Status** toggle to enable MCP Server. Penpot remembers this state per user across sessions. You still need to connect each file explicitly.
3. Use the **Status** toggle to enable MCP Server. Penpot remembers this state per user across sessions. You still need to connect each tab explicitly.
4. If this is your first time, Penpot will ask you to **generate an MCP key**. The key is shown only once, store it safely.
* Treat the MCP key like a password/token: do not share it in screenshots, logs, or code samples.
5. Once enabled, you will see:

View File

@ -160,6 +160,7 @@ Set <code class="language-js">"version": 2</code> in your
- **Code**: your plugin's file location. It needs to be compiled to JavaScript and reachable.
- **Icon**: your plugin's icon, which will be also displayed in the plugin manager modal. It'll be a <code class="language-js"><img src=""></code> tag so you can use whichever image format works better for you. **It's recommended to use a 56x56 pixel icon for the best appearance on all devices**.
- **Permissions**: your plugin's permissions, which allow access to different parts of the Penpot API.
- **Scope** (optional): <code class="language-js">"workspace"</code> (the default) or <code class="language-js">"global"</code>. A workspace plugin closes when its file closes. A global plugin keeps running while the user moves between the dashboard and files, and gets the <code class="language-js">penpotMgmt</code> object to find, create and open projects and files. Global scope adds the <code class="language-js">allow:global</code> permission, which the user must accept.
#### Types of permissions
@ -185,6 +186,9 @@ Typical use cases: downloading the full project file for backup or sharing.
- <code class="language-js">allow:localstorage</code>: Allows the access to the local storage proxy to store information. This info is only available for the plugin installation but be aware that a user can see this information in the browser.
Typical use cases: storing authentication tokens for a plugin login
- <code class="language-js">allow:global</code>: Allows the plugin to keep running while the user moves between the dashboard and files, and to find and open their projects and files. Added automatically when the manifest sets <code class="language-js">"scope": "global"</code>.
Typical use cases: assistants or integrations that work across several files.
_Note: Write permissions automatically includes its corresponding read permission (e.g., <code class="language-js">content:write</code> includes <code class="language-js">content:read</code>) because reading is required to perform write or modification actions._
### What are plugin.ts and plugin.js files?

View File

@ -16,6 +16,7 @@
[app.main.data.event :as ev]
[app.main.data.profile :as dp]
[app.main.data.websocket :as ws]
[app.main.data.workspace.mcp :as mcp]
[app.main.errors]
[app.main.features :as feat]
[app.main.rasterizer :as thr]
@ -81,6 +82,7 @@
(watch [_ _ stream]
(rx/merge
(rx/of (ev/initialize)
(mcp/initialize)
(dp/refresh-profile))
;; Watch for profile deletion events

View File

@ -58,7 +58,7 @@
(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]
[{:keys [plugin-id name version description host code permissions allow-background scope]} ^js extensions]
(let [load-plugin (unchecked-get ug/global "ɵloadPlugin")]
(if (fn? load-plugin)
(-> (load-plugin
@ -69,6 +69,7 @@
:host host
:code code
:allowBackground (boolean allow-background)
:scope scope
:permissions (apply array permissions)}
nil
extensions)
@ -82,7 +83,7 @@
:action "start-plugin!"))))
(defn- load-plugin!
[{:keys [plugin-id name version description host code icon permissions]}]
[{:keys [plugin-id name version description host code icon permissions scope]}]
(st/emit! (pflag/initialize plugin-id version)
(save-current-plugin plugin-id))
@ -96,6 +97,7 @@
:host host
:code code
:icon icon
:scope scope
:permissions (apply array permissions)}
(fn []
(st/emit! (remove-current-plugin plugin-id))))

View File

@ -55,7 +55,6 @@
[app.main.data.workspace.layers :as dwly]
[app.main.data.workspace.layout :as layout]
[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.pages :as dwpg]
[app.main.data.workspace.path :as dwdp]
@ -77,7 +76,6 @@
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.plugins.register :as preg]
[app.render-wasm :as wasm]
[app.render-wasm.api :as wasm.api]
[app.util.dom :as dom]
@ -259,15 +257,8 @@
ptk/WatchEvent
(watch [_ _ _]
(rx/merge
(rx/of (dp/check-open-plugin)
(fdf/fix-deleted-fonts-for-local-library file-id))
(if (contains? cf/flags :mcp)
;; We wait the plugin runtime to be ready before launch the
;; mcp initialization
(->> (rx/from (preg/wait-for-runtime))
(rx/map (fn [_] (mcp/init))))
(rx/empty))))))
(rx/of (dp/check-open-plugin)
(fdf/fix-deleted-fonts-for-local-library file-id)))))
(defn- compute-shape-stats
"Compute shape statistics in a single pass over pages-index.

View File

@ -12,13 +12,14 @@
[app.common.uri :as u]
[app.config :as cf]
[app.main.broadcast :as mbc]
[app.main.data.auth :as-alias auth]
[app.main.data.plugins :as dp]
[app.main.data.profile :as du]
[app.main.data.workspace :as-alias dw]
[app.main.store :as st]
[app.plugins.register :as preg]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[beicon.v2.operators :as rxo]
[potok.v2.core :as ptk]))
(def reconnect-fallback-interval 60000)
@ -36,11 +37,13 @@
:plugin-id preg/mcp-plugin-id
:description "This plugin enables interaction with the Penpot MCP server"
:allow-background true
:scope "global"
:permissions
#{"user:read"
"library:read" "library:write"
"comment:read" "comment:write"
"content:write" "content:read"}})
"content:write" "content:read"
"allow:global"}})
(defonce interval-sub
(atom nil))
@ -147,7 +150,7 @@
(assoc :host (str (u/join cf/public-uri "plugins/mcp/"))))
stopper-s (rx/merge
(rx/filter (ptk/type? ::dw/finalize-workspace) stream)
(rx/filter (ptk/type? ::auth/logged-out) stream)
(rx/filter (ptk/type? ::stop-mcp-plugin) stream)
(rx/filter (ptk/type? ::init) stream))
@ -234,7 +237,7 @@
ptk/WatchEvent
(watch [_ state stream]
(let [stopper-s (rx/merge
(rx/filter (ptk/type? ::dw/finalize-workspace) stream)
(rx/filter (ptk/type? ::auth/logged-out) stream)
(rx/filter (ptk/type? ::init) stream))
mcp-state (get state :mcp)]
@ -274,3 +277,25 @@
(stop-mcp-plugin))))))
(rx/take-until stopper-s))))))
(defn initialize
"Initialize the MCP controller for each authenticated app session."
[]
(ptk/reify ::initialize
ptk/WatchEvent
(watch [_ _ stream]
(if (contains? cf/flags :mcp)
(->> stream
(rx/filter #(or (ptk/type? ::du/set-profile %)
(ptk/type? ::auth/logged-out %)))
(rx/map #(when (ptk/type? ::du/set-profile %)
(:id (deref %))))
(rx/pipe (rxo/distinct-contiguous))
(rx/switch-map
(fn [profile-id]
(if profile-id
(->> (rx/from (preg/wait-for-runtime))
(rx/map (fn [_] (init))))
(rx/empty))))
(rx/take-until (rx/filter (ptk/type? ::initialize) stream)))
(rx/empty)))))

View File

@ -0,0 +1,152 @@
;; 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 SUBSIDIARY SL
(ns app.main.ui.components.mcp-menu
(:require-macros [app.main.style :as stl])
(:require
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.notifications :as ntf]
[app.main.data.workspace.mcp :as mcp]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.util.clipboard :as clipboard]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.timers :as ts]
[rumext.v2 :as mf]))
(defn- cancel-timer!
[timer-ref*]
(when-let [timer (mf/ref-val timer-ref*)]
(ts/dispose! timer)
(mf/set-ref-val! timer-ref* nil)))
(mf/defc mcp-menu*
{::mf/wrap [mf/memo]}
[{:keys [is-mcp-connected is-connection-requested session-id dashboard]}]
(let [copied-text (tr "workspace.toolbar.mcp-session-copied")
copy-error (tr "errors.clipboard-api-unavailable")
menu-open* (mf/use-state false)
menu-open? (deref menu-open*)
open-timer* (mf/use-ref nil)
close-timer* (mf/use-ref nil)
on-toggle-menu
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(cancel-timer! open-timer*)
(cancel-timer! close-timer*)
(swap! menu-open* not)))
on-close-menu
(mf/use-fn
(fn []
(cancel-timer! open-timer*)
(cancel-timer! close-timer*)
(reset! menu-open* false)))
on-display-menu
(mf/use-fn
(fn []
(cancel-timer! close-timer*)
(cancel-timer! open-timer*)
(mf/set-ref-val!
open-timer*
(ts/schedule 350
#(do
(reset! menu-open* true)
(mf/set-ref-val! open-timer* nil))))))
on-hide-menu
(mf/use-fn
(fn []
(cancel-timer! open-timer*)
(cancel-timer! close-timer*)
(mf/set-ref-val!
close-timer*
(ts/schedule 350
#(do
(reset! menu-open* false)
(mf/set-ref-val! close-timer* nil))))))
on-connect
(mf/use-fn
(mf/deps dashboard)
#(st/emit! (mcp/connect-mcp)
(ev/event {::ev/name "connect-mcp-plugin"
::ev/origin (if dashboard "dashboard:header" "workspace:toolbar")})))
on-disconnect
(mf/use-fn
#(st/emit! (mcp/user-disconnect-mcp)))
on-copy-session
(mf/use-fn
(mf/deps session-id copied-text copy-error)
(fn []
(-> (clipboard/to-clipboard session-id)
(.then #(st/emit! (ntf/info copied-text)))
(.catch #(st/emit! (ntf/error copy-error))))))]
(mf/with-effect []
(fn []
(cancel-timer! open-timer*)
(cancel-timer! close-timer*)))
[:div {:class (stl/css-case :mcp-tool true :dashboard dashboard)
:on-pointer-enter on-display-menu
:on-pointer-leave on-hide-menu}
[:> button* {:variant (if dashboard "secondary" "ghost")
:on-click on-toggle-menu
:aria-haspopup true
:aria-expanded menu-open?
:aria-pressed menu-open?
:data-tool "mcp"
:data-testid "mcp-btn"}
[:div {:class (stl/css-case :toolbar-mcp-button true
:selected menu-open?)}
[:span {:class (stl/css-case :toolbar-mcp-button-dot true
:connected is-mcp-connected)}]
[:span {:class (stl/css-case :toolbar-mcp-button-label true
:connected is-mcp-connected)}
(tr "workspace.toolbar.mcp")]]]
[:div {:class (stl/css :toolbar-mcp-menu)}
[:> dropdown-menu* {:show menu-open?
:on-close on-close-menu
:class (stl/css :toolbar-mcp-dropdown)}
(when (or is-mcp-connected session-id)
[:li {:class (stl/css :toolbar-mcp-dropdown-info)
:role "presentation"}
(when is-mcp-connected
[:span (tr "workspace.toolbar.mcp-connected")])
(when session-id
[:span (tr "workspace.toolbar.mcp-session-id" session-id)])])
(when session-id
[:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item)
:on-click on-copy-session}
(tr "workspace.toolbar.mcp-copy-session-id")])
[:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item)
:on-click (if is-connection-requested on-disconnect on-connect)}
(if is-connection-requested
(tr "workspace.header.menu.mcp.plugin.status.disconnect")
(tr "workspace.header.menu.mcp.plugin.status.connect"))]]]]))
(mf/defc dashboard-mcp-menu*
[]
(let [mcp-state (mf/deref refs/mcp)]
(when (and (contains? cf/flags :mcp)
(:enabled mcp-state)
(:token-valid mcp-state))
[:> mcp-menu* {:dashboard true
:is-mcp-connected (= "connected" (:connection-status mcp-state))
:is-connection-requested (:connection-requested mcp-state)
:session-id (:session-id mcp-state)}])))

View File

@ -0,0 +1,127 @@
// 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 SUBSIDIARY SL
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/spacing.scss" as *;
@use "ds/typography.scss" as t;
@use "ds/z-index.scss" as *;
.mcp-tool {
position: relative;
&.dashboard {
--mcp-disconnected-dot-color: var(--color-foreground-secondary);
}
}
.toolbar-mcp-button {
display: flex;
align-items: center;
gap: var(--sp-xs);
}
.toolbar-mcp-button-label {
&.connected {
color: var(--color-accent-primary);
}
}
.toolbar-mcp-button-dot {
position: relative;
display: flex;
flex-shrink: 0;
inline-size: px2rem(6);
block-size: px2rem(6);
border-radius: 50%;
background-color: var(--mcp-disconnected-dot-color, var(--color-background-disabled));
&.connected {
background-color: var(--color-accent-primary);
}
// One-shot "blob" ripple that confirms the moment the tab becomes
// connected (e.g. after using "Switch here"). Triggered purely by the
// `.connected` class appearing, in sync with the color change.
&.connected::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background-color: var(--color-accent-primary);
opacity: 0;
animation: mcp-status-blob 0.5s ease-out;
}
}
@keyframes mcp-status-blob {
from {
opacity: 0.5;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(2.5);
}
}
.toolbar-mcp-menu {
position: absolute;
z-index: var(--z-index-dropdown);
left: 0;
top: $sz-36;
}
// Same look as the workspace main menu (`.base-menu` in main_menu.scss).
// The toolbar overrides `--menu-background-color`, so restore the global
// menu value here.
.toolbar-mcp-dropdown {
--menu-background-color: var(--color-background-tertiary);
display: flex;
flex-direction: column;
gap: var(--sp-xs);
margin: 0;
padding: var(--sp-xs);
border: $b-2 solid var(--panel-border-color);
border-radius: $br-8;
background-color: var(--menu-background-color);
box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color);
}
.toolbar-mcp-dropdown-info {
@include t.use-typography("body-small");
display: flex;
flex-direction: column;
gap: var(--sp-xs);
padding: var(--sp-s) var(--sp-m);
border-radius: $br-8;
color: var(--color-foreground-secondary);
background-color: var(--color-background-primary);
font-size: px2rem(11);
white-space: nowrap;
}
.toolbar-mcp-dropdown-item {
@include t.use-typography("body-small");
cursor: pointer;
display: flex;
align-items: center;
padding: var(--sp-s) var(--sp-m);
border-radius: $br-8;
color: var(--menu-foreground-color);
background-color: var(--menu-background-color);
white-space: nowrap;
&:hover {
--menu-foreground-color: var(--menu-foreground-color-hover);
--menu-background-color: var(--menu-background-color-hover);
}
}

View File

@ -16,6 +16,7 @@
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
[app.main.ui.components.mcp-menu :refer [dashboard-mcp-menu*]]
[app.main.ui.dashboard.grid :refer [grid*]]
[app.main.ui.dashboard.layout-toggle :as lt :refer [layout-toggle*]]
[app.main.ui.dashboard.subscription :refer [get-subscription-type]]
@ -64,6 +65,7 @@
[:div#dashboard-deleted-title {:class (stl/css :dashboard-title)}
[:h1 (tr "dashboard.projects-title")]]
[:div {:class (stl/css :dashboard-header-actions)}
[:> dashboard-mcp-menu*]
[:> layout-toggle* {:layout layout :on-change on-change}]]])
(mf/defc project-context-menu*

View File

@ -14,6 +14,7 @@
[app.main.data.project :as dpj]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.mcp-menu :refer [dashboard-mcp-menu*]]
[app.main.ui.dashboard.grid :refer [grid*]]
[app.main.ui.dashboard.import :as udi]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
@ -102,6 +103,7 @@
(:name project)]]))
[:div {:class (stl/css :dashboard-header-actions)}
[:> dashboard-mcp-menu*]
[:> layout-toggle* {:layout layout
:on-change on-change}]

View File

@ -17,6 +17,7 @@
[app.main.data.team :as dtm]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.mcp-menu :refer [dashboard-mcp-menu*]]
[app.main.ui.dashboard.deleted :as deleted]
[app.main.ui.dashboard.grid :refer [line-grid*]]
[app.main.ui.dashboard.import :as udi]
@ -50,6 +51,7 @@
(tr "dashboard.personal-projects")
(tr "dashboard.projects-title"))]]
[:div {:class (stl/css :dashboard-header-actions)}
[:> dashboard-mcp-menu*]
[:> layout-toggle* {:layout layout
:on-change on-change}]
(when can-edit

View File

@ -331,6 +331,13 @@
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.allow-localstorage")]])
(cond
(contains? permissions "allow:global")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.allow-global")]])
(cond
(contains? permissions "clipboard:write")
[:div {:class (stl/css :permissions-list-entry)}

View File

@ -12,24 +12,20 @@
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.workspace :as dw]
[app.main.data.workspace.common :as dwc]
[app.main.data.workspace.drawing.common :as dwdc]
[app.main.data.workspace.mcp :as mcp]
[app.main.data.workspace.media :as dwm]
[app.main.data.workspace.path.state :as pst]
[app.main.data.workspace.shortcuts :as sc]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]]
[app.main.ui.components.file-uploader :as file-uploader]
[app.main.ui.components.mcp-menu :refer [mcp-menu*]]
[app.main.ui.context :as ctx]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.util.clipboard :as clipboard]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.keyboard :as kbd]
@ -297,118 +293,6 @@
:ref ref
:on-selected on-selected}]]))
(mf/defc mcp-tool*
{::mf/private true
::mf/wrap [mf/memo]}
[{:keys [is-mcp-connected is-connection-requested session-id]}]
(let [copied-text (tr "workspace.toolbar.mcp-session-copied")
copy-error (tr "errors.clipboard-api-unavailable")
menu-open* (mf/use-state false)
menu-open? (deref menu-open*)
open-timer* (mf/use-ref nil)
close-timer* (mf/use-ref nil)
on-open-menu
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(cancel-timer! open-timer*)
(cancel-timer! close-timer*)
(reset! menu-open* true)))
on-close-menu
(mf/use-fn
(fn []
(cancel-timer! open-timer*)
(cancel-timer! close-timer*)
(reset! menu-open* false)))
on-display-menu
(mf/use-fn
(fn []
(cancel-timer! close-timer*)
(cancel-timer! open-timer*)
(mf/set-ref-val!
open-timer*
(ts/schedule 350
#(do
(reset! menu-open* true)
(mf/set-ref-val! open-timer* nil))))))
on-hide-menu
(mf/use-fn
(fn []
(cancel-timer! open-timer*)
(cancel-timer! close-timer*)
(mf/set-ref-val!
close-timer*
(ts/schedule 350
#(do
(reset! menu-open* false)
(mf/set-ref-val! close-timer* nil))))))
on-connect
(mf/use-fn
#(st/emit! (mcp/connect-mcp)
(ev/event {::ev/name "connect-mcp-plugin"
::ev/origin "workspace:toolbar"})))
on-disconnect
(mf/use-fn
#(st/emit! (mcp/user-disconnect-mcp)))
on-copy-session
(mf/use-fn
(mf/deps session-id copied-text copy-error)
(fn []
(-> (clipboard/to-clipboard session-id)
(.then #(st/emit! (ntf/info copied-text)))
(.catch #(st/emit! (ntf/error copy-error))))))]
(mf/with-effect []
(fn []
(cancel-timer! open-timer*)
(cancel-timer! close-timer*)))
[:div {:on-pointer-enter on-display-menu
:on-pointer-leave on-hide-menu}
[:> button* {:variant "ghost"
:on-click on-open-menu
:aria-haspopup true
:aria-expanded menu-open?
:aria-pressed menu-open?
:data-tool "mcp"
:data-testid "mcp-btn"}
[:div {:class (stl/css-case :toolbar-mcp-button true
:selected menu-open?)}
[:span {:class (stl/css-case :toolbar-mcp-button-dot true
:connected is-mcp-connected)}]
[:span {:class (stl/css-case :toolbar-mcp-button-label true
:connected is-mcp-connected)}
(tr "workspace.toolbar.mcp")]]]
[:div {:class (stl/css :toolbar-mcp-menu)}
[:> dropdown-menu* {:show menu-open?
:on-close on-close-menu
:class (stl/css :toolbar-mcp-dropdown)}
(when (or is-mcp-connected session-id)
[:li {:class (stl/css :toolbar-mcp-dropdown-info)
:role "presentation"}
(when is-mcp-connected
[:span (tr "workspace.toolbar.mcp-connected")])
(when session-id
[:span (tr "workspace.toolbar.mcp-session-id" session-id)])])
(when session-id
[:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item)
:on-click on-copy-session}
(tr "workspace.toolbar.mcp-copy-session-id")])
[:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item)
:on-click (if is-connection-requested on-disconnect on-connect)}
(if is-connection-requested
(tr "workspace.header.menu.mcp.plugin.status.disconnect")
(tr "workspace.header.menu.mcp.plugin.status.connect"))]]]]))
(mf/defc top-toolbar*
{::mf/wrap [mf/memo]}
[{:keys [layout]}]
@ -545,7 +429,7 @@
(when mcp-show?
[:li {:class (stl/css :toolbar-option)}
[:> mcp-tool* {:is-mcp-connected mcp-connected?
[:> mcp-menu* {:is-mcp-connected mcp-connected?
:is-connection-requested (:connection-requested mcp)
:session-id (:session-id mcp)}]])]

View File

@ -101,112 +101,6 @@
}
}
.toolbar-mcp-button {
display: flex;
align-items: center;
gap: var(--sp-xs);
}
.toolbar-mcp-button-label {
&.connected {
color: var(--color-accent-primary);
}
}
.toolbar-mcp-button-dot {
position: relative;
display: flex;
flex-shrink: 0;
inline-size: px2rem(6);
block-size: px2rem(6);
border-radius: 50%;
background-color: var(--color-background-disabled);
&.connected {
background-color: var(--color-accent-primary);
}
// One-shot "blob" ripple that confirms the moment the tab becomes
// connected (e.g. after using "Switch here"). Triggered purely by the
// `.connected` class appearing, in sync with the color change.
&.connected::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background-color: var(--color-accent-primary);
opacity: 0;
animation: mcp-status-blob 0.5s ease-out;
}
}
@keyframes mcp-status-blob {
from {
opacity: 0.5;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(2.5);
}
}
.toolbar-mcp-menu {
position: absolute;
left: 0;
top: $sz-36;
}
// Same look as the workspace main menu (`.base-menu` in main_menu.scss).
// The toolbar overrides `--menu-background-color`, so restore the global
// menu value here.
.toolbar-mcp-dropdown {
--menu-background-color: var(--color-background-tertiary);
display: flex;
flex-direction: column;
gap: var(--sp-xs);
z-index: var(--z-index-dropdown);
margin: 0;
padding: var(--sp-xs);
border: $b-2 solid var(--panel-border-color);
border-radius: $br-8;
background-color: var(--menu-background-color);
box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color);
}
.toolbar-mcp-dropdown-info {
@include t.use-typography("body-small");
display: flex;
flex-direction: column;
gap: var(--sp-xs);
padding: var(--sp-s) var(--sp-m);
border-radius: $br-8;
color: var(--color-foreground-secondary);
background-color: var(--color-background-primary);
font-size: px2rem(11);
white-space: nowrap;
}
.toolbar-mcp-dropdown-item {
@include t.use-typography("body-small");
display: flex;
align-items: center;
padding: var(--sp-s) var(--sp-m);
border-radius: $br-8;
color: var(--menu-foreground-color);
background-color: var(--menu-background-color);
white-space: nowrap;
&:hover {
--menu-foreground-color: var(--menu-foreground-color-hover);
--menu-background-color: var(--menu-background-color-hover);
}
}
.toolbar-separator {
--separator-color: var(--color-background-quaternary);

View File

@ -43,6 +43,7 @@
[app.plugins.history :as history]
[app.plugins.library :as library]
[app.plugins.local-storage :as local-storage]
[app.plugins.management :as management]
[app.plugins.page :as page]
[app.plugins.parser :as parser]
[app.plugins.reflow :as wrfp]
@ -88,6 +89,9 @@
:$plugin {:enumerable false :get (fn [] plugin-id)}
;; Public properties
:management
{:get (fn [] (management/create-context))}
:version
{:this true
:get (constantly (:base cf/version))}

View File

@ -10,6 +10,7 @@
[app.main.data.helpers :as dsh]
[app.main.store :as st]
[app.plugins.file :as file]
[app.plugins.management :as management]
[app.plugins.page :as page]
[app.plugins.parser :as parser]
[app.plugins.shape :as shape]
@ -19,6 +20,21 @@
(defmulti handle-state-change (fn [type _ _ _ _] type))
(defmethod handle-state-change "workspacechange"
[_ _ old-val new-val _]
(let [old-workspace (management/workspace-context old-val)
new-workspace (management/workspace-context new-val)]
(if (= old-workspace new-workspace)
::not-changed
(clj->js new-workspace))))
(defmethod handle-state-change "logout"
[_ _ old-val new-val _]
(if (and (:profile-id old-val)
(not= (:profile-id old-val) (:profile-id new-val)))
::void
::not-changed))
(defmethod handle-state-change "finish"
[_ _ old-val new-val _]
(let [old-file-id (:current-file-id old-val)
@ -123,7 +139,9 @@
(try
(let [result (handle-state-change type plugin-id old-val new-val props)]
(when (not= ::not-changed result)
(debounced-callback result)))
(if (contains? #{"workspacechange" "logout"} type)
(safe-callback result)
(debounced-callback result))))
(catch :default cause
(.error js/console cause)))))

View File

@ -0,0 +1,190 @@
;; 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 SUBSIDIARY SL
(ns app.plugins.management
(:require
[app.common.features :as cfeat]
[app.common.uuid :as uuid]
[app.main.data.dashboard :as dd]
[app.main.data.helpers :as dsh]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.main.store :as st]
[app.util.object :as obj]
[beicon.v2.core :as rx]
[clojure.set :as set]
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
(defn- project-summary
[project]
#js {:id (str (:id project))
:teamId (str (:team-id project))
:name (:name project)
:isDefault (boolean (:is-default project))
:fileCount (or (:count project) 0)})
(defn- file-summary
[file]
#js {:id (str (:id file))
:teamId (str (:team-id file))
:projectId (str (:project-id file))
:name (:name file)
:modifiedAt (.toISOString ^js (:modified-at file))})
(defn workspace-context
[state]
(let [params (rt/get-params state)
file-id (when (= :workspace (rt/lookup-name state))
(uuid/parse* (:file-id params)))
ready? (and file-id
(nil? (:exception state))
(= file-id (:current-file-id state) (:workspace-ready state))
(some? (:current-page-id state))
(some? (dsh/lookup-page state file-id (:current-page-id state))))]
{:status (cond ready? "ready" file-id "loading" :else "none")
:fileId (some-> file-id str)
:fileName (when file-id (:name (dsh/lookup-file state file-id)))
:teamId (some-> (:current-team-id state) str)}))
(defn open-file
[file-id options]
(let [file-id (uuid/parse* file-id)
team-id (if-let [id (obj/get options "teamId")]
(uuid/parse* id)
(:current-team-id @st/state))]
(cond
(or (nil? file-id) (nil? team-id))
(js/Promise.reject (js/Error. "Expected a file UUID and a team UUID"))
(and (= "ready" (:status (workspace-context @st/state)))
(= file-id (:current-file-id @st/state))
(= team-id (:current-team-id @st/state)))
(js/Promise.resolve nil)
:else
(js/Promise.
(fn [resolve reject]
(let [key (js/Symbol)
initial-route (:route @st/state)
timer (atom nil)
finish (fn [error]
(remove-watch st/state key)
(js/clearTimeout @timer)
(if error (reject error) (resolve nil)))]
(reset! timer (js/setTimeout #(finish (js/Error. "Timed out opening file")) 30000))
(add-watch
st/state key
(fn [_ _ _ state]
(let [workspace (workspace-context state)
target? (and (= (str file-id) (:fileId workspace))
(= (str team-id) (:team-id (rt/get-params state))))]
(cond
(:exception state)
(finish (js/Error. "Unable to open file"))
(and target? (= "ready" (:status workspace)))
(finish nil)
(and (not= initial-route (:route state)) (not target?))
(finish (js/Error. "File navigation was superseded"))))))
(st/emit! (rt/nav :workspace {:team-id team-id :file-id file-id}))))))))
(defn list-projects
[options]
(let [team-id (if-let [id (obj/get options "teamId")]
(uuid/parse* id)
(:current-team-id @st/state))]
(if (nil? team-id)
(js/Promise.reject (js/Error. "Expected a team UUID"))
(js/Promise.
(fn [resolve reject]
(->> (rp/cmd! :get-projects {:team-id team-id})
(rx/map (fn [projects]
(into-array (map project-summary (remove :deleted-at projects)))))
(rx/subs! resolve reject)))))))
(defn list-files
[options]
(let [project-id (uuid/parse* (obj/get options "projectId"))]
(if (nil? project-id)
(js/Promise.reject (js/Error. "Expected a project UUID"))
(js/Promise.
(fn [resolve reject]
(->> (rp/cmd! :get-project-files {:project-id project-id})
(rx/map (fn [files]
(into-array (map file-summary files))))
(rx/subs! resolve reject)))))))
(defn- creation-name
[options]
(let [name (obj/get options "name")]
(when (string? name)
(let [name (str/trim name)]
(when (<= 1 (count name) 250)
name)))))
(defn create-project
[options]
(let [team-id (if-let [id (obj/get options "teamId")]
(uuid/parse* id)
(:current-team-id @st/state))
name (creation-name options)]
(cond
(nil? team-id)
(js/Promise.reject (js/Error. "Expected a team UUID"))
(nil? name)
(js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters"))
:else
(js/Promise.
(fn [resolve reject]
(->> (rp/cmd! :create-project {:team-id team-id :name name})
(rx/tap (fn [project]
(st/emit!
(fn [state]
(if (= (:team-id project) (:current-team-id state))
(assoc-in state [:projects (:id project)] (assoc project :count 0))
state)))))
(rx/map project-summary)
(rx/subs! resolve reject)))))))
(defn create-file
[options]
(let [project-id (uuid/parse* (obj/get options "projectId"))
name (creation-name options)]
(cond
(nil? project-id)
(js/Promise.reject (js/Error. "Expected a project UUID"))
(nil? name)
(js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters"))
:else
(js/Promise.
(fn [resolve reject]
(let [features (set/difference (:features @st/state #{}) cfeat/frontend-only-features)]
(->> (rp/cmd! :create-file {:project-id project-id :name name :features features})
(rx/tap (fn [file]
(st/emit!
(fn [state]
(if (= (:team-id file) (:current-team-id state))
(ptk/update (dd/file-created file) state)
state)))))
(rx/map file-summary)
(rx/subs! resolve reject))))))))
(defn create-context
[]
(obj/reify {:name "PenpotManagementContext"}
:workspace
{:get (fn [] (clj->js (workspace-context @st/state)))}
:openFile open-file
:listProjects list-projects
:listFiles list-files
:createProject create-project
:createFile create-file))

View File

@ -54,15 +54,20 @@
"Read the manifest.json defined by the plugins definition and transforms it into an
object that will be stored in the register."
[plugin-url ^js manifest]
(let [name (obj/get manifest "name")
desc (obj/get manifest "description")
code (obj/get manifest "code")
icon (obj/get manifest "icon")
vers (d/nilv (obj/get manifest "version") 1)
(let [name (obj/get manifest "name")
desc (obj/get manifest "description")
code (obj/get manifest "code")
icon (obj/get manifest "icon")
vers (d/nilv (obj/get manifest "version") 1)
scope (obj/get manifest "scope")
permissions (into #{} (obj/get manifest "permissions" []))
permissions
(cond-> permissions
;; Global plugins must be explicitly allowed by the user
(= "global" scope)
(conj "allow:global")
(contains? permissions "content:write")
(conj "content:read")
@ -102,6 +107,7 @@
{:plugin-id plugin-id
:url (str plugin-url)
:version vers
:scope scope
:name name
:description desc
:host origin

View File

@ -8,14 +8,43 @@
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.plugins :as dp]
[app.main.data.profile :as du]
[app.main.data.workspace.mcp :as mcp]
[app.plugins.register :as preg]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as a]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
(t/deftest ^:async test-initialize-once-per-authenticated-session
(let [stream (rx/subject)
events (atom [])
profile {:id (uuid/next)}]
(await
(mock/with-mocks*
{cf/flags #{:mcp}
preg/wait-for-runtime (fn [] (js/Promise.resolve true))}
(let [subscription (rx/subs! #(swap! events conj (ptk/type %))
(ptk/watch (mcp/initialize) {} stream))]
(try
(rx/push! stream (du/set-profile profile))
(await (a/wait-for #(= 1 (count @events))))
(rx/push! stream (du/set-profile profile))
(rx/push! stream (ptk/data-event :app.main.data.workspace/finalize-workspace))
(await (a/settle))
(t/is (= [:app.main.data.workspace.mcp/init] @events))
(rx/push! stream (ptk/data-event :app.main.data.auth/logged-out))
(rx/push! stream (du/set-profile profile))
(await (a/wait-for #(= 2 (count @events))))
(t/is (= [:app.main.data.workspace.mcp/init
:app.main.data.workspace.mcp/init] @events))
(finally
(rx/dispose! subscription)
(rx/end! stream))))))))
(t/deftest test-update-mcp-status
(t/testing "enables MCP in profile props and mcp state"
(let [state {:profile {:props {:mcp-enabled false}} :mcp {}}
@ -151,7 +180,7 @@
(t/is (= "disconnected" (get-in result [:mcp :connection-status])))
(t/is (nil? (get-in result [:mcp :session-id])))))
(t/deftest test-init-clears-previous-file-connection
(t/deftest test-init-clears-previous-app-connection
(let [state {:profile {:props {:mcp-enabled true}}
:mcp {:connection-requested true
:connection-status "connected"
@ -165,8 +194,8 @@
(let [state {:mcp {:connection-requested false :connection-status "disconnected"}}]
(t/is (= state (ptk/update (mcp/update-mcp-connection-status "connected") state)))))
(t/deftest test-plugin-callbacks-stop-with-workspace
(doseq [stop-event [:app.main.data.workspace/finalize-workspace
(t/deftest test-plugin-callbacks-stop-with-app-session
(doseq [stop-event [:app.main.data.auth/logged-out
:app.main.data.workspace.mcp/init]]
(let [stream (rx/subject)
extension (atom nil)
@ -179,6 +208,8 @@
(.on @extension "connect" #(swap! calls inc))
(rx/push! stream (ptk/data-event :app.main.data.workspace.mcp/connect))
(t/is (= 1 @calls))
(rx/push! stream (ptk/data-event :app.main.data.workspace/finalize-workspace))
(t/is (zero? @closed))
(rx/push! stream (ptk/data-event stop-event))
(t/is (= 1 @closed))
(t/is (false? (.isConnectionRequested @extension)))

View File

@ -0,0 +1,280 @@
;; 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 SUBSIDIARY SL
(ns frontend-tests.plugins.management-test
(:require
[app.common.uuid :as uuid]
[app.main.repo :as rp]
[app.main.store :as st]
[app.plugins.events :as events]
[app.plugins.management :as management]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as a]
[frontend-tests.helpers.mock :as mock]))
(def file-id (uuid/next))
(def page-id (uuid/next))
(def team-id (uuid/next))
(def dashboard
{:route {:data {:name :dashboard-recent}}
:current-team-id team-id})
(def loading
(assoc dashboard
:route {:data {:name :workspace}
:params {:query {:file-id (str file-id) :team-id (str team-id)}}}
:current-file-id file-id
:workspace-ready file-id))
(def ready
(assoc loading
:current-page-id page-id
:files {file-id {:id file-id :name "Design"
:data {:pages-index {page-id {:id page-id :objects {}}}}}}))
(t/deftest workspace-readiness-requires-an-initialized-page
(t/is (= "none" (:status (management/workspace-context dashboard))))
(t/is (= "loading" (:status (management/workspace-context loading))))
(t/is (= "ready" (:status (management/workspace-context ready))))
(t/is (= "none" (:status (management/workspace-context (assoc ready :route (:route dashboard)))))))
(t/deftest ^:async open-file-waits-for-page-readiness
(let [state (atom dashboard)
seen (atom [])
done? (atom false)]
(await
(mock/with-mocks*
{st/state state
st/emit! (fn ([_] (js/setTimeout #(reset! state loading) 0))
([_ & _] nil))}
(let [listener (events/add-listener "workspacechange" (str uuid/zero)
#(swap! seen conj (.-status %)) nil)
result (-> (management/open-file (str file-id) nil)
(.then #(reset! done? true)))]
(try
(await (a/wait-for #(= ["loading"] @seen)))
(t/is (false? @done?))
(reset! state ready)
(await result)
(t/is (= ["loading" "ready"] @seen))
(t/is (true? @done?))
(finally
(events/remove-listener listener))))))))
(t/deftest ^:async open-file-rejects-superseded-navigation
(let [state (atom dashboard)]
(await
(mock/with-mocks*
{st/state state
st/emit! (fn ([_] (js/setTimeout #(reset! state loading) 0))
([_ & _] nil))}
(let [result (-> (management/open-file (str file-id) nil)
(.then (fn [] nil) (fn [error] (.-message error))))]
(await (a/wait-for #(= loading @state)))
(reset! state {:route {:data {:name :settings-profile}}})
(t/is (= "File navigation was superseded" (await result))))))))
(t/deftest ^:async open-file-rejects-invalid-identifiers
(let [result (-> (management/open-file "invalid" #js {:teamId (str team-id)})
(.then (fn [] nil) (fn [error] (.-message error))))]
(t/is (= "Expected a file UUID and a team UUID" (await result)))))
(t/deftest ^:async list-projects-returns-active-metadata
(let [project-id (uuid/next)
other-team-id (uuid/next)]
(doseq [[state options expected-team-id]
[[dashboard nil team-id]
[ready #js {:teamId (str other-team-id)} other-team-id]]]
(let [state* (atom state)]
(await
(mock/with-mocks*
{st/state state*
rp/cmd! (mock/stub
(fn [command params]
(t/is (= [:get-projects {:team-id expected-team-id}]
[command params]))
(rx/of [{:id project-id :team-id expected-team-id
:name "Drafts" :is-default true :count 2 :total-count 3}
{:id (uuid/next) :deleted-at (js/Date.)}])))}
(let [projects (await (management/list-projects options))]
(t/is (= [{"id" (str project-id) "teamId" (str expected-team-id)
"name" "Drafts" "isDefault" true "fileCount" 2}]
(js->clj projects)))
(t/is (= state @state*)))))))))
(t/deftest ^:async list-files-returns-metadata-without-opening-a-file
(let [project-id (uuid/next)]
(doseq [state [dashboard ready]]
(let [state* (atom state)]
(await
(mock/with-mocks*
{st/state state*
rp/cmd! (mock/stub
(fn [command params]
(t/is (= [:get-project-files {:project-id project-id}]
[command params]))
(rx/of [{:id file-id :team-id team-id :project-id project-id
:name "Design" :modified-at (js/Date. "2026-01-02T03:04:05Z")
:revn 10}])))}
(let [files (await (management/list-files #js {:projectId (str project-id)}))]
(t/is (= [{"id" (str file-id) "teamId" (str team-id)
"projectId" (str project-id) "name" "Design"
"modifiedAt" "2026-01-02T03:04:05.000Z"}]
(js->clj files)))
(t/is (= state @state*)))))))))
(t/deftest ^:async listing-rejects-invalid-identifiers-without-querying
(await
(mock/with-mocks*
{st/state (atom {})
rp/cmd! (mock/stub (fn [_ _] (t/is false "Invalid IDs must not query the backend")))}
(doseq [[result message]
[[(management/list-projects nil) "Expected a team UUID"]
[(management/list-projects #js {:teamId "invalid"}) "Expected a team UUID"]
[(management/list-files nil) "Expected a project UUID"]
[(management/list-files #js {:projectId "invalid"}) "Expected a project UUID"]]]
(t/is (= message (await (.then result (fn [] nil) #(.-message %)))))))))
(t/deftest ^:async listing-preserves-empty-results-and-backend-errors
(doseq [list-items [(fn [] (management/list-projects #js {:teamId (str team-id)}))
(fn [] (management/list-files #js {:projectId (str (uuid/next))}))]]
(await
(mock/with-mocks*
{rp/cmd! (mock/stub (fn [_ _] (rx/of [])))}
(t/is (= [] (js->clj (await (list-items)))))))
(let [error (js/Error. "Access denied")]
(await
(mock/with-mocks*
{rp/cmd! (mock/stub (fn [_ _] (rx/throw error)))}
(t/is (identical? error (await (.then (list-items) (fn [] nil) identity)))))))))
(t/deftest ^:async create-project-returns-metadata-and-updates-only-the-current-team
(let [project-id (uuid/next)
other-team-id (uuid/next)]
(doseq [[initial options target-team-id]
[[dashboard #js {:name " Buttons "} team-id]
[ready #js {:name " Buttons " :teamId (str other-team-id)} other-team-id]]]
(let [state (atom initial)
requests (atom [])
project {:id project-id :team-id target-team-id :name "Buttons"
:is-default false :is-pinned false}]
(await
(mock/with-mocks*
{st/state state
st/emit! (mock/stub (fn [event] (swap! state event)))
rp/cmd! (mock/stub
(fn [command params]
(swap! requests conj [command params])
(->> (rx/of project) (rx/observe-on :async))))}
(let [context (management/create-context)
result (await (.createProject context options))]
(t/is (= {"id" (str project-id) "teamId" (str target-team-id)
"name" "Buttons" "isDefault" false "fileCount" 0}
(js->clj result)))
(t/is (= [[:create-project {:team-id target-team-id :name "Buttons"}]] @requests))
(t/is (= (if (= team-id target-team-id)
(assoc-in initial [:projects project-id] (assoc project :count 0))
initial)
@state)))))))))
(t/deftest ^:async create-file-returns-metadata-and-updates-the-dashboard-without-navigation
(let [project-id (uuid/next)
created-id (uuid/next)
modified-at (js/Date. "2026-01-02T03:04:05Z")]
(doseq [initial [dashboard ready]]
(let [initial (assoc initial
:features #{"components/v2" "styles/v2" "plugins/runtime"}
:projects {project-id {:id project-id :team-id team-id :count 2}})
state (atom initial)
requests (atom [])
file {:id created-id :team-id team-id :project-id project-id
:name "Login" :modified-at modified-at :data {:pages []}}]
(await
(mock/with-mocks*
{st/state state
st/emit! (mock/stub (fn [event] (swap! state event)))
rp/cmd! (mock/stub
(fn [command params]
(swap! requests conj [command params])
(->> (rx/of file) (rx/observe-on :async))))}
(let [context (management/create-context)
result (await (.createFile context #js {:projectId (str project-id) :name " Login "
:id "ignored" :isShared true}))]
(t/is (= {"id" (str created-id) "teamId" (str team-id)
"projectId" (str project-id) "name" "Login"
"modifiedAt" "2026-01-02T03:04:05.000Z"}
(js->clj result)))
(t/is (= [[:create-file {:project-id project-id :name "Login" :features #{"components/v2"}}]]
@requests))
(t/is (= (-> initial
(assoc-in [:files created-id] (dissoc file :data))
(assoc-in [:recent-files created-id] (dissoc file :data))
(update-in [:projects project-id :count] inc))
@state)))))))))
(t/deftest ^:async creation-rejects-invalid-input-before-querying
(let [project-id (uuid/next)
requests (atom [])]
(await
(mock/with-mocks*
{st/state (atom dashboard)
rp/cmd! (mock/stub (fn [command params]
(swap! requests conj [command params])
(->> (rx/of nil) (rx/observe-on :async))))}
(let [context (management/create-context)]
(doseq [options [nil #js {} #js {:name nil} #js {:name 1}
#js {:name ""} #js {:name " \n "}
#js {:name (apply str (repeat 251 "a"))}]]
(t/is (= "Expected a name with 1 to 250 characters"
(await (.then (.createProject context options) (fn [] nil) #(.-message %)))))
(let [options (js/Object.assign #js {:projectId (str project-id)} options)]
(t/is (= "Expected a name with 1 to 250 characters"
(await (.then (.createFile context options) (fn [] nil) #(.-message %)))))))
(doseq [[create options message]
[[#(.createProject context %) #js {:name "Buttons" :teamId "invalid"} "Expected a team UUID"]
[#(.createFile context %) #js {:name "Login"} "Expected a project UUID"]
[#(.createFile context %) #js {:name "Login" :projectId "invalid"} "Expected a project UUID"]]]
(t/is (= message (await (.then (create options) (fn [] nil) #(.-message %)))))))
(await (a/settle))
(t/is (empty? @requests))))))
(t/deftest ^:async creation-preserves-backend-errors-and-state
(doseq [method ["createProject" "createFile"]]
(let [state (atom dashboard)
error (js/Error. "Access denied")]
(await
(mock/with-mocks*
{st/state state
st/emit! (mock/stub (fn [event] (swap! state event)))
rp/cmd! (mock/stub (fn [_ _] (->> (rx/throw error) (rx/observe-on :async))))}
(let [context (management/create-context)
options #js {:name "Design" :projectId (str (uuid/next))}
result (.call (aget context method) context options)]
(t/is (identical? error (await (.then result (fn [] nil) identity))))
(t/is (= dashboard @state))))))))
(t/deftest ^:async creation-does-not-add-results-to-a-different-team-after-navigation
(doseq [method ["createProject" "createFile"]]
(let [state (atom dashboard)
response (rx/subject)
other (assoc dashboard :current-team-id (uuid/next))
item-id (uuid/next)
item {:id item-id :team-id team-id :project-id (uuid/next)
:name "Design" :modified-at (js/Date.)}]
(await
(mock/with-mocks*
{st/state state
st/emit! (mock/stub (fn [event] (swap! state event)))
rp/cmd! (mock/stub (fn [_ _] (->> response (rx/observe-on :async))))}
(let [context (management/create-context)
result (.call (aget context method) context
#js {:name "Design" :projectId (str (:project-id item))})]
(reset! state other)
(rx/push! response item)
(t/is (= (str item-id) (.-id (await result))))
(t/is (= other @state))))))))

View File

@ -320,3 +320,24 @@
(t/is (= [true false] @seen) "no calls after unsubscribing")
(done'))
done))))
;; --- parse-manifest ---
(t/deftest parse-manifest-adds-allow-global-for-global-scope
(let [manifest (preg/parse-manifest
"http://localhost:4400/manifest.json"
#js {:name "Global plugin"
:code "plugin.js"
:scope "global"
:permissions #js ["content:read"]})]
(t/is (= "global" (:scope manifest)))
(t/is (= #{"content:read" "allow:global"} (:permissions manifest)))))
(t/deftest parse-manifest-keeps-workspace-plugins-without-allow-global
(let [manifest (preg/parse-manifest
"http://localhost:4400/manifest.json"
#js {:name "Workspace plugin"
:code "plugin.js"
:permissions #js ["content:read"]})]
(t/is (nil? (:scope manifest)))
(t/is (= #{"content:read"} (:permissions manifest)))))

View File

@ -73,6 +73,7 @@
[frontend-tests.plugins.interactions-test]
[frontend-tests.plugins.library-test]
[frontend-tests.plugins.local-storage-test]
[frontend-tests.plugins.management-test]
[frontend-tests.plugins.page-active-validation-test]
[frontend-tests.plugins.page-test]
[frontend-tests.plugins.parser-test]
@ -214,6 +215,7 @@
'frontend-tests.plugins.comments-test
'frontend-tests.plugins.context-shapes-test
'frontend-tests.plugins.events-test
'frontend-tests.plugins.management-test
'frontend-tests.plugins.file-test
'frontend-tests.plugins.flex-test
'frontend-tests.plugins.format-test

View File

@ -8180,6 +8180,12 @@ msgstr ""
msgid "workspace.plugins.permissions.allow-download"
msgstr "Start file downloads."
#: src/app/main/ui/workspace/plugins.cljs:339
msgid "workspace.plugins.permissions.allow-global"
msgstr ""
"Keep running while you move between the dashboard and your files, and find "
"and open your projects and files."
#: src/app/main/ui/workspace/plugins.cljs:332
msgid "workspace.plugins.permissions.allow-localstorage"
msgstr "Store data in the browser."

View File

@ -8276,6 +8276,12 @@ msgstr ""
msgid "workspace.plugins.permissions.allow-download"
msgstr "Comenzar descargas de ficheros."
#: src/app/main/ui/workspace/plugins.cljs:339
msgid "workspace.plugins.permissions.allow-global"
msgstr ""
"Seguir funcionando mientras te mueves entre el panel y tus archivos, y "
"buscar y abrir tus proyectos y archivos."
#: src/app/main/ui/workspace/plugins.cljs:332
msgid "workspace.plugins.permissions.allow-localstorage"
msgstr "Guardar datos en el navegador."

View File

@ -243,7 +243,7 @@ of the prompt input area.
Connect the files you want to use:
* Open each file in a separate Penpot tab, run the plugin, and connect it to the same MCP server.
* Each connection has its own short session ID, shown in the plugin UI with a **Copy** button.
* Each browser tab uses a short session ID derived only from `penpot.currentUser.sessionId`, shown in the plugin UI with a **Copy** button. The ID stays the same across workspace navigation.
* Multiple tabs can connect to the same file. Connecting or disconnecting one tab does not disconnect the others.
Choose a session for your agent:

View File

@ -1,8 +1,9 @@
/** Metadata identifying one connected Penpot plugin session. */
export interface PenpotSession {
sessionId: string;
fileId: string;
fileName: string;
fileId: string | null;
fileName: string | null;
workspaceState?: "none" | "loading" | "ready";
}
/** First message sent by the plugin on each new WebSocket connection. */

View File

@ -14,7 +14,7 @@
},
"dependencies": {
"@penpot/plugin-styles": "1.5.0",
"@penpot/plugin-types": "1.5.0"
"@penpot/plugin-types": "link:../../../plugins/libs/plugin-types"
},
"devDependencies": {
"cross-env": "^10.1.0",

View File

@ -3,6 +3,7 @@
"code": "plugin.js",
"icon": "icon.jpg",
"version": 2,
"scope": "global",
"description": "This plugin enables interaction with the Penpot MCP server",
"permissions": ["user:read", "content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"]
}

View File

@ -92,7 +92,7 @@ test("sends file metadata before marking a plugin connection ready", async () =>
};
await ui.message(initialization);
const registeredSession = ui.sockets[0].sent[0].session;
assert.equal(registeredSession.sessionId, "pq3gxqddgj");
assert.equal(registeredSession.sessionId, "ftdmhltgwk");
assert.notEqual(registeredSession.sessionId, sessionId);
assert.deepEqual(registeredSession, { ...initialization.session, sessionId: registeredSession.sessionId });
assert.equal(
@ -100,7 +100,10 @@ test("sends file metadata before marking a plugin connection ready", async () =>
false
);
ui.sockets[0].receive({ type: "initialized" });
assert.equal(ui.messages.at(-1).status, "connected");
assert.equal(
ui.messages.filter((message) => message.type === "update-connection-status").at(-1).status,
"connected"
);
});
test("reconnects with the same session ID and ignores metadata for the old socket", async () => {
@ -163,6 +166,12 @@ test("plugin reads the current file for each connection metadata request", () =>
runInNewContext(compile("plugin.ts"), {
exports: {},
penpot,
penpotMgmt: {
get workspace() {
return { status: "ready", fileId: penpot.currentFile.id, fileName: penpot.currentFile.name };
},
on() {},
},
mcp: undefined,
require: () => ({ ExecuteCodeTaskHandler: class {} }),
});
@ -173,17 +182,55 @@ test("plugin reads the current file for each connection metadata request", () =>
{
type: "initialize",
penpotUserSessionId: "tab-1",
session: { sessionId: "first", fileId: "file-1", fileName: "First design" },
session: { sessionId: "first", fileId: "file-1", fileName: "First design", workspaceState: "ready" },
},
{
type: "initialize",
penpotUserSessionId: "tab-1",
session: { sessionId: "second", fileId: "file-2", fileName: "Second design" },
session: { sessionId: "second", fileId: "file-2", fileName: "Second design", workspaceState: "ready" },
},
]);
});
test("integrated connections reuse the short ID across reconnects", async () => {
test("plugin without penpotMgmt derives metadata from the open file", () => {
let onMessage!: (message: any) => void;
const messages: any[] = [];
const penpot = {
currentFile: { id: "file-1", name: "Design" } as { id: string; name: string } | null,
currentUser: { sessionId: "tab-1" },
theme: "dark",
ui: {
open() {},
onMessage: (handler: (message: any) => void) => {
onMessage = handler;
},
sendMessage: (message: any) => messages.push(JSON.parse(JSON.stringify(message))),
},
on() {},
};
runInNewContext(compile("plugin.ts"), {
exports: {},
penpot,
mcp: undefined,
require: () => ({ ExecuteCodeTaskHandler: class {} }),
});
onMessage({ type: "connection-metadata-request", sessionId: "first" });
penpot.currentFile = null;
onMessage({ type: "context-request" });
assert.deepEqual(messages, [
{
type: "initialize",
penpotUserSessionId: "tab-1",
session: { sessionId: "first", fileId: "file-1", fileName: "Design", workspaceState: "ready" },
},
{
type: "context-update",
context: { fileId: null, fileName: null, workspaceState: "none" },
},
]);
});
test("integrated connections reuse the short session ID across reconnects", async () => {
const ui = pluginUi();
ui.message({ type: "mcp-mode", integratedRemoteMcp: true });
const initialize = async (requestId: string) => {
@ -194,15 +241,18 @@ test("integrated connections reuse the short ID across reconnects", async () =>
});
};
await initialize(ui.connect());
assert.equal(ui.sockets[0].sent[0].session.sessionId, "pq3gxqddgj");
assert.equal(ui.sockets[0].sent[0].session.sessionId, "ftdmhltgwk");
ui.sockets[0].receive({ type: "initialized" });
assert.equal(ui.messages.at(-1).sessionId, "pq3gxqddgj");
assert.equal(
ui.messages.filter((message) => message.type === "update-connection-status").at(-1).sessionId,
"ftdmhltgwk"
);
ui.sockets[0].close();
[...ui.timers.values()][0]();
ui.sockets[1].open();
const requestId = ui.messages.filter((message) => message.type === "connection-metadata-request").at(-1).sessionId;
await initialize(requestId);
assert.equal(ui.sockets[1].sent[0].session.sessionId, "pq3gxqddgj");
assert.equal(ui.sockets[1].sent[0].session.sessionId, "ftdmhltgwk");
});
test("disconnect cancels metadata initialization and ignores a late acknowledgement", async () => {
@ -216,8 +266,9 @@ test("disconnect cancels metadata initialization and ignores a late acknowledgem
});
ui.message({ type: "stop-server" });
await pending;
ui.sockets[0].receive({ type: "initialized" });
assert.deepEqual(ui.sockets[0].sent, []);
ui.sockets[0].receive({ type: "initialized" });
assert.ok(!ui.messages.some((message) => message.status === "connected"));
assert.equal(ui.messages.at(-1).status, "disconnected");
assert.equal(ui.timers.size, 0);
});
@ -243,6 +294,7 @@ test("integrated plugin stays idle until explicitly connected, including while t
},
on() {},
},
penpotMgmt: { on() {} },
mcp: {
getToken: () => "test-token",
getServerUrl: () => "ws://localhost:4402",
@ -280,7 +332,10 @@ test("standalone session ID survives reconnects and plugin restarts in the same
});
assert.equal(ui.sockets[1].sent[0].session.sessionId, sessionId);
ui.sockets[1].receive({ type: "initialized" });
assert.equal(ui.messages.at(-1).sessionId, sessionId);
assert.equal(
ui.messages.filter((message) => message.type === "update-connection-status").at(-1).sessionId,
sessionId
);
const next = pluginUi();
const nextRequestId = next.connect();
@ -291,3 +346,37 @@ test("standalone session ID survives reconnects and plugin restarts in the same
});
assert.equal(next.sockets[0].sent[0].session.sessionId, sessionId);
});
test("file metadata does not affect the short session ID", async () => {
for (const fileId of [null, "file-1", "file-2"]) {
const ui = pluginUi();
const requestId = ui.connect();
await ui.message({
type: "initialize",
penpotUserSessionId: "tab-1",
session: { sessionId: requestId, fileId, fileName: fileId },
});
assert.equal(ui.sockets[0].sent[0].session.sessionId, "ftdmhltgwk");
}
});
test("dashboard sessions use the short session ID and publish context changes without reconnecting", async () => {
const ui = pluginUi();
const requestId = ui.connect();
await ui.message({
type: "initialize",
penpotUserSessionId: "tab-1",
session: { sessionId: requestId, fileId: null, fileName: null, workspaceState: "none" },
});
assert.equal(ui.sockets[0].sent[0].session.sessionId, "ftdmhltgwk");
ui.sockets[0].receive({ type: "initialized" });
assert.equal(ui.messages.at(-1).type, "context-request");
for (const context of [
{ fileId: "file-1", fileName: "Design", workspaceState: "ready" },
{ fileId: null, fileName: null, workspaceState: "none" },
]) {
await ui.message({ type: "context-update", context });
assert.deepEqual(ui.sockets[0].sent.at(-1), { type: "context-update", context });
}
assert.equal(ui.sockets.length, 1);
});

View File

@ -2,13 +2,13 @@ import assert from "node:assert/strict";
import test from "node:test";
import { SessionId } from "./SessionId.ts";
test("derives a stable 50-bit Base32 identifier from the Penpot user session and file", async () => {
assert.equal(await SessionId.forFile("tab-1", "file-1"), "pq3gxqddgj");
assert.equal(await SessionId.forFile("tab-1", "file-1"), "pq3gxqddgj");
assert.equal(await SessionId.forFile("tab-2", "file-1"), "jablgbun4p");
assert.equal(await SessionId.forFile("tab-1", "file-2"), "7s3kow4ucx");
test("derives a stable 50-bit Base32 identifier from only the Penpot user session", async () => {
assert.equal(await SessionId.forSession("tab-1"), "ftdmhltgwk");
assert.equal(await SessionId.forSession("tab-1"), "ftdmhltgwk");
assert.equal(await SessionId.forSession("tab-2"), "cb3oexpdmd");
});
test("encodes the Penpot user session and file without ambiguous boundaries", async () => {
assert.notEqual(await SessionId.forFile("ab", "c"), await SessionId.forFile("a", "bc"));
test("shortens a UUID session to ten copyable Base32 characters", async () => {
const sessionId = await SessionId.forSession("a7457b7c-cf45-80d4-8008-ba5b1d08e6c8");
assert.match(sessionId, /^[a-z2-7]{10}$/);
});

View File

@ -2,8 +2,8 @@
export class SessionId {
private static readonly ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
public static async forFile(penpotUserSessionId: string, fileId: string): Promise<string> {
const input = new TextEncoder().encode(JSON.stringify([penpotUserSessionId, fileId]));
public static async forSession(penpotUserSessionId: string): Promise<string> {
const input = new TextEncoder().encode(penpotUserSessionId);
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));
return SessionId.encode(digest);
}

View File

@ -249,6 +249,7 @@ function connectToMcpServer(baseUrl?: string, token?: string): void {
cancelReconnect();
startHeartbeat();
updateConnectionStatus("connected", "Connected");
parent.postMessage({ type: "context-request" }, "*");
return;
}
// Track the current task received from the MCP server
@ -349,10 +350,7 @@ window.addEventListener("message", async (event) => {
if (!initialization.penpotUserSessionId) {
throw new Error("Missing Penpot user session ID");
}
const sessionId = await SessionId.forFile(
initialization.penpotUserSessionId,
initialization.session.fileId
);
const sessionId = await SessionId.forSession(initialization.penpotUserSessionId);
if (
!shouldReconnect ||
ws !== socket ||
@ -377,6 +375,12 @@ window.addEventListener("message", async (event) => {
}
return;
}
if (event.data.type === "context-update") {
if (connectionReady && ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(event.data));
}
return;
}
if (event.data.type === "mcp-mode") {
isIntegratedRemoteMcp = event.data.integratedRemoteMcp;
}

View File

@ -2,6 +2,7 @@ import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler";
import { Task, TaskHandler } from "./TaskHandler";
import { formatTaskError } from "./ErrorUtils";
import type { PluginConnectionInit } from "../../common/src";
import type { WorkspaceContext } from "@penpot/plugin-types";
/**
* indicates whether the plugin is running in an environment with the Penpot-integrated remote MCP server
@ -9,6 +10,12 @@ import type { PluginConnectionInit } from "../../common/src";
*/
const isIntegratedRemoteMcp = !!mcp;
/**
* the management API, which Penpot only exposes to plugins running with global scope;
* null otherwise (e.g. in Penpot versions without global plugins)
*/
const management = typeof penpotMgmt === "undefined" ? null : penpotMgmt;
/**
* Extracts the major.minor.patch prefix from a version string.
*
@ -57,7 +64,7 @@ penpot.ui.onMessage<
penpotVersion: penpotVersionPrefix,
});
}
// connect only when requested in this workspace
// connect only when requested in this tab
if (isIntegratedRemoteMcp && mcp?.isConnectionRequested()) {
penpot.ui.sendMessage({
type: "start-server",
@ -66,15 +73,22 @@ penpot.ui.onMessage<
});
}
} else if (typeof message === "object" && message.type === "connection-metadata-request") {
const file = penpot.currentFile;
if (file && message.sessionId) {
const workspace = currentWorkspace();
if (message.sessionId) {
const initialization: PluginConnectionInit & { penpotUserSessionId?: string } = {
type: "initialize",
session: { sessionId: message.sessionId, fileId: file.id, fileName: file.name },
session: {
sessionId: message.sessionId,
fileId: workspace.fileId,
fileName: workspace.fileName,
workspaceState: workspace.status,
},
penpotUserSessionId: penpot.currentUser.sessionId,
};
penpot.ui.sendMessage(initialization);
}
} else if (typeof message === "object" && message.type === "context-request") {
sendWorkspaceContext();
} else if (typeof message === "object" && message.type === "update-connection-status") {
mcp?.setMcpStatus(message.status || "unknown", message.sessionId);
} else if (typeof message === "object" && message.task && message.id) {
@ -85,6 +99,38 @@ penpot.ui.onMessage<
}
});
/**
* Provides the workspace metadata of this tab.
* Without the management API, the metadata is derived from the open file.
*/
function currentWorkspace(): WorkspaceContext {
if (management) {
return management.workspace;
}
const file = penpot.currentFile;
return {
status: file ? "ready" : "none",
fileId: file?.id ?? null,
fileName: file?.name ?? null,
teamId: null,
};
}
/** Sends the current workspace metadata without replacing the connection. */
function sendWorkspaceContext(): void {
const workspace = currentWorkspace();
penpot.ui.sendMessage({
type: "context-update",
context: {
fileId: workspace.fileId,
fileName: workspace.fileName,
workspaceState: workspace.status,
},
});
}
management?.on("workspacechange", sendWorkspaceContext);
/**
* Handles plugin task requests received from the MCP server via WebSocket.
*

View File

@ -178,6 +178,7 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
// initialize context, making penpot, penpotUtils, storage and the custom console available
this.context = {
penpot: penpot,
penpotMgmt: typeof penpotMgmt === "undefined" ? null : penpotMgmt,
storage: {},
console: new ExecuteCodeTaskConsole(),
penpotUtils: PenpotUtils,
@ -216,8 +217,9 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
try {
// wait for layout updates prior to executing the supplied code (if method is available)
try {
const management = this.context.penpotMgmt;
// @ts-ignore - TODO Penpot.waitForLayoutUpdate is not yet in the released types
if (penpot.waitForLayoutUpdate) {
if ((!management || management.workspace.status === "ready") && penpot.waitForLayoutUpdate) {
// @ts-ignore
await penpot.waitForLayoutUpdate();
}

View File

@ -92,7 +92,7 @@ Penpot:
Hierarchy
* Omit<Context, "addListener" | "removeListener">
* Omit<Context, "addListener" | "removeListener" | "management">
+ Penpot
members:
Properties:
@ -5001,6 +5001,297 @@ CommonLayout:
The `remove` method removes the layout.
Returns void
WorkspaceContext:
overview: |-
Interface WorkspaceContext
==========================
Current workspace of this browser tab.
```
interface WorkspaceContext {
status: "none" | "loading" | "ready";
fileId: string | null;
fileName: string | null;
teamId: string | null;
}
```
Referenced by: EventsMap, PenpotMgmt
members:
Properties:
status: |-
```
readonly status: "none" | "loading" | "ready"
```
fileId: |-
```
readonly fileId: string | null
```
fileName: |-
```
readonly fileName: string | null
```
teamId: |-
```
readonly teamId: string | null
```
ProjectSummary:
overview: |-
Interface ProjectSummary
========================
Metadata snapshot of an accessible, non-deleted project.
```
interface ProjectSummary {
id: string;
teamId: string;
name: string;
isDefault: boolean;
fileCount: number;
}
```
Referenced by: PenpotMgmt
members:
Properties:
id: |-
```
readonly id: string
```
teamId: |-
```
readonly teamId: string
```
name: |-
```
readonly name: string
```
isDefault: |-
```
readonly isDefault: boolean
```
Whether this is the team's Drafts project.
fileCount: |-
```
readonly fileCount: number
```
FileSummary:
overview: |-
Interface FileSummary
=====================
Metadata snapshot of an accessible, non-deleted file.
```
interface FileSummary {
id: string;
teamId: string;
projectId: string;
name: string;
modifiedAt: string;
}
```
Referenced by: PenpotMgmt
members:
Properties:
id: |-
```
readonly id: string
```
teamId: |-
```
readonly teamId: string
```
projectId: |-
```
readonly projectId: string
```
name: |-
```
readonly name: string
```
modifiedAt: |-
```
readonly modifiedAt: string
```
Last modification time as an ISO 8601 string in UTC.
PenpotMgmt:
overview: |-
Interface PenpotMgmt
====================
Application management API exposed as `penpotMgmt` to global plugins.
* Discovery and navigation require `content:read`; creation requires `content:write`.
* Available in the dashboard and file workspaces.
* Use this API to discover, create and open files; file operations on `penpot` require a ready workspace.
* Reacquire file, page and shape objects after changing files.
```
interface PenpotMgmt {
workspace: WorkspaceContext;
listProjects(options?: { teamId?: string }): Promise<ProjectSummary[]>;
listFiles(options: { projectId: string }): Promise<FileSummary[]>;
createProject(options: { name: string; teamId?: string }): Promise<ProjectSummary>;
createFile(options: { name: string; projectId: string }): Promise<FileSummary>;
openFile(fileId: string, options?: { teamId?: string }): Promise<void>;
on(
type: "workspacechange",
callback: (workspace: WorkspaceContext) => void,
): symbol;
off(listenerId: symbol): void;
}
```
Referenced by: Context
members:
Properties:
workspace: |-
```
readonly workspace: WorkspaceContext
```
Current workspace availability and metadata; usable for file operations when status is `ready`.
Methods:
listProjects: |-
```
listProjects(options?: { teamId?: string }): Promise<ProjectSummary[]>
```
Lists accessible, non-deleted projects as metadata snapshots.
* `teamId` defaults to the current team.
* Queries the backend without opening a file or changing navigation.
* Rejects invalid identifiers and backend access errors.
Parameters
* options: { teamId?: string }
Returns Promise<ProjectSummary[]>
Example
```
const projects = await penpotMgmt.listProjects();
```
listFiles: |-
```
listFiles(options: { projectId: string }): Promise<FileSummary[]>
```
Lists accessible, non-deleted files in the specified project as metadata snapshots.
* Requires an explicit `projectId`.
* Queries the backend without opening a file or changing navigation.
* Rejects invalid identifiers and backend access errors.
Parameters
* options: { projectId: string }
Returns Promise<FileSummary[]>
Example
```
const files = await penpotMgmt.listFiles({ projectId: project.id });
```
createProject: |-
```
createProject(options: { name: string; teamId?: string }): Promise<ProjectSummary>
```
Creates a project and returns its metadata snapshot. Requires `content:write`.
* `teamId` defaults to the current team.
* Trims `name` and requires 1 to 250 characters.
* Does not change navigation or open a file.
* Rejects invalid input, backend access errors and quota errors.
Parameters
* options: { name: string; teamId?: string }
Returns Promise<ProjectSummary>
Example
```
const project = await penpotMgmt.createProject({ name: 'Design system' });
```
createFile: |-
```
createFile(options: { name: string; projectId: string }): Promise<FileSummary>
```
Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`.
* Requires an explicit `projectId`.
* Trims `name` and requires 1 to 250 characters.
* Does not open the file or change navigation. Use `openFile` to edit it.
* Rejects invalid input, backend access errors and quota errors.
Parameters
* options: { name: string; projectId: string }
Returns Promise<FileSummary>
Example
```
const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' });
await penpotMgmt.openFile(file.id, { teamId: file.teamId });
```
openFile: |-
```
openFile(fileId: string, options?: { teamId?: string }): Promise<void>
```
Opens a file in this tab and resolves once its workspace and page are ready.
* `teamId` defaults to the current team.
* Rejects invalid identifiers, failed or superseded navigation, and waits exceeding 30 seconds.
Parameters
* fileId: string
* options: { teamId?: string }
Returns Promise<void>
Example
```
await penpotMgmt.openFile(file.id, { teamId: file.teamId });
```
on: |-
```
on(
type: "workspacechange",
callback: (workspace: WorkspaceContext) => void,
): symbol
```
Subscribes to workspace availability and metadata changes.
Parameters
* type: "workspacechange"
* callback: (workspace: WorkspaceContext) => void
Returns symbol
off: |-
```
off(listenerId: symbol): void
```
Parameters
* listenerId: symbol
Returns void
Context:
overview: |-
@ -5011,6 +5302,7 @@ Context:
```
interface Context {
management?: Omit<PenpotMgmt, "on" | "off">;
version: string;
root: Shape | null;
currentFile: File | null;
@ -5082,6 +5374,13 @@ Context:
```
members:
Properties:
management: |-
```
readonly management?: Omit<PenpotMgmt, "on" | "off">
```
Backs `penpotMgmt`. Its events go through the plugin listener registry,
so `on` and `off` are not part of it.
version: |-
```
readonly version: string
@ -7109,6 +7408,8 @@ EventsMap:
```
interface EventsMap {
workspacechange: WorkspaceContext;
logout: void;
pagechange: Page;
filechange: File;
selectionchange: string[];
@ -7122,6 +7423,14 @@ EventsMap:
Referenced by: Context, Penpot
members:
Properties:
workspacechange: |-
```
readonly workspacechange: WorkspaceContext
```
logout: |-
```
readonly logout: void
```
pagechange: |-
```
readonly pagechange: Page

View File

@ -166,13 +166,15 @@ Boards can have layout systems that automatically control the positioning and sp
# The `penpot` and `penpotUtils` Objects, Exploring Designs
A key object to use in your code is the `penpot` object (which is of type `Penpot`):
A key object for navigating the currently active Penpot file is the `penpot` object (which is of type `Penpot`):
* `penpot.selection` provides the list of shapes the user has selected in the Penpot UI.
If it is unclear which elements to work on, you can ask the user to select them for you.
ALWAYS immediately copy the selected shape(s) into `storage`! Do not assume that the selection remains unchanged.
* `penpot.root` provides the root shape of the currently active page.
* Generation of CSS content for elements via `penpot.generateStyle`
* Generation of HTML/SVG content for elements via `penpot.generateMarkup`
Members of `penpot` (e.g. `penpot.currentFile` and `penpot.root`) will be null no Penpot file is currently opened.
You can use `penpotMgmt` to navigate to a file if needed (see below).
For example, to generate CSS for the currently selected elements, you can execute this:
return penpot.generateStyle(penpot.selection, { type: "css", withChildren: true });
@ -430,5 +432,17 @@ For many tasks, it can be critical to visually inspect the design. Remember to u
* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning.
Consider converting boards to flex layout when appropriate.
# The `penpotMgmt` Object
This object (of type `PenpotMgmt`) serves to manage Penpot design projects and files.
It allows you to discover, create and navigate to design projects and files the user has access to.
Create a project with `await penpotMgmt.createProject({ name: "Design system" })` (optionally specify `teamId`).
Create a file with `await penpotMgmt.createFile({ projectId: project.id, name: "Login" })`.
Both creation methods require `content:write` and return metadata without changing the current view.
To edit the new file, call `await penpotMgmt.openFile(file.id, { teamId: file.teamId })` and reacquire file, page and shape objects.
You navigate to different design files only when your task explicitly requires it/you are instructed to do so.
If `penpotMgmt` is `null`, file management is unavailable and only the file that is open in Penpot can be used;
ask the user to open another file if needed.
--
You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again.

View File

@ -143,3 +143,29 @@ test("invalid initialization metadata cannot register a session", async () => {
assert.equal((await closed)[0], 1008);
await assert.rejects(bridge.executePluginTask(new PluginTask("test", {})), /No Penpot sessions/);
});
test("a dashboard session remains routable while its workspace metadata changes", async () => {
const socket = new WebSocket(`ws://127.0.0.1:${port}?userToken=alice`);
sockets.push(socket);
await once(socket, "open");
const initialized = once(socket, "message");
socket.send(
JSON.stringify({
type: "initialize",
session: { sessionId: "tab", fileId: null, fileName: null, workspaceState: "none" },
})
);
await initialized;
socket.on("message", (raw) => {
const request = JSON.parse(raw.toString());
socket.send(JSON.stringify({ id: request.id, success: true, data: "ok" }));
});
for (const context of [
{ fileId: "file-1", fileName: "Design", workspaceState: "ready" },
{ fileId: null, fileName: null, workspaceState: "none" },
]) {
socket.send(JSON.stringify({ type: "context-update", context }));
assert.equal((await bridge.executePluginTask(new PluginTask("test", {}), "tab")).data, "ok");
assert.deepEqual(bridge.getUserConnections("alice")!.getSessions(), [{ sessionId: "tab", ...context }]);
}
});

View File

@ -27,8 +27,9 @@ const connectionInitSchema = z.object({
type: z.literal("initialize"),
session: z.object({
sessionId: z.string().min(1).max(128),
fileId: z.string().min(1).max(128),
fileName: z.string(),
fileId: z.string().min(1).max(128).nullable(),
fileName: z.string().nullable(),
workspaceState: z.enum(["none", "loading", "ready"]).optional(),
}),
});
@ -170,6 +171,13 @@ export class PluginBridge implements TaskDispatchHost {
return;
}
connection.frozen = false;
if (message?.type === "context-update") {
const context = connectionInitSchema.shape.session
.omit({ sessionId: true })
.parse(message.context);
Object.assign(connection.session, context);
return;
}
if (message?.type === "heartbeat") {
return;
}

View File

@ -31,7 +31,7 @@ export abstract class TaskDispatcher {
sessions
.map(
(session) =>
`${session.sessionId}: ${JSON.stringify(session.fileName)} (file ${session.fileId})`
`${session.sessionId}: ${session.fileId ? `${JSON.stringify(session.fileName)} (file ${session.fileId})` : "no active workspace"}`
)
.join("\n")
);

View File

@ -49,8 +49,8 @@ export class ExecuteCodeTool extends Tool<ExecuteCodeArgs> {
"Executes JavaScript code in the Penpot plugin context.\n" +
"IMPORTANT: Before using this tool, make sure you have read the 'Penpot High-Level Overview' and know " +
"which Penpot API functionality is necessary and how to use it.\n" +
"You have access two main objects: `penpot` (the Penpot API, of type `Penpot`), `penpotUtils`, " +
"and `storage`.\n" +
"You have access to `penpot` (of type `Penpot`), `penpotMgmt` (of type `PenpotMgmt`), `penpotUtils`, and `storage`.\n" +
"If `penpotMgmt` is null, file management is unavailable and only the open file can be used.\n" +
"`storage` is an object in which arbitrary data can be stored, simply by adding a new attribute; " +
"stored attributes can be referenced in future calls to this tool, so any intermediate results that " +
"could come in handy later should be stored in `storage` instead of just a fleeting variable; " +

9
mcp/pnpm-lock.yaml generated
View File

@ -188,8 +188,8 @@ importers:
specifier: 1.5.0
version: 1.5.0
'@penpot/plugin-types':
specifier: 1.5.0
version: 1.5.0
specifier: link:../../../plugins/libs/plugin-types
version: link:../../../plugins/libs/plugin-types
devDependencies:
cross-env:
specifier: ^10.1.0
@ -663,9 +663,6 @@ packages:
'@penpot/plugin-styles@1.5.0':
resolution: {integrity: sha512-rwXFBRPd0IZs3ens+eRT8oA5WqkQgF6xlW6fCfH7U1NHRjej9HH7vTGXmfjoIVml6Xx1sbyYHZVxkQC8Ub8+zA==}
'@penpot/plugin-types@1.5.0':
resolution: {integrity: sha512-hFk4Rx66rLaSUWoN6bhxXkDI2ymwKg9xokdslvZvXM5MSmEmfhsJKUMwtuzgYWljbbfaQhK+DjK8g8yVbbavtA==}
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@ -1880,8 +1877,6 @@ snapshots:
'@penpot/plugin-styles@1.5.0': {}
'@penpot/plugin-types@1.5.0': {}
'@pinojs/redact@0.4.0': {}
'@rolldown/binding-android-arm-eabi@1.2.11':

View File

@ -2,12 +2,15 @@
### 🚀 Features
- **plugin-types, plugins-runtime:** Added `penpotMgmt.createProject` and `penpotMgmt.createFile` to create projects and files from the dashboard or workspace with `content:write` permission.
- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events. Global scope adds the `allow:global` permission, which users must accept when installing the plugin.
- **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently.
- **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs.
- **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished
### 🩹 Fixes
- **plugin-runtime:** Cancel plugin loads before they execute after unloading or logout, and allow reopening plugins that close during startup.
- **plugins-runtime**: An interaction obtained from `Shape.interactions` now keeps addressing that interaction instead of the position it held when the array was read. Removing every interaction of a shape from a single read removes all of them rather than leaving some behind, and writing through a held interaction after an earlier one is removed no longer lands on a different interaction.
- **plugins-runtime**: `Shape.removeInteraction()` now rejects an interaction belonging to a different shape with a validation error, instead of removing whichever interaction sat at the same position on the target shape.
- **plugins-runtime**: Writing `trigger`, `delay` or `action` on an interaction the shape no longer has now raises a validation error. The write used to be sent to the workspace with no position to apply it at, where it failed out of the plugin's reach: nothing was written and nothing was reported.

View File

@ -4,7 +4,7 @@
*/
export interface Penpot extends Omit<
Context,
'addListener' | 'removeListener'
'addListener' | 'removeListener' | 'management'
> {
readonly ui: {
/**
@ -778,10 +778,135 @@ export interface CommonLayout {
remove(): void;
}
/** Current workspace of this browser tab. */
export interface WorkspaceContext {
readonly status: 'none' | 'loading' | 'ready';
readonly fileId: string | null;
readonly fileName: string | null;
readonly teamId: string | null;
}
/** Metadata snapshot of an accessible, non-deleted project. */
export interface ProjectSummary {
readonly id: string;
readonly teamId: string;
readonly name: string;
/** Whether this is the team's Drafts project. */
readonly isDefault: boolean;
readonly fileCount: number;
}
/** Metadata snapshot of an accessible, non-deleted file. */
export interface FileSummary {
readonly id: string;
readonly teamId: string;
readonly projectId: string;
readonly name: string;
/** Last modification time as an ISO 8601 string in UTC. */
readonly modifiedAt: string;
}
/**
* Application management API exposed as `penpotMgmt` to global plugins.
*
* - Discovery and navigation require `content:read`; creation requires `content:write`.
* - Available in the dashboard and file workspaces.
* - Use this API to discover, create and open files; file operations on `penpot` require a ready workspace.
* - Reacquire file, page and shape objects after changing files.
*/
export interface PenpotMgmt {
/** Current workspace availability and metadata; usable for file operations when status is `ready`. */
readonly workspace: WorkspaceContext;
/**
* Lists accessible, non-deleted projects as metadata snapshots.
*
* - `teamId` defaults to the current team.
* - Queries the backend without opening a file or changing navigation.
* - Rejects invalid identifiers and backend access errors.
*
* @example
* ```ts
* const projects = await penpotMgmt.listProjects();
* ```
*/
listProjects(options?: { teamId?: string }): Promise<ProjectSummary[]>;
/**
* Lists accessible, non-deleted files in the specified project as metadata snapshots.
*
* - Requires an explicit `projectId`.
* - Queries the backend without opening a file or changing navigation.
* - Rejects invalid identifiers and backend access errors.
*
* @example
* ```ts
* const files = await penpotMgmt.listFiles({ projectId: project.id });
* ```
*/
listFiles(options: { projectId: string }): Promise<FileSummary[]>;
/**
* Creates a project and returns its metadata snapshot. Requires `content:write`.
*
* - `teamId` defaults to the current team.
* - Trims `name` and requires 1 to 250 characters.
* - Does not change navigation or open a file.
* - Rejects invalid input, backend access errors and quota errors.
*
* @example
* ```ts
* const project = await penpotMgmt.createProject({ name: 'Design system' });
* ```
*/
createProject(options: {
name: string;
teamId?: string;
}): Promise<ProjectSummary>;
/**
* Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`.
*
* - Requires an explicit `projectId`.
* - Trims `name` and requires 1 to 250 characters.
* - Does not open the file or change navigation. Use `openFile` to edit it.
* - Rejects invalid input, backend access errors and quota errors.
*
* @example
* ```ts
* const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' });
* await penpotMgmt.openFile(file.id, { teamId: file.teamId });
* ```
*/
createFile(options: {
name: string;
projectId: string;
}): Promise<FileSummary>;
/**
* Opens a file in this tab and resolves once its workspace and page are ready.
*
* - `teamId` defaults to the current team.
* - Rejects invalid identifiers, failed or superseded navigation, and waits exceeding 30 seconds.
*
* @example
* ```ts
* await penpotMgmt.openFile(file.id, { teamId: file.teamId });
* ```
*/
openFile(fileId: string, options?: { teamId?: string }): Promise<void>;
/** Subscribes to workspace availability and metadata changes. */
on(
type: 'workspacechange',
callback: (workspace: WorkspaceContext) => void,
): symbol;
off(listenerId: symbol): void;
}
/**
* Represents the context of Penpot, providing access to various Penpot functionalities and data.
*/
export interface Context {
/**
* Backs `penpotMgmt`. Its events go through the plugin listener registry,
* so `on` and `off` are not part of it.
*/
readonly management?: Omit<PenpotMgmt, 'on' | 'off'>;
/**
* Returns the current penpot version.
*/
@ -1556,6 +1681,8 @@ export interface Ellipse extends ShapeBase {
* ```
*/
export interface EventsMap {
readonly workspacechange: WorkspaceContext;
readonly logout: void;
/**
* The `pagechange` event is triggered when the active page in the project is changed.
*/
@ -5757,4 +5884,5 @@ export interface Viewport {
declare global {
const penpot: Penpot;
const penpotMgmt: PenpotMgmt;
}

View File

@ -19,6 +19,7 @@ import type {
Page,
Path,
Penpot,
PenpotMgmt,
Rectangle,
Shape,
SvgRaw,
@ -36,6 +37,8 @@ import { createPluginManager } from '../plugin-manager.js';
export const validEvents = [
'finish',
'logout',
'workspacechange',
'pagechange',
'filechange',
'selectionchange',
@ -47,10 +50,24 @@ export const validEvents = [
export function createApi(
plugin: Awaited<ReturnType<typeof createPluginManager>>,
) {
const checkPermission = (permission: Permissions) => {
const workspaceUnavailable = () =>
plugin.manifest.scope === 'global' &&
plugin.context.management?.workspace.status !== 'ready';
const requireWorkspace = () => {
if (workspaceUnavailable()) {
throw new Error(
'No workspace is ready. Use penpotMgmt.openFile() first.',
);
}
};
const checkPermission = (permission: Permissions, workspace = true) => {
if (!plugin.manifest.permissions.includes(permission)) {
throw new Error(`Permission ${permission} is not granted`);
}
if (workspace && /^(content|library|comment):/.test(permission))
requireWorkspace();
};
const penpot: Penpot = {
@ -162,7 +179,7 @@ export function createApi(
z.function().parse(callback);
// To suscribe to events needs the read permission
checkPermission('content:read');
checkPermission('content:read', false);
return plugin.registerListener(type, callback, props);
},
@ -178,18 +195,18 @@ export function createApi(
},
get root(): Shape | null {
checkPermission('content:read');
return plugin.context.root;
checkPermission('content:read', false);
return workspaceUnavailable() ? null : plugin.context.root;
},
get currentFile(): File | null {
checkPermission('content:read');
return plugin.context.currentFile;
checkPermission('content:read', false);
return workspaceUnavailable() ? null : plugin.context.currentFile;
},
get currentPage(): Page | null {
checkPermission('content:read');
return plugin.context.currentPage;
checkPermission('content:read', false);
return workspaceUnavailable() ? null : plugin.context.currentPage;
},
get selection(): Shape[] {
@ -203,10 +220,12 @@ export function createApi(
},
get viewport(): Viewport {
requireWorkspace();
return plugin.context.viewport;
},
get history(): HistoryContext {
requireWorkspace();
return plugin.context.history;
},
@ -395,7 +414,44 @@ export function createApi(
},
};
const penpotMgmt: PenpotMgmt = {
get workspace() {
checkPermission('content:read', false);
return plugin.context.management!.workspace;
},
listProjects(options) {
checkPermission('content:read', false);
return plugin.context.management!.listProjects(options);
},
listFiles(options) {
checkPermission('content:read', false);
return plugin.context.management!.listFiles(options);
},
createProject(options) {
checkPermission('content:write', false);
return plugin.context.management!.createProject(options);
},
createFile(options) {
checkPermission('content:write', false);
return plugin.context.management!.createFile(options);
},
openFile(fileId, options) {
checkPermission('content:read', false);
return plugin.context.management!.openFile(fileId, options);
},
on(type, callback) {
z.literal('workspacechange').parse(type);
z.function().parse(callback);
checkPermission('content:read', false);
return plugin.registerListener(type, callback);
},
off(listenerId) {
plugin.destroyListener(listenerId);
},
};
return {
penpot,
penpotMgmt,
};
}

View File

@ -54,6 +54,148 @@ describe('Plugin api', () => {
vi.clearAllMocks();
});
describe('management listing', () => {
it.each(['none', 'loading', 'ready'])(
'lists metadata with workspace status %s',
async (status) => {
const projects = [{ id: 'project-id' }];
const files = [{ id: 'file-id' }];
const management = {
workspace: { status },
listProjects: vi.fn().mockResolvedValue(projects),
listFiles: vi.fn().mockResolvedValue(files),
};
const { penpotMgmt } = createApi({
...pluginManager,
manifest: { ...pluginManager.manifest, scope: 'global' },
context: { ...pluginManager.context, management },
} as any);
await expect(penpotMgmt.listProjects()).resolves.toEqual(projects);
expect(management.listProjects).toHaveBeenLastCalledWith(undefined);
await penpotMgmt.listProjects({ teamId: 'team-id' });
expect(management.listProjects).toHaveBeenLastCalledWith({
teamId: 'team-id',
});
await expect(
penpotMgmt.listFiles({ projectId: 'project-id' }),
).resolves.toEqual(files);
expect(management.listFiles).toHaveBeenCalledWith({
projectId: 'project-id',
});
},
);
it('requires content:read before querying metadata', () => {
const management = {
listProjects: vi.fn(),
listFiles: vi.fn(),
};
const { penpotMgmt } = createApi({
...pluginManager,
manifest: {
...pluginManager.manifest,
permissions: [],
scope: 'global',
},
context: { ...pluginManager.context, management },
} as any);
expect(() => penpotMgmt.listProjects()).toThrow();
expect(() => penpotMgmt.listFiles({ projectId: 'project-id' })).toThrow();
expect(management.listProjects).not.toHaveBeenCalled();
expect(management.listFiles).not.toHaveBeenCalled();
});
});
describe('management creation', () => {
it.each(['none', 'loading', 'ready'])(
'creates projects and files with workspace status %s',
async (status) => {
const project = { id: 'project-id', name: 'Buttons' };
const file = { id: 'file-id', name: 'Login' };
const management = {
workspace: { status },
createProject: vi.fn().mockResolvedValue(project),
createFile: vi.fn().mockResolvedValue(file),
};
const { penpotMgmt } = createApi({
...pluginManager,
manifest: {
...pluginManager.manifest,
permissions: ['content:write'],
scope: 'global',
},
context: { ...pluginManager.context, management },
} as any);
await expect(
penpotMgmt.createProject({ name: 'Buttons' }),
).resolves.toEqual(project);
await expect(
penpotMgmt.createProject({ name: 'Buttons', teamId: 'team-id' }),
).resolves.toEqual(project);
expect(management.createProject).toHaveBeenLastCalledWith({
name: 'Buttons',
teamId: 'team-id',
});
await expect(
penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }),
).resolves.toEqual(file);
expect(management.createFile).toHaveBeenCalledWith({
name: 'Login',
projectId: 'project-id',
});
},
);
it.each([{ permissions: [] }, { permissions: ['content:read'] }])(
'requires content:write before creating with permissions $permissions',
({ permissions }) => {
const management = {
createProject: vi.fn(),
createFile: vi.fn(),
};
const { penpotMgmt } = createApi({
...pluginManager,
manifest: { ...pluginManager.manifest, permissions, scope: 'global' },
context: { ...pluginManager.context, management },
} as any);
expect(() => penpotMgmt.createProject({ name: 'Buttons' })).toThrow(
'Permission content:write is not granted',
);
expect(() =>
penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }),
).toThrow('Permission content:write is not granted');
expect(management.createProject).not.toHaveBeenCalled();
expect(management.createFile).not.toHaveBeenCalled();
},
);
it('preserves backend creation errors', async () => {
const error = new Error('Access denied');
const { penpotMgmt } = createApi({
...pluginManager,
manifest: { ...pluginManager.manifest, scope: 'global' },
context: {
...pluginManager.context,
management: {
createProject: vi.fn().mockRejectedValue(error),
createFile: vi.fn().mockRejectedValue(error),
},
},
} as any);
await expect(penpotMgmt.createProject({ name: 'Buttons' })).rejects.toBe(
error,
);
await expect(
penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }),
).rejects.toBe(error);
});
});
describe('ui', () => {
describe.concurrent('permissions', () => {
const api = createApi({

View File

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

View File

@ -8,8 +8,13 @@ export async function createPlugin(
manifest: Manifest,
onCloseCallback: () => void,
apiExtensions?: object,
signal?: AbortSignal,
) {
// Closing can happen while the manager is still fetching code.
let sandbox: ReturnType<typeof createSandbox> | undefined = undefined;
const evaluateSandbox = async () => {
if (plugin.destroyed || !sandbox) return;
try {
sandbox.evaluate();
} catch (error) {
@ -23,15 +28,18 @@ export async function createPlugin(
context,
manifest,
function onClose() {
sandbox.cleanGlobalThis();
sandbox?.cleanGlobalThis();
onCloseCallback();
},
function onReloadModal() {
evaluateSandbox();
},
signal,
);
const sandbox = createSandbox(plugin, apiExtensions);
if (plugin.destroyed) return;
sandbox = createSandbox(plugin, apiExtensions);
await evaluateSandbox();

View File

@ -33,6 +33,7 @@ describe('createSandbox', () => {
beforeEach(() => {
mockPlugin = {
manifest: { scope: 'workspace' },
code: 'console.log("Plugin running");',
timeouts: new Set<ReturnType<typeof setTimeout>>(),
} as unknown as Awaited<ReturnType<typeof createPluginManager>>;

View File

@ -1,4 +1,3 @@
import type { Penpot } from '@penpot/plugin-types';
import type { createPluginManager } from './plugin-manager';
import { createApi } from './api';
import { ses } from './ses.js';
@ -67,7 +66,7 @@ export function createSandbox(
const pluginApi = createApi(plugin);
const safeHandler = {
get(target: Penpot, prop: string, receiver: unknown) {
get(target: object, prop: string, receiver: unknown) {
const originalValue = Reflect.get(target, prop, receiver);
if (typeof originalValue === 'function') {
@ -202,6 +201,12 @@ export function createSandbox(
publicPluginApi = Object.assign(publicPluginApi, apiExtensions);
}
if (plugin.manifest.scope === 'global') {
Object.assign(publicPluginApi, {
penpotMgmt: new Proxy(pluginApi.penpotMgmt, safeHandler),
});
}
const compartment = ses.createCompartment(publicPluginApi);
return {

View File

@ -30,7 +30,7 @@ describe('loadPlugin host context boundary (regression for #11001)', () => {
close: vi.fn(),
sendMessage: vi.fn(),
},
} as unknown as Awaited<ReturnType<typeof createPlugin>>);
} as unknown as NonNullable<Awaited<ReturnType<typeof createPlugin>>>);
});
it('does not freeze host-owned functions reachable through the context', async () => {
@ -60,6 +60,7 @@ describe('loadPlugin host context boundary (regression for #11001)', () => {
manifest,
expect.any(Function),
undefined,
expect.any(AbortSignal),
);
// Host-owned functions must remain extensible: page navigation and

View File

@ -1,6 +1,11 @@
import { describe, it, vi, expect, beforeAll } from 'vitest';
import 'ses';
import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
import {
loadPlugin,
setContextBuilder,
getPlugins,
ɵunloadPlugin,
} from './load-plugin';
import type { Context } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
@ -47,15 +52,23 @@ function makeManifest(
function makeHostFixture() {
const listenerTypes: string[] = [];
const listeners = new Map<symbol, string>();
const listeners = new Map<
symbol,
{ type: string; callback: (...args: unknown[]) => unknown }
>();
const shapes: object[] = [];
// Inline code (empty host + non-URL code) resolves without network, so no
// fetch mock is needed. UI/modal APIs are never touched by the probe code.
const createRectangle = vi.fn(() => ({ type: 'rectangle-marker' }));
const createRectangle = vi.fn(() => {
const shape = { type: 'rectangle-marker' };
shapes.push(shape);
return shape;
});
const selection: object[] = [{ id: 'shape-1' }];
const context = {
addListener: (type: string, _callback: (...args: unknown[]) => unknown) => {
addListener: (type: string, callback: (...args: unknown[]) => unknown) => {
const id = Symbol(type);
listeners.set(id, type);
listeners.set(id, { type, callback });
listenerTypes.push(type);
return id;
},
@ -63,13 +76,21 @@ function makeHostFixture() {
listeners.delete(id);
},
theme: 'dark',
management: { workspace: { status: 'ready' } },
createRectangle,
selection,
// Host-only member: present on the raw context but NOT part of the
// public penpot API. Plugin code must never see it (see B-2 below).
__internalSecret: 'host-internal',
} as unknown as Context;
return { context, listenerTypes, createRectangle, selection };
return {
context,
listeners,
listenerTypes,
createRectangle,
selection,
shapes,
};
}
function lastCompartmentGlobalThis(): Record<string, unknown> {
@ -98,9 +119,14 @@ describe('loadPlugin real initialization path (regression for #11001)', () => {
);
// The plugin code really ran inside the sandbox: the manager registers
// `themechange` + `finish`, and the plugin code adds its own `finish`
// `themechange` + `finish` + `logout`, and the plugin code adds its own `finish`
// listener through the public API.
expect(fixture.listenerTypes).toEqual(['themechange', 'finish', 'finish']);
expect(fixture.listenerTypes).toEqual([
'themechange',
'finish',
'logout',
'finish',
]);
expect(getPlugins()).toHaveLength(1);
// The user-facing behavior from #11001: host-side augmentation of a
@ -112,7 +138,7 @@ describe('loadPlugin real initialization path (regression for #11001)', () => {
expect(() => {
freshWrapper.toString = () => 'patched-by-runtime';
}).not.toThrow();
expect(fixture.listenerTypes.length).toBe(3);
expect(fixture.listenerTypes.length).toBe(4);
});
it('denies the write API without permission and leaves the host untouched', async () => {
@ -166,4 +192,302 @@ describe('loadPlugin real initialization path (regression for #11001)', () => {
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true);
});
it.each(['before', 'after'])(
'discards a cancelled global load that completes %s its replacement',
async (order) => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
const manifest = {
...makeManifest('plugin.js', ['content:read', 'content:write']),
host: 'https://plugins.test/',
scope: 'global' as const,
};
ɵunloadPlugin(manifest.pluginId);
let finishFetch!: (response: object) => void;
const response = new Promise((resolve) => {
finishFetch = resolve;
});
const fetch = vi.fn().mockReturnValue(response);
vi.stubGlobal('fetch', fetch);
try {
const cancelled = loadPlugin(manifest);
await loadPlugin(manifest);
expect(fetch).toHaveBeenCalledOnce();
ɵunloadPlugin(manifest.pluginId);
const replacement = () =>
loadPlugin({
...manifest,
host: '',
code: 'globalThis.marker = "replacement";',
});
if (order === 'after') await replacement();
finishFetch({
ok: true,
text: async () => 'penpot.createRectangle();',
});
await cancelled;
expect(fixture.shapes).toEqual([]);
if (order === 'before') {
expect(getPlugins()).toHaveLength(0);
expect(fixture.listeners.size).toBe(0);
await replacement();
}
expect(getPlugins()).toHaveLength(1);
expect(lastCompartmentGlobalThis()['marker']).toBe('replacement');
expect(fixture.listeners.size).toBe(3);
} finally {
ɵunloadPlugin(manifest.pluginId);
vi.unstubAllGlobals();
}
},
);
it('cancels a workspace load when another load replaces the same plugin', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
const manifest = {
...makeManifest('plugin.js', ['content:read', 'content:write']),
host: 'https://plugins.test/',
};
let finishFetch!: (response: object) => void;
vi.stubGlobal(
'fetch',
() =>
new Promise((resolve) => {
finishFetch = resolve;
}),
);
try {
const cancelled = loadPlugin(manifest);
await loadPlugin({
...manifest,
host: '',
code: 'globalThis.marker = "replacement";',
});
finishFetch({ ok: true, text: async () => 'penpot.createRectangle();' });
await cancelled;
expect(fixture.shapes).toEqual([]);
expect(getPlugins()).toHaveLength(1);
expect(lastCompartmentGlobalThis()['marker']).toBe('replacement');
} finally {
for (const plugin of [...getPlugins()]) plugin.plugin.close();
vi.unstubAllGlobals();
}
});
it('ignores a fetch failure after a load has been cancelled', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
const manifest = {
...makeManifest('plugin.js', []),
host: 'https://plugins.test/',
scope: 'global' as const,
};
let failFetch!: (error: Error) => void;
vi.stubGlobal(
'fetch',
() =>
new Promise((_resolve, reject) => {
failFetch = reject;
}),
);
try {
const cancelled = loadPlugin(manifest);
ɵunloadPlugin(manifest.pluginId);
await loadPlugin({
...manifest,
host: '',
code: 'globalThis.marker = "replacement";',
});
failFetch(new Error('Cancelled fetch failed'));
await cancelled;
expect(getPlugins()).toHaveLength(1);
expect(lastCompartmentGlobalThis()['marker']).toBe('replacement');
expect(fixture.listeners.size).toBe(3);
} finally {
ɵunloadPlugin(manifest.pluginId);
vi.unstubAllGlobals();
}
});
it('allows retrying a global plugin after a failed load', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
const manifest = {
...makeManifest('throw new Error("load failed");', []),
scope: 'global' as const,
};
await expect(loadPlugin(manifest)).rejects.toThrow('load failed');
expect(getPlugins()).toHaveLength(0);
await loadPlugin({ ...manifest, code: 'globalThis.marker = "retry";' });
expect(lastCompartmentGlobalThis()['marker']).toBe('retry');
ɵunloadPlugin(manifest.pluginId);
expect(fixture.listeners.size).toBe(0);
});
it('keeps a global sandbox through navigation and other plugin loads, then closes it on logout', async () => {
const fixture = makeHostFixture();
let workspace = {
status: 'none',
fileId: null,
fileName: null,
teamId: null,
};
const listeners = new Map<
symbol,
{ type: string; callback: (value?: unknown) => void }
>();
const context = {
...fixture.context,
management: {
get workspace() {
return workspace;
},
openFile: async () => {},
},
addListener(type: string, callback: (value?: unknown) => void) {
const id = Symbol();
listeners.set(id, { type, callback });
return id;
},
removeListener(id: symbol) {
listeners.delete(id);
},
} as unknown as Context;
setContextBuilder(() => context);
const manifest = {
...makeManifest(
'globalThis.savedPenpot = penpot; globalThis.probe = penpotMgmt.workspace.status;',
['content:read', 'content:write'],
),
pluginId: 'global-plugin',
scope: 'global' as const,
};
await loadPlugin(manifest);
const globalPlugin = getPlugins().find(
(plugin) => plugin.manifest.pluginId === 'global-plugin',
)!;
const globals = globalPlugin.compartment.compartment.globalThis;
expect(globals['probe']).toBe('none');
expect(() =>
globalPlugin.compartment.compartment.evaluate('penpot.createRectangle()'),
).toThrow(/No workspace/);
for (const listener of [...listeners.values()]) {
if (listener.type === 'finish') listener.callback();
}
await loadPlugin(makeManifest('globalThis.workspacePlugin = true;', []));
expect(getPlugins()).toContain(globalPlugin);
await loadPlugin(manifest);
expect(
getPlugins().filter(
(plugin) => plugin.manifest.pluginId === 'global-plugin',
),
).toHaveLength(1);
workspace = { ...workspace, status: 'ready' };
globalPlugin.compartment.compartment.evaluate(
'penpot.createRectangle(); globalThis.sameFacade = savedPenpot === penpot;',
);
expect(globals['sameFacade']).toBe(true);
expect(fixture.createRectangle).toHaveBeenCalledOnce();
await expect(
loadPlugin(makeManifest('throw new Error("failure");', [])),
).rejects.toThrow('failure');
expect(getPlugins()).toContain(globalPlugin);
for (const listener of [...listeners.values()]) {
if (listener.type === 'logout') listener.callback();
}
expect(getPlugins()).toHaveLength(0);
expect(globals['penpotMgmt']).toBeUndefined();
expect(listeners.size).toBe(0);
});
it('cancels a global load at logout before it can execute in another session', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
const manifest = {
...makeManifest('plugin.js', ['content:read', 'content:write']),
pluginId: 'logout-plugin',
host: 'https://plugins.test/',
scope: 'global' as const,
};
let finishFetch!: (response: object) => void;
vi.stubGlobal(
'fetch',
() =>
new Promise((resolve) => {
finishFetch = resolve;
}),
);
try {
const cancelled = loadPlugin(manifest);
for (const listener of [...fixture.listeners.values()]) {
if (listener.type === 'logout') listener.callback();
}
await loadPlugin({
...manifest,
host: '',
code: 'globalThis.marker = "new-session";',
});
finishFetch({ ok: true, text: async () => 'penpot.createRectangle();' });
await cancelled;
expect(fixture.shapes).toEqual([]);
expect(getPlugins()).toHaveLength(1);
expect(lastCompartmentGlobalThis()['marker']).toBe('new-session');
expect(fixture.listeners.size).toBe(3);
} finally {
ɵunloadPlugin(manifest.pluginId);
vi.unstubAllGlobals();
}
});
it('cleans up a failed fetch and allows loading the plugin again', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
const manifest = {
...makeManifest('plugin.js', []),
pluginId: 'failed-fetch-plugin',
host: 'https://plugins.test/',
scope: 'global' as const,
};
vi.stubGlobal('fetch', () => Promise.reject(new Error('Fetch failed')));
try {
await expect(loadPlugin(manifest)).rejects.toThrow('Fetch failed');
expect(getPlugins()).toHaveLength(0);
expect(fixture.listeners.size).toBe(0);
await loadPlugin({
...manifest,
host: '',
code: 'globalThis.marker = "retry";',
});
expect(lastCompartmentGlobalThis()['marker']).toBe('retry');
} finally {
ɵunloadPlugin(manifest.pluginId);
vi.unstubAllGlobals();
}
});
it('does not register a global plugin that closes during startup and permits reopening', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
const manifest = {
...makeManifest('penpot.closePlugin();', [
'content:read',
'content:write',
]),
pluginId: 'self-closing-plugin',
scope: 'global' as const,
};
try {
await loadPlugin(manifest);
expect(getPlugins()).toHaveLength(0);
expect(fixture.listeners.size).toBe(0);
await loadPlugin({ ...manifest, code: 'penpot.createRectangle();' });
expect(fixture.shapes).toEqual([{ type: 'rectangle-marker' }]);
expect(getPlugins()).toHaveLength(1);
} finally {
ɵunloadPlugin(manifest.pluginId);
}
expect(getPlugins()).toHaveLength(0);
expect(fixture.listeners.size).toBe(0);
});
});

View File

@ -33,7 +33,7 @@ vi.mock('./ses.js', () => ({
describe('plugin-loader', () => {
let mockContext: Context;
let manifest: Manifest;
let mockPluginApi: Awaited<ReturnType<typeof createPlugin>>;
let mockPluginApi: NonNullable<Awaited<ReturnType<typeof createPlugin>>>;
let mockClose: ReturnType<typeof vi.fn>;
beforeEach(() => {
@ -61,7 +61,7 @@ describe('plugin-loader', () => {
close: mockClose,
sendMessage: vi.fn(),
},
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
} as unknown as NonNullable<Awaited<ReturnType<typeof createPlugin>>>;
mockContext = {
addListener: vi.fn(),
@ -84,6 +84,7 @@ describe('plugin-loader', () => {
manifest,
expect.any(Function),
undefined,
expect.any(AbortSignal),
);
expect(mockPluginApi.plugin.close).not.toHaveBeenCalled();
expect(getPlugins()).toHaveLength(1);
@ -129,7 +130,7 @@ describe('plugin-loader', () => {
},
iframeWindow: mockIframeWindow,
manifest: { ...manifest, host: 'http://localhost:4202' },
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
} as unknown as NonNullable<Awaited<ReturnType<typeof createPlugin>>>;
vi.mocked(createPlugin).mockResolvedValue(mockPluginWithIframe);
@ -173,7 +174,7 @@ describe('plugin-loader', () => {
},
iframeWindow: mockIframeWindow1,
manifest: { ...manifest, host: 'http://localhost:4202' },
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
} as unknown as NonNullable<Awaited<ReturnType<typeof createPlugin>>>;
const mockPluginApi2 = {
plugin: {
@ -182,7 +183,7 @@ describe('plugin-loader', () => {
},
iframeWindow: mockIframeWindow2,
manifest: { ...manifest, host: 'http://localhost:4203' },
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
} as unknown as NonNullable<Awaited<ReturnType<typeof createPlugin>>>;
vi.mocked(createPlugin).mockResolvedValue(mockPluginApi1);
await loadPlugin(manifest);
@ -215,7 +216,7 @@ describe('plugin-loader', () => {
pluginId: 'background-plugin',
allowBackground: true,
},
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
} as unknown as NonNullable<Awaited<ReturnType<typeof createPlugin>>>;
vi.mocked(createPlugin).mockResolvedValue(backgroundPluginApi);
await loadPlugin(manifest);
@ -250,6 +251,7 @@ describe('plugin-loader', () => {
manifest,
expect.any(Function),
undefined,
expect.any(AbortSignal),
);
});
@ -265,6 +267,7 @@ describe('plugin-loader', () => {
manifest,
expect.any(Function),
undefined,
expect.any(AbortSignal),
);
});
});

View File

@ -4,7 +4,10 @@ import { loadManifest } from './parse-manifest.js';
import { Manifest } from './models/manifest.model.js';
import { createPlugin } from './create-plugin.js';
let plugins: Awaited<ReturnType<typeof createPlugin>>[] = [];
type Plugin = NonNullable<Awaited<ReturnType<typeof createPlugin>>>;
let plugins: Plugin[] = [];
const pendingPlugins = new Map<Manifest['pluginId'], AbortController>();
export type ContextBuilder = (id: string) => Context;
@ -17,20 +20,19 @@ export function setContextBuilder(builder: ContextBuilder) {
export const getPlugins = () => plugins;
const closeAllPlugins = () => {
// Background plugins keep running, so they must stay registered: the
// registry routes their UI messages and lets them be unloaded later.
const backgroundPlugins: typeof plugins = [];
plugins.forEach((pluginApi) => {
// Background and global plugins keep running, so they must stay registered:
// the registry routes their UI messages and lets them be unloaded later.
plugins = plugins.filter((pluginApi) => {
/* eslint-disable @typescript-eslint/no-explicit-any */
if ((pluginApi.manifest as any)?.allowBackground) {
backgroundPlugins.push(pluginApi);
} else {
pluginApi.plugin.close();
if (
pluginApi.manifest?.scope === 'global' ||
(pluginApi.manifest as any)?.allowBackground
) {
return true;
}
pluginApi.plugin.close();
return false;
});
plugins = backgroundPlugins;
};
window.addEventListener('message', (event) => {
@ -50,6 +52,7 @@ export const loadPlugin = async function (
closeCallback?: () => void,
apiExtensions?: object,
) {
const load = new AbortController();
try {
const context = contextBuilder && contextBuilder(manifest.pluginId);
@ -57,7 +60,18 @@ export const loadPlugin = async function (
return;
}
closeAllPlugins();
if (manifest.scope === 'global') {
if (
pendingPlugins.has(manifest.pluginId) ||
plugins.some(
(plugin) => plugin.manifest?.pluginId === manifest.pluginId,
)
) {
return;
}
} else {
closeAllPlugins();
}
// The host context is not deeply frozen at this load stage.
//
@ -74,10 +88,17 @@ export const loadPlugin = async function (
// `createSandbox`'s proxy handler applies `ses.safeReturn` to values
// crossing into the sandbox. Compartment isolation and intrinsics
// hardening are performed by createSandbox, not here.
const plugin = await createPlugin(
pendingPlugins.get(manifest.pluginId)?.abort();
pendingPlugins.set(manifest.pluginId, load);
let plugin: Plugin | undefined = undefined;
plugin = await createPlugin(
context,
manifest,
() => {
load.abort();
if (pendingPlugins.get(manifest.pluginId) === load) {
pendingPlugins.delete(manifest.pluginId);
}
plugins = plugins.filter((api) => api !== plugin);
if (closeCallback) {
@ -85,11 +106,20 @@ export const loadPlugin = async function (
}
},
apiExtensions,
load.signal,
);
if (!plugin || load.signal.aborted) {
plugin?.plugin.close();
return;
}
plugins.push(plugin);
} catch (error) {
closeAllPlugins();
if (manifest.scope !== 'global') closeAllPlugins();
throw error;
} finally {
if (pendingPlugins.get(manifest.pluginId) === load) {
pendingPlugins.delete(manifest.pluginId);
}
}
};
@ -107,6 +137,7 @@ export const ɵloadPluginByUrl = async function (manifestUrl: string) {
};
export const ɵunloadPlugin = function (id: Manifest['pluginId']) {
pendingPlugins.get(id)?.abort();
const plugin = plugins.find((plugin) => plugin.manifest.pluginId === id);
if (plugin) {

View File

@ -7,6 +7,7 @@ export const manifestSchema = z.object({
code: z.string(),
icon: z.string().optional(),
version: z.number().optional(),
scope: z.enum(['workspace', 'global']).optional(),
description: z.string().max(200).optional(),
permissions: z.array(
z.enum([
@ -19,6 +20,7 @@ export const manifestSchema = z.object({
'comment:write',
'allow:downloads',
'allow:localstorage',
'allow:global',
'clipboard:read',
'clipboard:write',
]),

View File

@ -14,8 +14,9 @@ export async function createPluginManager(
manifest: Manifest,
onCloseCallback: () => void,
onReloadModal: (code: string) => void,
signal?: AbortSignal,
) {
let code = await loadManifestCode(manifest);
let code = '';
let loaded = false;
let destroyed = false;
@ -40,11 +41,10 @@ export async function createPluginManager(
modal?.setTheme(theme);
});
const listenerId: symbol = context.addListener('finish', () => {
closePlugin();
context?.removeListener(listenerId);
const listenerId = context.addListener('finish', () => {
if (manifest.scope !== 'global') closePlugin();
});
const logoutId = context.addListener('logout', () => closePlugin());
let listeners: symbol[] = [];
@ -60,7 +60,12 @@ export async function createPluginManager(
};
const closePlugin = () => {
if (destroyed) return;
destroyed = true;
signal?.removeEventListener('abort', closePlugin);
removeAllEventListeners();
destroyListener(listenerId);
destroyListener(logoutId);
timeouts.forEach(clearTimeout);
timeouts.clear();
@ -74,8 +79,6 @@ export async function createPluginManager(
modal = null;
}
destroyed = true;
onCloseCallback();
};
@ -147,7 +150,24 @@ export async function createPluginManager(
context.removeListener(listenerId);
};
signal?.addEventListener('abort', closePlugin, { once: true });
try {
if (signal?.aborted) {
closePlugin();
} else {
code = await loadManifestCode(manifest);
}
} catch (error) {
if (!destroyed) {
closePlugin();
throw error;
}
}
return {
get destroyed() {
return destroyed;
},
close: closePlugin,
destroyListener,
openModal,