diff --git a/.opencode/agents/planner.md b/.opencode/agents/planner.md index 1236dd7458..ff838d77a5 100644 --- a/.opencode/agents/planner.md +++ b/.opencode/agents/planner.md @@ -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. diff --git a/CHANGES.md b/CHANGES.md index 7d555b47a8..039e0f8f5e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 doesn’t work within variant [Taiga #12314](https://tree.taiga.io/project/penpot/issue/12314) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 782dfabca7..571fb0d819 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -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) diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 327d64da99..520c921397 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -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] diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index c716ce8b85..d07090e074 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -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)}]]) diff --git a/backend/src/app/http/awsns.clj b/backend/src/app/http/awsns.clj index 051a2ecf8b..c90b0d7a82 100644 --- a/backend/src/app/http/awsns.clj +++ b/backend/src/app/http/awsns.clj @@ -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"))] diff --git a/backend/src/app/http/client.clj b/backend/src/app/http/client.clj index 7fcbd3ff00..5e10ee4c8f 100644 --- a/backend/src/app/http/client.clj +++ b/backend/src/app/http/client.clj @@ -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)))))) diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index 1915652bbd..bc70e4ec83 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -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 diff --git a/backend/src/app/loggers/mattermost.clj b/backend/src/app/loggers/mattermost.clj index c2fd3d1dfb..52fd2edce3 100644 --- a/backend/src/app/loggers/mattermost.clj +++ b/backend/src/app/loggers/mattermost.clj @@ -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" diff --git a/backend/src/app/loggers/webhooks.clj b/backend/src/app/loggers/webhooks.clj index 8edb646260..eab1344047 100644 --- a/backend/src/app/loggers/webhooks.clj +++ b/backend/src/app/loggers/webhooks.clj @@ -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)))) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index a1501e3ca0..9bcc96519a 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -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) diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index 9cf135213d..25bd3e0002 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -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 diff --git a/backend/src/app/media/sanitize.clj b/backend/src/app/media/sanitize.clj new file mode 100644 index 0000000000..4905f5b603 --- /dev/null +++ b/backend/src/app/media/sanitize.clj @@ -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))))) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 35d8d4f3ad..61f652c1ef 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -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] diff --git a/backend/src/app/rpc/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj index 5b64541824..7a3c441335 100644 --- a/backend/src/app/rpc/commands/webhooks.clj +++ b/backend/src/app/rpc/commands/webhooks.clj @@ -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]}] diff --git a/backend/src/app/setup/templates.clj b/backend/src/app/setup/templates.clj index 476ec25f58..5c86bc485d 100644 --- a/backend/src/app/setup/templates.clj +++ b/backend/src/app/setup/templates.clj @@ -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 diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj index aa2cae58e0..c6d989694b 100644 --- a/backend/src/app/tasks/telemetry.clj +++ b/backend/src/app/tasks/telemetry.clj @@ -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 diff --git a/backend/src/app/util/nio.clj b/backend/src/app/util/nio.clj new file mode 100644 index 0000000000..676d95aea3 --- /dev/null +++ b/backend/src/app/util/nio.clj @@ -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) diff --git a/backend/src/app/util/ssrf.clj b/backend/src/app/util/ssrf.clj new file mode 100644 index 0000000000..5348f1eaf7 --- /dev/null +++ b/backend/src/app/util/ssrf.clj @@ -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))) diff --git a/backend/test/backend_tests/loggers_webhooks_test.clj b/backend/test/backend_tests/loggers_webhooks_test.clj index c34df71543..d77b3b6be0 100644 --- a/backend/test/backend_tests/loggers_webhooks_test.clj +++ b/backend/test/backend_tests/loggers_webhooks_test.clj @@ -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" diff --git a/backend/test/backend_tests/media_sanitize_test.clj b/backend/test/backend_tests/media_sanitize_test.clj new file mode 100644 index 0000000000..0f8629a029 --- /dev/null +++ b/backend/test/backend_tests/media_sanitize_test.clj @@ -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 "") + 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 "") + 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)))))) diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index 070a105a1b..2c2bea19e9 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -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)) diff --git a/backend/test/backend_tests/rpc_webhooks_test.clj b/backend/test/backend_tests/rpc_webhooks_test.clj index bc1da4c64f..703c9b0643 100644 --- a/backend/test/backend_tests/rpc_webhooks_test.clj +++ b/backend/test/backend_tests/rpc_webhooks_test.clj @@ -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}) diff --git a/backend/test/backend_tests/test_files/sample.png b/backend/test/backend_tests/test_files/sample.png new file mode 100644 index 0000000000..586da94a96 Binary files /dev/null and b/backend/test/backend_tests/test_files/sample.png differ diff --git a/backend/test/backend_tests/test_files/sample.webp b/backend/test/backend_tests/test_files/sample.webp new file mode 100644 index 0000000000..be1eb528b5 Binary files /dev/null and b/backend/test/backend_tests/test_files/sample.webp differ diff --git a/backend/test/backend_tests/util_ssrf_test.clj b/backend/test/backend_tests/util_ssrf_test.clj new file mode 100644 index 0000000000..04e86291fd --- /dev/null +++ b/backend/test/backend_tests/util_ssrf_test.clj @@ -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,

hi

"))) + (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")))) diff --git a/common/src/app/common/buffer.cljc b/common/src/app/common/buffer.cljc index 7d23edeafe..87b4f72ad5 100644 --- a/common/src/app/common/buffer.cljc +++ b/common/src/app/common/buffer.cljc @@ -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)) diff --git a/common/src/app/common/colors.cljc b/common/src/app/common/colors.cljc index ab7c7e2a76..4d240cddb9 100644 --- a/common/src/app/common/colors.cljc +++ b/common/src/app/common/colors.cljc @@ -486,4 +486,3 @@ a (+ (* ah 100) (* av 10)) b (+ (* bh 100) (* bv 10))] (compare a b))) - diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index cc1247dd8e..e0ad3cab15 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -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 diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc index f626e48ae3..396f914645 100644 --- a/common/src/app/common/types/color.cljc +++ b/common/src/app/common/types/color.cljc @@ -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" diff --git a/common/src/app/common/types/stroke.cljc b/common/src/app/common/types/stroke.cljc index ef52ccefd8..b68a9d3775 100644 --- a/common/src/app/common/types/stroke.cljc +++ b/common/src/app/common/types/stroke.cljc @@ -6,7 +6,7 @@ (ns app.common.types.stroke (:require - [app.common.colors :as clr])) + [app.common.types.color :as clr])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SCHEMAS diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index 7d6b0f0e3d..5ed2fdeb83 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -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 diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index dbe015cf89..56b43078dc 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -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))) diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index d9d733dd10..b3d94db385 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -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 diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs index bbff39663a..83454294d6 100644 --- a/frontend/src/app/main/data/workspace/shape_layout.cljs +++ b/frontend/src/app/main/data/workspace/shape_layout.cljs @@ -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})) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 824c58e528..0b627d31db 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -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) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs index c8e43e7f1c..e98e294810 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs @@ -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 diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs index c5876940b8..737d204640 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs @@ -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}]]]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss index 39f56950d8..c2d6485284 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss @@ -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); diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs index 1cd082dcbf..3defb347d3 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs @@ -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 diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 194575231c..1f8bf2566d 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -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) diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index 0eac02f9a3..fb6837b70c 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -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) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 86efd4d067..c28a90cd86 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -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) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 5ce9687fd9..88b34b9c0b 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -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" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 546801aae6..7d5d03411c 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -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" diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index 85ed5a32d1..b464c599ef 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -236,6 +236,13 @@ export class ExecuteCodeTaskHandler extends TaskHandler { 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 = { result: result, @@ -243,4 +250,23 @@ export class ExecuteCodeTaskHandler extends TaskHandler { }; 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: }` + */ + 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) }; + } } diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index ae95724a09..47a3ee3355 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -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); } /** diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 5147d361fd..35d39aa728 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -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. * diff --git a/mcp/packages/server/src/ReplServer.ts b/mcp/packages/server/src/ReplServer.ts index 1496037521..1894ade79a 100644 --- a/mcp/packages/server/src/ReplServer.ts +++ b/mcp/packages/server/src/ReplServer.ts @@ -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 { 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(); }); }); diff --git a/mcp/packages/server/src/ToolResponse.ts b/mcp/packages/server/src/ToolResponse.ts index 6055f24ccf..db5babe4db 100644 --- a/mcp/packages/server/src/ToolResponse.ts +++ b/mcp/packages/server/src/ToolResponse.ts @@ -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: }` 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[]); } } diff --git a/package.json b/package.json index 8062f6315e..176a30c5da 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 647cecdd12..7699645c22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/render-wasm/_build_env b/render-wasm/_build_env index 7f9c2e2486..b54af40d34 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -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; diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index ee53f8a98c..bd6e26d1fa 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -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> = 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 { - 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() { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index a6822c991e..3df32255b5 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -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 { // 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, ¤t_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(); } } diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index f374e32af3..266a9043de 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -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(()) } diff --git a/render-wasm/src/render/gpu_state.rs b/render-wasm/src/render/gpu_state.rs index 77f8816435..47acd7a26b 100644 --- a/render-wasm/src/render/gpu_state.rs +++ b/render-wasm/src/render/gpu_state.rs @@ -41,7 +41,7 @@ impl GpuState { } }; - Ok(GpuState { + Ok(Self { context, framebuffer_info, }) diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index e1c66b2a51..b7a25388dc 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -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, raw_data: &[u8]) -> Option 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()), } } diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 40a3125ccd..0f80b28bcb 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -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, + 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 } } diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 3a142b05e6..6f31573288 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -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(); diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 688428f5eb..f4a3456e9b 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -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 { + 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. diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 10e6e11725..8c990942ec 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -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)); diff --git a/render-wasm/src/render/ui.rs b/render-wasm/src/render/ui.rs index bb7cc72c0f..243d775812 100644 --- a/render-wasm/src/render/ui.rs +++ b/render-wasm/src/render/ui.rs @@ -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)); diff --git a/render-wasm/src/shapes/strokes.rs b/render-wasm/src/shapes/strokes.rs index 4ca3b5f906..6b3f9443ed 100644 --- a/render-wasm/src/shapes/strokes.rs +++ b/render-wasm/src/shapes/strokes.rs @@ -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 { + 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 { diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 1a99000692..fcc9066777 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -199,6 +199,12 @@ pub struct TextContentLayout { cached_extrect: Cell>, } +impl Default for TextContentLayout { + fn default() -> Self { + Self::new() + } +} + impl Clone for TextContentLayout { fn clone(&self) -> Self { Self { diff --git a/render-wasm/src/shapes/text_paths.rs b/render-wasm/src/shapes/text_paths.rs index 238207152b..38cf30226f 100644 --- a/render-wasm/src/shapes/text_paths.rs +++ b/render-wasm/src/shapes/text_paths.rs @@ -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::>(); - 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::>(); + 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 } } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index c624dad43d..9591294abf 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -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, 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 { - 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, 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) { - 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 = self - .render_state + let indexed_tiles: Vec = 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) -> 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); } } } diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 3fd7ff2c51..32d95f8a3a 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -392,3 +392,9 @@ impl ShapesPoolImpl { }) } } + +impl Default for ShapesPoolImpl { + fn default() -> Self { + Self::new() + } +} diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index e604f6c4a3..974c1c74ce 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -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 == '_' } diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 6f0786df35..7c078ad2e6 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -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, + pub spiral: Vec, + 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 { - 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 { + 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); diff --git a/render-wasm/src/utils.rs b/render-wasm/src/utils.rs index 63a031d761..5d5c9a4c10 100644 --- a/render-wasm/src/utils.rs +++ b/render-wasm/src/utils.rs @@ -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 { - with_state_mut!(state, { state.render_state().fonts().get_fallback() }) + get_render_state().fonts().get_fallback() } pub fn get_font_collection() -> &'static FontCollection { diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index 9200861f8a..5d4db56a71 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -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, diff --git a/render-wasm/src/wasm/fonts.rs b/render-wasm/src/wasm/fonts.rs index f4723e20c3..21c4e6a797 100644 --- a/render-wasm/src/wasm/fonts.rs +++ b/render-wasm/src/wasm/fonts.rs @@ -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 } diff --git a/render-wasm/src/wasm/layouts/grid.rs b/render-wasm/src/wasm/layouts/grid.rs index aac2fd1928..87a7e45f28 100644 --- a/render-wasm/src/wasm/layouts/grid.rs +++ b/render-wasm/src/wasm/layouts/grid.rs @@ -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] diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 64765ae363..10853c98e5 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -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 } // ============================================================================