penpot/frontend/src/app/plugins/register.cljs
alonso.torres 4c9d30ef6a ✨ Make plugin writes fail while the workspace is read-only
A read-only workspace (saves failing, a user who cannot edit, a version
preview) only hid the editing tools: plugins, the MCP among them, still
changed shapes, and those edits were never saved. Every plugin write now
throws a read-only error, whatever the throwValidationErrors flag, the
same way a write to a removed shape does.

`check-permission` with a write permission runs the new `check-writable`,
which covers most writers. Writers without a permission check (most
context methods such as create*, group, align or flatten, the z-order
methods, applyTypography on a text range, removing a ruler guide) call it
themselves, and shape setters get it through `check-shape-writable`.
Reads, comments and undo blocks keep working, so a block opened before a
lock still closes.

The MCP instructions tell the agent what the read-only error means.
2026-11-03 11:17:34 +01:00

296 lines
10 KiB
Clojure

;; 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.register
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.common.types.plugins :as ctp]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.main.repo :as rp]
[app.main.store :as st]
[app.util.object :as obj]
[beicon.v2.core :as rx]
[promesa.core :as p]))
;; Needs to be here because moving it to `app.main.data.workspace.mcp` will
;; cause a circular dependency
(def mcp-plugin-id "96dfa740-005d-8020-8007-55ede24a2bae")
;; Promise that resolves when plugins runtime is initialized.
;; Lives here to avoid circular dependency: workspace.mcp -> app.plugins -> app.plugins.api -> workspace
(defonce ^:private runtime-ready-promise (p/deferred))
(defn wait-for-runtime
"Returns a promise that resolves when plugins runtime is initialized."
[]
runtime-ready-promise)
(defn signal-runtime-ready
"Signals that plugins runtime has been initialized. Called by app.plugins/init-plugins-runtime."
[]
(when (p/pending? runtime-ready-promise)
(p/resolve runtime-ready-promise true)))
;; Stores the installed plugins information
(defonce ^:private registry (atom {}))
(defn plugins-list
"Retrieves the plugin data as an ordered list of plugin elements"
[]
(->> (:ids @registry)
(mapv #(dm/get-in @registry [:data %]))))
(defn get-plugin
[id]
(dm/get-in @registry [:data id]))
(defn parse-manifest
"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)
permissions (into #{} (obj/get manifest "permissions" []))
permissions
(cond-> permissions
(contains? permissions "content:write")
(conj "content:read")
(contains? permissions "library:write")
(conj "library:read")
(contains? permissions "comment:write")
(conj "comment:read")
(contains? permissions "clipboard:write")
(conj "clipboard:read"))
plugin-url
(u/uri plugin-url)
origin
(if (= vers 1)
(-> plugin-url
(assoc :path "")
(str))
(-> plugin-url
(u/join ".")
(str)))
prev-plugin
(->> (:data @registry)
(vals)
(d/seek (fn [plugin]
(and (= name (:name plugin))
(= origin (:host plugin))))))
plugin-id
(d/nilv (:plugin-id prev-plugin) (str (uuid/next)))
manifest
(d/without-nils
{:plugin-id plugin-id
:url (str plugin-url)
:version vers
:name name
:description desc
:host origin
:code code
:icon icon
:permissions (into #{} (map str) permissions)})]
(if (sm/validate ctp/schema:registry-entry manifest)
manifest
(.error js/console (clj->js (sm/explain ctp/schema:registry-entry manifest))))))
(defn subscribe-registry!
"Subscribes f, called with no arguments on every registry change.
Returns f."
[f]
(add-watch registry f (fn [_ _ old new]
(when-not (identical? old new)
(f))))
f)
(defn unsubscribe-registry!
[f]
(remove-watch registry f)
nil)
(defn load-from-store
[]
(reset! registry (get-in @st/state [:profile :props :plugins] {})))
(defn init
[]
(load-from-store))
(declare remove-plugin!)
;; Plugin ids with a persist in flight; install/remove calls on them are skipped
(defonce ^:private in-flight (atom #{}))
(defonce ^:private in-flight-listeners (atom #{}))
(defn subscribe-in-flight!
"Subscribes f, called with the in-flight id set on every change.
Calls f immediately with the current set. Returns f."
[f]
(swap! in-flight-listeners conj f)
(f @in-flight)
f)
(defn unsubscribe-in-flight!
[f]
(swap! in-flight-listeners disj f)
nil)
(defn- notify-in-flight!
[]
(let [ids @in-flight]
(doseq [f @in-flight-listeners]
(f ids))))
(defn- track!
[plugin-id]
(swap! in-flight conj plugin-id)
(notify-in-flight!))
(defn- release!
[plugin-id]
(swap! in-flight disj plugin-id)
(notify-in-flight!))
(defn- validation-error?
[err]
(= :validation (:type (ex-data err))))
(defn- drop-local!
[{:keys [plugin-id]}]
(swap! registry #(-> %
(update :ids (fn [ids] (vec (remove (partial = plugin-id) ids))))
(update :data dissoc plugin-id))))
(defn- insert-at
[ids idx id]
(let [v (vec ids)
idx (max 0 (min idx (count v)))]
(vec (concat (subvec v 0 idx) [id] (subvec v idx)))))
(defn- restore-local!
"Puts the stored plugin back into the registry at position idx."
[{:keys [plugin-id] :as plugin} idx]
(swap! registry #(-> %
(update :ids (fn [ids]
(insert-at (remove (partial = plugin-id) ids)
idx
plugin-id)))
(assoc-in [:data plugin-id] plugin))))
(defn install-plugin!
[plugin]
(let [plugin-id (:plugin-id plugin)
previous (get-plugin plugin-id)
prev-idx (.indexOf (vec (:ids @registry)) plugin-id)]
(when-not (contains? @in-flight plugin-id)
(track! plugin-id)
(letfn [(update-ids [ids]
(conj
(->> ids (remove #(= % (:plugin-id plugin))))
(:plugin-id plugin)))]
(swap! registry #(-> %
(update :ids update-ids)
(update :data assoc (:plugin-id plugin) plugin)))
(->> (rp/cmd! :add-profile-plugin {:plugin plugin})
(rx/subs! (fn [_]
(release! plugin-id))
(fn [err]
(release! plugin-id)
;; Restore the previous version in place, else drop it
(if previous
(restore-local! previous prev-idx)
(drop-local! plugin))
;; Other failures may have reached the server: undo it
;; once by re-saving the previous version or removing
;; the new entry, without further rollback.
(when-not (validation-error? err)
(->> (if previous
(rp/cmd! :add-profile-plugin {:plugin previous})
(rp/cmd! :remove-profile-plugin {:plugin-id plugin-id}))
(rx/subs! (fn [_] nil)
(fn [err2]
(.error js/console "Rollback failed:" err2)))))
(.error js/console "Failed to install plugin:" err))))))))
(defn remove-plugin!
[{:keys [plugin-id]}]
(let [stored (get-plugin plugin-id)
prev-idx (.indexOf (vec (:ids @registry)) plugin-id)]
(when-not (contains? @in-flight plugin-id)
(track! plugin-id)
(letfn [(update-ids [ids]
(->> ids
(remove #(= % plugin-id))))]
(swap! registry #(-> %
(update :ids update-ids)
(update :data dissoc plugin-id)))
(->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id})
(rx/subs! (fn [_]
(release! plugin-id))
(fn [err]
(release! plugin-id)
(when stored
;; Restore in place; validation errors keep it server-side
(restore-local! stored prev-idx)
;; Other failures: re-save it once, without further rollback
(when-not (validation-error? err)
(->> (rp/cmd! :add-profile-plugin {:plugin stored})
(rx/subs! (fn [_] nil)
(fn [err2]
(.error js/console "Rollback install failed:" err2))))))
(.error js/console "Failed to remove plugin:" err))))))))
(defn has-permission?
"True when the plugin was granted `permission`."
[plugin-id permission]
(or (= plugin-id "00000000-0000-0000-0000-000000000000")
(= plugin-id mcp-plugin-id)
(let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])]
(contains? permissions permission))))
(defn check-writable
"Throws while the workspace is read-only: saves are failing, the user
cannot edit or a version is being previewed. A write made then would be
lost, so it fails whatever the plugin's validation flag."
[]
(let [wglobal (:workspace-global @st/state)]
(when (true? (:read-only? wglobal))
(throw (ex-info (if (some? (:persistence-lock wglobal))
"[PENPOT PLUGIN] The workspace is read-only because changes cannot be saved right now"
"[PENPOT PLUGIN] The workspace is read-only")
{:type ::read-only})))))
(def ^:private write-permissions
#{"content:write" "library:write"})
(defn check-permission
"True when the plugin was granted `permission`. A write permission also
runs `check-writable`, which throws while the workspace is read-only."
[plugin-id permission]
(when (contains? write-permissions permission)
(check-writable))
(has-permission? plugin-id permission))
(defn get-plugin-data
[state plugin-id]
(get-in state [:profile :props :plugins :data plugin-id]))