Compare commits

..
5 Commits
Author SHA1 Message Date
Andrey Antukh 1a135b75e1 🐛 Skip no-op plugin removal and clarify size comments
Return early from remove-profile-plugin when the id is absent:
no wasted write, no size check, and no manufactured :plugins key
that could spuriously fail on oversized profiles.

Clarify that per-field string caps count chars while the byte
budget is enforced by profile-props-max-size.

Closes #11592

AI-assisted-by: muse-spark-1.3-contributor
2026-09-09 19:09:32 +00:00
Andrey Antukh 8fc58b7f04 🐛 Guard notifications write and fix restore ordering
Route update-profile-notifications through check-props-size! so
oversized profiles cannot grow through that path; document the
exempt system writers. Remove the duplicated stale entries in
en.po, keeping the canonical translation refs.

Restore rejected plugin updates at their original list position
instead of leaving the optimistic move in place.

Closes #11592

AI-assisted-by: muse-spark-1.3-contributor
2026-09-09 18:58:13 +00:00
Andrey Antukh 5482e04551 🐛 Fix rollback loops and restore paths in plugin registry
Restore the previous plugin version instead of dropping the entry
when a validation error rejects an update of an installed plugin.

Make compensating writes one-shot with terminal callbacks so a
persistent failure cannot ping-pong between install and remove.
Restores keep the original list position; the unused public
plugin-persisting? predicate is removed.

Pin count-before-size precedence with a dedicated test and fix
translation source refs to their canonical lines.

Closes #11592

AI-assisted-by: muse-spark-1.3-contributor
2026-09-09 18:37:00 +00:00
Andrey Antukh 1774534881 🐛 Enforce plugin count cap, byte sizes and removal guard
Enforce the declared 50-plugin cap in add-profile-plugin with a
specific :too-many-plugins error (updates of existing entries
still pass); the cap lives in a shared max-plugins constant.

Measure profile props size in UTF-8 bytes instead of chars so
multibyte content cannot slip past the limit.

Cover install/remove persist logic with mocked-RPC frontend tests
(release semantics, in-flight dedupe, validation vs rollback
split) and add the missing boundary tests in common.

Expose the in-flight persist set from the plugin registry and
disable the remove button of entries being saved.

Closes #11592

AI-assisted-by: muse-spark-1.3-contributor
2026-09-09 18:06:12 +00:00
Andrey Antukh 1431cbd856 Add size limits to profile props and plugin registry
Bound the total serialized size of profile settings to 2 MiB
(:profile-props-max-size), checked on the merged result before
persisting, with a controlled :props-too-large error. Profiles
that already exceed the limit can still shrink but cannot grow.

Cap plugin registry entries in the shared schema (code 1 MiB, 50
plugins max, bounded name/host/description/icon) and restore rate
limiting on the plugin RPCs (profile-mutations bucket, one write
at a time per profile). The plugin manager now asks for
confirmation before removal and ignores repeated clicks while a
persist request is in flight.

Closes #11592

AI-assisted-by: muse-spark-1.3-contributor
2026-09-09 16:53:11 +00:00
78 changed files with 962 additions and 508 deletions

No files matched your search

