Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-05-10 09:20:27 +02:00
commit 60c718eba1
75 changed files with 2388 additions and 873 deletions

View File

@ -20,6 +20,9 @@ everything they need to know: which files to touch for each task, code, testing,
docs they might need to check, how to test it. Give them the whole plan as
bite-sized tasks. DRY. YAGNI. TDD. Frequent commits.
Do **not** suggest commit messages or commit names anywhere in your plans or
responses — committing is the developer's responsibility.
Assume they are a skilled developer, but know almost nothing about our toolset
or problem domain. Assume they don't know good test design very well.

View File

@ -146,18 +146,26 @@
### :sparkles: New features & Enhancements
- Access Tokens look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
- Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112)
- Add MCP server integration [Github #9174](https://github.com/penpot/penpot/issues/9174)
- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #8909](https://github.com/penpot/penpot/pull/8909)
- Improve team name validation [Github #9176](https://github.com/penpot/penpot/pull/9176)
### :bug: Bugs fixed
- Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296)
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
- Fix Plugin API token methods rejecting JS array of strings [Github #9162](https://github.com/penpot/penpot/issues/9162)
- Harden Nginx responses with standard security headers and hide upstream `X-Powered-By` headers
- Fix keep-alive interval leak in PluginBridge (by @opcode81) [Github #9435](https://github.com/penpot/penpot/pull/9435)
- Fix MCP "active in another tab" notification not clearing (by @Dexterity104) [Github #9321](https://github.com/penpot/penpot/pull/9321)
- Fix swapped analytics event names on MCP tab-switch dialog (by @Dexterity104) [Github #9322](https://github.com/penpot/penpot/pull/9322)
- Fix MCP ReplServer binding to all interfaces (0.0.0.0) instead of localhost, allowing unauthenticated RCE [Github #9400] (https://github.com/penpot/penpot/pull/9400)
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
- Fix false “text editing” warning when applying tokens [Github #6346](https://github.com/penpot/penpot/issues/9346)
- Fix SSRF in media URL import and restrict unauthenticated asset access to public buckets only [Github #9390](https://github.com/penpot/penpot/pull/9390)
- Fix text edition mode not exited when changing selection, blocking token application [Github #9346](https://github.com/penpot/penpot/issues/9346)
- Use base64 envelope for Uint8Array task results to avoid JSON expansion (by @opcode81) [Github #9431](https://github.com/penpot/penpot/pull/9431)
- Fix empty warning on login [Github #9056](https://github.com/penpot/penpot/pull/9056)
- Fix layer hierarchy to match old and new SCSS [Github #9126](https://github.com/penpot/penpot/pull/9126)
- Fix multiple selection on shapes with token applied to stroke color [Github #9110](https://github.com/penpot/penpot/pull/9110)
- Fix onboarding modals appearing behind libraries and templates panel [Github #9178](https://github.com/penpot/penpot/pull/9178)
## 2.14.5
@ -174,6 +182,7 @@
- Fix email blacklisting [Github #9122](https://github.com/penpot/penpot/pull/9122)
- Fix removeChild errors from unmount race conditions [Github #8927](https://github.com/penpot/penpot/pull/8927)
## 2.14.3
### :sparkles: New features & Enhancements
@ -183,7 +192,6 @@
### :bug: Bugs fixed
- Fix component "broken" after switch variant [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984)
- Fix variants corner cases with selrect and points [Github #8882](https://github.com/penpot/penpot/pull/8882)
- Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962)
- Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961)
@ -204,6 +212,7 @@
- Fix typo `:podition` in swap-shapes grid cell
- Fix multiple selection on shapes with token applied to stroke color
## 2.14.2
### :sparkles: New features & Enhancements
@ -264,8 +273,6 @@
- Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017)
- Remove tokens path node and bulk remove tokens [Taiga #13007](https://tree.taiga.io/project/penpot/us/13007)
- Replace themes management modal radio buttons for switches [Taiga #9215](https://tree.taiga.io/project/penpot/us/9215)
- [MCP server] Integrations section [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112)
- [Access Tokens] Look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
### :bug: Bugs fixed
@ -274,17 +281,13 @@
- Fix wrong image in the onboarding invitation block [Taiga #13040](https://tree.taiga.io/project/penpot/issue/13040)
- Fix wrong register image [Taiga #12955](https://tree.taiga.io/project/penpot/task/12955)
- Fix error message on components doesn't close automatically [Taiga #12012](https://tree.taiga.io/project/penpot/issue/12012)
- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113)
- Fix incorrect default option on tokens import dialog [Github #8051](https://github.com/penpot/penpot/pull/8051)
- Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110)
- Fix displaying a hidden user avatar when there is only one more [Taiga #13058](https://tree.taiga.io/project/penpot/issue/13058)
- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787)
- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135)
- Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174)
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
- Fix unable to finish the create account form using keyboard [Taiga #11333](https://tree.taiga.io/project/penpot/issue/11333)
- Fix 45 rotated board titles rendered incorrectly [Taiga #13306](https://tree.taiga.io/project/penpot/issue/13306)
- Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513)
- Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528)
- Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984)
@ -319,8 +322,6 @@
### :heart: Community contributions (Thank you!)
- Add 'page' special shapeId to MCP export_shape tool for full-page snapshots [Github #8689](https://github.com/penpot/penpot/issues/8689)
- Fix mask issues with component swap (by @dfelinto) [Github #7675](https://github.com/penpot/penpot/issues/7675)
### :sparkles: New features & Enhancements
@ -343,12 +344,8 @@
- Fix missing text color token from selected shapes in selected colors list [Taiga #12956](https://tree.taiga.io/project/penpot/issue/12956)
- Fix dropdown option width in Guides columns dropdown [Taiga #12959](https://tree.taiga.io/project/penpot/issue/12959)
- Fix typos on download modal [Taiga #12865](https://tree.taiga.io/project/penpot/issue/12865)
- Fix problem with text editor maintaining previous styles [Taiga #12835](https://tree.taiga.io/project/penpot/issue/12835)
- Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110)
- Fix allow negative spread values on shadow token creation [Taiga #13167](https://tree.taiga.io/project/penpot/issue/13167)
- Fix spanish translations on import export token modal [Taiga #13171](https://tree.taiga.io/project/penpot/issue/13171)
- Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133)
- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135)
- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787)
- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113)
- Fix several race conditions on path editor [Github #8187](https://github.com/penpot/penpot/pull/8187)
@ -441,10 +438,8 @@ example. It's still usable as before, we just removed the example.
### :bug: Bugs fixed
- Fix text line-height values are wrong [Taiga #12252](https://tree.taiga.io/project/penpot/issue/12252)
- Fix an error translation [Taiga #12402](https://tree.taiga.io/project/penpot/issue/12402)
- Fix pan cursor not disabling viewport guides [Github #6985](https://github.com/penpot/penpot/issues/6985)
- Fix viewport resize on locked shapes [Taiga #11974](https://tree.taiga.io/project/penpot/issue/11974)
- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299)
- Fix on copy instance inside a components chain touched are missing [Taiga #12371](https://tree.taiga.io/project/penpot/issue/12371)
- Fix problem with multiple selection and shadows [Github #7437](https://github.com/penpot/penpot/issues/7437)
- Fix search shortcut [Taiga #10265](https://tree.taiga.io/project/penpot/issue/10265)
@ -525,7 +520,7 @@ example. It's still usable as before, we just removed the example.
- Fix text override is lost after switch [Taiga #12269](https://tree.taiga.io/project/penpot/issue/12269)
- Fix exporting a board crashing the app [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12384)
- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299)
- Fix selected colors not showing colors from children shapes in multiple selection [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12385)
- Fix selected colors not showing colors from children shapes in multiple selection [Taiga #12385](https://tree.taiga.io/project/penpot/issue/12385)
- Fix scrollbar issue in design tab [Taiga #12367](https://tree.taiga.io/project/penpot/issue/12367)
- Fix library update notificacions showing when they should not [Taiga #12397](https://tree.taiga.io/project/penpot/issue/12397)
- Fix remove flex button doesnt work within variant [Taiga #12314](https://tree.taiga.io/project/penpot/issue/12314)

View File

@ -43,7 +43,7 @@
(defn- discover-oidc-config
[cfg {:keys [base-uri] :as provider}]
(let [uri (u/join base-uri ".well-known/openid-configuration")
rsp (http/req! cfg {:method :get :uri (dm/str uri)})]
rsp (http/req cfg {:method :get :uri (dm/str uri)})]
(if (= 200 (:status rsp))
(let [data (-> rsp :body json/decode)
@ -105,7 +105,7 @@
(defn- fetch-oidc-jwks
[cfg jwks-uri]
(let [{:keys [status body]} (http/req! cfg {:method :get :uri jwks-uri})]
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri})]
(if (= 200 status)
(-> body json/decode :keys process-oidc-jwks)
(ex/raise :type ::internal
@ -235,7 +235,7 @@
:timeout 6000
:method :get}
{:keys [status body]} (http/req! cfg params)]
{:keys [status body]} (http/req cfg params)]
(when-not (int-in-range? status 200 300)
(ex/raise :type :internal
@ -453,7 +453,7 @@
:grant-type (:grant_type params)
:redirect-uri (:redirect_uri params))
(let [{:keys [status body]} (http/req! cfg req)]
(let [{:keys [status body]} (http/req cfg req)]
(if (= status 200)
(let [data (json/decode body)
data {:token/access (get data :access_token)
@ -508,7 +508,7 @@
:headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))}
:timeout 6000
:method :get}
response (http/req! cfg params)]
response (http/req cfg params)]
(l/trc :hint "user info response"
:status (:status response)

View File

@ -85,7 +85,11 @@
:email-verify-threshold "15m"
:quotes-upload-sessions-per-profile 5
:quotes-upload-chunks-per-session 20})
:quotes-upload-chunks-per-session 20
;; SSRF protection
:ssrf-allowed-hosts #{}
:ssrf-extra-blocked-cidrs #{}})
(def schema:config
(do #_sm/optional-keys
@ -245,7 +249,11 @@
[:objects-storage-fs-directory {:optional true} :string]
[:objects-storage-s3-bucket {:optional true} :string]
[:objects-storage-s3-region {:optional true} :keyword]
[:objects-storage-s3-endpoint {:optional true} ::sm/uri]]))
[:objects-storage-s3-endpoint {:optional true} ::sm/uri]
;; SSRF protection
[:ssrf-allowed-hosts {:optional true} [::sm/set :string]]
[:ssrf-extra-blocked-cidrs {:optional true} [::sm/set :string]]]))
(defn- parse-flags
[config]

View File

@ -12,43 +12,56 @@
[app.common.time :as ct]
[app.common.uri :as u]
[app.db :as db]
[app.http.session :as session]
[app.storage :as sto]
[integrant.core :as ig]
[yetti.response :as-alias yres]))
(def ^:private cache-max-age
(def ^:private default-cache-max-age
(ct/duration {:hours 24}))
(def ^:private signature-max-age
(def ^:private default-signature-max-age
(ct/duration {:hours 24 :minutes 15}))
;; Buckets that are legitimately public and do not require authentication.
;; These are used by public shared board viewing, profile photos in UI,
;; and embedded export/binfile flows.
(def ^:private public-buckets
#{"file-media-object"
"file-object-thumbnail"
"team-font-variant"
"file-data-fragment"})
(defn get-id
[{:keys [path-params]}]
(or (some-> path-params :id d/parse-uuid)
(ex/raise :type :not-found
:hunt "object not found")))
:hint "object not found")))
(defn- get-file-media-object
[pool id]
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
(defn- serve-object-from-s3
[{:keys [::sto/storage] :as cfg} obj]
(let [{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age signature-max-age})]
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
(let [sig-max-age (or signature-max-age default-signature-max-age)
cch-max-age (or cache-max-age default-cache-max-age)
{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})]
{::yres/status 307
::yres/headers {"location" (str url)
"x-host" (cond-> host port (str ":" port))
"x-mtype" (-> obj meta :content-type)
"cache-control" (str "max-age=" (inst-ms cache-max-age))}}))
"cache-control" (str "max-age=" (inst-ms cch-max-age))}}))
(defn- serve-object-from-fs
[{:keys [::path]} obj]
(let [purl (u/join (u/uri path)
[{:keys [::path ::cache-max-age]} obj]
(let [cch-max-age (or cache-max-age default-cache-max-age)
purl (u/join (u/uri path)
(sto/object->relative-path obj))
mdata (meta obj)
headers {"x-accel-redirect" (:path purl)
"content-type" (:content-type mdata)
"cache-control" (str "max-age=" (inst-ms cache-max-age))}]
"cache-control" (str "max-age=" (inst-ms cch-max-age))}]
{::yres/status 204
::yres/headers headers}))
@ -60,14 +73,28 @@
(:s3 :assets-s3) (serve-object-from-s3 cfg obj)
(:fs :assets-fs) (serve-object-from-fs cfg obj)))
(defn- requires-auth?
"Check if the storage object requires authentication based on its bucket."
[obj]
(let [bucket (-> obj meta :bucket)]
(not (contains? public-buckets bucket))))
(defn objects-handler
"Handler that servers storage objects by id."
"Handler that serves storage objects by id.
For non-public buckets (e.g. profile), requires an authenticated session."
[{:keys [::sto/storage] :as cfg} request]
(let [id (get-id request)
obj (sto/get-object storage id)]
(if obj
(serve-object cfg obj)
{::yres/status 404})))
(cond
(nil? obj)
{::yres/status 404}
(and (requires-auth? obj)
(nil? (::session/profile-id request)))
{::yres/status 401}
:else
(serve-object cfg obj))))
(defn- generic-handler
"A generic handler helper/common code for file-media based handlers."
@ -96,11 +123,12 @@
(defmethod ig/assert-key ::routes
[_ params]
(assert (sto/valid-storage? (::sto/storage params)) "expected valid storage instance")
(assert (session/manager? (::session/manager params)) "expected valid session manager")
(assert (string? (::path params))))
(defmethod ig/init-key ::routes
[_ cfg]
["/assets"
["/assets" {:middleware [[session/authz cfg]]}
["/by-id/:id" {:handler (partial objects-handler cfg)}]
["/by-file-media-id/:id" {:handler (partial file-objects-handler cfg)}]
["/by-file-media-id/:id/thumbnail" {:handler (partial file-thumbnails-handler cfg)}]])

View File

@ -53,7 +53,7 @@
(let [surl (get body "SubscribeURL")
stopic (get body "TopicArn")]
(l/info :action "subscription received" :topic stopic :url surl)
(http/req! cfg {:uri surl :method :post :timeout 10000} {:sync? true}))
(http/req cfg {:uri surl :method :post :timeout 10000} {:sync? true}))
(= mtype "Notification")
(when-let [message (parse-json (get body "Message"))]

View File

@ -5,13 +5,24 @@
;; Copyright (c) KALEIDOS INC
(ns app.http.client
"Http client abstraction layer."
"Http client abstraction layer.
All outbound requests made through `req` and `req-with-redirects`
are validated against the SSRF blocklist by default. Pass
`:skip-ssrf-check? true` in the options map only when the target
is a well-known, operator-configured endpoint that cannot be
influenced by user input (e.g. internal telemetry, error webhooks)."
(:require
[app.common.schema :as sm]
[app.util.ssrf :as ssrf]
[cuerdas.core :as str]
[integrant.core :as ig]
[java-http-clj.core :as http])
(:import
java.net.http.HttpClient))
java.net.http.HttpClient
java.net.URI))
(def default-max-redirects 5)
(defn client?
[o]
@ -23,8 +34,8 @@
(defmethod ig/init-key ::client
[_ _]
(http/build-client {:connect-timeout 30000 ;; 10s
:follow-redirects :always}))
(http/build-client {:connect-timeout 30000
:follow-redirects :never}))
(defn send!
([client req] (send! client req {}))
@ -44,14 +55,82 @@
:else
(throw (UnsupportedOperationException. "invalid arguments"))))
(defn req!
"A convencience toplevel function for gradual migration to a new API
convention."
(defn req
"Issue a single HTTP request. SSRF validation is applied to the
target URI by default; pass `:skip-ssrf-check? true` in `options`
to bypass it for known-safe, operator-configured endpoints."
([cfg-or-client request]
(let [client (resolve-client cfg-or-client)
request (update request :uri str)]
(send! client request {})))
([cfg-or-client request options]
(let [client (resolve-client cfg-or-client)
request (update request :uri str)]
(send! client request options))))
(req cfg-or-client request {}))
([cfg-or-client request {:keys [skip-ssrf-check?] :as options}]
(let [request (if skip-ssrf-check?
(update request :uri str)
(update request :uri ssrf/validate-uri))
client (resolve-client cfg-or-client)]
(send! client request (dissoc options :skip-ssrf-check?)))))
(defn- resolve-location
"Resolve a Location header value against the original request URI.
Handles:
- Absolute URLs (http:// or https://) returned as-is.
- Protocol-relative URLs (//host/path) inherit the scheme from base-uri.
- Path-absolute and relative URLs resolved against base-uri via URI.resolve."
[^String base-uri ^String location]
(cond
(or (str/starts-with? location "http://")
(str/starts-with? location "https://"))
location
(str/starts-with? location "//")
(let [scheme (.getScheme (URI. base-uri))]
(str scheme ":" location))
:else
(str (.resolve (URI. base-uri) location))))
(defn- redirect-request
"Build the next request for a 3xx redirect.
Per RFC 7231 §6.4:
- 303 always issues GET (body dropped).
- 301/302 with non-GET/HEAD methods: downgrade to GET (body dropped).
- 307/308 preserve the original method and body.
The Location URI has already been resolved by the caller."
[orig-request ^String next-uri status]
(let [method (:method orig-request)]
(if (or (= status 303)
(and (contains? #{301 302} status)
(not (contains? #{:get :head} method))))
;; Downgrade to GET, drop body and content-type
(-> orig-request
(assoc :uri next-uri :method :get)
(dissoc :body)
(update :headers dissoc "content-type" "content-length"))
;; Preserve method/body (307, 308, or GET/HEAD 301/302)
(assoc orig-request :uri next-uri))))
(defn req-with-redirects
"Like `req`, but follows up to `max-redirects` HTTP 3xx redirects.
SSRF validation is applied before every hop (initial request and
each redirect target) unless `:skip-ssrf-check? true` is passed.
Redirect semantics follow RFC 7231 §6.4: 301/302 POST is downgraded
to GET; 303 always uses GET; 307/308 preserve the original method."
([cfg-or-client request]
(req-with-redirects cfg-or-client request {}))
([cfg-or-client request {:keys [max-redirects skip-ssrf-check?]
:or {max-redirects default-max-redirects}
:as opts}]
(let [send-opts (dissoc opts :max-redirects :skip-ssrf-check?)
uri-coerce (if skip-ssrf-check? str ssrf/validate-uri)]
(loop [current-req (update request :uri uri-coerce)
hops 0]
(let [client (resolve-client cfg-or-client)
resp (send! client current-req send-opts)
status (:status resp)]
(if (and (<= 300 status 399)
(< hops max-redirects))
(if-let [location (get-in resp [:headers "location"])]
(let [next-uri (resolve-location (str (:uri current-req)) location)]
(recur (update (redirect-request current-req next-uri status) :uri uri-coerce)
(inc hops)))
;; No Location header on a 3xx — return the response as-is
resp)
resp))))))

View File

@ -59,7 +59,7 @@
:method :post
:headers headers
:body body}
resp (http/req! cfg params)]
resp (http/req cfg params)]
(if (= (:status resp) 204)
true

View File

@ -52,12 +52,12 @@
trace
"```")))
resp (http/req! cfg
{:uri (cf/get :error-report-webhook)
:method :post
:headers {"content-type" "application/json"}
:body (json/encode-str {:text text})}
{:sync? true})]
resp (http/req cfg
{:uri (cf/get :error-report-webhook)
:method :post
:headers {"content-type" "application/json"}
:body (json/encode-str {:text text})}
{:sync? true})]
(when (not= 200 (:status resp))
(l/warn :hint "error on sending data"

View File

@ -159,7 +159,7 @@
:method :post
:body body}]
(try
(let [rsp (http/req! cfg req {:response-type :input-stream :sync? true})
(let [rsp (http/req cfg req {:response-type :input-stream :sync? true})
err (interpret-response rsp)]
(report-delivery! whook req rsp err)
(update-webhook! whook err))
@ -190,4 +190,11 @@
"invalid-uri"
(instance? java.net.http.HttpConnectTimeoutException cause)
"timeout"))
"timeout"
:else
(let [data (ex-data cause)]
(if (and (= :validation (:type data))
(= :ssrf-blocked-target (:code data)))
(str "blocked-request:" (:hint data))
nil))))

View File

@ -304,10 +304,11 @@
::session/manager (ig/ref ::session/manager)}
:app.http.assets/routes
{::http.assets/path (cf/get :assets-path)
::http.assets/cache-max-age (ct/duration {:hours 24})
::http.assets/cache-max-agesignature-max-age (ct/duration {:hours 24 :minutes 5})
::sto/storage (ig/ref ::sto/storage)}
{::http.assets/path (cf/get :assets-path)
::http.assets/cache-max-age (ct/duration {:hours 24})
::http.assets/signature-max-age (ct/duration {:hours 24 :minutes 15})
::sto/storage (ig/ref ::sto/storage)
::session/manager (ig/ref ::session/manager)}
::rpc/climit
{::mtx/metrics (ig/ref ::mtx/metrics)

View File

@ -18,6 +18,7 @@
[app.config :as cf]
[app.db :as-alias db]
[app.http.client :as http]
[app.media.sanitize :as sanitize]
[app.storage :as-alias sto]
[app.storage.tmp :as tmp]
[buddy.core.bytes :as bb]
@ -325,9 +326,11 @@
(let [{:keys [body] :as response}
(try
(http/req! client
{:method :get :uri uri}
{:response-type :input-stream})
(http/req-with-redirects
client
{:method :get :uri uri}
{:response-type :input-stream
:max-redirects 3})
(catch java.net.ConnectException cause
(ex/raise :type :validation
:code :unable-to-download-image
@ -358,9 +361,11 @@
:code :mismatch-write-size
:hint "unexpected state: unable to write to file"))
{;; :size size
:path path
:mtype mtype})))
;; Sanitize: strip trailing data after image EOF markers
(let [new-size (sanitize/truncate-after-eof path mtype)]
{:path path
:mtype mtype
:size new-size}))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; FONTS

View File

@ -0,0 +1,191 @@
;; 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 INC
(ns app.media.sanitize
"Image EOF truncation helpers strips trailing data after image EOF
markers to prevent exfiltration of non-image bytes appended to
valid image files."
(:require
[app.common.buffer :as buf]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.util.nio :as nio])
(:import
java.nio.ByteOrder
java.nio.channels.FileChannel))
(set! *warn-on-reflection* true)
(defn- scan-backwards
"Scan byte array `arr` backwards (from the end) for the byte pattern
`marker`. Returns the index in `arr` where the marker starts, or -1
if not found."
[^bytes arr ^bytes marker]
(let [arr-len (alength arr)
marker-len (alength marker)]
(loop [i (- arr-len marker-len)]
(if (< i 0)
-1
(if (loop [j 0]
(if (>= j marker-len)
true
(if (= (aget arr (+ i j)) (aget marker j))
(recur (inc j))
false)))
i
(recur (dec i)))))))
(defn- find-last-png-iend
"Find the byte offset of the end of the PNG IEND chunk (12 bytes:
4-byte length + 4-byte 'IEND' + 4-byte CRC32). Returns the offset
AFTER the CRC32, or nil if not found."
[^FileChannel channel]
(let [size (nio/channel-size channel)]
(when (> size 8)
(let [buf-size (min (int size) (* 1024 1024))
marker (byte-array [0x49 0x45 0x4E 0x44])] ;; "IEND"
(loop [pos (max 0 (- size buf-size))]
(when (< pos size)
(let [arr (nio/read-at channel pos buf-size)
idx (scan-backwards arr marker)]
(if (neg? idx)
;; Not found in this chunk, try earlier
(let [next-pos (max 0 (- pos (- buf-size 4)))]
(when (< next-pos pos)
(recur next-pos)))
;; Found "IEND" at idx. Chunk starts 4 bytes before.
(let [chunk-start (- (+ pos idx) 4)]
(when (>= chunk-start 0)
;; PNG chunk length is big-endian (network byte order).
;; buf/wrap defaults to little-endian, so set it to big-endian.
(let [len-arr (nio/read-at channel chunk-start 4)
len-buf (buf/set-order (buf/wrap len-arr) ByteOrder/BIG_ENDIAN)
chunk-len (buf/read-int len-buf 0)]
(when (zero? chunk-len)
(+ chunk-start 12)))))))))))))
(defn- find-last-jpeg-eoi
"Find the byte offset of the last JPEG EOI marker (0xFF 0xD9).
Returns the offset AFTER the marker, or nil if not found."
[^FileChannel channel]
(let [size (nio/channel-size channel)]
(when (> size 2)
(let [buf-size (min (int size) (* 1024 1024))
marker (byte-array [(unchecked-byte 0xFF) (unchecked-byte 0xD9)])]
(loop [pos (max 0 (- size buf-size))]
(when (< pos size)
(let [arr (nio/read-at channel pos buf-size)
idx (scan-backwards arr marker)]
(if (neg? idx)
(let [next-pos (max 0 (- pos (- buf-size 2)))]
(when (< next-pos pos)
(recur next-pos)))
(+ pos idx 2)))))))))
(defn- find-last-gif-trailer
"Find the byte offset immediately after the last GIF trailer byte (0x3B).
Scans backwards through the file so that appended data after the real
trailer is truncated even when it ends with 0x3B.
Returns the offset AFTER the trailer byte, or nil if 0x3B is not found."
[^FileChannel channel]
(let [size (nio/channel-size channel)]
(when (pos? size)
(let [buf-size (min (int size) (* 1024 1024))
marker (byte-array [(unchecked-byte 0x3B)])]
(loop [pos (max 0 (- size buf-size))]
(when (< pos size)
(let [arr (nio/read-at channel pos buf-size)
idx (scan-backwards arr marker)]
(if (neg? idx)
(let [next-pos (max 0 (- pos (- buf-size 1)))]
(when (< next-pos pos)
(recur next-pos)))
(+ pos idx 1)))))))))
(defn- find-webp-end
"Parse the WebP RIFF header to find the declared file size.
WebP format: 'RIFF' (4 bytes) + uint32 total-size (4 bytes, little-endian)
+ 'WEBP' (4 bytes). The total size is the offset of the end of the file.
Returns nil if the RIFF or WEBP magic bytes are missing."
[^FileChannel channel]
(let [size (nio/channel-size channel)]
(when (>= size 12)
(let [^bytes arr (nio/read-at channel 0 12)
buf (buf/wrap arr)]
;; Check RIFF magic (bytes 0-3) AND WEBP FourCC (bytes 8-11)
(when (and (= (aget arr 0) (byte 0x52)) ;; 'R'
(= (aget arr 1) (byte 0x49)) ;; 'I'
(= (aget arr 2) (byte 0x46)) ;; 'F'
(= (aget arr 3) (byte 0x46)) ;; 'F'
(= (aget arr 8) (byte 0x57)) ;; 'W'
(= (aget arr 9) (byte 0x45)) ;; 'E'
(= (aget arr 10) (byte 0x42)) ;; 'B'
(= (aget arr 11) (byte 0x50))) ;; 'P'
(let [riff-size (bit-and (buf/read-int buf 4) 0xFFFFFFFF)]
;; RIFF size field is the size of the file minus 8 bytes
(+ riff-size 8)))))))
(defn truncate-after-eof
"Given a `java.nio.file.Path` to a freshly-downloaded media file and a
declared MIME type, truncate the file in place to the position of the
format's EOF marker:
- image/png end of the IEND chunk (12 bytes: 4-byte length + 4-byte type + 4-byte CRC32)
- image/jpeg 2 bytes after FFD9
- image/gif immediately after the last GIF trailer byte 0x3B
- image/webp end of RIFF chunk declared in bytes 4..8
- image/svg+xml no-op (text format; processed by SAX parser)
- other no-op (return path unchanged)
Returns the new file size. Raises `:validation/:invalid-image` if no
EOF marker is found within the file."
[^java.nio.file.Path path ^String mtype]
(try
(with-open [channel (nio/open-channel path)]
(let [size (nio/channel-size channel)]
(if (zero? size)
0
(let [needs-eof-marker? (or (= mtype "image/png")
(= mtype "image/jpeg")
(= mtype "image/gif")
(= mtype "image/webp"))
eof-offset
(cond
(= mtype "image/png") (find-last-png-iend channel)
(= mtype "image/jpeg") (find-last-jpeg-eoi channel)
(= mtype "image/gif") (find-last-gif-trailer channel)
(= mtype "image/webp") (find-webp-end channel)
:else nil)]
(cond
;; No EOF marker applicable (SVG or other) — no-op
(nil? eof-offset)
(if needs-eof-marker?
(ex/raise :type :validation
:code :invalid-image
:hint "image format EOF marker not found")
size)
;; Truncate if needed
(< eof-offset size)
(do
(l/dbg :hint "truncating trailing data"
:path (str path)
:mtype mtype
:original-size size
:truncated-to eof-offset)
(nio/truncate channel eof-offset)
eof-offset)
;; Already at correct size or marker at end
:else
eof-offset)))))
(catch Exception e
(if (ex/exception? e)
(throw e)
(ex/raise :type :validation
:code :invalid-image
:hint "failed to sanitize image"
:cause e)))))

View File

@ -29,14 +29,14 @@
(defn- request-builder
[cfg method uri shared-key profile-id request-params]
(fn []
(http/req! cfg (cond-> {:method method
:headers {"content-type" "application/json"
"accept" "application/json"
"x-shared-key" shared-key
"x-profile-id" (str profile-id)}
:uri uri
:version :http1.1}
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))))))
(http/req cfg (cond-> {:method method
:headers {"content-type" "application/json"
"accept" "application/json"
"x-shared-key" shared-key
"x-profile-id" (str profile-id)}
:uri uri
:version :http1.1}
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))))))
(defn- with-retries
[handler max-retries]

View File

@ -50,24 +50,27 @@
(defn- validate-webhook!
[cfg whook params]
(when (not= (:uri whook) (:uri params))
(let [response (ex/try!
(http/req! cfg
(try
(let [response (http/req cfg
{:method :head
:uri (str (:uri params))
:timeout (ct/duration "3s")}
{:sync? true}))]
(if (ex/exception? response)
(if-let [hint (webhooks/interpret-exception response)]
(ex/raise :type :validation
:code :webhook-validation
:hint hint)
(ex/raise :type :internal
:code :webhook-validation
:cause response))
:timeout (ct/duration "3s")})]
(when-let [hint (webhooks/interpret-response response)]
(ex/raise :type :validation
:code :webhook-validation
:hint hint))))))
:hint hint)))
(catch Throwable cause
(if-let [hint (webhooks/interpret-exception cause)]
(ex/raise :type :validation
:code :webhook-validation
:hint hint
:webhook-uri (str (:uri params))
:cause cause)
(ex/raise :type :internal
:code :webhook-validation
:webhook-uri (str (:uri params))
:cause cause))))))
(defn- validate-quotes!
[{:keys [::db/pool]} {:keys [team-id]}]

View File

@ -57,9 +57,9 @@
(if (fs/exists? path)
(io/input-stream path)
(let [resp (http/req! cfg
{:method :get :uri (:file-uri template)}
{:response-type :input-stream :sync? true})]
(let [resp (http/req cfg
{:method :get :uri (:file-uri template)}
{:response-type :input-stream :sync? true})]
(when-not (= 200 (:status resp))
(ex/raise :type :internal
:code :unexpected-status-code

View File

@ -30,7 +30,7 @@
:uri (cf/get :telemetry-uri)
:headers {"content-type" "application/json"}
:body (json/encode-str data)}
response (http/req! cfg request)]
response (http/req cfg request)]
(when (> (:status response) 206)
(ex/raise :type :internal
:code :invalid-response

View File

@ -0,0 +1,91 @@
;; 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 INC
(ns app.util.nio
"NIO helpers for working with files and byte arrays.
These are thin wrappers around java.nio that provide a
Clojure-idiomatic API. Candidates for porting to datoteka."
(:import
java.nio.ByteBuffer
java.nio.channels.FileChannel
java.nio.file.Files
java.nio.file.OpenOption
java.nio.file.Path
java.nio.file.StandardOpenOption))
(set! *warn-on-reflection* true)
;; ----------------------------------------------------------------
;; File operations (via java.nio.file.Files)
;; ----------------------------------------------------------------
(defn read-bytes
"Read all bytes from a file at `path`. Returns a byte array."
^bytes [^Path path]
(Files/readAllBytes path))
(defn write-bytes
"Write `data` (byte array) to a file at `path`, replacing existing
content. Returns `path`."
[^Path path ^bytes data]
(Files/write path data ^"[Ljava.nio.file.OpenOption;" (into-array OpenOption []))
path)
(defn append-bytes
"Append `data` (byte array) to the end of the file at `path`.
Creates the file if it does not exist. Returns `path`."
[^Path path ^bytes data]
(Files/write path data
^"[Ljava.nio.file.OpenOption;"
(into-array OpenOption
[StandardOpenOption/CREATE
StandardOpenOption/APPEND]))
path)
;; ----------------------------------------------------------------
;; FileChannel operations (internal API)
;; ----------------------------------------------------------------
(def ^:private read-write-opts
(into-array OpenOption
[StandardOpenOption/READ StandardOpenOption/WRITE]))
(defn open-channel
"Open a FileChannel for read/write on the given path."
^FileChannel [^Path path]
(FileChannel/open path read-write-opts))
(defn channel-size
"Return the size of the file backed by the channel."
^long [^FileChannel channel]
(.size channel))
(defn read-at
"Read `length` bytes from `channel` starting at `position` into a
new byte array. Returns the byte array.
Loops until the ByteBuffer is fully populated to guard against OS
partial reads, which would otherwise cause BufferUnderflowException
when copying from the buffer into the result array."
^bytes [^FileChannel channel ^long position ^long length]
(let [buf (ByteBuffer/allocate (int length))]
(.position channel position)
(loop []
(when (.hasRemaining buf)
(let [n (.read channel buf)]
(when (pos? n)
(recur)))))
(.flip buf)
(let [remaining (.remaining buf)
arr (byte-array remaining)]
(.get buf arr)
arr)))
(defn truncate
"Truncate the file to the given size. Returns the channel."
[^FileChannel channel ^long size]
(.truncate channel size)
channel)

View File

@ -0,0 +1,229 @@
;; 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 INC
(ns app.util.ssrf
"URL/host validation to prevent Server-Side Request Forgery."
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.config :as cf]
[cuerdas.core :as str])
(:import
com.google.common.net.InetAddresses
java.net.InetAddress
java.net.UnknownHostException
java.net.URI))
(def ^:private allowed-schemes
#{"http" "https"})
(def ^:private cloud-metadata-ips
"Exact IP addresses for cloud metadata services."
#{"169.254.169.254"
"fd00:ec2::254"})
(def ^:private extra-blocked-ranges
"CIDR ranges not covered by standard JDK InetAddress predicates.
Each entry is [base-address prefix-length]."
;; Carrier-grade NAT
[[100 64 0 0 10]
;; RFC 6890 / documentation / reserved
[192 0 0 0 24]
[192 0 2 0 24]
[198 18 0 0 15]
[198 51 100 0 24]
[203 0 113 0 24]
;; Reserved / future-use (broadcast and above)
[240 0 0 0 4]])
(defn- ip4-to-long
"Convert a 4-element byte array (IPv4) to a 32-bit long."
^long [^bytes bs]
(bit-or (bit-shift-left (bit-and (aget bs 0) 0xFF) 24)
(bit-shift-left (bit-and (aget bs 1) 0xFF) 16)
(bit-shift-left (bit-and (aget bs 2) 0xFF) 8)
(bit-and (aget bs 3) 0xFF)))
(defn- prefix-mask
"Return a 32-bit mask for the given prefix length."
^long [^long prefix-len]
(if (zero? prefix-len)
0
(bit-shift-left (unsigned-bit-shift-right 0xFFFFFFFF (- 32 prefix-len)) (- 32 prefix-len))))
(defn- in-cidr4?
"Check if an IPv4 address (as byte array) falls within a CIDR range
specified as [a b c d prefix-len]."
[^bytes addr [^long a ^long b ^long c ^long d ^long prefix-len]]
(let [base (bit-or (bit-shift-left (bit-and a 0xFF) 24)
(bit-shift-left (bit-and b 0xFF) 16)
(bit-shift-left (bit-and c 0xFF) 8)
(bit-and d 0xFF))
mask (prefix-mask prefix-len)
ip-val (ip4-to-long addr)]
(= (bit-and ip-val mask) (bit-and base mask))))
(defn- parse-cidr*
"Parse a CIDR string like '10.0.0.0/8' into [a b c d prefix-len]. Throws on invalid input."
[^String cidr]
(let [parts (str/split cidr #"/" 2)
prefix-len (when (= 2 (count parts))
(parse-long (nth parts 1)))]
(when-not prefix-len
(ex/raise :type :internal
:code :invalid-cidr
:hint (str "invalid CIDR notation: " cidr)))
(let [octets (str/split (first parts) #"\.")]
(when (not= 4 (count octets))
(ex/raise :type :internal
:code :invalid-cidr
:hint (str "invalid CIDR notation (expected IPv4): " cidr)))
(let [[a b c d] (map parse-long octets)]
(when (or (nil? a) (nil? b) (nil? c) (nil? d)
(not (<= 0 a 255)) (not (<= 0 b 255))
(not (<= 0 c 255)) (not (<= 0 d 255))
(not (<= 0 prefix-len 32)))
(ex/raise :type :internal
:code :invalid-cidr
:hint (str "invalid CIDR notation: " cidr)))
[a b c d prefix-len]))))
(defn parse-cidr
"Parse a CIDR string like '10.0.0.0/8' into [a b c d prefix-len].
Returns nil and logs a warning on invalid input."
[^String cidr]
(try
(parse-cidr* cidr)
(catch Exception _
(l/warn :hint "ignoring invalid CIDR" :cidr cidr)
nil)))
(defonce ^:dynamic extra-blocked-cidrs
(into #{} (keep parse-cidr) (cf/get :ssrf-extra-blocked-cidrs #{})))
(defn- ipv6-ula?
"Check if an IPv6 address is in the Unique Local Address range (fc00::/7)."
[^InetAddress addr]
(let [bs (.getAddress addr)]
(and (>= (alength bs) 16)
(= (bit-and (aget bs 0) 0xFE) 0xFC))))
(defn- ipv4-mapped-loopback?
"Check if an IPv4-mapped IPv6 address maps to loopback (::ffff:127.x.x.x)."
[^InetAddress addr]
(let [bs (.getAddress addr)]
(and (= (alength bs) 16)
;; Check it's an IPv4-mapped address: ::ffff:x.x.x.x
(= (aget bs 10) (byte -1)) ;; 0xFF
(= (aget bs 11) (byte -1)) ;; 0xFF
;; Check the embedded IPv4 is loopback (127.x.x.x)
(= (bit-and (aget bs 12) 0xFF) 127))))
(defn- blocked-address?
"Check if an InetAddress should be blocked. Returns true if blocked."
[^InetAddress addr]
(or
(.isAnyLocalAddress addr) ;; 0.0.0.0 or ::
(.isLoopbackAddress addr) ;; 127/8 or ::1
(.isLinkLocalAddress addr) ;; 169.254/16 or fe80::/10
(.isSiteLocalAddress addr) ;; 10/8, 172.16/12, 192.168/16
(.isMulticastAddress addr)
;; IPv6 ULA (fc00::/7)
(ipv6-ula? addr)
;; IPv4-mapped loopback
(ipv4-mapped-loopback? addr)
;; Cloud metadata IPs (exact match)
(contains? cloud-metadata-ips (.getHostAddress addr))
;; Extra blocked CIDRs (IPv4 only)
(let [bs (.getAddress addr)]
(if (= (alength bs) 4)
(or (some #(in-cidr4? bs %) extra-blocked-ranges)
(some #(in-cidr4? bs %) extra-blocked-cidrs))
false))))
(defn resolve-host
"Resolve a hostname to all InetAddress objects. Wraps InetAddress/getAllByName
so it can be stubbed in tests."
[^String hostname]
(try
(InetAddress/getAllByName hostname)
(catch UnknownHostException _
nil)))
(defn validate-uri
"Validates `uri-or-string`:
- scheme must be http or https,
- host must resolve to at least one address, and
- **every** resolved address must NOT be in the blocklist
(loopback, link-local, site-local, multicast, any-local,
cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv4-mapped
IPv6 of any blocked IPv4, plus operator-supplied CIDRs).
When the host is an IP literal (decimal/octal/hex/IPv6) it is
normalized via `com.google.common.net.InetAddresses` before the
check.
Hosts in `:ssrf-allowed-hosts` (case-insensitive exact match) bypass
the IP check.
Throws `ex/raise :type :validation :code :ssrf-blocked-target` with
a hint that does NOT echo the resolved IP (avoid info leak)."
[uri-or-string]
(let [uri (if (instance? URI uri-or-string)
uri-or-string
(URI. (str uri-or-string)))
scheme (.getScheme uri)
host (.getHost uri)]
;; Validate scheme
(when (or (nil? scheme)
(not (contains? allowed-schemes (str/lower scheme))))
(ex/raise :type :validation
:code :ssrf-blocked-target
:hint "url scheme is not allowed"))
;; Validate host presence
(when (or (nil? host) (str/blank? host))
(ex/raise :type :validation
:code :ssrf-blocked-target
:hint "url host is missing"))
;; Check allowlist
(let [allowed-hosts (cf/get :ssrf-allowed-hosts #{})
host-lower (str/lower host)]
(when-not (contains? allowed-hosts host-lower)
;; Normalize the host: if it looks like an IP literal, normalize it
;; via Guava to catch decimal/octal/hex encodings
(let [normalized (if (InetAddresses/isInetAddress host)
(InetAddresses/forString host)
nil)
host-to-resolve (if normalized
(.getHostAddress ^InetAddress normalized)
host)
addresses (resolve-host host-to-resolve)]
(when (or (nil? addresses) (zero? (alength addresses)))
(ex/raise :type :validation
:code :ssrf-blocked-target
:hint "url host could not be resolved"))
;; All-or-nothing: if ANY resolved address is blocked, reject
(when (some blocked-address? (seq addresses))
(ex/raise :type :validation
:code :ssrf-blocked-target
:hint "url target is not allowed")))))
(str uri)))
(defn safe-url?
"Predicate version of `validate-uri`. Returns `true` if safe."
[uri-or-string]
(try
(validate-uri uri-or-string)
true
(catch Exception _
false)))

View File

@ -41,7 +41,7 @@
(t/is (nil? res)))))
(t/deftest run-webhook-handler-1
(with-mocks [http-mock {:target 'app.http.client/req! :return {:status 200}}]
(with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}]
(let [prof (th/create-profile* 1 {:is-active true})
whk (th/create-webhook* {:team-id (:default-team-id prof)})
evt {:type "command"
@ -63,7 +63,7 @@
(t/is (nil? (:error-code whk')))))))
(t/deftest run-webhook-handler-2
(with-mocks [http-mock {:target 'app.http.client/req! :return {:status 400}}]
(with-mocks [http-mock {:target 'app.http.client/req :return {:status 400}}]
(let [prof (th/create-profile* 1 {:is-active true})
whk (th/create-webhook* {:team-id (:default-team-id prof)})
evt {:type "command"

View File

@ -0,0 +1,501 @@
;; 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 INC
(ns backend-tests.media-sanitize-test
(:require
[app.media.sanitize :as sanitize]
[app.storage.tmp :as tmp]
[app.util.nio :as nio]
[clojure.test :as t]
[datoteka.fs :as fs]
[datoteka.io :as io]))
(defn- resource-path
"Return a URL to a test resource file."
[name]
(io/resource (str "backend_tests/test_files/" name)))
(defn- copy-resource-to-tempfile
"Copy a test resource file to a tempfile and return the Path."
[resource-name suffix]
(tmp/tempfile-from (resource-path resource-name) :prefix "test-real-" :suffix suffix))
;; ----------------------------------------------------------------
;; Crafted test data
;; ----------------------------------------------------------------
;; PNG test data
(def ^:private png-signature
(byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A]))
(def ^:private png-iend-chunk
(byte-array [0x00 0x00 0x00 0x00 0x49 0x45 0x4E 0x44 0xAE 0x42 0x60 0x82]))
(def ^:private png-ihdr-chunk
(byte-array [0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52
0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01
0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE]))
(defn- make-png [^bytes extra-bytes]
(let [parts (if extra-bytes
[png-signature png-ihdr-chunk png-iend-chunk extra-bytes]
[png-signature png-ihdr-chunk png-iend-chunk])
total (reduce + 0 (map alength parts))
result (byte-array total)
offset (volatile! 0)]
(doseq [part parts]
(System/arraycopy part 0 result @offset (alength part))
(vswap! offset + (alength part)))
result))
;; JPEG test data
(def ^:private jpeg-soi (byte-array [0xFF 0xD8]))
(def ^:private jpeg-eoi (byte-array [0xFF 0xD9]))
(defn- make-jpeg [^bytes extra-bytes]
(let [parts (if extra-bytes
[jpeg-soi jpeg-eoi extra-bytes]
[jpeg-soi jpeg-eoi])
total (reduce + 0 (map alength parts))
result (byte-array total)
offset (volatile! 0)]
(doseq [part parts]
(System/arraycopy part 0 result @offset (alength part))
(vswap! offset + (alength part)))
result))
;; GIF test data
(def ^:private gif-header
(byte-array [0x47 0x49 0x46 0x38 0x39 0x61 ;; "GIF89a"
0x01 0x00 0x01 0x00 ;; 1x1 canvas
0x00 ;; no GCT
0x00])) ;; background color
(def ^:private gif-trailer (byte-array [0x3B]))
;; WebP test data
(defn- make-webp [^long total-size]
(let [riff-size (- total-size 8)
data (byte-array total-size)]
(aset data 0 (byte 0x52)) ;; 'R'
(aset data 1 (byte 0x49)) ;; 'I'
(aset data 2 (byte 0x46)) ;; 'F'
(aset data 3 (byte 0x46)) ;; 'F'
(aset data 4 (byte (bit-and riff-size 0xFF)))
(aset data 5 (byte (bit-and (bit-shift-right riff-size 8) 0xFF)))
(aset data 6 (byte (bit-and (bit-shift-right riff-size 16) 0xFF)))
(aset data 7 (byte (bit-and (bit-shift-right riff-size 24) 0xFF)))
(aset data 8 (byte 0x57)) ;; 'W'
(aset data 9 (byte 0x45)) ;; 'E'
(aset data 10 (byte 0x42)) ;; 'B'
(aset data 11 (byte 0x50)) ;; 'P'
data))
(defn- write-data-to-tempfile
"Write byte array to a tempfile and return the Path."
[^bytes data suffix]
(let [path (tmp/tempfile :prefix "test-sanitize." :suffix suffix)]
(nio/write-bytes path data)
path))
;; ----------------------------------------------------------------
;; Tests with crafted data
;; ----------------------------------------------------------------
(t/deftest png-with-appended-secret-truncated
(let [secret (.getBytes "SECRET_DATA_HERE")
data (make-png secret)
path (write-data-to-tempfile data ".png")
_ (t/is (= (alength data) (alength (nio/read-bytes path))))
new-size (sanitize/truncate-after-eof path "image/png")]
(t/is (= new-size (+ (alength png-signature)
(alength png-ihdr-chunk)
(alength png-iend-chunk))))
(t/is (= new-size (alength (nio/read-bytes path))))
(let [expected (make-png nil)
actual (nio/read-bytes path)]
(t/is (java.util.Arrays/equals expected actual)))))
(t/deftest png-clean-not-truncated
(let [data (make-png nil)
path (write-data-to-tempfile data ".png")]
(t/is (= (alength data) (sanitize/truncate-after-eof path "image/png")))
(t/is (= (alength data) (alength (nio/read-bytes path))))))
(t/deftest jpeg-with-appended-secret-truncated
(let [secret (.getBytes "\u0000\u0000SECRET")
data (make-jpeg secret)
path (write-data-to-tempfile data ".jpg")
_ (t/is (= (alength data) (alength (nio/read-bytes path))))
new-size (sanitize/truncate-after-eof path "image/jpeg")]
(t/is (= new-size (+ (alength jpeg-soi) (alength jpeg-eoi))))
(let [expected (make-jpeg nil)
actual (nio/read-bytes path)]
(t/is (java.util.Arrays/equals expected actual)))))
(t/deftest jpeg-clean-not-truncated
(let [data (make-jpeg nil)
path (write-data-to-tempfile data ".jpg")]
(t/is (= (alength data) (sanitize/truncate-after-eof path "image/jpeg")))
(t/is (= (alength data) (alength (nio/read-bytes path))))))
(t/deftest gif-trailer-already-correct
(let [parts [gif-header gif-trailer]
total (reduce + 0 (map alength parts))
data (byte-array total)
offset (volatile! 0)]
(doseq [part parts]
(System/arraycopy part 0 data @offset (alength part))
(vswap! offset + (alength part)))
(let [path (write-data-to-tempfile data ".gif")]
(t/is (= total (sanitize/truncate-after-eof path "image/gif")))
(t/is (= total (alength (nio/read-bytes path)))))))
(t/deftest webp-declared-size-honored
(let [total-size 24
data (make-webp total-size)
extra (byte-array 10 (byte 0x42))
full-data (byte-array (+ total-size 10))]
(System/arraycopy data 0 full-data 0 total-size)
(System/arraycopy extra 0 full-data total-size 10)
(let [path (write-data-to-tempfile full-data ".webp")]
(t/is (= total-size (sanitize/truncate-after-eof path "image/webp")))
(t/is (= total-size (alength (nio/read-bytes path)))))))
(t/deftest webp-clean-not-truncated
(let [data (make-webp 24)
path (write-data-to-tempfile data ".webp")]
(t/is (= 24 (sanitize/truncate-after-eof path "image/webp")))
(t/is (= 24 (alength (nio/read-bytes path))))))
(t/deftest non-webp-riff-rejected-as-invalid-image
;; A RIFF file whose FourCC is not 'WEBP' (e.g. a WAV file) must be
;; rejected so it cannot bypass sanitization by pretending to be WebP.
(let [data (byte-array 24)]
;; Write RIFF magic
(aset data 0 (byte 0x52)) ;; 'R'
(aset data 1 (byte 0x49)) ;; 'I'
(aset data 2 (byte 0x46)) ;; 'F'
(aset data 3 (byte 0x46)) ;; 'F'
;; RIFF size = 16 (total 24 - 8)
(aset data 4 (byte 16))
;; FourCC = 'WAVE' (not 'WEBP')
(aset data 8 (byte 0x57)) ;; 'W'
(aset data 9 (byte 0x41)) ;; 'A'
(aset data 10 (byte 0x56)) ;; 'V'
(aset data 11 (byte 0x45)) ;; 'E'
(let [path (write-data-to-tempfile data ".webp")]
(try
(sanitize/truncate-after-eof path "image/webp")
(t/is false "should have thrown")
(catch Exception e
(t/is (= :invalid-image (:code (ex-data e)))))))))
(t/deftest svg-is-no-op
(let [data (.getBytes "<svg><rect/></svg>")
path (write-data-to-tempfile data ".svg")]
(t/is (= (alength data) (sanitize/truncate-after-eof path "image/svg+xml")))
(t/is (= (alength data) (alength (nio/read-bytes path))))))
(t/deftest unknown-mtype-is-no-op
(let [data (.getBytes "some binary data")
path (write-data-to-tempfile data ".bin")]
(t/is (= (alength data) (sanitize/truncate-after-eof path "application/octet-stream")))
(t/is (= (alength data) (alength (nio/read-bytes path))))))
(t/deftest png-missing-iend-raises-error
(let [data (byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A
0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52
0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01
0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE])
path (write-data-to-tempfile data ".png")]
(try
(sanitize/truncate-after-eof path "image/png")
(t/is false "should have thrown")
(catch Exception e
(t/is (= :validation (:type (ex-data e))))
(t/is (= :invalid-image (:code (ex-data e))))))))
;; ----------------------------------------------------------------
;; Tests with real files from test_files/
;; ----------------------------------------------------------------
(t/deftest real-png-clean-not-truncated
(let [path (copy-resource-to-tempfile "sample.png" ".png")
original (nio/read-bytes path)
size (sanitize/truncate-after-eof path "image/png")]
(t/is (= (alength original) size))
(t/is (= (alength original) (alength (nio/read-bytes path))))))
(t/deftest real-png-with-appended-secret-truncated
(let [path (copy-resource-to-tempfile "sample.png" ".png")
original (nio/read-bytes path)
orig-size (alength original)
secret (.getBytes "EXFILTRATED_SECRET_DATA_12345")
_ (nio/append-bytes path secret)
_ (t/is (= (+ orig-size (alength secret))
(alength (nio/read-bytes path))))
new-size (sanitize/truncate-after-eof path "image/png")]
(t/is (= orig-size new-size))
(t/is (= orig-size (alength (nio/read-bytes path))))
(t/is (java.util.Arrays/equals original (nio/read-bytes path)))))
(t/deftest real-jpg-clean-not-truncated
(let [path (copy-resource-to-tempfile "sample.jpg" ".jpg")
original (nio/read-bytes path)
size (sanitize/truncate-after-eof path "image/jpeg")]
(t/is (= (alength original) size))
(t/is (= (alength original) (alength (nio/read-bytes path))))))
(t/deftest real-jpg-with-appended-secret-truncated
(let [path (copy-resource-to-tempfile "sample.jpg" ".jpg")
original (nio/read-bytes path)
orig-size (alength original)
secret (.getBytes "EXFILTRATED_SECRET_DATA_12345")
_ (nio/append-bytes path secret)
_ (t/is (= (+ orig-size (alength secret))
(alength (nio/read-bytes path))))
new-size (sanitize/truncate-after-eof path "image/jpeg")]
(t/is (= orig-size new-size))
(t/is (= orig-size (alength (nio/read-bytes path))))
(t/is (java.util.Arrays/equals original (nio/read-bytes path)))))
(t/deftest real-webp-clean-not-truncated
(let [path (copy-resource-to-tempfile "sample.webp" ".webp")
original (nio/read-bytes path)
size (sanitize/truncate-after-eof path "image/webp")]
(t/is (= (alength original) size))
(t/is (= (alength original) (alength (nio/read-bytes path))))))
(t/deftest real-webp-with-appended-secret-truncated
(let [path (copy-resource-to-tempfile "sample.webp" ".webp")
original (nio/read-bytes path)
orig-size (alength original)
secret (.getBytes "EXFILTRATED_SECRET_DATA_12345")
_ (nio/append-bytes path secret)
_ (t/is (= (+ orig-size (alength secret))
(alength (nio/read-bytes path))))
new-size (sanitize/truncate-after-eof path "image/webp")]
(t/is (= orig-size new-size))
(t/is (= orig-size (alength (nio/read-bytes path))))
(t/is (java.util.Arrays/equals original (nio/read-bytes path)))))
;; ----------------------------------------------------------------
;; Edge cases and boundary conditions
;; ----------------------------------------------------------------
(t/deftest empty-file-returns-zero
(let [path (write-data-to-tempfile (byte-array 0) ".png")]
(t/is (zero? (sanitize/truncate-after-eof path "image/png")))))
(t/deftest png-signature-only-no-iend
;; Just the 8-byte PNG signature, no chunks at all
(let [path (write-data-to-tempfile png-signature ".png")]
(try
(sanitize/truncate-after-eof path "image/png")
(t/is false "should have thrown")
(catch Exception e
(t/is (= :invalid-image (:code (ex-data e))))))))
(t/deftest jpeg-soi-only-no-eoi
;; Just the 2-byte SOI marker, no EOI
(let [path (write-data-to-tempfile jpeg-soi ".jpg")]
(try
(sanitize/truncate-after-eof path "image/jpeg")
(t/is false "should have thrown")
(catch Exception e
(t/is (= :invalid-image (:code (ex-data e))))))))
(t/deftest jpeg-multiple-eoi-uses-last
;; Progressive JPEGs can have multiple EOI markers; we want the last one
(let [data (byte-array (concat [0xFF 0xD8] ;; SOI
[0x00 0x01 0x02] ;; some data
[0xFF 0xD9] ;; first EOI
[0x03 0x04 0x05] ;; more data
[0xFF 0xD9] ;; second (last) EOI
[0xDE 0xAD])) ;; secret
path (write-data-to-tempfile data ".jpg")
new-size (sanitize/truncate-after-eof path "image/jpeg")]
;; Should truncate at the last EOI (position 12: 2 + 3 + 2 + 3 + 2)
(t/is (= 12 new-size))
(let [result (nio/read-bytes path)]
(t/is (= 12 (alength result)))
;; Verify it ends with the second FFD9
(t/is (= (unchecked-byte 0xFF) (aget result 10)))
(t/is (= (unchecked-byte 0xD9) (aget result 11))))))
(t/deftest png-iend-with-nonzero-length-rejected
;; IEND chunk with non-zero length field (malformed)
(let [bad-iend (byte-array [0x00 0x00 0x00 0x05 ;; length=5 (should be 0)
0x49 0x45 0x4E 0x44 ;; "IEND"
0xAE 0x42 0x60 0x82]) ;; CRC
data (byte-array (concat png-signature png-ihdr-chunk bad-iend))
path (write-data-to-tempfile data ".png")]
(try
(sanitize/truncate-after-eof path "image/png")
(t/is false "should have thrown")
(catch Exception e
(t/is (= :invalid-image (:code (ex-data e))))))))
(t/deftest png-iend-length-read-as-big-endian
;; Verify the IEND length field is interpreted as big-endian (PNG spec).
;; Craft an IEND with length bytes [0x00 0x00 0x01 0x00]:
;; big-endian = 256 (non-zero → rejected)
;; little-endian = 65536 (also non-zero, but the code must still use BE)
;; We additionally verify that a length of [0x00 0x01 0x00 0x00] is correctly
;; read as 65536 in BE (not 256 as LE would give).
(let [be-iend (byte-array [0x00 0x01 0x00 0x00 ;; length=65536 BE (256 LE)
0x49 0x45 0x4E 0x44 ;; "IEND"
0xAE 0x42 0x60 0x82]) ;; CRC
data (byte-array (concat png-signature png-ihdr-chunk be-iend))
path (write-data-to-tempfile data ".png")]
(try
(sanitize/truncate-after-eof path "image/png")
(t/is false "should have thrown")
(catch Exception e
(t/is (= :invalid-image (:code (ex-data e))))))))
(t/deftest png-iend-in-chunk-data-not-falsely-matched
;; When "IEND" bytes appear inside chunk data (not as a chunk type),
;; the scanner must not falsely match them as the IEND chunk.
;; Build a PNG where the IHDR data contains "IEND" bytes, followed
;; by a legitimate IEND chunk.
(let [ihdr-with-iend-in-data
(byte-array [0x00 0x00 0x00 0x0D ;; length=13
0x49 0x48 0x44 0x52 ;; "IHDR"
0x00 0x00 0x00 0x01 ;; width=1
0x49 0x45 0x4E 0x44 ;; "IEND" embedded in data (bytes 8-11 of payload)
0x00 0x00 0x01 ;; remaining IHDR data bytes
0x90 0x77 0x53 0xDE]) ;; CRC
valid-iend png-iend-chunk
data (byte-array (concat png-signature ihdr-with-iend-in-data valid-iend))
path (write-data-to-tempfile data ".png")
expected-size (+ (alength png-signature)
(alength ihdr-with-iend-in-data)
(alength valid-iend))]
;; Should succeed and return the full size (no truncation needed)
(t/is (= expected-size (sanitize/truncate-after-eof path "image/png")))))
(t/deftest png-iend-correct-offset-returned
;; Verify that truncate-after-eof returns the exact byte offset of the
;; end of the IEND chunk for a minimal valid PNG.
(let [data (make-png nil)
path (write-data-to-tempfile data ".png")
expected (+ (alength png-signature)
(alength png-ihdr-chunk)
(alength png-iend-chunk))]
(t/is (= expected (sanitize/truncate-after-eof path "image/png")))
(t/is (= expected (alength (nio/read-bytes path))))))
(t/deftest gif-with-appended-data-truncated
;; Appended bytes after trailer must be stripped even when they don't end in 0x3B.
(let [valid-size (+ (alength gif-header) (alength gif-trailer))
parts [gif-header gif-trailer (byte-array [0x01 0x02 0x03])]
total (reduce + 0 (map alength parts))
data (byte-array total)
offset (volatile! 0)]
(doseq [part parts]
(System/arraycopy part 0 data @offset (alength part))
(vswap! offset + (alength part)))
(let [path (write-data-to-tempfile data ".gif")
new-size (sanitize/truncate-after-eof path "image/gif")]
(t/is (= valid-size new-size))
(t/is (= valid-size (alength (nio/read-bytes path)))))))
(t/deftest gif-with-appended-data-ending-in-trailer-byte-truncated
;; Security case: appended garbage that ends with 0x3B must NOT bypass the sanitizer.
;; scan-backwards finds the rightmost 0x3B, which is the one in the appended payload;
;; since that byte is AFTER the real trailer the truncation still drops the garbage.
;; Actually the scan finds the last 0x3B overall — if the appended section ends
;; with 0x3B we still truncate at that position, keeping only bytes up to the last 0x3B.
;; The real trailer 0x3B is within the kept portion, so the GIF remains valid.
(let [valid-size (+ (alength gif-header) (alength gif-trailer))
;; Append garbage: [0x01 0x02 0x3B] — ends with 0x3B
parts [gif-header gif-trailer (byte-array [0x01 0x02 (unchecked-byte 0x3B)])]
total (reduce + 0 (map alength parts))
data (byte-array total)
offset (volatile! 0)]
(doseq [part parts]
(System/arraycopy part 0 data @offset (alength part))
(vswap! offset + (alength part)))
(let [path (write-data-to-tempfile data ".gif")
new-size (sanitize/truncate-after-eof path "image/gif")]
;; The last 0x3B is at position total-1; scan finds it and returns total.
;; No truncation occurs but the 0x01 0x02 garbage bytes still remain.
;; This is an inherent limitation of the single-byte marker approach for GIF;
;; the test documents the known behaviour.
(t/is (= total new-size)))))
(t/deftest webp-riff-size-larger-than-file
;; RIFF declares size larger than actual file - should return declared end
;; even if it's beyond file size (FileChannel.truncate is a no-op for size >= file)
(let [data (make-webp 24)]
;; Manually set RIFF size to 100 (so declared end = 108)
(aset data 4 (byte 100))
(aset data 5 (byte 0))
(aset data 6 (byte 0))
(aset data 7 (byte 0))
(let [path (write-data-to-tempfile data ".webp")
result (sanitize/truncate-after-eof path "image/webp")]
;; Returns 108 (100 + 8), but file is only 24 bytes
;; truncate is no-op when target >= size
(t/is (= 108 result))
(t/is (= 24 (alength (nio/read-bytes path)))))))
(t/deftest webp-with-large-appended-data
(let [total-size 32
data (make-webp total-size)
;; Append 10000 bytes of secret
secret (byte-array 10000 (byte 0x42))
full-data (byte-array (+ total-size 10000))]
(System/arraycopy data 0 full-data 0 total-size)
(System/arraycopy secret 0 full-data total-size 10000)
(let [path (write-data-to-tempfile full-data ".webp")
new-size (sanitize/truncate-after-eof path "image/webp")]
(t/is (= total-size new-size))
(t/is (= total-size (alength (nio/read-bytes path)))))))
(t/deftest png-with-large-appended-secret
(let [data (make-png nil)
;; Append 1MB of secret data
secret (byte-array (* 1024 1024) (byte 0x42))
full (byte-array (+ (alength data) (alength secret)))]
(System/arraycopy data 0 full 0 (alength data))
(System/arraycopy secret 0 full (alength data) (alength secret))
(let [path (write-data-to-tempfile full ".png")
new-size (sanitize/truncate-after-eof path "image/png")]
(t/is (= (alength data) new-size))
(t/is (= (alength data) (alength (nio/read-bytes path)))))))
(t/deftest jpeg-with-large-appended-secret
(let [data (make-jpeg nil)
secret (byte-array (* 1024 1024) (byte 0x42))
full (byte-array (+ (alength data) (alength secret)))]
(System/arraycopy data 0 full 0 (alength data))
(System/arraycopy secret 0 full (alength data) (alength secret))
(let [path (write-data-to-tempfile full ".jpg")
new-size (sanitize/truncate-after-eof path "image/jpeg")]
(t/is (= (alength data) new-size))
(t/is (= (alength data) (alength (nio/read-bytes path)))))))
(t/deftest png-with-appended-png-signature
;; Appended data contains PNG signature bytes - should still find IEND
(let [extra (byte-array (concat [0x89 0x50 0x4E 0x47] ;; PNG sig fragment
[0xDE 0xAD 0xBE 0xEF]))
data (make-png extra)
path (write-data-to-tempfile data ".png")
new-size (sanitize/truncate-after-eof path "image/png")]
(t/is (= (+ (alength png-signature)
(alength png-ihdr-chunk)
(alength png-iend-chunk)) new-size))))
(t/deftest svg-with-trailing-data-is-no-op
;; SVG is text format, no EOF truncation
(let [data (.getBytes "<svg><rect/></svg><!-- secret -->")
path (write-data-to-tempfile data ".svg")]
(t/is (= (alength data) (sanitize/truncate-after-eof path "image/svg+xml")))
(t/is (= (alength data) (alength (nio/read-bytes path))))))

View File

@ -286,11 +286,11 @@
(t/deftest download-image-connection-error
(t/testing "connection refused raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req!
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:throw (java.net.ConnectException. "Connection refused")}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "http://unreachable.invalid/image.png")
(media/download-image cfg "https://example.com/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
@ -298,11 +298,11 @@
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "connection timeout raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req!
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:throw (java.net.http.HttpConnectTimeoutException. "Connect timed out")}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "http://unreachable.invalid/image.png")
(media/download-image cfg "https://example.com/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
@ -310,11 +310,11 @@
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "request timeout raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req!
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:throw (java.net.http.HttpTimeoutException. "Request timed out")}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "http://unreachable.invalid/image.png")
(media/download-image cfg "https://example.com/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
@ -322,11 +322,11 @@
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "I/O error raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req!
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:throw (java.io.IOException. "Stream closed")}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "http://unreachable.invalid/image.png")
(media/download-image cfg "https://example.com/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
@ -336,14 +336,14 @@
(t/deftest download-image-status-code-error
(t/testing "404 status raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req!
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:return {:status 404
:headers {"content-type" "text/html"
"content-length" "0"}
:body nil}}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "http://example.com/not-found.png")
(media/download-image cfg "https://example.com/not-found.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
@ -351,14 +351,14 @@
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "500 status raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req!
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:return {:status 500
:headers {"content-type" "text/html"
"content-length" "0"}
:body nil}}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "http://example.com/server-error.png")
(media/download-image cfg "https://example.com/server-error.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
@ -366,14 +366,14 @@
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "302 status raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req!
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:return {:status 302
:headers {"content-type" "text/html"
"content-length" "0"}
:body nil}}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "http://example.com/redirect.png")
(media/download-image cfg "https://example.com/redirect.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))

View File

@ -37,7 +37,7 @@
(t/is (contains? result :mtype))))
(t/deftest webhook-crud
(with-mocks [http-mock {:target 'app.http.client/req!
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200}}]
(let [prof (th/create-profile* 1 {:is-active true})
@ -151,7 +151,7 @@
(t/is (= (:code error-data) :object-not-found))))))))
(t/deftest webhooks-permissions-crud-viewer-only
(with-mocks [http-mock {:target 'app.http.client/req!
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200}}]
(let [owner (th/create-profile* 1 {:is-active true})
viewer (th/create-profile* 2 {:is-active true})
@ -214,7 +214,7 @@
(th/reset-mock! http-mock))))
(t/deftest webhooks-permissions-crud-viewer-owner
(with-mocks [http-mock {:target 'app.http.client/req!
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200}}]
(let [owner (th/create-profile* 1 {:is-active true})
viewer (th/create-profile* 2 {:is-active true})
@ -269,7 +269,7 @@
(t/is (= (:code error-data) :object-not-found)))))))
(t/deftest webhooks-quotes
(with-mocks [http-mock {:target 'app.http.client/req!
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200}}]
(let [prof (th/create-profile* 1 {:is-active true})

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 B

View File

@ -0,0 +1,176 @@
;; 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 INC
(ns backend-tests.util-ssrf-test
(:require
[app.common.exceptions :as ex]
[app.config :as cf]
[app.http.client :as http]
[app.util.ssrf :as ssrf]
[clojure.test :as t]))
(t/deftest validate-url-allows-public-https
(t/is (true? (ssrf/safe-url? "https://example.com/foo")))
(t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1"))))
(t/deftest validate-url-allows-public-http
(t/is (true? (ssrf/safe-url? "http://example.com/foo"))))
(t/deftest validate-url-blocks-disallowed-schemes
(t/is (false? (ssrf/safe-url? "file:///etc/passwd")))
(t/is (false? (ssrf/safe-url? "gopher://example.com")))
(t/is (false? (ssrf/safe-url? "ftp://example.com")))
(t/is (false? (ssrf/safe-url? "dict://example.com")))
(t/is (false? (ssrf/safe-url? "data:text/html,<h1>hi</h1>")))
(t/is (false? (ssrf/safe-url? "jar:http://example.com!/foo")))
(t/is (false? (ssrf/safe-url? "javascript:alert(1)"))))
(t/deftest validate-url-blocks-loopback
(t/is (false? (ssrf/safe-url? "http://127.0.0.1/foo")))
(t/is (false? (ssrf/safe-url? "http://127.0.0.2/foo")))
(t/is (false? (ssrf/safe-url? "http://[::1]/foo"))))
(t/deftest validate-url-blocks-any-local
(t/is (false? (ssrf/safe-url? "http://0.0.0.0/foo")))
(t/is (false? (ssrf/safe-url? "http://[::]/foo"))))
(t/deftest validate-url-blocks-link-local
(t/is (false? (ssrf/safe-url? "http://169.254.169.254/latest/meta-data/")))
(t/is (false? (ssrf/safe-url? "http://169.254.1.1/foo")))
(t/is (false? (ssrf/safe-url? "http://[fe80::1]/foo"))))
(t/deftest validate-url-blocks-site-local
(t/is (false? (ssrf/safe-url? "http://10.0.0.1/foo")))
(t/is (false? (ssrf/safe-url? "http://172.16.0.1/foo")))
(t/is (false? (ssrf/safe-url? "http://192.168.1.1/foo"))))
(t/deftest validate-url-blocks-cloud-metadata
(t/is (false? (ssrf/safe-url? "http://169.254.169.254/latest/meta-data/iam/security-credentials/role")))
(t/is (false? (ssrf/safe-url? "http://[fd00:ec2::254]/foo"))))
(t/deftest validate-url-blocks-carrier-grade-nat
(t/is (false? (ssrf/safe-url? "http://100.64.0.1/foo")))
(t/is (false? (ssrf/safe-url? "http://100.127.255.255/foo")))
;; Just outside the range should be allowed (but may be blocked by DNS resolution failing)
;; We test boundary: 100.63.255.255 is outside 100.64.0.0/10
;; But we can't easily test the "allowed" side without DNS, so we test the blocked side.
;; Test RFC reserved ranges
(t/is (false? (ssrf/safe-url? "http://240.0.0.1/foo")))
(t/is (false? (ssrf/safe-url? "http://255.255.255.255/foo"))))
(t/deftest validate-url-blocks-ipv6-ula
(t/is (false? (ssrf/safe-url? "http://[fd00::1]/foo")))
(t/is (false? (ssrf/safe-url? "http://[fc00::1]/foo"))))
(t/deftest validate-url-blocks-encoded-loopback
;; Decimal encoding of 127.0.0.1 = 2130706433
;; InetAddress normalizes this to 127.0.0.1
(t/is (false? (ssrf/safe-url? "http://2130706433/foo")))
;; Hex encoding 0x7f000001
(t/is (false? (ssrf/safe-url? "http://0x7f000001/foo"))))
(t/deftest validate-url-blocks-ipv4-mapped-loopback
(t/is (false? (ssrf/safe-url? "http://[::ffff:127.0.0.1]/foo"))))
(t/deftest validate-url-blocks-multicast
(t/is (false? (ssrf/safe-url? "http://224.0.0.1/foo"))))
(t/deftest validate-url-blocks-missing-scheme
(t/is (false? (ssrf/safe-url? "example.com/foo")))
(t/is (false? (ssrf/safe-url? ""))))
(t/deftest validate-url-blocks-missing-host
(t/is (false? (ssrf/safe-url? "http:///path")))
(t/is (false? (ssrf/safe-url? "http://"))))
(t/deftest validate-url-resolves-dns
;; DNS-resolved internal: we use with-redefs to simulate
(let [original ssrf/resolve-host]
(with-redefs [ssrf/resolve-host
(fn [hostname]
(if (= hostname "evil.internal")
(into-array java.net.InetAddress
[(java.net.InetAddress/getByName "127.0.0.1")])
(original hostname)))]
(t/is (false? (ssrf/safe-url? "http://evil.internal/foo")))
;; A hostname that fails DNS resolution
(t/is (false? (ssrf/safe-url? "http://nonexistent.invalid/foo"))))))
(t/deftest validate-url-dns-all-addresses-must-be-safe
;; If a hostname resolves to both a public and a private IP, it must be blocked
(let [original ssrf/resolve-host]
(with-redefs [ssrf/resolve-host
(fn [hostname]
(if (= hostname "split-brain.example")
(into-array java.net.InetAddress
[(java.net.InetAddress/getByName "1.1.1.1")
(java.net.InetAddress/getByName "127.0.0.1")])
(original hostname)))]
(t/is (false? (ssrf/safe-url? "http://split-brain.example/foo"))))))
(t/deftest validate-url-allowlist-override
(let [original-get cf/get]
(with-redefs [cf/get (fn [key & args]
(if (= key :ssrf-allowed-hosts)
#{"localhost"}
(apply original-get key args)))]
;; localhost resolves to 127.0.0.1 which would normally be blocked
(t/is (true? (ssrf/safe-url? "http://localhost:6060/foo"))))))
(t/deftest validate-url-extra-cidrs
(binding [ssrf/extra-blocked-cidrs #{(ssrf/parse-cidr "203.0.113.0/24")}]
(t/is (false? (ssrf/safe-url? "http://203.0.113.1/foo")))))
(t/deftest validate-url-throw-on-blocked
(try
(ssrf/validate-uri "http://127.0.0.1/foo")
(t/is false "should have thrown")
(catch Exception e
(t/is (= :validation (:type (ex-data e))))
(t/is (= :ssrf-blocked-target (:code (ex-data e)))))))
;; ---------------------------------------------------------------------------
;; http/req automatic SSRF validation
;; ---------------------------------------------------------------------------
(t/deftest http-req-validates-ssrf-by-default
;; `http/req` should invoke ssrf/validate-uri before sending the request.
;; We verify this by checking that a blocked URI raises an SSRF error
;; without ever reaching the network (validate-uri throws first).
(try
(http/req {} {:method :get :uri "http://127.0.0.1/secret"})
(t/is false "should have thrown an SSRF error")
(catch Exception e
(t/is (= :ssrf-blocked-target (:code (ex-data e)))))))
(t/deftest http-req-skip-ssrf-check-bypasses-validation
;; When :skip-ssrf-check? true is passed, ssrf/validate-uri must NOT be
;; called. We verify by patching validate-uri to record whether it was called.
(let [called? (atom false)]
(with-redefs [ssrf/validate-uri (fn [_] (reset! called? true))]
;; The request will fail at the network level (no real server), but that's
;; fine — we only care that validate-uri was not called beforehand.
(try
(http/req {} {:method :get :uri "http://127.0.0.1/secret"} {:skip-ssrf-check? true})
(catch Exception _))
(t/is (false? @called?) "validate-uri should not be called when :skip-ssrf-check? is true"))))
(t/deftest http-req-with-redirects-validates-ssrf-by-default
;; req-with-redirects must also validate the initial URI automatically.
(try
(http/req-with-redirects {} {:method :get :uri "http://10.0.0.1/internal"})
(t/is false "should have thrown an SSRF error")
(catch Exception e
(t/is (= :ssrf-blocked-target (:code (ex-data e)))))))
(t/deftest http-req-with-redirects-skip-ssrf-check-bypasses-validation
(let [called? (atom false)]
(with-redefs [ssrf/validate-uri (fn [_] (reset! called? true))]
(try
(http/req-with-redirects {} {:method :get :uri "http://10.0.0.1/internal"} {:skip-ssrf-check? true})
(catch Exception _))
(t/is (false? @called?) "validate-uri should not be called when :skip-ssrf-check? is true"))))

View File

@ -279,3 +279,10 @@
[o]
#?(:cljs (.-byteLength ^js o)
:clj (.capacity ^ByteBuffer o)))
#?(:clj
(defn set-order
"Set the byte order on a ByteBuffer. Returns the buffer."
[^ByteBuffer buffer ^ByteOrder order]
(.order buffer order)
buffer))

View File

@ -486,4 +486,3 @@
a (+ (* ah 100) (* av 10))
b (+ (* bh 100) (* bv 10))]
(compare a b)))

View File

@ -379,7 +379,7 @@
:else
(assoc object key value)))
object))
changes)))
(without-nils changes))))
(defn remove-at-index
"Takes a vector and returns a vector with an element in the

View File

@ -192,6 +192,9 @@
(def ^:const background-quaternary "#2e3434")
(def ^:const background-quaternary-light "#eef0f2")
(def ^:const canvas "#E8E9EA")
(def ^:const default-pixel-grid-color "#0070E4")
(def ^:const default-pixel-grid-opacity 0.2)
(def names
{"aliceblue" "#f0f8ff"

View File

@ -6,7 +6,7 @@
(ns app.common.types.stroke
(:require
[app.common.colors :as clr]))
[app.common.types.color :as clr]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMAS

View File

@ -113,7 +113,7 @@
(t/deftest ac-rgb-to-hsl
;; Black: h=0, s=0.0, l=0.0 (s is 0.0 not 0 on JVM, and ##NaN for white)
(let [[h s l] (c/rgb->hsl [0 0 0])]
(let [[h _s l] (c/rgb->hsl [0 0 0])]
(t/is (= 0 h))
(t/is (mth/close? l 0.0)))
;; White: h=0, s=##NaN (achromatic), l=1.0

View File

@ -532,7 +532,12 @@
(t/is (= {nil 0 :b 2} (d/patch-object {nil 0 :a 1 :b 2} {:a nil})))
;; transducer arity (1-arg returns a fn)
(let [f (d/patch-object {:a 99})]
(t/is (= {:a 99 :b 2} (f {:a 1 :b 2})))))
(t/is (= {:a 99 :b 2} (f {:a 1 :b 2}))))
;; when object is nil, nil values in changes are stripped (not preserved)
(t/is (= {} (d/patch-object nil {:a nil})))
(t/is (= {:a 1} (d/patch-object nil {:a 1 :b nil})))
;; nested path: patching a key that doesn't exist creates a new map without nils
(t/is (= {:b {:y 2}} (d/patch-object {:b nil} {:b {:x nil :y 2}}))))
(t/deftest without-obj-test
(t/is (= [1 3] (d/without-obj [1 2 3] 2)))

View File

@ -615,9 +615,10 @@
(defn create-webhook
[{:keys [uri mtype is-active] :as params}]
(dm/assert! (contains? valid-mtypes mtype))
(dm/assert! (boolean? is-active))
(dm/assert! (u/uri? uri))
(assert (contains? valid-mtypes mtype))
(assert (boolean? is-active))
(assert (u/uri? uri))
(ptk/reify ::create-webhook
ptk/WatchEvent

View File

@ -555,10 +555,9 @@
;; change parent to fixed
(and row? auto-height? (every? ctl/fill-height? all-children))
(assoc :layout-item-v-sizing :fix))))
(defn update-layout-child
([ids changes] (update-layout-child ids changes nil))
([ids changes options]
([ids attrs] (update-layout-child ids attrs nil))
([ids attrs options]
(ptk/reify ::update-layout-child
ptk/WatchEvent
(watch [_ state _]
@ -568,20 +567,20 @@
children-ids (->> ids (mapcat #(cfh/get-children-ids objects %)))
parent-ids (->> ids (map #(cfh/get-parent-id objects %)))
undo-id (js/Symbol)
margin-attrs (-> (get changes :layout-item-margin)
margin-attrs (-> (get attrs :layout-item-margin)
keys
set)]
(rx/of (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes ids (d/patch-object changes)
(dwsh/update-shapes ids (d/patch-object attrs)
(cond-> options
(seq margin-attrs)
(assoc :changed-sub-attr margin-attrs)))
(dwsh/update-shapes children-ids (partial fix-child-sizing objects changes) options)
(dwsh/update-shapes children-ids (partial fix-child-sizing objects attrs) options)
(dwsh/update-shapes
parent-ids
(fn [parent objects]
(-> parent
(fix-parent-sizing objects (set ids) changes)
(fix-parent-sizing objects (set ids) attrs)
(cond-> (ctl/grid-layout? parent)
(ctl/assign-cells objects))))
(merge options {:with-objects? true}))

View File

@ -1100,7 +1100,31 @@
(defn- extract-status
[error-code]
(-> error-code (str/split #":") second))
(-> error-code (str/split #":") second str/trim))
(defn- translate-error-hint
[hint]
(cond
(= hint "invalid-uri")
(tr "errors.webhooks.invalid-uri")
(= hint "ssl-validation-error")
(tr "errors.webhooks.ssl-validation")
(= hint "timeout")
(tr "errors.webhooks.timeout")
(= hint "connection-error")
(tr "errors.webhooks.connection")
(str/starts-with? hint "unexpected-status")
(tr "errors.webhooks.unexpected-status" (extract-status hint))
(str/starts-with? hint "blocked-request")
(tr "errors.webhooks.connection")
:else
(tr "errors.webhooks.unexpected")))
(mf/defc webhook-modal
{::mf/register modal/components
@ -1109,7 +1133,7 @@
(let [initial (mf/with-memo []
(or (some-> webhook (update :uri str))
{:is-active false :mtype "application/json"}))
{:is-active false :mtype "application/json" :uri "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}))
form (fm/use-form :schema schema:webhook-form
:initial initial)
on-success
@ -1121,25 +1145,14 @@
on-error
(mf/use-fn
(fn [form error]
(let [{:keys [type code hint]} (ex-data error)]
(fn [form cause]
(let [{:keys [type code hint] :as error} (ex-data cause)]
(if (and (= type :validation)
(= code :webhook-validation))
(let [message (cond
(= hint "unknown")
(tr "errors.webhooks.unexpected")
(= hint "invalid-uri")
(tr "errors.webhooks.invalid-uri")
(= hint "ssl-validation-error")
(tr "errors.webhooks.ssl-validation")
(= hint "timeout")
(tr "errors.webhooks.timeout")
(= hint "connection-error")
(tr "errors.webhooks.connection")
(str/starts-with? hint "unexpected-status")
(tr "errors.webhooks.unexpected-status" (extract-status hint)))]
(swap! form assoc-in [:errors :uri] {:message message}))
(rx/throw error)))))
(let [message (translate-error-hint hint)]
(swap! form assoc-in [:extra-errors :uri] {:message message})
(rx/empty))
(rx/throw cause)))))
on-create-submit
(mf/use-fn
@ -1169,6 +1182,7 @@
(if (:id data)
(on-update-submit form)
(on-create-submit form)))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:& fm/form {:form form :on-submit on-submit}
@ -1291,8 +1305,10 @@
(dm/str " " (tr "errors.webhooks.ssl-validation"))
(str/starts-with? error-code "unexpected-status")
(dm/str " " (tr "errors.webhooks.unexpected-status" (extract-status error-code))))))]
(dm/str " " (tr "errors.webhooks.unexpected-status" (extract-status error-code)))
:else
(dm/str " " (tr "errors.webhooks.unexpected")))))]
[:div {:class (stl/css :table-row :webhook-row)}
[:div {:class (stl/css :table-field :last-delivery)

View File

@ -147,6 +147,7 @@
:icon i/margin-top-bottom
:min 0
:attr :m1
:default nil
:input-type :vertical-margin
:property "Vertical margin "
:nillable true
@ -178,6 +179,7 @@
:min 0
:attr :m2
:align :right
:default nil
:input-type :horizontal-margin
:property "Horizontal margin"
:nillable true
@ -270,6 +272,7 @@
:icon i/margin-top
:class (stl/css :top-margin-wrapper)
:min 0
:default nil
:attr :m1
:input-type :vertical-margin
:property "Top margin"
@ -298,6 +301,7 @@
:icon i/margin-right
:class (stl/css :right-margin-wrapper)
:min 0
:default nil
:attr :m2
:align :right
:input-type :horizontal-margin
@ -329,6 +333,7 @@
:class (stl/css :bottom-margin-wrapper)
:min 0
:attr :m3
:default nil
:align :right
:input-type :vertical-margin
:property "Bottom margin"
@ -358,6 +363,7 @@
:icon i/margin-left
:class (stl/css :left-margin-wrapper)
:min 0
:default nil
:attr :m4
:property "Left margin"
:input-type :horizontal-margin

View File

@ -32,10 +32,6 @@
(-> (l/key :pixel-grid-opacity)
(l/derived refs/workspace-page)))
;; Default pixel grid color shown in the picker when the user hasn't
;; set a custom one. Matches the legacy hardcoded CSS variable.
(def ^:private default-pixel-grid-color "#0070E4")
(mf/defc options*
{::mf/wrap [mf/memo]}
[]
@ -54,35 +50,42 @@
{:color (d/nilv background clr/canvas)
:opacity 1})
grid (mf/with-memo [grid-color grid-alpha]
{:color (d/nilv grid-color default-pixel-grid-color)
:opacity (d/nilv grid-alpha 0.2)})]
grid-color (mf/with-memo [grid-color grid-alpha]
{:color (d/nilv grid-color clr/default-pixel-grid-color)
:opacity (d/nilv grid-alpha clr/default-pixel-grid-opacity)})]
[:div {:class (stl/css :element-set)}
[:div {:class (stl/css :element-title)}
[:> title-bar* {:collapsable false
:title (tr "workspace.options.canvas-background")
:class (stl/css :title-spacing-page)}]]
[:div {:class (stl/css :element-content)}
[:* [:div {:class (stl/css :element-set)}
[:div {:class (stl/css :element-title)}
[:> title-bar* {:collapsable false
:title (tr "workspace.options.canvas-background")
:class (stl/css :title-spacing-page)}]]
[:div {:class (stl/css :element-content)}
[:> color-row*
{:disable-gradient true
:disable-opacity true
:disable-image true
:title (tr "workspace.options.canvas-background")
:color color
:on-change on-change
:origin :canvas
:on-open on-open
:on-close on-close}]
[:> color-row*
{:disable-gradient true
:disable-opacity true
:disable-image true
:title (tr "workspace.options.canvas-background")
:color color
:on-change on-change
:origin :canvas
:on-open on-open
:on-close on-close}]]]
[:> color-row*
{:disable-gradient true
:disable-image true
:title "Pixel grid color"
:color grid
:on-change on-grid-change
:origin :pixel-grid
:on-open on-open
:on-close on-close}]]]))
[:div {:class (stl/css :element-set)}
[:div {:class (stl/css :element-title)}
[:> title-bar* {:collapsable false
:title (tr "workspace.options.pixel grid-color")
:class (stl/css :title-spacing-page)}]]
[:div {:class (stl/css :element-content)}
[:> color-row*
{:disable-gradient true
:disable-image true
:title (tr "workspace.options.pixel grid-color")
:color grid-color
:on-change on-grid-change
:origin :pixel-grid
:on-open on-open
:on-close on-close}]]]]))

View File

@ -65,6 +65,7 @@
block-size: $sz-32;
min-inline-size: 0;
inline-size: 100%;
max-inline-size: 100%;
padding: 0;
margin-inline-end: 0;
border: $b-1 solid var(--color-name-wrapper-boder-color);

View File

@ -7,7 +7,6 @@
(ns app.main.ui.workspace.tokens.management.forms.controls.color-input
(:require-macros [app.main.style :as stl])
(:require
[app.common.colors :as color]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.color :as cl]
@ -171,8 +170,8 @@
default-bullet-color
(case (:theme profile)
"light"
color/background-quaternary-light
color/background-quaternary)
cl/background-quaternary-light
cl/background-quaternary)
hex
(if valid-color
(tinycolor/->hex-string (tinycolor/valid-color valid-color))
@ -337,8 +336,8 @@
default-bullet-color
(case (:theme profile)
"light"
color/background-quaternary-light
color/background-quaternary)
cl/background-quaternary-light
cl/background-quaternary)
hex
(if valid-color

View File

@ -200,7 +200,7 @@
on-click (actions/on-click hover selected edition path-drawing? drawing-tool space? selrect z?)
on-context-menu (actions/on-context-menu hover hover-ids read-only?)
on-double-click (actions/on-double-click hover hover-ids hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?)
on-double-click (actions/on-double-click hover hover-ids selected hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?)
comp-inst-ref (mf/use-ref false)
on-drag-enter (actions/on-drag-enter comp-inst-ref)

View File

@ -196,10 +196,10 @@
(st/emit! (dw/increase-zoom pt)))))))))
(defn on-double-click
[hover hover-ids hover-top-frame-id drawing-path? objects edition drawing-tool z? read-only?]
[hover hover-ids selected hover-top-frame-id drawing-path? objects edition drawing-tool z? read-only?]
(mf/use-callback
(mf/deps @hover @hover-ids @hover-top-frame-id drawing-path? edition drawing-tool @z? read-only?)
(mf/deps @hover @hover-ids selected @hover-top-frame-id drawing-path? edition drawing-tool @z? read-only?)
(fn [event]
(dom/stop-propagation event)
(when-not @z?
@ -208,7 +208,16 @@
alt? (kbd/alt? event)
meta? (kbd/meta? event)
{:keys [id type] :as shape} (or @hover (get objects (first @hover-ids)))
selected-id-under-cursor
(->> @hover-ids
(filter selected)
last)
{:keys [id type] :as shape}
(or (when selected-id-under-cursor
(get objects selected-id-under-cursor))
@hover
(get objects (first @hover-ids)))
editable? (contains? #{:text :rect :path :image :circle} type)

View File

@ -257,7 +257,7 @@
on-click (actions/on-click hover selected edition path-drawing? drawing-tool space? selrect z?)
on-context-menu (actions/on-context-menu hover hover-ids read-only?)
on-double-click (actions/on-double-click hover hover-ids hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?)
on-double-click (actions/on-double-click hover hover-ids selected hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?)
comp-inst-ref (mf/use-ref false)
on-drag-enter (actions/on-drag-enter comp-inst-ref)

View File

@ -6377,6 +6377,10 @@ msgstr "Toggle blur"
msgid "workspace.options.canvas-background"
msgstr "Canvas background"
#: src/app/main/ui/workspace/sidebar/options/page.cljs
msgid "workspace.options.pixel grid-color"
msgstr "Pixel grid color"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:567
msgid "workspace.options.clip-content"
msgstr "Clip content"

View File

@ -6259,6 +6259,10 @@ msgstr "Mostrar/ocultar desenfoque"
msgid "workspace.options.canvas-background"
msgstr "Color de fondo"
#: src/app/main/ui/workspace/sidebar/options/page.cljs
msgid "workspace.options.pixel grid-color"
msgstr "Color de la rejilla de pixeles"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:567
msgid "workspace.options.clip-content"
msgstr "Truncar contenido"

View File

@ -236,6 +236,13 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
console.log("Code execution result:", result);
// transform a top-level Uint8Array result into a compact base64 envelope to avoid the
// ~10x JSON expansion that occurs when JSON.stringify serializes typed arrays as objects
// with numeric string keys (see penpot/penpot#9420)
if (result instanceof Uint8Array) {
result = ExecuteCodeTaskHandler.encodeBytesAsBase64Envelope(result);
}
// return result and captured log
let resultData: ExecuteCodeTaskResultData<any> = {
result: result,
@ -243,4 +250,23 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
};
task.sendSuccess(resultData);
}
/**
* Base64-encodes the given bytes and wraps the result in a tagged envelope that the
* server side recognizes (see `ImageContent.byteData`).
*
* @param bytes - the raw binary data to encode
* @returns an envelope of the form `{ __type: "base64", data: <base64 string> }`
*/
private static encodeBytesAsBase64Envelope(bytes: Uint8Array): { __type: "base64"; data: string } {
// build the binary string in chunks; calling `String.fromCharCode(...bytes)` directly
// would overflow the call stack for large arrays
const chunkSize = 0x8000;
let binary = "";
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode.apply(null, chunk as unknown as number[]);
}
return { __type: "base64", data: btoa(binary) };
}
}

View File

@ -92,7 +92,7 @@ export class PenpotMcpServer {
this.tools = this.initTools();
this.pluginBridge = new PluginBridge(this, this.webSocketPort);
this.replServer = new ReplServer(this.pluginBridge, this.replPort);
this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host);
}
/**

View File

@ -10,6 +10,7 @@ const KEEP_ALIVE_TIME = 30000; // 30 seconds
interface ClientConnection {
socket: WebSocket;
userToken: string | null;
pingInterval: NodeJS.Timeout;
}
/**
@ -40,8 +41,6 @@ export class PluginBridge {
* channel between the MCP mcpServer and Penpot plugin instances.
*/
private setupWebSocketHandlers(): void {
let interval: NodeJS.Timeout | undefined;
this.wsServer.on("connection", (ws: WebSocket, request: http.IncomingMessage) => {
// extract userToken from query parameters
const url = new URL(request.url!, `ws://${request.headers.host}`);
@ -60,13 +59,19 @@ export class PluginBridge {
this.logger.info("New WebSocket connection established");
}
// start the per-connection keep-alive ping interval
const pingInterval = setInterval(() => {
ws.ping();
}, KEEP_ALIVE_TIME);
// register the client connection with both indexes
const connection: ClientConnection = { socket: ws, userToken };
const connection: ClientConnection = { socket: ws, userToken, pingInterval };
this.connectedClients.set(ws, connection);
if (userToken) {
// ensure only one connection per userToken
if (this.clientsByToken.has(userToken)) {
this.logger.warn("Duplicate connection for given user token; rejecting new connection");
this.removeConnection(ws);
ws.close(1008, "Duplicate connection for given user token; close previous connection first.");
return;
}
@ -86,36 +91,39 @@ export class PluginBridge {
ws.on("close", () => {
this.logger.info("WebSocket connection closed");
const connection = this.connectedClients.get(ws);
this.connectedClients.delete(ws);
if (connection?.userToken) {
this.clientsByToken.delete(connection.userToken);
}
if (interval) {
clearInterval(interval);
}
this.removeConnection(ws);
});
ws.on("error", (error) => {
this.logger.error(error, "WebSocket connection error");
const connection = this.connectedClients.get(ws);
this.connectedClients.delete(ws);
if (connection?.userToken) {
this.clientsByToken.delete(connection.userToken);
}
if (interval) {
clearInterval(interval);
}
this.removeConnection(ws);
});
interval = setInterval(() => {
ws?.ping();
}, KEEP_ALIVE_TIME);
});
this.logger.info("WebSocket mcpServer started on port %d", this.port);
}
/**
* Removes a client connection and releases all resources associated with it.
*
* Clears the per-connection keep-alive interval and removes the connection
* from both the socket-keyed and token-keyed indexes. Safe to call with a
* socket that is not (or no longer) registered.
*
* @param ws - The WebSocket whose connection state should be removed
*/
private removeConnection(ws: WebSocket): void {
const connection = this.connectedClients.get(ws);
if (!connection) {
return;
}
clearInterval(connection.pingInterval);
this.connectedClients.delete(ws);
if (connection.userToken) {
this.clientsByToken.delete(connection.userToken);
}
}
/**
* Handles responses from the plugin for completed tasks.
*

View File

@ -17,13 +17,16 @@ export class ReplServer {
private readonly logger = createLogger("ReplServer");
private readonly app: express.Application;
private readonly port: number;
private readonly host: string;
private server: any;
constructor(
private readonly pluginBridge: PluginBridge,
port: number = 4403
port: number = 4403,
host: string = "localhost"
) {
this.port = port;
this.host = host;
this.app = express();
this.setupMiddleware();
this.setupRoutes();
@ -86,9 +89,9 @@ export class ReplServer {
*/
public async start(): Promise<void> {
return new Promise((resolve) => {
this.server = this.app.listen(this.port, () => {
this.server = this.app.listen(this.port, this.host, () => {
this.logger.info(`REPL server started on port ${this.port}`);
this.logger.info(`REPL interface URL: http://${this.pluginBridge.mcpServer.host}:${this.port}`);
this.logger.info(`REPL interface URL: http://${this.host}:${this.port}`);
resolve();
});
});

View File

@ -37,19 +37,26 @@ export class ImageContent implements ImageItem {
/**
* Utility function for ensuring a consistent Uint8Array representation of byte data.
* Input can be either a Uint8Array or an object (as obtained from JSON conversion of Uint8Array
* from the plugin).
* Input can be one of:
* - a `Uint8Array` (already in the desired form);
* - a base64 envelope `{ __type: "base64", data: <base64 string> }` produced by the plugin
* to avoid the ~10x JSON expansion of typed arrays (see penpot/penpot#9420);
* - a numeric-keyed object obtained from `JSON.stringify`-ing a `Uint8Array` (legacy fallback).
*
* @param data - data as Uint8Array or as object (from JSON conversion of Uint8Array)
* @return data as Uint8Array
* @param data - data as `Uint8Array`, base64 envelope, or numeric-keyed object
* @return data as `Uint8Array`
*/
public static byteData(data: Uint8Array | object): Uint8Array {
if (typeof data === "object") {
// convert object (as obtained from JSON conversion of Uint8Array) back to Uint8Array
return new Uint8Array(Object.values(data) as number[]);
} else {
if (data instanceof Uint8Array) {
return data;
}
// recognize the base64 envelope produced by the plugin's ExecuteCodeTaskHandler
const envelope = data as { __type?: unknown; data?: unknown };
if (envelope.__type === "base64" && typeof envelope.data === "string") {
return new Uint8Array(Buffer.from(envelope.data, "base64"));
}
// legacy fallback: object (as obtained from JSON conversion of Uint8Array) back to Uint8Array
return new Uint8Array(Object.values(data) as number[]);
}
}

View File

@ -16,9 +16,9 @@
"fmt": "./scripts/fmt"
},
"devDependencies": {
"@github/copilot": "^1.0.36",
"@github/copilot": "^1.0.43",
"@types/node": "^25.6.0",
"esbuild": "^0.28.0",
"opencode-ai": "^1.14.28"
"opencode-ai": "^1.14.40"
}
}

164
pnpm-lock.yaml generated
View File

@ -9,8 +9,8 @@ importers:
.:
devDependencies:
'@github/copilot':
specifier: ^1.0.36
version: 1.0.36
specifier: ^1.0.43
version: 1.0.43
'@types/node':
specifier: ^25.6.0
version: 25.6.0
@ -18,8 +18,8 @@ importers:
specifier: ^0.28.0
version: 0.28.0
opencode-ai:
specifier: ^1.14.28
version: 1.14.28
specifier: ^1.14.40
version: 1.14.40
packages:
@ -179,44 +179,44 @@ packages:
cpu: [x64]
os: [win32]
'@github/copilot-darwin-arm64@1.0.36':
resolution: {integrity: sha512-5qkb7frTS4K/LdTDLrzKo78VR4aw/EZ6JzLz4KfmaW4UYyPiNirExDFXa/By22X0o8YMfOp4MCA2KSCAxKdgTg==}
'@github/copilot-darwin-arm64@1.0.43':
resolution: {integrity: sha512-VMaWfoUwIt19TGzmvTv/In5ITgFWfu91ZILt4Lb77gSmVbwbs+DahP2lbvM1s/GtGwOknwhOJLp4q2WMgK3CoQ==}
cpu: [arm64]
os: [darwin]
hasBin: true
'@github/copilot-darwin-x64@1.0.36':
resolution: {integrity: sha512-AdsM8QtM5QSzMLpavLREh8HALO5G+VWzGNQqIHu4f0YQC/s1cGoiwo3wsgkpxRcLGBykFc+bDX3yK3MDQ8XvSw==}
'@github/copilot-darwin-x64@1.0.43':
resolution: {integrity: sha512-lCxg75zbgtWggb1p+IHhlhmulQj7BKIc+9pUsTwJ3Mjt51kiUYmD6LF2BD1/Ed4M3GMumSu4UTSIv9pL96n0Wg==}
cpu: [x64]
os: [darwin]
hasBin: true
'@github/copilot-linux-arm64@1.0.36':
resolution: {integrity: sha512-n7K1I6r0ggOJ4A9uAMS11USTvn6BKtAwvrOkzEaeRK89VNUJzpTe6p0mE13ItzRe5eot9WLBQOxvXLtL9f6E+g==}
'@github/copilot-linux-arm64@1.0.43':
resolution: {integrity: sha512-Jr1rvt/Syz5oVSiU53keRKEV8f5xTLkmiB2qasAV6Emk7K82/BZW5HfW8cDadfHnlAS1+UVPpoO+8ykgx4dikw==}
cpu: [arm64]
os: [linux]
hasBin: true
'@github/copilot-linux-x64@1.0.36':
resolution: {integrity: sha512-wBtCdR3ITZcq07BJbkwHfwI6ayiwbH5pF1ex+Ycl4UI+Lf1vP9eQD6wJppPgsrjwFcdeWRThaYTPCRTkSGHv5g==}
'@github/copilot-linux-x64@1.0.43':
resolution: {integrity: sha512-uyWyPpcwMC1tIHJTA1OPzIm1i/Eeku0NjFzPYnFvpfGug9pkL4xcUr8A2UNl8cVvxq/VQMwtYckV3G12ySJTFw==}
cpu: [x64]
os: [linux]
hasBin: true
'@github/copilot-win32-arm64@1.0.36':
resolution: {integrity: sha512-0GzZUZQn07alI8BgbzK0NlR5+ta/Rd0sWmd8kbRCns7oybAIkSALy6BKVwJmVHtXUi6h4iUE8oiFhkn0spymvw==}
'@github/copilot-win32-arm64@1.0.43':
resolution: {integrity: sha512-vY3rwkW1h7ixSqYd9XT/xGjxkepvQVJK2q1sYhSPBN4Wvuk37fRjN7ox3QU1lhpxlYQMc/g+ZR58u5cx31n1lw==}
cpu: [arm64]
os: [win32]
hasBin: true
'@github/copilot-win32-x64@1.0.36':
resolution: {integrity: sha512-UBX9qj0McCK/SLq93XIr1i80fj3b3XmE3befVFrzxQuTeOoxLURN35vi7W+4x+4ZfsDHQpRTlJNjZw9w0fPr+Q==}
'@github/copilot-win32-x64@1.0.43':
resolution: {integrity: sha512-AjGsebDbJmBxe9FFf2McSN5r2Joi8cFY879lxaQaXxfGjzOHblzvbhflbsX9x2GnvCfDdDiow413WvOGqKDH8g==}
cpu: [x64]
os: [win32]
hasBin: true
'@github/copilot@1.0.36':
resolution: {integrity: sha512-x0N5wLzw+tANzb+vCFYLHn3BV3qii2oyn14wC20RO7SsS8/YeBH8olvwlDLJ4PB0mL17QOiytNCdkvjvprm28w==}
'@github/copilot@1.0.43':
resolution: {integrity: sha512-2FO825Aq4bwmHcXVyplW+CpZaJFUYjqtqjBGnueM31gu4ufn6ReurzB2swBQ6bn4Pquyy2KeodMRPpT6JaLMhw==}
hasBin: true
'@types/node@25.6.0':
@ -227,67 +227,67 @@ packages:
engines: {node: '>=18'}
hasBin: true
opencode-ai@1.14.28:
resolution: {integrity: sha512-ZPukJNvujSVa+LVoXvj2ciUV57UcnuxmMtzpFQBYd6fbhjeT1vMC6jCurO/5mIp76fiPmGM7ilzRXVeY6bIwPw==}
opencode-ai@1.14.40:
resolution: {integrity: sha512-2Iwqe3wLdAGWxWGnTAPeOv7QNcm4stuWL2VQ7FM6OxKRE0h9zGoKtJ/bnX193hR6/QH81goOrocKfamRk4pM/A==}
hasBin: true
opencode-darwin-arm64@1.14.28:
resolution: {integrity: sha512-Gu2vZYACAeoewfPhgJDAaScwRo1K5YZq7tVpPKw2rpul34OpOPLk4oB4Pmr539iWiagK+DLuUxnbJIbRRYCS5g==}
opencode-darwin-arm64@1.14.40:
resolution: {integrity: sha512-dVItdZeaJw9xRtpVKQ0K9sigcaf8p/3nr3hW/NxJY3I/L+op29Wly5jnH0sdxq05gV/cGmJ0BTJzoYizXDDoKg==}
cpu: [arm64]
os: [darwin]
opencode-darwin-x64-baseline@1.14.28:
resolution: {integrity: sha512-/KsZkZh5oh6urHWwIHJudS6sedBil59E/4o7/7TuxPy/pOdRlSlSWVkMJd20AmqM4G/qILF/GthXy3D2+f99Tg==}
opencode-darwin-x64-baseline@1.14.40:
resolution: {integrity: sha512-ab2vqJmTPG48U2/6xZvPcZ+HdQBHFt6rpS0w2gURJobXHWgbW+uytfX4MCBprunS3K1DL7qoVWIgJRRu3YEMzQ==}
cpu: [x64]
os: [darwin]
opencode-darwin-x64@1.14.28:
resolution: {integrity: sha512-D6BnAXlSdQDRtZgAg6OxWT6CEzbbONnlYof9hdPbaIIaNyBLjqK+Er2O1rrbiXFhSbs7YHBiDoGd+nNUymx4Ng==}
opencode-darwin-x64@1.14.40:
resolution: {integrity: sha512-Mcfh2LP/QA3LJ03m6OF1FNCMO/O0PsT94Mw7NXbcmi6g+nQ6I2vHensIjKtTJM3LSmosOlTduxdv/b82AVFt+w==}
cpu: [x64]
os: [darwin]
opencode-linux-arm64-musl@1.14.28:
resolution: {integrity: sha512-7R1GHqSg/UuT9r77GF2skh8r66WkZcphmDWAWaV2dmptJlxEJeV9I2jbE2i8Ctp4BzPUexFqfSoBA82S9Dcf+A==}
opencode-linux-arm64-musl@1.14.40:
resolution: {integrity: sha512-5COW6bDCCbM/BZcrkiVR91VqrP9vD8J4uS7cHLPbdQHaDYETxOhHWgsYnp9JXWEbcDi6lBaxapj11RZPyUJziw==}
cpu: [arm64]
os: [linux]
opencode-linux-arm64@1.14.28:
resolution: {integrity: sha512-jdTrs4YpPGFGZOMLuiaSfOUzkjAA+lnIEaW6HYLvaey3WsBnu3S4utaBhXURincp20H1JPQcahDOe+jjGZH7xw==}
opencode-linux-arm64@1.14.40:
resolution: {integrity: sha512-nqsnGxV237XWCbaMtFXOiN1ndukNAhAmbe5dnDThSFOKcgwMs6VKuhHa+iOpQL1r6FZ5eE3SU6IKgscVM/eAAQ==}
cpu: [arm64]
os: [linux]
opencode-linux-x64-baseline-musl@1.14.28:
resolution: {integrity: sha512-GKxZXj8/Mbutfs1DW4v0/rEWcAQrD/RUI9kV9VhMoNA8vUt0nuA3H9UvbFXh9EJj2C+RBSPLlMGal++oCH4c4w==}
opencode-linux-x64-baseline-musl@1.14.40:
resolution: {integrity: sha512-l0xfAHVy2klD181UbKUjeMVltqEn+9DTP/bZ9ZyDuiFbVNeKfwiU7HMkPw5GFnSnlQTUrDHniXMwgBcI5yrRcA==}
cpu: [x64]
os: [linux]
opencode-linux-x64-baseline@1.14.28:
resolution: {integrity: sha512-Dtl+xjEAKaWNk2l3iC9ebwi79BkChHIdtx97ksZKTLjAeR424Zh3vnjuWjpMYk9YAnesVlwL8y4kHs2Y736Zpw==}
opencode-linux-x64-baseline@1.14.40:
resolution: {integrity: sha512-XmXJHicRNylnGz/nzJCmsXy6mozjmrrXHntrrS3DWgq8UiSdg9wB2Hsq+1EDbPjzF2NzQaVi6/9/AyERuxW2iA==}
cpu: [x64]
os: [linux]
opencode-linux-x64-musl@1.14.28:
resolution: {integrity: sha512-XyzWl35L8N6El/hxAM28bDUHLCY0aujMtprDTCYXVckeNxBkN3idM4EfdLtJaUHkE6bqMr+m6wXQl4oYDoOtpg==}
opencode-linux-x64-musl@1.14.40:
resolution: {integrity: sha512-AU8SAE4aOaGxzTDcv95GU8vrWeiYOzPVEt5LfcqhmOmhRiuN0jnDBO/gl62SITkGyu/sSes554L4UqkqkglC7g==}
cpu: [x64]
os: [linux]
opencode-linux-x64@1.14.28:
resolution: {integrity: sha512-XnpQrud15bvUBvOI58tOGUBTrwqKHl6bYQ3eoy5HhGa2spUnRv3B/HU8QiS6QuNbmkPxRPR+vuTGtBYQvtRGPw==}
opencode-linux-x64@1.14.40:
resolution: {integrity: sha512-Cb+keGDsjo1wyOJ2Kf+KOZWlJUs1fD/VmjYepl9Fv3KGqWQNhSOH/4kiwj3KHWwEMWroCtw5KnAFykiOgbsz8A==}
cpu: [x64]
os: [linux]
opencode-windows-arm64@1.14.28:
resolution: {integrity: sha512-emR1oEoLe6soASahJNX6IwR9x8rJkbwBXDnXNTWQcGdSxKBMD4/cLkq84k/5zqLfB7dbUChTw7eFz7u8Sa5VQw==}
opencode-windows-arm64@1.14.40:
resolution: {integrity: sha512-049+Z8G4o10OO7s5dGIVIZ31DMR+0JlAWCM/XR+VmBlbPeZYRKL/9Q2dM0Ljw11ekZ1qbZfGlK7xUEHkwvy8fQ==}
cpu: [arm64]
os: [win32]
opencode-windows-x64-baseline@1.14.28:
resolution: {integrity: sha512-ARKHTThHezib44QPLiivYI8c71iNE9iNDubwV5XxUhM2FtzMJkZGma+EgbcCsXwY5r0lAsarzzDMqYB0YfCZ1A==}
opencode-windows-x64-baseline@1.14.40:
resolution: {integrity: sha512-6JKSyc8oyCw4ZLMZ38l9/M7iQYfMF7qnc3d+P9dpfx80FiqHwWBxgYaGEaNHkQmoiTPVc39EsjaOiiBOLGZOQA==}
cpu: [x64]
os: [win32]
opencode-windows-x64@1.14.28:
resolution: {integrity: sha512-tEpblIEdmlJ7npo5Bq+1O7uup9jCOyqnnA63t+3JQiNQ1et3UTjNb5ruAjb7sudUer6i5MlQCwNXBjitjuU4Kg==}
opencode-windows-x64@1.14.40:
resolution: {integrity: sha512-0+La4i8fj+E+kj6hu8sQKfjbHm6VgbbSiZ6vo3fYqU4+Ex2UIyNa31A3rWOQ9EFbmKQ8jb2PmfBDvDE0ihJvRw==}
cpu: [x64]
os: [win32]
@ -374,32 +374,32 @@ snapshots:
'@esbuild/win32-x64@0.28.0':
optional: true
'@github/copilot-darwin-arm64@1.0.36':
'@github/copilot-darwin-arm64@1.0.43':
optional: true
'@github/copilot-darwin-x64@1.0.36':
'@github/copilot-darwin-x64@1.0.43':
optional: true
'@github/copilot-linux-arm64@1.0.36':
'@github/copilot-linux-arm64@1.0.43':
optional: true
'@github/copilot-linux-x64@1.0.36':
'@github/copilot-linux-x64@1.0.43':
optional: true
'@github/copilot-win32-arm64@1.0.36':
'@github/copilot-win32-arm64@1.0.43':
optional: true
'@github/copilot-win32-x64@1.0.36':
'@github/copilot-win32-x64@1.0.43':
optional: true
'@github/copilot@1.0.36':
'@github/copilot@1.0.43':
optionalDependencies:
'@github/copilot-darwin-arm64': 1.0.36
'@github/copilot-darwin-x64': 1.0.36
'@github/copilot-linux-arm64': 1.0.36
'@github/copilot-linux-x64': 1.0.36
'@github/copilot-win32-arm64': 1.0.36
'@github/copilot-win32-x64': 1.0.36
'@github/copilot-darwin-arm64': 1.0.43
'@github/copilot-darwin-x64': 1.0.43
'@github/copilot-linux-arm64': 1.0.43
'@github/copilot-linux-x64': 1.0.43
'@github/copilot-win32-arm64': 1.0.43
'@github/copilot-win32-x64': 1.0.43
'@types/node@25.6.0':
dependencies:
@ -434,55 +434,55 @@ snapshots:
'@esbuild/win32-ia32': 0.28.0
'@esbuild/win32-x64': 0.28.0
opencode-ai@1.14.28:
opencode-ai@1.14.40:
optionalDependencies:
opencode-darwin-arm64: 1.14.28
opencode-darwin-x64: 1.14.28
opencode-darwin-x64-baseline: 1.14.28
opencode-linux-arm64: 1.14.28
opencode-linux-arm64-musl: 1.14.28
opencode-linux-x64: 1.14.28
opencode-linux-x64-baseline: 1.14.28
opencode-linux-x64-baseline-musl: 1.14.28
opencode-linux-x64-musl: 1.14.28
opencode-windows-arm64: 1.14.28
opencode-windows-x64: 1.14.28
opencode-windows-x64-baseline: 1.14.28
opencode-darwin-arm64: 1.14.40
opencode-darwin-x64: 1.14.40
opencode-darwin-x64-baseline: 1.14.40
opencode-linux-arm64: 1.14.40
opencode-linux-arm64-musl: 1.14.40
opencode-linux-x64: 1.14.40
opencode-linux-x64-baseline: 1.14.40
opencode-linux-x64-baseline-musl: 1.14.40
opencode-linux-x64-musl: 1.14.40
opencode-windows-arm64: 1.14.40
opencode-windows-x64: 1.14.40
opencode-windows-x64-baseline: 1.14.40
opencode-darwin-arm64@1.14.28:
opencode-darwin-arm64@1.14.40:
optional: true
opencode-darwin-x64-baseline@1.14.28:
opencode-darwin-x64-baseline@1.14.40:
optional: true
opencode-darwin-x64@1.14.28:
opencode-darwin-x64@1.14.40:
optional: true
opencode-linux-arm64-musl@1.14.28:
opencode-linux-arm64-musl@1.14.40:
optional: true
opencode-linux-arm64@1.14.28:
opencode-linux-arm64@1.14.40:
optional: true
opencode-linux-x64-baseline-musl@1.14.28:
opencode-linux-x64-baseline-musl@1.14.40:
optional: true
opencode-linux-x64-baseline@1.14.28:
opencode-linux-x64-baseline@1.14.40:
optional: true
opencode-linux-x64-musl@1.14.28:
opencode-linux-x64-musl@1.14.40:
optional: true
opencode-linux-x64@1.14.28:
opencode-linux-x64@1.14.40:
optional: true
opencode-windows-arm64@1.14.28:
opencode-windows-arm64@1.14.40:
optional: true
opencode-windows-x64-baseline@1.14.28:
opencode-windows-x64-baseline@1.14.40:
optional: true
opencode-windows-x64@1.14.28:
opencode-windows-x64@1.14.40:
optional: true
undici-types@7.19.2: {}

View File

@ -42,6 +42,8 @@ export EMCC_CFLAGS="--no-entry \
-sEXPORTED_RUNTIME_METHODS=GL,UTF8ToString,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32 \
-sENVIRONMENT=web \
-sMODULARIZE=1 \
-sDISABLE_EXCEPTION_CATCHING=1 \
-sFILESYSTEM=0 \
-sEXPORT_ES6=1";
export EM_CACHE="/tmp/emsdk_cache";
@ -80,6 +82,9 @@ function copy_artifacts {
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js $DEST/$BUILD_NAME.js;
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm $DEST/$BUILD_NAME.wasm;
if [ -f target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map ]; then
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map;
fi
sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$VERSION_TAG/g" $DEST/$BUILD_NAME.js;

View File

@ -18,9 +18,11 @@ use std::collections::HashMap;
#[allow(unused_imports)]
use crate::error::{Error, Result};
use crate::state::TextEditorState;
use macros::wasm_error;
use math::{Bounds, Matrix};
use mem::SerializableResult;
use render::{gpu_state::GpuState, RenderState};
use shapes::{StructureEntry, StructureEntryType, TransformEntry};
use skia_safe as skia;
use state::State;
@ -28,6 +30,37 @@ use utils::uuid_from_u32_quartet;
use uuid::Uuid;
pub(crate) static mut STATE: Option<Box<State>> = None;
pub(crate) static mut TEXT_EDITOR_STATE: *mut TextEditorState = std::ptr::null_mut();
#[inline(always)]
pub fn get_text_editor_state() -> &'static mut TextEditorState {
unsafe {
debug_assert!(!TEXT_EDITOR_STATE.is_null(), "Text Editor state is null");
&mut *TEXT_EDITOR_STATE
}
}
/// GPU State.
static mut GPU_STATE: *mut GpuState = std::ptr::null_mut();
#[inline(always)]
pub(crate) fn get_gpu_state() -> &'static mut GpuState {
unsafe {
debug_assert!(!GPU_STATE.is_null(), "GPU State is null");
&mut *GPU_STATE
}
}
/// Render State.
static mut RENDER_STATE: *mut RenderState = std::ptr::null_mut();
#[inline(always)]
pub(crate) fn get_render_state() -> &'static mut RenderState {
unsafe {
debug_assert!(!RENDER_STATE.is_null(), "Render State is null");
&mut *RENDER_STATE
}
}
// FIXME: These with_state* macros should be using our CriticalError instead of expect.
// But to do that, we need to not use them at domain-level (i.e. in business logic), just
@ -101,12 +134,32 @@ macro_rules! with_state_mut_current_shape {
};
}
/// Initializes GPU.
fn gpu_init() {
unsafe {
let gpu_state = GpuState::try_new().expect("Cannot initialize GPU State");
GPU_STATE = Box::into_raw(Box::new(gpu_state));
}
}
/// Initializes RenderState.
fn render_init(width: i32, height: i32) {
unsafe {
let render_state =
RenderState::try_new(width, height).expect("Cannot intialize RenderState");
RENDER_STATE = Box::into_raw(Box::new(render_state));
}
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn init(width: i32, height: i32) -> Result<()> {
let state_box = Box::new(State::try_new(width, height)?);
gpu_init();
render_init(width, height);
unsafe {
let state_box = Box::new(State::new());
STATE = Some(state_box);
TEXT_EDITOR_STATE = Box::into_raw(Box::new(TextEditorState::new()));
}
Ok(())
}
@ -123,12 +176,10 @@ pub extern "C" fn set_browser(browser: u8) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn clean_up() -> Result<()> {
with_state_mut!(state, {
// Cancel the current animation frame if it exists so
// it won't try to render without context
let render_state = state.render_state_mut();
render_state.cancel_animation_frame();
});
// Cancel the current animation frame if it exists so
// it won't try to render without context
let render_state = get_render_state();
render_state.cancel_animation_frame();
unsafe { STATE = None }
mem::free_bytes()?;
Ok(())
@ -137,11 +188,9 @@ pub extern "C" fn clean_up() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> {
with_state_mut!(state, {
let render_state = state.render_state_mut();
render_state.set_debug_flags(debug);
render_state.set_dpr(dpr)?;
});
let render_state = get_render_state();
render_state.set_debug_flags(debug);
render_state.set_dpr(dpr)?;
Ok(())
}
@ -150,61 +199,48 @@ pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> {
pub extern "C" fn set_viewport_interest_area_threshold(
viewport_interest_area_threshold: i32,
) -> Result<()> {
with_state_mut!(state, {
let render_state = state.render_state_mut();
render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold);
});
let render_state = get_render_state();
render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold);
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_max_blocking_time_ms(max_blocking_time_ms: i32) -> Result<()> {
with_state_mut!(state, {
let render_state = state.render_state_mut();
render_state.set_max_blocking_time_ms(max_blocking_time_ms);
});
let render_state = get_render_state();
render_state.set_max_blocking_time_ms(max_blocking_time_ms);
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_node_batch_threshold(node_batch_threshold: i32) -> Result<()> {
with_state_mut!(state, {
let render_state = state.render_state_mut();
render_state.set_node_batch_threshold(node_batch_threshold);
});
let render_state = get_render_state();
render_state.set_node_batch_threshold(node_batch_threshold);
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_blur_downscale_threshold(blur_downscale_threshold: f32) -> Result<()> {
with_state_mut!(state, {
let render_state = state.render_state_mut();
render_state.set_blur_downscale_threshold(blur_downscale_threshold);
});
let render_state = get_render_state();
render_state.set_blur_downscale_threshold(blur_downscale_threshold);
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> {
with_state_mut!(state, {
state.render_state_mut().set_antialias_threshold(threshold);
});
get_render_state().set_antialias_threshold(threshold);
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_max_atlas_texture_size(max_px: i32) -> Result<()> {
with_state_mut!(state, {
state
.render_state_mut()
.surfaces
.set_max_atlas_texture_size(max_px);
});
get_render_state()
.surfaces
.set_max_atlas_texture_size(max_px);
Ok(())
}
@ -222,7 +258,7 @@ pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn render(_: i32) -> Result<()> {
pub extern "C" fn render(timestamp: i32) -> Result<()> {
with_state_mut!(state, {
state.rebuild_touched_tiles();
// Drain the throttled modifier-tile invalidation accumulated
@ -230,14 +266,14 @@ pub extern "C" fn render(_: i32) -> Result<()> {
// interactive_transform; we do it once here, with the current
// modifier set, so the cost is paid once per rAF rather than
// once per pointer move.
if state.render_state.options.is_interactive_transform() {
if get_render_state().options.is_interactive_transform() {
let ids = state.shapes.modifier_ids();
if !ids.is_empty() {
state.rebuild_modifier_tiles(ids)?;
}
}
state
.start_render_loop(performance::get_time())
.start_render_loop(timestamp)
.map_err(|_| Error::RecoverableError("Error rendering".to_string()))?;
});
Ok(())
@ -249,7 +285,7 @@ pub extern "C" fn render_sync() -> Result<()> {
with_state_mut!(state, {
state.rebuild_tiles();
state
.render_sync(performance::get_time())
.render_sync(0)
.map_err(|_| Error::RecoverableError("Error rendering".to_string()))?;
});
Ok(())
@ -275,7 +311,7 @@ pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()
state.rebuild_tiles_from(Some(&id));
state
.render_sync_shape(&id, performance::get_time())
.render_sync_shape(&id, 0)
.map_err(|e| Error::RecoverableError(e.to_string()))?;
});
Ok(())
@ -300,9 +336,7 @@ pub extern "C" fn render_from_cache(_: i32) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_preview_mode(enabled: bool) -> Result<()> {
with_state_mut!(state, {
state.render_state.set_preview_mode(enabled);
});
get_render_state().set_preview_mode(enabled);
Ok(())
}
@ -345,9 +379,7 @@ pub extern "C" fn end_loading() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_loading_overlay() -> Result<()> {
with_state_mut!(state, {
state.render_state.render_loading_overlay();
});
get_render_state().render_loading_overlay();
Ok(())
}
@ -364,30 +396,24 @@ pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn reset_canvas() -> Result<()> {
with_state_mut!(state, {
state.render_state_mut().reset_canvas();
});
get_render_state().reset_canvas();
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn resize_viewbox(width: i32, height: i32) -> Result<()> {
with_state_mut!(state, {
state.resize(width, height)?;
});
get_render_state().resize(width, height)?;
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) -> Result<()> {
with_state_mut!(state, {
performance::begin_measure!("set_view");
let render_state = state.render_state_mut();
render_state.set_view(zoom, x, y);
performance::end_measure!("set_view");
});
performance::begin_measure!("set_view");
let render_state = get_render_state();
render_state.set_view(zoom, x, y);
performance::end_measure!("set_view");
Ok(())
}
@ -397,15 +423,13 @@ static mut VIEW_INTERACTION_START: i32 = 0;
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_view_start() -> Result<()> {
with_state_mut!(state, {
#[cfg(feature = "profile-macros")]
unsafe {
VIEW_INTERACTION_START = performance::get_time();
}
performance::begin_measure!("set_view_start");
state.render_state.options.set_fast_mode(true);
performance::end_measure!("set_view_start");
});
#[cfg(feature = "profile-macros")]
unsafe {
VIEW_INTERACTION_START = performance::get_time();
}
performance::begin_measure!("set_view_start");
get_render_state().options.set_fast_mode(true);
performance::end_measure!("set_view_start");
Ok(())
}
@ -419,34 +443,33 @@ pub extern "C" fn set_view_start() -> Result<()> {
pub extern "C" fn set_view_end() -> Result<()> {
with_state_mut!(state, {
performance::begin_measure!("set_view_end");
state.render_state.options.set_fast_mode(false);
state.render_state.cancel_animation_frame();
let render_state = get_render_state();
render_state.options.set_fast_mode(false);
render_state.cancel_animation_frame();
let scale = state.render_state.get_scale();
state
.render_state
let scale = render_state.get_scale();
render_state
.tile_viewbox
.update(state.render_state.viewbox, scale);
.update(render_state.viewbox, scale);
if state.render_state.options.is_profile_rebuild_tiles() {
if render_state.options.is_profile_rebuild_tiles() {
state.rebuild_tiles();
} else if state.render_state.zoom_changed() {
} else if render_state.zoom_changed() {
// Zoom changed: tile sizes differ so all cached tile
// textures are invalid (wrong scale). Rebuild the tile
// index and clear the tile texture cache, but *preserve*
// the cache canvas so render_from_cache can show a scaled
// preview of the old content while new tiles render.
state.render_state.rebuild_tile_index(&state.shapes);
state.render_state.surfaces.invalidate_tile_cache();
render_state.rebuild_tile_index(&state.shapes);
render_state.surfaces.invalidate_tile_cache();
} else {
// Pure pan at the same zoom level: tile contents have not
// changed — only the viewport position moved. Update the
// tile index (which tiles are in the interest area) but
// keep cached tile textures so the render can blit them
// instead of re-drawing every visible tile from scratch.
state.render_state.rebuild_tile_index(&state.shapes);
render_state.rebuild_tile_index(&state.shapes);
}
performance::end_measure!("set_view_end");
});
Ok(())
@ -460,12 +483,11 @@ pub extern "C" fn set_view_end() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_modifiers_start() -> Result<()> {
with_state_mut!(state, {
performance::begin_measure!("set_modifiers_start");
state.render_state.options.set_fast_mode(true);
state.render_state.options.set_interactive_transform(true);
performance::end_measure!("set_modifiers_start");
});
performance::begin_measure!("set_modifiers_start");
let render_state = get_render_state();
render_state.options.set_fast_mode(true);
render_state.options.set_interactive_transform(true);
performance::end_measure!("set_modifiers_start");
Ok(())
}
@ -476,13 +498,12 @@ pub extern "C" fn set_modifiers_start() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_modifiers_end() -> Result<()> {
with_state_mut!(state, {
performance::begin_measure!("set_modifiers_end");
state.render_state.options.set_fast_mode(false);
state.render_state.options.set_interactive_transform(false);
state.render_state.cancel_animation_frame();
performance::end_measure!("set_modifiers_end");
});
performance::begin_measure!("set_modifiers_end");
let render_state = get_render_state();
render_state.options.set_fast_mode(false);
render_state.options.set_interactive_transform(false);
render_state.cancel_animation_frame();
performance::end_measure!("set_modifiers_end");
Ok(())
}
@ -798,11 +819,9 @@ pub extern "C" fn is_image_cached(
d: u32,
is_thumbnail: bool,
) -> Result<bool> {
with_state_mut!(state, {
let id = uuid_from_u32_quartet(a, b, c, d);
let result = state.render_state().has_image(&id, is_thumbnail);
Ok(result)
})
let id = uuid_from_u32_quartet(a, b, c, d);
let result = get_render_state().has_image(&id, is_thumbnail);
Ok(result)
}
#[no_mangle]
@ -951,15 +970,14 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> {
#[wasm_error]
pub extern "C" fn clean_modifiers() -> Result<()> {
with_state_mut!(state, {
let render_state = get_render_state();
let prev_modifier_ids = state.shapes.clean_all();
// Skip the tile-cache cleanup during interactive transform: the
// per-rAF `rebuild_modifier_tiles` in `render()` already evicts
// the same tiles for the active modifier set, so the eviction
// here is redundant and doubles the per-emission cost.
if !prev_modifier_ids.is_empty() && !state.render_state.options.is_interactive_transform() {
state
.render_state
.update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?;
if !prev_modifier_ids.is_empty() && !render_state.options.is_interactive_transform() {
render_state.update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?;
}
});
Ok(())
@ -985,7 +1003,7 @@ pub extern "C" fn set_modifiers() -> Result<()> {
with_state_mut!(state, {
state.set_modifiers(modifiers);
// TO CHECK
if !state.render_state.options.is_interactive_transform() {
if !get_render_state().options.is_interactive_transform() {
state.rebuild_modifier_tiles(ids)?;
}
});
@ -1051,9 +1069,7 @@ pub extern "C" fn render_shape_pixels(
#[no_mangle]
pub extern "C" fn render_stats() {
with_state!(state, {
state.render_state.print_stats();
})
get_render_state().print_stats();
}
fn main() {

View File

@ -2,7 +2,7 @@ mod debug;
mod fills;
pub mod filters;
mod fonts;
mod gpu_state;
pub mod gpu_state;
pub mod grid_layout;
mod images;
mod options;
@ -17,13 +17,10 @@ use skia_safe::{self as skia, Matrix, RRect, Rect};
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use gpu_state::GpuState;
use options::RenderOptions;
pub use surfaces::{SurfaceId, Surfaces};
use crate::error::{Error, Result};
use crate::performance;
use crate::shapes::{
all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor,
Stroke, StrokeKind, TextContent, Type,
@ -33,6 +30,7 @@ use crate::tiles::{self, PendingTiles, TileRect};
use crate::uuid::Uuid;
use crate::view::Viewbox;
use crate::wapi;
use crate::{get_gpu_state, performance};
pub use fonts::*;
pub use images::*;
@ -327,7 +325,6 @@ impl RenderStats {
}
pub(crate) struct RenderState {
gpu_state: GpuState,
pub options: RenderOptions,
stats: RenderStats,
pub surfaces: Surfaces,
@ -394,6 +391,12 @@ pub struct InteractiveDragCrop {
/// True if the captured crop bounds were fully inside the viewport at capture time.
/// Used to avoid serving partial/offscreen crops during interactive drag.
pub fits_viewport_at_capture: bool,
/// Viewbox origin (doc-space) at capture time.
pub capture_vb_left: f32,
pub capture_vb_top: f32,
/// Backbuffer pixel origin used for `snapshot_rect` (so we can do 1:1 blits).
pub capture_src_left: i32,
pub capture_src_top: i32,
pub image: skia::Image,
}
@ -486,13 +489,11 @@ impl RenderState {
pub fn try_new(width: i32, height: i32) -> Result<RenderState> {
// This needs to be done once per WebGL context.
let mut gpu_state = GpuState::try_new()?;
let sampling_options =
skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::Nearest);
let fonts = FontStore::try_new()?;
let surfaces = Surfaces::try_new(
&mut gpu_state,
(width, height),
sampling_options,
tiles::get_tile_dimensions(),
@ -505,15 +506,14 @@ impl RenderState {
let tiles = tiles::TileHashMap::new();
let options = RenderOptions::default();
Ok(RenderState {
gpu_state: gpu_state.clone(),
Ok(Self {
options,
stats: RenderStats::new(),
surfaces,
fonts,
viewbox,
cached_viewbox: Viewbox::new(0., 0.),
images: ImageStore::new(gpu_state.context.clone()),
images: ImageStore::new(),
background_color: skia::Color::TRANSPARENT,
render_request_id: None,
render_in_progress: false,
@ -525,10 +525,10 @@ impl RenderState {
tiles,
tile_viewbox: tiles::TileViewbox::new_with_interest(
viewbox,
options.viewport_interest_area_threshold,
options.dpr_viewport_interest_area_threshold,
1.0,
),
pending_tiles: PendingTiles::new_empty(),
pending_tiles: PendingTiles::new(),
nested_fills: vec![],
nested_blurs: vec![],
nested_shadows: vec![],
@ -742,8 +742,11 @@ impl RenderState {
}
pub fn set_dpr(&mut self, dpr: f32) -> Result<()> {
if Some(dpr) != self.options.dpr {
self.options.dpr = Some(dpr);
// Only when this function returns true (it means the value
// was properly changed) the rest of the functions is called.
if self.options.set_dpr(dpr) {
self.tile_viewbox
.set_interest(self.options.dpr_viewport_interest_area_threshold);
self.resize(
self.viewbox.width.floor() as i32,
self.viewbox.height.floor() as i32,
@ -758,11 +761,15 @@ impl RenderState {
}
pub fn set_viewport_interest_area_threshold(&mut self, value: i32) {
self.options.set_viewport_interest_area_threshold(value);
// The TileViewbox stores its own copy of `interest` (set at
// construction). Without propagating, options change wouldn't
// affect pending_tiles generation.
self.tile_viewbox.set_interest(value);
// Only when this function returns true (it means the value
// was changed properly) the tile_viewbox.set_interest is called.
if self.options.set_viewport_interest_area_threshold(value) {
// The TileViewbox stores its own copy of `interest` (set at
// construction). Without propagating, options change wouldn't
// affect pending_tiles generation.
self.tile_viewbox
.set_interest(self.options.dpr_viewport_interest_area_threshold);
}
}
pub fn set_node_batch_threshold(&mut self, value: i32) {
@ -786,10 +793,9 @@ impl RenderState {
}
pub fn resize(&mut self, width: i32, height: i32) -> Result<()> {
let dpr_width = (width as f32 * self.options.dpr()).floor() as i32;
let dpr_height = (height as f32 * self.options.dpr()).floor() as i32;
self.surfaces
.resize(&mut self.gpu_state, dpr_width, dpr_height)?;
let dpr_width = (width as f32 * self.options.dpr).floor() as i32;
let dpr_height = (height as f32 * self.options.dpr).floor() as i32;
self.surfaces.resize(dpr_width, dpr_height)?;
self.viewbox.set_wh(width as f32, height as f32);
self.tile_viewbox.update(self.viewbox, self.get_scale());
@ -797,8 +803,7 @@ impl RenderState {
}
pub fn flush_and_submit(&mut self) {
self.surfaces
.flush_and_submit(&mut self.gpu_state, SurfaceId::Target);
self.surfaces.flush_and_submit(SurfaceId::Target);
}
pub fn reset_canvas(&mut self) {
@ -877,7 +882,6 @@ impl RenderState {
.as_ref()
.ok_or(Error::CriticalError("Current tile not found".to_string()))?;
self.surfaces.cache_current_tile_texture(
&mut self.gpu_state,
&self.tile_viewbox,
&current_tile,
&tile_rect,
@ -1721,6 +1725,10 @@ impl RenderState {
src_doc_bounds,
src_selrect: selrect,
fits_viewport_at_capture,
capture_vb_left: vb_left,
capture_vb_top: vb_top,
capture_src_left: src_irect.left,
capture_src_top: src_irect.top,
image,
},
);
@ -1739,7 +1747,7 @@ impl RenderState {
// and drawing from it avoids mixing a partially-updated Cache surface with missing tiles.
if self.options.is_fast_mode() && self.render_in_progress && self.surfaces.has_atlas() {
self.surfaces
.draw_atlas_to_target(self.viewbox, self.options.dpr(), bg_color);
.draw_atlas_to_target(self.viewbox, self.options.dpr, bg_color);
if self.options.is_debug_visible() {
debug::render(self);
@ -1759,15 +1767,15 @@ impl RenderState {
// Scale and translate the target according to the cached data
let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom;
let interest = self.options.viewport_interest_area_threshold;
let interest = self.options.dpr_viewport_interest_area_threshold;
let TileRect(start_tile_x, start_tile_y, _, _) =
tiles::get_tiles_for_viewbox_with_interest(
self.cached_viewbox,
interest,
cached_scale,
);
let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr();
let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr();
let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr;
let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr;
let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x;
let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y;
@ -1780,8 +1788,8 @@ impl RenderState {
let cache_h = cache_dim.height as f32;
// Viewport in target pixels.
let vw = (self.viewbox.width * self.options.dpr()).max(1.0);
let vh = (self.viewbox.height * self.options.dpr()).max(1.0);
let vw = (self.viewbox.width * self.options.dpr).max(1.0);
let vh = (self.viewbox.height * self.options.dpr).max(1.0);
// Inverse-map viewport corners into cache coordinates.
// target = (cache * navigate_zoom) translated by (translate_x, translate_y) (in cache coords).
@ -1809,7 +1817,7 @@ impl RenderState {
if self.surfaces.has_atlas() {
self.surfaces.draw_atlas_to_target(
self.viewbox,
self.options.dpr(),
self.options.dpr,
bg_color,
);
@ -1966,12 +1974,12 @@ impl RenderState {
let viewbox_cache_size = get_cache_size(
self.viewbox,
scale,
self.options.viewport_interest_area_threshold,
self.options.dpr_viewport_interest_area_threshold,
);
let cached_viewbox_cache_size = get_cache_size(
self.cached_viewbox,
scale,
self.options.viewport_interest_area_threshold,
self.options.dpr_viewport_interest_area_threshold,
);
// Only resize cache if the new size is larger than the cached size
// This avoids unnecessary surface recreations when the cache size decreases
@ -1980,7 +1988,7 @@ impl RenderState {
{
self.surfaces.resize_cache(
viewbox_cache_size,
self.options.viewport_interest_area_threshold,
self.options.dpr_viewport_interest_area_threshold,
)?;
}
@ -2186,13 +2194,12 @@ impl RenderState {
// Clear export context so get_scale() returns to workspace zoom.
self.export_context = None;
self.surfaces
.flush_and_submit(&mut self.gpu_state, target_surface);
self.surfaces.flush_and_submit(target_surface);
let image = self.surfaces.snapshot(target_surface);
let data = image
.encode(
&mut self.gpu_state.context,
Some(&mut get_gpu_state().context),
skia::EncodedImageFormat::PNG,
100,
)
@ -3005,7 +3012,6 @@ impl RenderState {
if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) {
let crop_image = &crop.image;
let crop_src_selrect = crop.src_selrect;
let crop_src_doc_bounds = crop.src_doc_bounds;
let cur_selrect = tree.get(&node_id).map(|s| s.selrect());
let (dx, dy) = match cur_selrect {
@ -3015,23 +3021,10 @@ impl RenderState {
),
None => (0.0, 0.0),
};
let dst_doc_rect = Rect::new(
crop_src_doc_bounds.left + dx,
crop_src_doc_bounds.top + dy,
crop_src_doc_bounds.right + dx,
crop_src_doc_bounds.bottom + dy,
);
let scale = self.get_scale();
let translation = self
.surfaces
.get_render_context_translation(self.render_area, scale);
let dst_tile_rect = skia::Rect::from_xywh(
(dst_doc_rect.left + translation.0) * scale,
(dst_doc_rect.top + translation.1) * scale,
dst_doc_rect.width() * scale,
dst_doc_rect.height() * scale,
);
let canvas = self.surfaces.canvas(target_surface);
canvas.save();
@ -3051,12 +3044,14 @@ impl RenderState {
canvas.clip_path(&clip_path, skia::ClipOp::Intersect, true);
}
}
canvas.draw_image_rect(
crop_image,
None,
dst_tile_rect,
&skia::Paint::default(),
);
let doc_left =
crop.capture_vb_left + (crop.capture_src_left as f32 / scale) + dx;
let doc_top =
crop.capture_vb_top + (crop.capture_src_top as f32 / scale) + dy;
let x = (doc_left + translation.0) * scale;
let y = (doc_top + translation.1) * scale;
canvas.draw_image(crop_image, (x, y), Some(&skia::Paint::default()));
canvas.restore();
}
@ -3585,8 +3580,7 @@ impl RenderState {
}
pub fn remove_cached_tile(&mut self, tile: tiles::Tile) {
self.surfaces
.remove_cached_tile_surface(&mut self.gpu_state, tile);
self.surfaces.remove_cached_tile_surface(tile);
}
/// Rebuild the tile index (shape→tile mapping) for all top-level shapes.
@ -3750,11 +3744,11 @@ impl RenderState {
if let Some((_, export_scale)) = self.export_context {
return export_scale;
}
self.viewbox.zoom() * self.options.dpr()
self.viewbox.zoom() * self.options.dpr
}
pub fn get_cached_scale(&self) -> f32 {
self.cached_viewbox.zoom() * self.options.dpr()
self.cached_viewbox.zoom() * self.options.dpr
}
pub fn zoom_changed(&self) -> bool {
@ -3785,6 +3779,6 @@ impl RenderState {
impl Drop for RenderState {
fn drop(&mut self) {
self.gpu_state.context.free_gpu_resources();
get_gpu_state().context.free_gpu_resources();
}
}

View File

@ -1,7 +1,11 @@
use super::{tiles, RenderState, SurfaceId};
use crate::with_state_mut;
use crate::STATE;
#[cfg(target_arch = "wasm32")]
use macros::wasm_error;
#[cfg(target_arch = "wasm32")]
use crate::get_render_state;
use skia_safe::{self as skia, Rect};
#[cfg(target_arch = "wasm32")]
@ -227,9 +231,7 @@ pub fn console_debug_surface_rect(render_state: &mut RenderState, id: SurfaceId,
#[wasm_error]
#[cfg(target_arch = "wasm32")]
pub extern "C" fn debug_cache_console() -> Result<()> {
with_state_mut!(state, {
console_debug_surface(state.render_state_mut(), SurfaceId::Cache);
});
console_debug_surface(get_render_state(), SurfaceId::Cache);
Ok(())
}
@ -237,9 +239,7 @@ pub extern "C" fn debug_cache_console() -> Result<()> {
#[wasm_error]
#[cfg(target_arch = "wasm32")]
pub extern "C" fn debug_cache_base64() -> Result<()> {
with_state_mut!(state, {
console_debug_surface_base64(state.render_state_mut(), SurfaceId::Cache);
});
console_debug_surface_base64(get_render_state(), SurfaceId::Cache);
Ok(())
}
@ -247,9 +247,7 @@ pub extern "C" fn debug_cache_base64() -> Result<()> {
#[wasm_error]
#[cfg(target_arch = "wasm32")]
pub extern "C" fn debug_atlas_console() -> Result<()> {
with_state_mut!(state, {
console_debug_surface(state.render_state_mut(), SurfaceId::Atlas);
});
console_debug_surface(get_render_state(), SurfaceId::Atlas);
Ok(())
}
@ -257,8 +255,6 @@ pub extern "C" fn debug_atlas_console() -> Result<()> {
#[wasm_error]
#[cfg(target_arch = "wasm32")]
pub extern "C" fn debug_atlas_base64() -> Result<()> {
with_state_mut!(state, {
console_debug_surface_base64(state.render_state_mut(), SurfaceId::Atlas);
});
console_debug_surface_base64(get_render_state(), SurfaceId::Atlas);
Ok(())
}

View File

@ -41,7 +41,7 @@ impl GpuState {
}
};
Ok(GpuState {
Ok(Self {
context,
framebuffer_info,
})

View File

@ -3,6 +3,7 @@ use crate::shapes::ImageFill;
use crate::uuid::Uuid;
use crate::error::Result;
use crate::get_gpu_state;
use skia_safe::gpu::{surfaces, Budgeted, DirectContext};
use skia_safe::{self as skia, Codec, ISize};
use std::collections::HashMap;
@ -143,10 +144,12 @@ fn decode_image(context: &mut Box<DirectContext>, raw_data: &[u8]) -> Option<Ima
}
impl ImageStore {
pub fn new(context: DirectContext) -> Self {
pub fn new() -> Self {
let gpu_state = get_gpu_state();
let context = &gpu_state.context;
Self {
images: HashMap::with_capacity(2048),
context: Box::new(context),
context: Box::new(context.clone()),
}
}

View File

@ -7,7 +7,7 @@ const SHOW_WASM_INFO: u32 = 0x08;
// Render performance options
// This is the extra area used for tile rendering (tiles beyond viewport).
// Higher values pre-render more tiles, reducing empty squares during pan but using more memory.
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1;
const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 3;
const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0;
@ -15,7 +15,7 @@ const ANTIALIAS_THRESHOLD: f32 = 7.0;
#[derive(Debug, Copy, Clone, PartialEq)]
pub struct RenderOptions {
pub flags: u32,
pub dpr: Option<f32>,
pub dpr: f32,
fast_mode: bool,
/// Active while the user is interacting with a shape (drag, resize,
/// rotate). Implies `fast_mode` semantics for expensive effects but
@ -25,6 +25,7 @@ pub struct RenderOptions {
/// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled.
pub antialias_threshold: f32,
pub viewport_interest_area_threshold: i32,
pub dpr_viewport_interest_area_threshold: i32,
pub max_blocking_time_ms: i32,
pub node_batch_threshold: i32,
pub blur_downscale_threshold: f32,
@ -34,11 +35,12 @@ impl Default for RenderOptions {
fn default() -> Self {
Self {
flags: 0,
dpr: None,
dpr: 1.0,
fast_mode: false,
interactive_transform: false,
antialias_threshold: ANTIALIAS_THRESHOLD,
viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD,
dpr_viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD,
max_blocking_time_ms: MAX_BLOCKING_TIME_MS,
node_batch_threshold: NODE_BATCH_THRESHOLD,
blur_downscale_threshold: BLUR_DOWNSCALE_THRESHOLD,
@ -64,6 +66,24 @@ impl RenderOptions {
self.fast_mode = enabled;
}
/// Updates the dpr viewport interest area threshold.
/// This function is updated when the dpr or the
/// viewport_interest_area_threshold is changed
fn update_dpr_viewport_interest_area_threshold(&mut self) {
self.dpr_viewport_interest_area_threshold =
(self.dpr * self.viewport_interest_area_threshold as f32).ceil() as i32;
}
/// Sets the devicePixelRatio.
pub fn set_dpr(&mut self, value: f32) -> bool {
if value > 0.0 && self.dpr != value {
self.dpr = value;
self.update_dpr_viewport_interest_area_threshold();
return true;
}
false
}
/// Interactive transform is ON while the user is dragging, resizing
/// or rotating a shape. Callers use it to keep per-frame flushing
/// enabled and to render visible tiles in a single frame so tiles
@ -84,10 +104,6 @@ impl RenderOptions {
self.fast_mode && !self.interactive_transform
}
pub fn dpr(&self) -> f32 {
self.dpr.unwrap_or(1.0)
}
pub fn is_text_editor_v3(&self) -> bool {
self.flags & TEXT_EDITOR_V3 == TEXT_EDITOR_V3
}
@ -96,33 +112,44 @@ impl RenderOptions {
self.flags & SHOW_WASM_INFO == SHOW_WASM_INFO
}
pub fn set_antialias_threshold(&mut self, value: f32) {
pub fn set_antialias_threshold(&mut self, value: f32) -> bool {
if value.is_finite() && value > 0.0 {
self.antialias_threshold = value;
return true;
}
false
}
pub fn set_blur_downscale_threshold(&mut self, value: f32) {
pub fn set_blur_downscale_threshold(&mut self, value: f32) -> bool {
if value.is_finite() && value > 0.0 {
self.blur_downscale_threshold = value;
return true;
}
false
}
pub fn set_viewport_interest_area_threshold(&mut self, value: i32) {
if value >= 0 {
pub fn set_viewport_interest_area_threshold(&mut self, value: i32) -> bool {
if value >= 0 && self.viewport_interest_area_threshold != value {
self.viewport_interest_area_threshold = value;
self.update_dpr_viewport_interest_area_threshold();
return true;
}
false
}
pub fn set_node_batch_threshold(&mut self, value: i32) {
pub fn set_node_batch_threshold(&mut self, value: i32) -> bool {
if value > 0 {
self.node_batch_threshold = value;
return true;
}
false
}
pub fn set_max_blocking_time_ms(&mut self, value: i32) {
pub fn set_max_blocking_time_ms(&mut self, value: i32) -> bool {
if value > 0 {
self.max_blocking_time_ms = value;
return true;
}
false
}
}

View File

@ -302,6 +302,12 @@ fn handle_stroke_caps(
return;
}
// When both ends share the same simple line cap, Skia already drew it
// natively via `PaintCap` on the stroke paint, so skip the manual overlay.
if stroke.to_skia_linecap().is_some() {
return;
}
// Curves can have duplicated points, so let's remove consecutive duplicated points
let mut points = path.points().to_vec();
points.dedup();

View File

@ -1,7 +1,7 @@
use crate::error::{Error, Result};
use crate::performance;
use crate::shapes::Shape;
use crate::view::Viewbox;
use crate::{get_gpu_state, performance};
use skia_safe::{self as skia, IRect, Paint, RRect};
@ -101,11 +101,12 @@ pub struct Surfaces {
#[allow(dead_code)]
impl Surfaces {
pub fn try_new(
gpu_state: &mut GpuState,
(width, height): (i32, i32),
sampling_options: skia::SamplingOptions,
tile_dims: skia::ISize,
) -> Result<Self> {
let gpu_state = get_gpu_state();
let extra_tile_dims = skia::ISize::new(
tile_dims.width * TILE_SIZE_MULTIPLIER,
tile_dims.height * TILE_SIZE_MULTIPLIER,
@ -140,7 +141,7 @@ impl Surfaces {
atlas.canvas().clear(skia::Color::TRANSPARENT);
let tiles = TileTextureCache::new();
Ok(Surfaces {
Ok(Self {
target,
filter,
cache,
@ -445,12 +446,9 @@ impl Surfaces {
self.margins
}
pub fn resize(
&mut self,
gpu_state: &mut GpuState,
new_width: i32,
new_height: i32,
) -> Result<()> {
pub fn resize(&mut self, new_width: i32, new_height: i32) -> Result<()> {
let gpu_state = get_gpu_state();
self.reset_from_target(gpu_state.create_target_surface(new_width, new_height)?)?;
Ok(())
}
@ -547,7 +545,8 @@ impl Surfaces {
self.dirty_surfaces = 0;
}
pub fn flush_and_submit(&mut self, gpu_state: &mut GpuState, id: SurfaceId) {
pub fn flush_and_submit(&mut self, id: SurfaceId) {
let gpu_state = get_gpu_state();
let surface = self.get_mut(id);
gpu_state.context.flush_and_submit_surface(surface, None);
}
@ -946,13 +945,13 @@ impl Surfaces {
pub fn cache_current_tile_texture(
&mut self,
gpu_state: &mut GpuState,
tile_viewbox: &TileViewbox,
tile: &Tile,
tile_rect: &skia::Rect,
skip_cache_surface: bool,
tile_doc_rect: skia::Rect,
) {
let gpu_state = get_gpu_state();
let rect = IRect::from_xywh(
self.margins.width,
self.margins.height,
@ -985,7 +984,8 @@ impl Surfaces {
self.tiles.has(tile)
}
pub fn remove_cached_tile_surface(&mut self, gpu_state: &mut GpuState, tile: Tile) {
pub fn remove_cached_tile_surface(&mut self, tile: Tile) {
let gpu_state = get_gpu_state();
// Mark tile as invalid
// Old content stays visible until new tile overwrites it atomically,
// preventing flickering during tile re-renders.

View File

@ -21,7 +21,7 @@ pub fn render_overlay(
};
canvas.save();
let zoom = viewbox.zoom * options.dpr();
let zoom = viewbox.zoom * options.dpr;
canvas.scale((zoom, zoom));
canvas.translate((-viewbox.area.left, -viewbox.area.top));

View File

@ -11,7 +11,7 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
canvas.save();
let viewbox = render_state.viewbox;
let zoom = viewbox.zoom * render_state.options.dpr();
let zoom = viewbox.zoom * render_state.options.dpr;
canvas.scale((zoom, zoom));

View File

@ -293,6 +293,10 @@ impl Stroke {
}
}
if let Some(cap) = self.to_skia_linecap() {
paint.set_stroke_cap(cap);
}
paint
}
@ -330,6 +334,19 @@ impl Stroke {
cap_margin_for_cap(self.cap_start, self.width)
.max(cap_margin_for_cap(self.cap_end, self.width))
}
/// Returns a Skia `PaintCap` to apply natively on the stroke paint when
/// both ends share the same simple line cap (`Round/Round` or
/// `Square/Square`). Skia only emits cap geometry at sub-path endpoints,
/// so this is a no-op on closed paths and avoids the extra fill draw the
/// manual caps would otherwise require on open paths.
pub fn to_skia_linecap(&self) -> Option<skia::paint::Cap> {
match (self.cap_start, self.cap_end) {
(Some(StrokeCap::Round), Some(StrokeCap::Round)) => Some(skia::paint::Cap::Round),
(Some(StrokeCap::Square), Some(StrokeCap::Square)) => Some(skia::paint::Cap::Square),
_ => None,
}
}
}
fn align_rect_to_half_pixel(rect: &Rect, stroke_width: f32, scale: f32) -> Rect {

View File

@ -199,6 +199,12 @@ pub struct TextContentLayout {
cached_extrect: Cell<Option<CachedExtrect>>,
}
impl Default for TextContentLayout {
fn default() -> Self {
Self::new()
}
}
impl Clone for TextContentLayout {
fn clone(&self) -> Self {
Self {

View File

@ -1,11 +1,10 @@
use crate::get_render_state;
use crate::shapes::text::TextContent;
use skia_safe::{
self as skia, textlayout::Paragraph as SkiaParagraph, FontMetrics, Point, Rect, TextBlob,
};
use std::ops::Deref;
use crate::{with_state_mut, STATE};
pub struct TextPaths(TextContent);
// Note: This class is not being currently used.
@ -173,20 +172,18 @@ impl TextPaths {
blob_offset_x: f32,
blob_offset_y: f32,
) -> Option<(skia::Path, skia::Rect)> {
with_state_mut!(state, {
let utf16_text = span_text.encode_utf16().collect::<Vec<u16>>();
let text = unsafe { skia_safe::as_utf16_unchecked(&utf16_text) };
let emoji_font = state.render_state.fonts().get_emoji_font(font.size());
let use_font = emoji_font.as_ref().unwrap_or(font);
let utf16_text = span_text.encode_utf16().collect::<Vec<u16>>();
let text = unsafe { skia_safe::as_utf16_unchecked(&utf16_text) };
let emoji_font = get_render_state().fonts().get_emoji_font(font.size());
let use_font = emoji_font.as_ref().unwrap_or(font);
if let Some(mut text_blob) = TextBlob::from_text(text, use_font) {
let path = SkiaParagraph::get_path(&mut text_blob);
let d = Point::new(blob_offset_x, blob_offset_y);
let offset_path = path.with_offset(d);
let bounds = text_blob.bounds();
return Some((offset_path, *bounds));
}
});
if let Some(mut text_blob) = TextBlob::from_text(text, use_font) {
let path = SkiaParagraph::get_path(&mut text_blob);
let d = Point::new(blob_offset_x, blob_offset_y);
let offset_path = path.with_offset(d);
let bounds = text_blob.bounds();
return Some((offset_path, *bounds));
}
None
}
}

View File

@ -7,12 +7,9 @@ pub use shapes_pool::{ShapesPool, ShapesPoolMutRef, ShapesPoolRef};
pub use text_editor::*;
use crate::error::{Error, Result};
use crate::render::RenderState;
use crate::shapes::Shape;
use crate::tiles;
use crate::shapes::{grid_layout::grid_cell_data, Shape};
use crate::uuid::Uuid;
use crate::shapes::modifiers::grid_layout::grid_cell_data;
use crate::{get_render_state, tiles};
/// This struct holds the state of the Rust application between JS calls.
///
@ -20,8 +17,6 @@ use crate::shapes::modifiers::grid_layout::grid_cell_data;
/// Note that rust-skia data structures are not thread safe, so a state
/// must not be shared between different Web Workers.
pub(crate) struct State {
pub render_state: RenderState,
pub text_editor_state: TextEditorState,
pub current_id: Option<Uuid>,
pub current_browser: u8,
pub shapes: ShapesPool,
@ -31,16 +26,14 @@ pub(crate) struct State {
}
impl State {
pub fn try_new(width: i32, height: i32) -> Result<Self> {
Ok(State {
render_state: RenderState::try_new(width, height)?,
text_editor_state: TextEditorState::new(),
pub fn new() -> Self {
Self {
current_id: None,
current_browser: 0,
shapes: ShapesPool::new(),
saved_shapes: None,
loading: false,
})
}
}
// Creates a new temporary shapes pool.
@ -67,40 +60,16 @@ impl State {
Ok(self)
}
pub fn resize(&mut self, width: i32, height: i32) -> Result<()> {
self.render_state.resize(width, height)
}
pub fn render_state_mut(&mut self) -> &mut RenderState {
&mut self.render_state
}
pub fn render_state(&self) -> &RenderState {
&self.render_state
}
#[allow(dead_code)]
pub fn text_editor_state_mut(&mut self) -> &mut TextEditorState {
&mut self.text_editor_state
}
#[allow(dead_code)]
pub fn text_editor_state(&self) -> &TextEditorState {
&self.text_editor_state
}
pub fn render_from_cache(&mut self) {
self.render_state.render_from_cache(&self.shapes);
get_render_state().render_from_cache(&self.shapes);
}
pub fn render_sync(&mut self, timestamp: i32) -> Result<()> {
self.render_state
.start_render_loop(None, &self.shapes, timestamp, true)
get_render_state().start_render_loop(None, &self.shapes, timestamp, true)
}
pub fn render_sync_shape(&mut self, id: &Uuid, timestamp: i32) -> Result<()> {
self.render_state
.start_render_loop(Some(id), &self.shapes, timestamp, true)
get_render_state().start_render_loop(Some(id), &self.shapes, timestamp, true)
}
pub fn render_shape_pixels(
@ -109,36 +78,34 @@ impl State {
scale: f32,
timestamp: i32,
) -> Result<(Vec<u8>, i32, i32)> {
self.render_state
.render_shape_pixels(id, &self.shapes, scale, timestamp)
get_render_state().render_shape_pixels(id, &self.shapes, scale, timestamp)
}
pub fn start_render_loop(&mut self, timestamp: i32) -> Result<()> {
let render_state = get_render_state();
// If zoom changed (e.g. interrupted zoom render followed by pan), the
// tile index may be stale for the new viewport position. Rebuild the
// index so shapes are mapped to the correct tiles. We use
// rebuild_tile_index (NOT rebuild_tiles_shallow) to preserve the tile
// texture cache — otherwise cached tiles with shadows/blur would be
// cleared and re-rendered in fast mode without effects.
if self.render_state.zoom_changed() {
self.render_state.rebuild_tile_index(&self.shapes);
if render_state.zoom_changed() {
render_state.rebuild_tile_index(&self.shapes);
}
self.render_state
.start_render_loop(None, &self.shapes, timestamp, false)
render_state.start_render_loop(None, &self.shapes, timestamp, false)
}
pub fn process_animation_frame(&mut self, timestamp: i32) -> Result<()> {
self.render_state
.process_animation_frame(None, &self.shapes, timestamp)
get_render_state().process_animation_frame(None, &self.shapes, timestamp)
}
pub fn clear_focus_mode(&mut self) {
self.render_state.clear_focus_mode();
get_render_state().clear_focus_mode();
}
pub fn set_focus_mode(&mut self, shapes: Vec<Uuid>) {
self.render_state.set_focus_mode(shapes);
get_render_state().set_focus_mode(shapes);
}
pub fn init_shapes_pool(&mut self, capacity: usize) {
@ -153,6 +120,8 @@ impl State {
}
pub fn delete_shape_children(&mut self, parent_id: Uuid, id: Uuid) {
let render_state = get_render_state();
// We don't really do a self.shapes.remove so that redo/undo keep working
let Some(shape) = self.shapes.get(&id) else {
return;
@ -168,16 +137,15 @@ impl State {
//
// Instead, remove the shape from *all* tiles where it was indexed, and
// drop cached tiles for those entries.
let indexed_tiles: Vec<tiles::Tile> = self
.render_state
let indexed_tiles: Vec<tiles::Tile> = render_state
.tiles
.get_tiles_of(shape.id)
.map(|t| t.iter().copied().collect())
.unwrap_or_default();
for tile in indexed_tiles {
self.render_state.remove_cached_tile(tile);
self.render_state.tiles.remove_shape_at(tile, shape.id);
render_state.remove_cached_tile(tile);
render_state.tiles.remove_shape_at(tile, shape.id);
}
if let Some(shape_to_delete) = self.shapes.get(&id) {
@ -186,8 +154,8 @@ impl State {
if let Some(shape_to_delete) = self.shapes.get_mut(&shape_id) {
shape_to_delete.set_deleted(true);
}
if self.render_state.show_grid == Some(shape_id) {
self.render_state.show_grid = None;
if render_state.show_grid == Some(shape_id) {
render_state.show_grid = None;
}
}
}
@ -203,7 +171,7 @@ impl State {
}
pub fn set_background_color(&mut self, color: skia::Color) {
self.render_state.set_background_color(color);
get_render_state().set_background_color(color);
}
pub fn set_browser(&mut self, browser: u8) {
@ -238,33 +206,32 @@ impl State {
}
pub fn rebuild_tiles_shallow(&mut self) {
self.render_state.rebuild_tiles_shallow(&self.shapes);
get_render_state().rebuild_tiles_shallow(&self.shapes);
}
pub fn rebuild_tiles(&mut self) {
self.render_state.rebuild_tiles_from(&self.shapes, None);
get_render_state().rebuild_tiles_from(&self.shapes, None);
}
pub fn rebuild_tiles_from(&mut self, base_id: Option<&Uuid>) {
self.render_state.rebuild_tiles_from(&self.shapes, base_id);
get_render_state().rebuild_tiles_from(&self.shapes, base_id);
}
pub fn rebuild_touched_tiles(&mut self) {
self.render_state.rebuild_touched_tiles(&self.shapes);
get_render_state().rebuild_touched_tiles(&self.shapes);
}
pub fn render_preview(&mut self, timestamp: i32) {
let _ = self.render_state.render_preview(&self.shapes, timestamp);
let _ = get_render_state().render_preview(&self.shapes, timestamp);
}
pub fn rebuild_modifier_tiles(&mut self, ids: Vec<Uuid>) -> Result<()> {
// Index-based storage is safe
self.render_state
.rebuild_modifier_tiles(&mut self.shapes, ids)
get_render_state().rebuild_modifier_tiles(&mut self.shapes, ids)
}
pub fn font_collection(&self) -> &FontCollection {
self.render_state.fonts().font_collection()
get_render_state().fonts().font_collection()
}
pub fn get_grid_coords(&self, pos_x: f32, pos_y: f32) -> Option<(i32, i32)> {
@ -297,16 +264,18 @@ impl State {
}
pub fn touch_current(&mut self) {
let render_state = get_render_state();
if !self.loading {
if let Some(current_id) = self.current_id {
self.render_state.mark_touched(current_id);
render_state.mark_touched(current_id);
}
}
}
pub fn touch_shape(&mut self, id: Uuid) {
let render_state = get_render_state();
if !self.loading {
self.render_state.mark_touched(id);
render_state.mark_touched(id);
}
}
}

View File

@ -392,3 +392,9 @@ impl ShapesPoolImpl {
})
}
}
impl Default for ShapesPoolImpl {
fn default() -> Self {
Self::new()
}
}

View File

@ -255,6 +255,12 @@ impl TextEditorStyles {
}
}
impl Default for TextEditorStyles {
fn default() -> Self {
Self::new()
}
}
pub struct TextEditorTheme {
pub selection_color: Color,
pub cursor_color: Color,
@ -319,6 +325,12 @@ impl TextComposition {
}
}
impl Default for TextComposition {
fn default() -> Self {
Self::new()
}
}
pub struct TextEditorState {
pub theme: TextEditorTheme,
pub selection: TextSelection,
@ -880,6 +892,12 @@ impl TextEditorState {
}
}
impl Default for TextEditorState {
fn default() -> Self {
Self::new()
}
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}

View File

@ -22,43 +22,95 @@ impl Tile {
pub struct TileRect(pub i32, pub i32, pub i32, pub i32);
impl TileRect {
pub fn empty() -> Self {
Self(0, 0, 0, 0)
}
#[inline]
pub fn x1(&self) -> i32 {
self.0
}
#[inline]
pub fn y1(&self) -> i32 {
self.1
}
#[inline]
pub fn x2(&self) -> i32 {
self.2
}
#[inline]
pub fn y2(&self) -> i32 {
self.3
}
#[inline]
pub fn left(&self) -> i32 {
self.0
}
#[inline]
pub fn top(&self) -> i32 {
self.1
}
#[inline]
pub fn right(&self) -> i32 {
self.2
}
#[inline]
pub fn bottom(&self) -> i32 {
self.3
}
#[inline]
pub fn x(&self) -> i32 {
self.0
}
#[inline]
pub fn y(&self) -> i32 {
self.1
}
#[inline]
pub fn width(&self) -> i32 {
self.x2() - self.x1()
}
#[inline]
pub fn half_width(&self) -> i32 {
self.width() / 2
}
#[inline]
pub fn height(&self) -> i32 {
self.y2() - self.y1()
}
pub fn center_x(&self) -> i32 {
self.x1() + self.width() / 2
#[inline]
pub fn half_height(&self) -> i32 {
self.height() / 2
}
#[inline]
pub fn center_x(&self) -> i32 {
self.x() + self.half_width()
}
#[inline]
pub fn center_y(&self) -> i32 {
self.y1() + self.height() / 2
self.y() + self.half_height()
}
pub fn contains(&self, tile: &Tile) -> bool {
tile.x() >= self.x1()
&& tile.y() >= self.y1()
&& tile.x() <= self.x2()
&& tile.y() <= self.y2()
tile.x() >= self.left()
&& tile.y() >= self.top()
&& tile.x() <= self.right()
&& tile.y() <= self.bottom()
}
}
@ -195,43 +247,70 @@ impl TileHashMap {
}
const VIEWPORT_DEFAULT_CAPACITY: usize = 24 * 12;
const VIEWPORT_SPIRAL_DEFAULT_CAPACITY: usize = 64;
// This structure keeps the list of tiles that are in the pending list, the
// ones that are going to be rendered.
pub struct PendingTiles {
pub list: Vec<Tile>,
pub spiral: Vec<Tile>,
pub spiral_rect: TileRect,
}
impl PendingTiles {
pub fn new_empty() -> Self {
pub fn new() -> Self {
Self {
list: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY),
spiral: Vec::with_capacity(VIEWPORT_SPIRAL_DEFAULT_CAPACITY),
spiral_rect: TileRect::empty(),
}
}
// Generate tiles ordered by distance to the center (closest processed first).
fn generate_spiral(rect: &TileRect) -> Vec<Tile> {
let cx = rect.center_x();
let cy = rect.center_y();
// Generate tiles in spiral order from center
fn generate_spiral(columns: usize, rows: usize) -> Vec<Tile> {
let total = columns * rows;
let mut result = Vec::with_capacity(total);
let mut cx = 0;
let mut cy = 0;
// TileRect is inclusive (x1..=x2, y1..=y2).
let mut tiles = Vec::new();
for x in rect.x1()..=rect.x2() {
for y in rect.y1()..=rect.y2() {
tiles.push(Tile(x, y));
let ratio = (columns as f32 / rows as f32).ceil() as i32;
let mut direction_current = 0;
let mut direction_total_x = ratio;
let mut direction_total_y = 1;
let mut direction = 0;
result.push(Tile(cx, cy));
while result.len() < total {
match direction {
0 => cx += 1,
1 => cy += 1,
2 => cx -= 1,
3 => cy -= 1,
_ => unreachable!("Invalid direction"),
}
result.push(Tile(cx, cy));
direction_current += 1;
let direction_total = if direction % 2 == 0 {
direction_total_x
} else {
direction_total_y
};
if direction_current == direction_total {
if direction % 2 == 0 {
direction_total_x += 1;
} else {
direction_total_y += 1;
}
direction = (direction + 1) % 4;
direction_current = 0;
}
}
// We pop() from the end, so keep nearest-to-center tiles at the end.
tiles.sort_unstable_by(|a, b| {
let da = (a.x() - cx).abs() + (a.y() - cy).abs();
let db = (b.x() - cx).abs() + (b.y() - cy).abs();
da.cmp(&db)
.then_with(|| a.x().cmp(&b.x()))
.then_with(|| a.y().cmp(&b.y()))
});
tiles.reverse();
tiles
result.reverse();
result
}
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) {
@ -247,7 +326,18 @@ impl PendingTiles {
} else {
&tile_viewbox.interest_rect
};
let spiral = Self::generate_spiral(spiral_rect);
self.spiral_rect = *spiral_rect;
// We do not regenerate spiral if the spiral_rect
// doesn't change. The spiral_rect is based on the
// viewbox so, if the viewbox doesn't change
// the spiral should not change.
let total = (spiral_rect.width() * spiral_rect.height()) as usize;
if self.spiral.len() < total {
self.spiral =
Self::generate_spiral(spiral_rect.width() as usize, spiral_rect.height() as usize);
}
// Partition tiles into 4 priority groups (highest priority = processed last due to pop()):
// 1. visible + cached (fastest - just blit from cache)
@ -259,7 +349,9 @@ impl PendingTiles {
let mut interest_cached = Vec::new();
let mut interest_uncached = Vec::new();
for tile in spiral {
let center_tile = Tile(spiral_rect.center_x(), spiral_rect.center_y());
for spiral_tile in self.spiral.iter() {
let tile = Tile(spiral_tile.0 + center_tile.0, spiral_tile.1 + center_tile.1);
let is_visible = tile_viewbox.visible_rect.contains(&tile);
let is_cached = surfaces.has_cached_tile_surface(tile);

View File

@ -1,3 +1,4 @@
use crate::get_render_state;
use crate::skia::textlayout::FontCollection;
use crate::skia::Image;
use crate::uuid::Uuid;
@ -25,12 +26,12 @@ pub fn uuid_from_u32(id: [u32; 4]) -> Uuid {
}
pub fn get_image(image_id: &Uuid) -> Option<&Image> {
with_state_mut!(state, { state.render_state_mut().images.get(image_id) })
get_render_state().images.get(image_id)
}
// FIXME: move to a different place ?
pub fn get_fallback_fonts() -> &'static HashSet<String> {
with_state_mut!(state, { state.render_state().fonts().get_fallback() })
get_render_state().fonts().get_fallback()
}
pub fn get_font_collection() -> &'static FontCollection {

View File

@ -1,4 +1,5 @@
use crate::error::{Error, Result};
use crate::get_render_state;
use crate::mem;
use crate::shapes::Fill;
use crate::state::State;
@ -106,11 +107,7 @@ pub extern "C" fn store_image() -> Result<()> {
let image_bytes = &bytes[IMAGE_HEADER_SIZE..];
with_state_mut!(state, {
if let Err(msg) =
state
.render_state_mut()
.add_image(ids.image_id, is_thumbnail, image_bytes)
{
if let Err(msg) = get_render_state().add_image(ids.image_id, is_thumbnail, image_bytes) {
eprintln!("{}", msg);
}
touch_shapes_with_image(state, ids.image_id);
@ -180,7 +177,7 @@ pub extern "C" fn store_image_from_texture() -> Result<()> {
);
with_state_mut!(state, {
if let Err(msg) = state.render_state_mut().add_image_from_gl_texture(
if let Err(msg) = get_render_state().add_image_from_gl_texture(
ids.image_id,
is_thumbnail,
texture_id,

View File

@ -1,10 +1,9 @@
use macros::{wasm_error, ToJs};
use crate::get_render_state;
use crate::mem;
use crate::shapes::{FontFamily, FontStyle};
use crate::utils::uuid_from_u32_quartet;
use crate::with_state_mut;
use crate::STATE;
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
@ -41,20 +40,16 @@ pub extern "C" fn store_font(
is_emoji: bool,
is_fallback: bool,
) -> Result<()> {
with_state_mut!(state, {
let id = uuid_from_u32_quartet(a, b, c, d);
let font_bytes = mem::bytes();
let font_style = RawFontStyle::from(style);
let id = uuid_from_u32_quartet(a, b, c, d);
let font_bytes = mem::bytes();
let font_style = RawFontStyle::from(style);
let family = FontFamily::new(id, weight, font_style.into());
let _ =
state
.render_state_mut()
.fonts_mut()
.add(family, &font_bytes, is_emoji, is_fallback);
let family = FontFamily::new(id, weight, font_style.into());
let _ = get_render_state()
.fonts_mut()
.add(family, &font_bytes, is_emoji, is_fallback);
mem::free_bytes()?;
});
mem::free_bytes()?;
Ok(())
}
@ -68,12 +63,10 @@ pub extern "C" fn is_font_uploaded(
style: u8,
is_emoji: bool,
) -> bool {
with_state_mut!(state, {
let id = uuid_from_u32_quartet(a, b, c, d);
let font_style = RawFontStyle::from(style);
let family = FontFamily::new(id, weight, font_style.into());
let res = state.render_state().fonts().has_family(&family, is_emoji);
let id = uuid_from_u32_quartet(a, b, c, d);
let font_style = RawFontStyle::from(style);
let family = FontFamily::new(id, weight, font_style.into());
let res = get_render_state().fonts().has_family(&family, is_emoji);
res
})
res
}

View File

@ -1,9 +1,10 @@
use macros::{wasm_error, ToJs};
use crate::get_render_state;
use crate::mem;
use crate::shapes::{GridCell, GridDirection, GridTrack, GridTrackType};
use crate::uuid::Uuid;
use crate::{uuid_from_u32_quartet, with_current_shape_mut, with_state, with_state_mut, STATE};
use crate::{uuid_from_u32_quartet, with_current_shape_mut, with_state, STATE};
use super::align;
@ -241,17 +242,13 @@ pub extern "C" fn set_grid_cells() -> Result<()> {
#[no_mangle]
pub extern "C" fn show_grid(a: u32, b: u32, c: u32, d: u32) {
with_state_mut!(state, {
let id = uuid_from_u32_quartet(a, b, c, d);
state.render_state.show_grid = Some(id);
});
let id = uuid_from_u32_quartet(a, b, c, d);
get_render_state().show_grid = Some(id);
}
#[no_mangle]
pub extern "C" fn hide_grid() {
with_state_mut!(state, {
state.render_state.show_grid = None;
});
get_render_state().show_grid = None;
}
#[no_mangle]

View File

@ -1,7 +1,7 @@
use macros::{wasm_error, ToJs};
use crate::get_text_editor_state;
use crate::math::{Matrix, Point, Rect};
use crate::mem;
use crate::render::text_editor as text_editor_render;
use crate::render::SurfaceId;
use crate::shapes::{Shape, TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
@ -12,6 +12,7 @@ use crate::wasm::fills::RawFillData;
use crate::wasm::text::{
helpers as text_helpers, RawTextAlign, RawTextDecoration, RawTextDirection, RawTextTransform,
};
use crate::{get_render_state, mem};
use crate::{with_state, with_state_mut, STATE};
use skia_safe::Color;
@ -33,12 +34,10 @@ pub enum CursorDirection {
#[no_mangle]
pub extern "C" fn text_editor_apply_theme(selection_color: u32, cursor_color: u32) {
with_state_mut!(state, {
// NOTE: In the future could be interesting to fill al this data from
// a structure pointer.
state.text_editor_state.theme.selection_color = Color::new(selection_color);
state.text_editor_state.theme.cursor_color = Color::new(cursor_color);
})
// NOTE: In the future could be interesting to fill al this data from
// a structure pointer.
get_text_editor_state().theme.selection_color = Color::new(selection_color);
get_text_editor_state().theme.cursor_color = Color::new(cursor_color);
}
#[no_mangle]
@ -54,74 +53,66 @@ pub extern "C" fn text_editor_focus(a: u32, b: u32, c: u32, d: u32) -> bool {
return false;
}
state.text_editor_state.focus(shape_id);
get_text_editor_state().focus(shape_id);
true
})
}
#[no_mangle]
pub extern "C" fn text_editor_blur() -> bool {
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
return false;
}
state.text_editor_state.blur();
true
})
if !get_text_editor_state().has_focus {
return false;
}
get_text_editor_state().blur();
true
}
#[no_mangle]
pub extern "C" fn text_editor_dispose() -> bool {
with_state_mut!(state, {
state.text_editor_state.dispose();
true
})
get_text_editor_state().dispose();
true
}
#[no_mangle]
pub extern "C" fn text_editor_has_selection() -> bool {
with_state!(state, { state.text_editor_state.selection.is_selection() })
get_text_editor_state().selection.is_selection()
}
#[no_mangle]
pub extern "C" fn text_editor_has_focus() -> bool {
with_state!(state, { state.text_editor_state.has_focus })
get_text_editor_state().has_focus
}
#[no_mangle]
pub extern "C" fn text_editor_has_focus_with_id(a: u32, b: u32, c: u32, d: u32) -> bool {
with_state!(state, {
let shape_id = uuid_from_u32_quartet(a, b, c, d);
let Some(active_shape_id) = state.text_editor_state.active_shape_id else {
return false;
};
state.text_editor_state.has_focus && active_shape_id == shape_id
})
let shape_id = uuid_from_u32_quartet(a, b, c, d);
let Some(active_shape_id) = get_text_editor_state().active_shape_id else {
return false;
};
get_text_editor_state().has_focus && active_shape_id == shape_id
}
#[no_mangle]
pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) {
with_state!(state, {
if let Some(shape_id) = state.text_editor_state.active_shape_id {
let (a, b, c, d) = uuid_to_u32_quartet(&shape_id);
unsafe {
*buffer_ptr = a;
*buffer_ptr.add(1) = b;
*buffer_ptr.add(2) = c;
*buffer_ptr.add(3) = d;
}
if let Some(shape_id) = get_text_editor_state().active_shape_id {
let (a, b, c, d) = uuid_to_u32_quartet(&shape_id);
unsafe {
*buffer_ptr = a;
*buffer_ptr.add(1) = b;
*buffer_ptr.add(2) = c;
*buffer_ptr.add(3) = d;
}
})
}
}
#[no_mangle]
pub extern "C" fn text_editor_select_all() -> bool {
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return false;
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return false;
};
@ -132,18 +123,18 @@ pub extern "C" fn text_editor_select_all() -> bool {
let Type::Text(text_content) = &shape.shape_type else {
return false;
};
state.text_editor_state.select_all(text_content)
get_text_editor_state().select_all(text_content)
})
}
#[no_mangle]
pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return;
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return;
};
@ -157,16 +148,14 @@ pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) {
let point = Point::new(x, y);
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
state
.text_editor_state
.select_word_boundary(text_content, &position);
get_text_editor_state().select_word_boundary(text_content, &position);
}
})
}
#[no_mangle]
pub extern "C" fn text_editor_poll_event() -> u8 {
with_state_mut!(state, { state.text_editor_state.poll_event() as u8 })
get_text_editor_state().poll_event() as u8
}
// ============================================================================
@ -176,10 +165,10 @@ pub extern "C" fn text_editor_poll_event() -> u8 {
#[no_mangle]
pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return;
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return;
};
let Some(shape) = state.shapes.get(&shape_id) else {
@ -189,10 +178,10 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
return;
};
let point = Point::new(x, y);
state.text_editor_state.start_pointer_selection();
get_text_editor_state().start_pointer_selection();
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
state.text_editor_state.set_caret_from_position(&position);
state.text_editor_state.update_styles(text_content);
get_text_editor_state().set_caret_from_position(&position);
get_text_editor_state().update_styles(text_content);
}
});
}
@ -200,12 +189,12 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
#[no_mangle]
pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return;
}
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return;
};
@ -213,7 +202,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
return;
};
if !state.text_editor_state.is_pointer_selection_active {
if !get_text_editor_state().is_pointer_selection_active {
return;
}
@ -222,13 +211,11 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
};
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
state
.text_editor_state
.extend_selection_from_position(&position);
get_text_editor_state().extend_selection_from_position(&position);
// We need this flag to prevent handling the click behavior
// just after a pointerup event.
state.text_editor_state.is_click_event_skipped = true;
state.text_editor_state.update_styles(text_content);
get_text_editor_state().is_click_event_skipped = true;
get_text_editor_state().update_styles(text_content);
}
});
}
@ -236,29 +223,27 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
#[no_mangle]
pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return;
}
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return;
};
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
if !state.text_editor_state.is_pointer_selection_active {
if !get_text_editor_state().is_pointer_selection_active {
return;
}
let Type::Text(text_content) = &shape.shape_type else {
return;
};
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
state
.text_editor_state
.extend_selection_from_position(&position);
state.text_editor_state.update_styles(text_content);
get_text_editor_state().extend_selection_from_position(&position);
get_text_editor_state().update_styles(text_content);
}
state.text_editor_state.stop_pointer_selection();
get_text_editor_state().stop_pointer_selection();
});
}
@ -267,17 +252,17 @@ pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) {
with_state_mut!(state, {
// We need this flag to prevent handling the click behavior
// just after a pointerup event.
if state.text_editor_state.is_click_event_skipped {
state.text_editor_state.is_click_event_skipped = false;
if get_text_editor_state().is_click_event_skipped {
get_text_editor_state().is_click_event_skipped = false;
return;
}
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return;
}
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return;
};
@ -290,7 +275,7 @@ pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) {
};
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
state.text_editor_state.set_caret_from_position(&position);
get_text_editor_state().set_caret_from_position(&position);
}
});
}
@ -298,13 +283,13 @@ pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) {
#[no_mangle]
pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return;
}
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let view_matrix: Matrix = get_render_state().viewbox.get_matrix();
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return;
};
let Some(shape) = state.shapes.get(&shape_id) else {
@ -317,7 +302,7 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
state.text_editor_state.set_caret_from_position(&position);
get_text_editor_state().set_caret_from_position(&position);
}
});
}
@ -329,13 +314,10 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
#[no_mangle]
#[wasm_error]
pub extern "C" fn text_editor_composition_start() -> Result<()> {
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
return Ok(());
}
state.text_editor_state.composition.start();
});
if !get_text_editor_state().has_focus {
return Ok(());
}
get_text_editor_state().composition.start();
Ok(())
}
@ -349,11 +331,11 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> {
};
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return Ok(());
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return Ok(());
};
@ -365,35 +347,30 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> {
return Ok(());
};
state.text_editor_state.composition.update(&text);
get_text_editor_state().composition.update(&text);
let selection = state
.text_editor_state
let selection = get_text_editor_state()
.composition
.get_selection(&state.text_editor_state.selection);
.get_selection(&get_text_editor_state().selection);
text_helpers::delete_selection_range(text_content, &selection);
let cursor = state.text_editor_state.selection.focus;
let cursor = get_text_editor_state().selection.focus;
if let Some(new_cursor) =
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
{
state.text_editor_state.selection.set_caret(new_cursor);
get_text_editor_state().selection.set_caret(new_cursor);
}
text_content.layout.paragraphs.clear();
text_content.layout.paragraph_builders.clear();
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::NeedsLayout);
get_text_editor_state().reset_blink();
get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged);
get_text_editor_state().push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
get_render_state().mark_touched(shape_id);
state.text_editor_state.composition.end();
get_text_editor_state().composition.end();
});
crate::mem::free_bytes()?;
@ -410,11 +387,11 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> {
};
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return Ok(());
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return Ok(());
};
@ -426,29 +403,24 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> {
return Ok(());
};
state.text_editor_state.composition.update(&text);
get_text_editor_state().composition.update(&text);
let selection = state
.text_editor_state
let selection = get_text_editor_state()
.composition
.get_selection(&state.text_editor_state.selection);
.get_selection(&get_text_editor_state().selection);
text_helpers::delete_selection_range(text_content, &selection);
let cursor = state.text_editor_state.selection.focus;
let cursor = get_text_editor_state().selection.focus;
text_helpers::insert_text_with_newlines(text_content, &cursor, &text);
text_content.layout.paragraphs.clear();
text_content.layout.paragraph_builders.clear();
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::NeedsLayout);
get_text_editor_state().reset_blink();
get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged);
get_text_editor_state().push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
get_render_state().mark_touched(shape_id);
});
crate::mem::free_bytes()?;
@ -458,10 +430,8 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn text_editor_toggle_overtype_mode() -> Result<()> {
with_state_mut!(state, {
state.text_editor_state.toggle_overtype_mode();
Ok(())
})
get_text_editor_state().toggle_overtype_mode();
Ok(())
}
// FIXME: Review if all the return Ok(()) should be Err instead.
@ -475,11 +445,11 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> {
};
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return Ok(());
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return Ok(());
};
@ -491,39 +461,35 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> {
return Ok(());
};
let selection = state.text_editor_state.selection;
let selection = get_text_editor_state().selection;
if selection.is_selection() {
text_helpers::delete_selection_range(text_content, &selection);
let start = selection.start();
state.text_editor_state.selection.set_caret(start);
get_text_editor_state().selection.set_caret(start);
}
let cursor = state.text_editor_state.selection.focus;
if !state.text_editor_state.is_overtype_mode {
let cursor = get_text_editor_state().selection.focus;
if !get_text_editor_state().is_overtype_mode {
if let Some(new_cursor) =
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
{
state.text_editor_state.selection.set_caret(new_cursor);
get_text_editor_state().selection.set_caret(new_cursor);
}
} else if let Some(new_cursor) =
text_helpers::replace_text_with_newlines(text_content, &cursor, &text)
{
state.text_editor_state.selection.set_caret(new_cursor);
get_text_editor_state().selection.set_caret(new_cursor);
}
text_content.layout.paragraphs.clear();
text_content.layout.paragraph_builders.clear();
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(TextEditorEvent::NeedsLayout);
get_text_editor_state().reset_blink();
get_text_editor_state().push_event(TextEditorEvent::ContentChanged);
get_text_editor_state().push_event(TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
get_render_state().mark_touched(shape_id);
});
crate::mem::free_bytes()?;
@ -533,11 +499,11 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> {
#[no_mangle]
pub extern "C" fn text_editor_delete_backward(word_boundary: bool) {
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return;
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return;
};
@ -549,21 +515,19 @@ pub extern "C" fn text_editor_delete_backward(word_boundary: bool) {
return;
};
state
.text_editor_state
.delete_backward(text_content, word_boundary);
state.render_state.mark_touched(shape_id);
get_text_editor_state().delete_backward(text_content, word_boundary);
get_render_state().mark_touched(shape_id);
});
}
#[no_mangle]
pub extern "C" fn text_editor_delete_forward(word_boundary: bool) {
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return;
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return;
};
@ -575,21 +539,19 @@ pub extern "C" fn text_editor_delete_forward(word_boundary: bool) {
return;
};
state
.text_editor_state
.delete_forward(text_content, word_boundary);
state.render_state.mark_touched(shape_id);
get_text_editor_state().delete_forward(text_content, word_boundary);
get_render_state().mark_touched(shape_id);
});
}
#[no_mangle]
pub extern "C" fn text_editor_insert_paragraph() {
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return;
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return;
};
@ -601,8 +563,8 @@ pub extern "C" fn text_editor_insert_paragraph() {
return;
};
state.text_editor_state.insert_paragraph(text_content);
state.render_state.mark_touched(shape_id);
get_text_editor_state().insert_paragraph(text_content);
get_render_state().mark_touched(shape_id);
});
}
@ -617,11 +579,11 @@ pub extern "C" fn text_editor_move_cursor(
extend_selection: bool,
) {
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return;
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return;
};
@ -633,7 +595,7 @@ pub extern "C" fn text_editor_move_cursor(
return;
};
state.text_editor_state.move_cursor(
get_text_editor_state().move_cursor(
text_content,
direction,
word_boundary,
@ -649,11 +611,11 @@ pub extern "C" fn text_editor_move_cursor(
#[no_mangle]
pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 {
with_state_mut!(state, {
if !state.text_editor_state.has_focus || !state.text_editor_state.cursor_visible {
if !get_text_editor_state().has_focus || !get_text_editor_state().cursor_visible {
return std::ptr::null_mut();
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return std::ptr::null_mut();
};
@ -665,7 +627,7 @@ pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 {
return std::ptr::null_mut();
};
let cursor = &state.text_editor_state.selection.focus;
let cursor = &get_text_editor_state().selection.focus;
if let Some(rect) = get_cursor_rect(text_content, cursor, shape) {
let mut bytes = vec![0u8; 16];
@ -683,11 +645,11 @@ pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 {
#[no_mangle]
pub extern "C" fn text_editor_get_current_styles() -> *mut u8 {
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return std::ptr::null_mut();
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return std::ptr::null_mut();
};
@ -699,7 +661,7 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 {
return std::ptr::null_mut();
};
let styles = &state.text_editor_state.current_styles;
let styles = &get_text_editor_state().current_styles;
let vertical_align = match styles.vertical_align {
VerticalAlign::Top => 0_u32,
@ -851,15 +813,15 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 {
#[no_mangle]
pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 {
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return std::ptr::null_mut();
}
if state.text_editor_state.selection.is_collapsed() {
if get_text_editor_state().selection.is_collapsed() {
return std::ptr::null_mut();
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return std::ptr::null_mut();
};
@ -871,7 +833,7 @@ pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 {
return std::ptr::null_mut();
};
let selection = &state.text_editor_state.selection;
let selection = &get_text_editor_state().selection;
let rects = get_selection_rects(text_content, selection, shape);
if rects.is_empty() {
return std::ptr::null_mut();
@ -891,15 +853,13 @@ pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 {
#[no_mangle]
pub extern "C" fn text_editor_update_blink(timestamp_ms: f32) {
with_state_mut!(state, {
state.text_editor_state.update_blink(timestamp_ms);
});
get_text_editor_state().update_blink(timestamp_ms);
}
#[no_mangle]
pub extern "C" fn text_editor_render_overlay() {
with_state_mut!(state, {
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return;
};
@ -920,27 +880,27 @@ pub extern "C" fn text_editor_render_overlay() {
return;
};
let canvas = state.render_state.surfaces.canvas(SurfaceId::Target);
let viewbox = state.render_state.viewbox;
let canvas = get_render_state().surfaces.canvas(SurfaceId::Target);
let viewbox = get_render_state().viewbox;
text_editor_render::render_overlay(
canvas,
&viewbox,
&state.render_state.options,
&state.text_editor_state,
&get_render_state().options,
get_text_editor_state(),
shape,
);
state.render_state.flush_and_submit();
get_render_state().flush_and_submit();
});
}
#[no_mangle]
pub extern "C" fn text_editor_export_content() -> *mut u8 {
with_state!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return std::ptr::null_mut();
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return std::ptr::null_mut();
};
@ -979,10 +939,10 @@ pub extern "C" fn text_editor_export_content() -> *mut u8 {
pub extern "C" fn text_editor_export_selection() -> *mut u8 {
use std::ptr;
with_state!(state, {
if !state.text_editor_state.has_focus {
if !get_text_editor_state().has_focus {
return ptr::null_mut();
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return ptr::null_mut();
};
let Some(shape) = state.shapes.get(&shape_id) else {
@ -991,7 +951,7 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 {
let Type::Text(text_content) = &shape.shape_type else {
return ptr::null_mut();
};
let selection = &state.text_editor_state.selection;
let selection = &get_text_editor_state().selection;
let start = selection.start();
let end = selection.end();
let paragraphs = text_content.paragraphs();
@ -1055,19 +1015,17 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 {
#[no_mangle]
pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> bool {
with_state!(state, {
if !state.text_editor_state.selection.is_selection() {
return false;
}
let sel = &state.text_editor_state.selection;
unsafe {
*buffer_ptr = sel.anchor.paragraph as u32;
*buffer_ptr.add(1) = sel.anchor.offset as u32;
*buffer_ptr.add(2) = sel.focus.paragraph as u32;
*buffer_ptr.add(3) = sel.focus.offset as u32;
}
true
})
if !get_text_editor_state().selection.is_selection() {
return false;
}
let sel = &get_text_editor_state().selection;
unsafe {
*buffer_ptr = sel.anchor.paragraph as u32;
*buffer_ptr.add(1) = sel.anchor.offset as u32;
*buffer_ptr.add(2) = sel.focus.paragraph as u32;
*buffer_ptr.add(3) = sel.focus.offset as u32;
}
true
}
// ============================================================================