mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
Merge pull request #12090 from oraios/mcp-global-plugin
🎉 Add global plugins and enable MCP across workspaces
This commit is contained in:
commit
f62f4024be
@ -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"})
|
||||
|
||||
@ -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)}]]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -160,6 +160,7 @@ Set <code class="language-js">"version": 2</code> in your
|
||||
- **Code**: your plugin's file location. It needs to be compiled to JavaScript and reachable.
|
||||
- **Icon**: your plugin's icon, which will be also displayed in the plugin manager modal. It'll be a <code class="language-js"><img src=""></code> tag so you can use whichever image format works better for you. **It's recommended to use a 56x56 pixel icon for the best appearance on all devices**.
|
||||
- **Permissions**: your plugin's permissions, which allow access to different parts of the Penpot API.
|
||||
- **Scope** (optional): <code class="language-js">"workspace"</code> (the default) or <code class="language-js">"global"</code>. A workspace plugin closes when its file closes. A global plugin keeps running while the user moves between the dashboard and files, and gets the <code class="language-js">penpotMgmt</code> object to find, create and open projects and files. Global scope adds the <code class="language-js">allow:global</code> permission, which the user must accept.
|
||||
|
||||
#### Types of permissions
|
||||
|
||||
@ -185,6 +186,9 @@ Typical use cases: downloading the full project file for backup or sharing.
|
||||
- <code class="language-js">allow:localstorage</code>: Allows the access to the local storage proxy to store information. This info is only available for the plugin installation but be aware that a user can see this information in the browser.
|
||||
Typical use cases: storing authentication tokens for a plugin login
|
||||
|
||||
- <code class="language-js">allow:global</code>: Allows the plugin to keep running while the user moves between the dashboard and files, and to find and open their projects and files. Added automatically when the manifest sets <code class="language-js">"scope": "global"</code>.
|
||||
Typical use cases: assistants or integrations that work across several files.
|
||||
|
||||
_Note: Write permissions automatically includes its corresponding read permission (e.g., <code class="language-js">content:write</code> includes <code class="language-js">content:read</code>) because reading is required to perform write or modification actions._
|
||||
|
||||
### What are plugin.ts and plugin.js files?
|
||||
|
||||
@ -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,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)))))
|
||||
|
||||
152
frontend/src/app/main/ui/components/mcp_menu.cljs
Normal file
152
frontend/src/app/main/ui/components/mcp_menu.cljs
Normal file
@ -0,0 +1,152 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.main.ui.components.mcp-menu
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.config :as cf]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.workspace.mcp :as mcp]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.util.clipboard :as clipboard]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[app.util.timers :as ts]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defn- cancel-timer!
|
||||
[timer-ref*]
|
||||
(when-let [timer (mf/ref-val timer-ref*)]
|
||||
(ts/dispose! timer)
|
||||
(mf/set-ref-val! timer-ref* nil)))
|
||||
|
||||
(mf/defc mcp-menu*
|
||||
{::mf/wrap [mf/memo]}
|
||||
[{:keys [is-mcp-connected is-connection-requested session-id dashboard]}]
|
||||
(let [copied-text (tr "workspace.toolbar.mcp-session-copied")
|
||||
copy-error (tr "errors.clipboard-api-unavailable")
|
||||
menu-open* (mf/use-state false)
|
||||
menu-open? (deref menu-open*)
|
||||
|
||||
open-timer* (mf/use-ref nil)
|
||||
close-timer* (mf/use-ref nil)
|
||||
|
||||
on-toggle-menu
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
(dom/stop-propagation event)
|
||||
(cancel-timer! open-timer*)
|
||||
(cancel-timer! close-timer*)
|
||||
(swap! menu-open* not)))
|
||||
|
||||
on-close-menu
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(cancel-timer! open-timer*)
|
||||
(cancel-timer! close-timer*)
|
||||
(reset! menu-open* false)))
|
||||
|
||||
on-display-menu
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(cancel-timer! close-timer*)
|
||||
(cancel-timer! open-timer*)
|
||||
(mf/set-ref-val!
|
||||
open-timer*
|
||||
(ts/schedule 350
|
||||
#(do
|
||||
(reset! menu-open* true)
|
||||
(mf/set-ref-val! open-timer* nil))))))
|
||||
|
||||
on-hide-menu
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(cancel-timer! open-timer*)
|
||||
(cancel-timer! close-timer*)
|
||||
(mf/set-ref-val!
|
||||
close-timer*
|
||||
(ts/schedule 350
|
||||
#(do
|
||||
(reset! menu-open* false)
|
||||
(mf/set-ref-val! close-timer* nil))))))
|
||||
|
||||
on-connect
|
||||
(mf/use-fn
|
||||
(mf/deps dashboard)
|
||||
#(st/emit! (mcp/connect-mcp)
|
||||
(ev/event {::ev/name "connect-mcp-plugin"
|
||||
::ev/origin (if dashboard "dashboard:header" "workspace:toolbar")})))
|
||||
|
||||
on-disconnect
|
||||
(mf/use-fn
|
||||
#(st/emit! (mcp/user-disconnect-mcp)))
|
||||
|
||||
on-copy-session
|
||||
(mf/use-fn
|
||||
(mf/deps session-id copied-text copy-error)
|
||||
(fn []
|
||||
(-> (clipboard/to-clipboard session-id)
|
||||
(.then #(st/emit! (ntf/info copied-text)))
|
||||
(.catch #(st/emit! (ntf/error copy-error))))))]
|
||||
|
||||
(mf/with-effect []
|
||||
(fn []
|
||||
(cancel-timer! open-timer*)
|
||||
(cancel-timer! close-timer*)))
|
||||
|
||||
[:div {:class (stl/css-case :mcp-tool true :dashboard dashboard)
|
||||
:on-pointer-enter on-display-menu
|
||||
:on-pointer-leave on-hide-menu}
|
||||
[:> button* {:variant (if dashboard "secondary" "ghost")
|
||||
:on-click on-toggle-menu
|
||||
:aria-haspopup true
|
||||
:aria-expanded menu-open?
|
||||
:aria-pressed menu-open?
|
||||
:data-tool "mcp"
|
||||
:data-testid "mcp-btn"}
|
||||
[:div {:class (stl/css-case :toolbar-mcp-button true
|
||||
:selected menu-open?)}
|
||||
[:span {:class (stl/css-case :toolbar-mcp-button-dot true
|
||||
:connected is-mcp-connected)}]
|
||||
[:span {:class (stl/css-case :toolbar-mcp-button-label true
|
||||
:connected is-mcp-connected)}
|
||||
(tr "workspace.toolbar.mcp")]]]
|
||||
|
||||
[:div {:class (stl/css :toolbar-mcp-menu)}
|
||||
[:> dropdown-menu* {:show menu-open?
|
||||
:on-close on-close-menu
|
||||
:class (stl/css :toolbar-mcp-dropdown)}
|
||||
(when (or is-mcp-connected session-id)
|
||||
[:li {:class (stl/css :toolbar-mcp-dropdown-info)
|
||||
:role "presentation"}
|
||||
(when is-mcp-connected
|
||||
[:span (tr "workspace.toolbar.mcp-connected")])
|
||||
(when session-id
|
||||
[:span (tr "workspace.toolbar.mcp-session-id" session-id)])])
|
||||
(when session-id
|
||||
[:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item)
|
||||
:on-click on-copy-session}
|
||||
(tr "workspace.toolbar.mcp-copy-session-id")])
|
||||
[:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item)
|
||||
:on-click (if is-connection-requested on-disconnect on-connect)}
|
||||
(if is-connection-requested
|
||||
(tr "workspace.header.menu.mcp.plugin.status.disconnect")
|
||||
(tr "workspace.header.menu.mcp.plugin.status.connect"))]]]]))
|
||||
|
||||
(mf/defc dashboard-mcp-menu*
|
||||
[]
|
||||
(let [mcp-state (mf/deref refs/mcp)]
|
||||
(when (and (contains? cf/flags :mcp)
|
||||
(:enabled mcp-state)
|
||||
(:token-valid mcp-state))
|
||||
[:> mcp-menu* {:dashboard true
|
||||
:is-mcp-connected (= "connected" (:connection-status mcp-state))
|
||||
:is-connection-requested (:connection-requested mcp-state)
|
||||
:session-id (:session-id mcp-state)}])))
|
||||
127
frontend/src/app/main/ui/components/mcp_menu.scss
Normal file
127
frontend/src/app/main/ui/components/mcp_menu.scss
Normal file
@ -0,0 +1,127 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
@use "ds/_borders.scss" as *;
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/_utils.scss" as *;
|
||||
@use "ds/spacing.scss" as *;
|
||||
@use "ds/typography.scss" as t;
|
||||
@use "ds/z-index.scss" as *;
|
||||
|
||||
.mcp-tool {
|
||||
position: relative;
|
||||
|
||||
&.dashboard {
|
||||
--mcp-disconnected-dot-color: var(--color-foreground-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-mcp-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-xs);
|
||||
}
|
||||
|
||||
.toolbar-mcp-button-label {
|
||||
&.connected {
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-mcp-button-dot {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
inline-size: px2rem(6);
|
||||
block-size: px2rem(6);
|
||||
border-radius: 50%;
|
||||
background-color: var(--mcp-disconnected-dot-color, var(--color-background-disabled));
|
||||
|
||||
&.connected {
|
||||
background-color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
// One-shot "blob" ripple that confirms the moment the tab becomes
|
||||
// connected (e.g. after using "Switch here"). Triggered purely by the
|
||||
// `.connected` class appearing, in sync with the color change.
|
||||
&.connected::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-accent-primary);
|
||||
opacity: 0;
|
||||
animation: mcp-status-blob 0.5s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mcp-status-blob {
|
||||
from {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(2.5);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-mcp-menu {
|
||||
position: absolute;
|
||||
z-index: var(--z-index-dropdown);
|
||||
left: 0;
|
||||
top: $sz-36;
|
||||
}
|
||||
|
||||
// Same look as the workspace main menu (`.base-menu` in main_menu.scss).
|
||||
// The toolbar overrides `--menu-background-color`, so restore the global
|
||||
// menu value here.
|
||||
.toolbar-mcp-dropdown {
|
||||
--menu-background-color: var(--color-background-tertiary);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
margin: 0;
|
||||
padding: var(--sp-xs);
|
||||
border: $b-2 solid var(--panel-border-color);
|
||||
border-radius: $br-8;
|
||||
background-color: var(--menu-background-color);
|
||||
box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color);
|
||||
}
|
||||
|
||||
.toolbar-mcp-dropdown-info {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
padding: var(--sp-s) var(--sp-m);
|
||||
border-radius: $br-8;
|
||||
color: var(--color-foreground-secondary);
|
||||
background-color: var(--color-background-primary);
|
||||
font-size: px2rem(11);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-mcp-dropdown-item {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--sp-s) var(--sp-m);
|
||||
border-radius: $br-8;
|
||||
color: var(--menu-foreground-color);
|
||||
background-color: var(--menu-background-color);
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
--menu-foreground-color: var(--menu-foreground-color-hover);
|
||||
--menu-background-color: var(--menu-background-color-hover);
|
||||
}
|
||||
}
|
||||
@ -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*
|
||||
|
||||
@ -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}]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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)}]])]
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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)))))
|
||||
|
||||
|
||||
190
frontend/src/app/plugins/management.cljs
Normal file
190
frontend/src/app/plugins/management.cljs
Normal file
@ -0,0 +1,190 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.plugins.management
|
||||
(:require
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.dashboard :as dd]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.router :as rt]
|
||||
[app.main.store :as st]
|
||||
[app.util.object :as obj]
|
||||
[beicon.v2.core :as rx]
|
||||
[clojure.set :as set]
|
||||
[cuerdas.core :as str]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(defn- project-summary
|
||||
[project]
|
||||
#js {:id (str (:id project))
|
||||
:teamId (str (:team-id project))
|
||||
:name (:name project)
|
||||
:isDefault (boolean (:is-default project))
|
||||
:fileCount (or (:count project) 0)})
|
||||
|
||||
(defn- file-summary
|
||||
[file]
|
||||
#js {:id (str (:id file))
|
||||
:teamId (str (:team-id file))
|
||||
:projectId (str (:project-id file))
|
||||
:name (:name file)
|
||||
:modifiedAt (.toISOString ^js (:modified-at file))})
|
||||
|
||||
(defn workspace-context
|
||||
[state]
|
||||
(let [params (rt/get-params state)
|
||||
file-id (when (= :workspace (rt/lookup-name state))
|
||||
(uuid/parse* (:file-id params)))
|
||||
ready? (and file-id
|
||||
(nil? (:exception state))
|
||||
(= file-id (:current-file-id state) (:workspace-ready state))
|
||||
(some? (:current-page-id state))
|
||||
(some? (dsh/lookup-page state file-id (:current-page-id state))))]
|
||||
{:status (cond ready? "ready" file-id "loading" :else "none")
|
||||
:fileId (some-> file-id str)
|
||||
:fileName (when file-id (:name (dsh/lookup-file state file-id)))
|
||||
:teamId (some-> (:current-team-id state) str)}))
|
||||
|
||||
(defn open-file
|
||||
[file-id options]
|
||||
(let [file-id (uuid/parse* file-id)
|
||||
team-id (if-let [id (obj/get options "teamId")]
|
||||
(uuid/parse* id)
|
||||
(:current-team-id @st/state))]
|
||||
(cond
|
||||
(or (nil? file-id) (nil? team-id))
|
||||
(js/Promise.reject (js/Error. "Expected a file UUID and a team UUID"))
|
||||
|
||||
(and (= "ready" (:status (workspace-context @st/state)))
|
||||
(= file-id (:current-file-id @st/state))
|
||||
(= team-id (:current-team-id @st/state)))
|
||||
(js/Promise.resolve nil)
|
||||
|
||||
:else
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(let [key (js/Symbol)
|
||||
initial-route (:route @st/state)
|
||||
timer (atom nil)
|
||||
finish (fn [error]
|
||||
(remove-watch st/state key)
|
||||
(js/clearTimeout @timer)
|
||||
(if error (reject error) (resolve nil)))]
|
||||
(reset! timer (js/setTimeout #(finish (js/Error. "Timed out opening file")) 30000))
|
||||
(add-watch
|
||||
st/state key
|
||||
(fn [_ _ _ state]
|
||||
(let [workspace (workspace-context state)
|
||||
target? (and (= (str file-id) (:fileId workspace))
|
||||
(= (str team-id) (:team-id (rt/get-params state))))]
|
||||
(cond
|
||||
(:exception state)
|
||||
(finish (js/Error. "Unable to open file"))
|
||||
|
||||
(and target? (= "ready" (:status workspace)))
|
||||
(finish nil)
|
||||
|
||||
(and (not= initial-route (:route state)) (not target?))
|
||||
(finish (js/Error. "File navigation was superseded"))))))
|
||||
(st/emit! (rt/nav :workspace {:team-id team-id :file-id file-id}))))))))
|
||||
|
||||
(defn list-projects
|
||||
[options]
|
||||
(let [team-id (if-let [id (obj/get options "teamId")]
|
||||
(uuid/parse* id)
|
||||
(:current-team-id @st/state))]
|
||||
(if (nil? team-id)
|
||||
(js/Promise.reject (js/Error. "Expected a team UUID"))
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(->> (rp/cmd! :get-projects {:team-id team-id})
|
||||
(rx/map (fn [projects]
|
||||
(into-array (map project-summary (remove :deleted-at projects)))))
|
||||
(rx/subs! resolve reject)))))))
|
||||
|
||||
(defn list-files
|
||||
[options]
|
||||
(let [project-id (uuid/parse* (obj/get options "projectId"))]
|
||||
(if (nil? project-id)
|
||||
(js/Promise.reject (js/Error. "Expected a project UUID"))
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(->> (rp/cmd! :get-project-files {:project-id project-id})
|
||||
(rx/map (fn [files]
|
||||
(into-array (map file-summary files))))
|
||||
(rx/subs! resolve reject)))))))
|
||||
|
||||
(defn- creation-name
|
||||
[options]
|
||||
(let [name (obj/get options "name")]
|
||||
(when (string? name)
|
||||
(let [name (str/trim name)]
|
||||
(when (<= 1 (count name) 250)
|
||||
name)))))
|
||||
|
||||
(defn create-project
|
||||
[options]
|
||||
(let [team-id (if-let [id (obj/get options "teamId")]
|
||||
(uuid/parse* id)
|
||||
(:current-team-id @st/state))
|
||||
name (creation-name options)]
|
||||
(cond
|
||||
(nil? team-id)
|
||||
(js/Promise.reject (js/Error. "Expected a team UUID"))
|
||||
|
||||
(nil? name)
|
||||
(js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters"))
|
||||
|
||||
:else
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(->> (rp/cmd! :create-project {:team-id team-id :name name})
|
||||
(rx/tap (fn [project]
|
||||
(st/emit!
|
||||
(fn [state]
|
||||
(if (= (:team-id project) (:current-team-id state))
|
||||
(assoc-in state [:projects (:id project)] (assoc project :count 0))
|
||||
state)))))
|
||||
(rx/map project-summary)
|
||||
(rx/subs! resolve reject)))))))
|
||||
|
||||
(defn create-file
|
||||
[options]
|
||||
(let [project-id (uuid/parse* (obj/get options "projectId"))
|
||||
name (creation-name options)]
|
||||
(cond
|
||||
(nil? project-id)
|
||||
(js/Promise.reject (js/Error. "Expected a project UUID"))
|
||||
|
||||
(nil? name)
|
||||
(js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters"))
|
||||
|
||||
:else
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(let [features (set/difference (:features @st/state #{}) cfeat/frontend-only-features)]
|
||||
(->> (rp/cmd! :create-file {:project-id project-id :name name :features features})
|
||||
(rx/tap (fn [file]
|
||||
(st/emit!
|
||||
(fn [state]
|
||||
(if (= (:team-id file) (:current-team-id state))
|
||||
(ptk/update (dd/file-created file) state)
|
||||
state)))))
|
||||
(rx/map file-summary)
|
||||
(rx/subs! resolve reject))))))))
|
||||
|
||||
(defn create-context
|
||||
[]
|
||||
(obj/reify {:name "PenpotManagementContext"}
|
||||
:workspace
|
||||
{:get (fn [] (clj->js (workspace-context @st/state)))}
|
||||
:openFile open-file
|
||||
:listProjects list-projects
|
||||
:listFiles list-files
|
||||
:createProject create-project
|
||||
:createFile create-file))
|
||||
@ -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
|
||||
|
||||
@ -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)))
|
||||
|
||||
280
frontend/test/frontend_tests/plugins/management_test.cljs
Normal file
280
frontend/test/frontend_tests/plugins/management_test.cljs
Normal file
@ -0,0 +1,280 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns frontend-tests.plugins.management-test
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.events :as events]
|
||||
[app.plugins.management :as management]
|
||||
[beicon.v2.core :as rx]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.async :as a]
|
||||
[frontend-tests.helpers.mock :as mock]))
|
||||
|
||||
(def file-id (uuid/next))
|
||||
(def page-id (uuid/next))
|
||||
(def team-id (uuid/next))
|
||||
|
||||
(def dashboard
|
||||
{:route {:data {:name :dashboard-recent}}
|
||||
:current-team-id team-id})
|
||||
|
||||
(def loading
|
||||
(assoc dashboard
|
||||
:route {:data {:name :workspace}
|
||||
:params {:query {:file-id (str file-id) :team-id (str team-id)}}}
|
||||
:current-file-id file-id
|
||||
:workspace-ready file-id))
|
||||
|
||||
(def ready
|
||||
(assoc loading
|
||||
:current-page-id page-id
|
||||
:files {file-id {:id file-id :name "Design"
|
||||
:data {:pages-index {page-id {:id page-id :objects {}}}}}}))
|
||||
|
||||
(t/deftest workspace-readiness-requires-an-initialized-page
|
||||
(t/is (= "none" (:status (management/workspace-context dashboard))))
|
||||
(t/is (= "loading" (:status (management/workspace-context loading))))
|
||||
(t/is (= "ready" (:status (management/workspace-context ready))))
|
||||
(t/is (= "none" (:status (management/workspace-context (assoc ready :route (:route dashboard)))))))
|
||||
|
||||
(t/deftest ^:async open-file-waits-for-page-readiness
|
||||
(let [state (atom dashboard)
|
||||
seen (atom [])
|
||||
done? (atom false)]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state state
|
||||
st/emit! (fn ([_] (js/setTimeout #(reset! state loading) 0))
|
||||
([_ & _] nil))}
|
||||
(let [listener (events/add-listener "workspacechange" (str uuid/zero)
|
||||
#(swap! seen conj (.-status %)) nil)
|
||||
result (-> (management/open-file (str file-id) nil)
|
||||
(.then #(reset! done? true)))]
|
||||
(try
|
||||
(await (a/wait-for #(= ["loading"] @seen)))
|
||||
(t/is (false? @done?))
|
||||
(reset! state ready)
|
||||
(await result)
|
||||
(t/is (= ["loading" "ready"] @seen))
|
||||
(t/is (true? @done?))
|
||||
(finally
|
||||
(events/remove-listener listener))))))))
|
||||
|
||||
(t/deftest ^:async open-file-rejects-superseded-navigation
|
||||
(let [state (atom dashboard)]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state state
|
||||
st/emit! (fn ([_] (js/setTimeout #(reset! state loading) 0))
|
||||
([_ & _] nil))}
|
||||
(let [result (-> (management/open-file (str file-id) nil)
|
||||
(.then (fn [] nil) (fn [error] (.-message error))))]
|
||||
(await (a/wait-for #(= loading @state)))
|
||||
(reset! state {:route {:data {:name :settings-profile}}})
|
||||
(t/is (= "File navigation was superseded" (await result))))))))
|
||||
|
||||
(t/deftest ^:async open-file-rejects-invalid-identifiers
|
||||
(let [result (-> (management/open-file "invalid" #js {:teamId (str team-id)})
|
||||
(.then (fn [] nil) (fn [error] (.-message error))))]
|
||||
(t/is (= "Expected a file UUID and a team UUID" (await result)))))
|
||||
|
||||
(t/deftest ^:async list-projects-returns-active-metadata
|
||||
(let [project-id (uuid/next)
|
||||
other-team-id (uuid/next)]
|
||||
(doseq [[state options expected-team-id]
|
||||
[[dashboard nil team-id]
|
||||
[ready #js {:teamId (str other-team-id)} other-team-id]]]
|
||||
(let [state* (atom state)]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state state*
|
||||
rp/cmd! (mock/stub
|
||||
(fn [command params]
|
||||
(t/is (= [:get-projects {:team-id expected-team-id}]
|
||||
[command params]))
|
||||
(rx/of [{:id project-id :team-id expected-team-id
|
||||
:name "Drafts" :is-default true :count 2 :total-count 3}
|
||||
{:id (uuid/next) :deleted-at (js/Date.)}])))}
|
||||
(let [projects (await (management/list-projects options))]
|
||||
(t/is (= [{"id" (str project-id) "teamId" (str expected-team-id)
|
||||
"name" "Drafts" "isDefault" true "fileCount" 2}]
|
||||
(js->clj projects)))
|
||||
(t/is (= state @state*)))))))))
|
||||
|
||||
(t/deftest ^:async list-files-returns-metadata-without-opening-a-file
|
||||
(let [project-id (uuid/next)]
|
||||
(doseq [state [dashboard ready]]
|
||||
(let [state* (atom state)]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state state*
|
||||
rp/cmd! (mock/stub
|
||||
(fn [command params]
|
||||
(t/is (= [:get-project-files {:project-id project-id}]
|
||||
[command params]))
|
||||
(rx/of [{:id file-id :team-id team-id :project-id project-id
|
||||
:name "Design" :modified-at (js/Date. "2026-01-02T03:04:05Z")
|
||||
:revn 10}])))}
|
||||
(let [files (await (management/list-files #js {:projectId (str project-id)}))]
|
||||
(t/is (= [{"id" (str file-id) "teamId" (str team-id)
|
||||
"projectId" (str project-id) "name" "Design"
|
||||
"modifiedAt" "2026-01-02T03:04:05.000Z"}]
|
||||
(js->clj files)))
|
||||
(t/is (= state @state*)))))))))
|
||||
|
||||
(t/deftest ^:async listing-rejects-invalid-identifiers-without-querying
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state (atom {})
|
||||
rp/cmd! (mock/stub (fn [_ _] (t/is false "Invalid IDs must not query the backend")))}
|
||||
(doseq [[result message]
|
||||
[[(management/list-projects nil) "Expected a team UUID"]
|
||||
[(management/list-projects #js {:teamId "invalid"}) "Expected a team UUID"]
|
||||
[(management/list-files nil) "Expected a project UUID"]
|
||||
[(management/list-files #js {:projectId "invalid"}) "Expected a project UUID"]]]
|
||||
(t/is (= message (await (.then result (fn [] nil) #(.-message %)))))))))
|
||||
|
||||
(t/deftest ^:async listing-preserves-empty-results-and-backend-errors
|
||||
(doseq [list-items [(fn [] (management/list-projects #js {:teamId (str team-id)}))
|
||||
(fn [] (management/list-files #js {:projectId (str (uuid/next))}))]]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{rp/cmd! (mock/stub (fn [_ _] (rx/of [])))}
|
||||
(t/is (= [] (js->clj (await (list-items)))))))
|
||||
(let [error (js/Error. "Access denied")]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{rp/cmd! (mock/stub (fn [_ _] (rx/throw error)))}
|
||||
(t/is (identical? error (await (.then (list-items) (fn [] nil) identity)))))))))
|
||||
|
||||
(t/deftest ^:async create-project-returns-metadata-and-updates-only-the-current-team
|
||||
(let [project-id (uuid/next)
|
||||
other-team-id (uuid/next)]
|
||||
(doseq [[initial options target-team-id]
|
||||
[[dashboard #js {:name " Buttons "} team-id]
|
||||
[ready #js {:name " Buttons " :teamId (str other-team-id)} other-team-id]]]
|
||||
(let [state (atom initial)
|
||||
requests (atom [])
|
||||
project {:id project-id :team-id target-team-id :name "Buttons"
|
||||
:is-default false :is-pinned false}]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state state
|
||||
st/emit! (mock/stub (fn [event] (swap! state event)))
|
||||
rp/cmd! (mock/stub
|
||||
(fn [command params]
|
||||
(swap! requests conj [command params])
|
||||
(->> (rx/of project) (rx/observe-on :async))))}
|
||||
(let [context (management/create-context)
|
||||
result (await (.createProject context options))]
|
||||
(t/is (= {"id" (str project-id) "teamId" (str target-team-id)
|
||||
"name" "Buttons" "isDefault" false "fileCount" 0}
|
||||
(js->clj result)))
|
||||
(t/is (= [[:create-project {:team-id target-team-id :name "Buttons"}]] @requests))
|
||||
(t/is (= (if (= team-id target-team-id)
|
||||
(assoc-in initial [:projects project-id] (assoc project :count 0))
|
||||
initial)
|
||||
@state)))))))))
|
||||
|
||||
(t/deftest ^:async create-file-returns-metadata-and-updates-the-dashboard-without-navigation
|
||||
(let [project-id (uuid/next)
|
||||
created-id (uuid/next)
|
||||
modified-at (js/Date. "2026-01-02T03:04:05Z")]
|
||||
(doseq [initial [dashboard ready]]
|
||||
(let [initial (assoc initial
|
||||
:features #{"components/v2" "styles/v2" "plugins/runtime"}
|
||||
:projects {project-id {:id project-id :team-id team-id :count 2}})
|
||||
state (atom initial)
|
||||
requests (atom [])
|
||||
file {:id created-id :team-id team-id :project-id project-id
|
||||
:name "Login" :modified-at modified-at :data {:pages []}}]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state state
|
||||
st/emit! (mock/stub (fn [event] (swap! state event)))
|
||||
rp/cmd! (mock/stub
|
||||
(fn [command params]
|
||||
(swap! requests conj [command params])
|
||||
(->> (rx/of file) (rx/observe-on :async))))}
|
||||
(let [context (management/create-context)
|
||||
result (await (.createFile context #js {:projectId (str project-id) :name " Login "
|
||||
:id "ignored" :isShared true}))]
|
||||
(t/is (= {"id" (str created-id) "teamId" (str team-id)
|
||||
"projectId" (str project-id) "name" "Login"
|
||||
"modifiedAt" "2026-01-02T03:04:05.000Z"}
|
||||
(js->clj result)))
|
||||
(t/is (= [[:create-file {:project-id project-id :name "Login" :features #{"components/v2"}}]]
|
||||
@requests))
|
||||
(t/is (= (-> initial
|
||||
(assoc-in [:files created-id] (dissoc file :data))
|
||||
(assoc-in [:recent-files created-id] (dissoc file :data))
|
||||
(update-in [:projects project-id :count] inc))
|
||||
@state)))))))))
|
||||
|
||||
(t/deftest ^:async creation-rejects-invalid-input-before-querying
|
||||
(let [project-id (uuid/next)
|
||||
requests (atom [])]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state (atom dashboard)
|
||||
rp/cmd! (mock/stub (fn [command params]
|
||||
(swap! requests conj [command params])
|
||||
(->> (rx/of nil) (rx/observe-on :async))))}
|
||||
(let [context (management/create-context)]
|
||||
(doseq [options [nil #js {} #js {:name nil} #js {:name 1}
|
||||
#js {:name ""} #js {:name " \n "}
|
||||
#js {:name (apply str (repeat 251 "a"))}]]
|
||||
(t/is (= "Expected a name with 1 to 250 characters"
|
||||
(await (.then (.createProject context options) (fn [] nil) #(.-message %)))))
|
||||
(let [options (js/Object.assign #js {:projectId (str project-id)} options)]
|
||||
(t/is (= "Expected a name with 1 to 250 characters"
|
||||
(await (.then (.createFile context options) (fn [] nil) #(.-message %)))))))
|
||||
(doseq [[create options message]
|
||||
[[#(.createProject context %) #js {:name "Buttons" :teamId "invalid"} "Expected a team UUID"]
|
||||
[#(.createFile context %) #js {:name "Login"} "Expected a project UUID"]
|
||||
[#(.createFile context %) #js {:name "Login" :projectId "invalid"} "Expected a project UUID"]]]
|
||||
(t/is (= message (await (.then (create options) (fn [] nil) #(.-message %)))))))
|
||||
(await (a/settle))
|
||||
(t/is (empty? @requests))))))
|
||||
|
||||
(t/deftest ^:async creation-preserves-backend-errors-and-state
|
||||
(doseq [method ["createProject" "createFile"]]
|
||||
(let [state (atom dashboard)
|
||||
error (js/Error. "Access denied")]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state state
|
||||
st/emit! (mock/stub (fn [event] (swap! state event)))
|
||||
rp/cmd! (mock/stub (fn [_ _] (->> (rx/throw error) (rx/observe-on :async))))}
|
||||
(let [context (management/create-context)
|
||||
options #js {:name "Design" :projectId (str (uuid/next))}
|
||||
result (.call (aget context method) context options)]
|
||||
(t/is (identical? error (await (.then result (fn [] nil) identity))))
|
||||
(t/is (= dashboard @state))))))))
|
||||
|
||||
(t/deftest ^:async creation-does-not-add-results-to-a-different-team-after-navigation
|
||||
(doseq [method ["createProject" "createFile"]]
|
||||
(let [state (atom dashboard)
|
||||
response (rx/subject)
|
||||
other (assoc dashboard :current-team-id (uuid/next))
|
||||
item-id (uuid/next)
|
||||
item {:id item-id :team-id team-id :project-id (uuid/next)
|
||||
:name "Design" :modified-at (js/Date.)}]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state state
|
||||
st/emit! (mock/stub (fn [event] (swap! state event)))
|
||||
rp/cmd! (mock/stub (fn [_ _] (->> response (rx/observe-on :async))))}
|
||||
(let [context (management/create-context)
|
||||
result (.call (aget context method) context
|
||||
#js {:name "Design" :projectId (str (:project-id item))})]
|
||||
(reset! state other)
|
||||
(rx/push! response item)
|
||||
(t/is (= (str item-id) (.-id (await result))))
|
||||
(t/is (= other @state))))))))
|
||||
@ -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)))))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -178,6 +178,7 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
|
||||
// initialize context, making penpot, penpotUtils, storage and the custom console available
|
||||
this.context = {
|
||||
penpot: penpot,
|
||||
penpotMgmt: typeof penpotMgmt === "undefined" ? null : penpotMgmt,
|
||||
storage: {},
|
||||
console: new ExecuteCodeTaskConsole(),
|
||||
penpotUtils: PenpotUtils,
|
||||
@ -216,8 +217,9 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
|
||||
try {
|
||||
// wait for layout updates prior to executing the supplied code (if method is available)
|
||||
try {
|
||||
const management = this.context.penpotMgmt;
|
||||
// @ts-ignore - TODO Penpot.waitForLayoutUpdate is not yet in the released types
|
||||
if (penpot.waitForLayoutUpdate) {
|
||||
if ((!management || management.workspace.status === "ready") && penpot.waitForLayoutUpdate) {
|
||||
// @ts-ignore
|
||||
await penpot.waitForLayoutUpdate();
|
||||
}
|
||||
|
||||
@ -92,7 +92,7 @@ Penpot:
|
||||
|
||||
Hierarchy
|
||||
|
||||
* Omit<Context, "addListener" | "removeListener">
|
||||
* Omit<Context, "addListener" | "removeListener" | "management">
|
||||
+ Penpot
|
||||
members:
|
||||
Properties:
|
||||
@ -5001,6 +5001,297 @@ CommonLayout:
|
||||
|
||||
The `remove` method removes the layout.
|
||||
|
||||
Returns void
|
||||
WorkspaceContext:
|
||||
overview: |-
|
||||
Interface WorkspaceContext
|
||||
==========================
|
||||
|
||||
Current workspace of this browser tab.
|
||||
|
||||
```
|
||||
interface WorkspaceContext {
|
||||
status: "none" | "loading" | "ready";
|
||||
fileId: string | null;
|
||||
fileName: string | null;
|
||||
teamId: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
Referenced by: EventsMap, PenpotMgmt
|
||||
members:
|
||||
Properties:
|
||||
status: |-
|
||||
```
|
||||
readonly status: "none" | "loading" | "ready"
|
||||
```
|
||||
fileId: |-
|
||||
```
|
||||
readonly fileId: string | null
|
||||
```
|
||||
fileName: |-
|
||||
```
|
||||
readonly fileName: string | null
|
||||
```
|
||||
teamId: |-
|
||||
```
|
||||
readonly teamId: string | null
|
||||
```
|
||||
ProjectSummary:
|
||||
overview: |-
|
||||
Interface ProjectSummary
|
||||
========================
|
||||
|
||||
Metadata snapshot of an accessible, non-deleted project.
|
||||
|
||||
```
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
teamId: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
fileCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
Referenced by: PenpotMgmt
|
||||
members:
|
||||
Properties:
|
||||
id: |-
|
||||
```
|
||||
readonly id: string
|
||||
```
|
||||
teamId: |-
|
||||
```
|
||||
readonly teamId: string
|
||||
```
|
||||
name: |-
|
||||
```
|
||||
readonly name: string
|
||||
```
|
||||
isDefault: |-
|
||||
```
|
||||
readonly isDefault: boolean
|
||||
```
|
||||
|
||||
Whether this is the team's Drafts project.
|
||||
fileCount: |-
|
||||
```
|
||||
readonly fileCount: number
|
||||
```
|
||||
FileSummary:
|
||||
overview: |-
|
||||
Interface FileSummary
|
||||
=====================
|
||||
|
||||
Metadata snapshot of an accessible, non-deleted file.
|
||||
|
||||
```
|
||||
interface FileSummary {
|
||||
id: string;
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
modifiedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
Referenced by: PenpotMgmt
|
||||
members:
|
||||
Properties:
|
||||
id: |-
|
||||
```
|
||||
readonly id: string
|
||||
```
|
||||
teamId: |-
|
||||
```
|
||||
readonly teamId: string
|
||||
```
|
||||
projectId: |-
|
||||
```
|
||||
readonly projectId: string
|
||||
```
|
||||
name: |-
|
||||
```
|
||||
readonly name: string
|
||||
```
|
||||
modifiedAt: |-
|
||||
```
|
||||
readonly modifiedAt: string
|
||||
```
|
||||
|
||||
Last modification time as an ISO 8601 string in UTC.
|
||||
PenpotMgmt:
|
||||
overview: |-
|
||||
Interface PenpotMgmt
|
||||
====================
|
||||
|
||||
Application management API exposed as `penpotMgmt` to global plugins.
|
||||
|
||||
* Discovery and navigation require `content:read`; creation requires `content:write`.
|
||||
* Available in the dashboard and file workspaces.
|
||||
* Use this API to discover, create and open files; file operations on `penpot` require a ready workspace.
|
||||
* Reacquire file, page and shape objects after changing files.
|
||||
|
||||
```
|
||||
interface PenpotMgmt {
|
||||
workspace: WorkspaceContext;
|
||||
listProjects(options?: { teamId?: string }): Promise<ProjectSummary[]>;
|
||||
listFiles(options: { projectId: string }): Promise<FileSummary[]>;
|
||||
createProject(options: { name: string; teamId?: string }): Promise<ProjectSummary>;
|
||||
createFile(options: { name: string; projectId: string }): Promise<FileSummary>;
|
||||
openFile(fileId: string, options?: { teamId?: string }): Promise<void>;
|
||||
on(
|
||||
type: "workspacechange",
|
||||
callback: (workspace: WorkspaceContext) => void,
|
||||
): symbol;
|
||||
off(listenerId: symbol): void;
|
||||
}
|
||||
```
|
||||
|
||||
Referenced by: Context
|
||||
members:
|
||||
Properties:
|
||||
workspace: |-
|
||||
```
|
||||
readonly workspace: WorkspaceContext
|
||||
```
|
||||
|
||||
Current workspace availability and metadata; usable for file operations when status is `ready`.
|
||||
Methods:
|
||||
listProjects: |-
|
||||
```
|
||||
listProjects(options?: { teamId?: string }): Promise<ProjectSummary[]>
|
||||
```
|
||||
|
||||
Lists accessible, non-deleted projects as metadata snapshots.
|
||||
|
||||
* `teamId` defaults to the current team.
|
||||
* Queries the backend without opening a file or changing navigation.
|
||||
* Rejects invalid identifiers and backend access errors.
|
||||
|
||||
Parameters
|
||||
|
||||
* options: { teamId?: string }
|
||||
|
||||
Returns Promise<ProjectSummary[]>
|
||||
|
||||
Example
|
||||
```
|
||||
const projects = await penpotMgmt.listProjects();
|
||||
```
|
||||
listFiles: |-
|
||||
```
|
||||
listFiles(options: { projectId: string }): Promise<FileSummary[]>
|
||||
```
|
||||
|
||||
Lists accessible, non-deleted files in the specified project as metadata snapshots.
|
||||
|
||||
* Requires an explicit `projectId`.
|
||||
* Queries the backend without opening a file or changing navigation.
|
||||
* Rejects invalid identifiers and backend access errors.
|
||||
|
||||
Parameters
|
||||
|
||||
* options: { projectId: string }
|
||||
|
||||
Returns Promise<FileSummary[]>
|
||||
|
||||
Example
|
||||
```
|
||||
const files = await penpotMgmt.listFiles({ projectId: project.id });
|
||||
```
|
||||
createProject: |-
|
||||
```
|
||||
createProject(options: { name: string; teamId?: string }): Promise<ProjectSummary>
|
||||
```
|
||||
|
||||
Creates a project and returns its metadata snapshot. Requires `content:write`.
|
||||
|
||||
* `teamId` defaults to the current team.
|
||||
* Trims `name` and requires 1 to 250 characters.
|
||||
* Does not change navigation or open a file.
|
||||
* Rejects invalid input, backend access errors and quota errors.
|
||||
|
||||
Parameters
|
||||
|
||||
* options: { name: string; teamId?: string }
|
||||
|
||||
Returns Promise<ProjectSummary>
|
||||
|
||||
Example
|
||||
```
|
||||
const project = await penpotMgmt.createProject({ name: 'Design system' });
|
||||
```
|
||||
createFile: |-
|
||||
```
|
||||
createFile(options: { name: string; projectId: string }): Promise<FileSummary>
|
||||
```
|
||||
|
||||
Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`.
|
||||
|
||||
* Requires an explicit `projectId`.
|
||||
* Trims `name` and requires 1 to 250 characters.
|
||||
* Does not open the file or change navigation. Use `openFile` to edit it.
|
||||
* Rejects invalid input, backend access errors and quota errors.
|
||||
|
||||
Parameters
|
||||
|
||||
* options: { name: string; projectId: string }
|
||||
|
||||
Returns Promise<FileSummary>
|
||||
|
||||
Example
|
||||
```
|
||||
const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' });
|
||||
await penpotMgmt.openFile(file.id, { teamId: file.teamId });
|
||||
```
|
||||
openFile: |-
|
||||
```
|
||||
openFile(fileId: string, options?: { teamId?: string }): Promise<void>
|
||||
```
|
||||
|
||||
Opens a file in this tab and resolves once its workspace and page are ready.
|
||||
|
||||
* `teamId` defaults to the current team.
|
||||
* Rejects invalid identifiers, failed or superseded navigation, and waits exceeding 30 seconds.
|
||||
|
||||
Parameters
|
||||
|
||||
* fileId: string
|
||||
* options: { teamId?: string }
|
||||
|
||||
Returns Promise<void>
|
||||
|
||||
Example
|
||||
```
|
||||
await penpotMgmt.openFile(file.id, { teamId: file.teamId });
|
||||
```
|
||||
on: |-
|
||||
```
|
||||
on(
|
||||
type: "workspacechange",
|
||||
callback: (workspace: WorkspaceContext) => void,
|
||||
): symbol
|
||||
```
|
||||
|
||||
Subscribes to workspace availability and metadata changes.
|
||||
|
||||
Parameters
|
||||
|
||||
* type: "workspacechange"
|
||||
* callback: (workspace: WorkspaceContext) => void
|
||||
|
||||
Returns symbol
|
||||
off: |-
|
||||
```
|
||||
off(listenerId: symbol): void
|
||||
```
|
||||
|
||||
Parameters
|
||||
|
||||
* listenerId: symbol
|
||||
|
||||
Returns void
|
||||
Context:
|
||||
overview: |-
|
||||
@ -5011,6 +5302,7 @@ Context:
|
||||
|
||||
```
|
||||
interface Context {
|
||||
management?: Omit<PenpotMgmt, "on" | "off">;
|
||||
version: string;
|
||||
root: Shape | null;
|
||||
currentFile: File | null;
|
||||
@ -5082,6 +5374,13 @@ Context:
|
||||
```
|
||||
members:
|
||||
Properties:
|
||||
management: |-
|
||||
```
|
||||
readonly management?: Omit<PenpotMgmt, "on" | "off">
|
||||
```
|
||||
|
||||
Backs `penpotMgmt`. Its events go through the plugin listener registry,
|
||||
so `on` and `off` are not part of it.
|
||||
version: |-
|
||||
```
|
||||
readonly version: string
|
||||
@ -7109,6 +7408,8 @@ EventsMap:
|
||||
|
||||
```
|
||||
interface EventsMap {
|
||||
workspacechange: WorkspaceContext;
|
||||
logout: void;
|
||||
pagechange: Page;
|
||||
filechange: File;
|
||||
selectionchange: string[];
|
||||
@ -7122,6 +7423,14 @@ EventsMap:
|
||||
Referenced by: Context, Penpot
|
||||
members:
|
||||
Properties:
|
||||
workspacechange: |-
|
||||
```
|
||||
readonly workspacechange: WorkspaceContext
|
||||
```
|
||||
logout: |-
|
||||
```
|
||||
readonly logout: void
|
||||
```
|
||||
pagechange: |-
|
||||
```
|
||||
readonly pagechange: Page
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,8 @@ export class ExecuteCodeTool extends Tool<ExecuteCodeArgs> {
|
||||
"Executes JavaScript code in the Penpot plugin context.\n" +
|
||||
"IMPORTANT: Before using this tool, make sure you have read the 'Penpot High-Level Overview' and know " +
|
||||
"which Penpot API functionality is necessary and how to use it.\n" +
|
||||
"You have access two main objects: `penpot` (the Penpot API, of type `Penpot`), `penpotUtils`, " +
|
||||
"and `storage`.\n" +
|
||||
"You have access to `penpot` (of type `Penpot`), `penpotMgmt` (of type `PenpotMgmt`), `penpotUtils`, and `storage`.\n" +
|
||||
"If `penpotMgmt` is null, file management is unavailable and only the open file can be used.\n" +
|
||||
"`storage` is an object in which arbitrary data can be stored, simply by adding a new attribute; " +
|
||||
"stored attributes can be referenced in future calls to this tool, so any intermediate results that " +
|
||||
"could come in handy later should be stored in `storage` instead of just a fleeting variable; " +
|
||||
|
||||
9
mcp/pnpm-lock.yaml
generated
9
mcp/pnpm-lock.yaml
generated
@ -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':
|
||||
|
||||
@ -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.
|
||||
|
||||
130
plugins/libs/plugin-types/index.d.ts
vendored
130
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,135 @@ export interface CommonLayout {
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
/** Current workspace of this browser tab. */
|
||||
export interface WorkspaceContext {
|
||||
readonly status: 'none' | 'loading' | 'ready';
|
||||
readonly fileId: string | null;
|
||||
readonly fileName: string | null;
|
||||
readonly teamId: string | null;
|
||||
}
|
||||
|
||||
/** Metadata snapshot of an accessible, non-deleted project. */
|
||||
export interface ProjectSummary {
|
||||
readonly id: string;
|
||||
readonly teamId: string;
|
||||
readonly name: string;
|
||||
/** Whether this is the team's Drafts project. */
|
||||
readonly isDefault: boolean;
|
||||
readonly fileCount: number;
|
||||
}
|
||||
|
||||
/** Metadata snapshot of an accessible, non-deleted file. */
|
||||
export interface FileSummary {
|
||||
readonly id: string;
|
||||
readonly teamId: string;
|
||||
readonly projectId: string;
|
||||
readonly name: string;
|
||||
/** Last modification time as an ISO 8601 string in UTC. */
|
||||
readonly modifiedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application management API exposed as `penpotMgmt` to global plugins.
|
||||
*
|
||||
* - Discovery and navigation require `content:read`; creation requires `content:write`.
|
||||
* - Available in the dashboard and file workspaces.
|
||||
* - Use this API to discover, create and open files; file operations on `penpot` require a ready workspace.
|
||||
* - Reacquire file, page and shape objects after changing files.
|
||||
*/
|
||||
export interface PenpotMgmt {
|
||||
/** Current workspace availability and metadata; usable for file operations when status is `ready`. */
|
||||
readonly workspace: WorkspaceContext;
|
||||
/**
|
||||
* Lists accessible, non-deleted projects as metadata snapshots.
|
||||
*
|
||||
* - `teamId` defaults to the current team.
|
||||
* - Queries the backend without opening a file or changing navigation.
|
||||
* - Rejects invalid identifiers and backend access errors.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const projects = await penpotMgmt.listProjects();
|
||||
* ```
|
||||
*/
|
||||
listProjects(options?: { teamId?: string }): Promise<ProjectSummary[]>;
|
||||
/**
|
||||
* Lists accessible, non-deleted files in the specified project as metadata snapshots.
|
||||
*
|
||||
* - Requires an explicit `projectId`.
|
||||
* - Queries the backend without opening a file or changing navigation.
|
||||
* - Rejects invalid identifiers and backend access errors.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const files = await penpotMgmt.listFiles({ projectId: project.id });
|
||||
* ```
|
||||
*/
|
||||
listFiles(options: { projectId: string }): Promise<FileSummary[]>;
|
||||
/**
|
||||
* Creates a project and returns its metadata snapshot. Requires `content:write`.
|
||||
*
|
||||
* - `teamId` defaults to the current team.
|
||||
* - Trims `name` and requires 1 to 250 characters.
|
||||
* - Does not change navigation or open a file.
|
||||
* - Rejects invalid input, backend access errors and quota errors.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const project = await penpotMgmt.createProject({ name: 'Design system' });
|
||||
* ```
|
||||
*/
|
||||
createProject(options: {
|
||||
name: string;
|
||||
teamId?: string;
|
||||
}): Promise<ProjectSummary>;
|
||||
/**
|
||||
* Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`.
|
||||
*
|
||||
* - Requires an explicit `projectId`.
|
||||
* - Trims `name` and requires 1 to 250 characters.
|
||||
* - Does not open the file or change navigation. Use `openFile` to edit it.
|
||||
* - Rejects invalid input, backend access errors and quota errors.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' });
|
||||
* await penpotMgmt.openFile(file.id, { teamId: file.teamId });
|
||||
* ```
|
||||
*/
|
||||
createFile(options: {
|
||||
name: string;
|
||||
projectId: string;
|
||||
}): Promise<FileSummary>;
|
||||
/**
|
||||
* Opens a file in this tab and resolves once its workspace and page are ready.
|
||||
*
|
||||
* - `teamId` defaults to the current team.
|
||||
* - Rejects invalid identifiers, failed or superseded navigation, and waits exceeding 30 seconds.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await penpotMgmt.openFile(file.id, { teamId: file.teamId });
|
||||
* ```
|
||||
*/
|
||||
openFile(fileId: string, options?: { teamId?: string }): Promise<void>;
|
||||
/** Subscribes to workspace availability and metadata changes. */
|
||||
on(
|
||||
type: 'workspacechange',
|
||||
callback: (workspace: WorkspaceContext) => void,
|
||||
): symbol;
|
||||
off(listenerId: symbol): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the context of Penpot, providing access to various Penpot functionalities and data.
|
||||
*/
|
||||
export interface Context {
|
||||
/**
|
||||
* Backs `penpotMgmt`. Its events go through the plugin listener registry,
|
||||
* so `on` and `off` are not part of it.
|
||||
*/
|
||||
readonly management?: Omit<PenpotMgmt, 'on' | 'off'>;
|
||||
/**
|
||||
* Returns the current penpot version.
|
||||
*/
|
||||
@ -1556,6 +1681,8 @@ export interface Ellipse extends ShapeBase {
|
||||
* ```
|
||||
*/
|
||||
export interface EventsMap {
|
||||
readonly workspacechange: WorkspaceContext;
|
||||
readonly logout: void;
|
||||
/**
|
||||
* The `pagechange` event is triggered when the active page in the project is changed.
|
||||
*/
|
||||
@ -5757,4 +5884,5 @@ export interface Viewport {
|
||||
|
||||
declare global {
|
||||
const penpot: Penpot;
|
||||
const penpotMgmt: PenpotMgmt;
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ import type {
|
||||
Page,
|
||||
Path,
|
||||
Penpot,
|
||||
PenpotMgmt,
|
||||
Rectangle,
|
||||
Shape,
|
||||
SvgRaw,
|
||||
@ -36,6 +37,8 @@ import { createPluginManager } from '../plugin-manager.js';
|
||||
|
||||
export const validEvents = [
|
||||
'finish',
|
||||
'logout',
|
||||
'workspacechange',
|
||||
'pagechange',
|
||||
'filechange',
|
||||
'selectionchange',
|
||||
@ -47,10 +50,24 @@ export const validEvents = [
|
||||
export function createApi(
|
||||
plugin: Awaited<ReturnType<typeof createPluginManager>>,
|
||||
) {
|
||||
const checkPermission = (permission: Permissions) => {
|
||||
const workspaceUnavailable = () =>
|
||||
plugin.manifest.scope === 'global' &&
|
||||
plugin.context.management?.workspace.status !== 'ready';
|
||||
|
||||
const requireWorkspace = () => {
|
||||
if (workspaceUnavailable()) {
|
||||
throw new Error(
|
||||
'No workspace is ready. Use penpotMgmt.openFile() first.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const checkPermission = (permission: Permissions, workspace = true) => {
|
||||
if (!plugin.manifest.permissions.includes(permission)) {
|
||||
throw new Error(`Permission ${permission} is not granted`);
|
||||
}
|
||||
if (workspace && /^(content|library|comment):/.test(permission))
|
||||
requireWorkspace();
|
||||
};
|
||||
|
||||
const penpot: Penpot = {
|
||||
@ -162,7 +179,7 @@ export function createApi(
|
||||
z.function().parse(callback);
|
||||
|
||||
// To suscribe to events needs the read permission
|
||||
checkPermission('content:read');
|
||||
checkPermission('content:read', false);
|
||||
|
||||
return plugin.registerListener(type, callback, props);
|
||||
},
|
||||
@ -178,18 +195,18 @@ export function createApi(
|
||||
},
|
||||
|
||||
get root(): Shape | null {
|
||||
checkPermission('content:read');
|
||||
return plugin.context.root;
|
||||
checkPermission('content:read', false);
|
||||
return workspaceUnavailable() ? null : plugin.context.root;
|
||||
},
|
||||
|
||||
get currentFile(): File | null {
|
||||
checkPermission('content:read');
|
||||
return plugin.context.currentFile;
|
||||
checkPermission('content:read', false);
|
||||
return workspaceUnavailable() ? null : plugin.context.currentFile;
|
||||
},
|
||||
|
||||
get currentPage(): Page | null {
|
||||
checkPermission('content:read');
|
||||
return plugin.context.currentPage;
|
||||
checkPermission('content:read', false);
|
||||
return workspaceUnavailable() ? null : plugin.context.currentPage;
|
||||
},
|
||||
|
||||
get selection(): Shape[] {
|
||||
@ -203,10 +220,12 @@ export function createApi(
|
||||
},
|
||||
|
||||
get viewport(): Viewport {
|
||||
requireWorkspace();
|
||||
return plugin.context.viewport;
|
||||
},
|
||||
|
||||
get history(): HistoryContext {
|
||||
requireWorkspace();
|
||||
return plugin.context.history;
|
||||
},
|
||||
|
||||
@ -395,7 +414,44 @@ export function createApi(
|
||||
},
|
||||
};
|
||||
|
||||
const penpotMgmt: PenpotMgmt = {
|
||||
get workspace() {
|
||||
checkPermission('content:read', false);
|
||||
return plugin.context.management!.workspace;
|
||||
},
|
||||
listProjects(options) {
|
||||
checkPermission('content:read', false);
|
||||
return plugin.context.management!.listProjects(options);
|
||||
},
|
||||
listFiles(options) {
|
||||
checkPermission('content:read', false);
|
||||
return plugin.context.management!.listFiles(options);
|
||||
},
|
||||
createProject(options) {
|
||||
checkPermission('content:write', false);
|
||||
return plugin.context.management!.createProject(options);
|
||||
},
|
||||
createFile(options) {
|
||||
checkPermission('content:write', false);
|
||||
return plugin.context.management!.createFile(options);
|
||||
},
|
||||
openFile(fileId, options) {
|
||||
checkPermission('content:read', false);
|
||||
return plugin.context.management!.openFile(fileId, options);
|
||||
},
|
||||
on(type, callback) {
|
||||
z.literal('workspacechange').parse(type);
|
||||
z.function().parse(callback);
|
||||
checkPermission('content:read', false);
|
||||
return plugin.registerListener(type, callback);
|
||||
},
|
||||
off(listenerId) {
|
||||
plugin.destroyListener(listenerId);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
penpot,
|
||||
penpotMgmt,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -83,6 +83,7 @@ describe('createPlugin', () => {
|
||||
manifest,
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
expect(createSandbox).toHaveBeenCalledWith(mockPluginManager, undefined);
|
||||
expect(mockSandbox.evaluate).toHaveBeenCalled();
|
||||
|
||||
@ -8,8 +8,13 @@ export async function createPlugin(
|
||||
manifest: Manifest,
|
||||
onCloseCallback: () => void,
|
||||
apiExtensions?: object,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
// Closing can happen while the manager is still fetching code.
|
||||
let sandbox: ReturnType<typeof createSandbox> | undefined = undefined;
|
||||
|
||||
const evaluateSandbox = async () => {
|
||||
if (plugin.destroyed || !sandbox) return;
|
||||
try {
|
||||
sandbox.evaluate();
|
||||
} catch (error) {
|
||||
@ -23,15 +28,18 @@ export async function createPlugin(
|
||||
context,
|
||||
manifest,
|
||||
function onClose() {
|
||||
sandbox.cleanGlobalThis();
|
||||
sandbox?.cleanGlobalThis();
|
||||
onCloseCallback();
|
||||
},
|
||||
function onReloadModal() {
|
||||
evaluateSandbox();
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
const sandbox = createSandbox(plugin, apiExtensions);
|
||||
if (plugin.destroyed) return;
|
||||
|
||||
sandbox = createSandbox(plugin, apiExtensions);
|
||||
|
||||
await evaluateSandbox();
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -30,7 +30,7 @@ describe('loadPlugin host context boundary (regression for #11001)', () => {
|
||||
close: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof createPlugin>>);
|
||||
} as unknown as NonNullable<Awaited<ReturnType<typeof createPlugin>>>);
|
||||
});
|
||||
|
||||
it('does not freeze host-owned functions reachable through the context', async () => {
|
||||
@ -60,6 +60,7 @@ describe('loadPlugin host context boundary (regression for #11001)', () => {
|
||||
manifest,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
|
||||
// Host-owned functions must remain extensible: page navigation and
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { describe, it, vi, expect, beforeAll } from 'vitest';
|
||||
import 'ses';
|
||||
import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
|
||||
import {
|
||||
loadPlugin,
|
||||
setContextBuilder,
|
||||
getPlugins,
|
||||
ɵunloadPlugin,
|
||||
} from './load-plugin';
|
||||
import type { Context } from '@penpot/plugin-types';
|
||||
import type { Manifest } from './models/manifest.model.js';
|
||||
|
||||
@ -47,15 +52,23 @@ function makeManifest(
|
||||
|
||||
function makeHostFixture() {
|
||||
const listenerTypes: string[] = [];
|
||||
const listeners = new Map<symbol, string>();
|
||||
const listeners = new Map<
|
||||
symbol,
|
||||
{ type: string; callback: (...args: unknown[]) => unknown }
|
||||
>();
|
||||
const shapes: object[] = [];
|
||||
// Inline code (empty host + non-URL code) resolves without network, so no
|
||||
// fetch mock is needed. UI/modal APIs are never touched by the probe code.
|
||||
const createRectangle = vi.fn(() => ({ type: 'rectangle-marker' }));
|
||||
const createRectangle = vi.fn(() => {
|
||||
const shape = { type: 'rectangle-marker' };
|
||||
shapes.push(shape);
|
||||
return shape;
|
||||
});
|
||||
const selection: object[] = [{ id: 'shape-1' }];
|
||||
const context = {
|
||||
addListener: (type: string, _callback: (...args: unknown[]) => unknown) => {
|
||||
addListener: (type: string, callback: (...args: unknown[]) => unknown) => {
|
||||
const id = Symbol(type);
|
||||
listeners.set(id, type);
|
||||
listeners.set(id, { type, callback });
|
||||
listenerTypes.push(type);
|
||||
return id;
|
||||
},
|
||||
@ -63,13 +76,21 @@ function makeHostFixture() {
|
||||
listeners.delete(id);
|
||||
},
|
||||
theme: 'dark',
|
||||
management: { workspace: { status: 'ready' } },
|
||||
createRectangle,
|
||||
selection,
|
||||
// Host-only member: present on the raw context but NOT part of the
|
||||
// public penpot API. Plugin code must never see it (see B-2 below).
|
||||
__internalSecret: 'host-internal',
|
||||
} as unknown as Context;
|
||||
return { context, listenerTypes, createRectangle, selection };
|
||||
return {
|
||||
context,
|
||||
listeners,
|
||||
listenerTypes,
|
||||
createRectangle,
|
||||
selection,
|
||||
shapes,
|
||||
};
|
||||
}
|
||||
|
||||
function lastCompartmentGlobalThis(): Record<string, unknown> {
|
||||
@ -98,9 +119,14 @@ describe('loadPlugin real initialization path (regression for #11001)', () => {
|
||||
);
|
||||
|
||||
// The plugin code really ran inside the sandbox: the manager registers
|
||||
// `themechange` + `finish`, and the plugin code adds its own `finish`
|
||||
// `themechange` + `finish` + `logout`, and the plugin code adds its own `finish`
|
||||
// listener through the public API.
|
||||
expect(fixture.listenerTypes).toEqual(['themechange', 'finish', 'finish']);
|
||||
expect(fixture.listenerTypes).toEqual([
|
||||
'themechange',
|
||||
'finish',
|
||||
'logout',
|
||||
'finish',
|
||||
]);
|
||||
expect(getPlugins()).toHaveLength(1);
|
||||
|
||||
// The user-facing behavior from #11001: host-side augmentation of a
|
||||
@ -112,7 +138,7 @@ describe('loadPlugin real initialization path (regression for #11001)', () => {
|
||||
expect(() => {
|
||||
freshWrapper.toString = () => 'patched-by-runtime';
|
||||
}).not.toThrow();
|
||||
expect(fixture.listenerTypes.length).toBe(3);
|
||||
expect(fixture.listenerTypes.length).toBe(4);
|
||||
});
|
||||
|
||||
it('denies the write API without permission and leaves the host untouched', async () => {
|
||||
@ -166,4 +192,302 @@ describe('loadPlugin real initialization path (regression for #11001)', () => {
|
||||
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
|
||||
expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true);
|
||||
});
|
||||
it.each(['before', 'after'])(
|
||||
'discards a cancelled global load that completes %s its replacement',
|
||||
async (order) => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
const manifest = {
|
||||
...makeManifest('plugin.js', ['content:read', 'content:write']),
|
||||
host: 'https://plugins.test/',
|
||||
scope: 'global' as const,
|
||||
};
|
||||
ɵunloadPlugin(manifest.pluginId);
|
||||
let finishFetch!: (response: object) => void;
|
||||
const response = new Promise((resolve) => {
|
||||
finishFetch = resolve;
|
||||
});
|
||||
const fetch = vi.fn().mockReturnValue(response);
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
try {
|
||||
const cancelled = loadPlugin(manifest);
|
||||
await loadPlugin(manifest);
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
ɵunloadPlugin(manifest.pluginId);
|
||||
const replacement = () =>
|
||||
loadPlugin({
|
||||
...manifest,
|
||||
host: '',
|
||||
code: 'globalThis.marker = "replacement";',
|
||||
});
|
||||
if (order === 'after') await replacement();
|
||||
finishFetch({
|
||||
ok: true,
|
||||
text: async () => 'penpot.createRectangle();',
|
||||
});
|
||||
await cancelled;
|
||||
expect(fixture.shapes).toEqual([]);
|
||||
if (order === 'before') {
|
||||
expect(getPlugins()).toHaveLength(0);
|
||||
expect(fixture.listeners.size).toBe(0);
|
||||
await replacement();
|
||||
}
|
||||
expect(getPlugins()).toHaveLength(1);
|
||||
expect(lastCompartmentGlobalThis()['marker']).toBe('replacement');
|
||||
expect(fixture.listeners.size).toBe(3);
|
||||
} finally {
|
||||
ɵunloadPlugin(manifest.pluginId);
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('cancels a workspace load when another load replaces the same plugin', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
const manifest = {
|
||||
...makeManifest('plugin.js', ['content:read', 'content:write']),
|
||||
host: 'https://plugins.test/',
|
||||
};
|
||||
let finishFetch!: (response: object) => void;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishFetch = resolve;
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const cancelled = loadPlugin(manifest);
|
||||
await loadPlugin({
|
||||
...manifest,
|
||||
host: '',
|
||||
code: 'globalThis.marker = "replacement";',
|
||||
});
|
||||
finishFetch({ ok: true, text: async () => 'penpot.createRectangle();' });
|
||||
await cancelled;
|
||||
expect(fixture.shapes).toEqual([]);
|
||||
expect(getPlugins()).toHaveLength(1);
|
||||
expect(lastCompartmentGlobalThis()['marker']).toBe('replacement');
|
||||
} finally {
|
||||
for (const plugin of [...getPlugins()]) plugin.plugin.close();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores a fetch failure after a load has been cancelled', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
const manifest = {
|
||||
...makeManifest('plugin.js', []),
|
||||
host: 'https://plugins.test/',
|
||||
scope: 'global' as const,
|
||||
};
|
||||
let failFetch!: (error: Error) => void;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
failFetch = reject;
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const cancelled = loadPlugin(manifest);
|
||||
ɵunloadPlugin(manifest.pluginId);
|
||||
await loadPlugin({
|
||||
...manifest,
|
||||
host: '',
|
||||
code: 'globalThis.marker = "replacement";',
|
||||
});
|
||||
failFetch(new Error('Cancelled fetch failed'));
|
||||
await cancelled;
|
||||
expect(getPlugins()).toHaveLength(1);
|
||||
expect(lastCompartmentGlobalThis()['marker']).toBe('replacement');
|
||||
expect(fixture.listeners.size).toBe(3);
|
||||
} finally {
|
||||
ɵunloadPlugin(manifest.pluginId);
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows retrying a global plugin after a failed load', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
const manifest = {
|
||||
...makeManifest('throw new Error("load failed");', []),
|
||||
scope: 'global' as const,
|
||||
};
|
||||
await expect(loadPlugin(manifest)).rejects.toThrow('load failed');
|
||||
expect(getPlugins()).toHaveLength(0);
|
||||
await loadPlugin({ ...manifest, code: 'globalThis.marker = "retry";' });
|
||||
expect(lastCompartmentGlobalThis()['marker']).toBe('retry');
|
||||
ɵunloadPlugin(manifest.pluginId);
|
||||
expect(fixture.listeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps a global sandbox through navigation and other plugin loads, then closes it on logout', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
let workspace = {
|
||||
status: 'none',
|
||||
fileId: null,
|
||||
fileName: null,
|
||||
teamId: null,
|
||||
};
|
||||
const listeners = new Map<
|
||||
symbol,
|
||||
{ type: string; callback: (value?: unknown) => void }
|
||||
>();
|
||||
const context = {
|
||||
...fixture.context,
|
||||
management: {
|
||||
get workspace() {
|
||||
return workspace;
|
||||
},
|
||||
openFile: async () => {},
|
||||
},
|
||||
addListener(type: string, callback: (value?: unknown) => void) {
|
||||
const id = Symbol();
|
||||
listeners.set(id, { type, callback });
|
||||
return id;
|
||||
},
|
||||
removeListener(id: symbol) {
|
||||
listeners.delete(id);
|
||||
},
|
||||
} as unknown as Context;
|
||||
setContextBuilder(() => context);
|
||||
const manifest = {
|
||||
...makeManifest(
|
||||
'globalThis.savedPenpot = penpot; globalThis.probe = penpotMgmt.workspace.status;',
|
||||
['content:read', 'content:write'],
|
||||
),
|
||||
pluginId: 'global-plugin',
|
||||
scope: 'global' as const,
|
||||
};
|
||||
await loadPlugin(manifest);
|
||||
const globalPlugin = getPlugins().find(
|
||||
(plugin) => plugin.manifest.pluginId === 'global-plugin',
|
||||
)!;
|
||||
const globals = globalPlugin.compartment.compartment.globalThis;
|
||||
expect(globals['probe']).toBe('none');
|
||||
expect(() =>
|
||||
globalPlugin.compartment.compartment.evaluate('penpot.createRectangle()'),
|
||||
).toThrow(/No workspace/);
|
||||
for (const listener of [...listeners.values()]) {
|
||||
if (listener.type === 'finish') listener.callback();
|
||||
}
|
||||
await loadPlugin(makeManifest('globalThis.workspacePlugin = true;', []));
|
||||
expect(getPlugins()).toContain(globalPlugin);
|
||||
await loadPlugin(manifest);
|
||||
expect(
|
||||
getPlugins().filter(
|
||||
(plugin) => plugin.manifest.pluginId === 'global-plugin',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
workspace = { ...workspace, status: 'ready' };
|
||||
globalPlugin.compartment.compartment.evaluate(
|
||||
'penpot.createRectangle(); globalThis.sameFacade = savedPenpot === penpot;',
|
||||
);
|
||||
expect(globals['sameFacade']).toBe(true);
|
||||
expect(fixture.createRectangle).toHaveBeenCalledOnce();
|
||||
await expect(
|
||||
loadPlugin(makeManifest('throw new Error("failure");', [])),
|
||||
).rejects.toThrow('failure');
|
||||
expect(getPlugins()).toContain(globalPlugin);
|
||||
for (const listener of [...listeners.values()]) {
|
||||
if (listener.type === 'logout') listener.callback();
|
||||
}
|
||||
expect(getPlugins()).toHaveLength(0);
|
||||
expect(globals['penpotMgmt']).toBeUndefined();
|
||||
expect(listeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it('cancels a global load at logout before it can execute in another session', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
const manifest = {
|
||||
...makeManifest('plugin.js', ['content:read', 'content:write']),
|
||||
pluginId: 'logout-plugin',
|
||||
host: 'https://plugins.test/',
|
||||
scope: 'global' as const,
|
||||
};
|
||||
let finishFetch!: (response: object) => void;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishFetch = resolve;
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const cancelled = loadPlugin(manifest);
|
||||
for (const listener of [...fixture.listeners.values()]) {
|
||||
if (listener.type === 'logout') listener.callback();
|
||||
}
|
||||
await loadPlugin({
|
||||
...manifest,
|
||||
host: '',
|
||||
code: 'globalThis.marker = "new-session";',
|
||||
});
|
||||
finishFetch({ ok: true, text: async () => 'penpot.createRectangle();' });
|
||||
await cancelled;
|
||||
expect(fixture.shapes).toEqual([]);
|
||||
expect(getPlugins()).toHaveLength(1);
|
||||
expect(lastCompartmentGlobalThis()['marker']).toBe('new-session');
|
||||
expect(fixture.listeners.size).toBe(3);
|
||||
} finally {
|
||||
ɵunloadPlugin(manifest.pluginId);
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('cleans up a failed fetch and allows loading the plugin again', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
const manifest = {
|
||||
...makeManifest('plugin.js', []),
|
||||
pluginId: 'failed-fetch-plugin',
|
||||
host: 'https://plugins.test/',
|
||||
scope: 'global' as const,
|
||||
};
|
||||
vi.stubGlobal('fetch', () => Promise.reject(new Error('Fetch failed')));
|
||||
try {
|
||||
await expect(loadPlugin(manifest)).rejects.toThrow('Fetch failed');
|
||||
expect(getPlugins()).toHaveLength(0);
|
||||
expect(fixture.listeners.size).toBe(0);
|
||||
await loadPlugin({
|
||||
...manifest,
|
||||
host: '',
|
||||
code: 'globalThis.marker = "retry";',
|
||||
});
|
||||
expect(lastCompartmentGlobalThis()['marker']).toBe('retry');
|
||||
} finally {
|
||||
ɵunloadPlugin(manifest.pluginId);
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not register a global plugin that closes during startup and permits reopening', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
const manifest = {
|
||||
...makeManifest('penpot.closePlugin();', [
|
||||
'content:read',
|
||||
'content:write',
|
||||
]),
|
||||
pluginId: 'self-closing-plugin',
|
||||
scope: 'global' as const,
|
||||
};
|
||||
try {
|
||||
await loadPlugin(manifest);
|
||||
expect(getPlugins()).toHaveLength(0);
|
||||
expect(fixture.listeners.size).toBe(0);
|
||||
await loadPlugin({ ...manifest, code: 'penpot.createRectangle();' });
|
||||
expect(fixture.shapes).toEqual([{ type: 'rectangle-marker' }]);
|
||||
expect(getPlugins()).toHaveLength(1);
|
||||
} finally {
|
||||
ɵunloadPlugin(manifest.pluginId);
|
||||
}
|
||||
expect(getPlugins()).toHaveLength(0);
|
||||
expect(fixture.listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -33,7 +33,7 @@ vi.mock('./ses.js', () => ({
|
||||
describe('plugin-loader', () => {
|
||||
let mockContext: Context;
|
||||
let manifest: Manifest;
|
||||
let mockPluginApi: Awaited<ReturnType<typeof createPlugin>>;
|
||||
let mockPluginApi: NonNullable<Awaited<ReturnType<typeof createPlugin>>>;
|
||||
let mockClose: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
@ -61,7 +61,7 @@ describe('plugin-loader', () => {
|
||||
close: mockClose,
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
|
||||
} as unknown as NonNullable<Awaited<ReturnType<typeof createPlugin>>>;
|
||||
|
||||
mockContext = {
|
||||
addListener: vi.fn(),
|
||||
@ -84,6 +84,7 @@ describe('plugin-loader', () => {
|
||||
manifest,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(mockPluginApi.plugin.close).not.toHaveBeenCalled();
|
||||
expect(getPlugins()).toHaveLength(1);
|
||||
@ -129,7 +130,7 @@ describe('plugin-loader', () => {
|
||||
},
|
||||
iframeWindow: mockIframeWindow,
|
||||
manifest: { ...manifest, host: 'http://localhost:4202' },
|
||||
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
|
||||
} as unknown as NonNullable<Awaited<ReturnType<typeof createPlugin>>>;
|
||||
|
||||
vi.mocked(createPlugin).mockResolvedValue(mockPluginWithIframe);
|
||||
|
||||
@ -173,7 +174,7 @@ describe('plugin-loader', () => {
|
||||
},
|
||||
iframeWindow: mockIframeWindow1,
|
||||
manifest: { ...manifest, host: 'http://localhost:4202' },
|
||||
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
|
||||
} as unknown as NonNullable<Awaited<ReturnType<typeof createPlugin>>>;
|
||||
|
||||
const mockPluginApi2 = {
|
||||
plugin: {
|
||||
@ -182,7 +183,7 @@ describe('plugin-loader', () => {
|
||||
},
|
||||
iframeWindow: mockIframeWindow2,
|
||||
manifest: { ...manifest, host: 'http://localhost:4203' },
|
||||
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
|
||||
} as unknown as NonNullable<Awaited<ReturnType<typeof createPlugin>>>;
|
||||
|
||||
vi.mocked(createPlugin).mockResolvedValue(mockPluginApi1);
|
||||
await loadPlugin(manifest);
|
||||
@ -215,7 +216,7 @@ describe('plugin-loader', () => {
|
||||
pluginId: 'background-plugin',
|
||||
allowBackground: true,
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
|
||||
} as unknown as NonNullable<Awaited<ReturnType<typeof createPlugin>>>;
|
||||
|
||||
vi.mocked(createPlugin).mockResolvedValue(backgroundPluginApi);
|
||||
await loadPlugin(manifest);
|
||||
@ -250,6 +251,7 @@ describe('plugin-loader', () => {
|
||||
manifest,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
@ -265,6 +267,7 @@ describe('plugin-loader', () => {
|
||||
manifest,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,7 +4,10 @@ import { loadManifest } from './parse-manifest.js';
|
||||
import { Manifest } from './models/manifest.model.js';
|
||||
import { createPlugin } from './create-plugin.js';
|
||||
|
||||
let plugins: Awaited<ReturnType<typeof createPlugin>>[] = [];
|
||||
type Plugin = NonNullable<Awaited<ReturnType<typeof createPlugin>>>;
|
||||
|
||||
let plugins: Plugin[] = [];
|
||||
const pendingPlugins = new Map<Manifest['pluginId'], AbortController>();
|
||||
|
||||
export type ContextBuilder = (id: string) => Context;
|
||||
|
||||
@ -17,20 +20,19 @@ export function setContextBuilder(builder: ContextBuilder) {
|
||||
export const getPlugins = () => plugins;
|
||||
|
||||
const closeAllPlugins = () => {
|
||||
// Background plugins keep running, so they must stay registered: the
|
||||
// registry routes their UI messages and lets them be unloaded later.
|
||||
const backgroundPlugins: typeof plugins = [];
|
||||
|
||||
plugins.forEach((pluginApi) => {
|
||||
// Background and global plugins keep running, so they must stay registered:
|
||||
// the registry routes their UI messages and lets them be unloaded later.
|
||||
plugins = plugins.filter((pluginApi) => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
if ((pluginApi.manifest as any)?.allowBackground) {
|
||||
backgroundPlugins.push(pluginApi);
|
||||
} else {
|
||||
pluginApi.plugin.close();
|
||||
if (
|
||||
pluginApi.manifest?.scope === 'global' ||
|
||||
(pluginApi.manifest as any)?.allowBackground
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
pluginApi.plugin.close();
|
||||
return false;
|
||||
});
|
||||
|
||||
plugins = backgroundPlugins;
|
||||
};
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
@ -50,6 +52,7 @@ export const loadPlugin = async function (
|
||||
closeCallback?: () => void,
|
||||
apiExtensions?: object,
|
||||
) {
|
||||
const load = new AbortController();
|
||||
try {
|
||||
const context = contextBuilder && contextBuilder(manifest.pluginId);
|
||||
|
||||
@ -57,7 +60,18 @@ export const loadPlugin = async function (
|
||||
return;
|
||||
}
|
||||
|
||||
closeAllPlugins();
|
||||
if (manifest.scope === 'global') {
|
||||
if (
|
||||
pendingPlugins.has(manifest.pluginId) ||
|
||||
plugins.some(
|
||||
(plugin) => plugin.manifest?.pluginId === manifest.pluginId,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
closeAllPlugins();
|
||||
}
|
||||
|
||||
// The host context is not deeply frozen at this load stage.
|
||||
//
|
||||
@ -74,10 +88,17 @@ export const loadPlugin = async function (
|
||||
// `createSandbox`'s proxy handler applies `ses.safeReturn` to values
|
||||
// crossing into the sandbox. Compartment isolation and intrinsics
|
||||
// hardening are performed by createSandbox, not here.
|
||||
const plugin = await createPlugin(
|
||||
pendingPlugins.get(manifest.pluginId)?.abort();
|
||||
pendingPlugins.set(manifest.pluginId, load);
|
||||
let plugin: Plugin | undefined = undefined;
|
||||
plugin = await createPlugin(
|
||||
context,
|
||||
manifest,
|
||||
() => {
|
||||
load.abort();
|
||||
if (pendingPlugins.get(manifest.pluginId) === load) {
|
||||
pendingPlugins.delete(manifest.pluginId);
|
||||
}
|
||||
plugins = plugins.filter((api) => api !== plugin);
|
||||
|
||||
if (closeCallback) {
|
||||
@ -85,11 +106,20 @@ export const loadPlugin = async function (
|
||||
}
|
||||
},
|
||||
apiExtensions,
|
||||
load.signal,
|
||||
);
|
||||
if (!plugin || load.signal.aborted) {
|
||||
plugin?.plugin.close();
|
||||
return;
|
||||
}
|
||||
plugins.push(plugin);
|
||||
} catch (error) {
|
||||
closeAllPlugins();
|
||||
if (manifest.scope !== 'global') closeAllPlugins();
|
||||
throw error;
|
||||
} finally {
|
||||
if (pendingPlugins.get(manifest.pluginId) === load) {
|
||||
pendingPlugins.delete(manifest.pluginId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -107,6 +137,7 @@ export const ɵloadPluginByUrl = async function (manifestUrl: string) {
|
||||
};
|
||||
|
||||
export const ɵunloadPlugin = function (id: Manifest['pluginId']) {
|
||||
pendingPlugins.get(id)?.abort();
|
||||
const plugin = plugins.find((plugin) => plugin.manifest.pluginId === id);
|
||||
|
||||
if (plugin) {
|
||||
|
||||
@ -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',
|
||||
]),
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user