+7 -1
View File
@@ -47,8 +47,14 @@
:send-user-feedback/by-profile
{:permits 1 :queue 3}
:import-binfile/global
:import-binfile/global
{:permits 4}
:import-binfile/by-profile
{:permits 1 :queue 2}
:profile-plugin-ops/global
{:permits 4}
:profile-plugin-ops/by-profile
{:permits 1 :queue 2}}
+4 -2
View File
@@ -216,8 +216,10 @@
;; ═══════════════════════════════════════════════
;; Profile operations
;; ═══════════════════════════════════════════════
#{:main/update-profile
:main/update-profile-props
#{:main/update-profile
:main/update-profile-props
:main/add-profile-plugin
:main/remove-profile-plugin
:main/update-profile-photo
:main/update-profile-password
:main/update-profile-notifications
+7 -1
View File
@@ -99,7 +99,10 @@
;; Binfile import limits
:binfile-import-max-object-size (* 1024 1024 100) ;; 100 MiB
:binfile-import-max-zip-entries (* 500 1000)}) ;; 500,000
:binfile-import-max-zip-entries (* 500 1000) ;; 500,000
;; Profile props total size limit (serialized, after merge)
:profile-props-max-size (* 1024 1024 2)}) ;; 2 MiB
(def schema:config
(do #_sm/optional-keys
@@ -160,6 +163,9 @@
[:binfile-import-max-object-size {:optional true} ::sm/int]
[:binfile-import-max-zip-entries {:optional true} ::sm/int]
;; Profile props total size limit (PENPOT_PROFILE_PROPS_MAX_SIZE)
[:profile-props-max-size {:optional true} ::sm/int]
[:deletion-delay {:optional true} ::ct/duration]
[:file-clean-delay {:optional true} ::ct/duration]
[:telemetry-enabled {:optional true} ::sm/boolean]
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.arrow
"Bulk Ladybug ingest through in-memory Arrow.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.debug
"In-memory Ladybug sessions for the debug graph console."
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.ingest
"Penpot file -> Ladybug graph projection."
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.ladybug
"Ladybug access layer for graph-backed Penpot.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.meta
"`GraphMeta`: the graph's own account of who built it and from what.
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.projection.document
"Project a Penpot file-data map into Ladybug nodes and structural edges.
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.projection.transforms
"Derived graph links: edges a reader could compute from the projected
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.report
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema
"Ladybug DDL facade for the graph-backed Penpot vertical slice.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.contract
"Deliberate choices in Penpot's graph schema, recorded as data.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.nodes
"Single source of truth for graph node tables.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.projection
"Derive Ladybug node column schemas from Penpot Malli sources.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.types
"Map Malli schemas to Ladybug column types.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.values
"Shape a Penpot value into the plain data its Ladybug column type wants.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.stats
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.sync
"Incremental Ladybug graph updates from Penpot file-change events."
+42 -18
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.rpc.commands.plugins
(:require
@@ -11,6 +11,7 @@
[app.common.types.plugins :as ctp]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]))
@@ -33,6 +34,8 @@
(sv/defmethod ::add-profile-plugin
{::doc/added "2.18"
::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]
[:profile-plugin-ops/global]]
::sm/params schema:add-profile-plugin
::sm/result ctp/schema:registry-entry
::db/transaction true}
@@ -42,14 +45,21 @@
(let [profile (profile/get-profile conn profile-id ::db/for-update true)
plugins (get-in profile [:props :plugins] {:ids [] :data {}})
plugin-id (:plugin-id plugin)
plugins (-> plugins
(update :ids #(vec (distinct (conj % plugin-id))))
(assoc-in [:data plugin-id] plugin))]
(db/update! conn :profile
{:props (db/tjson (assoc (:props profile) :plugins plugins))}
{:id profile-id}
{::db/return-keys false})
plugin))
exists? (contains? (set (:ids plugins)) plugin-id)]
(when (and (not exists?) (>= (count (:ids plugins)) ctp/max-plugins))
(ex/raise :type :validation
:code :too-many-plugins
:hint "plugin registry exceeds maximum size"))
(let [plugins (-> plugins
(update :ids #(vec (distinct (conj % plugin-id))))
(assoc-in [:data plugin-id] plugin))
props (assoc (:props profile) :plugins plugins)]
(profile/check-props-size! (:props profile) props)
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id}
{::db/return-keys false})
plugin)))
(def ^:private
schema:remove-profile-plugin
@@ -58,18 +68,32 @@
(sv/defmethod ::remove-profile-plugin
{::doc/added "2.18"
::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]
[:profile-plugin-ops/global]]
::sm/params schema:remove-profile-plugin
::sm/result :nil
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id plugin-id]}]
(let [profile (profile/get-profile conn profile-id ::db/for-update true)
plugins (get-in profile [:props :plugins] {:ids [] :data {}})
plugin-id-str (str plugin-id)
plugins (-> plugins
(update :ids #(vec (remove (partial = plugin-id-str) %)))
(update :data dissoc plugin-id-str))]
(db/update! conn :profile
{:props (db/tjson (assoc (:props profile) :plugins plugins))}
{:id profile-id}
{::db/return-keys false})
nil))
plugin-id-str (str plugin-id)]
(if-not (or (some #(= % plugin-id-str) (:ids plugins))
(contains? (:data plugins) plugin-id-str))
;; Nothing to remove: skip the write (and the size check) entirely.
nil
(let [plugins (-> plugins
(update :ids #(vec (remove (partial = plugin-id-str) %)))
(update :data dissoc plugin-id-str))
props (assoc (:props profile) :plugins plugins)]
;; Removal only shrinks props, so this never raises; kept for uniformity
;; so the user-facing profile.props RPC writes (update-profile-props,
;; update-profile-notifications, add/remove-profile-plugin) all go
;; through the size check. System writers (OIDC login merge,
;; management subscription update) are exempt: they write fixed-key,
;; non-accumulating shapes.
(profile/check-props-size! (:props profile) props)
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id}
{::db/return-keys false})
nil))))
+27
View File
@@ -36,6 +36,7 @@
[cuerdas.core :as str]))
(declare check-profile-existence!)
(declare check-props-size!)
(declare decode-row)
(declare filter-props)
(declare get-profile)
@@ -280,6 +281,8 @@
(-> (get profile :props)
(assoc :notifications notifications))]
(check-props-size! (:props profile) props)
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id}
@@ -469,6 +472,28 @@
[:map {:title "update-profile-props"}
[:props schema:props-writeable]])
(defn- props-size
"Returns the serialized size in UTF-8 bytes of the props map."
[props]
(if-let [pg (db/tjson props)]
(alength (.getBytes ^String (.getValue ^org.postgresql.util.PGobject pg) "UTF-8"))
0))
(defn check-props-size!
"Raises :props-too-large when the new props exceed the configured total
size limit *and* grow beyond the current size. Profiles that already
exceed the limit can still shrink or hold steady (grandfathered), but
cannot grow further."
[old-props new-props]
(let [limit (cf/get :profile-props-max-size)
old-size (props-size old-props)
new-size (props-size new-props)]
(when (and (> new-size limit)
(> new-size old-size))
(ex/raise :type :validation
:code :props-too-large
:hint "profile props exceed maximum size"))))
(defn update-profile-props
[{:keys [::db/conn] :as cfg} profile-id props]
(let [profile (get-profile conn profile-id ::db/for-update true)
@@ -482,6 +507,8 @@
(:props profile)
(apply dissoc props system-managed-props))]
(check-props-size! (:props profile) props)
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id}
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.storage.pending-gc
"A maintenance task that reclaims storage objects created in 'pending'
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.tasks.demo-purge
"Task handler for delayed demo profile deletion. Submitted at demo
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.demo-test
(:require
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.graph-binder-gate-test
"Binder gate for the incremental-sync statement templates.
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.graph-sync-parity-test
"Cold projection and incremental sync are two implementations of one mapping,
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.passwords-test
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.rpc-demo-test
(:require
+123 -1
View File
@@ -2,11 +2,13 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.rpc-plugins-test
(:require
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[backend-tests.helpers :as th]
@@ -145,6 +147,126 @@
(t/is (= "Test Plugin" (get-in plugins [:data plugin-id-1 :name])))
(t/is (= "Second Plugin" (get-in plugins [:data plugin-id-2 :name]))))))
(t/deftest add-profile-plugin-rejects-oversized-code
;; The merged props must not exceed :profile-props-max-size
(let [profile (th/create-profile* 1)
plugin (assoc valid-plugin :code (apply str (repeat 200 "x")))
data {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin}]
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest remove-profile-plugin-allowed-on-oversized-profile
;; Removal shrinks props, so it passes even under a tight limit
(let [profile (th/create-profile* 1)
plugin (assoc valid-plugin :code (apply str (repeat 200 "x")))]
;; Seed an oversized registry while the limit is high
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100000})]
(let [out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin})]
(t/is (nil? (:error out)))))
;; Removal under a tight limit still passes
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [out (th/command! {::th/type :remove-profile-plugin
::rpc/profile-id (:id profile)
:plugin-id (uuid/uuid plugin-id-1)})]
(t/is (nil? (:error out)))))))
(t/deftest add-profile-plugin-rejects-51st-plugin
;; The registry holds at most 50 plugins; the 51st (new id) must fail
(let [profile (th/create-profile* 1)]
;; Seed 50 plugins
(doseq [i (range 50)]
(let [plugin (assoc valid-plugin
:plugin-id (str (uuid/next))
:name (str "Plugin " i))
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin})]
(t/is (nil? (:error out)) (str "seed plugin " i " should install"))))
;; The 51st must fail with a specific error
(let [extra (assoc valid-plugin
:plugin-id (str (uuid/next))
:name "One Too Many")
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin extra})]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :too-many-plugins)))
;; And nothing extra was persisted
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (= 50 (count (get-in props [:props :plugins :ids])))))))
(t/deftest add-profile-plugin-updates-existing-at-limit
;; Re-adding an existing id at the limit is an update, not a new entry
(let [profile (th/create-profile* 1)
ids (mapv (fn [_] (str (uuid/next))) (range 50))]
(doseq [[i pid] (map-indexed vector ids)]
(th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin (assoc valid-plugin :plugin-id pid :name (str "Plugin " i))}))
(let [out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin (assoc valid-plugin :plugin-id (first ids) :name "Renamed")})]
(t/is (nil? (:error out)))
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (= 50 (count (get-in props [:props :plugins :ids]))))
(t/is (= "Renamed" (get-in props [:props :plugins :data (first ids) :name])))))))
(t/deftest add-profile-plugin-full-registry-reports-too-many-before-size
;; A full registry plus oversized content reports the count guard,
;; which runs before the size check
(let [profile (th/create-profile* 1)]
;; Seed 50 plugins under a generous limit
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 1000000})]
(doseq [i (range 50)]
(let [plugin (assoc valid-plugin
:plugin-id (str (uuid/next))
:name (str "Plugin " i))
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin})]
(t/is (nil? (:error out)) (str "seed plugin " i " should install")))))
;; Tight limit + 51st small plugin: count wins over size
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [extra (assoc valid-plugin
:plugin-id (str (uuid/next))
:name "One Too Many")
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin extra})]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :too-many-plugins))))
;; And nothing extra was persisted
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (= 50 (count (get-in props [:props :plugins :ids])))))))
(t/deftest remove-profile-plugin-noop-on-oversized-profile-without-plugins
;; Removing an absent id changes nothing: no write, no size failure,
;; and no :plugins key is manufactured
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [out (th/command! {::th/type :remove-profile-plugin
::rpc/profile-id (:id profile)
:plugin-id (uuid/next)})]
(t/is (nil? (:error out)))))
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (nil? (get-in props [:props :plugins])))
(t/is (= big (get props :props))))))
(t/deftest update-profile-props-rejects-plugins
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-props
@@ -1341,6 +1341,104 @@
(t/is (th/ex-of-code? (:error out) :params-validation))))
(t/deftest update-profile-props-rejects-oversized-props
;; The merged props must not exceed :profile-props-max-size
(let [profile (th/create-profile* 1)]
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest update-profile-props-grandfathers-oversized-profile
;; Profiles that already exceed the limit can shrink or hold steady,
;; but cannot grow further
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
;; Seed an already-oversized profile directly in DB (bypasses RPC validation)
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
;; Shrinking update passes
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob "small"}}}
out (th/command! data)]
(t/is (nil? (:error out))))
;; Growing update fails
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob (apply str (repeat 300 "x"))}}}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest check-props-size-measures-bytes-not-chars
;; The limit is in UTF-8 bytes: multibyte content that fits in chars
;; but exceeds the byte limit must be rejected
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 200})]
;; 70 ASCII chars (~87 bytes serialized) passes
(t/is (nil? (profile/check-props-size! {} {:blob (apply str (repeat 70 "x"))})))
;; 70 CJK chars (~227 bytes serialized, still 70 chars) raises
(try
(profile/check-props-size! {} {:blob (apply str (repeat 70 "日"))})
(t/is false "should have thrown")
(catch clojure.lang.ExceptionInfo e
(t/is (= :validation (:type (ex-data e))))
(t/is (= :props-too-large (:code (ex-data e))))))))
(t/deftest update-profile-props-allows-steady-size-on-oversized-profile
;; Same size (not smaller) on an oversized profile passes
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob (apply str (repeat 200 "y"))}}}
out (th/command! data)]
(t/is (nil? (:error out)))))))
(t/deftest update-profile-notifications-rejects-growth-on-oversized-profile
;; The notifications write path goes through the same size check:
;; growing an oversized profile fails
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data {::th/type :update-profile-notifications
::rpc/profile-id (:id profile)
:dashboard-comments :all
:email-comments :all
:email-invites :all}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest update-profile-notifications-allows-steady-on-oversized-profile
;; Same-size notifications write on an oversized profile passes
(let [profile (th/create-profile* 1)
notifications {:dashboard-comments :all
:email-comments :all
:email-invites :all}
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}
:notifications notifications}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data (merge {::th/type :update-profile-notifications
::rpc/profile-id (:id profile)}
notifications)
out (th/command! data)]
(t/is (nil? (:error out)))))))
(t/deftest prepare-register-profile-password-too-short
(let [data {::th/type :prepare-register-profile
:email "user@example.com"
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.common.types.path.fit
"Curve fitting helpers."
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.common.types.path.selection
"Transforms selected path nodes and handlers."
+11 -7
View File
@@ -41,21 +41,25 @@
"Schema for plugin permissions - a set of valid permission strings."
[:set {:gen/max 11} (into [:enum] (sort valid-permissions))])
(def max-plugins
"Maximum number of plugins a profile can hold."
50)
(def schema:registry-entry
[:map
[:plugin-id :string]
[:version {:optional true} :int]
[:name :string]
[:description {:optional true} :string]
[:host :string]
[:code :string]
[:icon {:optional true} :string]
[:name [:string {:max 500}]]
[:description {:optional true} [:string {:max 4096}]]
[:host [:string {:max 500}]]
[:code [:string {:max 1048576}]] ;; 1 MiB chars; byte budget enforced by profile-props-max-size
[:icon {:optional true} [:string {:max 262144}]] ;; 256 KiB chars; see above
[:permissions schema:permissions]])
(def schema:plugin-registry
[:map
[:ids [:vector :string]]
[:ids [:vector {:max max-plugins} :string]]
[:data
[:map-of {:gen/max 5}
[:map-of {:gen/max 5 :max max-plugins}
:string
schema:registry-entry]]])
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.common.types.tokens-status
(:require
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns common-tests.files-migrations-0026-test
(:require
+2
View File
@@ -82,6 +82,7 @@
[common-tests.types.objects-map-test]
[common-tests.types.organization-test]
[common-tests.types.path-data-test]
[common-tests.types.plugins-test]
[common-tests.types.shape-decode-encode-test]
[common-tests.types.shape-interactions-test]
[common-tests.types.shape-layout-test]
@@ -162,6 +163,7 @@
'common-tests.types.objects-map-test
'common-tests.types.organization-test
'common-tests.types.path-data-test
'common-tests.types.plugins-test
'common-tests.types.shape-decode-encode-test
'common-tests.types.shape-interactions-test
'common-tests.types.shape-layout-test
@@ -0,0 +1,64 @@
;; 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 common-tests.types.plugins-test
(:require
[app.common.schema :as sm]
[app.common.types.plugins :as ctp]
[clojure.test :as t]))
(def ^:private valid-entry
{:plugin-id "plugin-1"
:name "Test Plugin"
:description "A test plugin"
:host "https://example.com"
:code "(function() {})()"
:permissions #{"content:read"}})
(t/deftest registry-entry-accepts-valid-plugin
(t/is (true? (sm/validate ctp/schema:registry-entry valid-entry))))
(t/deftest registry-entry-rejects-oversized-code
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry
:code (apply str (repeat (inc (* 1024 1024)) "x")))))))
(t/deftest registry-entry-rejects-oversized-name
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :name (apply str (repeat 501 "x")))))))
(t/deftest registry-entry-rejects-oversized-host
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :host (apply str (repeat 501 "x")))))))
(t/deftest registry-entry-rejects-oversized-description
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :description (apply str (repeat 4097 "x")))))))
(t/deftest registry-entry-rejects-oversized-icon
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :icon (apply str (repeat 262145 "x")))))))
(t/deftest registry-entry-accepts-values-at-max
(t/is (true? (sm/validate ctp/schema:registry-entry
(assoc valid-entry
:name (apply str (repeat 500 "x"))
:host (apply str (repeat 500 "x"))
:description (apply str (repeat 4096 "x"))
:icon (apply str (repeat 262144 "x"))
:code (apply str (repeat 1048576 "x")))))))
(defn- make-registry
[n]
(let [ids (mapv #(str "plugin-" %) (range n))]
{:ids ids
:data (into {} (map (fn [id] [id (assoc valid-entry :plugin-id id)]) ids))}))
(t/deftest plugin-registry-accepts-fifty-plugins
(t/is (true? (sm/validate ctp/schema:plugin-registry (make-registry 50)))))
(t/deftest plugin-registry-rejects-more-than-fifty-plugins
(t/is (false? (sm/validate ctp/schema:plugin-registry (make-registry 51)))))
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns common-tests.types.tokens-status-test
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.auth
"Resolves the caller's session cookie to a real profile id.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.handlers.export
"Handle export jobs"
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.handlers.jobs
"REST surface for export jobs, under `/api/export/jobs`.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.jobs
"Export job model and lifecycle.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.jobs.scheduler
"Admission control for export jobs.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.jobs.store
"Redis persistence for export jobs.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.jobs.utils
"Temp file ownership for export jobs.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.router
"Method + path dispatch.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm.pool
"Pool of headless render workers.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm.render
"Headless render pipeline: renders exports with the render-wasm Skia pipeline,
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm.worker
"Render worker entry point.
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns exporter-tests.export-shapes-test
"Chunking of the browser backend."
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns exporter-tests.jobs-test
"Job state machine. Runs without redis: a store write with no connection is
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns exporter-tests.scheduler-test
"Admission control. A headless job leases one render worker for its whole run,
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns exporter-tests.wasm-pool-test
"Worker leasing, against a stub pool: `with-worker` must give the worker back
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.main.data.workspace.path.clipboard
(:require
@@ -56,7 +56,7 @@
icon))
(mf/defc plugin-entry*
[{:keys [index manifest user-can-edit on-open-plugin on-remove-plugin]}]
[{:keys [index manifest user-can-edit on-open-plugin on-remove-plugin remove-disabled]}]
(let [{:keys [plugin-id host icon name description permissions]} manifest
plugins-permissions-peek (deref refs/plugins-permissions-peek)
@@ -100,6 +100,7 @@
[:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.plugins.remove-plugin")
:on-click handle-delete-click
:disabled remove-disabled
:icon i/delete}]]))
(mf/defc plugin-management-dialog
@@ -126,6 +127,11 @@
fetching-manifest?
(mf/use-state false)
;; Ids with a persist request in flight; their remove button
;; is disabled until the request completes.
in-flight*
(mf/use-state #{})
on-url-change
(mf/use-fn
(fn [value]
@@ -175,12 +181,23 @@
(fn [plugin-index]
(let [plugins-list (preg/plugins-list)
plugin (nth plugins-list plugin-index)]
(st/emit! (ev/event {::ev/name "remove-plugin"
:name (:name plugin)
:host (:host plugin)}))
(dp/close-plugin! plugin)
(preg/remove-plugin! plugin)
(reset! plugins-state* (preg/plugins-list)))))]
(modal/show!
{:type :confirm
:title (tr "workspace.plugins.remove-confirmation.title")
:message (tr "workspace.plugins.remove-confirmation.message" (:name plugin))
:accept-label (tr "workspace.plugins.remove-plugin")
:on-accept (fn [_]
(st/emit! (ev/event {::ev/name "remove-plugin"
:name (:name plugin)
:host (:host plugin)}))
(dp/close-plugin! plugin)
(preg/remove-plugin! plugin)
(reset! plugins-state* (preg/plugins-list))
(modal/show! :plugin-management {}))}))))]
(mf/with-effect []
(let [listener (preg/subscribe-in-flight! #(reset! in-flight* %))]
(partial preg/unsubscribe-in-flight! listener)))
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog :plugin-management)}
@@ -236,6 +253,7 @@
:index idx
:manifest manifest
:user-can-edit user-can-edit?
:remove-disabled (contains? @in-flight* (:plugin-id manifest))
:on-open-plugin on-open-plugin
:on-remove-plugin on-remove-plugin}])]])]]]))
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.main.ui.workspace.viewport.path-state
(:require
+118 -25
View File
@@ -122,36 +122,129 @@
(declare remove-plugin!)
;; Tracks plugin ids with a persist request in flight, so rapid repeated
;; install/remove clicks on the same plugin cannot stack RPC writes.
(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 install-plugin!
[plugin]
(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! identity
(fn [err]
(remove-plugin! plugin)
(.error js/console "Failed to install plugin:" err))))))
(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)
(if (validation-error? err)
;; The server kept the previous version (if any)
;; in its original position: drop the optimistic
;; entry and restore both position and data.
(if previous
(swap! registry #(-> %
(update :ids (fn [ids]
(insert-at (remove (partial = plugin-id) ids)
prev-idx
plugin-id)))
(assoc-in [:data plugin-id] previous)))
(drop-local! plugin))
;; One-shot compensating write with terminal
;; callbacks: never re-arms tracking or rollback.
(do
(drop-local! plugin)
(->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id})
(rx/subs! (fn [_] nil)
(fn [err2]
(.error js/console "Rollback remove failed:" err2))))))
(.error js/console "Failed to install plugin:" err))))))))
(defn remove-plugin!
[{:keys [plugin-id]}]
(let [plugin (get-plugin 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! identity
(fn [err]
(when plugin
(install-plugin! plugin))
(.error js/console "Failed to remove plugin:" err)))))))
(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 at the original position; the server
;; still holds the entry on validation errors.
(swap! registry #(-> %
(update :ids insert-at prev-idx plugin-id)
(update :data assoc plugin-id stored)))
;; One-shot compensating write with terminal
;; callbacks on any other failure.
(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 check-permission
[plugin-id permission]
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.logic.path-actions-test
(:require
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.logic.path-clipboard-test
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.logic.path-helpers-test
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.logic.path-lifecycle-test
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.logic.path-test-helpers
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.logic.path-tools-test
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.logic.wasm-modifiers-nil-id-test
"Reproduces the production crash \"Cannot read properties of null
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.plugins.flex-test
(:require
@@ -0,0 +1,271 @@
;; 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.register-test
(:require
[app.main.repo :as rp]
[app.plugins.register :as preg]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]))
(defn- record-cmd-mock
"Mock rp/cmd! that records calls in mock/rpc-calls and answers
with (response-fn cmd params)."
[response-fn]
(mock/stub
(fn [cmd params]
(swap! mock/rpc-calls conj {:cmd cmd :params params})
(response-fn cmd params))))
(defn- cmds
[]
(mapv :cmd @mock/rpc-calls))
;; --- install-plugin! ---
(t/deftest install-success-releases-id
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock (fn [_ _] (rx/of {:ok true})))}
(fn [done']
(let [plugin {:plugin-id "reg-install-ok"}]
(preg/install-plugin! plugin)
(t/is (= [:add-profile-plugin] (cmds)))
(t/is (= plugin (preg/get-plugin "reg-install-ok")))
;; id released: a second install issues a second RPC
(preg/install-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)))
(done')))
done)))
(t/deftest install-while-in-flight-issues-no-second-rpc
(t/async done
(let [subjects (atom [])]
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ _]
(let [sb (rx/subject)]
(swap! subjects conj sb)
sb)))}
(fn [done']
(let [plugin {:plugin-id "reg-install-dedupe"}]
(preg/install-plugin! plugin)
(preg/install-plugin! plugin)
(t/is (= 1 (count @mock/rpc-calls)) "second install while in flight is skipped")
;; complete the pending call; the id is released afterwards
(rx/push! (first @subjects) {:ok true})
(preg/install-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)))
(done')))
done))))
(t/deftest install-validation-error-cleans-local-only
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ _] (rx/throw (ex-info "rejected" {:type :validation :code :props-too-large}))))}
(fn [done']
(let [plugin {:plugin-id "reg-install-validation"}]
(preg/install-plugin! plugin)
(t/is (= 1 (count @mock/rpc-calls)) "no rollback write after validation rejection")
(t/is (nil? (preg/get-plugin "reg-install-validation")))
;; id released: the next install retries the RPC
(preg/install-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)))
(done')))
done)))
(t/deftest install-validation-error-restores-previous-version
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ params]
(if (= "v2" (get-in params [:plugin :name]))
(rx/throw (ex-info "rejected" {:type :validation}))
(rx/of {:ok true}))))}
(fn [done']
(let [v1 {:plugin-id "reg-install-prev" :name "v1"}
v2 {:plugin-id "reg-install-prev" :name "v2"}]
(preg/install-plugin! v1)
(preg/install-plugin! v2)
(t/is (= 2 (count @mock/rpc-calls)))
(t/is (= v1 (preg/get-plugin "reg-install-prev"))
"the server-kept version is restored, not dropped")
(done')))
done)))
(t/deftest install-validation-error-keeps-original-position
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ params]
(if (= "v2" (get-in params [:plugin :name]))
(rx/throw (ex-info "rejected" {:type :validation}))
(rx/of {:ok true}))))}
(fn [done']
(let [own #{"reg-pos-a" "reg-pos-b" "reg-pos-c"}
v1 {:plugin-id "reg-pos-b" :name "v1"}
v2 {:plugin-id "reg-pos-b" :name "v2"}]
(preg/install-plugin! {:plugin-id "reg-pos-a"})
(preg/install-plugin! v1)
(preg/install-plugin! {:plugin-id "reg-pos-c"})
(preg/install-plugin! v2)
;; installs prepend, so newest-first; the rejected update
;; must preserve order and restore v1
(t/is (= ["reg-pos-c" "reg-pos-b" "reg-pos-a"]
(filterv own (mapv :plugin-id (preg/plugins-list)))))
(t/is (= v1 (preg/get-plugin "reg-pos-b")))
(done')))
done)))
(t/deftest install-persistent-failure-terminates
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ _] (rx/throw (ex-info "boom" {:type :other}))))}
(fn [done']
(let [plugin {:plugin-id "reg-install-hang"}]
(preg/install-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds))
"one-shot rollback: no further calls")
(t/is (nil? (preg/get-plugin "reg-install-hang")))
(done')))
done)))
(t/deftest install-non-validation-error-rolls-back-via-rpc
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/throw (ex-info "boom" {:type :other}))
(rx/of nil))))}
(fn [done']
(let [plugin {:plugin-id "reg-install-rollback"}]
(preg/install-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds)))
(t/is (nil? (preg/get-plugin "reg-install-rollback")))
(done')))
done)))
;; --- remove-plugin! ---
(t/deftest remove-success-releases-id
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock (fn [_ _] (rx/of {:ok true})))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-ok"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds)))
(t/is (nil? (preg/get-plugin "reg-remove-ok")))
;; id released: a second remove issues another RPC
(preg/remove-plugin! plugin)
(t/is (= 3 (count @mock/rpc-calls)))
(done')))
done)))
(t/deftest remove-while-in-flight-issues-no-second-rpc
(t/async done
(let [subjects (atom [])]
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(let [sb (rx/subject)]
(swap! subjects conj sb)
sb))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-dedupe"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)) "second remove while in flight is skipped")
;; complete the pending call; the id is released afterwards
(rx/push! (first @subjects) nil)
(preg/remove-plugin! plugin)
(t/is (= 3 (count @mock/rpc-calls)))
(done')))
done))))
(t/deftest remove-validation-error-restores-local-only
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(rx/throw (ex-info "rejected" {:type :validation})))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-validation"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds))
"no reinstall write after validation rejection")
(t/is (= plugin (preg/get-plugin "reg-remove-validation"))
"local entry is restored")
(done')))
done)))
(t/deftest remove-non-validation-error-reinstalls-via-rpc
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :remove-profile-plugin)
(rx/throw (ex-info "boom" {:type :other}))
(rx/of {:ok true}))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-rollback"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin :add-profile-plugin] (cmds)))
(t/is (= plugin (preg/get-plugin "reg-remove-rollback")))
(done')))
done)))
(t/deftest remove-persistent-failure-terminates
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(rx/throw (ex-info "boom" {:type :other})))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-hang"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin :add-profile-plugin] (cmds))
"one-shot rollback: no further calls")
(t/is (= plugin (preg/get-plugin "reg-remove-hang")))
(done')))
done)))
(t/deftest remove-validation-error-keeps-original-position
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(rx/throw (ex-info "rejected" {:type :validation})))))}
(fn [done']
(let [own #{"reg-idx-a" "reg-idx-b" "reg-idx-c"}
plugin {:plugin-id "reg-idx-b"}]
(preg/install-plugin! {:plugin-id "reg-idx-a"})
(preg/install-plugin! plugin)
(preg/install-plugin! {:plugin-id "reg-idx-c"})
(preg/remove-plugin! plugin)
;; installs prepend, so the order is newest-first;
;; the failed removal must preserve it exactly
(t/is (= ["reg-idx-c" "reg-idx-b" "reg-idx-a"]
(filterv own (mapv :plugin-id (preg/plugins-list)))))
(done')))
done)))
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.plugins.user-test
(:require
+2
View File
@@ -59,6 +59,7 @@
[frontend-tests.plugins.page-active-validation-test]
[frontend-tests.plugins.page-test]
[frontend-tests.plugins.parser-test]
[frontend-tests.plugins.register-test]
[frontend-tests.plugins.shape-bugfixes-test]
[frontend-tests.plugins.text-test]
[frontend-tests.plugins.tokens-test]
@@ -168,6 +169,7 @@
'frontend-tests.plugins.page-active-validation-test
'frontend-tests.plugins.page-test
'frontend-tests.plugins.parser-test
'frontend-tests.plugins.register-test
'frontend-tests.plugins.shape-bugfixes-test
'frontend-tests.plugins.text-test
'frontend-tests.plugins.tokens-test
+11 -1
View File
@@ -9073,7 +9073,17 @@ msgstr "Read the profile information of the current user."
msgid "workspace.plugins.plugin-list-link"
msgstr "Plugins List"
#: src/app/main/ui/workspace/plugins.cljs:101
#: src/app/main/ui/workspace/plugins.cljs:187
msgid "workspace.plugins.remove-confirmation.message"
msgstr ""
"Are you sure you want to remove the plugin %s? You can install it again at "
"any time."
#: src/app/main/ui/workspace/plugins.cljs:186
msgid "workspace.plugins.remove-confirmation.title"
msgstr "Remove plugin"
#: src/app/main/ui/workspace/plugins.cljs:101, src/app/main/ui/workspace/plugins.cljs:188
msgid "workspace.plugins.remove-plugin"
msgstr "Remove plugin"
+9 -1
View File
@@ -8821,7 +8821,15 @@ msgstr "Leer la información del usuario actual."
msgid "workspace.plugins.plugin-list-link"
msgstr "Lista de extensiones"
#: src/app/main/ui/workspace/plugins.cljs:101
#: src/app/main/ui/workspace/plugins.cljs:187
msgid "workspace.plugins.remove-confirmation.message"
msgstr "¿Seguro que quieres eliminar la extensión %s? Puedes instalarla de nuevo en cualquier momento."
#: src/app/main/ui/workspace/plugins.cljs:186
msgid "workspace.plugins.remove-confirmation.title"
msgstr "Eliminar extensión"
#: src/app/main/ui/workspace/plugins.cljs:101, src/app/main/ui/workspace/plugins.cljs:188
msgid "workspace.plugins.remove-plugin"
msgstr "Eliminar extensión"
+50 -316
View File
@@ -268,6 +268,7 @@ fn difference(
.iter()
.filter(|s| path_a.contains(to_point(s.evaluate(TValue::Parametric(0.5)))))
.copied()
.map(|s| s.reverse())
.map(|b| (BezierSource::B, b)),
);
@@ -277,53 +278,16 @@ fn difference(
fn exclusion(segments_a: Vec<Bezier>, segments_b: Vec<Bezier>) -> Vec<(BezierSource, Bezier)> {
let mut result = Vec::new();
result.extend(segments_a.iter().copied().map(|b| (BezierSource::A, b)));
result.extend(segments_b.iter().copied().map(|b| (BezierSource::B, b)));
result.extend(
segments_b
.iter()
.copied()
.map(|s| s.reverse())
.map(|b| (BezierSource::B, b)),
);
result
}
// Mirrors `app.common.types.path.subpath/clockwise?`.
fn is_clockwise(path: &Path) -> bool {
let mut points: Vec<(f32, f32)> = Vec::new();
for segment in path.segments().iter() {
match *segment {
Segment::MoveTo(p) => {
if !points.is_empty() {
break;
}
points.push(p);
}
Segment::LineTo(p) => points.push(p),
Segment::CurveTo((_, _, p)) => points.push(p),
Segment::Close => break,
}
}
if points.len() < 3 {
return false;
}
let mut signed_area = 0.0f64;
for i in 0..points.len() {
let (x1, y1) = points[i];
let (x2, y2) = points[(i + 1) % points.len()];
signed_area += f64::from(x1) * f64::from(y2) - f64::from(x2) * f64::from(y1);
}
signed_area > 0.0
}
// The kept pieces of B must point the same way round as the kept pieces of A. Not the
// `path.bool/content-bool-pair` rule, which reverses intersection on same winding and
// relies on `subpath/merge-paths` flipping subpaths when it joins them.
fn should_reverse_b(bool_type: BoolType, a_is_clockwise: bool, path_b: &Path) -> bool {
let same_winding = a_is_clockwise == is_clockwise(path_b);
match bool_type {
BoolType::Union | BoolType::Intersection => !same_winding,
BoolType::Difference | BoolType::Exclusion => same_winding,
}
}
#[derive(Debug, Clone, PartialEq, Copy)]
enum BezierSource {
A,
@@ -341,14 +305,17 @@ fn pop_first_from_pool(pool: &mut BezierPool) -> Option<(BezierSource, Bezier)>
pool.iter_mut().find_map(|e| e.take())
}
// Same-source candidates get a tighter threshold so we stay on the original path. A
// candidate that joins by its `end` points the wrong way, so we reverse it.
// Find and remove the segment whose start point is closest to `end` within the
// appropriate threshold. Same-source segments use a tight threshold
// (INTERSECT_THRESHOLD_SAMEd) so we prefer staying on the same original path;
// cross-source segments use a wider threshold (INTERSECT_THRESHOLD_DIFFERENT)
// to allow switching paths at intersection points.
fn find_next_in_pool(
pool: &mut BezierPool,
end: DVec2,
source: BezierSource,
) -> Option<(BezierSource, Bezier)> {
let mut best: Option<(usize, bool)> = None;
let mut best_idx: Option<usize> = None;
let mut best_dist_sq = f64::MAX;
for (i, entry) in pool.iter().enumerate() {
@@ -360,21 +327,16 @@ fn find_next_in_pool(
} else {
INTERSECT_THRESHOLD_DIFFERENT as f64
};
for (reversed, point) in [(false, bezier.start), (true, bezier.end)] {
let dx = point.x - end.x;
let dy = point.y - end.y;
let dist_sq = dx * dx + dy * dy;
if dist_sq <= threshold * threshold && dist_sq < best_dist_sq {
best_dist_sq = dist_sq;
best = Some((i, reversed));
}
let dx = bezier.start.x - end.x;
let dy = bezier.start.y - end.y;
let dist_sq = dx * dx + dy * dy;
if dist_sq <= threshold * threshold && dist_sq < best_dist_sq {
best_dist_sq = dist_sq;
best_idx = Some(i);
}
}
let (idx, reversed) = best?;
pool[idx]
.take()
.map(|(src, bezier)| (src, if reversed { bezier.reverse() } else { bezier }))
best_idx.and_then(|i| pool[i].take())
}
fn push_bezier(result: &mut Vec<Segment>, bezier: &Bezier) {
@@ -448,45 +410,32 @@ fn beziers_to_segments(beziers: &[(BezierSource, Bezier)]) -> Vec<Segment> {
result
}
fn bool_beziers(
bool_type: BoolType,
path_a: &Path,
a_is_clockwise: bool,
path_b: &Path,
) -> (Vec<(BezierSource, Bezier)>, bool) {
let (segs_a, mut segs_b) = split_segments(path_a, path_b);
if should_reverse_b(bool_type, a_is_clockwise, path_b) {
for segment in segs_b.iter_mut() {
*segment = segment.reverse();
}
pub fn bool_from_shapes(bool_type: BoolType, children_ids: &[Uuid], shapes: ShapesPoolRef) -> Path {
if children_ids.is_empty() {
return Path::default();
}
let beziers = match bool_type {
BoolType::Union => union(path_a, segs_a, path_b, segs_b),
BoolType::Difference => difference(path_a, segs_a, path_b, segs_b),
BoolType::Intersection => intersection(path_a, segs_a, path_b, segs_b),
BoolType::Exclusion => exclusion(segs_a, segs_b),
};
(beziers, path_a.is_even_odd() || path_b.is_even_odd())
}
// Fold `paths` left to right; the first entry is the base operand.
fn bool_fold(bool_type: BoolType, paths: &[Path]) -> Path {
let Some((first, rest)) = paths.split_first() else {
let Some(child) = shapes.get(&children_ids[children_ids.len() - 1]) else {
return Path::default();
};
let mut current_path = first.clone();
// Every fold step chains A's fragments, which keep their direction, so the
// accumulated path keeps this winding. Carry it instead of re-reading it from the
// emitted segment list, whose subpath order and direction fall out of pool ordering.
let is_clockwise_a = is_clockwise(&current_path);
let mut current_path = child.to_path(shapes);
for other_path in rest {
let (beziers, is_even_odd) =
bool_beziers(bool_type, &current_path, is_clockwise_a, other_path);
for idx in (0..children_ids.len() - 1).rev() {
let Some(other) = shapes.get(&children_ids[idx]) else {
continue;
};
let other_path = other.to_path(shapes);
let (segs_a, segs_b) = split_segments(&current_path, &other_path);
let is_even_odd = current_path.is_even_odd() || other_path.is_even_odd();
let beziers = match bool_type {
BoolType::Union => union(&current_path, segs_a, &other_path, segs_b),
BoolType::Difference => difference(&current_path, segs_a, &other_path, segs_b),
BoolType::Intersection => intersection(&current_path, segs_a, &other_path, segs_b),
BoolType::Exclusion => exclusion(segs_a, segs_b),
};
current_path = Path::new(beziers_to_segments(&beziers)).with_even_odd(is_even_odd);
}
@@ -494,16 +443,6 @@ fn bool_fold(bool_type: BoolType, paths: &[Path]) -> Path {
current_path
}
pub fn bool_from_shapes(bool_type: BoolType, children_ids: &[Uuid], shapes: ShapesPoolRef) -> Path {
let paths: Vec<Path> = children_ids
.iter()
.rev()
.filter_map(|id| shapes.get(id).map(|child| child.to_path(shapes)))
.collect();
bool_fold(bool_type, &paths)
}
pub fn update_bool_to_path(shape: &mut Shape, shapes: ShapesPoolRef) {
let children_ids = shape.children_ids(true);
@@ -542,7 +481,6 @@ pub fn debug_render_bool_paths(
};
let mut current_path = child.to_path(shapes);
let is_clockwise_a = is_clockwise(&current_path);
for idx in (0..children_ids.len() - 1).rev() {
let Some(other) = shapes.get(&children_ids[idx]) else {
@@ -550,12 +488,15 @@ pub fn debug_render_bool_paths(
};
let other_path = other.to_path(shapes);
let (beziers, is_even_odd) = bool_beziers(
bool_data.bool_type,
&current_path,
is_clockwise_a,
&other_path,
);
let (segs_a, segs_b) = split_segments(&current_path, &other_path);
let is_even_odd = current_path.is_even_odd() || other_path.is_even_odd();
let beziers = match bool_data.bool_type {
BoolType::Union => union(&current_path, segs_a, &other_path, segs_b),
BoolType::Difference => difference(&current_path, segs_a, &other_path, segs_b),
BoolType::Intersection => intersection(&current_path, segs_a, &other_path, segs_b),
BoolType::Exclusion => exclusion(segs_a, segs_b),
};
current_path = Path::new(beziers_to_segments(&beziers)).with_even_odd(is_even_odd);
if idx == 0 {
@@ -631,210 +572,3 @@ pub fn debug_render_bool_paths(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn linear(from: (f64, f64), to: (f64, f64)) -> Bezier {
Bezier::from_linear_coordinates(from.0, from.1, to.0, to.1)
}
fn polygon(points: &[(f32, f32)]) -> Path {
let mut segments = vec![Segment::MoveTo(points[0])];
segments.extend(points[1..].iter().map(|p| Segment::LineTo(*p)));
segments.push(Segment::Close);
Path::new(segments)
}
fn count(segments: &[Segment], f: fn(&Segment) -> bool) -> usize {
segments.iter().filter(|s| f(s)).count()
}
fn is_move_to(s: &Segment) -> bool {
matches!(s, Segment::MoveTo(_))
}
fn is_close(s: &Segment) -> bool {
matches!(s, Segment::Close)
}
fn ring_area(ring: &[(f32, f32)]) -> f64 {
let mut area = 0.0f64;
for i in 0..ring.len() {
let (x1, y1) = ring[i];
let (x2, y2) = ring[(i + 1) % ring.len()];
area += f64::from(x1) * f64::from(y2) - f64::from(x2) * f64::from(y1);
}
area / 2.0
}
fn signed_area(segments: &[Segment]) -> f64 {
let mut total = 0.0f64;
let mut ring: Vec<(f32, f32)> = Vec::new();
for segment in segments {
match *segment {
Segment::MoveTo(p) => {
total += ring_area(&ring);
ring.clear();
ring.push(p);
}
Segment::LineTo(p) | Segment::CurveTo((_, _, p)) => ring.push(p),
Segment::Close => {
total += ring_area(&ring);
ring.clear();
}
}
}
total + ring_area(&ring)
}
// Operands and expected results taken from the CLJS bool (`app.common.types.path.bool`)
// run on the same shapes: A clockwise, B and C counter-clockwise, all overlapping.
const A_CW: [(f32, f32); 4] = [
(100.0, 100.0),
(300.0, 100.0),
(300.0, 300.0),
(100.0, 300.0),
];
const B_CCW: [(f32, f32); 4] = [
(200.0, 200.0),
(200.0, 400.0),
(400.0, 400.0),
(400.0, 200.0),
];
const C_CCW: [(f32, f32); 4] = [(60.0, 240.0), (60.0, 360.0), (260.0, 360.0), (260.0, 240.0)];
#[test]
fn test_is_clockwise() {
let cw = Path::new(vec![
Segment::MoveTo((0.0, 0.0)),
Segment::LineTo((10.0, 0.0)),
Segment::LineTo((10.0, 10.0)),
Segment::LineTo((0.0, 10.0)),
Segment::Close,
]);
assert!(is_clockwise(&cw));
let ccw = Path::new(vec![
Segment::MoveTo((0.0, 0.0)),
Segment::LineTo((0.0, 10.0)),
Segment::LineTo((10.0, 10.0)),
Segment::LineTo((10.0, 0.0)),
Segment::Close,
]);
assert!(!is_clockwise(&ccw));
}
#[test]
fn test_should_reverse_b_only_depends_on_relative_winding() {
let cw = Path::new(vec![
Segment::MoveTo((0.0, 0.0)),
Segment::LineTo((10.0, 0.0)),
Segment::LineTo((10.0, 10.0)),
Segment::LineTo((0.0, 10.0)),
Segment::Close,
]);
let ccw = Path::new(vec![
Segment::MoveTo((0.0, 0.0)),
Segment::LineTo((0.0, 10.0)),
Segment::LineTo((10.0, 10.0)),
Segment::LineTo((10.0, 0.0)),
Segment::Close,
]);
assert!(should_reverse_b(BoolType::Difference, true, &cw));
assert!(!should_reverse_b(BoolType::Difference, true, &ccw));
assert!(!should_reverse_b(BoolType::Union, true, &cw));
assert!(should_reverse_b(BoolType::Union, true, &ccw));
}
// Fragments from #11482: two point the wrong way, so joining them start-to-start only
// left five open subpaths.
#[test]
fn test_beziers_to_segments_closes_reversed_fragments() {
let beziers = vec![
(
BezierSource::A,
linear((2764.00, -240.00), (2834.74, -110.71)),
),
(
BezierSource::A,
linear((2809.29, -85.26), (2693.26, -201.29)),
),
(
BezierSource::A,
linear((2718.71, -226.74), (2764.00, -240.00)),
),
(
BezierSource::B,
linear((2718.71, -226.74), (2834.74, -110.71)),
),
(
BezierSource::B,
linear((2809.29, -85.26), (2693.26, -201.29)),
),
];
let segments = beziers_to_segments(&beziers);
let moves = segments
.iter()
.filter(|s| matches!(s, Segment::MoveTo(_)))
.count();
let closes = segments
.iter()
.filter(|s| matches!(s, Segment::Close))
.count();
assert_eq!(moves, 2);
assert_eq!(closes, 2);
// 3 fragments in the first subpath, 2 in the second, each dropping its closing LineTo.
assert_eq!(segments.len(), 7);
}
// #11482: A clockwise, B counter-clockwise. Reference (CLJS):
// M100,100 L300,100 L300,200 L200,200 L200,300 L100,300 Z
#[test]
fn test_difference_with_opposite_winding_operand() {
let result = bool_fold(BoolType::Difference, &[polygon(&A_CW), polygon(&B_CCW)]);
let segments = result.segments();
assert_eq!(count(segments, is_move_to), 1);
assert_eq!(count(segments, is_close), 1);
assert!((signed_area(segments) - 30000.0).abs() < 1.0);
assert!(is_clockwise(&result));
}
// Reference (CLJS):
// M100,100 L300,100 L300,200 L400,200 L400,400 L200,400 L200,300 L100,300 Z
#[test]
fn test_union_with_opposite_winding_operand() {
let result = bool_fold(BoolType::Union, &[polygon(&A_CW), polygon(&B_CCW)]);
let segments = result.segments();
assert_eq!(count(segments, is_move_to), 1);
assert_eq!(count(segments, is_close), 1);
assert!((signed_area(segments) - 70000.0).abs() < 1.0);
assert!(is_clockwise(&result));
}
// The second fold step must compare against A's winding, not against the winding of
// the intermediate path, whose subpath order and direction fall out of pool ordering.
// Reference (CLJS): M100,100 L300,100 L300,200 L200,200 L200,240 L100,240 Z
#[test]
fn test_difference_folds_three_opposite_winding_operands() {
let result = bool_fold(
BoolType::Difference,
&[polygon(&A_CW), polygon(&B_CCW), polygon(&C_CCW)],
);
let segments = result.segments();
assert_eq!(count(segments, is_move_to), 1);
assert_eq!(count(segments, is_close), 1);
assert!((signed_area(segments) - 24000.0).abs() < 1.0);
assert!(is_clockwise(&result));
}
}
+21 -3
View File
@@ -3,7 +3,8 @@ use skia_safe::{self as skia, Paint, RRect};
use super::{filters, RenderState, SurfaceId};
use crate::error::Result;
use crate::get_resources;
use crate::render::{get_image_dest_rect, get_source_rect};
use crate::math::Rect as MathRect;
use crate::render::get_source_rect;
use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type};
// Set the clipping area to the shape outline within the container bounds
@@ -93,7 +94,15 @@ fn draw_image_fill(
let container = &shape.selrect;
let sampling = get_resources().sampling_options;
let dest_rect = get_image_dest_rect(container, image_fill);
let dest_rect = match image_fill.transform() {
Some(tf) => MathRect::from_xywh(
container.left + tf.x * container.width(),
container.top + tf.y * container.height(),
tf.width * container.width(),
tf.height * container.height(),
),
None => *container,
};
let src_rect = get_source_rect(size, &dest_rect, image_fill);
let needs_clip = image_fill.transform().is_some() || !is_axis_aligned_image_rect(shape);
@@ -180,7 +189,16 @@ fn draw_svg_image_fill(
let fill_layer = skia::canvas::SaveLayerRec::default().paint(paint);
canvas.save_layer(&fill_layer);
let dest_rect = get_image_dest_rect(container, image_fill);
let dest_rect = match image_fill.transform() {
Some(tf) => MathRect::from_xywh(
container.left + tf.x * container.width(),
container.top + tf.y * container.height(),
tf.width * container.width(),
tf.height * container.height(),
),
None => *container,
};
let src_rect = get_source_rect(size, &dest_rect, image_fill);
if src_rect.width() <= 0.0 || src_rect.height() <= 0.0 {
canvas.restore();
-12
View File
@@ -20,18 +20,6 @@ pub fn get_dest_rect(container: &MathRect, delta: f32) -> MathRect {
)
}
pub fn get_image_dest_rect(container: &MathRect, image_fill: &ImageFill) -> MathRect {
match image_fill.transform() {
Some(tf) => MathRect::from_xywh(
container.left + tf.x * container.width(),
container.top + tf.y * container.height(),
tf.width * container.width(),
tf.height * container.height(),
),
None => *container,
}
}
pub fn get_source_rect(size: ISize, container: &MathRect, image_fill: &ImageFill) -> MathRect {
let image_width = size.width as f32;
let image_height = size.height as f32;
+8 -10
View File
@@ -5,8 +5,7 @@ use crate::shapes::{Fill, ImageFill, Shape};
use crate::state::ShapesPoolRef;
use super::document::SvgLayerCanvas;
use crate::math::Rect as MathRect;
use crate::render::{get_image_dest_rect, RenderResources};
use crate::render::RenderResources;
/// Emits fills bottom -> top for SVG export.
///
@@ -64,20 +63,19 @@ fn emit_image_fill(
let clip_id = builder.unique("imgclip");
builder.push_clip_path(&clip_id, shape, tree);
let href = xml_escape_attr(url);
let dest_rect = get_image_dest_rect(&shape.selrect(), image_fill);
emit_linked_image_element(builder, shape, image_fill, dest_rect, &href, &clip_id);
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
Ok(())
}
/// Emits `<g clip-path>` + `<image href>` at `dest_rect`, under the page CTM.
/// Emits `<g clip-path>` + `<image href>` using the shape selrect and page CTM.
pub(super) fn emit_linked_image_element(
builder: &mut SvgLayerCanvas,
shape: &Shape,
image_fill: &ImageFill,
dest_rect: MathRect,
href: &str,
clip_id: &str,
) {
let selrect = shape.selrect();
let opacity = image_fill.opacity() as f32 / 255.0;
let preserve = if image_fill.keep_aspect_ratio() {
"xMidYMid slice"
@@ -95,10 +93,10 @@ pub(super) fn emit_linked_image_element(
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
builder.push_raw(&format!(
r#"<image href="{href}" x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="{preserve}"{opacity_attr} transform="{transform}"/>"#,
dest_rect.left(),
dest_rect.top(),
dest_rect.width(),
dest_rect.height(),
selrect.left(),
selrect.top(),
selrect.width(),
selrect.height(),
));
builder.close_group();
}
+1 -40
View File
@@ -1,8 +1,6 @@
use super::fixtures::*;
use crate::shapes::{
BlendMode, Fill, ImageFill, ImageFillTransform, SolidColor, StrokeCap, StrokeKind,
};
use crate::shapes::{BlendMode, Fill, ImageFill, SolidColor, StrokeCap, StrokeKind};
use crate::state::ShapesPool;
use crate::uuid::Uuid;
@@ -1078,43 +1076,6 @@ fn exports_image_fill_as_linked_image() {
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_bounds_transform() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_rect_with_fills(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
vec![Fill::Image(ImageFill::new_with_transform(
image_id,
255,
200,
100,
false,
Some(ImageFillTransform {
x: 0.25,
y: 0.5,
width: 0.5,
height: 0.25,
}),
))],
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains(r#"x="25" y="40" width="50" height="20""#),
"linked image must keep the independent image bounds: {svg}"
);
}
#[test]
fn exports_mixed_solid_and_image_fills_in_order() {
// Image under a translucent solid; stretch (keep-aspect off); partial image
+1 -1
View File
@@ -117,6 +117,6 @@ fn emit_text_image_fill(
builder.finish_clip_path_fragment(&clip_id, canvas);
let href = xml_escape_attr(url);
emit_linked_image_element(builder, shape, image_fill, shape.selrect(), &href, &clip_id);
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
Ok(())
}
+3 -3
View File
@@ -12,7 +12,7 @@ use super::shape_renderer::ShapeRenderer;
use super::text;
use super::RenderResources;
use super::RenderState;
use super::{get_dest_rect, get_image_dest_rect, get_source_rect};
use super::{get_dest_rect, get_source_rect};
// ---------------------------------------------------------------------------
// VectorRenderer — implements ShapeRenderer for canvas-based vector export
@@ -1043,8 +1043,8 @@ fn draw_image_fill(
let size = image.dimensions();
let container = &shape.selrect;
let dest_rect = get_image_dest_rect(container, image_fill);
let src_rect = get_source_rect(size, &dest_rect, image_fill);
let src_rect = get_source_rect(size, container, image_fill);
let dest_rect = container;
canvas.save();
+3 -5
View File
@@ -173,11 +173,9 @@ main() {
fi
done
# Get tracked files matching our extensions, excluding this script itself
# (it mentions the search string in its own usage docs).
local files script_name
script_name=$(basename "$0")
files=$(git ls-files | grep -E "\.(${EXTENSIONS})$" | grep -v -F "$script_name" || true)
# Get tracked files matching our extensions
local files
files=$(git ls-files | grep -E "\.(${EXTENSIONS})$" || true)
if [[ -z "$files" ]]; then
log_warn "No tracked files found matching extensions: ${EXTENSIONS}"