mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
🎉 Introduce global plugins and move MCP to application scope
Global plugin lifecycle:
- Add optional manifest scope "global" for plugins that remain active
across dashboard and workspace navigation within one browser tab.
Existing plugins retain workspace scope by default.
- Preserve global sandboxes when a workspace ends or another plugin
loads, including when that other plugin fails to initialize.
- Avoid duplicate instances of an already-running global plugin.
- Close global plugins on explicit closure or logout, with idempotent
cleanup of listeners, timers and UI resources. Global scope does not
imply automatic startup or persistence across a browser reload.
Plugin API and navigation:
- Keep a permanently live penpot facade whose getters follow the current
workspace, while user, theme and UI facilities remain available on
the dashboard.
- Expose penpotMgmt to global plugins with workspace metadata,
none/loading/ready status and workspacechange subscriptions.
- Add awaitable openFile(fileId, {teamId}) navigation in the current tab,
defaulting to the current team and resolving only once the requested
file and its active page are ready.
- Reject invalid identifiers, failed or superseded navigation and waits
exceeding 30 seconds; release navigation watchers on completion.
- Guard workspace-bound operations while no workspace is ready and
return null from root, currentFile and currentPage in that state.
- Require content:read for the initial management API. Callers must
reacquire file, page and shape references after changing files.
MCP lifecycle and connection controls:
- Make MCP a global plugin and initialize its integrated controller per
authenticated application session instead of per workspace.
- Load the enabled integration with a valid token on the dashboard as
well as in a file, retaining its existing hidden-iframe implementation.
- Keep enabling and connecting separate: an enabled plugin starts idle,
and each tab connects explicitly through dashboard or workspace
controls.
- Preserve the iframe, WebSocket, heartbeat and reconnect machinery
during navigation; disconnect on request and close on disable/logout.
MCP sessions and execution:
- Derive the short MCP session ID solely from currentUser.sessionId,
excluding the file ID. Retain the SHA-256-based, 50-bit Base32 encoding
to produce a stable, copyable 10-character identifier.
- Allow sessions without an open file and update workspace metadata over
the existing connection without replacing its routing identity.
- Refresh context after the connection handshake so initialization does
not leave stale file metadata.
- Expose penpotMgmt to execute_code, preserve execution storage across
navigation and skip layout waits when no workspace is ready.
- Update API types, tool guidance, documentation and the changelog.
Validation:
- Cover authenticated startup, workspace survival, logout cleanup,
readiness, navigation failure, duplicate loads and session routing.
- Verify that short IDs remain stable across files and reconnects and
that disconnect cancels pending asynchronous ID initialization.
- Pass 16 focused frontend tests, 73 runtime tests, 30 MCP plugin tests,
builds, TypeScript checks and frontend/runtime lint.
- Pass all 73 MCP server tests with --test-force-exit; the normal runner
still stays alive after assertions finish.
- Verify a live dashboard-to-file-to-dashboard round trip without a
document or iframe reload, preserving facade identity and storage.
AI-assisted-by: gpt-6
This commit is contained in:
parent
31000c3055
commit
be17fabc6a
@ -58,6 +58,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)}]]
|
||||
|
||||
@ -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 sidebar or **MCP → Connect** in 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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))))
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,6 +37,7 @@
|
||||
: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"
|
||||
@ -147,7 +149,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 +236,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 +276,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)))))
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.team :as dtm]
|
||||
[app.main.data.workspace.mcp :as mcp]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.router :as rt]
|
||||
[app.main.store :as st]
|
||||
@ -255,6 +256,13 @@
|
||||
(get default-project :id)
|
||||
|
||||
team-id (get team :id)
|
||||
mcp-state (mf/deref refs/mcp)
|
||||
mcp-requested? (:connection-requested mcp-state)
|
||||
toggle-mcp (mf/use-fn
|
||||
(mf/deps mcp-requested?)
|
||||
#(st/emit! (if mcp-requested?
|
||||
(mcp/user-disconnect-mcp)
|
||||
(mcp/connect-mcp))))
|
||||
|
||||
projects? (= section :dashboard-recent)
|
||||
fonts? (= section :dashboard-fonts)
|
||||
@ -382,6 +390,21 @@
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]]
|
||||
|
||||
|
||||
(when (and (contains? cf/flags :mcp)
|
||||
(:enabled mcp-state)
|
||||
(:token-valid mcp-state))
|
||||
[:div {:class (stl/css :sidebar-content-section)}
|
||||
[:ul {:class (stl/css :sidebar-nav)}
|
||||
[:li {:class (stl/css :sidebar-nav-item)}
|
||||
[:> link* {:action toggle-mcp
|
||||
:keyboard-action toggle-mcp
|
||||
:class (stl/css :sidebar-link)}
|
||||
[:span {:class (stl/css :element-title)}
|
||||
"MCP: "
|
||||
(if mcp-requested?
|
||||
(tr "workspace.header.menu.mcp.plugin.status.disconnect")
|
||||
(tr "workspace.header.menu.mcp.plugin.status.connect"))]]]]])
|
||||
|
||||
[:div {:class (stl/css :sidebar-content-section)
|
||||
:data-testid "pinned-projects"}
|
||||
[:div {:class (stl/css :sidebar-section-title)}
|
||||
|
||||
@ -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))}
|
||||
|
||||
@ -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)))))
|
||||
|
||||
|
||||
78
frontend/src/app/plugins/management.cljs
Normal file
78
frontend/src/app/plugins/management.cljs
Normal file
@ -0,0 +1,78 @@
|
||||
;; 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.uuid :as uuid]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.router :as rt]
|
||||
[app.main.store :as st]
|
||||
[app.util.object :as obj]))
|
||||
|
||||
(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 create-context
|
||||
[]
|
||||
(obj/reify {:name "PenpotManagementContext"}
|
||||
:workspace
|
||||
{:get (fn [] (clj->js (workspace-context @st/state)))}
|
||||
:openFile open-file))
|
||||
@ -102,6 +102,7 @@
|
||||
{:plugin-id plugin-id
|
||||
:url (str plugin-url)
|
||||
:version vers
|
||||
:scope (obj/get manifest "scope")
|
||||
:name name
|
||||
:description desc
|
||||
:host origin
|
||||
|
||||
@ -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)))
|
||||
|
||||
83
frontend/test/frontend_tests/plugins/management_test.cljs
Normal file
83
frontend/test/frontend_tests/plugins/management_test.cljs
Normal file
@ -0,0 +1,83 @@
|
||||
;; 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.store :as st]
|
||||
[app.plugins.events :as events]
|
||||
[app.plugins.management :as management]
|
||||
[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)))))
|
||||
@ -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
|
||||
|
||||
@ -33,6 +33,16 @@ This repository thus contains not only the MCP server implementation itself
|
||||
but also the supporting Penpot MCP Plugin
|
||||
(see section [Repository Structure](#repository-structure) below).
|
||||
|
||||
## Global plugin lifecycle
|
||||
|
||||
- The MCP plugin stays loaded across dashboard and workspace navigation, including when the integration runs with its UI hidden.
|
||||
- Enabling the integration loads the plugin; connecting remains an explicit action in the current tab. Logout closes it.
|
||||
- The MCP session ID is a short, 10-character Base32 hash of `penpot.currentUser.sessionId` alone. File metadata changes without replacing the WebSocket connection or execution `storage`.
|
||||
- `penpotMgmt.workspace` reports `none`, `loading` or `ready`, with file and team metadata. Subscribe through `penpotMgmt.on("workspacechange", callback)`.
|
||||
- `await penpotMgmt.openFile(fileId, {teamId})` opens a file in the same tab and waits for its page to be ready. The team defaults to the current team; failed navigation rejects the promise.
|
||||
- The `penpot` facade stays live. File operations require a ready workspace; reacquire file, page and shape references after navigation.
|
||||
- Global plugins declare `"scope": "global"` in their manifest. Management metadata and navigation currently require `content:read` permission.
|
||||
|
||||
## Demonstration
|
||||
|
||||
[](https://v32155.1blu.de/penpot/PenpotFest2025.mp4)
|
||||
@ -243,7 +253,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:
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
@ -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,17 @@ 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("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 +203,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 +228,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 +256,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 +294,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 +308,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);
|
||||
});
|
||||
|
||||
@ -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}$/);
|
||||
});
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
export class SessionId {
|
||||
private static readonly ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
||||
|
||||
public static async forFile(penpotUserSessionId: string, fileId: string): Promise<string> {
|
||||
const input = new TextEncoder().encode(JSON.stringify([penpotUserSessionId, fileId]));
|
||||
public static async forSession(penpotUserSessionId: string): Promise<string> {
|
||||
const input = new TextEncoder().encode(penpotUserSessionId);
|
||||
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));
|
||||
return SessionId.encode(digest);
|
||||
}
|
||||
|
||||
1
mcp/packages/plugin/src/index.d.ts
vendored
1
mcp/packages/plugin/src/index.d.ts
vendored
@ -8,6 +8,7 @@ interface McpOptions {
|
||||
|
||||
declare global {
|
||||
const mcp: undefined | McpOptions;
|
||||
const penpotMgmt: import("../../../../plugins/libs/plugin-types").PenpotMgmt;
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -57,7 +57,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 +66,22 @@ penpot.ui.onMessage<
|
||||
});
|
||||
}
|
||||
} else if (typeof message === "object" && message.type === "connection-metadata-request") {
|
||||
const file = penpot.currentFile;
|
||||
if (file && message.sessionId) {
|
||||
const workspace = penpotMgmt.workspace;
|
||||
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 +92,21 @@ penpot.ui.onMessage<
|
||||
}
|
||||
});
|
||||
|
||||
/** Sends the current workspace metadata without replacing the connection. */
|
||||
function sendWorkspaceContext(): void {
|
||||
const workspace = penpotMgmt.workspace;
|
||||
penpot.ui.sendMessage({
|
||||
type: "context-update",
|
||||
context: {
|
||||
fileId: workspace.fileId,
|
||||
fileName: workspace.fileName,
|
||||
workspaceState: workspace.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
penpotMgmt.on("workspacechange", sendWorkspaceContext);
|
||||
|
||||
/**
|
||||
* Handles plugin task requests received from the MCP server via WebSocket.
|
||||
*
|
||||
|
||||
@ -178,6 +178,7 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
|
||||
// initialize context, making penpot, penpotUtils, storage and the custom console available
|
||||
this.context = {
|
||||
penpot: penpot,
|
||||
penpotMgmt: penpotMgmt,
|
||||
storage: {},
|
||||
console: new ExecuteCodeTaskConsole(),
|
||||
penpotUtils: PenpotUtils,
|
||||
@ -217,7 +218,7 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
|
||||
// wait for layout updates prior to executing the supplied code (if method is available)
|
||||
try {
|
||||
// @ts-ignore - TODO Penpot.waitForLayoutUpdate is not yet in the released types
|
||||
if (penpot.waitForLayoutUpdate) {
|
||||
if (penpotMgmt.workspace.status === "ready" && penpot.waitForLayoutUpdate) {
|
||||
// @ts-ignore
|
||||
await penpot.waitForLayoutUpdate();
|
||||
}
|
||||
|
||||
@ -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 }]);
|
||||
}
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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")
|
||||
);
|
||||
|
||||
@ -49,8 +49,10 @@ export class ExecuteCodeTool extends Tool<ExecuteCodeArgs> {
|
||||
"Executes JavaScript code in the Penpot plugin context.\n" +
|
||||
"IMPORTANT: Before using this tool, make sure you have read the 'Penpot High-Level Overview' and know " +
|
||||
"which Penpot API functionality is necessary and how to use it.\n" +
|
||||
"You have access two main objects: `penpot` (the Penpot API, of type `Penpot`), `penpotUtils`, " +
|
||||
"and `storage`.\n" +
|
||||
"You have access to `penpot` (the Penpot API, of type `Penpot`), `penpotMgmt`, `penpotUtils`, and `storage`.\n" +
|
||||
"`penpotMgmt.workspace.status` is none, loading or ready. Use `await penpotMgmt.openFile(fileId, {teamId})` " +
|
||||
"to open a file and wait until it is ready; teamId defaults to the current team. " +
|
||||
"File operations require a ready workspace. Storage survives navigation; reacquire file and shape objects after changing files.\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; " +
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- **plugins-runtime**: Added manifest `scope: "global"` for plugins that survive workspace navigation and close on logout.
|
||||
- **plugin-types**: Added `penpotMgmt` with workspace readiness, `workspacechange` events and `openFile()`, available to global plugins with `content:read` permission.
|
||||
- **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
|
||||
|
||||
27
plugins/libs/plugin-types/index.d.ts
vendored
27
plugins/libs/plugin-types/index.d.ts
vendored
@ -4,7 +4,7 @@
|
||||
*/
|
||||
export interface Penpot extends Omit<
|
||||
Context,
|
||||
'addListener' | 'removeListener'
|
||||
'addListener' | 'removeListener' | 'management'
|
||||
> {
|
||||
readonly ui: {
|
||||
/**
|
||||
@ -778,10 +778,32 @@ 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;
|
||||
}
|
||||
|
||||
/** Application management API available to global plugins. */
|
||||
export interface PenpotMgmt {
|
||||
readonly workspace: WorkspaceContext;
|
||||
/** Opens a file in this tab and resolves once its workspace and page are ready. */
|
||||
openFile(fileId: string, options?: { teamId?: string }): Promise<void>;
|
||||
/** Subscribes to workspace availability and metadata changes. */
|
||||
on(
|
||||
type: 'workspacechange',
|
||||
callback: (workspace: WorkspaceContext) => void,
|
||||
): symbol;
|
||||
off(listenerId: symbol): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the context of Penpot, providing access to various Penpot functionalities and data.
|
||||
*/
|
||||
export interface Context {
|
||||
readonly management?: Pick<PenpotMgmt, 'workspace' | 'openFile'>;
|
||||
/**
|
||||
* Returns the current penpot version.
|
||||
*/
|
||||
@ -1556,6 +1578,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 +5781,5 @@ export interface Viewport {
|
||||
|
||||
declare global {
|
||||
const penpot: Penpot;
|
||||
const penpotMgmt: PenpotMgmt;
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ import type {
|
||||
Page,
|
||||
Path,
|
||||
Penpot,
|
||||
PenpotMgmt,
|
||||
Rectangle,
|
||||
Shape,
|
||||
SvgRaw,
|
||||
@ -36,6 +37,8 @@ import { createPluginManager } from '../plugin-manager.js';
|
||||
|
||||
export const validEvents = [
|
||||
'finish',
|
||||
'logout',
|
||||
'workspacechange',
|
||||
'pagechange',
|
||||
'filechange',
|
||||
'selectionchange',
|
||||
@ -47,10 +50,24 @@ export const validEvents = [
|
||||
export function createApi(
|
||||
plugin: Awaited<ReturnType<typeof createPluginManager>>,
|
||||
) {
|
||||
const checkPermission = (permission: Permissions) => {
|
||||
const workspaceUnavailable = () =>
|
||||
plugin.manifest.scope === 'global' &&
|
||||
plugin.context.management?.workspace.status !== 'ready';
|
||||
|
||||
const requireWorkspace = () => {
|
||||
if (workspaceUnavailable()) {
|
||||
throw new Error(
|
||||
'No workspace is ready. Use penpotMgmt.openFile() first.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const checkPermission = (permission: Permissions, workspace = true) => {
|
||||
if (!plugin.manifest.permissions.includes(permission)) {
|
||||
throw new Error(`Permission ${permission} is not granted`);
|
||||
}
|
||||
if (workspace && /^(content|library|comment):/.test(permission))
|
||||
requireWorkspace();
|
||||
};
|
||||
|
||||
const penpot: Penpot = {
|
||||
@ -162,7 +179,7 @@ export function createApi(
|
||||
z.function().parse(callback);
|
||||
|
||||
// To suscribe to events needs the read permission
|
||||
checkPermission('content:read');
|
||||
checkPermission('content:read', false);
|
||||
|
||||
return plugin.registerListener(type, callback, props);
|
||||
},
|
||||
@ -178,18 +195,18 @@ export function createApi(
|
||||
},
|
||||
|
||||
get root(): Shape | null {
|
||||
checkPermission('content:read');
|
||||
return plugin.context.root;
|
||||
checkPermission('content:read', false);
|
||||
return workspaceUnavailable() ? null : plugin.context.root;
|
||||
},
|
||||
|
||||
get currentFile(): File | null {
|
||||
checkPermission('content:read');
|
||||
return plugin.context.currentFile;
|
||||
checkPermission('content:read', false);
|
||||
return workspaceUnavailable() ? null : plugin.context.currentFile;
|
||||
},
|
||||
|
||||
get currentPage(): Page | null {
|
||||
checkPermission('content:read');
|
||||
return plugin.context.currentPage;
|
||||
checkPermission('content:read', false);
|
||||
return workspaceUnavailable() ? null : plugin.context.currentPage;
|
||||
},
|
||||
|
||||
get selection(): Shape[] {
|
||||
@ -203,10 +220,12 @@ export function createApi(
|
||||
},
|
||||
|
||||
get viewport(): Viewport {
|
||||
requireWorkspace();
|
||||
return plugin.context.viewport;
|
||||
},
|
||||
|
||||
get history(): HistoryContext {
|
||||
requireWorkspace();
|
||||
return plugin.context.history;
|
||||
},
|
||||
|
||||
@ -395,7 +414,28 @@ export function createApi(
|
||||
},
|
||||
};
|
||||
|
||||
const penpotMgmt: PenpotMgmt = {
|
||||
get workspace() {
|
||||
checkPermission('content:read', false);
|
||||
return plugin.context.management!.workspace;
|
||||
},
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -33,6 +33,7 @@ describe('createSandbox', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlugin = {
|
||||
manifest: { scope: 'workspace' },
|
||||
code: 'console.log("Plugin running");',
|
||||
timeouts: new Set<ReturnType<typeof setTimeout>>(),
|
||||
} as unknown as Awaited<ReturnType<typeof createPluginManager>>;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -98,9 +98,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 +117,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 +171,79 @@ describe('loadPlugin real initialization path (regression for #11001)', () => {
|
||||
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
|
||||
expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -17,20 +17,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) => {
|
||||
@ -57,7 +56,17 @@ export const loadPlugin = async function (
|
||||
return;
|
||||
}
|
||||
|
||||
closeAllPlugins();
|
||||
if (manifest.scope === 'global') {
|
||||
if (
|
||||
plugins.some(
|
||||
(plugin) => plugin.manifest?.pluginId === manifest.pluginId,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
closeAllPlugins();
|
||||
}
|
||||
|
||||
// The host context is not deeply frozen at this load stage.
|
||||
//
|
||||
@ -74,7 +83,8 @@ 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(
|
||||
let plugin: Awaited<ReturnType<typeof createPlugin>> | undefined = undefined;
|
||||
plugin = await createPlugin(
|
||||
context,
|
||||
manifest,
|
||||
() => {
|
||||
@ -88,7 +98,7 @@ export const loadPlugin = async function (
|
||||
);
|
||||
plugins.push(plugin);
|
||||
} catch (error) {
|
||||
closeAllPlugins();
|
||||
if (manifest.scope !== 'global') closeAllPlugins();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -40,11 +40,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 +59,11 @@ export async function createPluginManager(
|
||||
};
|
||||
|
||||
const closePlugin = () => {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
removeAllEventListeners();
|
||||
destroyListener(listenerId);
|
||||
destroyListener(logoutId);
|
||||
|
||||
timeouts.forEach(clearTimeout);
|
||||
timeouts.clear();
|
||||
@ -74,8 +77,6 @@ export async function createPluginManager(
|
||||
modal = null;
|
||||
}
|
||||
|
||||
destroyed = true;
|
||||
|
||||
onCloseCallback();
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user