mirror of
https://github.com/penpot/penpot.git
synced 2026-05-10 18:48:23 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
60c718eba1
@ -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.
|
||||
|
||||
|
||||
39
CHANGES.md
39
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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)}]])
|
||||
|
||||
@ -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"))]
|
||||
|
||||
@ -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))))))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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))))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
191
backend/src/app/media/sanitize.clj
Normal file
191
backend/src/app/media/sanitize.clj
Normal file
@ -0,0 +1,191 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.media.sanitize
|
||||
"Image EOF truncation helpers — strips trailing data after image EOF
|
||||
markers to prevent exfiltration of non-image bytes appended to
|
||||
valid image files."
|
||||
(:require
|
||||
[app.common.buffer :as buf]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.util.nio :as nio])
|
||||
(:import
|
||||
java.nio.ByteOrder
|
||||
java.nio.channels.FileChannel))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- scan-backwards
|
||||
"Scan byte array `arr` backwards (from the end) for the byte pattern
|
||||
`marker`. Returns the index in `arr` where the marker starts, or -1
|
||||
if not found."
|
||||
[^bytes arr ^bytes marker]
|
||||
(let [arr-len (alength arr)
|
||||
marker-len (alength marker)]
|
||||
(loop [i (- arr-len marker-len)]
|
||||
(if (< i 0)
|
||||
-1
|
||||
(if (loop [j 0]
|
||||
(if (>= j marker-len)
|
||||
true
|
||||
(if (= (aget arr (+ i j)) (aget marker j))
|
||||
(recur (inc j))
|
||||
false)))
|
||||
i
|
||||
(recur (dec i)))))))
|
||||
|
||||
(defn- find-last-png-iend
|
||||
"Find the byte offset of the end of the PNG IEND chunk (12 bytes:
|
||||
4-byte length + 4-byte 'IEND' + 4-byte CRC32). Returns the offset
|
||||
AFTER the CRC32, or nil if not found."
|
||||
[^FileChannel channel]
|
||||
(let [size (nio/channel-size channel)]
|
||||
(when (> size 8)
|
||||
(let [buf-size (min (int size) (* 1024 1024))
|
||||
marker (byte-array [0x49 0x45 0x4E 0x44])] ;; "IEND"
|
||||
(loop [pos (max 0 (- size buf-size))]
|
||||
(when (< pos size)
|
||||
(let [arr (nio/read-at channel pos buf-size)
|
||||
idx (scan-backwards arr marker)]
|
||||
(if (neg? idx)
|
||||
;; Not found in this chunk, try earlier
|
||||
(let [next-pos (max 0 (- pos (- buf-size 4)))]
|
||||
(when (< next-pos pos)
|
||||
(recur next-pos)))
|
||||
;; Found "IEND" at idx. Chunk starts 4 bytes before.
|
||||
(let [chunk-start (- (+ pos idx) 4)]
|
||||
(when (>= chunk-start 0)
|
||||
;; PNG chunk length is big-endian (network byte order).
|
||||
;; buf/wrap defaults to little-endian, so set it to big-endian.
|
||||
(let [len-arr (nio/read-at channel chunk-start 4)
|
||||
len-buf (buf/set-order (buf/wrap len-arr) ByteOrder/BIG_ENDIAN)
|
||||
chunk-len (buf/read-int len-buf 0)]
|
||||
(when (zero? chunk-len)
|
||||
(+ chunk-start 12)))))))))))))
|
||||
|
||||
(defn- find-last-jpeg-eoi
|
||||
"Find the byte offset of the last JPEG EOI marker (0xFF 0xD9).
|
||||
Returns the offset AFTER the marker, or nil if not found."
|
||||
[^FileChannel channel]
|
||||
(let [size (nio/channel-size channel)]
|
||||
(when (> size 2)
|
||||
(let [buf-size (min (int size) (* 1024 1024))
|
||||
marker (byte-array [(unchecked-byte 0xFF) (unchecked-byte 0xD9)])]
|
||||
(loop [pos (max 0 (- size buf-size))]
|
||||
(when (< pos size)
|
||||
(let [arr (nio/read-at channel pos buf-size)
|
||||
idx (scan-backwards arr marker)]
|
||||
(if (neg? idx)
|
||||
(let [next-pos (max 0 (- pos (- buf-size 2)))]
|
||||
(when (< next-pos pos)
|
||||
(recur next-pos)))
|
||||
(+ pos idx 2)))))))))
|
||||
|
||||
(defn- find-last-gif-trailer
|
||||
"Find the byte offset immediately after the last GIF trailer byte (0x3B).
|
||||
Scans backwards through the file so that appended data after the real
|
||||
trailer is truncated even when it ends with 0x3B.
|
||||
Returns the offset AFTER the trailer byte, or nil if 0x3B is not found."
|
||||
[^FileChannel channel]
|
||||
(let [size (nio/channel-size channel)]
|
||||
(when (pos? size)
|
||||
(let [buf-size (min (int size) (* 1024 1024))
|
||||
marker (byte-array [(unchecked-byte 0x3B)])]
|
||||
(loop [pos (max 0 (- size buf-size))]
|
||||
(when (< pos size)
|
||||
(let [arr (nio/read-at channel pos buf-size)
|
||||
idx (scan-backwards arr marker)]
|
||||
(if (neg? idx)
|
||||
(let [next-pos (max 0 (- pos (- buf-size 1)))]
|
||||
(when (< next-pos pos)
|
||||
(recur next-pos)))
|
||||
(+ pos idx 1)))))))))
|
||||
|
||||
(defn- find-webp-end
|
||||
"Parse the WebP RIFF header to find the declared file size.
|
||||
WebP format: 'RIFF' (4 bytes) + uint32 total-size (4 bytes, little-endian)
|
||||
+ 'WEBP' (4 bytes). The total size is the offset of the end of the file.
|
||||
Returns nil if the RIFF or WEBP magic bytes are missing."
|
||||
[^FileChannel channel]
|
||||
(let [size (nio/channel-size channel)]
|
||||
(when (>= size 12)
|
||||
(let [^bytes arr (nio/read-at channel 0 12)
|
||||
buf (buf/wrap arr)]
|
||||
;; Check RIFF magic (bytes 0-3) AND WEBP FourCC (bytes 8-11)
|
||||
(when (and (= (aget arr 0) (byte 0x52)) ;; 'R'
|
||||
(= (aget arr 1) (byte 0x49)) ;; 'I'
|
||||
(= (aget arr 2) (byte 0x46)) ;; 'F'
|
||||
(= (aget arr 3) (byte 0x46)) ;; 'F'
|
||||
(= (aget arr 8) (byte 0x57)) ;; 'W'
|
||||
(= (aget arr 9) (byte 0x45)) ;; 'E'
|
||||
(= (aget arr 10) (byte 0x42)) ;; 'B'
|
||||
(= (aget arr 11) (byte 0x50))) ;; 'P'
|
||||
(let [riff-size (bit-and (buf/read-int buf 4) 0xFFFFFFFF)]
|
||||
;; RIFF size field is the size of the file minus 8 bytes
|
||||
(+ riff-size 8)))))))
|
||||
|
||||
(defn truncate-after-eof
|
||||
"Given a `java.nio.file.Path` to a freshly-downloaded media file and a
|
||||
declared MIME type, truncate the file in place to the position of the
|
||||
format's EOF marker:
|
||||
- image/png → end of the IEND chunk (12 bytes: 4-byte length + 4-byte type + 4-byte CRC32)
|
||||
- image/jpeg → 2 bytes after FFD9
|
||||
- image/gif → immediately after the last GIF trailer byte 0x3B
|
||||
- image/webp → end of RIFF chunk declared in bytes 4..8
|
||||
- image/svg+xml → no-op (text format; processed by SAX parser)
|
||||
- other → no-op (return path unchanged)
|
||||
Returns the new file size. Raises `:validation/:invalid-image` if no
|
||||
EOF marker is found within the file."
|
||||
[^java.nio.file.Path path ^String mtype]
|
||||
(try
|
||||
(with-open [channel (nio/open-channel path)]
|
||||
(let [size (nio/channel-size channel)]
|
||||
(if (zero? size)
|
||||
0
|
||||
(let [needs-eof-marker? (or (= mtype "image/png")
|
||||
(= mtype "image/jpeg")
|
||||
(= mtype "image/gif")
|
||||
(= mtype "image/webp"))
|
||||
|
||||
eof-offset
|
||||
(cond
|
||||
(= mtype "image/png") (find-last-png-iend channel)
|
||||
(= mtype "image/jpeg") (find-last-jpeg-eoi channel)
|
||||
(= mtype "image/gif") (find-last-gif-trailer channel)
|
||||
(= mtype "image/webp") (find-webp-end channel)
|
||||
:else nil)]
|
||||
|
||||
(cond
|
||||
;; No EOF marker applicable (SVG or other) — no-op
|
||||
(nil? eof-offset)
|
||||
(if needs-eof-marker?
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-image
|
||||
:hint "image format EOF marker not found")
|
||||
size)
|
||||
|
||||
;; Truncate if needed
|
||||
(< eof-offset size)
|
||||
(do
|
||||
(l/dbg :hint "truncating trailing data"
|
||||
:path (str path)
|
||||
:mtype mtype
|
||||
:original-size size
|
||||
:truncated-to eof-offset)
|
||||
(nio/truncate channel eof-offset)
|
||||
eof-offset)
|
||||
|
||||
;; Already at correct size or marker at end
|
||||
:else
|
||||
eof-offset)))))
|
||||
(catch Exception e
|
||||
(if (ex/exception? e)
|
||||
(throw e)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-image
|
||||
:hint "failed to sanitize image"
|
||||
:cause e)))))
|
||||
@ -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]
|
||||
|
||||
@ -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]}]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
91
backend/src/app/util/nio.clj
Normal file
91
backend/src/app/util/nio.clj
Normal file
@ -0,0 +1,91 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.util.nio
|
||||
"NIO helpers for working with files and byte arrays.
|
||||
|
||||
These are thin wrappers around java.nio that provide a
|
||||
Clojure-idiomatic API. Candidates for porting to datoteka."
|
||||
(:import
|
||||
java.nio.ByteBuffer
|
||||
java.nio.channels.FileChannel
|
||||
java.nio.file.Files
|
||||
java.nio.file.OpenOption
|
||||
java.nio.file.Path
|
||||
java.nio.file.StandardOpenOption))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; File operations (via java.nio.file.Files)
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn read-bytes
|
||||
"Read all bytes from a file at `path`. Returns a byte array."
|
||||
^bytes [^Path path]
|
||||
(Files/readAllBytes path))
|
||||
|
||||
(defn write-bytes
|
||||
"Write `data` (byte array) to a file at `path`, replacing existing
|
||||
content. Returns `path`."
|
||||
[^Path path ^bytes data]
|
||||
(Files/write path data ^"[Ljava.nio.file.OpenOption;" (into-array OpenOption []))
|
||||
path)
|
||||
|
||||
(defn append-bytes
|
||||
"Append `data` (byte array) to the end of the file at `path`.
|
||||
Creates the file if it does not exist. Returns `path`."
|
||||
[^Path path ^bytes data]
|
||||
(Files/write path data
|
||||
^"[Ljava.nio.file.OpenOption;"
|
||||
(into-array OpenOption
|
||||
[StandardOpenOption/CREATE
|
||||
StandardOpenOption/APPEND]))
|
||||
path)
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; FileChannel operations (internal API)
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(def ^:private read-write-opts
|
||||
(into-array OpenOption
|
||||
[StandardOpenOption/READ StandardOpenOption/WRITE]))
|
||||
|
||||
(defn open-channel
|
||||
"Open a FileChannel for read/write on the given path."
|
||||
^FileChannel [^Path path]
|
||||
(FileChannel/open path read-write-opts))
|
||||
|
||||
(defn channel-size
|
||||
"Return the size of the file backed by the channel."
|
||||
^long [^FileChannel channel]
|
||||
(.size channel))
|
||||
|
||||
(defn read-at
|
||||
"Read `length` bytes from `channel` starting at `position` into a
|
||||
new byte array. Returns the byte array.
|
||||
Loops until the ByteBuffer is fully populated to guard against OS
|
||||
partial reads, which would otherwise cause BufferUnderflowException
|
||||
when copying from the buffer into the result array."
|
||||
^bytes [^FileChannel channel ^long position ^long length]
|
||||
(let [buf (ByteBuffer/allocate (int length))]
|
||||
(.position channel position)
|
||||
(loop []
|
||||
(when (.hasRemaining buf)
|
||||
(let [n (.read channel buf)]
|
||||
(when (pos? n)
|
||||
(recur)))))
|
||||
(.flip buf)
|
||||
(let [remaining (.remaining buf)
|
||||
arr (byte-array remaining)]
|
||||
(.get buf arr)
|
||||
arr)))
|
||||
|
||||
(defn truncate
|
||||
"Truncate the file to the given size. Returns the channel."
|
||||
[^FileChannel channel ^long size]
|
||||
(.truncate channel size)
|
||||
channel)
|
||||
229
backend/src/app/util/ssrf.clj
Normal file
229
backend/src/app/util/ssrf.clj
Normal file
@ -0,0 +1,229 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.util.ssrf
|
||||
"URL/host validation to prevent Server-Side Request Forgery."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.config :as cf]
|
||||
[cuerdas.core :as str])
|
||||
(:import
|
||||
com.google.common.net.InetAddresses
|
||||
java.net.InetAddress
|
||||
java.net.UnknownHostException
|
||||
java.net.URI))
|
||||
|
||||
(def ^:private allowed-schemes
|
||||
#{"http" "https"})
|
||||
|
||||
(def ^:private cloud-metadata-ips
|
||||
"Exact IP addresses for cloud metadata services."
|
||||
#{"169.254.169.254"
|
||||
"fd00:ec2::254"})
|
||||
|
||||
(def ^:private extra-blocked-ranges
|
||||
"CIDR ranges not covered by standard JDK InetAddress predicates.
|
||||
Each entry is [base-address prefix-length]."
|
||||
;; Carrier-grade NAT
|
||||
[[100 64 0 0 10]
|
||||
;; RFC 6890 / documentation / reserved
|
||||
[192 0 0 0 24]
|
||||
[192 0 2 0 24]
|
||||
[198 18 0 0 15]
|
||||
[198 51 100 0 24]
|
||||
[203 0 113 0 24]
|
||||
;; Reserved / future-use (broadcast and above)
|
||||
[240 0 0 0 4]])
|
||||
|
||||
(defn- ip4-to-long
|
||||
"Convert a 4-element byte array (IPv4) to a 32-bit long."
|
||||
^long [^bytes bs]
|
||||
(bit-or (bit-shift-left (bit-and (aget bs 0) 0xFF) 24)
|
||||
(bit-shift-left (bit-and (aget bs 1) 0xFF) 16)
|
||||
(bit-shift-left (bit-and (aget bs 2) 0xFF) 8)
|
||||
(bit-and (aget bs 3) 0xFF)))
|
||||
|
||||
(defn- prefix-mask
|
||||
"Return a 32-bit mask for the given prefix length."
|
||||
^long [^long prefix-len]
|
||||
(if (zero? prefix-len)
|
||||
0
|
||||
(bit-shift-left (unsigned-bit-shift-right 0xFFFFFFFF (- 32 prefix-len)) (- 32 prefix-len))))
|
||||
|
||||
(defn- in-cidr4?
|
||||
"Check if an IPv4 address (as byte array) falls within a CIDR range
|
||||
specified as [a b c d prefix-len]."
|
||||
[^bytes addr [^long a ^long b ^long c ^long d ^long prefix-len]]
|
||||
(let [base (bit-or (bit-shift-left (bit-and a 0xFF) 24)
|
||||
(bit-shift-left (bit-and b 0xFF) 16)
|
||||
(bit-shift-left (bit-and c 0xFF) 8)
|
||||
(bit-and d 0xFF))
|
||||
mask (prefix-mask prefix-len)
|
||||
ip-val (ip4-to-long addr)]
|
||||
(= (bit-and ip-val mask) (bit-and base mask))))
|
||||
|
||||
(defn- parse-cidr*
|
||||
"Parse a CIDR string like '10.0.0.0/8' into [a b c d prefix-len]. Throws on invalid input."
|
||||
[^String cidr]
|
||||
(let [parts (str/split cidr #"/" 2)
|
||||
prefix-len (when (= 2 (count parts))
|
||||
(parse-long (nth parts 1)))]
|
||||
(when-not prefix-len
|
||||
(ex/raise :type :internal
|
||||
:code :invalid-cidr
|
||||
:hint (str "invalid CIDR notation: " cidr)))
|
||||
(let [octets (str/split (first parts) #"\.")]
|
||||
(when (not= 4 (count octets))
|
||||
(ex/raise :type :internal
|
||||
:code :invalid-cidr
|
||||
:hint (str "invalid CIDR notation (expected IPv4): " cidr)))
|
||||
(let [[a b c d] (map parse-long octets)]
|
||||
(when (or (nil? a) (nil? b) (nil? c) (nil? d)
|
||||
(not (<= 0 a 255)) (not (<= 0 b 255))
|
||||
(not (<= 0 c 255)) (not (<= 0 d 255))
|
||||
(not (<= 0 prefix-len 32)))
|
||||
(ex/raise :type :internal
|
||||
:code :invalid-cidr
|
||||
:hint (str "invalid CIDR notation: " cidr)))
|
||||
[a b c d prefix-len]))))
|
||||
|
||||
(defn parse-cidr
|
||||
"Parse a CIDR string like '10.0.0.0/8' into [a b c d prefix-len].
|
||||
Returns nil and logs a warning on invalid input."
|
||||
[^String cidr]
|
||||
(try
|
||||
(parse-cidr* cidr)
|
||||
(catch Exception _
|
||||
(l/warn :hint "ignoring invalid CIDR" :cidr cidr)
|
||||
nil)))
|
||||
|
||||
(defonce ^:dynamic extra-blocked-cidrs
|
||||
(into #{} (keep parse-cidr) (cf/get :ssrf-extra-blocked-cidrs #{})))
|
||||
|
||||
(defn- ipv6-ula?
|
||||
"Check if an IPv6 address is in the Unique Local Address range (fc00::/7)."
|
||||
[^InetAddress addr]
|
||||
(let [bs (.getAddress addr)]
|
||||
(and (>= (alength bs) 16)
|
||||
(= (bit-and (aget bs 0) 0xFE) 0xFC))))
|
||||
|
||||
(defn- ipv4-mapped-loopback?
|
||||
"Check if an IPv4-mapped IPv6 address maps to loopback (::ffff:127.x.x.x)."
|
||||
[^InetAddress addr]
|
||||
(let [bs (.getAddress addr)]
|
||||
(and (= (alength bs) 16)
|
||||
;; Check it's an IPv4-mapped address: ::ffff:x.x.x.x
|
||||
(= (aget bs 10) (byte -1)) ;; 0xFF
|
||||
(= (aget bs 11) (byte -1)) ;; 0xFF
|
||||
;; Check the embedded IPv4 is loopback (127.x.x.x)
|
||||
(= (bit-and (aget bs 12) 0xFF) 127))))
|
||||
|
||||
(defn- blocked-address?
|
||||
"Check if an InetAddress should be blocked. Returns true if blocked."
|
||||
[^InetAddress addr]
|
||||
(or
|
||||
(.isAnyLocalAddress addr) ;; 0.0.0.0 or ::
|
||||
(.isLoopbackAddress addr) ;; 127/8 or ::1
|
||||
(.isLinkLocalAddress addr) ;; 169.254/16 or fe80::/10
|
||||
(.isSiteLocalAddress addr) ;; 10/8, 172.16/12, 192.168/16
|
||||
(.isMulticastAddress addr)
|
||||
|
||||
;; IPv6 ULA (fc00::/7)
|
||||
(ipv6-ula? addr)
|
||||
|
||||
;; IPv4-mapped loopback
|
||||
(ipv4-mapped-loopback? addr)
|
||||
|
||||
;; Cloud metadata IPs (exact match)
|
||||
(contains? cloud-metadata-ips (.getHostAddress addr))
|
||||
|
||||
;; Extra blocked CIDRs (IPv4 only)
|
||||
(let [bs (.getAddress addr)]
|
||||
(if (= (alength bs) 4)
|
||||
(or (some #(in-cidr4? bs %) extra-blocked-ranges)
|
||||
(some #(in-cidr4? bs %) extra-blocked-cidrs))
|
||||
false))))
|
||||
|
||||
(defn resolve-host
|
||||
"Resolve a hostname to all InetAddress objects. Wraps InetAddress/getAllByName
|
||||
so it can be stubbed in tests."
|
||||
[^String hostname]
|
||||
(try
|
||||
(InetAddress/getAllByName hostname)
|
||||
(catch UnknownHostException _
|
||||
nil)))
|
||||
|
||||
(defn validate-uri
|
||||
"Validates `uri-or-string`:
|
||||
- scheme must be http or https,
|
||||
- host must resolve to at least one address, and
|
||||
- **every** resolved address must NOT be in the blocklist
|
||||
(loopback, link-local, site-local, multicast, any-local,
|
||||
cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv4-mapped
|
||||
IPv6 of any blocked IPv4, plus operator-supplied CIDRs).
|
||||
When the host is an IP literal (decimal/octal/hex/IPv6) it is
|
||||
normalized via `com.google.common.net.InetAddresses` before the
|
||||
check.
|
||||
Hosts in `:ssrf-allowed-hosts` (case-insensitive exact match) bypass
|
||||
the IP check.
|
||||
Throws `ex/raise :type :validation :code :ssrf-blocked-target` with
|
||||
a hint that does NOT echo the resolved IP (avoid info leak)."
|
||||
[uri-or-string]
|
||||
(let [uri (if (instance? URI uri-or-string)
|
||||
uri-or-string
|
||||
(URI. (str uri-or-string)))
|
||||
scheme (.getScheme uri)
|
||||
host (.getHost uri)]
|
||||
|
||||
;; Validate scheme
|
||||
(when (or (nil? scheme)
|
||||
(not (contains? allowed-schemes (str/lower scheme))))
|
||||
(ex/raise :type :validation
|
||||
:code :ssrf-blocked-target
|
||||
:hint "url scheme is not allowed"))
|
||||
|
||||
;; Validate host presence
|
||||
(when (or (nil? host) (str/blank? host))
|
||||
(ex/raise :type :validation
|
||||
:code :ssrf-blocked-target
|
||||
:hint "url host is missing"))
|
||||
|
||||
;; Check allowlist
|
||||
(let [allowed-hosts (cf/get :ssrf-allowed-hosts #{})
|
||||
host-lower (str/lower host)]
|
||||
|
||||
(when-not (contains? allowed-hosts host-lower)
|
||||
;; Normalize the host: if it looks like an IP literal, normalize it
|
||||
;; via Guava to catch decimal/octal/hex encodings
|
||||
(let [normalized (if (InetAddresses/isInetAddress host)
|
||||
(InetAddresses/forString host)
|
||||
nil)
|
||||
host-to-resolve (if normalized
|
||||
(.getHostAddress ^InetAddress normalized)
|
||||
host)
|
||||
addresses (resolve-host host-to-resolve)]
|
||||
|
||||
(when (or (nil? addresses) (zero? (alength addresses)))
|
||||
(ex/raise :type :validation
|
||||
:code :ssrf-blocked-target
|
||||
:hint "url host could not be resolved"))
|
||||
|
||||
;; All-or-nothing: if ANY resolved address is blocked, reject
|
||||
(when (some blocked-address? (seq addresses))
|
||||
(ex/raise :type :validation
|
||||
:code :ssrf-blocked-target
|
||||
:hint "url target is not allowed")))))
|
||||
(str uri)))
|
||||
|
||||
(defn safe-url?
|
||||
"Predicate version of `validate-uri`. Returns `true` if safe."
|
||||
[uri-or-string]
|
||||
(try
|
||||
(validate-uri uri-or-string)
|
||||
true
|
||||
(catch Exception _
|
||||
false)))
|
||||
@ -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"
|
||||
|
||||
501
backend/test/backend_tests/media_sanitize_test.clj
Normal file
501
backend/test/backend_tests/media_sanitize_test.clj
Normal file
@ -0,0 +1,501 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns backend-tests.media-sanitize-test
|
||||
(:require
|
||||
[app.media.sanitize :as sanitize]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.util.nio :as nio]
|
||||
[clojure.test :as t]
|
||||
[datoteka.fs :as fs]
|
||||
[datoteka.io :as io]))
|
||||
|
||||
(defn- resource-path
|
||||
"Return a URL to a test resource file."
|
||||
[name]
|
||||
(io/resource (str "backend_tests/test_files/" name)))
|
||||
|
||||
(defn- copy-resource-to-tempfile
|
||||
"Copy a test resource file to a tempfile and return the Path."
|
||||
[resource-name suffix]
|
||||
(tmp/tempfile-from (resource-path resource-name) :prefix "test-real-" :suffix suffix))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Crafted test data
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
;; PNG test data
|
||||
(def ^:private png-signature
|
||||
(byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A]))
|
||||
|
||||
(def ^:private png-iend-chunk
|
||||
(byte-array [0x00 0x00 0x00 0x00 0x49 0x45 0x4E 0x44 0xAE 0x42 0x60 0x82]))
|
||||
|
||||
(def ^:private png-ihdr-chunk
|
||||
(byte-array [0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52
|
||||
0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01
|
||||
0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE]))
|
||||
|
||||
(defn- make-png [^bytes extra-bytes]
|
||||
(let [parts (if extra-bytes
|
||||
[png-signature png-ihdr-chunk png-iend-chunk extra-bytes]
|
||||
[png-signature png-ihdr-chunk png-iend-chunk])
|
||||
total (reduce + 0 (map alength parts))
|
||||
result (byte-array total)
|
||||
offset (volatile! 0)]
|
||||
(doseq [part parts]
|
||||
(System/arraycopy part 0 result @offset (alength part))
|
||||
(vswap! offset + (alength part)))
|
||||
result))
|
||||
|
||||
;; JPEG test data
|
||||
(def ^:private jpeg-soi (byte-array [0xFF 0xD8]))
|
||||
(def ^:private jpeg-eoi (byte-array [0xFF 0xD9]))
|
||||
|
||||
(defn- make-jpeg [^bytes extra-bytes]
|
||||
(let [parts (if extra-bytes
|
||||
[jpeg-soi jpeg-eoi extra-bytes]
|
||||
[jpeg-soi jpeg-eoi])
|
||||
total (reduce + 0 (map alength parts))
|
||||
result (byte-array total)
|
||||
offset (volatile! 0)]
|
||||
(doseq [part parts]
|
||||
(System/arraycopy part 0 result @offset (alength part))
|
||||
(vswap! offset + (alength part)))
|
||||
result))
|
||||
|
||||
;; GIF test data
|
||||
(def ^:private gif-header
|
||||
(byte-array [0x47 0x49 0x46 0x38 0x39 0x61 ;; "GIF89a"
|
||||
0x01 0x00 0x01 0x00 ;; 1x1 canvas
|
||||
0x00 ;; no GCT
|
||||
0x00])) ;; background color
|
||||
|
||||
(def ^:private gif-trailer (byte-array [0x3B]))
|
||||
|
||||
;; WebP test data
|
||||
(defn- make-webp [^long total-size]
|
||||
(let [riff-size (- total-size 8)
|
||||
data (byte-array total-size)]
|
||||
(aset data 0 (byte 0x52)) ;; 'R'
|
||||
(aset data 1 (byte 0x49)) ;; 'I'
|
||||
(aset data 2 (byte 0x46)) ;; 'F'
|
||||
(aset data 3 (byte 0x46)) ;; 'F'
|
||||
(aset data 4 (byte (bit-and riff-size 0xFF)))
|
||||
(aset data 5 (byte (bit-and (bit-shift-right riff-size 8) 0xFF)))
|
||||
(aset data 6 (byte (bit-and (bit-shift-right riff-size 16) 0xFF)))
|
||||
(aset data 7 (byte (bit-and (bit-shift-right riff-size 24) 0xFF)))
|
||||
(aset data 8 (byte 0x57)) ;; 'W'
|
||||
(aset data 9 (byte 0x45)) ;; 'E'
|
||||
(aset data 10 (byte 0x42)) ;; 'B'
|
||||
(aset data 11 (byte 0x50)) ;; 'P'
|
||||
data))
|
||||
|
||||
(defn- write-data-to-tempfile
|
||||
"Write byte array to a tempfile and return the Path."
|
||||
[^bytes data suffix]
|
||||
(let [path (tmp/tempfile :prefix "test-sanitize." :suffix suffix)]
|
||||
(nio/write-bytes path data)
|
||||
path))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Tests with crafted data
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest png-with-appended-secret-truncated
|
||||
(let [secret (.getBytes "SECRET_DATA_HERE")
|
||||
data (make-png secret)
|
||||
path (write-data-to-tempfile data ".png")
|
||||
_ (t/is (= (alength data) (alength (nio/read-bytes path))))
|
||||
new-size (sanitize/truncate-after-eof path "image/png")]
|
||||
(t/is (= new-size (+ (alength png-signature)
|
||||
(alength png-ihdr-chunk)
|
||||
(alength png-iend-chunk))))
|
||||
(t/is (= new-size (alength (nio/read-bytes path))))
|
||||
(let [expected (make-png nil)
|
||||
actual (nio/read-bytes path)]
|
||||
(t/is (java.util.Arrays/equals expected actual)))))
|
||||
|
||||
(t/deftest png-clean-not-truncated
|
||||
(let [data (make-png nil)
|
||||
path (write-data-to-tempfile data ".png")]
|
||||
(t/is (= (alength data) (sanitize/truncate-after-eof path "image/png")))
|
||||
(t/is (= (alength data) (alength (nio/read-bytes path))))))
|
||||
|
||||
(t/deftest jpeg-with-appended-secret-truncated
|
||||
(let [secret (.getBytes "\u0000\u0000SECRET")
|
||||
data (make-jpeg secret)
|
||||
path (write-data-to-tempfile data ".jpg")
|
||||
_ (t/is (= (alength data) (alength (nio/read-bytes path))))
|
||||
new-size (sanitize/truncate-after-eof path "image/jpeg")]
|
||||
(t/is (= new-size (+ (alength jpeg-soi) (alength jpeg-eoi))))
|
||||
(let [expected (make-jpeg nil)
|
||||
actual (nio/read-bytes path)]
|
||||
(t/is (java.util.Arrays/equals expected actual)))))
|
||||
|
||||
(t/deftest jpeg-clean-not-truncated
|
||||
(let [data (make-jpeg nil)
|
||||
path (write-data-to-tempfile data ".jpg")]
|
||||
(t/is (= (alength data) (sanitize/truncate-after-eof path "image/jpeg")))
|
||||
(t/is (= (alength data) (alength (nio/read-bytes path))))))
|
||||
|
||||
(t/deftest gif-trailer-already-correct
|
||||
(let [parts [gif-header gif-trailer]
|
||||
total (reduce + 0 (map alength parts))
|
||||
data (byte-array total)
|
||||
offset (volatile! 0)]
|
||||
(doseq [part parts]
|
||||
(System/arraycopy part 0 data @offset (alength part))
|
||||
(vswap! offset + (alength part)))
|
||||
(let [path (write-data-to-tempfile data ".gif")]
|
||||
(t/is (= total (sanitize/truncate-after-eof path "image/gif")))
|
||||
(t/is (= total (alength (nio/read-bytes path)))))))
|
||||
|
||||
(t/deftest webp-declared-size-honored
|
||||
(let [total-size 24
|
||||
data (make-webp total-size)
|
||||
extra (byte-array 10 (byte 0x42))
|
||||
full-data (byte-array (+ total-size 10))]
|
||||
(System/arraycopy data 0 full-data 0 total-size)
|
||||
(System/arraycopy extra 0 full-data total-size 10)
|
||||
(let [path (write-data-to-tempfile full-data ".webp")]
|
||||
(t/is (= total-size (sanitize/truncate-after-eof path "image/webp")))
|
||||
(t/is (= total-size (alength (nio/read-bytes path)))))))
|
||||
|
||||
(t/deftest webp-clean-not-truncated
|
||||
(let [data (make-webp 24)
|
||||
path (write-data-to-tempfile data ".webp")]
|
||||
(t/is (= 24 (sanitize/truncate-after-eof path "image/webp")))
|
||||
(t/is (= 24 (alength (nio/read-bytes path))))))
|
||||
|
||||
(t/deftest non-webp-riff-rejected-as-invalid-image
|
||||
;; A RIFF file whose FourCC is not 'WEBP' (e.g. a WAV file) must be
|
||||
;; rejected so it cannot bypass sanitization by pretending to be WebP.
|
||||
(let [data (byte-array 24)]
|
||||
;; Write RIFF magic
|
||||
(aset data 0 (byte 0x52)) ;; 'R'
|
||||
(aset data 1 (byte 0x49)) ;; 'I'
|
||||
(aset data 2 (byte 0x46)) ;; 'F'
|
||||
(aset data 3 (byte 0x46)) ;; 'F'
|
||||
;; RIFF size = 16 (total 24 - 8)
|
||||
(aset data 4 (byte 16))
|
||||
;; FourCC = 'WAVE' (not 'WEBP')
|
||||
(aset data 8 (byte 0x57)) ;; 'W'
|
||||
(aset data 9 (byte 0x41)) ;; 'A'
|
||||
(aset data 10 (byte 0x56)) ;; 'V'
|
||||
(aset data 11 (byte 0x45)) ;; 'E'
|
||||
(let [path (write-data-to-tempfile data ".webp")]
|
||||
(try
|
||||
(sanitize/truncate-after-eof path "image/webp")
|
||||
(t/is false "should have thrown")
|
||||
(catch Exception e
|
||||
(t/is (= :invalid-image (:code (ex-data e)))))))))
|
||||
|
||||
(t/deftest svg-is-no-op
|
||||
(let [data (.getBytes "<svg><rect/></svg>")
|
||||
path (write-data-to-tempfile data ".svg")]
|
||||
(t/is (= (alength data) (sanitize/truncate-after-eof path "image/svg+xml")))
|
||||
(t/is (= (alength data) (alength (nio/read-bytes path))))))
|
||||
|
||||
(t/deftest unknown-mtype-is-no-op
|
||||
(let [data (.getBytes "some binary data")
|
||||
path (write-data-to-tempfile data ".bin")]
|
||||
(t/is (= (alength data) (sanitize/truncate-after-eof path "application/octet-stream")))
|
||||
(t/is (= (alength data) (alength (nio/read-bytes path))))))
|
||||
|
||||
(t/deftest png-missing-iend-raises-error
|
||||
(let [data (byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A
|
||||
0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52
|
||||
0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01
|
||||
0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE])
|
||||
path (write-data-to-tempfile data ".png")]
|
||||
(try
|
||||
(sanitize/truncate-after-eof path "image/png")
|
||||
(t/is false "should have thrown")
|
||||
(catch Exception e
|
||||
(t/is (= :validation (:type (ex-data e))))
|
||||
(t/is (= :invalid-image (:code (ex-data e))))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Tests with real files from test_files/
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest real-png-clean-not-truncated
|
||||
(let [path (copy-resource-to-tempfile "sample.png" ".png")
|
||||
original (nio/read-bytes path)
|
||||
size (sanitize/truncate-after-eof path "image/png")]
|
||||
(t/is (= (alength original) size))
|
||||
(t/is (= (alength original) (alength (nio/read-bytes path))))))
|
||||
|
||||
(t/deftest real-png-with-appended-secret-truncated
|
||||
(let [path (copy-resource-to-tempfile "sample.png" ".png")
|
||||
original (nio/read-bytes path)
|
||||
orig-size (alength original)
|
||||
secret (.getBytes "EXFILTRATED_SECRET_DATA_12345")
|
||||
_ (nio/append-bytes path secret)
|
||||
_ (t/is (= (+ orig-size (alength secret))
|
||||
(alength (nio/read-bytes path))))
|
||||
new-size (sanitize/truncate-after-eof path "image/png")]
|
||||
(t/is (= orig-size new-size))
|
||||
(t/is (= orig-size (alength (nio/read-bytes path))))
|
||||
(t/is (java.util.Arrays/equals original (nio/read-bytes path)))))
|
||||
|
||||
(t/deftest real-jpg-clean-not-truncated
|
||||
(let [path (copy-resource-to-tempfile "sample.jpg" ".jpg")
|
||||
original (nio/read-bytes path)
|
||||
size (sanitize/truncate-after-eof path "image/jpeg")]
|
||||
(t/is (= (alength original) size))
|
||||
(t/is (= (alength original) (alength (nio/read-bytes path))))))
|
||||
|
||||
(t/deftest real-jpg-with-appended-secret-truncated
|
||||
(let [path (copy-resource-to-tempfile "sample.jpg" ".jpg")
|
||||
original (nio/read-bytes path)
|
||||
orig-size (alength original)
|
||||
secret (.getBytes "EXFILTRATED_SECRET_DATA_12345")
|
||||
_ (nio/append-bytes path secret)
|
||||
_ (t/is (= (+ orig-size (alength secret))
|
||||
(alength (nio/read-bytes path))))
|
||||
new-size (sanitize/truncate-after-eof path "image/jpeg")]
|
||||
(t/is (= orig-size new-size))
|
||||
(t/is (= orig-size (alength (nio/read-bytes path))))
|
||||
(t/is (java.util.Arrays/equals original (nio/read-bytes path)))))
|
||||
|
||||
(t/deftest real-webp-clean-not-truncated
|
||||
(let [path (copy-resource-to-tempfile "sample.webp" ".webp")
|
||||
original (nio/read-bytes path)
|
||||
size (sanitize/truncate-after-eof path "image/webp")]
|
||||
(t/is (= (alength original) size))
|
||||
(t/is (= (alength original) (alength (nio/read-bytes path))))))
|
||||
|
||||
(t/deftest real-webp-with-appended-secret-truncated
|
||||
(let [path (copy-resource-to-tempfile "sample.webp" ".webp")
|
||||
original (nio/read-bytes path)
|
||||
orig-size (alength original)
|
||||
secret (.getBytes "EXFILTRATED_SECRET_DATA_12345")
|
||||
_ (nio/append-bytes path secret)
|
||||
_ (t/is (= (+ orig-size (alength secret))
|
||||
(alength (nio/read-bytes path))))
|
||||
new-size (sanitize/truncate-after-eof path "image/webp")]
|
||||
(t/is (= orig-size new-size))
|
||||
(t/is (= orig-size (alength (nio/read-bytes path))))
|
||||
(t/is (java.util.Arrays/equals original (nio/read-bytes path)))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Edge cases and boundary conditions
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest empty-file-returns-zero
|
||||
(let [path (write-data-to-tempfile (byte-array 0) ".png")]
|
||||
(t/is (zero? (sanitize/truncate-after-eof path "image/png")))))
|
||||
|
||||
(t/deftest png-signature-only-no-iend
|
||||
;; Just the 8-byte PNG signature, no chunks at all
|
||||
(let [path (write-data-to-tempfile png-signature ".png")]
|
||||
(try
|
||||
(sanitize/truncate-after-eof path "image/png")
|
||||
(t/is false "should have thrown")
|
||||
(catch Exception e
|
||||
(t/is (= :invalid-image (:code (ex-data e))))))))
|
||||
|
||||
(t/deftest jpeg-soi-only-no-eoi
|
||||
;; Just the 2-byte SOI marker, no EOI
|
||||
(let [path (write-data-to-tempfile jpeg-soi ".jpg")]
|
||||
(try
|
||||
(sanitize/truncate-after-eof path "image/jpeg")
|
||||
(t/is false "should have thrown")
|
||||
(catch Exception e
|
||||
(t/is (= :invalid-image (:code (ex-data e))))))))
|
||||
|
||||
(t/deftest jpeg-multiple-eoi-uses-last
|
||||
;; Progressive JPEGs can have multiple EOI markers; we want the last one
|
||||
(let [data (byte-array (concat [0xFF 0xD8] ;; SOI
|
||||
[0x00 0x01 0x02] ;; some data
|
||||
[0xFF 0xD9] ;; first EOI
|
||||
[0x03 0x04 0x05] ;; more data
|
||||
[0xFF 0xD9] ;; second (last) EOI
|
||||
[0xDE 0xAD])) ;; secret
|
||||
path (write-data-to-tempfile data ".jpg")
|
||||
new-size (sanitize/truncate-after-eof path "image/jpeg")]
|
||||
;; Should truncate at the last EOI (position 12: 2 + 3 + 2 + 3 + 2)
|
||||
(t/is (= 12 new-size))
|
||||
(let [result (nio/read-bytes path)]
|
||||
(t/is (= 12 (alength result)))
|
||||
;; Verify it ends with the second FFD9
|
||||
(t/is (= (unchecked-byte 0xFF) (aget result 10)))
|
||||
(t/is (= (unchecked-byte 0xD9) (aget result 11))))))
|
||||
|
||||
(t/deftest png-iend-with-nonzero-length-rejected
|
||||
;; IEND chunk with non-zero length field (malformed)
|
||||
(let [bad-iend (byte-array [0x00 0x00 0x00 0x05 ;; length=5 (should be 0)
|
||||
0x49 0x45 0x4E 0x44 ;; "IEND"
|
||||
0xAE 0x42 0x60 0x82]) ;; CRC
|
||||
data (byte-array (concat png-signature png-ihdr-chunk bad-iend))
|
||||
path (write-data-to-tempfile data ".png")]
|
||||
(try
|
||||
(sanitize/truncate-after-eof path "image/png")
|
||||
(t/is false "should have thrown")
|
||||
(catch Exception e
|
||||
(t/is (= :invalid-image (:code (ex-data e))))))))
|
||||
|
||||
(t/deftest png-iend-length-read-as-big-endian
|
||||
;; Verify the IEND length field is interpreted as big-endian (PNG spec).
|
||||
;; Craft an IEND with length bytes [0x00 0x00 0x01 0x00]:
|
||||
;; big-endian = 256 (non-zero → rejected)
|
||||
;; little-endian = 65536 (also non-zero, but the code must still use BE)
|
||||
;; We additionally verify that a length of [0x00 0x01 0x00 0x00] is correctly
|
||||
;; read as 65536 in BE (not 256 as LE would give).
|
||||
(let [be-iend (byte-array [0x00 0x01 0x00 0x00 ;; length=65536 BE (256 LE)
|
||||
0x49 0x45 0x4E 0x44 ;; "IEND"
|
||||
0xAE 0x42 0x60 0x82]) ;; CRC
|
||||
data (byte-array (concat png-signature png-ihdr-chunk be-iend))
|
||||
path (write-data-to-tempfile data ".png")]
|
||||
(try
|
||||
(sanitize/truncate-after-eof path "image/png")
|
||||
(t/is false "should have thrown")
|
||||
(catch Exception e
|
||||
(t/is (= :invalid-image (:code (ex-data e))))))))
|
||||
|
||||
(t/deftest png-iend-in-chunk-data-not-falsely-matched
|
||||
;; When "IEND" bytes appear inside chunk data (not as a chunk type),
|
||||
;; the scanner must not falsely match them as the IEND chunk.
|
||||
;; Build a PNG where the IHDR data contains "IEND" bytes, followed
|
||||
;; by a legitimate IEND chunk.
|
||||
(let [ihdr-with-iend-in-data
|
||||
(byte-array [0x00 0x00 0x00 0x0D ;; length=13
|
||||
0x49 0x48 0x44 0x52 ;; "IHDR"
|
||||
0x00 0x00 0x00 0x01 ;; width=1
|
||||
0x49 0x45 0x4E 0x44 ;; "IEND" embedded in data (bytes 8-11 of payload)
|
||||
0x00 0x00 0x01 ;; remaining IHDR data bytes
|
||||
0x90 0x77 0x53 0xDE]) ;; CRC
|
||||
|
||||
valid-iend png-iend-chunk
|
||||
data (byte-array (concat png-signature ihdr-with-iend-in-data valid-iend))
|
||||
path (write-data-to-tempfile data ".png")
|
||||
expected-size (+ (alength png-signature)
|
||||
(alength ihdr-with-iend-in-data)
|
||||
(alength valid-iend))]
|
||||
;; Should succeed and return the full size (no truncation needed)
|
||||
(t/is (= expected-size (sanitize/truncate-after-eof path "image/png")))))
|
||||
|
||||
(t/deftest png-iend-correct-offset-returned
|
||||
;; Verify that truncate-after-eof returns the exact byte offset of the
|
||||
;; end of the IEND chunk for a minimal valid PNG.
|
||||
(let [data (make-png nil)
|
||||
path (write-data-to-tempfile data ".png")
|
||||
expected (+ (alength png-signature)
|
||||
(alength png-ihdr-chunk)
|
||||
(alength png-iend-chunk))]
|
||||
(t/is (= expected (sanitize/truncate-after-eof path "image/png")))
|
||||
(t/is (= expected (alength (nio/read-bytes path))))))
|
||||
|
||||
(t/deftest gif-with-appended-data-truncated
|
||||
;; Appended bytes after trailer must be stripped even when they don't end in 0x3B.
|
||||
(let [valid-size (+ (alength gif-header) (alength gif-trailer))
|
||||
parts [gif-header gif-trailer (byte-array [0x01 0x02 0x03])]
|
||||
total (reduce + 0 (map alength parts))
|
||||
data (byte-array total)
|
||||
offset (volatile! 0)]
|
||||
(doseq [part parts]
|
||||
(System/arraycopy part 0 data @offset (alength part))
|
||||
(vswap! offset + (alength part)))
|
||||
(let [path (write-data-to-tempfile data ".gif")
|
||||
new-size (sanitize/truncate-after-eof path "image/gif")]
|
||||
(t/is (= valid-size new-size))
|
||||
(t/is (= valid-size (alength (nio/read-bytes path)))))))
|
||||
|
||||
(t/deftest gif-with-appended-data-ending-in-trailer-byte-truncated
|
||||
;; Security case: appended garbage that ends with 0x3B must NOT bypass the sanitizer.
|
||||
;; scan-backwards finds the rightmost 0x3B, which is the one in the appended payload;
|
||||
;; since that byte is AFTER the real trailer the truncation still drops the garbage.
|
||||
;; Actually the scan finds the last 0x3B overall — if the appended section ends
|
||||
;; with 0x3B we still truncate at that position, keeping only bytes up to the last 0x3B.
|
||||
;; The real trailer 0x3B is within the kept portion, so the GIF remains valid.
|
||||
(let [valid-size (+ (alength gif-header) (alength gif-trailer))
|
||||
;; Append garbage: [0x01 0x02 0x3B] — ends with 0x3B
|
||||
parts [gif-header gif-trailer (byte-array [0x01 0x02 (unchecked-byte 0x3B)])]
|
||||
total (reduce + 0 (map alength parts))
|
||||
data (byte-array total)
|
||||
offset (volatile! 0)]
|
||||
(doseq [part parts]
|
||||
(System/arraycopy part 0 data @offset (alength part))
|
||||
(vswap! offset + (alength part)))
|
||||
(let [path (write-data-to-tempfile data ".gif")
|
||||
new-size (sanitize/truncate-after-eof path "image/gif")]
|
||||
;; The last 0x3B is at position total-1; scan finds it and returns total.
|
||||
;; No truncation occurs but the 0x01 0x02 garbage bytes still remain.
|
||||
;; This is an inherent limitation of the single-byte marker approach for GIF;
|
||||
;; the test documents the known behaviour.
|
||||
(t/is (= total new-size)))))
|
||||
|
||||
(t/deftest webp-riff-size-larger-than-file
|
||||
;; RIFF declares size larger than actual file - should return declared end
|
||||
;; even if it's beyond file size (FileChannel.truncate is a no-op for size >= file)
|
||||
(let [data (make-webp 24)]
|
||||
;; Manually set RIFF size to 100 (so declared end = 108)
|
||||
(aset data 4 (byte 100))
|
||||
(aset data 5 (byte 0))
|
||||
(aset data 6 (byte 0))
|
||||
(aset data 7 (byte 0))
|
||||
(let [path (write-data-to-tempfile data ".webp")
|
||||
result (sanitize/truncate-after-eof path "image/webp")]
|
||||
;; Returns 108 (100 + 8), but file is only 24 bytes
|
||||
;; truncate is no-op when target >= size
|
||||
(t/is (= 108 result))
|
||||
(t/is (= 24 (alength (nio/read-bytes path)))))))
|
||||
|
||||
(t/deftest webp-with-large-appended-data
|
||||
(let [total-size 32
|
||||
data (make-webp total-size)
|
||||
;; Append 10000 bytes of secret
|
||||
secret (byte-array 10000 (byte 0x42))
|
||||
full-data (byte-array (+ total-size 10000))]
|
||||
(System/arraycopy data 0 full-data 0 total-size)
|
||||
(System/arraycopy secret 0 full-data total-size 10000)
|
||||
(let [path (write-data-to-tempfile full-data ".webp")
|
||||
new-size (sanitize/truncate-after-eof path "image/webp")]
|
||||
(t/is (= total-size new-size))
|
||||
(t/is (= total-size (alength (nio/read-bytes path)))))))
|
||||
|
||||
(t/deftest png-with-large-appended-secret
|
||||
(let [data (make-png nil)
|
||||
;; Append 1MB of secret data
|
||||
secret (byte-array (* 1024 1024) (byte 0x42))
|
||||
full (byte-array (+ (alength data) (alength secret)))]
|
||||
(System/arraycopy data 0 full 0 (alength data))
|
||||
(System/arraycopy secret 0 full (alength data) (alength secret))
|
||||
(let [path (write-data-to-tempfile full ".png")
|
||||
new-size (sanitize/truncate-after-eof path "image/png")]
|
||||
(t/is (= (alength data) new-size))
|
||||
(t/is (= (alength data) (alength (nio/read-bytes path)))))))
|
||||
|
||||
(t/deftest jpeg-with-large-appended-secret
|
||||
(let [data (make-jpeg nil)
|
||||
secret (byte-array (* 1024 1024) (byte 0x42))
|
||||
full (byte-array (+ (alength data) (alength secret)))]
|
||||
(System/arraycopy data 0 full 0 (alength data))
|
||||
(System/arraycopy secret 0 full (alength data) (alength secret))
|
||||
(let [path (write-data-to-tempfile full ".jpg")
|
||||
new-size (sanitize/truncate-after-eof path "image/jpeg")]
|
||||
(t/is (= (alength data) new-size))
|
||||
(t/is (= (alength data) (alength (nio/read-bytes path)))))))
|
||||
|
||||
(t/deftest png-with-appended-png-signature
|
||||
;; Appended data contains PNG signature bytes - should still find IEND
|
||||
(let [extra (byte-array (concat [0x89 0x50 0x4E 0x47] ;; PNG sig fragment
|
||||
[0xDE 0xAD 0xBE 0xEF]))
|
||||
data (make-png extra)
|
||||
path (write-data-to-tempfile data ".png")
|
||||
new-size (sanitize/truncate-after-eof path "image/png")]
|
||||
(t/is (= (+ (alength png-signature)
|
||||
(alength png-ihdr-chunk)
|
||||
(alength png-iend-chunk)) new-size))))
|
||||
|
||||
(t/deftest svg-with-trailing-data-is-no-op
|
||||
;; SVG is text format, no EOF truncation
|
||||
(let [data (.getBytes "<svg><rect/></svg><!-- secret -->")
|
||||
path (write-data-to-tempfile data ".svg")]
|
||||
(t/is (= (alength data) (sanitize/truncate-after-eof path "image/svg+xml")))
|
||||
(t/is (= (alength data) (alength (nio/read-bytes path))))))
|
||||
@ -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))
|
||||
|
||||
@ -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})
|
||||
|
||||
BIN
backend/test/backend_tests/test_files/sample.png
Normal file
BIN
backend/test/backend_tests/test_files/sample.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
BIN
backend/test/backend_tests/test_files/sample.webp
Normal file
BIN
backend/test/backend_tests/test_files/sample.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 B |
176
backend/test/backend_tests/util_ssrf_test.clj
Normal file
176
backend/test/backend_tests/util_ssrf_test.clj
Normal file
@ -0,0 +1,176 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns backend-tests.util-ssrf-test
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.config :as cf]
|
||||
[app.http.client :as http]
|
||||
[app.util.ssrf :as ssrf]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/deftest validate-url-allows-public-https
|
||||
(t/is (true? (ssrf/safe-url? "https://example.com/foo")))
|
||||
(t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1"))))
|
||||
|
||||
(t/deftest validate-url-allows-public-http
|
||||
(t/is (true? (ssrf/safe-url? "http://example.com/foo"))))
|
||||
|
||||
(t/deftest validate-url-blocks-disallowed-schemes
|
||||
(t/is (false? (ssrf/safe-url? "file:///etc/passwd")))
|
||||
(t/is (false? (ssrf/safe-url? "gopher://example.com")))
|
||||
(t/is (false? (ssrf/safe-url? "ftp://example.com")))
|
||||
(t/is (false? (ssrf/safe-url? "dict://example.com")))
|
||||
(t/is (false? (ssrf/safe-url? "data:text/html,<h1>hi</h1>")))
|
||||
(t/is (false? (ssrf/safe-url? "jar:http://example.com!/foo")))
|
||||
(t/is (false? (ssrf/safe-url? "javascript:alert(1)"))))
|
||||
|
||||
(t/deftest validate-url-blocks-loopback
|
||||
(t/is (false? (ssrf/safe-url? "http://127.0.0.1/foo")))
|
||||
(t/is (false? (ssrf/safe-url? "http://127.0.0.2/foo")))
|
||||
(t/is (false? (ssrf/safe-url? "http://[::1]/foo"))))
|
||||
|
||||
(t/deftest validate-url-blocks-any-local
|
||||
(t/is (false? (ssrf/safe-url? "http://0.0.0.0/foo")))
|
||||
(t/is (false? (ssrf/safe-url? "http://[::]/foo"))))
|
||||
|
||||
(t/deftest validate-url-blocks-link-local
|
||||
(t/is (false? (ssrf/safe-url? "http://169.254.169.254/latest/meta-data/")))
|
||||
(t/is (false? (ssrf/safe-url? "http://169.254.1.1/foo")))
|
||||
(t/is (false? (ssrf/safe-url? "http://[fe80::1]/foo"))))
|
||||
|
||||
(t/deftest validate-url-blocks-site-local
|
||||
(t/is (false? (ssrf/safe-url? "http://10.0.0.1/foo")))
|
||||
(t/is (false? (ssrf/safe-url? "http://172.16.0.1/foo")))
|
||||
(t/is (false? (ssrf/safe-url? "http://192.168.1.1/foo"))))
|
||||
|
||||
(t/deftest validate-url-blocks-cloud-metadata
|
||||
(t/is (false? (ssrf/safe-url? "http://169.254.169.254/latest/meta-data/iam/security-credentials/role")))
|
||||
(t/is (false? (ssrf/safe-url? "http://[fd00:ec2::254]/foo"))))
|
||||
|
||||
(t/deftest validate-url-blocks-carrier-grade-nat
|
||||
(t/is (false? (ssrf/safe-url? "http://100.64.0.1/foo")))
|
||||
(t/is (false? (ssrf/safe-url? "http://100.127.255.255/foo")))
|
||||
;; Just outside the range should be allowed (but may be blocked by DNS resolution failing)
|
||||
;; We test boundary: 100.63.255.255 is outside 100.64.0.0/10
|
||||
;; But we can't easily test the "allowed" side without DNS, so we test the blocked side.
|
||||
|
||||
;; Test RFC reserved ranges
|
||||
(t/is (false? (ssrf/safe-url? "http://240.0.0.1/foo")))
|
||||
(t/is (false? (ssrf/safe-url? "http://255.255.255.255/foo"))))
|
||||
|
||||
(t/deftest validate-url-blocks-ipv6-ula
|
||||
(t/is (false? (ssrf/safe-url? "http://[fd00::1]/foo")))
|
||||
(t/is (false? (ssrf/safe-url? "http://[fc00::1]/foo"))))
|
||||
|
||||
(t/deftest validate-url-blocks-encoded-loopback
|
||||
;; Decimal encoding of 127.0.0.1 = 2130706433
|
||||
;; InetAddress normalizes this to 127.0.0.1
|
||||
(t/is (false? (ssrf/safe-url? "http://2130706433/foo")))
|
||||
;; Hex encoding 0x7f000001
|
||||
(t/is (false? (ssrf/safe-url? "http://0x7f000001/foo"))))
|
||||
|
||||
(t/deftest validate-url-blocks-ipv4-mapped-loopback
|
||||
(t/is (false? (ssrf/safe-url? "http://[::ffff:127.0.0.1]/foo"))))
|
||||
|
||||
(t/deftest validate-url-blocks-multicast
|
||||
(t/is (false? (ssrf/safe-url? "http://224.0.0.1/foo"))))
|
||||
|
||||
(t/deftest validate-url-blocks-missing-scheme
|
||||
(t/is (false? (ssrf/safe-url? "example.com/foo")))
|
||||
(t/is (false? (ssrf/safe-url? ""))))
|
||||
|
||||
(t/deftest validate-url-blocks-missing-host
|
||||
(t/is (false? (ssrf/safe-url? "http:///path")))
|
||||
(t/is (false? (ssrf/safe-url? "http://"))))
|
||||
|
||||
(t/deftest validate-url-resolves-dns
|
||||
;; DNS-resolved internal: we use with-redefs to simulate
|
||||
(let [original ssrf/resolve-host]
|
||||
(with-redefs [ssrf/resolve-host
|
||||
(fn [hostname]
|
||||
(if (= hostname "evil.internal")
|
||||
(into-array java.net.InetAddress
|
||||
[(java.net.InetAddress/getByName "127.0.0.1")])
|
||||
(original hostname)))]
|
||||
(t/is (false? (ssrf/safe-url? "http://evil.internal/foo")))
|
||||
;; A hostname that fails DNS resolution
|
||||
(t/is (false? (ssrf/safe-url? "http://nonexistent.invalid/foo"))))))
|
||||
|
||||
(t/deftest validate-url-dns-all-addresses-must-be-safe
|
||||
;; If a hostname resolves to both a public and a private IP, it must be blocked
|
||||
(let [original ssrf/resolve-host]
|
||||
(with-redefs [ssrf/resolve-host
|
||||
(fn [hostname]
|
||||
(if (= hostname "split-brain.example")
|
||||
(into-array java.net.InetAddress
|
||||
[(java.net.InetAddress/getByName "1.1.1.1")
|
||||
(java.net.InetAddress/getByName "127.0.0.1")])
|
||||
(original hostname)))]
|
||||
(t/is (false? (ssrf/safe-url? "http://split-brain.example/foo"))))))
|
||||
|
||||
(t/deftest validate-url-allowlist-override
|
||||
(let [original-get cf/get]
|
||||
(with-redefs [cf/get (fn [key & args]
|
||||
(if (= key :ssrf-allowed-hosts)
|
||||
#{"localhost"}
|
||||
(apply original-get key args)))]
|
||||
;; localhost resolves to 127.0.0.1 which would normally be blocked
|
||||
(t/is (true? (ssrf/safe-url? "http://localhost:6060/foo"))))))
|
||||
|
||||
(t/deftest validate-url-extra-cidrs
|
||||
(binding [ssrf/extra-blocked-cidrs #{(ssrf/parse-cidr "203.0.113.0/24")}]
|
||||
(t/is (false? (ssrf/safe-url? "http://203.0.113.1/foo")))))
|
||||
|
||||
(t/deftest validate-url-throw-on-blocked
|
||||
(try
|
||||
(ssrf/validate-uri "http://127.0.0.1/foo")
|
||||
(t/is false "should have thrown")
|
||||
(catch Exception e
|
||||
(t/is (= :validation (:type (ex-data e))))
|
||||
(t/is (= :ssrf-blocked-target (:code (ex-data e)))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; http/req automatic SSRF validation
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(t/deftest http-req-validates-ssrf-by-default
|
||||
;; `http/req` should invoke ssrf/validate-uri before sending the request.
|
||||
;; We verify this by checking that a blocked URI raises an SSRF error
|
||||
;; without ever reaching the network (validate-uri throws first).
|
||||
(try
|
||||
(http/req {} {:method :get :uri "http://127.0.0.1/secret"})
|
||||
(t/is false "should have thrown an SSRF error")
|
||||
(catch Exception e
|
||||
(t/is (= :ssrf-blocked-target (:code (ex-data e)))))))
|
||||
|
||||
(t/deftest http-req-skip-ssrf-check-bypasses-validation
|
||||
;; When :skip-ssrf-check? true is passed, ssrf/validate-uri must NOT be
|
||||
;; called. We verify by patching validate-uri to record whether it was called.
|
||||
(let [called? (atom false)]
|
||||
(with-redefs [ssrf/validate-uri (fn [_] (reset! called? true))]
|
||||
;; The request will fail at the network level (no real server), but that's
|
||||
;; fine — we only care that validate-uri was not called beforehand.
|
||||
(try
|
||||
(http/req {} {:method :get :uri "http://127.0.0.1/secret"} {:skip-ssrf-check? true})
|
||||
(catch Exception _))
|
||||
(t/is (false? @called?) "validate-uri should not be called when :skip-ssrf-check? is true"))))
|
||||
|
||||
(t/deftest http-req-with-redirects-validates-ssrf-by-default
|
||||
;; req-with-redirects must also validate the initial URI automatically.
|
||||
(try
|
||||
(http/req-with-redirects {} {:method :get :uri "http://10.0.0.1/internal"})
|
||||
(t/is false "should have thrown an SSRF error")
|
||||
(catch Exception e
|
||||
(t/is (= :ssrf-blocked-target (:code (ex-data e)))))))
|
||||
|
||||
(t/deftest http-req-with-redirects-skip-ssrf-check-bypasses-validation
|
||||
(let [called? (atom false)]
|
||||
(with-redefs [ssrf/validate-uri (fn [_] (reset! called? true))]
|
||||
(try
|
||||
(http/req-with-redirects {} {:method :get :uri "http://10.0.0.1/internal"} {:skip-ssrf-check? true})
|
||||
(catch Exception _))
|
||||
(t/is (false? @called?) "validate-uri should not be called when :skip-ssrf-check? is true"))))
|
||||
@ -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))
|
||||
|
||||
@ -486,4 +486,3 @@
|
||||
a (+ (* ah 100) (* av 10))
|
||||
b (+ (* bh 100) (* bv 10))]
|
||||
(compare a b)))
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
(ns app.common.types.stroke
|
||||
(:require
|
||||
[app.common.colors :as clr]))
|
||||
[app.common.types.color :as clr]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SCHEMAS
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}]]]]))
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -236,6 +236,13 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
|
||||
|
||||
console.log("Code execution result:", result);
|
||||
|
||||
// transform a top-level Uint8Array result into a compact base64 envelope to avoid the
|
||||
// ~10x JSON expansion that occurs when JSON.stringify serializes typed arrays as objects
|
||||
// with numeric string keys (see penpot/penpot#9420)
|
||||
if (result instanceof Uint8Array) {
|
||||
result = ExecuteCodeTaskHandler.encodeBytesAsBase64Envelope(result);
|
||||
}
|
||||
|
||||
// return result and captured log
|
||||
let resultData: ExecuteCodeTaskResultData<any> = {
|
||||
result: result,
|
||||
@ -243,4 +250,23 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
|
||||
};
|
||||
task.sendSuccess(resultData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64-encodes the given bytes and wraps the result in a tagged envelope that the
|
||||
* server side recognizes (see `ImageContent.byteData`).
|
||||
*
|
||||
* @param bytes - the raw binary data to encode
|
||||
* @returns an envelope of the form `{ __type: "base64", data: <base64 string> }`
|
||||
*/
|
||||
private static encodeBytesAsBase64Envelope(bytes: Uint8Array): { __type: "base64"; data: string } {
|
||||
// build the binary string in chunks; calling `String.fromCharCode(...bytes)` directly
|
||||
// would overflow the call stack for large arrays
|
||||
const chunkSize = 0x8000;
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode.apply(null, chunk as unknown as number[]);
|
||||
}
|
||||
return { __type: "base64", data: btoa(binary) };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -17,13 +17,16 @@ export class ReplServer {
|
||||
private readonly logger = createLogger("ReplServer");
|
||||
private readonly app: express.Application;
|
||||
private readonly port: number;
|
||||
private readonly host: string;
|
||||
private server: any;
|
||||
|
||||
constructor(
|
||||
private readonly pluginBridge: PluginBridge,
|
||||
port: number = 4403
|
||||
port: number = 4403,
|
||||
host: string = "localhost"
|
||||
) {
|
||||
this.port = port;
|
||||
this.host = host;
|
||||
this.app = express();
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
@ -86,9 +89,9 @@ export class ReplServer {
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.server = this.app.listen(this.port, () => {
|
||||
this.server = this.app.listen(this.port, this.host, () => {
|
||||
this.logger.info(`REPL server started on port ${this.port}`);
|
||||
this.logger.info(`REPL interface URL: http://${this.pluginBridge.mcpServer.host}:${this.port}`);
|
||||
this.logger.info(`REPL interface URL: http://${this.host}:${this.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
@ -37,19 +37,26 @@ export class ImageContent implements ImageItem {
|
||||
|
||||
/**
|
||||
* Utility function for ensuring a consistent Uint8Array representation of byte data.
|
||||
* Input can be either a Uint8Array or an object (as obtained from JSON conversion of Uint8Array
|
||||
* from the plugin).
|
||||
* Input can be one of:
|
||||
* - a `Uint8Array` (already in the desired form);
|
||||
* - a base64 envelope `{ __type: "base64", data: <base64 string> }` produced by the plugin
|
||||
* to avoid the ~10x JSON expansion of typed arrays (see penpot/penpot#9420);
|
||||
* - a numeric-keyed object obtained from `JSON.stringify`-ing a `Uint8Array` (legacy fallback).
|
||||
*
|
||||
* @param data - data as Uint8Array or as object (from JSON conversion of Uint8Array)
|
||||
* @return data as Uint8Array
|
||||
* @param data - data as `Uint8Array`, base64 envelope, or numeric-keyed object
|
||||
* @return data as `Uint8Array`
|
||||
*/
|
||||
public static byteData(data: Uint8Array | object): Uint8Array {
|
||||
if (typeof data === "object") {
|
||||
// convert object (as obtained from JSON conversion of Uint8Array) back to Uint8Array
|
||||
return new Uint8Array(Object.values(data) as number[]);
|
||||
} else {
|
||||
if (data instanceof Uint8Array) {
|
||||
return data;
|
||||
}
|
||||
// recognize the base64 envelope produced by the plugin's ExecuteCodeTaskHandler
|
||||
const envelope = data as { __type?: unknown; data?: unknown };
|
||||
if (envelope.__type === "base64" && typeof envelope.data === "string") {
|
||||
return new Uint8Array(Buffer.from(envelope.data, "base64"));
|
||||
}
|
||||
// legacy fallback: object (as obtained from JSON conversion of Uint8Array) back to Uint8Array
|
||||
return new Uint8Array(Object.values(data) as number[]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -16,9 +16,9 @@
|
||||
"fmt": "./scripts/fmt"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@github/copilot": "^1.0.36",
|
||||
"@github/copilot": "^1.0.43",
|
||||
"@types/node": "^25.6.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"opencode-ai": "^1.14.28"
|
||||
"opencode-ai": "^1.14.40"
|
||||
}
|
||||
}
|
||||
|
||||
164
pnpm-lock.yaml
generated
164
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -18,9 +18,11 @@ use std::collections::HashMap;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use crate::error::{Error, Result};
|
||||
use crate::state::TextEditorState;
|
||||
use macros::wasm_error;
|
||||
use math::{Bounds, Matrix};
|
||||
use mem::SerializableResult;
|
||||
use render::{gpu_state::GpuState, RenderState};
|
||||
use shapes::{StructureEntry, StructureEntryType, TransformEntry};
|
||||
use skia_safe as skia;
|
||||
use state::State;
|
||||
@ -28,6 +30,37 @@ use utils::uuid_from_u32_quartet;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) static mut STATE: Option<Box<State>> = None;
|
||||
pub(crate) static mut TEXT_EDITOR_STATE: *mut TextEditorState = std::ptr::null_mut();
|
||||
|
||||
#[inline(always)]
|
||||
pub fn get_text_editor_state() -> &'static mut TextEditorState {
|
||||
unsafe {
|
||||
debug_assert!(!TEXT_EDITOR_STATE.is_null(), "Text Editor state is null");
|
||||
&mut *TEXT_EDITOR_STATE
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU State.
|
||||
static mut GPU_STATE: *mut GpuState = std::ptr::null_mut();
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn get_gpu_state() -> &'static mut GpuState {
|
||||
unsafe {
|
||||
debug_assert!(!GPU_STATE.is_null(), "GPU State is null");
|
||||
&mut *GPU_STATE
|
||||
}
|
||||
}
|
||||
|
||||
/// Render State.
|
||||
static mut RENDER_STATE: *mut RenderState = std::ptr::null_mut();
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn get_render_state() -> &'static mut RenderState {
|
||||
unsafe {
|
||||
debug_assert!(!RENDER_STATE.is_null(), "Render State is null");
|
||||
&mut *RENDER_STATE
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: These with_state* macros should be using our CriticalError instead of expect.
|
||||
// But to do that, we need to not use them at domain-level (i.e. in business logic), just
|
||||
@ -101,12 +134,32 @@ macro_rules! with_state_mut_current_shape {
|
||||
};
|
||||
}
|
||||
|
||||
/// Initializes GPU.
|
||||
fn gpu_init() {
|
||||
unsafe {
|
||||
let gpu_state = GpuState::try_new().expect("Cannot initialize GPU State");
|
||||
GPU_STATE = Box::into_raw(Box::new(gpu_state));
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes RenderState.
|
||||
fn render_init(width: i32, height: i32) {
|
||||
unsafe {
|
||||
let render_state =
|
||||
RenderState::try_new(width, height).expect("Cannot intialize RenderState");
|
||||
RENDER_STATE = Box::into_raw(Box::new(render_state));
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn init(width: i32, height: i32) -> Result<()> {
|
||||
let state_box = Box::new(State::try_new(width, height)?);
|
||||
gpu_init();
|
||||
render_init(width, height);
|
||||
unsafe {
|
||||
let state_box = Box::new(State::new());
|
||||
STATE = Some(state_box);
|
||||
TEXT_EDITOR_STATE = Box::into_raw(Box::new(TextEditorState::new()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@ -123,12 +176,10 @@ pub extern "C" fn set_browser(browser: u8) -> Result<()> {
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn clean_up() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
// Cancel the current animation frame if it exists so
|
||||
// it won't try to render without context
|
||||
let render_state = state.render_state_mut();
|
||||
render_state.cancel_animation_frame();
|
||||
});
|
||||
// Cancel the current animation frame if it exists so
|
||||
// it won't try to render without context
|
||||
let render_state = get_render_state();
|
||||
render_state.cancel_animation_frame();
|
||||
unsafe { STATE = None }
|
||||
mem::free_bytes()?;
|
||||
Ok(())
|
||||
@ -137,11 +188,9 @@ pub extern "C" fn clean_up() -> Result<()> {
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
let render_state = state.render_state_mut();
|
||||
render_state.set_debug_flags(debug);
|
||||
render_state.set_dpr(dpr)?;
|
||||
});
|
||||
let render_state = get_render_state();
|
||||
render_state.set_debug_flags(debug);
|
||||
render_state.set_dpr(dpr)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -150,61 +199,48 @@ pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> {
|
||||
pub extern "C" fn set_viewport_interest_area_threshold(
|
||||
viewport_interest_area_threshold: i32,
|
||||
) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
let render_state = state.render_state_mut();
|
||||
render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold);
|
||||
});
|
||||
let render_state = get_render_state();
|
||||
render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn set_max_blocking_time_ms(max_blocking_time_ms: i32) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
let render_state = state.render_state_mut();
|
||||
render_state.set_max_blocking_time_ms(max_blocking_time_ms);
|
||||
});
|
||||
let render_state = get_render_state();
|
||||
render_state.set_max_blocking_time_ms(max_blocking_time_ms);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn set_node_batch_threshold(node_batch_threshold: i32) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
let render_state = state.render_state_mut();
|
||||
render_state.set_node_batch_threshold(node_batch_threshold);
|
||||
});
|
||||
let render_state = get_render_state();
|
||||
render_state.set_node_batch_threshold(node_batch_threshold);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn set_blur_downscale_threshold(blur_downscale_threshold: f32) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
let render_state = state.render_state_mut();
|
||||
render_state.set_blur_downscale_threshold(blur_downscale_threshold);
|
||||
});
|
||||
let render_state = get_render_state();
|
||||
render_state.set_blur_downscale_threshold(blur_downscale_threshold);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
state.render_state_mut().set_antialias_threshold(threshold);
|
||||
});
|
||||
get_render_state().set_antialias_threshold(threshold);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn set_max_atlas_texture_size(max_px: i32) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
state
|
||||
.render_state_mut()
|
||||
.surfaces
|
||||
.set_max_atlas_texture_size(max_px);
|
||||
});
|
||||
get_render_state()
|
||||
.surfaces
|
||||
.set_max_atlas_texture_size(max_px);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -222,7 +258,7 @@ pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> {
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn render(_: i32) -> Result<()> {
|
||||
pub extern "C" fn render(timestamp: i32) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
state.rebuild_touched_tiles();
|
||||
// Drain the throttled modifier-tile invalidation accumulated
|
||||
@ -230,14 +266,14 @@ pub extern "C" fn render(_: i32) -> Result<()> {
|
||||
// interactive_transform; we do it once here, with the current
|
||||
// modifier set, so the cost is paid once per rAF rather than
|
||||
// once per pointer move.
|
||||
if state.render_state.options.is_interactive_transform() {
|
||||
if get_render_state().options.is_interactive_transform() {
|
||||
let ids = state.shapes.modifier_ids();
|
||||
if !ids.is_empty() {
|
||||
state.rebuild_modifier_tiles(ids)?;
|
||||
}
|
||||
}
|
||||
state
|
||||
.start_render_loop(performance::get_time())
|
||||
.start_render_loop(timestamp)
|
||||
.map_err(|_| Error::RecoverableError("Error rendering".to_string()))?;
|
||||
});
|
||||
Ok(())
|
||||
@ -249,7 +285,7 @@ pub extern "C" fn render_sync() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
state.rebuild_tiles();
|
||||
state
|
||||
.render_sync(performance::get_time())
|
||||
.render_sync(0)
|
||||
.map_err(|_| Error::RecoverableError("Error rendering".to_string()))?;
|
||||
});
|
||||
Ok(())
|
||||
@ -275,7 +311,7 @@ pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()
|
||||
|
||||
state.rebuild_tiles_from(Some(&id));
|
||||
state
|
||||
.render_sync_shape(&id, performance::get_time())
|
||||
.render_sync_shape(&id, 0)
|
||||
.map_err(|e| Error::RecoverableError(e.to_string()))?;
|
||||
});
|
||||
Ok(())
|
||||
@ -300,9 +336,7 @@ pub extern "C" fn render_from_cache(_: i32) -> Result<()> {
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn set_preview_mode(enabled: bool) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
state.render_state.set_preview_mode(enabled);
|
||||
});
|
||||
get_render_state().set_preview_mode(enabled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -345,9 +379,7 @@ pub extern "C" fn end_loading() -> Result<()> {
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn render_loading_overlay() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
state.render_state.render_loading_overlay();
|
||||
});
|
||||
get_render_state().render_loading_overlay();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -364,30 +396,24 @@ pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> {
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn reset_canvas() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
state.render_state_mut().reset_canvas();
|
||||
});
|
||||
get_render_state().reset_canvas();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn resize_viewbox(width: i32, height: i32) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
state.resize(width, height)?;
|
||||
});
|
||||
get_render_state().resize(width, height)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
performance::begin_measure!("set_view");
|
||||
let render_state = state.render_state_mut();
|
||||
render_state.set_view(zoom, x, y);
|
||||
performance::end_measure!("set_view");
|
||||
});
|
||||
performance::begin_measure!("set_view");
|
||||
let render_state = get_render_state();
|
||||
render_state.set_view(zoom, x, y);
|
||||
performance::end_measure!("set_view");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -397,15 +423,13 @@ static mut VIEW_INTERACTION_START: i32 = 0;
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn set_view_start() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
#[cfg(feature = "profile-macros")]
|
||||
unsafe {
|
||||
VIEW_INTERACTION_START = performance::get_time();
|
||||
}
|
||||
performance::begin_measure!("set_view_start");
|
||||
state.render_state.options.set_fast_mode(true);
|
||||
performance::end_measure!("set_view_start");
|
||||
});
|
||||
#[cfg(feature = "profile-macros")]
|
||||
unsafe {
|
||||
VIEW_INTERACTION_START = performance::get_time();
|
||||
}
|
||||
performance::begin_measure!("set_view_start");
|
||||
get_render_state().options.set_fast_mode(true);
|
||||
performance::end_measure!("set_view_start");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -419,34 +443,33 @@ pub extern "C" fn set_view_start() -> Result<()> {
|
||||
pub extern "C" fn set_view_end() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
performance::begin_measure!("set_view_end");
|
||||
state.render_state.options.set_fast_mode(false);
|
||||
state.render_state.cancel_animation_frame();
|
||||
let render_state = get_render_state();
|
||||
render_state.options.set_fast_mode(false);
|
||||
render_state.cancel_animation_frame();
|
||||
|
||||
let scale = state.render_state.get_scale();
|
||||
state
|
||||
.render_state
|
||||
let scale = render_state.get_scale();
|
||||
render_state
|
||||
.tile_viewbox
|
||||
.update(state.render_state.viewbox, scale);
|
||||
.update(render_state.viewbox, scale);
|
||||
|
||||
if state.render_state.options.is_profile_rebuild_tiles() {
|
||||
if render_state.options.is_profile_rebuild_tiles() {
|
||||
state.rebuild_tiles();
|
||||
} else if state.render_state.zoom_changed() {
|
||||
} else if render_state.zoom_changed() {
|
||||
// Zoom changed: tile sizes differ so all cached tile
|
||||
// textures are invalid (wrong scale). Rebuild the tile
|
||||
// index and clear the tile texture cache, but *preserve*
|
||||
// the cache canvas so render_from_cache can show a scaled
|
||||
// preview of the old content while new tiles render.
|
||||
state.render_state.rebuild_tile_index(&state.shapes);
|
||||
state.render_state.surfaces.invalidate_tile_cache();
|
||||
render_state.rebuild_tile_index(&state.shapes);
|
||||
render_state.surfaces.invalidate_tile_cache();
|
||||
} else {
|
||||
// Pure pan at the same zoom level: tile contents have not
|
||||
// changed — only the viewport position moved. Update the
|
||||
// tile index (which tiles are in the interest area) but
|
||||
// keep cached tile textures so the render can blit them
|
||||
// instead of re-drawing every visible tile from scratch.
|
||||
state.render_state.rebuild_tile_index(&state.shapes);
|
||||
render_state.rebuild_tile_index(&state.shapes);
|
||||
}
|
||||
|
||||
performance::end_measure!("set_view_end");
|
||||
});
|
||||
Ok(())
|
||||
@ -460,12 +483,11 @@ pub extern "C" fn set_view_end() -> Result<()> {
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn set_modifiers_start() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
performance::begin_measure!("set_modifiers_start");
|
||||
state.render_state.options.set_fast_mode(true);
|
||||
state.render_state.options.set_interactive_transform(true);
|
||||
performance::end_measure!("set_modifiers_start");
|
||||
});
|
||||
performance::begin_measure!("set_modifiers_start");
|
||||
let render_state = get_render_state();
|
||||
render_state.options.set_fast_mode(true);
|
||||
render_state.options.set_interactive_transform(true);
|
||||
performance::end_measure!("set_modifiers_start");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -476,13 +498,12 @@ pub extern "C" fn set_modifiers_start() -> Result<()> {
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn set_modifiers_end() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
performance::begin_measure!("set_modifiers_end");
|
||||
state.render_state.options.set_fast_mode(false);
|
||||
state.render_state.options.set_interactive_transform(false);
|
||||
state.render_state.cancel_animation_frame();
|
||||
performance::end_measure!("set_modifiers_end");
|
||||
});
|
||||
performance::begin_measure!("set_modifiers_end");
|
||||
let render_state = get_render_state();
|
||||
render_state.options.set_fast_mode(false);
|
||||
render_state.options.set_interactive_transform(false);
|
||||
render_state.cancel_animation_frame();
|
||||
performance::end_measure!("set_modifiers_end");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -798,11 +819,9 @@ pub extern "C" fn is_image_cached(
|
||||
d: u32,
|
||||
is_thumbnail: bool,
|
||||
) -> Result<bool> {
|
||||
with_state_mut!(state, {
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
let result = state.render_state().has_image(&id, is_thumbnail);
|
||||
Ok(result)
|
||||
})
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
let result = get_render_state().has_image(&id, is_thumbnail);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
@ -951,15 +970,14 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> {
|
||||
#[wasm_error]
|
||||
pub extern "C" fn clean_modifiers() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
let render_state = get_render_state();
|
||||
let prev_modifier_ids = state.shapes.clean_all();
|
||||
// Skip the tile-cache cleanup during interactive transform: the
|
||||
// per-rAF `rebuild_modifier_tiles` in `render()` already evicts
|
||||
// the same tiles for the active modifier set, so the eviction
|
||||
// here is redundant and doubles the per-emission cost.
|
||||
if !prev_modifier_ids.is_empty() && !state.render_state.options.is_interactive_transform() {
|
||||
state
|
||||
.render_state
|
||||
.update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?;
|
||||
if !prev_modifier_ids.is_empty() && !render_state.options.is_interactive_transform() {
|
||||
render_state.update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?;
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
@ -985,7 +1003,7 @@ pub extern "C" fn set_modifiers() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
state.set_modifiers(modifiers);
|
||||
// TO CHECK
|
||||
if !state.render_state.options.is_interactive_transform() {
|
||||
if !get_render_state().options.is_interactive_transform() {
|
||||
state.rebuild_modifier_tiles(ids)?;
|
||||
}
|
||||
});
|
||||
@ -1051,9 +1069,7 @@ pub extern "C" fn render_shape_pixels(
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn render_stats() {
|
||||
with_state!(state, {
|
||||
state.render_state.print_stats();
|
||||
})
|
||||
get_render_state().print_stats();
|
||||
}
|
||||
|
||||
fn main() {
|
||||
|
||||
@ -2,7 +2,7 @@ mod debug;
|
||||
mod fills;
|
||||
pub mod filters;
|
||||
mod fonts;
|
||||
mod gpu_state;
|
||||
pub mod gpu_state;
|
||||
pub mod grid_layout;
|
||||
mod images;
|
||||
mod options;
|
||||
@ -17,13 +17,10 @@ use skia_safe::{self as skia, Matrix, RRect, Rect};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use gpu_state::GpuState;
|
||||
|
||||
use options::RenderOptions;
|
||||
pub use surfaces::{SurfaceId, Surfaces};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::performance;
|
||||
use crate::shapes::{
|
||||
all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor,
|
||||
Stroke, StrokeKind, TextContent, Type,
|
||||
@ -33,6 +30,7 @@ use crate::tiles::{self, PendingTiles, TileRect};
|
||||
use crate::uuid::Uuid;
|
||||
use crate::view::Viewbox;
|
||||
use crate::wapi;
|
||||
use crate::{get_gpu_state, performance};
|
||||
|
||||
pub use fonts::*;
|
||||
pub use images::*;
|
||||
@ -327,7 +325,6 @@ impl RenderStats {
|
||||
}
|
||||
|
||||
pub(crate) struct RenderState {
|
||||
gpu_state: GpuState,
|
||||
pub options: RenderOptions,
|
||||
stats: RenderStats,
|
||||
pub surfaces: Surfaces,
|
||||
@ -394,6 +391,12 @@ pub struct InteractiveDragCrop {
|
||||
/// True if the captured crop bounds were fully inside the viewport at capture time.
|
||||
/// Used to avoid serving partial/offscreen crops during interactive drag.
|
||||
pub fits_viewport_at_capture: bool,
|
||||
/// Viewbox origin (doc-space) at capture time.
|
||||
pub capture_vb_left: f32,
|
||||
pub capture_vb_top: f32,
|
||||
/// Backbuffer pixel origin used for `snapshot_rect` (so we can do 1:1 blits).
|
||||
pub capture_src_left: i32,
|
||||
pub capture_src_top: i32,
|
||||
pub image: skia::Image,
|
||||
}
|
||||
|
||||
@ -486,13 +489,11 @@ impl RenderState {
|
||||
|
||||
pub fn try_new(width: i32, height: i32) -> Result<RenderState> {
|
||||
// This needs to be done once per WebGL context.
|
||||
let mut gpu_state = GpuState::try_new()?;
|
||||
let sampling_options =
|
||||
skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::Nearest);
|
||||
|
||||
let fonts = FontStore::try_new()?;
|
||||
let surfaces = Surfaces::try_new(
|
||||
&mut gpu_state,
|
||||
(width, height),
|
||||
sampling_options,
|
||||
tiles::get_tile_dimensions(),
|
||||
@ -505,15 +506,14 @@ impl RenderState {
|
||||
let tiles = tiles::TileHashMap::new();
|
||||
let options = RenderOptions::default();
|
||||
|
||||
Ok(RenderState {
|
||||
gpu_state: gpu_state.clone(),
|
||||
Ok(Self {
|
||||
options,
|
||||
stats: RenderStats::new(),
|
||||
surfaces,
|
||||
fonts,
|
||||
viewbox,
|
||||
cached_viewbox: Viewbox::new(0., 0.),
|
||||
images: ImageStore::new(gpu_state.context.clone()),
|
||||
images: ImageStore::new(),
|
||||
background_color: skia::Color::TRANSPARENT,
|
||||
render_request_id: None,
|
||||
render_in_progress: false,
|
||||
@ -525,10 +525,10 @@ impl RenderState {
|
||||
tiles,
|
||||
tile_viewbox: tiles::TileViewbox::new_with_interest(
|
||||
viewbox,
|
||||
options.viewport_interest_area_threshold,
|
||||
options.dpr_viewport_interest_area_threshold,
|
||||
1.0,
|
||||
),
|
||||
pending_tiles: PendingTiles::new_empty(),
|
||||
pending_tiles: PendingTiles::new(),
|
||||
nested_fills: vec![],
|
||||
nested_blurs: vec![],
|
||||
nested_shadows: vec![],
|
||||
@ -742,8 +742,11 @@ impl RenderState {
|
||||
}
|
||||
|
||||
pub fn set_dpr(&mut self, dpr: f32) -> Result<()> {
|
||||
if Some(dpr) != self.options.dpr {
|
||||
self.options.dpr = Some(dpr);
|
||||
// Only when this function returns true (it means the value
|
||||
// was properly changed) the rest of the functions is called.
|
||||
if self.options.set_dpr(dpr) {
|
||||
self.tile_viewbox
|
||||
.set_interest(self.options.dpr_viewport_interest_area_threshold);
|
||||
self.resize(
|
||||
self.viewbox.width.floor() as i32,
|
||||
self.viewbox.height.floor() as i32,
|
||||
@ -758,11 +761,15 @@ impl RenderState {
|
||||
}
|
||||
|
||||
pub fn set_viewport_interest_area_threshold(&mut self, value: i32) {
|
||||
self.options.set_viewport_interest_area_threshold(value);
|
||||
// The TileViewbox stores its own copy of `interest` (set at
|
||||
// construction). Without propagating, options change wouldn't
|
||||
// affect pending_tiles generation.
|
||||
self.tile_viewbox.set_interest(value);
|
||||
// Only when this function returns true (it means the value
|
||||
// was changed properly) the tile_viewbox.set_interest is called.
|
||||
if self.options.set_viewport_interest_area_threshold(value) {
|
||||
// The TileViewbox stores its own copy of `interest` (set at
|
||||
// construction). Without propagating, options change wouldn't
|
||||
// affect pending_tiles generation.
|
||||
self.tile_viewbox
|
||||
.set_interest(self.options.dpr_viewport_interest_area_threshold);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_node_batch_threshold(&mut self, value: i32) {
|
||||
@ -786,10 +793,9 @@ impl RenderState {
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, width: i32, height: i32) -> Result<()> {
|
||||
let dpr_width = (width as f32 * self.options.dpr()).floor() as i32;
|
||||
let dpr_height = (height as f32 * self.options.dpr()).floor() as i32;
|
||||
self.surfaces
|
||||
.resize(&mut self.gpu_state, dpr_width, dpr_height)?;
|
||||
let dpr_width = (width as f32 * self.options.dpr).floor() as i32;
|
||||
let dpr_height = (height as f32 * self.options.dpr).floor() as i32;
|
||||
self.surfaces.resize(dpr_width, dpr_height)?;
|
||||
self.viewbox.set_wh(width as f32, height as f32);
|
||||
self.tile_viewbox.update(self.viewbox, self.get_scale());
|
||||
|
||||
@ -797,8 +803,7 @@ impl RenderState {
|
||||
}
|
||||
|
||||
pub fn flush_and_submit(&mut self) {
|
||||
self.surfaces
|
||||
.flush_and_submit(&mut self.gpu_state, SurfaceId::Target);
|
||||
self.surfaces.flush_and_submit(SurfaceId::Target);
|
||||
}
|
||||
|
||||
pub fn reset_canvas(&mut self) {
|
||||
@ -877,7 +882,6 @@ impl RenderState {
|
||||
.as_ref()
|
||||
.ok_or(Error::CriticalError("Current tile not found".to_string()))?;
|
||||
self.surfaces.cache_current_tile_texture(
|
||||
&mut self.gpu_state,
|
||||
&self.tile_viewbox,
|
||||
¤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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ impl GpuState {
|
||||
}
|
||||
};
|
||||
|
||||
Ok(GpuState {
|
||||
Ok(Self {
|
||||
context,
|
||||
framebuffer_info,
|
||||
})
|
||||
|
||||
@ -3,6 +3,7 @@ use crate::shapes::ImageFill;
|
||||
use crate::uuid::Uuid;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::get_gpu_state;
|
||||
use skia_safe::gpu::{surfaces, Budgeted, DirectContext};
|
||||
use skia_safe::{self as skia, Codec, ISize};
|
||||
use std::collections::HashMap;
|
||||
@ -143,10 +144,12 @@ fn decode_image(context: &mut Box<DirectContext>, raw_data: &[u8]) -> Option<Ima
|
||||
}
|
||||
|
||||
impl ImageStore {
|
||||
pub fn new(context: DirectContext) -> Self {
|
||||
pub fn new() -> Self {
|
||||
let gpu_state = get_gpu_state();
|
||||
let context = &gpu_state.context;
|
||||
Self {
|
||||
images: HashMap::with_capacity(2048),
|
||||
context: Box::new(context),
|
||||
context: Box::new(context.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ const SHOW_WASM_INFO: u32 = 0x08;
|
||||
// Render performance options
|
||||
// This is the extra area used for tile rendering (tiles beyond viewport).
|
||||
// Higher values pre-render more tiles, reducing empty squares during pan but using more memory.
|
||||
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
|
||||
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1;
|
||||
const MAX_BLOCKING_TIME_MS: i32 = 32;
|
||||
const NODE_BATCH_THRESHOLD: i32 = 3;
|
||||
const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0;
|
||||
@ -15,7 +15,7 @@ const ANTIALIAS_THRESHOLD: f32 = 7.0;
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
pub struct RenderOptions {
|
||||
pub flags: u32,
|
||||
pub dpr: Option<f32>,
|
||||
pub dpr: f32,
|
||||
fast_mode: bool,
|
||||
/// Active while the user is interacting with a shape (drag, resize,
|
||||
/// rotate). Implies `fast_mode` semantics for expensive effects but
|
||||
@ -25,6 +25,7 @@ pub struct RenderOptions {
|
||||
/// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled.
|
||||
pub antialias_threshold: f32,
|
||||
pub viewport_interest_area_threshold: i32,
|
||||
pub dpr_viewport_interest_area_threshold: i32,
|
||||
pub max_blocking_time_ms: i32,
|
||||
pub node_batch_threshold: i32,
|
||||
pub blur_downscale_threshold: f32,
|
||||
@ -34,11 +35,12 @@ impl Default for RenderOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
flags: 0,
|
||||
dpr: None,
|
||||
dpr: 1.0,
|
||||
fast_mode: false,
|
||||
interactive_transform: false,
|
||||
antialias_threshold: ANTIALIAS_THRESHOLD,
|
||||
viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD,
|
||||
dpr_viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD,
|
||||
max_blocking_time_ms: MAX_BLOCKING_TIME_MS,
|
||||
node_batch_threshold: NODE_BATCH_THRESHOLD,
|
||||
blur_downscale_threshold: BLUR_DOWNSCALE_THRESHOLD,
|
||||
@ -64,6 +66,24 @@ impl RenderOptions {
|
||||
self.fast_mode = enabled;
|
||||
}
|
||||
|
||||
/// Updates the dpr viewport interest area threshold.
|
||||
/// This function is updated when the dpr or the
|
||||
/// viewport_interest_area_threshold is changed
|
||||
fn update_dpr_viewport_interest_area_threshold(&mut self) {
|
||||
self.dpr_viewport_interest_area_threshold =
|
||||
(self.dpr * self.viewport_interest_area_threshold as f32).ceil() as i32;
|
||||
}
|
||||
|
||||
/// Sets the devicePixelRatio.
|
||||
pub fn set_dpr(&mut self, value: f32) -> bool {
|
||||
if value > 0.0 && self.dpr != value {
|
||||
self.dpr = value;
|
||||
self.update_dpr_viewport_interest_area_threshold();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Interactive transform is ON while the user is dragging, resizing
|
||||
/// or rotating a shape. Callers use it to keep per-frame flushing
|
||||
/// enabled and to render visible tiles in a single frame so tiles
|
||||
@ -84,10 +104,6 @@ impl RenderOptions {
|
||||
self.fast_mode && !self.interactive_transform
|
||||
}
|
||||
|
||||
pub fn dpr(&self) -> f32 {
|
||||
self.dpr.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
pub fn is_text_editor_v3(&self) -> bool {
|
||||
self.flags & TEXT_EDITOR_V3 == TEXT_EDITOR_V3
|
||||
}
|
||||
@ -96,33 +112,44 @@ impl RenderOptions {
|
||||
self.flags & SHOW_WASM_INFO == SHOW_WASM_INFO
|
||||
}
|
||||
|
||||
pub fn set_antialias_threshold(&mut self, value: f32) {
|
||||
pub fn set_antialias_threshold(&mut self, value: f32) -> bool {
|
||||
if value.is_finite() && value > 0.0 {
|
||||
self.antialias_threshold = value;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn set_blur_downscale_threshold(&mut self, value: f32) {
|
||||
pub fn set_blur_downscale_threshold(&mut self, value: f32) -> bool {
|
||||
if value.is_finite() && value > 0.0 {
|
||||
self.blur_downscale_threshold = value;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn set_viewport_interest_area_threshold(&mut self, value: i32) {
|
||||
if value >= 0 {
|
||||
pub fn set_viewport_interest_area_threshold(&mut self, value: i32) -> bool {
|
||||
if value >= 0 && self.viewport_interest_area_threshold != value {
|
||||
self.viewport_interest_area_threshold = value;
|
||||
self.update_dpr_viewport_interest_area_threshold();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn set_node_batch_threshold(&mut self, value: i32) {
|
||||
pub fn set_node_batch_threshold(&mut self, value: i32) -> bool {
|
||||
if value > 0 {
|
||||
self.node_batch_threshold = value;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn set_max_blocking_time_ms(&mut self, value: i32) {
|
||||
pub fn set_max_blocking_time_ms(&mut self, value: i32) -> bool {
|
||||
if value > 0 {
|
||||
self.max_blocking_time_ms = value;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::error::{Error, Result};
|
||||
use crate::performance;
|
||||
use crate::shapes::Shape;
|
||||
use crate::view::Viewbox;
|
||||
use crate::{get_gpu_state, performance};
|
||||
|
||||
use skia_safe::{self as skia, IRect, Paint, RRect};
|
||||
|
||||
@ -101,11 +101,12 @@ pub struct Surfaces {
|
||||
#[allow(dead_code)]
|
||||
impl Surfaces {
|
||||
pub fn try_new(
|
||||
gpu_state: &mut GpuState,
|
||||
(width, height): (i32, i32),
|
||||
sampling_options: skia::SamplingOptions,
|
||||
tile_dims: skia::ISize,
|
||||
) -> Result<Self> {
|
||||
let gpu_state = get_gpu_state();
|
||||
|
||||
let extra_tile_dims = skia::ISize::new(
|
||||
tile_dims.width * TILE_SIZE_MULTIPLIER,
|
||||
tile_dims.height * TILE_SIZE_MULTIPLIER,
|
||||
@ -140,7 +141,7 @@ impl Surfaces {
|
||||
atlas.canvas().clear(skia::Color::TRANSPARENT);
|
||||
|
||||
let tiles = TileTextureCache::new();
|
||||
Ok(Surfaces {
|
||||
Ok(Self {
|
||||
target,
|
||||
filter,
|
||||
cache,
|
||||
@ -445,12 +446,9 @@ impl Surfaces {
|
||||
self.margins
|
||||
}
|
||||
|
||||
pub fn resize(
|
||||
&mut self,
|
||||
gpu_state: &mut GpuState,
|
||||
new_width: i32,
|
||||
new_height: i32,
|
||||
) -> Result<()> {
|
||||
pub fn resize(&mut self, new_width: i32, new_height: i32) -> Result<()> {
|
||||
let gpu_state = get_gpu_state();
|
||||
|
||||
self.reset_from_target(gpu_state.create_target_surface(new_width, new_height)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@ -547,7 +545,8 @@ impl Surfaces {
|
||||
self.dirty_surfaces = 0;
|
||||
}
|
||||
|
||||
pub fn flush_and_submit(&mut self, gpu_state: &mut GpuState, id: SurfaceId) {
|
||||
pub fn flush_and_submit(&mut self, id: SurfaceId) {
|
||||
let gpu_state = get_gpu_state();
|
||||
let surface = self.get_mut(id);
|
||||
gpu_state.context.flush_and_submit_surface(surface, None);
|
||||
}
|
||||
@ -946,13 +945,13 @@ impl Surfaces {
|
||||
|
||||
pub fn cache_current_tile_texture(
|
||||
&mut self,
|
||||
gpu_state: &mut GpuState,
|
||||
tile_viewbox: &TileViewbox,
|
||||
tile: &Tile,
|
||||
tile_rect: &skia::Rect,
|
||||
skip_cache_surface: bool,
|
||||
tile_doc_rect: skia::Rect,
|
||||
) {
|
||||
let gpu_state = get_gpu_state();
|
||||
let rect = IRect::from_xywh(
|
||||
self.margins.width,
|
||||
self.margins.height,
|
||||
@ -985,7 +984,8 @@ impl Surfaces {
|
||||
self.tiles.has(tile)
|
||||
}
|
||||
|
||||
pub fn remove_cached_tile_surface(&mut self, gpu_state: &mut GpuState, tile: Tile) {
|
||||
pub fn remove_cached_tile_surface(&mut self, tile: Tile) {
|
||||
let gpu_state = get_gpu_state();
|
||||
// Mark tile as invalid
|
||||
// Old content stays visible until new tile overwrites it atomically,
|
||||
// preventing flickering during tile re-renders.
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
@ -293,6 +293,10 @@ impl Stroke {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cap) = self.to_skia_linecap() {
|
||||
paint.set_stroke_cap(cap);
|
||||
}
|
||||
|
||||
paint
|
||||
}
|
||||
|
||||
@ -330,6 +334,19 @@ impl Stroke {
|
||||
cap_margin_for_cap(self.cap_start, self.width)
|
||||
.max(cap_margin_for_cap(self.cap_end, self.width))
|
||||
}
|
||||
|
||||
/// Returns a Skia `PaintCap` to apply natively on the stroke paint when
|
||||
/// both ends share the same simple line cap (`Round/Round` or
|
||||
/// `Square/Square`). Skia only emits cap geometry at sub-path endpoints,
|
||||
/// so this is a no-op on closed paths and avoids the extra fill draw the
|
||||
/// manual caps would otherwise require on open paths.
|
||||
pub fn to_skia_linecap(&self) -> Option<skia::paint::Cap> {
|
||||
match (self.cap_start, self.cap_end) {
|
||||
(Some(StrokeCap::Round), Some(StrokeCap::Round)) => Some(skia::paint::Cap::Round),
|
||||
(Some(StrokeCap::Square), Some(StrokeCap::Square)) => Some(skia::paint::Cap::Square),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn align_rect_to_half_pixel(rect: &Rect, stroke_width: f32, scale: f32) -> Rect {
|
||||
|
||||
@ -199,6 +199,12 @@ pub struct TextContentLayout {
|
||||
cached_extrect: Cell<Option<CachedExtrect>>,
|
||||
}
|
||||
|
||||
impl Default for TextContentLayout {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for TextContentLayout {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
use crate::get_render_state;
|
||||
use crate::shapes::text::TextContent;
|
||||
use skia_safe::{
|
||||
self as skia, textlayout::Paragraph as SkiaParagraph, FontMetrics, Point, Rect, TextBlob,
|
||||
};
|
||||
use std::ops::Deref;
|
||||
|
||||
use crate::{with_state_mut, STATE};
|
||||
|
||||
pub struct TextPaths(TextContent);
|
||||
|
||||
// Note: This class is not being currently used.
|
||||
@ -173,20 +172,18 @@ impl TextPaths {
|
||||
blob_offset_x: f32,
|
||||
blob_offset_y: f32,
|
||||
) -> Option<(skia::Path, skia::Rect)> {
|
||||
with_state_mut!(state, {
|
||||
let utf16_text = span_text.encode_utf16().collect::<Vec<u16>>();
|
||||
let text = unsafe { skia_safe::as_utf16_unchecked(&utf16_text) };
|
||||
let emoji_font = state.render_state.fonts().get_emoji_font(font.size());
|
||||
let use_font = emoji_font.as_ref().unwrap_or(font);
|
||||
let utf16_text = span_text.encode_utf16().collect::<Vec<u16>>();
|
||||
let text = unsafe { skia_safe::as_utf16_unchecked(&utf16_text) };
|
||||
let emoji_font = get_render_state().fonts().get_emoji_font(font.size());
|
||||
let use_font = emoji_font.as_ref().unwrap_or(font);
|
||||
|
||||
if let Some(mut text_blob) = TextBlob::from_text(text, use_font) {
|
||||
let path = SkiaParagraph::get_path(&mut text_blob);
|
||||
let d = Point::new(blob_offset_x, blob_offset_y);
|
||||
let offset_path = path.with_offset(d);
|
||||
let bounds = text_blob.bounds();
|
||||
return Some((offset_path, *bounds));
|
||||
}
|
||||
});
|
||||
if let Some(mut text_blob) = TextBlob::from_text(text, use_font) {
|
||||
let path = SkiaParagraph::get_path(&mut text_blob);
|
||||
let d = Point::new(blob_offset_x, blob_offset_y);
|
||||
let offset_path = path.with_offset(d);
|
||||
let bounds = text_blob.bounds();
|
||||
return Some((offset_path, *bounds));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,12 +7,9 @@ pub use shapes_pool::{ShapesPool, ShapesPoolMutRef, ShapesPoolRef};
|
||||
pub use text_editor::*;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::render::RenderState;
|
||||
use crate::shapes::Shape;
|
||||
use crate::tiles;
|
||||
use crate::shapes::{grid_layout::grid_cell_data, Shape};
|
||||
use crate::uuid::Uuid;
|
||||
|
||||
use crate::shapes::modifiers::grid_layout::grid_cell_data;
|
||||
use crate::{get_render_state, tiles};
|
||||
|
||||
/// This struct holds the state of the Rust application between JS calls.
|
||||
///
|
||||
@ -20,8 +17,6 @@ use crate::shapes::modifiers::grid_layout::grid_cell_data;
|
||||
/// Note that rust-skia data structures are not thread safe, so a state
|
||||
/// must not be shared between different Web Workers.
|
||||
pub(crate) struct State {
|
||||
pub render_state: RenderState,
|
||||
pub text_editor_state: TextEditorState,
|
||||
pub current_id: Option<Uuid>,
|
||||
pub current_browser: u8,
|
||||
pub shapes: ShapesPool,
|
||||
@ -31,16 +26,14 @@ pub(crate) struct State {
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn try_new(width: i32, height: i32) -> Result<Self> {
|
||||
Ok(State {
|
||||
render_state: RenderState::try_new(width, height)?,
|
||||
text_editor_state: TextEditorState::new(),
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current_id: None,
|
||||
current_browser: 0,
|
||||
shapes: ShapesPool::new(),
|
||||
saved_shapes: None,
|
||||
loading: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a new temporary shapes pool.
|
||||
@ -67,40 +60,16 @@ impl State {
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, width: i32, height: i32) -> Result<()> {
|
||||
self.render_state.resize(width, height)
|
||||
}
|
||||
|
||||
pub fn render_state_mut(&mut self) -> &mut RenderState {
|
||||
&mut self.render_state
|
||||
}
|
||||
|
||||
pub fn render_state(&self) -> &RenderState {
|
||||
&self.render_state
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn text_editor_state_mut(&mut self) -> &mut TextEditorState {
|
||||
&mut self.text_editor_state
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn text_editor_state(&self) -> &TextEditorState {
|
||||
&self.text_editor_state
|
||||
}
|
||||
|
||||
pub fn render_from_cache(&mut self) {
|
||||
self.render_state.render_from_cache(&self.shapes);
|
||||
get_render_state().render_from_cache(&self.shapes);
|
||||
}
|
||||
|
||||
pub fn render_sync(&mut self, timestamp: i32) -> Result<()> {
|
||||
self.render_state
|
||||
.start_render_loop(None, &self.shapes, timestamp, true)
|
||||
get_render_state().start_render_loop(None, &self.shapes, timestamp, true)
|
||||
}
|
||||
|
||||
pub fn render_sync_shape(&mut self, id: &Uuid, timestamp: i32) -> Result<()> {
|
||||
self.render_state
|
||||
.start_render_loop(Some(id), &self.shapes, timestamp, true)
|
||||
get_render_state().start_render_loop(Some(id), &self.shapes, timestamp, true)
|
||||
}
|
||||
|
||||
pub fn render_shape_pixels(
|
||||
@ -109,36 +78,34 @@ impl State {
|
||||
scale: f32,
|
||||
timestamp: i32,
|
||||
) -> Result<(Vec<u8>, i32, i32)> {
|
||||
self.render_state
|
||||
.render_shape_pixels(id, &self.shapes, scale, timestamp)
|
||||
get_render_state().render_shape_pixels(id, &self.shapes, scale, timestamp)
|
||||
}
|
||||
|
||||
pub fn start_render_loop(&mut self, timestamp: i32) -> Result<()> {
|
||||
let render_state = get_render_state();
|
||||
// If zoom changed (e.g. interrupted zoom render followed by pan), the
|
||||
// tile index may be stale for the new viewport position. Rebuild the
|
||||
// index so shapes are mapped to the correct tiles. We use
|
||||
// rebuild_tile_index (NOT rebuild_tiles_shallow) to preserve the tile
|
||||
// texture cache — otherwise cached tiles with shadows/blur would be
|
||||
// cleared and re-rendered in fast mode without effects.
|
||||
if self.render_state.zoom_changed() {
|
||||
self.render_state.rebuild_tile_index(&self.shapes);
|
||||
if render_state.zoom_changed() {
|
||||
render_state.rebuild_tile_index(&self.shapes);
|
||||
}
|
||||
|
||||
self.render_state
|
||||
.start_render_loop(None, &self.shapes, timestamp, false)
|
||||
render_state.start_render_loop(None, &self.shapes, timestamp, false)
|
||||
}
|
||||
|
||||
pub fn process_animation_frame(&mut self, timestamp: i32) -> Result<()> {
|
||||
self.render_state
|
||||
.process_animation_frame(None, &self.shapes, timestamp)
|
||||
get_render_state().process_animation_frame(None, &self.shapes, timestamp)
|
||||
}
|
||||
|
||||
pub fn clear_focus_mode(&mut self) {
|
||||
self.render_state.clear_focus_mode();
|
||||
get_render_state().clear_focus_mode();
|
||||
}
|
||||
|
||||
pub fn set_focus_mode(&mut self, shapes: Vec<Uuid>) {
|
||||
self.render_state.set_focus_mode(shapes);
|
||||
get_render_state().set_focus_mode(shapes);
|
||||
}
|
||||
|
||||
pub fn init_shapes_pool(&mut self, capacity: usize) {
|
||||
@ -153,6 +120,8 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn delete_shape_children(&mut self, parent_id: Uuid, id: Uuid) {
|
||||
let render_state = get_render_state();
|
||||
|
||||
// We don't really do a self.shapes.remove so that redo/undo keep working
|
||||
let Some(shape) = self.shapes.get(&id) else {
|
||||
return;
|
||||
@ -168,16 +137,15 @@ impl State {
|
||||
//
|
||||
// Instead, remove the shape from *all* tiles where it was indexed, and
|
||||
// drop cached tiles for those entries.
|
||||
let indexed_tiles: Vec<tiles::Tile> = self
|
||||
.render_state
|
||||
let indexed_tiles: Vec<tiles::Tile> = render_state
|
||||
.tiles
|
||||
.get_tiles_of(shape.id)
|
||||
.map(|t| t.iter().copied().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
for tile in indexed_tiles {
|
||||
self.render_state.remove_cached_tile(tile);
|
||||
self.render_state.tiles.remove_shape_at(tile, shape.id);
|
||||
render_state.remove_cached_tile(tile);
|
||||
render_state.tiles.remove_shape_at(tile, shape.id);
|
||||
}
|
||||
|
||||
if let Some(shape_to_delete) = self.shapes.get(&id) {
|
||||
@ -186,8 +154,8 @@ impl State {
|
||||
if let Some(shape_to_delete) = self.shapes.get_mut(&shape_id) {
|
||||
shape_to_delete.set_deleted(true);
|
||||
}
|
||||
if self.render_state.show_grid == Some(shape_id) {
|
||||
self.render_state.show_grid = None;
|
||||
if render_state.show_grid == Some(shape_id) {
|
||||
render_state.show_grid = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -203,7 +171,7 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn set_background_color(&mut self, color: skia::Color) {
|
||||
self.render_state.set_background_color(color);
|
||||
get_render_state().set_background_color(color);
|
||||
}
|
||||
|
||||
pub fn set_browser(&mut self, browser: u8) {
|
||||
@ -238,33 +206,32 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn rebuild_tiles_shallow(&mut self) {
|
||||
self.render_state.rebuild_tiles_shallow(&self.shapes);
|
||||
get_render_state().rebuild_tiles_shallow(&self.shapes);
|
||||
}
|
||||
|
||||
pub fn rebuild_tiles(&mut self) {
|
||||
self.render_state.rebuild_tiles_from(&self.shapes, None);
|
||||
get_render_state().rebuild_tiles_from(&self.shapes, None);
|
||||
}
|
||||
|
||||
pub fn rebuild_tiles_from(&mut self, base_id: Option<&Uuid>) {
|
||||
self.render_state.rebuild_tiles_from(&self.shapes, base_id);
|
||||
get_render_state().rebuild_tiles_from(&self.shapes, base_id);
|
||||
}
|
||||
|
||||
pub fn rebuild_touched_tiles(&mut self) {
|
||||
self.render_state.rebuild_touched_tiles(&self.shapes);
|
||||
get_render_state().rebuild_touched_tiles(&self.shapes);
|
||||
}
|
||||
|
||||
pub fn render_preview(&mut self, timestamp: i32) {
|
||||
let _ = self.render_state.render_preview(&self.shapes, timestamp);
|
||||
let _ = get_render_state().render_preview(&self.shapes, timestamp);
|
||||
}
|
||||
|
||||
pub fn rebuild_modifier_tiles(&mut self, ids: Vec<Uuid>) -> Result<()> {
|
||||
// Index-based storage is safe
|
||||
self.render_state
|
||||
.rebuild_modifier_tiles(&mut self.shapes, ids)
|
||||
get_render_state().rebuild_modifier_tiles(&mut self.shapes, ids)
|
||||
}
|
||||
|
||||
pub fn font_collection(&self) -> &FontCollection {
|
||||
self.render_state.fonts().font_collection()
|
||||
get_render_state().fonts().font_collection()
|
||||
}
|
||||
|
||||
pub fn get_grid_coords(&self, pos_x: f32, pos_y: f32) -> Option<(i32, i32)> {
|
||||
@ -297,16 +264,18 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn touch_current(&mut self) {
|
||||
let render_state = get_render_state();
|
||||
if !self.loading {
|
||||
if let Some(current_id) = self.current_id {
|
||||
self.render_state.mark_touched(current_id);
|
||||
render_state.mark_touched(current_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn touch_shape(&mut self, id: Uuid) {
|
||||
let render_state = get_render_state();
|
||||
if !self.loading {
|
||||
self.render_state.mark_touched(id);
|
||||
render_state.mark_touched(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -392,3 +392,9 @@ impl ShapesPoolImpl {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ShapesPoolImpl {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 == '_'
|
||||
}
|
||||
|
||||
@ -22,43 +22,95 @@ impl Tile {
|
||||
pub struct TileRect(pub i32, pub i32, pub i32, pub i32);
|
||||
|
||||
impl TileRect {
|
||||
pub fn empty() -> Self {
|
||||
Self(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn x1(&self) -> i32 {
|
||||
self.0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn y1(&self) -> i32 {
|
||||
self.1
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn x2(&self) -> i32 {
|
||||
self.2
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn y2(&self) -> i32 {
|
||||
self.3
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn left(&self) -> i32 {
|
||||
self.0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn top(&self) -> i32 {
|
||||
self.1
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn right(&self) -> i32 {
|
||||
self.2
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn bottom(&self) -> i32 {
|
||||
self.3
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn x(&self) -> i32 {
|
||||
self.0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn y(&self) -> i32 {
|
||||
self.1
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn width(&self) -> i32 {
|
||||
self.x2() - self.x1()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn half_width(&self) -> i32 {
|
||||
self.width() / 2
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn height(&self) -> i32 {
|
||||
self.y2() - self.y1()
|
||||
}
|
||||
|
||||
pub fn center_x(&self) -> i32 {
|
||||
self.x1() + self.width() / 2
|
||||
#[inline]
|
||||
pub fn half_height(&self) -> i32 {
|
||||
self.height() / 2
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn center_x(&self) -> i32 {
|
||||
self.x() + self.half_width()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn center_y(&self) -> i32 {
|
||||
self.y1() + self.height() / 2
|
||||
self.y() + self.half_height()
|
||||
}
|
||||
|
||||
pub fn contains(&self, tile: &Tile) -> bool {
|
||||
tile.x() >= self.x1()
|
||||
&& tile.y() >= self.y1()
|
||||
&& tile.x() <= self.x2()
|
||||
&& tile.y() <= self.y2()
|
||||
tile.x() >= self.left()
|
||||
&& tile.y() >= self.top()
|
||||
&& tile.x() <= self.right()
|
||||
&& tile.y() <= self.bottom()
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,43 +247,70 @@ impl TileHashMap {
|
||||
}
|
||||
|
||||
const VIEWPORT_DEFAULT_CAPACITY: usize = 24 * 12;
|
||||
const VIEWPORT_SPIRAL_DEFAULT_CAPACITY: usize = 64;
|
||||
|
||||
// This structure keeps the list of tiles that are in the pending list, the
|
||||
// ones that are going to be rendered.
|
||||
pub struct PendingTiles {
|
||||
pub list: Vec<Tile>,
|
||||
pub spiral: Vec<Tile>,
|
||||
pub spiral_rect: TileRect,
|
||||
}
|
||||
|
||||
impl PendingTiles {
|
||||
pub fn new_empty() -> Self {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
list: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY),
|
||||
spiral: Vec::with_capacity(VIEWPORT_SPIRAL_DEFAULT_CAPACITY),
|
||||
spiral_rect: TileRect::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
// Generate tiles ordered by distance to the center (closest processed first).
|
||||
fn generate_spiral(rect: &TileRect) -> Vec<Tile> {
|
||||
let cx = rect.center_x();
|
||||
let cy = rect.center_y();
|
||||
// Generate tiles in spiral order from center
|
||||
fn generate_spiral(columns: usize, rows: usize) -> Vec<Tile> {
|
||||
let total = columns * rows;
|
||||
let mut result = Vec::with_capacity(total);
|
||||
let mut cx = 0;
|
||||
let mut cy = 0;
|
||||
|
||||
// TileRect is inclusive (x1..=x2, y1..=y2).
|
||||
let mut tiles = Vec::new();
|
||||
for x in rect.x1()..=rect.x2() {
|
||||
for y in rect.y1()..=rect.y2() {
|
||||
tiles.push(Tile(x, y));
|
||||
let ratio = (columns as f32 / rows as f32).ceil() as i32;
|
||||
|
||||
let mut direction_current = 0;
|
||||
let mut direction_total_x = ratio;
|
||||
let mut direction_total_y = 1;
|
||||
let mut direction = 0;
|
||||
|
||||
result.push(Tile(cx, cy));
|
||||
while result.len() < total {
|
||||
match direction {
|
||||
0 => cx += 1,
|
||||
1 => cy += 1,
|
||||
2 => cx -= 1,
|
||||
3 => cy -= 1,
|
||||
_ => unreachable!("Invalid direction"),
|
||||
}
|
||||
|
||||
result.push(Tile(cx, cy));
|
||||
|
||||
direction_current += 1;
|
||||
let direction_total = if direction % 2 == 0 {
|
||||
direction_total_x
|
||||
} else {
|
||||
direction_total_y
|
||||
};
|
||||
|
||||
if direction_current == direction_total {
|
||||
if direction % 2 == 0 {
|
||||
direction_total_x += 1;
|
||||
} else {
|
||||
direction_total_y += 1;
|
||||
}
|
||||
direction = (direction + 1) % 4;
|
||||
direction_current = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// We pop() from the end, so keep nearest-to-center tiles at the end.
|
||||
tiles.sort_unstable_by(|a, b| {
|
||||
let da = (a.x() - cx).abs() + (a.y() - cy).abs();
|
||||
let db = (b.x() - cx).abs() + (b.y() - cy).abs();
|
||||
da.cmp(&db)
|
||||
.then_with(|| a.x().cmp(&b.x()))
|
||||
.then_with(|| a.y().cmp(&b.y()))
|
||||
});
|
||||
tiles.reverse();
|
||||
tiles
|
||||
result.reverse();
|
||||
result
|
||||
}
|
||||
|
||||
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) {
|
||||
@ -247,7 +326,18 @@ impl PendingTiles {
|
||||
} else {
|
||||
&tile_viewbox.interest_rect
|
||||
};
|
||||
let spiral = Self::generate_spiral(spiral_rect);
|
||||
|
||||
self.spiral_rect = *spiral_rect;
|
||||
|
||||
// We do not regenerate spiral if the spiral_rect
|
||||
// doesn't change. The spiral_rect is based on the
|
||||
// viewbox so, if the viewbox doesn't change
|
||||
// the spiral should not change.
|
||||
let total = (spiral_rect.width() * spiral_rect.height()) as usize;
|
||||
if self.spiral.len() < total {
|
||||
self.spiral =
|
||||
Self::generate_spiral(spiral_rect.width() as usize, spiral_rect.height() as usize);
|
||||
}
|
||||
|
||||
// Partition tiles into 4 priority groups (highest priority = processed last due to pop()):
|
||||
// 1. visible + cached (fastest - just blit from cache)
|
||||
@ -259,7 +349,9 @@ impl PendingTiles {
|
||||
let mut interest_cached = Vec::new();
|
||||
let mut interest_uncached = Vec::new();
|
||||
|
||||
for tile in spiral {
|
||||
let center_tile = Tile(spiral_rect.center_x(), spiral_rect.center_y());
|
||||
for spiral_tile in self.spiral.iter() {
|
||||
let tile = Tile(spiral_tile.0 + center_tile.0, spiral_tile.1 + center_tile.1);
|
||||
let is_visible = tile_viewbox.visible_rect.contains(&tile);
|
||||
let is_cached = surfaces.has_cached_tile_surface(tile);
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
use crate::get_render_state;
|
||||
use crate::skia::textlayout::FontCollection;
|
||||
use crate::skia::Image;
|
||||
use crate::uuid::Uuid;
|
||||
@ -25,12 +26,12 @@ pub fn uuid_from_u32(id: [u32; 4]) -> Uuid {
|
||||
}
|
||||
|
||||
pub fn get_image(image_id: &Uuid) -> Option<&Image> {
|
||||
with_state_mut!(state, { state.render_state_mut().images.get(image_id) })
|
||||
get_render_state().images.get(image_id)
|
||||
}
|
||||
|
||||
// FIXME: move to a different place ?
|
||||
pub fn get_fallback_fonts() -> &'static HashSet<String> {
|
||||
with_state_mut!(state, { state.render_state().fonts().get_fallback() })
|
||||
get_render_state().fonts().get_fallback()
|
||||
}
|
||||
|
||||
pub fn get_font_collection() -> &'static FontCollection {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user