diff --git a/backend/test/backend_tests/rpc_plugins_test.clj b/backend/test/backend_tests/rpc_plugins_test.clj
index 8c57931844..8d6be84dc6 100644
--- a/backend/test/backend_tests/rpc_plugins_test.clj
+++ b/backend/test/backend_tests/rpc_plugins_test.clj
@@ -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"})
diff --git a/common/src/app/common/types/plugins.cljc b/common/src/app/common/types/plugins.cljc
index 035be0efb3..8db72497fd 100644
--- a/common/src/app/common/types/plugins.cljc
+++ b/common/src/app/common/types/plugins.cljc
@@ -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)}]]
diff --git a/docs/mcp/index.md b/docs/mcp/index.md
index 815a468fea..fb9c927106 100644
--- a/docs/mcp/index.md
+++ b/docs/mcp/index.md
@@ -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:
diff --git a/docs/plugins/getting-started.md b/docs/plugins/getting-started.md
index ea993640a9..5f72ac4ada 100644
--- a/docs/plugins/getting-started.md
+++ b/docs/plugins/getting-started.md
@@ -160,6 +160,7 @@ Set "version": 2 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 ![]()
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): "workspace" (the default) or "global". 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 penpotMgmt object to find, create and open projects and files. Global scope adds the allow:global 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.
- allow:localstorage: 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
+- allow:global: 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 "scope": "global".
+Typical use cases: assistants or integrations that work across several files.
+
_Note: Write permissions automatically includes its corresponding read permission (e.g., content:write includes content:read) because reading is required to perform write or modification actions._
### What are plugin.ts and plugin.js files?
diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs
index c7d87ba8ba..06ffc849da 100644
--- a/frontend/src/app/main.cljs
+++ b/frontend/src/app/main.cljs
@@ -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
diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs
index 33d898cf69..e9b15e0849 100644
--- a/frontend/src/app/main/data/plugins.cljs
+++ b/frontend/src/app/main/data/plugins.cljs
@@ -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))))
diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs
index b04bf5394b..e4b79d7b66 100644
--- a/frontend/src/app/main/data/workspace.cljs
+++ b/frontend/src/app/main/data/workspace.cljs
@@ -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.
diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs
index 74884289c4..e4b03fb956 100644
--- a/frontend/src/app/main/data/workspace/mcp.cljs
+++ b/frontend/src/app/main/data/workspace/mcp.cljs
@@ -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)))))
diff --git a/frontend/src/app/main/ui/components/mcp_menu.cljs b/frontend/src/app/main/ui/components/mcp_menu.cljs
new file mode 100644
index 0000000000..8e5091bfd3
--- /dev/null
+++ b/frontend/src/app/main/ui/components/mcp_menu.cljs
@@ -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)}])))
diff --git a/frontend/src/app/main/ui/components/mcp_menu.scss b/frontend/src/app/main/ui/components/mcp_menu.scss
new file mode 100644
index 0000000000..fd466d4206
--- /dev/null
+++ b/frontend/src/app/main/ui/components/mcp_menu.scss
@@ -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);
+ }
+}
diff --git a/frontend/src/app/main/ui/dashboard/deleted.cljs b/frontend/src/app/main/ui/dashboard/deleted.cljs
index 2f41c028e6..6bef519d98 100644
--- a/frontend/src/app/main/ui/dashboard/deleted.cljs
+++ b/frontend/src/app/main/ui/dashboard/deleted.cljs
@@ -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*
diff --git a/frontend/src/app/main/ui/dashboard/files.cljs b/frontend/src/app/main/ui/dashboard/files.cljs
index b3318fc6f8..2d6a98f519 100644
--- a/frontend/src/app/main/ui/dashboard/files.cljs
+++ b/frontend/src/app/main/ui/dashboard/files.cljs
@@ -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}]
diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs
index fef26f5b9b..2efbbefd21 100644
--- a/frontend/src/app/main/ui/dashboard/projects.cljs
+++ b/frontend/src/app/main/ui/dashboard/projects.cljs
@@ -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
diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs
index a6ff1b34f0..12bd45e766 100644
--- a/frontend/src/app/main/ui/workspace/plugins.cljs
+++ b/frontend/src/app/main/ui/workspace/plugins.cljs
@@ -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)}
diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs
index 0a10cf095d..7a7f767621 100644
--- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs
+++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs
@@ -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)}]])]
diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.scss b/frontend/src/app/main/ui/workspace/top_toolbar.scss
index 7f5637c8f5..71ad771b3c 100644
--- a/frontend/src/app/main/ui/workspace/top_toolbar.scss
+++ b/frontend/src/app/main/ui/workspace/top_toolbar.scss
@@ -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);
diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs
index 226b324c1f..2f0944f800 100644
--- a/frontend/src/app/plugins/api.cljs
+++ b/frontend/src/app/plugins/api.cljs
@@ -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))}
diff --git a/frontend/src/app/plugins/events.cljs b/frontend/src/app/plugins/events.cljs
index 884a1e0bac..51ac7c596f 100644
--- a/frontend/src/app/plugins/events.cljs
+++ b/frontend/src/app/plugins/events.cljs
@@ -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)))))
diff --git a/frontend/src/app/plugins/management.cljs b/frontend/src/app/plugins/management.cljs
new file mode 100644
index 0000000000..0337da56ba
--- /dev/null
+++ b/frontend/src/app/plugins/management.cljs
@@ -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))
diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs
index c70b4f9e7a..8b5bcc3bde 100644
--- a/frontend/src/app/plugins/register.cljs
+++ b/frontend/src/app/plugins/register.cljs
@@ -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
diff --git a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs
index 8bd36d07aa..802be3ae2f 100644
--- a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs
+++ b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs
@@ -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)))
diff --git a/frontend/test/frontend_tests/plugins/management_test.cljs b/frontend/test/frontend_tests/plugins/management_test.cljs
new file mode 100644
index 0000000000..eb74e0b09d
--- /dev/null
+++ b/frontend/test/frontend_tests/plugins/management_test.cljs
@@ -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))))))))
diff --git a/frontend/test/frontend_tests/plugins/register_test.cljs b/frontend/test/frontend_tests/plugins/register_test.cljs
index 55ddd1c4a6..49c387d4bc 100644
--- a/frontend/test/frontend_tests/plugins/register_test.cljs
+++ b/frontend/test/frontend_tests/plugins/register_test.cljs
@@ -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)))))
diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs
index a12c63a205..a17d9b3149 100644
--- a/frontend/test/frontend_tests/runner.cljs
+++ b/frontend/test/frontend_tests/runner.cljs
@@ -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
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index d71621ab02..18000c059e 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -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."
diff --git a/frontend/translations/es.po b/frontend/translations/es.po
index c1464fe980..03bac196ed 100644
--- a/frontend/translations/es.po
+++ b/frontend/translations/es.po
@@ -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."
diff --git a/mcp/README.md b/mcp/README.md
index 1661f0d019..59d60aaf61 100644
--- a/mcp/README.md
+++ b/mcp/README.md
@@ -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:
diff --git a/mcp/packages/common/src/types.ts b/mcp/packages/common/src/types.ts
index 3809a14fdd..f9d7cde400 100644
--- a/mcp/packages/common/src/types.ts
+++ b/mcp/packages/common/src/types.ts
@@ -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. */
diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json
index 1a44059515..d4a3723755 100644
--- a/mcp/packages/plugin/package.json
+++ b/mcp/packages/plugin/package.json
@@ -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",
diff --git a/mcp/packages/plugin/public/manifest.json b/mcp/packages/plugin/public/manifest.json
index 022c7ece9c..c67296f1db 100644
--- a/mcp/packages/plugin/public/manifest.json
+++ b/mcp/packages/plugin/public/manifest.json
@@ -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"]
}
diff --git a/mcp/packages/plugin/src/ConnectionMetadata.test.ts b/mcp/packages/plugin/src/ConnectionMetadata.test.ts
index 954eefeb9f..3131086153 100644
--- a/mcp/packages/plugin/src/ConnectionMetadata.test.ts
+++ b/mcp/packages/plugin/src/ConnectionMetadata.test.ts
@@ -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);
+});
diff --git a/mcp/packages/plugin/src/SessionId.test.ts b/mcp/packages/plugin/src/SessionId.test.ts
index 8837fc7d2b..dbaa3ed162 100644
--- a/mcp/packages/plugin/src/SessionId.test.ts
+++ b/mcp/packages/plugin/src/SessionId.test.ts
@@ -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}$/);
});
diff --git a/mcp/packages/plugin/src/SessionId.ts b/mcp/packages/plugin/src/SessionId.ts
index e81f3e5a59..22e14b4309 100644
--- a/mcp/packages/plugin/src/SessionId.ts
+++ b/mcp/packages/plugin/src/SessionId.ts
@@ -2,8 +2,8 @@
export class SessionId {
private static readonly ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
- public static async forFile(penpotUserSessionId: string, fileId: string): Promise {
- const input = new TextEncoder().encode(JSON.stringify([penpotUserSessionId, fileId]));
+ public static async forSession(penpotUserSessionId: string): Promise {
+ const input = new TextEncoder().encode(penpotUserSessionId);
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));
return SessionId.encode(digest);
}
diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts
index 29e5c0a286..71973ade0b 100644
--- a/mcp/packages/plugin/src/main.ts
+++ b/mcp/packages/plugin/src/main.ts
@@ -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;
}
diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts
index eb01d2d571..025354df74 100644
--- a/mcp/packages/plugin/src/plugin.ts
+++ b/mcp/packages/plugin/src/plugin.ts
@@ -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.
*
diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts
index 87ea2c7616..47004e5cfe 100644
--- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts
+++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts
@@ -178,6 +178,7 @@ export class ExecuteCodeTaskHandler extends TaskHandler {
// 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 {
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();
}
diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml
index e0ebcde698..7fc050cec7 100644
--- a/mcp/packages/server/data/api_types.yml
+++ b/mcp/packages/server/data/api_types.yml
@@ -92,7 +92,7 @@ Penpot:
Hierarchy
- * Omit
+ * Omit
+ 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;
+ listFiles(options: { projectId: string }): Promise;
+ createProject(options: { name: string; teamId?: string }): Promise;
+ createFile(options: { name: string; projectId: string }): Promise;
+ openFile(fileId: string, options?: { teamId?: string }): Promise;
+ 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
+ ```
+
+ 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
+
+ Example
+ ```
+ const projects = await penpotMgmt.listProjects();
+ ```
+ listFiles: |-
+ ```
+ listFiles(options: { projectId: string }): Promise
+ ```
+
+ 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
+
+ Example
+ ```
+ const files = await penpotMgmt.listFiles({ projectId: project.id });
+ ```
+ createProject: |-
+ ```
+ createProject(options: { name: string; teamId?: string }): Promise
+ ```
+
+ 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
+
+ Example
+ ```
+ const project = await penpotMgmt.createProject({ name: 'Design system' });
+ ```
+ createFile: |-
+ ```
+ createFile(options: { name: string; projectId: string }): Promise
+ ```
+
+ 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
+
+ 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
+ ```
+
+ 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
+
+ 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;
version: string;
root: Shape | null;
currentFile: File | null;
@@ -5082,6 +5374,13 @@ Context:
```
members:
Properties:
+ management: |-
+ ```
+ readonly management?: Omit
+ ```
+
+ 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
diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md
index be9177619c..437c58c7ab 100644
--- a/mcp/packages/server/data/initial_instructions.md
+++ b/mcp/packages/server/data/initial_instructions.md
@@ -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.
diff --git a/mcp/packages/server/src/PluginBridge.sessions.test.ts b/mcp/packages/server/src/PluginBridge.sessions.test.ts
index 1472353588..1a42241169 100644
--- a/mcp/packages/server/src/PluginBridge.sessions.test.ts
+++ b/mcp/packages/server/src/PluginBridge.sessions.test.ts
@@ -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 }]);
+ }
+});
diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts
index f53ed3f849..913b28b26a 100644
--- a/mcp/packages/server/src/PluginBridge.ts
+++ b/mcp/packages/server/src/PluginBridge.ts
@@ -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;
}
diff --git a/mcp/packages/server/src/dispatch/TaskDispatcher.ts b/mcp/packages/server/src/dispatch/TaskDispatcher.ts
index b9fdcf17aa..062af6b80c 100644
--- a/mcp/packages/server/src/dispatch/TaskDispatcher.ts
+++ b/mcp/packages/server/src/dispatch/TaskDispatcher.ts
@@ -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")
);
diff --git a/mcp/packages/server/src/tools/ExecuteCodeTool.ts b/mcp/packages/server/src/tools/ExecuteCodeTool.ts
index ee15401005..98932a154a 100644
--- a/mcp/packages/server/src/tools/ExecuteCodeTool.ts
+++ b/mcp/packages/server/src/tools/ExecuteCodeTool.ts
@@ -49,8 +49,8 @@ export class ExecuteCodeTool extends Tool {
"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; " +
diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml
index 1ebfccbe6d..854a012147 100644
--- a/mcp/pnpm-lock.yaml
+++ b/mcp/pnpm-lock.yaml
@@ -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':
diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md
index 4b45df68b0..041d252969 100644
--- a/plugins/CHANGELOG.md
+++ b/plugins/CHANGELOG.md
@@ -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.
diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts
index b7dbcc02fc..031f7cc3ba 100644
--- a/plugins/libs/plugin-types/index.d.ts
+++ b/plugins/libs/plugin-types/index.d.ts
@@ -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;
+ /**
+ * 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;
+ /**
+ * 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;
+ /**
+ * 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;
+ /**
+ * 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;
+ /** 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;
/**
* 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;
}
diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts
index 8956a5124e..44f5645cc5 100644
--- a/plugins/libs/plugins-runtime/src/lib/api/index.ts
+++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts
@@ -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>,
) {
- 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,
};
}
diff --git a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts
index 308fb81245..e53eddacc3 100644
--- a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts
+++ b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts
@@ -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({
diff --git a/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts b/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts
index 29d0b6d691..7960f77ec2 100644
--- a/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts
+++ b/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts
@@ -83,6 +83,7 @@ describe('createPlugin', () => {
manifest,
expect.any(Function),
expect.any(Function),
+ undefined,
);
expect(createSandbox).toHaveBeenCalledWith(mockPluginManager, undefined);
expect(mockSandbox.evaluate).toHaveBeenCalled();
diff --git a/plugins/libs/plugins-runtime/src/lib/create-plugin.ts b/plugins/libs/plugins-runtime/src/lib/create-plugin.ts
index fc4ca233f3..c2b4a2f548 100644
--- a/plugins/libs/plugins-runtime/src/lib/create-plugin.ts
+++ b/plugins/libs/plugins-runtime/src/lib/create-plugin.ts
@@ -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 | 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();
diff --git a/plugins/libs/plugins-runtime/src/lib/create-sandbox.spec.ts b/plugins/libs/plugins-runtime/src/lib/create-sandbox.spec.ts
index 97cc4bb123..45af8b7621 100644
--- a/plugins/libs/plugins-runtime/src/lib/create-sandbox.spec.ts
+++ b/plugins/libs/plugins-runtime/src/lib/create-sandbox.spec.ts
@@ -33,6 +33,7 @@ describe('createSandbox', () => {
beforeEach(() => {
mockPlugin = {
+ manifest: { scope: 'workspace' },
code: 'console.log("Plugin running");',
timeouts: new Set>(),
} as unknown as Awaited>;
diff --git a/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts b/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts
index 292ae49183..c8aaf3c40a 100644
--- a/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts
+++ b/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts
@@ -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 {
diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts
index 52a0267db0..9305878b3f 100644
--- a/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts
+++ b/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts
@@ -30,7 +30,7 @@ describe('loadPlugin host context boundary (regression for #11001)', () => {
close: vi.fn(),
sendMessage: vi.fn(),
},
- } as unknown as Awaited>);
+ } as unknown as NonNullable>>);
});
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
diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts
index 06ef7b20b7..f921455fbe 100644
--- a/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts
+++ b/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts
@@ -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();
+ 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 {
@@ -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);
+ });
});
diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts
index 779619279c..853c1d12c7 100644
--- a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts
+++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts
@@ -33,7 +33,7 @@ vi.mock('./ses.js', () => ({
describe('plugin-loader', () => {
let mockContext: Context;
let manifest: Manifest;
- let mockPluginApi: Awaited>;
+ let mockPluginApi: NonNullable>>;
let mockClose: ReturnType;
beforeEach(() => {
@@ -61,7 +61,7 @@ describe('plugin-loader', () => {
close: mockClose,
sendMessage: vi.fn(),
},
- } as unknown as Awaited>;
+ } as unknown as NonNullable>>;
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>;
+ } as unknown as NonNullable>>;
vi.mocked(createPlugin).mockResolvedValue(mockPluginWithIframe);
@@ -173,7 +174,7 @@ describe('plugin-loader', () => {
},
iframeWindow: mockIframeWindow1,
manifest: { ...manifest, host: 'http://localhost:4202' },
- } as unknown as Awaited>;
+ } as unknown as NonNullable>>;
const mockPluginApi2 = {
plugin: {
@@ -182,7 +183,7 @@ describe('plugin-loader', () => {
},
iframeWindow: mockIframeWindow2,
manifest: { ...manifest, host: 'http://localhost:4203' },
- } as unknown as Awaited>;
+ } as unknown as NonNullable>>;
vi.mocked(createPlugin).mockResolvedValue(mockPluginApi1);
await loadPlugin(manifest);
@@ -215,7 +216,7 @@ describe('plugin-loader', () => {
pluginId: 'background-plugin',
allowBackground: true,
},
- } as unknown as Awaited>;
+ } as unknown as NonNullable>>;
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),
);
});
});
diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts
index e22e62c70c..8da32c4d64 100644
--- a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts
+++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts
@@ -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>[] = [];
+type Plugin = NonNullable>>;
+
+let plugins: Plugin[] = [];
+const pendingPlugins = new Map();
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) {
diff --git a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts
index 16d4fd5e28..357fe9f95f 100644
--- a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts
+++ b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts
@@ -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',
]),
diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts
index 7878a1abcd..3cb6a9c261 100644
--- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts
+++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts
@@ -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,