diff --git a/.agents/skills/local-ci/SKILL.md b/.agents/skills/local-ci/SKILL.md index 5cda6acf89..d04f581dc1 100644 --- a/.agents/skills/local-ci/SKILL.md +++ b/.agents/skills/local-ci/SKILL.md @@ -1,6 +1,6 @@ --- name: local-ci -description: Run local CI-style checks with ./scripts/ci (lint, tests, format) per monorepo module. Use when verifying changes before declaring work done, running lint or tests locally, fixing formatting, or repairing Clojure delimiter errors. +description: Run local CI-style checks with ./scripts/ci (lint, tests, format, and on request production builds) per monorepo module. Use when verifying changes before declaring work done, running lint or tests locally, fixing formatting, repairing Clojure delimiter errors, or checking that a module still compiles. --- # Local CI @@ -29,10 +29,12 @@ Run from the repo root: ``` Modules: `frontend` `backend` `common` `render-wasm` `exporter` `mcp` -`plugins` `library`, or `--all` for every module. +`plugins` `library` `media-processor`, or `--all` for every module. With no task flags it runs three tasks per module, in order: **lint**, -**test**, **fmt** (format check; `--fix` formats files instead). +**test**, **fmt** (format check; `--fix` formats files instead). **build** +is opt-in: it compiles the module the way production does and is the slow +one, so it never runs unless you ask for it. | Flag | Effect | |------|--------| @@ -41,6 +43,7 @@ With no task flags it runs three tasks per module, in order: **lint**, | `--lint` / `--no-lint` | Run only lint / drop lint | | `--test` / `--no-test` | Run only tests / drop tests | | `--fmt` / `--no-fmt` | Run only format check / drop it | +| `--build` / `--no-build` | Run only the production build / drop it | | `--fix` | Format files instead of checking (other tasks unaffected) | | `--paren-repair` | Fix delimiter errors in Clojure/CLJS files | | `--fail-fast` | Stop at the first failure | @@ -72,6 +75,12 @@ log path. # Format the module without running the test suite ./scripts/ci --fix --no-test frontend +# Check that the module still compiles (slow: minutes) +./scripts/ci --build frontend + +# Compile every module (8 builds, ~4 min with warm caches) +./scripts/ci --build --all + # Broke delimiters in Clojure/CLJS files: repair first, then lint ./scripts/ci --paren-repair frontend ./scripts/ci --lint frontend @@ -89,7 +98,12 @@ log path. - Test tasks are long-running (backend runs `clojure -M:dev:test`); give the bash call a generous timeout (10–20 minutes) instead of letting it time out mid-run. -- `mcp` has no lint task — it shows as skipped, not failed. +- `mcp` and `media-processor` have no lint task — they show as skipped, + not failed. `common` has no build task for the same reason. +- Build runs are the slow ones (frontend ~2 min even with warm caches; + cold caches roughly double it). Give the shell a generous timeout and + do not run two builds at once in the same checkout: `render-wasm` takes + a build lock. - `--paren-repair` only fixes delimiters; run lint afterwards to catch what remains. See `mem:scripts/paren-repair`. - What to run and how to read test results: `mem:testing`. diff --git a/.devenv/README.md b/.devenv/README.md index f56b1e09d1..fb2a7bc69b 100644 --- a/.devenv/README.md +++ b/.devenv/README.md @@ -85,6 +85,10 @@ target instance is running and its MCP config has been generated: # 'main', 'ws1' and similar spellings are rejected. ./manage.sh start-coding-agent claude --ws 1 ./manage.sh start-coding-agent opencode --ws 2 + +# Run a different executable (e.g. a wrapper script) with the same MCP +# wiring. --ws and --bin may come in any order. +./manage.sh start-coding-agent claude --bin claude-alt ``` Equivalents by hand (run from inside the workspace directory): diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index a1c99e2bdd..0b62cf100e 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -38,13 +38,13 @@ jobs: uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to Docker Registry (push destination) - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} - name: Login to Docker Hardened Images registry (base image pull) - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: dhi.io username: ${{ secrets.PUB_DOCKER_USERNAME }} diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 613f806571..4d15460ad6 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -200,7 +200,7 @@ jobs: ref: ${{ inputs.sha != '' && inputs.sha || inputs.gh_ref }} - name: Login to Docker Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ${{ secrets.DOCKER_REGISTRY }} username: ${{ secrets.DOCKER_USERNAME }} @@ -210,7 +210,7 @@ jobs: # images from DockerHub for unregistered users. # https://docs.docker.com/docker-hub/usage/ - name: Login to DockerHub Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} @@ -222,7 +222,7 @@ jobs: # reuses the same PUB_DOCKER_* credentials as the DockerHub # login above. - name: Login to Docker Hardened Images registry (base image pull) - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: dhi.io username: ${{ secrets.PUB_DOCKER_USERNAME }} @@ -301,7 +301,7 @@ jobs: uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to Docker Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ${{ secrets.DOCKER_REGISTRY }} username: ${{ secrets.DOCKER_USERNAME }} @@ -371,7 +371,7 @@ jobs: uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to Docker Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ${{ secrets.DOCKER_REGISTRY }} username: ${{ secrets.DOCKER_USERNAME }} diff --git a/.github/workflows/build-renovate.yml b/.github/workflows/build-renovate.yml index 09f8a51aa0..6145e7e2fd 100644 --- a/.github/workflows/build-renovate.yml +++ b/.github/workflows/build-renovate.yml @@ -114,20 +114,20 @@ jobs: persist-credentials: false - name: Login to Docker Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ${{ secrets.DOCKER_REGISTRY }} username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to DockerHub Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} - name: Login to Docker Hardened Images registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: dhi.io username: ${{ secrets.PUB_DOCKER_USERNAME }} @@ -191,13 +191,13 @@ jobs: persist-credentials: false - name: Login to DockerHub Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} - name: Login to Docker Hardened Images registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: dhi.io username: ${{ secrets.PUB_DOCKER_USERNAME }} @@ -242,7 +242,7 @@ jobs: persist-credentials: false - name: Login to Docker Hardened Images registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: dhi.io username: ${{ secrets.PUB_DOCKER_USERNAME }} diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index f513ba6af9..f5301f9844 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -48,10 +48,10 @@ jobs: # No dependency cache here: a deploy must not restore a cache that # other workflows can write to. - name: Setup pnpm + Node.js - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 + uses: pnpm/setup@fa36a5ef7dcdefc3a7cb25655c122e00cf6e636e # v3.1.2 with: working-directory: plugins - runtime: node@24.22.1 + runtime: node@26.13.0 install: false - name: Install deps diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 65ed5c80f3..d084affd59 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -46,10 +46,10 @@ jobs: # No dependency cache here: a deploy must not restore a cache that # other workflows can write to. - name: Setup pnpm + Node.js - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 + uses: pnpm/setup@fa36a5ef7dcdefc3a7cb25655c122e00cf6e636e # v3.1.2 with: working-directory: plugins - runtime: node@24.22.1 + runtime: node@26.13.0 install: false - name: Install deps diff --git a/.nvmrc b/.nvmrc index 474d750b50..86456ddf02 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24.22.1 +v24.22.2 diff --git a/.serena/memories/backend/auth-permissions-product-domains.md b/.serena/memories/backend/auth-permissions-product-domains.md index 2a58e49dfd..d9ee64c171 100644 --- a/.serena/memories/backend/auth-permissions-product-domains.md +++ b/.serena/memories/backend/auth-permissions-product-domains.md @@ -13,6 +13,9 @@ - Logout may return an OIDC provider redirect URI when the session claims include provider/session data and the provider has a logout URI. - Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation. - When public registration is disabled, invitation-based password registration also requires a matching, non-expired `team_invitation` row; registration revalidates it under a row lock before creating the profile. +- Access tokens (`app.http.access-token`): personal tokens have JWT issuer `access-token` and DB type other than `mcp`; MCP tokens have issuer `urn:penpot:mcp-token` (`actoken/mcp-token-issuer`) and DB type `mcp`. Authentication requires issuer and type to agree, and the claim `uid` to match the row's profile; other issuers and undecryptable tokens are treated as anonymous. +- Personal tokens need flag `:access-tokens`. MCP tokens need `:mcp` or `:access-tokens`, and only on routes whose `actoken/authz` gets `::actoken/allow-mcp true` (the RPC routes); elsewhere (e.g. assets) they are anonymous. +- On RPC, an MCP token reaches only commands with `::rpc/token-type "mcp"` (`wrap-authentication` in `app.rpc`); every other command answers 403 `:token-auth-required`. A command with that metadata also rejects sessions and personal tokens. Currently only `send-mcp-feedback`. Blocking a profile invalidates sessions, not tokens, so such commands check `:is-blocked`/`:is-active` themselves. - HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/subtleties`. ## Permission model diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index 2d44a88b76..7a70eb4669 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -32,7 +32,7 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m RPC commands are defined with `app.util.services/defmethod` and schemas. Use `get-` prefixes for read operations. Command metadata usually includes auth, docs version, params schema, and result schema. Return plain maps/vectors or raise structured exceptions from `app.common.exceptions`. -Backend RPC command areas without focused memories include access tokens, binfile, demo, feedback, file snapshots, fonts, management, Nitrate, and webhooks beyond the notes in `mem:backend/auth-permissions-product-domains`; inspect nearby command tests and command metadata before changing them. +Backend RPC command areas without focused memories include access tokens (auth rules, incl. MCP tokens, in `mem:backend/auth-permissions-product-domains`), binfile, demo, feedback, file snapshots, fonts, management, Nitrate, and webhooks beyond the notes in `mem:backend/auth-permissions-product-domains`; inspect nearby command tests and command metadata before changing them. ## DB conventions diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index b6c3c9afb3..922c20bcb7 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -18,9 +18,10 @@ - The waiting counters are a field of the `ConcurrencyLimiter` component (a `ConcurrentHashMap` of name to `AtomicLong`), so they start at zero, die with the component, and the sampler that closes over that map republishes only its own names every 15 s; the sampler stops with the component in `halt-key!`. `impl-invoke` pairs one decrement with one increment through a per-call flag: the flag turns a second call into a no-op and the `finally` guarantees that there is one. The increment sits inside the `try` and the decrement lives only in the acquired callback and the `finally`, so a handler that throws, a rejection and a throwable from the publishing path all leave the count where it was and the thread without the limiter marked as held (a leftover mark would skip that limiter for that key for ever). Concurrent `inc`/`dec` publications of two threads can land out of order and the value can read low for a moment; the republish corrects it. - The `app.rpc.climit` log lines carry `queue`, `queue-max`, `permits`, `elapsed`, `code` and `reason`, and their `id` carries the key (`name/`). That is the only place where the profile behind a queue is visible: a pile-up is chased there, on demand, not in the metrics. The `queue` of the line is the calls inside the limiter minus its permits, so it reads 0 for a lone caller that times out: that line goes out at TRACE instead of WARN, and the rejection count has to come from the metric. - Nothing in the limiter path has a timeout unless the config sets one, and `psm/acquire` then calls `Semaphore.acquire`, which parks the calling thread forever. A parked thread still counts as in-system for every outer limiter of its chain, so the outermost one (the method's own by-profile limiter) stops granting permits and answers 429 to everything. A limiter without `:queue` gets an unlimited queue and therefore never rejects: its pile-up is silent, with no 429 and no log line except the WARN `enqueued`/`acquired` lines of the keyed id. -- Deadlock shape between a command's own limits and the root ones (fixed): a command declaring its own limits held `process-image/by-profile` before `root/by-profile`, while a command with no limits of its own takes the roots first and then re-enters with the same keyed limits from inside its handler (`create-file-media-object-from-url`, `upload-photo`) and waits for them holding a root permit. Two calls of one profile then held the same two limiters inverted and parked for good: 69 threads at 0 CPU, an audit limiter with 0 acquisitions in 24h and 100% 429s, on an instance that never served a 204. -- `climit/wrap` now appends the root limiters after the command's own, so `root/global`/`root/by-profile` end up outermost and every path meets them first; the nested `climit/invoke!` chains carry only the command's own limits and run inside them, which puts both paths in the same order. `impl-invoke` also keeps, per thread, the set of limiters that thread is inside of and runs the handler inline when a call asks for one it already holds, instead of queueing behind itself. That check relies on `pbh/invoke!` running the handler on the calling thread; the mark is removed in a `finally`. Commands declare the by-profile limit before the global one, which makes it the outermost of the pair: keep that declaration order, the reverse one would put two paths back in inverted order. The two nested call sites (`create-file-media-object-from-url`, `upload-photo`) still share `process-image` with the enclosing call instead of getting a limiter of their own, so the comment there about keeping the download out of that limit no longer holds. -- The shipped `backend/resources/climit.edn` sets a timeout on every limiter, 60s for operations that can be slow and 30s otherwise, with a test that fails when an entry ships without one, and `init-key` logs a WARN for every entry without a timeout. The production file lives outside the repository: mirror it there, and remember that a parked thread only goes away with a restart or a deploy. +- Deadlock shape between a command's own limits and the root ones (fixed): a command declaring its own limits held `process-image/by-profile` before `root/by-profile`, while a command with no limits of its own takes the roots first and then re-enters with the same keyed limits from inside its handler (`create-file-media-object-from-url`, `upload-photo`) and waits for them holding a root permit. Two calls of one profile then held the same two limiters inverted and parked for good: 69 threads at 0 CPU, an audit limiter with 0 acquisitions in 24h and 100% 429s, on an instance that never served a 204. Both commands declare their limits now, so the shape cannot happen any more: the root limits are taken last and every path takes the limiters in the same order. +- `climit/wrap` takes the command's own limits before the root ones: the root limiters are appended to the sequence first and the last element of the sequence is the outermost one, so `root/global`/`root/by-profile` end up innermost. A request waiting for its own limits no longer holds the root permits of its profile, which is what kept a pile-up of heavy requests from blocking the other commands of that profile. Every acquisition goes through that wrapper, so every path takes the limiters in the same order: there is no way to enter the limiter from inside a handler, and a command that calls another command internally would take the limiters twice (the second call waits for a permit the first one holds and answers 429 when the timeout runs out). The key of a limit is a function of the params (a keyword is looked up in them, a function is applied to them, any other value is the key itself). Commands declare the by-profile limit before the global one, which makes it the outermost of the pair, and a test fails when the relation between all the limits the commands declare cannot be sorted: two commands that take the same limits in opposite order, or a ring of three, leave the paths of one profile holding them in opposite order. +- The two commands that used to enter climit from inside their handler declare their limits now, so the whole command runs inside them: the download of an external image takes a `process-image` permit, and that limit has one permit per profile, so a slow download holds the image processing of that profile for as long as it takes. It takes the global permit of that limit too (8 permits, queue 20), so eight slow downloads of eight different profiles leave the image processing of every profile waiting. The copy of the body has no timeout (the 30 s of the request covers the connection and the headers), so a server that stops sending keeps the permits until the operating system gives up. The storage write of the profile photo runs inside its limit too. +- The shipped `backend/resources/climit.edn` sets a timeout and a queue on every limiter, 60s for operations that can be slow and 30s otherwise, with a test that fails when an entry ships without one, and `init-key` logs a WARN for every entry without a timeout. The queue matters because the root limits no longer bound the waiters: a command takes its own limits first, so a pile-up waits in the queue of that limit. The file and the declarations have to list the same ids: a `::climit/id` without an entry makes `wrap` log `no config found for specified queue` and run the handler with no limiter at all, and a test fails when a command declares an id the file does not list. Only key a limit by profile when the work contends on something shared (`update-file`, images, fonts, thumbnails, the profile row): a global cap on a cheap command adds a rejection path nobody needs, which is why `profile-plugin-ops` and `submit-audit-events` declare only `by-profile`. The production file lives outside the repository: mirror it there, and remember that a parked thread only goes away with a restart or a deploy. ## DB helpers @@ -94,10 +95,12 @@ - Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written. A pointer keeps its id while nothing changes; content (`assoc`/`without`) and metadata (`with-meta`) changes produce a fresh id, so a fragment row is never rewritten after it is written. Re-assoc with an identical value returns the same map in array and hash maps alike, so a save keeps the fragment id of every page it does not change. - `enable-objects-map` skips unloaded pointer pages: their fragment was already wrapped when it was written, and loading it only to wrap it again costs one page read per untouched page on every save. A page with a plain fragment is wrapped when it is first loaded or edited; a data migration that enables `fdata/objects-map` must rewrite the data itself. - Snapshots share fragment rows with the live file (they are stored with pointers, not materialized). `file-gc` marks every fragment referenced neither by the current main file nor by an unpurged snapshot; the file is read unrealized and unchanged pages keep their ids via reattach, so a pass over an unchanged file writes no new fragments. -- A save attaches its automatic snapshot before applying pending file migrations: the snapshot keeps the pre-migration pointers and migration set, and restoring it leaves the next read to migrate the file. +- A save attaches its automatic snapshot before applying pending file migrations: the snapshot keeps the pre-migration pointers, data and migration set, and restoring it leaves the next read to migrate the file. This also holds for files without the feature. The preview (`get-file-snapshot`) applies the pending migrations in memory with the live file's libraries and never persists them: the stored version keeps its data, the payload carries the snapshot's `:migrations` (an ordered set) and `fdata/objects-map` is realized for clients that do not declare it. The historical feature set is deliberately not checked against the enabled one: a version may predate a feature change. The window is narrow: the `get-file` RPC reads with `migrate?` on and its ETag hashes `available-migrations`, so a post-deploy read cannot be a 304 from the pre-deploy state and migrates before any save in the normal flow; the pre-migration snapshot only appears when the save is the first request to touch the file after the deploy (an open tab that saves without re-reading, or a non-UI writer). `create-file-snapshot` and the restore backup read with `migrate?` on, so those versions are stored already migrated. +- `:migrations` is always a `d/ordered-set`: `file-migrations/resolve-applied-migrations` builds one, `fmg/migrate` re-wraps the union after applying, and `file-snapshots/decode-snapshot` converts the `file_change` pgarray (as a plain vector, `set/difference` and `contains?` would compare indexes instead of names). A snapshot with NULL `:migrations` and an old `:version` predates the `file_change.migrations` column (SQL migration 0140): the preview regenerates the set from the stored version and applies that full chain, while `restore!` uses a fixed 67 baseline, so preview and restore can disagree for very old snapshots. - The migration read path (`binfile.common/migrate-file`) captures `fdata/original-pointers` before realizing, reattaches unchanged positions after applying the migrations, and persists the reattached file; it returns the realized file because reads expose plain data. A migration that adds `fdata/objects-map` skips the reattach so the pages get wrapped. - `fdata/load-pointer` reads rows that a previous `file-gc` pass marked on purpose (explicit `::db/remove-deleted false`): the fragment is only gone once `objects-gc` purges it. The `get-file-fragment` RPC keeps rejecting marked rows. -- Bind the fragment loader through `fdata/with-pointers` (reads), `fdata/with-tracked-pointers` (writes with a loader) or `fdata/with-tracked` (writes with nothing to load yet, e.g. file creation), and `fdata/with-untracked` (validation passes that must track nothing) instead of hand-rolled `binding` forms; `file-snapshots/reduce-snapshots` already binds its loader while walking snapshot content. +- Bind the fragment loader through `fdata/with-pointers` (reads), `fdata/with-tracked-pointers` (writes with a loader) or `fdata/with-tracked` (writes with nothing to load yet, e.g. file creation), and `fdata/with-untracked` (validation passes that must track nothing) instead of hand-rolled `binding` forms. Two direct bindings remain on purpose: the fine-grained tracked toggles of `app.rpc.commands.files-update` (validation/repair turn tracking off, restore the existing atom and read it before repairing), and `binfile.common/encode-file` (a fresh tracked set inheriting the current one). Converge them only if atom-scoped macros (`with-tracking`, `with-inherited-tracking`) are added; today the extra indirection buys nothing. +- Snapshot creation takes `FOR SHARE` on the file row: both `create-file-snapshot` and the backup read of `restore-file-snapshot` hold it from the read to the insert, so they serialize with a `file-gc` pass (`FOR UPDATE ... SKIP LOCKED`) instead of pinning fragments the pass is about to mark. `get-file-snapshot` realizes the snapshot pointers with the live file id (fragment rows are keyed by file, not by snapshot). - `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders. - A `file_data` row is one of three classes, told apart by `type`: `main` is the file's current state and carries `id = file_id`; `snapshot` is a point-in-time copy whose `id` is the one its `file_change` row shares; `fragment` is a piece of the pointer-map, referenced from the file's data. - Their lifecycles differ: `main` and `snapshot` live with the file (dated when it is deleted, undated when it is restored), while a `fragment` carries no date and is collected by `file_gc` once the file stops referencing it. Neither is a session buffer, so do not read a `fragment` as an ephemeral revision. diff --git a/.serena/memories/clojure/promesa.md b/.serena/memories/clojure/promesa.md new file mode 100644 index 0000000000..75cb5f6642 --- /dev/null +++ b/.serena/memories/clojure/promesa.md @@ -0,0 +1,9 @@ +# Promesa + +Promise library of the monorepo (frontend CLJS and common, `funcool/promesa` 12.0.1 in `common/deps.edn`). Its API has two call styles and mixing them in one expression is the trap: `fmap`/`mcat`/`merr` are **fn first, promise last** (`->>` friendly) and `then`/`then'`/`catch`/`handle` are **promise first, fn last** (`->` friendly). Convention: `fmap`/`mcat`/`merr` inside `->>` pipelines, `then`/`catch` with `->`. A `then` written inside a `->>` reads backwards and hides whether the step carries a value or a promise. +- `fmap` (alias `map`, both `(f p)`) is functor map: `f` returns a plain value. +- `mcat` (alias `mapcat`, both `(f p)`) is monadic bind: `f` must return a promise, and the result mirrors it. +- `then` also unwraps a promise returned by `f`, which is why it covers both cases at the cost of not naming which one it is; `then'` maps without unwrapping. +- `merr` (fn first) and `catch` (promise first) are the error side. +- `all` takes a collection of promises (a lazy seq works); `let` chains sequential bindings. +- Seam with the rx pipelines of the frontend: `beicon.v2.core/from` builds an observable from "arrays, collections, and promise instance" (`external/beicon`), so `(->> p (p/mcat f) (rx/from) (rx/map g))` is the usual bridge (`app.main.data.imports`, `app.util.zip`). diff --git a/.serena/memories/common/file-change-validation-migration-subtleties.md b/.serena/memories/common/file-change-validation-migration-subtleties.md index d013415730..7333faadc3 100644 --- a/.serena/memories/common/file-change-validation-migration-subtleties.md +++ b/.serena/memories/common/file-change-validation-migration-subtleties.md @@ -21,6 +21,7 @@ - Full referential/semantic validation currently runs only when file features contain `"components/v2"`. - Validation starts at root plus orphan shapes, then validates component records. `validate-file!` raises `:validation :referential-integrity` with collected details. - `repair-file` does not mutate data directly; it reduces validation errors into redo changes using `changes-builder`. Callers must apply or persist those changes. +- `repair-file-until-valid` stops on: no errors; unchanged `:data` after apply (backend page/`PointerMap` `=` is pointer identity); stable error fingerprint `(frequencies (map #(dissoc % :shape) errors))` vs previous pass (check fingerprint before `repair-file`/`process-changes`); or `max-iterations`. Each `repair-error` builds from the original `file-data`, so N `:set`-based errors on one shape take N passes. - `:missing-slot` fires only for a REAL swap: a copy sub-head whose `shape-ref` is no longer a child of the near main parent. A pure positional mismatch (ref still a sibling elsewhere) is a reorder — valid, realigned by the async component sync; do not "repair" it by assigning swap slots (a slot freezes the child out of normal sync). `fix-missing-swap-slots` (migration 0019) follows the same membership rule. ## Migrations diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index 5804c47212..28e1043431 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -8,6 +8,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. - Cross-cutting testing principles, TDD workflow, and anti-patterns: `mem:testing`. - Verified Clojure language behaviors that contradict common assumptions (e.g. `int?` covers `Long`; `integer?` is the general predicate): `mem:clojure/idioms` — read before assuming stdlib predicate semantics. - Read `mem:clojure/design-rules` (function shape, boundary checks, naming) before writing or modifying Clojure/ClojureScript code — not only when designing a solution or an API. +- Promises: which call style to use where (`fmap`/`mcat`/`merr` vs `then`/`catch`) and how they bridge to rx: `mem:clojure/promesa` — read before chaining promises or mixing them with observables. # Development workflow @@ -17,6 +18,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. - Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, "Note:" line) - Before a repo-wide pnpm version update → `mem:workflow/updating-pnpm` (workspace layout, `scripts/sync-pnpm-version` flow, the stamp-missing-field and ignored-builds gotchas, verification steps) - Before a Node version update → `mem:workflow/updating-node` (the six pin sites in `.nvmrc`, the devenv and image Dockerfiles and the plugins-deploy workflows, the DHI tag and digest rules, what to verify) +- Before bumping a non-JS dependency pinned in `docker/images/` (JDK, ImageMagick) → `mem:workflow/docker-images` - **Never `git push`, force-push, or modify `git origin`** (or any other remote). The user pushes from their own shell; if a push is required, say so and wait. Never amend a commit that the user has already pushed unless explicitly asked. - **Never edit `CHANGES.md` by hand.** The changelog is generated from GitHub milestones during the release process; update it only via the `update-changelog` skill flow or on explicit user request. - You have access to the GitHub CLI `gh` or corresponding MCP tools. @@ -25,6 +27,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. - Align `let` binding values: when a `let` form has multiple bindings spanning several lines, align the value forms to the same column with spaces. - If you introduce delimiter errors (mismatched parens/brackets) in Clojure/CLJS files, fix them with `scripts/paren-repair` BEFORE running lint/format checks. See `mem:scripts/paren-repair` for usage. - Never run anything that destroys data without explicit permission, including `drop-devenv`, `docker compose down -v`, `docker volume rm ...`. The user's real work lives in the volumes of the shared infra. +- **Never wait for background work with a sleep.** A long command moved to the background reports its own completion: do not poll it with `sleep 150` (or any other wait) inside a foreground command, and do not re-read its output file to check whether it finished. Keep working on something else, or end the response and wait for the notification. # Project modules @@ -61,7 +64,7 @@ The memory is structured in a way that you can get the critical information abou - `scripts/error-reports.mjs` — Query error reports via RPC API with token authentication. Supports list/get operations with filtering and pagination. See `mem:scripts/error-reports`. - `scripts/clean-node-modules` — Remove stale `node_modules` from all pnpm workspaces (root, modules, member packages). Keeps the shared pnpm store at `/.pnpm-store` unless `--store`; ignores `external/` and `.opencode/`. Usage and reinstall steps: `mem:workflow/updating-pnpm`. - `scripts/setup-opencode` — Install the opencode CLI in-container as a global pnpm package in `$PNPM_HOME`. Not part of the devenv image; see `mem:devenv/core`. -- `scripts/ci` — CI orchestration script: runs lint, tests, and format checks per module (`frontend backend common render-wasm exporter mcp plugins library`). Logs go to `.ci-logs/`; read the log file on failure. It is the preferred way to run the backend tests, and it exports `PENPOT_FLAGS` with `enable-backend-asserts`, which is what makes `clojure.core/assert` exist in the compiled test code. See `mem:scripts/ci`. +- `scripts/ci` — CI orchestration script: runs lint, tests, and format checks per module, plus the production build on request (`--build`). Modules: `frontend backend common render-wasm exporter mcp plugins library media-processor`. Logs go to `.ci-logs/`; read the log file on failure. It is the preferred way to run the backend tests, and it exports `PENPOT_FLAGS` with `enable-backend-asserts`, which is what makes `clojure.core/assert` exist in the compiled test code. See `mem:scripts/ci`. # Dependency graph diff --git a/.serena/memories/frontend/dashboard-viewer-subtleties.md b/.serena/memories/frontend/dashboard-viewer-subtleties.md index 9c753d8875..fd5df5962b 100644 --- a/.serena/memories/frontend/dashboard-viewer-subtleties.md +++ b/.serena/memories/frontend/dashboard-viewer-subtleties.md @@ -5,7 +5,7 @@ - Dashboard initialization fetches projects and fonts for the team, then listens to websocket messages only for global topic `uuid/zero` or the current profile id. - Project fetch replaces each project map completely instead of merging, so fields such as `deleted-at` can disappear cleanly. - Dashboard file/project mutations are often optimistic local updates with fire-and-forget RPC watchers. Bulk permanent delete/restore paths use SSE progress and progress notifications. -- The import wizard runs the package as a job of the backend: it reads the manifest of the file in the main thread (`app.main.data.imports`, no web worker) and follows the job, so its rows paint the milestone of the job (the stage and its counter, with the file in course) instead of the messages the old SSE stream sent. Before the job exists, the rows paint a client-side `:upload` milestone built from the chunked upload (`uploads/upload-blob-chunked` `:on-progress`, chunks done/total), which never comes from the backend; the chunks go one at a time and the row writes them as a percentage of the file, not as a counter. Once created and still waiting for a worker, each entry reports `:queued` ("Queued"); the `:start`/`:retry` events move it to bare progress, and only `:progress` carries a milestone. The export modal follows the same one-job-per-file model, but every file is queued up front (even the ones whose job is created only after the previous one is over) and its queue ends only with a milestone: `:start`/`:retry` do not end it there. Neither modal paints a generic "…ing file…" line any more: each row carries its own status. The `:error` of an import entry is user-facing text (the backend hint, or a translated label), never an i18n key: the wizard renders it as-is, it does not translate it again. The link-later step takes the file id from the `:id` of its resolution entry, never from the key of the resolution map: the map comes from the job result, so what a key holds depends on the result schema of the job-def that wrote it, while the wizard indexes its selection by the id of the entry. The legacy SSE import stays in the backend for other clients. +- The import wizard runs the package as a job of the backend: it reads the manifest of the file in the main thread (`app.main.data.imports`, no web worker) and follows the job, so its header paints the milestone of the job (the stage and its counter) instead of the messages the old SSE stream sent; every file of a container reports the same milestone, so `jp/current-milestone-text` writes it once and the rows never repeat it. Before the job exists, the header paints a client-side `:upload` milestone built from the chunked upload (`uploads/upload-blob-chunked` `:on-progress`, chunks done/total), which never comes from the backend; the chunks go one at a time and it writes them as a percentage of the file, not as a counter. Once created and still waiting for a worker, no row says anything: the header says the import waits for a slot while no job of it has started. The bar of the header measures the whole import, never a file: indeterminate while checking, waiting for a slot or rebuilding, because the client cannot know a global index and total of operations. The upload is the exception — the chunked upload knows its chunks, so the bar is determinate while the containers are sent, weighted by their size (`imp/upload-pct` over `:container-size`, the only percentage the client really knows). The export modal follows the same one-job-per-file model, but every file is queued up front (even the ones whose job is created only after the previous one is over) and its queue ends only with a milestone: `:start`/`:retry` do not end it there. Neither modal paints a generic "…ing file…" line on the rows any more: the rows of the import wizard carry only what is not progress — the name, the size, `Ready` once their container finished uploading, and the error text —, and the button that drops a file only shows while the wizard still offers its import step (the confirmation screen). Above the rows, the import wizard paints a stepper (Check/Upload/Import) plus one phase line: `imp/import-step` derives the step from the dialog status and the entries (upload holds while any file is pending, either `:import-ready` or carrying its own `:upload` milestone), so Check covers analyze, Upload covers the chunked client-side upload, and Import covers queue plus job. A file of a version 3 package shows what it occupies inside its container, computed while analyzing (`imp/file-sizes`): its own `files/…` entries plus the storage objects its media and thumbnails reference, whose metadata is the only link to `objects/…`. The container's own size stays in `:container-size` (what the dropped file weighs) and is what the upload bar weighs, never what a row shows. A file the manifest lists but the container holds no entry of has no size at all, and its row then shows no subtitle: what the package weighs never stands in for it. The subtitle is the size alone, with no extension. While analyzing with no failures nothing renders below the phase header — no rows, no spinner (`imp/awaiting-analysis?`); the header text is the only "Checking…" message. Rows appear once the analysis resolves (one per file inside the package), and a failed analysis takes the error-summary path instead. The ready step and its Continue wait for the end of the analysis (`imp/ready-to-import?`, fed with the completion of `imp/analyze`): a ready file is not enough, because the files still to arrive would never be imported. While rebuilding, the phase title counts the files and `imp/imported-count` counts them done with the job's own `:files` counter, so the number moves while a container rebuilds its files one by one. The `:error` of an import entry is user-facing text (the backend hint, or a translated label), never an i18n key: the wizard renders it as-is, it does not translate it again. The link-later step takes the file id from the `:id` of its resolution entry, never from the key of the resolution map: the map comes from the job result, so what a key holds depends on the result schema of the job-def that wrote it, while the wizard indexes its selection by the id of the entry. The legacy SSE import stays in the backend for other clients. - File creation/duplication strips file `:data` before putting file summaries into dashboard state. ## Viewer @@ -13,6 +13,6 @@ - `app.main.ui.ui` parses the viewer's `file-id` query param with `uuid/parse*`, so a missing or malformed id arrives as nil. `viewer-page*` calls `check-file-id!` (same namespace) during render, before mounting `viewer*`: it raises `ex/raise :type :not-found :code :missing-file-id`, which the error boundary turns into the not-found page. Without the guard the `dv/initialize` assertion fails on the nil file-id and the user gets an internal-error page. Test: `frontend-tests.ui.viewer-test/check-file-id-raises-not-found-for-invalid-file-id`. - Viewer initialization sets `:current-file-id`, `:current-share-id`, and `:viewer-local`, then fetches the view-only bundle. Comment threads are fetched only for logged-in users. - Viewer bundle fetch sends the full supported feature set because anonymous shared viewers may not know team-enabled features. -- View-only bundles can contain pointer values in `:pages-index` and file data. Viewer resolves those fragments with `:get-file-fragment` before storing the bundle. +- View-only bundles can contain pointer values in `:pages-index` and file data. Viewer resolves those fragments with `:get-file-fragment` before storing the bundle, through `app.main.features.pointer-map/resolve-pointer` (share-link scoped; the workspace and library loaders use `resolve-file`, which resolves the same two levels). Both fail loudly: a fragment that cannot be resolved rejects the stream and is logged with file id and fragment id instead of silently dropping the page. - `bundle-fetched` indexes pages and precomputes viewer frames/all-frames, stores libraries/users/thumbnails/permissions under `:viewer`, then navigates to frame id, query index, or auto-selected frame. - Viewer zoom and interaction mode changes update both `:viewer-local` and the `:viewer` route query params. `update-zoom-querystring` guards with a query-param comparison (`not= current expected`) and uses `::rt/replace`; without that guard the load sequence (`bundle-fetched` → `zoom-to-fill` → `update-zoom-querystring` → `rt/nav`) re-runs on every navigation and crashes with React "maximum update depth exceeded" (2.18.0-RC5 regression, fixed in `31b73460c3`; regression test: `bundle-fetched-with-zoom-fill-url-does-not-navigate`). \ No newline at end of file diff --git a/.serena/memories/frontend/ui-conventions-and-style-system.md b/.serena/memories/frontend/ui-conventions-and-style-system.md index 1161b55d3a..59390c27e7 100644 --- a/.serena/memories/frontend/ui-conventions-and-style-system.md +++ b/.serena/memories/frontend/ui-conventions-and-style-system.md @@ -12,7 +12,7 @@ - Co-located SCSS modules are preferred. Use `app.main.style/stl` helpers from CLJS and design-system SCSS tokens/mixins instead of legacy global selectors or high-specificity nesting. - Keep CSS specificity low. Avoid nested selectors unless they target elements the component owns; CSS Modules already prevent class-name collisions. -- Prefer CSS logical properties for directional spacing/layout (`padding-inline-start`, etc.). Physical `width`/`height` are still acceptable where they are clearer. +- Prefer CSS logical properties for directional spacing/layout (`padding-inline-start`, etc.). Physical `width`/`height` are still acceptable where they are clearer. Never mix: a stylesheet logical property (`inline-size`) and an inline physical `width` on the same element cascade separately, and the logical mapping wins — a dynamically driven width must be `width` on both sides (import modal progress bar hit this). And an inline style passed from CLJS must be a JS object (`#js {:width …}`): for plain `[:div …]` elements the Rumext macro does not convert the `:style` map at runtime — a plain CLJS map reaches React raw and React never finds the keys (static style literals are converted at compile time, which is why `:style` maps seem to work elsewhere). - Use named design-system variables/tokens for spacing, borders, fixed dimensions, colors, and typography. Avoid hardcoded px/rem values and deprecated `resources/styles/common/refactor/spacing.scss` variables. - Use component-local CSS custom properties for variants and theming instead of one-off Sass variables when a component has multiple visual states. - Prefer DS typography components (`heading*`, `text*`) and typography mixins instead of plain text wrappers or deprecated typography mixins. diff --git a/.serena/memories/mcp/core.md b/.serena/memories/mcp/core.md index 04bfef910e..dbc4b85b92 100644 --- a/.serena/memories/mcp/core.md +++ b/.serena/memories/mcp/core.md @@ -13,19 +13,26 @@ enabling advanced AI-driven features in Penpot. - Build Tool: TypeScript Compiler (tsc) + esbuild - Package Manager: pnpm -## General Principles +## Principles & Code Style IMPORTANT: Use an idiomatic, object-oriented style. -In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions -rather than mere functions (i.e. use the strategy pattern, for example). +* You keep each concern in exactly one home. + A mechanism whose parts are only correct in combination is implemented as a single component/class, and its parts are private to it; + expose a minimal public surface. Java-like encapsulation: If helper functions and constants are only used by one abstraction, + make them internal to the respective class. +* For any non-trivial interfaces, you use functions/methods that expect explicitly typed abstractions + rather than mere functions (i.e. use the strategy pattern, for example). +* Prefer declaring and instantiating concrete classes over defining only interfaces and using object literals. Comments: -When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase -clearly defines *what* it is. Any details then follow in subsequent sentences. +* When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase + clearly defines *what* it is. Any details then follow in subsequent sentences. +* When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless + the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is + required for sentences). -When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless -the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is -required for sentences). +Syntax: +* Never use if blocks (or other blocks) without braces, even for single-line cases. ## Project Structure (Excerpt) @@ -102,6 +109,19 @@ From the `mcp/` directory, run stdout directly. `NODE_TEST_CONTEXT` is the env var `node --test` sets. * Cross-cutting testing principles and anti-patterns: `mem:testing`. +## OAuth for remote MCP clients + +- Authorization/discovery URLs, self-contained client IDs, consent and PKCE/code binding, backend Redis state, deployment wiring, and compatibility limits: `mem:mcp/oauth`. + +## Backend tools (`submit_feedback`) + +- Tools that call the Penpot backend directly (not through the plugin) register only when `PENPOT_MCP_BACKEND_URI` is nonblank (`PenpotMcpServer.initTools`). Currently only `SubmitFeedbackTool`. +- `PenpotBackendClient` owns the HTTP call and the status-to-message mapping; it posts to `api/main/methods/send-mcp-feedback` with `Authorization: Token `. The token comes from the session context (OAuth bearer or `?userToken=`); without one the tool fails before calling the backend. +- Tool zod schemas mirror the backend malli schema (lengths, blank, control characters); keep both in sync. The backend is the authority. +- Success means the mail job was queued, not delivered; the tool text says so. `formatArgs` hides the feedback from logs. +- Backend side: `send-mcp-feedback` in `app.rpc.commands.feedback`, configured by `PENPOT_MCP_FEEDBACK_EMAIL`; MCP-token auth rules in `mem:backend/auth-permissions-product-domains`. +- User docs: `mcp/README.md` (env tables), `docs/mcp/index.md` (tool list), `docs/technical-guide/configuration.md` (MCP feedback section). + ## Devenv plugin/server wiring In the normal Penpot devenv MCP path, the browser plugin does not discover or route through Postgres. The frontend provides the plugin extension API with `mcp.getServerUrl()`, currently derived from `frontend/src/app/config.cljs` as `penpotMcpServerURI` if set, otherwise `/mcp/ws`. The MCP plugin opens a direct WebSocket to that URL and appends the current MCP access token as a query parameter. diff --git a/.serena/memories/mcp/oauth.md b/.serena/memories/mcp/oauth.md new file mode 100644 index 0000000000..110372cb4c --- /dev/null +++ b/.serena/memories/mcp/oauth.md @@ -0,0 +1,47 @@ +# MCP OAuth + +## Contract and scope + +- MCP-specific authorization; existing Penpot login identifies the user on the approval page. No application vetting or persistent consent policy. Access requires user approval; registration alone grants nothing. +- Return the existing fixed MCP user token. On approval, enable `:mcp-enabled` and create the token only if no usable one exists; preserve an existing usable token. A token is unusable, and replaced, when expired (settings can give MCP tokens an expiry), when its JWT issuer is not `urn:penpot:mcp-token` (tokens issued before MCP tokens had their own issuer), or when this instance cannot decrypt it (e.g. secret key changed); the backend rejects all three. No refresh token, `expires_in`, or new token lifetime. +- No new client database table or schema migration. Client IDs must remain usable across separate authorization flows without server-side registration storage. Do not replace them with expiring Redis registrations. +- MCP transport continues matching user tokens to connected Penpot plugins; no new per-request token introspection or independent semantic validation. The logged-in Penpot instance remains the content-access boundary. Do not silently add resource-server validation or change token semantics. +- README scope: user/developer usage instructions; document `PENPOT_MCP_AUTH_URI` in its environment-variable table. Keep OAuth architecture and protocol details here and non-obvious invariants in source comments. + +## Deployment and discovery + +- MCP process: `PENPOT_MCP_AUTH_URI=https://penpot.example/api/mcp/oauth` with `--multi-user`. Unset/empty disables OAuth; configured OAuth requires multi-user. Absolute HTTPS, or HTTP on loopback for development; no userinfo, query, or fragment. Invalid configuration fails startup; startup logs OAuth status. +- Devenv: `mcp/scripts/start-mcp-devenv-oauth` starts multi-user mode with `PENPOT_MCP_AUTH_URI` derived from `PENPOT_PUBLIC_URI`. Manual opt-in only: agentic tooling needs the single-user `start-mcp-devenv`. +- Public MCP transport: `/mcp/stream`, proxied to Node `/mcp`. Missing credentials with OAuth enabled: 401 plus `WWW-Authenticate` referencing `/resource` and scope `mcp`. +- Bearer header takes precedence over legacy `?userToken=`. Malformed headers do not fall back to the query token. Existing query-token clients remain supported. +- Protected-resource metadata: `/api/mcp/oauth/resource`; issuer: `/api/mcp/oauth`; authorization-server metadata: `/.well-known/oauth-authorization-server/api/mcp/oauth`. All reach the backend. Bundled devenv/production nginx configs forward the well-known path; custom proxies must do likewise. Keep Penpot public URI and external URLs consistent. +- Node implementation: `mcp/packages/server/src/McpAuthentication.ts`, integrated by `PenpotMcpServer.ts`; OAuth-enabled tools advertise `_meta.securitySchemes`. + +## Client identification + +- `backend/src/app/mcp/oauth_clients.clj`: dynamic registration at `/api/mcp/oauth/register`, HTTPS client metadata documents (CIMD), and registrations obtained in advance via `(app.mcp.oauth-clients/register! {} metadata)`. +- DCR returns `penpot-mcp-v1.` plus unpadded base64url JSON: normalized callback URLs, optional name, authentication method, random nonce, optional secret hash. Fixed grant/response/scope values are reconstructed. Nonce distinguishes registrations with otherwise identical metadata. +- IDs are public, unsigned, unencrypted, non-expiring metadata; validate on every resolution. Client secrets are generated randomly and returned separately; only the hash enters the ID. No signing/encryption key or client record in PostgreSQL/Redis. Old experimental database-backed UUID IDs require registration again. +- An edited payload changes the ID. Authorization-code exchange compares the exact ID string against the approved request before resolving metadata. Do not replace this with comparison of decoded fields; code ownership and PKCE binding must survive metadata edits. +- Supported authentication: `none`, `client_secret_basic`, `client_secret_post`; no `private_key_jwt`. CIMD selects public-client `none` from the document's supported methods; other listed methods do not imply support here. Scope `mcp`; grant `authorization_code`; response `code`. CIMD may advertise additional grants (e.g. Codex lists `refresh_token`); require `authorization_code` support and normalize to the grant Penpot implements, rather than reject broader client capabilities. +- Entire ID capped at 2,048 ASCII characters. DCR returns `invalid_client_metadata` if encoded metadata exceeds this; shorter/fewer callback URLs reduce size. OAuth defines no fixed ID-length maximum (RFC 6749 §2.2); stateless IDs are explicitly described by RFC 7591 §A.5.2. Installed MCP client SDK 2.3.1 accepts a 2,048-character ID; this does not establish Codex/Claude compatibility. +- Redirect matching is exact except loopback HTTP port changes for native clients (`127.0.0.1`, `[::1]`, exact `localhost`). Host, path and query must still match; reject fragments/userinfo and lookalike localhost domains. Allow HTTPS, loopback HTTP, and reverse-domain native schemes. +- CIMD: fetch with existing SSRF-checked `app.http.client/req`, no redirect following, require matching document `client_id`, bounded 64-KiB body and request/body-read timeouts. Cache max 256 entries/one minute, further limited by HTTP max-age; no-store/no-cache/no max-age means no reuse. + +## Approval and exchange + +- `backend/src/app/http/mcp_oauth.clj`: separate router branch from ordinary RPC middleware; preserve OAuth snake_case JSON and form encoding. Session auth applies to approval request/decision only. Public metadata/register/token endpoints allow noncredentialed CORS; MCP transport CORS was not added. +- Errors: every failure answers with an OAuth `{"error": ...}` body, never the shared Penpot error shape. Failures without `:oauth-error` are also passed to `app.http.errors/handle` for logging only; its response is discarded. `wrap-errors` also wraps the session middleware of `/request` and `/decision`. Safe to log because these routes never set request `:params`. +- `/authorize`: validate client/callback, `code`, PKCE S256, scope/resource/state; store pending request for 600 seconds; redirect to `?screen=mcp-authorize&request=`. +- `frontend/src/app/main/ui/mcp_authorize.cljs`: existing login with return URL; show application and account details; submit explicit Allow/Cancel. No fixed MCP token in frontend state or browser redirect. Approval refreshes profile and broadcasts MCP enablement; does not connect a plugin automatically. +- `/request`: first authenticated read binds request to profile plus hashed login-session ID and CSRF value, using compare-and-replace. Re-read after the attempt: concurrent GETs may win/lose binding; only the matching session may proceed. +- `/decision`: check exact Origin, session binding and CSRF, then atomically consume the request. Denial creates no token/enables nothing. Approval calls transactional `access-token/ensure-mcp-access!`, then stores code for 120 seconds. Code state holds token row ID, never the fixed token. Callback echoes OAuth `state` and RFC 9207 `iss`. +- `/token`: validate grant, exact client ID/callback/resource and PKCE; resolve current client metadata/authentication method and verify any secret; atomically consume validated code; fetch the unexpired token by original row ID/profile/type. Regeneration deletes the old token row, so a pending code cannot silently yield the replacement token. Return fixed `access_token`, `token_type: Bearer`, scope `mcp`. +- `backend/src/app/mcp/oauth_state.clj` and Lua: tenant/kind/hash-prefixed keys; random handles; SET NX EX must succeed before returning handle. Reads retain raw JSON for atomic comparison. Consume/delete and bind/replace compare the validated raw record in Lua; replacement preserves TTL. Redis failures fail closed. Backend Redis is required independently of Node's optional Redis plugin bridge. +- `backend/src/app/rpc/commands/access_token.clj`: `ensure-mcp-access!` decodes the stored token with `ex/ignoring` to apply the usable-token rule above. Lock profile row in both MCP generation and OAuth provisioning, including when no token exists. Serializes simultaneous approvals and settings regeneration. Provisioning must run within a transaction. + +## Verification and limits + +- Tests: `mcp_oauth_clients_test.clj`, `mcp_oauth_http_test.clj`, `mcp_oauth_state_test.clj`, OAuth provisioning in `rpc_access_tokens_test.clj`; MCP authentication/HTTP tests; frontend approval tests. Cover no-storage registration, size boundaries, edited-ID rejection, code replay/concurrency, session/CSRF binding, and fixed-token reuse. +- Live OpenAI/Codex/Claude compatibility and marketplace acceptance remain unverified. In particular, verify token persistence without `expires_in` and acceptance of the existing matching-only resource boundary; do not claim protocol conformance from SDK tests alone. +- Protocol references: https://www.rfc-editor.org/rfc/rfc6749.html#section-2.2 ; https://www.rfc-editor.org/rfc/rfc7591.html#appendix-A.5.2 ; https://www.rfc-editor.org/rfc/rfc7636.html ; https://www.rfc-editor.org/rfc/rfc9207.html . diff --git a/.serena/memories/memory-maintenance.md b/.serena/memories/memory-maintenance.md index fbb800b99c..67a480a770 100644 --- a/.serena/memories/memory-maintenance.md +++ b/.serena/memories/memory-maintenance.md @@ -26,6 +26,7 @@ No manual line wrapping: one line per bullet or paragraph, however long. Memorie ## Add/update threshold Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future. +Do not restate what the code already says (comments included): keep only what code cannot represent — decisions and their reasons, cross-file invariants, ordering constraints, environment and tool behavior, and traps that break builds. Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon. ## Maintenance Actions diff --git a/.serena/memories/scripts/ci.md b/.serena/memories/scripts/ci.md index b6e722ec17..be509b7a22 100644 --- a/.serena/memories/scripts/ci.md +++ b/.serena/memories/scripts/ci.md @@ -1,8 +1,9 @@ # CI (scripts/ci) -`scripts/ci` runs CI-style checks — lint, tests, format — for one or more -monorepo modules and prints a per-task summary. It is the local equivalent -of CI; use it to verify changes before declaring work done. +`scripts/ci` runs CI-style checks — lint, tests, format — and, on request, +the production build of each module, for one or more monorepo modules, and +prints a per-task summary. It is the local equivalent of CI; use it to +verify changes before declaring work done. It also exports `PENPOT_FLAGS` with `enable-backend-asserts` appended (keeping whatever the caller already set), which is what puts `clojure.core/assert` @@ -25,6 +26,7 @@ Run from the repo root: ```bash ./scripts/ci MODULE... # lint + test + fmt per module +./scripts/ci --build MODULE # production build only ./scripts/ci --all --no-test # lint + fmt on all modules ./scripts/ci --lint frontend # lint only ./scripts/ci --fix --no-test frontend # format files, skip tests @@ -32,14 +34,15 @@ Run from the repo root: ./scripts/ci --dry-run --all # preview what would run ``` -Modules: `frontend backend common render-wasm exporter mcp plugins library`. +Modules: `frontend backend common render-wasm exporter mcp plugins library +media-processor`. Flags: - Default tasks: `lint`, `test`, `fmt` (format check; `--fix` formats - instead). -- `--lint` / `--test` / `--fmt` run one task only; `--no-lint` / - `--no-test` / `--no-fmt` drop one task from the default set. + instead). Build is **not** in the default set. +- `--lint` / `--test` / `--fmt` / `--build` run one task only; `--no-lint` / + `--no-test` / `--no-fmt` / `--no-build` drop one task from the default set. - `--paren-repair` runs only the delimiter repair — it wraps `scripts/paren-repair` over each module's Clojure/CLJS sources; see `mem:scripts/paren-repair`. @@ -48,6 +51,49 @@ Flags: output; `--dry-run` prints commands without running; `--clean` removes the log directory. +## Build task + +`--build` compiles each module the way production does, reusing caches +(`.shadow-cljs`, `target/`, `node_modules`) instead of clearing them. It +does not package bundles: `manage.sh build-bundle` remains the way to +produce deployable artifacts. + +| Module | Command | Output | +|---|---|---| +| `frontend` | `NODE_ENV=production pnpm run build:app:main && pnpm run build:app:libs && pnpm run build:app:assets` | `frontend/resources/public/`, `frontend/target/` | +| `backend` | `clojure -T:build jar` | `backend/target/penpot.jar` | +| `common` | *(none; skipped)* | — | +| `render-wasm` | `./build` (targets `frontend` and `export`) | `render-wasm/target/` | +| `exporter` | `NODE_ENV=production pnpm run build:app` | `exporter/target/` | +| `mcp` | `pnpm run build` | `mcp/*/dist` | +| `plugins` | `pnpm run build:runtime && pnpm run build:plugins && pnpm run build:styles-example && pnpm run build:doc` | `plugins/dist/` | +| `library` | `NODE_ENV=production clojure -M:dev:shadow-cljs release library` | `library/target/library/` | +| `media-processor` | `pnpm run types:check && pnpm run build` | `media-processor/dist/` | + +- Every output path above is gitignored, so a build leaves `git status` + clean. +- The module `build` scripts in each `scripts/build` are **not** used: they + delete `node_modules`, wipe `resources/public` and run `pnpm install`, + which belongs to the bundle pipeline, not to a local check. +- `frontend` and `library` have a `build` script that clears the shadow-cljs + cache first; the task calls the inner commands to stay incremental. + `exporter` uses `build:app` for the same reason. +- `common` has no artifact of its own: its compilation is exercised by its + `test` task and by the builds of `frontend`, `backend` and `exporter`. +- `media-processor` is the ninth module. It has no lint, so that task shows + as skipped; its build runs `tsc --noEmit` before esbuild because esbuild + does not type-check. +- Cold-cache cost measured on the devenv: `library` ~120 s, `frontend` ~120 s, + `exporter` ~80 s, `backend` ~35 s. With warm caches the whole set (8 + builds) takes ~4 min. The first run of `clojure -T:build jar` downloads + `tools.build` if it is not in `~/.m2`. +- `pnpm run` may trigger a dependency check/install when `node_modules` + drifts from the lockfile (`verify-deps-before-run`), so the first run + after a dependency change can need network. +- `render-wasm/build` takes a lock (`_build_lock`): never run two builds at + once in the same checkout. + + ## Logs and exit codes - Full output of every task: `.ci-logs/-.log`. @@ -59,12 +105,16 @@ Flags: ## Notes -- `mcp` has no lint task (shows as skipped). `render-wasm` uses `./lint`, - `./test`, and `cargo fmt`. +- `mcp` has no lint task and `media-processor` has no lint task; both show + as skipped. `render-wasm` uses `./lint`, `./test`, and `cargo fmt`. +- `scripts/test_ci.py` (unittest, `python3 scripts/test_ci.py`) covers the + flag wiring, one build command per module and the help text through + `--dry-run`; it executes no real build. - Exports `PENPOT_FLAGS` with `enable-backend-asserts` appended to any existing value, so backend test runs compile in and exercise precondition asserts; see `mem:backend/testing`. -- Test tasks are long-running (backend: `clojure -M:dev:test`); use a - generous timeout when calling it from an agent shell. +- Test and build tasks are long-running (backend: `clojure -M:dev:test`; + frontend build: ~2 min warm); use a generous timeout when calling it from + an agent shell. - Skill entry point: `.agents/skills/local-ci/SKILL.md`. - Testing principles and output discipline: `mem:testing`. diff --git a/.serena/memories/workflow/docker-images.md b/.serena/memories/workflow/docker-images.md new file mode 100644 index 0000000000..28d42bc9e7 --- /dev/null +++ b/.serena/memories/workflow/docker-images.md @@ -0,0 +1,6 @@ +# Non-JS dependencies in the production images + +Node and pnpm pins: `mem:workflow/updating-node` and `mem:workflow/updating-pnpm`. Only the parts the Dockerfiles cannot show: + +- JDK: the image tracks a non-LTS line, so there is no minor to pick — the last build of the line is the ceiling; moving line is a major and needs a real build to validate the `jlink` module list. +- ImageMagick: publish `penpotapp/imagemagick:` **before** bumping the `COPY --from` refs in the backend, exporter and devenv Dockerfiles; otherwise both builds break on the missing tag. diff --git a/.serena/memories/workflow/updating-node.md b/.serena/memories/workflow/updating-node.md index a1359bb93f..f8b803d9b5 100644 --- a/.serena/memories/workflow/updating-node.md +++ b/.serena/memories/workflow/updating-node.md @@ -5,7 +5,7 @@ No single source of truth: nothing reads `.nvmrc` at build time, and the version ## Pin sites - `.nvmrc` → `v`, with the leading `v`. -- `docker/devenv/Dockerfile`, `setup-node` stage → `ENV NODE_VERSION=v`; downloads `https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-{arm64,x64}.tar.gz`, one tarball per arch, and verifies each against a `NODE_SHA256` right below its `BINARY_URL`. Both hashes come from `SHASUMS256.txt` in that same dist directory, so a bump must recompute the two. +- `docker/devenv/Dockerfile`, `setup-node` stage → `ENV NODE_VERSION=v`; downloads `https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-{arm64,x64}.tar.gz`, one tarball per arch, and verifies each against a `NODE_SHA256` right below its `BINARY_URL`. Both hashes come from `SHASUMS256.txt` in that same dist directory, so a bump must recompute the two; only the arch SHAs change, the structure stays. - `docker/images/Dockerfile.mcp` → two stages: `FROM dhi.io/node:-debian13-dev AS build` and `FROM dhi.io/node:-debian13 AS image`. The release stage carries no `-dev`. - `docker/images/Dockerfile.media-processor` and `docker/images/Dockerfile.exporter` → single `FROM dhi.io/node:-debian13-dev`. - `.github/workflows/plugins-deploy-api-doc.yml` and `.github/workflows/plugins-deploy-styles-doc.yml` → `runtime: node@` in the `pnpm/setup` step. Its `working-directory: plugins` makes pnpm's own version come from the manifest, so this pin is the Node one; keep it equal to `.nvmrc` minus the `v`. @@ -18,6 +18,7 @@ No single source of truth: nothing reads `.nvmrc` at build time, and the version - Node 24.x is the Krypton LTS line: take the newest patch of that major unless a major bump is the point. - Renovate also opens these bumps, on `renovate-*` branches and with its config living outside this repo; a manual bump and Renovate's must not diverge on any of the six sites. - A backport merge can deliver a newer Node into `docker/` and the plugins-deploy workflows while main's `.nvmrc` stays behind, because main receives partial backports of `.github/` and `docker/` from develop. Merging main into staging therefore conflicts on the pin lines: take main's set and finish the bump (`.nvmrc` plus the `packageManager` fields via `mem:workflow/updating-pnpm`), or the six sites drift. +- The newer version wins in both directions. A maintenance→main merge (`main-2.18.x` into `main`) can carry the newer Node while `.nvmrc` and the `packageManager` fields stay on the older pair; then adopt the newer set and finish the bump on all six sites plus the 35 `packageManager` fields. - Both workflows must stay valid YAML. The `python3` in the devenv has no `yaml` module, so validate with `uv run --with pyyaml python3 -c ...` instead of a bare import. ## Verification diff --git a/.serena/memories/workflow/updating-pnpm.md b/.serena/memories/workflow/updating-pnpm.md index c8455ff680..4a7f5eec7e 100644 --- a/.serena/memories/workflow/updating-pnpm.md +++ b/.serena/memories/workflow/updating-pnpm.md @@ -27,8 +27,8 @@ file (never pipe tool output through filters). members) must carry a `packageManager` field with the identical `pnpm@+sha512.` value. Do not let them drift. `external/` (vendored trees with their own lifecycles), `.opencode/`, - `.pnpm-store/`, `bundles/` and `docker/images/bundle-*` copies are - never stamped. + `.pnpm-store/`, `bundles/`, `target/` build output and + `docker/images/bundle-*` copies are never stamped. - pnpm is a system binary everywhere (devenv image, CI runners, Docker images). Nothing may call corepack: it is gone from Node 25+. pnpm auto-downloads the `packageManager` version on mismatch @@ -71,6 +71,7 @@ file (never pipe tool output through filters). 4. Run `scripts/sync-pnpm-version --install` (plain `pnpm install` in each of the 11 workspace roots) to refresh lockfile metadata, then fix any failing workspace (see gotchas) and re-run that directory. + For a version-only bump, `pnpm install --lockfile-only` in each root is enough: it rewrites only the pnpm self-reference entries and needs no node_modules. The repo-root `pnpm-lock.yaml` is rewritten by itself on the first `pnpm` call that self-installs the target version. ## Gotchas @@ -96,6 +97,9 @@ file (never pipe tool output through filters). section in lockfiles last written by older pnpm. Large diffs mean re-resolution; inspect them before accepting. - A backport merge of main into staging can bring a newer `PNPM_VERSION` in `docker/` while every `packageManager` field stays behind, since main receives partial `.github/`+`docker/` backports from develop. Resolve those conflicts with main's version and run `scripts/sync-pnpm-version ` in the same merge. +- Direction does not decide the winner, the version does. A maintenance→main merge (`main-2.18.x` into `main`) can carry the newer Node and pnpm pins in `docker/` and the `plugins-deploy-*` workflows while main's `.nvmrc` and 35 `packageManager` fields stay on the older pair. Take the newer pair on every pin site (`docker/`, workflows, `.nvmrc`, `packageManager`) and run `scripts/sync-pnpm-version ` in the same merge; check with `--check` plus `pnpm install --lockfile-only --frozen-lockfile` per workspace. +- Those merges conflict on the pnpm self-reference block in all 11 lockfiles (importers `packageManagerDependencies`, `packages/@pnpm/exe.*@`, `pnpm@` and the matching `snapshots` entries). Resolve each workspace's lockfile keeping its own importer/package resolution and swapping only that block to the newer version. Do not start from the older side and regenerate with `pnpm install --lockfile-only`: pnpm re-resolves every changed range upward (this merge took root `@playwright/test` from 1.63.0 to 1.64.1 and `@modelcontextprotocol/server` from 2.3.1 to 2.4.0), which pollutes the merge with unrelated bumps. If a workspace later needs a real re-resolution after a manifest bump, `pnpm install --lockfile-only --fix-lockfile` accepts the hand-merged file and only fills what the manifests demand. +- `pnpm install` cannot run in `frontend` or `exporter` when `github.com` does not resolve: the `@penpot/svgo` git dependency triggers a `git fetch`. Frontend's lockfile entry carries no integrity, so pnpm always refetches; exporter's has one and still works. For frontend, resolve the self-reference hunks only. ## Verification diff --git a/AGENTS.md b/AGENTS.md index 26db47c2c1..e9264d2924 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,6 +155,6 @@ precision while maintaining a strong focus on maintainability and performance. - `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend). - `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines. - `scripts/check-fmt-clj` — Check Clojure formatting without modifying files. -- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `mem:scripts/ci`. +- `scripts/ci` — CI orchestration script for running lint, tests, format checks and, on request (`--build`), production builds across modules. See `mem:scripts/ci`. - `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`. diff --git a/backend/package.json b/backend/package.json index f877761df9..f573defead 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index ec457e4c24..8d3278fb84 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/backend/resources/climit.edn b/backend/resources/climit.edn index 1d10df0f8f..95487cb2a1 100644 --- a/backend/resources/climit.edn +++ b/backend/resources/climit.edn @@ -6,73 +6,75 @@ ;; 60s for operations that can be slow (file updates, images, fonts, thumbnails, ;; snapshots, imports, branches, pull requests) and 30s for the rest. ;; Note: a queue bounds how many waiters pile up, a timeout bounds how long one -;; waiter waits. Every entry sets a timeout; the by-profile ones also set a -;; queue, which is where a pile-up hurts. +;; waiter waits. Every entry sets a timeout and a queue: a command takes the +;; limits it declares before the root ones, so the queue of a limit is where +;; its own pile-up waits. The root limits bound what runs at the same time, +;; not what waits for it. +;; The production file lives outside this repository: mirror the shape there, +;; not these numbers. It has to list every id a command declares: an id without +;; an entry runs with no limiter at all. {:update-file/global - {:permits 20 :timeout 60000} + {:permits 20 :queue 50 :timeout 60000} :update-file/by-profile {:permits 1 :queue 5 :timeout 60000} :process-font/global - {:permits 4 :timeout 60000} + {:permits 4 :queue 10 :timeout 60000} :process-font/by-profile - {:permits 1 :timeout 60000} + {:permits 1 :queue 5 :timeout 60000} :process-image/global - {:permits 8 :timeout 60000} + {:permits 8 :queue 20 :timeout 60000} :process-image/by-profile - {:permits 1 :timeout 60000} + {:permits 1 :queue 5 :timeout 60000} :auth/global - {:permits 8 :timeout 30000} + {:permits 8 :queue 20 :timeout 30000} :root/global - {:permits 40 :timeout 30000} + {:permits 40 :queue 100 :timeout 30000} :root/by-profile {:permits 10 :queue 30 :timeout 30000} :file-thumbnail-ops/global - {:permits 20 :timeout 60000} + {:permits 20 :queue 50 :timeout 60000} :file-thumbnail-ops/by-profile - {:permits 2 :timeout 60000} + {:permits 2 :queue 10 :timeout 60000} :submit-audit-events/by-profile {:permits 1 :queue 3 :timeout 30000} :restore-file-snapshot/global - {:permits 3 :timeout 60000} + {:permits 3 :queue 5 :timeout 60000} :restore-file-snapshot/by-profile {:permits 1 :queue 2 :timeout 60000} :create-file-snapshot/global - {:permits 3 :timeout 60000} + {:permits 3 :queue 5 :timeout 60000} :create-file-snapshot/by-profile {:permits 1 :queue 2 :timeout 60000} :send-user-feedback/global - {:permits 4 :timeout 30000} + {:permits 4 :queue 10 :timeout 30000} :send-user-feedback/by-profile {:permits 1 :queue 3 :timeout 30000} :import-binfile/global - {:permits 4 :timeout 60000} + {:permits 4 :queue 10 :timeout 60000} :import-binfile/by-profile {:permits 1 :queue 2 :timeout 60000} :create-import-binfile-job/global - {:permits 4 :timeout 60000} + {:permits 4 :queue 10 :timeout 60000} :create-import-binfile-job/by-profile {:permits 1 :queue 2 :timeout 60000} - :profile-plugin-ops/global - {:permits 4 :timeout 30000} - :profile-plugin-ops/by-profile {:permits 1 :queue 2 :timeout 30000} @@ -113,16 +115,18 @@ ;; `files_pull_request.clj::update-pull-request-snapshot`), which is ;; the anchor's shape again. ;; - ;; The write-side operations carry a queue so that a refusal follows a - ;; bounded wait rather than arriving instantly. The queue sits on the - ;; per-profile entry, following the anchor, and on the global entry - ;; where the command declares no per-profile queue. The per-profile - ;; entry of one permit keeps a single designer to one operation at a - ;; time. The two read-side entries are generous by design and carry no - ;; queue, so a panel open waits for its turn instead of being refused: - ;; six three-document comparisons hold eighteen realized documents, - ;; fewer than the twenty one-document saves `:update-file/global` - ;; already admits. + ;; Every entry carries a queue, as the header requires: a command takes + ;; its own limits before the root ones, so an entry without a queue lets + ;; its waiters pile up without bound. The write-side global queues + ;; follow the anchor's ratio of five waiters to three permits, rounded + ;; up. The per-profile entry of one permit and a queue of two keeps a + ;; single designer to one operation at a time. The two read-side + ;; entries are generous by design, with the ratio of + ;; `:update-file/global` (fifty waiters to twenty permits), so a panel + ;; open waits for its turn instead of being refused: six + ;; three-document comparisons hold eighteen realized documents, fewer + ;; than the twenty one-document saves `:update-file/global` already + ;; admits. ;; ;; Every entry below, global and per-profile, read and write, takes the ;; 60 s timeout of the slow operations above. The size gates in @@ -135,7 +139,7 @@ ;; of `:create-file-snapshot` above, so it takes the anchor's numbers ;; unchanged. Provisional. :create-file-branch/global - {:permits 3 :timeout 60000} + {:permits 3 :queue 5 :timeout 60000} :create-file-branch/by-profile {:permits 1 :queue 2 :timeout 60000} @@ -144,7 +148,7 @@ ;; whole-file write into main, so one instance-wide operation holds the ;; anchor's whole three-permit budget on its own. Provisional. :merge-file-branch/global - {:permits 1 :timeout 60000} + {:permits 1 :queue 2 :timeout 60000} :merge-file-branch/by-profile {:permits 1 :queue 2 :timeout 60000} @@ -153,7 +157,7 @@ ;; snapshot write and a squash diff on top, so it takes the merge's ;; count. Provisional. :update-branch-from-main/global - {:permits 1 :timeout 60000} + {:permits 1 :queue 2 :timeout 60000} :update-branch-from-main/by-profile {:permits 1 :queue 2 :timeout 60000} @@ -163,7 +167,7 @@ ;; so it admits two where the merge admits one and the anchor admits ;; three. Provisional. :materialize-file-branch/global - {:permits 2 :timeout 60000} + {:permits 2 :queue 4 :timeout 60000} :materialize-file-branch/by-profile {:permits 1 :queue 2 :timeout 60000} @@ -178,18 +182,18 @@ ;; one-document saves `:update-file/global` above already admits. ;; Provisional. :get-file-branches/global - {:permits 6 :timeout 60000} + {:permits 6 :queue 15 :timeout 60000} ;; The compare dialog's query holds main, the derived branch, and the ;; base at once, the same three documents the listing holds, so it takes ;; the same count. Provisional. :get-branch-diff/global - {:permits 6 :timeout 60000} + {:permits 6 :queue 15 :timeout 60000} ;; Creating a pull request realizes the branch and pins one review ;; snapshot, the anchor's shape. Provisional. :create-pull-request/global - {:permits 3 :timeout 60000} + {:permits 3 :queue 5 :timeout 60000} :create-pull-request/by-profile {:permits 1 :queue 2 :timeout 60000} diff --git a/backend/scripts/_env b/backend/scripts/_env index 06e3c3206e..cb7167997c 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -93,6 +93,7 @@ export JAVA_OPTS="\ -XX:+UseShenandoahGC \ -XX:+UseCompactObjectHeaders \ -XX:ShenandoahGCMode=generational \ + -XX:NativeMemoryTracking=summary \ -XX:-OmitStackTraceInFastThrow \ --sun-misc-unsafe-memory-access=allow \ --enable-preview \ diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index 91858cf136..346f0e913c 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -997,7 +997,8 @@ "Make the file its own tokens source when its tokens source is not one of the given library-ids, as happens when the library was deleted. The tokens lib of the source is copied while the library still exists, even - if deleted; otherwise the tokens source and status are removed." + if deleted; otherwise the tokens source is removed and the file gets back + the tokens status kept aside for its own tokens lib, if any." [cfg data library-ids] (let [source-id (cfo/get-tokens-source data)] (if (and (some? source-id) @@ -1006,7 +1007,7 @@ :include-deleted? true :throw-if-not-exists? false)] (cfo/absorb-tokens-lib data (:data library)) - (dissoc data :tokens-source :tokens-status)) + (cfo/detach-tokens-source data)) data))) (defn embed-assets @@ -1446,12 +1447,13 @@ :else :detach-libraries)) (defn export-cfg - "The domain cfg of an export: the frozen set of files and how their - libraries are handled." - [cfg {:keys [ids export-type]}] + "The domain cfg of an export: the frozen set of files, how their + libraries are handled, and where the package is written." + [cfg {:keys [file-ids export-type output]}] (-> cfg - (assoc ::ids ids) - (assoc ::export-type export-type))) + (assoc ::file-ids file-ids) + (assoc ::export-type export-type) + (assoc ::output output))) (defn import-cfg "The domain cfg of an import: the destination team/profile/project, the diff --git a/backend/src/app/binfile/jobs.clj b/backend/src/app/binfile/jobs.clj index 950d157220..3bdfe668db 100644 --- a/backend/src/app/binfile/jobs.clj +++ b/backend/src/app/binfile/jobs.clj @@ -5,7 +5,7 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.binfile.jobs - "The binfile work a job runs. + "The progress bridge of the binfile jobs. A job has no SSE channel, so the `:progress` points that `bf.v1` and `bf.v3` emit become `progress` events of the job through @@ -17,20 +17,15 @@ so the adapter holds no state between taps. The ids the taps carry are not part of the job event contract. - The adapter checks that the job is still active before it starts, and - the beat checks it on every write: a job that was cancelled, or that + The handlers in `app.tasks.export-binfile` and + `app.tasks.import-binfile` run the core through `run` and + guard it with `check-active`: a job that was cancelled, or that already reached a terminal state, raises an `:interrupt` right there on the run thread, so the work stops at its next event point and the transaction of its handler rolls back with it. A run whose last tap happened before the interruption is discarded once it returns, which is what makes that transaction roll back too." (:require - [app.binfile.common :as bfc] - [app.binfile.v1 :as bf.v1] - [app.binfile.v3 :as bf.v3] - [app.common.exceptions :as ex] - [app.common.time :as ct] - [app.db :as db] [app.jobs :as jobs] [app.util.events :as events])) @@ -39,7 +34,7 @@ The substrate owns both the check and the error; this is the adapter's entry point to it, used by the two handlers before their run and by the - tests." + tests. Answers with the cfg, so a handler can thread it." [cfg context] (jobs/check-active cfg (:id context))) @@ -98,10 +93,13 @@ (assoc :counters (cond-> (or outer-counters {}) (some? own) (assoc stage own)))))) -(defn- with-progress +(defn run "Run `f` with the progress taps of the core turned into milestones of the job. + `f` is called with the cfg, so the adapter forwards what it receives + and a caller can pass the core directly. + Every tap is reported: the substrate throttles the durable events, and a milestone that does not fit loses no information because the next one carries the same state. @@ -117,55 +115,6 @@ :job-id job-id :progress (milestone data))))] (binding [events/*sink* sink] - (let [result (f)] + (let [result (f cfg)] (check-active cfg context) result)))) - -(defn export-files - "Export a set of files into `output` as part of a job, reporting - progress as job events." - [cfg context {:keys [ids export-type output]}] - (check-active cfg context) - (with-progress cfg context - #(bf.v3/export-files! (bfc/export-cfg cfg {:ids ids - :export-type export-type}) - output))) - -(defn- touch-project - "Mark the destination project as modified. - - An import adds files, and the triggers of the schema only touch the - project on an update, so a project that just received files keeps its - old date and stays where it was in the list." - [cfg project-id] - (db/update! cfg :project - {:modified-at (ct/now)} - {:id project-id} - {::db/return-keys false})) - -(defn import-files - "Import the package at `input` as part of a job, reporting progress as - job events. The package was read from the resource of the job, so the - caller resolves the destination team and the adapter only fixes what - the legacy RPC and a job must share. - - Both formats answer with the same shape: the version 1 reader returns - the set of files it created, and the version 3 one returns a map that - already carries them together with the resolution of the libraries." - [cfg context {:keys [profile-id project-id team name input version]}] - (check-active cfg context) - (let [cfg (bfc/import-cfg cfg {:profile-id profile-id - :project-id project-id - :team team - :name name - :input input})] - (with-progress cfg context - #(let [result (case (int version) - 1 (bf.v1/import-files! cfg) - 3 (bf.v3/import-files! cfg) - (ex/raise :type :validation - :code :unsupported-version - :hint "unsupported binfile version" - :version version))] - (touch-project cfg project-id) - result)))) diff --git a/backend/src/app/binfile/v1.clj b/backend/src/app/binfile/v1.clj index 649494a2c6..76fc870f5d 100644 --- a/backend/src/app/binfile/v1.clj +++ b/backend/src/app/binfile/v1.clj @@ -329,13 +329,13 @@ [:v1/metadata :v1/files :v1/rels :v1/sobjects])))) (defmethod write-section :v1/metadata - [{:keys [::output ::bfc/ids ::bfc/include-libraries] :as cfg}] - (if-let [fids (get-files cfg ids)] + [{:keys [::output ::bfc/file-ids ::bfc/include-libraries] :as cfg}] + (if-let [fids (get-files cfg file-ids)] (let [lids (when include-libraries - (bfc/get-libraries cfg ids)) - ids (into fids lids)] - (write-obj! output {:version cf/version :files ids}) - (vswap! bfc/*state* assoc :files ids)) + (bfc/get-libraries cfg file-ids)) + file-ids (into fids lids)] + (write-obj! output {:version cf/version :files file-ids}) + (vswap! bfc/*state* assoc :files file-ids)) (ex/raise :type :not-found :code :files-not-found :hint "unable to retrieve files for export"))) @@ -689,12 +689,12 @@ `::bfc/embed-assets`: instead of including the libraries, embed in the same file library all assets used from external libraries." - [{:keys [::bfc/ids] :as cfg} output] + [{:keys [::bfc/file-ids] :as cfg} output] (dm/assert! - "expected a set of uuid's for `::bfc/ids` parameter" - (and (set? ids) - (every? uuid? ids))) + "expected a set of uuid's for `::bfc/file-ids` parameter" + (and (set? file-ids) + (every? uuid? file-ids))) (dm/assert! "expected instance of jio/IOFactory for `input`" diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index 3b446a63d3..93f4a8a14a 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -454,14 +454,15 @@ (write-entry! output path encoded-status))))) (defn- export-files - [{:keys [::bfc/ids ::bfc/export-type ::output] :as cfg}] + [{:keys [::bfc/file-ids ::bfc/export-type ::output] :as cfg}] - (let [original-ids ids - ids (into ids (when (= export-type :include-libraries) (bfc/get-libraries cfg ids))) - rels (if (= export-type :include-libraries) - (->> (bfc/get-files-rels cfg ids) - (mapv (juxt :file-id :library-file-id))) - []) + (let [original-ids file-ids + file-ids (into file-ids (when (= export-type :include-libraries) + (bfc/get-libraries cfg file-ids))) + rels (if (= export-type :include-libraries) + (->> (bfc/get-files-rels cfg file-ids) + (mapv (juxt :file-id :library-file-id))) + []) ;; Compute external libraries: referenced by original files but ;; not included in the export set. Only relevant for :link-later. @@ -486,11 +487,11 @@ (vswap! bfc/*state* assoc :files (d/ordered-map)) ;; Write all the exporting files - (doseq [[index file-id] (d/enumerate ids)] + (doseq [[index file-id] (d/enumerate file-ids)] (-> cfg (assoc ::file-id file-id) (with-outer-counter :files {:current (inc index) - :total (count ids)}) + :total (count file-ids)}) (export-file))) ;; Write manifest file @@ -1398,24 +1399,25 @@ (defn export-files! "Do the exportation of a specified file in custom penpot binary - format. There are some options available for customize the output: + format. The cfg carries what `bfc/export-cfg` froze: `::bfc/file-ids` + (the files to export), `::bfc/export-type` (how linked libraries are + handled) and `::bfc/output` (where the package is written). - `::bfc/export-type`: determines how linked libraries are handled. - Valid values: - `:include-libraries` (include linked libraries), + `::bfc/export-type` determines how linked libraries are handled. + Valid values: `:include-libraries` (include linked libraries), `:merge-libraries` (embed library assets in the file), `:detach-libraries` (treat assets as basic objects), `:link-later` (preserve component metadata for relinking on import)." - [{:keys [::bfc/ids] :as cfg} output] + [{:keys [::bfc/file-ids ::bfc/output] :as cfg}] (assert - (and (set? ids) (every? uuid? ids)) - "expected a set of uuid's for `::bfc/ids` parameter") + (and (set? file-ids) (every? uuid? file-ids)) + "expected a set of uuid's for `::bfc/file-ids` parameter") (assert (satisfies? jio/IOFactory output) - "expected instance of jio/IOFactory for `input`") + "expected instance of jio/IOFactory for `::bfc/output`") (let [id (uuid/next) tp (ct/tpoint) diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index cd1e212964..329fc2333b 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -226,6 +226,7 @@ [:allow-demo-users {:optional true} ::sm/boolean] [:error-report-webhook {:optional true} :string] [:user-feedback-destination {:optional true} :string] + [:mcp-feedback-email {:optional true} ::sm/email] [:default-rpc-rlimit {:optional true} [::sm/vec :string]] [:rpc-rlimit-config {:optional true} ::fs/path] diff --git a/backend/src/app/features/file_snapshots.clj b/backend/src/app/features/file_snapshots.clj index 65243fb59c..4460c84fd6 100644 --- a/backend/src/app/features/file_snapshots.clj +++ b/backend/src/app/features/file_snapshots.clj @@ -22,7 +22,6 @@ [app.jobs :as jobs] [app.storage :as sto] [app.util.blob :as blob] - [app.util.pointer-map :as pmap] [cuerdas.core :as str])) (def sql:snapshots @@ -49,11 +48,19 @@ AND fd.type = 'snapshot') WHERE c.label IS NOT NULL") +;; `:migrations` is an ordered set everywhere (see `common.types.file`), +;; and `need-migration?`/`migrate` use set semantics on it: the pgarray +;; must never surface as a vector, where `contains?`/`set/difference` +;; would compare indexes instead of names. +(defn- decode-migrations + [value] + (into (d/ordered-set) (db/decode-pgarray value []))) + (defn- decode-snapshot [snapshot] (some-> snapshot (-> (d/update-when :metadata fdata/decode-metadata) - (d/update-when :migrations db/decode-pgarray []) + (d/update-when :migrations decode-migrations) (d/update-when :features db/decode-pgarray #{})))) (def ^:private sql:get-minimal-file @@ -469,12 +476,11 @@ pointer ids never trigger a load." [cfg file-id xform f init] (let [conn (db/get-connection cfg) - load (partial fdata/load-pointer cfg file-id) xform (comp (map (partial fdata/resolve-file-data cfg)) (map (partial fdata/decode-file-data cfg)) xform)] - (binding [pmap/*load-fn* load] + (fdata/with-pointers cfg file-id (->> (db/plan conn [sql:get-snapshots file-id] {:fetch-size 1}) (transduce xform f init))))) diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index aa6950fdeb..83af60c3d2 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -19,6 +19,7 @@ [app.http.errors :as errors] [app.http.link-preview :as-alias link-preview] [app.http.management :as mgmt] + [app.http.mcp-oauth :as-alias mcp-oauth] [app.http.middleware :as mw] [app.http.security :as sec] [app.http.session :as session] @@ -236,6 +237,7 @@ [::ws/routes schema:routes] [::rpc/routes schema:routes] [::oidc/routes schema:routes] + [::mcp-oauth/routes schema:routes] [::assets/routes schema:routes] [::link-preview/routes schema:routes] [::debug/routes schema:routes] @@ -278,4 +280,7 @@ (::ws/routes cfg) (::oidc/routes cfg) - (::rpc/routes cfg)]])) + (::rpc/routes cfg)] + ;; OAuth owns form parsing, protocol JSON names and cross-origin handling; + ;; it must not inherit the RPC transformations and session middleware above. + (::mcp-oauth/routes cfg)])) diff --git a/backend/src/app/http/access_token.clj b/backend/src/app/http/access_token.clj index 57f19fdda8..017b042bd4 100644 --- a/backend/src/app/http/access_token.clj +++ b/backend/src/app/http/access_token.clj @@ -14,14 +14,20 @@ [app.setup :as-alias setup] [app.tokens :as tokens])) +(def mcp-token-issuer "urn:penpot:mcp-token") + +(def ^:private valid-issuers + #{"access-token" mcp-token-issuer}) + (defn decode-token [cfg token] (try - (tokens/verify cfg {:token token :iss "access-token"}) - (catch Throwable cause - (l/trc :hint "exception on decoding token" - :token token - :cause cause)))) + (let [claims (tokens/verify cfg {:token token})] + (when (contains? valid-issuers (:iss claims)) + claims)) + ;; The cause is not logged because its data carries the token + (catch Throwable _ + (l/trc :hint "exception on decoding token")))) (def sql:get-token-data "SELECT perms, profile_id, expires_at, type @@ -34,32 +40,48 @@ [pool claims] (when-not (db/read-only? pool) (when-let [token-id (get claims :tid)] - (some-> (db/exec-one! pool [sql:get-token-data token-id]) - (update :perms db/decode-pgarray #{}))))) + (let [token (db/exec-one! pool [sql:get-token-data token-id]) + mcp-claims? (= mcp-token-issuer (:iss claims)) + mcp-token? (= "mcp" (:type token))] + (when (and (= (:uid claims) (:profile-id token)) + (= mcp-claims? mcp-token?)) + (update token :perms db/decode-pgarray #{})))))) (defn- wrap-authz - [handler {:keys [::db/pool]}] - (fn [request] - (let [{:keys [type claims]} (get request ::http/auth-data)] - (if (= :token type) - (let [{:keys [perms profile-id expires-at type]} (some->> claims (get-token-data pool)) - token-id (get claims :tid)] - (handler (cond-> request - (some? perms) - (assoc ::perms perms) - (some? profile-id) - (assoc ::profile-id profile-id) - (some? expires-at) - (assoc ::expires-at expires-at) - (some? token-id) - (assoc ::id token-id) - (some? type) - (assoc ::type type)))) + "Authenticate requests carrying an access token. Personal tokens need the + `access-tokens` flag. MCP tokens are restricted to specific commands, so + only RPC routes opt in (the RPC wrapper enforces those restrictions); they + need either the `mcp` or the `access-tokens` flag." + [handler {:keys [::db/pool ::allow-mcp]}] + (let [personal-enabled? (contains? cf/flags :access-tokens) + mcp-enabled? (and allow-mcp + (or personal-enabled? + (contains? cf/flags :mcp)))] + (fn [request] + (let [{:keys [type claims]} (get request ::http/auth-data)] + (if (and (= :token type) + (if (= mcp-token-issuer (:iss claims)) + mcp-enabled? + personal-enabled?)) + (let [{:keys [perms profile-id expires-at type]} (some->> claims (get-token-data pool)) + token-id (get claims :tid)] + (handler (cond-> request + (some? perms) + (assoc ::perms perms) + (some? profile-id) + (assoc ::profile-id profile-id) + (some? expires-at) + (assoc ::expires-at expires-at) + (some? token-id) + (assoc ::id token-id) + (some? type) + (assoc ::type type)))) - (handler request))))) + (handler request)))))) (def authz {:name ::authz :compile (fn [& _] - (when (contains? cf/flags :access-tokens) + (when (or (contains? cf/flags :access-tokens) + (contains? cf/flags :mcp)) wrap-authz))}) diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index e75f599fdc..f09c694eb4 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -100,7 +100,7 @@ headers (cond-> {"location" (str url) "x-host" (cond-> host port (str ":" port)) "x-mtype" (-> obj meta :content-type) - "cache-control" (str "max-age=" (inst-ms cch-max-age))} + "cache-control" (str "max-age=" (ct/seconds cch-max-age))} (not public?) (assoc "content-disposition" "attachment"))] {::yres/status 307 @@ -115,7 +115,7 @@ bucket (:bucket mdata) headers (cond-> {"x-accel-redirect" (:path purl) "content-type" (:content-type mdata) - "cache-control" (str "max-age=" (inst-ms cch-max-age))} + "cache-control" (str "max-age=" (ct/seconds cch-max-age))} (not (public-bucket? bucket)) (assoc "content-disposition" "attachment"))] {::yres/status 204 diff --git a/backend/src/app/http/debug.clj b/backend/src/app/http/debug.clj index 6995464290..d62520706f 100644 --- a/backend/src/app/http/debug.clj +++ b/backend/src/app/http/debug.clj @@ -359,10 +359,11 @@ (let [path (tmp/tempfile :prefix "penpot.export." :min-age "30m")] (with-open [output (io/output-stream path)] (-> cfg - (assoc ::bfc/ids file-ids) + (assoc ::bfc/file-ids file-ids) (assoc ::bfc/embed-assets embed?) (assoc ::bfc/include-libraries libs?) - (bf.v3/export-files! output))) + (assoc ::bfc/output output) + (bf.v3/export-files!))) (if clone? (let [profile (profile/get-profile pool profile-id) @@ -784,8 +785,12 @@ (db/tx-run! (assoc cfg ::db/rollback true) (fn [cfg] - (let [file (bfc/get-file cfg file-id) - libs (bfc/get-resolved-file-libraries cfg file-id)] + ;; Validation walks every page and component, so the + ;; file is read realized; the library map is built + ;; from the file (not its id) and its own loader + ;; resolves each library's pointers. + (let [file (bfc/get-file cfg file-id :realize? true) + libs (bfc/get-resolved-file-libraries cfg file)] (if file (let [errors (cfv/validate-file file libs)] {::yres/status 200 diff --git a/backend/src/app/http/mcp_oauth.clj b/backend/src/app/http/mcp_oauth.clj new file mode 100644 index 0000000000..5184cc7e65 --- /dev/null +++ b/backend/src/app/http/mcp_oauth.clj @@ -0,0 +1,353 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.http.mcp-oauth + "MCP-specific OAuth authorization server. + + The authorize endpoint stages a request; the existing Penpot session identifies + the user on the approval page. Approval creates a one-use code, and token + exchange returns the existing fixed MCP user token after verifying PKCE. + Client registration, login sessions, and authorization codes are distinct." + (:require + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.http.errors :as errors] + [app.http.middleware :as mw] + [app.http.session :as session] + [app.mcp.oauth-clients :as clients] + [app.mcp.oauth-state :as state] + [app.rpc.commands.access-token :as access-token] + [app.util.json :as json] + [clojure.string :as str] + [integrant.core :as ig] + [yetti.request :as yreq] + [yetti.response :as yres]) + (:import + java.io.InputStream + java.net.URI + java.net.URLDecoder + java.net.URLEncoder + java.nio.charset.StandardCharsets + java.util.Base64)) + +(defn issuer [] (str (cf/get-public-uri "api/mcp/oauth"))) +(defn resource [] (str (cf/get-public-uri "mcp/stream"))) + +(defn authorization-metadata + "OAuth capabilities and endpoints advertised to MCP clients." + [] + {:issuer (issuer) + :authorization_endpoint (str (issuer) "/authorize") + :token_endpoint (str (issuer) "/token") + :registration_endpoint (str (issuer) "/register") + :response_types_supported ["code"] + :grant_types_supported ["authorization_code"] + :code_challenge_methods_supported ["S256"] + :token_endpoint_auth_methods_supported ["none" "client_secret_basic" "client_secret_post"] + :scopes_supported ["mcp"] + :authorization_response_iss_parameter_supported true + :client_id_metadata_document_supported true}) + +(defn resource-metadata + "Protected-resource metadata referenced by the MCP server's 401 challenge." + [] + {:resource (resource) + :authorization_servers [(issuer)] + :scopes_supported ["mcp"] + :bearer_methods_supported ["header"] + :resource_name "Penpot MCP"}) + +(defn- response + ([body] (response 200 body)) + ([status body] + {::yres/status status + ::yres/headers {"content-type" "application/json" + "cache-control" "no-store" + "pragma" "no-cache" + "referrer-policy" "no-referrer"} + ::yres/body (json/encode-str body)})) + +(defn- redirect + [url] + {::yres/status 303 + ::yres/headers {"location" url "cache-control" "no-store" + "referrer-policy" "no-referrer"} + ::yres/body ""}) + +(defn- encode [^String value] (URLEncoder/encode value StandardCharsets/UTF_8)) +(defn- decode + [^String value] + (try (URLDecoder/decode value StandardCharsets/UTF_8) + (catch IllegalArgumentException _ (clients/invalid! "invalid_request")))) + +(defn- parse-form + "Bounded OAuth form/query parser preserving protocol parameter names. + Rejects duplicate keys so validation never depends on which value wins." + [query] + (when (> (count query) 16384) (clients/invalid! "invalid_request")) + (reduce (fn [result part] + (let [[key value] (str/split part #"=" 2) + key (keyword (decode key))] + (when (contains? result key) (clients/invalid! "invalid_request")) + (assoc result key (decode (or value ""))))) + {} + (remove str/blank? (str/split (or query "") #"&")))) + +(defn- read-body + [request content-type] + (when-not (str/starts-with? (or (yreq/get-header request "content-type") "") content-type) + (clients/invalid! "invalid_request")) + (with-open [^InputStream body (yreq/body request)] + (when-not body (clients/invalid! "invalid_request")) + (let [bytes (.readNBytes body 16385)] + (when (> (alength bytes) 16384) (clients/invalid! "invalid_request")) + (String. bytes StandardCharsets/UTF_8)))) + +(defn- append-query + [url params] + (str url (if (str/includes? url "?") "&" "?") + (str/join "&" (for [[key value] params :when (some? value)] + (str (encode (name key)) "=" (encode value)))))) + +(defn- limit! + [cfg request kind limit] + (when-not (state/allow-request! cfg kind (str (yreq/remote-addr request)) limit) + (clients/invalid! "temporarily_unavailable" 429))) + +(defn- authorize + "Validate and stage an authorization request before user login or consent. + The browser receives only an opaque handle to the ten-minute Redis record." + [cfg request] + (limit! cfg request :authorize-rate 60) + (let [params (parse-form (yreq/query request)) + client (clients/resolve-client cfg (:client_id params)) + callback (:redirect_uri params)] + ;; Never redirect until the client and its callback have been checked. + (when-not (clients/redirect-matches? client callback) + (clients/invalid! "invalid_request")) + (when-not (and (= "code" (:response_type params)) + (= "S256" (:code_challenge_method params)) + (string? (:code_challenge params)) + (re-matches #"[A-Za-z0-9_-]{43}" (:code_challenge params)) + (= "mcp" (get params :scope "mcp")) + (= (resource) (get params :resource (resource))) + (<= (count (:state params)) 2048)) + (clients/invalid! "invalid_request")) + (let [handle (state/create! cfg :request + {:client-id (:client_id client) + :client-name (:client_name client) + :logo-uri (:logo_uri client) + :redirect-uri callback + :challenge (:code_challenge params) + :state (:state params) + :resource (resource) + :scope "mcp"} + 600)] + (redirect (append-query (str (cf/get-public-uri)) + {:screen "mcp-authorize" :request handle}))))) + +(defn- session-binding + "Profile and login-session identity used to bind one approval request." + [request] + (let [session (session/get-session request)] + (when-not (:profile-id session) (clients/invalid! "login_required" 401)) + {:profile-id (str (:profile-id session)) + :session-id (state/digest (str (:id session)))})) + +(defn- approval-request + "Bind a pending request to its first authenticated session and return UI data. + Compare-and-replace prevents another session from taking over that request." + [cfg request] + (let [binding (session-binding request) + handle (:request (parse-form (yreq/query request))) + record (state/read! cfg :request handle)] + (when-not record (clients/invalid! "invalid_request")) + (let [value (:value record)] + (if (:session-id value) + (when-not (= binding (select-keys value [:profile-id :session-id])) + (clients/invalid! "access_denied" 403)) + (state/replace! cfg :request handle record + (merge value binding {:csrf (state/random-secret)})))) + ;; Another GET may have won the binding race. Read the winning record so + ;; same-session retries share its CSRF value; other sessions cannot proceed. + (let [value (:value (state/read! cfg :request handle))] + (when-not (= binding (select-keys value [:profile-id :session-id])) + (clients/invalid! "invalid_request")) + (response (select-keys value [:client-id :client-name :logo-uri :csrf :scope]))))) + +(defn- same-origin? + [request] + (let [public (URI. (str (cf/get-public-uri))) + origin (str (.getScheme public) "://" (.getRawAuthority public))] + (= origin (yreq/get-header request "origin")))) + +(defn- decision + "Accept one session-bound approval/denial and return the client callback. + Origin and CSRF checks protect the cookie-authenticated decision endpoint." + [cfg request] + (when-not (same-origin? request) (clients/invalid! "access_denied" 403)) + (let [binding (session-binding request) + params (parse-form (read-body request "application/x-www-form-urlencoded")) + handle (:request params) + record (state/read! cfg :request handle) + value (:value record)] + (when-not (and record + (= binding (select-keys value [:profile-id :session-id])) + (state/secret= (:csrf value) (:csrf params)) + (contains? #{"approve" "deny"} (:decision params))) + (clients/invalid! "invalid_request")) + ;; Consume before provisioning: concurrent clicks must not issue two codes. + ;; A later failure requires a fresh authorization attempt. + (when-not (state/consume! cfg :request handle record) + (clients/invalid! "invalid_request")) + (let [result (if (= "deny" (:decision params)) + {:error "access_denied"} + (let [token (db/tx-run! cfg access-token/ensure-mcp-access! + (uuid/parse (:profile-id value))) + ;; Retain the token row ID, never the fixed token itself. + code (state/create! cfg :code + (-> value + (dissoc :csrf :session-id :client-name) + (assoc :token-id (str (:id token)))) + 120)] + {:code code}))] + (response {:redirect-uri (append-query (:redirect-uri value) + (merge result {:state (:state value) + :iss (issuer)}))})))) + +(defn- client-authentication + "Extract one OAuth client-authentication method, rejecting mixed credentials. + Basic credentials are form-decoded after splitting the decoded header." + [request params] + (let [header (yreq/get-header request "authorization")] + (if header + (do + (when (or (:client_secret params) (not (str/starts-with? header "Basic "))) + (clients/invalid! "invalid_client" 401)) + (let [decoded (try (String. (.decode (Base64/getDecoder) (subs header 6)) + StandardCharsets/UTF_8) + (catch IllegalArgumentException _ + (clients/invalid! "invalid_client" 401))) + [id secret] (str/split decoded #":" 2) + id (decode id)] + (when (or (nil? secret) + (and (:client_id params) (not= id (:client_id params)))) + (clients/invalid! "invalid_client" 401)) + {:id id :secret (decode secret) :method "client_secret_basic"})) + {:id (:client_id params) :secret (:client_secret params) + :method (if (:client_secret params) "client_secret_post" "none")}))) + +(defn- token + "Exchange a code for the fixed MCP token after client and PKCE verification." + [cfg request] + (limit! cfg request :token-rate 120) + (let [params (parse-form (read-body request "application/x-www-form-urlencoded")) + auth (client-authentication request params) + record (state/read! cfg :code (:code params)) + value (:value record)] + (when-not (= "authorization_code" (:grant_type params)) + (clients/invalid! "unsupported_grant_type")) + ;; Compare the exact client ID, not decoded metadata: a self-contained ID + ;; edited after approval identifies a different client for this exchange. + (when-not (and record + (= (:id auth) (:client-id value)) + (= (:redirect_uri params) (:redirect-uri value)) + (= (:resource value) (get params :resource (:resource value))) + (string? (:code_verifier params)) + (re-matches #"[A-Za-z0-9._~-]{43,128}" (:code_verifier params)) + (state/secret= (:challenge value) (state/digest (:code_verifier params)))) + (clients/invalid! "invalid_grant")) + ;; Resolve again because an HTTPS metadata document may have changed + ;; since approval; its current callback and authentication method apply. + (let [client (clients/resolve-client cfg (:id auth))] + (when-not (and (= (:method auth) (:token_endpoint_auth_method client)) + (clients/redirect-matches? client (:redirect-uri value)) + (or (= "none" (:method auth)) + (and (string? (:secret auth)) + (state/secret= (:secret-hash client) + (state/digest (:secret auth)))))) + (clients/invalid! "invalid_client" 401))) + ;; Validate before consuming so an invalid verifier/secret cannot burn the + ;; code. Atomic consumption allows only one valid exchange to succeed. + (when-not (state/consume! cfg :code (:code params) record) + (clients/invalid! "invalid_grant")) + ;; Regeneration deletes the old token row. Never substitute the profile's + ;; latest token for the one this approval authorized, nor return it expired. + (let [row (db/exec-one! (::db/pool cfg) + [(str "SELECT token FROM access_token" + " WHERE id = ? AND profile_id = ? AND type = 'mcp'" + " AND (expires_at IS NULL OR expires_at > now())") + (uuid/parse (:token-id value)) + (uuid/parse (:profile-id value))])] + (when-not row (clients/invalid! "invalid_grant")) + (response {:access_token (:token row) :token_type "Bearer" :scope "mcp"})))) + +(defn- register + "Issue a self-contained client registration; does not grant user access." + [cfg request] + (limit! cfg request :registration-rate 10) + (let [body (read-body request "application/json") + metadata (try (json/decode body) + (catch Exception _ (clients/invalid! "invalid_request")))] + (response 201 (clients/register! cfg metadata)))) + +(defn- wrap-errors + "Answer every failure with an OAuth error body. Unexpected failures also go + through the shared HTTP error handler, only for its logging; these routes + never set request params, so codes and credentials stay out of the log." + [handler] + (fn [request] + (try (handler request) + (catch Exception cause + (let [{:keys [oauth-error status]} (ex-data cause) + status (or status (if (and oauth-error (not= oauth-error "temporarily_unavailable")) 400 503))] + (when-not oauth-error + (errors/handle cause request)) + (cond-> (response status {:error (or oauth-error "temporarily_unavailable")}) + (and (= status 401) (= oauth-error "invalid_client")) + (assoc-in [::yres/headers "www-authenticate"] "Basic realm=\"Penpot MCP\""))))))) + +(defn- wrap-public + "CORS for discovery, registration and exchange without browser credentials. + Cookie-authenticated approval endpoints must not use this wrapper." + [handler] + (fn [request] + (let [response (if (= :options (yreq/method request)) + {::yres/status 204} + (handler request))] + (update response ::yres/headers merge + {"access-control-allow-origin" "*" + "access-control-allow-methods" "GET, POST, OPTIONS" + "access-control-allow-headers" "Content-Type, Authorization" + "access-control-expose-headers" "WWW-Authenticate"})))) + +(defn- endpoint + [cfg handler method public?] + {:handler (partial handler cfg) + :middleware (into (if public? [{:compile (constantly wrap-public)}] []) + [{:compile (constantly wrap-errors)} [mw/restrict-methods]]) + :allowed-methods (cond-> #{method} public? (conj :options))}) + +;; OAuth endpoints parse forms and emit snake_case JSON independently of RPC +;; middleware. Only the approval UI endpoints require a Penpot login session. +(defmethod ig/init-key ::routes + [_ cfg] + ["" + ["/.well-known/oauth-authorization-server/api/mcp/oauth" + (endpoint cfg (fn [_ _] (response (authorization-metadata))) :get true)] + ["/api/mcp/oauth" + ["/resource" (endpoint cfg (fn [_ _] (response (resource-metadata))) :get true)] + ["/authorize" (endpoint cfg authorize :get false)] + ["/register" (endpoint cfg register :post true)] + ["/token" (endpoint cfg token :post true)] + ;; Error handling wraps the session middleware too, so its failures also + ;; get OAuth error bodies and logging. + ["" {:middleware [{:compile (constantly wrap-errors)} + [mw/auth {:cookie (partial session/decode-token cfg)}] + [session/authz cfg]]} + ["/request" (endpoint cfg approval-request :get false)] + ["/decision" (endpoint cfg decision :post false)]]]]) diff --git a/backend/src/app/jobs.clj b/backend/src/app/jobs.clj index caf19b6f4e..271414ebcc 100644 --- a/backend/src/app/jobs.clj +++ b/backend/src/app/jobs.clj @@ -847,7 +847,8 @@ The interrupt is an internal signal, not an error: the caller is a task that must stop the work in course. `heartbeat` calls this when one of its writes finds the job gone, and a caller can also ask explicitly, - which is what the binfile adapter does before and after its run." + which is what the binfile adapter does before and after its run. + Answers with the cfg, so a caller can thread the check into its run." [cfg job-id] (let [job (get-job cfg job-id)] (when-not (and (some? job) (contains? active-statuses (:status job))) @@ -855,7 +856,8 @@ :code :job-interrupted :hint "the job is no longer active" :job-id job-id - :status (or (:status job) "gone"))))) + :status (or (:status job) "gone"))) + cfg)) (defn heartbeat "Keep a running job alive and, optionally, report its progress. diff --git a/backend/src/app/loggers/audit/consumer_task.clj b/backend/src/app/loggers/audit/consumer_task.clj index 7270cfae94..3180f936f0 100644 --- a/backend/src/app/loggers/audit/consumer_task.clj +++ b/backend/src/app/loggers/audit/consumer_task.clj @@ -213,6 +213,7 @@ "accept-team-invitation-from" "add-member-to-organization" "add-team-to-organization" + "assign-organization-attribute" "auditlog-exported" "auditlog-guide-downloaded" "cancel-organization-invitation" @@ -253,6 +254,7 @@ "sso-config-tested" "success-activate-organization-sso" "success-apply-organization-sso-changes" + "unassign-organization-attribute" "update-organization-invitation" "update-organization-permissions" "update-team-invitation" diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 2f119a769f..9267c889f4 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -22,6 +22,7 @@ [app.http.debug :as-alias http.debug] [app.http.link-preview :as-alias http.link-preview] [app.http.management :as mgmt] + [app.http.mcp-oauth :as-alias http.mcp-oauth] [app.http.session :as session] [app.http.websocket :as http.ws] [app.jobs :as-alias jobs] @@ -77,6 +78,12 @@ ::mdef/buckets mtx/default-request-histogram-buckets ::mdef/type :histogram} + :update-file-repair + {::mdef/name "penpot_rpc_update_file_repair_total" + ::mdef/help "update-file auto-repair attempts by outcome (repaired or unrepaired)." + ::mdef/labels ["outcome"] + ::mdef/type :counter} + :rpc-main-timing {::mdef/name "penpot_rpc_main_timing" ::mdef/help "RPC command method call timing for main" @@ -534,9 +541,17 @@ ::http.debug/routes (ig/ref ::http.debug/routes) ::http.assets/routes (ig/ref ::http.assets/routes) ::http.link-preview/routes (ig/ref ::http.link-preview/routes) + ::http.mcp-oauth/routes (ig/ref ::http.mcp-oauth/routes) ::http.ws/routes (ig/ref ::http.ws/routes) ::http.awsns/routes (ig/ref ::http.awsns/routes)} + ::http.mcp-oauth/routes + {::db/pool (ig/ref ::db/pool) + ::rds/pool (ig/ref ::rds/pool) + ::setup/props (ig/ref ::setup/props) + ::session/manager (ig/ref ::session/manager) + ::http.client/client (ig/ref ::http.client/client)} + ::http.link-preview/routes {::db/pool (ig/ref ::db/pool)} diff --git a/backend/src/app/mcp/oauth_clients.clj b/backend/src/app/mcp/oauth_clients.clj new file mode 100644 index 0000000000..2e36fa6751 --- /dev/null +++ b/backend/src/app/mcp/oauth_clients.clj @@ -0,0 +1,220 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.mcp.oauth-clients + "Client metadata from self-contained registrations or HTTPS documents. + Registration describes an application; it does not authorize a Penpot user. + Both sources resolve to the same metadata shape without a client database." + (:require + [app.http.client :as http] + [app.mcp.oauth-state :as state] + [app.util.cache :as cache] + [app.util.json :as json] + [clojure.string :as str]) + (:import + java.io.InputStream + java.net.URI + java.util.Base64)) + +(defn invalid! + ([error] (invalid! error 400)) + ([error status] + (throw (ex-info "MCP OAuth request rejected" {:oauth-error error :status status})))) + +(defn uri + [value] + (when (and (string? value) (<= 1 (count value) 2048)) + (try (URI. value) (catch Exception _ nil)))) + +(defn- loopback? + [^URI uri] + (and (= "http" (.getScheme uri)) + (contains? #{"127.0.0.1" "[::1]" "localhost"} (.getHost uri)))) + +(defn redirect-uri? + [value] + (when-let [uri (uri value)] + (and (nil? (.getFragment uri)) + (nil? (.getUserInfo uri)) + (or (and (= "https" (.getScheme uri)) (some? (.getHost uri))) + (loopback? uri) + ;; Native applications use a reverse-domain scheme. + (and (some? (.getScheme uri)) + (str/includes? (.getScheme uri) ".") + (not (.isOpaque uri))))))) + +(defn redirect-matches? + "Exact callback match, except for the port of loopback HTTP URLs. + Native applications may allocate a different local port for each login." + [registered requested] + (and (redirect-uri? requested) + (some (fn [value] + (or (= value requested) + (let [a (uri value) + b (uri requested)] + (and (loopback? a) (loopback? b) + (= (.getHost a) (.getHost b)) + (= (.getRawPath a) (.getRawPath b)) + (= (.getRawQuery a) (.getRawQuery b)))))) + (:redirect_uris registered)))) + +(defn- logo-uri? + [value] + (when-let [uri (uri value)] + (and (= "https" (.getScheme uri)) + (some? (.getHost uri)) + (nil? (.getUserInfo uri)) + (nil? (.getFragment uri))))) + +(defn validate-metadata + "Validate untrusted metadata and retain the fields this server supports. + CIMD selects public-client authentication from the advertised methods; + dynamic registration also supports Basic and form-post client secrets." + [metadata cimd?] + (let [redirects (:redirect_uris metadata) + grants (get metadata :grant_types ["authorization_code"]) + methods (if cimd? + (or (:token_endpoint_auth_methods_supported metadata) + [(get metadata :token_endpoint_auth_method "none")]) + [(get metadata :token_endpoint_auth_method "client_secret_basic")]) + method (if cimd? "none" (first methods))] + (when-not (and (map? metadata) + (vector? redirects) (<= 1 (count redirects) 20) + (every? redirect-uri? redirects) + (or (nil? (:client_name metadata)) + (and (string? (:client_name metadata)) + (<= 1 (count (:client_name metadata)) 200))) + ;; Published metadata describes client capabilities across + ;; servers. Extra grants do not require us to issue them. + (if cimd? + (and (vector? grants) (every? string? grants) + (some #{"authorization_code"} grants)) + (= ["authorization_code"] grants)) + (= ["code"] (get metadata :response_types ["code"])) + (or (nil? (:scope metadata)) (= "mcp" (:scope metadata))) + (if cimd? + (and (vector? methods) (some #{"none"} methods)) + (contains? #{"none" "client_secret_basic" "client_secret_post"} method))) + (invalid! "invalid_client_metadata")) + (cond-> {:redirect_uris redirects + :grant_types ["authorization_code"] + :response_types ["code"] + :scope "mcp" + :token_endpoint_auth_method method} + (:client_name metadata) (assoc :client_name (:client_name metadata)) + ;; Logos are optional browser-loaded images. Ignore unusable URLs + ;; instead of preventing authorization because of cosmetic metadata. + (logo-uri? (:logo_uri metadata)) (assoc :logo_uri (:logo_uri metadata))))) + +(defn register! + "Issue a reusable client ID and, when applicable, a separate client secret. + No client record is stored. The public ID carries only the secret hash; + the random nonce distinguishes registrations with otherwise equal metadata." + [_cfg metadata] + (let [metadata (validate-metadata metadata false) + secret (when-not (= "none" (:token_endpoint_auth_method metadata)) + (state/random-secret)) + ;; Omit fixed protocol values to keep IDs short; validation restores + ;; them on resolution. The version prefix defines the payload format. + payload (cond-> (-> metadata + (dissoc :grant_types :response_types :scope) + (assoc :nonce (state/random-secret))) + secret (assoc :secret-hash (state/digest secret))) + id (str "penpot-mcp-v1." + (.encodeToString (.withoutPadding (Base64/getUrlEncoder)) + (json/encode payload)))] + ;; Enforce the resolver's size cap here rather than issue an unusable ID. + (when (> (count id) 2048) + (invalid! "invalid_client_metadata")) + (cond-> (assoc metadata :client_id id + :client_id_issued_at (quot (System/currentTimeMillis) 1000)) + secret (assoc :client_secret secret :client_secret_expires_at 0)))) + +(defn- decode-client-id + [client-id] + ;; Open registration accepts client-supplied metadata, not a trusted identity. + ;; Changing these details changes the ID; codes are bound to the exact ID + ;; approved by the user, so an edited ID cannot redeem an existing code. + (try + (let [[_ encoded] (re-matches #"penpot-mcp-v1\.([A-Za-z0-9_-]+)" client-id) + _ (when-not encoded (invalid! "invalid_client")) + payload (json/decode (.decode (Base64/getUrlDecoder) ^String encoded)) + metadata (validate-metadata payload false) + nonce (:nonce payload) + secret-hash (:secret-hash payload)] + (when-not (and (string? nonce) (re-matches #"[A-Za-z0-9_-]{43}" nonce) + (if (= "none" (:token_endpoint_auth_method metadata)) + (nil? secret-hash) + (and (string? secret-hash) + (re-matches #"[A-Za-z0-9_-]{43}" secret-hash)))) + (invalid! "invalid_client")) + (cond-> (assoc metadata :client_id client-id) + secret-hash (assoc :secret-hash secret-hash))) + (catch Exception _ + (invalid! "invalid_client")))) + +(defonce ^:private metadata-cache + (cache/create :expire "1m" :max-size 256)) + +(defn- fetch-metadata + "Fetch a client's own HTTPS metadata document with bounded size and freshness. + The shared HTTP client checks SSRF targets and does not follow redirects." + [cfg client-id] + (let [response (http/req cfg {:method :get :uri client-id :timeout 5000 + :headers {"accept" "application/json"}} + {:response-type :input-stream})] + (with-open [^InputStream body (:body response)] + (when-not (and (= 200 (:status response)) + (str/starts-with? (get-in response [:headers "content-type"] "") + "application/json")) + (invalid! "invalid_client")) + ;; Bound both bytes read and time spent receiving the response body. + (let [reader (future (.readNBytes body 65537)) + bytes (deref reader 5000 ::timeout)] + (when (= ::timeout bytes) + (future-cancel reader) + (invalid! "invalid_client")) + (when (> (alength ^bytes bytes) 65536) + (invalid! "invalid_client")) + (let [metadata (json/decode bytes) + control (get-in response [:headers "cache-control"] "") + ;; The local cache ceiling never extends the publisher's TTL. + ;; Missing cache lifetime is treated as immediately stale. + max-age (some-> (re-find #"(?i)max-age=(\d+)" control) second parse-long) + ttl (if (re-find #"(?i)no-store|no-cache" control) + 0 + (min 60 (or max-age 0)))] + (when-not (= client-id (:client_id metadata)) + (invalid! "invalid_client")) + {:client (assoc (validate-metadata metadata true) :client_id client-id) + :expires (+ (System/currentTimeMillis) (* ttl 1000))}))))) + +(defn resolve-client + "Resolve a client ID to validated metadata for authorization or exchange. + HTTPS IDs use the document cache; versioned IDs decode locally without + PostgreSQL or Redis. Neither form establishes user approval." + [cfg client-id] + (when-not (and (string? client-id) (<= 1 (count client-id) 2048)) + (invalid! "invalid_client")) + (if-let [url (when (str/starts-with? client-id "https://") (uri client-id))] + (do + (when-not (and (.getHost url) + (nil? (.getUserInfo url)) + (nil? (.getFragment url)) + (not (str/blank? (.getPath url)))) + (invalid! "invalid_client")) + (try + (let [cached (cache/get metadata-cache client-id) + entry (if (and cached (> (:expires cached) (System/currentTimeMillis))) + cached + (fetch-metadata cfg client-id))] + (when-not (identical? cached entry) + (cache/invalidate metadata-cache client-id) + (cache/get metadata-cache client-id (constantly entry))) + (:client entry)) + (catch Exception _ + (invalid! "invalid_client")))) + (decode-client-id client-id))) diff --git a/backend/src/app/mcp/oauth_rate.lua b/backend/src/app/mcp/oauth_rate.lua new file mode 100644 index 0000000000..0a8a168cfc --- /dev/null +++ b/backend/src/app/mcp/oauth_rate.lua @@ -0,0 +1,5 @@ +-- Copyright (c) KALEIDOS SUBSIDIARY SL +-- MPL-2.0 +local count = redis.call('INCR', KEYS[1]) +if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end +return {count} diff --git a/backend/src/app/mcp/oauth_state.clj b/backend/src/app/mcp/oauth_state.clj new file mode 100644 index 0000000000..6f27a3b05e --- /dev/null +++ b/backend/src/app/mcp/oauth_state.clj @@ -0,0 +1,105 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.mcp.oauth-state + "Temporary approval requests and authorization codes in Redis/Valkey. + Handles are opaque; tenant, record kind and handle hash form the key. + Mutations compare the exact record that the HTTP handler validated." + (:require + [app.config :as cf] + [app.redis :as rds] + [app.redis.script :as-alias script] + [app.util.json :as json]) + (:import + java.nio.charset.StandardCharsets + java.security.MessageDigest + java.security.SecureRandom + java.util.Base64)) + +(defn random-secret + "32 random bytes encoded as a 43-character, unpadded base64url string." + [] + (let [bytes (byte-array 32)] + (.nextBytes (SecureRandom.) bytes) + (.encodeToString (.withoutPadding (Base64/getUrlEncoder)) bytes))) + +(defn digest + "Unpadded base64url SHA-256 digest, also used for PKCE S256 challenges." + [^String value] + (-> (.digest (MessageDigest/getInstance "SHA-256") + (.getBytes value StandardCharsets/UTF_8)) + (#(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) %)))) + +(defn secret= + [a b] + (and (string? a) (string? b) + (MessageDigest/isEqual (.getBytes ^String a StandardCharsets/UTF_8) + (.getBytes ^String b StandardCharsets/UTF_8)))) + +(defn- state-key + [kind handle] + (str "penpot:" (cf/get :tenant) ":mcp-oauth:" (name kind) ":" (digest handle))) + +(defn create! + "Store a new record for ttl seconds and return its random handle. + The handle is returned only after Redis confirms SET NX EX succeeded." + [cfg kind value ttl] + (let [handle (random-secret) + result (rds/run! cfg + (fn [{::rds/keys [conn]}] + (rds/set conn (state-key kind handle) + (json/encode-str value) {:ex ttl :nx true})))] + (when-not (= "OK" result) + (throw (ex-info "Unable to store OAuth state" {:oauth-error "temporarily_unavailable"}))) + handle)) + +(defn read! + "Read an unexpired record, or nil for a missing/malformed handle. + Return both decoded :value for validation and original :raw for atomic + comparison; serializing the decoded map again is not an equivalent check." + [cfg kind handle] + (when (and (string? handle) (re-matches #"[A-Za-z0-9_-]{43}" handle)) + (when-let [raw (rds/run! cfg (fn [{::rds/keys [conn]}] + (rds/get conn (state-key kind handle))))] + {:raw raw :value (json/decode raw)}))) + +(defn- compare-and-update! + "Atomically change only the exact record the caller read and validated. + Lua deletes for nil replacement, or replaces while preserving the TTL. + False means the record expired or another request changed/consumed it." + [cfg kind handle record replacement] + (let [result (rds/run! cfg + (fn [{::rds/keys [conn]}] + (rds/eval conn + {::script/name ::compare-and-update + ::script/path "app/mcp/oauth_state.lua" + ::script/keys [(state-key kind handle)] + ::script/vals [(:raw record) (or replacement "")]})))] + (= [1] result))) + +(defn consume! + "Delete a validated record once; true only for the request that consumes it." + [cfg kind handle record] + (compare-and-update! cfg kind handle record nil)) + +(defn replace! + "Replace a validated record without extending its original expiration." + [cfg kind handle record value] + (compare-and-update! cfg kind handle record (json/encode-str value))) + +(defn allow-request! + "Limit requests per endpoint kind, tenant and remote address. + The one-minute window begins with the first request; subsequent requests + increment the count without extending that window." + [cfg kind remote-address limit] + (let [[count] (rds/run! cfg + (fn [{::rds/keys [conn]}] + (rds/eval conn + {::script/name ::rate + ::script/path "app/mcp/oauth_rate.lua" + ::script/keys [(state-key kind (or remote-address "unknown"))] + ::script/vals [60]})))] + (and (number? count) (<= count limit)))) diff --git a/backend/src/app/mcp/oauth_state.lua b/backend/src/app/mcp/oauth_state.lua new file mode 100644 index 0000000000..b7bcd02330 --- /dev/null +++ b/backend/src/app/mcp/oauth_state.lua @@ -0,0 +1,11 @@ +-- Copyright (c) KALEIDOS SUBSIDIARY SL +-- MPL-2.0 +if redis.call('GET', KEYS[1]) ~= ARGV[1] then + return {0} +end +if ARGV[2] == '' then + redis.call('DEL', KEYS[1]) +else + redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL') +end +return {1} diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 5f1cb34883..5c7fb00756 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -124,7 +124,8 @@ (cond-> (and (not (uuid? session-pid)) (uuid? token-pid)) (-> (assoc ::auth-type :token) - (assoc ::token-perms (set (::actoken/perms request #{}))))) + (assoc ::token-perms (set (::actoken/perms request #{}))) + (assoc ::token-type (::actoken/type request)))) (cond-> key-id (assoc ::auth-key-id key-id)) ;; Server-side only: request bodies cannot forge @@ -171,10 +172,12 @@ [_ f mdata] (let [required-auth? (::auth mdata true) required-auth-type (::auth-type mdata) + required-token-type (::token-type mdata) required-perms (into #{} (::perms mdata))] (fn [cfg params] (let [profile-id (::profile-id params) auth-type (::auth-type params) + token-type (::token-type params) token-perms (set (::token-perms params #{}))] (cond (and required-auth? (not (uuid? profile-id))) @@ -182,8 +185,13 @@ :code :authentication-required :hint "authentication required for this endpoint") - (and (= required-auth-type :token) - (not= auth-type :token)) + (or (and (= required-auth-type :token) + (not= auth-type :token)) + (and required-token-type + (or (not= auth-type :token) + (not= required-token-type token-type))) + (and (= token-type "mcp") + (not= required-token-type "mcp"))) (ex/raise :type :authorization :code :token-auth-required :hint "access token authentication required for this endpoint") @@ -524,7 +532,7 @@ {:middleware [[mw/cors] [sec/client-header-check] [session/authz cfg] - [actoken/authz cfg]] + [actoken/authz (assoc cfg ::actoken/allow-mcp true)]] :handler (make-rpc-handler methods)}] (doc/routes :methods methods @@ -542,5 +550,5 @@ {:middleware [[mw/cors] [sec/client-header-check] [session/authz cfg] - [actoken/authz cfg]] + [actoken/authz (assoc cfg ::actoken/allow-mcp true)]] :handler (make-rpc-handler methods)}]]) diff --git a/backend/src/app/rpc/climit.clj b/backend/src/app/rpc/climit.clj index 82fe9ff234..3f7330238e 100644 --- a/backend/src/app/rpc/climit.clj +++ b/backend/src/app/rpc/climit.clj @@ -5,7 +5,16 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.climit - "Concurrencly limiter for RPC." + "Concurrencly limiter for RPC. + + A command declares its limits in its `::climit/id` metadata and takes them + before the root ones, so a request waiting for its own limits does not hold + the root permits of its profile. + + Every acquisition goes through this wrapper, so every path takes the + limiters in the same order. A command that calls another command from + inside its handler would take them twice: the second call waits for a + permit the first one holds, and answers 429 when the timeout runs out." (:refer-clojure :exclude [run!]) (:require [app.common.exceptions :as ex] @@ -221,16 +230,6 @@ (def ^:private idseq (AtomicLong. 0)) -(def ^:private ^ThreadLocal held-limiters - ;; Limiters a thread is inside of, identified by the limiter instance, which - ;; already encodes the limit id and its key: the cache is per id+key pair. - ;; `pbh/invoke!` runs the handler on the calling thread, so a handler that - ;; enters climit again for a limit it holds finds it here. Without this a - ;; thread parks forever behind a permit it holds itself. - (ThreadLocal/withInitial - (reify java.util.function.Supplier - (get [_] (java.util.HashSet.))))) - (defn- impl-invoke [metrics counters cache config handler] (let [limit-id (::id config) @@ -249,69 +248,57 @@ :permits (or (:permits config) (:concurrency config) 1) :queue-max (or (:queue config) (:queue-size config))} limiter (cache/get cache limit-id (partial create-limiter config)) - held ^java.util.Set (.get ^ThreadLocal held-limiters)] + tpoint (ct/tpoint) + req-id (.incrementAndGet ^AtomicLong idseq) + waiting (volatile! true) + stop-waiting (fn [] + ;; Exactly one decrement per increment: the flag + ;; makes a second call a no-op and the `finally` + ;; guarantees that there is one. + (when @waiting + (vreset! waiting false) + (waiting-dec metrics counters limit-name)))] - (if (.contains held limiter) - ;; The calling thread already holds this limiter for this key: run the - ;; handler inline instead of queueing behind itself. - (do - (l/trc :hint "reentrant invocation" :id limit-id :label limit-label) - (handler)) + (try + ;; The increment lives inside the `try`, so a throwable from the + ;; publishing path unwinds through the `finally`: the count comes back + ;; down instead of staying up for good. + (waiting-inc metrics counters limit-name) - (let [tpoint (ct/tpoint) - req-id (.incrementAndGet ^AtomicLong idseq) - waiting (volatile! true) - stop-waiting (fn [] - ;; Exactly one decrement per increment: the flag - ;; makes a second call a no-op and the `finally` - ;; guarantees that there is one. - (when @waiting - (vreset! waiting false) - (waiting-dec metrics counters limit-name)))] + (let [stats (pbh/get-stats limiter)] + (log limit "enqueued" req-id stats nil nil nil)) - (.add held limiter) + (pbh/invoke! limiter (fn [] + (stop-waiting) + (let [elapsed (tpoint) + stats (pbh/get-stats limiter)] + (measure metrics mlabels elapsed) + (log limit "acquired" req-id stats elapsed nil nil) + (handler)))) - (try - ;; The increment lives inside the `try`, so a throwable from the - ;; publishing path unwinds through the `finally`: the thread drops the - ;; mark and the count comes back down instead of staying up for good. - (waiting-inc metrics counters limit-name) - - (let [stats (pbh/get-stats limiter)] - (log limit "enqueued" req-id stats nil nil nil)) - - (pbh/invoke! limiter (fn [] - (stop-waiting) - (let [elapsed (tpoint) - stats (pbh/get-stats limiter)] - (measure metrics mlabels elapsed) - (log limit "acquired" req-id stats elapsed nil nil) - (handler)))) - - (catch ExceptionInfo cause - (let [{:keys [type code] :as data} (ex-data cause)] - (if (= :bulkhead-error type) - (let [elapsed (tpoint) - stats (pbh/get-stats limiter) - reason (if (some? (:timeout data)) "timeout" "queue-full")] - (mtx/run! metrics - :id :rpc-climit-rejected - :inc 1 - :labels (into-array String [limit-name reason])) - (log limit "rejected" req-id stats elapsed code reason) - (ex/raise :type :concurrency-limit - :code code - :hint "concurrency limit reached" - :cause cause)) - (throw cause)))) - - (finally - (.remove held limiter) - (stop-waiting) + (catch ExceptionInfo cause + (let [{:keys [type code] :as data} (ex-data cause)] + (if (= :bulkhead-error type) (let [elapsed (tpoint) - stats (pbh/get-stats limiter)] + stats (pbh/get-stats limiter) + reason (if (some? (:timeout data)) "timeout" "queue-full")] + (mtx/run! metrics + :id :rpc-climit-rejected + :inc 1 + :labels (into-array String [limit-name reason])) + (log limit "rejected" req-id stats elapsed code reason) + (ex/raise :type :concurrency-limit + :code code + :hint "concurrency limit reached" + :cause cause)) + (throw cause)))) - (log limit "finished" req-id stats elapsed nil nil)))))))) + (finally + (stop-waiting) + (let [elapsed (tpoint) + stats (pbh/get-stats limiter)] + + (log limit "finished" req-id stats elapsed nil nil)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; MIDDLEWARE @@ -340,6 +327,16 @@ :else (throw (IllegalArgumentException. "unable to normalize limit"))))) +(defn- resolve-limit-key + "The key of a limit, resolved the same way in every path: a keyword is + looked up in the params, a function is applied to them, any other value is + the key itself." + [key params] + (cond + (keyword? key) (get params key) + (fn? key) (key params) + :else key)) + (defn wrap [cfg handler {label ::sv/name :as mdata}] (if-let [climit (::rpc/climit cfg)] @@ -363,7 +360,7 @@ (fn [cfg params] (let [config (-> config - (assoc ::key (key-fn params)) + (assoc ::key (resolve-limit-key key-fn params)) (assoc ::label label) ;; NOTE: only used for debugging output (assoc ::params params))] @@ -373,40 +370,11 @@ (l/wrn :hint "no config found for specified queue" :id (id->str limit-id)) handler))) handler - ;; The root limiters go last so they end up outermost: every path - ;; meets them before the command's own limits. The nested - ;; `climit/invoke!` chains carry only the command's own limits and - ;; run inside those of the enclosing call, so this puts both paths - ;; in the same order. With the opposite order two calls of the same - ;; profile hold the same limiters inverted and park for good. - (concat (get-limits mdata) global-limits)) + ;; The root limiters go first so they end up innermost: a command + ;; takes the limits it declares before the root ones. Every path + ;; meets the limiters in the same order, because every acquisition + ;; goes through this wrapper. + (concat global-limits (get-limits mdata))) handler)) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; PUBLIC API -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- build-exec-chain - [{:keys [::label ::rpc/climit] :as cfg} f] - (reduce (fn [handler [limit-id limit-key]] - (if-let [config (get-config climit limit-id)] - (let [config (-> config - (assoc ::key limit-key) - (assoc ::label label))] - (fn [cfg params] - (let [config (assoc config ::params params)] - (invoke climit config (partial handler cfg params))))) - (do - (l/wrn :hint "config not found" :label label :id limit-id) - f))) - f - (get-limits cfg))) - -(defn invoke! - "Run a function in context of climit." - [{:keys [::rpc/climit] :as cfg} f params] - (let [f (if climit - (build-exec-chain cfg f) - f)] - (f cfg params))) diff --git a/backend/src/app/rpc/commands/access_token.clj b/backend/src/app/rpc/commands/access_token.clj index 09aa9189c2..8474d2ba80 100644 --- a/backend/src/app/rpc/commands/access_token.clj +++ b/backend/src/app/rpc/commands/access_token.clj @@ -6,12 +6,15 @@ (ns app.rpc.commands.access-token (:require + [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uuid :as uuid] [app.db :as db] + [app.http.access-token :as actoken] [app.main :as-alias main] [app.rpc :as-alias rpc] + [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] [app.rpc.quotes :as quotes] [app.setup :as-alias setup] @@ -28,16 +31,27 @@ AND id != ? AND type = 'mcp'") +(def ^:private sql:get-valid-mcp-token + "SELECT * FROM access_token + WHERE profile_id = ? + AND type = 'mcp' + AND (expires_at IS NULL OR expires_at > now()) + ORDER BY created_at DESC + LIMIT 1") + (defn create-access-token "Create an access token with empty perms. Elevated permissions (e.g. error-reports:read) are not assignable via the public API; grant them with SQL or `repl:grant-access-token-perm`." [{:keys [::db/conn] :as cfg} profile-id name expiration type] + ;; Serialize regeneration with OAuth provisioning for the same profile. + (when (= type "mcp") + (db/get-by-id conn :profile profile-id {::db/for-update true})) (let [token-id (uuid/next) expires-at (some-> expiration (ct/in-future)) created-at (ct/now) - token-iss (if (= type "mcp") "urn:penpot:mcp-token" "access-token") + token-iss (if (= type "mcp") actoken/mcp-token-issuer "access-token") token (tokens/generate cfg {:iss token-iss :uid profile-id :iat created-at @@ -62,6 +76,27 @@ (decode-row token))) +(defn ensure-mcp-access! + "Enable MCP and return the profile's unexpired token, creating it if absent. + Must run in a transaction. Lock the profile even when no token exists, so + concurrent approvals and settings regeneration use the same lock. Enabling + MCP must not regenerate a token already held by a connected client; an + expired token is replaced because no client can use it, and so is a token + issued before MCP tokens had their own issuer, or one this instance cannot + decrypt, because the backend rejects both." + [{:keys [::db/conn] :as cfg} profile-id] + (let [profile (profile/get-profile conn profile-id ::db/for-update true) + token (db/exec-one! conn [sql:get-valid-mcp-token profile-id]) + ;; A token this instance cannot decrypt is as unusable as an expired one + token (when (= actoken/mcp-token-issuer + (ex/ignoring (:iss (tokens/decode cfg (:token token))))) + token)] + (when-not (:mcp-enabled (:props profile)) + (profile/update-profile-props cfg profile-id {:mcp-enabled true})) + (if token + (decode-row token) + (create-access-token cfg profile-id "MCP" nil "mcp")))) + (defn repl:create-access-token [cfg profile-id name expiration] (db/tx-run! cfg create-access-token profile-id name expiration)) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 29d3fcdd52..35dbf7eb67 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -50,9 +50,10 @@ (let [output (tmp/tempfile*)] (try - (-> (bfc/export-cfg cfg {:ids #{file-id} - :export-type (bfc/resolve-export-type params)}) - (bf.v3/export-files! output)) + (-> (bfc/export-cfg cfg {:file-ids #{file-id} + :export-type (bfc/resolve-export-type params) + :output output}) + (bf.v3/export-files!)) (let [data (sto/content output) object (sto/put-object! storage @@ -126,6 +127,35 @@ result)) +(defn- prepare-import-event + "Build the audit event of an import. The RPC layer cannot emit it + because the import runs later, inside the SSE response, and the + event must carry the ids of the files it creates." + [params manifest] + (-> (audit/event-from-rpc-params params) + (assoc :type "command") + (assoc :name "import-binfile") + (assoc :props (-> params + (dissoc :file) + (assoc :generated-by (:generated-by manifest)) + (assoc :referer (:referer manifest)) + (audit/clean-props))) + (assoc ::webhooks/event? true))) + +(defn- import-binfile-and-audit + [cfg params event] + (try + (let [result (import-binfile cfg params) + file-ids (:file-ids result)] + (audit/submit cfg (update event :props assoc + :file-ids (vec file-ids) + :file-count (count file-ids) + :status "completed")) + result) + (catch Throwable cause + (audit/submit cfg (assoc-in event [:props :status] "failed")) + (throw cause)))) + (def ^:private schema:import-binfile [:and [:map {:title "import-binfile" :closed true} @@ -151,6 +181,7 @@ ["2.15" "Add upload-id param for chunked upload support"]] ::webhooks/event? true + ::audit/skip true ::sse/stream? true ::sm/params schema:import-binfile ::rpc/multipart-only-params [:file] @@ -168,10 +199,6 @@ :binfile-v1 1 :binfile-v3 3)) - params (-> params - (assoc :profile-id profile-id) - (assoc :version version)) - manifest (case (int version) 1 nil @@ -179,10 +206,10 @@ (throw (ex-info (str "Unsupported binfile version: " version) {:type :validation :code :unsupported-version - :version version})))] + :version version}))) - (with-meta - (sse/response (partial import-binfile cfg params)) - {::audit/props {:file nil - :generated-by (:generated-by manifest) - :referer (:referer manifest)}}))) + params (assoc params :version version) + event (prepare-import-event params manifest) + params (assoc params :profile-id profile-id)] + + (sse/response (partial import-binfile-and-audit cfg params event)))) diff --git a/backend/src/app/rpc/commands/feedback.clj b/backend/src/app/rpc/commands/feedback.clj index 7ff4076da7..69354345a1 100644 --- a/backend/src/app/rpc/commands/feedback.clj +++ b/backend/src/app/rpc/commands/feedback.clj @@ -13,11 +13,57 @@ [app.config :as cf] [app.db :as db] [app.email :as eml] + [app.loggers.audit :as-alias audit] [app.rpc :as-alias rpc] [app.rpc.climit :as-alias climit] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] - [app.util.services :as sv])) + [app.rpc.helpers :as rph] + [app.util.services :as sv] + [clojure.string :as str])) + +(def schema:send-mcp-feedback + [:map {:title "send-mcp-feedback" :closed true} + [:subject [:and [:string {:min 1 :max 120}] + [:fn #(and (not (str/blank? %)) + (not (re-find #"\p{Cc}" %)))]]] + ;; Line breaks and tabs are the only control characters allowed + [:feedback [:and [:string {:min 1 :max 5000}] + [:fn #(and (not (str/blank? %)) + (not (re-find #"[\p{Cc}&&[^\t\n\r]]" %)))]]]]) + +(sv/defmethod ::send-mcp-feedback + {::rpc/token-type "mcp" + ::db/transaction true + ::doc/added "2.20" + ::sm/params schema:send-mcp-feedback} + [cfg {:keys [::rpc/profile-id subject feedback]}] + (let [profile (profile/get-profile cfg profile-id) + destination (cf/get :mcp-feedback-email)] + ;; Blocking a profile invalidates its sessions but not its access tokens + (when (:is-blocked profile) + (ex/raise :type :authorization + :code :profile-blocked + :hint "profile is marked as blocked")) + (when-not (:is-active profile) + (ex/raise :type :authorization + :code :profile-not-active + :hint "profile is not active")) + (when (str/blank? destination) + (ex/raise :type :restriction + :code :feedback-disabled + :hint "MCP feedback is not configured")) + (eml/send cfg {::eml/reuse-conn true + :to destination + :reply-to (:email profile) + :subject (str "[MCP feedback] " subject) + :body {"text/plain" (str "Submitted by: " (:fullname profile) + " <" (:email profile) ">\n" + "Profile ID: " (:id profile) "\n\n" + "Feedback:\n\n" feedback)}}) + ;; The audit event keeps who sent feedback, not its text + (rph/with-meta {:status "queued"} + {::audit/replace-props {}}))) (declare ^:private send-user-feedback!) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index b187474f15..66d55d3117 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -1039,14 +1039,13 @@ :library-id (str (:id ldata)) :file-id (str file-id)) - ;; the file as read, features included: the absorbed data carries - ;; pointer maps whose ids `absorb-assets` regenerated, and without - ;; `:features` the encode below would skip the pointer persistence - ;; and leave those fragments unwritten - (bfc/update-file! cfg (assoc file - :revn (inc (:revn file)) - :modified-at (ct/now) - :has-media-trimmed false))))) + (bfc/update-file! cfg {:id file-id + :features (:features file) + :migrations (:migrations file) + :revn (inc (:revn file)) + :data (:data file) + :modified-at (ct/now) + :has-media-trimmed false})))) (defn- get-referenced-files [cfg library-id] @@ -1452,38 +1451,46 @@ WHERE file_id = ANY(?::uuid[])") (def ^:private sql:restore-storage-objects - "WITH ids AS (SELECT unnest(?::uuid[]) AS file_id) - - UPDATE storage_object + ;; Collect referenced storage ids first (UUID set), then join by PK. + ;; Casting `storage_object.id` to text (or OR-ing that with `id IN`) forces + ;; a scan of deleted rows; cast the JSON side instead. + "WITH ids AS (SELECT unnest(?::uuid[]) AS file_id), + refs AS ( + SELECT fmo.media_id AS id + FROM file_media_object AS fmo + JOIN ids ON (ids.file_id = fmo.file_id) + WHERE fmo.deleted_at IS NULL + AND fmo.media_id IS NOT NULL + UNION + SELECT fmo.thumbnail_id + FROM file_media_object AS fmo + JOIN ids ON (ids.file_id = fmo.file_id) + WHERE fmo.deleted_at IS NULL + AND fmo.thumbnail_id IS NOT NULL + UNION + SELECT ft.media_id + FROM file_thumbnail AS ft + JOIN ids ON (ids.file_id = ft.file_id) + WHERE ft.deleted_at IS NULL + AND ft.media_id IS NOT NULL + UNION + SELECT fot.media_id + FROM file_tagged_object_thumbnail AS fot + JOIN ids ON (ids.file_id = fot.file_id) + WHERE fot.deleted_at IS NULL + AND fot.media_id IS NOT NULL + UNION + SELECT (fd.metadata->>'storage-ref-id')::uuid + FROM file_data AS fd + JOIN ids ON (ids.file_id = fd.file_id) + WHERE fd.deleted_at IS NULL + AND (fd.metadata->>'storage-ref-id') IS NOT NULL + ) + UPDATE storage_object AS so SET deleted_at = null - WHERE deleted_at IS NOT NULL - AND (id IN (SELECT media_id - FROM file_media_object AS fmo - JOIN ids ON (ids.file_id = fmo.file_id) - WHERE fmo.deleted_at IS NULL - AND fmo.media_id IS NOT NULL - UNION - SELECT thumbnail_id - FROM file_media_object AS fmo - JOIN ids ON (ids.file_id = fmo.file_id) - WHERE fmo.deleted_at IS NULL - AND fmo.thumbnail_id IS NOT NULL - UNION - SELECT ft.media_id - FROM file_thumbnail AS ft - JOIN ids ON (ids.file_id = ft.file_id) - WHERE ft.deleted_at IS NULL - AND ft.media_id IS NOT NULL - UNION - SELECT fot.media_id - FROM file_tagged_object_thumbnail AS fot - JOIN ids ON (ids.file_id = fot.file_id) - WHERE fot.deleted_at IS NULL - AND fot.media_id IS NOT NULL) - OR id::text IN (SELECT fd.metadata->>'storage-ref-id' - FROM file_data AS fd - JOIN ids ON (ids.file_id = fd.file_id) - WHERE fd.deleted_at IS NULL))") + FROM refs + WHERE so.id = refs.id + AND so.deleted_at IS NOT NULL") (defn- restore-files [conn file-ids] diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index 5f4f345d4b..1423a53881 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -9,6 +9,7 @@ [app.binfile.common :as bfc] [app.common.exceptions :as ex] [app.common.features :as-alias cfeat] + [app.common.files.migrations :as fmg] [app.common.schema :as sm] [app.common.time :as ct] [app.db :as db] @@ -54,7 +55,7 @@ ::sm/params schema:get-file-snapshot ::sm/result files/schema:file-with-permissions ::db/transaction true} - [cfg {:keys [::rpc/profile-id file-id id] :as params}] + [cfg {:keys [::rpc/profile-id file-id id features] :as params}] (let [perms (perms/get-file-read-permissions cfg profile-id file-id)] (files/check-read-permissions! perms) (let [snapshot (fsnap/get-snapshot cfg file-id id)] @@ -71,13 +72,37 @@ ;; file, so materialize them here: the preview UI applies the ;; response straight into state and cannot resolve pointers. ;; NOTE: the loader binds the live file-id, not the snapshot id. + ;; + ;; A snapshot taken while a migration was pending stores the old + ;; format. Apply the pending migrations in memory, the same pure + ;; step a normal read runs, and never persist them: the stored + ;; version keeps its data and restoring it re-migrates on the + ;; next read. `decode-snapshot` hands an ordered set, which is + ;; what `need-migration?`/`migrate` require; a stored NULL is + ;; regenerated from the snapshot's version. (let [base-file (bfc/get-file cfg file-id :load-data? false) - data (fdata/realize-pointers cfg file-id (:data snapshot))] + snapshot (assoc snapshot :id file-id) + file (if (fmg/need-migration? snapshot) + (let [file (fdata/realize cfg snapshot)] + (fmg/migrate-file + file + (delay (bfc/get-resolved-file-libraries cfg file)))) + (update snapshot :data + #(fdata/realize-pointers cfg file-id %))) + ;; Works like in `get-file`: a client that does not declare + ;; `fdata/objects-map` gets plain objects. The historical + ;; feature set is deliberately not checked against the + ;; enabled one: a version may predate a feature change. + file (if (and (contains? (:features file) "fdata/objects-map") + (not (contains? features "fdata/objects-map"))) + (fdata/realize-objects cfg file) + file)] (-> base-file - (assoc :data data) - (assoc :version (:version snapshot)) - (assoc :features (:features snapshot)) - (assoc :revn (:revn snapshot)) + (assoc :data (:data file)) + (assoc :version (:version file)) + (assoc :features (:features file)) + (assoc :migrations (:migrations file)) + (assoc :revn (:revn file)) (assoc :vern (rand-int 100000)) (assoc :permissions perms)))))) diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index a8d2606ae9..4508154c91 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -13,6 +13,7 @@ [app.common.files.branch-merge :as bm] [app.common.files.changes :as cpc] [app.common.files.migrations :as fmg] + [app.common.files.repair :as cfr] [app.common.files.validate :as val] [app.common.logging :as l] [app.common.schema :as sm] @@ -36,11 +37,13 @@ [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.util.blob :as blob] + [app.util.pointer-map :as pmap] [app.util.services :as sv] [clojure.set :as set])) (declare ^:private get-lagged-changes) (declare ^:private send-notifications!) +(declare ^:private send-repair-notifications) (declare ^:private update-file) (declare ^:private update-file*) (declare ^:private process-changes-and-validate) @@ -147,6 +150,27 @@ (contains? token-change-types type) (contains? file-change-types type))) +;; Not a real editor session: file-change messages with this id are +;; delivered to every connected session, including the saver. +(def repair-session-id + #uuid "a11c0de5-0000-4000-8000-000000000001") + +(defn- referential-integrity-error? + [cause] + (= :referential-integrity (:code (ex-data cause)))) + +(defn- record-repair-outcome + [cfg outcome] + (when-let [metrics (::mtx/metrics cfg)] + (when (mtx/metrics? metrics) + (mtx/run! metrics {:id :update-file-repair + :labels [(name outcome)] + :inc 1})))) + +(defn- error-codes + [errors] + (into [] (keep :code) errors)) + ;; If features are specified from params and the final feature ;; set is different than the persisted one, update it on the ;; database. @@ -256,7 +280,9 @@ :soft-validate-affected-ms (get timings :soft-validate-affected-ms) :soft-validate-schema-ms (get timings :soft-validate-schema-ms) :validate-affected-ms (get timings :validate-affected-ms) - :validate-schema-ms (get timings :validate-schema-ms))))) + :validate-schema-ms (get timings :validate-schema-ms) + :repair-ms (get timings :repair-ms) + :reprocess-changes-ms (get timings :reprocess-changes-ms))))) (catch Throwable cause ;; NOTE: `with-defer` hooks only run on the success path ;; (`handle-response`), so timings collected before a hard @@ -272,7 +298,9 @@ :soft-validate-affected-ms (get timings :soft-validate-affected-ms) :soft-validate-schema-ms (get timings :soft-validate-schema-ms) :validate-affected-ms (get timings :validate-affected-ms) - :validate-schema-ms (get timings :validate-schema-ms))) + :validate-schema-ms (get timings :validate-schema-ms) + :repair-ms (get timings :repair-ms) + :reprocess-changes-ms (get timings :reprocess-changes-ms))) (throw cause)))))) (defn- update-file* @@ -320,27 +348,65 @@ changes skip-validate)) deleted-at - (ct/plus timestamp (ct/duration {:hours 1}))] + (ct/plus timestamp (ct/duration {:hours 1})) - (when-let [file (::snapshot file)] - (let [deleted-at (ct/plus timestamp (ldel/get-deletion-delay team)) - label (str "internal/snapshot/" revn)] + repair + (::repair file) - (fsnap/create! cfg file - {:label label + snapshot-deleted-at + (ct/plus timestamp (ldel/get-deletion-delay team)) + + change-created-at + (cond-> timestamp + repair + (ct/plus (ct/duration {:millis 1})))] + + (if repair + (let [snapshot-file (or (::snapshot file) + (fdata/realize cfg (:original repair)))] + (fsnap/create! cfg snapshot-file + {:label (str "internal/repair/" revn) :created-by "system" - :deleted-at deleted-at + :deleted-at snapshot-deleted-at + :profile-id profile-id + :session-id repair-session-id})) + (when-let [file (::snapshot file)] + (fsnap/create! cfg file + {:label (str "internal/snapshot/" revn) + :created-by "system" + :deleted-at snapshot-deleted-at :profile-id profile-id :session-id session-id}))) + (when repair + (db/insert! conn :file-change + {:id (uuid/next) + :session-id repair-session-id + :profile-id profile-id + :created-at timestamp + :updated-at timestamp + :deleted-at deleted-at + :file-id (:id file) + :revn (:revn repair) + :version (:version file) + :features (into-array (:features file)) + :changes (blob/encode (:changes repair))} + {::db/return-keys false}) + (l/wrn :hint "file auto-repaired" + :file-id (str (:id file)) + :error-codes (error-codes (:errors repair)) + :error-count (count (:errors repair)) + :change-count (count (:changes repair))) + (record-repair-outcome cfg :repaired)) + ;; Insert change (xlog) with deleted_at in a future data for ;; make them automatically eleggible for GC once they expires (db/insert! conn :file-change {:id (uuid/next) :session-id session-id :profile-id profile-id - :created-at timestamp - :updated-at timestamp + :created-at change-created-at + :updated-at change-created-at :deleted-at deleted-at :file-id (:id file) :revn (:revn file) @@ -349,14 +415,17 @@ :changes (blob/encode changes)} {::db/return-keys false}) - (if (:is-branch file) - (persist-branch-file! cfg file op-changes media-index) - (persist-file! cfg file)) + (let [file (dissoc file ::repair)] + (if (:is-branch file) + (persist-branch-file! cfg file op-changes media-index) + (persist-file! cfg file))) (when (contains? cf/flags :redis-cache) (invalidate-caches! cfg file)) ;; Send asynchronous notifications + (when repair + (send-repair-notifications cfg params file repair)) (send-notifications! cfg params file) (let [branch? (:is-branch file) @@ -595,9 +664,115 @@ (l/error :hint "file validation error" :cause cause)))) +(defn- apply-changes + "Apply `changes` to `file` and run the media fix-up. Returns the + triple `[file op-changes media-index]`: the changes as the fix-up + rewrote them (the vector a branch appends to its op log) and the id + remapping the fix-up applied, nil when it applied none." + [cfg file changes] + (let [state (atom {}) + file (binding [cpc/*state* state] + (-> file + (update :data cpc/process-changes changes) + (update :data d/without-nils)))] + (if-let [media-refs (-> @state :media-refs not-empty)] + (let [[file media-index] (bfc/update-media-references! cfg file media-refs)] + [file (bm/remap-changes changes media-index) media-index]) + [file changes nil]))) + +(defn- fail-repair + [cfg file errors cause] + (l/wrn :hint "file auto-repair did not recover the file" + :file-id (str (:id file)) + :error-codes (error-codes errors) + :error-count (count errors)) + (record-repair-outcome cfg :unrepaired) + (throw cause)) + +(defn- repair-preexisting-damage + "Repair damage that was already in `original` on the pages/components + the client's `changes` touch, then re-apply those changes. Throws + `cause` when the client's changes are at fault or the repair cannot + fix the file." + [cfg original libs changes tracked tracked-0 cause] + (let [libs (or libs {})] + (try + (let [pre-errors + (binding [pmap/*tracked* nil] + (val/validate-file-affected original libs changes))] + + (when (empty? pre-errors) + (throw cause)) + + (when tracked + (reset! tracked tracked-0)) + + (let [trepair (ct/tpoint) + + result + (try + (binding [pmap/*tracked* tracked] + (cfr/repair-file-until-valid + original libs + (fn [file applied] + (binding [pmap/*tracked* nil] + (val/validate-file-affected file libs (into applied changes)))) + :initial-errors pre-errors)) + (finally + (record-validation-timing cfg :repair-ms "repair" (inst-ms (trepair))))) + + repair-changes (:changes result)] + + (when (seq (:errors result)) + (fail-repair cfg original (:errors result) cause)) + + (let [treprocess (ct/tpoint) + [repaired op-changes media-index] + (try + (binding [pmap/*tracked* tracked] + (apply-changes cfg (:file result) changes)) + (finally + (record-validation-timing cfg :reprocess-changes-ms "reprocess-changes" + (inst-ms (treprocess))))) + scope (into repair-changes changes)] + + (binding [pmap/*tracked* nil] + (val/validate-file-affected! repaired libs scope) + + (when (contains? cf/flags :file-schema-validation) + (let [t (ct/tpoint)] + (try + (val/validate-file-schema! repaired) + (finally + (record-validation-timing cfg :validate-schema-ms "validate-schema" (inst-ms (t)))))))) + + ;; A branch derives its data from its op log, so the log + ;; records the repair changes ahead of the client's ones. + [(-> repaired + (assoc :revn (+ (:revn original) 2)) + (assoc ::repair {:changes repair-changes + :errors pre-errors + :revn (inc (:revn original)) + :original (dissoc original ::snapshot)})) + (into (vec repair-changes) op-changes) + media-index]))) + (catch Throwable inner + (if (identical? inner cause) + (throw cause) + (do + (l/wrn :hint "file auto-repair failed" + :file-id (str (:id original)) + :cause inner) + (record-repair-outcome cfg :unrepaired) + (throw cause))))))) + (defn- process-changes-and-validate [cfg file changes skip-validate] - (let [should-resolve-libs? + (let [original file + tracked pmap/*tracked* + tracked-0 (some-> tracked deref) + + should-resolve-libs? (and (or (contains? cf/flags :file-validation) (contains? cf/flags :soft-file-validation)) (not skip-validate)) @@ -613,14 +788,6 @@ (when should-resolve-libs? (record-validation-timing cfg :resolve-libs-ms "resolve-libs" (inst-ms (tlibs)))))) - ;; The main purpose of this atom is provide a contextual state - ;; for the changes subsystem where optionally some hints can - ;; be provided for the changes processing. Right now we are - ;; using it for notify about the existence of media refs when - ;; a new shape is added. - state - (atom {}) - tproc (ct/tpoint) @@ -634,18 +801,15 @@ ;; pairing from the branch's own record. [file op-changes media-index] (try - (let [file - (binding [cpc/*state* state] - (-> (files/check-version! file) - (update :revn inc) - (update :data cpc/process-changes changes) - (update :data d/without-nils)))] - (if-let [media-refs (-> @state :media-refs not-empty)] - (let [[file media-index] (bfc/update-media-references! cfg file media-refs)] - [file (bm/remap-changes changes media-index) media-index]) - [file changes nil])) + (apply-changes cfg + (-> (files/check-version! file) + (update :revn inc)) + changes) (finally - (record-validation-timing cfg :process-changes-ms "process-changes" (inst-ms (tproc)))))] + (record-validation-timing cfg :process-changes-ms "process-changes" (inst-ms (tproc))))) + + pending-repair + (atom nil)] (fdata/with-untracked (when (contains? cf/flags :soft-file-validation) @@ -667,18 +831,25 @@ (let [t (ct/tpoint)] (try (val/validate-file-affected! file libs changes) + (catch Throwable cause + (reset! pending-repair cause) + (when-not (referential-integrity-error? cause) + (throw cause))) (finally (record-validation-timing cfg :validate-affected-ms "validate-affected" (inst-ms (t))))))) (when (and (contains? cf/flags :file-schema-validation) - (not skip-validate)) + (not skip-validate) + (nil? @pending-repair)) (let [t (ct/tpoint)] (try (val/validate-file-schema! file) (finally (record-validation-timing cfg :validate-schema-ms "validate-schema" (inst-ms (t)))))))) - [file op-changes media-index])) + (if-let [cause @pending-repair] + (repair-preexisting-damage cfg original libs changes tracked tracked-0 cause) + [file op-changes media-index]))) (defn- take-snapshot? "Defines the rule when file `data` snapshot should be saved." @@ -708,6 +879,34 @@ (mapv (fn [row] (update row :changes blob/decode))))) +(defn- send-repair-notifications + [cfg {:keys [team session-id] :as params} file repair] + (let [changes (:changes repair) + lchanges (filter library-change? changes) + msgbus (::mbus/msgbus cfg)] + + (mbus/pub! msgbus + :topic (:id file) + :message {:type :file-change + :profile-id (:profile-id params) + :file-id (:id file) + :session-id repair-session-id + :revn (:revn repair) + :vern (:vern file) + :changes changes + :repaired true}) + + (when (and (:is-shared file) (seq lchanges)) + (mbus/pub! msgbus + :topic (:id team) + :message {:type :library-change + :profile-id (:profile-id params) + :file-id (:id file) + :session-id session-id + :revn (:revn repair) + :modified-at (ct/now) + :changes lchanges})))) + (defn- send-notifications! [cfg {:keys [team changes session-id] :as params} file] (let [lchanges (filter library-change? changes) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index c5a78df076..3ba28923b0 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -239,6 +239,8 @@ (sv/defmethod ::create-file-media-object-from-url {::doc/added "1.17" ::doc/changes [["2.19" "The optional :id param is rejected with a params-validation error; the server always generates the identifier"]] + ::climit/id [[:process-image/by-profile ::rpc/profile-id] + [:process-image/global]] ::sm/params schema:create-file-media-object-from-url} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] (files/check-edition-permissions! pool profile-id file-id) @@ -262,16 +264,7 @@ (assoc :from-url? true) (assoc :content content) (assoc :name (d/nilv name "unknown")))] - - ;; NOTE: we use the climit here in a dynamic invocation because we - ;; don't want saturate the process-image limit with IO (download - ;; of external image) - - (-> cfg - (assoc ::climit/id [[:process-image/by-profile (:profile-id params)] - [:process-image/global]]) - (assoc ::climit/label "create-file-media-object-from-url") - (climit/invoke! #(db/run! %1 create-file-media-object %2) params)))) + (db/run! cfg create-file-media-object params))) ;; --- Clone File Media object (Upload and create from url) diff --git a/backend/src/app/rpc/commands/plugins.clj b/backend/src/app/rpc/commands/plugins.clj index 8f01b3b521..882ab5e597 100644 --- a/backend/src/app/rpc/commands/plugins.clj +++ b/backend/src/app/rpc/commands/plugins.clj @@ -34,8 +34,7 @@ (sv/defmethod ::add-profile-plugin {::doc/added "2.18" - ::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id] - [:profile-plugin-ops/global]] + ::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]] ::sm/params schema:add-profile-plugin ::sm/result ctp/schema:registry-entry ::db/transaction true} @@ -69,8 +68,7 @@ (sv/defmethod ::remove-profile-plugin {::doc/added "2.18" - ::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id] - [:profile-plugin-ops/global]] + ::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]] ::sm/params schema:remove-profile-plugin ::sm/result :nil ::db/transaction true} diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index cefd382b72..7cc977c6ca 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -306,6 +306,8 @@ (sv/defmethod ::update-profile-photo {:doc/added "1.1" + ::climit/id [[:process-image/by-profile ::rpc/profile-id] + [:process-image/global]] ::sm/params schema:update-profile-photo ::rpc/multipart-only-params [:file] ::sm/result :nil} @@ -355,13 +357,12 @@ :content-type (:mtype thumb)})) (defn upload-photo - [{:keys [::sto/storage] :as cfg} {:keys [file] :as params}] - (let [params (-> cfg - (assoc ::climit/id [[:process-image/by-profile (:profile-id params)] - [:process-image/global]]) - (assoc ::climit/label "upload-photo") - (climit/invoke! generate-thumbnail file))] - (sto/put-object! storage params))) + "Generate the thumbnail of a photo and store it. + + The caller declares the `process-image` limits: this helper runs inside + them, so a command that calls it has to take them." + [{:keys [::sto/storage] :as cfg} {:keys [file]}] + (sto/put-object! storage (generate-thumbnail cfg file))) ;; --- MUTATION: Delete Photo diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index f6af4116ab..a5938b4d4f 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -27,6 +27,7 @@ [app.msgbus :as mbus] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] + [app.rpc.climit :as-alias climit] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] @@ -1094,6 +1095,8 @@ (sv/defmethod ::update-team-photo {::doc/added "1.17" + ::climit/id [[:process-image/by-profile ::rpc/profile-id] + [:process-image/global]] ::sm/params schema:update-team-photo ::rpc/multipart-only-params [:file]} [cfg {:keys [::rpc/profile-id file] :as params}] diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index c8c4f83e8c..20cd899ae7 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -17,7 +17,7 @@ [app.common.time :as ct] [app.common.types.organization :as cto] [app.common.types.profile :refer [schema:profile, schema:basic-profile]] - [app.common.types.team :refer [schema:team]] + [app.common.types.team :as ctt :refer [schema:team]] [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] @@ -562,6 +562,55 @@ RETURNING id, deleted_at;") (db/exec! conn [sql:get-organization-member-team-counts ids-array]))))))) +;; ---- API: get-organization-member-teams + +(def ^:private sql:get-organization-member-teams + "SELECT t.id, t.name, t.photo_id, + CASE WHEN tpr.is_owner THEN 'owner' + WHEN tpr.is_admin THEN 'admin' + WHEN tpr.can_edit THEN 'editor' + ELSE 'viewer' + END AS role + FROM team_profile_rel AS tpr + JOIN team AS t ON t.id = tpr.team_id + WHERE tpr.profile_id = ? + AND t.id = ANY(?) + AND t.deleted_at IS NULL + AND t.is_default IS FALSE;") + +(def ^:private schema:get-organization-member-teams-params + [:map + [:profile-id ::sm/uuid] + [:team-ids [:or ::sm/uuid [:vector ::sm/uuid]]]]) + +(def ^:private schema:get-organization-member-teams-result + [:vector [:map + [:id ::sm/uuid] + [:name ::sm/text] + [:photo-url {:optional true} ::sm/uri] + [:role ctt/schema:role]]]) + +(sv/defmethod ::get-organization-member-teams + "Get the non-default teams a profile belongs to within a set of teams, + with the profile's role in each one." + {::doc/added "2.18" + ::sm/params schema:get-organization-member-teams-params + ::sm/result schema:get-organization-member-teams-result + ::nitrate/sso false} + [cfg {:keys [profile-id team-ids]}] + (let [team-ids (if (uuid? team-ids) [team-ids] team-ids)] + (if (empty? team-ids) + [] + (db/run! cfg (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (->> (db/exec! conn [sql:get-organization-member-teams profile-id ids-array]) + (mapv (fn [{:keys [photo-id role] :as row}] + (cond-> (-> row + (dissoc :photo-id) + (assoc :role (keyword role))) + photo-id (assoc :photo-url (files/resolve-public-uri photo-id)))))))))))) + + ;; API: invite-to-organization (defn- get-invitation-organization diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index 51075ecda0..a0bab89055 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -170,6 +170,89 @@ (db/get-update-count) (pos?))))))) +(defn update-profile-email! + "Support helper: change a profile's email address. + + Looks up the profile by its current email. Rejects with + `:email-already-exists` when the new address is already used by + another non-deleted profile. Does not clear bounce/complaint + reports; call `clear-email-complaint-reports!` for that." + [& {:keys [email new-email]}] + (assert (string? email) "expected email") + (assert (string? new-email) "expected new-email") + + (let [email (profile/clean-email email) + new-email (profile/clean-email new-email)] + (db/tx-run! sys/system + (fn [{:keys [::db/conn] :as system}] + (let [profile (profile/get-profile-by-email conn email)] + (when-not profile + (ex/raise :type :not-found + :code :profile-not-found + :hint "profile not found for email" + :email email)) + + (if (= email new-email) + :unchanged + (do + (when (profile/get-profile-by-email conn new-email) + (ex/raise :type :validation + :code :email-already-exists)) + + (db/update! conn :profile + {:email new-email} + {:id (:id profile)} + {::db/return-keys false}) + + (audit/insert system + {:name "update-profile-email" + :type "action" + :props {:id (:id profile) + :email email + :new-email new-email} + :context {:triggered-by "srepl" + :cause "explicit call to update-profile-email!"}}) + :updated))))))) + +(defn clear-email-complaint-reports! + "Support helper: delete bounce and complaint reports for an email + from `global_complaint_report` and, when a profile owns that email, + from `profile_complaint_report`. Also unmutes that profile so mail + can be sent again after a false-positive bounce or complaint." + [email] + (assert (string? email) "expected email") + + (let [email (profile/clean-email email)] + (db/tx-run! sys/system + (fn [{:keys [::db/conn] :as system}] + (let [profile (db/get* conn :profile {:email email}) + global-deleted + (-> (db/delete! conn :global-complaint-report {:email email}) + (db/get-update-count)) + profile-deleted + (if profile + (-> (db/delete! conn :profile-complaint-report + {:profile-id (:id profile)}) + (db/get-update-count)) + 0)] + (when profile + (db/update! conn :profile + {:is-muted false} + {:id (:id profile)} + {::db/return-keys false})) + + (when (or profile (pos? global-deleted)) + (audit/insert system + {:name "clear-email-complaint-reports" + :type "action" + :props (cond-> {:email email + :global-deleted global-deleted + :profile-deleted profile-deleted} + profile (assoc :id (:id profile))) + :context {:triggered-by "srepl" + :cause "explicit call to clear-email-complaint-reports!"}}) + :cleared)))))) + (defn parse-emails "Parse the emails into a seq of cleaned emails. Accepts a single email, a comma separated list of emails or a coll of emails. diff --git a/backend/src/app/srepl/procs/file_repair.clj b/backend/src/app/srepl/procs/file_repair.clj index f863f250cf..c671f2d736 100644 --- a/backend/src/app/srepl/procs/file_repair.clj +++ b/backend/src/app/srepl/procs/file_repair.clj @@ -6,7 +6,6 @@ (ns app.srepl.procs.file-repair (:require - [app.common.files.changes :as cfc] [app.common.files.repair :as cfr] [app.common.files.validate :as cfv] [app.common.logging :as l])) @@ -17,44 +16,28 @@ (defn repair-file "Internal helper for validate and repair the file. The operation is - applied multiple times untile file is fixed or max iteration counter + applied multiple times until file is fixed or max iteration counter is reached (default 10). - This function should not be used directly, it is used throught the + This function should not be used directly, it is used through the app.srepl.main/repair-file! helper. In practical terms this function is private and implementation detail." [file libs & {:keys [max-iterations] :or {max-iterations 10}}] - (let [validate-and-repair - (fn [file libs iteration] - (when-let [errors (not-empty (cfv/validate-file file libs))] - (l/trc :hint "repairing file" - :file-id (str (:id file)) - :iteration iteration - :errors (count errors)) - (let [changes (cfr/repair-file file libs errors)] - (-> file - (update :revn inc) - (update :data cfc/process-changes changes))))) + (let [orig-revn (:revn file) + result (cfr/repair-file-until-valid + file libs + (fn [file _] + (cfv/validate-file file libs)) + :max-iterations max-iterations) + file' (update (:file result) :revn (fnil + 0) (:iterations result))] - process-file - (fn [file libs] - (loop [file file - iteration 0] - (if (< iteration max-iterations) - (if-let [file (validate-and-repair file libs iteration)] - (recur file (inc iteration)) - file) - (do - (l/wrn :hint "max retry num reached on repairing file" - :file-id (str (:id file)) - :iteration iteration) - file)))) + (when (>= (:iterations result) max-iterations) + (l/wrn :hint "max retry num reached on repairing file" + :file-id (str (:id file)) + :iteration (:iterations result))) - file' - (process-file file libs)] - - (when (not= (:revn file) (:revn file')) + (when (not= orig-revn (:revn file')) (l/trc :hint "file repaired" :file-id (str (:id file)))) file')) diff --git a/backend/src/app/tasks/export_binfile.clj b/backend/src/app/tasks/export_binfile.clj index eaa1ba0f55..5ac105a087 100644 --- a/backend/src/app/tasks/export_binfile.clj +++ b/backend/src/app/tasks/export_binfile.clj @@ -8,7 +8,9 @@ "The `:export-binfile` job: it produces the `.penpot` artifact of the set of files frozen in its params." (:require + [app.binfile.common :as bfc] [app.binfile.jobs :as bfj] + [app.binfile.v3 :as bf.v3] [app.common.schema :as sm] [app.db :as db] [app.jobs :as jobs] @@ -64,14 +66,17 @@ (doseq [file-id file-ids] (files/check-read-permissions! cfg profile-id file-id)) - (let [output (tmp/tempfile* :suffix ".penpot")] + (let [output (tmp/tempfile* :suffix ".penpot") + cfg (bfc/export-cfg cfg {:file-ids file-ids + :export-type (:export-type params) + :output output})] (try - (bfj/export-files cfg context {:ids file-ids - :export-type (:export-type params) - :output output}) - (let [resource (js/put-resource cfg profile-id - (assoc artifact :content (sto/content output)))] - {:result (check-result (dissoc resource :resource-id)) + (bfj/check-active cfg context) + (bfj/run cfg context bf.v3/export-files!) + (let [resource (->> (assoc artifact :content (sto/content output)) + (js/put-resource cfg profile-id)) + result (check-result (dissoc resource :resource-id))] + {:result result :resource-id (:resource-id resource)}) (finally (fs/delete output)))))) diff --git a/backend/src/app/tasks/import_binfile.clj b/backend/src/app/tasks/import_binfile.clj index 754bb4bf25..27949b0074 100644 --- a/backend/src/app/tasks/import_binfile.clj +++ b/backend/src/app/tasks/import_binfile.clj @@ -8,9 +8,13 @@ "The `:import-binfile` job: it imports the package the job owns as a resource into the project frozen in its params." (:require + [app.binfile.common :as bfc] [app.binfile.jobs :as bfj] + [app.binfile.v1 :as bf.v1] + [app.binfile.v3 :as bf.v3] [app.common.exceptions :as ex] [app.common.schema :as sm] + [app.common.time :as ct] [app.db :as db] [app.jobs :as jobs] [app.jobs.storage :as js] @@ -101,6 +105,18 @@ :type :validation :code :invalid-result)) +(defn- touch-project + "Mark the destination project as modified. + + An import adds files, and the triggers of the schema only touch the + project on an update, so a project that just received files keeps its + old date and stays where it was in the list." + [cfg project-id] + (db/update! cfg :project + {:modified-at (ct/now)} + {:id project-id} + {::db/return-keys false})) + (defn execute-import "Plain handler, importable and testable without integrant. @@ -124,32 +140,38 @@ (try (projects/check-edition-permissions! cfg profile-id project-id) - (let [team (or (teams/get-team cfg - :profile-id profile-id - :project-id project-id) - (ex/raise :type :not-found - :code :project-not-found - :hint "the destination project is not available" - :profile-id profile-id - :project-id project-id)) - result (db/tx-run! cfg - (fn [tx-cfg] - ;; the core joins this transaction: it - ;; is the one that decides whether the - ;; whole import is kept or rolled back - (check-result - (assoc (bfj/import-files tx-cfg context - {:profile-id profile-id - :project-id project-id - :team team - :name (:name params) - :input input - :version (:version params)}) - :name (:name params) - :version (:version params)))))] - - result) - + (let [team (or (teams/get-team cfg + :profile-id profile-id + :project-id project-id) + (ex/raise :type :not-found + :code :project-not-found + :hint "the destination project is not available" + :profile-id profile-id + :project-id project-id))] + (db/tx-run! cfg (fn [tx-cfg] + ;; the core joins this transaction: it + ;; is the one that decides whether the + ;; whole import is kept or rolled back + (-> (bfc/import-cfg tx-cfg {:profile-id profile-id + :project-id project-id + :team team + :name (:name params) + :input input}) + (bfj/check-active context) + (bfj/run context + (fn [cfg] + (let [result (case (int (:version params)) + 1 (bf.v1/import-files! cfg) + 3 (bf.v3/import-files! cfg) + (ex/raise :type :validation + :code :unsupported-version + :hint "unsupported binfile version" + :version (:version params)))] + (touch-project cfg project-id) + result))) + (assoc :name (:name params)) + (assoc :version (:version params)) + (check-result))))) (finally (fs/delete input) (js/release-input cfg context))))) diff --git a/backend/test/backend_tests/binfile_jobs_test.clj b/backend/test/backend_tests/binfile_jobs_test.clj index e03cb79421..d5f33dbdd9 100644 --- a/backend/test/backend_tests/binfile_jobs_test.clj +++ b/backend/test/backend_tests/binfile_jobs_test.clj @@ -10,6 +10,7 @@ (:require [app.binfile.common :as bfc] [app.binfile.jobs :as bfj] + [app.binfile.v1 :as v1] [app.binfile.v3 :as v3] [app.common.time :as ct] [app.common.uuid :as uuid] @@ -59,6 +60,37 @@ [job-id] (jobs/make-context (jobs/get-job th/*system* job-id))) +(defn- run-export! + "Run an export through the adapter, as the job handler does: the + frozen cfg in, the progress out as job events." + [cfg context file-ids export-type output] + (let [cfg (bfc/export-cfg cfg {:file-ids file-ids + :export-type export-type + :output output})] + (bfj/check-active cfg context) + (bfj/run cfg context v3/export-files!))) + +(defn- run-import! + "Run an import through the adapter, as the job handler does: the + frozen cfg in, the progress out as job events. The project touch + stays in the handler, covered by its own tests." + [cfg context {:keys [profile-id project-id team name input version]}] + (bfj/check-active cfg context) + (let [cfg (bfc/import-cfg cfg {:profile-id profile-id + :project-id project-id + :team team + :name name + :input input})] + (bfj/run cfg context + (fn [cfg] + (case (int version) + 1 (v1/import-files! cfg) + 3 (v3/import-files! cfg) + (throw (ex-info "unsupported binfile version" + {:type :validation + :code :unsupported-version + :version version}))))))) + (defn- progress-events "The progress events of a job, oldest first, with decoded payloads." [job-id] @@ -88,9 +120,9 @@ :library-file-id (:id library)}) (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) (db/update! th/*system* :file {:deleted-at (ct/now)} @@ -118,10 +150,8 @@ job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) output (tmp/tempfile* :suffix ".penpot")] - (bfj/export-files (job-cfg) (job-context job-id) - {:ids #{file-id} - :export-type :detach-libraries - :output output}) + (run-export! (job-cfg) (job-context job-id) + #{file-id} :detach-libraries output) (t/testing "the artifact is a penpot file with its manifest" (t/is (fs/exists? output)) @@ -150,13 +180,13 @@ job-id (th/create-job* :import-binfile {:profile-id (:id profile) :resource-id (:resource-id staged)}) context (job-context job-id) - result (bfj/import-files (job-cfg) context - {:profile-id (:id profile) - :project-id (:default-project-id profile) - :team team - :name "imported" - :input (js/load-input th/*system* context) - :version 3})] + result (run-import! (job-cfg) context + {:profile-id (:id profile) + :project-id (:default-project-id profile) + :team team + :name "imported" + :input (js/load-input th/*system* context) + :version 3})] (t/testing "the package is imported in the destination project" (t/is (= 1 (count (:file-ids result))))) @@ -175,10 +205,9 @@ (with-redefs [jobs/heartbeat (fn [_cfg & {:as options}] (swap! reports conj (:progress options)) 1)] - (bfj/export-files (job-cfg) (job-context job-id) - {:ids #{file-id} - :export-type :detach-libraries - :output (tmp/tempfile* :suffix ".penpot")})) + (run-export! (job-cfg) (job-context job-id) + #{file-id} :detach-libraries + (tmp/tempfile* :suffix ".penpot"))) (t/testing "the file opens the run with its position" (let [event (first @reports)] @@ -207,10 +236,9 @@ ;; the taps themselves, before the adapter turns them into events (with-redefs [events/tap (fn [_type data] (swap! taps conj data))] - (bfj/export-files (job-cfg) (job-context job-id) - {:ids #{file-id} - :export-type :detach-libraries - :output (tmp/tempfile* :suffix ".penpot")})) + (run-export! (job-cfg) (job-context job-id) + #{file-id} :detach-libraries + (tmp/tempfile* :suffix ".penpot"))) (t/testing "the tap that opens the file carries the outer counters" (let [tap (first (filter #(= :file (:section %)) @taps))] @@ -269,13 +297,13 @@ (with-redefs [jobs/heartbeat (fn [_cfg & {:as options}] (swap! reports conj (:progress options)) 1)] - (bfj/import-files (job-cfg) context - {:profile-id (:id profile) - :project-id (:default-project-id profile) - :team team - :name "imported" - :input (js/load-input th/*system* context) - :version 3})) + (run-import! (job-cfg) context + {:profile-id (:id profile) + :project-id (:default-project-id profile) + :team team + :name "imported" + :input (js/load-input th/*system* context) + :version 3})) (t/testing "the run opens with the manifest, which has no units to count" (let [event (first @reports)] @@ -301,8 +329,8 @@ job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) context (job-context job-id)] - (t/testing "a running job is active" - (t/is (nil? (bfj/check-active th/*system* context)))) + (t/testing "a running job is active, and answers with the cfg to thread it" + (t/is (identical? th/*system* (bfj/check-active th/*system* context)))) (t/testing "a cancelled job is not" (th/db-update! :job {:status "cancelled"} {:id job-id}) @@ -314,10 +342,8 @@ (t/testing "so the export stops at its next progress point" (let [output (tmp/tempfile* :suffix ".penpot") cause (try - (bfj/export-files (job-cfg) context - {:ids #{file-id} - :export-type :detach-libraries - :output output}) + (run-export! (job-cfg) context + #{file-id} :detach-libraries output) nil (catch Throwable raised raised))] (t/is (= :interrupt (th/ex-type cause))) @@ -342,14 +368,14 @@ cause (ex-info "the job is no longer active" {:type :interrupt :code :job-interrupted})] (with-redefs [jobs/heartbeat (fn [& _] (throw cause))] - (let [raised (try (@#'bfj/with-progress th/*system* context - (fn [] - (swap! steps conj :before) - ;; the sink calls the beat - ;; inline, so its interrupt - ;; aborts the run here - (events/tap :progress {:section :file}) - (swap! steps conj :after))) + (let [raised (try (bfj/run th/*system* context + (fn [_cfg] + (swap! steps conj :before) + ;; the sink calls the beat + ;; inline, so its interrupt + ;; aborts the run here + (events/tap :progress {:section :file}) + (swap! steps conj :after))) nil (catch Throwable stopped stopped))] (t/is (= :interrupt (:type (ex-data raised)))) diff --git a/backend/test/backend_tests/binfile_svg_test.clj b/backend/test/backend_tests/binfile_svg_test.clj index 12cd353a8d..b954226726 100644 --- a/backend/test/backend_tests/binfile_svg_test.clj +++ b/backend/test/backend_tests/binfile_svg_test.clj @@ -246,10 +246,10 @@ (let [bundle (tmp/tempfile :prefix "penpot-export-" :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/file-ids #{(:id file)}) (assoc ::bfc/embed-assets false) - (assoc ::bfc/include-libraries false)) - (io/output-stream bundle)) + (assoc ::bfc/include-libraries false) + (assoc ::bfc/output (io/output-stream bundle)))) (let [evil (tmp/tempfile :prefix "penpot-evil-" :suffix ".zip") _ (tamper-svg-entry! bundle evil media-id (utf8bytes evil-text) :content-type content-type) @@ -364,7 +364,7 @@ (let [bundle (tmp/tempfile :prefix "penpot-export-v1-" :suffix ".bin")] (v1/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/file-ids #{(:id file)}) (assoc ::bfc/embed-assets false) (assoc ::bfc/include-libraries false)) (io/output-stream bundle)) diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index 888952eb0f..8dad9f5412 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -284,9 +284,9 @@ (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :detach-libraries)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries) + (assoc ::bfc/output (io/output-stream output)))) (let [result (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) @@ -373,9 +373,9 @@ (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :detach-libraries)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries) + (assoc ::bfc/output (io/output-stream output)))) (let [result (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) @@ -407,9 +407,9 @@ (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :detach-libraries)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries) + (assoc ::bfc/output (io/output-stream output)))) (let [result (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) @@ -473,9 +473,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Read the manifest and check external-libraries (let [manifest (v3/get-manifest th/*system* output)] @@ -508,9 +508,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :detach-libraries)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries) + (assoc ::bfc/output (io/output-stream output)))) ;; Only the file is exported, not the library (let [manifest (v3/get-manifest th/*system* output) @@ -569,9 +569,9 @@ (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :merge-libraries)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :merge-libraries) + (assoc ::bfc/output (io/output-stream output)))) ;; Only the file is exported, not the library (let [manifest (v3/get-manifest th/*system* output) @@ -645,9 +645,9 @@ (v3/export-files! (assoc th/*system* - ::bfc/ids #{(:id file)} - ::bfc/export-type :detach-libraries) - (io/output-stream output)) + ::bfc/file-ids #{(:id file)} + ::bfc/export-type :detach-libraries + ::bfc/output (io/output-stream output))) (let [names (zip-entry-names output)] (t/is (contains? names (str "files/" (:id file) "/tokens.json"))) @@ -696,9 +696,9 @@ (v3/export-files! (assoc th/*system* - ::bfc/ids #{(:id file)} - ::bfc/export-type :include-libraries) - (io/output-stream output)) + ::bfc/file-ids #{(:id file)} + ::bfc/export-type :include-libraries + ::bfc/output (io/output-stream output))) (let [files (import-output profile output) library' (get files (:name library)) @@ -733,9 +733,9 @@ (v3/export-files! (assoc th/*system* - ::bfc/ids #{(:id file)} - ::bfc/export-type :include-libraries) - (io/output-stream output)) + ::bfc/file-ids #{(:id file)} + ::bfc/export-type :include-libraries + ::bfc/output (io/output-stream output))) (t/is (contains? (zip-entry-names output) (str "files/" (:id file) "/tokens.json"))) @@ -769,9 +769,9 @@ (v3/export-files! (assoc th/*system* - ::bfc/ids #{(:id file)} - ::bfc/export-type :include-libraries) - (io/output-stream output)) + ::bfc/file-ids #{(:id file)} + ::bfc/export-type :include-libraries + ::bfc/output (io/output-stream output))) (let [files (import-output profile output) file-data (:data (get files (:name file)))] @@ -801,9 +801,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Remove the source library to simulate a cross-environment import ;; where the original library does not exist in the target team. @@ -865,9 +865,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :detach-libraries)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries) + (assoc ::bfc/output (io/output-stream output)))) ;; Remove the source library to simulate a cross-environment import ;; where no matching library exists in the target team. @@ -907,9 +907,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Remove the source library to simulate a cross-environment import ;; where the original library does not exist in the target team. @@ -985,9 +985,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Remove the source library and recreate a matching one owned by owner (db/update! th/*system* :file @@ -1067,9 +1067,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file1) (:id file2)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file1) (:id file2)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Remove the source library and recreate a matching one (db/update! th/*system* :file @@ -1168,9 +1168,9 @@ ;; Export file with link-later (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{file-id}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Verify manifest has external-libraries (let [manifest (v3/get-manifest th/*system* output) @@ -1205,9 +1205,9 @@ ;; Export file with link-later (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{file-id}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; First import (let [result1 (-> th/*system* @@ -1238,9 +1238,9 @@ ;; Export file with link-later (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{file-id}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Import with overwrite (file-id set) (let [result (-> th/*system* @@ -1267,9 +1267,9 @@ ;; Export file with link-later from team 1 (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{file-id}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Import in team 2 (where library with same name exists) (let [result (-> th/*system* @@ -1300,9 +1300,9 @@ ;; Export file with link-later (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{file-id}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Import in team 2 (no library exists) (let [result (-> th/*system* @@ -1325,9 +1325,9 @@ ;; Export file with link-later (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{file-id}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Import in team 2 (library with different name) (let [result (-> th/*system* @@ -1356,9 +1356,9 @@ ;; Export file with link-later (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{file-id}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Import in team 2 (library exists but not shared) (let [result (-> th/*system* @@ -1381,9 +1381,9 @@ ;; Export file with link-later (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{file-id}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Delete the library in team 2 (db/update! th/*system* :file @@ -1433,9 +1433,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Delete original libraries (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) @@ -1494,9 +1494,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Delete both libraries (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) @@ -1546,9 +1546,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Delete original libraries (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) @@ -1614,9 +1614,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Delete original libraries (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) @@ -1680,9 +1680,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file1) (:id file2)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file1) (:id file2)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Delete original library and recreate (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) @@ -1726,9 +1726,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file1) (:id file2)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file1) (:id file2)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Delete original library and recreate (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) @@ -1778,9 +1778,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file1) (:id file2)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file1) (:id file2)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Delete original libraries and recreate (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib-icons)}) @@ -1837,9 +1837,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Import as viewer (no edit permission on library) (let [result (-> th/*system* @@ -1879,9 +1879,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Import as editor (has edit permission) (let [result (-> th/*system* @@ -1919,9 +1919,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Verify slug in manifest (let [manifest (v3/get-manifest th/*system* output) @@ -1946,9 +1946,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Library with empty slug should be dropped from external-libraries (let [manifest (v3/get-manifest th/*system* output) @@ -1966,9 +1966,9 @@ ;; Export file with no libraries (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Manifest should have no external-libraries (let [manifest (v3/get-manifest th/*system* output)] @@ -2000,9 +2000,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Delete original and create with different case (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) @@ -2037,9 +2037,9 @@ ;; Export file with link-later (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{file-id}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Re-import in same team (let [result (-> th/*system* @@ -2075,9 +2075,9 @@ ;; Export file with link-later (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{file-id}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Import in team 2 (no library exists) (let [result (-> th/*system* @@ -2127,9 +2127,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Delete and recreate library (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) @@ -2182,9 +2182,9 @@ (let [output (tmp/tempfile :suffix ".zip")] (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Delete and create two libraries with same name (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) @@ -2240,9 +2240,9 @@ ;; Export file with link-later (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{file-id}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Re-import in same team (let [result (-> th/*system* @@ -2292,9 +2292,9 @@ ;; Export file with link-later (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/export-type :link-later)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{file-id}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/output (io/output-stream output)))) ;; Create a viewer profile (no edit permission on the library) (let [viewer (th/create-profile* th/*system* 2 {}) @@ -2342,10 +2342,10 @@ ;; type should win (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/file-ids #{file-id}) (assoc ::bfc/export-type :link-later) - (assoc ::bfc/include-libraries true)) - (io/output-stream output)) + (assoc ::bfc/include-libraries true) + (assoc ::bfc/output (io/output-stream output)))) ;; Verify manifest has external-libraries (only produced by link-later) (let [manifest (v3/get-manifest th/*system* output) @@ -2362,9 +2362,9 @@ (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :detach-libraries)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries) + (assoc ::bfc/output (io/output-stream output)))) ;; Import with max-zip-entries=1 — the exported ZIP has more entries (let [cfg (-> th/*system* @@ -2419,9 +2419,9 @@ (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :detach-libraries)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries) + (assoc ::bfc/output (io/output-stream output)))) ;; Import with max-binary-entry-size=1 — the media object will exceed this (let [cfg (-> th/*system* @@ -2504,10 +2504,10 @@ (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/file-ids #{(:id file)}) (assoc ::bfc/embed-assets false) - (assoc ::bfc/include-libraries false)) - (io/output-stream exported)) + (assoc ::bfc/include-libraries false) + (assoc ::bfc/output (io/output-stream exported)))) (let [bombed (tmp/tempfile :suffix ".zip")] (replace-zip-entry! exported bombed @@ -2550,10 +2550,10 @@ (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/file-ids #{(:id file)}) (assoc ::bfc/embed-assets false) - (assoc ::bfc/include-libraries false)) - (io/output-stream output)) + (assoc ::bfc/include-libraries false) + (assoc ::bfc/output (io/output-stream output)))) (let [cfg (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) @@ -2597,10 +2597,10 @@ (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/file-ids #{(:id file)}) (assoc ::bfc/embed-assets false) - (assoc ::bfc/include-libraries false)) - (io/output-stream exported)) + (assoc ::bfc/include-libraries false) + (assoc ::bfc/output (io/output-stream exported)))) (let [sizes (text-entries-sizes exported) max-single (apply max 0 sizes) @@ -2795,10 +2795,10 @@ (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id simple) (:id svg)}) + (assoc ::bfc/file-ids #{(:id simple) (:id svg)}) (assoc ::bfc/embed-assets false) - (assoc ::bfc/include-libraries false)) - (io/output-stream output)) + (assoc ::bfc/include-libraries false) + (assoc ::bfc/output (io/output-stream output)))) ;; import returns the imported file ids plus the library link ;; resolution; the test only needs the ids @@ -2874,10 +2874,10 @@ (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/file-ids #{(:id file)}) (assoc ::bfc/embed-assets false) - (assoc ::bfc/include-libraries false)) - (io/output-stream output)) + (assoc ::bfc/include-libraries false) + (assoc ::bfc/output (io/output-stream output)))) ;; import returns the imported file ids plus the library link ;; resolution; the test only needs the ids @@ -2931,9 +2931,9 @@ (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :detach-libraries)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries) + (assoc ::bfc/output (io/output-stream output)))) (let [out (th/command! {::th/type :create-file-branch ::rpc/profile-id (:id profile) diff --git a/backend/test/backend_tests/climit_test.clj b/backend/test/backend_tests/climit_test.clj index b5cef6bda3..090ae4d6b8 100644 --- a/backend/test/backend_tests/climit_test.clj +++ b/backend/test/backend_tests/climit_test.clj @@ -38,20 +38,16 @@ (mapcat :labels) (vec))) -(defn- saw-call? - "Wait until a metrics call mentions `label`." - [label timeout] - (let [deadline (+ (System/currentTimeMillis) timeout)] - (loop [] - (cond - (some (fn [call] (some #{label} (:labels call))) @calls) - true - - (> (System/currentTimeMillis) deadline) - false - - :else - (do (Thread/sleep 10) (recur)))))) +(defn- invoke! + "Run `f` under the limits the cfg declares, the way a command does: the key + of each limit comes from the params, and the root limiters come after the + ones the cfg declares." + [cfg f params] + (let [wrapped (climit/wrap cfg + f + {::sv/name "test-command" + ::climit/id (::climit/id cfg)})] + (wrapped cfg params))) (defn- failed-type "Run `f` and return its value, or the type of the error it raised." @@ -101,11 +97,10 @@ ;; ACQUISITION ORDER ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(t/deftest acquires-root-limits-before-the-command-own-limits - ;; A command declaring its own limits must meet the root limiters first. - ;; The nested `climit/invoke!` calls take only the command's own limits and - ;; run inside the root ones, so any other order lets two calls of the same - ;; profile hold the same limiters in opposite order and park for good. +(t/deftest acquires-the-command-own-limits-before-the-root-limits + ;; A command declaring its own limits must meet them before the root + ;; limiters. No command enters climit from inside its handler any more, so + ;; every path takes the limiters in the same order. (record-metrics (fn [] (th/with-climit [climit test-config] @@ -113,124 +108,57 @@ wrapped (climit/wrap {::rpc/climit climit} (fn [_ _] :ok) mdata) result (wrapped {::rpc/climit climit} {::rpc/profile-id (uuid/next)})] (t/is (= :ok result)) - (t/is (= ["root/by-profile" - "root/global" - "process-image/by-profile" - "process-image/global"] + (t/is (= ["process-image/by-profile" + "process-image/global" + "root/by-profile" + "root/global"] (acquired-in-order)))))))) -(t/deftest nested-limits-do-not-deadlock-with-the-command-own-limits - ;; Two paths over one profile: a command declaring its own limits (like - ;; `upload-file-media-object`) and a command that takes the root limiters and - ;; then asks for the same limits from inside its handler (like - ;; `create-file-media-object-from-url`). Each path holds what the other one - ;; waits for the moment the order differs, and nothing releases. +(t/deftest the-key-of-a-limit-comes-from-the-params + ;; The key of a limit is a function of the params: a keyword is looked up in + ;; them, and that is what tells one profile from another. Two calls with the + ;; same key share the limiter, and two calls with a different key do not. + (th/with-climit [climit {:test-key/by-profile {:permits 1 :timeout 2000}}] + (let [pid (uuid/next) + limits [[:test-key/by-profile ::rpc/profile-id]] + cfg {::rpc/climit climit + ::climit/id limits} + gate (promise) + entered (promise) + wrapped (climit/wrap cfg + (fn [_ params] + ;; only the call of the first profile waits + (when (= pid (::rpc/profile-id params)) + (deliver entered true) + @gate) + :ok) + {::sv/name "test-command" ::climit/id limits}) + holder (future (wrapped cfg {::rpc/profile-id pid}))] + + ;; The call of the same profile waits for the permit this thread holds. + (t/is (true? (deref entered 5000 false))) + (t/is (= :timeout (deref (future (wrapped cfg {::rpc/profile-id pid})) 300 :timeout))) + + ;; The call of another profile takes the permit of its own key. + (t/is (= :ok (deref (future (wrapped cfg {::rpc/profile-id (uuid/next)})) 5000 :timeout))) + + (deliver gate true) + (t/is (= :ok (deref holder 5000 :timeout)))))) + +(t/deftest a-command-without-limits-takes-the-root-limits + ;; A command that declares no limits of its own still takes the root ones, + ;; and in the same order as the ones of a command that declares them. (record-metrics (fn [] (th/with-climit [climit test-config] - (let [pid (uuid/next) - gate (promise) - entered (promise) + (let [mdata {::sv/name "test-command"} + wrapped (climit/wrap {::rpc/climit climit} (fn [_ _] :ok) mdata) + result (wrapped {::rpc/climit climit} {::rpc/profile-id (uuid/next)})] + (t/is (= :ok result)) + (t/is (= ["root/by-profile" + "root/global"] + (acquired-in-order)))))))) - handler-b (fn [cfg params] - (deliver entered true) - @gate - (-> cfg - (assoc ::climit/id [[:process-image/by-profile pid] - [:process-image/global]]) - (assoc ::climit/label "path-b") - (climit/invoke! (fn [_ _] :nested-ok) params))) - - wrapped-a (climit/wrap {::rpc/climit climit} - (fn [_ _] :ok) - {::sv/name "path-a" ::climit/id image-limits}) - - wrapped-b (climit/wrap {::rpc/climit climit} - handler-b - {::sv/name "path-b"})] - - ;; Path B takes the root limiters and waits inside its handler. - (let [fb (future (failed-type #(wrapped-b {::rpc/climit climit} - {::rpc/profile-id pid})))] - (t/is (true? (deref entered 5000 false))) - - ;; Path A, started now, takes its own limiters and then waits for the - ;; root ones, which B holds. The recorded metrics are how the test - ;; knows A got that far instead of guessing with a sleep. - (reset! calls []) - (let [fa (future (failed-type #(wrapped-a {::rpc/climit climit} - {::rpc/profile-id pid})))] - (t/is (true? (saw-call? "root/by-profile" 5000))) - - ;; Let B ask for the limits A holds: if the order differs, each path - ;; now waits for a permit the other one holds. - (deliver gate true) - - (t/is (= :ok (deref fa 15000 :timeout))) - (t/is (= :nested-ok (deref fb 15000 :timeout)))))))))) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; REENTRANCY -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(t/deftest reentrant-invoke-does-not-consume-a-second-permit - ;; A handler entering climit for a limit it already holds must not queue - ;; behind itself: it runs inline and takes no second permit. Another thread - ;; asking for the same limiter still waits, so the limit stays in place. - (th/with-climit [climit {:process-image/by-profile - {:permits 1 :timeout 2000}}] - (let [pid (uuid/next) - cfg {::rpc/climit climit - ::climit/id [[:process-image/by-profile pid]]} - inner (promise) - other (promise) - handler (fn [cfg params] - (deliver inner - (failed-type #(climit/invoke! cfg (fn [_ _] :inner-ok) params))) - (deliver other (future (climit/invoke! cfg (fn [_ _] :other-ok) params))) - (t/is (= :waiting (deref @other 300 :waiting))) - :outer-ok) - - outcome (failed-type #(climit/invoke! cfg handler {}))] - - ;; With no reentrancy handling the inner call waits for the permit this very - ;; thread holds and ends failing by timeout. - (t/is (= :inner-ok (deref inner 5000 :timeout))) - (t/is (= :outer-ok outcome)) - - ;; once the outer call releases, the other thread gets its permit - (t/is (= :other-ok (deref @other 5000 :timeout)))))) - -(t/deftest reentrant-tracking-is-cleaned-up-when-the-handler-fails - ;; If the mark is not removed, the thread keeps believing it holds the - ;; limiter and every later call of that key would skip the limit. - (th/with-climit [climit {:process-image/by-profile - {:permits 1 :timeout 1000}}] - (let [pid (uuid/next) - cfg {::rpc/climit climit - ::climit/id [[:process-image/by-profile pid]]} - holding (promise) - gate (promise)] - - ;; The failing call runs on this thread, so this thread is the one that - ;; must forget it held the limiter. - (t/is (thrown? clojure.lang.ExceptionInfo - (climit/invoke! cfg (fn [_ _] (throw (ex-info "boom" {}))) {}))) - - ;; another thread takes the only permit - (let [fb (future (climit/invoke! cfg (fn [_ _] - (deliver holding true) - @gate - :held) - {}))] - (t/is (true? (deref holding 5000 false))) - - ;; this thread must wait for it, not skip the limiter - (t/is (= :concurrency-limit - (failed-type #(climit/invoke! cfg (fn [_ _] :inner-ok) {})))) - - (deliver gate true) - (t/is (= :held (deref fb 5000 :timeout))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SHIPPED CONFIG @@ -239,14 +167,19 @@ (t/deftest example-config-bounds-every-wait ;; A limiter without `:timeout` parks the calling thread forever, which is ;; how a limit nobody releases turns into lost worker threads and days of - ;; silent 429s. Every entry of the shipped config must carry one. + ;; silent 429s. Every entry of the shipped config must carry one. A limiter + ;; without `:queue` lets its waiters pile up without bound, and a command + ;; takes its own limits before the root ones, so the queue of a limit is + ;; where its own pile-up waits. (let [path (io/resource "climit.edn")] (t/is (some? path)) (let [data (edn/read-string (slurp path))] (t/is (seq data)) (doseq [[id config] data] (t/is (int? (:timeout config)) - (str id " must declare a timeout")))))) + (str id " must declare a timeout")) + (t/is (int? (:queue config)) + (str id " must declare a queue")))))) (t/deftest warns-when-a-limiter-has-no-timeout ;; The config that production loads lives outside the repository and is @@ -321,17 +254,17 @@ (let [name (limiter-name :test-waiting/by-profile) entered (promise) gate (promise) - holder (future (climit/invoke! (limiter-cfg climit :test-waiting/by-profile "a") - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! (limiter-cfg climit :test-waiting/by-profile "a") + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) ;; the holder is running, not waiting (reset! calls []) - (let [waiter (future (climit/invoke! (limiter-cfg climit :test-waiting/by-profile "a") - (fn [_ _] :waited) - {}))] + (let [waiter (future (invoke! (limiter-cfg climit :test-waiting/by-profile "a") + (fn [_ _] :waited) + {}))] (t/is (true? (wait-until #(= 1 (waiting-last name)) 5000))) (t/is (= 1 (waiting-count climit name))) @@ -350,30 +283,30 @@ (fn [] (th/with-climit [climit {:test-waiting-keys/by-profile {:permits 1 :timeout 3000}}] (let [name (limiter-name :test-waiting-keys/by-profile) - gates {:a (promise) :b (promise)} + gates {"a" (promise) "b" (promise)} holders (into {} (map (fn [k] (let [entered (promise)] [k {:entered entered - :future (future (climit/invoke! (limiter-cfg climit :test-waiting-keys/by-profile k) - (fn [_ _] - (deliver entered true) - @(gates k) - :held) - {}))}]))) - [:a :b])] + :future (future (invoke! (limiter-cfg climit :test-waiting-keys/by-profile k) + (fn [_ _] + (deliver entered true) + @(gates k) + :held) + {}))}]))) + ["a" "b"])] - (doseq [k [:a :b]] + (doseq [k ["a" "b"]] (t/is (true? (deref (:entered (holders k)) 5000 false)))) ;; one call per key, waiting behind its own holder (let [waiters (into {} (map (fn [k] - [k (future (climit/invoke! (limiter-cfg climit :test-waiting-keys/by-profile k) - (fn [_ _] :waited) - {}))])) - [:a :b])] + [k (future (invoke! (limiter-cfg climit :test-waiting-keys/by-profile k) + (fn [_ _] :waited) + {}))])) + ["a" "b"])] (t/is (true? (wait-until #(= 2 (waiting-count climit name)) 5000))) ;; the increment that reaches 2 always publishes 2, whichever waiter @@ -381,9 +314,9 @@ ;; contract, the value is. (t/is (some #(= 2 %) (waiting-value-of name))) - (doseq [k [:a :b]] (deliver (gates k) true)) + (doseq [k ["a" "b"]] (deliver (gates k) true)) - (doseq [k [:a :b]] + (doseq [k ["a" "b"]] (t/is (= :waited (deref (waiters k) 5000 :timeout))) (t/is (= :held (deref (:future (holders k)) 5000 :timeout)))) @@ -413,17 +346,17 @@ cfg (limiter-cfg climit :test-rejected/by-profile "a") entered (promise) gate (promise) - holder (future (climit/invoke! cfg - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! cfg + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) (reset! calls []) ;; the queue holds one waiter, so the next call is rejected right away - (let [queued (future (failed-type #(climit/invoke! cfg (fn [_ _] :queued) {})))] + (let [queued (future (failed-type #(invoke! cfg (fn [_ _] :queued) {})))] (t/is (true? (wait-until #(= 1 (waiting-last name)) 5000))) - (t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :extra) {})))) + (t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :extra) {})))) (t/is (= [[name "queue-full"]] (rejections))) (deliver gate true) @@ -436,13 +369,13 @@ cfg (limiter-cfg climit :test-timeout/by-profile "a") entered (promise) gate (promise) - holder (future (climit/invoke! cfg - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! cfg + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) (reset! calls []) - (t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :late) {})))) + (t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :late) {})))) (t/is (= [[name "timeout"]] (rejections))) (deliver gate true) @@ -458,14 +391,14 @@ cfg (limiter-cfg climit :test-rejected-baseline/by-profile "a") entered (promise) gate (promise) - holder (future (climit/invoke! cfg - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! cfg + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) (reset! calls []) - (t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :extra) {})))) + (t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :extra) {})))) (t/is (= 0 (waiting-count climit name))) (t/is (true? (wait-until #(= 0 (waiting-last name)) 5000))) @@ -482,7 +415,7 @@ (reset! calls []) (t/is (thrown? clojure.lang.ExceptionInfo - (climit/invoke! cfg (fn [_ _] (throw (ex-info "boom" {}))) {}))) + (invoke! cfg (fn [_ _] (throw (ex-info "boom" {}))) {}))) (t/is (= 0 (waiting-count climit name))) (t/is (true? (wait-until #(= 0 (waiting-last name)) 5000)))))))) @@ -510,21 +443,24 @@ (reset! calls []) (with-redefs [mtx/run! (failing-first-waiting-publish calls)] (t/is (thrown? clojure.lang.ExceptionInfo - (climit/invoke! cfg (fn [_ _] :never) {})))) + (invoke! cfg (fn [_ _] :never) {})))) - ;; the count came back down instead of staying up for good + ;; the count came back down instead of staying up for good. Only the + ;; last publication is asserted, not the exact list: the sampler + ;; republishes the same count when its first tick lands inside the + ;; window. (t/is (= 0 (waiting-count climit name))) - (t/is (= [0] (waiting-value-of name))) + (t/is (= 0 (waiting-last name))) ;; and the thread dropped the mark: with it left behind, this call would ;; run inline instead of waiting for the permit another thread holds. (let [entered (promise) gate (promise) - holder (future (climit/invoke! cfg - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! cfg + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) - (t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :inline) {})))) + (t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :inline) {})))) (deliver gate true) (t/is (= :held (deref holder 5000 :timeout))))))) @@ -549,13 +485,13 @@ (let [cfg (limiter-cfg climit :test-registry/by-profile "a") entered (promise) gate (promise) - holder (future (climit/invoke! cfg - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! cfg + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) - (let [waiter (future (climit/invoke! cfg (fn [_ _] :waited) {}))] + (let [waiter (future (invoke! cfg (fn [_ _] :waited) {}))] (t/is (true? (wait-until #(= 1.0 (gauge-value metrics name)) 5000))) (deliver gate true) (t/is (= :waited (deref waiter 5000 :timeout))) @@ -592,20 +528,20 @@ cfg (limiter-cfg climit :test-log/by-profile "a") entered (promise) gate (promise) - holder (future (climit/invoke! cfg - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! cfg + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) (reset! calls []) - (let [q1 (future (climit/invoke! cfg (fn [_ _] :q1) {})) - q2 (future (climit/invoke! cfg (fn [_ _] :q2) {}))] + (let [q1 (future (invoke! cfg (fn [_ _] :q1) {})) + q2 (future (invoke! cfg (fn [_ _] :q2) {}))] ;; the two of them fill the queue, so the next one is rejected (t/is (true? (wait-until #(= 2 (waiting-count climit name)) 5000))) (t/is (some #(= 2 %) (waiting-value-of name))) - (t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :x) {})))) + (t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :x) {})))) (let [props (::l/props (rejected-record records "queue-full"))] (t/is (= 1 (get props :permits))) @@ -630,14 +566,14 @@ gate (promise) capture (fn [_ action _req-id _stats _elapsed code reason] (swap! lines conj [action code reason])) - holder (future (climit/invoke! cfg - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! cfg + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) (with-redefs [climit/log capture] - (t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :late) {})))) + (t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :late) {})))) (t/is (= [["rejected" :capacity-limit-reached "timeout"]] (filterv #(= "rejected" (first %)) @lines)))) diff --git a/backend/test/backend_tests/db_test.clj b/backend/test/backend_tests/db_test.clj index 29d3095f32..aa6732ead5 100644 --- a/backend/test/backend_tests/db_test.clj +++ b/backend/test/backend_tests/db_test.clj @@ -18,6 +18,24 @@ (t/use-fixtures :once th/state-init) +(defn- get-consistent-pool-stats + "Read pool stats until total equals active + idle, or attempts run out. + Hikari exposes each counter through a separate MXBean call, so a borrow + or return between the reads can leave a single snapshot inconsistent + (total off by one). Retrying until the pool is quiescent keeps the test + deterministic without weakening the invariant." + [pool] + (loop [attempt 20] + (let [stats (db/pool-stats pool) + total (:total-connections stats) + active (:active-connections stats) + idle (:idle-connections stats)] + (if (or (= total (+ active idle)) + (zero? attempt)) + stats + (do (Thread/sleep 10) + (recur (dec attempt))))))) + (t/deftest pool-requires-metrics ;; Metrics is required by the pool schema: a pool wired without it ;; fails at the boundary (AssertionError when asserts are enabled) or @@ -28,7 +46,7 @@ {::db/uri (java.net.URI. "postgresql://localhost/test")})))) (t/deftest pool-stats-returns-expected-keys - (let [stats (db/pool-stats th/*pool*)] + (let [stats (get-consistent-pool-stats th/*pool*)] (t/testing "all expected keys are present" (t/is (contains? stats :active-connections)) (t/is (contains? stats :idle-connections)) diff --git a/backend/test/backend_tests/file_pointer_snapshot_test.clj b/backend/test/backend_tests/file_pointer_snapshot_test.clj index f71f50ed3b..15262eb93e 100644 --- a/backend/test/backend_tests/file_pointer_snapshot_test.clj +++ b/backend/test/backend_tests/file_pointer_snapshot_test.clj @@ -29,14 +29,16 @@ (t/use-fixtures :each th/database-reset) (defn- update-file! - [& {:keys [profile-id file-id changes revn] :or {revn 0}}] + [& {:keys [profile-id file-id changes revn features] + :or {revn 0 + features cfeat/supported-features}}] (let [params {::th/type :update-file ::rpc/profile-id profile-id :id file-id :session-id (uuid/random) :revn revn :vern 0 - :features cfeat/supported-features + :features features :changes changes} out (th/command! params)] (t/is (nil? (:error out))) @@ -516,7 +518,8 @@ (t/is (contains? @pmap/*tracked* (pmap/get-id wrapped))) (t/is (contains? @pmap/*tracked* (pmap/get-id unloaded))) (t/is (= {:a 1} @wrapped)) - (t/is (thrown? UnsupportedOperationException @unloaded))))) \n + (t/is (thrown? UnsupportedOperationException @unloaded))))) + (t/deftest legacy-snapshot-does-not-break-liveness ;; Snapshots written before the feature carry materialized data in ;; file_change.data (backend "legacy-db") instead of a file_data row. @@ -973,3 +976,49 @@ (fdata/realize-entry))))))] (t/is (= lib-page (:id page))) (t/is (contains? (:objects page) uuid/zero))))) + +(t/deftest feature-off-keeps-plain-snapshots-and-gc + ;; The feature is disabled by default (`fdata/pointer-map` is not in + ;; `cfeat/default-features`, only an explicit flag or team feature + ;; enables it). Pin that the paths this branch touches stay plain for + ;; such a file: a save takes a snapshot without pointers, no fragment + ;; rows appear, file-gc writes nothing, and the preview still resolves. + (with-redefs [cfeat/get-enabled-features (fn [& _] cfeat/default-features)] + (let [features (disj cfeat/supported-features + "fdata/pointer-map" + "fdata/objects-map") + profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + file-id (:id file) + page-id (first (get-in file [:data :pages]))] + + (t/is (not (contains? (:features file) "fdata/pointer-map"))) + (t/is (not (contains? (:features file) "fdata/objects-map"))) + (t/is (empty? (fragment-rows file-id))) + + (update-file! :profile-id (:id profile) + :file-id file-id + :revn 0 + :features features + :changes (add-rect-change page-id (uuid/random))) + + (t/is (empty? (fragment-rows file-id))) + + (let [snapshot (first (snapshots-by-revn file-id 0)) + snap (fsnap/get-snapshot th/*system* file-id (:id snapshot))] + (t/is (uuid? (:id snapshot))) + (t/is (empty? (fdata/get-used-pointer-ids (:data snap)))) + (t/is (true? (th/run-task! :file-gc {:file-id file-id}))) + (t/is (empty? (fragment-rows file-id))) + + (let [out (th/command! {::th/type :get-file-snapshot + ::rpc/profile-id (:id profile) + :file-id file-id + :id (:id snapshot) + :features features})] + (t/is (nil? (:error out))) + (t/is (map? (get-in out [:result :data]))) + (t/is (empty? (fdata/get-used-pointer-ids + (get-in out [:result :data]))))))))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 5daac2befb..6507ef356a 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -633,13 +633,19 @@ (defn init-climit "Build a concurrency limiter component from an inline config map. The optional second argument is the metrics instance to publish to (a - `metrics-stub` by default, where every `run!` is a no-op)." + `metrics-stub` by default, where every `run!` is a no-op). + + The root limiters are added with permits that no test reaches: every + request goes through them, and a test that does not list them would log a + warning per request." ([config] (init-climit config (metrics-stub))) ([config metrics] (let [file (doto (java.io.File/createTempFile "climit" ".edn") (.deleteOnExit))] - (spit file (pr-str config)) + (spit file (pr-str (merge {:root/global {:permits 1000 :timeout 3000} + :root/by-profile {:permits 1000 :timeout 3000}} + config))) (ig/init-key ::rpc/climit {::climit/enabled true ::climit/config (.getAbsolutePath file) diff --git a/backend/test/backend_tests/http_assets_test.clj b/backend/test/backend_tests/http_assets_test.clj index 1f6c473a51..c9503f1cca 100644 --- a/backend/test/backend_tests/http_assets_test.clj +++ b/backend/test/backend_tests/http_assets_test.clj @@ -31,7 +31,9 @@ io.prometheus.client.Counter io.prometheus.client.Counter$Child)) -(t/use-fixtures :once th/state-init) +(t/use-fixtures :once (t/compose-fixtures + (partial th/init-config [:enable-access-tokens]) + th/init-system)) (t/use-fixtures :each (th/serial th/database-reset th/clean-storage)) @@ -464,7 +466,7 @@ ;; 2. actoken/authz reads ::http/auth-data and sets ::actoken/profile-id request {::http/auth-data {:type :token :token (:token atoken) - :claims {:tid (:id atoken)}} + :claims (actoken/decode-token th/*system* (:token atoken))} :path-params {:id (str (:id object))}} ;; Apply actoken/authz middleware @@ -517,7 +519,7 @@ (t/testing "access token auth sets ::actoken/profile-id" (let [request {::http/auth-data {:type :token :token (:token atoken) - :claims {:tid (:id atoken)}}} + :claims (actoken/decode-token th/*system* (:token atoken))}} result (with-session request)] (t/is (= (:id profile) (::actoken/profile-id result))))) diff --git a/backend/test/backend_tests/http_debug_test.clj b/backend/test/backend_tests/http_debug_test.clj new file mode 100644 index 0000000000..9c874601f4 --- /dev/null +++ b/backend/test/backend_tests/http_debug_test.clj @@ -0,0 +1,29 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.http-debug-test + "The /dbg validate action walks the whole file and its libraries: it + must hand `get-resolved-file-libraries` the file (not its id) and + resolve the pointer-mapped pages with the file's own loader." + (:require + [app.http.debug :as debug] + [backend-tests.helpers :as th] + [clojure.test :as t] + [yetti.response :as-alias yres])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(t/deftest validate-file-action-resolves-pointer-mapped-data + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + handler (deref #'debug/validate-file) + result (handler th/*system* {:params {:file-id (str (:id file))}})] + + (t/is (= 200 (::yres/status result))) + (t/is (= "NO VALIDATION ERRORS FOUND" (::yres/body result))))) diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index fc4b58e5a4..98890f0691 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -34,7 +34,9 @@ io.prometheus.client.CollectorRegistry io.undertow.server.RequestTooBigException)) -(t/use-fixtures :once th/state-init) +(t/use-fixtures :once (t/compose-fixtures + (partial th/init-config [:enable-access-tokens]) + th/init-system)) (t/use-fixtures :each th/database-reset) (t/deftest auth-middleware-1 @@ -106,7 +108,8 @@ (let [response (handler nil)] (t/is (nil? response))) - (let [response (handler {::http/auth-data {:type :token :token "foobar" :claims {:tid (:id token)}}})] + (let [response (handler {::http/auth-data {:type :token :token (:token token) + :claims (tokens/verify th/*system* {:token (:token token)})}})] (t/is (= #{} (:app.http.access-token/perms response))) (t/is (= (:id profile) (:app.http.access-token/profile-id response)))))) @@ -114,9 +117,14 @@ (let [profile (th/create-profile* 1) token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil "mcp") - handler (#'app.http.access-token/wrap-authz identity th/*system*) - request {::http/auth-data {:type :token :token "foobar" :claims {:tid (:id token)}}} + handler (#'app.http.access-token/wrap-authz + identity (assoc th/*system* :app.http.access-token/allow-mcp true)) + request {::http/auth-data {:type :token :token (:token token) + :claims (tokens/verify th/*system* {:token (:token token)})}} response (handler request)] + (t/is (nil? (:app.http.access-token/profile-id + ((#'app.http.access-token/wrap-authz identity th/*system*) request))) + "MCP tokens do not authenticate routes that have not opted in") ;; Must set ::actoken/id from claims :tid (t/is (= (:id token) (:app.http.access-token/id response))) ;; Must set ::actoken/type from database @@ -125,6 +133,30 @@ (t/is (= #{} (:app.http.access-token/perms response))) (t/is (= (:id profile) (:app.http.access-token/profile-id response))))) +(t/deftest access-token-authz-follows-flags + (let [profile (th/create-profile* 1) + create #(db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token + (:id profile) "test" nil %) + request (fn [{:keys [token]}] + {::http/auth-data {:type :token :token token + :claims (tokens/verify th/*system* {:token token})}}) + auth (fn [flags token] + (with-redefs [cf/flags flags] + (let [handler (#'app.http.access-token/wrap-authz + identity (assoc th/*system* :app.http.access-token/allow-mcp true))] + (:app.http.access-token/profile-id (handler (request token)))))) + personal (create nil) + mcp (create "mcp")] + (t/testing "only mcp: MCP tokens authenticate, personal tokens do not" + (t/is (= (:id profile) (auth #{:mcp} mcp))) + (t/is (nil? (auth #{:mcp} personal)))) + (t/testing "only access-tokens: both authenticate" + (t/is (= (:id profile) (auth #{:access-tokens} mcp))) + (t/is (= (:id profile) (auth #{:access-tokens} personal)))) + (t/testing "neither flag: the middleware is not installed" + (with-redefs [cf/flags #{}] + (t/is (nil? ((:compile app.http.access-token/authz)))))))) + (defrecord MethodAwareDummyRequest [req-method headers] yreq/IRequest (method [_] req-method) diff --git a/backend/test/backend_tests/mcp_oauth_clients_test.clj b/backend/test/backend_tests/mcp_oauth_clients_test.clj new file mode 100644 index 0000000000..04db71a48b --- /dev/null +++ b/backend/test/backend_tests/mcp_oauth_clients_test.clj @@ -0,0 +1,219 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.mcp-oauth-clients-test + (:require + [app.mcp.oauth-clients :as clients] + [app.mcp.oauth-state :as state] + [app.util.json :as json] + [backend-tests.helpers :as th] + [clojure.string :as str] + [clojure.test :as t] + [mockery.core :refer [with-mocks]])) + +(t/use-fixtures :once th/state-init) + +(t/deftest registered-clients + (let [registration (clients/register! {} + {:client_name "Example" + :redirect_uris ["https://client.example/callback"]}) + client (clients/resolve-client {} (:client_id registration))] + (t/is (string? (:client_secret registration))) + (t/is (nil? (:client_secret client))) + (t/is (state/secret= (:secret-hash client) (state/digest (:client_secret registration)))) + (t/is (clients/redirect-matches? client "https://client.example/callback")) + (doseq [redirect ["https://client.example/callback?extra=1" + "https://client.example/callback/../evil" + "https://evil.example/callback"]] + (t/is (not (clients/redirect-matches? client redirect)))))) + +(t/deftest registrations-are-self-contained-and-distinct + (doseq [method ["none" "client_secret_basic" "client_secret_post"]] + (let [metadata {:client_name "Example α" + :logo_uri "https://client.example/logo.png" + :redirect_uris ["https://client.example/callback?source=mcp&mode=oauth"] + :token_endpoint_auth_method method} + first (clients/register! {} metadata) + second (clients/register! {} metadata) + client (clients/resolve-client {} (:client_id first))] + (t/is (not= (:client_id first) (:client_id second))) + (t/is (<= (count (:client_id first)) 2048)) + (t/is (= metadata (select-keys client (keys metadata)))) + (t/is (= (:client_id first) (:client_id client))) + (t/is (nil? (:client_secret client))) + (if (= method "none") + (do (t/is (nil? (:client_secret first))) + (t/is (nil? (:secret-hash client)))) + (t/is (state/secret= (:secret-hash client) + (state/digest (:client_secret first)))))))) + +(t/deftest oversized-registration-is-rejected + (let [metadata {:redirect_uris [(str "https://client.example/" (apply str (repeat 1800 "a")))]}] + (t/is (= "invalid_client_metadata" + (try (clients/register! {} metadata) + (catch clojure.lang.ExceptionInfo cause (:oauth-error (ex-data cause)))))))) + +(t/deftest unusable-client-logos-are-ignored + ;; An optional image must not prevent a client from authorizing. + (doseq [logo [nil "" 42 "/logo.png" "http://client.example/logo.png" + "javascript:alert(1)" "data:image/svg+xml," + "https://user:secret@client.example/logo.png" + (str "https://client.example/" (apply str (repeat 2048 "a")))]] + (let [client (clients/validate-metadata + {:redirect_uris ["https://client.example/callback"] + :logo_uri logo} + false)] + (t/is (not (contains? client :logo_uri)))))) + +(t/deftest client-id-size-boundary + (let [callback "https://client.example/callback/" + metadata {:redirect_uris [callback] :token_endpoint_auth_method "none"} + registration (clients/register! {} metadata) + [_ encoded] (str/split (:client_id registration) #"\." 2) + payload-size (alength (.decode (java.util.Base64/getUrlDecoder) encoded)) + ;; 1,525 payload bytes encode to 2,034 characters plus the 14-char prefix. + padding (apply str (repeat (- 1525 payload-size) "a")) + metadata (assoc metadata :redirect_uris [(str callback padding)]) + largest (clients/register! {} metadata)] + (t/is (= 2048 (count (:client_id largest)))) + (t/is (= (:redirect_uris metadata) + (:redirect_uris (clients/resolve-client {} (:client_id largest))))) + (t/is (= "invalid_client_metadata" + (try (clients/register! {} (update-in metadata [:redirect_uris 0] str "a")) + (catch clojure.lang.ExceptionInfo cause (:oauth-error (ex-data cause)))))))) + +(t/deftest malformed-client-identifiers-are-rejected + (doseq [id ["unknown" "penpot-mcp-v2.e30" "penpot-mcp-v1.e30" + "penpot-mcp-v1.W10" "penpot-mcp-v1.%%" "penpot-mcp-v1.bm90LWpzb24" + (str "penpot-mcp-v1." (apply str (repeat 2048 "a")))]] + (t/is (= "invalid_client" + (try (clients/resolve-client {} id) + (catch clojure.lang.ExceptionInfo cause (:oauth-error (ex-data cause)))))))) + +(t/deftest client-metadata-validation + (doseq [redirect ["https://client.example/callback#fragment" "javascript:alert(1)" + "http://client.example/callback" "https://user:secret@client.example/cb"]] + (t/is (thrown? clojure.lang.ExceptionInfo + (clients/validate-metadata {:redirect_uris [redirect]} false)))) + (t/is (clients/redirect-matches? {:redirect_uris ["http://127.0.0.1:3000/callback"]} + "http://127.0.0.1:12345/callback")) + (t/is (= "none" (:token_endpoint_auth_method + (clients/validate-metadata + {:redirect_uris ["https://chatgpt.com/connector_platform_oauth_redirect"] + :token_endpoint_auth_methods_supported ["none" "private_key_jwt"]} + true)))) + (t/is (thrown? clojure.lang.ExceptionInfo + (clients/validate-metadata + {:redirect_uris ["https://example.com/cb"] + :grant_types ["authorization_code" "refresh_token"]} false)))) + +(t/deftest client-metadata-documents-can-advertise-additional-grants + (let [metadata {:redirect_uris ["https://client.example/callback"] + :grant_types ["authorization_code" "refresh_token"]}] + (t/is (= ["authorization_code"] + (:grant_types (clients/validate-metadata metadata true)))))) + +(t/deftest client-metadata-documents-require-authorization-code-support + (doseq [grants [[] ["refresh_token"] "authorization_code" + ["authorization_code" nil] ["authorization_code" 1]]] + (t/is (thrown? clojure.lang.ExceptionInfo + (clients/validate-metadata + {:redirect_uris ["https://client.example/callback"] + :grant_types grants} + true))))) + +(t/deftest localhost-callbacks-allow-only-port-changes + (let [client {:redirect_uris ["http://localhost/callback"]}] + (t/is (clients/redirect-uri? "http://localhost/callback")) + (t/is (clients/redirect-matches? client "http://localhost:52351/callback")) + (doseq [redirect ["http://localhost.evil.example:52351/callback" + "http://127.0.0.1:52351/callback" + "http://localhost:52351/other" + "http://localhost:52351/callback?extra=1" + "http://localhost:52351/callback#fragment" + "http://user@localhost:52351/callback"]] + (t/is (not (clients/redirect-matches? client redirect)))) + (doseq [redirect ["http://localhost.evil.example/callback" + "http://user@localhost/callback"]] + (t/is (not (clients/redirect-uri? redirect)))))) + +(t/deftest codex-published-client-metadata + ;; Public metadata from https://chatgpt.com/oauth/codex/client.json. + ;; Keep this fixture offline so upstream changes cannot make CI flaky. + (let [client-id "https://chatgpt.com/oauth/codex/client.json" + metadata {:client_id client-id + :client_uri "https://chatgpt.com/codex" + :application_type "native" + :redirect_uris ["http://127.0.0.1/callback" "http://localhost/callback"] + :token_endpoint_auth_method "none" + :token_endpoint_auth_methods_supported ["none"] + :grant_types ["authorization_code" "refresh_token"] + :response_types ["code"] + :client_name "Codex" + :logo_uri "https://persistent.oaistatic.com/sonic/misc/openai-logo.png"}] + (with-mocks [dns {:target 'app.util.ssrf/resolve-host + :return (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "93.184.216.34")])} + sender {:target 'app.http.client/send! + :return (fn [& _] + {:status 200 + :headers {"content-type" "application/json" "cache-control" "no-store"} + :body (java.io.ByteArrayInputStream. (json/encode metadata))})}] + (let [client (clients/resolve-client th/*system* client-id)] + (t/is (= client-id (:client_id client))) + (t/is (= (:logo_uri metadata) (:logo_uri client))) + (t/is (= ["authorization_code"] (:grant_types client))) + (t/is (= "none" (:token_endpoint_auth_method client))) + (t/is (clients/redirect-matches? client "http://127.0.0.1:52351/callback")))))) + +(t/deftest client-metadata-documents + (let [client-id "https://client.example/client.json" + calls (atom 0) + metadata (atom {:client_id client-id + :client_name "Example" + :redirect_uris ["https://client.example/callback"] + :token_endpoint_auth_methods_supported ["none" "private_key_jwt"]})] + (with-mocks [dns {:target 'app.util.ssrf/resolve-host + :return (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "93.184.216.34")])} + sender {:target 'app.http.client/send! + :return (fn [_ _ _] + (swap! calls inc) + {:status 200 + :headers {"content-type" "application/json" "cache-control" "no-store"} + :body (java.io.ByteArrayInputStream. + (json/encode @metadata))})}] + (t/is (= "none" (:token_endpoint_auth_method (clients/resolve-client th/*system* client-id)))) + (swap! metadata assoc :redirect_uris ["https://client.example/changed"]) + (t/is (not (clients/redirect-matches? (clients/resolve-client th/*system* client-id) + "https://client.example/callback"))) + (t/is (= 2 @calls)) + (swap! metadata assoc :client_id "https://different.example/client.json") + (t/is (thrown? clojure.lang.ExceptionInfo (clients/resolve-client th/*system* client-id))) + (swap! metadata assoc :client_id client-id :client_name (apply str (repeat 65537 "a"))) + (t/is (thrown? clojure.lang.ExceptionInfo (clients/resolve-client th/*system* client-id)))))) + +(t/deftest client-metadata-rejects-unsafe-fetches + (with-mocks [sender {:target 'app.http.client/send! + :return (fn [& _] (throw (AssertionError. "Unsafe request reached HTTP client")))}] + (doseq [client-id ["https://127.0.0.1/client.json" + "https://169.254.169.254/client.json" + "https://[::1]/client.json" + "https://user:secret@example.com/client.json" + "https://example.com/client.json#fragment"]] + (t/is (thrown? clojure.lang.ExceptionInfo (clients/resolve-client th/*system* client-id)))) + (t/is (not (:called? @sender))))) + +(t/deftest client-metadata-rejects-redirects + (with-mocks [dns {:target 'app.util.ssrf/resolve-host + :return (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "93.184.216.34")])} + sender {:target 'app.http.client/send! + :return {:status 302 + :headers {"location" "https://127.0.0.1/internal"} + :body (java.io.ByteArrayInputStream. (byte-array 0))}}] + (t/is (thrown? clojure.lang.ExceptionInfo + (clients/resolve-client th/*system* "https://redirect.example/client.json"))))) diff --git a/backend/test/backend_tests/mcp_oauth_http_test.clj b/backend/test/backend_tests/mcp_oauth_http_test.clj new file mode 100644 index 0000000000..3a19d8bd93 --- /dev/null +++ b/backend/test/backend_tests/mcp_oauth_http_test.clj @@ -0,0 +1,253 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.mcp-oauth-http-test + (:require + [app.common.uri :as uri] + [app.config :as cf] + [app.db :as db] + [app.http :as http] + [app.http.errors :as errors] + [app.http.mcp-oauth :as oauth] + [app.http.session :as session] + [app.mcp.oauth-clients :as clients] + [app.mcp.oauth-state :as state] + [app.rpc.commands.access-token :as access-token] + [app.setup.clock :as clock] + [app.util.json :as json] + [backend-tests.helpers :as th] + [clojure.string :as str] + [clojure.test :as t] + [yetti.response :as yres]) + (:import java.net.URLEncoder java.nio.charset.StandardCharsets)) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset + (fn [run] + (binding [cf/config (assoc cf/config :tenant (str (java.util.UUID/randomUUID)))] + (run)))) + +(defn- form [params] + (str/join "&" (for [[key value] params] + (str (name key) "=" (URLEncoder/encode (str value) StandardCharsets/UTF_8))))) + +(defn- request [path & {:as options}] + (let [handler (#'http/router-handler (:app.http/router th/*system*))] + (handler (th/make-dummy-request (assoc options :path path))))) + +(defn- body [response] (json/decode (::yres/body response))) + +(defn- login-cookie [profile] + (let [session (session/create-session (:app.http.session/manager th/*system*) + {:profile-id (:id profile)}) + token (:token (#'session/assign-token th/*system* session))] + {(cf/get :auth-token-cookie-name) token})) + +(defn- begin [client] + (let [reply (request "/api/mcp/oauth/authorize" + :query (form {:client_id (:client_id client) + :redirect_uri (first (:redirect_uris client)) + :response_type "code" :scope "mcp" + :resource (oauth/resource) + :state "client-state" + :code_challenge (state/digest (apply str (repeat 43 "a"))) + :code_challenge_method "S256"}))] + (t/is (= 303 (::yres/status reply))) + (get (uri/query-string->map (.getQuery (java.net.URI. (get-in reply [::yres/headers "location"])))) + :request))) + +(t/deftest discovery-and-registration + (let [metadata (body (request "/.well-known/oauth-authorization-server/api/mcp/oauth")) + resource (body (request "/api/mcp/oauth/resource")) + registered (request "/api/mcp/oauth/register" :method :post + :headers {"content-type" "application/json"} + :body-bytes (json/encode-str {:redirect_uris ["https://example.com/callback"] + :token_endpoint_auth_method "none"}))] + (t/is (= (oauth/issuer) (:issuer metadata))) + (t/is (= ["S256"] (:code_challenge_methods_supported metadata))) + (t/is (= (oauth/resource) (:resource resource))) + (t/is (= 201 (::yres/status registered))) + (t/is (= "none" (:token_endpoint_auth_method (body registered)))) + (t/is (nil? (:client_secret (body registered)))))) + +(t/deftest complete-authorization-flow + (let [profile (th/create-profile* 1 {:is-active true}) + cookies (login-cookie profile) + client (clients/register! th/*system* {:redirect_uris ["https://example.com/callback"] + :logo_uri "https://example.com/logo.png" + :token_endpoint_auth_method "none"}) + handle (begin client) + details (request "/api/mcp/oauth/request" :query (form {:request handle}) :cookies cookies) + origin (str (cf/get-public-uri)) + origin (str/replace origin #"/$" "") + decision {:request handle :decision "approve" :csrf (:csrf (body details))} + submit #(request "/api/mcp/oauth/decision" :method :post :cookies cookies + :headers {"content-type" "application/x-www-form-urlencoded" "origin" origin} + :body-bytes (form %))] + (t/is (= 401 (::yres/status (request "/api/mcp/oauth/request" :query (form {:request handle}))))) + (t/is (= 200 (::yres/status details))) + (t/is (= "https://example.com/logo.png" (:logo-uri (body details)))) + (t/is (= 400 (::yres/status (submit (assoc decision :csrf "wrong"))))) + (let [approved (submit decision) + callback (java.net.URI. (:redirect-uri (body approved))) + params (uri/query-string->map (.getQuery callback)) + exchange {:grant_type "authorization_code" :code (:code params) + :client_id (:client_id client) :redirect_uri "https://example.com/callback" + :resource (oauth/resource) :code_verifier (apply str (repeat 43 "a"))} + redeem #(request "/api/mcp/oauth/token" :method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :body-bytes (form %))] + (t/is (= 200 (::yres/status approved))) + (t/is (= "client-state" (:state params))) + (t/is (= (oauth/issuer) (:iss params))) + (t/is (= 400 (::yres/status (submit decision)))) + ;; Metadata is public and editable, but changing it changes the client ID. + ;; Even with the correct verifier, an edited ID cannot use this approval. + (let [[prefix encoded] (str/split (:client_id client) #"\." 2) + payload (json/decode (.decode (java.util.Base64/getUrlDecoder) encoded))] + (doseq [changed [(assoc payload :client_name "Different application") + (assoc payload :redirect_uris ["https://attacker.example/callback"])]] + (let [id (str prefix "." (.encodeToString (.withoutPadding (java.util.Base64/getUrlEncoder)) + (json/encode changed)))] + (t/is (string? (:client_id (clients/resolve-client {} id)))) + (t/is (= "invalid_grant" (:error (body (redeem (assoc exchange :client_id id))))))))) + (t/is (= 400 (::yres/status (redeem (assoc exchange :code_verifier (apply str (repeat 43 "b"))))))) + (let [token-response (redeem exchange) + token (body token-response) + stored (first (db/query th/*pool* :access-token {:profile-id (:id profile) :type "mcp"}))] + (t/is (= 200 (::yres/status token-response))) + (t/is (= "no-store" (get-in token-response [::yres/headers "cache-control"]))) + (t/is (= (:token stored) (:access_token token))) + (t/is (= "Bearer" (:token_type token))) + (t/is (nil? (:expires_in token))) + (t/is (nil? (:refresh_token token))) + (t/is (= 400 (::yres/status (redeem exchange)))))))) + +(t/deftest denial-and-session-binding + (let [profile (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + cookies (login-cookie profile) + client (clients/register! th/*system* {:redirect_uris ["https://example.com/callback"] + :token_endpoint_auth_method "none"}) + handle (begin client) + details (body (request "/api/mcp/oauth/request" :query (form {:request handle}) :cookies cookies)) + params {:request handle :csrf (:csrf details) :decision "deny"} + submit #(request "/api/mcp/oauth/decision" :method :post :cookies cookies + :headers {"content-type" "application/x-www-form-urlencoded" "origin" %} + :body-bytes (form params))] + (t/is (= 403 (::yres/status + (request "/api/mcp/oauth/request" :query (form {:request handle}) + :cookies (login-cookie other))))) + (t/is (= 403 (::yres/status (submit "https://attacker.example")))) + (let [denied (submit (str/replace (str (cf/get-public-uri)) #"/$" "")) + callback (java.net.URI. (:redirect-uri (body denied))) + result (uri/query-string->map (.getQuery callback))] + (t/is (= "access_denied" (:error result))) + (t/is (= "client-state" (:state result))) + (t/is (empty? (db/query th/*pool* :access-token {:profile-id (:id profile)}))) + (t/is (not (:mcp-enabled (db/decode-transit-pgobject + (:props (th/db-get :profile {:id (:id profile)}))))))))) + +(t/deftest oauth-input-and-cors-errors + (doseq [data ["{" "[]" (apply str (repeat 16385 "a"))]] + (let [reply (request "/api/mcp/oauth/register" :method :post + :headers {"content-type" "application/json"} + :body-bytes data)] + (t/is (= 400 (::yres/status reply))) + (t/is (= "*" (get-in reply [::yres/headers "access-control-allow-origin"]))))) + (let [reply (request "/api/mcp/oauth/token" :method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :body-bytes "code=a&code=b")] + (t/is (= 400 (::yres/status reply)))) + (t/is (= 204 (::yres/status (request "/api/mcp/oauth/token" :method :options))))) + +(t/deftest confidential-client-exchange + (let [profile (th/create-profile* 1 {:is-active true}) + stored (db/tx-run! th/*system* access-token/ensure-mcp-access! (:id profile)) + verifier (apply str (repeat 43 "a"))] + (doseq [method ["client_secret_basic" "client_secret_post"]] + (let [client (clients/register! th/*system* + {:redirect_uris ["https://example.com/callback"] + :token_endpoint_auth_method method}) + code (state/create! th/*system* :code + {:client-id (:client_id client) + :redirect-uri "https://example.com/callback" + :resource (oauth/resource) + :challenge (state/digest verifier) + :profile-id (str (:id profile)) + :token-id (str (:id stored))} + 120) + params {:grant_type "authorization_code" :client_id (:client_id client) + :code code :code_verifier verifier :redirect_uri "https://example.com/callback"} + redeem (fn [secret] + (request "/api/mcp/oauth/token" :method :post + :headers (cond-> {"content-type" "application/x-www-form-urlencoded"} + (= method "client_secret_basic") + (assoc "authorization" + (str "Basic " (.encodeToString + (java.util.Base64/getEncoder) + (.getBytes (str (:client_id client) ":" secret) "UTF-8"))))) + :body-bytes (form (cond-> params + (= method "client_secret_post") + (assoc :client_secret secret)))))] + (t/is (= 401 (::yres/status (redeem "wrong")))) + (t/is (= (:token stored) (:access_token (body (redeem (:client_secret client)))))))))) + +(t/deftest exchange-rejects-expired-token + (let [profile (th/create-profile* 1 {:is-active true}) + stored (db/tx-run! th/*system* access-token/ensure-mcp-access! (:id profile)) + verifier (apply str (repeat 43 "a")) + client (clients/register! th/*system* {:redirect_uris ["https://example.com/callback"] + :token_endpoint_auth_method "none"}) + code (state/create! th/*system* :code + {:client-id (:client_id client) + :redirect-uri "https://example.com/callback" + :resource (oauth/resource) + :challenge (state/digest verifier) + :profile-id (str (:id profile)) + :token-id (str (:id stored))} + 120)] + (db/exec! th/*pool* ["UPDATE access_token SET expires_at = now() - interval '1 day' WHERE id = ?" + (:id stored)]) + (let [reply (request "/api/mcp/oauth/token" :method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :body-bytes (form {:grant_type "authorization_code" :code code + :client_id (:client_id client) :code_verifier verifier + :redirect_uri "https://example.com/callback"}))] + (t/is (= 400 (::yres/status reply))) + (t/is (= "invalid_grant" (:error (body reply))))))) + +(t/deftest unexpected-errors-are-logged + (let [logged (atom []) + cookies (login-cookie (th/create-profile* 1 {:is-active true})) + fail (fn [& _] (throw (RuntimeException. "unexpected")))] + (with-redefs [errors/handle (fn [cause _] (swap! logged conj (ex-message cause))) + clients/register! fail + clock/get-clock fail] + (t/testing "endpoint failures" + (let [reply (request "/api/mcp/oauth/register" :method :post + :headers {"content-type" "application/json"} + :body-bytes (json/encode-str {:redirect_uris ["https://example.com/callback"]}))] + (t/is (= 503 (::yres/status reply))) + (t/is (= {:error "temporarily_unavailable"} (body reply))) + (t/is (= "*" (get-in reply [::yres/headers "access-control-allow-origin"]))))) + (t/testing "session middleware failures" + (let [reply (request "/api/mcp/oauth/request" :query (form {:request "x"}) :cookies cookies)] + (t/is (= 503 (::yres/status reply))) + (t/is (= {:error "temporarily_unavailable"} (body reply))))) + (t/testing "OAuth rejections are not logged" + (t/is (= 400 (::yres/status (request "/api/mcp/oauth/token" :method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :body-bytes "code=a&code=b")))))) + (t/is (= ["unexpected" "unexpected"] @logged)))) + +(t/deftest authorization-rejects-unregistered-callback + (let [client (clients/register! th/*system* {:redirect_uris ["https://example.com/callback"]}) + reply (request "/api/mcp/oauth/authorize" + :query (form {:client_id (:client_id client) + :redirect_uri "https://attacker.example/callback"}))] + (t/is (= 400 (::yres/status reply))) + (t/is (nil? (get-in reply [::yres/headers "location"]))))) diff --git a/backend/test/backend_tests/mcp_oauth_state_test.clj b/backend/test/backend_tests/mcp_oauth_state_test.clj new file mode 100644 index 0000000000..196c8378a2 --- /dev/null +++ b/backend/test/backend_tests/mcp_oauth_state_test.clj @@ -0,0 +1,53 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.mcp-oauth-state-test + (:require + [app.config :as cf] + [app.mcp.oauth-state :as state] + [backend-tests.helpers :as th] + [clojure.test :as t] + [mockery.core :refer [with-mocks]])) + +(t/use-fixtures :once th/state-init) + +(t/deftest authorization-state + (let [cfg th/*system* + handle (state/create! cfg :code {:client-id "test"} 60) + record (state/read! cfg :code handle)] + (t/is (= {:client-id "test"} (:value record))) + (t/is (nil? (state/read! cfg :request handle))) + (t/is (false? (state/consume! cfg :code handle (assoc record :raw "wrong")))) + (t/is (= record (state/read! cfg :code handle))) + (let [results (mapv deref (repeatedly 8 #(future (state/consume! cfg :code handle record))))] + (t/is (= 1 (count (filter true? results))))) + (t/is (nil? (state/read! cfg :code handle))))) + +(t/deftest bind-pending-request-once + (let [cfg th/*system* + handle (state/create! cfg :request {:client-id "test"} 60) + record (state/read! cfg :request handle)] + (t/is (true? (state/replace! cfg :request handle record {:session-id "first"}))) + (t/is (false? (state/replace! cfg :request handle record {:session-id "second"}))) + (t/is (= {:session-id "first"} (:value (state/read! cfg :request handle)))))) + +(t/deftest pkce-and-secrets + ;; RFC 7636 appendix B + (t/is (= "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + (state/digest "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")))) + +(t/deftest expiry-and-tenant-isolation + (let [cfg th/*system* + handle (state/create! cfg :request {:test true} 1)] + (binding [cf/config (assoc cf/config :tenant "another-tenant")] + (t/is (nil? (state/read! cfg :request handle)))) + (Thread/sleep 1100) + (t/is (nil? (state/read! cfg :request handle))))) + +(t/deftest state-storage-fails-closed + (with-mocks [store {:target 'app.redis/set :return nil}] + (t/is (thrown? clojure.lang.ExceptionInfo + (state/create! th/*system* :request {:test true} 60))))) diff --git a/backend/test/backend_tests/offload_file_data_test.clj b/backend/test/backend_tests/offload_file_data_test.clj index f4c99c3310..e7158f0567 100644 --- a/backend/test/backend_tests/offload_file_data_test.clj +++ b/backend/test/backend_tests/offload_file_data_test.clj @@ -453,9 +453,9 @@ (v3/export-files! (-> th/*system* - (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/export-type :detach-libraries)) - (io/output-stream output)) + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries) + (assoc ::bfc/output (io/output-stream output)))) (let [result (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) diff --git a/backend/test/backend_tests/rpc_access_tokens_test.clj b/backend/test/backend_tests/rpc_access_tokens_test.clj index 5fa3a1496a..359bbf66fd 100644 --- a/backend/test/backend_tests/rpc_access_tokens_test.clj +++ b/backend/test/backend_tests/rpc_access_tokens_test.clj @@ -6,12 +6,15 @@ (ns backend-tests.rpc-access-tokens-test (:require + [app.common.time :as ct] [app.common.uuid :as uuid] [app.db :as db] [app.http :as http] [app.http.access-token :as actoken] [app.rpc :as-alias rpc] + [app.rpc.commands.access-token :as access-token] [app.storage :as sto] + [app.tokens :as tokens] [backend-tests.helpers :as th] [clojure.test :as t] [mockery.core :refer [with-mocks]])) @@ -207,7 +210,7 @@ (t/is (not (contains? all-ids (:id second-mcp)))) (t/is (contains? all-ids (:id third-mcp)))))))) -(t/deftest mcp-tokens-cannot-be-used-as-access-tokens +(t/deftest access-token-decoder-accepts-mcp-and-personal-issuers (let [prof (th/create-profile* 1 {:is-active true}) cfg th/*system*] @@ -218,9 +221,9 @@ :name "mcp token"}) mcp-token (:token result) - ;; Try to decode as access token (should fail) decoded (actoken/decode-token cfg mcp-token)] - (t/is (nil? decoded)))) + (t/is (= "urn:penpot:mcp-token" (:iss decoded))) + (t/is (= (:id prof) (:uid decoded))))) (t/testing "Regular access tokens use access-token issuer claim" (let [{:keys [result]} (th/command! {::th/type :create-access-token @@ -232,3 +235,71 @@ decoded (actoken/decode-token cfg access-token)] (t/is (some? decoded)) (t/is (= (:id prof) (:uid decoded))))))) + +(t/deftest oauth-mcp-token-provisioning + (let [prof (th/create-profile* 1 {:is-active true}) + cfg th/*system* + provision #(db/tx-run! cfg access-token/ensure-mcp-access! (:id prof))] + (t/testing "approval enables MCP and creates a fixed token" + (let [token (provision) + props (-> (th/db-get :profile {:id (:id prof)}) + :props db/decode-transit-pgobject)] + (t/is (string? (:token token))) + (t/is (nil? (:expires-at token))) + (t/is (true? (:mcp-enabled props))) + (t/is (= token (provision))))) + (t/testing "approval preserves a token generated in settings" + (let [{:keys [result error]} + (th/command! {::th/type :create-access-token + ::rpc/profile-id (:id prof) + :type "mcp" + :name "From settings"})] + (t/is (nil? error)) + (t/is (= (:token result) (:token (provision)))))) + (t/testing "approval replaces an expired token" + (db/exec! th/*pool* [(str "UPDATE access_token SET expires_at = now() - interval '1 day'" + " WHERE profile_id = ?") + (:id prof)]) + (let [expired (first (db/query th/*pool* :access-token {:profile-id (:id prof) :type "mcp"})) + token (provision)] + (t/is (not= (:token expired) (:token token))) + (t/is (nil? (:expires-at token))) + (t/is (= [(:id token)] + (map :id (db/query th/*pool* :access-token {:profile-id (:id prof) :type "mcp"})))))) + (t/testing "approval replaces a token issued with the personal-token issuer" + (db/delete! th/*pool* :access-token {:profile-id (:id prof)}) + (let [token-id (uuid/next) + now (ct/now) + old (tokens/generate cfg {:iss "access-token" :uid (:id prof) + :iat now :tid token-id})] + (db/tx-run! cfg (fn [{:keys [::db/conn]}] + (db/insert! conn :access-token + {:id token-id :name "MCP" :token old :type "mcp" + :profile-id (:id prof) :created-at now :updated-at now + :perms (db/create-array conn "text" [])}))) + (let [token (provision)] + (t/is (not= old (:token token))) + (t/is (= actoken/mcp-token-issuer + (:iss (actoken/decode-token cfg (:token token))))) + (t/is (= [(:id token)] + (map :id (db/query th/*pool* :access-token + {:profile-id (:id prof) :type "mcp"}))))))) + (t/testing "approval replaces a token this instance cannot decrypt" + (db/delete! th/*pool* :access-token {:profile-id (:id prof)}) + (let [now (ct/now)] + (db/tx-run! cfg (fn [{:keys [::db/conn]}] + (db/insert! conn :access-token + {:id (uuid/next) :name "MCP" :token "not-a-jwe" :type "mcp" + :profile-id (:id prof) :created-at now :updated-at now + :perms (db/create-array conn "text" [])}))) + (let [token (provision)] + (t/is (not= "not-a-jwe" (:token token))) + (t/is (= [(:id token)] + (map :id (db/query th/*pool* :access-token + {:profile-id (:id prof) :type "mcp"}))))))) + (t/testing "concurrent approvals share one token" + (db/delete! th/*pool* :access-token {:profile-id (:id prof)}) + (let [results (mapv deref (repeatedly 4 #(future (provision))))] + (t/is (= 1 (count (set (map :token results))))) + (t/is (= 1 (count (db/query th/*pool* :access-token + {:profile-id (:id prof) :type "mcp"})))))))) diff --git a/backend/test/backend_tests/rpc_binfile_test.clj b/backend/test/backend_tests/rpc_binfile_test.clj index 0447fe135d..0a829d23db 100644 --- a/backend/test/backend_tests/rpc_binfile_test.clj +++ b/backend/test/backend_tests/rpc_binfile_test.clj @@ -6,6 +6,7 @@ (ns backend-tests.rpc-binfile-test (:require + [app.binfile.common :as bfc] [app.binfile.v3 :as bf.v3] [app.common.schema :as sm] [app.common.uuid :as uuid] @@ -13,9 +14,11 @@ [app.db :as db] [app.rpc :as-alias rpc] [app.rpc.commands.binfile :as binfile] + [app.storage.tmp :as tmp] [backend-tests.helpers :as th] [clojure.test :as t] [datoteka.fs :as fs] + [datoteka.io :as io] [mockery.core :refer [with-mocks]])) (t/use-fixtures :once th/state-init) @@ -155,3 +158,69 @@ (t/is (= file-id (get-in (first events) [:props :id]))) (t/is (= (:id project) (get-in (first events) [:props :project-id]))) (t/is (= (:id team) (get-in (first events) [:props :team-id])))))))) + +(defn- export-file + [profile] + (let [file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + output (tmp/tempfile :suffix ".zip")] + (bf.v3/export-files! + (-> th/*system* + (assoc ::bfc/file-ids #{(:id file)}) + (assoc ::bfc/embed-assets false) + (assoc ::bfc/include-libraries false) + (assoc ::bfc/output (io/output-stream output)))) + output)) + +(defn- import-file + [profile path] + (let [out (th/multipart-command! {::th/type :import-binfile + ::rpc/profile-id (:id profile) + :project-id (:default-project-id profile) + :name "imported" + :file {:filename "imported.penpot" + :size (fs/size path) + :path path + :mtype "application/zip"}})] + (t/is (nil? (:error out))) + (th/consume-sse (:result out)))) + +(defn- import-events + [mock] + (->> (:call-args-list @mock) + (map second) + (filter #(= "import-binfile" (:name %))))) + +(t/deftest import-binfile-audit-event-records-file-ids + (with-mocks [mock {:target 'app.loggers.audit/submit :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + events (import-file profile (export-file profile)) + file-ids (-> events last second :file-ids) + [event :as audit-events] (import-events mock)] + + (t/is (= :end (-> events last first))) + (t/is (= 1 (count audit-events))) + (t/is (= 1 (count file-ids))) + (t/is (= (vec file-ids) (-> event :props :file-ids))) + (t/is (= 1 (-> event :props :file-count))) + (t/is (= "completed" (-> event :props :status))) + (t/is (= (:default-project-id profile) (-> event :props :project-id))) + (t/is (= "penpot" (-> event :props :referer))) + (t/is (= (:id profile) (:profile-id event))) + (t/is (= "command" (:type event))) + (t/is (not (contains? (:props event) :file)))))) + +(t/deftest import-binfile-audit-event-records-failure + (with-mocks [mock {:target 'app.loggers.audit/submit :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + path (export-file profile) + events (with-redefs [bf.v3/import-files! + (fn [_] (throw (ex-info "boom" {:type :internal})))] + (import-file profile path)) + [event :as audit-events] (import-events mock)] + + (t/is (= :error (-> events last first))) + (t/is (= 1 (count audit-events))) + (t/is (= "failed" (-> event :props :status))) + (t/is (not (contains? (:props event) :file-ids)))))) diff --git a/backend/test/backend_tests/rpc_climit_test.clj b/backend/test/backend_tests/rpc_climit_test.clj index b5b8e853d8..aa058f03d2 100644 --- a/backend/test/backend_tests/rpc_climit_test.clj +++ b/backend/test/backend_tests/rpc_climit_test.clj @@ -6,13 +6,22 @@ (ns backend-tests.rpc-climit-test (:require + [app.rpc :as-alias rpc] [app.rpc.climit :as-alias climit] [app.util.services :as-alias sv] [backend-tests.helpers :as th] + [clojure.edn :as edn] + [clojure.java.io :as io] [clojure.test :as t])) (t/use-fixtures :once th/state-init) +(defn- command-limits + "The `::climit/id` declaration of the RPC command named `name`." + [name] + (let [[mdata _] (get (:app.rpc/methods th/*system*) (keyword name))] + (::climit/id mdata))) + (defn- method-limit-declarations "The `::climit/id` declarations of every RPC command, as the running system wraps them." @@ -24,11 +33,87 @@ (when-let [limits (::climit/id mdata)] [mdata limits]))))) +(defn- declared-ids + "The limit ids a command declares, in the order it declares them." + [limits] + (cond + (keyword? limits) + [limits] + + (vector? (first limits)) + (mapv first limits) + + :else + [(first limits)])) + +(defn- limit-order-edges + "The pairs of limit ids the commands declare, as edges: the first one of the + pair is taken before the second one." + [declarations] + (reduce (fn [edges [mdata limits]] + (let [ids (declared-ids limits) + size (count ids)] + (into edges + (for [i (range size) + j (range (inc i) size)] + [(nth ids i) (nth ids j)])))) + #{} + declarations)) + +(defn- limit-order-cycle + "The limit ids whose relative order the commands disagree about: the ones + left when the relation cannot be sorted. + + Two commands that take the same limiters in opposite order leave two paths + of one profile holding them in opposite order, and the threads park for + good. A ring of three does the same, so the relation is checked as a whole + and not pair by pair." + [edges] + (let [nodes (into #{} (mapcat identity) edges) + outgoing (reduce (fn [acc [a b]] (update acc a (fnil conj #{}) b)) {} edges) + indegree (reduce (fn [acc [_ b]] (update acc b (fnil inc 0))) {} edges)] + (loop [pending nodes + degrees indegree] + (let [ready (filterv #(zero? (get degrees % 0)) pending)] + (if (empty? ready) + (seq pending) + (recur (apply disj pending ready) + (reduce (fn [degrees node] + (reduce (fn [degrees next] + (update degrees next (fnil dec 0))) + degrees + (get outgoing node))) + degrees + ready))))))) + +(t/deftest the-media-object-from-url-command-declares-its-own-limits + ;; The command used to enter climit from inside its handler, which forced + ;; every path to take the root limiters before the command ones. It declares + ;; its limits now, so there is a single place where they are read. + (t/is (= [[:process-image/by-profile ::rpc/profile-id] + [:process-image/global]] + (command-limits "create-file-media-object-from-url")))) + +(t/deftest the-update-profile-photo-command-declares-its-own-limits + ;; The command used to enter climit from inside its handler, which forced + ;; every path to take the root limiters before the command ones. It declares + ;; its limits now, so there is a single place where they are read. + (t/is (= [[:process-image/by-profile ::rpc/profile-id] + [:process-image/global]] + (command-limits "update-profile-photo")))) + +(t/deftest the-update-team-photo-command-declares-its-own-limits + ;; The command generates the thumbnail of a team through `upload-photo`, + ;; which used to enter climit on its own. It declares the limits now, so the + ;; image processing of a team is bounded like the one of a profile. + (t/is (= [[:process-image/by-profile ::rpc/profile-id] + [:process-image/global]] + (command-limits "update-team-photo")))) + (t/deftest commands-declare-the-by-profile-limit-before-the-global-one ;; `climit/get-limits` reverses a declared vector of pairs, so the first one - ;; declared is acquired first, and the nested `climit/invoke!` chains read the - ;; same declaration. A pair declared global first puts a command back in the - ;; inverted order that parked two paths of one profile for good. + ;; declared is acquired first. A pair declared global first puts a command + ;; back in the inverted order that parked two paths of one profile for good. (let [declarations (method-limit-declarations)] ;; the system wraps every command, so a short list means the scan went stale @@ -49,3 +134,46 @@ (t/is (< idx gidx) (str where " must declare " id " before " (keyword (namespace id) "global"))))))))) + +(t/deftest every-command-takes-the-shared-limits-in-the-same-order + ;; Two commands that share a limit and take it in opposite order can hold + ;; the same limiters in opposite order and park for good. The order is free + ;; now that no command enters climit from inside its handler, so this test + ;; is what keeps the commands consistent with each other. + (let [declarations (method-limit-declarations) + edges (limit-order-edges declarations) + cycle (limit-order-cycle edges)] + + ;; the system wraps every command, so a short list means the scan went stale + (t/is (<= 10 (count declarations))) + (t/is (seq edges)) + (t/is (nil? cycle) + (str "the commands take these limits in different orders: " + (pr-str cycle))))) + +(t/deftest a-ring-of-three-limits-is-a-cycle + ;; A ring of three limits does the same as a pair in opposite order: three + ;; paths hold the three limiters and wait for each other. The relation is + ;; checked as a whole for that reason, and not pair by pair. + (t/is (= #{:a :b :c} + (set (limit-order-cycle #{[:a :b] [:b :c] [:c :a]})))) + (t/is (nil? (limit-order-cycle #{[:a :b] [:b :c]})))) + +(t/deftest every-declared-limit-has-an-entry-in-the-shipped-config + ;; `climit/wrap` logs a warning and runs the handler with no limiter at all + ;; when the config has no entry for a declared id, so the command runs on + ;; every thread that asks for it. The production file is maintained by hand + ;; outside the repository and mirrors this one, so a command declaring a limit + ;; the file does not list is the shape of an unbounded command in production. + (let [path (io/resource "climit.edn") + config (edn/read-string (slurp path)) + declared (into #{} + (mapcat (fn [[_ limits]] (declared-ids limits))) + (method-limit-declarations))] + + ;; the system wraps every command, so an empty set means the scan went stale + (t/is (seq declared)) + + (doseq [id declared] + (t/is (some? (get config id)) + (str id " is declared by a command but has no entry in climit.edn"))))) diff --git a/backend/test/backend_tests/rpc_commands_mcp_feedback_test.clj b/backend/test/backend_tests/rpc_commands_mcp_feedback_test.clj new file mode 100644 index 0000000000..7987596481 --- /dev/null +++ b/backend/test/backend_tests/rpc_commands_mcp_feedback_test.clj @@ -0,0 +1,179 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.rpc-commands-mcp-feedback-test + (:require + [app.common.json :as json] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.email :as email] + [app.http :as http] + [app.jobs :as jobs] + [app.rpc :as-alias rpc] + [app.rpc.commands.access-token :as access-token] + [app.tokens :as tokens] + [backend-tests.helpers :as th] + [clojure.test :as t] + [yetti.response :as-alias yres])) + +(t/use-fixtures :once (t/compose-fixtures + (partial th/init-config [:enable-access-tokens + :enable-client-header-check-middleware]) + th/init-system)) +(t/use-fixtures :each th/database-reset) + +(def ^:private feedback + {:subject "Export fails" + :feedback "Exporting a valid shape repeatedly fails. Expected a PNG."}) + +(defn- create-identity + [] + (let [profile (th/create-profile* (uuid/next) {:is-active true}) + token (db/tx-run! th/*system* access-token/create-access-token + (:id profile) "MCP" nil "mcp")] + {:profile profile :token token})) + +(defn- request + ([token] (request token feedback)) + ([token params] (request token params {})) + ([token params options] + (with-redefs [cf/config (assoc cf/config :mcp-feedback-email "mcp@example.com")] + ((#'http/router-handler (::http/router th/*system*)) + (th/make-dummy-request + (merge {:path "/api/main/methods/send-mcp-feedback" + :method :post + :headers (cond-> {"content-type" "application/json" + "accept" "application/json" + "x-client" "penpot-mcp"} + token (assoc "authorization" (str "Token " token))) + :body-bytes (json/encode params)} + options)))))) + +(defn- emails + [] + (map #(jobs/decode-params (jobs/get-job-def (::jobs/defs th/*system*) :sendmail) (:params %)) + (db/query th/*system* :job {:name "sendmail"}))) + +(t/deftest queues-plain-text-feedback + (let [{:keys [token profile]} (create-identity) + response (request (:token token)) + email (first (emails))] + (t/is (= 200 (::yres/status response))) + (t/is (= 1 (count (emails)))) + (t/is (= "mcp@example.com" (:to email))) + (t/is (= (:email profile) (:reply-to email))) + (t/is (= "[MCP feedback] Export fails" (:subject email))) + (t/is (= {"text/plain" (str "Submitted by: " (:fullname profile) + " <" (:email profile) ">\n" + "Profile ID: " (:id profile) "\n\n" + "Feedback:\n\n" (:feedback feedback))} + (:body email))) + (t/is (nil? (:attachments email))))) + +(t/deftest rejects-invalid-credentials + (let [{:keys [profile token]} (create-identity)] + (doseq [credential [nil "" "invalid" + (tokens/generate th/*system* {:iss "other" + :uid (:id profile) :tid (:id token)}) + (tokens/generate th/*system* {:iss "access-token" + :uid (:id profile) :tid (:id token)}) + (tokens/generate th/*system* {:iss "urn:penpot:mcp-token" + :uid (uuid/next) :tid (:id token)}) + (tokens/generate th/*system* {:iss "urn:penpot:mcp-token" + :uid (:id profile) :tid (uuid/next)}) + (tokens/generate th/*system* {:iss "urn:penpot:mcp-token" + :uid (:id profile) :tid (:id token) + :exp (ct/minus (ct/now) (ct/duration "1h"))})]] + (t/is (= 401 (::yres/status (request credential))))) + (t/is (empty? (emails))))) + +(t/deftest rejects-expired-revoked-and-wrong-type-tokens + (let [{:keys [token profile]} (create-identity)] + (db/update! th/*system* :access-token + {:expires-at (ct/minus (ct/now) (ct/duration "1h"))} {:id (:id token)}) + (t/is (= 401 (::yres/status (request (:token token))))) + (db/update! th/*system* :access-token {:expires-at nil :type "personal"} {:id (:id token)}) + (t/is (= 401 (::yres/status (request (:token token))))) + (db/update! th/*system* :access-token {:type "mcp"} {:id (:id token)}) + (db/tx-run! th/*system* access-token/create-access-token (:id profile) "Replacement" nil "mcp") + (t/is (= 401 (::yres/status (request (:token token))))) + (t/is (empty? (emails))))) + +(t/deftest rejects-unavailable-profiles + (doseq [changes [{:is-active false} + {:is-blocked true} + {:deleted-at (ct/minus (ct/now) (ct/duration "1h"))}]] + (let [{:keys [token profile]} (create-identity)] + (db/update! th/*system* :profile changes {:id (:id profile)}) + (t/is (= (if (:deleted-at changes) 404 403) + (::yres/status (request (:token token))))))) + (t/is (empty? (emails)))) + +(t/deftest validates-feedback-before-queueing + (let [{:keys [token]} (create-identity)] + (doseq [params [{} + (assoc feedback :subject "") + (assoc feedback :subject " ") + (assoc feedback :subject "a\r\nBcc: victim@example.com") + (assoc feedback :subject "a\tb") + (assoc feedback :subject "a\u0085b") + (assoc feedback :subject "a\u0000b") + (assoc feedback :feedback "a\u0000b") + (assoc feedback :feedback "a\u001bb") + (assoc feedback :subject (apply str (repeat 121 "x"))) + (assoc feedback :feedback " \n") + (assoc feedback :feedback (apply str (repeat 5001 "x"))) + (assoc feedback :to "victim@example.com") + (assoc feedback :attachments {}) + (assoc feedback :feedback 42)]] + (t/is (= 400 (::yres/status (request (:token token) params))))) + (t/is (empty? (emails))) + (t/is (= 200 (::yres/status + (request (:token token) {:subject (apply str (repeat 120 "x")) + :feedback (apply str (repeat 5000 "x"))})))) + (t/is (= 200 (::yres/status + (request (:token token) {:subject "Tabs and breaks" + :feedback "line one\r\n\tline two\n"})))))) + +(t/deftest audit-event-omits-feedback-text + (let [{:keys [profile]} (create-identity)] + (with-redefs [cf/config (assoc cf/config :mcp-feedback-email "mcp@example.com")] + (let [result (th/command-raw! (merge feedback {::th/type :send-mcp-feedback + ::rpc/profile-id (:id profile) + ::rpc/auth-type :token + ::rpc/token-type "mcp"}))] + (t/is (= {} (:app.loggers.audit/replace-props (meta result)))))))) + +(t/deftest disabled-without-feedback-email + (let [{:keys [token]} (create-identity)] + (with-redefs [cf/config (dissoc cf/config :mcp-feedback-email)] + (let [response ((#'http/router-handler (::http/router th/*system*)) + (th/make-dummy-request + {:path "/api/main/methods/send-mcp-feedback" :method :post + :headers {"authorization" (str "Token " (:token token)) + "content-type" "application/json" + "x-client" "penpot-mcp"} + :body-bytes (json/encode feedback)}))] + (t/is (= 400 (::yres/status response))))) + (t/is (empty? (emails))))) + +(t/deftest feedback-is-post-only + (let [{:keys [token]} (create-identity)] + (t/is (= 405 (::yres/status (request (:token token) feedback {:method :get}))))) + (t/is (empty? (emails)))) + +(t/deftest mcp-token-does-not-authorize-ordinary-rpc + (let [{:keys [token]} (create-identity)] + (t/is (= 403 (::yres/status + (request (:token token) {} {:path "/api/rpc/command/get-teams"})))))) + +(t/deftest does-not-acknowledge-a-failed-queue-submission + (let [{:keys [token]} (create-identity)] + (with-redefs [email/send (fn [& _] (throw (ex-info "Queue unavailable" {})))] + (t/is (= 500 (::yres/status (request (:token token))))))) + (t/is (empty? (emails)))) diff --git a/backend/test/backend_tests/rpc_file_snapshot_test.clj b/backend/test/backend_tests/rpc_file_snapshot_test.clj index 4f34ef15b3..e2fb27c80f 100644 --- a/backend/test/backend_tests/rpc_file_snapshot_test.clj +++ b/backend/test/backend_tests/rpc_file_snapshot_test.clj @@ -7,10 +7,14 @@ (ns backend-tests.rpc-file-snapshot-test (:require [app.binfile.common :as bfc] + [app.common.data :as d] [app.common.features :as cfeat] + [app.common.files.defaults :as cfd] + [app.common.files.migrations :as fmg] [app.common.pprint :as pp] [app.common.thumbnails :as thc] [app.common.time :as ct] + [app.common.types.objects-map :as omap] [app.common.types.shape :as cts] [app.common.uuid :as uuid] [app.config :as cf] @@ -664,3 +668,189 @@ (t/is (nil? (:error out))) (t/is (empty? (fdata/get-used-pointer-ids (get-in out [:result :data]))))))))) + +(t/deftest snapshot-preview-applies-pending-migrations + ;; A snapshot taken while a migration was pending stores the old + ;; format. The preview must apply the pending migration in memory + ;; (the same pure step a normal read runs) instead of handing the old + ;; format to the UI, and must not persist anything: the stored + ;; version keeps its data so restoring it re-migrates on next read. + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + file-id (:id file) + page-id (first (get-in file [:data :pages])) + shape-id (uuid/random)] + + (update-file! :profile-id (:id profile) + :file-id file-id + :revn 0 + :changes [{:type :add-obj + :page-id page-id + :id shape-id + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (-> (cts/setup-shape {:id shape-id + :name "rect" + :frame-id uuid/zero + :parent-id uuid/zero + :type :rect}) + (assoc :applied-tokens {:stroke-width "token"}))}]) + + ;; Force the 0031 migration on the next save. + (th/db-delete! :file-migration {:file-id file-id + :name "0031-migrate-stroke-width-token-attr"}) + + (update-file! :profile-id (:id profile) + :file-id file-id + :revn 1 + :changes [{:type :add-page :name "b" :id (uuid/random)}]) + + (let [snapshot (->> (th/db-query :file-change {:file-id file-id}) + (filter #(and (some? (:label %)) + (= 1 (:revn %)))) + (first)) + snapshot-id (:id snapshot) + stored (fdata/realize th/*system* + (assoc (fsnap/get-snapshot th/*system* file-id snapshot-id) + :id file-id)) + out (th/command! {::th/type :get-file-snapshot + ::rpc/profile-id (:id profile) + :file-id file-id + :id snapshot-id + :features cfeat/supported-features})] + + (t/is (uuid? snapshot-id)) + + ;; The stored version keeps the old format and the pending + ;; migration: the preview must not touch the stored row. + (t/is (not (contains? (set (:migrations stored)) + "0031-migrate-stroke-width-token-attr"))) + (t/is (= {:stroke-width "token"} + (get-in stored [:data :pages-index page-id + :objects shape-id :applied-tokens]))) + + ;; The preview migrates it in memory. + (t/is (nil? (:error out))) + (t/is (= file-id (get-in out [:result :data :id]))) + (let [tokens (get-in out [:result :data :pages-index page-id + :objects shape-id :applied-tokens])] + (t/is (not (contains? tokens :stroke-width))) + (t/is (contains? tokens :stroke-width-top)) + (t/is (contains? tokens :stroke-width-bottom))) + + ;; The preview declared the migrated set in the payload. + (t/is (contains? (set (get-in out [:result :migrations])) + "0031-migrate-stroke-width-token-attr"))) + + ;; Nothing was persisted by the preview. + (let [snapshot-id (:id (->> (th/db-query :file-change {:file-id file-id}) + (filter #(and (some? (:label %)) + (= 1 (:revn %)))) + (first))) + stored (fdata/realize th/*system* + (assoc (fsnap/get-snapshot th/*system* file-id snapshot-id) + :id file-id))] + (t/is (= {:stroke-width "token"} + (get-in stored [:data :pages-index page-id + :objects shape-id :applied-tokens])))))) + +(t/deftest snapshot-preview-realizes-objects-for-clients-without-the-feature + ;; `:features` works like in `get-file`: a client that does not + ;; declare `fdata/objects-map` receives plain objects instead of the + ;; storage structure of the fragment. + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + file-id (:id file) + page-id (first (get-in file [:data :pages])) + shape-id (uuid/random) + _ (update-file! :profile-id (:id profile) + :file-id file-id + :revn 0 + :changes [{:type :add-obj + :page-id page-id + :id shape-id + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape + {:id shape-id + :name "rect" + :frame-id uuid/zero + :parent-id uuid/zero + :type :rect})}]) + + snapshot-id (:id (:result (th/command! {::th/type :create-file-snapshot + ::rpc/profile-id (:id profile) + :file-id file-id + :label "v1"}))) + preview (fn [features] + (th/command! {::th/type :get-file-snapshot + ::rpc/profile-id (:id profile) + :file-id file-id + :id snapshot-id + :features features})) + with-feature (preview cfeat/supported-features) + without-feature (preview (disj cfeat/supported-features + "fdata/objects-map"))] + + (t/is (nil? (:error with-feature))) + (t/is (nil? (:error without-feature))) + (t/is (omap/objects-map? + (get-in with-feature [:result :data :pages-index page-id :objects]))) + (t/is (not (omap/objects-map? + (get-in without-feature [:result :data :pages-index page-id :objects])))))) + +(t/deftest snapshot-preview-migrates-legacy-snapshots-without-migrations + ;; Snapshots written before the `migrations` column existed (SQL + ;; migration 0140) store NULL and the format version of their time. + ;; The decoder must hand an ordered set (a vector would make + ;; `need-migration?` compare indexes), and the preview must + ;; regenerate the applied set from the stored version, migrate the + ;; data in memory and leave the row untouched. + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + file-id (:id file) + snap-id (:id (:result (th/command! {::th/type :create-file-snapshot + ::rpc/profile-id (:id profile) + :file-id file-id + :label "legacy"})))] + + (t/is (d/ordered-set? (:migrations (fsnap/get-snapshot th/*system* file-id snap-id)))) + + ;; Turn the version into a pre-0140 snapshot: no applied set and an + ;; old format version. + (th/db-exec! ["UPDATE file_change SET migrations = NULL, version = 60 WHERE id = ?" + snap-id]) + + (let [stored (fsnap/get-snapshot th/*system* file-id snap-id) + out (th/command! {::th/type :get-file-snapshot + ::rpc/profile-id (:id profile) + :file-id file-id + :id snap-id + :features cfeat/supported-features}) + result (:result out)] + + (t/is (nil? (:migrations stored))) + (t/is (= 60 (:version stored))) + + (t/is (nil? (:error out))) + (t/is (d/ordered-set? (:migrations result))) + ;; The preview regenerates everything the stored version lacks + ;; instead of trusting the absent applied set. + (t/is (= cfd/version (:version result))) + (t/is (= (into #{} fmg/available-migrations) + (set (:migrations result)))) + (t/is (set/subset? (into #{} (fmg/generate-migrations-from-version cfd/version)) + (set (:migrations result)))) + + ;; Nothing is persisted. + (let [stored (fsnap/get-snapshot th/*system* file-id snap-id)] + (t/is (nil? (:migrations stored))) + (t/is (= 60 (:version stored))))))) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 991cea67b0..593c769546 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2387,6 +2387,61 @@ (t/is (nil? (:error out))) (check-tokens-absorbed prepared))) +(t/deftest unpublishing-a-library-keeps-its-consumers-readable + ;; The test helpers enable all supported features, so these files use + ;; fdata/pointer-map and the absorbed page is stored as a fragment + (let [profile (th/create-profile* 1 {:is-active true}) + library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true}) + consumer (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + _ (th/link-file-to-library* {:file-id (:id consumer) :library-id (:id library)}) + color-id (uuid/random) + page-id (first (get-in consumer [:data :pages])) + shape-id (uuid/random)] + + (update-file! :file-id (:id library) + :profile-id (:id profile) + :revn 0 + :changes [{:type :add-color + :color {:id color-id :name "Brand" :color "#abcdef" :opacity 1}}]) + (update-file! + :file-id (:id consumer) + :profile-id (:id profile) + :revn 0 + :changes + [{:type :add-obj + :page-id page-id + :id shape-id + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape {:id shape-id :name "Rect" :frame-id uuid/zero + :parent-id uuid/zero :type :rect + :fills [{:fill-color "#abcdef" + :fill-opacity 1 + :fill-color-ref-id color-id + :fill-color-ref-file (:id library)}]})}]) + + (let [out (th/command! {::th/type :set-file-shared + ::rpc/profile-id (:id profile) + :id (:id library) + :is-shared false})] + (t/is (nil? (:error out)))) + + ;; The absorption changed the page of the shape: the consumer reads + ;; it back, with the fill now linked to its own color + (let [out (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id (:id consumer)}) + data (-> out :result :data) + fill (get-in data [:pages-index page-id :objects shape-id :fills 0])] + (t/is (nil? (:error out))) + (t/is (contains? (:colors data) color-id)) + (t/is (= (:id consumer) (:fill-color-ref-file fill)))))) + (t/deftest deleted-files-permanently-delete (let [prof (th/create-profile* 1 {:is-active true}) team-id (:default-team-id prof) diff --git a/backend/test/backend_tests/rpc_file_update_repair_test.clj b/backend/test/backend_tests/rpc_file_update_repair_test.clj new file mode 100644 index 0000000000..332a81aa61 --- /dev/null +++ b/backend/test/backend_tests/rpc_file_update_repair_test.clj @@ -0,0 +1,432 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.rpc-file-update-repair-test + (:require + [app.binfile.common :as bfc] + [app.common.features :as cfeat] + [app.common.files.repair :as cfr] + [app.common.files.validate :as cfv] + [app.common.test-helpers.compositions :as tho] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.features.file-snapshots :as fsnap] + [app.metrics :as mtx] + [app.msgbus :as mbus] + [app.rpc :as-alias rpc] + [app.rpc.commands.files-update :as files-update] + [app.srepl.procs.file-repair :as file-repair] + [app.util.pointer-map :as pmap] + [backend-tests.helpers :as th] + [clojure.test :as t]) + (:import + io.prometheus.client.CollectorRegistry)) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(defn- with-repair-flags + [f] + (with-redefs [cf/flags (conj cf/flags :file-validation)] + (f))) + +(defn- with-full-repair-flags + [f] + (with-redefs [cf/flags (conj cf/flags :file-validation :file-schema-validation)] + (f))) + +(defn- repair-count + [outcome] + (or (.getSampleValue ^CollectorRegistry + (mtx/get-registry (::mtx/metrics th/*system*)) + "penpot_rpc_update_file_repair_total" + (into-array String ["outcome"]) + (into-array String [(name outcome)])) + 0.0)) + +(defn- add-rect + [page-id shape-id] + {:type :add-obj + :page-id page-id + :id shape-id + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape + {:id shape-id + :name "rect" + :frame-id uuid/zero + :parent-id uuid/zero + :type :rect})}) + +(defn- set-attr + [page-id shape-id attr val] + {:type :mod-obj + :id shape-id + :page-id page-id + :operations [{:type :set :attr attr :val val}]}) + +(defn- update-file + [profile-id file-id revn changes & {:keys [skip-validate session-id] + :or {session-id (uuid/random)}}] + (th/command! (cond-> {::th/type :update-file + ::rpc/profile-id profile-id + :id file-id + :session-id session-id + :revn revn + :vern 0 + :features cfeat/supported-features + :changes changes} + skip-validate (assoc :skip-validate true)))) + +(defn- stored-file + [file-id] + (bfc/get-file th/*system* file-id :realize? true)) + +(defn- change-rows + [file-id] + (->> (th/db-query :file-change {:file-id file-id} {::db/remove-deleted false}) + (filter (fn [row] + (and (some? (:changes row)) + (nil? (:label row))))) + (sort-by :created-at) + (vec))) + +(defn- label-rows + [file-id] + (->> (th/db-query :file-change {:file-id file-id} {::db/remove-deleted false}) + (filter :label) + (vec))) + +(defn- pointer-map-in? + [x] + (cond + (pmap/pointer-map? x) true + (map? x) (boolean (some pointer-map-in? (vals x))) + (sequential? x) (boolean (some pointer-map-in? x)) + :else false)) + +(defn- setup-file + [& {:keys [is-shared] :or {is-shared false}}] + (let [profile (th/create-profile* 1 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared is-shared}) + page-id (first (get-in file [:data :pages])) + shape-a (uuid/next) + shape-b (uuid/next) + out (update-file (:id profile) (:id file) 0 + [(add-rect page-id shape-a) + (add-rect page-id shape-b)])] + (t/is (th/success? out)) + {:profile profile + :file-id (:id file) + :page-id page-id + :shape-a shape-a + :shape-b shape-b + :revn (:revn (stored-file (:id file)))})) + +(defn- break-frame + [{:keys [profile file-id page-id shape-a revn]}] + (let [out (update-file (:id profile) file-id revn + [(set-attr page-id shape-a :frame-id (uuid/next))] + :skip-validate true)] + (t/is (th/success? out)) + (stored-file file-id))) + +(t/deftest repairs-preexisting-frame-not-found + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-a shape-b] :as ctx} (setup-file) + broken (break-frame ctx) + before (repair-count :repaired) + out (update-file (:id profile) file-id (:revn broken) + [(set-attr page-id shape-b :name "renamed")]) + stored (stored-file file-id) + shape (get-in stored [:data :pages-index page-id :objects shape-a]) + frame (get-in stored [:data :pages-index page-id :objects (:frame-id shape)])] + (t/is (th/success? out)) + (t/is (nil? (cfv/validate-file stored {}))) + (t/is (some? frame)) + (t/is (= "renamed" + (get-in stored [:data :pages-index page-id :objects shape-b :name]))) + (t/is (= 1.0 (- (repair-count :repaired) before))))))) + +(t/deftest repairs-preexisting-frame-not-found-with-schema-validation + (with-full-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-a shape-b] :as ctx} (setup-file) + broken (break-frame ctx) + n (:revn broken) + before-repaired (repair-count :repaired) + before-unrepaired (repair-count :unrepaired) + out (update-file (:id profile) file-id n + [(set-attr page-id shape-b :name "renamed")]) + stored (stored-file file-id) + shape (get-in stored [:data :pages-index page-id :objects shape-a]) + frame (get-in stored [:data :pages-index page-id :objects (:frame-id shape)]) + labels (label-rows file-id) + repair (filter #(= (str "internal/repair/" n) (:label %)) labels) + snap (filter #(= (str "internal/snapshot/" n) (:label %)) labels)] + (t/is (th/success? out)) + (t/is (nil? (cfv/validate-file stored {}))) + (t/is (some? frame)) + (t/is (= "renamed" + (get-in stored [:data :pages-index page-id :objects shape-b :name]))) + (t/is (= (+ n 2) (:revn stored))) + (t/is (= 1 (count repair))) + (t/is (zero? (count snap))) + (t/is (= 1.0 (- (repair-count :repaired) before-repaired))) + (t/is (= 0.0 (- (repair-count :unrepaired) before-unrepaired))))))) + +(t/deftest rejects-client-caused-break + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-a revn]} (setup-file) + before (stored-file file-id) + out (update-file (:id profile) file-id revn + [(set-attr page-id shape-a :frame-id (uuid/next))]) + after (stored-file file-id)] + (t/is (not (th/success? out))) + (t/is (= :referential-integrity (:code (ex-data (:error out))))) + (t/is (= (:revn before) (:revn after))))))) + +(t/deftest skip-validate-does-not-repair + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-a shape-b] :as ctx} (setup-file) + broken (break-frame ctx) + out (update-file (:id profile) file-id (:revn broken) + [(set-attr page-id shape-b :name "renamed")] + :skip-validate true) + stored (stored-file file-id) + shape (get-in stored [:data :pages-index page-id :objects shape-a])] + (t/is (th/success? out)) + (t/is (nil? (get-in stored [:data :pages-index page-id :objects (:frame-id shape)]))))))) + +(t/deftest damage-on-untouched-page-is-left + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-a revn]} (setup-file) + page-2 (uuid/next) + shape-c (uuid/next) + _ (t/is (th/success? + (update-file (:id profile) file-id revn + [{:type :add-page :id page-2 :name "page-2"} + (add-rect page-2 shape-c)]))) + file (stored-file file-id) + _ (t/is (th/success? + (update-file (:id profile) file-id (:revn file) + [(set-attr page-2 shape-c :frame-id (uuid/next))] + :skip-validate true))) + file (stored-file file-id) + out (update-file (:id profile) file-id (:revn file) + [(set-attr page-id shape-a :name "on-page-1")]) + stored (stored-file file-id) + shape (get-in stored [:data :pages-index page-2 :objects shape-c])] + (t/is (th/success? out)) + (t/is (nil? (get-in stored [:data :pages-index page-2 :objects (:frame-id shape)]))))))) + +(t/deftest growing-scope-repairs-component-touched-by-repair + (thi/reset-idmap!) + (with-repair-flags + (fn [] + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (assoc :revn 0) + (tho/add-simple-component :component1 :root1 :child1) + (ths/add-sample-shape :other) + (update-in [:data :components (thi/id :component1)] + assoc :main-instance-id (uuid/next) + :objects {(uuid/next) {:id (uuid/next)}})) + page-id (thi/id :page1) + changes [(set-attr page-id (thi/id :other) :name "renamed")] + cfg {::files-update/validation-timings (atom {})} + [stored] (with-redefs [bfc/get-resolved-file-libraries (fn [_ _] {})] + (@#'files-update/process-changes-and-validate cfg file changes nil))] + (t/is (some? (::files-update/repair stored))) + (t/is (nil? (cfv/validate-file stored {}))) + (t/is (nil? (get-in stored [:data :components (thi/id :component1) :objects]))))))) + +(t/deftest repair-records-timings-with-schema-validation + (thi/reset-idmap!) + (with-full-repair-flags + (fn [] + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (assoc :revn 0) + (ths/add-sample-shape :shape1) + (ths/update-shape :shape1 :frame-id (uuid/next))) + changes [(set-attr (thi/id :page1) (thi/id :shape1) :name "renamed")] + timings (atom {}) + cfg {::files-update/validation-timings timings} + [result] (with-redefs [bfc/get-resolved-file-libraries (fn [_ _] {})] + (@#'files-update/process-changes-and-validate cfg file changes nil))] + (t/is (some? (::files-update/repair result))) + (t/is (number? (:repair-ms @timings))) + (t/is (number? (:reprocess-changes-ms @timings))) + (t/is (number? (:validate-schema-ms @timings))))))) + +(t/deftest unrepairable-rejects-and-calls-repair-once + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-b] :as ctx} (setup-file) + broken (break-frame ctx) + calls (atom 0) + before (repair-count :unrepaired) + out (with-redefs [cfr/repair-file (fn [_ _ _] + (swap! calls inc) + [])] + (update-file (:id profile) file-id (:revn broken) + [(set-attr page-id shape-b :name "renamed")]))] + (t/is (not (th/success? out))) + (t/is (= :referential-integrity (:code (ex-data (:error out))))) + (t/is (= 1 @calls)) + (t/is (= 1.0 (- (repair-count :unrepaired) before))))))) + +(t/deftest repair-writes-change-rows-and-reuses-snapshot + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-b] :as ctx} (setup-file) + broken (break-frame ctx) + before (count (change-rows file-id)) + session (uuid/next) + calls (atom []) + n (:revn broken) + out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}] + (swap! calls conj {:topic topic :message message}))] + (update-file (:id profile) file-id n + [(set-attr page-id shape-b :name "renamed")] + :session-id session)) + rows (change-rows file-id) + new (subvec rows before) + labels (label-rows file-id) + repair (filter #(= (str "internal/repair/" n) (:label %)) labels) + snap (filter #(= (str "internal/snapshot/" n) (:label %)) labels) + msgs (filter #(= :file-change (:type (:message %))) @calls) + stored (stored-file file-id)] + (t/is (th/success? out)) + (t/is (= "renamed" + (get-in stored [:data :pages-index page-id :objects shape-b :name]))) + (t/is (= 2 (count new))) + (t/is (= (inc n) (:revn (first new)))) + (t/is (= files-update/repair-session-id (:session-id (first new)))) + (t/is (some? (:deleted-at (first new)))) + (t/is (= (:created-at (first new)) (:updated-at (first new)))) + (t/is (= (+ n 2) (:revn (second new)))) + (t/is (= session (:session-id (second new)))) + (t/is (some? (:deleted-at (second new)))) + (t/is (= (:created-at (second new)) (:updated-at (second new)))) + (t/is (not (pos? (compare (:created-at (first new)) + (:created-at (second new)))))) + (t/is (= 1 (count repair))) + (t/is (zero? (count snap))) + (t/is (>= (count msgs) 2)) + (t/is (true? (get-in (first msgs) [:message :repaired]))) + (t/is (= files-update/repair-session-id + (get-in (first msgs) [:message :session-id]))) + (t/is (= session (get-in (second msgs) [:message :session-id]))) + (t/is (not (true? (get-in (second msgs) [:message :repaired])))) + (t/is (= (+ n 2) (get-in (second msgs) [:message :revn]))))))) + +(t/deftest repair-library-change-keeps-saver-session + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-b] :as ctx} (setup-file :is-shared true) + broken (break-frame ctx) + session (uuid/next) + calls (atom []) + out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}] + (swap! calls conj {:topic topic :message message}))] + (update-file (:id profile) file-id (:revn broken) + [(set-attr page-id shape-b :name "renamed")] + :session-id session)) + lib-msgs (filter #(= :library-change (:type (:message %))) @calls)] + (t/is (th/success? out)) + (t/is (seq lib-msgs)) + (doseq [msg lib-msgs] + (t/is (= session (get-in msg [:message :session-id]))) + (t/is (not= files-update/repair-session-id + (get-in msg [:message :session-id])))))))) + +(t/deftest repair-snapshot-without-auto-snapshot + (with-redefs [cf/flags (-> cf/flags + (conj :file-validation) + (disj :auto-file-snapshot))] + (let [{:keys [profile file-id page-id shape-b] :as ctx} (setup-file) + broken (break-frame ctx) + n (:revn broken) + out (update-file (:id profile) file-id n + [(set-attr page-id shape-b :name "renamed")]) + labels (label-rows file-id) + repair (filter #(= (str "internal/repair/" n) (:label %)) labels) + snaps (filter #(re-find #"^internal/snapshot/" (str (:label %))) labels) + row (first repair) + snap (fsnap/get-snapshot th/*system* file-id (:id row)) + sfile {:id file-id + :features (:features (stored-file file-id)) + :data (:data snap)}] + (t/is (th/success? out)) + (t/is (= 1 (count repair))) + (t/is (zero? (count snaps))) + (t/is (false? (pointer-map-in? (:data snap)))) + (t/is (some? (cfv/validate-file sfile {})))))) + +(t/deftest srepl-repair-grows-revn-by-iterations + (thi/reset-idmap!) + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (assoc :revn 3) + (ths/add-sample-shape :shape1) + (ths/update-shape :shape1 :frame-id (uuid/next))) + loop (cfr/repair-file-until-valid + file {} + (fn [file _] (cfv/validate-file file {}))) + wrapped (file-repair/repair-file file {})] + (t/is (pos? (:iterations loop))) + (t/is (= (+ (:revn file) (:iterations loop)) (:revn wrapped))) + (t/is (nil? (cfv/validate-file wrapped {}))))) + +(t/deftest healthy-save-writes-one-change-row + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-b revn]} (setup-file) + before (count (change-rows file-id)) + out (update-file (:id profile) file-id revn + [(set-attr page-id shape-b :name "ok")]) + labels (label-rows file-id)] + (t/is (th/success? out)) + (t/is (= 1 (- (count (change-rows file-id)) before))) + (t/is (empty? (filter #(re-find #"^internal/repair/" (str (:label %))) labels))))))) + +(t/deftest repairs-several-missing-children-on-root + (with-full-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-a shape-b revn]} (setup-file) + r1 (uuid/next) + r2 (uuid/next) + r3 (uuid/next) + _ (t/is (th/success? + (update-file (:id profile) file-id revn + [(set-attr page-id uuid/zero :shapes + [shape-a shape-b r1 r2 r3])] + :skip-validate true))) + broken (stored-file file-id) + errors (cfv/validate-file broken {}) + out (update-file (:id profile) file-id (:revn broken) + [(set-attr page-id shape-b :name "renamed")]) + stored (stored-file file-id) + root (get-in stored [:data :pages-index page-id :objects uuid/zero])] + (t/is (= [:child-not-found :child-not-found :child-not-found] + (mapv :code errors))) + (t/is (= #{uuid/zero} (into #{} (map :shape-id) errors))) + (t/is (th/success? out)) + (t/is (= [shape-a shape-b] (:shapes root))) + (t/is (nil? (cfv/validate-file stored {}))) + (t/is (= "renamed" + (get-in stored [:data :pages-index page-id :objects shape-b :name]))))))) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 500b241279..2d411bf816 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -251,6 +251,53 @@ (t/is (false? (get-in teams-by-id [(:id owned-team) :is-default]))) (t/is (false? (get-in teams-by-id [(:id member-team) :is-default]))))))) +(t/deftest get-organization-member-teams-returns-roles-within-given-teams + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + owned-team (th/create-team* 1 {:profile-id (:id profile)}) + admin-team (th/create-team* 2 {:profile-id (:id other)}) + editor-team (th/create-team* 3 {:profile-id (:id other)}) + viewer-team (th/create-team* 4 {:profile-id (:id other)}) + foreign-team (th/create-team* 5 {:profile-id (:id other)}) + not-member (th/create-team* 6 {:profile-id (:id other)}) + deleted-team (th/create-team* 7 {:profile-id (:id profile)}) + _ (th/db-update! :team + {:deleted-at (ct/now)} + {:id (:id deleted-team)}) + _ (doseq [[team role] [[admin-team :admin] + [editor-team :editor] + [viewer-team :viewer] + [foreign-team :editor]]] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id profile) + :role role})) + team-ids (mapv :id [owned-team admin-team editor-team viewer-team + not-member deleted-team + {:id (:default-team-id profile)}]) + out (th/management-command! {::th/type :get-organization-member-teams + ::rpc/profile-id (:id other) + :profile-id (:id profile) + :team-ids team-ids})] + (t/is (th/success? out)) + (t/is (= {(:id owned-team) :owner + (:id admin-team) :admin + (:id editor-team) :editor + (:id viewer-team) :viewer} + (->> out :result (map (juxt :id :role)) (into {})))) + (t/is (= (:name owned-team) + (->> out :result (d/seek #(= (:id owned-team) (:id %))) :name)))))) + +(t/deftest get-organization-member-teams-empty-team-ids + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + out (th/management-command! {::th/type :get-organization-member-teams + ::rpc/profile-id (:id profile) + :profile-id (:id profile) + :team-ids []})] + (t/is (th/success? out)) + (t/is (= [] (:result out)))))) + (t/deftest update-profile-theme-updates-only-theme (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] (let [profile (th/create-profile* 1 {:is-active true diff --git a/backend/test/backend_tests/rpc_test.clj b/backend/test/backend_tests/rpc_test.clj index 233ffdd8e2..a6e18d24f3 100644 --- a/backend/test/backend_tests/rpc_test.clj +++ b/backend/test/backend_tests/rpc_test.clj @@ -902,6 +902,27 @@ (t/is (some? ex)) (t/is (th/ex-of-type? ex :not-found)))) +;; --- TOKEN TYPE RESTRICTIONS + +(t/deftest mcp-token-authentication + (let [pid (uuid/custom 1 1) + method (fn [mdata] + (rpc/make-rpc-handler + {:test-method [mdata (partial (#'rpc/wrap-authentication + nil (fn [_ params] params) mdata) + nil)]})) + handler (method {::rpc/auth-type :token ::rpc/token-type "mcp"})] + (doseq [extras [{::session/profile-id pid} + {::actoken/profile-id pid} + {::actoken/profile-id pid ::actoken/type "personal"}]] + (t/is (th/ex-of-code? (call-ex handler {:extras extras}) :token-auth-required))) + (let [extras {::actoken/profile-id pid ::actoken/type "mcp"}] + (t/is (= "mcp" (::rpc/token-type (call-body handler {:extras extras})))) + (t/is (th/ex-of-code? (call-ex (method {}) {:extras extras}) :token-auth-required))) + (t/is (= pid (::rpc/profile-id + (call-body (method {}) {:extras {::actoken/profile-id pid + ::actoken/type "personal"}})))))) + ;; --- PARAMS VALIDATION: SERVER AUTH CONTEXT VS CLIENT PARAMS (t/deftest client-qualified-keys-do-not-override-server-auth-context @@ -915,7 +936,8 @@ request {:params {:name "valid" :app.rpc/profile-id attacker-pid :app.rpc/auth-type :token - :app.rpc/token-perms #{"admin"}}} + :app.rpc/token-perms #{"admin"} + :app.rpc/token-type "mcp"}} params (with-meta {::rpc/profile-id server-pid ::rpc/auth-type :session} {::http/request request})] @@ -923,6 +945,7 @@ (t/is (= server-pid (::rpc/profile-id @captured))) (t/is (= :session (::rpc/auth-type @captured))) (t/is (not (contains? @captured ::rpc/token-perms))) + (t/is (not (contains? @captured ::rpc/token-type))) (t/is (= "valid" (:name @captured))))) ;; --- RESPONSE: NIL BODY WITH CUSTOM HEADERS diff --git a/backend/test/backend_tests/srepl_main_test.clj b/backend/test/backend_tests/srepl_main_test.clj index 1d131d1fe3..9cbf5a39af 100644 --- a/backend/test/backend_tests/srepl_main_test.clj +++ b/backend/test/backend_tests/srepl_main_test.clj @@ -205,3 +205,104 @@ ;; to the zero one. (t/is (nil? (:profile-id event))) (t/is (= (:id profile) (get-in event [:props :id]))))))))) + +(t/deftest update-profile-email-changes-email + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil}] + (with-redefs [sys/system th/*system*] + (let [profile (th/create-profile* 1 {:is-active true}) + result (srepl/update-profile-email! + :email (:email profile) + :new-email "corrected@example.com")] + (t/is (= :updated result)) + (t/is (= "corrected@example.com" + (:email (th/db-get :profile {:id (:id profile)})))) + (let [event (->> (:call-args-list @audit-mock) + (map second) + first)] + (t/is (= "update-profile-email" (:name event))) + (t/is (= "action" (:type event))) + (t/is (= (:id profile) (get-in event [:props :id]))) + (t/is (= (:email profile) (get-in event [:props :email]))) + (t/is (= "corrected@example.com" + (get-in event [:props :new-email])))))))) + +(t/deftest update-profile-email-rejects-collision + (with-redefs [sys/system th/*system*] + (let [profile1 (th/create-profile* 1 {:is-active true}) + profile2 (th/create-profile* 2 {:is-active true}) + error (try + (srepl/update-profile-email! + :email (:email profile1) + :new-email (:email profile2)) + (catch Throwable e e))] + (t/is (th/ex-of-code? error :email-already-exists)) + (t/is (= (:email profile1) + (:email (th/db-get :profile {:id (:id profile1)}))))))) + +(t/deftest update-profile-email-unchanged-when-same + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil}] + (with-redefs [sys/system th/*system*] + (let [profile (th/create-profile* 1 {:is-active true}) + result (srepl/update-profile-email! + :email (:email profile) + :new-email (:email profile))] + (t/is (= :unchanged result)) + (t/is (empty? (:call-args-list @audit-mock))))))) + +(t/deftest update-profile-email-not-found + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil}] + (with-redefs [sys/system th/*system*] + (let [error (try + (srepl/update-profile-email! + :email "missing@example.com" + :new-email "corrected@example.com") + (catch Throwable e e))] + (t/is (th/ex-of-code? error :profile-not-found)) + (t/is (= :not-found (:type (ex-data error)))) + (t/is (empty? (:call-args-list @audit-mock))))))) + +(t/deftest clear-email-complaint-reports-deletes-and-unmutes + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil}] + (with-redefs [sys/system th/*system*] + (let [profile (th/create-profile* 1 {:is-active true :is-muted true + :email "bounced@example.com"}) + _ (th/create-complaint-for th/*system* + {:id (:id profile) :type :bounce}) + _ (th/create-complaint-for th/*system* + {:id (:id profile) :type :complaint}) + _ (th/create-global-complaint-for th/*system* + {:email (:email profile) + :type :bounce}) + _ (th/create-global-complaint-for th/*system* + {:email (:email profile) + :type :complaint}) + result (srepl/clear-email-complaint-reports! (:email profile))] + (t/is (= :cleared result)) + (t/is (empty? (th/db-query :global-complaint-report + {:email (:email profile)}))) + (t/is (empty? (th/db-query :profile-complaint-report + {:profile-id (:id profile)}))) + (t/is (false? (:is-muted (th/db-get :profile {:id (:id profile)})))) + (let [event (->> (:call-args-list @audit-mock) + (map second) + first)] + (t/is (= "clear-email-complaint-reports" (:name event))) + (t/is (= "action" (:type event))) + (t/is (= (:id profile) (get-in event [:props :id]))) + (t/is (= (:email profile) (get-in event [:props :email])))))))) + +(t/deftest clear-email-complaint-reports-without-profile + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil}] + (with-redefs [sys/system th/*system*] + (let [email "orphan-bounce@example.com" + _ (th/create-global-complaint-for th/*system* + {:email email :type :bounce}) + result (srepl/clear-email-complaint-reports! email)] + (t/is (= :cleared result)) + (t/is (empty? (th/db-query :global-complaint-report {:email email}))) + (let [event (->> (:call-args-list @audit-mock) + (map second) + first)] + (t/is (= "clear-email-complaint-reports" (:name event))) + (t/is (= email (get-in event [:props :email]))) + (t/is (nil? (get-in event [:props :id])))))))) diff --git a/backend/test/backend_tests/tasks/binfile_jobs_integration_test.clj b/backend/test/backend_tests/tasks/binfile_jobs_integration_test.clj index 4d6277a0ff..07a959aa1a 100644 --- a/backend/test/backend_tests/tasks/binfile_jobs_integration_test.clj +++ b/backend/test/backend_tests/tasks/binfile_jobs_integration_test.clj @@ -249,11 +249,13 @@ (with-redefs [jobs/heartbeat (fn [_cfg & {:as options}] (swap! calls conj options) 1)] - (bfj/export-files th/*system* - (jobs/make-context (jobs/get-job th/*system* job-id)) - {:ids #{file-id} - :export-type :detach-libraries - :output (tmp/tempfile* :suffix ".penpot")})) + (let [cfg (bfc/export-cfg th/*system* + {:file-ids #{file-id} + :export-type :detach-libraries + :output (tmp/tempfile* :suffix ".penpot")}) + context (jobs/make-context (jobs/get-job th/*system* job-id))] + (bfj/check-active cfg context) + (bfj/run cfg context v3/export-files!))) (let [reports (map :progress @calls)] diff --git a/backend/test/backend_tests/tasks/import_binfile_test.clj b/backend/test/backend_tests/tasks/import_binfile_test.clj index eadc44104f..e82d462df3 100644 --- a/backend/test/backend_tests/tasks/import_binfile_test.clj +++ b/backend/test/backend_tests/tasks/import_binfile_test.clj @@ -9,7 +9,6 @@ it leaves behind when it fails." (:require [app.binfile.common :as bfc] - [app.binfile.jobs :as bfj] [app.binfile.v1 :as v1] [app.binfile.v3 :as v3] [app.common.uuid :as uuid] @@ -50,7 +49,7 @@ [file-id] (let [output (tmp/tempfile* :suffix ".penpot")] (v1/export-files! (-> th/*system* - (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/file-ids #{file-id}) (assoc ::bfc/include-libraries false) (assoc ::bfc/embed-assets false)) output) @@ -249,12 +248,12 @@ ;; the core does its work inside the transaction and answers with a result ;; the schema of the task does not accept: the check runs before the ;; transaction commits, so the work is rolled back with it - (with-redefs [bfj/import-files - (fn [tx-cfg _context _params] - (th/create-file* tx-cfg 7 {:profile-id (:id profile) - :project-id project-id - :is-shared false - :name "half imported"}) + (with-redefs [v3/import-files! + (fn [cfg] + (th/create-file* cfg 7 {:profile-id (:id profile) + :project-id project-id + :is-shared false + :name "half imported"}) {:file-ids [(uuid/next)] :resolution "not-a-map"})] diff --git a/common/package.json b/common/package.json index 2c63d73b31..2569c0fb8c 100644 --- a/common/package.json +++ b/common/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "type": "module", "repository": { "type": "git", diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index 182b225f53..cd80e3a302 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index ce45edd485..8e4bbb67d9 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -407,6 +407,12 @@ [:theme-ids [:set ::sm/uuid]] [:set-ids [:set ::sm/uuid]]]] + [:set-own-tokens-status + [:map {:title "SetOwnTokensStatus"} + [:type [:= :set-own-tokens-status]] + [:theme-ids [:maybe [:set ::sm/uuid]]] + [:set-ids [:maybe [:set ::sm/uuid]]]]] + [:rename-token-set-group [:map {:title "RenameTokenSetGroup"} [:type [:= :rename-token-set-group]] @@ -1067,6 +1073,13 @@ (-> (cfo/ensure-tokens-lib data) (cfo/update-tokens-status ctos/set-tokens-status theme-ids set-ids))) +(defmethod process-change :set-own-tokens-status + [data {:keys [theme-ids set-ids]}] + (cfo/set-own-tokens-status data + (when (or (some? theme-ids) (some? set-ids)) + (ctos/make-tokens-status :active-theme-ids theme-ids + :active-set-ids set-ids)))) + (defmethod process-change :rename-token-set-group [data {:keys [set-group-path set-group-fname]}] (-> (cfo/ensure-tokens-lib data) diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index 3317d91653..94f50fccef 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -1083,6 +1083,24 @@ (update :undo-changes conj {:type :set-tokens-status :theme-ids prev-theme-ids :set-ids prev-set-ids}) (apply-changes-local))))) +(defn set-own-tokens-status + "Keep aside the tokens status of the file's own tokens-lib, or remove it + when `tokens-status` is nil." + [changes tokens-status] + (assert-library! changes) + (assert (or (ctos/tokens-status? tokens-status) + (nil? tokens-status))) + (let [library-data (::library-data (meta changes)) + prev-tokens-status (cfo/get-own-tokens-status library-data) + ->change (fn [tokens-status] + {:type :set-own-tokens-status + :theme-ids (some-> tokens-status ctos/get-active-theme-ids) + :set-ids (some-> tokens-status ctos/get-active-set-ids)})] + (-> changes + (update :redo-changes conj (->change tokens-status)) + (update :undo-changes conj (->change prev-tokens-status)) + (apply-changes-local)))) + (defn rename-token-set-group [changes set-group-path set-group-fname] (let [undo-path (ctob/replace-last-path-name set-group-path set-group-fname) diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index 72c5dcac18..5bf1bcad27 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -153,7 +153,9 @@ (-> file (assoc :data data) - (update :migrations set/union diff) + ;; `:migrations` is an ordered set everywhere; keep it that way + ;; even if a caller hands a plain set. + (update :migrations #(into (d/ordered-set) (set/union % diff))) (vary-meta assoc ::migrated (not-empty diff)))))) (defn generate-migrations-from-version diff --git a/common/src/app/common/files/repair.cljc b/common/src/app/common/files/repair.cljc index 4756f8a758..f0a52b788c 100644 --- a/common/src/app/common/files/repair.cljc +++ b/common/src/app/common/files/repair.cljc @@ -8,6 +8,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.files.changes :as cfc] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.files.tokens :as cfo] @@ -22,8 +23,6 @@ [app.common.types.variant :as ctv] [app.common.uuid :as uuid])) -(log/set-level! :debug) - (defmulti repair-error (fn [code _error _file-data _libraries] code)) @@ -867,3 +866,58 @@ (pcb/empty-changes nil) errors)] redo-changes)) + +(defn- error-fingerprint + [errors] + (frequencies (map #(dissoc % :shape) errors))) + +(defn repair-file-until-valid + "Repeatedly validate and repair `file` until `errors-fn` returns nil, + a repair pass leaves `:data` unchanged, or the error fingerprint + (errors without `:shape`) is unchanged from the previous pass. + + `errors-fn` is `(fn [file applied-changes] errors-or-nil)`. Never + changes `:revn`. + + Returns `{:file file' :changes [...] :iterations n :errors remaining}`." + [file libraries errors-fn & {:keys [max-iterations initial-errors] + :or {max-iterations 10}}] + (loop [file file + applied [] + iteration 0 + prev-fp nil + seed-errors initial-errors] + (if (>= iteration max-iterations) + {:file file + :changes applied + :iterations iteration + :errors (not-empty (errors-fn file applied))} + (let [errors (if (and (zero? iteration) (some? seed-errors)) + seed-errors + (errors-fn file applied))] + (if (empty? errors) + {:file file + :changes applied + :iterations iteration + :errors nil} + (let [fp (error-fingerprint errors)] + (cond + (= fp prev-fp) + {:file file + :changes applied + :iterations (inc iteration) + :errors (not-empty errors)} + + :else + (let [batch (repair-file file libraries errors) + data' (cfc/process-changes (:data file) batch)] + (if (= data' (:data file)) + {:file file + :changes applied + :iterations (inc iteration) + :errors (not-empty errors)} + (recur (assoc file :data data') + (into applied batch) + (inc iteration) + fp + nil)))))))))) diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index e8a28e7ca2..f8fb4e32aa 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -549,6 +549,37 @@ [file-data f & args] (d/update-when file-data :tokens-status #(apply f % args))) +(defn get-own-tokens-status + "The tokens status of the file's own tokens-lib, kept aside while the + tokens source is an external library." + [file-data] + (:own-tokens-status file-data)) + +(defn set-own-tokens-status + "Keep aside the tokens status of the file's own tokens-lib. Setting it to + nil removes the attribute." + [file-data tokens-status] + (assert (or (nil? tokens-status) (ctos/tokens-status? tokens-status)) + "expected nil or valid tokens-status") + (if (nil? tokens-status) + (dissoc file-data :own-tokens-status) + (assoc file-data :own-tokens-status tokens-status))) + +(defn detach-tokens-source + "Make the file its own tokens source again, restoring the tokens status + kept aside for its own tokens-lib. If none was kept, the tokens status + is removed." + [file-data] + (let [own-tokens-status (get-own-tokens-status file-data)] + (if (some? own-tokens-status) + (-> file-data + (set-tokens-source nil) + (set-own-tokens-status nil) + (assoc :tokens-status own-tokens-status)) + (-> file-data + (set-tokens-source nil) + (dissoc :tokens-status))))) + (defn absorb-tokens-lib "Copy the tokens-lib of the library into the file, replacing the previous one if any, and unset the tokens-source, so the file becomes @@ -558,19 +589,26 @@ one of the library is copied along with the tokens-lib. If the library has no tokens-lib, only the tokens-source is unset, and - the file keeps its own tokens-lib and tokens-status. If there is no - library-data, there is nothing to absorb and the file is returned - unchanged." + the file keeps its own tokens-lib, with the tokens status kept aside for + it if any. If there is no library-data, there is nothing to absorb and + the file is returned unchanged." [file-data library-data] (if (nil? library-data) file-data (let [tokens-lib (get-tokens-lib library-data)] (if (nil? tokens-lib) - (set-tokens-source file-data nil) + (let [own-tokens-status (get-own-tokens-status file-data)] + (cond-> (-> file-data + (set-tokens-source nil) + (set-own-tokens-status nil)) + (some? own-tokens-status) + (assoc :tokens-status own-tokens-status))) (let [tokens-status (or (get-tokens-status file-data) (get-tokens-status library-data))] (cond-> (-> file-data (set-tokens-source nil) + ;; The kept status refers to the replaced tokens-lib + (set-own-tokens-status nil) (assoc :tokens-lib tokens-lib)) (some? tokens-status) (assoc :tokens-status tokens-status))))))) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 1764c05113..3a6efa168c 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -241,8 +241,7 @@ :enable-stroke-per-side :enable-token-combobox :enable-custom-shortcuts - :enable-token-lib-sync - :enable-link-unfurl]) + :enable-token-lib-sync]) (defn parse [& flags] diff --git a/common/src/app/common/geom/shapes/constraints.cljc b/common/src/app/common/geom/shapes/constraints.cljc index ca92c93cc7..325a6d3069 100644 --- a/common/src/app/common/geom/shapes/constraints.cljc +++ b/common/src/app/common/geom/shapes/constraints.cljc @@ -235,7 +235,7 @@ (ctm/move-modifiers (displacement center-before center-after before-side-vector after-side-vector)))) (defmethod constraint-modifier :default [_ _ _ _ _ _] - []) + (ctm/empty)) (def const->type+axis {:left :start diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index ac5a516156..838d0e2463 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -13,6 +13,7 @@ [app.common.files.helpers :as cfh] [app.common.files.variant :as cfv] [app.common.geom.matrix :as gmt] + [app.common.geom.modifiers :as gm] [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.geom.shapes :as gsh] @@ -27,6 +28,7 @@ [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.library :as ctl] + [app.common.types.modifiers :as ctm] [app.common.types.page :as ctp] [app.common.types.pages-list :as ctpl] [app.common.types.path.segment :as segment] @@ -577,15 +579,19 @@ (container-log :debug (:id container) :msg "Sync page in local file" :page-id (:id container)) (container-log :debug (:id container) :msg "Sync component in local library" :component-id (:id container))) - (let [linked-shapes (->> (vals (:objects container)) - (filter #(uses-assets? asset-type asset-id % library-id)))] + (let [objects (:objects container) + ;; Deepest first, so a copy places its swapped children after their own sync + linked-shapes (->> (vals objects) + (filter #(uses-assets? asset-type asset-id % library-id)) + (sort-by #(- (count (cfh/get-parent-ids objects (:id %))))))] (loop [shapes (seq linked-shapes) changes (-> changes (pcb/with-container container) - (pcb/with-objects (:objects container)))] + (pcb/with-objects objects))] (if-let [shape (first shapes)] (recur (next shapes) - (let [objects' (pcb/get-objects changes) + (let [changes (pcb/apply-changes-local changes) + objects' (pcb/get-objects changes) shape' (get objects' (:id shape))] ;; The shape could have been deleted in previous changes, if this nested component ;; comes from components-v1 era or if there has been some error with the swap slot. @@ -594,7 +600,7 @@ (generate-sync-shape asset-type changes library-id - container + (assoc container :objects objects') shape' libraries current-file-id) @@ -832,7 +838,8 @@ root-inst root-main reset? - initial-root?) + initial-root? + nil) ;; If the component is not found, because the master component has been ;; deleted or the library unlinked, do nothing. @@ -866,8 +873,88 @@ (recur shape-inst' next-data component') nil)))))) +(defn- geometry-sync-delta + "Offset the sync applies to the top-left of `dest-shape` when it copies the + geometry of `origin-shape`." + [dest-shape origin-shape dest-root origin-root] + (gpt/subtract (grc/rect->point (:selrect (reposition-shape origin-shape origin-root dest-root))) + (grc/rect->point (:selrect dest-shape)))) + +(defn- follow-parent-modifiers + "Modifiers that make `child` follow the change of its parent from + `parent-before` to `parent-after`, through the child constraints." + [child parent-before parent-after] + (let [sr-before (:selrect parent-before) + sr-after (:selrect parent-after) + modifiers (-> (ctm/empty) + (ctm/resize (gpt/point (/ (:width sr-after) (max 0.01 (:width sr-before))) + (/ (:height sr-after) (max 0.01 (:height sr-before)))) + (first (:points parent-before)) + (:transform parent-before) + (:transform-inverse parent-before)) + (ctm/move (gpt/subtract (first (:points parent-after)) + (first (:points parent-before)))))] + ;; A move doesn't need the constraints + (if (or (ctm/empty? modifiers) (ctm/only-move? modifiers)) + modifiers + (gsh/calc-child-modifiers parent-before child modifiers false + (:points child) + (:points parent-before) + (delay (:points parent-after)))))) + +(defn- follow-parent-modif-tree + "Modifiers that make `child` and its children follow the change of the + parent of `child` from `parent-before` to `parent-after`, each one through + its own constraints." + [objects child parent-before parent-after] + (let [modifiers (follow-parent-modifiers child parent-before parent-after)] + (if (ctm/empty? modifiers) + {} + (gm/set-objects-modifiers {(:id child) {:modifiers modifiers}} + (select-keys objects (cfh/get-children-ids-with-self objects (:id child))))))) + +(defn- resize-with-parent + "Resizes `shape` the way its constraints do when its parent changes from + `parent-before` to `parent-after`, keeping its top-left." + [shape parent-before parent-after] + (let [modifiers (follow-parent-modifiers shape parent-before parent-after)] + (if (ctm/empty? modifiers) + shape + (let [shape' (gsh/transform-shape shape modifiers)] + (gsh/move shape' (gpt/subtract (grc/rect->point (:selrect shape)) + (grc/rect->point (:selrect shape')))))))) + +(defn- update-geometry-untouched + "Applies `update-fn` to the shapes without marking them touched." + [changes ids update-fn container] + (pcb/concat-changes + changes + (-> (pcb/empty-changes) + (pcb/with-container container) + (pcb/with-objects (:objects container)) + (pcb/update-shapes ids update-fn {:ignore-touched true})))) + +(defn generate-follow-parent + "Makes `child` and its children follow the change of the parent of `child` + from `parent-before` to `parent-after`, each one through its own + constraints, without marking them touched. `delta-fn`, if given, receives + `child` after that and returns the offset to move the subtree by." + [changes container child parent-before parent-after delta-fn] + (let [objects (:objects container) + modif-tree (follow-parent-modif-tree objects child parent-before parent-after) + follow #(gsh/transform-shape % (dm/get-in modif-tree [(:id %) :modifiers])) + delta (if delta-fn (delta-fn (follow child)) (gpt/point 0 0))] + (cond-> changes + (or (seq modif-tree) (not (gpt/almost-zero? delta))) + (update-geometry-untouched (cfh/get-children-ids-with-self objects (:id child)) + #(gsh/move (follow %) delta) + container)))) + (defn- generate-sync-shape-direct-recursive - [changes container shape-inst component library file libraries shape-main root-inst root-main reset? initial-root?] + "Syncs `shape-inst` and its children from `shape-main`. `parent-change` is + the geometry of the parent of `shape-inst` before and after the sync, or + nil." + [changes container shape-inst component library file libraries shape-main root-inst root-main reset? initial-root? parent-change] (shape-log :debug (:id shape-inst) container :msg "Sync shape direct recursive" :shape-inst (str (:name shape-inst) " " (pretty-uuid (:id shape-inst))) @@ -879,6 +966,19 @@ (let [omit-touched? (not reset?) clear-remote-synced? (and initial-root? reset?) set-remote-synced? (and (not initial-root?) reset?) + geometry-kept? (and omit-touched? + (ctk/touched-group? shape-inst :geometry-group)) + main-geometry (reposition-shape shape-main root-main root-inst) + follow-modifiers (when (and geometry-kept? (some? parent-change)) + (let [[parent-before parent-after] parent-change] + (follow-parent-modifiers shape-inst parent-before parent-after))) + shape-after (cond + (not geometry-kept?) main-geometry + (some? follow-modifiers) (gsh/transform-shape shape-inst follow-modifiers) + :else shape-inst) + ;; Offset between where the sync leaves shape-inst and where the main puts it + main-gap (gpt/subtract (grc/rect->point (:selrect shape-after)) + (grc/rect->point (:selrect main-geometry))) changes (cond-> changes :always @@ -889,6 +989,11 @@ container omit-touched?) + (and (some? follow-modifiers) (not (ctm/empty? follow-modifiers))) + (update-geometry-untouched [(:id shape-inst)] + #(gsh/transform-shape % follow-modifiers) + container) + (ctsl/flex-layout? shape-main) (update-flex-child-copy-attrs shape-main shape-inst @@ -962,15 +1067,20 @@ root-inst root-main reset? - initial-root?)) + initial-root? + [shape-inst shape-after])) swapped (fn [changes child-inst child-main] (shape-log :trace (:id child-inst) container :msg "Match slot" :child-inst (str (:name child-inst) " " (pretty-uuid (:id child-inst))) :child-main (str (:name child-main) " " (pretty-uuid (:id child-main)))) - ;; For now we don't make any sync here. - changes) + ;; A swapped child follows its parent and, if the user didn't move it, + ;; goes to the place of the slot's occupant in the main + (generate-follow-parent changes container child-inst shape-inst shape-after + (when-not (ctk/touched-group? child-inst :geometry-group) + #(gpt/add (geometry-sync-delta % child-main root-inst root-main) + main-gap)))) moved (fn [changes child-inst child-main] (shape-log :trace (:id child-inst) container @@ -1338,6 +1448,12 @@ container {:type :reg-objects :shapes all-parents})) + ;; Conj reg before the deletions, so undo recalculates the parents + ;; after removing the shapes. + changes' (update changes' :undo-changes conj (make-change + container + {:type :reg-objects + :shapes all-parents})) changes' (reduce del-obj-change changes' new-shapes)] (if (and (ctk/touched-group? parent-shape :shapes-group) omit-touched?) @@ -1444,6 +1560,12 @@ changes' (update changes' :redo-changes conj {:type :reg-objects :component-id (:id component) :shapes all-parents}) + ;; Conj reg before the other undo changes, so undo recalculates the + ;; parents after removing the shapes. + changes' (update changes' :undo-changes conj (make-change + component-container + {:type :reg-objects + :shapes all-parents})) changes' (reduce mod-obj-change changes' updated-shapes) changes' (reduce del-obj-change changes' new-shapes)] @@ -1491,6 +1613,12 @@ changes' (-> (reduce add-redo-change changes ids) (update :redo-changes conj (make-change + container + {:type :reg-objects + :shapes (vec parents)})) + ;; Conj reg before the additions, so undo recalculates + ;; the parents after restoring the shapes. + (update :undo-changes conj (make-change container {:type :reg-objects :shapes (vec parents)}))) @@ -1693,15 +1821,19 @@ container {:type :reg-objects :shapes all-parents})) - (update :undo-changes conj (make-change - container - {:type :mod-obj - :id (:id dest-shape) - :operations (vec uoperations)})) - (update :undo-changes concat [(make-change - container - {:type :reg-objects - :shapes all-parents})]) + ;; Conj reg before mod, so undo restores the shape and then its + ;; parents. Do not append with concat: it nests one lazy seq per + ;; shape and overflows the stack on big syncs. + (update :undo-changes conj + (make-change + container + {:type :reg-objects + :shapes all-parents}) + (make-change + container + {:type :mod-obj + :id (:id dest-shape) + :operations (vec uoperations)})) (pcb/apply-changes-local)))) @@ -2192,7 +2324,10 @@ [changes current-shape previous-shape current-root prev-root origin-ref-shape container] (let [;; We need to sync only the position relative to the origin of the component. ;; (see update-attrs for a full explanation) - previous-shape (reposition-shape previous-shape prev-root current-root) + previous-shape (-> previous-shape + (resize-with-parent (get (:objects container) (:parent-id previous-shape)) + (get (pcb/get-objects changes) (:parent-id current-shape))) + (reposition-shape prev-root current-root)) touched (get previous-shape :touched #{}) text-auto? (and (cfh/text-shape? current-shape) (contains? #{:auto-height :auto-width} (:grow-type current-shape)))] diff --git a/common/src/app/common/logic/tokens.cljc b/common/src/app/common/logic/tokens.cljc index 53d2ae5396..c1f948e8c7 100644 --- a/common/src/app/common/logic/tokens.cljc +++ b/common/src/app/common/logic/tokens.cljc @@ -16,36 +16,58 @@ ;; Tokens source (defn- own-tokens-status - "The current tokens status of `file-data`, keeping only the themes and sets - that exist in its own tokens lib." - [file-data] - (let [tokens-status (cfo/get-tokens-status file-data) - tokens-lib (cfo/get-tokens-lib file-data)] + "`tokens-status` keeping only the themes and sets that exist in the own + tokens lib of `file-data`." + [file-data tokens-status] + (let [tokens-lib (cfo/get-tokens-lib file-data)] (if (and tokens-status tokens-lib) (cfo/sync-tokens-status-with-lib tokens-status tokens-lib) (ctos/make-tokens-status)))) (defn generate-set-tokens-source "Create changes for setting the tokens source of a file to `library`, - copying the library's tokens status. If the library is the file itself, - the themes and sets that don't exist in its own tokens lib are deactivated. - If the library is nil, the tokens source is removed and the tokens status - is cleared." + copying the library's tokens status. + + While an external library is the source, the tokens status of the file's + own tokens lib is kept aside, and it is restored when the file goes back + to being its own source (the library is the file itself, or nil). With + nothing kept aside, going back to the file itself deactivates the themes + and sets that don't exist in its own tokens lib, and a nil library clears + the tokens status." [changes library] - (let [library-id (:id library) - file-data (pcb/get-library-data changes) - library-tokens-status (cond - (nil? library) - (ctos/make-tokens-status) + (let [file-data (pcb/get-library-data changes) + file-id (:id file-data) + library-id (:id library) + to-own? (or (nil? library) (= library-id file-id)) + from-own? (cfo/effective-tokens-source? file-data file-id) + current-status (cfo/get-tokens-status file-data) + kept-status (cfo/get-own-tokens-status file-data) - (= library-id (:id file-data)) - (own-tokens-status file-data) + tokens-status + (cond + (and to-own? (some? kept-status)) + (own-tokens-status file-data kept-status) - :else - (-> library ctf/file-data cfo/get-tokens-status))] - (-> changes - (pcb/set-tokens-source library-id) - (pcb/set-tokens-status library-tokens-status)))) + (nil? library) + (ctos/make-tokens-status) + + to-own? + (own-tokens-status file-data current-status) + + :else + (-> library ctf/file-data cfo/get-tokens-status)) + + kept-status' + (cond + to-own? nil + from-own? (own-tokens-status file-data current-status) + :else kept-status)] + + (cond-> (-> changes + (pcb/set-tokens-source library-id) + (pcb/set-tokens-status tokens-status)) + (not (identical? kept-status kept-status')) + (pcb/set-own-tokens-status kept-status')))) ;; Tokens lib diff --git a/common/src/app/common/logic/variant_properties.cljc b/common/src/app/common/logic/variant_properties.cljc index fe87fa3e5e..81dc28399d 100644 --- a/common/src/app/common/logic/variant_properties.cljc +++ b/common/src/app/common/logic/variant_properties.cljc @@ -16,22 +16,24 @@ (defn generate-update-property-name [changes variant-id pos new-name] - (let [data (pcb/get-library-data changes) - objects (pcb/get-objects changes) - related-components (cfv/find-variant-components data objects variant-id) + (if-let [new-name (ctv/prepare-property-name new-name)] + (let [data (pcb/get-library-data changes) + objects (pcb/get-objects changes) + related-components (cfv/find-variant-components data objects variant-id) - props (-> related-components last :variant-properties) - prop-names (mapv :name props) - prop-names (concat (subvec prop-names 0 pos) (subvec prop-names (inc pos))) - new-name (ctv/update-number-in-repeated-item prop-names new-name)] - (reduce (fn [changes component] - (pcb/update-component - changes (:id component) - (fn [component] - (d/update-in-when component [:variant-properties pos] #(assoc % :name new-name))) - {:apply-changes-local-library? true})) - changes - related-components))) + props (-> related-components last :variant-properties) + prop-names (mapv :name props) + prop-names (concat (subvec prop-names 0 pos) (subvec prop-names (inc pos))) + new-name (ctv/update-number-in-repeated-item prop-names new-name)] + (reduce (fn [changes component] + (pcb/update-component + changes (:id component) + (fn [component] + (d/update-in-when component [:variant-properties pos] #(assoc % :name new-name))) + {:apply-changes-local-library? true})) + changes + related-components)) + changes)) (defn generate-remove-property @@ -57,16 +59,18 @@ (defn generate-update-property-value [changes component-id pos value] - (let [data (pcb/get-library-data changes) - component (ctcl/get-component data component-id true) - main-id (:main-instance-id component) - name (-> (:variant-properties component) - (update pos assoc :value value) - ctv/properties-to-name)] - (-> changes - (pcb/update-component component-id #(assoc-in % [:variant-properties pos :value] value) - {:apply-changes-local-library? true}) - (pcb/update-shapes [main-id] #(assoc % :variant-name name))))) + (if-let [value (ctv/prepare-property-value value)] + (let [data (pcb/get-library-data changes) + component (ctcl/get-component data component-id true) + main-id (:main-instance-id component) + name (-> (:variant-properties component) + (update pos assoc :value value) + ctv/properties-to-name)] + (-> changes + (pcb/update-component component-id #(assoc-in % [:variant-properties pos :value] value) + {:apply-changes-local-library? true}) + (pcb/update-shapes [main-id] #(assoc % :variant-name name)))) + changes)) (defn generate-set-variant-error @@ -108,40 +112,45 @@ props (-> related-components last :variant-properties) next-prop-num (ctv/next-property-number props) - property-name (or property-name (str ctv/property-prefix next-prop-num)) + property-name (if (some? property-name) + (ctv/prepare-property-name property-name) + (str ctv/property-prefix next-prop-num))] + (if (nil? property-name) + changes + (let [prop-names (mapv :name props) + property-name (ctv/update-number-in-repeated-item prop-names property-name) + property-value (not-empty + (when property-value + (ctv/prepare-property-value property-value))) + mdata (if editing? {:editing? true} nil) - prop-names (mapv :name props) - property-name (ctv/update-number-in-repeated-item prop-names property-name) + [_ changes] + (reduce (fn [[num changes] component] + (let [main-id (:main-instance-id component) - mdata (if editing? {:editing? true} nil) + update-props #(-> (d/nilv % []) + (conj (with-meta {:name property-name + :value (cond fill-values? (str ctv/value-prefix num) + property-value property-value + :else "")} + mdata))) - [_ changes] - (reduce (fn [[num changes] component] - (let [main-id (:main-instance-id component) - - update-props #(-> (d/nilv % []) - (conj (with-meta {:name property-name - :value (cond fill-values? (str ctv/value-prefix num) - property-value property-value - :else "")} - mdata))) - - update-name #(cond fill-values? (if (str/empty? %) - (str ctv/value-prefix num) - (str % ", " ctv/value-prefix num)) - property-value (if (str/empty? %) - property-value - (str % ", " property-value)) - :else %)] - [(inc num) - (-> changes - (pcb/update-component (:id component) - #(update % :variant-properties update-props) - {:apply-changes-local-library? true}) - (pcb/update-shapes [main-id] #(update % :variant-name update-name)))])) - [1 changes] - related-components)] - changes)) + update-name #(cond fill-values? (if (str/empty? %) + (str ctv/value-prefix num) + (str % ", " ctv/value-prefix num)) + property-value (if (str/empty? %) + property-value + (str % ", " property-value)) + :else %)] + [(inc num) + (-> changes + (pcb/update-component (:id component) + #(update % :variant-properties update-props) + {:apply-changes-local-library? true}) + (pcb/update-shapes [main-id] #(update % :variant-name update-name)))])) + [1 changes] + related-components)] + changes)))) (defn- generate-make-shape-no-variant [changes shape] @@ -175,7 +184,7 @@ props (ctv/add-new-props props (repeat new-props ""))] (if add-name? - (ctv/add-new-prop props (:name component)) + (ctv/add-new-prop props (or (ctv/sanitize-property-text (:name component)) "")) props))) (defn- create-new-properties-from-non-variant diff --git a/common/src/app/common/logic/variants.cljc b/common/src/app/common/logic/variants.cljc index 883a3a40da..79de230b5a 100644 --- a/common/src/app/common/logic/variants.cljc +++ b/common/src/app/common/logic/variants.cljc @@ -4,6 +4,8 @@ [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.files.variant :as cfv] + [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] [app.common.logic.libraries :as cll] [app.common.logic.shapes :as cls] [app.common.logic.variant-properties :as clvp] @@ -72,7 +74,10 @@ copy that was swapped (orig-swapped-child), and its related shape on the new copy (related-shape-in-new), move the orig-swapped-child into the parent of related-shape-in-new, fix its swap-slot if needed, and then delete - related-shape-in-new" + related-shape-in-new. + + orig-swapped-child follows its parent through its constraints and, if the + user didn't move it, takes the place of related-shape-in-new." [changes related-shape-in-new orig-swapped-child ldata page swap-ref-id] (let [;; Before to the swap, temporary move the previous ;; shape to the root panel to avoid problems when @@ -83,15 +88,28 @@ (pcb/change-parent uuid/zero [orig-swapped-child] 0 {:allow-altering-copies true})) objects (pcb/get-objects changes) + page-objects (:objects page) prev-swap-slot (ctk/get-swap-slot orig-swapped-child) current-parent (get objects (:parent-id related-shape-in-new)) - pos (d/index-of (:shapes current-parent) (:id related-shape-in-new))] + pos (d/index-of (:shapes current-parent) (:id related-shape-in-new)) + + orig-child (get page-objects (:id orig-swapped-child)) + ;; If the user didn't move it, it goes to the place of related-shape-in-new + delta-fn (when-not (ctk/touched-group? orig-child :geometry-group) + #(gpt/subtract (grc/rect->point (:selrect related-shape-in-new)) + (grc/rect->point (:selrect %))))] (-> (pcb/concat-changes before-changes changes) ;; Move the previous shape to the new parent (pcb/change-parent (:parent-id related-shape-in-new) [orig-swapped-child] pos {:allow-altering-copies true}) + (cll/generate-follow-parent (ctn/make-container page :page) + orig-child + (get page-objects (:parent-id orig-child)) + current-parent + delta-fn) + ;; We need to update the swap slot only when it pointed ;; to the swap-ref-id. Oterwise this is a swapped item ;; inside a nested copy, so we need to keep it. diff --git a/common/src/app/common/path_names.cljc b/common/src/app/common/path_names.cljc index cc1526eae0..3f5fd28603 100644 --- a/common/src/app/common/path_names.cljc +++ b/common/src/app/common/path_names.cljc @@ -55,6 +55,17 @@ Some naming conventions: (->> (split-path path) (join-path))) +(defn normalize-path-name + "Trim and clean a path-style name. Returns nil when the name is not usable." + [name] + (let [name (some-> name str/trim) + clean-name (when (string? name) (clean-path name))] + (when (and (string? name) + (not (str/ends-with? name "/")) + (string? clean-name) + (not (str/blank? clean-name))) + clean-name))) + (defn merge-path-item "Put the item at the end of the path." [path name] diff --git a/common/src/app/common/test_helpers/compositions.cljc b/common/src/app/common/test_helpers/compositions.cljc index a4fa6b748b..e2efb1a170 100644 --- a/common/src/app/common/test_helpers/compositions.cljc +++ b/common/src/app/common/test_helpers/compositions.cljc @@ -10,6 +10,7 @@ [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] [app.common.logic.libraries :as cll] [app.common.logic.shapes :as cls] [app.common.logic.variants :as clv] @@ -443,6 +444,33 @@ :propagate-fn propagate-fn :library library))) +(defn change-shape + "Applies `update-fn` to the shape identified by shape-tag. With + `ignore-touched` the change is not an override, as when a layout changes it." + [file shape-tag update-fn & {:keys [ignore-touched]}] + (let [page (thf/current-page file)] + (thf/apply-changes + file + (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(thi/id shape-tag)} + update-fn + (:objects page) + {:ignore-touched ignore-touched})))) + +(defn move-shape-tree + "Moves the shape identified by shape-tag and its children by `delta`. With + `ignore-touched` the move is not an override, as when a layout moves it." + [file shape-tag delta & {:keys [ignore-touched]}] + (let [page (thf/current-page file) + objects (:objects page)] + (thf/apply-changes + file + (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + (cfh/get-children-ids-with-self objects (thi/id shape-tag)) + #(gsh/move % delta) + objects + {:ignore-touched ignore-touched})))) + (defn update-color "Update the first fill color for the shape identified by shape-tag" [file shape-tag color & {:keys [page-label propagate-fn]}] diff --git a/common/src/app/common/test_helpers/tokens.cljc b/common/src/app/common/test_helpers/tokens.cljc index 23a0550a4a..88923c691b 100644 --- a/common/src/app/common/test_helpers/tokens.cljc +++ b/common/src/app/common/test_helpers/tokens.cljc @@ -30,6 +30,10 @@ [file] (-> file (ctf/file-data) (cfo/get-tokens-status))) +(defn get-own-tokens-status + [file] + (-> file (ctf/file-data) (cfo/get-own-tokens-status))) + (defn add-tokens-lib "Ensure the file has a tokens-lib and a tokens-status in its data, creating empty ones if not" [file] diff --git a/common/src/app/common/types/component.cljc b/common/src/app/common/types/component.cljc index deb5379e72..c45b59ada0 100644 --- a/common/src/app/common/types/component.cljc +++ b/common/src/app/common/types/component.cljc @@ -251,6 +251,24 @@ [shape] (boolean (:is-variant-container shape))) +(defn is-variant-main? + "True when this shape is a variant component main and not the variant + container." + [shape] + (and (is-variant? shape) + (not (is-variant-container? shape)))) + +(defn variant-formula-rename? + "True when renaming this shape with `name` should update the variant + formula instead of the layer or component name. + + `name` must already be trimmed by the caller." + [shape name] + (and (is-variant-main? shape) + (some? (:variant-name shape)) + (string? name) + (not (str/blank? name)))) + (defn set-touched-group [touched group] (when group diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index df4733c25a..444484b68f 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -90,7 +90,8 @@ [:plugin-data {:optional true} schema:plugin-data] [:tokens-source {:optional true} ::sm/uuid] ;; The tokens-lib may be in this file or in an external library [:tokens-lib {:optional true} ctob/schema:tokens-lib] - [:tokens-status {:optional true} ctos/schema:tokens-status]]) + [:tokens-status {:optional true} ctos/schema:tokens-status] + [:own-tokens-status {:optional true} ctos/schema:tokens-status]]) ;; Kept aside while the tokens source is an external library (def schema:file-metadata [:map {:title "Metadata"} @@ -534,12 +535,13 @@ shape-meta (meta ref-shape) ref-file (:file shape-meta) ref-container (:container shape-meta)] - (when ref-shape + (if ref-shape (if-let [swap-slot (ctk/get-swap-slot ref-shape)] swap-slot (if (ctk/main-instance? ref-shape) (:id shape) - (find-swap-slot ref-shape ref-container ref-file libraries (conj viewed-ids (:id shape))))))))))) + (find-swap-slot ref-shape ref-container ref-file libraries (conj viewed-ids (:id shape))))) + (:id shape))))))) ;; If cannot locate ref-shape (e.g. in an unlinked external library), use what we have (defn match-swap-slot? [shape-main shape-inst container-inst container-main file libraries] @@ -681,7 +683,8 @@ (defn find-asset-type-usages "Find all usages of an asset in a file (may be in pages or in the components - of the local library). + of the local library, deleted ones included, since they keep their shapes + to be restored and to resolve their remaining copies). Returns a list ((asset ((container shapes) (container shapes)...))...)" [file-data library-data asset-type] @@ -697,12 +700,17 @@ (when (d/not-empty? instances) [[container instances]]))) + containers + (concat (containers-seq file-data) + (map #(ctn/make-container % :component) + (ctkl/deleted-components-seq file-data))) + find-asset-usages - (fn [file-data asset] - (mapcat #(find-usages-in-container % asset) (containers-seq file-data)))] + (fn [asset] + (mapcat #(find-usages-in-container % asset) containers))] (mapcat (fn [asset] - (let [instances (find-asset-usages file-data asset)] + (let [instances (find-asset-usages asset)] (when (d/not-empty? instances) [[asset instances]]))) assets))) @@ -1241,7 +1249,7 @@ (cond-> (update-in file [:data :pages-index] detach-pages) (not (cfo/effective-tokens-source? (:data file) file-id)) - (update :data dissoc :tokens-source :tokens-status)))) + (update :data cfo/detach-tokens-source)))) ;; Base font size diff --git a/common/src/app/common/types/shape_tree.cljc b/common/src/app/common/types/shape_tree.cljc index 90109d5b46..0080aae00d 100644 --- a/common/src/app/common/types/shape_tree.cljc +++ b/common/src/app/common/types/shape_tree.cljc @@ -376,7 +376,7 @@ (defn update-object-indices [file page-id] - (update-in file [:pages-index page-id :objects] update-page-index)) + (d/update-in-when file [:pages-index page-id :objects] update-page-index)) (defn rotated-frame? [frame] diff --git a/common/src/app/common/types/variant.cljc b/common/src/app/common/types/variant.cljc index 660fec5304..f91075a170 100644 --- a/common/src/app/common/types/variant.cljc +++ b/common/src/app/common/types/variant.cljc @@ -55,19 +55,47 @@ [value] (some-> value str/trim)) +(def ^:private forbidden-property-chars-re #"[,=]") + +(defn sanitize-property-text + "Remove `,` and `=` from property name/value text, then trim. + Non-strings (including nil) return nil — never call str/replace on them." + [value] + (when (string? value) + (-> value + (str/replace forbidden-property-chars-re "") + normalize-property-text))) + (defn valid-property-name? [value] (let [value (normalize-property-text value)] (and (string? value) (not (str/blank? value)) + (nil? (re-find forbidden-property-chars-re value)) (<= (count value) property-max-length)))) (defn valid-property-value? [value] (let [value (normalize-property-text value)] (and (string? value) + (nil? (re-find forbidden-property-chars-re value)) (<= (count value) property-max-length)))) +(defn prepare-property-name + "Sanitize then validate a property name. Returns the cleaned string or nil." + [value] + (let [value (sanitize-property-text value)] + (when (valid-property-name? value) + value))) + +(defn prepare-property-value + "Sanitize then validate a property value. nil becomes \"\". + Other non-strings yield nil. Empty string remains valid." + [value] + (let [value (sanitize-property-text (if (nil? value) "" value))] + (when (valid-property-value? value) + value))) + (defn can-remove-property? [properties] (> (count properties) 1)) @@ -112,7 +140,8 @@ ([path properties] (path-to-properties path properties 0)) ([path properties min-props] - (let [cpath (cpn/split-path path) + (let [cpath (->> (cpn/split-path path) + (mapv #(or (sanitize-property-text %) ""))) total-props (max (count cpath) min-props) assigned (mapv #(assoc % :value (nth cpath %2 "")) properties (range)) ;; Add empty strings to the end of cpath to reach the minimum number of properties diff --git a/common/test/common_tests/files/repair_test.cljc b/common/test/common_tests/files/repair_test.cljc index 27f6964de2..f6f24821ce 100644 --- a/common/test/common_tests/files/repair_test.cljc +++ b/common/test/common_tests/files/repair_test.cljc @@ -378,3 +378,171 @@ (t/is (= uuid/zero (:parent-id shape1'))) (t/is (= uuid/zero (:parent-id shape2'))) (t/is (= #{(thi/id :shape1) (thi/id :shape2)} (set (:shapes root'))))))) + +(defn- broken-frame-file + [] + (-> (thf/sample-file :file1 :page-label :page1) + (assoc :revn 3) + (ths/add-sample-shape :shape1) + (ths/update-shape :shape1 :frame-id (uuid/next)))) + +(t/deftest repair-file-until-valid-fixes-frame-not-found + (t/testing "repairs a :frame-not-found shape and leaves :revn untouched" + (let [file (broken-frame-file) + errors (cfv/validate-file file {}) + result (cfr/repair-file-until-valid + file {} + (fn [file _] (cfv/validate-file file {}))) + file' (:file result) + errors' (cfv/validate-file file' {}) + shape' (ths/get-shape file' :shape1 :page-label :page1)] + + (t/is (= #{:frame-not-found} (into #{} (map :code) errors))) + (t/is (pos? (count (:changes result)))) + (t/is (nil? (:errors result))) + (t/is (nil? errors')) + (t/is (= 3 (:revn file'))) + (t/is (some? (get-in file' [:data :pages-index (thi/id :page1) :objects (:frame-id shape')])))))) + +(t/deftest repair-file-until-valid-healthy-file + (t/testing "a valid file is returned unchanged" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (assoc :revn 2) + (ths/add-sample-shape :shape1)) + result (cfr/repair-file-until-valid + file {} + (fn [file _] (cfv/validate-file file {})))] + (t/is (identical? file (:file result))) + (t/is (= [] (:changes result))) + (t/is (zero? (:iterations result))) + (t/is (nil? (:errors result))) + (t/is (= 2 (:revn (:file result))))))) + +(t/deftest repair-file-until-valid-passes-applied-changes + (t/testing "errors-fn receives the changes applied so far" + (let [calls (atom []) + file (broken-frame-file) + result (cfr/repair-file-until-valid + file {} + (fn [file applied] + (swap! calls conj applied) + (cfv/validate-file file {})))] + (t/is (nil? (:errors result))) + (t/is (>= (count @calls) 2)) + (t/is (= [] (first @calls))) + (t/is (pos? (count (last @calls))))))) + +(t/deftest repair-file-until-valid-stops-on-noop-repair + (t/testing "an empty repair batch stops after one iteration" + (let [file (broken-frame-file) + errors (cfv/validate-file file {})] + (with-redefs [cfr/repair-file (fn [_ _ _] [])] + (let [result (cfr/repair-file-until-valid + file {} + (fn [file _] (cfv/validate-file file {})))] + (t/is (= 1 (:iterations result))) + (t/is (= [] (:changes result))) + (t/is (= (mapv :code errors) (mapv :code (:errors result))))))))) + +(t/deftest repair-file-until-valid-repairs-several-errors-on-one-shape + (t/testing "three missing children of one frame are all removed although + every pass reports the same [code shape-id]" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (ths/add-sample-shape :frame1 :type :frame) + (ths/update-shape :frame1 :shapes [(uuid/next) (uuid/next) (uuid/next)])) + errors (cfv/validate-file file {}) + result (cfr/repair-file-until-valid + file {} + (fn [file _] (cfv/validate-file file {}))) + frame1' (ths/get-shape (:file result) :frame1 :page-label :page1)] + (t/is (= [:child-not-found :child-not-found :child-not-found] (mapv :code errors))) + (t/is (= #{(thi/id :frame1)} (into #{} (map :shape-id) errors))) + (t/is (nil? (:errors result))) + (t/is (nil? (cfv/validate-file (:file result) {}))) + (t/is (= [] (:shapes frame1')))))) + +(t/deftest repair-file-until-valid-stops-when-repair-changes-nothing + (t/testing "a non-empty repair that leaves data equal stops after one iteration" + (let [file (broken-frame-file) + shape-id (thi/id :shape1) + page-id (thi/id :page1) + cur-name (:name (ths/get-shape file :shape1 :page-label :page1))] + (with-redefs [cfr/repair-file + (fn [_ _ _] + [{:type :mod-obj + :id shape-id + :page-id page-id + :operations [{:type :set :attr :name :val cur-name}]}])] + (let [result (cfr/repair-file-until-valid + file {} + (fn [_ _] [{:code :frame-not-found :shape-id shape-id}]) + :max-iterations 10)] + (t/is (= 1 (:iterations result))) + (t/is (= [] (:changes result))) + (t/is (= [:frame-not-found] (mapv :code (:errors result))))))))) + +(t/deftest repair-file-until-valid-stops-when-error-fingerprint-stable + (t/testing "a mutating repair with an unchanged error report stops after two iterations" + (let [file (broken-frame-file) + shape-id (thi/id :shape1) + page-id (thi/id :page1) + pass (atom 0) + sticky {:code :frame-not-found + :shape-id shape-id + :hint "stuck" + :args {:x 1}}] + (with-redefs [cfr/repair-file + (fn [_ _ _] + [{:type :mod-obj + :id shape-id + :page-id page-id + :operations [{:type :set + :attr :name + :val (str "pass-" (swap! pass inc))}]}])] + (let [result (cfr/repair-file-until-valid + file {} + (fn [_ _] [sticky]) + :max-iterations 10)] + (t/is (= 2 (:iterations result))) + (t/is (= 1 (count (:changes result)))) + (t/is (= [:frame-not-found] (mapv :code (:errors result))))))))) + +(t/deftest repair-file-until-valid-stops-at-max-iterations + (t/testing "a repair that never empties errors stops at max-iterations" + (let [file (broken-frame-file) + pass (atom 0) + shape-id (thi/id :shape1) + page-id (thi/id :page1)] + (with-redefs [cfr/repair-file + (fn [_ _ _] + [{:type :mod-obj + :id shape-id + :page-id page-id + :operations [{:type :set + :attr :name + :val (str "pass-" (swap! pass inc))}]}])] + (let [result (cfr/repair-file-until-valid + file {} + (fn [_ _] + [{:code :frame-not-found + :shape-id (uuid/next)}]) + :max-iterations 3)] + (t/is (= 3 (:iterations result))) + (t/is (= 3 (count (:changes result)))) + (t/is (some? (:errors result)))))))) + +(t/deftest repair-file-until-valid-uses-initial-errors + (t/testing "errors-fn is not called on the first iteration when :initial-errors is given" + (let [calls (atom 0) + file (broken-frame-file) + seed (cfv/validate-file file {}) + result (cfr/repair-file-until-valid + file {} + (fn [file applied] + (swap! calls inc) + (cfv/validate-file file {})) + :initial-errors seed)] + (t/is (nil? (:errors result))) + ;; First iteration used the seed; errors-fn runs only after the repair. + (t/is (= 1 @calls))))) + diff --git a/common/test/common_tests/files_ref_paths_test.cljc b/common/test/common_tests/files_ref_paths_test.cljc index 2975d3d789..6105ead264 100644 --- a/common/test/common_tests/files_ref_paths_test.cljc +++ b/common/test/common_tests/files_ref_paths_test.cljc @@ -587,7 +587,9 @@ [:set-token-theme :id] [:set-token-theme :attrs :id] [:set-tokens-status :theme-ids ::set] - [:set-tokens-status :set-ids ::set]}) + [:set-tokens-status :set-ids ::set] + [:set-own-tokens-status :theme-ids ::set] + [:set-own-tokens-status :set-ids ::set]}) (t/deftest every-change-uuid-is-classified (let [known (set/union change-ref-paths change-non-reference-paths) diff --git a/common/test/common_tests/geom_shapes_constraints_test.cljc b/common/test/common_tests/geom_shapes_constraints_test.cljc index 4f6cd3ce4c..081d32325e 100644 --- a/common/test/common_tests/geom_shapes_constraints_test.cljc +++ b/common/test/common_tests/geom_shapes_constraints_test.cljc @@ -7,21 +7,11 @@ (ns common-tests.geom-shapes-constraints-test (:require [app.common.geom.shapes.constraints :as gsc] + [app.common.types.modifiers :as ctm] [clojure.test :as t])) ;; ---- constraint-modifier :default ---- -(t/deftest constraint-modifier-default-returns-empty-vector - (t/testing ":default method accepts 6 args and returns an empty vector" - ;; Before the fix the :default method only accepted 5 positional args - ;; (plus the dispatch value), so calling it with 6 args would throw an - ;; arity error. After the fix it takes [_ _ _ _ _ _] and returns []. - (let [result (gsc/constraint-modifier :unknown-constraint-type - :x nil nil nil nil)] - (t/is (vector? result)) - (t/is (empty? result)))) - - (t/testing ":default method returns [] for :scale-like unknown type on :y axis" - (let [result (gsc/constraint-modifier :some-other-unknown - :y nil nil nil nil)] - (t/is (= [] result))))) +(t/deftest constraint-modifier-default-returns-empty-modifiers + (t/is (= (ctm/empty) + (gsc/constraint-modifier :unknown-constraint-type :x nil nil nil nil)))) diff --git a/common/test/common_tests/logic/comp_flex_interactions_test.cljc b/common/test/common_tests/logic/comp_flex_interactions_test.cljc index 0fa3644c6f..b3a339bc51 100644 --- a/common/test/common_tests/logic/comp_flex_interactions_test.cljc +++ b/common/test/common_tests/logic/comp_flex_interactions_test.cljc @@ -10,7 +10,6 @@ [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.logic.libraries :as cll] - [app.common.logic.shapes :as cls] [app.common.math :as mth] [app.common.test-helpers.components :as thc] [app.common.test-helpers.compositions :as tho] @@ -19,6 +18,7 @@ [app.common.test-helpers.shapes :as ths] [app.common.types.component :as ctk] [app.common.types.container :as ctn] + [app.common.types.modifiers :as ctm] [clojure.test :as t])) (t/use-fixtures :each thi/test-fixture) @@ -39,17 +39,6 @@ (ths/add-interaction :main-child :main-popup) (ths/add-interaction :copy-child :copy-popup))) -(defn- update-shape - [file label update-fn] - (let [page (thf/current-page file)] - (thf/apply-changes - file - (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) - #{(thi/id label)} - update-fn - (:objects page) - {})))) - (defn- destinations [shape] (mapv :destination (:interactions shape))) @@ -60,8 +49,8 @@ margin {:m1 5 :m2 5 :m3 5 :m4 5} ;; ==== Action - updated-file (update-shape file :main-child - #(assoc % :hidden true :layout-item-margin margin)) + updated-file (tho/change-shape file :main-child + #(assoc % :hidden true :layout-item-margin margin)) changes (cll/generate-sync-file-changes (pcb/empty-changes) nil :components @@ -85,7 +74,7 @@ file (setup-file) ;; ==== Action - updated-file (update-shape file :copy-child #(assoc % :hidden true)) + updated-file (tho/change-shape file :copy-child #(assoc % :hidden true)) page (thf/current-page updated-file) container (ctn/make-container page :page) changes (-> (pcb/empty-changes) @@ -144,7 +133,7 @@ ;; ==== Action library' (-> library - (update-shape :main1-child #(gsh/move % (gpt/point 1 0))) + (tho/change-shape :main1-child #(gsh/move % (gpt/point 1 0))) (tho/propagate-component-changes :component1)) file' (thf/apply-changes file (sync-file-from-library file library')) @@ -190,7 +179,7 @@ :layout :flex :layout-flex-dir :row}) (thc/instantiate-component :icon-a :nested-head {:parent-label :board-root}) - (update-shape :nested-head #(assoc % :layout-item-absolute true)) + (tho/change-shape :nested-head #(assoc % :layout-item-absolute true)) (thc/make-component :board :board-root))) (t/deftest test-sync-library-keeps-swapped-nested-absolute @@ -205,8 +194,8 @@ swapped (ths/get-shape file :copy-nested-head) ;; ==== Action - library' (update-shape library :board-root - #(assoc % :fills (ths/sample-fills-color :fill-color "#fabada"))) + library' (tho/change-shape library :board-root + #(assoc % :fills (ths/sample-fills-color :fill-color "#fabada"))) file' (thf/apply-changes file (sync-file-from-library file library')) swapped' (ths/get-shape file' :copy-nested-head)] @@ -229,8 +218,8 @@ file (swap-to-library-component file library :copy-nested-head :icon-b) ;; ==== Action - library' (update-shape library :nested-head - #(assoc % :layout-item-absolute false)) + library' (tho/change-shape library :nested-head + #(assoc % :layout-item-absolute false)) file' (thf/apply-changes file (sync-file-from-library file library')) swapped' (ths/get-shape file' :copy-nested-head)] @@ -250,7 +239,7 @@ :layout-flex-dir :row}) (thc/instantiate-component :icon-a :nested-head {:parent-label :board-root}) (ths/add-sample-shape :sibling :type :rect :name "Sibling" :parent-label :board-root) - (update-shape :nested-head #(assoc % :layout-item-absolute true)) + (tho/change-shape :nested-head #(assoc % :layout-item-absolute true)) (thc/make-component :board :board-root)) file (-> (thf/sample-file :file) (thc/instantiate-component :board @@ -260,8 +249,8 @@ file (swap-to-library-component file library :copy-nested-head :icon-b) ;; ==== Action - library' (update-shape library :sibling - #(assoc % :layout-item-margin margin)) + library' (tho/change-shape library :sibling + #(assoc % :layout-item-margin margin)) file' (thf/apply-changes file (sync-file-from-library file library')) sibling' (ths/get-shape file' :copy-sibling) swapped' (ths/get-shape file' :copy-nested-head)] @@ -270,3 +259,212 @@ (t/is (= margin (:layout-item-margin sibling'))) (t/is (true? (:layout-item-absolute swapped'))) (t/is (some? (ctk/get-swap-slot swapped'))))) + +(defn- setup-library-with-icon-slot + "Library: icon A, icon B, and a flex board whose `:slot` frame holds an + instance of icon A. The slot has a flex layout and the icon is absolute; + with `:layout? false` the slot has no layout and the icon is a plain child." + [& {:keys [layout?] :or {layout? true}}] + (-> (thf/sample-file :library) + (tho/add-simple-component :icon-a :icon-a-root :icon-a-child) + (tho/add-simple-component :icon-b :icon-b-root :icon-b-child) + (tho/add-frame :board-root {:name "Board" + :layout :flex + :layout-flex-dir :row}) + (tho/add-frame :slot (cond-> {:name "Slot" :parent-label :board-root} + layout? (assoc :layout :flex :layout-flex-dir :row))) + (thc/instantiate-component :icon-a :nested-head {:parent-label :slot}) + (cond-> layout? (tho/change-shape :nested-head #(assoc % :layout-item-absolute true))) + (thc/make-component :board :board-root))) + +(defn- instantiate-board + "File with a copy of the library board, labelling its slot and nested icon." + [library] + (let [file (-> (thf/sample-file :file) + (thc/instantiate-component :board + :copy-board + :library library + :children-labels [:copy-slot])) + slot (ths/get-shape file :copy-slot)] + (thi/set-id! :copy-nested-head (first (:shapes slot))) + file)) + +(defn- offset-in-slot + [file slot-label icon-label] + (let [slot (ths/get-shape file slot-label) + icon (ths/get-shape file icon-label)] + (gpt/point (- (:x icon) (:x slot)) + (- (:y icon) (:y slot))))) + +(defn- sync-after-unrelated-change + [file library root-label] + (let [library' (tho/change-shape library root-label + #(assoc % :fills (ths/sample-fills-color :fill-color "#fabada")))] + (thf/apply-changes file (sync-file-from-library file library')))) + +(t/deftest test-sync-places-swapped-child-in-moved-slot + (let [;; ==== Setup + library (setup-library-with-icon-slot) + file (-> (instantiate-board library) + (swap-to-library-component library :copy-nested-head :icon-b) + ;; The layout moves the slot, and a previous sync moved the icon away + (tho/move-shape-tree :copy-slot (gpt/point 800 0) :ignore-touched true) + (tho/move-shape-tree :copy-nested-head (gpt/point 800 0) :ignore-touched true)) + + ;; ==== Action + file' (sync-after-unrelated-change file library :board-root)] + + ;; ==== Check + (t/is (gpt/close? (offset-in-slot library :slot :nested-head) + (offset-in-slot file' :copy-slot :copy-nested-head))) + (t/is (true? (:layout-item-absolute (ths/get-shape file' :copy-nested-head)))))) + +(t/deftest test-sync-places-swapped-child-in-moved-slot-without-layout + (let [;; ==== Setup + library (setup-library-with-icon-slot :layout? false) + file (-> (instantiate-board library) + (swap-to-library-component library :copy-nested-head :icon-b) + ;; The layout moves the slot, and a previous sync moved the icon away + (tho/move-shape-tree :copy-slot (gpt/point 800 0) :ignore-touched true) + (tho/move-shape-tree :copy-nested-head (gpt/point 800 0) :ignore-touched true)) + + ;; ==== Action + file' (sync-after-unrelated-change file library :board-root)] + + ;; ==== Check + (t/is (gpt/close? (offset-in-slot library :slot :nested-head) + (offset-in-slot file' :copy-slot :copy-nested-head))))) + +(t/deftest test-sync-keeps-moved-child-in-moved-slot + (let [;; ==== Setup + library (setup-library-with-icon-slot) + file (-> (instantiate-board library) + (tho/move-shape-tree :copy-nested-head (gpt/point 5 0)) + (tho/move-shape-tree :copy-slot (gpt/point 800 0) :ignore-touched true)) + before (offset-in-slot file :copy-slot :copy-nested-head) + + ;; ==== Action + file' (sync-after-unrelated-change file library :board-root)] + + ;; ==== Check + (t/is (gpt/close? before (offset-in-slot file' :copy-slot :copy-nested-head))))) + +(t/deftest test-sync-keeps-user-moved-swapped-child-in-moved-slot + (let [;; ==== Setup + library (setup-library-with-icon-slot) + file (-> (instantiate-board library) + (swap-to-library-component library :copy-nested-head :icon-b) + (tho/move-shape-tree :copy-nested-head (gpt/point 5 0)) + (tho/move-shape-tree :copy-slot (gpt/point 800 0) :ignore-touched true)) + before (offset-in-slot file :copy-slot :copy-nested-head) + + ;; ==== Action + file' (sync-after-unrelated-change file library :board-root)] + + ;; ==== Check + (t/is (gpt/close? before (offset-in-slot file' :copy-slot :copy-nested-head))))) + +(defn- with-shapes-first + "Puts the shapes with the given labels first in the page objects, in that + order. The sync visits the shapes of a page in this order." + [file labels] + (let [page-id (:id (thf/current-page file)) + ids (mapv thi/id labels)] + (update-in file [:data :pages-index page-id :objects] + (fn [objects] + (apply array-map (concat (mapcat #(vector % (get objects %)) ids) + (mapcat identity (apply dissoc objects ids)))))))) + +(t/deftest test-sync-places-swapped-child-where-main-slot-occupant-is + (let [;; ==== Setup + library (setup-library-with-icon-slot) + file (-> (instantiate-board library) + (swap-to-library-component library :copy-nested-head :icon-b) + ;; The copy comes before its swapped icon, which the sync also syncs on its own + (with-shapes-first [:copy-board :copy-nested-head])) + + ;; ==== Action + library' (-> library + (tho/move-shape-tree :nested-head (gpt/point 4 4)) + (tho/change-shape :icon-b-root #(gsh/transform-shape % (ctm/change-size % 32 nil)))) + file' (thf/apply-changes file (sync-file-from-library file library')) + + ;; ==== Get + icon' (ths/get-shape file' :copy-nested-head)] + + ;; ==== Check + (t/is (gpt/close? (offset-in-slot library' :slot :nested-head) + (offset-in-slot file' :copy-slot :copy-nested-head))) + (t/is (mth/close? (:width (ths/get-shape library' :icon-b-root)) (:width icon'))) + (t/is (mth/close? (:x icon') (-> icon' :selrect :x))))) + +(defn- setup-library-with-root-icon + "Library: icon A, and a 300px wide flex card whose `:close` child, placed + directly in the root, is an absolute instance of icon A with the given + horizontal constraint." + [constraints-h] + (-> (thf/sample-file :library) + (tho/add-simple-component :icon-a :icon-a-root :icon-a-child + :root-params {:width 24 :height 24} + :child-params {:width 24 :height 24}) + (tho/add-simple-component :icon-b :icon-b-root :icon-b-child + :root-params {:width 24 :height 24} + :child-params {:width 24 :height 24}) + (tho/add-frame :card-root {:name "Card" + :layout :flex + :layout-flex-dir :row + :width 300 + :height 48}) + (thc/instantiate-component :icon-a :close {:parent-label :card-root}) + (tho/change-shape :close #(assoc % :layout-item-absolute true :constraints-h constraints-h)) + (thc/make-component :card :card-root))) + +(defn- instantiate-filled-card + "File with a copy of the card, widened by 800px and with its close icon + moved by `icon-shift`, the way the layout does when the copy fills a wider + parent." + [library icon-shift] + (-> (thf/sample-file :file) + (thc/instantiate-component :card + :copy-card + :library library + :children-labels [:copy-close]) + (tho/change-shape :copy-card + #(gsh/transform-shape % (ctm/change-size % (+ (:width %) 800) nil)) + :ignore-touched true) + (tho/move-shape-tree :copy-close (gpt/point icon-shift 0) :ignore-touched true))) + +(t/deftest test-sync-moved-root-child-follows-root-through-right-constraint + (let [;; ==== Setup + library (setup-library-with-root-icon :right) + file (-> (instantiate-filled-card library 800) + (tho/move-shape-tree :copy-close (gpt/point 5 0))) + + ;; ==== Action + file' (sync-after-unrelated-change file library :card-root)] + + ;; ==== Check + (t/is (gpt/close? (gpt/add (offset-in-slot library :card-root :close) (gpt/point 5 0)) + (offset-in-slot file' :copy-card :copy-close))))) + +(t/deftest test-sync-scales-swapped-root-child-back-with-root + (let [;; ==== Setup + library (setup-library-with-root-icon :scale) + file (-> (instantiate-filled-card library 0) + (swap-to-library-component library :copy-close :icon-b) + ;; The swap drops the constraints; set them again so this path runs + (tho/change-shape :copy-close #(assoc % :constraints-h :scale))) + + ;; ==== Action + file' (sync-after-unrelated-change file library :card-root) + + ;; ==== Get + scale (/ (:width (ths/get-shape file' :copy-card)) + (:width (ths/get-shape file :copy-card))) + inner #(ths/get-shape-by-id % (first (:shapes (ths/get-shape % :copy-close))))] + + ;; ==== Check + (t/is (mth/close? (* scale (:width (ths/get-shape file :copy-close))) + (:width (ths/get-shape file' :copy-close)))) + ;; The icon content follows the icon through its own (left) constraint + (t/is (mth/close? (:width (inner file)) (:width (inner file')))))) diff --git a/common/test/common_tests/logic/comp_sync_test.cljc b/common/test/common_tests/logic/comp_sync_test.cljc index a929c7a616..02cd219779 100644 --- a/common/test/common_tests/logic/comp_sync_test.cljc +++ b/common/test/common_tests/logic/comp_sync_test.cljc @@ -18,6 +18,7 @@ [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.shapes :as ths] [app.common.types.component :as ctk] + [app.common.types.container :as ctn] [app.common.types.shape-tree :as ctst] [clojure.test :as t])) @@ -539,3 +540,258 @@ ;; A position-only change in the main component must not propagate to copies ;; and therefore must produce no redo-changes. (t/is (empty? (:redo-changes sync-changes))))) + +(defn- sync-file-changes + [file] + (cll/generate-sync-file-changes (pcb/empty-changes) + nil + :components + (:id file) + (thi/id :component1) + (:id file) + {(:id file) file} + (:id file))) + +(t/deftest test-sync-many-updated-shapes + ;; The undo changes of a sync used to grow one lazy level per updated + ;; shape (#12576). Overflowing the stack needs thousands of shapes, which + ;; is too slow here; test-update-attr-changes-many-calls covers that. + (let [;; ==== Setup + n 500 + child-labels (mapv #(keyword (str "main-child-" %)) (range n)) + file (-> (thf/sample-file :file1) + (tho/add-component-with-many-children-and-copy + :component1 + :main-root + child-labels + :copy-root + :main-child-params-list (repeat n {:fills (ths/sample-fills-color + :fill-color "#abcdef")}))) + page (thf/current-page file) + + ;; ==== Action + changes1 (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + (into #{} (map thi/id) child-labels) + (fn [shape] + (assoc shape :fills (ths/sample-fills-color :fill-color "#fabada"))) + (:objects page) + {}) + updated-file (thf/apply-changes file changes1) + changes2 (sync-file-changes updated-file) + file' (thf/apply-changes updated-file changes2) + file'' (thf/apply-undo-changes file' changes2) + + ;; ==== Get + copy-fill (fn [file] + (let [copy-root (ths/get-shape file :copy-root)] + (into #{} + (map #(-> (ths/get-shape-by-id file %) :fills first :fill-color)) + (:shapes copy-root))))] + + ;; ==== Check + (t/is (= #{"#fabada"} (copy-fill file'))) + (t/is (= #{"#abcdef"} (copy-fill file''))))) + +(t/deftest test-update-attr-changes-many-calls + ;; Regression: each call nested the undo list in one more lazy concat, so + ;; walking it after many calls overflowed the stack (#12576). + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (tho/add-rect :rect1)) + page (thf/current-page file) + rect (ths/get-shape file :rect1) + roperations [{:type :set :attr :opacity :val 0.5 :ignore-touched true}] + uoperations (list {:type :set :attr :opacity :val 1 :ignore-touched true}) + + ;; ==== Action + changes (reduce (fn [changes _] + (#'cll/add-update-attr-changes changes rect page roperations uoperations)) + (pcb/empty-changes) + (range 50000)) + undo (:undo-changes changes)] + + ;; ==== Check + (t/is (= 100000 (count undo))) + ;; Undo restores the shape first and then recalculates its parents. + (t/is (= [:mod-obj :reg-objects] (map :type (take 2 undo)))))) + +(defn- geometry + [shape] + ;; Compare as doubles: a recalculated selrect has doubles where the + ;; original one may have longs. + {:selrect (update-vals (select-keys (:selrect shape) [:x :y :width :height]) double) + :points (mapv #(update-vals (select-keys % [:x :y]) double) (:points shape))}) + +(defn- add-group-component-with-copy + [file] + ;; Generated shape tree: + ;; :main-root [:name Frame1] # [Component :component1] + ;; :main-group [:name Group1] + ;; :main-child1 [:name Rect1] + ;; :main-child2 [:name Rect2] + ;; + ;; :copy-root [:name Frame1] #--> [Component :component1] :main-root + ;; :copy-group [:name Group1] ---> :main-group + ;; :copy-child1 [:name Rect1] ---> :main-child1 + ;; :copy-child2 [:name Rect2] ---> :main-child2 + (-> file + (tho/add-frame :main-root :x 0 :y 0 :width 100 :height 100) + (tho/add-group :main-group :parent-label :main-root :x 10 :y 10 :width 30 :height 10) + (ths/add-sample-shape :main-child1 :parent-label :main-group :name "Rect1" + :x 10 :y 10 :width 10 :height 10 + :fills (ths/sample-fills-color :fill-color "#abcdef")) + (ths/add-sample-shape :main-child2 :parent-label :main-group :name "Rect2" + :x 30 :y 10 :width 10 :height 10 + :fills (ths/sample-fills-color :fill-color "#abcdef")) + (thc/make-component :component1 :main-root) + (thc/instantiate-component :component1 :copy-root + :children-labels [:copy-group :copy-child1 :copy-child2]))) + +(defn- touch-copy-group-geometry + ;; With its geometry touched, the sync does not copy the group's own + ;; geometry from the main, so only the parents recalculation decides it. + [file] + (reduce #(ths/update-shape %1 %2 :touched #{:geometry-group}) + file + [:copy-group :copy-child1 :copy-child2])) + +(defn- update-fill + [file label color] + (let [page (thf/current-page file) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(thi/id label)} + #(assoc % :fills (ths/sample-fills-color :fill-color color)) + (:objects page) + {})] + (thf/apply-changes file changes))) + +(t/deftest test-sync-undo-restores-group-geometry + (let [;; ==== Setup + file (add-group-component-with-copy (thf/sample-file :file1)) + page (thf/current-page file) + copy-group (ths/get-shape file :copy-group) + + ;; ==== Action + changes1 (-> (pcb/empty-changes nil (:id page)) + (pcb/with-objects (:objects page)) + (cls/generate-update-shapes #{(thi/id :main-child2)} + #(gsh/absolute-move % (gpt/point 60 60)) + (:objects page) + {}) + (pcb/resize-parents [(thi/id :main-child2)])) + updated-file (thf/apply-changes file changes1) + changes2 (sync-file-changes updated-file) + file' (thf/apply-changes updated-file changes2) + file'' (thf/apply-undo-changes file' changes2) + + ;; ==== Get + copy-group' (ths/get-shape file' :copy-group) + copy-group'' (ths/get-shape file'' :copy-group)] + + ;; ==== Check + (t/is (not= (geometry copy-group) (geometry copy-group'))) + (t/is (= (geometry copy-group) (geometry copy-group''))))) + +(t/deftest test-sync-undo-group-after-add-and-update + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (add-group-component-with-copy) + (touch-copy-group-geometry) + (ths/add-sample-shape :free-shape :x 60 :y 60 :width 10 :height 10)) + page (thf/current-page file) + copy-group (ths/get-shape file :copy-group) + + ;; ==== Action + ;; The new shape goes first in the group, so the sync handles it before + ;; the updated sibling. + changes1 (cls/generate-relocate (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-objects (:objects page))) + (thi/id :main-group) + 0 + #{(thi/id :free-shape)}) + updated-file (-> (thf/apply-changes file changes1) + (update-fill :main-child1 "#fabada")) + changes2 (sync-file-changes updated-file) + file' (thf/apply-changes updated-file changes2) + file'' (thf/apply-undo-changes file' changes2) + + ;; ==== Get + copy-group' (ths/get-shape file' :copy-group) + copy-group'' (ths/get-shape file'' :copy-group)] + + ;; ==== Check + (t/is (= 3 (count (:shapes copy-group')))) + (t/is (not= (geometry copy-group) (geometry copy-group'))) + (t/is (= (:shapes copy-group) (:shapes copy-group''))) + (t/is (= (geometry copy-group) (geometry copy-group''))))) + +(t/deftest test-sync-undo-group-after-remove-and-update + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (add-group-component-with-copy) + (touch-copy-group-geometry)) + page (thf/current-page file) + copy-group (ths/get-shape file :copy-group) + + ;; ==== Action + ;; The removed shape is the first one in the group, so the sync + ;; handles it before the updated sibling. Remove it only from the + ;; main, as a library update does, so that the sync removes it from + ;; the copy. + changes1 (-> (pcb/empty-changes nil (:id page)) + (pcb/with-objects (:objects page)) + (pcb/remove-objects #{(thi/id :main-child1)})) + updated-file (-> (thf/apply-changes file changes1) + (update-fill :main-child2 "#fabada")) + changes2 (sync-file-changes updated-file) + file' (thf/apply-changes updated-file changes2) + file'' (thf/apply-undo-changes file' changes2) + + ;; ==== Get + copy-group' (ths/get-shape file' :copy-group) + copy-group'' (ths/get-shape file'' :copy-group)] + + ;; ==== Check + (t/is (= 1 (count (:shapes copy-group')))) + (t/is (not= (geometry copy-group) (geometry copy-group'))) + (t/is (= (:shapes copy-group) (:shapes copy-group''))) + (t/is (= (geometry copy-group) (geometry copy-group''))))) + +(t/deftest test-sync-inverse-undo-group-after-add-and-update + (let [;; ==== Setup + ;; Penpot does not let users move shapes into a copy, but files can + ;; still hold copies with shapes the main lacks, so build the data + ;; directly. The new shape goes first in the copy group, so the + ;; inverse sync handles it before the updated sibling. + file (-> (thf/sample-file :file1) + (add-group-component-with-copy) + (ths/add-sample-shape :free-shape :parent-label :copy-group + :x 160 :y 160 :width 10 :height 10) + (ths/update-shape :copy-group :shapes [(thi/id :free-shape) + (thi/id :copy-child1) + (thi/id :copy-child2)])) + main-group (ths/get-shape file :main-group) + + ;; ==== Action + updated-file (update-fill file :copy-child1 "#fabada") + page (thf/current-page updated-file) + container (ctn/make-container page :page) + changes (-> (pcb/empty-changes) + (pcb/with-container container) + (cll/generate-sync-shape-inverse (:data updated-file) + {(:id updated-file) updated-file} + container + (thi/id :copy-root))) + file' (thf/apply-changes updated-file changes) + file'' (thf/apply-undo-changes file' changes) + + ;; ==== Get + main-group' (ths/get-shape file' :main-group) + main-group'' (ths/get-shape file'' :main-group)] + + ;; ==== Check + (t/is (= 3 (count (:shapes main-group')))) + (t/is (not= (geometry main-group) (geometry main-group'))) + (t/is (= (:shapes main-group) (:shapes main-group''))) + (t/is (= (geometry main-group) (geometry main-group''))))) diff --git a/common/test/common_tests/logic/swap_missing_library_test.cljc b/common/test/common_tests/logic/swap_missing_library_test.cljc new file mode 100644 index 0000000000..64853a1a74 --- /dev/null +++ b/common/test/common_tests/logic/swap_missing_library_test.cljc @@ -0,0 +1,79 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns common-tests.logic.swap-missing-library-test + (:require + [app.common.files.validate :as cfv] + [app.common.test-helpers.components :as thc] + [app.common.test-helpers.compositions :as tho] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [clojure.test :as t])) + +(t/use-fixtures :each thi/test-fixture) + +(defn- setup-library [] + ;; {:frame-a} [:name Frame1] # [Component :component-a] + ;; :child-a [:name Rect1] + (-> (thf/sample-file :library) + (tho/add-simple-component :component-a :frame-a :child-a + :root-params {:name "component-a"} + :child-params {:name "child-a" + :type :rect + :fills (ths/sample-fills-color :fill-color "#111111")}))) + +(defn- setup-file [library] + ;; {:frame-b} [:name Frame1] # [Component :component-b] + ;; :copy-a [:name component-a] @--> [Component :component-a] (in library) :frame-a + ;; [:name child-a] ---> :child-a + ;; + ;; :copy-b [:name Frame1] #--> [Component :component-b] :frame-b + ;; [:name component-a] @--> [Component :component-a] (in library) :copy-a + ;; [:name child-a] ---> + ;; + ;; {:frame-c} [:name Frame1] # [Component :component-c] + ;; :child-c [:name Rect1] + (-> (thf/sample-file :file) + (tho/add-frame :frame-b) + (thc/instantiate-component :component-a :copy-a + :parent-label :frame-b + :library library) + (thc/make-component :component-b :frame-b) + (thc/instantiate-component :component-b :copy-b) + (tho/add-simple-component :component-c :frame-c :child-c + :root-params {:name "component-c"} + :child-params {:name "child-c" + :type :rect + :fills (ths/sample-fills-color :fill-color "#333333")}))) + +(defn- propagate-all-component-changes [file] + (-> file + (tho/propagate-component-changes :component-c) + (tho/propagate-component-changes :component-b))) + +(defn- fill-colors [file] + [(tho/bottom-fill-color file :frame-b) + (tho/bottom-fill-color file :copy-b)]) + +(t/deftest test-swap-nested-copy-with-main-in-missing-library + (let [library (setup-library) + file (setup-file library) + + ;; The library is not passed to the swap, so the main of A is not + ;; available. + file' (tho/swap-component-in-first-child file :copy-b :component-c)] + + (cfv/validate-file! file' {(:id library) library}) + + (t/testing "after the swap, B and its copy contain the new copy of C" + (t/is (= ["#111111" "#333333"] (fill-colors file')))) + + (t/testing "changes in the main of C propagate to B and its copy" + (let [file'' (tho/update-bottom-color file' :frame-c "#444444" + :propagate-fn propagate-all-component-changes)] + + (t/is (= ["#111111" "#444444"] (fill-colors file''))))))) diff --git a/common/test/common_tests/logic/token_test.cljc b/common/test/common_tests/logic/token_test.cljc index 799d895f44..f9b11e2fb3 100644 --- a/common/test/common_tests/logic/token_test.cljc +++ b/common/test/common_tests/logic/token_test.cljc @@ -115,6 +115,54 @@ (t/is (= (ctos/get-active-set-ids file-tokens-status) (ctos/get-active-set-ids undo-tokens-status))))) + (t/testing "the file's own tokens status is kept aside while a library is the source, and restored when going back" + (let [own-set-id (uuid/next) + lib-set-id (uuid/next) + lib2-set-id (uuid/next) + library-file (tht/sample-file-with-tokens + :file-id :library + :lib-fn #(ctob/add-set % (ctob/make-token-set :id lib-set-id :name "library set")) + :status-fn #(ctos/set-tokens-status % #{} #{lib-set-id})) + library2-file (tht/sample-file-with-tokens + :file-id :library2 + :lib-fn #(ctob/add-set % (ctob/make-token-set :id lib2-set-id :name "library2 set")) + :status-fn #(ctos/set-tokens-status % #{} #{lib2-set-id})) + file (tht/sample-file-with-tokens + :file-id :file1 + :lib-fn #(ctob/add-set % (ctob/make-token-set :id own-set-id :name "own set")) + :status-fn #(ctos/set-tokens-status % #{} #{own-set-id})) + set-source (fn [file library] + (let [changes (-> (pcb/empty-changes) + (pcb/with-library-data (ctf/file-data file)) + (clt/generate-set-tokens-source library))] + [(thf/apply-changes file changes) changes])) + active-sets #(some-> % ctos/get-active-set-ids) + + [to-library to-library-changes] (set-source file library-file) + [to-library2 _] (set-source to-library library2-file) + [back-to-own back-changes] (set-source to-library2 to-library2) + undo-to-library (thf/apply-undo-changes to-library to-library-changes) + undo-back-to-own (thf/apply-undo-changes back-to-own back-changes)] + + ;; A library as source: its status is used and the own status is kept aside + (t/is (= #{lib-set-id} (active-sets (tht/get-tokens-status to-library)))) + (t/is (= #{own-set-id} (active-sets (tht/get-own-tokens-status to-library)))) + + ;; Another library as source: the own status is still kept aside + (t/is (= #{lib2-set-id} (active-sets (tht/get-tokens-status to-library2)))) + (t/is (= #{own-set-id} (active-sets (tht/get-own-tokens-status to-library2)))) + + ;; Back to the file itself: the own status is restored, nothing is kept aside + (t/is (nil? (tht/get-tokens-source back-to-own))) + (t/is (= #{own-set-id} (active-sets (tht/get-tokens-status back-to-own)))) + (t/is (nil? (tht/get-own-tokens-status back-to-own))) + + ;; Undo works both ways + (t/is (= #{own-set-id} (active-sets (tht/get-tokens-status undo-to-library)))) + (t/is (nil? (tht/get-own-tokens-status undo-to-library))) + (t/is (= #{lib2-set-id} (active-sets (tht/get-tokens-status undo-back-to-own)))) + (t/is (= #{own-set-id} (active-sets (tht/get-own-tokens-status undo-back-to-own)))))) + (t/testing "if the library is nil, the tokens source is cleared and the tokens status is reset to empty" (let [set-id (uuid/next) file (tht/sample-file-with-tokens diff --git a/common/test/common_tests/logic/variants_switch_test.cljc b/common/test/common_tests/logic/variants_switch_test.cljc index 06e8b5ac34..acaa4faf06 100644 --- a/common/test/common_tests/logic/variants_switch_test.cljc +++ b/common/test/common_tests/logic/variants_switch_test.cljc @@ -12,6 +12,7 @@ [app.common.geom.shapes :as gsh] [app.common.logic.libraries :as cll] [app.common.logic.shapes :as cls] + [app.common.math :as mth] [app.common.test-helpers.components :as thc] [app.common.test-helpers.compositions :as tho] [app.common.test-helpers.files :as thf] @@ -19,6 +20,7 @@ [app.common.test-helpers.shapes :as ths] [app.common.test-helpers.variants :as thv] [app.common.types.component :as ctk] + [app.common.types.modifiers :as ctm] [clojure.test :as t])) (t/use-fixtures :each thi/test-fixture) @@ -3181,3 +3183,145 @@ (t/is (= (get-in synced-t font-size-path-0) "30")) (t/is (contains? (:touched synced-t) :content-group)) (t/is (contains? (:touched synced-t) :text-content-text)))))) + +;; ============================================================ +;; SWAPPED CHILD POSITION +;; ============================================================ + +(defn- setup-variant-with-swapped-icon + "Variants whose roots hold an icon A copy, placed 8px lower in the second + variant, and a copy of the first variant with its icon swapped to icon B." + [] + (-> (thf/sample-file :file1) + (tho/add-simple-component :icon-a :icon-a-root :icon-a-child) + (tho/add-simple-component :icon-b :icon-b-root :icon-b-child) + (thv/add-variant-with-copy :v01 :c01 :m01 :c02 :m02 :m01-icon :m02-icon :icon-a) + (tho/move-shape-tree :m02-icon (gpt/point 0 8)) + (thc/instantiate-component :c01 :copy01 :children-labels [:copy-icon]) + (thc/component-swap :copy-icon :icon-b :copy-icon-b))) + +(defn- offset-in-root + [file root-label child-label] + (let [root (ths/get-shape file root-label) + child (ths/get-shape file child-label)] + (gpt/point (- (:x child) (:x root)) + (- (:y child) (:y root))))) + +(t/deftest test-switch-places-swapped-child-where-new-variant-occupant-is + (let [;; ==== Setup + file (setup-variant-with-swapped-icon) + + ;; ==== Action + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 + :keep-touched? true})] + + ;; ==== Check + (t/is (gpt/close? (offset-in-root file :m02 :m02-icon) + (offset-in-root file' :copy02 :copy-icon-b))))) + +(defn- resize-width + [shape width] + (gsh/transform-shape shape (ctm/change-size shape width nil))) + +(defn- setup-card-copy + "Variants of a 300x48 flex card whose `:m01-icon` / `:m02-icon` is an + absolute icon A copy placed directly in the root at x 270, with the given + horizontal constraint. A copy of the first variant fills 800px more, the + way the layout grows it." + [constraints-h] + (let [place-icon (fn [file root-label icon-label] + (let [root (ths/get-shape file root-label) + icon (ths/get-shape file icon-label)] + (-> file + (tho/move-shape-tree icon-label (gpt/point (- (+ (:x root) 270) (:x icon)) + (- (+ (:y root) 12) (:y icon)))) + (tho/change-shape icon-label #(assoc % :layout-item-absolute true + :constraints-h constraints-h))))) + card (fn [file root-label] + (tho/change-shape file root-label #(-> % + (resize-width 300) + (gsh/transform-shape (ctm/change-size % nil 48)) + (assoc :layout :flex :layout-flex-dir :row))))] + (-> (thf/sample-file :file1) + (tho/add-simple-component :icon-a :icon-a-root :icon-a-child + :root-params {:width 24 :height 24} + :child-params {:width 24 :height 24}) + (tho/add-simple-component :icon-b :icon-b-root :icon-b-child + :root-params {:width 24 :height 24} + :child-params {:width 24 :height 24}) + (thv/add-variant-with-copy :v01 :c01 :m01 :c02 :m02 :m01-icon :m02-icon :icon-a) + (card :m01) + (card :m02) + (place-icon :m01 :m01-icon) + (place-icon :m02 :m02-icon) + (thc/instantiate-component :c01 :copy01 :children-labels [:copy-icon]) + (tho/change-shape :copy01 #(resize-width % 1100) :ignore-touched true) + (tho/move-shape-tree :copy-icon (gpt/point (case constraints-h :right 800 0) 0) :ignore-touched true)))) + +(defn- setup-card-variant + "Same as `setup-card-copy`, with the icon of the copy swapped to icon B." + [constraints-h] + (-> (setup-card-copy constraints-h) + (thc/component-swap :copy-icon :icon-b :copy-icon-b) + ;; The swap drops the constraints; set them again so this path runs + (tho/change-shape :copy-icon-b #(assoc % :constraints-h constraints-h)))) + +(defn- distance-to-right-edge + [file root-label child-label] + (let [root (ths/get-shape file root-label) + child (ths/get-shape file child-label)] + (- (+ (:x root) (:width root)) (:x child)))) + +(t/deftest test-switch-moved-swapped-child-follows-right-constraint + (let [;; ==== Setup + file (-> (setup-card-variant :right) + (tho/move-shape-tree :copy-icon-b (gpt/point 5 0))) + before (distance-to-right-edge file :copy01 :copy-icon-b) + + ;; ==== Action + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 + :keep-touched? true})] + + ;; ==== Check + (t/is (mth/close? before (distance-to-right-edge file' :copy02 :copy-icon-b))))) + +(t/deftest test-switch-scales-swapped-child-back-with-root + (let [;; ==== Setup + file (setup-card-variant :scale) + width (:width (ths/get-shape file :copy-icon-b)) + root-w (:width (ths/get-shape file :copy01)) + + ;; ==== Action + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 + :keep-touched? true}) + scale (/ (:width (ths/get-shape file' :copy02)) root-w) + inner #(ths/get-shape-by-id % (first (:shapes (ths/get-shape % :copy-icon-b))))] + + ;; ==== Check + (t/is (mth/close? (* width scale) + (:width (ths/get-shape file' :copy-icon-b)))) + ;; The icon content follows the icon through its own (left) constraint + (t/is (mth/close? (:width (inner file)) (:width (inner file')))))) + +(t/deftest test-switch-scales-moved-child-back-with-root + (let [;; ==== Setup + card (setup-card-copy :scale) + grow (/ (:width (ths/get-shape card :copy01)) + (:width (ths/get-shape card :m01))) + file (-> card + ;; The layout scales the icon with the copy + (tho/change-shape :copy-icon #(resize-width % (* grow (:width %))) :ignore-touched true) + (tho/move-shape-tree :copy-icon (gpt/point 5 0))) + width (:width (ths/get-shape file :copy-icon)) + root-w (:width (ths/get-shape file :copy01)) + + ;; ==== Action + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 + :keep-touched? true}) + + ;; ==== Get + copy02' (ths/get-shape file' :copy02) + icon' (get-in (thf/current-page file') [:objects (-> copy02' :shapes first)])] + + ;; ==== Check + (t/is (mth/close? (* width (/ (:width copy02') root-w)) (:width icon'))))) diff --git a/common/test/common_tests/logic/variants_test.cljc b/common/test/common_tests/logic/variants_test.cljc index c4cfc1b95a..d4a4696336 100644 --- a/common/test/common_tests/logic/variants_test.cljc +++ b/common/test/common_tests/logic/variants_test.cljc @@ -16,7 +16,9 @@ [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.shapes :as ths] [app.common.test-helpers.variants :as thv] - [clojure.test :as t])) + [app.common.types.variant :as ctv] + [clojure.test :as t] + [cuerdas.core :as str])) (t/use-fixtures :each thi/test-fixture) @@ -234,6 +236,178 @@ (t/is (= (-> comp01' :variant-properties first :value) "NewValue1")) (t/is (= (-> comp02' :variant-properties first :value) "NewValue2")))) +(t/deftest test-update-property-value-sanitizes + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02)) + page (thf/current-page file) + comp01 (thc/get-component file :c01) + before (-> comp01 :variant-properties first :value) + + changes (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-update-property-value (:id comp01) 0 "a=b,")) + + file' (thf/apply-changes file changes) + comp01' (thc/get-component file' :c01) + main' (ths/get-shape file' :m01) + + noop (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-update-property-value (:id comp01) 0 42)) + file-noop (thf/apply-changes file noop) + comp-noop (thc/get-component file-noop :c01) + + cleared (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-update-property-value (:id comp01) 0 "=,=")) + file-cleared (thf/apply-changes file cleared) + comp-cleared (thc/get-component file-cleared :c01) + main-cleared (ths/get-shape file-cleared :m01) + + nilled (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-update-property-value (:id comp01) 0 nil)) + file-nilled (thf/apply-changes file nilled) + comp-nilled (thc/get-component file-nilled :c01)] + + (t/is (= "ab" (-> comp01' :variant-properties first :value))) + (t/is (= "ab" (:variant-name main'))) + (t/is (= before (-> comp-noop :variant-properties first :value))) + (t/is (= "" (-> comp-cleared :variant-properties first :value))) + (t/is (not (str/includes? (str (:variant-name main-cleared)) "="))) + (t/is (= "" (-> comp-nilled :variant-properties first :value))))) + +(t/deftest test-update-property-name-sanitizes-and-noops + (let [file (-> (thf/sample-file :file1) + (thv/add-variant-two-properties :v01 :c01 :m01 :c02 :m02)) + v-id (-> (ths/get-shape file :v01) :id) + page (thf/current-page file) + before (-> (thc/get-component file :c01) :variant-properties first :name) + + changes (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-update-property-name v-id 0 "Size=Big")) + + file' (thf/apply-changes file changes) + + noop (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-update-property-name v-id 0 "=,=")) + file-noop (thf/apply-changes file noop)] + + (t/is (= "SizeBig" (-> (thc/get-component file' :c01) :variant-properties first :name))) + (t/is (= before (-> (thc/get-component file-noop :c01) :variant-properties first :name))))) + +(t/deftest test-add-new-property-sanitizes + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02)) + v-id (-> (ths/get-shape file :v01) :id) + page (thf/current-page file) + before-count (-> (thc/get-component file :c01) :variant-properties count) + + changes (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-add-new-property v-id {:property-name "Size=Big" + :property-value "a=b,"})) + + file' (thf/apply-changes file changes) + comp01' (thc/get-component file' :c01) + + noop (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-add-new-property v-id {:property-name "=,="})) + file-noop (thf/apply-changes file noop)] + + (t/is (= "SizeBig" (-> comp01' :variant-properties last :name))) + (t/is (= "ab" (-> comp01' :variant-properties last :value))) + (t/is (= before-count + (-> (thc/get-component file-noop :c01) :variant-properties count))))) + +(t/deftest test-add-new-property-blank-value-does-not-trail-separator + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02)) + v-id (-> (ths/get-shape file :v01) :id) + page (thf/current-page file) + main (ths/get-shape file :m01) + before (:variant-name main) + changes (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-add-new-property v-id {:property-name "Size" + :property-value "=,="})) + file' (thf/apply-changes file changes) + comp01' (thc/get-component file' :c01) + main' (ths/get-shape file' :m01)] + + (t/is (= before (:variant-name main'))) + (t/is (not (str/ends-with? (str (:variant-name main')) ", "))) + (t/is (= "" (-> comp01' :variant-properties last :value))) + (t/is (= "Size" (-> comp01' :variant-properties last :name))))) + +(t/deftest test-update-property-value-from-formula-strips-equal-in-value + ;; Option A / M2: formula parse may leave `=` inside a value; write path strips it. + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02)) + page (thf/current-page file) + comp01 (thc/get-component file :c01) + parsed (ctv/properties-formula->map "border=yes color=yes") + value (-> parsed first :value) + changes (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-update-property-value (:id comp01) 0 value)) + file' (thf/apply-changes file changes) + comp01' (thc/get-component file' :c01) + main' (ths/get-shape file' :m01)] + + (t/is (= "yes color=yes" value)) + (t/is (= "yes coloryes" (-> comp01' :variant-properties first :value))) + (t/is (= "yes coloryes" (:variant-name main'))) + (t/is (not= "yes color=yes" (-> comp01' :variant-properties first :value))))) + +(t/deftest test-make-shapes-variant-sanitizes-component-name-as-value + ;; create-new-properties-from-variant adds (:name component) as a value when + ;; moving a variant into a differently named container. Production always + ;; pairs generate-make-shapes-variant with change-parent. + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02) + (thv/add-variant :v02 :c03 :m03 :c04 :m04) + (thc/update-component :c03 {:name "Big=red"})) + page (thf/current-page file) + container (ths/get-shape file :v01) + shape (ths/get-shape file :m03) + changes (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-make-shapes-variant [shape] container) + (pcb/change-parent (:id container) [shape] 0)) + file' (thf/apply-changes file changes) + values (->> (thc/get-component file' :c03) + :variant-properties + (map :value) + set)] + + (t/is (contains? values "Bigred")))) + (t/deftest test-duplicate-variant-container (let [;; ==== Setup diff --git a/common/test/common_tests/path_names_test.cljc b/common/test/common_tests/path_names_test.cljc index 9e0d3fb873..04cf0650b8 100644 --- a/common/test/common_tests/path_names_test.cljc +++ b/common/test/common_tests/path_names_test.cljc @@ -31,3 +31,12 @@ (t/is (= (first path) "group")) (t/is (= (second path) "subgroup")) (t/is (= (nth path 2) "name")))) + +(t/deftest normalize-path-name + (t/is (= "Button" (cpn/normalize-path-name " Button "))) + (t/is (= "Group / Button" (cpn/normalize-path-name "Group / Button"))) + (t/is (nil? (cpn/normalize-path-name nil))) + (t/is (nil? (cpn/normalize-path-name ""))) + (t/is (nil? (cpn/normalize-path-name " "))) + (t/is (nil? (cpn/normalize-path-name "Button/"))) + (t/is (nil? (cpn/normalize-path-name "Group / ")))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index d1b228656a..ff5e03d2db 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -74,6 +74,7 @@ [common-tests.logic.swap-and-reset-test] [common-tests.logic.swap-as-override-test] [common-tests.logic.swap-keeps-id-test] + [common-tests.logic.swap-missing-library-test] [common-tests.logic.text-sync-test] [common-tests.logic.text-touched-test] [common-tests.logic.token-apply-test] @@ -182,6 +183,7 @@ 'common-tests.logic.swap-and-reset-test 'common-tests.logic.swap-as-override-test 'common-tests.logic.swap-keeps-id-test + 'common-tests.logic.swap-missing-library-test 'common-tests.logic.text-sync-test 'common-tests.logic.text-touched-test 'common-tests.logic.token-apply-test diff --git a/common/test/common_tests/types/absorb_assets_test.cljc b/common/test/common_tests/types/absorb_assets_test.cljc index 8e8ee2ee36..6d2e46e0db 100644 --- a/common/test/common_tests/types/absorb_assets_test.cljc +++ b/common/test/common_tests/types/absorb_assets_test.cljc @@ -7,6 +7,9 @@ (ns common-tests.types.absorb-assets-test (:require [app.common.data :as d] + [app.common.files.changes-builder :as pcb] + [app.common.files.validate :as cfv] + [app.common.logic.shapes :as cls] [app.common.test-helpers.components :as thc] [app.common.test-helpers.compositions :as tho] [app.common.test-helpers.files :as thf] @@ -57,6 +60,50 @@ (t/is (ctk/is-main-of? main-root' copy-root')) (t/is (ctk/main-instance-of? (:id main-root') (:id (second pages')) component')))) +(t/deftest absorb-components-used-in-deleted-components + ;; A deleted component keeps its shapes in `:objects`, to be able to + ;; restore it and to resolve its remaining copies. When those shapes + ;; use library components, they must also be remapped to the local + ;; file, or they will not match the copies in the pages. + (let [;; Setup + library (-> (thf/sample-file :library :is-shared true) + (tho/add-simple-component :icon :icon-main :icon-rect)) + + file (-> (thf/sample-file :file) + (tho/add-frame :card-main :name "Card") + (thc/instantiate-component :icon :card-icon + :library library + :parent-label :card-main) + (thc/make-component :card :card-main) + (thc/instantiate-component :card :card-copy)) + + page (thf/current-page file) + changes (-> (pcb/empty-changes nil (:id page)) + (cls/generate-delete-shapes file page (:objects page) + #{(thi/id :card-main)} {}) + (second)) + file (thf/apply-changes file changes :validate? false) + errors (cfv/validate-file file {(:id library) library}) + + ;; Action + file' (ctf/update-file-data + file + #(ctf/absorb-assets % (:data library))) + + ;; Get + data' (ctf/file-data file') + card' (ctkl/get-deleted-component data' (thi/id :card)) + shapes' (concat (mapcat (comp vals :objects) (ctpl/pages-seq data')) + (mapcat (comp vals :objects) (vals (:components data')))) + library-refs' (filter #(= (thi/id :library) (:component-file %)) shapes') + errors' (cfv/validate-file file' {})] + + ;; Check + (t/is (nil? errors) "the file is valid before absorbing the library") + (t/is (some? (:objects card')) "the deleted component keeps its shapes") + (t/is (empty? library-refs') "no shape points to the library") + (t/is (nil? errors')))) + (t/deftest absorb-colors (let [;; Setup library (-> (thf/sample-file :library :is-shared true) diff --git a/common/test/common_tests/types/components_test.cljc b/common/test/common_tests/types/components_test.cljc index 4d42df92f8..6098db1e80 100644 --- a/common/test/common_tests/types/components_test.cljc +++ b/common/test/common_tests/types/components_test.cljc @@ -397,3 +397,24 @@ (t/is (= (:id near-copy2-nested-head) (thi/id :nested2-head))) (t/is (= (:id near-copy2-nested4-head) (thi/id :nested4-head))) (t/is (= (:id near-copy2-nested4-child) (thi/id :nested4-child)))))) + +(t/deftest variant-main-and-formula-rename + (let [container {:id (thi/new-id! :container) + :is-variant-container true} + main {:id (thi/new-id! :main) + :variant-id (thi/id :container) + :variant-name "Value1"} + main-bare {:id (thi/new-id! :main-bare) + :variant-id (thi/id :container)} + plain {:id (thi/new-id! :plain)}] + (t/is (false? (ctk/is-variant-main? container))) + (t/is (true? (ctk/is-variant-main? main))) + (t/is (true? (ctk/is-variant-main? main-bare))) + (t/is (false? (ctk/is-variant-main? plain))) + + (t/is (true? (ctk/variant-formula-rename? main "Property 1=another 21"))) + (t/is (false? (ctk/variant-formula-rename? main ""))) + (t/is (false? (ctk/variant-formula-rename? main nil))) + (t/is (false? (ctk/variant-formula-rename? main-bare "Property 1=another 21"))) + (t/is (false? (ctk/variant-formula-rename? container "Property 1=another 21"))) + (t/is (false? (ctk/variant-formula-rename? plain "Button"))))) diff --git a/common/test/common_tests/variant_test.cljc b/common/test/common_tests/variant_test.cljc index d0a7099dc7..0dea2aa947 100644 --- a/common/test/common_tests/variant_test.cljc +++ b/common/test/common_tests/variant_test.cljc @@ -171,3 +171,45 @@ (t/is (= (ctv/find-boolean-pair ["false" "true"]) {"true" true "false" false})) (t/is (= (ctv/find-boolean-pair ["off" "on" "other"]) nil)) (t/is (= (ctv/find-boolean-pair ["hello" "bye"]) nil)))) + + +(t/deftest sanitize-and-prepare-property-text + (t/testing "sanitize-property-text" + (t/is (= (ctv/sanitize-property-text "a=b") "ab")) + (t/is (= (ctv/sanitize-property-text "a,b") "ab")) + (t/is (= (ctv/sanitize-property-text " Big, red ") "Big red")) + (t/is (= (ctv/sanitize-property-text " a=b, ") "ab")) + (t/is (= (ctv/sanitize-property-text ",=") "")) + (t/is (nil? (ctv/sanitize-property-text nil))) + (t/is (nil? (ctv/sanitize-property-text 42)))) + + (t/testing "valid-property-name? rejects forbidden chars" + (t/is (true? (ctv/valid-property-name? "ok"))) + (t/is (false? (ctv/valid-property-name? "a=b"))) + (t/is (false? (ctv/valid-property-name? "a,b"))) + (t/is (false? (ctv/valid-property-name? "")))) + + (t/testing "valid-property-value? rejects forbidden chars" + (t/is (true? (ctv/valid-property-value? "ok"))) + (t/is (true? (ctv/valid-property-value? ""))) + (t/is (false? (ctv/valid-property-value? "a=b"))) + (t/is (false? (ctv/valid-property-value? "a,b")))) + + (t/testing "prepare-property-name" + (t/is (= (ctv/prepare-property-name "Size=Big") "SizeBig")) + (t/is (nil? (ctv/prepare-property-name "=,="))) + (t/is (= (ctv/prepare-property-name " ok ") "ok")) + (t/is (nil? (ctv/prepare-property-name 42)))) + + (t/testing "prepare-property-value" + (t/is (= (ctv/prepare-property-value "a=b,") "ab")) + (t/is (= (ctv/prepare-property-value "=,=") "")) + (t/is (= (ctv/prepare-property-value nil) "")) + (t/is (nil? (ctv/prepare-property-value 42))))) + + +(t/deftest path-to-properties-sanitizes-values + (let [props [{:name "Property 1" :value ""}] + result (ctv/path-to-properties "Button / Big, red" props)] + (t/is (= "Button" (-> result first :value))) + (t/is (= "Big red" (-> result second :value))))) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index ba6b51f2ca..a41de1feb1 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS base +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9e479c64f8d27f8fa368ef62804c2ddb9b3e43a68c13a200b2f668b4771b049e AS base ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' \ @@ -66,8 +66,8 @@ RUN set -ex; \ FROM base AS setup-node -ENV NODE_VERSION=v24.22.1 \ - PNPM_VERSION=12.15.0 \ +ENV NODE_VERSION=v24.22.2 \ + PNPM_VERSION=12.16.1 \ PATH=/opt/node/bin:$PATH RUN set -eux; \ @@ -76,16 +76,16 @@ RUN set -eux; \ aarch64|arm64) \ OPENSSL_ARCH='linux-aarch64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ - NODE_SHA256='33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3'; \ + NODE_SHA256='95c264150aceb7f3437a29fd6984924f5f8d1511ea3cbd6c508b83f6c555dae2'; \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ OPENSSL_ARCH='linux-x86_64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ - NODE_SHA256='eb6728e77c576a35162c122c45ccadf224d89ff00abc8dae46cc2897c6002d7c'; \ + NODE_SHA256='361718c444e49af2ca5a110c83ba6a8901143f23ab285ebbef0341f391f482a9'; \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -431,6 +431,8 @@ RUN set -ex; \ fontforge \ \ xvfb \ + xauth \ + libvulkan1 \ fonts-noto-color-emoji \ fonts-unifont \ libfontconfig1 \ diff --git a/docker/devenv/docker-compose.gpu.yml b/docker/devenv/docker-compose.gpu.yml new file mode 100644 index 0000000000..99c21037b0 --- /dev/null +++ b/docker/devenv/docker-compose.gpu.yml @@ -0,0 +1,14 @@ +services: + main: + environment: + - NVIDIA_DRIVER_CAPABILITIES=all + - PENPOT_GPU=1 + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + volumes: + - /usr/share/vulkan/icd.d:/usr/share/vulkan/icd.d:ro diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index 5a0a81f091..9a3cfd3222 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -141,6 +141,11 @@ http { proxy_pass http://127.0.0.1:6061; } + location = /.well-known/oauth-authorization-server/api/mcp/oauth { + proxy_pass http://127.0.0.1:6060; + proxy_http_version 1.1; + } + location /api { proxy_pass http://127.0.0.1:6060/api; proxy_http_version 1.1; diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index f875c09a95..405d5e2f8c 100644 --- a/docker/imagemagick/Dockerfile +++ b/docker/imagemagick/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9e479c64f8d27f8fa368ef62804c2ddb9b3e43a68c13a200b2f668b4771b049e AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -74,7 +74,7 @@ RUN set -ex; \ rm -rf /var/lib/apt/lists/* -FROM dhi.io/debian-base:trixie-debian13@sha256:de80b468d8c492508f24b30e529accfaa8c59fe4505ddd72e969b324d276ad21 AS image +FROM dhi.io/debian-base:trixie-debian13@sha256:fefc620cea08f18e60c8f1afbf670200463384726b6f31f8d59f8a4707976f81 AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index 32e8c2c1fa..b62bc0e8a2 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9e479c64f8d27f8fa368ef62804c2ddb9b3e43a68c13a200b2f668b4771b049e AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -53,7 +53,7 @@ RUN set -eux; \ --output /opt/jre; -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS image +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9e479c64f8d27f8fa368ef62804c2ddb9b3e43a68c13a200b2f668b4771b049e AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 97daca2dfe..f10fe4ab3e 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 +FROM dhi.io/node:24.22.2-debian13-dev@sha256:3083ddf2287edc010579950602f4e07659b08b23efa74ee1de7582931dbe31cf LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ @@ -95,17 +95,17 @@ WORKDIR /opt/penpot/exporter # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); the generated ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.15.0 +ARG PNPM_VERSION=12.16.1 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 26f84d4640..91c2192132 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.7-debian13-dev@sha256:940ee353dd807f5c5e32720923bb9b34ef9e631ae567320f3eaea7797d6c7964 +FROM dhi.io/nginx:1.31.8-debian13-dev@sha256:f8c15eca396af66e240b9258323fb97e29abd534b76e7cf34acfdd3bc74dd282 LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index f018302d33..e54c34ac2d 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 AS build +FROM dhi.io/node:24.22.2-debian13-dev@sha256:3083ddf2287edc010579950602f4e07659b08b23efa74ee1de7582931dbe31cf AS build LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive @@ -11,7 +11,7 @@ WORKDIR /opt/penpot/mcp # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.15.0 +ARG PNPM_VERSION=12.16.1 RUN set -eux; \ apt-get -qq update; \ apt-get -qqy --no-install-recommends install curl ca-certificates gzip; \ @@ -20,11 +20,11 @@ RUN set -eux; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -42,7 +42,7 @@ RUN set -eux; \ RUN ./setup -FROM dhi.io/node:24.22.1-debian13@sha256:32a2fa6af5209698a9354cef0b19ada3dbfac3090b1010b70afa9f1a298ba2d9 AS image +FROM dhi.io/node:24.22.2-debian13@sha256:ce582f85254ceba6c572a854b8fb4008ced2f5d365c929f4f580360829037184 AS image LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index ee0694bc34..ff12e10bd2 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 +FROM dhi.io/node:24.22.2-debian13-dev@sha256:3083ddf2287edc010579950602f4e07659b08b23efa74ee1de7582931dbe31cf LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ @@ -58,7 +58,7 @@ WORKDIR /opt/penpot/media-processor # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.15.0 +ARG PNPM_VERSION=12.16.1 RUN set -eux; \ apt-get -qq update; \ apt-get -qqy --no-install-recommends install gzip; \ @@ -67,11 +67,11 @@ RUN set -eux; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.storybook b/docker/images/Dockerfile.storybook index 2b259ffb61..bf67fabf88 100644 --- a/docker/images/Dockerfile.storybook +++ b/docker/images/Dockerfile.storybook @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.7-alpine3.24@sha256:6d3811fbc76c5d0652752b39a003769759ccfeb2c1e874bacf99e3d44e5da3b9 +FROM dhi.io/nginx:1.31.8-alpine3.24@sha256:7a4e9021737f414ad0f851efb939600690d9d092f51712ce6512bfd0a767ceae LABEL maintainer="Penpot " ARG BUNDLE_PATH="./bundle-storybook/" diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 4c69760e24..5bca9bd6a0 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -191,6 +191,10 @@ services: PENPOT_SMTP_TLS: "false" PENPOT_SMTP_SSL: "false" + ## Inbox for the feedback that MCP agents send. Set it together with + ## PENPOT_MCP_BACKEND_URI on the penpot-mcp service. + # PENPOT_MCP_FEEDBACK_EMAIL: mcp-feedback@example.com + penpot-admin-console: image: "penpotapp/admin-console:${PENPOT_VERSION:-2.18}" restart: always @@ -215,6 +219,11 @@ services: networks: - penpot + # environment: + ## Offers the submit_feedback tool, which emails agent feedback through the + ## backend. Set it together with PENPOT_MCP_FEEDBACK_EMAIL on the backend. + # PENPOT_MCP_BACKEND_URI: http://penpot-backend:6060/ + penpot-exporter: image: "penpotapp/exporter:${PENPOT_VERSION:-2.18}" restart: always diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index c8800b0503..54d62e18eb 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -155,6 +155,11 @@ http { proxy_pass $PENPOT_EXPORTER_URI; } + location = /.well-known/oauth-authorization-server/api/mcp/oauth { + proxy_set_header Host $proxy_host; + proxy_pass $PENPOT_BACKEND_URI$request_uri; + } + location /api { proxy_set_header Host $proxy_host; proxy_pass $PENPOT_BACKEND_URI/api; diff --git a/docs/mcp/index.md b/docs/mcp/index.md index fb9c927106..6b55a3f816 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -104,6 +104,9 @@ Current tools in **local MCP**: * `penpot_api_info` * `export_shape` * `import_image` +* `submit_feedback`, only when the server has `PENPOT_MCP_BACKEND_URI` set (see the [configuration guide](/technical-guide/configuration/#mcp)) + +`submit_feedback` emails plain-text feedback about Penpot MCP to the team that runs your Penpot instance. The agent sends it only when you ask or approve, and the email includes your name and email address. It needs your MCP key: the remote setup already sends it, and in local MCP you add it to the server URL as `userToken`. Because **remote MCP** does not expose local file-system access: diff --git a/docs/package.json b/docs/package.json index 076c461693..4d1cae270b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -39,5 +39,5 @@ "markdown-it-anchor": "^10.0.0", "markdown-it-plantuml": "^1.4.1" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 460dc46c55..e647b1e78b 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 91c6ab740d..4759855e14 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -959,6 +959,30 @@ The defaults match the service name used in the official `docker-compose.yaml`. them only if your MCP service has a different name or listens on other ports. Both variables are ignored when the `enable-mcp` flag is not set. +#### MCP feedback + +Agents can email feedback about Penpot MCP to an inbox you choose, through the +`submit_feedback` tool. Set the backend URI on the MCP container to offer the tool, +and the inbox on the backend to accept the emails: + +```bash +# MCP +PENPOT_MCP_BACKEND_URI: http://penpot-backend:6060/ + +# Backend +PENPOT_MCP_FEEDBACK_EMAIL: mcp-feedback@example.com +``` + +- `PENPOT_MCP_BACKEND_URI`: The URI the MCP server uses to reach the backend. When + unset, the MCP server does not offer the tool. +- `PENPOT_MCP_FEEDBACK_EMAIL`: The address that receives the feedback. When unset, + the backend rejects submissions. + +The backend accepts feedback when the `enable-mcp` or `enable-access-tokens` flag is +set, and sends it through the [email configuration](#email-configuration). Each +email carries the name and email address of the user who sent it, and uses that +address as reply-to. + ### Internal resolver The frontend container resolves the backend, exporter and MCP service names with the diff --git a/docs/technical-guide/developer/agentic-devenv.md b/docs/technical-guide/developer/agentic-devenv.md index 932f555d6f..c754574207 100644 --- a/docs/technical-guide/developer/agentic-devenv.md +++ b/docs/technical-guide/developer/agentic-devenv.md @@ -209,15 +209,18 @@ below): The files are committed templates + an `envsubst` pass; see [`.devenv/README.md`](../../../.devenv/README.md) for the full layout. -`./manage.sh start-coding-agent [--ws N] [...passthrough]` +`./manage.sh start-coding-agent [--ws N] [--bin EXE] [...passthrough]` launches the chosen client against one workspace, `cd`'ing into the right -directory and refusing to launch if the instance is not running: +directory and refusing to launch if the instance is not running. `--bin` +runs another executable in place of the client's default one (for example a +wrapper script), with the same MCP wiring: ```bash ./manage.sh start-coding-agent claude # ws0 ./manage.sh start-coding-agent opencode --ws 1 # ws1 ./manage.sh start-coding-agent vscode # opens VS Code on ws0 ./manage.sh start-coding-agent codex --ws 2 # ws2 +./manage.sh start-coding-agent claude --bin claude-alt ``` A given AI client session drives **exactly one workspace**, so running N diff --git a/docs/technical-guide/developer/devenv.md b/docs/technical-guide/developer/devenv.md index 89144fd399..6ec4c8fc25 100644 --- a/docs/technical-guide/developer/devenv.md +++ b/docs/technical-guide/developer/devenv.md @@ -12,7 +12,7 @@ in order to correctly set up the development environment. You can [look here][1] for complete instructions. -[1]: /technical-guide/getting-started/#install-with-docker +[1]: /technical-guide/getting-started/docker Optionally, to improve performance, you can also increase the maximum number of @@ -119,8 +119,9 @@ Container-internal ports stay fixed. Target a specific instance with accepts a **non-negative integer only** — `--ws main` or `--ws ws1` is rejected, keeping the flag shape uniform across commands. `run-devenv` is ws0-only and takes no workspace flag. `run-devenv` also accepts -`--serena-context CTX` (used together with `--agentic`) and `--git-user-name NAME` / `--git-user-email -EMAIL` (see below). +`--serena-context CTX` (used together with `--agentic`), `--gpu` to enable +hardware rendering in Chromium (e.g. for benchmarks), and `--git-user-name NAME` +/ `--git-user-email EMAIL` (see below). Configuration lives in one tracked file, `docker/devenv/defaults.env` (the ws0 baseline); `ws1+` values (offset ports, `wsN` container/volume names) are @@ -166,6 +167,49 @@ container (same UID mapping as the source tree). Opencode's own state (sessions, `auth.json`) lives in `~/.local/share/opencode`, which stays in the container's data volume regardless of this flag. +### NVIDIA GPU access + +On Linux, `run-devenv --gpu` grants the selected container access to NVIDIA +GPUs. The host needs an NVIDIA driver, NVIDIA Container Toolkit configured +with a Docker runtime named `nvidia`, and a readable Vulkan driver manifest +at `/usr/share/vulkan/icd.d/nvidia_icd.json`. The option checks the runtime +and manifest before provisioning. + +Build an image from this checkout to include `xauth` and the Vulkan loader +(`libvulkan1`), then use the same tag when starting the container: + +```bash +./manage.sh build-devenv --tag gpu-local +DEVENV_TAG=gpu-local ./manage.sh run-devenv --gpu +``` + +`--gpu` works with `--ws N`, `--agentic`, `--attach`, and +`--opencode-config-dir DIR`. For example: + +```bash +DEVENV_TAG=gpu-local ./manage.sh run-devenv --gpu --ws 1 --agentic +``` + +The GPU overlay requests all NVIDIA devices and driver capabilities and +mounts the host Vulkan manifest directory read-only. Processes inside the +container inherit `PENPOT_GPU=1` and `NVIDIA_DRIVER_CAPABILITIES=all`. +`PENPOT_GPU` signals GPU access to applications. The explicit `--gpu` flag +selects the overlay. + +GPU mode applies at container creation and lasts for that container's +lifetime. To change it, stop the selected instance and run it again with +or without `--gpu`: + +```bash +./manage.sh stop-devenv --ws 1 +DEVENV_TAG=gpu-local ./manage.sh run-devenv --ws 1 +``` + +Run browser commands inside devenv. The Chromium ANGLE Vulkan backend +needs an X display in this setup, so run those commands under +`xvfb-run -a`. Each caller starts its own Xvfb display. Applications choose +their rendering backend. + ### Shared state and workers All instances share one Penpot database and one RustFS bucket; users, teams, @@ -385,7 +429,7 @@ Warning: by default Cursor won't support HTTPS with a self-signed certificate. I An example of your cursor configuration can be: -```javascript +```json { "mcpServers": { "penpot-devenv": { diff --git a/docs/user-guide/first-steps/migration-guide.njk b/docs/user-guide/first-steps/migration-guide.njk index cd8600ff68..23450d3f1a 100644 --- a/docs/user-guide/first-steps/migration-guide.njk +++ b/docs/user-guide/first-steps/migration-guide.njk @@ -9,8 +9,7 @@ desc: Move a design system from Figma to Penpot. Read a short summary of the ent

If you are moving a design system to Penpot, especially from Figma, start with the enterprise migration guide. It covers file and library migration, tokens, validation, dual-tool workflows, and how different roles can run a pilot.

What the guide covers

diff --git a/docs/user-guide/first-steps/shortcuts.njk b/docs/user-guide/first-steps/shortcuts.njk index ab8279486a..e80d59f025 100644 --- a/docs/user-guide/first-steps/shortcuts.njk +++ b/docs/user-guide/first-steps/shortcuts.njk @@ -35,9 +35,9 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface

Paste cannot be customized.

Keyboard layouts

-

Letters are recorded by the character they type, so they follow your layout (on AZERTY, the A key records a). With Shift, digit keys record the digit itself (Shift0, not the shifted symbol), on every layout and in every browser.

-

Punctuation keys (such as -, [, / or ') are recorded by their physical position on the keyboard, so the same key works on AZERTY, QWERTZ and other layouts — but only in browsers that expose the keyboard layout (Chromium). There, Shift with the + key records Shift+. In browsers without that support, such as Firefox or Safari, punctuation falls back to the typed character (Shift* instead): those shortcuts still work in the browser where you recorded them, but Shift combinations with punctuation may stop working if you switch browsers. If that affects you, record those shortcuts in a Chromium browser.

-

Two known limitations: number keys set opacity (0–9) only where digits are typed without Shift, so AZERTY layouts need the numeric keypad for them; and \ needs AltGr on some layouts (Spanish), where it is recorded with Alt and its exact form depends on the browser.

+

Letters are recorded by the character they type, so they follow your layout (on AZERTY, the A key records a). With Shift, Alt or another modifier, letter and digit keys record the key itself, not the character the modifier makes (Shift0, not Shift); OptionM on macOS, not µ).

+

Punctuation keys (such as -, [, / or ') are recorded by their physical position on the keyboard, so Shift with the + key records Shift+ on a Spanish layout. In browsers that do not expose the keyboard layout, such as Firefox or Safari, Penpot records the character you type instead; the shortcut still works there. Keys typed with AltGr record the character they type (\ on a Spanish layout).

+

One known limitation: number keys set opacity (0–9) only where digits are typed without Shift, so AZERTY layouts need the numeric keypad for them.

Reset to default

When you have custom shortcuts, a Reset all button appears next to the search field. It lists every shortcut that will go back to its default before you confirm.

@@ -617,11 +617,6 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface - - Add node - Shift+ - ⇧+ - Delete node, segment or handle Delete or Backspace diff --git a/exporter/package.json b/exporter/package.json index 40c8f6e923..d50b5b7a40 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" @@ -21,7 +21,7 @@ "playwright": "1.63.0", "raw-body": "^4.0.0", "source-map-support": "^0.5.21", - "undici": "^8.11.2", + "undici": "^8.12.3", "xml-js": "^1.6.11", "xregexp": "^5.1.3" }, diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index 843416db40..ad05f01e75 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' @@ -203,8 +203,8 @@ importers: specifier: ^0.5.21 version: 0.5.21 undici: - specifier: ^8.11.2 - version: 8.11.2 + specifier: ^8.12.3 + version: 8.12.3 xml-js: specifier: ^1.6.11 version: 1.6.11 @@ -218,8 +218,8 @@ importers: packages: - '@babel/runtime-corejs3@7.29.7': - resolution: {integrity: sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==} + '@babel/runtime-corejs3@7.29.10': + resolution: {integrity: sha512-R4FKrg4Vy0hB7PfAxf9LOorEPd5/36SCIXPXAvjlQaQmPvtrjwoDXV15jsSm2ecsqBW1Iewwb7mEnG83xIEApg==} engines: {node: '>=6.9.0'} '@ioredis/commands@2.0.0': @@ -505,8 +505,8 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - readable-stream@4.7.0: - resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + readable-stream@4.8.0: + resolution: {integrity: sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} readdir-glob@3.0.0: @@ -574,8 +574,8 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} - undici@8.11.2: - resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} + undici@8.12.3: + resolution: {integrity: sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==} engines: {node: '>=22.19.0'} util-deprecate@1.0.2: @@ -606,7 +606,7 @@ packages: snapshots: - '@babel/runtime-corejs3@7.29.7': + '@babel/runtime-corejs3@7.29.10': dependencies: core-js-pure: 3.50.0 @@ -631,7 +631,7 @@ snapshots: is-stream: 4.0.1 lazystream: 1.0.1 normalize-path: 3.0.0 - readable-stream: 4.7.0 + readable-stream: 4.8.0 readdir-glob: 3.0.0 tar-stream: 3.2.2 zip-stream: 7.0.5 @@ -702,7 +702,7 @@ snapshots: crc32-stream: 7.0.1 is-stream: 4.0.1 normalize-path: 3.0.0 - readable-stream: 4.7.0 + readable-stream: 4.8.0 cookies@0.9.2: dependencies: @@ -718,7 +718,7 @@ snapshots: crc32-stream@7.0.1: dependencies: crc-32: 1.2.2 - readable-stream: 4.7.0 + readable-stream: 4.8.0 css-select@7.0.0: dependencies: @@ -873,7 +873,7 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 - readable-stream@4.7.0: + readable-stream@4.8.0: dependencies: abort-controller: 3.0.0 buffer: 6.0.3 @@ -953,7 +953,7 @@ snapshots: tsscmp@1.0.6: {} - undici@8.11.2: {} + undici@8.12.3: {} util-deprecate@1.0.2: {} @@ -965,10 +965,10 @@ snapshots: xregexp@5.1.3: dependencies: - '@babel/runtime-corejs3': 7.29.7 + '@babel/runtime-corejs3': 7.29.10 zip-stream@7.0.5: dependencies: compress-commons: 7.0.1 normalize-path: 3.0.0 - readable-stream: 4.7.0 + readable-stream: 4.8.0 diff --git a/frontend/package.json b/frontend/package.json index 4e1433e206..4a73f7a7ec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "browserslist": [ "defaults" ], diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index 293e69d269..6531f4ca78 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -4,7 +4,7 @@ "description": "Penpot Draft-JS Wrapper", "main": "index.js", "type": "module", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/mousetrap/index.js b/frontend/packages/mousetrap/index.js index e3864396a4..3166b24404 100644 --- a/frontend/packages/mousetrap/index.js +++ b/frontend/packages/mousetrap/index.js @@ -94,8 +94,8 @@ const _SPECIAL_ALIASES = { /** * Physical codes treated positionally. Letters and digits resolve by * glyph (`event.key`), but punctuation resolves by position (`event.code`) - * through the layout map, so the same physical key matches on every - * layout with and without `Shift`. Without a map the typed glyph applies. + * through the layout map, or `_US_PUNCTUATION` without one, so the same + * physical key matches with and without `Shift`. See `_candidates`. * * @type {Set} */ @@ -113,6 +113,45 @@ const _PUNCTUATION_CODES = new Set([ "Slash", ]); +/** + * Primary character of each punctuation key on a US layout. Used when + * the browser has no layout map (Firefox, Safari), so punctuation still + * resolves by position and every browser names a key the same way. + * + * @type {Object} + */ +const _US_PUNCTUATION = { + Minus: "-", + Equal: "=", + BracketLeft: "[", + BracketRight: "]", + Backslash: "\\", + Semicolon: ";", + Quote: "'", + Backquote: "`", + Comma: ",", + Period: ".", + Slash: "/", +}; + +/** + * Letter or digit named by `keyCode`. With a modifier held, `event.key` + * carries the glyph the modifier makes (`!` for Shift+1, `µ` for + * Option+M on macOS, or `Dead`), while `keyCode` still names the key. + * Unlike `event.code` it follows the layout, so Ctrl+Z stays `z` on + * AZERTY. + * + * @param {Event} e + * @return {string|null} + */ +function _letterOrDigitFromKeyCode(e) { + const keyCode = e.keyCode; + if ((keyCode >= 48 && keyCode <= 57) || (keyCode >= 65 && keyCode <= 90)) { + return String.fromCharCode(keyCode).toLowerCase(); + } + return null; +} + /** * Resolves an event to its canonical key. Implemented as * `Mousetrap#_characterFromEvent` so punctuation can consult the @@ -130,27 +169,38 @@ function _modifiersMatch(modifiers1, modifiers2) { return modifiers1.sort().join(",") === modifiers2.sort().join(","); } +/** + * whether the `AltGraph` modifier is active for the event + * + * @param {Event} e + * @returns {boolean} + */ +function _isAltGraph(e) { + return ( + typeof e.getModifierState === "function" && e.getModifierState("AltGraph") + ); +} + /** * takes a key event and figures out what the modifiers are * * On Windows `AltGr` reports `ctrlKey + altKey` as well. When the - * `AltGraph` modifier is active the phantom `ctrl` is dropped so typing - * (e.g. `]` on Spanish keyboards) does not fire `ctrl+alt` shortcuts. + * `AltGraph` modifier is active both phantom modifiers are dropped, so + * typing (e.g. `]` or `\` on Spanish keyboards) neither fires `ctrl+alt` + * shortcuts nor hides the plain glyph binding. * * @param {Event} e * @returns {Array} */ function _eventModifiers(e) { const modifiers = []; - const altGraph = - typeof e.getModifierState === "function" && - e.getModifierState("AltGraph"); + const altGraph = _isAltGraph(e); if (e.shiftKey) { modifiers.push("shift"); } - if (e.altKey) { + if (e.altKey && !altGraph) { modifiers.push("alt"); } @@ -383,22 +433,209 @@ class Mousetrap { } /** - * takes the event and returns the canonical key + * drops the layout map when it does not describe the active layout * - * Letters, digits and named keys resolve by glyph: single characters - * lowercase so `Shift+Z` matches a `ctrl+shift+z` bind, `" "` maps to - * `space`, and named keys map through `_KEY_NAMES`. The exception is a - * digit row key held with `Shift`, which resolves to the digit itself - * (`Digit0` types ")" on US layouts but "0" on AZERTY, so `shift+0` - * must mean the physical digit key everywhere). Punctuation - * resolves positionally via the layout map (see `setLayoutMap`), - * falling back to the glyph when no map is set. Numpad digits stay - * positional (`num0`..`num9`) so they keep their own bindings. + * Chrome on Linux returns the map of the first configured layout, not + * the active one: with Spanish first and US active, the map names the + * `=` key `¡`. A punctuation key pressed without modifiers types the + * primary character of its position, so when that differs from the + * map, punctuation goes back to the glyph and US-table fallback for the + * rest of the session. + * + * @param {Event} e + * @returns void + */ + _checkLayoutMap(e) { + if ( + !this._layoutMap || + e.type !== "keydown" || + !e.code || + !_PUNCTUATION_CODES.has(e.code) || + typeof e.key !== "string" || + e.key.length !== 1 || + e.shiftKey || + e.ctrlKey || + e.altKey || + e.metaKey || + _isAltGraph(e) + ) { + return; + } + + let mapped = null; + try { + mapped = this._layoutMap.get(e.code); + } catch { + mapped = null; + } + + if ( + typeof mapped === "string" && + mapped !== "" && + mapped.toLowerCase() !== e.key.toLowerCase() + ) { + this._layoutMap = null; + } + } + + /** + * takes the event and returns the canonical key: the first candidate + * of `_candidates`, which is also what the shortcut recorder stores + * (see `keyForRecording`). * * @param {Event} e * @return {string|null} */ _characterFromEvent(e) { + const candidates = this._candidates(e); + return candidates.length ? candidates[0][0] : null; + } + + /** + * public entry point for the shortcut recorder: the key token to store + * for this event, always one of `_candidates`, so this instance matches + * the recorded shortcut. Returns `null` for modifiers alone. + * + * Letters and digits with a modifier use `keyCode`. Punctuation uses + * its position when there is a layout map, since the map names the + * printed key. Without a map (Firefox, Safari) the typed glyph is a + * better label than the US key at that position, so the Spanish `+` + * records `+`, not `]`. With AltGr the typed glyph wins too: AltGr+º + * on a Spanish layout records `\`, the character the user typed. + * + * @param {Event} e + * @return {string|null} + */ + keyForRecording(e) { + this._checkLayoutMap(e); + + const modifiers = _eventModifiers(e); + const numpad = e.code && e.code.indexOf("Numpad") === 0; + const keyCodeKey = + modifiers.length && !numpad ? _letterOrDigitFromKeyCode(e) : null; + const position = this._positionFromEvent(e); + const glyph = this._glyphFromEvent(e); + + let key; + if (keyCodeKey) { + key = keyCodeKey; + } else if (this._layoutMap && !_isAltGraph(e)) { + key = position || glyph; + } else { + key = glyph || position; + } + + return key && !_isModifier(key) ? key : null; + } + + /** + * the `[key, modifiers]` pairs an event may match, most specific + * first. `_handleKeyEvent` dispatches the first pair that has a + * binding: + * + * 1. With a modifier held, letters and digits by `keyCode`, so + * `Shift+1` is `shift+1` and `Option+M` is `alt+m`. + * 2. Punctuation by position: the primary character from the layout + * map (see `setLayoutMap`), or from a US table when the browser has + * no map, so `Shift+*` on a Spanish layout is `shift++`. + * 3. The typed glyph (`event.key`), so `+` on a US layout (typed with + * Shift) matches a `shift++` binding. + * 4. The typed glyph without the Shift it needed, so `?` and `+` + * bindings fire. + * + * With AltGr, 3 comes before 2 (see `keyForRecording`). + * + * @param {Event} e + * @return {Array} + */ + _candidates(e) { + const modifiers = _eventModifiers(e); + const candidates = []; + const add = (key, mods) => { + if (key) { + candidates.push([key, mods]); + } + }; + + const numpad = e.code && e.code.indexOf("Numpad") === 0; + if (modifiers.length && !numpad) { + add(_letterOrDigitFromKeyCode(e), modifiers); + } + + // With AltGr the typed glyph comes before the position: AltGr+º + // types `\` on a Spanish layout, and the key at that position is `º`. + const altGraph = _isAltGraph(e); + const position = this._positionFromEvent(e); + const glyph = this._glyphFromEvent(e); + if (altGraph) { + add(glyph, modifiers); + add(position, modifiers); + } else { + add(position, modifiers); + add(glyph, modifiers); + } + + if ( + glyph && + glyph.length === 1 && + modifiers.includes("shift") && + !/[a-z0-9]/.test(glyph) + ) { + add( + glyph, + modifiers.filter((m) => m !== "shift"), + ); + } + + // On Windows Ctrl+Alt is AltGr: Ctrl+Alt+E types `€` on a Spanish + // layout. When nothing is bound to the glyph, fall back to the + // `ctrl+alt` binding of the key. + if (altGraph && e.ctrlKey && e.altKey && !numpad) { + add(_letterOrDigitFromKeyCode(e), [...modifiers, "alt", "ctrl"]); + } + + return candidates; + } + + /** + * primary character of a punctuation key by its position, or `null` + * + * @param {Event} e + * @return {string|null} + */ + _positionFromEvent(e) { + if (!e.code || !_PUNCTUATION_CODES.has(e.code)) { + return null; + } + + let mapped = null; + if (this._layoutMap) { + try { + mapped = this._layoutMap.get(e.code); + } catch { + mapped = null; + } + } + + if (typeof mapped !== "string" || mapped === "") { + mapped = _US_PUNCTUATION[e.code]; + } + + return mapped ? mapped.toLowerCase() : null; + } + + /** + * the typed glyph of the event as a canonical key + * + * Single characters lowercase so `Shift+Z` matches a `ctrl+shift+z` + * bind, `" "` maps to `space`, and named keys map through + * `_KEY_NAMES`. Numpad digits stay positional (`num0`..`num9`) so they + * keep their own bindings. + * + * @param {Event} e + * @return {string|null} + */ + _glyphFromEvent(e) { // Numpad digits as "num0".."num9" — keeps them separate from main-row bindings across NumLock states and event types. if (e.code && e.code.indexOf("Numpad") === 0) { const suffix = e.code.substring(6); @@ -407,35 +644,11 @@ class Mousetrap { } } - // Punctuation is positional: with a layout map the primary - // (unshifted) character of the physical key wins, so `Shift+*` on a - // Spanish layout (code `BracketRight`) resolves to `+` like the - // recorder stores. Without a map the typed glyph applies. - if (e.code && _PUNCTUATION_CODES.has(e.code) && this._layoutMap) { - let mapped = null; - try { - mapped = this._layoutMap.get(e.code); - } catch { - mapped = null; - } - if (typeof mapped === "string" && mapped !== "") { - return mapped.toLowerCase(); - } - } - const key = e.key; if (typeof key !== "string" || key === "") { return null; } - // Shifted digits resolve to the digit itself: with `Shift` held, - // `Digit0` types ")" on US layouts but "0" on AZERTY, so the glyph - // can't be the token. `shift+0` means the physical digit key - // everywhere, like `shift+z` means `z` regardless of `Shift`. - if (e.shiftKey && e.code && /^Digit[0-9]$/.test(e.code) && key.length === 1) { - return e.code.charAt(5); - } - if (_KEY_NAMES[key]) { return _KEY_NAMES[key]; } @@ -862,18 +1075,35 @@ class Mousetrap { * @returns void */ _handleKeyEvent(e) { - const character = this._characterFromEvent(e); + this._checkLayoutMap(e); + + const candidates = this._candidates(e); // no character found then stop - if (!character) { + if (!candidates.length) { return; } + // dispatch the first candidate that has a binding, or the first one + // so sequences still reset on unbound keys + let picked = candidates[0]; + for (const candidate of candidates) { + if ( + this._callbacks[candidate[0]] && + this._getMatches(candidate[0], candidate[1], e).length + ) { + picked = candidate; + break; + } + } + + const [character, modifiers] = picked; + if (e.type == "keyup" && this._ignoreNextKeyup === character) { this._ignoreNextKeyup = false; return; } - this.handleKey(character, _eventModifiers(e), e); + this.handleKey(character, modifiers, e); } } diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json index 3eb605929d..3c22c33ff5 100644 --- a/frontend/packages/mousetrap/package.json +++ b/frontend/packages/mousetrap/package.json @@ -7,7 +7,7 @@ "scripts": { "test": "node --test \"test/*.test.js\"" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "author": "Craig Campbell", "license": "Apache-2.0 WITH LLVM-exception" } diff --git a/frontend/packages/mousetrap/test/bind-dispatch.test.js b/frontend/packages/mousetrap/test/bind-dispatch.test.js index 7512151126..6ae0eb45a2 100644 --- a/frontend/packages/mousetrap/test/bind-dispatch.test.js +++ b/frontend/packages/mousetrap/test/bind-dispatch.test.js @@ -36,11 +36,15 @@ describe("bind and dispatch", () => { shiftPlus++; }); fire(target, makeKeyEvent({ key: "+", code: "Equal" })); + // Without a layout map Shift + the Spanish "+" key types "*" and + // nothing says the key is "+", so "shift++" does not fire. fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true })); - // "*" normalizes by glyph, so "shift++" (shift + "+" key) does not fire; - // this pins the layout-independent behavior for the "+" physical key. assert.equal(plus, 1); assert.equal(shiftPlus, 0); + // A layout map names the key, so it does. + mousetrap.setLayoutMap({ get: (code) => (code === "BracketRight" ? "+" : undefined) }); + fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true })); + assert.equal(shiftPlus, 1); }); it("resolves bind-string aliases to canonical tokens", () => { diff --git a/frontend/packages/mousetrap/test/layout-map.test.js b/frontend/packages/mousetrap/test/layout-map.test.js index 3cb34eb722..e3b914932d 100644 --- a/frontend/packages/mousetrap/test/layout-map.test.js +++ b/frontend/packages/mousetrap/test/layout-map.test.js @@ -23,7 +23,16 @@ describe("layout map", () => { it("resolves punctuation by position, so Shift+* fires a shift++ bind", () => { const event = { key: "*", code: "BracketRight", shiftKey: true }; assert.equal(firedWithMap(spanishMap, "shift++", event), 1); - assert.equal(firedWithMap(spanishMap, "shift+*", event), 0); + }); + + it("prefers the position over the glyph when both are bound", () => { + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(spanishMap); + const calls = []; + mousetrap.bind("shift++", () => calls.push("shift++")); + mousetrap.bind("shift+*", () => calls.push("shift+*")); + fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true })); + assert.deepEqual(calls, ["shift++"]); }); it("matches the unshifted primary on the same position", () => { @@ -92,4 +101,74 @@ describe("layout map", () => { fire(target, makeKeyEvent({ key: "a", code: "KeyQ" })); assert.equal(calls, 1); }); + + // Spanish layout as Chrome reports it: the "'" key sits on Minus and + // "º" on Backquote. + const fullSpanishMap = { + get(code) { + return { BracketRight: "+", Minus: "'", Backquote: "º", Equal: "¡" }[code]; + }, + }; + + const altGraph = (over) => ({ + getModifierState: (name) => name === "AltGraph", + ...over, + }); + + it("fires ? on Spanish, where it is Shift + the ' key", () => { + const event = { key: "?", code: "Minus", shiftKey: true }; + assert.equal(firedWithMap(fullSpanishMap, "?", event), 1); + }); + + it("matches the AltGr glyph before the key position (Linux)", () => { + // AltGr+º types "\" with no ctrl or alt on Linux. + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(fullSpanishMap); + const calls = []; + mousetrap.bind("\\", () => calls.push("\\")); + mousetrap.bind("º", () => calls.push("º")); + fire(target, makeKeyEvent(altGraph({ key: "\\", code: "Backquote" }))); + assert.deepEqual(calls, ["\\"]); + }); + + it("matches the AltGr glyph when AltGr reports ctrl+alt (Windows)", () => { + const event = altGraph({ key: "\\", code: "Backquote", ctrlKey: true, altKey: true }); + assert.equal(firedWithMap(fullSpanishMap, "\\", event), 1); + }); + + it("records the AltGr glyph, not the key position", () => { + const { mousetrap } = freshInstance(); + mousetrap.setLayoutMap(fullSpanishMap); + const event = makeKeyEvent(altGraph({ key: "\\", code: "Backquote" })); + assert.equal(mousetrap.keyForRecording(event), "\\"); + }); + + it("drops a map that disagrees with an unmodified punctuation key", () => { + // Chrome on Linux keeps the map of the first layout (Spanish) while + // US is active: the "=" key types "=" but the map says "¡". + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(fullSpanishMap); + const calls = []; + mousetrap.bind("=", () => calls.push("=")); + mousetrap.bind("-", () => calls.push("-")); + fire(target, makeKeyEvent({ key: "=", code: "Equal" })); + fire(target, makeKeyEvent({ key: "-", code: "Minus" })); + assert.deepEqual(calls, ["=", "-"]); + assert.equal(mousetrap._layoutMap, null); + }); + + it("drops a disagreeing map before recording", () => { + const { mousetrap } = freshInstance(); + mousetrap.setLayoutMap(fullSpanishMap); + assert.equal(mousetrap.keyForRecording(makeKeyEvent({ key: "=", code: "Equal" })), "="); + }); + + it("keeps a map that agrees with the typed key", () => { + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(fullSpanishMap); + fire(target, makeKeyEvent({ key: "+", code: "BracketRight" })); + fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true })); + fire(target, makeKeyEvent({ key: "Dead", code: "Quote" })); + assert.equal(mousetrap._layoutMap, fullSpanishMap); + }); }); diff --git a/frontend/packages/mousetrap/test/modifiers.test.js b/frontend/packages/mousetrap/test/modifiers.test.js index 748b71feeb..a12453821c 100644 --- a/frontend/packages/mousetrap/test/modifiers.test.js +++ b/frontend/packages/mousetrap/test/modifiers.test.js @@ -31,7 +31,7 @@ describe("modifiers", () => { assert.equal(fired("shift+ctrl+alt+q", event), 1); }); - it("drops the phantom ctrl while AltGraph is active", () => { + it("drops the phantom ctrl and alt while AltGraph is active", () => { const altgr = { key: "]", code: "BracketRight", @@ -40,7 +40,38 @@ describe("modifiers", () => { getModifierState: (mod) => mod === "AltGraph", }; assert.equal(fired("ctrl+alt+]", altgr), 0); - assert.equal(fired("alt+]", altgr), 1); + assert.equal(fired("alt+]", altgr), 0); + assert.equal(fired("]", altgr), 1); + }); + + it("falls back to ctrl+alt for a letter that AltGr turns into a glyph", () => { + // Spanish layout on Windows: Ctrl+Alt+E types "€". + const event = { + key: "€", + code: "KeyE", + keyCode: 69, + ctrlKey: true, + altKey: true, + getModifierState: (mod) => mod === "AltGraph", + }; + assert.equal(fired("ctrl+alt+e", event), 1); + assert.equal(fired("€", event), 1); + }); + + it("matches letters and digits by keyCode when a modifier changes the glyph", () => { + // Shift+1 types "!" on US and Spanish layouts. + assert.equal(fired("shift+1", { key: "!", code: "Digit1", keyCode: 49, shiftKey: true }), 1); + // Spanish layout: Shift+0 types "=". + assert.equal(fired("shift+0", { key: "=", code: "Digit0", keyCode: 48, shiftKey: true }), 1); + // macOS: Option+M types "µ" and Option+E is a dead key. + assert.equal(fired("alt+m", { key: "µ", code: "KeyM", keyCode: 77, altKey: true }), 1); + assert.equal(fired("alt+e", { key: "Dead", code: "KeyE", keyCode: 69, altKey: true }), 1); + assert.equal(fired("alt+shift+5", { key: "%", code: "Digit5", keyCode: 53, altKey: true, shiftKey: true }), 1); + }); + + it("keeps AZERTY letters by layout, not by position", () => { + // AZERTY: the key at the US "W" position types "z" and has keyCode 90. + assert.equal(fired("ctrl+z", { key: "z", code: "KeyW", keyCode: 90, ctrlKey: true }), 1); }); it("keeps a real ctrl+alt without AltGraph", () => { diff --git a/frontend/packages/mousetrap/test/normalize.test.js b/frontend/packages/mousetrap/test/normalize.test.js index b91b5ff40d..3219a08458 100644 --- a/frontend/packages/mousetrap/test/normalize.test.js +++ b/frontend/packages/mousetrap/test/normalize.test.js @@ -54,30 +54,54 @@ describe("canonical key normalization", () => { it("resolves a shifted digit row key to the digit itself", () => { // US Shift+0 types ")", AZERTY Shift+0 types "0": shift+0 must mean - // the physical digit key on every layout (production keyCode behavior). - assert.equal(fired("shift+0", { key: ")", code: "Digit0", shiftKey: true }), 1); - assert.equal(fired("shift+1", { key: "!", code: "Digit1", shiftKey: true }), 1); - assert.equal(fired("shift+2", { key: "@", code: "Digit2", shiftKey: true }), 1); - assert.equal(fired("shift+8", { key: "*", code: "Digit8", shiftKey: true }), 1); - // ...so the shifted glyphs are not bindable tokens for digit codes. - assert.equal(fired("shift+)", { key: ")", code: "Digit0", shiftKey: true }), 0); - assert.equal(fired("shift+*", { key: "*", code: "Digit8", shiftKey: true }), 0); + // the physical digit key on every layout, named by keyCode. + assert.equal(fired("shift+0", { key: ")", code: "Digit0", keyCode: 48, shiftKey: true }), 1); + assert.equal(fired("shift+1", { key: "!", code: "Digit1", keyCode: 49, shiftKey: true }), 1); + assert.equal(fired("shift+2", { key: "@", code: "Digit2", keyCode: 50, shiftKey: true }), 1); + assert.equal(fired("shift+8", { key: "*", code: "Digit8", keyCode: 56, shiftKey: true }), 1); // AZERTY shifted digits already type the digit: unchanged. - assert.equal(fired("shift+0", { key: "0", code: "Digit0", shiftKey: true }), 1); + assert.equal(fired("shift+0", { key: "0", code: "Digit0", keyCode: 48, shiftKey: true }), 1); // Unshifted digits are untouched. - assert.equal(fired("0", { key: "0", code: "Digit0" }), 1); - assert.equal(fired("&", { key: "&", code: "Digit1" }), 1); + assert.equal(fired("0", { key: "0", code: "Digit0", keyCode: 48 }), 1); }); - it("without a layout map, punctuation resolves by glyph", () => { + it("prefers the digit over the shifted glyph when both are bound", () => { + const { mousetrap, target } = freshInstance(); + const calls = []; + mousetrap.bind("shift+0", () => calls.push("shift+0")); + mousetrap.bind("shift+)", () => calls.push("shift+)")); + fire(target, makeKeyEvent({ key: ")", code: "Digit0", keyCode: 48, shiftKey: true })); + assert.deepEqual(calls, ["shift+0"]); + }); + + it("without a layout map, punctuation resolves by US position or by glyph", () => { // Spanish layout: Shift + the physical "+" key (BracketRight) types "*". + assert.equal(fired("shift+]", { key: "*", code: "BracketRight", shiftKey: true }), 1); assert.equal(fired("shift+*", { key: "*", code: "BracketRight", shiftKey: true }), 1); - assert.equal(fired("shift+]", { key: "*", code: "BracketRight", shiftKey: true }), 0); assert.equal(fired("+", { key: "+", code: "BracketRight" }), 1); - // US layout: Shift + BracketRight types "}". + // US layout: Shift + BracketRight types "}"; the glyph still matches. assert.equal(fired("shift+}", { key: "}", code: "BracketRight", shiftKey: true }), 1); - assert.equal(fired("shift+]", { key: "}", code: "BracketRight", shiftKey: true }), 0); assert.equal(fired("?", { key: "?", code: "Slash" }), 1); + // US layout: Shift + Comma types "<". + assert.equal(fired("shift+,", { key: "<", code: "Comma", shiftKey: true }), 1); + assert.equal(fired("ctrl+shift+.", { key: ">", code: "Period", ctrlKey: true, shiftKey: true }), 1); + }); + + it("matches a glyph binding without the Shift the glyph needs", () => { + // US layout: "?" is Shift+Slash and "+" is Shift+Equal. + assert.equal(fired("?", { key: "?", code: "Slash", shiftKey: true }), 1); + assert.equal(fired("+", { key: "+", code: "Equal", shiftKey: true }), 1); + // Spanish layout: "?" is Shift+Minus. + assert.equal(fired("?", { key: "?", code: "Minus", shiftKey: true }), 1); + }); + + it("dispatches only the most specific match", () => { + const { mousetrap, target } = freshInstance(); + const calls = []; + mousetrap.bind("shift+/", () => calls.push("shift+/")); + mousetrap.bind("?", () => calls.push("?")); + fire(target, makeKeyEvent({ key: "?", code: "Slash", shiftKey: true })); + assert.deepEqual(calls, ["shift+/"]); }); it("lowercases function keys", () => { @@ -107,3 +131,60 @@ describe("canonical key normalization", () => { assert.equal(fired("ñ", { key: "Ñ", code: "Semicolon" }), 1); }); }); + +describe("keyForRecording", () => { + const record = (eventOver, layoutMap = null) => { + const { mousetrap } = freshInstance(); + mousetrap.setLayoutMap(layoutMap); + return mousetrap.keyForRecording(makeKeyEvent(eventOver)); + }; + const spanishMap = { get: (code) => ({ BracketRight: "+", Minus: "'" })[code] }; + + it("records letters and digits with a modifier by keyCode", () => { + assert.equal(record({ key: "!", code: "Digit1", keyCode: 49, shiftKey: true }), "1"); + assert.equal(record({ key: "µ", code: "KeyM", keyCode: 77, altKey: true }), "m"); + assert.equal(record({ key: "Dead", code: "KeyE", keyCode: 69, altKey: true }), "e"); + }); + + it("records punctuation by position when there is a layout map", () => { + assert.equal(record({ key: "*", code: "BracketRight", shiftKey: true }, spanishMap), "+"); + assert.equal(record({ key: "+", code: "BracketRight" }, spanishMap), "+"); + }); + + it("records the typed glyph when there is no layout map", () => { + assert.equal(record({ key: "+", code: "BracketRight" }), "+"); + assert.equal(record({ key: "*", code: "BracketRight", shiftKey: true }), "*"); + }); + + it("falls back to the US position for a dead key without a map", () => { + assert.equal(record({ key: "Dead", code: "Quote" }), "'"); + }); + + it("returns null for a modifier pressed alone", () => { + assert.equal(record({ key: "Shift", code: "ShiftLeft", shiftKey: true }), null); + }); + + it("records a key the dispatcher then matches", () => { + const cases = [ + [{ key: "!", code: "Digit1", keyCode: 49, shiftKey: true }, null], + [{ key: "µ", code: "KeyM", keyCode: 77, altKey: true }, null], + [{ key: "*", code: "BracketRight", shiftKey: true }, null], + [{ key: "*", code: "BracketRight", shiftKey: true }, spanishMap], + [{ key: "?", code: "Minus", shiftKey: true, ctrlKey: true }, null], + [{ key: "?", code: "Minus", shiftKey: true, ctrlKey: true }, spanishMap], + ]; + for (const [eventOver, map] of cases) { + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(map); + const event = makeKeyEvent(eventOver); + const mods = ["ctrl", "alt", "shift"].filter((m) => event[m + "Key"]); + const combo = [...mods, mousetrap.keyForRecording(event)].join("+"); + let calls = 0; + mousetrap.bind(combo.replace(/\+\+$/, "++"), () => { + calls++; + }); + fire(target, event); + assert.equal(calls, 1, `${combo} should fire`); + } + }); +}); diff --git a/frontend/packages/ui/package.json b/frontend/packages/ui/package.json index cd8c59aad1..aea882f601 100644 --- a/frontend/packages/ui/package.json +++ b/frontend/packages/ui/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "types": "./dist/index.d.ts", "type": "module", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "exports": { ".": { "import": "./dist/index.js" diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js index 1e74d525eb..087e6ba56f 100644 --- a/frontend/playwright.config.js +++ b/frontend/playwright.config.js @@ -65,7 +65,9 @@ export default defineConfig({ }, { name: "ds", - use: { ...devices["Desktop Chrome"] }, + // Accounts without a theme follow the system one; keep the visual + // specs on the dark theme so their screenshots stay comparable. + use: { ...devices["Desktop Chrome"], colorScheme: "dark" }, testDir: "./playwright/ui/visual-specs", expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.005 }, diff --git a/frontend/playwright/data/dashboard/get-builtin-templates.json b/frontend/playwright/data/dashboard/get-builtin-templates.json new file mode 100644 index 0000000000..ab08f3b31d --- /dev/null +++ b/frontend/playwright/data/dashboard/get-builtin-templates.json @@ -0,0 +1,34 @@ +[ + { + "~:id": "tokens-starter-kit", + "~:name": "Design tokens starter kit" + }, + { + "~:id": "penpot-design-system", + "~:name": "Penpot Design System | Pencil" + }, + { + "~:id": "wireframing-kit", + "~:name": "Wireframe library" + }, + { + "~:id": "prototype-examples", + "~:name": "Prototype template" + }, + { + "~:id": "plants-app", + "~:name": "UI mockup example" + }, + { + "~:id": "tutorial-for-beginners", + "~:name": "Tutorial for beginners" + }, + { + "~:id": "lucide-icons", + "~:name": "Lucide Icons" + }, + { + "~:id": "font-awesome", + "~:name": "Font Awesome" + } +] diff --git a/frontend/playwright/data/dashboard/get-projects-deleted.json b/frontend/playwright/data/dashboard/get-projects-deleted.json new file mode 100644 index 0000000000..bb1fe484ee --- /dev/null +++ b/frontend/playwright/data/dashboard/get-projects-deleted.json @@ -0,0 +1,32 @@ +[ + { + "~:id": "~ue5a24d1b-ef1e-812f-8004-52bab84be6f7", + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1715266551088", + "~:modified-at": "~m1715266551088", + "~:is-default": false, + "~:name": "New Project 1", + "~:is-pinned": false, + "~:count": 1 + }, + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1713533116382", + "~:modified-at": "~m1713873823633", + "~:is-default": true, + "~:name": "Drafts", + "~:count": 1 + }, + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f7920c", + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1715266551088", + "~:modified-at": "~m1715266551088", + "~:is-default": false, + "~:name": "Old Project", + "~:is-pinned": false, + "~:count": 1, + "~:deleted-at": "~m1732111500000" + } +] diff --git a/frontend/playwright/data/dashboard/get-team-deleted-files-pending.json b/frontend/playwright/data/dashboard/get-team-deleted-files-pending.json new file mode 100644 index 0000000000..d85f16b500 --- /dev/null +++ b/frontend/playwright/data/dashboard/get-team-deleted-files-pending.json @@ -0,0 +1,47 @@ +[ + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f41234", + "~:revn": 1, + "~:vern": 1, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1705307400000", + "~:modified-at": "~m1732111500000", + "~:deleted-at": "~m1732111500000", + "~:name": "Deleted Design File 1", + "~:is-shared": false, + "~:will-be-deleted-at": "~m4102444800000", + "~:thumbnail-id": null, + "~:row-num": 1, + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d" + }, + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f41235", + "~:revn": 2, + "~:vern": 2, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1704875700000", + "~:modified-at": "~m1732025400000", + "~:deleted-at": "~m1732025400000", + "~:name": "Deleted Design File 2", + "~:is-shared": true, + "~:will-be-deleted-at": "~m4102444800000", + "~:thumbnail-id": null, + "~:row-num": 2, + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d" + }, + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f41236", + "~:revn": 3, + "~:vern": 3, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920c", + "~:created-at": "~m1706792400000", + "~:modified-at": "~m1731939600000", + "~:deleted-at": "~m1731939600000", + "~:name": "Old Project Design", + "~:is-shared": false, + "~:will-be-deleted-at": "~m4102444800000", + "~:thumbnail-id": null, + "~:row-num": 3, + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d" + } +] diff --git a/frontend/playwright/data/viewer/create-share-link.json b/frontend/playwright/data/viewer/create-share-link.json new file mode 100644 index 0000000000..2d92a75545 --- /dev/null +++ b/frontend/playwright/data/viewer/create-share-link.json @@ -0,0 +1,8 @@ +{ + "~:id": "~u0c7a1f2e-1b2c-8180-8007-0000000000a1", + "~:file-id": "~udd5cc0bb-91ff-81b9-8004-77df9cd3edb1", + "~:who-comment": "team", + "~:who-inspect": "team", + "~:pages": { "~#set": ["~udd5cc0bb-91ff-81b9-8004-77df9cd3edb2"] }, + "~:owner-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b" +} diff --git a/frontend/playwright/data/workspace/get-teams-shared.json b/frontend/playwright/data/workspace/get-teams-shared.json new file mode 100644 index 0000000000..2c6119c75f --- /dev/null +++ b/frontend/playwright/data/workspace/get-teams-shared.json @@ -0,0 +1,25 @@ +[ + { + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true + }, + "~:name": "Default", + "~:modified-at": "~m1713533116375", + "~:id": "~uc7ce0794-0992-8105-8004-38e630f7920a", + "~:created-at": "~m1713533116375", + "~:is-default": false + } +] diff --git a/frontend/playwright/ui/specs/colorpicker.spec.js b/frontend/playwright/ui/specs/colorpicker.spec.js index ee9dbcd770..6625cb4b8a 100644 --- a/frontend/playwright/ui/specs/colorpicker.spec.js +++ b/frontend/playwright/ui/specs/colorpicker.spec.js @@ -144,15 +144,14 @@ test("Create a RADIAL gradient", async ({ page }) => { const gradOption = workspacePage.page.getByText("Gradient"); await gradOption.click(); - const gradTypeOptions = workspacePage.colorpicker - .locator("div") - .filter({ hasText: "Linear" }) - .nth(3); - await gradTypeOptions.click(); + const gradTypeSelect = workspacePage.colorpicker + .getByRole("combobox") + .filter({ hasText: "Linear" }); + await gradTypeSelect.click(); - const gradRadialOption = workspacePage.page - .locator("li") - .filter({ hasText: "Radial" }); + const gradRadialOption = workspacePage.page.getByRole("option", { + name: "Radial", + }); await gradRadialOption.click(); const addStopBtn = workspacePage.page.getByRole("button", { diff --git a/frontend/playwright/ui/specs/email-verification.spec.js b/frontend/playwright/ui/specs/email-verification.spec.js index 65c1a9cb99..f15be88e20 100644 --- a/frontend/playwright/ui/specs/email-verification.spec.js +++ b/frontend/playwright/ui/specs/email-verification.spec.js @@ -1,32 +1,33 @@ import { test, expect } from "@playwright/test"; import { RegisterPage } from "../pages/RegisterPage"; -// Regression test for the bug where a freshly verified account (whose -// profile never had a theme persisted) ended up with an empty string as -// its theme instead of falling back to the dark default: the workspace -// switched to light mode and Settings > UI Theme showed a blank field. +// A freshly verified account has no saved theme preference. It must follow +// the system theme and show that preference in settings, not a blank field. test.beforeEach(async ({ page }) => { await RegisterPage.initWithLoggedOutUser(page); }); test.describe("Email verification", () => { - test("Newly verified account defaults to the dark theme", async ({ - page, - }) => { - const registerPage = new RegisterPage(page); - await registerPage.setupEmailVerificationSuccess(); + for (const colorScheme of ["light", "dark"]) { + test(`Newly verified account follows the ${colorScheme} system theme`, async ({ + page, + }) => { + await page.emulateMedia({ colorScheme }); + const registerPage = new RegisterPage(page); + await registerPage.setupEmailVerificationSuccess(); - await registerPage.goToVerifyToken(); - await page.waitForURL(/screen=dashboard/); + await registerPage.goToVerifyToken(); + await page.waitForURL(/screen=dashboard/); - // `default` is the body class applied for dark theme, `light` for - // light theme (see app.util.theme/set-color-scheme). - await expect(page.locator("body")).toHaveClass(/default/); - await expect(page.locator("body")).not.toHaveClass(/light/); - }); + // The dark theme uses the legacy body class "default". + await expect(page.locator("body")).toHaveClass( + colorScheme === "dark" ? "default" : "light", + ); + }); + } - test("Settings > UI Theme shows Penpot Dark (default) selected, not blank", async ({ + test("Settings > UI Theme shows System theme selected, not blank", async ({ page, }) => { const registerPage = new RegisterPage(page); @@ -40,6 +41,6 @@ test.describe("Email verification", () => { // The language select is the first combobox on the page, the theme // select is the second one. const themeSelect = page.getByRole("combobox").nth(1); - await expect(themeSelect).toHaveText("Penpot Dark (default)"); + await expect(themeSelect).toHaveText("System theme"); }); }); diff --git a/frontend/playwright/ui/specs/tokens/apply.spec.js b/frontend/playwright/ui/specs/tokens/apply.spec.js index a0e91b7c60..e13a377ae7 100644 --- a/frontend/playwright/ui/specs/tokens/apply.spec.js +++ b/frontend/playwright/ui/specs/tokens/apply.spec.js @@ -259,6 +259,36 @@ test.describe("Tokens: Apply token", () => { await expect(opacityLowPill).toBeVisible(); }); + test("Token dropdown of a scrubbable numeric input shows the default cursor", async ({ + page, + }) => { + const { workspacePage } = await setupTokensFileRender(page); + + await page.getByRole("tab", { name: "Layers" }).click(); + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + + const layerMenuSection = page.getByRole("region", { + name: "Layer menu section", + }); + const opacityWrapper = layerMenuSection + .getByTestId("numeric-input-wrapper") + .first(); + await expect(opacityWrapper).toHaveCSS("cursor", "ew-resize"); + + await layerMenuSection + .getByRole("button", { name: "Open token list" }) + .click(); + + // The scrub cursor belongs to the input, not to the dropdown it opens. + const tokenList = layerMenuSection.getByRole("listbox"); + await expect(tokenList).toBeVisible(); + await expect(tokenList).toHaveCSS("cursor", "default"); + await expect(tokenList.getByRole("option").first()).toHaveCSS( + "cursor", + "default", + ); + }); + test("User applies typography token to a text shape", async ({ page }) => { const { workspacePage, tokensSidebar, tokenContextMenuForToken } = await setupTypographyTokensFileRender(page); diff --git a/frontend/playwright/ui/specs/workspace.spec.js b/frontend/playwright/ui/specs/workspace.spec.js index 051530b877..b59266f5eb 100644 --- a/frontend/playwright/ui/specs/workspace.spec.js +++ b/frontend/playwright/ui/specs/workspace.spec.js @@ -107,6 +107,8 @@ test("Selection size badge appears on selection and hides on deselect", async ({ test("Selection size badge uses component color for component selection", async ({ page, }) => { + // The expected component color below is the dark palette value. + await page.emulateMedia({ colorScheme: "dark" }); const workspacePage = new WasmWorkspacePage(page); await workspacePage.setupEmptyFile(); await workspacePage.mockGetFile("components/get-file-13267.json"); diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 06e3cd2639..932ebb7061 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -338,6 +338,74 @@ test.describe("Delete account modal", () => { }); }); +test.describe("Fonts page controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Installed font variant, menu and edition", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await dashboardPage.goToDashboard(); + await dashboardPage.fontsLink.click(); + await expect(dashboardPage.mainHeading).toHaveText("Fonts"); + + const installed = page.locator("[class*='dashboard-installed-fonts']"); + const row = installed.locator("[class*='font-item']").first(); + await expect(row).toBeVisible(); + + // The variant delete icon only shows on hover. + await row.locator("[class$='__variant'], [class*='__variant ']").hover(); + await expect(installed).toHaveScreenshot(shot); + + // The menu button: a span before the DS migration, a DS icon button + // named "Options" after it. + const options = row.locator("[class*='options']"); + await options + .locator("span[class*='icon']") + .or(options.getByRole("button", { name: "Options" })) + .first() + .click(); + await expect(page.getByText("Download")).toBeVisible(); + await expect(page).toHaveScreenshot(shot); + + await page.getByText("Edit", { exact: true }).click(); + await expect(row.locator("input")).toBeVisible(); + await page.mouse.move(10, 700); + await expect(installed).toHaveScreenshot(shot); + }); + + test("Fonts ready to upload", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await dashboardPage.goToDashboard(); + await dashboardPage.fontsLink.click(); + await expect(dashboardPage.mainHeading).toHaveText("Fonts"); + + await page + .locator("#font-upload") + .setInputFiles("playwright/data/render-wasm/assets/ebgaramond.ttf"); + await expect(page.getByTestId("upload-all")).toBeVisible(); + await page.mouse.move(10, 700); + await expect( + page.locator("[class*='dashboard-fonts-upload']"), + ).toHaveScreenshot(shot); + }); + + test("Fonts loading placeholder", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + // Never answer, so the page keeps showing the loader. + await page.route(/get-font-variants/, () => {}); + await dashboardPage.goToDashboard(); + await dashboardPage.fontsLink.click(); + await expect(dashboardPage.mainHeading).toHaveText("Fonts"); + + const placeholder = page.locator("[class*='fonts-placeholder']"); + await expect(placeholder).toBeVisible(); + await page.mouse.move(10, 700); + await expect(placeholder).toHaveScreenshot(shot); + }); +}); + test.describe("Subscription controls", () => { const shot = { maxDiffPixelRatio: 0, threshold: 0 }; @@ -439,6 +507,78 @@ test.describe("Subscription controls", () => { }); }); +test.describe("Dashboard list controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Trash project options", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + // Deleted files due in 2100, so the trash keeps them. + await dashboardPage.mockRPC( + "get-team-deleted-files?team-id=*", + "dashboard/get-team-deleted-files-pending.json", + ); + // A deleted project, so its row shows the options button. + await dashboardPage.mockRPC( + "get-projects?team-id=*", + "dashboard/get-projects-deleted.json", + ); + await dashboardPage.goToDeleted(); + + const options = page.getByTestId("project-options").first(); + await expect(options).toBeVisible(); + const row = page.locator("[class*='dashboard-project-row']").first(); + await page.mouse.move(640, 700); + await expect(row.locator("header")).toHaveScreenshot(shot); + await options.hover(); + await expect(row.locator("header")).toHaveScreenshot(shot); + }); + + test("Empty project create button", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupNewProject(); + await dashboardPage.goToDashboard(); + await expect(dashboardPage.projectName).toBeVisible(); + + const create = page.locator("[class*='create-new']").first(); + await expect(create).toBeVisible(); + await page.mouse.move(640, 700); + await expect(create).toHaveScreenshot(shot); + await create.hover(); + await expect(create).toHaveScreenshot(shot); + }); + + test("Project rename", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await dashboardPage.goToDashboard(); + + await page.getByTestId("project-options").first().click(); + await page.getByText("Rename", { exact: true }).click(); + const edit = page.locator("[class*='edit-wrapper']").first(); + await expect(edit).toBeVisible(); + await expect(edit).toHaveScreenshot(shot); + }); + + test("Comments placeholder", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.mockRPC( + "get-unread-comment-threads?team-id=*", + "logged-in-user/get-unread-comment-threads-empty.json", + ); + await dashboardPage.goToDashboard(); + + await page.getByTestId("open-comments").click(); + const placeholder = page.locator("[class*='thread-groups-placeholder']"); + await expect(placeholder).toBeVisible(); + await expect(placeholder).toHaveScreenshot(shot); + // The whole dropdown, with the header title. + await expect( + page.locator("[class*='comment-threads-section']"), + ).toHaveScreenshot(shot); + }); +}); + test.describe("Team modals controls", () => { const shot = { maxDiffPixelRatio: 0, threshold: 0 }; @@ -487,6 +627,43 @@ test.describe("Team modals controls", () => { }); }); +test.describe("Settings subscription controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Plans page and management dialog", async ({ page }) => { + await DashboardPage.mockConfigFlags( + page, + ["enable-subscriptions", "disable-onboarding"], + { penpotIsSaas: true }, + ); + await DashboardPage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage.json", + ); + await page.goto("/?screen=settings-subscription"); + await expect( + page.getByRole("heading", { name: "Subscription", level: 2 }), + ).toBeVisible(); + + const trial = page + .getByRole("button", { name: "Try it free for 14 days" }) + .first(); + await expect(trial).toBeVisible(); + await page.mouse.move(10, 700); + await expect(page).toHaveScreenshot(shot); + + await trial.click(); + const dialog = page.locator("[class*='modal-dialog']"); + await expect(dialog).toBeVisible(); + await page.mouse.move(10, 700); + await expect(dialog).toHaveScreenshot(shot); + + await dialog.locator("button").first().hover(); + await expect(dialog).toHaveScreenshot(shot); + }); +}); + test.describe("Dashboard sidebar controls", () => { const shot = { maxDiffPixelRatio: 0, threshold: 0 }; @@ -602,6 +779,32 @@ test.describe("Team pages controls", () => { }); }); +test.describe("Settings controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Sidebar and change email modal", async ({ page }) => { + await DashboardPage.mockConfigFlags(page, ["enable-user-feedback"]); + await page.goto("/?screen=settings-profile"); + const sidebar = page.locator( + "[class*='settings_sidebar__dashboard-sidebar']", + ); + await expect(page.getByText("Contact us")).toBeVisible(); + await page.mouse.move(640, 700); + await expect(sidebar).toHaveScreenshot(shot); + + await page.getByText("Dashboard", { exact: true }).hover(); + await expect(sidebar).toHaveScreenshot(shot); + + await page.getByText("Change email", { exact: true }).click(); + const modal = page.locator("[class*='modal-container']"); + await expect(modal).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + await modal.locator("[class*='modal-header'] button").hover(); + await expect(modal).toHaveScreenshot(shot); + }); +}); + test.describe("Onboarding questions controls", () => { const shot = { maxDiffPixelRatio: 0, threshold: 0 }; @@ -629,3 +832,118 @@ test.describe("Onboarding questions controls", () => { await expect(actions).toHaveScreenshot(shot); }); }); + +test.describe("Profile page controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Profile page avatar, form and links", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + await dashboardPage.goToAccount(); + + const container = page.locator( + "[class*='settings_profile__form-container']", + ); + await expect(page.getByText("Change email")).toBeVisible(); + await page.mouse.move(10, 700); + await expect(container).toHaveScreenshot(shot); + + // The "Update" overlay shows when the pointer is on the avatar. + await page.locator("[class*='image-change-field']").hover(); + await expect(container).toHaveScreenshot(shot); + + // Save turns enabled once the name changes. + await page.getByLabel("Your name").fill("Renamed user"); + await page.mouse.move(10, 700); + await expect(container).toHaveScreenshot(shot); + + await page.getByText("Change email").hover(); + await expect(container).toHaveScreenshot(shot); + + // The "Update" overlay is a button: it shows on keyboard focus too. + await page.mouse.move(10, 700); + await page.getByRole("button", { name: "Update" }).focus(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + await expect(container).toHaveScreenshot(shot); + }); +}); + +test.describe("Templates section controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Template cards, scroll buttons and collapse", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.mockRPC( + "get-builtin-templates", + "dashboard/get-builtin-templates.json", + ); + await dashboardPage.goToDashboard(); + + const section = page.locator("[class*='dashboard-templates-section']"); + const card = section.locator("[class*='template-card']").first(); + await expect(card).toBeVisible(); + await expect(section.locator("img").first()).toHaveJSProperty( + "complete", + true, + ); + await page.mouse.move(640, 100); + await expect(section).toHaveScreenshot(shot); + + // Hovering a card shows its add icon in the accent color. + await card.hover(); + await expect(card).toHaveScreenshot(shot); + + const moveButtons = section.locator("[class*='move-button']"); + await moveButtons.last().hover(); + await expect(moveButtons.last()).toHaveScreenshot(shot); + + await moveButtons.last().click(); + await expect(moveButtons).toHaveCount(2); + await page.mouse.move(640, 100); + await expect(moveButtons.first()).toHaveScreenshot(shot); + + await section.locator("[class*='title-btn']").click(); + await expect(section).toHaveClass(/collapsed/); + await page.waitForTimeout(500); + await expect(section.locator("[class*='title-btn']")).toHaveScreenshot( + shot, + ); + }); +}); + +test.describe("Context notification", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Info and warning levels", async ({ page }) => { + await page.goto("/?screen=settings-profile"); + + // Info: the change email modal. + await page.getByText("Change email", { exact: true }).click(); + const info = page.locator("[class*='modal-container'] aside"); + await expect(info).toBeVisible(); + await expect(info).toHaveScreenshot(shot); + await page.keyboard.press("Escape"); + await expect(info).toBeHidden(); + + // Warning: the delete account modal. + await page.getByText("Want to remove your account?").click(); + const warning = page.locator("[class*='modal-container'] aside"); + await expect(warning).toBeVisible(); + await expect(warning).toHaveScreenshot(shot); + }); + + test("Warning with html content", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + await dashboardPage.fontsLink.click(); + await page + .locator("#font-upload") + .setInputFiles("playwright/data/render-wasm/assets/ebgaramond.ttf"); + const warning = page + .locator("[class*='dashboard-fonts-hero'] aside") + .last(); + await expect(warning).toBeVisible(); + await expect(warning).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 5dac8b7377..450bba4a2e 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -442,3 +442,50 @@ test.describe("Copy buttons", () => { await expect(copy).toHaveScreenshot(shot); }); }); + +test.describe("Share link modal controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Get, copy and destroy link", async ({ page }) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithSingleBoard(); + await viewerPage.mockRPC( + "create-share-link", + "viewer/create-share-link.json", + ); + await viewerPage.goToViewer({ + fileId: singleBoardFileId, + pageId: singleBoardPageId, + }); + + await page.getByRole("button", { name: "Share" }).click(); + const getLink = page.getByRole("button", { name: "Get link" }); + await expect(getLink).toBeVisible(); + const modal = page.locator("[class*='share-link-dialog']"); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + + await getLink.click(); + const destroy = page.getByRole("button", { name: "Destroy link" }); + await expect(destroy).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + + // A raw button with a title before, a DS icon button named "Copy link" after. + await modal + .getByTitle("Copy link") + .or(modal.getByRole("button", { name: "Copy link" })) + .hover(); + await expect(modal).toHaveScreenshot(shot); + + await page.getByText("Manage permissions").click(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + + await destroy.click(); + await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 5893ae0d80..5441abf974 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -780,6 +780,30 @@ test.describe("Right header", () => { }); }); +test.describe("Share button", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Share button in the right header", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + // A shared team where the user is admin, so the header shows Share. + await workspace.mockRPC("get-teams", "workspace/get-teams-shared.json"); + await workspace.goToWorkspace(); + + const header = page.locator('[class*="workspace-header-right"]'); + const share = header + .locator("[class*='share-btn']") + .or(header.getByRole("button", { name: "Share" })); + await expect(share).toBeVisible(); + await page.mouse.move(640, 300); + await expect(header).toHaveScreenshot(shot); + + await share.hover(); + await page.waitForTimeout(1000); + await expect(header).toHaveScreenshot(shot); + }); +}); + test.describe("Undo history", () => { test("Shows the actions list with an entry expanded", async ({ page }) => { const workspace = new WorkspacePage(page); @@ -1223,3 +1247,84 @@ test.describe("Assets tab controls", () => { await expect(modal).toHaveScreenshot(shot); }); }); + +test.describe("Comments panel filter", () => { + test("Shows the filter trigger and its menu", async ({ page }) => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.showComments(); + + const trigger = workspace.rightSidebar.getByRole("button", { + name: "Show all comments", + }); + await expect(trigger).toBeVisible(); + await page.mouse.move(640, 600); + await expect(trigger).toHaveScreenshot(shot); + + await trigger.hover(); + await expect(trigger).toHaveScreenshot(shot); + + await trigger.click(); + const menu = page.locator("[class*='comment-mode-dropdown']"); + await expect(menu).toBeVisible(); + await page.mouse.move(640, 600); + await expect(menu).toHaveScreenshot(shot); + }); +}); + +test.describe("Gradient type select", () => { + test("Shows the gradient type select closed, open and changed", async ({ + page, + }) => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-not-empty.json"); + await workspace.goToWorkspace({ + fileId: "6191cd35-bb1f-81f7-8004-7cc63d087374", + pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375", + }); + await workspace.clickLeafLayer("Rectangle"); + await page.getByRole("button", { name: "#B1B2B5" }).click(); + await page.getByText("Solid").click(); + await page.getByText("Gradient").click(); + + const typeSelect = workspace.colorpicker + .getByRole("combobox") + .filter({ hasText: "Linear" }); + await expect(typeSelect).toBeVisible(); + const pickerBox = await workspace.colorpicker.boundingBox(); + const selectBox = await typeSelect.boundingBox(); + // The gradient bar above the select and room below it for the open list. + const clip = { + x: Math.floor(pickerBox.x), + y: Math.floor(selectBox.y) - 60, + width: Math.ceil(pickerBox.width), + height: 200, + }; + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot({ clip, ...shot }); + + await typeSelect.hover(); + await expect(page).toHaveScreenshot({ clip, ...shot }); + + await typeSelect.click(); + const radial = page.getByRole("option", { name: "Radial" }); + await expect(radial).toBeVisible(); + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot({ clip, ...shot }); + + await radial.click(); + await expect( + workspace.colorpicker.getByRole("combobox").filter({ hasText: "Radial" }), + ).toBeVisible(); + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot({ clip, ...shot }); + + // The whole gradient panel: preview bar, stops and the stop list. + const panel = workspace.colorpicker.locator("[class*='gradient-panel']"); + await expect(panel).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 6a0901cc8c..3efa68def5 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/frontend/resources/images/features/2.19-paths.jpg b/frontend/resources/images/features/2.19-paths.jpg new file mode 100644 index 0000000000..72ed1c9e43 Binary files /dev/null and b/frontend/resources/images/features/2.19-paths.jpg differ diff --git a/frontend/resources/images/features/2.19-per-side-strokes.jpg b/frontend/resources/images/features/2.19-per-side-strokes.jpg new file mode 100644 index 0000000000..aebb4bb263 Binary files /dev/null and b/frontend/resources/images/features/2.19-per-side-strokes.jpg differ diff --git a/frontend/resources/images/features/2.19-performance.jpg b/frontend/resources/images/features/2.19-performance.jpg new file mode 100644 index 0000000000..dbdcad2aa6 Binary files /dev/null and b/frontend/resources/images/features/2.19-performance.jpg differ diff --git a/frontend/resources/images/features/2.19-shortcuts.jpg b/frontend/resources/images/features/2.19-shortcuts.jpg new file mode 100644 index 0000000000..39e20afca4 Binary files /dev/null and b/frontend/resources/images/features/2.19-shortcuts.jpg differ diff --git a/frontend/resources/images/features/2.19-slide-0.jpg b/frontend/resources/images/features/2.19-slide-0.jpg new file mode 100644 index 0000000000..3937dcbd66 Binary files /dev/null and b/frontend/resources/images/features/2.19-slide-0.jpg differ diff --git a/frontend/resources/images/features/2.19-tokens.jpg b/frontend/resources/images/features/2.19-tokens.jpg new file mode 100644 index 0000000000..6650b335c0 Binary files /dev/null and b/frontend/resources/images/features/2.19-tokens.jpg differ diff --git a/frontend/resources/images/icons/handlers-equal.svg b/frontend/resources/images/icons/handlers-equal.svg index 645e773d8c..c1b3cb33aa 100644 --- a/frontend/resources/images/icons/handlers-equal.svg +++ b/frontend/resources/images/icons/handlers-equal.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/resources/images/icons/handlers-independent.svg b/frontend/resources/images/icons/handlers-independent.svg index 36e1c06917..cb782fbfe2 100644 --- a/frontend/resources/images/icons/handlers-independent.svg +++ b/frontend/resources/images/icons/handlers-independent.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/resources/images/icons/handlers-mirror.svg b/frontend/resources/images/icons/handlers-mirror.svg index 78f71c740e..1e8ac8868c 100644 --- a/frontend/resources/images/icons/handlers-mirror.svg +++ b/frontend/resources/images/icons/handlers-mirror.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/resources/images/icons/loader.svg b/frontend/resources/images/icons/loader.svg deleted file mode 100644 index ee1d9b96bf..0000000000 --- a/frontend/resources/images/icons/loader.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/resources/images/icons/share.svg b/frontend/resources/images/icons/share.svg index 2c0d41de63..0677a583d3 100644 --- a/frontend/resources/images/icons/share.svg +++ b/frontend/resources/images/icons/share.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/frontend/resources/images/icons/snap-nodes.svg b/frontend/resources/images/icons/snap-nodes.svg deleted file mode 100644 index 6ce0a90d67..0000000000 --- a/frontend/resources/images/icons/snap-nodes.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/resources/images/icons/snap.svg b/frontend/resources/images/icons/snap.svg index 0acdce1f20..b2f88cb835 100644 --- a/frontend/resources/images/icons/snap.svg +++ b/frontend/resources/images/icons/snap.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/scripts/_helpers.js b/frontend/scripts/_helpers.js index 60c59e8f6d..9a6bf14ec8 100644 --- a/frontend/scripts/_helpers.js +++ b/frontend/scripts/_helpers.js @@ -165,9 +165,22 @@ export async function compileSassAll(worker) { export function concatSass(data) { const output = []; + const charset = '@charset "UTF-8";'; + let hasCharset = false; + // Sass adds @charset to each file with non-ASCII characters. Browsers + // only honor it as the first rule of the stylesheet, so move it there. for (let path of data.items) { - output.push(data.index[path]); + let css = data.index[path]; + if (css.includes(charset)) { + hasCharset = true; + css = css.replaceAll(charset + "\n", "").replaceAll(charset, ""); + } + output.push(css); + } + + if (hasCharset) { + output.unshift(charset); } return output.join("\n"); diff --git a/frontend/scripts/check-translations/words.es.txt b/frontend/scripts/check-translations/words.es.txt index 6b9622c5bc..89cfcb7dc0 100644 --- a/frontend/scripts/check-translations/words.es.txt +++ b/frontend/scripts/check-translations/words.es.txt @@ -281,7 +281,6 @@ lectores letras manifiesto menos -navegadores nosotros otras pero diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index bd8afa6ff7..ee8eaa33f3 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -75,7 +75,9 @@ (not-empty))) (def ^:private xf:map-page-id - (map :page-id)) + ;; Component-only changes have no :page-id; keep drops those nils so + ;; update-object-indices does not create a nil key in :pages-index. + (keep :page-id)) (def ^:private wasm-structural-change-types #{:add-obj :mov-objects}) diff --git a/frontend/src/app/main/data/exports/files.cljs b/frontend/src/app/main/data/exports/files.cljs index 87d64649d4..905716897c 100644 --- a/frontend/src/app/main/data/exports/files.cljs +++ b/frontend/src/app/main/data/exports/files.cljs @@ -105,7 +105,8 @@ (= "completed" status) (rx/of {:file-id (:id file) :uri (:resource-uri result) - :filename (:name file)}) + :filename (:name file) + :size (:size result)}) (= "failed" status) (rx/of {:file-id (:id file) @@ -121,9 +122,14 @@ :else (rx/empty))))))) + ;; a transport failure carries no job error: a nil `ex-data` would + ;; make the dialog drop the message and leave the row hanging, so a + ;; synthetic error takes its place and the row reports it (rx/catch (fn [cause] (rx/of {:file-id (:id file) - :error (ex-data cause)}))))) + :error (or (ex-data cause) + {:type :internal + :code :unknown-error})}))))) (defn export-files "Start files exportation process. diff --git a/frontend/src/app/main/data/imports.cljs b/frontend/src/app/main/data/imports.cljs index 44cdd681d3..6fd079b015 100644 --- a/frontend/src/app/main/data/imports.cljs +++ b/frontend/src/app/main/data/imports.cljs @@ -25,7 +25,8 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.zip :as uz] [beicon.v2.core :as rx] - [cuerdas.core :as str])) + [cuerdas.core :as str] + [promesa.core :as p])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; MESSAGES OF A FAILURE @@ -98,10 +99,111 @@ (uz/read-as-text entry)))) (rx/map json/decode))) +(defn- segment-of + "The segment of a zip path that follows `prefix`, up to the next slash, + or nil when the path does not start with it." + [prefix path] + (when (str/starts-with? path prefix) + (let [rest (subs path (count prefix)) + idx (str/index-of rest "/")] + (if (some? idx) (subs rest 0 idx) rest)))) + +(defn- file-of-entry + "The file of a container a zip entry belongs to. A container keeps every + file of a package under its own prefix: `files/.json` for its data + and `files//…` for the rest." + [path] + (some-> (segment-of "files/" path) + (str/replace #"\.json$" ""))) + +(defn- object-of-entry + "The storage object a zip entry holds. A container keeps the media and + the thumbnails of its files shared under `objects/`, and both the + metadata (`objects/.json`) and the binary (`objects/.`) + belong to the same object." + [path] + (when-some [segment (segment-of "objects/" path)] + (let [dot (str/index-of segment ".")] + (if (some? dot) (subs segment 0 dot) segment)))) + +(defn- reference-entry? + "Whether an entry of a file is the metadata of one of the media or the + thumbnails that file references, the only place that links a file to the + objects it occupies." + [path] + (or (str/includes? path "/media/") + (str/includes? path "/thumbnails/"))) + +(defn- index-container + "Read the entries of a container in one pass: what every file of the + package occupies by itself, what every storage object occupies, and the + entries that say which objects a file references." + [entries] + (reduce + (fn [index entry] + (let [path (.-filename ^js entry) + size (.-compressedSize ^js entry) + file-id (file-of-entry path)] + (cond + (some? file-id) + (cond-> (update-in index [:files file-id] (fnil + 0) size) + (reference-entry? path) + (update :references conj [file-id entry])) + + :else + (if-some [object-id (object-of-entry path)] + (update-in index [:objects object-id] (fnil + 0) size) + index)))) + {:files {} :objects {} :references []} + entries)) + +(defn- read-objects + "The objects every file of a container references, read from the + metadata of its media and its thumbnails." + [references] + (->> (map (fn [[file-id entry]] + (->> (uz/read-as-text entry) + (p/fmap (fn [text] [file-id (json/decode text)])))) + references) + (p/all) + (p/fmap + (fn [decoded] + (reduce (fn [result [file-id data]] + (->> [:media-id :thumbnail-id] + (keep #(get data %)) + (reduce (fn [result object-id] + (update result file-id (fnil conj #{}) object-id)) + result))) + {} + decoded))))) + +(defn- file-sizes + "How much every file of a container occupies inside it: its own entries + plus the storage objects it references, which the container keeps shared + and which are only linked from the file. The manifest carries neither a + size, so the parts of a package are the only account of what it + occupies." + [entries] + (let [{:keys [files objects references]} (index-container entries)] + (->> (read-objects references) + (p/fmap + (fn [referenced] + (reduce-kv (fn [sizes file-id object-ids] + (update sizes file-id (fnil + 0) + (reduce + 0 (keep objects object-ids)))) + files + referenced)))))) + (defn- analyze-file "The entries of one file the user dropped: one per file of a version 3 - package, one for a version 1 file, and an error for anything else." - [{:keys [uri] :as file}] + package, one for a version 1 file, and an error for anything else. + + A container carries its own weight and the weight of every file in it: + the entry of a file of a version 3 package sizes the file inside its + container, and keeps what the container itself occupies in + `:container-size`, which is what the upload of the package is measured + on." + [{:keys [uri size] :as file}] (let [stream (->> (slurp-uri uri :buffer) (rx/merge-map (fn [body] @@ -110,16 +212,23 @@ (= "application/zip" mtype) (let [zip-reader (uz/reader body)] (->> (read-zip-manifest zip-reader) - (rx/map + (rx/merge-map (fn [manifest] (if (= (:type manifest) "penpot/export-files") - (let [manifest (decode-manifest manifest)] - (assoc file :type :binfile-v3 :files (:files manifest))) - (assoc file :type :unknown)))) + (->> (uz/get-entries zip-reader) + (p/mcat file-sizes) + (rx/from) + (rx/map (fn [sizes] + (-> file + (assoc :type :binfile-v3) + (assoc :container-size size) + (assoc :files (:files (decode-manifest manifest))) + (assoc :sizes sizes))))) + (rx/of (assoc file :type :unknown))))) (rx/finalize (partial uz/close zip-reader)))) (= "application/octet-stream" mtype) - (rx/of (assoc file :type :binfile-v1)) + (rx/of (assoc file :type :binfile-v1 :container-size size)) :else (rx/of (assoc file :type :unknown)))))) @@ -137,19 +246,28 @@ (->> stream (rx/filter (fn [entry] (= :binfile-v3 (:type entry)))) - (rx/merge-map (fn [{:keys [files] :as entry}] + (rx/merge-map (fn [{:keys [files sizes] :as entry}] (->> (rx/from files) (rx/map (fn [file] (-> entry - (dissoc :files) + (dissoc :files :sizes) (assoc :name (:name file)) (assoc :file-id (:id file)) + ;; the size the file itself + ;; occupies inside the + ;; container, never the + ;; container's + (assoc :size (get sizes (str (:id file)))) (assoc :status :success)))))))) (->> stream (rx/filter (fn [data] (= :unknown (:type data)))) (rx/map (fn [_] {:uri (:uri file) + :name (:name file) + :size (:size file) + :container-size (:size file) + :type :unknown :status :error :error (tr "dashboard.import.analyze-error")})))) @@ -273,6 +391,163 @@ (rx/from (entry-messages entries {:status :error :error (cause-message cause (tr "labels.error"))})))))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; WIZARD STEP, UPLOAD AND FILE SIZE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn format-file-size + "Human size of a dropped file, like `234MB` or `512KB`. + Returns nil when the size is unknown, so the row shows no subtitle." + [size] + (when (and (some? size) (number? size) (>= size 0)) + (let [kb 1024 + mb (* kb 1024) + gb (* mb 1024)] + (cond + (< size kb) + (str (js/Math.round size) "B") + + (< size mb) + (let [v (/ size kb)] + (str (if (< v 10) + (-> (.toFixed v 1) + (str/replace #"\.0$" "")) + (str (js/Math.round v))) + "KB")) + + (< size gb) + (let [v (/ size mb)] + (str (if (< v 10) + (-> (.toFixed v 1) + (str/replace #"\.0$" "")) + (str (js/Math.round v))) + "MB")) + + :else + (let [v (/ size gb)] + (str (if (< v 10) + (-> (.toFixed v 1) + (str/replace #"\.0$" "")) + (str (js/Math.round v))) + "GB")))))) + +(defn- upload-ratio + "The fraction of the container of an entry already sent: the chunks + uploaded over the chunks the file has, or nil while its upload has not + started." + [entry] + (let [{:keys [current total]} (get-in entry [:progress :counters :upload])] + (when (and (some? current) (some? total) (pos? total)) + (/ current total)))) + +(defn- uploaded-ratio + "The fraction of the container of an entry already uploaded. A container + is uploaded whole before its job exists, so from the moment its entries + leave the upload stage they count as sent: only an entry still sending + chunks, or one that has not started, says less than everything." + [entry] + (cond + (= :upload (get-in entry [:progress :stage])) (or (upload-ratio entry) 0) + (contains? #{:analyze :import-ready} (:status entry)) 0 + :else 1)) + +(defn upload-done? + "Whether the container of an entry finished uploading: the chunks sent + reached the chunks the file has." + [entry] + (let [{:keys [current total]} (get-in entry [:progress :counters :upload])] + (and (some? current) (some? total) (= current total)))) + +(defn upload-pct + "The percentage of the whole upload: the bytes already sent over the + bytes of every container. The containers are weighted by their size, + never averaged, because a `.penpot` is uploaded whole: a container of + 200MB moves the bar as much as four of 50MB." + [entries] + (let [containers (map (comp first :entries) (packages entries)) + total (reduce + 0 (keep :container-size containers))] + (when (pos? total) + (let [sent (reduce + 0 (map (fn [{:keys [container-size] :as entry}] + (* container-size (uploaded-ratio entry))) + containers))] + (js/Math.round (* 100 (/ sent total))))))) + +(defn- files-done + "How many files of a container are already rebuilt. A container that + finished leaves all of its files behind; while it runs, its milestone + says which file it is working on, one based, so the ones before it are + done." + [entries] + (if (every? #(= :import-success (:status %)) entries) + (count entries) + (let [{:keys [current total]} (get-in (first entries) [:progress :counters :files]) + total (or total (count entries))] + (-> (or current 1) dec (max 0) (min total))))) + +(defn imported-count + "How many of the files being imported are already rebuilt: the files of + the containers that finished plus the ones a running container left + behind. A container rebuilds its files one by one, so counting only the + finished containers (or only their files) would leave the number still + for the whole import." + [entries] + (reduce + 0 (map (comp files-done :entries) (packages entries)))) + +(defn waiting-slot? + "Whether every file of the wizard waits for a free slot: the jobs of the + containers exist, but no worker picked them up yet." + [entries] + (and (pos? (count entries)) + (every? #(= :import-queued (:status %)) entries))) + +(defn- pending-upload? + "Whether a file is still to be uploaded, or is being uploaded right now: + the wizard is in its upload step while any file is pending." + [entry] + (or (= :import-ready (:status entry)) + (= :upload (get-in entry [:progress :stage])))) + +(defn import-step + "The wizard step the header paints: `:check` while the entries are + analyzed, `:upload` until every file of every container is uploaded, and + `:import` once the jobs run or the wizard reaches its outcome. + + A file leaves the upload behind when its job exists: the queued message + carries no milestone, and the upload one disappears with it, so the + presence of a milestone is not enough to keep the step in the upload." + [status entries] + (case status + (:analyze :import-ready) + :check + + :import-progress + (if (some pending-upload? entries) + :upload + :import) + + (:import-success :import-error :library-resolution :library-summary) + :import + + :check)) + +(defn awaiting-analysis? + "Whether the wizard hides the rows and shows the checking placeholder + instead: the dialog is still analyzing and no entry failed yet. Entries + that fail during analyze take the error-summary path instead of rows." + [status errors?] + (and (= :analyze status) (not errors?))) + +(defn ready-to-import? + "Whether the wizard can offer its import step: the analysis is over and + every file it found can be imported. A ready file is not enough on its + own: until the analysis ends the wizard does not know how many files the + dropped containers carry, and the ones still to arrive would never be + imported." + [entries analysis-ended?] + (and analysis-ended? + (pos? (count entries)) + (every? #(= :import-ready (:status %)) entries))) + (defn import-files "Import the entries the wizard analyzed, one job per package. diff --git a/frontend/src/app/main/data/shortcuts.cljs b/frontend/src/app/main/data/shortcuts.cljs index 9a045d4995..be6d97068b 100644 --- a/frontend/src/app/main/data/shortcuts.cljs +++ b/frontend/src/app/main/data/shortcuts.cljs @@ -17,36 +17,18 @@ (log/set-level! :warn) -;; Keyboard layout map shared by the shortcut recorder -;; (`app.main.ui.shortcuts`) and the Mousetrap dispatcher: punctuation -;; resolves by physical position through this map, so recording and -;; dispatch use the same token. `nil` until `getLayoutMap` resolves (or -;; when the browser has no keyboard API); both sides then fall back to -;; the typed glyph, which keeps them consistent with and without a map. -(defonce layout-map (atom nil)) - -(defn layout-map-supported? - "Synchronous keyboard-layout API probe: true when the browser exposes - navigator.keyboard.getLayoutMap (Chromium). Punctuation then resolves - by physical position; without it both sides fall back to the typed - glyph, so Shift+punctuation shortcuts may not fire." - ([] (layout-map-supported? - (when (exists? js/navigator) js/navigator))) - ([navigator] - (let [keyboard (when (some? navigator) (.-keyboard ^js navigator))] - (boolean - (and (some? keyboard) - (exists? (.-getLayoutMap ^js keyboard))))))) - -(let [navigator (when (exists? js/navigator) js/navigator)] - (when (layout-map-supported? navigator) - (-> (.getLayoutMap ^js (.-keyboard ^js navigator)) - (.then (fn [m] - (cljs.core/reset! layout-map m) - (.setLayoutMap mousetrap m)) - (fn [_] - (cljs.core/reset! layout-map nil) - (.setLayoutMap mousetrap nil)))))) +;; Keyboard layout map for Mousetrap: punctuation resolves by physical +;; position through it. The shortcut recorder (`app.main.ui.shortcuts`) +;; asks Mousetrap for the key it stores, so both use the same map. Absent +;; until `getLayoutMap` resolves, and in browsers without the keyboard API +;; (Firefox, Safari). +(let [navigator (when (exists? js/navigator) js/navigator) + keyboard (when (some? navigator) (.-keyboard ^js navigator))] + (when (and (some? keyboard) + (exists? (.-getLayoutMap ^js keyboard))) + (-> (.getLayoutMap ^js keyboard) + (.then (fn [m] (.setLayoutMap mousetrap m)) + (fn [_] (.setLayoutMap mousetrap nil)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Helpers diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 9601aa295d..3e0c9108f7 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -737,6 +737,37 @@ (update [_ state] (assoc-in state [:workspace-local :shape-for-rename] id)))) +(defn- rename-variant-main + "Apply a layers-panel formula to one variant main. + + A valid formula updates the property names and values. Any other + text keeps the current malformed path: values are cleared and the + text is stored as the variant error." + [file-id shape name] + (ptk/reify ::rename-variant-main + ptk/WatchEvent + (watch [_ state _] + (let [file-data (dsh/lookup-file-data state file-id) + component-id (:component-id shape) + component (ctkl/get-component file-data component-id) + variant-id (:variant-id shape) + variant-properties (:variant-properties component)] + (if (ctv/valid-properties-formula? name) + (rx/of (dwva/update-properties-names-and-values + component-id + variant-id + variant-properties + (ctv/properties-formula->map name)) + (dwva/remove-empty-properties variant-id) + (dwva/update-error component-id)) + (rx/of (dwva/update-properties-names-and-values + component-id + variant-id + variant-properties + {}) + (dwva/remove-empty-properties variant-id) + (dwva/update-error component-id name))))))) + (defn end-rename-shape "End the ongoing shape rename process" ([] (end-rename-shape nil nil)) @@ -751,14 +782,17 @@ (when-let [shape-id (d/nilv shape-id (dm/get-in state [:workspace-local :shape-for-rename]))] (let [shape (dsh/lookup-shape state shape-id) name (str/trim name) - clean-name (cpn/clean-path name) - valid? (and (not (str/ends-with? name "/")) - (string? clean-name) - (not (str/blank? clean-name))) + clean-name (cpn/normalize-path-name name) component-id (:component-id shape) - undo-id (js/Symbol)] + undo-id (js/Symbol)] - (when valid? + (cond + ;; Variant formula edits go through rename-shape-or-variant. + ;; A variant main must not fall through to a plain rename. + (ctc/is-variant-main? shape) + (rx/empty) + + (some? clean-name) (if (ctc/is-variant-container? shape) ;; Rename the full variant when it is a variant container (rx/of (dwva/rename-variant shape-id clean-name)) @@ -782,32 +816,12 @@ (watch [_ state _] (let [file-id (d/nilv file-id (:current-file-id state)) page-id (d/nilv page-id (:current-page-id state)) - - file-data (dsh/lookup-file-data state file-id) - shape - (-> (dsh/lookup-page-objects state file-id page-id) - (get id)) - - is-variant? (ctc/is-variant? shape) - variant-id (when is-variant? (:variant-id shape)) - variant-name (when is-variant? (:variant-name shape)) - component-id (:component-id shape) - component (ctkl/get-component file-data (:component-id shape)) - variant-properties (:variant-properties component)] - (cond - (and variant-name (ctv/valid-properties-formula? name)) - (rx/of (dwva/update-properties-names-and-values - component-id variant-id variant-properties (ctv/properties-formula->map name)) - (dwva/remove-empty-properties variant-id) - (dwva/update-error component-id)) - - variant-name - (rx/of (dwva/update-properties-names-and-values - component-id variant-id variant-properties {}) - (dwva/remove-empty-properties variant-id) - (dwva/update-error component-id name)) - - :else + shape (-> (dsh/lookup-page-objects state file-id page-id) + (get id)) + name (str/trim name)] + (if (ctc/variant-formula-rename? shape name) + ;; update-properties-names-and-values clears :shape-for-rename. + (rx/of (rename-variant-main file-id shape name)) (rx/of (end-rename-shape id name)))))))) ;; --- Update Selected Shapes attrs diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index b84faf4356..bc52e63736 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -498,15 +498,11 @@ (ptk/reify ::rename-component-and-main-instance ptk/WatchEvent (watch [_ state _] - (let [name (str/trim name) - clean-name (cpn/clean-path name) - valid? (and (not (str/ends-with? name "/")) - (string? clean-name) - (not (str/blank? clean-name))) + (let [clean-name (cpn/normalize-path-name name) data (dsh/lookup-file-data state) component (dm/get-in data [:components component-id])] - (when (and valid? component) + (when (and (some? clean-name) component) (let [shape-id (:main-instance-id component) page-id (:main-instance-page component)] @@ -1462,6 +1458,12 @@ (wrf/finish! task) (swap! pending-sync-barriers* disj task))) + ;; Each batch entry is [[event task] old-data]. + finish-batch-barriers! + (fn [batch] + (doseq [[[_ task] _] batch] + (finish-sync-barrier! task))) + commits-s (->> stream (rx/filter dch/commit?) @@ -1474,6 +1476,9 @@ (rx/filter (complement :skip-component-sync?)) ;; Keep waits pending while component changes are checked. (rx/map start-sync-barrier) + ;; Paired as it arrives, so later commits in the same batch + ;; cannot hide the shapes this one moved or deleted. + (rx/with-latest-from workspace-buffer-s) (rx/share)) ;; Buffers commits until a timer turn passes with no new ones. The @@ -1499,32 +1504,33 @@ (log/info :hint "detected component changes" :ids (map str changed-components) :undo-group undo-group) - (map #(vector ::component-changed % undo-group) + (map #(vector ::component-changed % (:id old-data) undo-group) changed-components)) :else ;; Undos only bump :modified-at. (map #(vector ::touch-component %) changed-components)))) - ;; One event per distinct component change in the batch + ;; One event per distinct component change in the batch, each + ;; commit checked against the file data right before it get-component-events - (fn [batch old-data] + (fn [batch] (->> batch - (mapcat #(get-component-changes old-data (first %))) + (mapcat (fn [[[event _] old-data]] + (get-component-changes old-data event))) (distinct) - (map (fn [[type component-id undo-group]] + (map (fn [[type component-id file-id undo-group]] (if (= type ::component-changed) - (component-changed component-id (:id old-data) undo-group) + (component-changed component-id file-id undo-group) (touch-component component-id)))) (rx/from))) component-events-s (->> commit-batches-s - (rx/with-latest-from workspace-buffer-s) (rx/mapcat - (fn [[batch old-data]] - (->> (get-component-events batch old-data) - (rx/finalize #(run! (comp finish-sync-barrier! second) batch))))) + (fn [batch] + (->> (get-component-events batch) + (rx/finalize #(finish-batch-barriers! batch))))) ;; Close barriers left behind when the page shuts down. (rx/finalize #(wrf/finish-tasks! @pending-sync-barriers*)) (rx/share)) diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index 58ce2f5060..07f766ab61 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -17,6 +17,7 @@ [app.main.data.profile :as du] [app.main.store :as st] [app.plugins.register :as preg] + [app.util.globals :as globals] [app.util.timers :as ts] [beicon.v2.core :as rx] [beicon.v2.operators :as rxo] @@ -44,6 +45,32 @@ (defonce interval-sub (atom nil)) +;; Resolves the promise that keeps the session Web Lock held; nil when none is. +(defonce ^:private session-lock-release + (atom nil)) + +(defn- hold-session-lock + "Holds a shared Web Lock while the MCP connection is requested. Chrome + does not freeze a page that holds a Web Lock, and a frozen page cannot + run the plugin." + [] + (when-let [locks (and (nil? @session-lock-release) + (unchecked-get globals/navigator "locks"))] + (let [released (js/Promise. (fn [resolve] (reset! session-lock-release resolve))) + release @session-lock-release] + (-> (.request locks "penpot-mcp-session" #js {:mode "shared"} (constantly released)) + (.catch (fn [cause] + ;; lets a later connection request try again, unless a + ;; newer lock request has already replaced this one + (compare-and-set! session-lock-release release nil) + (log/warn :hint "unable to hold the MCP session lock" :cause cause))))))) + +(defn- release-session-lock + [] + (when-let [release @session-lock-release] + (reset! session-lock-release nil) + (release))) + (defn connect-mcp [] (ptk/reify ::connect-mcp @@ -60,7 +87,12 @@ (watch [_ state _] (if (get-in state [:mcp :connection-requested]) (rx/of (ptk/data-event ::connect)) - (rx/empty))))) + (rx/empty))) + + ptk/EffectEvent + (effect [_ state _] + (when (get-in state [:mcp :connection-requested]) + (hold-session-lock))))) (defn- start-reconnect-watcher [] @@ -133,8 +165,38 @@ ptk/EffectEvent (effect [_ _ _] + (release-session-lock) (stop-reconnect-watcher!)))) +(defn- extension-hello? + "Checks if a window message is a browser extension's request for the + MCP session, posted on this page by its content script." + [event] + (and (identical? (unchecked-get event "source") globals/window) + (= (unchecked-get event "origin") (.-origin globals/location)) + (= "penpot-mcp-extension:hello" + (some-> (unchecked-get event "data") (unchecked-get "type"))))) + +(defn extension-status + "Returns the connection status to announce to a browser extension. The + plugin also reports `disconnected` while it reconnects, so the + extension, which forgets a session on `disconnected`, only hears it + once the user has stopped the connection." + [status connection-requested?] + (if (and (= status "disconnected") connection-requested?) + "connecting" + status)) + +(defn- announce-session + "Posts the MCP session of this page to a browser extension, which keeps + this tab from being discarded." + [status session-id] + (.postMessage globals/window + #js {:type "penpot-mcp:session" + :sessionId session-id + :status status} + (.-origin globals/location))) + (defn- init-mcp-plugin "Waits the plugin runtime and initializes the bundled MCP plugin." [{:keys [token]}] @@ -151,6 +213,19 @@ (rx/filter (ptk/type? ::init) stream)) active? (atom true) + + ;; Whether a browser extension asked for the session; nothing is + ;; posted on the page before that. + announce? (atom false) + + on-message + (fn [event] + (when (extension-hello? event) + (reset! announce? true) + (let [{:keys [connection-status connection-requested session-id]} (get @st/state :mcp)] + (announce-session (extension-status connection-status connection-requested) + session-id)))) + extension #js {:getToken (constantly token) :getServerUrl #(str cf/mcp-ws-uri) :isConnectionRequested #(and @active? @@ -162,6 +237,9 @@ (get-in @st/state [:mcp :connection-requested])) (start-reconnect-watcher)) (st/emit! (update-mcp-connection-status status session-id)) + (when @announce? + (announce-session (extension-status status (get-in @st/state [:mcp :connection-requested])) + session-id)) (log/info :hint "MCP STATUS" :status status))) :on @@ -182,8 +260,16 @@ (rx/take 1) (rx/subs! (fn [_] (reset! active? false) + (.removeEventListener globals/window "message" on-message) + (when @announce? + (announce-session "disconnected" nil)) + (release-session-lock) (stop-reconnect-watcher!) (dp/close-plugin! default-manifest)))) + (.addEventListener globals/window "message" on-message) + ;; A content script that said hello before this listener existed + ;; says it again + (.postMessage globals/window #js {:type "penpot-mcp:ready"} (.-origin globals/location)) (dp/start-plugin! manifest #js {:mcp extension}))))) (defn- stop-mcp-plugin diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index bb8f1a7072..8833022e94 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -16,6 +16,7 @@ [app.main.data.common :as dc] [app.main.data.helpers :as dsh] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.data.plugins :as dpl] [app.main.data.websocket :as dws] [app.main.data.workspace :as-alias dw] @@ -27,6 +28,7 @@ [app.main.data.workspace.texts :as dwt] [app.main.router :as rt] [app.util.globals :refer [global]] + [app.util.i18n :refer [tr]] [app.util.mouse :as mse] [app.util.object :as obj] [app.util.rxops :as rxs] @@ -253,7 +255,8 @@ [:session-id ::sm/uuid] [:revn :int] [:vern :int] - [:changes cpc/schema:changes]]) + [:changes cpc/schema:changes] + [:repaired {:optional true} :boolean]]) (def ^:private check-file-change-params! (sm/check-fn schema:handle-file-change)) @@ -275,13 +278,20 @@ ;; and update the persistence internal state with the updated ;; file-revn - (rx/of (dch/commit {:file-id file-id - :file-revn revn - :file-vern vern - :save-undo? false - :source :remote - :redo-changes (vec changes) - :undo-changes []}))))) + (rx/concat + (rx/of (dch/commit {:file-id file-id + :file-revn revn + :file-vern vern + :save-undo? false + :source :remote + :redo-changes (vec changes) + :undo-changes []})) + (if (:repaired msg) + (rx/of (ntf/show {:content (tr "workspace.notifications.file-repaired") + :type :toast + :level :info + :timeout nil})) + (rx/empty)))))) (defn handle-file-deleted [{:keys [file-id session-id] :as msg}] diff --git a/frontend/src/app/main/data/workspace/pages.cljs b/frontend/src/app/main/data/workspace/pages.cljs index c8b8180c41..ea76aa091c 100644 --- a/frontend/src/app/main/data/workspace/pages.cljs +++ b/frontend/src/app/main/data/workspace/pages.cljs @@ -147,7 +147,12 @@ :workspace-focus-selected))] (cond-> state - exit? (dissoc :workspace-drawing)))))) + exit? (dissoc :workspace-drawing)))) + + ptk/EffectEvent + (effect [_ _ _] + ;; Free the worker's copy of the page and its indexes + (mw/emit! {:cmd :index/finalize :page-id page-id})))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Workspace Page CRUD diff --git a/frontend/src/app/main/data/workspace/path.cljs b/frontend/src/app/main/data/workspace/path.cljs index de8decc1ba..636d253a2f 100644 --- a/frontend/src/app/main/data/workspace/path.cljs +++ b/frontend/src/app/main/data/workspace/path.cljs @@ -55,7 +55,6 @@ ;; Path tools (dm/export tools/make-curve) (dm/export tools/make-corner) -(dm/export tools/add-node) (dm/export tools/remove-node) (dm/export tools/delete-selected) (dm/export tools/delete-selected-with-segments) diff --git a/frontend/src/app/main/data/workspace/path/shortcuts.cljs b/frontend/src/app/main/data/workspace/path/shortcuts.cljs index 21f1c87960..fbe1483de6 100644 --- a/frontend/src/app/main/data/workspace/path/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/path/shortcuts.cljs @@ -57,14 +57,6 @@ :overwrite true :fn #(st/emit! (drp/change-edit-mode :draw))} - :add-node {:tooltip (ds/shift "+") - :label (fn [] (tr "shortcuts.add-node")) - :command ["shift++" "shift+="] - :show-command "shift++" - :subsections [:path-editor] - :section [:workspace] - :fn #(st/emit! (drp/add-node))} - :delete-node {:tooltip (ds/supr) :label (fn [] (tr "shortcuts.delete-node")) :command ["del" "backspace"] diff --git a/frontend/src/app/main/data/workspace/path/tools.cljs b/frontend/src/app/main/data/workspace/path/tools.cljs index fe25a9e13e..54fa954410 100644 --- a/frontend/src/app/main/data/workspace/path/tools.cljs +++ b/frontend/src/app/main/data/workspace/path/tools.cljs @@ -121,9 +121,6 @@ (fn [ht] (reduce #(assoc %1 %2 type) (or ht {}) nodes))))) state))))) -(defn add-node [] - (process-path-tool (fn [content points] (path/split-segments content points 0.5)))) - (defn remove-node "Removes nodes and heals the gap with a fitted curve." ([] diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 380ab0b0d8..0adf37788b 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -689,8 +689,7 @@ :hide-ui {:tooltip "\\" :label (fn [] (tr "shortcuts.hide-ui")) - :command ["\\" "alt+\\" "alt+º"] - :show-command "\\" + :command "\\" :subsections [:panels :basics] :section [:workspace :basics] :fn #(st/emit! (toggle-layout-flag :hide-ui))} @@ -727,8 +726,7 @@ :zoom-selected {:tooltip (ds/shift "2") :label (fn [] (tr "shortcuts.zoom-selected")) - :command ["shift+2" "shift+num2" "shift+'" "shift+\""] - :show-command "shift+2" + :command ["shift+2" "shift+num2"] :subsections [:zoom-workspace] :section [:workspace] :fn #(st/emit! dw/zoom-to-selected-shape)} diff --git a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs index 6b22169719..cb0f4ea573 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -510,7 +510,8 @@ (pcb/with-library-data data) (clo/generate-set-tokens-source library) (pcb/set-tokens-source library-id))] - (rx/of (dch/commit-changes changes)))))) + (rx/of (dch/commit-changes changes) + (dwtp/propagate-workspace-tokens)))))) (defn delete-token-set [id] diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index f4bee091d1..d3a901849a 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -22,6 +22,7 @@ [app.main.worker] [app.util.dom :as dom] [app.util.globals :as g] + [app.util.http :as http] [app.util.i18n :refer [tr]] [app.util.timers :as ts] [app.util.webapi :as wapi] @@ -439,16 +440,31 @@ (defn on-update-loop "Handler for the rumext update scheduler trips: logs the throttled - update loop with the events being processed, at most once per - `report-window-ms`. Runs inside store watchers and effect bodies, so it - never throws." - [_] + update loop and reports it as an unhandled exception with the events + being processed, at most once per `report-window-ms`. Runs inside store + watchers and effect bodies, so it never throws." + [{:keys [rounds]}] (try (let [now (inst-ms (ct/now))] (when (>= (- now @update-loop-warned-at) report-window-ms) (vreset! update-loop-warned-at now) - (.warn js/console "[update-scheduler] update loop throttled; in-flight events:" - (into-array (map ptk/repr-event (ptk/in-flight-events st/state)))))) + (let [events (ptk/in-flight-events st/state) + cause (ex-info "update loop throttled" + {:type :internal + :code :update-loop-throttled + :rounds rounds})] + (.warn js/console "[update-scheduler] update loop throttled; in-flight events:" + (into-array (map ptk/repr-event events))) + (when (seq events) + (.set in-flight-by-cause cause (in-flight-lines events))) + ;; Report outside the loop that tripped the scheduler, as an + ;; unhandled exception: a throttled loop is an application defect, + ;; so it must reach the error reports and alerts, not only audit. + (ts/schedule + #(submit-report :event-name "unhandled-exception" + :hint (ex-message cause) + :report (fn [] (generate-report cause)) + :cause cause))))) (catch :default _ nil))) @@ -1135,6 +1151,15 @@ (and (= (.-name ^js cause) "NotFoundError") (str/includes? message "removeChild"))))) +(defn network-failure->error + "Wrap a raw network TypeError as a connectivity error, so it is reported + as an environment failure instead of an application crash." + [cause] + (ex-info (ex-message cause) + {:type (if (false? (.-onLine ^js g/navigator)) :offline :network) + :hint "unable to perform fetch operation"} + cause)) + (defn- from-plugin? "Check if the error is marked as originating from plugin code. The plugin runtime tracks plugin errors in a WeakMap, which works even @@ -1161,6 +1186,10 @@ (is-ignorable-exception? cause) nil + ;; Raw fetch failures: connectivity, not a crash + (http/network-failure? cause) + (on-error (network-failure->error cause)) + ;; All other errors: show exception page :else @@ -1188,6 +1217,10 @@ (is-ignorable-exception? cause) nil + ;; Raw fetch failures: connectivity, not a crash + (http/network-failure? cause) + (on-error (network-failure->error cause)) + ;; All other errors: show exception page :else (let [data (ex-data cause) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 377f49d116..7f24270a26 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -646,6 +646,8 @@ (defn workspace-thumbnail-by-id [object-id] + ;; Resolving a media id builds a new map on each read; compare by + ;; value so subscribers do not re-render on every store change. (l/derived (fn [state] (when-let [entry (dm/get-in state [:thumbnails object-id])] @@ -653,7 +655,8 @@ (and (:uri entry) (not (resolved-uri? (:uri entry)))) (update :uri cf/resolve-media)))) - st/state)) + st/state + =)) (def workspace-text-modifier (l/derived :workspace-text-modifier st/state)) diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 44fa8794b2..f6f668f908 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -20,6 +20,7 @@ [app.main.ui.error-boundary :refer [error-boundary*]] [app.main.ui.exports.files] [app.main.ui.frame-preview :as frame-preview] + [app.main.ui.mcp-authorize :refer [mcp-authorize*]] [app.main.ui.nitrate.entry :as nitrate-entry] [app.main.ui.notifications :as notifications] [app.main.ui.onboarding.questions :refer [questions-modal]] @@ -118,6 +119,9 @@ :auth-verify-token [:? [:> verify-token-page* {:route route}]] + :mcp-authorize + [:> mcp-authorize* {:route route :profile profile}] + :nitrate-entry [:> nitrate-entry/nitrate-entry-page* {:profile profile}] diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index d200c3746d..b43a954986 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -70,7 +70,7 @@ [:string {:min 1}]]]) (mf/defc login-form* - [{:keys [params handle-redirect on-success-callback on-recovery-request origin] :as props}] + [{:keys [params handle-redirect on-success-callback on-recovery-request origin hide-recovery-link] :as props}] (let [initial (mf/with-memo [params] params) form (fm/use-form :schema schema:login-form @@ -193,7 +193,8 @@ :label (tr "auth.password") :class (stl/css :form-field)}]]) - (when (and (not= origin :viewer) + (when (and (not hide-recovery-link) + (not= origin :viewer) (or (contains? cf/flags :login) (contains? cf/flags :login-with-password))) [:div {:class (stl/css :form-row :forgot-password-row)} diff --git a/frontend/src/app/main/ui/auth/login_modal.cljs b/frontend/src/app/main/ui/auth/login_modal.cljs new file mode 100644 index 0000000000..ee192893a4 --- /dev/null +++ b/frontend/src/app/main/ui/auth/login_modal.cljs @@ -0,0 +1,146 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.ui.auth.login-modal + (:require-macros [app.main.style :as stl]) + (:require + [app.config :as cf] + [app.main.router :as rt] + [app.main.store :as st] + [app.main.ui.auth.login :refer [login-dialog*]] + [app.main.ui.auth.recovery-request :refer [recovery-request-page*]] + [app.main.ui.auth.register :refer [register-methods* register-success-page* + register-validate-form* terms-service-privacy-policy*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.util.dom :as dom] + [app.util.i18n :as i18n :refer [tr]] + [app.util.keyboard :as k] + [rumext.v2 :as mf])) + +(mf/defc login-register-modal* + [{:keys [params on-close on-register]}] + (let [user-email (mf/use-state "") + register-token (mf/use-state "") + + current-section* (mf/use-state :login) + current-section (deref current-section*) + + set-current-section + (mf/use-fn + #(reset! current-section* %)) + + set-section + (mf/use-fn + (fn [event] + (let [section (-> (dom/get-current-target event) + (dom/get-data "value") + (keyword))] + (set-current-section section)))) + + go-back-to-login + (mf/use-fn + #(set-current-section :login)) + + main-section (or + (= current-section :login) + (= current-section :register) + (= current-section :register-validate)) + close + (fn [event] + (dom/prevent-default event) + (dom/stop-propagation event) + (on-close)) + + success-email-sent + (fn [email] + (reset! user-email email) + (set-current-section :email-sent)) + + success-login + (fn [& _] + (.reload js/window.location true)) + + success-register + (fn [data] + (cond + (:invitation-token data) + (st/emit! (rt/nav :auth-verify-token {:token (:invitation-token data)})) + + (:is-active data) + (if on-register (on-register) (on-close)) + + :else + (do + (reset! user-email (:email data)) + (set-current-section :email-sent))))] + + [:div {:class (stl/css :modal-overlay) + :on-key-down #(when (k/esc? %) (close %))} + [:div {:class (stl/css :modal-container) + :role "dialog" + :aria-modal true + :aria-labelledby "login-modal-title"} + [:div {:class (stl/css :modal-header)} + [:h2 {:class (stl/css :modal-header-title) + :id "login-modal-title"} (tr "labels.continue-with-penpot")] + [:> icon-button* {:variant "ghost" + :class (stl/css :modal-close) + :aria-label (tr "labels.close") + :on-click close + :icon i/close}]] + + [:div {:class (stl/css :modal-content)} + (case current-section + :login + [:div {:class (stl/css :login-form)} + [:> login-dialog* {:params params + :on-success-callback success-login + :hide-recovery-link true}] + [:div {:class (stl/css :login-links)} + [:div + [:a {:on-click set-section + :data-value "recovery-request"} + (tr "auth.forgot-password")]] + (when (contains? cf/flags :registration) + [:div + [:span + (tr "auth.register") " "] + [:a {:on-click set-section + :data-value "register"} + (tr "auth.register-submit")]])]] + + :register + [:div {:class (stl/css :login-form)} + [:> register-methods* {:params params :on-success-callback success-register}] + [:div {:class (stl/css :login-links)} + [:div + [:span (tr "auth.already-have-account") " "] + [:a {:on-click set-section + :data-value "login"} + (tr "auth.login-here")]]]] + + :register-validate + [:div {:class (stl/css :login-form)} + [:> register-validate-form* {:params {:token @register-token} + :on-success-callback success-email-sent}] + [:div {:class (stl/css :login-links)} + [:div + [:a {:on-click set-section + :data-value "register"} + (tr "labels.go-back")]]]] + + :recovery-request + [:> recovery-request-page* {:go-back-callback go-back-to-login + :on-success-callback success-email-sent}] + + :email-sent + [:div {:class (stl/css :login-form)} + [:> register-success-page* {:params {:email @user-email}}]]) + + (when main-section + [:div {:class (stl/css :login-links)} + [:> terms-service-privacy-policy*]])]]])) diff --git a/frontend/src/app/main/ui/viewer/login.scss b/frontend/src/app/main/ui/auth/login_modal.scss similarity index 100% rename from frontend/src/app/main/ui/viewer/login.scss rename to frontend/src/app/main/ui/auth/login_modal.scss diff --git a/frontend/src/app/main/ui/components/copy_button.scss b/frontend/src/app/main/ui/components/copy_button.scss index 8ae7cbf664..cecd60f2f1 100644 --- a/frontend/src/app/main/ui/components/copy_button.scss +++ b/frontend/src/app/main/ui/components/copy_button.scss @@ -28,7 +28,6 @@ .icon { flex-shrink: 0; - stroke-width: 1.33px; color: var(--color-foreground-secondary); } } @@ -81,7 +80,6 @@ .icon { flex-shrink: 0; - stroke-width: 1.33px; color: var(--color-foreground-primary); display: none; } diff --git a/frontend/src/app/main/ui/components/editable_select.scss b/frontend/src/app/main/ui/components/editable_select.scss index 7257ee14f2..fc5bc19cf8 100644 --- a/frontend/src/app/main/ui/components/editable_select.scss +++ b/frontend/src/app/main/ui/components/editable_select.scss @@ -123,7 +123,6 @@ justify-content: center; align-items: center; fill: none; - stroke-width: 1.33px; visibility: hidden; color: var(--dropdown-element-icon-color); } diff --git a/frontend/src/app/main/ui/components/progress.cljs b/frontend/src/app/main/ui/components/progress.cljs index 52fb0b2393..35ae9f3101 100644 --- a/frontend/src/app/main/ui/components/progress.cljs +++ b/frontend/src/app/main/ui/components/progress.cljs @@ -31,8 +31,8 @@ [] (let [state (mf/deref refs/progress) profile (mf/deref refs/profile) - theme (get profile :theme theme/default) - default-theme? (= theme/default theme) + theme (theme/use-theme profile) + dark-theme? (= "dark" theme) error? (:error state) healthy? (:healthy state) visible? (:visible state) @@ -48,13 +48,13 @@ color (cond error? clr/new-danger - healthy? (if default-theme? + healthy? (if dark-theme? clr/new-primary clr/new-primary-light) (not healthy?) clr/new-warning) background-clr - (if default-theme? + (if dark-theme? clr/background-quaternary clr/background-quaternary-light) diff --git a/frontend/src/app/main/ui/dashboard/comments.cljs b/frontend/src/app/main/ui/dashboard/comments.cljs index 794b41a1ce..7af63a7016 100644 --- a/frontend/src/app/main/ui/dashboard/comments.cljs +++ b/frontend/src/app/main/ui/dashboard/comments.cljs @@ -18,15 +18,11 @@ [app.main.ui.comments :as cmt] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) -(def ^:private comments-icon-svg - (deprecated-icon/icon-xref :comments (stl/css :comments-icon))) - (mf/defc comments-icon* [{:keys [profile on-show-comments]}] @@ -154,5 +150,7 @@ (empty? pending) [:div {:class (stl/css :thread-groups-placeholder)} - comments-icon-svg + [:> icon* {:icon-id i/comments + :size "l" + :class (stl/css :comments-icon)}] (tr "labels.no-comments-available")])]]]])) diff --git a/frontend/src/app/main/ui/dashboard/comments.scss b/frontend/src/app/main/ui/dashboard/comments.scss index 0e7cd075ef..249240cd05 100644 --- a/frontend/src/app/main/ui/dashboard/comments.scss +++ b/frontend/src/app/main/ui/dashboard/comments.scss @@ -5,45 +5,38 @@ // Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as *; .dashboard-comments-section { - @include deprecated.flex-center; - + display: flex; + justify-content: center; + align-items: center; position: relative; - border-radius: deprecated.$br-8; + border-radius: $br-8; } .thread-groups { - height: calc(100% - deprecated.$s-32); - max-height: deprecated.$s-440; + block-size: calc(100% - #{$sz-32}); + max-block-size: px2rem(440); overflow: auto; } -.thread-group { - display: flex; - flex-direction: column; - font-size: deprecated.$fs-12; -} - .thread-groups-placeholder { align-items: center; display: flex; flex-direction: column; - font-size: deprecated.$fs-12; - padding: deprecated.$s-24; + font-size: px2rem(12); + padding: var(--sp-xxl); text-align: center; color: var(--color-foreground-secondary); } .comments-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); - height: deprecated.$s-24; - width: deprecated.$s-24; - margin-bottom: deprecated.$s-24; + flex-shrink: 0; + color: var(--color-foreground-secondary); + margin-block-end: var(--sp-xxl); } .comment-button { @@ -51,57 +44,44 @@ .unread { position: absolute; - width: deprecated.$s-8; - height: deprecated.$s-8; - border: deprecated.$s-2 solid var(--color-background-tertiary); + inline-size: px2rem(8); + block-size: px2rem(8); + border: $b-2 solid var(--color-background-tertiary); border-radius: 50%; background: var(--color-foreground-error); - top: deprecated.$s-6; - right: deprecated.$s-6; + inset-block-start: px2rem(6); + inset-inline-end: px2rem(6); } } -.comments-icon-small { - @extend %button-icon; - - stroke: var(--comment-icon-small-foreground-color); -} - .dropdown { - @include deprecated.menu-shadow; - + box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); background-color: var(--color-background-tertiary); - border-radius: deprecated.$br-8; - border: deprecated.$s-1 solid transparent; - bottom: deprecated.$s-4; - height: 40vh; - max-height: deprecated.$s-480; - min-height: deprecated.$s-200; + border-radius: $br-8; + border: $b-1 solid transparent; + inset-block-end: var(--sp-xs); + block-size: 40vh; + max-block-size: $sz-480; + min-block-size: $sz-200; position: absolute; overflow: hidden; - width: 100%; - z-index: deprecated.$z-index-4; - - hr { - margin: 0; - border-color: var(--color-foreground-secondary); - } + inline-size: 100%; + z-index: var(--z-index-dropdown); } .header { display: flex; - height: deprecated.$s-40; + block-size: $sz-40; align-items: center; - padding: 0 deprecated.$s-12; - gap: deprecated.$s-4; + padding: 0 var(--sp-m); + gap: var(--sp-xs); } .header-title { + @include use-typography("headline-small"); + color: var(--color-foreground-secondary); - font-size: deprecated.$fs-11; - line-height: 1.28; flex-grow: 1; - text-transform: uppercase; } .notifications-button { @@ -110,8 +90,8 @@ } &.mark-all-as-read-button { - border-radius: deprecated.$s-8; - border: deprecated.$s-1 solid; + border-radius: $br-8; + border: $b-1 solid; } } diff --git a/frontend/src/app/main/ui/dashboard/deleted.cljs b/frontend/src/app/main/ui/dashboard/deleted.cljs index 6bef519d98..d2f8420121 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.cljs +++ b/frontend/src/app/main/ui/dashboard/deleted.cljs @@ -21,9 +21,10 @@ [app.main.ui.dashboard.layout-toggle :as lt :refer [layout-toggle*]] [app.main.ui.dashboard.subscription :refer [get-subscription-type]] [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] @@ -33,9 +34,6 @@ (def ^:private ref:deleted-files (l/derived :deleted-files st/state)) -(def ^:private menu-icon - (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) - (defn- on-restore-project [project] (let [on-accept #(st/emit! (dd/restore-project-immediately project))] @@ -158,13 +156,14 @@ [:div {:class (stl/css :info-wrapper)} [:div {:class (stl/css-case :project-actions true)} - [:button {:class (stl/css :options-btn) - :on-click on-menu-click - :title (tr "dashboard.options") - :aria-label (tr "dashboard.options") - :data-testid "project-options" - :on-key-down handle-menu-click} - menu-icon]] + [:> icon-button* {:variant "ghost" + :class (stl/css :options-btn) + :icon i/menu + :aria-label (tr "dashboard.options") + :aria-pressed (boolean (:menu-open @local)) + :data-testid "project-options" + :on-click on-menu-click + :on-key-down handle-menu-click}]] (when (:menu-open @local) [:> project-context-menu* diff --git a/frontend/src/app/main/ui/dashboard/deleted.scss b/frontend/src/app/main/ui/dashboard/deleted.scss index d6e9b376ea..7b9db012d6 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.scss +++ b/frontend/src/app/main/ui/dashboard/deleted.scss @@ -4,19 +4,21 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +// The shared dashboard header styles move in their own change: they are +// used by ten dashboard modules. @use "common/refactor/common-dashboard"; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/spacing.scss" as *; @use "ds/z-index.scss" as *; @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; .dashboard-container { flex: 1 0 0; - width: 100%; + inline-size: 100%; margin-inline-end: var(--sp-l); - border-top: $b-1 solid var(--panel-border-color); + border-block-start: $b-1 solid var(--color-background-quaternary); overflow-y: auto; padding-block-end: var(--sp-xxxl); } @@ -31,10 +33,10 @@ display: block; color: var(--color-foreground-secondary); - @include t.use-typography("body-large"); + @include use-typography("body-large"); line-height: 0.8; - height: var(--sp-xl); + block-size: var(--sp-xl); } .info-text-highlight { @@ -43,7 +45,7 @@ .deleted-options { display: flex; - gap: 5px; + gap: px2rem(5); flex-shrink: 0; } @@ -51,12 +53,12 @@ background: var(--color-background-default); padding: var(--sp-xxl) var(--sp-xxl) var(--sp-s) var(--sp-xxl); position: sticky; - top: 0; + inset-block-start: 0; z-index: var(--z-index-panels); } .nav-inside { - border-bottom: $b-1 solid var(--panel-border-color); + border-block-end: $b-1 solid var(--color-background-quaternary); display: flex; gap: var(--sp-l); justify-content: space-between; @@ -74,7 +76,7 @@ .selected { color: var(--color-foreground-primary); - border-bottom: $b-1 solid var(--color-foreground-primary); + border-block-end: $b-1 solid var(--color-foreground-primary); } .project { @@ -83,8 +85,8 @@ align-items: center; justify-content: space-between; gap: var(--sp-s); - width: 99%; - max-height: $sz-40; + inline-size: 99%; + max-block-size: $sz-40; padding: var(--sp-s) var(--sp-s) var(--sp-s) var(--sp-l); margin-block-start: var(--sp-l); border-radius: $br-4; @@ -94,19 +96,19 @@ display: flex; align-items: center; justify-content: flex-start; - width: 100%; - min-height: var(--sp-xxxl); + inline-size: 100%; + min-block-size: var(--sp-xxxl); margin-inline-start: var(--sp-s); } .project-name { - @include t.use-typography("body-large"); + @include use-typography("body-large"); - width: fit-content; + inline-size: fit-content; margin-inline-end: var(--sp-m); line-height: 0.8; - color: var(--title-foreground-color-hover); - height: var(--sp-l); + color: var(--color-foreground-primary); + block-size: var(--sp-l); } .project-actions { @@ -115,14 +117,8 @@ margin-inline-start: var(--sp-xxxl); } -.add-file-btn, .options-btn { - @extend %button-tertiary; - - height: var(--sp-xxxl); - width: var(--sp-xxxl); margin: 0 var(--sp-s); - padding: var(--sp-s); } .info-wrapper { @@ -130,10 +126,3 @@ align-items: center; gap: var(--sp-s); } - -.add-icon, -.menu-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); -} diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index 5574f21572..ed27260fe9 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -21,8 +21,13 @@ [app.main.store :as st] [app.main.ui.components.context-menu-a11y :refer [context-menu*]] [app.main.ui.components.file-uploader :refer [file-uploader]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.dom :as dom] [app.util.http :as http] @@ -195,13 +200,18 @@ [:div {:class (stl/css :dashboard-fonts-upload)} [:div {:class (stl/css :dashboard-fonts-hero)} [:div {:class (stl/css :desc)} - [:h2 (tr "labels.upload-custom-fonts")] - [:> i18n/tr-html* {:content (tr "dashboard.fonts.hero-text1")}] + [:> heading* {:level 2 + :typography t/title-large + :class (stl/css :hero-title)} + (tr "labels.upload-custom-fonts")] + [:> i18n/tr-html* {:content (tr "dashboard.fonts.hero-text1") + :class (stl/css :hero-text)}] - [:button {:class (stl/css :btn-primary) - :on-click on-click - :tab-index "0"} - [:span (tr "labels.add-custom-font")] + [:> button* {:variant "primary" + :type "button" + :class (stl/css :add-font-button) + :on-click on-click} + (tr "labels.add-custom-font") [:& file-uploader {:input-id "font-upload" :accept accept-font-types :multi true @@ -223,17 +233,19 @@ [:div {:class (stl/css :font-item :table-row)} [:span (tr "dashboard.fonts.fonts-added" (i18n/c (count fonts)))] [:div {:class (stl/css :table-field :options)} - [:button {:class (stl/css-case - :btn-primary true - :disabled disable-upload-all?) - :on-click on-upload-all - :data-testid "upload-all" - :disabled disable-upload-all?} - [:span (tr "dashboard.fonts.upload-all")]] - [:button {:class (stl/css :btn-secondary) - :on-click on-dismis-all - :data-testid "dismiss-all"} - [:span (tr "dashboard.fonts.dismiss-all")]]]]) + [:> button* {:variant "primary" + :type "button" + :class (stl/css :upload-button) + :on-click on-upload-all + :data-testid "upload-all" + :disabled disable-upload-all?} + (tr "dashboard.fonts.upload-all")] + [:> button* {:variant "secondary" + :type "button" + :class (stl/css :dismiss-button) + :on-click on-dismis-all + :data-testid "dismiss-all"} + (tr "dashboard.fonts.dismiss-all")]]]) (for [{:keys [id] :as item} (sort-by :font-family font-vals)] (let [uploading? (contains? uploading id) @@ -242,6 +254,7 @@ :key (dm/str id)} [:div {:class (stl/css :table-field :family)} [:input {:type "text" + :class (stl/css :name-input) :data-id (dm/str id) :on-blur on-blur-name :on-change on-change-name @@ -256,23 +269,23 @@ [:div {:class (stl/css :table-field :options)} (when (:height-warning? item) - [:span {:class (stl/css :icon :failure)} - deprecated-icon/msg-neutral]) + [:> icon* {:icon-id i/msg-neutral + :class (stl/css :warning-icon)}]) - [:button {:on-click on-upload - :data-id (dm/str id) - :class (stl/css-case - :btn-primary true - :upload-button true - :disabled disable-upload?) - :disabled disable-upload?} + [:> button* {:variant "primary" + :type "button" + :on-click on-upload + :data-id (dm/str id) + :class (stl/css :upload-button) + :disabled disable-upload?} (if ^boolean uploading? (tr "labels.uploading") (tr "labels.upload"))] - [:span {:class (stl/css :icon :close) - :data-id (dm/str id) - :on-click on-delete} - deprecated-icon/close]]]))]])) + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.remove") + :icon i/close + :data-id (dm/str id) + :on-click on-delete}]]]))]])) (mf/defc installed-font-context-menu* {::mf/private true} @@ -292,7 +305,7 @@ :show is-open :fixed false :min-width true - :top -15 + :top -21 :left -115 :options options}])) @@ -406,6 +419,7 @@ :is-edition edition?)} (if ^boolean edition? [:input {:type "text" + :class (stl/css :name-input) :auto-focus true :default-value font-family :on-key-down on-key-down @@ -420,29 +434,37 @@ [:span {:class (stl/css :label)} [:> font-variant-display-name* {:variant item}]] (when can-edit - [:span - {:class (stl/css :icon :close) - :data-id (dm/str id) - :on-click on-delete-variant} - deprecated-icon/add])])] + [:button {:type "button" + :class (stl/css :variant-delete) + :aria-label (tr "dashboard.fonts.delete-variant") + :title (tr "dashboard.fonts.delete-variant") + :data-id (dm/str id) + :on-click on-delete-variant} + [:> icon* {:icon-id i/close + :size "s" + :aria-hidden true}]])])] (if ^boolean edition? [:div {:class (stl/css :table-field :options)} - [:button - {:disabled (str/blank? font-family) - :on-click on-save - :class (stl/css-case :btn-primary true - :btn-disabled (str/blank? font-family))} + [:> button* {:variant "primary" + :type "button" + :class (stl/css :upload-button) + :disabled (str/blank? font-family) + :on-click on-save} (tr "labels.save")] - [:button {:class (stl/css :icon :close) - :on-click on-cancel} - deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.cancel") + :icon i/close + :on-click on-cancel}]] (when can-edit [:div {:class (stl/css :table-field :options)} - [:span {:class (stl/css :icon) - :on-click on-menu-open} - deprecated-icon/menu] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.options") + :aria-haspopup "menu" + :aria-expanded menu-open? + :icon i/menu + :on-click on-menu-open}] [:> installed-font-context-menu* {:on-close on-menu-close @@ -468,12 +490,16 @@ (cond (seq fonts) [:* - [:h3 (tr "labels.installed-fonts")] + [:> heading* {:level 3 + :typography t/title-small + :class (stl/css :installed-fonts-title)} + (tr "labels.installed-fonts")] [:div {:class (stl/css :installed-fonts-header)} [:div {:class (stl/css :table-field :family)} (tr "labels.font-family")] [:div {:class (stl/css :table-field :variants)} (tr "labels.font-variants")] [:div {:class (stl/css :table-field :search-input)} - [:input {:placeholder (tr "labels.search-font") + [:input {:class (stl/css :search-font-input) + :placeholder (tr "labels.search-font") :default-value "" :on-change on-change}]]] (for [[font-id variants] (->> (vals fonts) @@ -486,13 +512,16 @@ (nil? fonts) [:div {:class (stl/css :fonts-placeholder)} - [:div {:class (stl/css :icon)} deprecated-icon/loader] + [:> loader* {:width 32 + :title (tr "labels.loading")}] [:div {:class (stl/css :label)} (tr "dashboard.loading-fonts")]] :else (if ^boolean can-edit [:div {:class (stl/css :fonts-placeholder)} - [:div {:class (stl/css :icon)} deprecated-icon/text] + [:> icon* {:icon-id i/text + :size "xl" + :class (stl/css :placeholder-icon)}] [:div {:class (stl/css :label)} (tr "dashboard.fonts.empty-placeholder")]] [:> empty-placeholder* diff --git a/frontend/src/app/main/ui/dashboard/fonts.scss b/frontend/src/app/main/ui/dashboard/fonts.scss index c8cbcdf808..e2e6430ede 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.scss +++ b/frontend/src/app/main/ui/dashboard/fonts.scss @@ -4,66 +4,60 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// The shared dashboard header styles move in their own change: they are +// used by ten dashboard modules. @use "common/refactor/common-dashboard"; @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/spacing.scss" as *; @use "ds/mixins.scss" as *; .dashboard-fonts { - border-top: $b-1 solid var(--color-background-quaternary); + border-block-start: $b-1 solid var(--color-background-quaternary); display: flex; flex-direction: column; - padding-left: px2rem(120); - padding-bottom: px2rem(120); + padding-inline-start: px2rem(120); + padding-block-end: px2rem(120); overflow-y: auto; - - .btn-primary { - font-size: px2rem(11); - height: $sz-32; - min-width: px2rem(100); - } } .dashboard-installed-fonts { - max-width: px2rem(1000); - width: 100%; + max-inline-size: px2rem(1000); + inline-size: 100%; display: flex; - margin-top: var(--sp-xxl); + margin-block-start: var(--sp-xxl); flex-direction: column; - h3 { - @include t.use-typography("title-small"); - - color: var(--color-foreground-secondary); - margin: var(--sp-xs); - } - .font-item { color: var(--color-background-secondary); } } +.installed-fonts-title { + color: var(--color-foreground-secondary); + margin: var(--sp-xs); +} + .installed-fonts-header { - @include t.use-typography("headline-small"); + @include use-typography("headline-small"); align-items: center; color: var(--color-foreground-secondary); display: flex; - height: $sz-40; - padding-left: var(--sp-xxl); + block-size: $sz-40; + padding-inline-start: var(--sp-xxl); > .family { @include two-line-text-ellipsis; - min-width: $sz-200; - width: $sz-200; + min-inline-size: $sz-200; + inline-size: $sz-200; } > .variants { - padding-left: var(--sp-m); + padding-inline-start: var(--sp-m); } } @@ -71,31 +65,31 @@ display: flex; flex-grow: 1; justify-content: flex-end; +} - input { - @include t.use-typography("body-medium"); +.search-font-input { + @include use-typography("body-medium"); - background-color: var(--color-background-tertiary); - border-radius: $br-8; - border: $b-1 solid transparent; - color: var(--color-foreground-primary); - height: $sz-32; - margin: 0; - padding: 0 var(--sp-s); - width: px2rem(152); + background-color: var(--color-background-tertiary); + border-radius: $br-8; + border: $b-1 solid transparent; + color: var(--color-foreground-primary); + block-size: $sz-32; + margin: 0; + padding: 0 var(--sp-s); + inline-size: px2rem(152); - &:focus { - outline: $b-1 solid var(--color-accent-primary); - } + &:focus { + outline: $b-1 solid var(--color-accent-primary); + } - &::placeholder { - color: var(--color-foreground-secondary); - } + &::placeholder { + color: var(--color-foreground-secondary); } } .font-item { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); align-items: center; background-color: var(--color-background-tertiary); @@ -103,13 +97,13 @@ color: var(--color-foreground-secondary); display: flex; justify-content: space-between; - margin-top: var(--sp-xs); - max-width: px2rem(1000); + margin-block-start: var(--sp-xs); + max-inline-size: px2rem(1000); padding: var(--sp-m) var(--sp-xxl); - width: 100%; + inline-size: 100%; - input { - @include t.use-typography("body-medium"); + .name-input { + @include use-typography("body-medium"); @include text-ellipsis; border: $b-1 solid transparent; @@ -129,8 +123,8 @@ > .family { @include two-line-text-ellipsis; - min-width: $sz-200; - width: $sz-200; + min-inline-size: $sz-200; + inline-size: $sz-200; &.is-edition { overflow: visible; @@ -140,16 +134,16 @@ > .filenames { @include text-ellipsis; - min-width: $sz-200; + min-inline-size: $sz-200; } > .variants { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); display: flex; flex-wrap: wrap; flex-grow: 1; - padding-left: var(--sp-l); + padding-inline-start: var(--sp-l); gap: $sz-6; .variant { @@ -160,25 +154,11 @@ cursor: pointer; gap: var(--sp-xs); - .icon { - display: flex; - align-items: center; - justify-content: center; - height: $sz-16; - width: $sz-16; - - svg { - fill: none; - width: $sz-12; - height: $sz-12; - transform: rotate(45deg); - } - } + // The delete button only shows its icon while the variant is hovered. + --variant-delete-color: transparent; &:hover { - .icon svg { - stroke: var(--color-foreground-secondary); - } + --variant-delete-color: var(--color-foreground-secondary); } } @@ -197,79 +177,76 @@ } .filenames { - @include t.use-typography("body-small"); + @include use-typography("body-small"); @include text-ellipsis; - min-width: $sz-400; - padding-left: var(--sp-xxxl); + min-inline-size: $sz-400; + padding-inline-start: var(--sp-xxxl); } .options { display: flex; justify-content: flex-end; - min-width: px2rem(180); - - .icon { - width: $sz-24; - cursor: pointer; - display: flex; - margin-left: var(--sp-m); - justify-content: center; - align-items: center; - - svg { - width: $sz-16; - height: $sz-16; - stroke: var(--color-foreground-secondary); - fill: none; - } - - &.failure { - margin-right: var(--sp-m); - - svg { - stroke: var(--element-foreground-warning); - } - } - - &.close { - background: none; - border: none; - - svg { - stroke: var(--color-foreground-secondary); - } - } - } + min-inline-size: px2rem(180); + align-items: center; + gap: var(--sp-s); } } +.variant-delete { + display: flex; + align-items: center; + justify-content: center; + block-size: $sz-16; + inline-size: $sz-16; + padding: 0; + border: none; + background: none; + cursor: pointer; + color: var(--variant-delete-color); + + &:focus-visible { + --variant-delete-color: var(--color-foreground-secondary); + } +} + +// With the 8px gap of .options it keeps 12px on each side. +.warning-icon { + margin-inline: var(--sp-xs); + color: var(--color-accent-warning); +} + +// A DS button keeps its label at the start when it grows. +.upload-button { + min-inline-size: px2rem(100); + justify-content: center; +} + .dashboard-fonts-upload { - max-width: px2rem(1000); - width: 100%; + max-inline-size: px2rem(1000); + inline-size: 100%; display: flex; flex-direction: column; - .upload-button { - width: px2rem(100); - } - - .btn-secondary { - margin-left: var(--sp-m); + .dismiss-button { + margin-inline-start: var(--sp-xs); } } .dashboard-fonts-hero { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); padding: var(--sp-xxxl) 0; - margin-top: px2rem(80); + margin-block-start: px2rem(80); display: flex; justify-content: space-between; - .btn-primary { - height: $sz-40; - width: 100%; + .add-font-button { + --button-height: #{$sz-40}; + + inline-size: 100%; + flex-shrink: 0; + justify-content: center; } .desc { @@ -277,23 +254,18 @@ flex-direction: column; gap: var(--sp-xxl); color: var(--color-background-secondary); - width: $sz-500; + inline-size: $sz-500; - h2 { + .hero-title { color: var(--color-foreground-primary); - font-weight: 400; } - p { - @include t.use-typography("body-large"); + .hero-text { + @include use-typography("body-large"); color: var(--color-foreground-secondary); } } - - .btn-primary { - flex-shrink: 0; - } } .fonts-placeholder { @@ -302,21 +274,18 @@ border: $b-1 solid var(--color-background-quaternary); display: flex; flex-direction: column; - height: $sz-160; + block-size: $sz-160; justify-content: center; - margin-top: var(--sp-l); - max-width: px2rem(1000); - width: 100%; + margin-block-start: var(--sp-l); + max-inline-size: px2rem(1000); + inline-size: 100%; - .icon svg { - stroke: var(--color-foreground-secondary); - fill: none; - width: $sz-32; - height: $sz-32; + .placeholder-icon { + color: var(--color-foreground-secondary); } .label { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index 347364ff67..048c0d7e73 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -29,8 +29,6 @@ [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] - [app.main.ui.ds.product.loader :refer [loader*]] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.jobs.progress :as jp] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -52,6 +50,7 @@ (let [entries (->> entries (mapv (fn [file] {:name (.-name file) + :size (.-size file) :uri (wapi/create-uri file)})) (not-empty))] (when entries @@ -171,7 +170,7 @@ pending))))) (defn- analyze-entries - [state entries] + [state entries on-analyze-end] (->> (imp/analyze entries) (rx/mapcat #(rx/delay emit-delay (rx/of %))) (rx/filter some?) @@ -180,7 +179,11 @@ (when (some? (:error message)) (st/emit! (ev/event {::ev/name "import-files-error" :error (:error message)}))) - (swap! state update-with-analyze-result message))))) + (swap! state update-with-analyze-result message)) + (constantly nil) + ;; the wizard cannot offer its import step until it knows every + ;; file of every container + on-analyze-end))) (defn- import-files [state library-resolution-data* project-id entries on-job] @@ -202,30 +205,31 @@ (mf/defc import-entry* {::mf/memo true ::mf/private true} - [{:keys [entries entry edition can-be-deleted is-progress on-edit on-change on-delete]}] + [{:keys [entries entry edition on-edit on-change on-delete]}] (let [status (:status entry) ;; FIXME: rename to format format (:type entry) - loading? (or (= :analyze status) - (= :import-queued status) - (= :import-progress status) - (and is-progress (= :import-ready status))) analyze-error? (= :analyze-error status) - import-success? (= :import-success status) import-error? (= :import-error status) import-ready? (= :import-ready status) - level (cond - import-success? :success - import-ready? :success - import-error? :error - analyze-error? :error - loading? nil - :else :default) + progress (:progress entry) + upload? (= :upload (:stage progress)) + + ;; the container the file came in is fully sent, but its job does + ;; not exist yet: the wizard is still uploading the other files + ready? (and (= :import-progress status) + upload? + (imp/upload-done? entry)) + + ;; a file can be dropped while the wizard has not started yet: once + ;; the import runs, its entries are committed + can-be-deleted? (and import-ready? + (> (count entries) 1)) is-shared? (:shared entry) - progress (:progress entry) + subtitle (imp/format-file-size (:size entry)) file-id (:file-id entry) editing? (and (some? file-id) (= edition file-id)) @@ -265,44 +269,42 @@ [:div {:class (stl/css-case :file-entry true - :loading loading? - :success import-success? + :ready ready? + :success (= :import-success status) :error (or import-error? analyze-error?) :editable (and import-ready? (not editing?)))} - [:div {:class (stl/css :file-name)} - (when loading? [:> loader* {:width 26 :title (tr "labels.loading")}]) + [:div {:class (stl/css :file-main)} + [:div {:class (stl/css :file-icon) + :aria-hidden "true"} + [:> icon* {:icon-id i/folder :size "m"}]] - (if editing? - [:div {:class (stl/css :file-name-edit)} - [:input {:type "text" - :auto-focus true - :class (stl/css :file-name-input) - :aria-label (tr "dashboard.import.file-name-label") - :default-value (:name entry) - :on-key-press on-edit-key-press - :on-blur on-edit-blur}]] + [:div {:class (stl/css :file-meta)} + (if editing? + [:div {:class (stl/css :file-name-edit)} + [:input {:type "text" + :auto-focus true + :class (stl/css :file-name-input) + :aria-label (tr "dashboard.import.file-name-label") + :default-value (:name entry) + :on-key-press on-edit-key-press + :on-blur on-edit-blur}]] - [:div {:class (stl/css :file-name-label)} - (if loading? - [:> text* {:class (stl/css :file-name-label) - :as "span" - :typography t/body-medium} - (:name entry) - (when ^boolean is-shared? - [:> icon* {:icon-id i/library :class (stl/css :file-label-icon)}])] - [:> context-notification* - {:level level - :appearance :ghost - :class (stl/css :file-name-notification)} - [:> text* {:class (stl/css :file-name-label) + [:* + [:> text* {:class (stl/css :file-name-label) + :as "span" + :typography t/body-medium} + [:span {:class (stl/css :file-name-text)} + (:name entry)] + (when ^boolean is-shared? + [:> icon* {:icon-id i/library :class (stl/css :file-label-icon)}])] + (when (some? subtitle) + [:> text* {:class (stl/css :file-subtitle) :as "span" - :typography t/body-medium} - (:name entry) - (when ^boolean is-shared? - [:> icon* {:icon-id i/library :class (stl/css :file-label-icon)}])]])]) + :typography t/body-small} + subtitle])])] - (when ^boolean (or editable? can-be-deleted) + (when ^boolean (or editable? can-be-deleted?) [:div {:class (stl/css :edit-entry-buttons)} (when ^boolean editable? [:> icon-button* {:on-click on-edit' @@ -310,38 +312,36 @@ :icon-size "s" :aria-label (tr "labels.edit") :icon i/curve}]) - (when ^boolean can-be-deleted + (when ^boolean can-be-deleted? [:> icon-button* {:on-click on-delete' :variant "ghost" :icon-size "s" :aria-label (tr "labels.delete") - :icon i/delete}])])] - (cond - analyze-error? - [:> text* {:class (stl/css :error-message) - :as "span" - :typography t/body-small} - ;; the message is user-facing text already (a hint or a translated - ;; label), never a key - (if (some? (:error entry)) - (:error entry) - (tr "dashboard.import.analyze-error"))] + :icon i/delete}])]) - import-error? - [:> text* {:class (stl/css :error-message) - :as "span" - :typography t/body-small} - (if (some? (:error entry)) - (:error entry) - (tr "labels.error"))] + ;; a row carries its name and its size, and only the state that is not + ;; progress: the milestone in course and the files already imported are + ;; the business of the header, which writes the milestone once for + ;; every file of a container + (when ^boolean ready? + [:div {:class (stl/css :file-status)} + [:> icon* {:icon-id i/status-tick :size "s"}] + [:> text* {:as "span" + :typography t/body-medium} + (tr "dashboard.import.status.ready")]])] - (and (= :import-queued status) (not import-success?)) - [:div {:class (stl/css :progress-message)} - (tr "jobs.queued")] - - (and (not import-success?) (some? progress)) - [:div {:class (stl/css :progress-message)} - (jp/milestone-text progress :file? true)]) + (when ^boolean (or analyze-error? import-error?) + [:div {:class (stl/css :file-error)} + [:> text* {:class (stl/css :error-message) + :as "span" + :typography t/body-small} + ;; the message is user-facing text already (a hint or a translated + ;; label), never a key + (if (some? (:error entry)) + (:error entry) + (if analyze-error? + (tr "dashboard.import.analyze-error") + (tr "labels.error")))]]) ;; This is legacy code, will be removed when legacy-zip format is removed [:div {:class (stl/css :linked-libraries)} @@ -356,7 +356,7 @@ [:span {:class (stl/css-case :linked-library-tag true :error error?)} - deprecated-icon/detach]])))]])) + [:> icon* {:icon-id i/detach}]]])))]])) (defn initialize-state [entries] @@ -561,13 +561,126 @@ ;; ── Stage components ──────────────────────────────────────────────── +(mf/defc import-steps* + {::mf/private true} + [{:keys [step]}] + (let [order [:check :upload :import] + label {:check (tr "dashboard.import.steps.check") + :upload (tr "dashboard.import.steps.upload") + :import (tr "dashboard.import.steps.import")} + current (count (take-while #(not= % step) order))] + [:ol {:class (stl/css :import-steps) + :aria-label (tr "dashboard.import.steps.label")} + (for [[idx id] (d/enumerate order)] + (let [done? (< idx current) + active? (= idx current)] + [:li {:key (name id) + :class (stl/css-case :import-step true + :done done? + :active active?) + :aria-current (when active? "step")} + [:span {:class (stl/css :step-marker) + :aria-hidden "true"} + (if done? + [:> icon* {:icon-id i/status-tick :size "s"}] + (str (inc idx)))] + [:> text* {:as "span" + :typography t/body-medium + :class (stl/css :step-label)} + (get label id)] + (when (< idx 2) + [:span {:class (stl/css :step-line) :aria-hidden "true"}])]))])) + +(defn- importing-title + "The title of the phase that rebuilds the files, with the files the + wizard is working on and the ones already done." + [total done] + (dm/str (tr "dashboard.import.importing.title" (i18n/c total)) + " · " + (tr "dashboard.import.importing.done" (i18n/c done)))) + +(mf/defc import-phase-header* + {::mf/private true} + [{:keys [status step entries template]}] + (let [total (+ (count entries) (if (some? template) 1 0)) + ;; the file the job is working on is reported by the job itself, so + ;; the counter moves while a container rebuilds its files one by one + done (imp/imported-count entries) + + phase (cond + (= :analyze status) :check + (= :import-ready status) :ready + (not= :import-progress status) nil + (= :upload step) :upload + (imp/waiting-slot? entries) :queued + :else :importing) + + ;; the bar measures the whole import, never a single file: the + ;; upload of every container put together while they are sent, and + ;; the animated one from there on, because the stages of a job are + ;; not comparable one with another + [title desc pct] + (case phase + :check [(tr "dashboard.import.check.title") + (tr "dashboard.import.check.message") + nil] + :ready [(tr "dashboard.import.ready.title") + (tr "dashboard.import.ready.message") + nil] + :upload [(tr "dashboard.import.upload.title") + (tr "dashboard.import.upload.message") + (imp/upload-pct entries)] + :queued [(tr "dashboard.import.queued.title") + (tr "dashboard.import.queued.message") + nil] + :importing [(importing-title total done) + (tr "dashboard.import.importing.message") + nil] + [nil nil nil]) + + ;; the milestone in course takes the place of the message: the files + ;; of a container share it, so writing it here keeps it once + desc + (or (jp/current-milestone-text entries) desc) + + ;; the bar paints what is running: indeterminate while the wizard + ;; works —checking, waiting for a slot, rebuilding— and determinate + ;; while it uploads, which is the only percentage it really knows + bar? (contains? #{:check :upload :queued :importing} phase)] + + (when (some? phase) + [:div {:class (stl/css :phase-header)} + [:> heading* {:level 3 + :typography t/headline-medium + :class (stl/css :phase-title)} + title] + [:> text* {:as "p" + :typography t/body-medium + :class (stl/css :phase-message)} + desc] + (when bar? + [:div {:class (stl/css-case :progress-bar true + :determinate (some? pct)) + :role "progressbar" + :aria-valuemin 0 + :aria-valuemax 100 + :aria-valuenow pct} + [:div {:class (stl/css :progress-fill) + :style (when (some? pct) + #js {:width (str pct "%")})}]])]))) + (mf/defc import-files-stage* {::mf/private true} - [{:keys [entries template status errors? import-success-total auto-linked-count + [{:keys [entries template status step errors? import-success-total auto-linked-count edition on-edit on-change on-delete on-cancel on-cancel-import on-continue on-accept pending-analysis?]}] [:* + [:> import-steps* {:step step}] [:div {:class (stl/css :modal-content)} + [:> import-phase-header* {:status status + :step step + :entries entries + :template template}] (when (and (= :analyze status) errors?) [:> context-notification* {:level :warning @@ -590,7 +703,8 @@ :class (stl/css :context-notification-error)} (tr "dashboard.import.import-error.disclaimer")]) - (if (or (= :import-error status) (and (= :analyze status) errors?)) + (cond + (or (= :import-error status) (and (= :analyze status) errors?)) [:div {:class (stl/css :import-error-disclaimer)} [:div (tr "dashboard.import.import-error.message1")] [:ul {:class (stl/css :import-error-list)} @@ -614,20 +728,24 @@ (tr "dashboard.import.import-error.unknown-error"))])]))] [:div (tr "dashboard.import.import-error.message2")]] + (imp/awaiting-analysis? status errors?) + ;; while checking, the phase header carries the message: no rows, + ;; no spinners + nil + + :else (for [entry entries] [:> import-entry* {:edition edition :key (dm/str (:uri entry) "/" (:file-id entry)) :entry entry :entries entries - :is-progress (= :import-progress status) :on-edit on-edit :on-change on-change - :on-delete on-delete - :can-be-deleted (> (count entries) 1)}])) + :on-delete on-delete}])) - (when (some? template) - [:> import-entry* {:entry (assoc template :status status) - :can-be-deleted false}])] + (when (and (some? template) + (not (imp/awaiting-analysis? status errors?))) + [:> import-entry* {:entry (assoc template :status status)}])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} @@ -736,6 +854,13 @@ status* (mf/use-state :analyze) status (deref status*) + ;; the analysis of the dropped files reached its end: only then the + ;; wizard knows every file it is going to import + analysis-ended* + (mf/use-state false) + analysis-ended? + (deref analysis-ended*) + ;; Jobs created while importing, to cancel them on demand, and ;; the subscription to their messages, to stop listening. jobs* (mf/use-state #{}) @@ -971,13 +1096,12 @@ (every? #(contains? visited (:id %)) unresolved-files)) (reset! status* :library-summary))) - (mf/with-effect [entries resolution] + (mf/with-effect [entries resolution analysis-ended?] (cond (some? template) (reset! status* :import-ready) - (and (seq entries) - (every? #(= :import-ready (:status %)) entries)) + (imp/ready-to-import? entries analysis-ended?) (reset! status* :import-ready) (and (seq entries) @@ -994,7 +1118,7 @@ ;; Run analyze operation on component mount (mf/with-effect [] - (let [sub (analyze-entries state* entries)] + (let [sub (analyze-entries state* entries #(reset! analysis-ended* true))] (partial rx/dispose! sub))) [:div {:class (stl/css :modal-overlay)} @@ -1003,7 +1127,7 @@ [:> heading* {:level 2 :typography t/headline-large :class (stl/css :modal-title)} - (tr "dashboard.import")] + (tr "dashboard.import.title")] [:> icon-button* {:variant "ghost" :aria-label (tr "labels.close") :on-click on-cancel @@ -1016,6 +1140,7 @@ {:entries entries :template template :status status + :step (imp/import-step status entries) :errors? errors? :import-success-total import-success-total :auto-linked-count auto-linked-count @@ -1030,23 +1155,27 @@ :pending-analysis? pending-analysis?}] :library-resolution - [:> import-library-resolution-stage* - {:current-unresolved-file current-unresolved-file - :selection selection - :on-select manage-on-select - :on-disconnect manage-on-disconnect - :visited visited - :last-file? last-unresolved-file? - :on-wizard-prev on-wizard-prev - :on-wizard-next on-wizard-next - :on-wizard-skip on-wizard-skip}] + [:* + [:> import-steps* {:step :import}] + [:> import-library-resolution-stage* + {:current-unresolved-file current-unresolved-file + :selection selection + :on-select manage-on-select + :on-disconnect manage-on-disconnect + :visited visited + :last-file? last-unresolved-file? + :on-wizard-prev on-wizard-prev + :on-wizard-next on-wizard-next + :on-wizard-skip on-wizard-skip}]] :library-summary - [:> import-library-summary-stage* - {:resolution resolution - :selection selection - :visited visited - :on-summary-back on-summary-back - :on-confirm-library-links on-confirm-library-links}] + [:* + [:> import-steps* {:step :import}] + [:> import-library-summary-stage* + {:resolution resolution + :selection selection + :visited visited + :on-summary-back on-summary-back + :on-confirm-library-links on-confirm-library-links}]] nil)]])) \ No newline at end of file diff --git a/frontend/src/app/main/ui/dashboard/import.scss b/frontend/src/app/main/ui/dashboard/import.scss index eafdc1ecc6..b67a2fe80a 100644 --- a/frontend/src/app/main/ui/dashboard/import.scss +++ b/frontend/src/app/main/ui/dashboard/import.scss @@ -34,7 +34,7 @@ background-color: var(--color-background-primary); border: $b-2 solid var(--color-background-quaternary); min-block-size: $sz-192; - inline-size: px2rem(640); + inline-size: px2rem(720); max-block-size: px2rem(800); } @@ -56,6 +56,104 @@ .modal-title { color: var(--color-foreground-primary); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +// ################################ +// WIZARD STEPS +// ################################ + +.import-steps { + display: flex; + align-items: center; + gap: 0; + list-style: none; + margin: 0; + padding: 0; +} + +.import-step { + --step-fg-color: var(--color-foreground-secondary); + --step-marker-border: var(--color-foreground-secondary); + + display: flex; + align-items: center; + gap: var(--sp-s); + flex-shrink: 0; + + &:not(:last-child) { + flex: 1 1 auto; + } + + &.active { + --step-fg-color: var(--color-foreground-primary); + --step-marker-border: var(--color-accent-primary); + } + + &.done { + --step-fg-color: var(--color-foreground-secondary); + --step-marker-border: var(--color-accent-primary); + --step-marker-bg-color: var(--color-accent-primary); + --step-marker-fg-color: var(--color-background-secondary); + } +} + +.step-marker { + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; + flex-shrink: 0; + border-radius: 50%; + border: $b-1 solid var(--step-marker-border); + background-color: var(--step-marker-bg-color, transparent); + color: var(--step-marker-fg-color, var(--step-marker-border)); + font-size: px2rem(12); + font-weight: 500; + + svg { + block-size: $sz-16; + inline-size: $sz-16; + } +} + +.step-label { + color: var(--step-fg-color); + white-space: nowrap; +} + +.step-line { + flex: 1 1 auto; + block-size: $b-1; + min-inline-size: $sz-32; + margin-inline: var(--sp-m); + background-color: var(--color-background-quaternary); +} + +.import-step.active .step-line, +.import-step.done .step-line { + background-color: var(--color-background-quaternary); +} + +// ################################ +// PHASE HEADER +// ################################ + +.phase-header { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.phase-title { + color: var(--color-foreground-primary); +} + +.phase-message { + color: var(--color-foreground-secondary); + margin: 0; } .modal-close-btn { @@ -86,8 +184,9 @@ flex-direction: column; gap: var(--sp-m); + &.ready, &.success { - --file-entry-fg-color: var(--color-accent-sucess); + --file-entry-fg-color: var(--color-accent-success); } &.error { @@ -99,16 +198,10 @@ } } -.error-message, -.progress-message { - display: flex; - align-items: center; - min-block-size: $sz-32; - color: var(--file-entry-fg-color); -} - .error-message { + display: flex; align-items: flex-start; + color: var(--file-entry-fg-color); white-space: pre-wrap; overflow-wrap: anywhere; } @@ -153,6 +246,86 @@ color: var(--file-entry-fg-color); } +.file-main { + display: flex; + align-items: center; + gap: var(--sp-m); +} + +.file-icon { + display: flex; + justify-content: center; + align-items: center; + block-size: px2rem(40); + inline-size: px2rem(40); + flex-shrink: 0; + border-radius: $br-8; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-secondary); +} + +.file-meta { + display: flex; + flex-direction: column; + gap: var(--sp-xxs); + flex: 1 1 auto; + min-inline-size: 0; +} + +.file-subtitle { + color: var(--color-foreground-secondary); +} + +.file-status { + display: flex; + align-items: center; + gap: var(--sp-s); + flex-shrink: 0; + color: var(--file-entry-fg-color); + white-space: nowrap; +} + +.file-error { + padding-inline-start: calc(px2rem(40) + var(--sp-m)); +} + +.progress-bar { + block-size: px2rem(4); + border-radius: $br-8; + background-color: var(--color-background-tertiary); + overflow: hidden; +} + +.progress-fill { + block-size: 100%; + border-radius: $br-8; + background-color: var(--color-accent-primary); + + // the client cannot know a global percentage of the import work, so + // the fill slides through the bar while the jobs run; the upload is + // the exception, its chunk counters are real and the inline width + // style drives the fill then. The rule is written as physical `width` + // on purpose: the determinate fill gets its width from an inline + // style, and a logical `inline-size` here would map after the cascade + // and win over it. + width: 40%; + animation: progress-slide 1.2s ease-in-out infinite; + + .determinate & { + animation: none; + } +} + +@keyframes progress-slide { + from { + transform: translateX(-100%); + } + + to { + transform: translateX(250%); + } +} + .edit-entry-buttons { display: flex; align-items: center; @@ -175,7 +348,15 @@ display: flex; align-items: center; gap: var(--sp-m); - flex-grow: 1; + min-inline-size: 0; + color: var(--color-foreground-primary); +} + +// the status sits at the end of the same line: a long name gives way to it +.file-name-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .file-label-icon { @@ -214,10 +395,6 @@ --context-notification-bg-color: var(--color-background-primary); } -.file-name-notification { - flex-grow: 1; -} - .import-error-disclaimer { color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/dashboard/inline_edition.cljs b/frontend/src/app/main/ui/dashboard/inline_edition.cljs index 18f5930c60..c3686d3147 100644 --- a/frontend/src/app/main/ui/dashboard/inline_edition.cljs +++ b/frontend/src/app/main/ui/dashboard/inline_edition.cljs @@ -7,7 +7,6 @@ (ns app.main.ui.dashboard.inline-edition (:require-macros [app.main.style :as stl]) (:require - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.keyboard :as kbd] [rumext.v2 :as mf])) @@ -68,7 +67,5 @@ :on-change on-input :on-key-down on-keyup :on-blur on-blur - :max-length max-length}] - [:span {:class (stl/css :close) - :on-click on-cancel} deprecated-icon/close]])) + :max-length max-length}]])) diff --git a/frontend/src/app/main/ui/dashboard/inline_edition.scss b/frontend/src/app/main/ui/dashboard/inline_edition.scss index 0414167696..1310bd2925 100644 --- a/frontend/src/app/main/ui/dashboard/inline_edition.scss +++ b/frontend/src/app/main/ui/dashboard/inline_edition.scss @@ -4,50 +4,31 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; .edit-wrapper { - border-radius: deprecated.$br-4; + border-radius: $br-4; display: flex; - padding-right: deprecated.$s-24; + padding-inline-end: var(--sp-xxl); position: relative; - margin-right: deprecated.$s-24; + margin-inline-end: var(--sp-xxl); } -input.element-title { - background-color: var(--input-background-color-active); - border-radius: deprecated.$br-8; +.element-title { + background-color: var(--color-background-primary); + border-radius: $br-8; color: var(--color-foreground-primary); - font-size: deprecated.$fs-16; - height: deprecated.$s-32; + font-size: px2rem(16); + block-size: $sz-32; margin: 0; border: none; - padding: deprecated.$s-6; - width: 100%; + padding: px2rem(6); + inline-size: 100%; &:focus-visible { - border: deprecated.$s-1 solid var(--color-accent-primary); + border: $b-1 solid var(--color-accent-primary); outline: none; } } - -.close { - cursor: pointer; - position: absolute; - top: deprecated.$s-1; - right: calc(-1 * deprecated.$s-8); - - svg { - fill: var(--color-foreground-secondary); - height: deprecated.$s-16; - transform: rotate(45deg) translateY(7px); - width: deprecated.$s-16; - margin: 0; - } - - &:hover { - svg { - fill: var(--element-foreground-warning); - } - } -} diff --git a/frontend/src/app/main/ui/dashboard/organization_team_switch.scss b/frontend/src/app/main/ui/dashboard/organization_team_switch.scss index 3fee83b7a4..bba4da94e7 100644 --- a/frontend/src/app/main/ui/dashboard/organization_team_switch.scss +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch.scss @@ -80,7 +80,6 @@ width: $sz-16; color: transparent; fill: none; - stroke-width: 1px; stroke: var(--icon-foreground); } @@ -127,7 +126,6 @@ .chevron-icon svg { color: transparent; fill: none; - stroke-width: 1px; stroke: var(--icon-foreground); } @@ -416,7 +414,6 @@ width: $sz-16; color: transparent; fill: none; - stroke-width: 1px; stroke: var(--sidebar-action-icon-color); } diff --git a/frontend/src/app/main/ui/dashboard/placeholder.cljs b/frontend/src/app/main/ui/dashboard/placeholder.cljs index cfd7dad317..a3b06c2d2d 100644 --- a/frontend/src/app/main/ui/dashboard/placeholder.cljs +++ b/frontend/src/app/main/ui/dashboard/placeholder.cljs @@ -11,10 +11,10 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.dashboard.import :as udi] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]] [app.main.ui.ds.product.loader :refer [loader*]] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [okulary.core :as l] @@ -133,7 +133,7 @@ :on-mouse-leave on-mouse-leave} (if show-text? (tr "dashboard.empty-project.create") - deprecated-icon/add)]])))) + [:> icon* {:icon-id i/add :size "xl"}])]])))) (mf/defc loading-placeholder* [] diff --git a/frontend/src/app/main/ui/dashboard/search.cljs b/frontend/src/app/main/ui/dashboard/search.cljs index bce8ccc0d6..11d613f907 100644 --- a/frontend/src/app/main/ui/dashboard/search.cljs +++ b/frontend/src/app/main/ui/dashboard/search.cljs @@ -12,8 +12,8 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.dashboard.grid :refer [grid*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [okulary.core :as l] @@ -62,17 +62,20 @@ (cond (empty? search-term) [:div {:class (stl/css :grid-empty-placeholder :search)} - [:div {:class (stl/css :icon)} deprecated-icon/search] + [:div {:class (stl/css :icon)} + [:> icon* {:icon-id i/search :size "xl"}]] [:div {:class (stl/css :text)} (tr "dashboard.type-something")]] (nil? result) [:div {:class (stl/css :grid-empty-placeholder :search)} - [:div {:class (stl/css :icon)} deprecated-icon/search] + [:div {:class (stl/css :icon)} + [:> icon* {:icon-id i/search :size "xl"}]] [:div {:class (stl/css :text)} (tr "dashboard.searching-for" search-term)]] (empty? result) [:div {:class (stl/css :grid-empty-placeholder :search)} - [:div {:class (stl/css :icon)} deprecated-icon/search] + [:div {:class (stl/css :icon)} + [:> icon* {:icon-id i/search :size "xl"}]] [:div {:class (stl/css :text)} (tr "dashboard.no-matches-for" search-term)]] :else diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 424f56427b..21a5c112bf 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -147,7 +147,6 @@ // side keeps the drawing and the text where they were. .pin-icon { flex-shrink: 0; - stroke-width: 1.33px; color: var(--color-foreground-secondary); margin: 0 px2rem(10); } @@ -213,7 +212,6 @@ .search-icon { flex-shrink: 0; - stroke-width: 1px; } .clear-search-btn:hover { @@ -374,6 +372,5 @@ } .exit-icon { - stroke-width: 1px; color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/dashboard/subscription.scss b/frontend/src/app/main/ui/dashboard/subscription.scss index 6cfe4c2e9a..e90a800019 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.scss +++ b/frontend/src/app/main/ui/dashboard/subscription.scss @@ -167,7 +167,6 @@ .subscription-icon { stroke: var(--color-foreground-secondary); - stroke-width: 2.25px; } .menu-item { diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index b51bd9d071..2720ab6d58 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -78,7 +78,6 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - stroke-width: $b-1; margin: 0 auto; color: var(--color-foreground-secondary); } @@ -125,7 +124,6 @@ } .image-icon { - stroke-width: $b-1; color: var(--color-foreground-primary); } @@ -571,7 +569,6 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - stroke-width: $b-1; color: var(--color-accent-success); } @@ -581,7 +578,6 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - stroke-width: $b-1; color: var(--color-accent-warning); } @@ -641,7 +637,6 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - stroke-width: $b-1; color: var(--color-foreground-secondary); } @@ -976,7 +971,6 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - stroke-width: $b-1; color: var(--stroke-color); } diff --git a/frontend/src/app/main/ui/dashboard/templates.cljs b/frontend/src/app/main/ui/dashboard/templates.cljs index cecb4db1aa..55e0176651 100644 --- a/frontend/src/app/main/ui/dashboard/templates.cljs +++ b/frontend/src/app/main/ui/dashboard/templates.cljs @@ -16,7 +16,7 @@ [app.main.data.modal :as modal] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.dom :as dom] [app.util.dom.normalize-wheel :as nw] [app.util.i18n :refer [tr]] @@ -25,12 +25,6 @@ [okulary.core :as l] [rumext.v2 :as mf])) -(def ^:private arrow-icon - (deprecated-icon/icon-xref :arrow (stl/css :arrow-icon))) - -(def ^:private download-icon - (deprecated-icon/icon-xref :add (stl/css :download-icon))) - (def builtin-templates (l/derived :builtin-templates st/state)) @@ -77,6 +71,7 @@ [:div {:class (stl/css :title)} [:button {:tab-index "0" :class (stl/css :title-btn) + :aria-expanded (not is-collapsed) :on-click on-click :on-key-down on-key-down} [:span {:class (stl/css :title-text)} @@ -85,11 +80,13 @@ [:span {:class (stl/css :title-icon-container)} [:span {:class (stl/css :title-icon-text)} (tr "labels.show")] [:span {:class (stl/css :title-icon :title-icon-collapsed)} - arrow-icon]] + [:> icon* {:icon-id i/arrow + :class (stl/css :arrow-icon)}]]] [:span {:class (stl/css :title-icon-container)} [:span {:class (stl/css :title-icon-text)} (tr "labels.hide")] [:span {:class (stl/css :title-icon)} - arrow-icon]])]])) + [:> icon* {:icon-id i/arrow + :class (stl/css :arrow-icon)}]]])]])) (mf/defc card-item* [{:keys [item index is-visible collapsed on-import]}] @@ -131,7 +128,8 @@ (if @hover? (tr "dashboard.template.add-to-project") (:name item))] - download-icon]]])) + [:> icon* {:icon-id i/add + :class (stl/css :download-icon)}]]]])) (mf/defc card-item-link* [{:keys [total is-visible collapsed section]}] @@ -286,8 +284,10 @@ [:button {:class (stl/css :move-button :move-left) :tab-index (if ^boolean collapsed "-1" "0") :on-click on-move-left - :on-key-down on-move-left} - arrow-icon]) + :on-key-down on-move-left + :aria-label (tr "labels.previous")} + [:> icon* {:icon-id i/arrow + :class (stl/css :arrow-icon)}]]) (when (:right @can-move) [:button {:class (stl/css :move-button :move-right) @@ -295,4 +295,5 @@ :on-click on-move-right :on-key-down on-move-right :aria-label (tr "labels.next")} - arrow-icon])])) + [:> icon* {:icon-id i/arrow + :class (stl/css :arrow-icon)}]])])) diff --git a/frontend/src/app/main/ui/dashboard/templates.scss b/frontend/src/app/main/ui/dashboard/templates.scss index eabb3c8976..b9ae6c92f2 100644 --- a/frontend/src/app/main/ui/dashboard/templates.scss +++ b/frontend/src/app/main/ui/dashboard/templates.scss @@ -105,10 +105,7 @@ align-items: center; height: $sz-16; width: $sz-16; - color: transparent; - fill: none; - stroke-width: $b-1; - stroke: var(--color-foreground-secondary); + color: var(--color-foreground-secondary); } .move-button { @@ -131,7 +128,7 @@ background-color: var(--color-accent-primary); .arrow-icon { - stroke: var(--color-background-tertiary); + color: var(--color-background-tertiary); } } } @@ -196,7 +193,7 @@ border-color: var(--color-accent-primary); .download-icon { - stroke: var(--color-accent-primary); + color: var(--color-accent-primary); } .card-text { @@ -243,10 +240,7 @@ align-items: center; height: $sz-16; width: $sz-16; - color: transparent; - fill: none; - stroke-width: $b-1; - stroke: var(--icon-foreground); + color: var(--color-foreground-secondary); } .template-link { diff --git a/frontend/src/app/main/ui/ds/buttons/button.cljs b/frontend/src/app/main/ui/ds/buttons/button.cljs index c7ea4245ab..14985a6896 100644 --- a/frontend/src/app/main/ui/ds/buttons/button.cljs +++ b/frontend/src/app/main/ui/ds/buttons/button.cljs @@ -36,7 +36,8 @@ :button-destructive (= variant "destructive")) props (mf/spread-props props {:class [class internal-class] :href to - :type (d/nilv type "button") + ;; On an anchor, `type` is a MIME type hint. + :type (when-not to (d/nilv type "button")) :ref (fn [node] (when on-ref (on-ref node)))})] diff --git a/frontend/src/app/main/ui/ds/buttons/button.stories.jsx b/frontend/src/app/main/ui/ds/buttons/button.stories.jsx index f030431e12..fcdd86108d 100644 --- a/frontend/src/app/main/ui/ds/buttons/button.stories.jsx +++ b/frontend/src/app/main/ui/ds/buttons/button.stories.jsx @@ -6,6 +6,7 @@ import * as React from "react"; import Components from "@target/components"; +import { within, expect } from "storybook/test"; const { Button } = Components; const { icons } = Components.meta; @@ -67,3 +68,34 @@ export const Destructive = { variant: "destructive", }, }; + +// With `to`, the button renders an anchor: it keeps the button look but it is +// a real link (href, open in a new tab, announced as a link). +export const AsLink = { + args: { + variant: "secondary", + to: "https://penpot.app", + children: "Go to Penpot", + }, +}; + +export const TestAsLinkRendersAnchor = { + args: AsLink.args, + play: async ({ canvasElement }) => { + const link = within(canvasElement).getByRole("link", { + name: "Go to Penpot", + }); + await expect(link).toHaveAttribute("href", "https://penpot.app"); + // `type` on an anchor is a MIME type hint, not a button type. + await expect(link).not.toHaveAttribute("type"); + }, +}; + +export const TestButtonDefaultsToTypeButton = { + play: async ({ canvasElement }) => { + const button = within(canvasElement).getByRole("button", { + name: "Lorem ipsum", + }); + await expect(button).toHaveAttribute("type", "button"); + }, +}; diff --git a/frontend/src/app/main/ui/ds/buttons/buttons.mdx b/frontend/src/app/main/ui/ds/buttons/buttons.mdx index 5eaf8ca5b5..4bcbbd0156 100644 --- a/frontend/src/app/main/ui/ds/buttons/buttons.mdx +++ b/frontend/src/app/main/ui/ds/buttons/buttons.mdx @@ -71,8 +71,23 @@ Action buttons are a variant of `icon-button*` (`"action"`) but smaller. ### Semantics -In some cases, a button needs to be semantically an 'a' tag, but visually maintain the button style. -In that case, the button accepts a 'to' property with the expected 'href'. It will then be rendered as an anchor element. +When the control takes the user to another page or URL, it must be a link, even if it looks like a button. +Pass the destination in `to`: `button*` then renders an `` with the button style, so it can be +opened in a new tab and assistive technologies announce it as a link. It renders no `type` attribute, +which on an anchor would mean a MIME type. + +```clj +[:> button* {:variant "secondary" + :to (rt/resolve-uri router :auth-login {})} + (tr "profile.recovery.go-to-login")] +``` + +To keep the in-app navigation on a plain click, add an `on-click` that calls `prevent-default` (unless +Ctrl/Cmd is pressed) and emits the navigation event. Other anchor attributes, such as `target`, pass through. + + + +Use a plain `button*` (without `to`) for actions that do not change the URL, such as opening a modal. ### Accessibility diff --git a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss index c961b9e06c..6480e6cd79 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss @@ -29,6 +29,10 @@ z-index: var(--z-index-dropdown); box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); + // Do not inherit the cursor of the control that opens it (e.g. the + // scrub cursor of the numeric input). + cursor: default; + &:focus { outline: none; } diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs index 3e72f5d721..11512ae6c5 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -178,6 +178,9 @@ (def ^:icon-id group "group") (def ^:icon-id gutter-horizontal "gutter-horizontal") (def ^:icon-id gutter-vertical "gutter-vertical") +(def ^:icon-id handlers-equal "handlers-equal") +(def ^:icon-id handlers-independent "handlers-independent") +(def ^:icon-id handlers-mirror "handlers-mirror") (def ^:icon-id help "help") (def ^:icon-id hide "hide") (def ^:icon-id history "history") @@ -252,11 +255,12 @@ (def ^:icon-id search "search") (def ^:icon-id separate-nodes "separate-nodes") (def ^:icon-id settings "settings") +(def ^:icon-id share "share") (def ^:icon-id shown "shown") (def ^:icon-id side-by-side "side-by-side") (def ^:icon-id size-horizontal "size-horizontal") (def ^:icon-id size-vertical "size-vertical") -(def ^:icon-id snap-nodes "snap-nodes") +(def ^:icon-id snap "snap") (def ^:icon-id status-alert "status-alert") (def ^:icon-id status-tick "status-tick") (def ^:icon-id status-update "status-update") diff --git a/frontend/src/app/main/ui/exports/assets.cljs b/frontend/src/app/main/ui/exports/assets.cljs index eb842c1f6b..6f7f2311aa 100644 --- a/frontend/src/app/main/ui/exports/assets.cljs +++ b/frontend/src/app/main/ui/exports/assets.cljs @@ -207,8 +207,8 @@ [] (let [state (mf/deref refs/export) profile (mf/deref refs/profile) - theme (or (:theme profile) theme/default) - is-default-theme? (= theme/default theme) + theme (theme/use-theme profile) + dark-theme? (= "dark" theme) error? (:error state) ;; The exporter is at capacity: worth its own wording, so the user ;; knows retrying later is the thing to do. @@ -245,13 +245,13 @@ error? clr/new-danger (or cancelling? cancelled?) clr/new-warning - healthy? (if is-default-theme? + healthy? (if dark-theme? clr/new-primary clr/new-primary-light) (not healthy?) clr/new-warning) background-clr - (if is-default-theme? + (if dark-theme? clr/background-quaternary clr/background-quaternary-light) diff --git a/frontend/src/app/main/ui/exports/files.cljs b/frontend/src/app/main/ui/exports/files.cljs index 8a527c2209..fba7916046 100644 --- a/frontend/src/app/main/ui/exports/files.cljs +++ b/frontend/src/app/main/ui/exports/files.cljs @@ -11,55 +11,107 @@ [app.common.data.macros :as dm] [app.config :as cf] [app.main.data.exports.files :as fexp] + [app.main.data.imports :as imp] [app.main.data.jobs :as dj] [app.main.data.modal :as modal] [app.main.store :as st] [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.ds.foundations.typography :as t] [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] - [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.jobs.progress :as jp] - [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] + [cuerdas.core :as str] [rumext.v2 :as mf])) +(defn- same-file? + "Whether an id from a message names a row of the dialog. The ids cross + serialization boundaries, so a message can carry the id as a string + while the row keeps the uuid (or the other way round): both sides are + canonicalized instead of assuming one type." + [a b] + (= (str a) (str b))) + +(defn- error-hint + "What a failed row reports: the message of the failure when it says + something useful, or nil. A string error already is the message; a map + carries it under `:hint`; anything else is unwrapped with `ex-data` + first, which itself can answer a string." + [error] + (let [data (cond + (string? error) error + (map? error) error + :else (ex-data error))] + (some-> (if (string? data) data (:hint data)) + (str/trim) + (not-empty)))) + (defn- mark-file-error - [files file-id] - (mapv #(cond-> % - (= file-id (:id %)) - (assoc :export-error true - :loading false)) + "The export of a file failed: a terminal state that replaces any + other, so a row never reports success and failure at once." + [files file-id error] + (mapv (fn [file] + (if (same-file? file-id (:id file)) + (-> file + (assoc :export-error true + :error error + :loading false) + (dissoc :export-success :size :progress :queued)) + file)) files)) (defn- mark-file-cancelled "The job of a file was cancelled elsewhere: a neutral terminal state, - neither success nor error." + neither success nor error, replacing any other." [files file-id] - (mapv #(cond-> % - (= file-id (:id %)) - (assoc :export-cancelled true - :loading false)) + (mapv (fn [file] + (if (same-file? file-id (:id file)) + (-> file + (assoc :export-cancelled true + :loading false) + (dissoc :export-success :size :export-error :error + :progress :queued)) + file)) + files)) + +(defn- mark-all-cancelled + "Every file still loading is over: its job was just cancelled, or it + was never created because the exports run one after another." + [files] + (mapv (fn [file] + (if (:loading file) + (-> file + (assoc :export-cancelled true + :loading false) + (dissoc :progress :queued)) + file)) files)) (defn- mark-file-success - [files file-id] - (mapv #(cond-> % - (= file-id (:id %)) - (assoc :export-success true - :loading false)) + "The export of a file produced its artifact: a terminal state that + replaces any other, so a row never reports success and failure at + once." + [files file-id size] + (mapv (fn [file] + (if (same-file? file-id (:id file)) + (-> file + (assoc :export-success true + :size size + :loading false) + (dissoc :export-error :error :progress :queued)) + file)) files)) (defn- mark-file-progress "The milestone the job of a file is in, to show it while it runs." [files file-id progress] (mapv #(cond-> % - (= file-id (:id %)) + (same-file? file-id (:id %)) (assoc :progress progress :queued false)) files)) @@ -68,7 +120,7 @@ "The job of a file exists but no worker picked it up yet." [files file-id] (mapv #(cond-> % - (= file-id (:id %)) + (same-file? file-id (:id %)) (assoc :queued true)) files)) @@ -83,53 +135,115 @@ (mf/defc export-entry* {::mf/private true} [{:keys [file]}] - (let [level (cond - (:export-success file) :success - (:export-error file) :error - (:export-cancelled file) :warning - :else :info)] + (let [success? (:export-success file) + error? (:export-error file) + cancelled? (:export-cancelled file) + progress (:progress file) + running? (and (:loading file) (some? progress)) + ;; the job of the file exists but no worker picked it up yet: its + ;; queue only ends with a milestone, so until then the row waits + waiting? (and (:loading file) (nil? progress)) + size (imp/format-file-size (:size file)) + ;; the size of the artifact only exists once the job produced it: + ;; until then the subtitle only says what it will be + subtitle (if (some? size) (dm/str size " .penpot") ".penpot")] [:div {:class (stl/css-case :file-entry true - :loading (:loading file) - :success (:export-success file) - :error (:export-error file))} + :success success? + :error error?)} - (if (:loading file) - [:* - [:div {:class (stl/css :file-name)} - [:> loader* {:width 26 - :title (tr "labels.loading")}] - [:> text* {:class (stl/css :file-name-label) - :as "span" - :typography t/body-large} - (:name file)]] + [:div {:class (stl/css :file-main)} + [:div {:class (stl/css :file-icon) + :aria-hidden "true"} + [:> icon* {:icon-id (if error? i/close i/folder) :size "m"}]] - ;; while the file is being produced: waiting for a worker, then - ;; the milestone of the job - (when (or (some? (:progress file)) (:queued file)) - (let [progress (:progress file)] - [:> text* {:class (stl/css :status-message) - :as "span" - :typography (if (some? progress) t/body-large t/body-medium) - :role "status" - :aria-live "polite"} - (if (some? progress) - (jp/milestone-text progress) - (tr "jobs.queued"))]))] + [:div {:class (stl/css :file-meta)} + [:> text* {:class (stl/css :file-name-label) + :as "span" + :typography t/body-medium} + (:name file)] + [:> text* {:class (stl/css :file-subtitle) + :as "span" + :typography t/body-small} + subtitle]] - [:* - [:> context-notification {:level level - :content (:name file)}] + ;; a row carries its name and what its artifact will weigh, and only + ;; the state that is its own: while the file runs, the milestone and + ;; the bar live under it + (cond + success? + [:div {:class (stl/css :file-status)} + [:> icon* {:icon-id i/status-tick :size "s"}] + [:> text* {:as "span" + :typography t/body-medium} + (tr "files-export-modal.status.exported")]] - ;; a cancelled file is a neutral terminal state, but the label is - ;; what tells the user why no artifact came out - (when (:export-cancelled file) - [:> text* {:class (stl/css :status-message) - :as "span" - :typography t/body-medium - :role "status" - :aria-live "polite"} - (tr "jobs.export-cancelled")])])])) + waiting? + [:div {:class (stl/css :file-status)} + [:> icon* {:icon-id i/clock :size "s"}] + [:> text* {:as "span" + :typography t/body-medium} + (tr "files-export-modal.status.waiting")]])] + + ;; the milestone of the job, with an animated bar: the stages of an + ;; export are not comparable one with another, so the client cannot + ;; know a percentage of the file, only that it moves + (when running? + [:div {:class (stl/css :file-progress)} + [:> text* {:class (stl/css :progress-message) + :as "span" + :typography t/body-small + :role "status" + :aria-live "polite"} + (jp/milestone-text progress)] + [:div {:class (stl/css :progress-bar) + :role "progressbar"} + [:div {:class (stl/css :progress-fill)}]]]) + + (when error? + [:div {:class (stl/css :file-error)} + [:> text* {:class (stl/css :error-message) + :as "span" + :typography t/body-small} + ;; the reason is user-facing text already (the hint of the job), + ;; never a key + (tr "files-export-modal.export-error" + (or (error-hint (:error file)) + (tr "labels.error")))]]) + + ;; a cancelled file is a neutral terminal state, but the label is + ;; what tells the user why no artifact came out + (when cancelled? + [:div {:class (stl/css :file-error)} + [:> text* {:class (stl/css :status-message) + :as "span" + :typography t/body-small + :role "status" + :aria-live "polite"} + (tr "jobs.export-cancelled")]])])) + +(mf/defc export-phase-header* + {::mf/private true} + [{:keys [files]}] + (let [total (count files) + done (count (filterv :export-success files)) + ;; every file is its own job, so the header only counts them: the + ;; progress of each one lives in its row, there is no global bar + running? (boolean (some :loading files))] + [:div {:class (stl/css :phase-header)} + [:> heading* {:level 3 + :typography t/headline-medium + :class (stl/css :phase-title)} + (if running? + (tr "files-export-modal.exporting.title" (i18n/c total)) + (tr "files-export-modal.exported.title" (i18n/c done)))] + (when (or running? (pos? done)) + [:> text* {:as "p" + :typography t/body-medium + :class (stl/css :phase-message)} + (if running? + (tr "files-export-modal.exporting.message") + (tr "files-export-modal.exported.message"))])])) (mf/defc export-dialog {::mf/register modal/components @@ -145,6 +259,10 @@ state (deref state*) selected (:selected state) status (:status state) + ;; files still in flight: the dialog status never leaves + ;; :exporting, so loading rows are the signal of a running + ;; export, not the status + in-progress? (boolean (->> state :files (some :loading))) ;; Jobs still running, as file-id -> job-id, to cancel them on ;; demand; a file that reaches its outcome leaves the map, so @@ -164,7 +282,7 @@ (reset! sub* (->> (fexp/export-files :files files :type selected :on-job #(swap! jobs* assoc (:file-id %) (:job-id %))) (rx/subs! - (fn [{:keys [file-id error filename uri progress queued + (fn [{:keys [file-id error filename uri size progress queued cancelled]}] (cond (some? progress) @@ -175,7 +293,7 @@ (some? error) (swap! jobs* dissoc file-id) - (swap! state* update :files mark-file-error file-id) + (swap! state* update :files mark-file-error file-id error) (true? cancelled) (swap! jobs* dissoc file-id) @@ -186,35 +304,52 @@ (some? uri) (do (swap! jobs* dissoc file-id) - (swap! state* update :files mark-file-success file-id) + (swap! state* update :files mark-file-success file-id size) (dom/trigger-download-uri filename "application/penpot" uri))))))))) - on-cancel-export + on-stop-jobs (mf/use-fn (mf/deps jobs sub) - (fn [event] - (dom/prevent-default event) - ;; stop listening first, so no late message repaints the - ;; entries of a dialog that is going away + (fn [] + ;; stop listening first, so no late message repaints the rows (when (some? sub) (rx/dispose! sub)) ;; only the jobs still in flight are cancelled: finished ones ;; already left the map, and failures are ignored because a job may have just finished on its own (run! dj/cancel-job (vals jobs)) (reset! jobs* {}) - (reset! sub* nil) + (reset! sub* nil))) + + on-cancel-export + (mf/use-fn + (mf/deps on-stop-jobs state*) + (fn [event] + (dom/prevent-default event) + (on-stop-jobs) + ;; the dialog stays open with the terminal states: closing it + ;; is what dismisses it, cancelling whatever is still pending + (swap! state* update :files mark-all-cancelled))) + + on-close + (mf/use-fn + (mf/deps on-stop-jobs) + (fn [event] + (dom/prevent-default event) + ;; closing dismisses the dialog, cancelling whatever is still + ;; pending, so an export never outlives its dialog + (on-stop-jobs) (st/emit! (modal/hide)))) on-cancel (mf/use-fn - (mf/deps status on-cancel-export) + (mf/deps status in-progress? on-cancel-export on-close) (fn [event] - (if (= :exporting status) - ;; closing mid-export stops the jobs, like Cancel + ;; the X cancels like Cancel Export while files are still in + ;; flight, and dismisses like Done once everything reached a + ;; terminal state + (if (and (= :exporting status) in-progress?) (on-cancel-export event) - (do - (dom/prevent-default event) - (st/emit! (modal/hide)))))) + (on-close event)))) on-accept (mf/use-fn @@ -243,7 +378,7 @@ [:> heading* {:level 2 :typography t/headline-large :class (stl/css :modal-title)} - (tr "files-download-modal.title")] + (tr "files-export-modal.title")] [:> icon-button* {:variant "ghost" :aria-label (tr "labels.close") :on-click on-cancel @@ -308,7 +443,7 @@ [:div {:class (stl/css :action-buttons)} [:> button* {:variant "secondary" :type "button" - :on-click on-cancel} + :on-click on-close} (tr "labels.cancel")] [:> button* {:variant "primary" @@ -317,21 +452,20 @@ (tr "labels.continue")]]]] (= status :exporting) - (let [in-progress? (->> state :files (some :loading))] - [:* - [:div {:class (stl/css :modal-content)} - (for [file (:files state)] - [:> export-entry* {:file file :key (dm/str (:id file))}])] + [:* + [:div {:class (stl/css :modal-content)} + [:> export-phase-header* {:files (:files state)}] + (for [file (:files state)] + [:> export-entry* {:file file :key (dm/str (:id file))}])] - [:div {:class (stl/css :modal-footer)} - [:div {:class (stl/css :action-buttons)} - (when in-progress? - [:> button* {:variant "secondary" - :type "button" - :on-click on-cancel-export} - (tr "labels.cancel")]) + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + (if in-progress? + [:> button* {:variant "secondary" + :type "button" + :on-click on-cancel-export} + (tr "files-export-modal.cancel-export")] [:> button* {:variant "primary" :type "button" - :disabled in-progress? - :on-click on-cancel} - (tr "labels.close")]]]]))]])) + :on-click on-close} + (tr "files-export-modal.done")])]]])]])) diff --git a/frontend/src/app/main/ui/exports/files.scss b/frontend/src/app/main/ui/exports/files.scss index b2a56e435e..6fa97eae35 100644 --- a/frontend/src/app/main/ui/exports/files.scss +++ b/frontend/src/app/main/ui/exports/files.scss @@ -48,6 +48,12 @@ flex-direction: column; gap: var(--sp-l); margin-block-end: var(--sp-xxl); + + // like the import wizard: the rows scroll and the header and footer + // stay put, instead of the content pushing the footer out of the box + flex: 1; + min-block-size: 0; + overflow: hidden auto; } .modal-content-extended { @@ -153,60 +159,153 @@ margin: 0; } -.file-entry { - --file-entry-color: var(--color-foreground-secondary); +.phase-header { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} - &.loading { - .file-name { - color: var(--color-foreground-secondary); - } +.phase-title { + color: var(--color-foreground-primary); +} + +.phase-message { + color: var(--color-foreground-secondary); + margin: 0; +} + +.file-entry { + --file-entry-fg-color: var(--color-foreground-secondary); + + display: flex; + flex-direction: column; + gap: var(--sp-m); + + &.success { + --file-entry-fg-color: var(--color-accent-success); } &.error { - .file-name { - color: var(--color-foreground-secondary); + --file-entry-fg-color: var(--color-accent-error); - .file-icon svg { - stroke: var(--color-foreground-secondary); - } - } - } - - &.success { - .file-name { - color: var(--color-foreground-secondary); - - .file-icon svg { - stroke: var(--color-foreground-secondary); - } - } + padding: var(--sp-m); + border-radius: $br-8; + background-color: var(--color-background-error); } } -.file-name { +.file-main { display: flex; align-items: center; gap: var(--sp-m); +} - .file-icon { - display: flex; - justify-content: center; - align-items: center; - block-size: px2rem(16); - inline-size: px2rem(16); - color: var(--color-foreground-secondary); - } +.file-icon { + display: flex; + justify-content: center; + align-items: center; + block-size: px2rem(40); + inline-size: px2rem(40); + flex-shrink: 0; + border-radius: $br-8; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-secondary); - .file-name-label { - display: block; - max-inline-size: 99%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + .error & { + background-color: var(--color-accent-error); + color: var(--color-background-error); } } -.icon-status-tick { - fill: none; - stroke: var(--color-accent-primary); +.file-meta { + display: flex; + flex-direction: column; + gap: var(--sp-xxs); + flex: 1 1 auto; + min-inline-size: 0; + + .error & { + color: var(--color-foreground-primary); + } +} + +.file-name-label { + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-foreground-primary); +} + +.file-subtitle { + color: var(--color-foreground-secondary); + + .error & { + color: var(--file-entry-fg-color); + } +} + +.file-status { + display: flex; + align-items: center; + gap: var(--sp-s); + flex-shrink: 0; + color: var(--file-entry-fg-color); + white-space: nowrap; +} + +.file-progress { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.progress-message { + color: var(--color-foreground-secondary); +} + +.progress-bar { + block-size: px2rem(4); + border-radius: $br-8; + background-color: var(--color-background-tertiary); + overflow: hidden; +} + +.progress-fill { + block-size: 100%; + border-radius: $br-8; + background-color: var(--color-accent-primary); + + // the twin of the indeterminate bar of the import wizard: the stages + // of an export are not comparable one with another, so the fill slides + // through the bar while the job of the file runs + width: 40%; + animation: progress-slide 1.2s ease-in-out infinite; +} + +@keyframes progress-slide { + from { + transform: translateX(-100%); + } + + to { + transform: translateX(250%); + } +} + +.file-error { + padding-inline-start: calc(px2rem(40) + var(--sp-m)); + + .error & { + padding-inline-start: 0; + } +} + +.error-message { + display: flex; + align-items: flex-start; + color: var(--file-entry-fg-color); + white-space: pre-wrap; + overflow-wrap: anywhere; } diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 7dec276e5d..4dc9408889 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -13,7 +13,6 @@ ;; Keep the list of icons sorted (def ^:icon icon-verify (icon-xref :icon-verify)) -(def ^:icon loader (icon-xref :loader)) (def ^:icon logo (icon-xref :penpot-logo)) (def ^:icon logo-icon (icon-xref :penpot-logo-icon)) (def ^:icon logo-error-screen (icon-xref :logo-error-screen)) @@ -224,7 +223,6 @@ (def ^:icon size-horizontal (icon-xref :size-horizontal)) (def ^:icon size-vertical (icon-xref :size-vertical)) (def ^:icon snap (icon-xref :snap)) -(def ^:icon snap-nodes (icon-xref :snap-nodes)) (def ^:icon status-alert (icon-xref :status-alert)) (def ^:icon status-tick (icon-xref :status-tick)) (def ^:icon status-update (icon-xref :status-update)) diff --git a/frontend/src/app/main/ui/jobs/progress.cljs b/frontend/src/app/main/ui/jobs/progress.cljs index 83967aa978..c3f1404a66 100644 --- a/frontend/src/app/main/ui/jobs/progress.cljs +++ b/frontend/src/app/main/ui/jobs/progress.cljs @@ -5,8 +5,8 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.jobs.progress - "How a milestone of a job is written in a row: the stage in course and - its counter. + "How the milestone of a job is written: the stage in course and its + counter. The stage is a stable key of the vocabulary of the worker, never a display string, so it is resolved here with a `case` of literal `tr` @@ -42,28 +42,38 @@ (str current "/" total) (str current)))) +(defn upload-pct + "The whole percentage of the file already uploaded. The chunked upload + knows how many chunks the file has and which one is in course, so + this is the only counter the client can trust as a percentage." + [{:keys [counters]}] + (let [{:keys [current total]} (get counters :upload)] + (when (and (some? current) (some? total) (pos? total)) + (js/Math.round (* 100 (/ current total)))))) + (defn- upload-label "The upload of a file counts chunks, not units of work, so it is written as the percentage of the file already sent instead of a counter." - [{:keys [current total]}] - (when (and (some? current) (some? total) (pos? total)) - (str "(" (js/Math.round (* 100 (/ current total))) "%)"))) + [milestone] + (when-some [pct (upload-pct milestone)] + (str "(" pct "%)"))) (defn milestone-text - "The text of a milestone: the stage in course and its counter, and with - `:file?` the file in course behind it, when the milestone carries its - counter. The upload stage is the exception: it counts chunks, so it is - written as a percentage of the file." - [milestone & {:keys [file?]}] + "The text of a milestone: the stage in course and its counter. The + upload of a file counts chunks, not units of work, so it is written as + the percentage of the file already sent instead of a counter." + [milestone] (when-some [stage (:stage milestone)] - (let [counters (:counters milestone) - own (if (= :upload stage) - (upload-label (get counters :upload)) - (counter-label (get counters stage)))] + (let [own (if (= :upload stage) + (upload-label milestone) + (counter-label (get (:counters milestone) stage)))] (str (stage-label stage) (when (some? own) - (str " " own)) - (when-let [files (and file? - (not= :files stage) - (counter-label (get counters :files)))] - (str " · " (stage-label :files) " " files)))))) + (str " " own)))))) + +(defn current-milestone-text + "The text of the milestone in course, for a caller that writes the + progress once for a whole group of files: every file of a container + reports the same milestone, so a row would repeat it." + [entries] + (some (comp milestone-text :progress) entries)) diff --git a/frontend/src/app/main/ui/mcp_authorize.cljs b/frontend/src/app/main/ui/mcp_authorize.cljs new file mode 100644 index 0000000000..5f47fd5df6 --- /dev/null +++ b/frontend/src/app/main/ui/mcp_authorize.cljs @@ -0,0 +1,162 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.ui.mcp-authorize + (:require-macros [app.main.style :as stl]) + (:require + [app.common.uri :as uri] + [app.config :as cf] + [app.main.broadcast :as mbc] + [app.main.data.profile :as profile] + [app.main.router :as rt] + [app.main.store :as st] + [app.main.ui.auth.login-modal :refer [login-register-modal*]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.foundations.typography :as typography] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.main.ui.ds.product.loader :refer [loader*]] + [app.util.http :as http] + [app.util.i18n :refer [tr]] + [app.util.storage :as storage] + [beicon.v2.core :as rx] + [rumext.v2 :as mf])) + +(defn- endpoint + [path] + (str (uri/join cf/public-uri (str "api/mcp/oauth/" path)))) + +(defn- prepare-login! + [] + (let [href (rt/get-current-href)] + ;; Save the return URL even when only SSO buttons are enabled and the + ;; password form (which normally stores it) never mounts. + (binding [storage/*sync* true] + (swap! storage/session assoc :login-redirect href)) + {:callback-url href})) + +(defn- cancel-login! + [] + ;; Do not let a later, unrelated login resume this cancelled attempt. + (when (= (:login-redirect storage/session) (rt/get-current-href)) + (binding [storage/*sync* true] + (swap! storage/session dissoc :login-redirect)))) + +(defn- oauth-request + [method path params] + (->> (http/send! (cond-> {:method method + :uri (endpoint path) + :response-type :json + :omit-default-headers true} + (= method :get) (assoc :query params) + (= method :post) (assoc :headers {"content-type" "application/x-www-form-urlencoded"} + :body (uri/map->query-string params)))) + (rx/map #(update % :body js->clj :keywordize-keys true)))) + +(mf/defc client-logo* + {::mf/private true} + [{:keys [uri]}] + (let [failed (mf/use-state false)] + (when-not @failed + [:img {:class (stl/css :client-logo) + :src uri + :alt "" + :referrer-policy "no-referrer" + :on-error #(reset! failed true)}]))) + +(mf/defc mcp-authorize* + [{:keys [route profile]}] + (let [handle (get-in route [:query-params :request]) + details (mf/use-state nil) + failed? (mf/use-state false) + login-params (mf/use-state nil) + cancelled? (mf/use-state false) + busy? (mf/use-state false) + ;; The ref also blocks a second click before React renders disabled buttons. + submitting (mf/use-memo (fn [] (atom false))) + + decide! + (mf/use-fn + (mf/deps handle @details) + (fn [decision] + (when (compare-and-set! submitting false true) + (reset! busy? true) + (->> (oauth-request :post "decision" + {:request handle :csrf (:csrf @details) :decision decision}) + (rx/subs! + (fn [{:keys [status body]}] + (if (= status 200) + (do + (when (= decision "approve") + (st/emit! (profile/refresh-profile) + (mbc/event :mcp/enable {}))) + (st/emit! (rt/nav-raw :href (:redirect-uri body)))) + (reset! failed? true))) + (fn [_] (reset! failed? true)))))))] + + (mf/with-effect [handle] + (reset! details nil) + (reset! failed? false) + (reset! login-params nil) + (reset! cancelled? false) + (reset! busy? false) + (reset! submitting false) + (let [subscription + (->> (oauth-request :get "request" {:request handle}) + (rx/subs! + (fn [{:keys [status body]}] + (case status + 200 (reset! details body) + 401 (reset! login-params (prepare-login!)) + (reset! failed? true))) + (fn [_] (reset! failed? true))))] + #(rx/dispose! subscription))) + + [:main {:class (stl/css :page)} + (if @login-params + [:> login-register-modal* + {:params @login-params + :on-register #(st/emit! (rt/reload true)) + :on-close (fn [] + (cancel-login!) + (reset! login-params nil) + (reset! cancelled? true))}] + [:section {:class (stl/css :card) :aria-labelledby "mcp-approval-title"} + [:> heading* {:level 1 :typography typography/title-large :id "mcp-approval-title"} + (tr "mcp.oauth.title")] + (cond + @cancelled? + [:> text* {:typography typography/body-large :role "status"} + (tr "mcp.oauth.cancelled")] + + @failed? + [:> text* {:typography typography/body-large :role "alert"} + (tr "mcp.oauth.failed")] + + (nil? @details) + [:> loader* {:title (tr "labels.loading")}] + + :else + [:* + [:div {:class (stl/css :client)} + (when-let [logo-uri (:logo-uri @details)] + [:> client-logo* {:key logo-uri :uri logo-uri}]) + [:div {:class (stl/css :client-details)} + [:> text* {:typography typography/body-large :class (stl/css :client-text)} + (tr "mcp.oauth.request" (or (:client-name @details) (:client-id @details)))] + [:> text* {:typography typography/body-small :class (stl/css :client-text)} + (:client-id @details)]]] + [:> text* {:typography typography/body-medium} + (tr "mcp.oauth.account" (:email profile))] + [:> text* {:typography typography/body-large} + (tr "mcp.oauth.access")] + [:> text* {:typography typography/body-medium} + (tr "mcp.oauth.enable")] + [:div {:class (stl/css :actions)} + [:> button* {:variant "secondary" :disabled @busy? :on-click #(decide! "deny")} + (tr "labels.cancel")] + [:> button* {:variant "primary" :disabled @busy? :on-click #(decide! "approve")} + (tr "mcp.oauth.approve")]]])])])) diff --git a/frontend/src/app/main/ui/mcp_authorize.scss b/frontend/src/app/main/ui/mcp_authorize.scss new file mode 100644 index 0000000000..64eed408fa --- /dev/null +++ b/frontend/src/app/main/ui/mcp_authorize.scss @@ -0,0 +1,61 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS SUBSIDIARY SL + +@use "ds/_sizes.scss" as *; + +.page { + display: grid; + place-items: center; + min-block-size: 100%; + padding: var(--sp-xl); + background: var(--color-background-primary); + color: var(--color-foreground-primary); +} + +.card { + display: flex; + flex-direction: column; + gap: var(--sp-l); + max-inline-size: 36rem; + padding: var(--sp-xxl); + border: 1px solid var(--color-background-quaternary); + border-radius: var(--br-8); +} + +.client { + display: flex; + align-items: center; + gap: var(--sp-l); + margin-block-end: var(--sp-s); + overflow-wrap: anywhere; +} + +.client-logo { + inline-size: $sz-48; + block-size: $sz-48; + flex-shrink: 0; + object-fit: contain; + border-radius: var(--br-8); +} + +.client-details { + display: flex; + flex-direction: column; + gap: calc(var(--sp-s) - var(--sp-xxs)); + min-inline-size: 0; +} + +.client-text { + margin: 0; +} + +.actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--sp-m); + margin-block-start: var(--sp-l); +} diff --git a/frontend/src/app/main/ui/notifications/context_notification.cljs b/frontend/src/app/main/ui/notifications/context_notification.cljs index 923a4958a3..9455f284c2 100644 --- a/frontend/src/app/main/ui/notifications/context_notification.cljs +++ b/frontend/src/app/main/ui/notifications/context_notification.cljs @@ -10,29 +10,16 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.main.ui.components.link-button :as lb] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [rumext.v2 :as mf])) -(def ^:private neutral-icon - (deprecated-icon/icon-xref :msg-neutral (stl/css :icon))) - -(def ^:private error-icon - (deprecated-icon/icon-xref :delete-text (stl/css :icon))) - -(def ^:private success-icon - (deprecated-icon/icon-xref :status-tick (stl/css :icon))) - -(def ^:private info-icon - (deprecated-icon/icon-xref :help (stl/css :icon))) - -(defn get-icon-by-level +(defn- level->icon-id [level] (case level - :warning neutral-icon - :error error-icon - :success success-icon - :info info-icon - neutral-icon)) + :error i/delete-text + :success i/status-tick + :info i/help + i/msg-neutral)) (mf/defc context-notification "They are persistent, informative and non-actionable. @@ -49,7 +36,8 @@ :success (= level :success) :info (= level :info)))} - (get-icon-by-level level) + [:> icon* {:icon-id (level->icon-id level) + :class (stl/css :icon)}] ;; The content can arrive in markdown format, in these cases ;; we will use the prop is-html to true to indicate it and diff --git a/frontend/src/app/main/ui/notifications/context_notification.scss b/frontend/src/app/main/ui/notifications/context_notification.scss index e4cb80b794..cd8de495c1 100644 --- a/frontend/src/app/main/ui/notifications/context_notification.scss +++ b/frontend/src/app/main/ui/notifications/context_notification.scss @@ -4,71 +4,65 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/typography.scss" as *; .context-notification { - --context-notification-bg-color: var(--alert-background-color-default); - --context-notification-fg-color: var(--alert-text-foreground-color-default); - --context-notification-icon-color: var(--alert-icon-foreground-color-default); - --context-notification-border-color: var(--alert-border-color-default); + --context-notification-bg-color: var(--color-background-primary); + --context-notification-fg-color: var(--color-foreground-primary); + --context-notification-icon-color: var(--color-foreground-primary); + --context-notification-border-color: var(--color-background-quaternary); box-sizing: border-box; display: grid; - grid-template-columns: deprecated.$s-16 1fr; - gap: deprecated.$s-8; - min-height: deprecated.$s-32; - height: fit-content; - width: 100%; - padding: deprecated.$s-8; - border: deprecated.$s-1 solid var(--context-notification-border-color); - border-radius: deprecated.$br-8; + grid-template-columns: $sz-16 1fr; + gap: var(--sp-s); + min-block-size: $sz-32; + block-size: fit-content; + inline-size: 100%; + padding: var(--sp-s); + border: $b-1 solid var(--context-notification-border-color); + border-radius: $br-8; background-color: var(--context-notification-bg-color); } .warning { - --context-notification-bg-color: var(--alert-background-color-warning); - --context-notification-fg-color: var(--alert-text-foreground-color-warning); - --context-notification-icon-color: var(--alert-icon-foreground-color-warning); - --context-notification-border-color: var(--alert-border-color-warning); + --context-notification-bg-color: var(--color-background-warning); + --context-notification-fg-color: var(--color-foreground-primary); + --context-notification-icon-color: var(--color-accent-warning); + --context-notification-border-color: var(--color-accent-warning); } .success { - --context-notification-bg-color: var(--alert-background-color-success); - --context-notification-fg-color: var(--alert-text-foreground-color-success); - --context-notification-icon-color: var(--alert-icon-foreground-color-success); - --context-notification-border-color: var(--alert-border-color-success); + --context-notification-bg-color: var(--color-background-success); + --context-notification-fg-color: var(--color-foreground-primary); + --context-notification-icon-color: var(--color-accent-success); + --context-notification-border-color: var(--color-accent-success); } .info { - --context-notification-bg-color: var(--alert-background-color-info); - --context-notification-fg-color: var(--alert-text-foreground-color-info); - --context-notification-icon-color: var(--alert-icon-foreground-color-info); - --context-notification-border-color: var(--alert-border-color-info); -} - -.default { - --context-notification-bg-color: var(--alert-background-color-default); - --context-notification-fg-color: var(--alert-text-foreground-color-default); - --context-notification-icon-color: var(--alert-icon-foreground-color-default); - --context-notification-border-color: var(--alert-border-color-default); + --context-notification-bg-color: var(--color-background-info); + --context-notification-fg-color: var(--color-foreground-primary); + --context-notification-icon-color: var(--color-accent-info); + --context-notification-border-color: var(--color-accent-info); } .error { - --context-notification-bg-color: var(--alert-background-color-error); - --context-notification-fg-color: var(--alert-text-foreground-color-error); - --context-notification-icon-color: var(--alert-icon-foreground-color-error); - --context-notification-border-color: var(--alert-border-color-error); + --context-notification-bg-color: var(--color-background-error); + --context-notification-fg-color: var(--color-foreground-primary); + --context-notification-icon-color: var(--color-accent-error); + --context-notification-border-color: var(--color-accent-error); } .icon { - @extend %button-icon; - + flex-shrink: 0; align-self: self-start; - stroke: var(--context-notification-icon-color); + color: var(--context-notification-icon-color); } .context-text { - @include deprecated.body-small-typography; + @include use-typography("body-small"); align-self: center; color: var(--context-notification-fg-color); @@ -81,12 +75,12 @@ .link, .contain-html .context-text a { - @include deprecated.body-small-typography; + @include use-typography("body-small"); align-self: center; display: inline; - text-align: left; - height: deprecated.$s-16; + text-align: start; + block-size: $sz-16; margin: 0; - color: var(--modal-link-foreground-color); + color: var(--color-accent-primary); } diff --git a/frontend/src/app/main/ui/releases.cljs b/frontend/src/app/main/ui/releases.cljs index 8252d42849..86289fe4ed 100644 --- a/frontend/src/app/main/ui/releases.cljs +++ b/frontend/src/app/main/ui/releases.cljs @@ -37,6 +37,7 @@ [app.main.ui.releases.v2-16] [app.main.ui.releases.v2-17] [app.main.ui.releases.v2-18] + [app.main.ui.releases.v2-19] [app.main.ui.releases.v2-2] [app.main.ui.releases.v2-3] [app.main.ui.releases.v2-4] @@ -108,4 +109,4 @@ (defmethod rc/render-release-notes "0.0" [params] - (rc/render-release-notes (assoc params :version "2.18"))) + (rc/render-release-notes (assoc params :version "2.19"))) diff --git a/frontend/src/app/main/ui/releases/v2_19.cljs b/frontend/src/app/main/ui/releases/v2_19.cljs new file mode 100644 index 0000000000..241be4cc05 --- /dev/null +++ b/frontend/src/app/main/ui/releases/v2_19.cljs @@ -0,0 +1,208 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.ui.releases.v2-19 + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data.macros :as dm] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.releases.common :as c] + [rumext.v2 :as mf])) + +(defmethod c/render-release-notes "2.19" + [{:keys [slide klass next finish navigate version]}] + (mf/html + (case slide + :start + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.19-slide-0.jpg" + :class (stl/css :start-image) + :border "0" + :alt "Penpot 2.19 is here!"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "What’s new in Penpot?"] + + [:div {:class (stl/css :version-tag)} + (dm/str "Version " version)]] + + [:div {:class (stl/css :features-block)} + + [:p {:class (stl/css :feature-content)} + "Per-side strokes, your own shortcuts, tokens shared in libraries, and a big step for WebGL rendering."] + + [:p {:class (stl/css :feature-content)} + "2.19 is a packed release. You can now set stroke width per side, customize your keyboard shortcuts, and draw and edit paths with far more control. Design tokens can now live in a shared library and stay in sync across files, and WebGL rendering gets better text, smoother canvases, and much faster exports. On top of that come performance improvements, a wide round of bug fixes, and plenty of quality-of-life polish."]] + + [:div {:class (stl/css :navigation)} + [:> button* {:class (stl/css :next-btn) + :on-click next + :variant "primary"} + "Continue"]]]]]] + + 0 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.19-per-side-strokes.jpg" + :class (stl/css :start-image) + :border "0" + :alt "Per-side strokes. Yes, finally!"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Per-side strokes. Yes, finally!"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "We know, this one kept you waiting. Rectangles and boards can now have a different stroke width on each side (top, right, bottom, left). Switch from uniform to per-side in the Stroke section, just like independent border radius. Need only a bottom divider or a left accent? No more extra shapes or masking tricks."] + + [:p {:class (stl/css :feature-content)} + "It works with design tokens too: apply a token to all sides or a different one to each. Inspect shows the value or token applied to every side, matching how CSS borders behave in code."]] + + [:div {:class (stl/css :navigation)} + [:> c/navigation-bullets* + {:slide slide + :navigate navigate + :total 5}] + + [:> button* {:class (stl/css :next-btn) + :on-click next + :variant "primary"} + "Continue"]]]]]] + + 1 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.19-shortcuts.jpg" + :class (stl/css :start-image) + :border "0" + :alt "Shortcuts that fit your hands"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Shortcuts that fit your hands"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "You can now reassign any keyboard shortcut in Penpot. Search by action or by key combination, record a new binding, and Penpot warns you if it is already taken. Reset one shortcut or all of them whenever you want."] + + [:p {:class (stl/css :feature-content)} + "Great news if you type on AZERTY, QWERTZ, or any other keyboard layout, and for anyone who wants Penpot to match their muscle memory. You can also export and import your setup to take it with you."]] + + [:div {:class (stl/css :navigation)} + [:> c/navigation-bullets* + {:slide slide + :navigate navigate + :total 5}] + + [:> button* {:class (stl/css :next-btn) + :on-click next + :variant "primary"} + "Continue"]]]]]] + + 2 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.19-paths.jpg" + :class (stl/css :start-image) + :border "0" + :alt "Drawing and path editing, refreshed"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Drawing and path editing, refreshed"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "The path editor gets a full refresh: smoother freehand curves that keep sharp corners sharp, the path toolbar available while you draw, node snapping on by default, and the ability to continue drawing from existing nodes and segments. Hold Shift to snap angles in 15° steps."] + + [:p {:class (stl/css :feature-content)} + "The result: everything you need to draw your own icons and bring any illustration you can imagine to life, without leaving Penpot."]] + + [:div {:class (stl/css :navigation)} + [:> c/navigation-bullets* + {:slide slide + :navigate navigate + :total 5}] + + [:> button* {:class (stl/css :next-btn) + :on-click next + :variant "primary"} + "Continue"]]]]]] + + 3 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.19-tokens.jpg" + :class (stl/css :start-image) + :border "0" + :alt "Design tokens, shared from a library"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Design tokens, shared from a library"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "Keep your tokens in one library and use them in every file that links it. Tokens stay in sync when the library changes, just like components and colors. In the consuming file you can apply tokens and switch themes, while edits happen in the source library, so your design system stays the single source of truth."] + + [:p {:class (stl/css :feature-content)} + "The Assets tab shows where your tokens come from."]] + + [:div {:class (stl/css :navigation)} + [:> c/navigation-bullets* + {:slide slide + :navigate navigate + :total 5}] + + [:> button* {:class (stl/css :next-btn) + :on-click next + :variant "primary"} + "Continue"]]]]]] + + 4 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.19-performance.jpg" + :class (stl/css :start-image) + :border "0" + :alt "WebGL rendering takes a big step"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "WebGL rendering takes a big step"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "WebGL rendering gets one of its biggest upgrades yet. Improved tile rendering keeps large files smooth while you move around the canvas, and the new text editor built for WebGL is more precise and much faster. Exports now use the same engine, so they are radically faster and higher quality."] + + [:p {:class (stl/css :feature-content)} + "If you have not tried it yet, enable it in Your account → Settings. Your feedback keeps shaping it."]] + + [:div {:class (stl/css :navigation)} + [:> c/navigation-bullets* + {:slide slide + :navigate navigate + :total 5}] + + [:> button* {:class (stl/css :next-btn) + :on-click finish + :variant "primary"} + "Let's go"]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v2_19.scss b/frontend/src/app/main/ui/releases/v2_19.scss new file mode 100644 index 0000000000..ca0969b1a1 --- /dev/null +++ b/frontend/src/app/main/ui/releases/v2_19.scss @@ -0,0 +1,7 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS SUBSIDIARY SL + +@use "base"; diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index 8096051d40..fa9926911b 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -33,7 +33,8 @@ "Enabled route names. Query-string routing: the `screen` query param carries the route name (`?screen=¶ms`); it is router-owned and reserved, every other param travels as a plain query param." - (into #{:auth-login + (into #{:mcp-authorize + :auth-login :auth-register :auth-register-validate :auth-register-success diff --git a/frontend/src/app/main/ui/settings/change_email.cljs b/frontend/src/app/main/ui/settings/change_email.cljs index 98e15e2c58..613ae3a9d6 100644 --- a/frontend/src/app/main/ui/settings/change_email.cljs +++ b/frontend/src/app/main/ui/settings/change_email.cljs @@ -14,7 +14,8 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.forms :as fm] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] @@ -88,8 +89,11 @@ [:h2 {:class (stl/css :modal-title) :data-testid "change-email-title"} (tr "modals.change-email.title")] - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}]] [:div {:class (stl/css :modal-content)} [:& context-notification @@ -114,7 +118,8 @@ [:div {:class (stl/css :action-buttons) :data-testid "change-email-submit"} [:> fm/submit-button* - {:label (tr "modals.change-email.submit")}]]]]]])) + {:label (tr "modals.change-email.submit") + :class (stl/css :submit-button)}]]]]]])) diff --git a/frontend/src/app/main/ui/settings/change_email.scss b/frontend/src/app/main/ui/settings/change_email.scss index 4795cf6ac6..afff303004 100644 --- a/frontend/src/app/main/ui/settings/change_email.scss +++ b/frontend/src/app/main/ui/settings/change_email.scss @@ -4,7 +4,11 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*), which move with +// the modals refactor. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .modal-overlay { @extend %modal-overlay-base; @@ -13,49 +17,45 @@ .modal-container { @extend %modal-container-base; - min-width: deprecated.$s-408; + min-inline-size: px2rem(408); } .modal-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .modal-title { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } +// Tooltip wrapper of the close button: it places it in the corner. .modal-close-btn { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content { - @include deprecated.flex-column; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - gap: deprecated.$s-24; - margin-bottom: deprecated.$s-24; + display: flex; + flex-direction: column; + gap: var(--sp-xxl); + margin-block-end: var(--sp-xxl); } .fields-row { - @include deprecated.flex-column; -} - -.select-title { - @include deprecated.body-small-typography; - - color: var(--modal-title-foreground-color); + display: flex; + flex-direction: column; + gap: var(--sp-xs); } .action-buttons { @extend %modal-action-btns; - - button { - @extend %modal-accept-btn; - } } -.cancel-button { - @extend %modal-cancel-btn; +.submit-button { + @extend %modal-accept-btn; } diff --git a/frontend/src/app/main/ui/settings/options.cljs b/frontend/src/app/main/ui/settings/options.cljs index c4d711ba79..12ba30ef6a 100644 --- a/frontend/src/app/main/ui/settings/options.cljs +++ b/frontend/src/app/main/ui/settings/options.cljs @@ -49,7 +49,7 @@ (update :lang #(or % "")) (update :theme #(if (= % "default") "dark" - (or % "dark"))))) + (or % theme/default))))) form (fm/use-form :schema schema:options-form :initial initial)] diff --git a/frontend/src/app/main/ui/settings/profile.cljs b/frontend/src/app/main/ui/settings/profile.cljs index 1c1add422b..74f073ae36 100644 --- a/frontend/src/app/main/ui/settings/profile.cljs +++ b/frontend/src/app/main/ui/settings/profile.cljs @@ -17,6 +17,8 @@ [app.main.ui.components.file-uploader :refer [file-uploader]] [app.main.ui.components.forms :as fm] [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -71,7 +73,8 @@ [:div {:class (stl/css :options)} [:div.change-email - [:a {:on-click on-show-change-email} + [:a {:class (stl/css :link) + :on-click on-show-change-email} (tr "dashboard.change-email")]]]] [:> fm/submit-button* @@ -80,8 +83,9 @@ :class (stl/css :btn-primary)}] [:div {:class (stl/css :links)} - [:div {:class (stl/css :link-item)} - [:a {:on-click on-show-delete-account + [:div + [:a {:class (stl/css :link) + :on-click on-show-delete-account :data-testid "remove-acount-btn"} (tr "dashboard.remove-account")]]]])) @@ -119,9 +123,12 @@ [:form {:class (stl/css :avatar-form)} [:div {:class (stl/css :image-change-field)} - [:span {:class (stl/css :update-overlay) - :on-click on-image-click} (tr "labels.update")] - [:img {:src photo}] + [:button {:type "button" + :class (stl/css :update-overlay) + :on-click on-image-click} + (tr "labels.update")] + [:img {:class (stl/css :avatar-image) + :src photo}] (when has-photo? [:button {:type "button" :class (stl/css :delete-overlay) @@ -146,6 +153,10 @@ [:section {:class (stl/css :dashboard-settings) :aria-labelledby "profile-section-title"} [:div {:class (stl/css :form-container)} - [:h2 {:id "profile-section-title"} (tr "labels.profile")] + [:> heading* {:level 2 + :typography t/title-large + :id "profile-section-title" + :class (stl/css :page-title)} + (tr "labels.profile")] [:> profile-photo-form*] [:> profile-form*]]]) diff --git a/frontend/src/app/main/ui/settings/profile.scss b/frontend/src/app/main/ui/settings/profile.scss index 1a6fca03c6..0c2b954a18 100644 --- a/frontend/src/app/main/ui/settings/profile.scss +++ b/frontend/src/app/main/ui/settings/profile.scss @@ -4,316 +4,83 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "common/refactor/common-refactor" as *; +// Only for %button-primary: the save button is the shared forms submit +// button, which moves to the DS with the forms component. +@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .dashboard-settings { display: flex; - width: 100%; + inline-size: 100%; justify-content: center; align-items: center; +} - a:not(.button-primary, .link) { - color: var(--color-foreground-secondary); - } +.link { + @include use-typography("body-medium"); + + color: var(--color-accent-primary); } .form-container { display: flex; flex-direction: column; - max-width: $s-500; - width: $s-580; - margin: $s-80 auto $s-120 auto; + max-inline-size: $sz-500; + inline-size: px2rem(580); + margin-block: px2rem(80) px2rem(120); + margin-inline: auto; justify-content: center; - - form { - display: flex; - flex-direction: column; - - .btn-secondary { - width: 100%; - font-size: $fs-11; - text-transform: uppercase; - background-color: var(--color-background-tertiary); - color: var(--color-foreground-primary); - - &:hover { - color: var(--color-accent-primary); - background-color: var(--color-background-quaternary); - } - } - - hr { - display: none; - } - } - - .fields-row { - --input-height: #{$s-40}; - - margin-bottom: $s-20; - flex-direction: column; - - .options { - display: flex; - justify-content: flex-end; - font-size: $fs-14; - margin-top: $s-12; - } - } - - .field { - margin-bottom: $s-20; - } - - .field-title { - color: var(--color-foreground-primary); - - &:not(:first-child) { - margin-top: $s-64; - } - } - - .field-text { - color: var(--color-foreground-secondary); - } - - .custom-input, - .custom-select { - flex-direction: column-reverse; - - label { - position: relative; - text-transform: uppercase; - color: var(--color-foreground-primary); - font-size: $fs-11; - margin-bottom: $s-12; - margin-left: calc(-1 * $s-4); - } - - input, - select { - background-color: var(--color-background-tertiary); - border-radius: $br-8; - border-color: transparent; - color: var(--color-foreground-primary); - padding: 0 $s-16; - - &:focus { - outline: $s-1 solid var(--color-accent-primary); - } - - ::placeholder { - color: var(--color-foreground-secondary); - } - } - - .help-icon { - bottom: $s-12; - top: auto; - - svg { - fill: var(--color-foreground-secondary); - } - } - - &.disabled { - input { - background-color: var(--input-background-color-disabled); - border-color: var(--color-background-quaternary); - color: var(--color-foreground-secondary); - } - } - - .input-container { - background-color: var(--color-background-tertiary); - border-radius: $br-8; - border-color: transparent; - margin-top: $s-24; - - .main-content { - label { - position: absolute; - top: calc(-1 * $s-24); - } - - span { - color: var(--color-foreground-primary); - } - } - - &:focus { - border: $s-1 solid var(--color-accent-primary); - } - } - - textarea { - border-radius: $br-8; - padding: $s-12 $s-16; - background-color: var(--color-background-tertiary); - color: var(--color-foreground-primary); - border: none; - - &:focus { - outline: $s-1 solid var(--color-accent-primary); - } - } - } - - &.two-columns { - max-width: $s-520; - justify-content: space-between; - flex-direction: row; - } - - h1 { - font-size: $fs-36; - color: var(--color-background-tertiary); - margin-bottom: $s-20; - } - - .subtitle { - font-size: $fs-24; - color: var(--color-background-tertiary); - margin-bottom: $s-20; - } - - .notification-icon { - justify-content: center; - display: flex; - margin-bottom: $s-48; - - svg { - fill: var(--color-background-primary); - height: 40%; - width: 40%; - } - } - - .notification-text { - font-size: $fs-16; - color: var(--color-background-primary); - margin-bottom: $s-20; - } - - .notification-text-email { - background: var(--color-foreground-primary); - border-radius: $br-4; - color: var(--color-background-primary); - font-size: $fs-16; - font-weight: $fw500; - margin: $s-24 0 $s-40 0; - padding: $s-16; - text-align: center; - } - - h2 { - font-size: $fs-24; - font-weight: $fw400; - color: var(--color-foreground-primary); - display: flex; - align-items: center; - margin: $s-16 0; - } - - h3 { - font-size: $fs-12; - font-weight: $fw400; - color: var(--color-foreground-primary); - display: flex; - align-items: center; - margin: $s-8 0; - text-transform: uppercase; - } - - a:not(.button-primary) { - &:hover { - text-decoration: underline; - } - } - - p { - color: var(--color-background-primary); - } - - hr { - border-color: var(--color-foreground-secondary); - } - - .links { - margin-top: $s-12; - } } -form.avatar-form { +.page-title { + color: var(--color-foreground-primary); + display: flex; + align-items: center; + margin-block: var(--sp-l); + margin-inline: 0; +} + +.fields-row { + --input-height: #{$sz-40}; + + margin-block-end: var(--sp-xl); + flex-direction: column; +} + +.options { + display: flex; + justify-content: flex-end; + margin-block-start: var(--sp-m); +} + +.links { + margin-block-start: var(--sp-m); +} + +.avatar-form { display: flex; flex-direction: column; - width: $s-148; - height: $s-148; - margin: $s-16 0; + inline-size: px2rem(148); + block-size: px2rem(148); + margin-block: var(--sp-l); + margin-inline: 0; +} - img { - border-radius: 50%; - flex-shrink: 0; - height: 100%; - margin-right: $s-16; - width: 100%; - } +.avatar-image { + border-radius: $br-circle; + flex-shrink: 0; + block-size: 100%; + margin-inline-end: var(--sp-l); + inline-size: 100%; } .image-change-field { position: relative; - width: 100%; - height: 100%; - - .update-overlay { - opacity: 0; - cursor: pointer; - position: absolute; - width: 100%; - height: 100%; - border-radius: 50%; - font-size: $fs-24; - color: var(--color-foreground-primary); - line-height: 6; - text-align: center; - background: var(--color-accent-tertiary); - z-index: $z-index-modal; - } - - .delete-overlay { - position: absolute; - top: $s-4; - inset-inline-end: $s-4; - display: flex; - align-items: center; - justify-content: center; - width: $s-32; - height: $s-32; - padding: 0; - border: none; - border-radius: 50%; - background: var(--color-background-primary); - color: var(--color-foreground-primary); - cursor: pointer; - opacity: 0; - transition: opacity 0.15s ease-in-out; - z-index: calc(#{$z-index-modal} + 1); - - &:hover { - background: var(--color-background-quaternary); - color: var(--color-accent-primary); - } - } - - input[type="file"] { - width: 100%; - height: 100%; - position: absolute; - opacity: 0; - cursor: pointer; - top: 0; - z-index: $z-index-modal; - } + inline-size: 100%; + block-size: 100%; &:hover { .update-overlay { @@ -326,52 +93,70 @@ form.avatar-form { } } +.update-overlay { + @include use-typography("title-large"); + + opacity: 0; + cursor: pointer; + position: absolute; + inline-size: 100%; + block-size: 100%; + border: none; + border-radius: $br-circle; + color: var(--color-foreground-primary); + + // Draws the label in the middle of the circle. + line-height: 6; + text-align: center; + background: var(--color-accent-tertiary); + + // The button centers its 144px line in the 148px circle; the bottom + // padding keeps the label where the old span drew it. + padding: 0 0 var(--sp-xs); + z-index: var(--z-index-set); + + &:focus-visible { + opacity: 0.8; + } +} + +.delete-overlay { + --delete-overlay-background-color: var(--color-background-primary); + --delete-overlay-foreground-color: var(--color-foreground-primary); + + position: absolute; + inset-block-start: var(--sp-xs); + inset-inline-end: var(--sp-xs); + display: flex; + align-items: center; + justify-content: center; + inline-size: $sz-32; + block-size: $sz-32; + padding: 0; + border: none; + border-radius: $br-circle; + background: var(--delete-overlay-background-color); + color: var(--delete-overlay-foreground-color); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease-in-out; + z-index: calc(var(--z-index-set) + 1); + + &:hover { + --delete-overlay-background-color: var(--color-background-quaternary); + --delete-overlay-foreground-color: var(--color-accent-primary); + } +} + .profile-form { display: flex; flex-direction: column; - max-width: $s-368; - width: 100%; -} - -.newsletter-subs { - border-bottom: $s-1 solid var(--color-foreground-secondary); - border-top: $s-1 solid var(--color-foreground-secondary); - padding: $s-32 0; - margin-bottom: $s-32; - - .newsletter-title { - font-family: "worksans", "vazirmatn", sans-serif; - color: var(--color-foreground-secondary); - font-size: $fs-14; - } - - label { - font-family: "worksans", "vazirmatn", sans-serif; - color: var(--color-background-primary); - font-size: $fs-12; - margin-right: calc(-1 * $s-16); - margin-bottom: $s-12; - } - - .info { - color: var(--color-foreground-secondary); - font-size: $fs-12; - margin-bottom: $s-8; - } - - .input-checkbox label { - align-items: flex-start; - } -} - -.btn-secondary { - @extend %button-secondary; - - height: $s-32; + max-inline-size: px2rem(368); + inline-size: 100%; } .btn-primary { @extend %button-primary; - height: $s-32; + block-size: $sz-32; } diff --git a/frontend/src/app/main/ui/settings/shortcuts.cljs b/frontend/src/app/main/ui/settings/shortcuts.cljs index 841841ac5b..e1e9b884c0 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.cljs +++ b/frontend/src/app/main/ui/settings/shortcuts.cljs @@ -23,7 +23,6 @@ [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]] - [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.product.empty-state :refer [empty-state*]] [app.main.ui.settings.import-shortcuts-diff-modal] [app.main.ui.settings.restore-shortcuts-modal] @@ -529,10 +528,6 @@ [:> icon* {:icon-id i/info :class (stl/css :shortcuts-description-icon)}] (tr "shortcuts.reload-hint")] - (when-not (ds/layout-map-supported?) - [:> context-notification* {:level :warning - :class (stl/css :layout-map-warning)} - (tr "shortcuts.layout-map-missing")]) [:> (mf/provider ctx/shortcuts-ctx) {:value shortcuts-ctx-value} [:div {:class (stl/css :shortcuts-content)} diff --git a/frontend/src/app/main/ui/settings/shortcuts.scss b/frontend/src/app/main/ui/settings/shortcuts.scss index c86ca879b5..522147f61e 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.scss +++ b/frontend/src/app/main/ui/settings/shortcuts.scss @@ -314,7 +314,3 @@ padding: var(--sp-s) 0; margin-block-end: var(--sp-l); } - -.layout-map-warning { - margin-block-end: var(--sp-m); -} diff --git a/frontend/src/app/main/ui/settings/sidebar.cljs b/frontend/src/app/main/ui/settings/sidebar.cljs index 652842ff26..3bd4545bf6 100644 --- a/frontend/src/app/main/ui/settings/sidebar.cljs +++ b/frontend/src/app/main/ui/settings/sidebar.cljs @@ -15,16 +15,11 @@ [app.main.router :as rt] [app.main.store :as st] [app.main.ui.dashboard.sidebar :refer [profile-section*]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [rumext.v2 :as mf])) -(def ^:private arrow-icon - (deprecated-icon/icon-xref :arrow (stl/css :arrow-icon))) - -(def ^:private feedback-icon - (deprecated-icon/icon-xref :feedback (stl/css :feedback-icon))) ;; FIXME: move to common (def ^:private go-settings-profile @@ -82,7 +77,8 @@ [:div {:class (stl/css :sidebar-content-section)} [:button {:class (stl/css :back-to-dashboard) :on-click go-dashboard} - arrow-icon + [:> icon* {:icon-id i/arrow-left + :class (stl/css :arrow-icon)}] [:span {:class (stl/css :back-text)} (tr "labels.dashboard")]]] [:hr {:class (stl/css :sidebar-separator)}] @@ -143,7 +139,9 @@ [:li {:class (stl/css-case :current feedback? :settings-item true) :on-click go-settings-feedback} - feedback-icon + [:> icon* {:icon-id i/feedback + :size "s" + :class (stl/css :feedback-icon)}] [:span {:class (stl/css :element-title)} (tr "labels.contact-us")]])]]])) (mf/defc sidebar* diff --git a/frontend/src/app/main/ui/settings/sidebar.scss b/frontend/src/app/main/ui/settings/sidebar.scss index e63882a113..dc905a9911 100644 --- a/frontend/src/app/main/ui/settings/sidebar.scss +++ b/frontend/src/app/main/ui/settings/sidebar.scss @@ -4,8 +4,11 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_utils.scss" as *; @use "ds/mixins.scss" as *; +@use "ds/typography.scss" as *; +@use "ds/z-index.scss" as *; .dashboard-sidebar { @include custom-scrollbar; @@ -14,24 +17,24 @@ grid-row: 1 / span 2; display: grid; grid-template-rows: 1fr auto; - height: 100%; - padding-block-start: deprecated.$s-16; - border-right: deprecated.$s-1 solid var(--panel-border-color); - z-index: deprecated.$z-index-1; - background-color: var(--panel-background-color); + block-size: 100%; + padding-block-start: var(--sp-l); + border-inline-end: $b-1 solid var(--color-background-quaternary); + z-index: var(--z-index-panels); + background-color: var(--color-background-primary); } .sidebar-content { display: grid; grid-template-rows: auto auto 1fr; - height: 100%; + block-size: 100%; padding: 0; overflow-y: auto; } .sidebar-separator { border-color: transparent; - margin: deprecated.$s-12 deprecated.$s-16; + margin: var(--sp-m) var(--sp-l); } .sidebar-nav-settings { @@ -43,46 +46,48 @@ } .settings-item { - --settings-foreground-color: var(--menu-foreground-color-rest); + --settings-foreground-color: var(--color-foreground-secondary); --settings-background-color: transparent; display: flex; align-items: center; - padding: deprecated.$s-8 deprecated.$s-8 deprecated.$s-8 deprecated.$s-24; + padding: var(--sp-s) var(--sp-s) var(--sp-s) var(--sp-xxl); color: var(--settings-foreground-color); background-color: var(--settings-background-color); cursor: pointer; &:hover { - --settings-foreground-color: var(--sidebar-element-foreground-color-hover); - --settings-background-color: var(--sidebar-element-background-color-hover); + --settings-foreground-color: var(--color-accent-primary); + --settings-background-color: var(--color-background-secondary); } &.current { - --settings-foreground-color: var(--sidebar-element-foreground-color-selected); - --settings-background-color: var(--sidebar-element-background-color-selected); + --settings-foreground-color: var(--color-accent-primary); + --settings-background-color: var(--color-background-quaternary); } } +// The DS small icon draws 12px inside a 16px box: 4px less margin keeps +// the label where it was (the drawing sits 2px to the right). .feedback-icon { - @extend %button-icon-small; - - stroke: var(--settings-foreground-color); - margin-right: deprecated.$s-8; + flex-shrink: 0; + color: var(--settings-foreground-color); + margin-inline-end: var(--sp-xs); } .element-title { - @include deprecated.text-ellipsis; - @include deprecated.body-medium-typography; + @include text-ellipsis; + @include use-typography("body-medium"); } .back-to-dashboard { - @include deprecated.button-style; - + border: none; + background: none; + cursor: pointer; display: flex; align-items: center; - padding: deprecated.$s-12 deprecated.$s-16; - font-size: deprecated.$fs-14; + padding: var(--sp-m) var(--sp-l); + font-size: px2rem(14); } .back-text { @@ -90,9 +95,7 @@ } .arrow-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); - transform: rotate(180deg); - margin-right: deprecated.$s-12; + flex-shrink: 0; + color: var(--color-foreground-secondary); + margin-inline-end: var(--sp-m); } diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index d1db4cab5f..3b879563a9 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -16,6 +16,7 @@ [app.main.ui.components.forms :as fm] [app.main.ui.dashboard.subscription :refer [get-subscription-type]] [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] [app.main.ui.nitrate.nitrate-activation-success-modal] @@ -93,6 +94,7 @@ [:button {:class (stl/css :cta-button :more-info) :on-click cta-link-with-icon} cta-text-with-icon [:> icon* {:icon-id "open-link" + :class (stl/css :cta-icon) :size "s"}]]) (when has-cta-link [:button {:class (stl/css-case :cta-button true @@ -137,7 +139,7 @@ [:button {:class (stl/css :cta-button :show-editors-button) :on-click handle-click} (tr "subscription.settings.management.dialog.editors") [:> icon* {:icon-id (if show-editors-list i/arrow-up i/arrow-down) - :class (stl/css :icon-dropdown) + :class (stl/css :cta-icon) :size "s"}]] (when show-editors-list [:* @@ -262,9 +264,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} - [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} - [:> icon* {:icon-id "close" - :size "m"}]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click handle-close-dialog}] [:div {:class (stl/css :modal-title :subscription-title)} (if (= unlimited-modal-step 2) (tr "subscription.settings.management-dialog.step-2-title") @@ -382,12 +386,15 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog :subscription-success)} - [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} - [:> icon* {:icon-id "close" - :size "m"}]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click handle-close-dialog}] [:div {:class (stl/css :modal-success-content)} [:div {:class (stl/css :modal-start)} - [:> raw-svg* {:id (if (= "light" (:theme profile)) "logo-subscription-light" "logo-subscription")}]] + [:> raw-svg* {:id (if (= "light" (:theme profile)) "logo-subscription-light" "logo-subscription") + :class (stl/css :modal-start-logo)}]] [:div {:class (stl/css :modal-end)} [:div {:class (stl/css :modal-title)} @@ -835,9 +842,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} - [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} - [:> icon* {:icon-id "close" - :size "m"}]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click handle-close-dialog}] [:div {:class (stl/css :modal-title :subscription-title :nitrate-subscription)} (tr "nitrate.form.title")] @@ -889,9 +898,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} - [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} - [:> icon* {:icon-id "close" - :size "m"}]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click handle-close-dialog}] [:div {:class (stl/css :modal-title :subscription-title)} (tr "nitrate.contact-sales.title" subscription-type)] [:div {:class (stl/css :modal-content)} @@ -942,9 +953,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} - [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} - [:> icon* {:icon-id "close" - :size "m"}]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click handle-close-dialog}] [:div {:class (stl/css :modal-title :subscription-title)} (tr "nitrate.subscription.settings.manual-cancel")] diff --git a/frontend/src/app/main/ui/settings/subscription.scss b/frontend/src/app/main/ui/settings/subscription.scss index ff7885506d..b1fd03d2b6 100644 --- a/frontend/src/app/main/ui/settings/subscription.scss +++ b/frontend/src/app/main/ui/settings/subscription.scss @@ -4,8 +4,10 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*), which move with +// the modals refactor. @use "refactor/common-refactor.scss" as deprecated; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/_borders.scss" as *; @use "ds/spacing.scss" as *; @use "ds/_sizes.scss" as *; @@ -39,34 +41,29 @@ } .membership-date { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-secondary); margin-inline-start: var(--sp-s); } -.subscription-member, .penpot-member { - @extend %button-icon; -} - -.penpot-member { - stroke: var(--icon-foreground); + color: var(--color-foreground-secondary); } .subscription-member { - stroke: var(--color-badge-premium); + color: var(--color-badge-premium); } .title-section { - @include t.use-typography("title-large"); + @include use-typography("title-large"); color: var(--color-foreground-primary); margin-block-end: var(--sp-xxl); } .plan-section-title { - @include t.use-typography("headline-small"); + @include use-typography("headline-small"); color: var(--color-foreground-primary); } @@ -100,29 +97,26 @@ } .plan-title-icon { - @extend %button-icon; - + display: flex; + justify-content: center; + align-items: center; + color: var(--color-foreground-primary); block-size: var(--sp-xl); inline-size: var(--sp-xl); - border-radius: 6px; - border: 1.75px solid var(--color-foreground-primary); -} - -.plan-title-icon svg { - stroke: var(--color-foreground-primary); - stroke-width: 2.25px; + border-radius: $br-6; + border: $b-2 solid var(--color-foreground-primary); } .plan-card-title, .plan-price-value, .price-value { - @include t.use-typography("title-medium"); + @include use-typography("title-medium"); color: var(--color-foreground-primary); } .plan-editors { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); align-self: end; color: var(--color-foreground-primary); @@ -130,7 +124,7 @@ } .plan-price-period { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-primary); } @@ -144,7 +138,7 @@ } .plan-cancel-date { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-primary); } @@ -158,7 +152,7 @@ .benefits-title, .benefit { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-secondary); } @@ -172,29 +166,24 @@ } .cta-button { - @include t.use-typography("body-medium"); - @include deprecated.button-style; + @include use-typography("body-medium"); + border: none; + background: none; + cursor: pointer; align-items: center; color: var(--color-accent-primary); display: flex; margin-block-start: var(--sp-m); - text-align: left; + text-align: start; } -.cta-button svg { - @extend %button-icon; - +.cta-icon { block-size: var(--sp-l); inline-size: var(--sp-l); - stroke: var(--color-accent-primary); margin-inline-start: var(--sp-xs); } -.icon-dropdown svg { - transform: rotate(90deg); -} - .bottom-link { margin-block-start: var(--sp-xs); } @@ -204,7 +193,7 @@ } .inline-error { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-error); margin-block: var(--sp-s) 0; @@ -225,15 +214,18 @@ min-inline-size: px2rem(648); } +// Tooltip wrapper of the close buttons: it places them in the corner. .close-btn { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-title { - @include t.use-typography("title-large"); + @include use-typography("title-large"); margin-block-end: var(--sp-xxxl); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); display: flex; gap: var(--sp-m); } @@ -243,7 +235,7 @@ } .modal-text-medium { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); } .modal-text-cap { @@ -257,7 +249,7 @@ .modal-text-medium strong, .text-strikethrough strong, .modal-text-cap strong { - font-weight: deprecated.$fw700; + font-weight: 700; } .modal-content, @@ -309,13 +301,13 @@ } } -.modal-start svg { +.modal-start-logo { inline-size: 100%; block-size: auto; } .editors-text { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); margin: 0; } @@ -326,7 +318,7 @@ } .editors-list { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); list-style-position: inside; list-style-type: none; @@ -342,7 +334,7 @@ } .error-message { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-error); margin-block-start: var(--sp-s); @@ -361,7 +353,7 @@ } .unlimited-capped-warning { - @include t.use-typography("body-small"); + @include use-typography("body-small"); background-color: var(--color-background-tertiary); border-radius: var(--sp-s); @@ -374,28 +366,12 @@ padding-inline: 0; } -.radio-btns { - display: flex; - flex-direction: column; - padding-block-end: var(--sp-xl); - gap: var(--sp-s); -} - -.radio-btns label { - @include t.use-typography("body-large"); - - padding: 0; - display: flex; - align-items: center; - color: var(--color-foreground-secondary); -} - .modal-contact-content { gap: var(--sp-xl); } .downgrade-warning { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); background-color: var(--color-background-tertiary); border-radius: var(--sp-s); @@ -412,13 +388,13 @@ } .downgrade-item { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); margin-block-end: var(--sp-l); } .cta-link { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); align-items: center; color: var(--color-accent-primary); diff --git a/frontend/src/app/main/ui/shortcuts.cljs b/frontend/src/app/main/ui/shortcuts.cljs index 30aed17a2c..4166314a37 100644 --- a/frontend/src/app/main/ui/shortcuts.cljs +++ b/frontend/src/app/main/ui/shortcuts.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.shortcuts (:require-macros [app.main.style :as stl]) (:require + ["@penpot/mousetrap$default" :as mousetrap] [app.common.data :as d] [app.common.data.macros :as dm] [app.config :as cf] @@ -34,10 +35,6 @@ (def ^:private modifier-keys #{"Control" "Shift" "Alt" "Meta"}) -(def ^:private punctuation-codes - #{"Minus" "Equal" "BracketLeft" "BracketRight" "Backslash" - "Semicolon" "Quote" "Backquote" "Comma" "Period" "Slash"}) - (def ^:private key-name-map {"ArrowUp" "up" "Up" "up" @@ -202,71 +199,16 @@ (fn? (.-getModifierState native))) (.getModifierState native "AltGraph")))))) -(defn- numpad-code? - [code] - (and (string? code) - (str/starts-with? code "Numpad") - (= 1 (count (subs code 6))))) - -(defn- numpad-alias - [code] - (let [suffix (subs code 6)] - (when (re-matches #"[0-9]" suffix) - (str "num" suffix)))) - -(defn- punctuation-alias - "Resolves punctuation by physical position through the shared layout - map (`app.main.data.shortcuts/layout-map`, also pushed into Mousetrap), - so recording and dispatch use the same token. Without a map the typed - glyph applies, which keeps both sides consistent." - [code key] - (let [mapped (when (some? @ds/layout-map) - (.get ^js @ds/layout-map code))] - (if (string? mapped) - (str/lower mapped) - (str/lower key)))) - -(defn- digit-code? - [code] - (and (string? code) - (boolean (re-matches #"Digit[0-9]" code)))) - -(defn- digit-alias - "Shifted digits resolve to the digit itself: with Shift held, Digit0 - types ) on US layouts but 0 on AZERTY, so shift+0 must mean the - physical digit key everywhere, like shift+z means z." - [^js event code key] - (when (and (.-shiftKey ^js event) - (digit-code? code) - (= 1 (count key))) - (subs code 5))) - (defn- normalize-key - "Normalizes `event.key` to the lowercase canonical token mousetrap - matches on. Letters and unshifted digits resolve by glyph (`a`, not - `q` on AZERTY); a digit row key held with Shift resolves to the digit - itself (`shift+0`, never `shift+)`); punctuation resolves by physical - position through the layout map, so `Shift+*` on a Spanish layout - records `shift++`, the primary key the user sees." + "Returns the key token Mousetrap matches for `event`, asking Mousetrap + itself (`keyForRecording`) so a recorded shortcut always fires: letters + and digits with a modifier by keyCode (`shift+1`, `alt+m`), punctuation + by position when the browser has a layout map, the typed glyph + otherwise." [^js event] - (let [native (event->native event) - key (.-key ^js event) - code (.-code ^js native)] + (let [key (.-key event)] (when (and (string? key) (not (contains? modifier-keys key))) - (cond - ;; Numpad digits keep their own `numN` bindings, like mousetrap. - (numpad-code? code) - (numpad-alias code) - - ;; Punctuation: primary key of the physical position. - (contains? punctuation-codes code) - (punctuation-alias code key) - - :else - (or (get key-name-map key) - ;; Shifted digit row: the digit itself, on every layout. - (digit-alias event code key) - (str/lower key)))))) + (.keyForRecording ^js mousetrap (event->native event))))) (defn- event->modifier-parts [^js event] @@ -281,7 +223,8 @@ (cf/check-platform? :macos)) (conj "command") - (.-altKey event) + (and (.-altKey event) + (not alt-graph?)) (conj "alt") (.-shiftKey event) diff --git a/frontend/src/app/main/ui/viewer/login.cljs b/frontend/src/app/main/ui/viewer/login.cljs index e99e7e3383..fca78a1aa4 100644 --- a/frontend/src/app/main/ui/viewer/login.cljs +++ b/frontend/src/app/main/ui/viewer/login.cljs @@ -5,140 +5,13 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.login - (:require-macros [app.main.style :as stl]) (:require - [app.common.logging :as log] - [app.config :as cf] [app.main.data.modal :as modal] - [app.main.router :as rt] - [app.main.store :as st] - [app.main.ui.auth.login :refer [login-dialog*]] - [app.main.ui.auth.recovery-request :refer [recovery-request-page*]] - [app.main.ui.auth.register :refer [register-methods* register-success-page* - register-validate-form* terms-service-privacy-policy*]] - [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] - [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [tr]] + [app.main.ui.auth.login-modal :refer [login-register-modal*]] [rumext.v2 :as mf])) -(log/set-level! :warn) - (mf/defc login-register-modal {::mf/register modal/components ::mf/register-as :login-register} [_] - (let [user-email (mf/use-state "") - register-token (mf/use-state "") - - current-section* (mf/use-state :login) - current-section (deref current-section*) - - set-current-section - (mf/use-fn - #(reset! current-section* %)) - - set-section - (mf/use-fn - (fn [event] - (let [section (-> (dom/get-current-target event) - (dom/get-data "value") - (keyword))] - (set-current-section section)))) - - go-back-to-login - (mf/use-fn - #(set-current-section :login)) - - main-section (or - (= current-section :login) - (= current-section :register) - (= current-section :register-validate)) - close - (fn [event] - (dom/prevent-default event) - (st/emit! (modal/hide))) - - success-email-sent - (fn [email] - (reset! user-email email) - (set-current-section :email-sent)) - - success-login - (fn [] - (.reload js/window.location true)) - - success-register - (fn [data] - (cond - (:invitation-token data) - (st/emit! (rt/nav :auth-verify-token {:token (:invitation-token data)})) - - (:is-active data) - (st/emit! (modal/hide)) - - :else - (do - (reset! user-email (:email data)) - (set-current-section :email-sent))))] - - [:div {:class (stl/css :modal-overlay)} - [:div {:class (stl/css :modal-container)} - [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-header-title)} (tr "labels.continue-with-penpot")] - [:> icon-button* {:variant "ghost" - :class (stl/css :modal-close) - :aria-label (tr "labels.close") - :on-click close - :icon i/close}]] - - [:div {:class (stl/css :modal-content)} - (case current-section - :login - [:div {:class (stl/css :login-form)} - [:> login-dialog* {:on-success-callback success-login - :origin :viewer}] - [:div {:class (stl/css :login-links)} - [:div - [:a {:on-click set-section - :data-value "recovery-request"} - (tr "auth.forgot-password")]] - (when (contains? cf/flags :registration) - [:div - [:span - (tr "auth.register") " "] - [:a {:on-click set-section - :data-value "register"} - (tr "auth.register-submit")]])]] - - :register - [:div {:class (stl/css :login-form)} - [:> register-methods* {:on-success-callback success-register}] - [:div {:class (stl/css :login-links)} - [:div - [:span (tr "auth.already-have-account") " "] - [:a {:on-click set-section - :data-value "login"} - (tr "auth.login-here")]]]] - - :register-validate - [:div {:class (stl/css :login-form)} - [:> register-validate-form* {:params {:token @register-token} - :on-success-callback success-email-sent}] - [:div {:class (stl/css :login-links)} - [:div - [:a {:on-click set-section - :data-value "register"} - (tr "labels.go-back")]]]] - - :recovery-request - [:> recovery-request-page* {:go-back-callback go-back-to-login - :on-success-callback success-email-sent}] - - :email-sent - [:div {:class (stl/css :login-form)} - [:> register-success-page* {:params {:email @user-email}}]]) - - (when main-section - [:div {:class (stl/css :login-links)} - [:> terms-service-privacy-policy*]])]]])) + [:> login-register-modal* {:on-close modal/hide!}]) diff --git a/frontend/src/app/main/ui/viewer/share_link.cljs b/frontend/src/app/main/ui/viewer/share_link.cljs index f0a4c3de22..6d43e1c60b 100644 --- a/frontend/src/app/main/ui/viewer/share_link.cljs +++ b/frontend/src/app/main/ui/viewer/share_link.cljs @@ -19,6 +19,9 @@ [app.main.router :as rt] [app.main.store :as st] [app.main.ui.components.select :refer [select]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.icons :as deprecated-icon] [app.util.clipboard :as clipboard] [app.util.dom :as dom] @@ -169,10 +172,11 @@ [:div {:class (stl/css :share-link-header)} [:h2 {:class (stl/css :share-link-title)} (tr "common.share-link.title")] - [:button {:class (stl/css :modal-close-button) - :on-click on-close - :title (tr "labels.close")} - deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-button) + :aria-label (tr "labels.close") + :icon i/close + :on-click on-close}]] [:div {:class (stl/css :modal-content)} [:div {:class (stl/css :share-link-section)} (when (and (not confirm?) (some? current-link)) @@ -183,10 +187,10 @@ :placeholder (tr "common.share-link.placeholder") :read-only true}] - [:button {:class (stl/css :copy-button) - :title (tr "viewer.header.share.copy-link") - :on-click copy-link} - deprecated-icon/clipboard]]) + [:> icon-button* {:variant "secondary" + :aria-label (tr "viewer.header.share.copy-link") + :icon i/clipboard + :on-click copy-link}]]) [:div {:class (stl/css :hint-wrapper)} (when (not ^boolean confirm?) @@ -197,37 +201,38 @@ [:div {:class (stl/css :description)} (tr "common.share-link.confirm-deletion-link-description")] [:div {:class (stl/css :actions)} - [:input {:type "button" - :class (stl/css :button-cancel) - :on-click #(reset! confirm* false) - :value (tr "labels.cancel")}] - [:input {:type "button" - :class (stl/css :button-danger) - :on-click delete-link - :value (tr "common.share-link.destroy-link")}]]] + [:> button* {:variant "secondary" + :type "button" + :on-click #(reset! confirm* false)} + (tr "labels.cancel")] + [:> button* {:variant "destructive" + :type "button" + :on-click delete-link} + (tr "common.share-link.destroy-link")]]] (some? current-link) - [:input - {:type "button" - :class (stl/css :button-danger) - :on-click try-delete-link - :value (tr "common.share-link.destroy-link")}] + [:> button* {:variant "destructive" + :type "button" + :on-click try-delete-link} + (tr "common.share-link.destroy-link")] :else - [:input - {:type "button" - :class (stl/css :button-active) - :on-click create-link - :value (tr "common.share-link.get-link")}])]] + [:> button* {:variant "primary" + :type "button" + :on-click create-link} + (tr "common.share-link.get-link")])]] (when (not ^boolean confirm?) [:div {:class (stl/css :permissions-section)} [:button {:class (stl/css :manage-permissions) + :type "button" + :aria-expanded perms-visible? :on-click toggle-perms-visibility} [:span {:class (stl/css-case :icon true :rotated perms-visible?)} - deprecated-icon/arrow] + [:> icon* {:icon-id i/arrow + :class (stl/css :arrow-icon)}]] (tr "common.share-link.manage-ops")] (when ^boolean perms-visible? @@ -278,7 +283,7 @@ [:ul {:class (stl/css :pages-selection)} (for [{:keys [id name]} pages] - [:li {:class (stl/css :checkbox-wrapper) + [:li {:class (stl/css :checkbox-wrapper :page-row) :key (dm/str id)} [:label {:for (dm/str "page-" id)} [:span {:class (stl/css-case :global/checked (contains? selected id))} diff --git a/frontend/src/app/main/ui/viewer/share_link.scss b/frontend/src/app/main/ui/viewer/share_link.scss index 43e2ba8910..7cb4e94245 100644 --- a/frontend/src/app/main/ui/viewer/share_link.scss +++ b/frontend/src/app/main/ui/viewer/share_link.scss @@ -4,149 +4,139 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; + +// refactor/ stays for the modal placeholder (%modal-container-base), the +// link input (%input-element) and the page checkboxes (%input-checkbox, +// %button-icon-small for their tick), which move with their own groups. @use "refactor/common-refactor.scss" as deprecated; .share-modal { display: block; position: absolute; - top: deprecated.$s-52; - right: deprecated.$s-12; - left: calc(100vw - deprecated.$s-512); - z-index: deprecated.$z-index-modal; + inset-block-start: px2rem(52); + inset-inline: calc(100vw - #{$sz-512}) var(--sp-m); + z-index: var(--z-index-set); } .share-link-dialog { @extend %modal-container-base; - min-height: unset; + min-block-size: unset; } .share-link-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .share-link-title { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } +// Tooltip wrapper of the close button: it places it in the corner. .modal-close-button { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content { - @include deprecated.body-small-typography; - @include deprecated.flex-column; + @include use-typography("body-small"); - gap: deprecated.$s-24; + display: flex; + flex-direction: column; + gap: var(--sp-xxl); } .share-link-section { - @include deprecated.flex-column; - - gap: deprecated.$s-8; + display: flex; + flex-direction: column; + gap: var(--sp-s); } .hint-wrapper { - @include deprecated.flex-row; + display: flex; + align-items: center; + gap: var(--sp-xs); } .hint { flex-grow: 1; - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } .custon-input-wrapper { - @include deprecated.flex-row; - - border-radius: deprecated.$br-8; - height: deprecated.$s-32; - width: 100%; - background-color: var(--input-background-color); + display: flex; + align-items: center; + gap: var(--sp-xs); + border-radius: $br-8; + block-size: $sz-32; + inline-size: 100%; + background-color: var(--color-background-tertiary); } .input-text { @extend %input-element; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--input-foreground-color-active); - padding-left: deprecated.$s-8; + color: var(--color-foreground-primary); + padding-inline-start: var(--sp-s); margin: 0; flex-grow: 1; &:focus { outline: none; - border: deprecated.$s-1 solid var(--input-border-color-active); - } -} - -.copy-button { - @extend %button-secondary; - @include deprecated.flex-row; - - gap: deprecated.$s-8; - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground-hover); + border: $b-1 solid var(--color-accent-primary); } } .description { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--modal-text-foreground-color); - margin-bottom: deprecated.$s-24; + color: var(--color-foreground-secondary); + margin-block-end: var(--sp-xxl); } .actions { - @include deprecated.flex-row; - + display: flex; + align-items: center; + gap: var(--sp-xs); justify-content: flex-end; } -.button-active { - @extend %modal-accept-btn; -} - -.button-cancel { - @extend %modal-cancel-btn; -} - -.button-danger { - @extend %modal-danger-btn; -} - .permissions-section { - @include deprecated.flex-column; - - gap: deprecated.$s-8; + display: flex; + flex-direction: column; + gap: var(--sp-s); } .manage-permissions { - @include deprecated.button-style; - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - color: var(--menu-foreground-color-rest); - height: deprecated.$s-32; + border: none; + background: none; + cursor: pointer; + color: var(--color-foreground-secondary); + block-size: $sz-32; display: flex; align-items: center; padding: 0; } .icon { - @include deprecated.flex-center; + display: flex; + justify-content: center; + align-items: center; + margin-inline-end: px2rem(6); - margin-right: deprecated.$s-6; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); + .arrow-icon { + flex-shrink: 0; + color: var(--color-foreground-secondary); } &.rotated { @@ -158,74 +148,76 @@ .access-mode, .inspect-mode { display: flex; - width: 100%; + inline-size: 100%; } .view-mode { - max-height: deprecated.$s-216; + max-block-size: px2rem(216); overflow: hidden auto; scrollbar-gutter: stable; } .subtitle { - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); display: flex; align-items: center; justify-content: flex-start; - width: deprecated.$s-136; - height: deprecated.$s-32; + inline-size: px2rem(136); + block-size: $sz-32; } .items { flex-grow: 1; - color: var(--input-foreground-color-active); + color: var(--color-foreground-primary); } .select-all-row { - @include deprecated.flex-row; - + display: flex; + align-items: center; + gap: var(--sp-xs); justify-content: space-between; - height: deprecated.$s-32; - border-bottom: deprecated.$s-1 solid var(--input-border-color-disabled); + block-size: $sz-32; + border-block-end: $b-1 solid var(--color-background-quaternary); } .select-all-label { - color: var(--input-foreground-color-active); + color: var(--color-foreground-primary); } .pages-selection { margin: 0; +} - li { - border-bottom: deprecated.$s-1 solid var(--input-border-color-disabled); - } +.page-row { + border-block-end: $b-1 solid var(--color-background-quaternary); - li:last-child { - border-bottom: none; + &:last-child { + border-block-end: none; } } .count-pages, .current-tag { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--input-foreground-color); + color: var(--color-foreground-secondary); } .checkbox-wrapper { @extend %input-checkbox; - height: deprecated.$s-32; + block-size: $sz-32; padding: 0; span.checked { - background-color: var(--input-checkbox-background-color-active); - border: deprecated.$s-1 solid var(--input-checkbox-background-color-active); + background-color: var(--color-accent-primary); + border: $b-1 solid var(--color-accent-primary); + // The legacy tick icon has no class; it moves with the checkboxes. svg { @extend %button-icon-small; - stroke: var(--input-checkbox-foreground-color-active); + stroke: var(--color-background-primary); } } } diff --git a/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs b/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs index 8338544f73..e1817621c6 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs @@ -15,8 +15,8 @@ [app.main.features :as features] [app.main.ui.components.numeric-input :as deprecated-input] [app.main.ui.components.reorder-handler :refer [reorder-handler*]] - [app.main.ui.components.select :refer [select]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.select :refer [select*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.formats :as fmt] [app.main.ui.hooks :as h] @@ -167,6 +167,10 @@ :on-focus handle-focus-stop-color :on-blur handle-blur-stop-color}]])) +(def ^:private gradient-type-options + [{:id "linear-gradient" :label "Linear"} + {:id "radial-gradient" :label "Radial"}]) + (mf/defc gradients* [{:keys [type stops @@ -200,9 +204,9 @@ handle-change-type (mf/use-callback (mf/deps on-change-type) - (fn [event] + (fn [type-id] (when on-change-type - (on-change-type event)))) + (on-change-type (keyword type-id))))) handle-add-stop (mf/use-callback @@ -336,12 +340,10 @@ :style {"--position" (dm/str (* offset 100) "%")}}]])]] [:div {:class (stl/css :gradient-options)} - [:& select - {:default-value type - :options [{:value :linear-gradient :label "Linear"} - {:value :radial-gradient :label "Radial"}] - :on-change handle-change-type - :class (stl/css :gradient-options-select)}] + [:> select* {:default-selected (d/name (d/nilv type :linear-gradient)) + :options gradient-type-options + :on-change handle-change-type + :wrapper-class (stl/css :gradient-options-select)}] [:div {:class (stl/css :gradient-options-buttons)} [:> icon-button* {:variant "ghost" diff --git a/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss b/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss index 0ce4019fd4..038e326ea4 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss @@ -4,18 +4,24 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for %input-element: the stop offset input moves to the DS with the +// numeric inputs group. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .gradient-panel { - margin-top: deprecated.$s-12; + margin-block-start: var(--sp-m); display: grid; - gap: deprecated.$s-4; - grid-template-rows: deprecated.$s-56 deprecated.$s-32 1fr; + gap: var(--sp-xs); + grid-template-rows: px2rem(56) $sz-32 1fr; } .gradient-preview { - width: 100%; - height: 100%; + inline-size: 100%; + block-size: 100%; display: flex; align-items: center; position: relative; @@ -23,19 +29,19 @@ } .gradient-background { - width: 100%; - height: deprecated.$s-20; - border-radius: deprecated.$br-4; + inline-size: 100%; + block-size: px2rem(20); + border-radius: $br-4; position: relative; cursor: pointer; } .gradient-preview-stop-wrapper { position: absolute; - width: calc(100% - deprecated.$s-24 - deprecated.$s-4); - height: 100%; - left: deprecated.$s-2; - top: calc(-1 * deprecated.$s-4); + inline-size: calc(100% - #{px2rem(28)}); + block-size: 100%; + inset-inline-start: px2rem(2); + inset-block-start: calc(-1 * var(--sp-xs)); pointer-events: none; } @@ -43,47 +49,47 @@ background-color: var(--color-foreground-primary); background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAADFJREFUOE9jZGBgEAFifOANPknGUQMYhkkYEEgG+NMJKAwIAbwJbdQABnBCIgRoG4gAIF8IsXB/Rs4AAAAASUVORK5CYII="); background-position: left center; - background-size: deprecated.$s-8; - border-radius: deprecated.$br-6; - border: deprecated.$s-2 solid var(--color-foreground-primary); - box-shadow: 0 0 deprecated.$s-4 0 var(--menu-shadow-color); - height: calc(deprecated.$s-24 - deprecated.$s-2); - left: var(--position); + background-size: px2rem(8); + border-radius: $br-6; + border: $b-2 solid var(--color-foreground-primary); + box-shadow: 0 0 px2rem(4) 0 var(--color-shadow-dark); + block-size: px2rem(22); + inset-inline-start: var(--position); overflow: hidden; pointer-events: initial; position: absolute; - width: calc(deprecated.$s-24 - deprecated.$s-2); + inline-size: px2rem(22); &.is-selected, &:hover { - outline: deprecated.$s-2 solid var(--color-accent-primary); + outline: $b-2 solid var(--color-accent-primary); } } .gradient-preview-stop-decoration { background: var(--color-foreground-primary); border-radius: 100%; - bottom: deprecated.$s-32; - box-shadow: 0 0 deprecated.$s-4 0 var(--menu-shadow-color); - height: deprecated.$s-4; - left: calc(var(--position) + deprecated.$s-8); + inset-block-end: var(--sp-xxxl); + box-shadow: 0 0 px2rem(4) 0 var(--color-shadow-dark); + block-size: px2rem(4); + inset-inline-start: calc(var(--position) + var(--sp-s)); position: absolute; - width: deprecated.$s-4; + inline-size: px2rem(4); } .gradient-preview-stop-color { position: absolute; - left: 0; - width: 50%; - height: 100%; + inset-inline-start: 0; + inline-size: 50%; + block-size: 100%; background: var(--color-solid); } .gradient-preview-stop-alpha { position: absolute; - left: 50%; - width: 50%; - height: 100%; + inset-inline-start: 50%; + inline-size: 50%; + block-size: 100%; background: var(--color-alpha); } @@ -98,7 +104,7 @@ } .gradient-options-select { - width: deprecated.$s-140; + inline-size: px2rem(140); } .rotate-icon { @@ -108,18 +114,18 @@ .gradient-stops-list { display: flex; flex-direction: column; - gap: deprecated.$s-4; - max-height: deprecated.$s-180; + gap: var(--sp-xs); + max-block-size: px2rem(180); overflow: hidden auto; padding: 0 0 var(--sp-s) var(--sp-m); } .gradient-stops-entry { display: flex; - gap: deprecated.$s-4; - padding: deprecated.$s-2; - border-radius: deprecated.$br-12; - border: deprecated.$s-1 solid transparent; + gap: var(--sp-xs); + padding: var(--sp-xxs); + border-radius: $br-12; + border: $b-1 solid transparent; position: relative; &.is-selected { @@ -141,26 +147,26 @@ .offset-input-wrapper { @extend %input-element; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - width: deprecated.$s-92; + inline-size: px2rem(92); } .gradient-separator { border-color: var(--color-background-quaternary); - border-width: deprecated.$s-3; - margin-left: calc(var(--sp-m) / 2); + border-width: px2rem(3); + margin-inline-start: calc(var(--sp-m) / 2); position: relative; - width: calc(100% - var(--sp-m)); + inline-size: calc(100% - var(--sp-m)); } .gradient-preview-stop-preview { background: var(--color-foreground-primary); border-radius: 50%; - height: deprecated.$s-4; - left: calc(var(--preview-position, 0%) - deprecated.$s-2); + block-size: px2rem(4); + inset-inline-start: calc(var(--preview-position, 0%) - #{px2rem(2)}); pointer-events: none; position: absolute; - top: calc(50% - deprecated.$s-2); - width: deprecated.$s-4; + inset-block-start: calc(50% - #{px2rem(2)}); + inline-size: px2rem(4); } diff --git a/frontend/src/app/main/ui/workspace/comments.cljs b/frontend/src/app/main/ui/workspace/comments.cljs index a6596bf3b6..7ab6ca7dda 100644 --- a/frontend/src/app/main/ui/workspace/comments.cljs +++ b/frontend/src/app/main/ui/workspace/comments.cljs @@ -17,10 +17,9 @@ [app.main.ui.comments :as cmt] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.context :as ctx] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.product.empty-state :refer [empty-state*]] [app.main.ui.ds.product.panel-title :refer [panel-title*]] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -56,25 +55,29 @@ :on-click update-mode} [:span {:class (stl/css :label)} (tr "labels.show-all-comments")] - [:span {:class (stl/css :icon)} deprecated-icon/tick]] + [:span {:class (stl/css :icon)} + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :tick-icon)}]]] [:li {:class (stl/css-case :dropdown-item true :selected (= :yours cmode)) :data-value "yours" :on-click update-mode} [:span {:class (stl/css :label)} (tr "labels.show-your-comments")] - [:span {:class (stl/css :icon)} deprecated-icon/tick]] + [:span {:class (stl/css :icon)} + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :tick-icon)}]]] [:li {:class (stl/css-case :dropdown-item true :selected (= :mentions cmode)) :data-value "mentions" :on-click update-mode} [:span {:class (stl/css :label)} (tr "labels.show-mentions")] - [:span {:class (stl/css :icon)} deprecated-icon/tick]] + [:span {:class (stl/css :icon)} + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :tick-icon)}]]] [:li {:class (stl/css :separator)}] [:li {:class (stl/css-case :dropdown-item true :selected (= :pending cshow)) :on-click update-show} [:span {:class (stl/css :label)} (tr "labels.hide-resolved-comments")] - [:span {:class (stl/css :icon)} deprecated-icon/tick]]])) + [:span {:class (stl/css :icon)} + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :tick-icon)}]]]])) (mf/defc comments-sidebar* [{:keys [profiles threads page-id from-viewer]}] @@ -131,6 +134,9 @@ :on-close close-section}] [:button {:class (stl/css :mode-dropdown-wrapper) + :type "button" + :aria-haspopup "menu" + :aria-expanded options? :on-click toggle-mode-selector} [:span {:class (stl/css :mode-label)} @@ -138,7 +144,8 @@ (nil :all) (tr "labels.show-all-comments") :yours (tr "labels.show-your-comments") :mentions (tr "labels.show-mentions"))] - [:div {:class (stl/css :arrow-icon)} deprecated-icon/arrow]] + [:div {:class (stl/css :arrow-icon)} + [:> icon* {:icon-id i/arrow-down :size "s" :class (stl/css :arrow-svg)}]]] [:& dropdown {:show options? :on-close #(reset! state* false)} diff --git a/frontend/src/app/main/ui/workspace/comments.scss b/frontend/src/app/main/ui/workspace/comments.scss index b94930789d..ea18d51a42 100644 --- a/frontend/src/app/main/ui/workspace/comments.scss +++ b/frontend/src/app/main/ui/workspace/comments.scss @@ -6,17 +6,22 @@ @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; + +// refactor/ stays for the dropdown placeholders (%dropdown-wrapper, +// %dropdown-element-base), which move with the menus group. @use "refactor/common-refactor.scss" as deprecated; .comments-section { position: relative; - background-color: var(--panel-background-color); + background-color: var(--color-background-primary); display: grid; - grid-template-rows: deprecated.$s-40 deprecated.$s-48 1fr; + grid-template-rows: $sz-40 $sz-48 1fr; } .from-viewer { - padding: 0 deprecated.$s-8; + padding: 0 var(--sp-s); } .comments-title { @@ -24,53 +29,60 @@ } .mode-dropdown-wrapper { - @include deprecated.button-style; - @extend %asset-element; + @include use-typography("body-small"); - background-color: var(--color-background-tertiary); + --mode-dropdown-bg-color: var(--color-background-tertiary); + + border: none; + align-items: center; + color: var(--color-foreground-primary); + background-color: var(--mode-dropdown-bg-color); display: flex; - width: 100%; - max-width: deprecated.$s-256; - height: deprecated.$s-32; - padding: deprecated.$s-8; - border-radius: deprecated.$br-8; - margin: deprecated.$s-16 auto 0 auto; + inline-size: 100%; + max-inline-size: px2rem(256); + block-size: $sz-32; + padding: var(--sp-s); + border-radius: $br-8; + margin: var(--sp-l) auto 0 auto; cursor: pointer; position: relative; + + &:hover { + --mode-dropdown-bg-color: var(--color-background-quaternary); + } } .mode-label { - padding-right: 8px; + padding-inline-end: var(--sp-s); flex-grow: 1; display: flex; justify-content: flex-start; } .arrow-icon { - @include deprecated.flex-center; + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; - height: deprecated.$s-24; - width: deprecated.$s-24; - - svg { - @extend %button-icon-small; - - transform: rotate(90deg); - stroke: var(--icon-foreground); + .arrow-svg { + flex-shrink: 0; + color: var(--color-foreground-secondary); } } .comment-mode-dropdown { @extend %dropdown-wrapper; - top: deprecated.$s-92; - left: deprecated.$s-12; - max-width: deprecated.$s-256; - width: 100%; + inset-block-start: px2rem(92); + inset-inline-start: var(--sp-m); + max-inline-size: px2rem(256); + inline-size: 100%; } .viewer-dropdown { - left: deprecated.$s-8; + inset-inline-start: var(--sp-s); } .dropdown-item { @@ -79,35 +91,40 @@ justify-content: space-between; .icon { - @include deprecated.flex-center; + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; - height: deprecated.$s-24; - width: deprecated.$s-24; - - svg { - @extend %button-icon-small; - - stroke: transparent; + // %dropdown-element-base styles any svg inside a span (12px box and a + // stroke color); the DS icon needs its 16px box and currentColor. + .tick-icon { + flex-shrink: 0; + inline-size: $sz-16; + block-size: $sz-16; + stroke: currentcolor; + color: transparent; } } .label { - @include deprecated.body-small-typography; + @include use-typography("body-small"); } &:hover { - .icon svg { - stroke: transparent; + .tick-icon { + color: transparent; } } &.selected { .label { - color: var(--menu-foreground-color); + color: var(--color-foreground-primary); } - .icon svg { - stroke: var(--icon-foreground-hover); + .tick-icon { + color: var(--color-foreground-primary); } } } @@ -116,13 +133,13 @@ position: relative; block-size: var(--sp-xs); inline-size: calc(100% + var(--sp-s)); - border-top: $b-1 solid var(--color-background-quaternary); - left: calc(-1 * var(--sp-xs)); - margin-top: var(--sp-s); + border-block-start: $b-1 solid var(--color-background-quaternary); + inset-inline-start: calc(-1 * var(--sp-xs)); + margin-block-start: var(--sp-s); } .comments-section-content { - height: 100%; + block-size: 100%; overflow-y: auto; } diff --git a/frontend/src/app/main/ui/workspace/context_menu.scss b/frontend/src/app/main/ui/workspace/context_menu.scss index 3061e59a4b..e46fae5ace 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.scss +++ b/frontend/src/app/main/ui/workspace/context_menu.scss @@ -127,7 +127,6 @@ align-items: center; inline-size: $sz-16; color: var(--color-foreground-primary); - stroke-width: 1.33px; } .separator { diff --git a/frontend/src/app/main/ui/workspace/right_header.cljs b/frontend/src/app/main/ui/workspace/right_header.cljs index 0b2f9a9b0e..b76ac50fa9 100644 --- a/frontend/src/app/main/ui/workspace/right_header.cljs +++ b/frontend/src/app/main/ui/workspace/right_header.cljs @@ -25,7 +25,6 @@ [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.exports.assets :refer [progress-widget]] [app.main.ui.formats :as fmt] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.presence :refer [active-sessions*]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -239,10 +238,11 @@ :on-click toggle-history}]]) (when display-share-button? - [:a {:class (stl/css :share-btn) - :title (tr "workspace.header.share") - :on-click open-share-dialog} - deprecated-icon/share]) + [:> icon-button* {:variant "action" + :tooltip-placement "bottom-left" + :aria-label (tr "workspace.header.share") + :icon i/share + :on-click open-share-dialog}]) [:> icon-button* {:variant "action" :tooltip-placement "bottom-left" diff --git a/frontend/src/app/main/ui/workspace/right_header.scss b/frontend/src/app/main/ui/workspace/right_header.scss index 040b270490..a67b400537 100644 --- a/frontend/src/app/main/ui/workspace/right_header.scss +++ b/frontend/src/app/main/ui/workspace/right_header.scss @@ -9,8 +9,8 @@ @use "ds/_utils.scss" as *; @use "ds/typography.scss" as *; -// Only for the shared menu placeholders, and the button placeholders of -// the share link until the DS has a share icon. +// Only for the shared menu placeholders (%menu-dropdown, %menu-item-base, +// %shortcut-*), which move with the menus group. @use "refactor/common-refactor.scss" as deprecated; .workspace-header-right { @@ -120,31 +120,6 @@ position: relative; } -// Raw link until the DS has a share icon. -.share-btn { - @extend %button-tertiary; - - border-radius: $br-8; - margin: 0; - inline-size: $sz-28; - block-size: $sz-28; - border: none; - - // The legacy share icon has no class. - svg { - @extend %button-icon; - - block-size: $sz-16; - inline-size: $sz-16; - stroke: var(--color-foreground-secondary); - } - - &:hover { - background-color: transparent; - border: none; - } -} - .unread { position: absolute; inline-size: px2rem(8); diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.scss b/frontend/src/app/main/ui/workspace/sidebar/assets.scss index 6d57b06db7..63bb123b08 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.scss @@ -41,7 +41,6 @@ .section-icon { flex-shrink: 0; - stroke-width: 1px; color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss index 9f770611f7..27ef6f4b32 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss @@ -5,6 +5,7 @@ // Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; +@use "ds/typography.scss" as t; @use "ds/_utils.scss" as *; .shape-info { @@ -13,26 +14,19 @@ background-color: var(--color-background-primary); color: var(--color-foreground-primary); - // TODO: No exact DS typography match found. - // Closest match: body-small. - font-size: px2rem(12); + @include t.use-typography("body-small"); + user-select: text; } -// TODO: Unused in debug_shape_info.cljs; check by hand before removing. -// .shape-info-title { -// margin: var(--sp-s) var(--sp-s) 0 var(--sp-s); -// } - .attrs-container { padding: var(--sp-l) var(--sp-s); overflow: hidden; } .shape-title { - // TODO: No exact DS typography match found. - // Closest match: body-medium. - font-size: px2rem(14); + @include t.use-typography("body-medium"); + background: var(--color-background-quaternary); color: var(--color-foreground-primary); padding: var(--sp-s); diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss index d7cbad6289..18a1395d52 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss @@ -211,8 +211,6 @@ } .toggle-content-icon { - // Same stroke weight as the old 12px icons. - stroke-width: 1.33px; color: var(--color-foreground-secondary); .layer-row.hidden & { @@ -284,9 +282,6 @@ // The DS icon box is 16px and the button leaves 12px: keep the box // whole, centered, instead of letting it shrink and cut the icon. flex-shrink: 0; - - // Same stroke weight as the old 12px icons. - stroke-width: 1.33px; color: var(--color-foreground-secondary); .layer-row.hidden & { diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs index db497cf242..95afbd2922 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs @@ -93,7 +93,7 @@ name (str/trim (dom/get-value name-input))] (on-stop-edit) (reset! edition* false) - (st/emit! (dw/end-rename-shape shape-id name)) + (st/emit! (dw/rename-shape-or-variant shape-id name)) (when (fn? on-tab-press) (on-tab-press event))))))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 5b4f23d1db..f3c9764e15 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -396,7 +396,7 @@ (mf/use-fn (mf/deps component-ids) (fn [pos value] - (let [value (ctv/normalize-property-text (d/nilv value ""))] + (when-let [value (ctv/prepare-property-value value)] (doseq [id component-ids] (st/emit! (ev/event {::ev/name "variant-edit-property-value" ::ev/origin "workspace:combo-design-tab"}) @@ -407,11 +407,11 @@ (mf/use-fn (mf/deps variant-id) (fn [event] - (let [value (ctv/normalize-property-text (dom/get-target-val event)) + (let [value (ctv/prepare-property-name (dom/get-target-val event)) pos (-> (dom/get-current-target event) (dom/get-data "position") int)] - (when (ctv/valid-property-name? value) + (when value (st/emit! (dwv/update-property-name variant-id pos value {:trigger "workspace:design-tab-variant"})))))) @@ -1246,11 +1246,11 @@ (mf/use-fn (mf/deps variant-id) (fn [event] - (let [value (ctv/normalize-property-text (dom/get-target-val event)) + (let [value (ctv/prepare-property-name (dom/get-target-val event)) pos (-> (dom/get-current-target event) (dom/get-data "position") int)] - (when (ctv/valid-property-name? value) + (when value (st/emit! (dwv/update-property-name variant-id pos value {:trigger "workspace:design-tab-component"})))))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs index 28443e1e7e..b06f672e9c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs @@ -508,30 +508,37 @@ :title "Align self end" :id "align-self-end"}]]) +;; A multiple selection gives nil for missing attrs and :multiple for mixed ones +(def ^:private schema:number-value + [:maybe [:or :float :int [:= :multiple]]]) + (def ^:private schema:layout-item-props-schema [:map [:layout-item-margin {:optional true} - [:map - [:m1 {:optional true} [:or :float :int]] - [:m2 {:optional true} [:or :float :int]] - [:m3 {:optional true} [:or :float :int]] - [:m4 {:optional true} [:or :float :int]]]] + [:maybe + [:or + [:= :multiple] + [:map + [:m1 {:optional true} schema:number-value] + [:m2 {:optional true} schema:number-value] + [:m3 {:optional true} schema:number-value] + [:m4 {:optional true} schema:number-value]]]]] - [:layout-item-margin-type {:optional true} :keyword] + [:layout-item-margin-type {:optional true} [:maybe :keyword]] - [:layout-item-h-sizing {:optional true} :keyword] - [:layout-item-v-sizing {:optional true} :keyword] + [:layout-item-h-sizing {:optional true} [:maybe :keyword]] + [:layout-item-v-sizing {:optional true} [:maybe :keyword]] - [:layout-item-min-w {:optional true} [:or :float :int]] - [:layout-item-max-w {:optional true} [:or :float :int]] - [:layout-item-min-h {:optional true} [:or :float :int]] - [:layout-item-max-h {:optional true} [:or :float :int]]]) + [:layout-item-min-w {:optional true} schema:number-value] + [:layout-item-max-w {:optional true} schema:number-value] + [:layout-item-min-h {:optional true} schema:number-value] + [:layout-item-max-h {:optional true} schema:number-value]]) (def ^:private schema:layout-size-constraints [:map [:values schema:layout-item-props-schema] - [:applied-tokens [:maybe [:map-of :keyword :string]]] + [:applied-tokens [:maybe [:map-of :keyword [:maybe [:or :string [:= :multiple]]]]]] [:ids [::sm/vec ::sm/uuid]]]) (mf/defc layout-size-constraints* diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs index 357f1faf34..2c5374ced8 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs @@ -181,7 +181,7 @@ (let [name-input (mf/ref-val ref) name (str/trim (dom/get-value name-input))] (reset! edition* false) - (st/emit! (dw/end-rename-shape frame-id name)) + (st/emit! (dw/rename-shape-or-variant frame-id name)) (on-frame-leave frame-id)))) cancel-edit diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index ed3c12c906..be94b45249 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -779,12 +779,13 @@ :renameProperty (fn [pos name] - (let [nprops (->> (get-variant-components file-id id) first :variant-properties count)] + (let [nprops (->> (get-variant-components file-id id) first :variant-properties count) + prepared (ctv/prepare-property-name name)] (cond (or (not (nat-int? pos)) (>= pos nprops)) (u/not-valid plugin-id :pos pos) - (not (ctv/valid-property-name? name)) + (nil? prepared) (u/not-valid plugin-id :name name) (not (r/check-permission plugin-id "library:write")) @@ -792,7 +793,7 @@ :else (st/emit! - (dwv/update-property-name id pos (ctv/normalize-property-text name) + (dwv/update-property-name id pos prepared {:trigger "plugin:rename-property"}))))))) (set! shape/variant-proxy variant-proxy) @@ -917,6 +918,9 @@ (not (r/check-permission plugin-id "library:write")) (u/not-valid plugin-id :restore "Plugin doesn't have 'library:write' permission") + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :restore "Can only restore components from the current file's library") + (nil? component) (u/not-valid plugin-id :restore "Component does not exist") @@ -1087,12 +1091,13 @@ :setVariantProperty (fn [pos value] - (let [nprops (-> (u/locate-library-component file-id id) :variant-properties count)] + (let [nprops (-> (u/locate-library-component file-id id) :variant-properties count) + prepared (ctv/prepare-property-value value)] (cond (or (not (nat-int? pos)) (>= pos nprops)) (u/not-valid plugin-id :pos (str pos)) - (not (ctv/valid-property-value? value)) + (nil? prepared) (u/not-valid plugin-id :value value) (not (r/check-permission plugin-id "library:write")) @@ -1101,7 +1106,7 @@ :else (st/emit! (se/event plugin-id "variant-edit-property-value") - (dwv/update-property-value id pos (ctv/normalize-property-text value)))))))) + (dwv/update-property-value id pos prepared))))))) (defn library-proxy? [p] (obj/type-of? p "LibraryProxy")) diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 89cfdd6daf..9a86ebd0d3 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -70,8 +70,7 @@ [app.plugins.utils :as u] [app.util.http :as http] [app.util.object :as obj] - [beicon.v2.core :as rx] - [cuerdas.core :as str])) + [beicon.v2.core :as rx])) (declare shape-proxy) (declare shape-proxy?) @@ -385,11 +384,8 @@ :get #(-> % u/proxy->shape :name) :set (fn [self value] - (let [id (obj/get self "$id") - value (when (string? value) (-> value str/trim cpn/clean-path)) - valid? (and (some? value) - (not (str/ends-with? value "/")) - (not (str/blank? value)))] + (let [id (obj/get self "$id") + value (cpn/normalize-path-name value)] (cond (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") @@ -397,7 +393,7 @@ (not (u/page-active? page-id)) (u/not-valid plugin-id :name "Cannot modify a page that is not currently active") - (not valid?) + (nil? value) (u/not-valid plugin-id :name value) :else diff --git a/frontend/src/app/util/globals.js b/frontend/src/app/util/globals.js index 5ac6cd3130..8b398ba53d 100644 --- a/frontend/src/app/util/globals.js +++ b/frontend/src/app/util/globals.js @@ -84,6 +84,9 @@ goog.scope(function () { forward() {}, go() {}, }; + // `app.main.data.workspace.mcp` talks to a browser extension through + // window messages; nothing receives them here. + mockWindow.postMessage = function () {}; return mockWindow; } })(); diff --git a/frontend/src/app/util/http.cljs b/frontend/src/app/util/http.cljs index 62a8b1cac7..97638f7231 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -146,6 +146,26 @@ :body (.-body ^js response) ::response response}) +;; Messages of the TypeError a failed `fetch` rejects with, or a failed +;; read of its response body. +(def ^:private network-failure-messages + #{"Failed to fetch" ; Chrome + "NetworkError when attempting to fetch resource." ; Firefox + "Load failed" ; Safari + "network error" ; Chrome, body read + "Error in body stream"}) ; Firefox, body read + +(defn network-failure? + "True when the error is the browser's TypeError for a request that never + got an answer. Other TypeErrors, such as reading a body twice, are bugs + and do not match." + [cause] + (and (= (.-name ^js cause) "TypeError") + (let [message (ex-message cause)] + (or (contains? network-failure-messages message) + ;; Newer Chrome versions append the host in parentheses + (str/starts-with? message "Failed to fetch ("))))) + (defn process-response-type [response-type response] (let [native-response (::response response) @@ -156,7 +176,19 @@ :blob (.blob ^js native-response))] (->> (rx/from body) (rx/map (fn [body] - (assoc response :body body)))))) + (assoc response :body body))) + ;; The connection can drop while the body streams in, after + ;; `fetch` already resolved; the browser then rejects the read + ;; with a bare TypeError. Other errors, such as parse failures + ;; (SyntaxError), pass as is. + (rx/catch (fn [cause] + (if (network-failure? cause) + (rx/throw (ex-info (ex-message cause) + {:type :network + :hint "unable to read response body" + :uri (:uri response)} + cause)) + (rx/throw cause))))))) (defn send! [{:keys [response-type] :or {response-type :text} :as params}] diff --git a/frontend/src/app/util/theme.cljs b/frontend/src/app/util/theme.cljs index 75c0837d40..b5c0437a6e 100644 --- a/frontend/src/app/util/theme.cljs +++ b/frontend/src/app/util/theme.cljs @@ -17,7 +17,7 @@ (defonce ^:private color-scheme-listeners* (atom #{})) -(def ^:const default "dark") +(def ^:const default "system") (defn get-system-theme [] @@ -47,16 +47,17 @@ (defn resolve-theme "Resolves the profile's theme setting to the effective UI theme ('dark' or - 'light'): 'system' follows the given system theme, 'default' and an unset - theme mean dark, and any other value is taken as-is. Single source of truth - for the app's own theme and the theme reported to plugins." + 'light'). An unset preference or 'system' follows the system theme; + the legacy 'default' preference means dark. Used by the app and plugins." [profile-theme system-theme] - (cond - (= profile-theme "system") system-theme - (= profile-theme "default") "dark" - :else (d/nilv profile-theme "dark"))) + (let [preference (d/nilv profile-theme default)] + (case preference + "system" system-theme + "default" "dark" + preference))) -(defn use-initialize +(defn use-theme + "Return the effective theme and follow system preference changes." [{profile-theme :theme}] (let [system-theme* (mf/use-state get-system-theme) system-theme (deref system-theme*)] @@ -68,6 +69,11 @@ (fn [] (rx/dispose! s)))) - (mf/with-effect [system-theme profile-theme] - (set-color-scheme (resolve-theme profile-theme system-theme)) + (resolve-theme profile-theme system-theme))) + +(defn use-initialize + [profile] + (let [color (use-theme profile)] + (mf/with-effect [color] + (set-color-scheme color) (notify-color-scheme-listeners!)))) diff --git a/frontend/src/app/worker/index.cljs b/frontend/src/app/worker/index.cljs index 1117440563..cafcf0c591 100644 --- a/frontend/src/app/worker/index.cljs +++ b/frontend/src/app/worker/index.cljs @@ -35,27 +35,44 @@ (log/dbg :hint "page indexed" :id (:id page) :elapsed elapsed ::log/sync? true)))) nil)) +(defmethod impl/handler :index/finalize + [{:keys [page-id]}] + (swap! state #(-> % + (update :pages-index dissoc page-id) + (update ::selection dissoc page-id) + (update ::snap dissoc page-id))) + (log/dbg :hint "page index dropped" :id page-id ::log/sync? true) + nil) + (defmethod impl/handler :index/update [{:keys [page-id changes] :as message}] (let [tpoint (ct/tpoint-ms)] (try - (let [old-page (dm/get-in @state [:pages-index page-id]) - new-page (-> state - (swap! ch/process-changes changes false) - (dm/get-in [:pages-index page-id])) + (if-let [old-page (dm/get-in @state [:pages-index page-id])] + (let [new-page (-> state + (swap! ch/process-changes changes false) + (dm/get-in [:pages-index page-id]))] + (if (nil? new-page) + ;; The changes deleted the page + (swap! state #(-> % + (update ::snap dissoc page-id) + (update ::selection dissoc page-id))) - text-rects (dm/get-in @state [::text-rect page-id]) + (let [text-rects (dm/get-in @state [::text-rect page-id]) - ;; Update page objects with the text data - new-page - (reduce-kv - (fn [page id data] - (update-in page [:objects id] d/patch-object data)) - new-page - text-rects)] + ;; Update page objects with the text data + new-page + (reduce-kv + (fn [page id data] + (update-in page [:objects id] d/patch-object data)) + new-page + text-rects)] - (swap! state update ::snap snap/update-page old-page new-page) - (swap! state update ::selection selection/update-page old-page new-page)) + (swap! state update ::snap snap/update-page old-page new-page) + (swap! state update ::selection selection/update-page old-page new-page)))) + + ;; A later `:index/initialize` sends the whole page again + (log/dbg :hint "page not indexed, skipping update" :id page-id ::log/sync? true)) (catch :default cause (log/error :hint "error updating page index" :id page-id :cause cause)) (finally diff --git a/frontend/test/frontend_tests/data/exports_files_test.cljs b/frontend/test/frontend_tests/data/exports_files_test.cljs index fc648c3b55..9e7f15ebd5 100644 --- a/frontend/test/frontend_tests/data/exports_files_test.cljs +++ b/frontend/test/frontend_tests/data/exports_files_test.cljs @@ -125,7 +125,8 @@ (t/is (= {:file-id (:id target) :uri "http://assets/export" - :filename "My file"} + :filename "My file" + :size 120} (last @seen))))))))) (t/deftest ^:async a-failure-fails-only-its-own-file @@ -165,7 +166,8 @@ (t/testing "the failure does not touch the file that worked" (t/is (= {:file-id (:id one) :uri "http://assets/first" - :filename "First"} + :filename "First" + :size nil} (first (filter #(and (= (:id one) (:file-id %)) (:uri %)) @seen))))) diff --git a/frontend/test/frontend_tests/data/imports_test.cljs b/frontend/test/frontend_tests/data/imports_test.cljs index e73802fe38..e173ec3671 100644 --- a/frontend/test/frontend_tests/data/imports_test.cljs +++ b/frontend/test/frontend_tests/data/imports_test.cljs @@ -137,6 +137,7 @@ (mock/with-mocks* {http/send! (slurp-stub zip-magic) uz/get-entry (mock/stub (fn [_ _] (p/resolved ::entry))) + uz/get-entries (mock/stub (fn [_] (p/resolved #js []))) uz/read-as-text (mock/stub (fn [_] (p/resolved (manifest [first-id "First"] [second-id "Second"])))) uz/close (mock/stub (fn [_] nil))} @@ -153,6 +154,62 @@ (t/testing "all of them share the upload of the file that was dropped" (t/is (every? #(= uri (:uri %)) @seen))))))) +(t/deftest ^:async a-version-3-package-sizes-every-file-with-what-it-occupies + (let [first-id (uuid/next) + second-id (uuid/next) + unknown-id (uuid/next) + object-id (str (uuid/next)) + seen (atom []) + ;; the entries of a container: every file keeps its own under + ;; `files/`, the binaries of its media shared under `objects/`, + ;; and the manifest belongs to the container itself + entries + #js [#js {:filename "manifest.json" + :compressedSize 100} + #js {:filename (str "files/" first-id ".json") + :compressedSize 200} + #js {:filename (str "files/" first-id "/pages/one.json") + :compressedSize 300} + #js {:filename (str "files/" first-id "/media/one.json") + :compressedSize 10 + :content (str "{\"mediaId\": \"" object-id "\"}")} + #js {:filename (str "objects/" object-id ".json") + :compressedSize 20} + #js {:filename (str "objects/" object-id ".png") + :compressedSize 1000} + #js {:filename (str "files/" second-id ".json") + :compressedSize 400}]] + + (await + (mock/with-mocks* + {http/send! (slurp-stub zip-magic) + ;; the manifest is read through the same reader as the rest of the + ;; entries, so the fake carries its text and the mock answers what + ;; every entry holds + uz/get-entry (mock/stub (fn [_ _] + (p/resolved #js {:content (manifest [first-id "First"] + [second-id "Second"] + [unknown-id "Unknown"])}))) + uz/get-entries (mock/stub (fn [_] (p/resolved entries))) + uz/read-as-text (mock/stub (fn [entry] (p/resolved (.-content entry)))) + uz/close (mock/stub (fn [_] nil))} + + (await (hva/observe (imp/analyze [{:uri uri :name "package.penpot" :size 10000}]) + {:on-next #(swap! seen conj %)})))) + + (t/testing "a file occupies its own entries and the objects it references" + ;; 200 + 300 + 10 of its own, and 20 + 1000 of the object its media + ;; points at; the 100 of the manifest belong to the container + (t/is (= [1530 400 nil] (mapv :size @seen)))) + + (t/testing "a file the container does not hold has no size" + ;; the manifest lists it, but no entry of the zip belongs to it: the + ;; row says nothing rather than borrowing what the package weighs + (t/is (nil? (:size (first (filter #(= unknown-id (:file-id %)) @seen)))))) + + (t/testing "what the container occupies is kept apart" + (t/is (= [10000 10000 10000] (mapv :container-size @seen)))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; IMPORTING A PACKAGE ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -510,6 +567,190 @@ (rx/push! ws-stream (message (event job-id :end {:outcome "completed"}))) (await done)))))) +(t/deftest format-file-size-writes-human-sizes + (t/testing "unknown sizes show nothing" + (t/is (nil? (imp/format-file-size nil))) + (t/is (nil? (imp/format-file-size -1)))) + + (t/testing "bytes, kilobytes, megabytes and gigabytes" + (t/is (= "0B" (imp/format-file-size 0))) + (t/is (= "512B" (imp/format-file-size 512))) + (t/is (= "1KB" (imp/format-file-size 1024))) + (t/is (= "1.5KB" (imp/format-file-size 1536))) + (t/is (= "512KB" (imp/format-file-size (* 512 1024)))) + (t/is (= "234MB" (imp/format-file-size (* 234 1024 1024)))) + (t/is (= "1.2GB" (imp/format-file-size (* 1.2 1024 1024 1024)))))) + +(defn- uploading + "A file of the wizard while the container it came in is uploaded: the + chunks sent out of the chunks the container has." + [uri size current total] + {:file-id (uuid/next) + :uri uri + :size (* 1024 1024) + :container-size size + :type :binfile-v3 + :status :import-progress + :progress {:stage :upload :counters {:upload {:current current :total total}}}}) + +(t/deftest upload-pct-weighs-every-container-by-its-size + (let [small (* 100 1024 1024) + big (* 300 1024 1024)] + + (t/testing "the bytes sent of every container over the bytes of all of them" + ;; 100MB of 100MB and half of 300MB: 250 of 400, not the average of + ;; 100% and 50% + (t/is (= 63 (imp/upload-pct [(uploading "a" small 4 4) + (uploading "b" big 1 2)])))) + + (t/testing "a container that has not started counts as nothing sent" + (t/is (= 25 (imp/upload-pct [(uploading "a" small 4 4) + {:uri "b" :container-size big :type :binfile-v3 + :status :import-ready}])))) + + (t/testing "the files of one container are one container" + (t/is (= 100 (imp/upload-pct [(uploading "a" big 1 1) + (uploading "a" big 1 1)])))) + + (t/testing "a container with no size leaves the bar indeterminate" + (t/is (nil? (imp/upload-pct []))) + (t/is (nil? (imp/upload-pct [{:uri "a" :type :binfile-v3 :status :import-ready}])))))) + +(t/deftest a-container-past-the-upload-counts-whole + ;; the job of a container is created with its upload as its input, so the + ;; moment the entries leave the upload stage the container is sent + (t/is (= 100 (imp/upload-pct [{:uri "a" :name "a.penpot" :container-size (* 100 1024 1024) + :type :binfile-v1 :status :import-queued} + {:uri "b" :name "b.penpot" :container-size (* 300 1024 1024) + :type :binfile-v1 :status :import-success}])))) + +(t/deftest upload-done?-tells-when-the-chunks-of-a-container-are-sent + (t/testing "the last chunk closes the upload" + (t/is (true? (imp/upload-done? (uploading "a" 100 3 3)))) + (t/is (false? (imp/upload-done? (uploading "a" 100 2 3))))) + + (t/testing "a file that is not uploading yet is not done" + (t/is (false? (imp/upload-done? {:status :import-ready}))) + (t/is (false? (imp/upload-done? {}))))) + +(t/deftest imported-count-counts-the-files-the-job-left-behind + (let [file (fn [uri status progress] + (cond-> {:uri uri :type :binfile-v3 :status status} + (some? progress) (assoc :progress progress)))] + + (t/testing "a container that finished counts all of its files" + (t/is (= 2 (imp/imported-count [(file "a" :import-success nil) + (file "a" :import-success nil)])))) + + (t/testing "a running container counts the files it left behind" + ;; its milestone says it is working on the second file of three + (t/is (= 1 (imp/imported-count [(file "a" :import-progress + {:stage :pages + :counters {:files {:current 2 :total 3} + :pages {:current 1 :total 5}}})])))) + + (t/testing "a container that has not started has nothing done" + (t/is (= 0 (imp/imported-count [(file "a" :import-queued nil)]))) + (t/is (= 0 (imp/imported-count [(file "a" :import-progress + {:stage :pages :counters {}})])))) + + (t/testing "the files of every container add up" + (t/is (= 3 (imp/imported-count [(file "a" :import-success nil) + (file "a" :import-success nil) + (file "b" :import-progress + {:stage :pages + :counters {:files {:current 2 :total 4}}})])))) + + (t/testing "no files, nothing done" + (t/is (= 0 (imp/imported-count [])))))) + +(t/deftest waiting-slot?-is-true-until-a-worker-picks-the-jobs-up + (t/testing "every file queued is a wizard waiting for a slot" + (t/is (true? (imp/waiting-slot? [{:status :import-queued} + {:status :import-queued}])))) + + (t/testing "a job in course leaves the wait behind" + (t/is (false? (imp/waiting-slot? [{:status :import-queued} + {:status :import-progress}])))) + + (t/testing "a file already imported is not waiting either" + (t/is (false? (imp/waiting-slot? [{:status :import-success}])))) + + (t/testing "no files, no wait" + (t/is (false? (imp/waiting-slot? []))))) + +(t/deftest awaiting-analysis-hides-the-rows-while-checking + (t/testing "only a clean analysis shows the placeholder" + (t/is (true? (imp/awaiting-analysis? :analyze false))) + (t/is (false? (imp/awaiting-analysis? :analyze true)))) + (t/testing "any later stage shows the rows" + (t/is (false? (imp/awaiting-analysis? :import-ready false))) + (t/is (false? (imp/awaiting-analysis? :import-progress false))) + (t/is (false? (imp/awaiting-analysis? :import-success false))))) + +(t/deftest ready-to-import?-waits-for-the-whole-analysis + (t/testing "every file found and ready, with the analysis over" + (t/is (true? (imp/ready-to-import? [{:status :import-ready} + {:status :import-ready}] + true)))) + + (t/testing "a file that has not arrived keeps the wizard checking" + ;; the files of a container are only known once its manifest is read, so + ;; offering the import step here would leave the missing one behind + (t/is (false? (imp/ready-to-import? [{:status :import-ready}] false)))) + + (t/testing "a file that cannot be imported is not ready either" + (t/is (false? (imp/ready-to-import? [{:status :import-ready} + {:status :analyze-error}] + true)))) + + (t/testing "no files at all is not ready" + (t/is (false? (imp/ready-to-import? [] true))))) + +(t/deftest import-step-follows-the-wizard-status + (t/testing "analysis paints the check step" + (t/is (= :check (imp/import-step :analyze []))) + (t/is (= :check (imp/import-step :import-ready [])))) + + (t/testing "an upload milestone paints the upload step" + (let [entries [{:file-id (uuid/next) + :status :import-progress + :progress {:stage :upload :counters {:upload {:current 1 :total 2}}}}]] + (t/is (= :upload (imp/import-step :import-progress entries))))) + + (t/testing "a file that has not reported anything yet is still uploading" + ;; the wizard is uploading from the click, before the first chunk arrives + (let [entries [{:file-id (uuid/next) :status :import-ready} + {:file-id (uuid/next) + :status :import-progress + :progress {:stage :upload :counters {:upload {:current 1 :total 2}}}}]] + (t/is (= :upload (imp/import-step :import-progress entries))))) + + (t/testing "a finished upload with its job still to come is uploading" + (let [entries [{:file-id (uuid/next) + :status :import-progress + :progress {:stage :upload :counters {:upload {:current 2 :total 2}}}} + {:file-id (uuid/next) + :status :import-progress + :progress {:stage :upload :counters {:upload {:current 1 :total 4}}}}]] + (t/is (= :upload (imp/import-step :import-progress entries))))) + + (t/testing "queued or job milestones paint the import step" + (let [queued [{:file-id (uuid/next) :status :import-queued}] + started [{:file-id (uuid/next) :status :import-progress}] + pages [{:file-id (uuid/next) + :status :import-progress + :progress {:stage :pages :counters {:pages {:current 1 :total 8}}}}]] + (t/is (= :import (imp/import-step :import-progress queued))) + (t/is (= :import (imp/import-step :import-progress started))) + (t/is (= :import (imp/import-step :import-progress pages))))) + + (t/testing "terminal and resolution stages paint the import step" + (t/is (= :import (imp/import-step :import-success []))) + (t/is (= :import (imp/import-step :import-error []))) + (t/is (= :import (imp/import-step :library-resolution []))) + (t/is (= :import (imp/import-step :library-summary []))))) + (t/deftest ^:async cancel-job-asks-the-server-and-ignores-failures (let [calls (atom []) job-id (uuid/next)] diff --git a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs index 802be3ae2f..77c685036d 100644 --- a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs @@ -13,6 +13,7 @@ [app.main.data.profile :as du] [app.main.data.workspace.mcp :as mcp] [app.plugins.register :as preg] + [app.util.globals :as globals] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [frontend-tests.helpers.async :as a] @@ -58,6 +59,18 @@ (t/is (false? (get-in result [:profile :props :mcp-enabled]))) (t/is (false? (get-in result [:mcp :enabled])))))) +(t/deftest test-extension-status + (t/testing "keeps a session the plugin is reconnecting" + (t/is (= "connecting" (mcp/extension-status "disconnected" true)))) + + (t/testing "reports the disconnect once the user has stopped the connection" + (t/is (= "disconnected" (mcp/extension-status "disconnected" false))) + (t/is (= "disconnected" (mcp/extension-status "disconnected" nil)))) + + (t/testing "passes other statuses through" + (t/is (= "connected" (mcp/extension-status "connected" true))) + (t/is (= "error" (mcp/extension-status "error" true))))) + (t/deftest test-update-mcp-connection-status (t/testing "sets connection status to connected" (let [state {:mcp {:connection-requested true :connection-status "disconnected"}} @@ -171,6 +184,47 @@ :on-next #(swap! events conj (ptk/type %)))) (t/is (= [:app.main.data.workspace.mcp/connect] @events)))) +(defn- connect + "Runs the effect of a connection request made with the given MCP state." + [mcp-state] + (let [event (mcp/connect-mcp)] + (ptk/effect event (ptk/update event {:mcp mcp-state}) nil))) + +(t/deftest ^:async test-session-lock-follows-connection-request + (let [requests (atom []) + released (atom 0) + locks #js {:request (fn [lock-name options callback] + (swap! requests conj [lock-name (.-mode options)]) + (.then (callback nil) #(swap! released inc)))}] + (await + (mock/with-mocks* + {globals/navigator #js {:locks locks}} + (connect {:enabled false}) + (t/is (empty? @requests)) + (connect {:enabled true :token-valid true}) + (connect {:enabled true :token-valid true}) + (t/is (= [["penpot-mcp-session" "shared"]] @requests)) + (ptk/effect (mcp/user-disconnect-mcp) nil nil) + (await (a/settle)) + (t/is (= 1 @released)) + (ptk/effect (mcp/user-disconnect-mcp) nil nil) + (await (a/settle)) + (t/is (= 1 @released)))))) + +(t/deftest ^:async test-session-lock-retried-after-failed-request + (let [requests (atom 0) + locks #js {:request (fn [_ _ _] + (swap! requests inc) + (js/Promise.reject (js/Error. "lock request denied")))}] + (await + (mock/with-mocks* + {globals/navigator #js {:locks locks}} + (connect {:enabled true :token-valid true}) + (await (a/settle)) + (connect {:enabled true :token-valid true}) + (await (a/settle)) + (t/is (= 2 @requests)))))) + (t/deftest test-disconnect-clears-connection-intent (let [state {:mcp {:connection-requested true :connection-status "connecting" diff --git a/frontend/test/frontend_tests/data/workspace_notifications_test.cljs b/frontend/test/frontend_tests/data/workspace_notifications_test.cljs new file mode 100644 index 0000000000..aceafe6bed --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_notifications_test.cljs @@ -0,0 +1,52 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.data.workspace-notifications-test + (:require + [app.common.data :as d] + [app.common.uuid :as uuid] + [app.main.data.changes :as dch] + [app.main.data.notifications :as ntf] + [app.main.data.workspace.notifications :as dwntf] + [app.util.i18n :as i18n] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] + [potok.v2.core :as ptk])) + +(defn- file-change-msg + [extra] + (merge {:type :file-change + :profile-id (uuid/next) + :file-id (uuid/next) + :session-id (uuid/next) + :revn 1 + :vern 0 + :changes []} + extra)) + +(defn- ^:async watch-file-change + [msg] + (let [events (atom [])] + (await (async/observe (ptk/watch (dwntf/handle-file-change msg) {} (rx/empty)) + :on-next #(swap! events conj %))) + @events)) + +(t/deftest ^:async repaired-file-change-applies-and-notifies + (let [events (await (watch-file-change (file-change-msg {:repaired true}))) + types (mapv ptk/type events) + show (d/seek #(= ::ntf/show (ptk/type %)) events) + state (ptk/update show {})] + (t/is (= [::dch/commit ::ntf/show] types)) + (t/is (= :info (get-in state [:notification :level]))) + (t/is (nil? (get-in state [:notification :timeout])) + "the toast stays until the user closes it") + (t/is (= (i18n/tr "workspace.notifications.file-repaired") + (get-in state [:notification :content]))))) + +(t/deftest ^:async ordinary-file-change-does-not-notify + (let [events (await (watch-file-change (file-change-msg {})))] + (t/is (= [::dch/commit] (mapv ptk/type events))))) diff --git a/frontend/test/frontend_tests/data/workspace_variant_rename_test.cljs b/frontend/test/frontend_tests/data/workspace_variant_rename_test.cljs new file mode 100644 index 0000000000..5e177b1b21 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_variant_rename_test.cljs @@ -0,0 +1,232 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.data.workspace-variant-rename-test + (:require + [app.common.test-helpers.components :as cthc] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.test-helpers.variants :as thv] + [app.common.types.file :as ctf] + [app.common.types.pages-list :as ctpl] + [app.main.data.workspace :as dw] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths])) + +(t/use-fixtures :each + {:before cthi/reset-idmap!}) + +(defn- variant-file + [] + (-> (cthf/sample-file :file1) + (thv/add-variant :variant :comp1 :root1 :comp2 :root2 + :variant1-params {:name "Button"} + :variant2-params {:name "Button"}) + (cths/update-shape :variant :name "Button"))) + +(defn- without-variant-name + [file label] + (let [page-id (cthf/current-page-id file) + shape-id (cthi/id label)] + (ctf/update-file-data + file + (fn [data] + (ctpl/update-page data + page-id + #(update-in % [:objects shape-id] dissoc :variant-name)))))) + +(defn- prop-value + [component] + (-> component :variant-properties first :value)) + +(defn- run + "Run workspace events, then validate the file before assertions. + + Pass `:validate? false` only when the fixture is intentionally incomplete + (e.g. a variant main without `:variant-name`)." + [file events done assert-fn & {:keys [validate?] :or {validate? true}}] + (ths/run-store + (ths/setup-store file) + done + events + (fn [state] + (when validate? + (cthf/validate-file! (ths/get-file-from-state state))) + (assert-fn state)))) + +(t/deftest end-rename-shape-does-not-apply-variant-formula + (t/async + done + (let [file (variant-file) + component (cthc/get-component file :comp1) + original-name (:name component) + original-path (:path component) + root2-id (cthi/id :root2)] + (run file + [(dw/start-rename-shape (cthi/id :root1)) + (dw/end-rename-shape (cthi/id :root1) "Property 1=another 21") + (dw/start-rename-shape root2-id)] + done + (fn [state] + (let [file (ths/get-file-from-state state) + shape (cths/get-shape file :root1) + component (cthc/get-component file :comp1)] + ;; Formula updates belong to rename-shape-or-variant. + (t/is (= "Value1" (prop-value component))) + (t/is (= "Value1" (:variant-name shape))) + (t/is (= "Button" (:name shape))) + (t/is (= "Button" (:name (cths/get-shape file :variant)))) + (t/is (= original-name (:name component))) + (t/is (= original-path (:path component))) + (t/is (= "Value2" (prop-value (cthc/get-component file :comp2)))) + (t/is (= root2-id (:shape-for-rename (:workspace-local state)))))))))) + +(t/deftest rename-shape-or-variant-updates-variant-value-and-keeps-layer-name + (t/async + done + (let [file (variant-file) + component (cthc/get-component file :comp1) + original-name (:name component) + original-path (:path component)] + (run file + [(dw/start-rename-shape (cthi/id :root1)) + (dw/rename-shape-or-variant (cthi/id :root1) "Property 1=another 21")] + done + (fn [state] + (let [file (ths/get-file-from-state state) + shape (cths/get-shape file :root1) + component (cthc/get-component file :comp1)] + (t/is (= "another 21" (prop-value component))) + (t/is (= "another 21" (:variant-name shape))) + (t/is (= "Button" (:name shape))) + (t/is (= original-name (:name component))) + (t/is (= original-path (:path component))) + (t/is (nil? (:shape-for-rename (:workspace-local state)))))))))) + +(t/deftest rename-shape-or-variant-clears-shape-for-rename-on-malformed-formula + (t/async + done + (run (variant-file) + [(dw/start-rename-shape (cthi/id :root1)) + (dw/rename-shape-or-variant (cthi/id :root1) "not-a-formula")] + done + (fn [state] + (let [file (ths/get-file-from-state state) + shape (cths/get-shape file :root1)] + (t/is (= "Button" (:name shape))) + (t/is (= "not-a-formula" (:variant-error shape))) + (t/is (= "" (prop-value (cthc/get-component file :comp1)))) + (t/is (nil? (:shape-for-rename (:workspace-local state))))))))) + +(t/deftest rename-shape-or-variant-blank-name-does-not-clear-variant-values + (t/async + done + (run (variant-file) + [(dw/start-rename-shape (cthi/id :root1)) + (dw/rename-shape-or-variant (cthi/id :root1) "")] + done + (fn [state] + (let [file (ths/get-file-from-state state) + shape (cths/get-shape file :root1)] + (t/is (= "Button" (:name shape))) + (t/is (= "Value1" (:variant-name shape))) + (t/is (= "Value1" (prop-value (cthc/get-component file :comp1)))) + (t/is (nil? (:variant-error shape))) + (t/is (nil? (:shape-for-rename (:workspace-local state))))))))) + +(t/deftest end-rename-shape-nil-leaves-variant-main-unchanged + (t/async + done + (run (variant-file) + [(dw/start-rename-shape (cthi/id :root1)) + (dw/end-rename-shape (cthi/id :root1) nil)] + done + (fn [state] + (let [file (ths/get-file-from-state state) + shape (cths/get-shape file :root1)] + (t/is (= "Button" (:name shape))) + (t/is (= "Value1" (:variant-name shape))) + (t/is (= "Value1" (prop-value (cthc/get-component file :comp1)))) + (t/is (nil? (:shape-for-rename (:workspace-local state))))))))) + +(t/deftest end-rename-shape-ignores-variant-main-without-variant-name + (t/async + done + (let [file (-> (variant-file) + (without-variant-name :root1)) + component (cthc/get-component file :comp1) + original-name (:name component) + original-path (:path component) + root2-id (cthi/id :root2)] + (run file + [(dw/start-rename-shape (cthi/id :root1)) + (dw/end-rename-shape (cthi/id :root1) "Property 1=another 21") + (dw/start-rename-shape root2-id)] + done + (fn [state] + (let [file (ths/get-file-from-state state) + shape (cths/get-shape file :root1) + component (cthc/get-component file :comp1)] + (t/is (= "Button" (:name shape))) + (t/is (nil? (:variant-name shape))) + (t/is (= "Value1" (prop-value component))) + (t/is (= original-name (:name component))) + (t/is (= original-path (:path component))) + (t/is (= root2-id (:shape-for-rename (:workspace-local state)))))) + ;; Fixture starts without :variant-name, so the file is incomplete. + :validate? false)))) + +(t/deftest end-rename-shape-keeps-formula-text-off-the-layer-name + (t/async + done + (run (variant-file) + [(dw/start-rename-shape (cthi/id :root1)) + (dw/end-rename-shape (cthi/id :root1) "not-a-formula")] + done + (fn [state] + (let [file (ths/get-file-from-state state) + shape (cths/get-shape file :root1)] + (t/is (= "Button" (:name shape))) + (t/is (= "Value1" (:variant-name shape))) + (t/is (= "Value1" (prop-value (cthc/get-component file :comp1)))) + (t/is (nil? (:variant-error shape))) + (t/is (nil? (:shape-for-rename (:workspace-local state))))))))) + +(t/deftest end-rename-shape-renames-variant-container + (t/async + done + (run (variant-file) + [(dw/end-rename-shape (cthi/id :variant) "Renamed set")] + done + (fn [state] + (let [file (ths/get-file-from-state state)] + (t/is (= "Renamed set" (:name (cths/get-shape file :variant)))) + (t/is (= "Renamed set" (:name (cths/get-shape file :root1)))) + (t/is (= "Renamed set" (:name (cths/get-shape file :root2)))) + (t/is (= "Renamed set" (:name (cthc/get-component file :comp1)))) + (t/is (= "Renamed set" (:name (cthc/get-component file :comp2))))))))) + +(t/deftest end-rename-shape-renames-plain-shape-and-component-main + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (cths/add-sample-shape :frame :type :frame :name "Frame") + (cthc/make-component :comp :frame) + (cths/add-sample-shape :rect :name "Rect")) + rect-id (cthi/id :rect)] + (run file + [(dw/start-rename-shape (cthi/id :frame)) + (dw/end-rename-shape (cthi/id :frame) "Renamed") + (dw/start-rename-shape rect-id) + (dw/end-rename-shape rect-id "Box")] + done + (fn [state] + (let [file (ths/get-file-from-state state)] + (t/is (= "Renamed" (:name (cths/get-shape file :frame)))) + (t/is (= "Renamed" (:name (cthc/get-component file :comp)))) + (t/is (= "Box" (:name (cths/get-shape file :rect)))) + (t/is (nil? (:shape-for-rename (:workspace-local state)))))))))) diff --git a/frontend/test/frontend_tests/errors_test.cljs b/frontend/test/frontend_tests/errors_test.cljs index c373926680..baa5da1f96 100644 --- a/frontend/test/frontend_tests/errors_test.cljs +++ b/frontend/test/frontend_tests/errors_test.cljs @@ -7,6 +7,7 @@ (ns frontend-tests.errors-test (:require [app.main.errors :as errors] + [app.util.globals :as g] [cljs.test :as t :include-macros true])) (defn- make-error @@ -148,3 +149,33 @@ ;; V8 gives native rejections such as a failed fetch no frames at all. (let [cause (make-error "TypeError" "Failed to fetch" :stack "TypeError: Failed to fetch")] (t/is (false? (errors/is-ignorable-exception? cause)))))) + +;; --------------------------------------------------------------------------- +;; network-failure->error tests +;; --------------------------------------------------------------------------- + +(defn- with-online + "Call `f` while `navigator.onLine` reads `online?`." + [online? f] + (js/Object.defineProperty g/navigator "onLine" + #js {:value online? :configurable true}) + (try + (f) + (finally + (js-delete g/navigator "onLine")))) + +(t/deftest test-network-failure-becomes-environment-error + (t/testing "A raw fetch failure is wrapped as an environment error" + (let [cause (make-error "TypeError" "Failed to fetch") + error (errors/network-failure->error cause)] + (t/is (true? (errors/environment-error? error))) + (t/is (= "Failed to fetch" (ex-message error))) + (t/is (identical? cause (ex-cause error)))))) + +(t/deftest test-network-failure-type-follows-connection + (let [cause (make-error "TypeError" "Failed to fetch") + type #(:type (ex-data (errors/network-failure->error cause)))] + (t/testing "Offline browsers report :offline" + (t/is (= :offline (with-online false type)))) + (t/testing "Online browsers report :network" + (t/is (= :network (with-online true type)))))) diff --git a/frontend/test/frontend_tests/logic/comp_watcher_test.cljs b/frontend/test/frontend_tests/logic/comp_watcher_test.cljs new file mode 100644 index 0000000000..f8d3bc8783 --- /dev/null +++ b/frontend/test/frontend_tests/logic/comp_watcher_test.cljs @@ -0,0 +1,100 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL +(ns frontend-tests.logic.comp-watcher-test + "Automatic propagation of main edits to copies, driven by the real + `dwl/watch-component-changes` on an isolated store." + (:require + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.main.data.helpers :as dsh] + [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.pages :as dwpg] + [app.main.data.workspace.shapes :as dwsh] + [app.main.data.workspace.thumbnails-wasm :as dwt.wasm] + [app.main.refs :as refs] + [app.main.store :as st] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] + [frontend-tests.helpers.mock :as mock] + [frontend-tests.helpers.pages :as thp] + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw] + [okulary.core :as l] + [potok.v2.core :as ptk])) + +(t/use-fixtures :each + {:before (fn [] + (thp/reset-idmap!) + (thw/setup-wasm-mocks!)) + :after thw/teardown-wasm-mocks!}) + +(defn- setup-file + [] + ;; {:main-root} [:name Frame1] # [Component :component1] + ;; :main-child [:name Rect1] + ;; + ;; :copy-root [:name Frame1] #--> [Component :component1] :main-root + ;; :copy-child [:name Rect1] ---> :main-child + ;; + ;; :outside [:name Frame1] + (-> (cthf/sample-file :file1) + (ctho/add-simple-component-with-copy :component1 + :main-root + :main-child + :copy-root + :copy-root-params {:children-labels [:copy-child]}) + (ctho/add-frame :outside))) + +(defn- noop-render-thumbnail + [& _] + (ptk/reify ::noop-render-thumbnail + ptk/WatchEvent + (watch [_ _ _] (rx/empty)))) + +(defn- copy-children + [store] + (let [file (ths/get-file-from-state @store)] + (:shapes (cths/get-shape file :copy-root)))) + +(t/deftest ^:async child-moved-out-of-main-leaves-copy-when-another-edit-follows + ;; The move and the fill change commit in the same tick, so the watcher + ;; inspects both in one batch. The move must be checked against the file + ;; data right before it, where the rect still sits in the main. + (let [file (setup-file) + store (ths/setup-store file)] + (await + (mock/with-mocks* + {;; The watcher reads the file data through this lens. + refs/workspace-data (l/derived dsh/lookup-file-data store) + ;; Thumbnail renders read the global store. + st/state store + dwt.wasm/render-thumbnail noop-render-thumbnail} + + (ptk/emit! store (dwl/watch-component-changes)) + (t/is (= 1 (count (copy-children store))) + "the copy starts with its rect") + + (ptk/emit! store + (dwsh/relocate-shapes #{(cthi/id :main-child)} (cthi/id :outside) 0) + (dwsh/update-shapes [(cthi/id :outside)] + #(assoc % :fills [{:fill-color "#ff0000" + :fill-opacity 1}]))) + + (await (async/wait-for #(empty? (copy-children store)) + "the copy to lose its rect")) + + (let [file' (ths/get-file-from-state @store)] + (t/is (= (cthi/id :outside) + (:parent-id (cths/get-shape file' :main-child))) + "the rect moved out of the main") + (t/is (empty? (copy-children store)) + "the copy lost its rect")) + + (ptk/emit! store (dwpg/finalize-page (:id file) (cthf/current-page-id file))) + (await (async/settle)))))) diff --git a/frontend/test/frontend_tests/main/refs_test.cljs b/frontend/test/frontend_tests/main/refs_test.cljs index 99c9f57657..581ff949d2 100644 --- a/frontend/test/frontend_tests/main/refs_test.cljs +++ b/frontend/test/frontend_tests/main/refs_test.cljs @@ -8,6 +8,7 @@ (:require [app.common.uuid :as uuid] [app.main.refs :as refs] + [app.main.store :as st] [cljs.test :as t :include-macros true])) (def ^:private resolved-uri? @#'refs/resolved-uri?) @@ -28,3 +29,23 @@ (t/is (false? (resolved-uri? (uuid/next)))) (t/is (false? (resolved-uri? nil))) (t/is (false? (resolved-uri? 42)))) + +(t/deftest thumbnail-ref-ignores-unrelated-state-changes + ;; A thumbnail loaded from the server keeps a media id as :uri, and + ;; the ref resolves it to a new map on each read. Subscribers (root + ;; frames, assets) must not re-render on every unrelated store change. + (let [object-id (str (uuid/next)) + ref (refs/workspace-thumbnail-by-id object-id) + calls (atom 0)] + (swap! st/state assoc-in [:thumbnails object-id] {:uri (uuid/next) :rendered-at nil}) + (add-watch ref ::probe (fn [_ _ _ _] (swap! calls inc))) + (try + (swap! st/state assoc ::probe (uuid/next)) + (t/is (zero? @calls) "unrelated change does not notify") + (swap! st/state assoc-in [:thumbnails object-id :uri] (uuid/next)) + (t/is (= 1 @calls) "a new thumbnail uri notifies") + (finally + (remove-watch ref ::probe) + (swap! st/state #(-> % + (dissoc ::probe) + (update :thumbnails dissoc object-id))))))) diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs index c15fb8c639..f56a65841a 100644 --- a/frontend/test/frontend_tests/main_errors_test.cljs +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -14,7 +14,8 @@ - events in flight – the full report lists the events the store was processing when it caught the exception; a failing stream pipeline lists the emitters of the value, not its own event - - on-update-loop – never throws into the scheduler's caller + - on-update-loop – never throws into the scheduler's caller; + reports the throttled loop as unhandled, with the events in flight - flash schedules async report and toast – neither the report nor ntf/show is emitted synchronously - organization SSO recovery – expired SSO sessions go back to the provider @@ -554,6 +555,30 @@ {ptk/in-flight-events (fn [_] (throw (js/Error. "in-flight read failure")))} (t/is (nil? (errors/on-update-loop {:rounds 26})))))) +(t/deftest ^:async update-loop-is-reported-with-the-events-in-flight + (errors/reset-report-governor!) + (vreset! @#'errors/update-loop-warned-at 0) + (let [events (atom []) + loop-ev (ptk/reify ::test-update-loop-source)] + (await + (mock/with-mocks* + {st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + ptk/in-flight-events (fn [_] [loop-ev]) + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + tm/schedule (mock/stub (fn [f] (f)))} + (errors/on-update-loop {:rounds 26}) + ;; A second trip within the window is not reported again + (errors/on-update-loop {:rounds 26}) + (t/is (= 1 (count @events))) + (let [props (deref (first @events))] + ;; An unhandled report: it reaches the error reports and alerts, + ;; not only the audit log. + (t/is (= "unhandled-exception" (::ev/name props))) + (t/is (= "update loop throttled" (:hint props))) + (t/is (str/includes? (in-flight-section (:report props)) + "test-update-loop-source"))))))) + ;; --------------------------------------------------------------------------- ;; Expired organization SSO session ;; diff --git a/frontend/test/frontend_tests/plugins/library_test.cljs b/frontend/test/frontend_tests/plugins/library_test.cljs index 8edd8a0e30..2f8ecf6a82 100644 --- a/frontend/test/frontend_tests/plugins/library_test.cljs +++ b/frontend/test/frontend_tests/plugins/library_test.cljs @@ -55,13 +55,10 @@ (cthf/apply-changes file changes))) (t/deftest ^:async component-restore-preserves-the-users-view - (doseq [scenario [:same-page :other-page :linked-library]] + (doseq [scenario [:same-page :other-page]] (let [file (cond-> (file-with-deleted-component) (= scenario :other-page) (cthf/add-sample-page :other-page)) - store (if (= scenario :linked-library) - (ths/setup-store (cthf/sample-file :local-file) - {:renderer :svg :libraries [file]}) - (ths/setup-store file {:renderer :svg})) + store (ths/setup-store file {:renderer :svg}) view {:selected #{(thi/id :copy)} :zoom 2 :vport {:x 100 :y 200 :width 800 :height 600} @@ -108,6 +105,23 @@ (t/is (empty? @events)) (t/is (true? (get-in @store [:files (:id file) :data :components (thi/id :restorable) :deleted])))))))) +(t/deftest ^:async component-restore-rejects-linked-libraries + (let [library (file-with-deleted-component) + file (cthf/sample-file :consumer-file) + store (ths/setup-store file {:renderer :svg :libraries [library]}) + before @store + events (atom [])] + (ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true)) + (await + (mock/with-mocks* + {st/state store + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))} + (let [component (library/lib-component-proxy plugin-id (:id library) (thi/id :restorable))] + (t/is (thrown-with-msg? js/Error #"current file" (.restore component))) + (await (async/settle)) + (t/is (empty? @events)) + (t/is (= (:files before) (:files @store)))))))) + (t/deftest ^:async component-restore-rejects-a-missing-component (let [file (cthf/sample-file :restore-file) store (ths/setup-store file {:renderer :svg})] @@ -374,6 +388,48 @@ (t/is (= [plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission"] (first @errors))))))) +(t/deftest variant-rename-property-sanitizes-name + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + renamed (atom nil) + errors (atom [])] + (with-redefs [r/check-permission (constantly true) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}]) + dwv/update-property-name (fn [vid pos name opts] + (reset! renamed {:id vid :pos pos :name name :opts opts}) + ::rename-event) + st/emit! mock/noop] + (let [proxy (library/variant-proxy plugin-id file-id id)] + (.renameProperty proxy 0 "a=b") + (t/is (empty? @errors)) + (t/is (= "ab" (:name @renamed))) + (.renameProperty proxy 0 "=,=") + (t/is (= 1 (count @errors))) + (t/is (= :name (second (first @errors)))))))) + +(t/deftest component-set-variant-property-sanitizes-value + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + updated (atom nil) + errors (atom [])] + (with-redefs [r/check-permission (constantly true) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + u/locate-library-component (constantly {:id id :variant-properties [{:name "color"}]}) + dwv/update-property-value (fn [cid pos value] + (reset! updated {:id cid :pos pos :value value}) + ::value-event) + st/emit! mock/noop] + (let [proxy (library/lib-component-proxy plugin-id file-id id)] + (.setVariantProperty proxy 0 "a=b") + (t/is (empty? @errors)) + (t/is (= "ab" (:value @updated))) + (.setVariantProperty proxy 0 "=,=") + (t/is (empty? @errors)) + (t/is (= "" (:value @updated))))))) + ;; --------------------------------------------------------------------------- ;; connectLibrary ;; --------------------------------------------------------------------------- diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index c744d262a6..bc7878f14c 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -41,6 +41,7 @@ [frontend-tests.data.workspace-shortcuts-test] [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] + [frontend-tests.data.workspace-variant-rename-test] [frontend-tests.data.workspace-versions-test] [frontend-tests.debug-logging-test] [frontend-tests.errors-governor-test] @@ -49,6 +50,7 @@ [frontend-tests.helpers-shapes-test] [frontend-tests.logic.bool-move-test] [frontend-tests.logic.comp-remove-swap-slots-test] + [frontend-tests.logic.comp-watcher-test] [frontend-tests.logic.components-and-tokens] [frontend-tests.logic.copy-paste-typography-test] [frontend-tests.logic.copying-and-duplicating-test] @@ -107,6 +109,7 @@ [frontend-tests.tokens.logic.token-actions-test] [frontend-tests.tokens.logic.token-data-test] [frontend-tests.tokens.logic.token-remapping-test] + [frontend-tests.tokens.logic.tokens-source-test] [frontend-tests.tokens.logic.tokens-status-test] [frontend-tests.tokens.referenced-token-value-test] [frontend-tests.tokens.style-dictionary-test] @@ -133,6 +136,7 @@ [frontend-tests.ui.inspect-stroke-tokens-test] [frontend-tests.ui.jobs-progress-test] [frontend-tests.ui.layout-container-multiple-test] + [frontend-tests.ui.mcp-authorize-test] [frontend-tests.ui.measurements-test] [frontend-tests.ui.measures-menu-props-test] [frontend-tests.ui.organization-team-switch-test] @@ -148,18 +152,21 @@ [frontend-tests.ui.text-attrs-multiple-test] [frontend-tests.ui.workspace-libraries-test] [frontend-tests.util-clipboard-test] + [frontend-tests.util-http-test] [frontend-tests.util-object-test] [frontend-tests.util-queue-test] [frontend-tests.util-range-tree-test] [frontend-tests.util-simple-math-test] [frontend-tests.util-text-clipboard-test] [frontend-tests.util-text-editor-test] + [frontend-tests.util-theme-test] [frontend-tests.util-webapi-test] [frontend-tests.util-zip-test] [frontend-tests.util.code-gen-markup-svg-wasm-test] [frontend-tests.util.dom.dnd-test] [frontend-tests.worker-snap-test] [frontend-tests.worker.exports-test] + [frontend-tests.worker.index-test] [goog.object :as gobj])) (enable-console-print!) @@ -195,6 +202,7 @@ 'frontend-tests.data.imports-test 'frontend-tests.data.jobs-test 'frontend-tests.worker.exports-test + 'frontend-tests.worker.index-test 'frontend-tests.data.svg-upload-test 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test @@ -211,6 +219,7 @@ 'frontend-tests.data.workspace-shortcuts-test 'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-thumbnails-test + 'frontend-tests.data.workspace-variant-rename-test 'frontend-tests.data.workspace-versions-test 'frontend-tests.debug-logging-test 'frontend-tests.errors-governor-test @@ -219,6 +228,7 @@ 'frontend-tests.helpers-shapes-test 'frontend-tests.logic.bool-move-test 'frontend-tests.logic.comp-remove-swap-slots-test + 'frontend-tests.logic.comp-watcher-test 'frontend-tests.logic.components-and-tokens 'frontend-tests.logic.copy-paste-typography-test 'frontend-tests.logic.copying-and-duplicating-test @@ -281,6 +291,7 @@ 'frontend-tests.tokens.token-errors-test 'frontend-tests.tokens.token-path-test 'frontend-tests.tokens.tokenscript-test + 'frontend-tests.tokens.logic.tokens-source-test 'frontend-tests.tokens.logic.tokens-status-test 'frontend-tests.tokens.workspace-tokens-remap-test 'frontend-tests.ui.branch-review-common-test @@ -305,6 +316,7 @@ 'frontend-tests.ui.measurements-test 'frontend-tests.ui.measures-menu-props-test 'frontend-tests.ui.organization-team-switch-test + 'frontend-tests.ui.mcp-authorize-test 'frontend-tests.ui.routes-test 'frontend-tests.render-dimensions-test 'frontend-tests.text-editor-paste-guard-test @@ -319,12 +331,14 @@ 'frontend-tests.ui.text-attrs-multiple-test 'frontend-tests.ui.workspace-libraries-test 'frontend-tests.util-clipboard-test + 'frontend-tests.util-http-test 'frontend-tests.util-object-test 'frontend-tests.util-queue-test 'frontend-tests.util-range-tree-test 'frontend-tests.util-simple-math-test 'frontend-tests.util-text-clipboard-test 'frontend-tests.util-text-editor-test + 'frontend-tests.util-theme-test 'frontend-tests.util-webapi-test 'frontend-tests.util.code-gen-markup-svg-wasm-test 'frontend-tests.util.dom.dnd-test diff --git a/frontend/test/frontend_tests/tokens/logic/tokens_source_test.cljs b/frontend/test/frontend_tests/tokens/logic/tokens_source_test.cljs new file mode 100644 index 0000000000..6813fd1da4 --- /dev/null +++ b/frontend/test/frontend_tests/tokens/logic/tokens_source_test.cljs @@ -0,0 +1,136 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.tokens.logic.tokens-source-test + (:require + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.test-helpers.tokens :as ctht] + [app.common.types.tokens-lib :as ctob] + [app.common.types.tokens-status :as ctos] + [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.tokens.library-edit :as dwtl] + [app.main.repo :as rp] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] + [frontend-tests.helpers.mock :as mock] + [frontend-tests.helpers.pages :as thp] + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw] + [potok.v2.core :as ptk])) + +(t/use-fixtures :each + {:before (fn [] + (thp/reset-idmap!) + (thw/setup-wasm-mocks!)) + :after thw/teardown-wasm-mocks!}) + +(defn- setup-library + "A library whose active set defines `red` as green." + [] + (ctht/sample-file-with-tokens + :file-id :library + :lib-fn #(-> % + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :library-set) + :name "library-set")) + (ctob/add-token (cthi/id :library-set) + (ctob/make-token :id (cthi/new-id! :library-red) + :name "red" + :type :color + :value "#00ff00"))) + :status-fn #(ctos/set-tokens-status % #{} #{(cthi/id :library-set)}))) + +(defn- setup-file + "A file whose own active set defines `red` as dark red, applied to the + fill of a rectangle." + [] + (-> (ctht/sample-file-with-tokens + :file-id :local + :lib-fn #(-> % + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :local-set) + :name "local-set")) + (ctob/add-token (cthi/id :local-set) + (ctob/make-token :id (cthi/new-id! :local-red) + :name "red" + :type :color + :value "rgb(123, 34, 34)"))) + :status-fn #(ctos/set-tokens-status % #{} #{(cthi/id :local-set)})) + (ctho/add-rect :rect1) + (ctht/apply-token-to-shape :rect1 "red" [:fill] [:fill] "#7b2222"))) + +(defn- fill-color + [store] + (let [file (ths/get-file-from-state @store)] + (-> (cths/get-shape file :rect1) :fills first :fill-color))) + +(defn- active-set-ids + [store] + (-> (ths/get-file-from-state @store) ctht/get-tokens-status ctos/get-active-set-ids)) + +(defn- tokens-source + [store] + (-> (ths/get-file-from-state @store) ctht/get-tokens-source)) + +(defn- ^:async use-library-tokens + "Set `library` as tokens source and wait for the canvas to use it." + [store library] + (ptk/emit! store (dwtl/set-tokens-source (:id library))) + (await (async/wait-for #(= "#00ff00" (fill-color store)) + "the fill uses the library value of `red`" + {:max-ticks 200}))) + +(t/deftest ^:async switching-tokens-source-updates-the-canvas + (let [library (setup-library) + file (setup-file) + store (ths/setup-store file {:libraries [library]})] + + (t/is (= "#7b2222" (fill-color store))) + + (ptk/emit! store (dwtl/set-tokens-source (:id library))) + (await (async/wait-for #(= "#00ff00" (fill-color store)) + "the fill uses the library value of `red`" + {:max-ticks 200})) + (t/is (= "#00ff00" (fill-color store)) + "Setting the library as tokens source resolves `red` from the library") + + (ptk/emit! store (dwtl/set-tokens-source (:id file))) + (await (async/settle)) + (t/is (contains? (active-set-ids store) (cthi/id :local-set)) + "Going back to the local tokens keeps the local set active") + (await (async/wait-for #(= "#7b2222" (fill-color store)) + "the fill uses the local value of `red` again" + {:max-ticks 200})) + (t/is (= "#7b2222" (fill-color store)) + "Going back to the local tokens resolves `red` from the file again") + + (rx/dispose! store))) + +(t/deftest ^:async unlinking-the-tokens-source-library-restores-the-local-tokens + (let [library (setup-library) + file (setup-file) + store (ths/setup-store file {:libraries [library]})] + (await (use-library-tokens store library)) + + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] (rx/observe-on :async (rx/of []))))} + ;; The same events the libraries dialog emits on unlink + (ptk/emit! store + (dwl/unlink-file-from-library (:id file) (:id library)) + (dwtl/set-tokens-source (:id file))) + (await (async/wait-for #(= "#7b2222" (fill-color store)) + "the fill uses the local value of `red` again" + {:max-ticks 200})))) + + (t/is (nil? (tokens-source store))) + (t/is (contains? (active-set-ids store) (cthi/id :local-set)) + "Unlinking the library keeps the local set active") + (t/is (= "#7b2222" (fill-color store))) + + (rx/dispose! store))) + diff --git a/frontend/test/frontend_tests/ui/jobs_progress_test.cljs b/frontend/test/frontend_tests/ui/jobs_progress_test.cljs index 9d25f852af..55937e561e 100644 --- a/frontend/test/frontend_tests/ui/jobs_progress_test.cljs +++ b/frontend/test/frontend_tests/ui/jobs_progress_test.cljs @@ -5,8 +5,7 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.jobs-progress-test - "How a job milestone is written: the stage and its counter, and the - upload stage as a percentage of the file." + "How a job milestone is written: the stage and its counter." (:require [app.main.ui.jobs.progress :as jp] [app.util.i18n :as i18n] @@ -24,15 +23,7 @@ (t/is (= "TR:jobs.progress.stage.pages 3" (jp/milestone-text {:stage :pages :counters {:pages {:current 3}}})))))) -(t/deftest a-file-milestone-appends-the-file-counter - (with-redefs [i18n/tr stub-tr] - (t/is (= "TR:jobs.progress.stage.pages 3/8 · TR:jobs.progress.stage.files 1/2" - (jp/milestone-text {:stage :pages - :counters {:pages {:current 3 :total 8} - :files {:current 1 :total 2}}} - :file? true))))) - -(t/deftest an-upload-milestone-writes-the-percentage-of-the-file +(t/deftest the-upload-milestone-writes-the-percentage-of-the-file (with-redefs [i18n/tr stub-tr] (t/testing "the chunks sent become a whole percentage" (t/is (= "TR:jobs.progress.stage.upload (33%)" @@ -42,3 +33,22 @@ (t/testing "an upload with no total keeps the bare label" (t/is (= "TR:jobs.progress.stage.upload" (jp/milestone-text {:stage :upload :counters {:upload {:current 1}}})))))) + +(t/deftest the-milestone-in-course-is-the-one-of-the-files-that-report-it + (with-redefs [i18n/tr stub-tr] + (t/testing "the files of a container share their milestone, so one is enough" + (t/is (= "TR:jobs.progress.stage.pages 4/5" + (jp/current-milestone-text + [{:status :import-queued} + {:progress {:stage :pages :counters {:pages {:current 4 :total 5}}}} + {:progress {:stage :pages :counters {:pages {:current 4 :total 5}}}}])))) + + (t/testing "a file with nothing to report says nothing" + (t/is (nil? (jp/current-milestone-text [{:status :import-ready} + {:status :import-queued}]))) + (t/is (nil? (jp/current-milestone-text [])))))) + +(t/deftest a-milestone-without-a-stage-writes-nothing + (with-redefs [i18n/tr stub-tr] + (t/is (nil? (jp/milestone-text nil))) + (t/is (nil? (jp/milestone-text {}))))) diff --git a/frontend/test/frontend_tests/ui/mcp_authorize_test.cljs b/frontend/test/frontend_tests/ui/mcp_authorize_test.cljs new file mode 100644 index 0000000000..b91e3cbef2 --- /dev/null +++ b/frontend/test/frontend_tests/ui/mcp_authorize_test.cljs @@ -0,0 +1,88 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.ui.mcp-authorize-test + (:require + [app.common.uri :as uri] + [app.main.router :as rt] + [app.main.store :as st] + [app.main.ui.mcp-authorize :as oauth] + [app.util.http :as http] + [app.util.storage :as storage] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as a] + [frontend-tests.helpers.mock :as mock])) + +(t/deftest ^:async approval-uses-form-encoding-and-preserves-response-keys + (let [sent (atom nil)] + (await + (mock/with-mocks* + {http/send! (mock/stub + (fn [request] + (reset! sent request) + (->> (rx/of {:status 200 :body #js {"redirect-uri" "https://client.example/cb?code=abc"}}) + (rx/observe-on :async))))} + (let [response (await (a/->promise + (#'oauth/oauth-request :post "decision" + {:request "opaque-handle" :csrf "a+b&c" :decision "approve"})))] + (t/is (= :post (:method @sent))) + (t/is (= "application/x-www-form-urlencoded" (get-in @sent [:headers "content-type"]))) + (t/is (= {:request "opaque-handle" :csrf "a+b&c" :decision "approve"} + (uri/query-string->map (:body @sent)))) + (t/is (= "https://client.example/cb?code=abc" (get-in response [:body :redirect-uri]))) + (t/is (nil? (get-in response [:body :access_token])))))))) + +(t/deftest ^:async approval-request-preserves-login-required-response + (await + (mock/with-mocks* + {http/send! (mock/stub + (fn [_] + (->> (rx/of {:status 401 :body #js {:error "login_required"}}) + (rx/observe-on :async))))} + (let [response (await (a/->promise (#'oauth/oauth-request :get "request" {:request "opaque-handle"})))] + (t/is (= 401 (:status response))) + (t/is (= "login_required" (get-in response [:body :error]))))))) + +(t/deftest ^:async login-keeps-the-approval-return-url-without-navigation + (let [href "https://penpot.example/?screen=mcp-authorize&request=opaque-handle" + saved (:login-redirect storage/session) + emitted (atom [])] + (try + (await + (mock/with-mocks* + {rt/get-current-href (mock/stub (fn [] href)) + st/emit! (mock/stub (fn [event] (swap! emitted conj event)))} + (let [params (#'oauth/prepare-login!)] + (await (a/settle)) + (t/is (= href (:callback-url params))) + (t/is (= href (:login-redirect storage/session))) + (t/is (empty? @emitted))))) + (finally + (binding [storage/*sync* true] + (if saved + (swap! storage/session assoc :login-redirect saved) + (swap! storage/session dissoc :login-redirect))))))) + +(t/deftest ^:async cancelling-login-clears-only-this-attempts-return-url + (let [href "https://penpot.example/?screen=mcp-authorize&request=opaque-handle" + saved (:login-redirect storage/session)] + (try + (await + (mock/with-mocks* + {rt/get-current-href (mock/stub (fn [] href))} + (#'oauth/prepare-login!) + (#'oauth/cancel-login!) + (t/is (nil? (:login-redirect storage/session))) + (binding [storage/*sync* true] + (swap! storage/session assoc :login-redirect "https://penpot.example/other")) + (#'oauth/cancel-login!) + (t/is (= "https://penpot.example/other" (:login-redirect storage/session))))) + (finally + (binding [storage/*sync* true] + (if saved + (swap! storage/session assoc :login-redirect saved) + (swap! storage/session dissoc :login-redirect))))))) diff --git a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs index 3ed7e035c9..f2df889ef0 100644 --- a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs +++ b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs @@ -1,5 +1,6 @@ (ns frontend-tests.ui.settings-shortcuts-test (:require + ["@penpot/mousetrap$default" :as mousetrap] [app.config :as cf] [app.main.data.dashboard.shortcuts :as dsc] [app.main.data.profile :as du] @@ -495,17 +496,25 @@ (defn- mock-wrapped-keydown "Simulates a goog.events.BrowserEvent-wrapped keydown: `key` and modifiers - are forwarded directly, and `code` is only reachable via `getBrowserEvent`. - `key` is passed through as-is (not defaulted) so callers can simulate a - missing `event.key` with `:key nil` - substituting \"\" would defeat that, - since an empty string is truthy in ClojureScript." - [{:keys [key code shift ctrl alt meta]}] - #js {:key key - :shiftKey (boolean shift) - :ctrlKey (boolean ctrl) - :altKey (boolean alt) - :metaKey (boolean meta) - :getBrowserEvent (fn [] #js {:code code})}) + are forwarded directly, and the native event behind `getBrowserEvent` + carries every field (`code` and `keyCode` only live there). `key` is + passed through as-is (not defaulted) so callers can simulate a missing + `event.key` with `:key nil` - substituting \"\" would defeat that, since + an empty string is truthy in ClojureScript." + [{:keys [key code key-code shift ctrl alt meta]}] + (let [native #js {:key key + :code code + :keyCode key-code + :shiftKey (boolean shift) + :ctrlKey (boolean ctrl) + :altKey (boolean alt) + :metaKey (boolean meta)}] + #js {:key key + :shiftKey (boolean shift) + :ctrlKey (boolean ctrl) + :altKey (boolean alt) + :metaKey (boolean meta) + :getBrowserEvent (fn [] native)})) (defn- mock-native-keydown "Simulates a plain, unwrapped native KeyboardEvent (no getBrowserEvent), @@ -531,7 +540,7 @@ even when the typed glyph differs" (with-redefs [cf/check-platform? (constantly false)] (try - (reset! ds/layout-map #js {:get (fn [code] (when (= code "Period") "."))}) + (.setLayoutMap mousetrap #js {:get (fn [code] (when (= code "Period") "."))}) (t/is (= "shift+." (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key ">" :code "Period" :shift true})))) @@ -539,7 +548,7 @@ (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key ":" :code "Period" :shift true})))) (finally - (reset! ds/layout-map nil)))))) + (.setLayoutMap mousetrap nil)))))) (t/deftest mousetrap-shift-period-on-spanish-iso-layout (t/testing "Spanish (ISO) layout without a layout map: Shift + Period @@ -633,28 +642,27 @@ (t/deftest mousetrap-shifted-digit-resolves-to-digit (t/testing "a digit row key held with Shift records the digit itself: US Shift+0 types \")\" but records \"shift+0\", so the physical - digit key means the same on every layout (production keyCode - behavior)" + digit key means the same on every layout (named by keyCode)" (with-redefs [cf/check-platform? (constantly false)] (t/is (= "shift+0" (#'ui-shortcuts/keyboard-event->mousetrap - (mock-wrapped-keydown {:key ")" :code "Digit0" :shift true})))) + (mock-wrapped-keydown {:key ")" :code "Digit0" :key-code 48 :shift true})))) (t/is (= "shift+1" (#'ui-shortcuts/keyboard-event->mousetrap - (mock-wrapped-keydown {:key "!" :code "Digit1" :shift true})))) + (mock-wrapped-keydown {:key "!" :code "Digit1" :key-code 49 :shift true})))) (t/is (= "shift+2" (#'ui-shortcuts/keyboard-event->mousetrap - (mock-wrapped-keydown {:key "@" :code "Digit2" :shift true})))) + (mock-wrapped-keydown {:key "@" :code "Digit2" :key-code 50 :shift true})))) (t/is (= "shift+8" (#'ui-shortcuts/keyboard-event->mousetrap - (mock-wrapped-keydown {:key "*" :code "Digit8" :shift true}))))))) + (mock-wrapped-keydown {:key "*" :code "Digit8" :key-code 56 :shift true}))))))) (t/deftest mousetrap-shifted-digit-already-digit-unchanged (t/testing "AZERTY Shift+Digit0 already types \"0\": unchanged" (with-redefs [cf/check-platform? (constantly false)] (t/is (= "shift+0" (#'ui-shortcuts/keyboard-event->mousetrap - (mock-wrapped-keydown {:key "0" :code "Digit0" :shift true}))))))) + (mock-wrapped-keydown {:key "0" :code "Digit0" :key-code 48 :shift true}))))))) (t/deftest mousetrap-space-and-named-keys (t/testing "\" \" records as space and named keys stay lowercase" @@ -672,15 +680,15 @@ (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "Delete" :code "Delete"}))))))) -(t/deftest mousetrap-altgr-drops-phantom-ctrl - (t/testing "Spanish AltGr++] types \"]\" with ctrl+alt flags, but the - phantom ctrl is dropped so no ctrl+alt shortcut fires" +(t/deftest mousetrap-altgr-drops-phantom-modifiers + (t/testing "Spanish AltGr+] types \"]\" with ctrl+alt flags, but both + phantom modifiers are dropped and it records the glyph" (with-redefs [cf/check-platform? (constantly false)] (let [event #js {:key "]" :shiftKey false :ctrlKey true :altKey true :metaKey false :getModifierState (fn [m] (= m "AltGraph")) :getBrowserEvent (fn [] nil)}] - (t/is (= "alt+]" + (t/is (= "]" (#'ui-shortcuts/keyboard-event->mousetrap event))))))) (t/deftest mousetrap-real-ctrl-alt-keeps-both @@ -699,7 +707,7 @@ what the user sees and what Mousetrap matches" (with-redefs [cf/check-platform? (constantly false)] (try - (reset! ds/layout-map #js {:get (fn [code] (when (= code "BracketRight") "+"))}) + (.setLayoutMap mousetrap #js {:get (fn [code] (when (= code "BracketRight") "+"))}) (t/is (= "shift++" (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "*" :code "BracketRight" :shift true})))) @@ -707,7 +715,7 @@ (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "+" :code "BracketRight"})))) (finally - (reset! ds/layout-map nil)))))) + (.setLayoutMap mousetrap nil)))))) (t/deftest mousetrap-spanish-shift-plus-without-layout-map-records-glyph (t/testing "Spanish layout without a layout map: Shift + the physical @@ -725,11 +733,24 @@ (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "+" :code "BracketRight"}))))))) -(t/deftest layout-map-supported-matches-keyboard-api - (t/testing "false without a navigator or without a keyboard API" - (t/is (false? (ds/layout-map-supported? nil))) - (t/is (false? (ds/layout-map-supported? #js {}))) - (t/is (false? (ds/layout-map-supported? #js {:keyboard #js {}})))) - (t/testing "true when getLayoutMap exists (Chromium)" - (t/is (true? (ds/layout-map-supported? - #js {:keyboard #js {:getLayoutMap (fn [] nil)}}))))) +(t/deftest mousetrap-shift-digit-records-the-digit + (t/testing "Shift+1 types \"!\" but records \"shift+1\", the key the user + sees and the default zoom shortcuts use" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "shift+1" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "!" :code "Digit1" :key-code 49 :shift true})))) + (t/is (= "shift+0" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "=" :code "Digit0" :key-code 48 :shift true}))))))) + +(t/deftest mousetrap-option-letter-records-the-letter + (t/testing "macOS Option+M types \"µ\" and Option+E is a dead key, but + both record the letter" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "alt+m" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "µ" :code "KeyM" :key-code 77 :alt true})))) + (t/is (= "alt+e" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "Dead" :code "KeyE" :key-code 69 :alt true}))))))) diff --git a/frontend/test/frontend_tests/util_http_test.cljs b/frontend/test/frontend_tests/util_http_test.cljs new file mode 100644 index 0000000000..e686d4d3a9 --- /dev/null +++ b/frontend/test/frontend_tests/util_http_test.cljs @@ -0,0 +1,75 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.util-http-test + (:require + [app.util.http :as http] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async])) + +(def ^:private uri "https://example.com/assets/file.json") + +(defn- failing-response + "A response map whose native body read rejects with `cause`." + [cause] + {:status 200 + :uri uri + :headers {} + ::http/response #js {:text (fn [] (js/Promise.reject cause))}}) + +(defn- ^:async read-error + "Read the body of `response` as text and resolve with the error raised." + [response] + (try + (await (async/->promise (http/process-response-type :text response))) + nil + (catch :default cause + cause))) + +(t/deftest ^:async body-read-network-failure-is-a-network-error + (let [cause (js/TypeError. "network error") + error (await (read-error (failing-response cause))) + data (ex-data error)] + (t/is (= :network (:type data))) + (t/is (= uri (:uri data))) + (t/is (identical? cause (ex-cause error))))) + +(t/deftest ^:async body-parse-failure-passes-unchanged + (let [cause (js/SyntaxError. "Unexpected token < in JSON") + error (await (read-error (failing-response cause)))] + (t/is (identical? cause error)))) + +(t/deftest ^:async body-read-programming-error-passes-unchanged + (let [cause (js/TypeError. "body stream already read") + error (await (read-error (failing-response cause)))] + (t/is (identical? cause error)))) + +;; --------------------------------------------------------------------------- +;; network-failure? tests +;; --------------------------------------------------------------------------- + +(defn- make-error + [error-name message] + (let [err (js/Error. message)] + (set! (.-name err) error-name) + err)) + +(t/deftest test-network-failure-browser-messages + (t/testing "The TypeError each browser gives a failed fetch is a network failure" + (doseq [message ["Failed to fetch" + "Failed to fetch (design.penpot.app)" + "NetworkError when attempting to fetch resource." + "Load failed" + "network error" + "Error in body stream"]] + (t/is (true? (http/network-failure? (make-error "TypeError" message))) + message)))) + +(t/deftest test-network-failure-needs-type-error + (t/testing "Other errors with the same message are not network failures" + (t/is (false? (http/network-failure? (make-error "Error" "Failed to fetch"))))) + (t/testing "Other TypeErrors are not network failures" + (t/is (false? (http/network-failure? (make-error "TypeError" "x is not a function")))))) diff --git a/frontend/test/frontend_tests/util_theme_test.cljs b/frontend/test/frontend_tests/util_theme_test.cljs new file mode 100644 index 0000000000..83889bab96 --- /dev/null +++ b/frontend/test/frontend_tests/util_theme_test.cljs @@ -0,0 +1,23 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.util-theme-test + (:require + [app.util.theme :as theme] + [cljs.test :as t])) + +(t/deftest unset-theme-follows-system + (doseq [system ["light" "dark"]] + (t/is (= system (theme/resolve-theme nil system))) + (t/is (= system (theme/resolve-theme theme/default system))) + (t/is (= system (theme/resolve-theme "system" system))))) + +(t/deftest explicit-theme-overrides-system + (doseq [system ["light" "dark"]] + (t/is (= "light" (theme/resolve-theme "light" system))) + (t/is (= "dark" (theme/resolve-theme "dark" system))) + ;; Older profiles store the dark preference as "default". + (t/is (= "dark" (theme/resolve-theme "default" system))))) diff --git a/frontend/test/frontend_tests/worker/index_test.cljs b/frontend/test/frontend_tests/worker/index_test.cljs new file mode 100644 index 0000000000..a5e0f558f0 --- /dev/null +++ b/frontend/test/frontend_tests/worker/index_test.cljs @@ -0,0 +1,99 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.worker.index-test + (:require + [app.common.files.builder :as fb] + [app.common.geom.rect :as grc] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [app.worker.impl :as impl] + [app.worker.index :as idx] + [cljs.test :as t :include-macros true])) + +(t/use-fixtures :each + {:before #(reset! idx/state {:pages-index {}})}) + +(defn- make-page + [name] + (-> (fb/create-state) + (fb/add-file {:name "Test"}) + (fb/add-page {:name name}) + (fb/add-shape {:type :rect :x 0 :y 0 :width 100 :height 100}) + (fb/get-current-page))) + +(defn- initialize + [page] + (impl/handler {:cmd :index/initialize :page page})) + +(defn- update-index + [page-id changes] + (impl/handler {:cmd :index/update :page-id page-id :changes changes})) + +(defn- indexed-ids + [state] + {:pages (set (keys (:pages-index state))) + :selection (set (keys (::idx/selection state))) + :snap (set (keys (::idx/snap state)))}) + +(defn- add-rect-change + [page-id shape-id] + (let [shape (cts/setup-shape {:id shape-id :type :rect + :x 200 :y 200 :width 50 :height 50})] + {:type :add-obj + :id shape-id + :page-id page-id + :parent-id uuid/zero + :frame-id uuid/zero + :obj shape})) + +(t/deftest finalize-drops-only-that-page + (let [page-a (make-page "Page A") + page-b (make-page "Page B")] + (initialize page-a) + (initialize page-b) + (impl/handler {:cmd :index/finalize :page-id (:id page-a)}) + (t/is (= {:pages #{(:id page-b)} + :selection #{(:id page-b)} + :snap #{(:id page-b)}} + (indexed-ids @idx/state))))) + +(t/deftest finalize-unknown-page-is-noop + (let [page (make-page "Page 1")] + (initialize page) + (let [before @idx/state] + (impl/handler {:cmd :index/finalize :page-id (uuid/next)}) + (t/is (= before @idx/state))))) + +(t/deftest update-ignores-pages-not-indexed + (let [page (make-page "Page 1") + page-id (uuid/next)] + (initialize page) + (update-index page-id [(add-rect-change page-id (uuid/next))]) + (t/is (= {:pages #{(:id page)} + :selection #{(:id page)} + :snap #{(:id page)}} + (indexed-ids @idx/state))))) + +(t/deftest update-drops-indexes-of-deleted-page + (let [page (make-page "Page 1") + page-id (:id page)] + (initialize page) + (update-index page-id [{:type :del-page :id page-id :page-id page-id}]) + (t/is (= {:pages #{} :selection #{} :snap #{}} + (indexed-ids @idx/state))))) + +(t/deftest update-indexes-new-shapes-of-indexed-page + (let [page (make-page "Page 1") + page-id (:id page) + shape-id (uuid/next)] + (initialize page) + (update-index page-id [(add-rect-change page-id shape-id)]) + (t/is (contains? (get-in @idx/state [:pages-index page-id :objects]) shape-id)) + (t/is (contains? (set (impl/handler {:cmd :index/query-selection + :page-id page-id + :rect (grc/make-rect 190 190 70 70)})) + shape-id)))) diff --git a/frontend/text-editor/package.json b/frontend/text-editor/package.json index 02cbcf2349..45fc013e71 100644 --- a/frontend/text-editor/package.json +++ b/frontend/text-editor/package.json @@ -28,5 +28,5 @@ "vite": "^8.2.0", "vitest": "^5.0.2" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/frontend/translations/ar.po b/frontend/translations/ar.po index a027c7f3dd..0b07c80ca7 100644 --- a/frontend/translations/ar.po +++ b/frontend/translations/ar.po @@ -1727,10 +1727,6 @@ msgstr "مُختلط" msgid "shortcuts.add-comment" msgstr "تعليقات" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "إضافة عقدة" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "محاذاة لأسفل" diff --git a/frontend/translations/ca.po b/frontend/translations/ca.po index 900e2048d1..005dc3b908 100644 --- a/frontend/translations/ca.po +++ b/frontend/translations/ca.po @@ -4745,10 +4745,6 @@ msgstr "Selecciona els elements que utilitzen aquest estil" msgid "shortcuts.add-comment" msgstr "Comentaris" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Afegeix node" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Alinea a baix" diff --git a/frontend/translations/cs.po b/frontend/translations/cs.po index 6807d634eb..5760d638f1 100644 --- a/frontend/translations/cs.po +++ b/frontend/translations/cs.po @@ -2597,10 +2597,6 @@ msgstr "Vyberte položky pomocí tohoto stylu" msgid "shortcuts.add-comment" msgstr "Komentáře" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Přidat uzel" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Zarovnat dolů" diff --git a/frontend/translations/de.po b/frontend/translations/de.po index b535d2d859..b04b506455 100644 --- a/frontend/translations/de.po +++ b/frontend/translations/de.po @@ -3507,10 +3507,6 @@ msgstr "Elemente mit diesem Stil auswählen" msgid "shortcuts.add-comment" msgstr "Kommentare" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Punkt hinzufügen" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Unten ausrichten" diff --git a/frontend/translations/en.po b/frontend/translations/en.po index e77bd55687..77447579cc 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -583,6 +583,10 @@ msgstr "Grid view" msgid "dashboard.files-layout.list" msgstr "List view" +#: src/app/main/ui/dashboard/fonts.cljs:431 +msgid "dashboard.fonts.delete-variant" +msgstr "Delete font style" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:608 msgid "dashboard.fonts.deleted-placeholder" msgstr "Missing font" @@ -681,6 +685,70 @@ msgstr "Connect selected libraries" msgid "dashboard.import.file-name-label" msgstr "File name" +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.title" +msgstr "Import file" + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.status.ready" +msgstr "Ready" + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.steps.label" +msgstr "Import steps" + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.steps.check" +msgstr "Check" + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.steps.upload" +msgstr "Upload" + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.steps.import" +msgstr "Import" + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.check.title" +msgstr "Checking your files..." + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.check.message" +msgstr "Making sure each file can be imported. This only takes a moment." + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.ready.title" +msgstr "Files ready" + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.ready.message" +msgstr "Your files can be imported. Continue to upload them." + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.upload.title" +msgstr "Uploading your files..." + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.upload.message" +msgstr "Sending your files. Keep this dialog open until the upload ends." + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.importing.title" +msgid_plural "dashboard.import.importing.title" +msgstr[0] "Importing 1 file" +msgstr[1] "Importing %s files" + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.importing.done" +msgid_plural "dashboard.import.importing.done" +msgstr[0] "1 done" +msgstr[1] "%s done" + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.importing.message" +msgstr "Rebuilding pages, components and assets in your project." + #: src/app/main/ui/dashboard/import.cljs:619 msgid "dashboard.import.import-error.check-error" msgstr "We couldn't verify this file." @@ -723,6 +791,14 @@ msgstr "Next file" msgid "dashboard.import.no-perms" msgstr "You don’t have permission to import to this team" +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.queued.title" +msgstr "Waiting to start..." + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.queued.message" +msgstr "Your files are uploaded. The import starts as soon as a slot is free." + #: src/app/main/ui/dashboard/import.cljs:443 msgid "dashboard.import.resolve-libraries" msgstr "" @@ -2120,9 +2196,37 @@ msgstr "Idea" msgid "feedback.type.issue" msgstr "Issue" -#: src/app/main/ui/exports/files.cljs:140 -msgid "files-download-modal.title" -msgstr "Download files" +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.cancel-export" +msgstr "Cancel export" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.done" +msgstr "Done" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.export-error" +msgstr "Couldn't export: %s" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.exported.message" +msgstr "Check your downloads folder." + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.exported.title" +msgid_plural "files-export-modal.exported.title" +msgstr[0] "1 file exported" +msgstr[1] "%s files exported" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.exporting.message" +msgstr "Each file downloads as soon as it's ready." + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.exporting.title" +msgid_plural "files-export-modal.exporting.title" +msgstr[0] "Exporting 1 file" +msgstr[1] "Exporting %s files" #: src/app/main/ui/exports/files.cljs:183 msgid "files-export-modal.options.detach-libraries.message" @@ -2164,6 +2268,18 @@ msgstr "Embed library assets in the file" msgid "files-download-modal.unsaved-changes" msgstr "Some of your latest changes are not saved yet, so this download will not include them." +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.status.exported" +msgstr "Exported" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.status.waiting" +msgstr "Waiting" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.title" +msgstr "Export file" + #: src/app/main/ui/settings/password.cljs:45 msgid "generic.error" msgstr "An error has occurred" @@ -2715,7 +2831,7 @@ msgstr "Uploading file…" #: src/app/main/ui/dashboard/import.cljs:328, src/app/main/ui/exports/files.cljs:103 msgid "jobs.queued" -msgstr "Queued" +msgstr "Waiting job slot" #: src/app/main/ui/dashboard/comments.cljs:94 msgid "label.mark-all-as-read" @@ -4833,10 +4949,6 @@ msgstr "Select items using this style" msgid "shortcuts.add-comment" msgstr "Comments" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Add node" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Align bottom" @@ -5113,13 +5225,6 @@ msgstr "Join nodes" msgid "shortcuts.key-combo" msgstr "Press the new keys" -#: src/app/main/ui/settings/shortcuts.cljs:484 -msgid "shortcuts.layout-map-missing" -msgstr "" -"This browser does not expose the keyboard layout, so shortcuts that " -"combine Shift with punctuation (for example Shift+,) may not work here. " -"They keep working in Chromium browsers." - #: src/app/main/data/workspace/text/shortcuts.cljs:249 msgid "shortcuts.line-through" msgstr "Toggle line through" @@ -8160,6 +8265,12 @@ msgstr "UPDATES" msgid "workspace.notification-pill.detail" msgstr "Details" +#: src/app/main/data/workspace/notifications.cljs:282 +msgid "workspace.notifications.file-repaired" +msgstr "" +"We fixed some issues in this file. You can find a copy of the original version in " +"the version history." + #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:784 msgid "workspace.options.add-interaction" msgstr "Click the + button to add interactions." @@ -11970,3 +12081,27 @@ msgstr[1] "Choosing a side here also decides %s copies." #: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs msgid "workspace.branches.review.copies.via" msgstr "%s follows the change through its copy of %s" + +msgid "mcp.oauth.title" +msgstr "Connect to Penpot MCP" + +msgid "mcp.oauth.request" +msgstr "%s wants to connect to Penpot." + +msgid "mcp.oauth.account" +msgstr "You are signed in as %s." + +msgid "mcp.oauth.access" +msgstr "This client can read and change designs through MCP connections established from Penpot." + +msgid "mcp.oauth.enable" +msgstr "Allowing access enables the MCP integration and creates your MCP token if needed." + +msgid "mcp.oauth.approve" +msgstr "Allow access" + +msgid "mcp.oauth.failed" +msgstr "This request has expired, was already used, or could not be completed. Start the connection again from your MCP client." + +msgid "mcp.oauth.cancelled" +msgstr "Connection cancelled" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 198623617c..cd75f0e2f4 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -587,6 +587,10 @@ msgstr "Vista de cuadrícula" msgid "dashboard.files-layout.list" msgstr "Vista de lista" +#: src/app/main/ui/dashboard/fonts.cljs:431 +msgid "dashboard.fonts.delete-variant" +msgstr "Eliminar estilo de fuente" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:608 msgid "dashboard.fonts.deleted-placeholder" msgstr "Fuente no encontrada" @@ -676,6 +680,16 @@ msgstr[1] "%s bibliotecas fueron vinculadas automáticamente por nombre." msgid "dashboard.import.bad-url" msgstr "La importación ha fallado. La URL de la plantilla es incorrecta" +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.check.message" +msgstr "" +"Estamos comprobando que cada archivo se puede importar. Solo tardará un " +"momento." + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.check.title" +msgstr "Comprobando tus archivos..." + #: src/app/main/ui/dashboard/import.cljs:733 msgid "dashboard.import.confirm-library-links" msgstr "Confirmar vínculos de biblioteca" @@ -722,6 +736,22 @@ msgstr[1] "%s ficheros se han importado correctamente." msgid "dashboard.import.import-warning" msgstr "Algunos ficheros contenían objetos erroneos que no han sido importados." +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.importing.done" +msgid_plural "dashboard.import.importing.done" +msgstr[0] "1 hecho" +msgstr[1] "%s hechos" + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.importing.message" +msgstr "Reconstruyendo páginas, componentes y recursos en tu proyecto." + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.importing.title" +msgid_plural "dashboard.import.importing.title" +msgstr[0] "Importando 1 archivo" +msgstr[1] "Importando %s archivos" + #: src/app/main/ui/dashboard/import.cljs:710 msgid "dashboard.import.next-file" msgstr "Siguiente archivo" @@ -730,6 +760,24 @@ msgstr "Siguiente archivo" msgid "dashboard.import.no-perms" msgstr "No tienes permisos para importar en este equipo" +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.queued.message" +msgstr "" +"Tus archivos ya están subidos. La importación empieza en cuanto haya un " +"hueco libre." + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.queued.title" +msgstr "Esperando a empezar..." + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.ready.message" +msgstr "Tus archivos se pueden importar. Continúa con la subida." + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.ready.title" +msgstr "Archivos listos" + #: src/app/main/ui/dashboard/import.cljs:443 msgid "dashboard.import.resolve-libraries" msgstr "" @@ -748,6 +796,26 @@ msgstr "conectar a" msgid "dashboard.import.resolve-libraries.original-library" msgstr "biblioteca original" +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.status.ready" +msgstr "Listo" + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.steps.check" +msgstr "Comprobación" + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.steps.import" +msgstr "Importación" + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.steps.label" +msgstr "Pasos de la importación" + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.steps.upload" +msgstr "Subida" + #: src/app/main/ui/dashboard/import.cljs:521 msgid "dashboard.import.summary.linked" msgstr "auto-vinculada" @@ -768,6 +836,20 @@ msgstr "Ninguna biblioteca seleccionada" msgid "dashboard.import.summary.original" msgstr "Original" +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.title" +msgstr "Importar archivo" + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.upload.message" +msgstr "" +"Enviando tus archivos. Mantén este diálogo abierto hasta que termine la " +"subida." + +#: src/app/main/ui/dashboard/import.cljs +msgid "dashboard.import.upload.title" +msgstr "Subiendo tus archivos..." + #: src/app/main/ui/dashboard/team.cljs:881 msgid "dashboard.invitation-modal.delete" msgstr "Vas a eliminar las invitaciones para:" @@ -2135,9 +2217,37 @@ msgstr "Idea" msgid "feedback.type.issue" msgstr "Problema" -#: src/app/main/ui/exports/files.cljs:140 -msgid "files-download-modal.title" -msgstr "Descargar ficheros" +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.cancel-export" +msgstr "Cancelar exportación" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.done" +msgstr "Listo" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.export-error" +msgstr "No se ha podido exportar: %s" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.exported.message" +msgstr "Revisa tu carpeta de descargas." + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.exported.title" +msgid_plural "files-export-modal.exported.title" +msgstr[0] "1 archivo exportado" +msgstr[1] "%s archivos exportados" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.exporting.message" +msgstr "Cada archivo se descarga en cuanto está listo." + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.exporting.title" +msgid_plural "files-export-modal.exporting.title" +msgstr[0] "Exportando 1 archivo" +msgstr[1] "Exportando %s archivos" #: src/app/main/ui/exports/files.cljs:183 msgid "files-export-modal.options.detach-libraries.message" @@ -2181,6 +2291,18 @@ msgstr "Incluir librerias compartidas dentro de las librerias del fichero" msgid "files-download-modal.unsaved-changes" msgstr "Algunos de tus últimos cambios aún no se han guardado, así que esta descarga no los incluirá." +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.status.exported" +msgstr "Exportado" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.status.waiting" +msgstr "En espera" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.title" +msgstr "Exportar archivo" + #: src/app/main/ui/settings/password.cljs:45 msgid "generic.error" msgstr "Ha ocurrido un error" @@ -2734,7 +2856,7 @@ msgstr "Subiendo archivo…" #: src/app/main/ui/dashboard/import.cljs:328, src/app/main/ui/exports/files.cljs:103 msgid "jobs.queued" -msgstr "En cola" +msgstr "Esperando hueco para el job" #: src/app/main/ui/dashboard/comments.cljs:94 msgid "label.mark-all-as-read" @@ -4873,10 +4995,6 @@ msgstr "Seleccionar elementos que usan este estilo" msgid "shortcuts.add-comment" msgstr "Añadir comentarios" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Añadir nodo" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Alinear abajo" @@ -5153,13 +5271,6 @@ msgstr "Unir nodos" msgid "shortcuts.key-combo" msgstr "Pulsa las nuevas teclas" -#: src/app/main/ui/settings/shortcuts.cljs:484 -msgid "shortcuts.layout-map-missing" -msgstr "" -"Este navegador no expone el mapa de teclado, así que los atajos que " -"combinan Shift con puntuación (por ejemplo Shift+,) pueden no funcionar " -"aquí. Siguen funcionando en navegadores Chromium." - #: src/app/main/data/workspace/text/shortcuts.cljs:249 msgid "shortcuts.line-through" msgstr "Alternar tachado" @@ -7377,6 +7488,12 @@ msgstr "ACTUALIZACIONES" msgid "workspace.notification-pill.detail" msgstr "Detalles" +#: src/app/main/data/workspace/notifications.cljs:282 +msgid "workspace.notifications.file-repaired" +msgstr "" +"Hemos corregido algunos problemas de este archivo. Hay una copia de la " +"versión original en el historial de versiones." + #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:784 msgid "workspace.options.add-interaction" msgstr "Pulsa el botón + para añadir interacciones." @@ -10065,3 +10182,9 @@ msgstr "Revisión:" #: src/app/main/ui/workspace/sidebar/pull_requests.cljs:623 msgid "workspace.pull-requests.banner.cancel" msgstr "Cancelar revisión" + +msgid "mcp.oauth.access" +msgstr "Este cliente puede leer y modificar diseños mediante conexiones MCP establecidas desde Penpot." + +msgid "mcp.oauth.cancelled" +msgstr "Conexión cancelada" diff --git a/frontend/translations/eu.po b/frontend/translations/eu.po index 78a7089a5e..4594f82431 100644 --- a/frontend/translations/eu.po +++ b/frontend/translations/eu.po @@ -1862,10 +1862,6 @@ msgstr "Batzuk" msgid "shortcuts.add-comment" msgstr "Gehitu iruzkinak" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Gehitu nodoa" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Lerrokatu behean" diff --git a/frontend/translations/fa.po b/frontend/translations/fa.po index bf01868ef6..a8c6460fba 100644 --- a/frontend/translations/fa.po +++ b/frontend/translations/fa.po @@ -4678,10 +4678,6 @@ msgstr "انتخاب موارد با استفاده از این سبک" msgid "shortcuts.add-comment" msgstr "نظرات" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "افزودن گره" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "تراز به پایین" diff --git a/frontend/translations/fr.po b/frontend/translations/fr.po index 5686f05331..63c56a4fc0 100644 --- a/frontend/translations/fr.po +++ b/frontend/translations/fr.po @@ -4785,10 +4785,6 @@ msgstr "Sélectionnez les éléments qui utilisent ce style" msgid "shortcuts.add-comment" msgstr "Commentaires" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Ajouter un nœud" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Aligner en bas" diff --git a/frontend/translations/fr_CA.po b/frontend/translations/fr_CA.po index 79fb1a0323..e5b34259e6 100644 --- a/frontend/translations/fr_CA.po +++ b/frontend/translations/fr_CA.po @@ -4509,10 +4509,6 @@ msgstr "Sélectionner les éléments avec ce style" msgid "shortcuts.add-comment" msgstr "Commentaires" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Ajouter un nœud" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Aligner en bas" diff --git a/frontend/translations/ha.po b/frontend/translations/ha.po index dbc38704ca..6382d0fa45 100644 --- a/frontend/translations/ha.po +++ b/frontend/translations/ha.po @@ -1957,10 +1957,6 @@ msgstr "gauraya" msgid "shortcuts.add-comment" msgstr "Bayyana Ra'ayi" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "kara Girma" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "tsarin kasa" diff --git a/frontend/translations/he.po b/frontend/translations/he.po index 0e9eabca97..38e7e83532 100644 --- a/frontend/translations/he.po +++ b/frontend/translations/he.po @@ -3967,10 +3967,6 @@ msgstr "בחירת פריטים שמשתמשים בסגנון הזה" msgid "shortcuts.add-comment" msgstr "הערות" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "הוספת מפרק" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "יישור לתחתית" diff --git a/frontend/translations/hi.po b/frontend/translations/hi.po index b55d4239cd..7d0d01d99d 100644 --- a/frontend/translations/hi.po +++ b/frontend/translations/hi.po @@ -3362,10 +3362,6 @@ msgstr "इस शैली का उपयोग करके आइटम च msgid "shortcuts.add-comment" msgstr "टिप्पणियाँ" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "नोड जोड़ें" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "नीचे संरेखित करें" diff --git a/frontend/translations/hr.po b/frontend/translations/hr.po index b3c94b2d6a..93081b3c64 100644 --- a/frontend/translations/hr.po +++ b/frontend/translations/hr.po @@ -2596,10 +2596,6 @@ msgstr "Odaberite stavke koristeći ovaj stil" msgid "shortcuts.add-comment" msgstr "Komentari" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Dodaj čvor" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Poravnaj dno" diff --git a/frontend/translations/id.po b/frontend/translations/id.po index 03058d1683..250ff26b5f 100644 --- a/frontend/translations/id.po +++ b/frontend/translations/id.po @@ -2805,10 +2805,6 @@ msgstr "Pilih item menggunakan gaya ini" msgid "shortcuts.add-comment" msgstr "Komentar" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Tambahkan simpul" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Sesuaikan ke bawah" diff --git a/frontend/translations/it.po b/frontend/translations/it.po index 3512c5d007..3fa5b6d7b9 100644 --- a/frontend/translations/it.po +++ b/frontend/translations/it.po @@ -3875,10 +3875,6 @@ msgstr "Seleziona gli elementi che utilizzano questo stile" msgid "shortcuts.add-comment" msgstr "Commenti" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Aggiungi nodo" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Allinea in basso" diff --git a/frontend/translations/jpn_JP.po b/frontend/translations/jpn_JP.po index 7b0a3d78b9..65ee25f35b 100644 --- a/frontend/translations/jpn_JP.po +++ b/frontend/translations/jpn_JP.po @@ -4521,10 +4521,6 @@ msgstr "このスタイルを使用している要素を選択" msgid "shortcuts.add-comment" msgstr "コメント" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "ノードを追加" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "下揃え" diff --git a/frontend/translations/ko.po b/frontend/translations/ko.po index fdb131f37d..e3beca6dcc 100644 --- a/frontend/translations/ko.po +++ b/frontend/translations/ko.po @@ -3200,10 +3200,6 @@ msgstr "이 스타일을 사용하는 항목 선택" msgid "shortcuts.add-comment" msgstr "댓글" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "노드 추가" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "아래 정렬" diff --git a/frontend/translations/lv.po b/frontend/translations/lv.po index 6f2f7b95a9..33ab93be32 100644 --- a/frontend/translations/lv.po +++ b/frontend/translations/lv.po @@ -3106,10 +3106,6 @@ msgstr "Atlasīt vienumus, kas izmanto šo stilu" msgid "shortcuts.add-comment" msgstr "Komentāri" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Pievienot mezglu" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Līdzināt pie apakšas" diff --git a/frontend/translations/ms.po b/frontend/translations/ms.po index 4a858ea689..42d2623a31 100644 --- a/frontend/translations/ms.po +++ b/frontend/translations/ms.po @@ -2019,10 +2019,6 @@ msgstr "Bercampur" msgid "shortcuts.add-comment" msgstr "Komen" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Tambah nota" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Sejajarkan bahagian bawah" diff --git a/frontend/translations/nl.po b/frontend/translations/nl.po index 3d55474017..678e93c364 100644 --- a/frontend/translations/nl.po +++ b/frontend/translations/nl.po @@ -4755,10 +4755,6 @@ msgstr "Selecteer items in deze stijl" msgid "shortcuts.add-comment" msgstr "Commentaar" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Knooppunt toevoegen" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Onderaan uitlijnen" diff --git a/frontend/translations/pl.po b/frontend/translations/pl.po index c3afe44ee7..352c7e9eec 100644 --- a/frontend/translations/pl.po +++ b/frontend/translations/pl.po @@ -1856,10 +1856,6 @@ msgstr "Mieszane" msgid "shortcuts.add-comment" msgstr "Komentarze" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Dodaj węzeł" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Wyrównaj do dołu" diff --git a/frontend/translations/pt_BR.po b/frontend/translations/pt_BR.po index 3c27a3205f..0847bf55bf 100644 --- a/frontend/translations/pt_BR.po +++ b/frontend/translations/pt_BR.po @@ -2511,10 +2511,6 @@ msgstr "Misto" msgid "shortcuts.add-comment" msgstr "Comentários" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Adicionar ponto" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Alinhar a base" diff --git a/frontend/translations/pt_PT.po b/frontend/translations/pt_PT.po index 3140b7a1a6..57f7cbd423 100644 --- a/frontend/translations/pt_PT.po +++ b/frontend/translations/pt_PT.po @@ -2657,10 +2657,6 @@ msgstr "Selecionar itens que usem este estilo" msgid "shortcuts.add-comment" msgstr "Comentários" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Adicionar nó" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Alinhar abaixo" diff --git a/frontend/translations/ro.po b/frontend/translations/ro.po index d4b4fc6751..2581de553f 100644 --- a/frontend/translations/ro.po +++ b/frontend/translations/ro.po @@ -3189,10 +3189,6 @@ msgstr "Alege elemente folosind acest stil" msgid "shortcuts.add-comment" msgstr "Comentarii" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Adaugă nod" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Aliniază jos" diff --git a/frontend/translations/ru.po b/frontend/translations/ru.po index ccf5d11a4c..af06c34ea8 100644 --- a/frontend/translations/ru.po +++ b/frontend/translations/ru.po @@ -4722,10 +4722,6 @@ msgstr "Выбрать элементы, использующие этот ст msgid "shortcuts.add-comment" msgstr "Комментарии" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Добавить узел" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Выровнять по нижнему краю" diff --git a/frontend/translations/sr.po b/frontend/translations/sr.po index 589b4d6c88..75ebabcdde 100644 --- a/frontend/translations/sr.po +++ b/frontend/translations/sr.po @@ -2200,10 +2200,6 @@ msgstr "Изаберите ставке користећи овај стил" msgid "shortcuts.add-comment" msgstr "Коментари" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Додај чвор" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Поравнај на дно" diff --git a/frontend/translations/sv.po b/frontend/translations/sv.po index 293b654895..c749f67233 100644 --- a/frontend/translations/sv.po +++ b/frontend/translations/sv.po @@ -4706,10 +4706,6 @@ msgstr "Välj objekt med denna stil" msgid "shortcuts.add-comment" msgstr "Kommentarer" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Lägg till nod" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Justera botten" diff --git a/frontend/translations/tr.po b/frontend/translations/tr.po index adbc51754c..853e76ab7a 100644 --- a/frontend/translations/tr.po +++ b/frontend/translations/tr.po @@ -4702,10 +4702,6 @@ msgstr "Bu biçimi kullanan ögeleri seç" msgid "shortcuts.add-comment" msgstr "Yorumlar" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Düğüm ekle" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Alta hizala" diff --git a/frontend/translations/ukr_UA.po b/frontend/translations/ukr_UA.po index e716947918..519ce16bf8 100644 --- a/frontend/translations/ukr_UA.po +++ b/frontend/translations/ukr_UA.po @@ -3581,10 +3581,6 @@ msgstr "Оберіть елементи, використовуючи цей с msgid "shortcuts.add-comment" msgstr "Коментарі" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Додати вузол" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Вирівняти по низу" diff --git a/frontend/translations/yo.po b/frontend/translations/yo.po index 1f8e5ab1b4..23d4d7dfc4 100644 --- a/frontend/translations/yo.po +++ b/frontend/translations/yo.po @@ -1844,10 +1844,6 @@ msgstr "Dàárú" msgid "shortcuts.add-comment" msgstr "Ọ́rọ̀íwòye" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Fí ípàdé kùn" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Sópọ sí ísàle" diff --git a/frontend/translations/zh_CN.po b/frontend/translations/zh_CN.po index 94376eaabc..f17366656f 100644 --- a/frontend/translations/zh_CN.po +++ b/frontend/translations/zh_CN.po @@ -3948,10 +3948,6 @@ msgstr "选择使用此样式的项目" msgid "shortcuts.add-comment" msgstr "评论" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "添加节点" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "底部对齐" diff --git a/frontend/translations/zh_Hant.po b/frontend/translations/zh_Hant.po index ebb8577137..56f41a99d3 100644 --- a/frontend/translations/zh_Hant.po +++ b/frontend/translations/zh_Hant.po @@ -4443,10 +4443,6 @@ msgstr "選取使用此樣式的項目" msgid "shortcuts.add-comment" msgstr "留言" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "新增節點" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "靠下對齊" diff --git a/library/package.json b/library/package.json index e41f4a6480..6b1911e2bd 100644 --- a/library/package.json +++ b/library/package.json @@ -3,7 +3,7 @@ "version": "1.2.0", "license": "MPL-2.0", "author": "KALEIDOS SUBSIDIARY SL", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "type": "module", "repository": { "type": "git", diff --git a/library/pnpm-lock.yaml b/library/pnpm-lock.yaml index d8a67b195d..6a7c1982b3 100644 --- a/library/pnpm-lock.yaml +++ b/library/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/manage.sh b/manage.sh index f9a5824bfe..950c184001 100755 --- a/manage.sh +++ b/manage.sh @@ -236,6 +236,24 @@ function infra-compose { "$@" } +function check-devenv-gpu-support { + local runtimes + if ! runtimes=$(env -i HOME="$HOME" PATH="$PATH" PWD="$PWD" \ + docker info --format '{{range $name, $_ := .Runtimes}}{{$name}} {{end}}'); then + echo "run-devenv: cannot query Docker runtimes; check that Docker is running and accessible." >&2 + return 1 + fi + if [[ " $runtimes " != *" nvidia "* ]]; then + echo "run-devenv: --gpu requires Docker runtime 'nvidia'; install and configure NVIDIA Container Toolkit." >&2 + return 1 + fi + local icd=/usr/share/vulkan/icd.d/nvidia_icd.json + if [[ ! -f "$icd" || ! -r "$icd" ]]; then + echo "run-devenv: --gpu requires a readable $icd; install the host NVIDIA Vulkan driver." >&2 + return 1 + fi +} + function instance-compose { local instance="$1"; shift local source_path @@ -258,6 +276,10 @@ function instance-compose { compose_files+=(-f docker/devenv/docker-compose.opencode.yml) opencode_env=("PENPOT_OPENCODE_CONFIG_DIR=${PENPOT_OPENCODE_CONFIG_DIR}") fi + # Bash dynamic scope exposes run-devenv's local option here. + if [[ "${devenv_gpu:-false}" == "true" ]]; then + compose_files+=(-f docker/devenv/docker-compose.gpu.yml) + fi env -i HOME="$HOME" PATH="$PATH" PWD="$PWD" \ CURRENT_USER_ID="${CURRENT_USER_ID:-$(id -u)}" \ @@ -784,13 +806,16 @@ function start-instance { instance-compose "$instance" up -d main - # Wait briefly for main to be reachable; the tmux session lives inside. + # Running does not mean entrypoint setup has finished. Nginx starts only + # after ownership and Cargo setup, so wait for its local health endpoint + # before launching builds as penpot (especially on a fresh container). local container deadline container=$(devenv-main-container "$instance") - deadline=$(( SECONDS + 30 )) - while ! docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -q true; do + deadline=$(( SECONDS + 120 )) + while ! docker exec "$container" curl --fail --silent --max-time 2 \ + --output /dev/null http://127.0.0.1:8082/stub_status 2>/dev/null; do [[ $SECONDS -ge $deadline ]] && { - echo "[${instance}] main container did not reach Running within 30s" >&2 + echo "[${instance}] main container initialization did not finish within 120s; check docker logs $container" >&2 return 1 } sleep 1 @@ -872,6 +897,7 @@ function run-devenv { local do_sync=false local do_attach=false local agentic=false + local devenv_gpu=false local serena_context="desktop-app" local git_user_name="" local git_user_email="" @@ -886,6 +912,8 @@ function run-devenv { do_sync=true; shift;; --agentic) agentic=true; shift;; + --gpu) + devenv_gpu=true; shift;; --attach) do_attach=true; shift;; --serena-context) @@ -901,12 +929,13 @@ function run-devenv { -e*) extra_env_args+=(-e "${1#-e}"); shift;; -h|--help) - echo "Usage: run-devenv [--ws N] [--sync] [--attach] [--agentic] [--serena-context CTX] [--opencode-config-dir DIR] [--git-user-name NAME] [--git-user-email EMAIL] [-e KEY=VAL]" + echo "Usage: run-devenv [--ws N] [--sync] [--attach] [--agentic] [--gpu] [--serena-context CTX] [--opencode-config-dir DIR] [--git-user-name NAME] [--git-user-email EMAIL] [-e KEY=VAL]" echo " Bring a single workspace up." echo " --ws N target workspace (default: 0)." echo " --sync re-seed the wsN clone from the live repo (forbidden on ws0)." echo " --attach attach to the tmux session after startup." echo " --agentic enable MCP + Serena (AI-agent mode)." + echo " --gpu grant NVIDIA GPU access at container creation." echo " --serena-context CTX context passed to Serena (default: desktop-app)." echo " --opencode-config-dir DIR bind-mount DIR at ~/.config/opencode inside the" echo " container (personal agents/prompts/skills kept in a" @@ -960,6 +989,10 @@ function run-devenv { return 1 fi + if [[ "$devenv_gpu" == "true" ]]; then + check-devenv-gpu-support || return 1 + fi + pull-devenv-if-not-exists ensure-devenv-network ensure-infra-up @@ -1052,6 +1085,11 @@ function attach-devenv { # no flag → ws0 (the live repo at $PWD). # --ws N → wsN's workspace clone at ${PENPOT_WORKSPACES_DIR}/wsN # (N is an integer; non-integer values are rejected). +# --bin EXE → run EXE instead of the client's default executable (e.g. a +# wrapper such as 'claude-alt'); the client still picks +# how the MCP config is wired in. +# --ws and --bin may come in any order; everything after them is passed +# through to the client. # Before launching, the function cd's into the resolved workspace and refuses # to start unless the target instance's 'main' container is up — the Penpot # and Serena MCP servers only exist while the devenv is running. @@ -1066,14 +1104,14 @@ function attach-devenv { # committed templates). Nothing is written to .codex/config.toml, # so the developer's own Codex config is left untouched. # -# Usage: ./manage.sh start-coding-agent [--ws N] [...passthrough] +# Usage: ./manage.sh start-coding-agent [--ws N] [--bin EXE] [...passthrough] function start-coding-agent { local client="${1:-}" [[ $# -gt 0 ]] && shift case "$client" in ""|-h|--help) - echo "Usage: $0 start-coding-agent [--ws N] [...passthrough]" >&2 + echo "Usage: $0 start-coding-agent [--ws N] [--bin EXE] [...passthrough]" >&2 return 1 ;; claude|opencode|vscode|codex) @@ -1085,10 +1123,26 @@ function start-coding-agent { esac local instance="ws0" - if [[ $# -gt 0 && "$1" == "--ws" ]]; then - instance="$(parse-ws-integer "$2")" || return 1 - shift 2 - fi + local bin_override="" + while [[ $# -gt 0 ]]; do + case "$1" in + --ws) + instance="$(parse-ws-integer "$2")" || return 1 + shift 2 + ;; + --bin) + if [[ -z "${2:-}" ]]; then + echo "start-coding-agent: --bin requires an executable name or path." >&2 + return 1 + fi + bin_override="$2" + shift 2 + ;; + *) + break + ;; + esac + done # --ws is the default-elided flag: only emit it in suggestion strings for # ws1+; ws0 is the default target so 'run-devenv --agentic' is the right hint. @@ -1129,6 +1183,7 @@ function start-coding-agent { vscode) bin="code"; cfg_rel=".vscode/mcp.json" ;; codex) bin="codex"; cfg_rel=".devenv/templates/codex.toml" ;; esac + [[ -n "$bin_override" ]] && bin="$bin_override" if ! command -v "$bin" >/dev/null 2>&1; then echo "start-coding-agent: '$bin' is not on PATH." >&2 @@ -1146,13 +1201,13 @@ function start-coding-agent { cd "$workspace" || return 1 case "$client" in claude) - exec claude --mcp-config "$cfg_rel" "$@" + exec "$bin" --mcp-config "$cfg_rel" "$@" ;; opencode) - OPENCODE_CONFIG="$cfg_rel" exec opencode "$@" + OPENCODE_CONFIG="$cfg_rel" exec "$bin" "$@" ;; vscode) - exec code "$workspace" "$@" + exec "$bin" "$workspace" "$@" ;; codex) # Build our servers as `-c mcp_servers.....` overrides from the @@ -1174,7 +1229,7 @@ function start-coding-agent { echo " .devenv/{shared,templates}/codex.toml." >&2 return 1 fi - exec codex "${codex_args[@]}" "$@" + exec "$bin" "${codex_args[@]}" "$@" ;; esac } @@ -1482,6 +1537,7 @@ function usage { echo " start of a wsN with no on-disk workspace yet)." echo " --attach attach to the tmux session after startup." echo " --agentic enable MCP + Serena (AI-agent mode)." + echo " --gpu grant NVIDIA GPU access at container creation." echo " --serena-context CTX passed to Serena (default: desktop-app)." echo " --opencode-config-dir DIR" echo " bind-mount DIR over the container's" @@ -1513,7 +1569,8 @@ function usage { echo " config wired in. cd's into the workspace, refuses to launch if the" echo " instance is not running, and forwards extra args to the client." echo " client: claude | opencode | vscode | codex" - echo " Options: --ws N (default: 0). See agentic-devenv.md and" + echo " Options: --ws N (default: 0), --bin EXE (run EXE instead of" + echo " the client's default executable). See agentic-devenv.md and" echo " .devenv/README.md for per-client setup and override paths." echo "" echo "- build-bundle Build all bundles (frontend, backend, exporter, storybook and mcp)." diff --git a/mcp/README.md b/mcp/README.md index 59d60aaf61..4c206d9e35 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -290,7 +290,7 @@ The Penpot MCP server can be configured using environment variables. | Environment Variable | Description | Default | |--------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------| | `PENPOT_MCP_SERVER_HOST` | Address on which the MCP server listens (binds to) | `localhost` | -| `PENPOT_MCP_SERVER_PORT` | Port for the MCP server | `4401` | +| `PENPOT_MCP_SERVER_PORT` | Port for the MCP server | `4401` | | `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` | | `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | | `PENPOT_MCP_REPL_HOST` | Address on which the REPL server listens (binds to) | `localhost` | @@ -299,7 +299,19 @@ The Penpot MCP server can be configured using environment variables. | `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools in local single-user mode. Set to `true` to enable. | `false` | | `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` | | `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) | +| `PENPOT_MCP_AUTH_URI` | Public base URL of the MCP authorization service, e.g. `https://penpot.example/api/mcp/oauth`. Set to enable OAuth; requires `--multi-user`. Use HTTPS (HTTP is allowed on loopback for development), without credentials, query parameters, or a fragment. | (unset; OAuth disabled) | | `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) | +| `PENPOT_MCP_BACKEND_URI` | Penpot instance base URI. Setting a nonblank value enables tools that communicate with the Penpot backend (currently only the `submit_feedback` tool). The MCP server appends suffixes such as `api/main/methods/send-mcp-feedback`. Backend interactions require MCP user tokens to be provided. | (unset) | + +### Backend Configuration + +If `PENPOT_MCP_BACKEND_URI` is set, the backend must itself be configured correspondingly, i.e. set +the following variables for the backend process: + +| Environment Variable | Description | Default | +|----------------------|-------------|---------| +| `PENPOT_MCP_FEEDBACK_EMAIL` | Selects the inbox for MCP feedback. Unset means the command rejects submissions. Requires the `enable-mcp` or `enable-access-tokens` flag and uses the backend's existing SMTP configuration. | (unset) | + ### Logging Configuration diff --git a/mcp/package.json b/mcp/package.json index a62d084e1f..97012b38b0 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -23,9 +23,9 @@ "type": "git", "url": "https://github.com/penpot/penpot.git" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "devDependencies": { - "concurrently": "^10.0.5", - "prettier": "^3.9.9" + "concurrently": "^10.0.6", + "prettier": "^3.9.10" } } diff --git a/mcp/packages/browser-extension/.gitignore b/mcp/packages/browser-extension/.gitignore new file mode 100644 index 0000000000..f06235c460 --- /dev/null +++ b/mcp/packages/browser-extension/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/mcp/packages/browser-extension/README.md b/mcp/packages/browser-extension/README.md new file mode 100644 index 0000000000..21597628ae --- /dev/null +++ b/mcp/packages/browser-extension/README.md @@ -0,0 +1,53 @@ +# Penpot MCP + +A Chrome and Firefox extension that keeps the browser from discarding Penpot tabs connected to +MCP. A discarded tab reloads when the user returns to it, and loses its MCP session. + +## How it works + +- The content script asks the Penpot page for its MCP session. The page answers with the + session ID and connection status. +- The background (a service worker in Chrome, an event page in Firefox) maps each session to + its tab and marks the tab as not discardable (`autoDiscardable: false`) until the session + ends. + +The popup lists the connected tabs. + +The icon is the Penpot logo; `public/icons/icon.svg` is its source. To redraw the PNGs after +changing it: + +```sh +for size in 16 32 48 128; do + rsvg-convert -w $size -h $size public/icons/icon.svg -o public/icons/icon-$size.png +done +``` + +## Frozen tabs + +Chrome can also freeze a background page, which then cannot run the MCP plugin. The extension +does not handle this: the Penpot page holds a shared Web Lock while its MCP connection is +requested, and Chrome does not freeze a page holding one. Firefox does not freeze background +pages. + +## Build and load + +From `mcp/`: + +```sh +pnpm install +pnpm --filter mcp-common run build +pnpm --filter mcp-browser-extension run build +``` + +The build writes one extension per browser, in `dist/chrome` and `dist/firefox`. + +- **Chrome:** open `chrome://extensions`, turn on developer mode, choose "Load unpacked" and + pick `mcp/packages/browser-extension/dist/chrome`. +- **Firefox** (140 or later): open `about:debugging#/runtime/this-firefox`, choose "Load + Temporary Add-on…" and pick `mcp/packages/browser-extension/dist/firefox/manifest.json`. + Firefox removes temporary add-ons when it closes. + +Reload Penpot tabs opened before the extension was loaded, so the content script runs in them. + +The content script runs on `https://design.penpot.app`, on the test environments under +`https://*.penpot.dev` and on `localhost` at any port. diff --git a/mcp/packages/browser-extension/package.json b/mcp/packages/browser-extension/package.json new file mode 100644 index 0000000000..0f1c0fe8ae --- /dev/null +++ b/mcp/packages/browser-extension/package.json @@ -0,0 +1,21 @@ +{ + "name": "mcp-browser-extension", + "private": true, + "version": "0.1.0", + "description": "Browser extension that keeps Penpot tabs connected to MCP from being discarded", + "type": "module", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "scripts": { + "build": "tsc && node ./scripts/build.mjs", + "test": "tsx --test \"src/**/*.test.ts\"", + "types:check": "tsc", + "clean": "rm -rf dist/" + }, + "devDependencies": { + "@penpot/mcp-common": "workspace:../common", + "@types/chrome": "^0.3.6", + "esbuild": "^0.28.2", + "tsx": "^4.23.15", + "typescript": "^6.0.3" + } +} diff --git a/mcp/packages/browser-extension/public/icons/icon-128.png b/mcp/packages/browser-extension/public/icons/icon-128.png new file mode 100644 index 0000000000..a545f42dd5 Binary files /dev/null and b/mcp/packages/browser-extension/public/icons/icon-128.png differ diff --git a/mcp/packages/browser-extension/public/icons/icon-16.png b/mcp/packages/browser-extension/public/icons/icon-16.png new file mode 100644 index 0000000000..3e6eb3e20e Binary files /dev/null and b/mcp/packages/browser-extension/public/icons/icon-16.png differ diff --git a/mcp/packages/browser-extension/public/icons/icon-32.png b/mcp/packages/browser-extension/public/icons/icon-32.png new file mode 100644 index 0000000000..31f2fe30e5 Binary files /dev/null and b/mcp/packages/browser-extension/public/icons/icon-32.png differ diff --git a/mcp/packages/browser-extension/public/icons/icon-48.png b/mcp/packages/browser-extension/public/icons/icon-48.png new file mode 100644 index 0000000000..f92dd70100 Binary files /dev/null and b/mcp/packages/browser-extension/public/icons/icon-48.png differ diff --git a/mcp/packages/browser-extension/public/icons/icon.svg b/mcp/packages/browser-extension/public/icons/icon.svg new file mode 100644 index 0000000000..b12e54c10e --- /dev/null +++ b/mcp/packages/browser-extension/public/icons/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/mcp/packages/browser-extension/public/popup.css b/mcp/packages/browser-extension/public/popup.css new file mode 100644 index 0000000000..acbc7ba3dd --- /dev/null +++ b/mcp/packages/browser-extension/public/popup.css @@ -0,0 +1,54 @@ +:root { + color-scheme: light dark; + --text: #1f1f1f; + --muted: #6b6b6b; + --background: #ffffff; +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #ededed; + --muted: #a0a0a0; + --background: #1f1f1f; + } +} + +body { + width: 320px; + margin: 0; + padding: 12px 16px; + font: + 13px/1.4 system-ui, + sans-serif; + color: var(--text); + background: var(--background); +} + +h1 { + margin: 0 0 12px; + font-size: 15px; +} + +h2 { + margin: 16px 0 4px; + font-size: 12px; + text-transform: uppercase; + color: var(--muted); +} + +.note { + margin: 0; + color: var(--muted); +} + +ul { + margin: 0; + padding-left: 18px; + overflow-wrap: anywhere; +} + +li.empty { + list-style: none; + margin-left: -18px; + color: var(--muted); +} diff --git a/mcp/packages/browser-extension/public/popup.html b/mcp/packages/browser-extension/public/popup.html new file mode 100644 index 0000000000..778829cfab --- /dev/null +++ b/mcp/packages/browser-extension/public/popup.html @@ -0,0 +1,15 @@ + + + + + Penpot MCP + + + +

Penpot MCP

+

The browser does not discard these tabs while they are connected to MCP.

+

Connected tabs

+
    + + + diff --git a/mcp/packages/browser-extension/scripts/build.mjs b/mcp/packages/browser-extension/scripts/build.mjs new file mode 100644 index 0000000000..6d7b6b3069 --- /dev/null +++ b/mcp/packages/browser-extension/scripts/build.mjs @@ -0,0 +1,18 @@ +import { build } from "esbuild"; +import { cp, rm, writeFile } from "node:fs/promises"; +import { BROWSERS, createManifest } from "./manifest.mjs"; + +const TARGETS = { chrome: "chrome116", firefox: "firefox140" }; + +await rm("dist", { recursive: true, force: true }); +for (const browser of BROWSERS) { + const outdir = `dist/${browser}`; + const options = { bundle: true, target: TARGETS[browser], outdir, logLevel: "info" }; + // Chrome loads the service worker as a module, Firefox the event page as a classic script + await build({ ...options, entryPoints: ["src/background.ts"], format: browser === "chrome" ? "esm" : "iife" }); + await build({ ...options, entryPoints: ["src/popup.ts"], format: "esm" }); + // content scripts load as classic scripts + await build({ ...options, entryPoints: ["src/content.ts"], format: "iife" }); + await cp("public", outdir, { recursive: true }); + await writeFile(`${outdir}/manifest.json`, JSON.stringify(createManifest(browser), null, 4) + "\n"); +} diff --git a/mcp/packages/browser-extension/scripts/manifest.mjs b/mcp/packages/browser-extension/scripts/manifest.mjs new file mode 100644 index 0000000000..5b11972c55 --- /dev/null +++ b/mcp/packages/browser-extension/scripts/manifest.mjs @@ -0,0 +1,61 @@ +/** Browsers the extension is built for, one directory of `dist/` each. */ +export const BROWSERS = ["chrome", "firefox"]; + +const ICONS = { + 16: "icons/icon-16.png", + 32: "icons/icon-32.png", + 48: "icons/icon-48.png", + 128: "icons/icon-128.png", +}; + +const CONTENT_SCRIPTS = [ + { + matches: ["https://design.penpot.app/*", "https://*.penpot.dev/*", "https://localhost/*", "http://localhost/*"], + js: ["content.js"], + run_at: "document_idle", + }, +]; + +/** + * Creates the extension manifest for a browser. + * + * Chrome runs the background as a module service worker. Firefox runs it as an event page and + * needs an add-on ID. + * + * @param {"chrome" | "firefox"} browser - The browser to build for + */ +export function createManifest(browser) { + const common = { + manifest_version: 3, + name: "Penpot MCP", + version: "0.1.0", + description: "Keeps the browser from discarding Penpot tabs connected to MCP.", + icons: ICONS, + permissions: ["storage"], + action: { + default_title: "Penpot MCP", + default_popup: "popup.html", + default_icon: { 16: ICONS[16], 32: ICONS[32] }, + }, + content_scripts: CONTENT_SCRIPTS, + }; + if (browser === "chrome") { + return { + ...common, + minimum_chrome_version: "116", + background: { service_worker: "background.js", type: "module" }, + }; + } + return { + ...common, + background: { scripts: ["background.js"] }, + browser_specific_settings: { + gecko: { + id: "mcp-extension@penpot.app", + // the first version that knows data_collection_permissions + strict_min_version: "140.0", + data_collection_permissions: { required: ["none"] }, + }, + }, + }; +} diff --git a/mcp/packages/browser-extension/src/DiscardGuard.ts b/mcp/packages/browser-extension/src/DiscardGuard.ts new file mode 100644 index 0000000000..a50b06d682 --- /dev/null +++ b/mcp/packages/browser-extension/src/DiscardGuard.ts @@ -0,0 +1,28 @@ +import type { SessionChange, SessionMapObserver } from "./SessionMap.ts"; + +/** Browser control over the automatic discarding of tabs. */ +export interface DiscardControl { + setAutoDiscardable(tabId: number, autoDiscardable: boolean): Promise; +} + +/** + * Keeps the browser from discarding tabs that hold an MCP session, since a discarded tab reloads + * and loses its MCP connection. Tabs regain automatic discarding when they stop holding a session. + */ +export class DiscardGuard implements SessionMapObserver { + constructor(private readonly control: DiscardControl) {} + + sessionsChanged(change: SessionChange): void { + for (const tabId of change.tabsAdded) { + this.set(tabId, false); + } + for (const tabId of change.tabsRemoved) { + this.set(tabId, true); + } + } + + private set(tabId: number, autoDiscardable: boolean): void { + // the tab may have been closed meanwhile + this.control.setAutoDiscardable(tabId, autoDiscardable).catch(() => {}); + } +} diff --git a/mcp/packages/browser-extension/src/PageBridge.test.ts b/mcp/packages/browser-extension/src/PageBridge.test.ts new file mode 100644 index 0000000000..afaeabd048 --- /dev/null +++ b/mcp/packages/browser-extension/src/PageBridge.test.ts @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; +import type { ContentMessage } from "./messages.ts"; +import { PageBridge, type PageWindow, type WorkerChannel } from "./PageBridge.ts"; + +const ORIGIN = "https://design.penpot.app"; + +/** Page recording what the bridge posts and dispatching events to it. */ +class FakePage implements PageWindow { + readonly location = { origin: ORIGIN }; + readonly posted: { message: unknown; targetOrigin: string }[] = []; + private readonly listeners = new Map void)[]>(); + + postMessage(message: unknown, targetOrigin: string): void { + this.posted.push({ message, targetOrigin }); + } + + addEventListener(type: string, listener: (event: any) => void): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + dispatch(type: string, event: object = {}): void { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + /** Posts a message on the page as the Penpot app does. */ + post(data: unknown, origin = ORIGIN, source: unknown = this): void { + this.dispatch("message", { data, origin, source }); + } +} + +/** Worker channel recording what the bridge sends. */ +class FakeWorker implements WorkerChannel { + readonly received: ContentMessage[] = []; + + async sendMessage(message: ContentMessage): Promise { + this.received.push(message); + return undefined; + } +} + +const announcement = { type: "penpot-mcp:session", sessionId: "abcdefghij", status: "connected" }; + +let page: FakePage; +let worker: FakeWorker; + +beforeEach(() => { + page = new FakePage(); + worker = new FakeWorker(); + new PageBridge(page, worker).start(); +}); + +test("asks the page for its session on start, restricted to the page's origin", () => { + assert.deepEqual(page.posted, [{ message: { type: "penpot-mcp-extension:hello" }, targetOrigin: ORIGIN }]); +}); + +test("says hello again when the page reports it has started listening", () => { + page.post({ type: "penpot-mcp:ready" }); + page.post({ type: "penpot-mcp:ready" }, "https://evil.example"); + assert.equal(page.posted.length, 2); + assert.deepEqual(page.posted[1], { message: { type: "penpot-mcp-extension:hello" }, targetOrigin: ORIGIN }); +}); + +test("forwards the page's session announcements to the worker", () => { + page.post(announcement); + assert.deepEqual(worker.received, [{ type: "session", announcement }]); +}); + +test("ignores announcements from other origins or other windows", () => { + page.post(announcement, "https://evil.example"); + page.post(announcement, ORIGIN, {}); + assert.deepEqual(worker.received, []); +}); + +test("ignores invalid announcements and its own hello", () => { + page.post({ ...announcement, status: "unknown" }); + page.post({ type: "penpot-mcp-extension:hello" }); + assert.deepEqual(worker.received, []); +}); + +test("reports a hidden page and asks again when it returns from the back/forward cache", () => { + page.dispatch("pagehide"); + page.dispatch("pageshow", { persisted: false }); + assert.equal(page.posted.length, 1); + page.dispatch("pageshow", { persisted: true }); + assert.deepEqual(worker.received, [{ type: "page-hidden" }]); + assert.equal(page.posted.length, 2); +}); + +test("keeps working when the worker cannot be reached", () => { + const failing = new FakePage(); + new PageBridge(failing, { + sendMessage: () => { + throw new Error("Extension context invalidated."); + }, + }).start(); + failing.post(announcement); + new PageBridge(failing, { + sendMessage: () => Promise.reject(new Error("No receiver")), + }).start(); + failing.post(announcement); +}); diff --git a/mcp/packages/browser-extension/src/PageBridge.ts b/mcp/packages/browser-extension/src/PageBridge.ts new file mode 100644 index 0000000000..42a0a54887 --- /dev/null +++ b/mcp/packages/browser-extension/src/PageBridge.ts @@ -0,0 +1,69 @@ +import type { ExtensionHello } from "@penpot/mcp-common"; +import { parseAnnouncement, type ContentMessage } from "./messages.ts"; + +/** Page operations the bridge uses; the content script passes its window. */ +export interface PageWindow { + readonly location: { readonly origin: string }; + postMessage(message: unknown, targetOrigin: string): void; + addEventListener(type: string, listener: (event: any) => void): void; +} + +/** Channel from the content script to the service worker. */ +export interface WorkerChannel { + sendMessage(message: ContentMessage): Promise; +} + +/** + * Relays the MCP session announced by a Penpot page to the extension's service worker. + * + * The bridge asks the page for its session on load, when the page reports it has started + * listening, and when the page returns from the back/forward cache. It accepts only messages the + * page posted on its own window from its own origin. + */ +export class PageBridge { + constructor( + private readonly page: PageWindow, + private readonly worker: WorkerChannel + ) {} + + /** Starts listening to the page and asks it for its session. */ + start(): void { + this.page.addEventListener("message", (event: MessageEvent) => this.handleMessage(event)); + this.page.addEventListener("pagehide", () => this.send({ type: "page-hidden" })); + this.page.addEventListener("pageshow", (event: PageTransitionEvent) => { + if (event.persisted) { + this.sayHello(); + } + }); + this.sayHello(); + } + + private sayHello(): void { + const hello: ExtensionHello = { type: "penpot-mcp-extension:hello" }; + this.page.postMessage(hello, this.page.location.origin); + } + + private handleMessage(event: MessageEvent): void { + if (event.source !== this.page || event.origin !== this.page.location.origin) { + return; + } + // the page started listening after the hello sent on load + if (event.data?.type === "penpot-mcp:ready") { + this.sayHello(); + return; + } + const announcement = parseAnnouncement(event.data); + if (announcement) { + this.send({ type: "session", announcement }); + } + } + + private send(message: ContentMessage): void { + try { + // the worker cannot be reached after the extension is reloaded or removed + this.worker.sendMessage(message).catch(() => {}); + } catch { + // ignored for the same reason + } + } +} diff --git a/mcp/packages/browser-extension/src/PopupState.test.ts b/mcp/packages/browser-extension/src/PopupState.test.ts new file mode 100644 index 0000000000..94a1b9d892 --- /dev/null +++ b/mcp/packages/browser-extension/src/PopupState.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getPopupState } from "./PopupState.ts"; +import { SessionMap } from "./SessionMap.ts"; + +test("lists the sessions and their tabs", async () => { + const sessions = new SessionMap({ load: async () => undefined, save: async () => {} }); + await sessions.announce( + { tabId: 7, windowId: 1 }, + { type: "penpot-mcp:session", sessionId: "tab", status: "connected" } + ); + assert.deepEqual(getPopupState(sessions), { sessions: [{ sessionId: "tab", tabId: 7 }] }); +}); diff --git a/mcp/packages/browser-extension/src/PopupState.ts b/mcp/packages/browser-extension/src/PopupState.ts new file mode 100644 index 0000000000..1bf6678a69 --- /dev/null +++ b/mcp/packages/browser-extension/src/PopupState.ts @@ -0,0 +1,22 @@ +import type { SessionMap } from "./SessionMap.ts"; + +/** Request of the popup for the extension's state. */ +export interface PopupStateRequest { + type: "popup:get-state"; +} + +/** State of the extension shown in the popup. */ +export interface PopupState { + sessions: { sessionId: string; tabId: number }[]; +} + +/** + * Collects the state shown in the popup. + * + * @param sessions - The map of sessions to tabs + */ +export function getPopupState(sessions: SessionMap): PopupState { + return { + sessions: sessions.all().map(([sessionId, entry]) => ({ sessionId, tabId: entry.tabId })), + }; +} diff --git a/mcp/packages/browser-extension/src/SessionMap.test.ts b/mcp/packages/browser-extension/src/SessionMap.test.ts new file mode 100644 index 0000000000..38e31fa874 --- /dev/null +++ b/mcp/packages/browser-extension/src/SessionMap.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; +import type { SessionAnnouncement } from "@penpot/mcp-common"; +import { DiscardGuard } from "./DiscardGuard.ts"; +import { SessionMap, type SessionChange, type SessionEntry, type SessionStore } from "./SessionMap.ts"; + +/** In-memory store standing in for session storage. */ +class MemoryStore implements SessionStore { + constructor(public saved?: Record) {} + + async load() { + return this.saved; + } + + async save(entries: Record) { + this.saved = structuredClone(entries); + } +} + +const TAB = { tabId: 1, windowId: 10 }; +const OTHER_TAB = { tabId: 2, windowId: 10 }; + +function announcement( + status: SessionAnnouncement["status"], + sessionId: string | null = "session" +): SessionAnnouncement { + return { type: "penpot-mcp:session", sessionId, status }; +} + +let store: MemoryStore; +let changes: SessionChange[]; +let sessions: SessionMap; + +beforeEach(() => { + store = new MemoryStore(); + changes = []; + sessions = new SessionMap(store); + sessions.observe({ sessionsChanged: (change) => changes.push(change) }); +}); + +test("maps a connected session to the announcing tab and saves it", async () => { + await sessions.announce(TAB, announcement("connected")); + assert.deepEqual(sessions.get("session"), TAB); + assert.deepEqual(store.saved, { session: TAB }); + assert.deepEqual(changes, [{ tabsAdded: [1], tabsRemoved: [] }]); +}); + +test("ignores announcements without a connected session", async () => { + await sessions.announce(TAB, announcement("connecting", null)); + await sessions.announce(TAB, announcement("connected", null)); + await sessions.announce(TAB, announcement("error")); + assert.deepEqual(sessions.all(), []); + assert.deepEqual(changes, []); +}); + +test("keeps the session while the plugin reconnects", async () => { + await sessions.announce(TAB, announcement("connected")); + await sessions.announce(TAB, announcement("connecting")); + await sessions.announce(TAB, announcement("connected")); + assert.equal(sessions.get("session")?.tabId, 1); + assert.equal(changes.length, 1); +}); + +test("forgets the tab's session when its page announces a disconnect", async () => { + await sessions.announce(TAB, announcement("connected")); + await sessions.announce(TAB, announcement("disconnected", null)); + assert.equal(sessions.get("session"), undefined); + assert.deepEqual(changes.at(-1), { tabsAdded: [], tabsRemoved: [1] }); +}); + +test("a tab holds only the session it announced last", async () => { + await sessions.announce(TAB, announcement("connected", "old")); + await sessions.announce(TAB, announcement("connected", "new")); + assert.equal(sessions.get("old"), undefined); + assert.equal(sessions.get("new")?.tabId, 1); + assert.deepEqual(changes.at(-1), { tabsAdded: [], tabsRemoved: [] }); +}); + +test("forgets only the sessions of a removed tab", async () => { + await sessions.announce(TAB, announcement("connected", "first")); + await sessions.announce(OTHER_TAB, announcement("connected", "second")); + await sessions.removeTab(1); + assert.deepEqual( + sessions.all().map(([sessionId]) => sessionId), + ["second"] + ); + assert.deepEqual(changes.at(-1), { tabsAdded: [], tabsRemoved: [1] }); +}); + +test("restores the saved sessions when the worker restarts", async () => { + await sessions.announce(TAB, announcement("connected")); + const restarted = new SessionMap(store); + await restarted.ready(); + assert.equal(restarted.get("session")?.tabId, 1); +}); + +test("changes made while restoring apply to the restored sessions", async () => { + const saved = new MemoryStore({ first: TAB }); + const restored = new SessionMap(saved); + await restored.announce(OTHER_TAB, announcement("connected", "second")); + assert.deepEqual(Object.keys(saved.saved!).sort(), ["first", "second"]); +}); + +test("the discard guard protects tabs holding a session and releases them after", async () => { + const calls: [number, boolean][] = []; + const guard = new DiscardGuard({ + setAutoDiscardable: async (tabId, autoDiscardable) => { + calls.push([tabId, autoDiscardable]); + }, + }); + const guarded = new SessionMap(new MemoryStore()); + guarded.observe(guard); + await guarded.announce(TAB, announcement("connected")); + await guarded.removeTab(1); + assert.deepEqual(calls, [ + [1, false], + [1, true], + ]); +}); + +test("the discard guard ignores tabs closed meanwhile", async () => { + const guard = new DiscardGuard({ setAutoDiscardable: () => Promise.reject(new Error("No tab with id")) }); + guard.sessionsChanged({ tabsAdded: [1], tabsRemoved: [2] }); + await new Promise((resolve) => setImmediate(resolve)); +}); diff --git a/mcp/packages/browser-extension/src/SessionMap.ts b/mcp/packages/browser-extension/src/SessionMap.ts new file mode 100644 index 0000000000..53eca8e1e2 --- /dev/null +++ b/mcp/packages/browser-extension/src/SessionMap.ts @@ -0,0 +1,129 @@ +import type { SessionAnnouncement } from "@penpot/mcp-common"; + +/** The tab holding an MCP session. */ +export interface SessionEntry { + tabId: number; + windowId: number; +} + +/** Persistence of the session map across service worker restarts. */ +export interface SessionStore { + load(): Promise | undefined>; + save(entries: Record): Promise; +} + +/** Change of the tabs holding sessions, reported after each change of the map. */ +export interface SessionChange { + /** tabs that hold a session and held none before. */ + tabsAdded: number[]; + /** tabs that held a session and hold none now. */ + tabsRemoved: number[]; +} + +/** Receiver of the changes of a session map. */ +export interface SessionMapObserver { + sessionsChanged(change: SessionChange): void; +} + +/** + * Map from MCP session IDs to the tabs holding them. + * + * A tab holds the session it last announced as connected, until it announces a disconnect, is + * hidden or is closed. The map is saved after each change and restored when the service worker + * restarts. + */ +export class SessionMap { + private entries = new Map(); + private readonly observers: SessionMapObserver[] = []; + private readonly loaded: Promise; + + constructor(private readonly store: SessionStore) { + this.loaded = store.load().then( + (saved) => { + this.entries = new Map(Object.entries(saved ?? {})); + }, + () => {} + ); + } + + /** Adds a receiver of the changes of this map. */ + observe(observer: SessionMapObserver): void { + this.observers.push(observer); + } + + /** Resolves once the saved entries have been restored. */ + ready(): Promise { + return this.loaded; + } + + /** + * Records a session announcement made by the page in a tab. + * + * @param tab - The tab and window of the announcing page + * @param announcement - The page's announcement + */ + async announce(tab: { tabId: number; windowId: number }, announcement: SessionAnnouncement): Promise { + await this.loaded; + if (announcement.status === "disconnected") { + await this.change(() => this.deleteTab(tab.tabId)); + } else if (announcement.status === "connected" && announcement.sessionId) { + const { sessionId } = announcement; + await this.change(() => { + // a tab holds a single session + this.deleteTab(tab.tabId); + this.entries.set(sessionId, { ...tab }); + }); + } + } + + /** + * Forgets the sessions held by a tab. + * + * @param tabId - The tab whose page was hidden, or which was closed or replaced + */ + async removeTab(tabId: number): Promise { + await this.loaded; + await this.change(() => this.deleteTab(tabId)); + } + + /** Returns the entry of a session, if a tab holds it. */ + get(sessionId: string): SessionEntry | undefined { + return this.entries.get(sessionId); + } + + /** Returns all sessions with their entries. */ + all(): [string, SessionEntry][] { + return [...this.entries]; + } + + /** Applies a change, then saves the map and reports the change if any tab gained or lost a session. */ + private async change(apply: () => void): Promise { + const before = this.tabs(); + const snapshot = JSON.stringify([...this.entries]); + apply(); + if (JSON.stringify([...this.entries]) === snapshot) { + return; + } + const after = this.tabs(); + await this.store.save(Object.fromEntries(this.entries)); + const change = { + tabsAdded: [...after].filter((tabId) => !before.has(tabId)), + tabsRemoved: [...before].filter((tabId) => !after.has(tabId)), + }; + for (const observer of this.observers) { + observer.sessionsChanged(change); + } + } + + private deleteTab(tabId: number): void { + for (const [sessionId, entry] of [...this.entries]) { + if (entry.tabId === tabId) { + this.entries.delete(sessionId); + } + } + } + + private tabs(): Set { + return new Set([...this.entries.values()].map((entry) => entry.tabId)); + } +} diff --git a/mcp/packages/browser-extension/src/background.ts b/mcp/packages/browser-extension/src/background.ts new file mode 100644 index 0000000000..fc4f6ddf43 --- /dev/null +++ b/mcp/packages/browser-extension/src/background.ts @@ -0,0 +1,44 @@ +/** + * Service worker of the Penpot MCP extension. + * + * It keeps the map from MCP sessions to the tabs holding them, and keeps the browser from + * discarding those tabs. + * + * Chrome may stop the worker, and Firefox its event page, at any time, so listeners are + * registered synchronously at start and state lives in session storage. + */ +import { ChromeDiscardControl, ChromeSessionStore } from "./chrome.ts"; +import { DiscardGuard } from "./DiscardGuard.ts"; +import { parseAnnouncement, type ContentMessage } from "./messages.ts"; +import { getPopupState, type PopupStateRequest } from "./PopupState.ts"; +import { SessionMap } from "./SessionMap.ts"; + +const sessions = new SessionMap(new ChromeSessionStore()); +sessions.observe(new DiscardGuard(new ChromeDiscardControl())); + +chrome.runtime.onMessage.addListener((message: ContentMessage | PopupStateRequest, sender, sendResponse) => { + if (sender.id !== chrome.runtime.id) { + return; + } + const tab = sender.tab; + if (message?.type === "popup:get-state" && !tab) { + void sessions.ready().then(() => sendResponse(getPopupState(sessions))); + // the response is sent asynchronously + return true; + } + if (tab?.id === undefined) { + return; + } + if (message?.type === "page-hidden") { + void sessions.removeTab(tab.id); + } else if (message?.type === "session") { + const announcement = parseAnnouncement(message.announcement); + if (announcement) { + void sessions.announce({ tabId: tab.id, windowId: tab.windowId }, announcement); + } + } +}); + +chrome.tabs.onRemoved.addListener((tabId) => void sessions.removeTab(tabId)); + +chrome.tabs.onReplaced.addListener((_addedTabId, removedTabId) => void sessions.removeTab(removedTabId)); diff --git a/mcp/packages/browser-extension/src/chrome.ts b/mcp/packages/browser-extension/src/chrome.ts new file mode 100644 index 0000000000..38954c7ced --- /dev/null +++ b/mcp/packages/browser-extension/src/chrome.ts @@ -0,0 +1,26 @@ +import type { DiscardControl } from "./DiscardGuard.ts"; +import type { SessionEntry, SessionStore } from "./SessionMap.ts"; + +const SESSIONS_KEY = "sessions"; + +/** + * Session store in `chrome.storage.session`, which lives in memory, survives service worker + * restarts and clears when the browser closes. + */ +export class ChromeSessionStore implements SessionStore { + async load(): Promise | undefined> { + const stored = await chrome.storage.session.get(SESSIONS_KEY); + return stored[SESSIONS_KEY] as Record | undefined; + } + + async save(entries: Record): Promise { + await chrome.storage.session.set({ [SESSIONS_KEY]: entries }); + } +} + +/** Discard control through the Chrome tabs API. */ +export class ChromeDiscardControl implements DiscardControl { + async setAutoDiscardable(tabId: number, autoDiscardable: boolean): Promise { + await chrome.tabs.update(tabId, { autoDiscardable }); + } +} diff --git a/mcp/packages/browser-extension/src/content.ts b/mcp/packages/browser-extension/src/content.ts new file mode 100644 index 0000000000..1e115228a4 --- /dev/null +++ b/mcp/packages/browser-extension/src/content.ts @@ -0,0 +1,3 @@ +import { PageBridge } from "./PageBridge.ts"; + +new PageBridge(window, chrome.runtime).start(); diff --git a/mcp/packages/browser-extension/src/manifest.test.ts b/mcp/packages/browser-extension/src/manifest.test.ts new file mode 100644 index 0000000000..f78006c58a --- /dev/null +++ b/mcp/packages/browser-extension/src/manifest.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { access } from "node:fs/promises"; +import test from "node:test"; +import { BROWSERS, createManifest } from "../scripts/manifest.mjs"; + +const manifests = BROWSERS.map((browser) => createManifest(browser)); +const [chrome, firefox] = manifests; + +test("runs the background as a module service worker in Chrome and as an event page in Firefox", () => { + assert.deepEqual(chrome.background, { service_worker: "background.js", type: "module" }); + assert.deepEqual(firefox.background, { scripts: ["background.js"] }); +}); + +test("requests only storage", () => { + for (const manifest of manifests) { + assert.equal(manifest.manifest_version, 3); + assert.deepEqual(manifest.permissions, ["storage"]); + assert.equal(manifest.optional_permissions, undefined); + assert.equal(manifest.host_permissions, undefined); + } +}); + +test("gives the Firefox build an add-on ID and a minimum version", () => { + assert.deepEqual(firefox.browser_specific_settings, { + gecko: { + id: "mcp-extension@penpot.app", + strict_min_version: "140.0", + data_collection_permissions: { required: ["none"] }, + }, + }); + assert.equal(chrome.browser_specific_settings, undefined); +}); + +test("references icons and a popup that exist in public/", async () => { + for (const manifest of manifests) { + const paths = [ + ...Object.values(manifest.icons as Record), + ...Object.values(manifest.action.default_icon as Record), + manifest.action.default_popup, + ]; + for (const path of paths) { + await access(new URL(`../public/${path}`, import.meta.url)); + } + } +}); diff --git a/mcp/packages/browser-extension/src/messages.test.ts b/mcp/packages/browser-extension/src/messages.test.ts new file mode 100644 index 0000000000..14040124e5 --- /dev/null +++ b/mcp/packages/browser-extension/src/messages.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseAnnouncement } from "./messages.ts"; + +const announcement = { type: "penpot-mcp:session", sessionId: "abcdefghij", status: "connected" }; + +test("accepts session announcements, with or without a session ID", () => { + for (const data of [announcement, { ...announcement, sessionId: null, status: "connecting" }]) { + assert.deepEqual(parseAnnouncement(data), data); + } +}); + +test("keeps only the announcement fields", () => { + assert.deepEqual(parseAnnouncement({ ...announcement, extra: 1 }), announcement); +}); + +test("rejects other messages and malformed announcements", () => { + for (const data of [ + null, + "penpot-mcp:session", + { ...announcement, type: "penpot-mcp-extension:hello" }, + { ...announcement, sessionId: "" }, + { ...announcement, sessionId: 42 }, + { ...announcement, status: "frozen" }, + ]) { + assert.equal(parseAnnouncement(data), null, JSON.stringify(data)); + } +}); diff --git a/mcp/packages/browser-extension/src/messages.ts b/mcp/packages/browser-extension/src/messages.ts new file mode 100644 index 0000000000..e55644973e --- /dev/null +++ b/mcp/packages/browser-extension/src/messages.ts @@ -0,0 +1,32 @@ +import type { McpConnectionStatus, SessionAnnouncement } from "@penpot/mcp-common"; + +/** + * Message sent by the content script to the service worker. + * + * - `session`: the page announced its MCP session + * - `page-hidden`: the page is being unloaded or stored in the back/forward cache + */ +export type ContentMessage = { type: "session"; announcement: SessionAnnouncement } | { type: "page-hidden" }; + +const STATUSES: readonly McpConnectionStatus[] = ["idle", "connecting", "connected", "disconnected", "error"]; + +function isSessionId(value: unknown): value is string | null { + return value === null || (typeof value === "string" && value.length > 0 && value.length <= 128); +} + +/** + * Validates a session announcement posted on the page. + * + * @param data - The data of a window message + * @returns The announcement, or null if the data is not a valid announcement + */ +export function parseAnnouncement(data: unknown): SessionAnnouncement | null { + if (typeof data !== "object" || data === null) { + return null; + } + const { type, sessionId, status } = data as Record; + if (type !== "penpot-mcp:session" || !isSessionId(sessionId) || !STATUSES.includes(status as McpConnectionStatus)) { + return null; + } + return { type, sessionId, status: status as McpConnectionStatus }; +} diff --git a/mcp/packages/browser-extension/src/popup.ts b/mcp/packages/browser-extension/src/popup.ts new file mode 100644 index 0000000000..fa29fac3fb --- /dev/null +++ b/mcp/packages/browser-extension/src/popup.ts @@ -0,0 +1,28 @@ +import type { PopupState, PopupStateRequest } from "./PopupState.ts"; + +const REFRESH_INTERVAL_MS = 1_000; + +const sessionsList = document.getElementById("sessions") as HTMLUListElement; + +function render(state: PopupState): void { + const items = state.sessions.map((session) => `${session.sessionId} (tab ${session.tabId})`); + sessionsList.replaceChildren( + ...(items.length ? items : ["No Penpot tab is connected to MCP."]).map((text) => { + const item = document.createElement("li"); + item.textContent = text; + item.classList.toggle("empty", items.length === 0); + return item; + }) + ); +} + +async function refresh(): Promise { + const request: PopupStateRequest = { type: "popup:get-state" }; + const state: PopupState | undefined = await chrome.runtime.sendMessage(request); + if (state) { + render(state); + } +} + +void refresh(); +setInterval(() => void refresh(), REFRESH_INTERVAL_MS); diff --git a/mcp/packages/browser-extension/tsconfig.json b/mcp/packages/browser-extension/tsconfig.json new file mode 100644 index 0000000000..b05f0b7842 --- /dev/null +++ b/mcp/packages/browser-extension/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM"], + "types": ["chrome"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["src/*.test.ts"] +} diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json index b87f8ce17b..6ddd0bd605 100644 --- a/mcp/packages/common/package.json +++ b/mcp/packages/common/package.json @@ -4,7 +4,7 @@ "description": "Shared type definitions and interfaces for Penpot MCP", "main": "dist/index.js", "types": "dist/index.d.ts", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "scripts": { "build": "tsc --build --clean && tsc --build", "watch": "tsc --watch", diff --git a/mcp/packages/common/src/types.ts b/mcp/packages/common/src/types.ts index f9d7cde400..5168594f54 100644 --- a/mcp/packages/common/src/types.ts +++ b/mcp/packages/common/src/types.ts @@ -97,3 +97,36 @@ export interface ExecuteCodeTaskResultData { */ log: string; } + +/** + * Status of the Penpot MCP plugin connection, as reported to the Penpot app. + */ +export type McpConnectionStatus = "idle" | "connecting" | "connected" | "disconnected" | "error"; + +/** + * Message posted by a browser extension's content script to ask the Penpot page for its MCP session. + */ +export interface ExtensionHello { + type: "penpot-mcp-extension:hello"; +} + +/** + * Message posted by the Penpot page when it starts listening for an {@link ExtensionHello}. + * + * A content script that said hello before the page was listening says it again. + */ +export interface PageReady { + type: "penpot-mcp:ready"; +} + +/** + * Message posted by the Penpot page to announce its MCP session to a browser extension. + * + * The page sends it in reply to {@link ExtensionHello} and on every connection status change. + */ +export interface SessionAnnouncement { + type: "penpot-mcp:session"; + /** the MCP session ID of the page, or null while it is not known. */ + sessionId: string | null; + status: McpConnectionStatus; +} diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index ffa4f3837a..ce0a394d1c 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -3,7 +3,7 @@ "private": true, "version": "1.0.0", "type": "module", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "scripts": { "start": "vite build --watch --config vite.config.ts", "start:multi-user": "pnpm run start", @@ -19,7 +19,7 @@ "devDependencies": { "cross-env": "^10.1.0", "typescript": "^6.0.3", - "vite": "^8.3.2", + "vite": "^8.3.5", "vite-live-preview": "^0.4.0" } } diff --git a/mcp/packages/plugin/src/ConnectionMetadata.test.ts b/mcp/packages/plugin/src/ConnectionMetadata.test.ts index 3131086153..1ae436afd6 100644 --- a/mcp/packages/plugin/src/ConnectionMetadata.test.ts +++ b/mcp/packages/plugin/src/ConnectionMetadata.test.ts @@ -147,6 +147,17 @@ test("resume and freeze events cannot send liveness messages before initializati assert.deepEqual(ui.sockets[0].sent, [{ type: "heartbeat" }]); }); +test("answers heartbeat requests once initialized, without forwarding them to the plugin", () => { + const ui = pluginUi(); + ui.connect(); + ui.sockets[0].receive({ type: "heartbeat-request" }); + assert.deepEqual(ui.sockets[0].sent, []); + ui.sockets[0].receive({ type: "initialized" }); + ui.sockets[0].receive({ type: "heartbeat-request" }); + assert.deepEqual(ui.sockets[0].sent, [{ type: "heartbeat" }]); + assert.equal(ui.messages.filter((message) => message.type === "heartbeat-request").length, 0); +}); + test("plugin reads the current file for each connection metadata request", () => { let onMessage!: (message: any) => void; const messages: any[] = []; diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 71973ade0b..8ae1163ad6 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -145,7 +145,9 @@ function sendTaskResponse(response: any): void { * Emits a liveness signal from the plugin event loop. * * WebSocket ping/pong is not enough here: browsers can answer protocol pings while - * page JavaScript is frozen and unable to run MCP tasks. + * page JavaScript is frozen and unable to run MCP tasks. The plugin sends one on a timer and + * in answer to each heartbeat request of the server, since a browser runs the timers of a + * hidden page as rarely as once a minute but handles socket messages at once. */ function sendHeartbeat(): boolean { if (connectionReady && ws?.readyState === WebSocket.OPEN) { @@ -252,6 +254,10 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { parent.postMessage({ type: "context-request" }, "*"); return; } + if (request.type === "heartbeat-request") { + sendHeartbeat(); + return; + } // Track the current task received from the MCP server if (request.task) { updateCurrentTask(request.task); diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index ee816a759b..51f765414c 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -25,17 +25,17 @@ "author": "", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=20.20.2" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "dependencies": { "@modelcontextprotocol/node": "^2.1.1", "@modelcontextprotocol/server": "^2.3.1", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", - "express": "^5.2.1", + "express": "^5.3.0", "ioredis": "^6.0.0", - "js-yaml": "^5.4.2", + "js-yaml": "^5.5.0", "nrepl-client": "^0.3.0", "pino": "^10.4.0", "pino-loki": "^3.0.0", @@ -43,14 +43,14 @@ "reflect-metadata": "^0.2.2", "sharp": "^0.35.5", "ws": "^8.22.0", - "zod": "^4.6.5" + "zod": "^4.6.7" }, "devDependencies": { "@modelcontextprotocol/client": "^2.3.1", "@penpot/mcp-common": "workspace:../common", "@types/express": "^5.0.6", "@types/js-yaml": "^4.0.9", - "@types/node": "^26.6.4", + "@types/node": "^26.6.5", "@types/ws": "^8.18.2", "cross-env": "^10.1.0", "esbuild": "^0.28.1", diff --git a/mcp/packages/server/src/Feedback.test.ts b/mcp/packages/server/src/Feedback.test.ts new file mode 100644 index 0000000000..b5b6460250 --- /dev/null +++ b/mcp/packages/server/src/Feedback.test.ts @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import { once } from "node:events"; +import { afterEach, beforeEach, test } from "node:test"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; +import { PenpotMcpServer } from "./PenpotMcpServer"; +import { PenpotBackendClient } from "./PenpotBackendClient"; + +let previousEnv: NodeJS.ProcessEnv; +let backend: Server; +let backendUri: string; +let server: PenpotMcpServer | undefined; +let client: Client | undefined; +let requests: { url: string; authorization?: string; client?: string; body: unknown }[]; +let responseStatus: number; +let nextPort = 17_700; + +beforeEach(async () => { + previousEnv = { ...process.env }; + process.env.PENPOT_MCP_SERVER_HOST = "127.0.0.1"; + process.env.PENPOT_MCP_SERVER_PORT = String(nextPort++); + process.env.PENPOT_MCP_WEBSOCKET_PORT = "0"; + process.env.PENPOT_MCP_DEVENV = "false"; + process.env.PENPOT_MCP_REPL_ENABLE = "false"; + delete process.env.PENPOT_MCP_REDIS_URI; + delete process.env.PENPOT_MCP_BACKEND_URI; + requests = []; + responseStatus = 200; + backend = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + requests.push({ + url: req.url!, + authorization: req.headers.authorization, + client: req.headers["x-client"] as string, + body: JSON.parse(body), + }); + res.writeHead(responseStatus, { + "content-type": "application/json", + ...(responseStatus === 307 ? { location: `${backendUri}/redirected` } : {}), + }); + res.end(JSON.stringify({ status: "queued", detail: "private backend diagnostics" })); + }); + backend.listen(0, "127.0.0.1"); + await once(backend, "listening"); + const address = backend.address(); + assert.ok(address && typeof address === "object"); + backendUri = `http://127.0.0.1:${address.port}/penpot`; +}); + +afterEach(async () => { + await client?.close(); + await server?.stop(); + client = undefined; + server = undefined; + backend.closeAllConnections(); + await new Promise((resolve, reject) => backend.close((error) => (error ? reject(error) : resolve()))); + process.env = previousEnv; +}); + +async function connect(multiUser: boolean, token?: string) { + server = new PenpotMcpServer(multiUser); + await server.start(); + client = new Client({ name: "feedback-test", version: "1" }); + const url = new URL(`http://127.0.0.1:${server.port}/mcp`); + if (token) url.searchParams.set("userToken", token); + await client.connect(new StreamableHTTPClientTransport(url)); + return client; +} + +const feedback = { subject: "Export fails", feedback: "Expected a PNG, received an error." }; + +for (const multiUser of [false, true]) { + test(`feedback is absent without a backend URI (multi-user=${multiUser})`, async () => { + const connected = await connect(multiUser, "alice"); + assert.ok(!(await connected.listTools()).tools.some((tool) => tool.name === "submit_feedback")); + }); + + test(`feedback works without a plugin (multi-user=${multiUser})`, async () => { + process.env.PENPOT_MCP_BACKEND_URI = backendUri; + const connected = await connect(multiUser, "alice"); + const tool = (await connected.listTools()).tools.find((tool) => tool.name === "submit_feedback"); + assert.ok(tool); + assert.deepEqual(tool.inputSchema.required, ["subject", "feedback"]); + const result = await connected.callTool({ name: "submit_feedback", arguments: feedback }); + assert.deepEqual(result.content, [ + { type: "text", text: "Feedback queued. It is emailed in the background, so delivery is not confirmed." }, + ]); + assert.deepEqual(requests, [ + { + url: "/penpot/api/main/methods/send-mcp-feedback", + authorization: "Token alice", + client: "penpot-mcp", + body: feedback, + }, + ]); + }); + + test(`missing credentials leave the tool listed but prevent submission (multi-user=${multiUser})`, async () => { + process.env.PENPOT_MCP_BACKEND_URI = backendUri; + const connected = await connect(multiUser); + assert.ok((await connected.listTools()).tools.some((tool) => tool.name === "submit_feedback")); + const result = await connected.callTool({ name: "submit_feedback", arguments: feedback }); + assert.match(JSON.stringify(result.content), /userToken.*MCP.*URL/); + assert.equal(requests.length, 0); + }); +} + +test("blank backend URI does not enable feedback", async () => { + process.env.PENPOT_MCP_BACKEND_URI = " "; + const connected = await connect(false); + assert.ok(!(await connected.listTools()).tools.some((tool) => tool.name === "submit_feedback")); +}); + +test("rejects invalid input without contacting the backend", async () => { + process.env.PENPOT_MCP_BACKEND_URI = backendUri; + const connected = await connect(false, "alice"); + for (const args of [ + { ...feedback, subject: "" }, + { ...feedback, subject: " " }, + { ...feedback, subject: "a\r\nBcc: other@example.com" }, + { ...feedback, subject: "a\tb" }, + { ...feedback, subject: "a\u0085b" }, + { ...feedback, feedback: "a\u0000b" }, + { ...feedback, feedback: "a\u001bb" }, + { ...feedback, subject: "x".repeat(121) }, + { ...feedback, feedback: " \n" }, + { ...feedback, feedback: "x".repeat(5001) }, + ]) { + // SDK versions may return a tool error or reject with a protocol error. + try { + const result = await connected.callTool({ name: "submit_feedback", arguments: args }); + assert.equal(result.isError, true); + } catch (error) { + assert.match(String(error), /invalid|validation/i); + } + } + assert.equal(requests.length, 0); +}); + +test("preserves deployment prefixes with and without a trailing slash", async () => { + for (const uri of [backendUri, backendUri + "/"]) { + await new PenpotBackendClient(uri).submitFeedback("alice", feedback); + } + assert.deepEqual( + requests.map((request) => request.url), + ["/penpot/api/main/methods/send-mcp-feedback", "/penpot/api/main/methods/send-mcp-feedback"] + ); +}); + +test("keeps credentials isolated across concurrent submissions", async () => { + const backendClient = new PenpotBackendClient(backendUri); + await Promise.all( + ["alice", "bob"].map((token) => + backendClient.submitFeedback(token, { + subject: token, + feedback: "A concrete problem.", + }) + ) + ); + assert.deepEqual( + requests + .map((request) => ({ + authorization: request.authorization, + subject: (request.body as typeof feedback).subject, + })) + .sort((a, b) => a.subject.localeCompare(b.subject)), + [ + { authorization: "Token alice", subject: "alice" }, + { authorization: "Token bob", subject: "bob" }, + ] + ); +}); + +for (const status of [400, 401, 403, 404, 429, 503, 500, 307]) { + test(`does not retry or expose response bodies on status ${status}`, async () => { + responseStatus = status; + const backendClient = new PenpotBackendClient(backendUri); + await assert.rejects(backendClient.submitFeedback("secret-token", feedback), (error: Error) => { + assert.doesNotMatch(error.message, /private backend diagnostics|secret-token/); + return true; + }); + assert.equal(requests.length, 1); + }); +} + +test("bounds network waits and does not retry an uncertain submission", async (t) => { + backend.removeAllListeners("request"); + let count = 0; + backend.on("request", (_req, _res) => { + count++; + }); + const timeout = AbortSignal.timeout.bind(AbortSignal); + t.mock.method(AbortSignal, "timeout", (milliseconds: number) => { + assert.equal(milliseconds, 10_000); + return timeout(50); + }); + await assert.rejects( + new PenpotBackendClient(backendUri).submitFeedback("secret-token", feedback), + /could not be confirmed.*Do not retry automatically/ + ); + assert.ok(count <= 1); +}); + +test("rejects invalid backend configuration without echoing credentials", () => { + for (const uri of [ + "invalid", + "file:///tmp", + "https://user:secret@example.com", + "https://example.com/?secret=value", + "https://example.com/#fragment", + ]) { + assert.throws( + () => new PenpotBackendClient(uri), + (error: Error) => { + assert.doesNotMatch(error.message, /secret|value|user:/); + return true; + } + ); + } +}); diff --git a/mcp/packages/server/src/McpAuthentication.test.ts b/mcp/packages/server/src/McpAuthentication.test.ts new file mode 100644 index 0000000000..715e60fdc9 --- /dev/null +++ b/mcp/packages/server/src/McpAuthentication.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import express from "express"; +import { McpAuthentication } from "./McpAuthentication"; + +test("OAuth is opt-in and requires multi-user mode", () => { + assert.equal(new McpAuthentication(undefined, false).isOAuthEnabled, false); + assert.equal(new McpAuthentication("", true).isOAuthEnabled, false); + assert.throws(() => new McpAuthentication("https://penpot.example/api/mcp/oauth", false), /multi-user/); + for (const uri of [ + "not a URL", + "http://remote.example/auth", + "https://user:secret@example.com/auth", + "https://example.com/auth?x=1", + "https://example.com/auth#fragment", + ]) { + assert.throws(() => new McpAuthentication(uri, true), /PENPOT_MCP_AUTH_URI/); + } + assert.equal(new McpAuthentication("http://localhost:3449/api/mcp/oauth", true).isOAuthEnabled, true); +}); + +async function withEndpoint(uri: string | undefined, run: (base: string) => Promise) { + const auth = new McpAuthentication(uri, true); + const app = express(); + app.all("/mcp", (req, res) => { + const result = auth.authenticate(req, res); + if (!result.isRejected) res.json({ token: result.userToken ?? null }); + }); + const server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + try { + await run(`http://127.0.0.1:${address.port}/mcp`); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +} + +test("missing credentials challenge the client only when OAuth is enabled", async () => { + await withEndpoint(undefined, async (base) => { + const response = await fetch(base); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { token: null }); + }); + await withEndpoint("https://penpot.example/api/mcp/oauth/", async (base) => { + const response = await fetch(base); + assert.equal(response.status, 401); + assert.equal( + response.headers.get("www-authenticate"), + 'Bearer resource_metadata="https://penpot.example/api/mcp/oauth/resource", scope="mcp"' + ); + assert.equal(response.headers.get("cache-control"), "no-store"); + }); +}); + +test("bearer credentials take precedence and query credentials remain supported", async () => { + for (const uri of [undefined, "https://penpot.example/api/mcp/oauth"]) { + await withEndpoint(uri, async (base) => { + const bearer = await fetch(base + "?userToken=query-token", { + headers: { Authorization: "bEaReR header-token" }, + }); + assert.deepEqual(await bearer.json(), { token: "header-token" }); + const query = await fetch(base + "?userToken=query-token"); + assert.deepEqual(await query.json(), { token: "query-token" }); + }); + } +}); + +test("malformed credentials never fall back to another token", async () => { + await withEndpoint("https://penpot.example/api/mcp/oauth", async (base) => { + for (const header of ["Basic abc", "Bearer", "Bearer two tokens", "Bearer a, Bearer b"]) { + const response = await fetch(base + "?userToken=valid", { headers: { Authorization: header } }); + assert.equal(response.status, 401, header); + assert.deepEqual(await response.json(), { error: "invalid_token" }); + } + for (const query of ["?userToken=a&userToken=b", "?userToken=", "?userToken=a%20b"]) { + assert.equal((await fetch(base + query)).status, 401, query); + } + }); +}); diff --git a/mcp/packages/server/src/McpAuthentication.ts b/mcp/packages/server/src/McpAuthentication.ts new file mode 100644 index 0000000000..6df14f6733 --- /dev/null +++ b/mcp/packages/server/src/McpAuthentication.ts @@ -0,0 +1,120 @@ +import type { Request, Response } from "express"; + +/** + * Result of processing credentials from an MCP HTTP request. + */ +export class McpAuthenticationResult { + /** + * @param userToken the extracted user token, if present + * @param isRejected whether the request was rejected and its HTTP error response sent + */ + constructor( + public readonly userToken: string | undefined, + public readonly isRejected: boolean = false + ) {} +} + +/** + * HTTP credential extraction and optional OAuth discovery for MCP clients. + * + * Tokens retain their existing role of matching requests to Penpot connections. + */ +export class McpAuthentication { + public readonly isOAuthEnabled: boolean; + private readonly resourceMetadataUri?: string; + + /** + * Creates a new MCP authentication handler. + * + * @param authUri the authentication URI from PENPOT_MCP_AUTH_URI, or undefined/empty to disable OAuth + * @param multiUser whether the server is running in multi-user mode + */ + constructor(authUri: string | undefined, multiUser: boolean) { + if (!authUri?.trim()) { + this.isOAuthEnabled = false; + } else { + if (!multiUser) { + throw new Error("PENPOT_MCP_AUTH_URI (which enables OAuth) requires multi-user mode"); + } + + // validate the authentication URI + let uri: URL; + try { + uri = new URL(authUri); + } catch { + throw new Error("PENPOT_MCP_AUTH_URI must be an absolute HTTP(S) URL"); + } + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(uri.hostname); + if ( + (uri.protocol !== "https:" && !(uri.protocol === "http:" && loopback)) || + uri.username || + uri.password || + uri.search || + uri.hash + ) { + throw new Error( + "PENPOT_MCP_AUTH_URI must use HTTPS (HTTP is allowed on loopback) without credentials, query, or fragment" + ); + } + + this.resourceMetadataUri = uri.href.replace(/\/$/, "") + "/resource"; + this.isOAuthEnabled = true; + } + } + + /** + * Extracts credentials. + * If credentials are absent while OAuth is enabled, sends an authentication error. + */ + public authenticate(req: Request, res: Response): McpAuthenticationResult { + let userToken: string | undefined; + + // check for a Bearer token in the Authorization header + const header = req.headers.authorization; + if (header !== undefined) { + const headerCount = req.rawHeaders.filter( + (_, i) => i % 2 === 0 && req.rawHeaders[i].toLowerCase() === "authorization" + ).length; + const match = /^Bearer +([A-Za-z0-9\-._~+/]+=*)$/i.exec(header); + if (headerCount !== 1 || !match) { + this.rejectOAuth(res, "invalid_token"); + return new McpAuthenticationResult(undefined, true); + } + userToken = match[1]; + } + // check for a userToken query parameter + else if (req.query.userToken !== undefined) { + const value = req.query.userToken; + if (typeof value !== "string" || !/^[A-Za-z0-9\-._~+/]+=*$/.test(value)) { + this.rejectOAuth(res, "invalid_token"); + return new McpAuthenticationResult(undefined, true); + } + userToken = value; + } + + // NOTE: The MCP server does not validate userToken against the backend. + // It uses the token to match MCP requests to a connection established + // from the Penpot webapp. Without that matching connection, requests + // cannot access or modify designs. The user must be logged in to Penpot + // to establish that connection; Penpot login and file permissions thus + // provide the authentication and access control boundary. + + if (this.isOAuthEnabled && !userToken) { + this.rejectOAuth(res); + return new McpAuthenticationResult(undefined, true); + } else { + return new McpAuthenticationResult(userToken); + } + } + + private rejectOAuth(res: Response, error?: "invalid_token"): void { + let challenge = "Bearer"; + if (this.resourceMetadataUri) { + challenge += ` resource_metadata="${this.resourceMetadataUri}", scope="mcp"`; + } + if (error) challenge += `${this.resourceMetadataUri ? "," : ""} error="${error}"`; + res.setHeader("WWW-Authenticate", challenge); + res.setHeader("Cache-Control", "no-store"); + res.status(401).json({ error: error ?? "unauthorized" }); + } +} diff --git a/mcp/packages/server/src/PenpotBackendClient.ts b/mcp/packages/server/src/PenpotBackendClient.ts new file mode 100644 index 0000000000..07bebddf97 --- /dev/null +++ b/mcp/packages/server/src/PenpotBackendClient.ts @@ -0,0 +1,86 @@ +import { createLogger } from "./logger"; + +/** A plain-text MCP feedback report. */ +export interface Feedback { + subject: string; + feedback: string; +} + +/** Backend operations available to MCP tools. */ +export interface PenpotBackend { + submitFeedback(userToken: string, feedback: Feedback): Promise; +} + +/** Client for the configured Penpot instance's backend services. */ +export class PenpotBackendClient implements PenpotBackend { + private readonly logger = createLogger("PenpotBackendClient"); + private readonly baseUri: URL; + + constructor(baseUri: string) { + try { + this.baseUri = new URL(baseUri); + } catch { + throw new Error("PENPOT_MCP_BACKEND_URI must be an absolute HTTP(S) instance URI."); + } + if ( + !["http:", "https:"].includes(this.baseUri.protocol) || + this.baseUri.username || + this.baseUri.password || + this.baseUri.search || + this.baseUri.hash + ) { + throw new Error( + "PENPOT_MCP_BACKEND_URI must be an HTTP(S) instance URI without credentials, query or fragment." + ); + } + if (!this.baseUri.pathname.endsWith("/")) this.baseUri.pathname += "/"; + } + + public async submitFeedback(userToken: string, feedback: Feedback): Promise { + await this.callRpc("send-mcp-feedback", userToken, feedback); + } + + /** Calls a backend RPC method whose result body is not needed. */ + private async callRpc(methodName: string, userToken: string, params: object): Promise { + let response: Response; + try { + response = await fetch(new URL(`api/main/methods/${encodeURIComponent(methodName)}`, this.baseUri), { + method: "POST", + headers: { + Authorization: `Token ${userToken}`, + "X-Client": "penpot-mcp", + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(params), + redirect: "manual", + signal: AbortSignal.timeout(10_000), + }); + // discard backend diagnostics, which may contain private data + await response.body?.cancel(); + } catch (error) { + this.logger.error(error, "Penpot RPC request failed"); + throw new Error("The backend operation could not be confirmed. Do not retry automatically."); + } + + if (response.ok) return; + + switch (response.status) { + case 401: + throw new Error( + "The backend did not accept the MCP user token. It may be outdated, " + + "or the Penpot instance may not have access tokens enabled." + ); + case 403: + throw new Error("The MCP user token is not authorized for this backend operation."); + case 400: + throw new Error("The backend rejected the request parameters or the operation is disabled."); + case 429: + throw new Error("The backend request limit has been reached. Do not retry automatically."); + default: + throw new Error( + `The backend operation could not be confirmed (HTTP ${response.status}). Do not retry automatically.` + ); + } + } +} diff --git a/mcp/packages/server/src/PenpotConnection.ts b/mcp/packages/server/src/PenpotConnection.ts index 267c1b159b..902f248a48 100644 --- a/mcp/packages/server/src/PenpotConnection.ts +++ b/mcp/packages/server/src/PenpotConnection.ts @@ -17,6 +17,8 @@ export interface PenpotConnection extends PluginLivenessState { readonly userToken: string | null; readonly session: PenpotSession; readonly pingInterval: NodeJS.Timeout; + /** the interval sending heartbeat requests to the plugin. */ + readonly heartbeatRequestInterval: NodeJS.Timeout; /** whether initialization and dispatch subscriptions have completed. */ ready: boolean; } diff --git a/mcp/packages/server/src/PenpotMcpServer.http.test.ts b/mcp/packages/server/src/PenpotMcpServer.http.test.ts index 2a72ed5305..693e3197ab 100644 --- a/mcp/packages/server/src/PenpotMcpServer.http.test.ts +++ b/mcp/packages/server/src/PenpotMcpServer.http.test.ts @@ -16,6 +16,7 @@ beforeEach(async () => { process.env.PENPOT_MCP_DEVENV = "false"; process.env.PENPOT_MCP_REPL_ENABLE = "false"; delete process.env.PENPOT_MCP_REDIS_URI; + delete process.env.PENPOT_MCP_AUTH_URI; server = new PenpotMcpServer(true); baseUrl = `http://127.0.0.1:${server.port}`; await server.start(); @@ -26,7 +27,7 @@ afterEach(async () => { process.env = previousEnv; }); -async function modernRequest(method: string, params: Record = {}, query = "") { +async function modernRequest(method: string, params: Record = {}, query = "", bearer?: string) { return fetch(`${baseUrl}/mcp${query}`, { method: "POST", headers: { @@ -34,6 +35,7 @@ async function modernRequest(method: string, params: Record = { Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2026-07-28", "Mcp-Method": method, + ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), ...(typeof params.name === "string" ? { "Mcp-Name": params.name } : {}), }, body: JSON.stringify({ @@ -93,7 +95,8 @@ test("isolates user tokens across overlapping tool calls with the same request I name: "execute_code", arguments: { code: "return 1;" }, }, - `?userToken=${token}` + token === "alice" ? "" : `?userToken=${token}`, + token === "alice" ? token : undefined ); const body = (await response.json()) as { result: CallToolResult }; assert.equal(response.status, 200, JSON.stringify(body)); @@ -135,3 +138,30 @@ test("supports older Streamable HTTP clients without allocating a session", asyn await client.close(); } }); + +test("OAuth challenges missing credentials and accepts bearer and query tokens", async () => { + await server.stop(); + process.env.PENPOT_MCP_AUTH_URI = "https://penpot.example/api/mcp/oauth"; + server = new PenpotMcpServer(true); + await server.start(); + + const missing = await modernRequest("tools/list"); + assert.equal(missing.status, 401); + assert.match(missing.headers.get("www-authenticate") ?? "", /resource_metadata=/); + + for (const useBearer of [true, false]) { + const client = new Client({ name: "oauth-test", version: "1" }); + const transport = new StreamableHTTPClientTransport( + new URL(`${baseUrl}/mcp${useBearer ? "" : "?userToken=alice"}`), + useBearer ? { requestInit: { headers: { Authorization: "Bearer alice" } } } : undefined + ); + try { + await client.connect(transport); + const result = await client.listTools(); + const tool = result.tools.find((tool) => tool.name === "execute_code"); + assert.ok(tool, "authenticated clients can discover execute_code"); + } finally { + await client.close(); + } + } +}); diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 73ffd83a9f..445fb4ee4e 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -8,20 +8,23 @@ import { ExecuteCodeTool } from "./tools/ExecuteCodeTool"; import { PluginBridge } from "./PluginBridge"; import { RedisBridge } from "./RedisBridge"; import { ConfigurationLoader } from "./ConfigurationLoader"; +import { PenpotBackendClient } from "./PenpotBackendClient"; +import { SubmitFeedbackTool } from "./tools/SubmitFeedbackTool"; import { createLogger } from "./logger"; import { Tool } from "./Tool"; import { HighLevelOverviewTool } from "./tools/HighLevelOverviewTool"; import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool"; import { ExportShapeTool } from "./tools/ExportShapeTool"; import { ImportImageTool } from "./tools/ImportImageTool"; -import { CljsReplTool } from "./tools/CljsReplTool"; -import { ImportPenpotFileTool } from "./tools/ImportPenpotFileTool"; -import { CljsCompilerOutputTool } from "./tools/CljsCompilerOutputTool"; -import { CljCheckParentheses } from "./tools/CljCheckParentheses"; -import { ReadTaigaIssueTool } from "./tools/ReadTaigaIssueTool"; +import { CljsReplTool } from "./tools/dev/CljsReplTool"; +import { ImportPenpotFileTool } from "./tools/dev/ImportPenpotFileTool"; +import { CljsCompilerOutputTool } from "./tools/dev/CljsCompilerOutputTool"; +import { CljCheckParentheses } from "./tools/dev/CljCheckParentheses"; +import { ReadTaigaIssueTool } from "./tools/dev/ReadTaigaIssueTool"; import { NreplClient } from "./NreplClient"; import { ReplServer } from "./ReplServer"; import { ApiDocs } from "./ApiDocs"; +import { McpAuthentication } from "./McpAuthentication"; /** * Session context for request-scoped data. @@ -111,6 +114,7 @@ export class PenpotMcpServer { private app!: Express; private httpServer?: HttpServer; private readonly mcpHandler: McpHttpHandler; + private readonly authentication: McpAuthentication; public readonly pluginBridge: PluginBridge; private readonly replServer: ReplServer | null; private apiDocs: ApiDocs; @@ -144,6 +148,7 @@ export class PenpotMcpServer { private readonly tenant: string; constructor(private isMultiUser: boolean = false) { + this.authentication = new McpAuthentication(process.env.PENPOT_MCP_AUTH_URI, isMultiUser); // read port configuration from environment variables this.host = process.env.PENPOT_MCP_SERVER_HOST ?? "localhost"; this.port = parseInt(process.env.PENPOT_MCP_SERVER_PORT ?? "4401", 10); @@ -261,6 +266,10 @@ export class PenpotMcpServer { if (this.isFileSystemAccessEnabled()) { toolInstances.push(new ImportImageTool(this)); } + const backendUri = process.env.PENPOT_MCP_BACKEND_URI?.trim(); + if (backendUri) { + toolInstances.push(new SubmitFeedbackTool(this, new PenpotBackendClient(backendUri))); + } if (shouldRegisterDeveloperTools(this.isDevEnv(), this.isMultiUserMode())) { const nreplClient = new NreplClient(); toolInstances.push(new CljsReplTool(this, nreplClient)); @@ -298,11 +307,16 @@ export class PenpotMcpServer { private setupHttpEndpoints(): void { const handleMcpRequest = toNodeHandler(this.mcpHandler); this.app.all("/mcp", async (req, res) => { - const userToken = req.query.userToken as string | undefined; + const authResult = this.authentication.authenticate(req, res); + if (authResult.isRejected) { + return; + } this.logger.info( - `Received MCP request: method=${req.body?.method ?? ""}; userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}` + `Received MCP request: method=${req.body?.method ?? ""}; userTokenFp=${PenpotMcpServer.tokenFingerprint(authResult.userToken)}` + ); + await this.sessionContext.run({ userToken: authResult.userToken }, () => + handleMcpRequest(req, res, req.body) ); - await this.sessionContext.run({ userToken }, () => handleMcpRequest(req, res, req.body)); }); } @@ -316,6 +330,7 @@ export class PenpotMcpServer { return new Promise((resolve, reject) => { this.httpServer = this.app.listen(this.port, this.host, async () => { this.logger.info(`Multi-user mode: ${this.isMultiUserMode()}`); + this.logger.info(`OAuth enabled: ${this.authentication.isOAuthEnabled}`); this.logger.info( `Multi-instance mode with Redis-backed transport: ${this.redisBridge ? "true" : "false"}` ); diff --git a/mcp/packages/server/src/PluginBridge.sessions.test.ts b/mcp/packages/server/src/PluginBridge.sessions.test.ts index 1a42241169..3e676725f2 100644 --- a/mcp/packages/server/src/PluginBridge.sessions.test.ts +++ b/mcp/packages/server/src/PluginBridge.sessions.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import { once } from "node:events"; -import { afterEach, beforeEach, test } from "node:test"; +import { afterEach, beforeEach, mock, test } from "node:test"; import { WebSocket } from "ws"; -import { PluginBridge } from "./PluginBridge"; +import { HEARTBEAT_REQUEST_INTERVAL_MS, PluginBridge } from "./PluginBridge"; import { PluginTask } from "./PluginTask"; import type { PenpotMcpServer } from "./PenpotMcpServer"; @@ -37,17 +37,33 @@ async function connect(sessionId: string, token = "alice", fileName = "Design", const socket = new WebSocket(`ws://127.0.0.1:${port}?userToken=${token}`); sockets.push(socket); await once(socket, "open"); + // answer tasks from the start, since one may arrive right behind the initialization + socket.on("message", (raw) => { + const request = JSON.parse(raw.toString()); + if (request.type !== "initialized" && request.type !== "heartbeat-request") { + socket.send(JSON.stringify({ id: request.id, success: true, data: reply })); + } + }); const initialized = once(socket, "message", { signal: AbortSignal.timeout(1500) }); socket.send(JSON.stringify({ type: "initialize", session: { sessionId, fileId: "file-1", fileName } })); const [message] = await initialized; assert.equal(JSON.parse(message.toString()).type, "initialized"); - socket.on("message", (raw) => { - const request = JSON.parse(raw.toString()); - socket.send(JSON.stringify({ id: request.id, success: true, data: reply })); - }); return socket; } +test("asks each plugin for a heartbeat at a fixed interval", async () => { + mock.timers.enable({ apis: ["setInterval"] }); + try { + const socket = await connect("tab-1"); + const requested = once(socket, "message", { signal: AbortSignal.timeout(1500) }); + mock.timers.tick(HEARTBEAT_REQUEST_INTERVAL_MS); + const [message] = await requested; + assert.deepEqual(JSON.parse(message.toString()), { type: "heartbeat-request" }); + } finally { + mock.timers.reset(); + } +}); + test("keeps two tabs for the same file and dispatches to the selected session", async () => { await connect("tab-1"); await connect("tab-2"); @@ -169,3 +185,21 @@ test("a dashboard session remains routable while its workspace metadata changes" assert.deepEqual(bridge.getUserConnections("alice")!.getSessions(), [{ sessionId: "tab", ...context }]); } }); + +test("a task fails at once when its plugin connection closes before answering", async () => { + const plugin = new WebSocket(`ws://127.0.0.1:${port}?userToken=alice`); + sockets.push(plugin); + await once(plugin, "open"); + const initialized = once(plugin, "message"); + plugin.send(JSON.stringify({ type: "initialize", session: { sessionId: "tab", fileId: null, fileName: null } })); + await initialized; + // the socket closes once the task reaches it + plugin.on("message", () => plugin.terminate()); + const started = Date.now(); + await assert.rejects( + bridge.executePluginTask(new PluginTask("test", {}), "tab"), + /connection closed before the task finished/ + ); + // well within the 1s task timeout of these tests + assert.ok(Date.now() - started < 500); +}); diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 913b28b26a..0c1136deea 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -15,6 +15,14 @@ import { MultiInstanceTaskDispatcher } from "./dispatch/MultiInstanceTaskDispatc const KEEP_ALIVE_TIME = 30000; // 30 seconds const INITIALIZATION_TIMEOUT_MS = 10_000; +/** + * The interval, in ms, between the heartbeat requests sent to each plugin. + * + * A browser runs the timers of a hidden page as rarely as once a minute, which would leave the + * plugin's own heartbeat older than the stale threshold, but it handles socket messages at once. + */ +export const HEARTBEAT_REQUEST_INTERVAL_MS = 10_000; + /** * Maximum plugin heartbeat age before a connection is stale. * @@ -187,8 +195,8 @@ export class PluginBridge implements TaskDispatchHost { } }); - ws.on("close", () => { - this.logger.info("WebSocket connection closed"); + ws.on("close", (code: number, reason: Buffer) => { + this.logger.info("WebSocket connection closed (code %d%s)", code, reason.length ? `: ${reason}` : ""); clearTimeout(initializationTimeout); this.removeConnection(ws); }); @@ -218,12 +226,17 @@ export class PluginBridge implements TaskDispatchHost { const pingInterval = setInterval(() => { ws.ping(); }, KEEP_ALIVE_TIME); + // the plugin answers with a heartbeat, which a frozen page cannot send + const heartbeatRequestInterval = setInterval(() => { + ws.send(JSON.stringify({ type: "heartbeat-request" })); + }, HEARTBEAT_REQUEST_INTERVAL_MS); // register the client connection with both indexes const connection: PenpotConnection = { socket: ws, userToken, pingInterval, + heartbeatRequestInterval, session, ready: false, lastHeartbeat: Date.now(), @@ -260,10 +273,11 @@ export class PluginBridge implements TaskDispatchHost { /** * Removes a client connection and releases all resources associated with it. * - * Clears the per-connection keep-alive interval and removes the connection from the - * socket-keyed index and the user's session collection. The token-keyed index entry - * is removed only when the user's last local connection closes. Safe to call with a - * socket that is not (or no longer) registered. + * Clears the per-connection keep-alive and heartbeat-request intervals and removes the + * connection from the socket-keyed index and the user's session collection. The + * token-keyed index entry is removed only when the user's last local connection closes. + * Tasks sent over the connection and still awaiting a response fail at once. Safe to call + * with a socket that is not (or no longer) registered. * * @param ws - The WebSocket whose connection state should be removed */ @@ -274,9 +288,17 @@ export class PluginBridge implements TaskDispatchHost { } connection.ready = false; clearInterval(connection.pingInterval); + clearInterval(connection.heartbeatRequestInterval); this.connectionsBySocket.delete(ws); const connections = this.getUserConnections(connection.userToken)!; connections.remove(connection); + // a task sent over the closed socket can no longer be answered, since responses only count + // from the connection the task went to + for (const [taskId, taskConnection] of [...this.taskConnections]) { + if (taskConnection === connection) { + this.rejectPendingTask(taskId, new Error("The plugin connection closed before the task finished.")); + } + } if (connection.userToken !== null && connections.size === 0) { this.userPenpotConnectionsByToken.delete(connection.userToken); } diff --git a/mcp/packages/server/src/tools/SubmitFeedbackTool.ts b/mcp/packages/server/src/tools/SubmitFeedbackTool.ts new file mode 100644 index 0000000000..4c7b92f952 --- /dev/null +++ b/mcp/packages/server/src/tools/SubmitFeedbackTool.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; +import type { Feedback, PenpotBackend } from "../PenpotBackendClient"; +import type { PenpotMcpServer } from "../PenpotMcpServer"; +import { Tool } from "../Tool"; +import { TextResponse, type ToolResponse } from "../ToolResponse"; + +/** Arguments for submitting a plain-text feedback report. */ +export class SubmitFeedbackArgs implements Feedback { + static schema = { + subject: z + .string() + .min(1) + .max(120) + .regex(/\S/) + .regex(/^\P{Cc}*$/u) + .describe("Short, specific summary without line breaks or other control characters (max 120 characters)."), + feedback: z + .string() + .min(1) + .max(5000) + .regex(/\S/) + // line breaks and tabs are the only control characters allowed + .regex(/^[^\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]*$/) + .describe("Concise plain-text feedback (max 5,000 characters)."), + }; + + subject!: string; + feedback!: string; +} + +/** Tool for user-approved feedback about the Penpot MCP integration. */ +export class SubmitFeedbackTool extends Tool { + constructor( + mcpServer: PenpotMcpServer, + private readonly backend: PenpotBackend + ) { + super(mcpServer, SubmitFeedbackArgs.schema); + } + + public getToolName(): string { + return "submit_feedback"; + } + + public getToolDescription(): string { + return ( + "Submit feedback about Penpot MCP, provided that the user explicitly requests or approves submission " + + "and there is a concrete, actionable issue to report. " + + "Focus on bugs, limitations, or unclear documentation that blocked work or caused substantial rework. " + + "Briefly propose what to report and ask the user at a natural pause. " + + "Skip vague dissatisfaction, routine transient errors, and your own coding mistakes. " + + "Describe the operation, expected and actual behavior, and any workaround. Exclude sensitive content." + ); + } + + protected formatArgs(_args: SubmitFeedbackArgs): string { + return "[feedback omitted]"; + } + + protected async executeCore(args: SubmitFeedbackArgs): Promise { + const userToken = this.getSessionContext()?.userToken; + if (!userToken?.trim()) { + throw new Error("Include userToken in the MCP server URL to submit feedback."); + } + await this.backend.submitFeedback(userToken, { subject: args.subject, feedback: args.feedback }); + return new TextResponse("Feedback queued. It is emailed in the background, so delivery is not confirmed."); + } +} diff --git a/mcp/packages/server/src/tools/CljCheckParentheses.ts b/mcp/packages/server/src/tools/dev/CljCheckParentheses.ts similarity index 97% rename from mcp/packages/server/src/tools/CljCheckParentheses.ts rename to mcp/packages/server/src/tools/dev/CljCheckParentheses.ts index 81e2a9628c..d7a5af6fd2 100644 --- a/mcp/packages/server/src/tools/CljCheckParentheses.ts +++ b/mcp/packages/server/src/tools/dev/CljCheckParentheses.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { Tool } from "../Tool"; +import { Tool } from "../../Tool"; import "reflect-metadata"; -import type { ToolResponse } from "../ToolResponse"; -import { TextResponse } from "../ToolResponse"; -import type { PenpotMcpServer } from "../PenpotMcpServer"; +import type { ToolResponse } from "../../ToolResponse"; +import { TextResponse } from "../../ToolResponse"; +import type { PenpotMcpServer } from "../../PenpotMcpServer"; import * as fs from "fs"; /** diff --git a/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts b/mcp/packages/server/src/tools/dev/CljsCompilerOutputTool.ts similarity index 86% rename from mcp/packages/server/src/tools/CljsCompilerOutputTool.ts rename to mcp/packages/server/src/tools/dev/CljsCompilerOutputTool.ts index 11989c6303..772d9461df 100644 --- a/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts +++ b/mcp/packages/server/src/tools/dev/CljsCompilerOutputTool.ts @@ -1,9 +1,9 @@ -import { Tool, EmptyToolArgs } from "../Tool"; +import { Tool, EmptyToolArgs } from "../../Tool"; import "reflect-metadata"; -import type { ToolResponse } from "../ToolResponse"; -import { TextResponse } from "../ToolResponse"; -import { PenpotMcpServer } from "../PenpotMcpServer"; -import { NreplClient } from "../NreplClient"; +import type { ToolResponse } from "../../ToolResponse"; +import { TextResponse } from "../../ToolResponse"; +import { PenpotMcpServer } from "../../PenpotMcpServer"; +import { NreplClient } from "../../NreplClient"; /** * Reports the compiler status of the shadow-cljs `:main` build. diff --git a/mcp/packages/server/src/tools/CljsReplTool.ts b/mcp/packages/server/src/tools/dev/CljsReplTool.ts similarity index 89% rename from mcp/packages/server/src/tools/CljsReplTool.ts rename to mcp/packages/server/src/tools/dev/CljsReplTool.ts index bd894caef3..ab2babf56d 100644 --- a/mcp/packages/server/src/tools/CljsReplTool.ts +++ b/mcp/packages/server/src/tools/dev/CljsReplTool.ts @@ -1,10 +1,10 @@ import { z } from "zod"; -import { Tool } from "../Tool"; +import { Tool } from "../../Tool"; import "reflect-metadata"; -import type { ToolResponse } from "../ToolResponse"; -import { TextResponse } from "../ToolResponse"; -import { PenpotMcpServer } from "../PenpotMcpServer"; -import { NreplClient } from "../NreplClient"; +import type { ToolResponse } from "../../ToolResponse"; +import { TextResponse } from "../../ToolResponse"; +import { PenpotMcpServer } from "../../PenpotMcpServer"; +import { NreplClient } from "../../NreplClient"; /** * Arguments for the CljsReplTool. diff --git a/mcp/packages/server/src/tools/ImportPenpotFileTool.ts b/mcp/packages/server/src/tools/dev/ImportPenpotFileTool.ts similarity index 98% rename from mcp/packages/server/src/tools/ImportPenpotFileTool.ts rename to mcp/packages/server/src/tools/dev/ImportPenpotFileTool.ts index f55ae194f2..50d18f5ae5 100644 --- a/mcp/packages/server/src/tools/ImportPenpotFileTool.ts +++ b/mcp/packages/server/src/tools/dev/ImportPenpotFileTool.ts @@ -1,10 +1,10 @@ import { z } from "zod"; -import { Tool } from "../Tool"; -import { TextResponse, ToolResponse } from "../ToolResponse"; +import { Tool } from "../../Tool"; +import { TextResponse, ToolResponse } from "../../ToolResponse"; import "reflect-metadata"; -import { PenpotMcpServer } from "../PenpotMcpServer"; -import { NreplClient } from "../NreplClient"; -import { createLogger } from "../logger"; +import { PenpotMcpServer } from "../../PenpotMcpServer"; +import { NreplClient } from "../../NreplClient"; +import { createLogger } from "../../logger"; import * as crypto from "crypto"; import * as fs from "fs"; import * as path from "path"; diff --git a/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts b/mcp/packages/server/src/tools/dev/ReadTaigaIssueTool.ts similarity index 96% rename from mcp/packages/server/src/tools/ReadTaigaIssueTool.ts rename to mcp/packages/server/src/tools/dev/ReadTaigaIssueTool.ts index 29e8bea460..decd926182 100644 --- a/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts +++ b/mcp/packages/server/src/tools/dev/ReadTaigaIssueTool.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { Tool } from "../Tool"; +import { Tool } from "../../Tool"; import "reflect-metadata"; -import type { ToolResponse } from "../ToolResponse"; -import { TextResponse } from "../ToolResponse"; -import { PenpotMcpServer } from "../PenpotMcpServer"; +import type { ToolResponse } from "../../ToolResponse"; +import { TextResponse } from "../../ToolResponse"; +import { PenpotMcpServer } from "../../PenpotMcpServer"; /** * Arguments for the {@link ReadTaigaIssueTool}. diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 7b7f98e50e..64b7eae00a 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' @@ -171,11 +171,29 @@ importers: .: devDependencies: concurrently: - specifier: ^10.0.5 - version: 10.0.5 + specifier: ^10.0.6 + version: 10.0.6 prettier: - specifier: ^3.9.9 - version: 3.9.9 + specifier: ^3.9.10 + version: 3.9.10 + + packages/browser-extension: + devDependencies: + '@penpot/mcp-common': + specifier: workspace:../common + version: link:../common + '@types/chrome': + specifier: ^0.3.6 + version: 0.3.6 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 + tsx: + specifier: ^4.23.15 + version: 4.23.15 + typescript: + specifier: ^6.0.3 + version: 6.0.3 packages/common: devDependencies: @@ -199,11 +217,11 @@ importers: specifier: ^6.0.3 version: 6.0.3 vite: - specifier: ^8.3.2 - version: 8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15) + specifier: ^8.3.5 + version: 8.3.5(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15) vite-live-preview: specifier: ^0.4.0 - version: 0.4.0(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15)) + version: 0.4.0(vite@8.3.5(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15)) packages/server: dependencies: @@ -220,14 +238,14 @@ importers: specifier: ^0.15.1 version: 0.15.1 express: - specifier: ^5.2.1 - version: 5.2.1(supports-color@10.2.2) + specifier: ^5.3.0 + version: 5.3.0(supports-color@10.2.2) ioredis: specifier: ^6.0.0 version: 6.0.0(supports-color@10.2.2) js-yaml: - specifier: ^5.4.2 - version: 5.4.2 + specifier: ^5.5.0 + version: 5.5.0 nrepl-client: specifier: ^0.3.0 version: 0.3.0 @@ -245,13 +263,13 @@ importers: version: 0.2.2 sharp: specifier: ^0.35.5 - version: 0.35.5(@types/node@26.6.4) + version: 0.35.5(@types/node@26.6.5) ws: specifier: ^8.22.0 version: 8.22.0 zod: - specifier: ^4.6.5 - version: 4.6.5 + specifier: ^4.6.7 + version: 4.6.7 devDependencies: '@modelcontextprotocol/client': specifier: ^2.3.1 @@ -266,8 +284,8 @@ importers: specifier: ^4.0.9 version: 4.0.9 '@types/node': - specifier: ^26.6.4 - version: 26.6.4 + specifier: ^26.6.5 + version: 26.6.5 '@types/ws': specifier: ^8.18.2 version: 8.18.2 @@ -279,7 +297,7 @@ importers: version: 0.28.2 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@26.6.4)(typescript@6.0.3) + version: 10.9.2(@types/node@26.6.5)(typescript@6.0.3) tsx: specifier: ^4.23.15 version: 4.23.15 @@ -654,8 +672,8 @@ packages: resolution: {integrity: sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==} engines: {node: '>=20'} - '@oxc-project/types@0.152.0': - resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + '@oxc-project/types@0.155.0': + resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} '@penpot/plugin-styles@1.5.0': resolution: {integrity: sha512-rwXFBRPd0IZs3ens+eRT8oA5WqkQgF6xlW6fCfH7U1NHRjej9HH7vTGXmfjoIVml6Xx1sbyYHZVxkQC8Ub8+zA==} @@ -663,98 +681,98 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@rolldown/binding-android-arm-eabi@1.2.12': - resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} + '@rolldown/binding-android-arm-eabi@1.2.15': + resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.12': - resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} + '@rolldown/binding-android-arm64@1.2.15': + resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.12': - resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} + '@rolldown/binding-darwin-arm64@1.2.15': + resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.12': - resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} + '@rolldown/binding-darwin-x64@1.2.15': + resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.12': - resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} + '@rolldown/binding-freebsd-x64@1.2.15': + resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.12': - resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': + resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.12': - resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==} + '@rolldown/binding-linux-arm64-gnu@1.2.15': + resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.12': - resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==} + '@rolldown/binding-linux-arm64-musl@1.2.15': + resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.12': - resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==} + '@rolldown/binding-linux-ppc64-gnu@1.2.15': + resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.12': - resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==} + '@rolldown/binding-linux-s390x-gnu@1.2.15': + resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.12': - resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==} + '@rolldown/binding-linux-x64-gnu@1.2.15': + resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.12': - resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==} + '@rolldown/binding-linux-x64-musl@1.2.15': + resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.12': - resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==} + '@rolldown/binding-openharmony-arm64@1.2.15': + resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.12': - resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==} + '@rolldown/binding-win32-arm64-msvc@1.2.15': + resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.12': - resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==} + '@rolldown/binding-win32-x64-msvc@1.2.15': + resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -783,6 +801,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chrome@0.3.6': + resolution: {integrity: sha512-NXCrCBefCHCKMVbkaxfBQqyP766Wg85S2FQhixCCxq7AtjJwoEaja2y5NSqCzpihIi9mPqhaF5RuybTnrdYD2Q==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -792,6 +813,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -801,6 +825,9 @@ packages: '@types/node@26.6.4': resolution: {integrity: sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==} + '@types/node@26.6.5': + resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -843,8 +870,8 @@ packages: arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argparse@3.0.4: + resolution: {integrity: sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==} atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} @@ -853,8 +880,8 @@ packages: bencode@2.0.3: resolution: {integrity: sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==} - body-parser@2.3.0: - resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + body-parser@2.4.0: + resolution: {integrity: sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==} engines: {node: '>=18'} bytes@3.1.2: @@ -890,19 +917,15 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - concurrently@10.0.5: - resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} + concurrently@10.0.6: + resolution: {integrity: sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==} engines: {node: '>=22'} hasBin: true - content-disposition@1.1.0: - resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} engines: {node: '>=18'} - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - content-type@2.1.0: resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} engines: {node: '>=18'} @@ -1012,8 +1035,8 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} engines: {node: '>= 18'} fast-copy@4.1.2: @@ -1118,8 +1141,8 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - js-yaml@5.4.2: - resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} + js-yaml@5.5.0: + resolution: {integrity: sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==} hasBin: true libphonenumber-js@1.13.14: @@ -1248,8 +1271,8 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + on-finished@2.4.2: + resolution: {integrity: sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==} engines: {node: '>= 0.8'} once@1.4.0: @@ -1296,17 +1319,17 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} - postcss@8.5.29: - resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} engines: {node: ^10 || ^12 || >=14} - prettier@3.9.9: - resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} + prettier@3.9.10: + resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} engines: {node: '>=14'} hasBin: true - process-warning@5.1.0: - resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + process-warning@5.1.1: + resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==} proxy-addr@2.0.8: resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} @@ -1319,6 +1342,10 @@ packages: resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} + qs@6.17.0: + resolution: {integrity: sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==} + engines: {node: '>=0.6'} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -1340,8 +1367,8 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - rolldown@1.2.12: - resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} + rolldown@1.2.15: + resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1359,8 +1386,8 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - secure-json-parse@4.1.0: - resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + secure-json-parse@4.1.1: + resolution: {integrity: sha512-lnd81MQEZKjhW/Gd2TQqdd4XP6QYNdDW/EP+I7nTrFHP9y2LmaGPIHk9xsnzcOiEr1c39c/e84xFgGviQKBTRw==} semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} @@ -1522,13 +1549,13 @@ packages: peerDependencies: vite: '>=5.4.0' - vite@8.3.2: - resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} + vite@8.3.5: + resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.7.1 + '@vitejs/devtools': ^0.7.1 || ^0.8.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -1605,8 +1632,8 @@ packages: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} - zod@4.6.5: - resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} snapshots: @@ -1828,11 +1855,11 @@ snapshots: eventsource-parser: 3.1.1 jose: 6.2.12 pkce-challenge: 5.0.1 - zod: 4.6.5 + zod: 4.6.7 '@modelcontextprotocol/core@2.3.1': dependencies: - zod: 4.6.5 + zod: 4.6.7 '@modelcontextprotocol/node@2.1.1(@modelcontextprotocol/server@2.3.1)': dependencies: @@ -1843,57 +1870,57 @@ snapshots: '@modelcontextprotocol/server@2.3.1': dependencies: '@modelcontextprotocol/core': 2.3.1 - zod: 4.6.5 + zod: 4.6.7 - '@oxc-project/types@0.152.0': {} + '@oxc-project/types@0.155.0': {} '@penpot/plugin-styles@1.5.0': {} '@pinojs/redact@0.4.0': {} - '@rolldown/binding-android-arm-eabi@1.2.12': + '@rolldown/binding-android-arm-eabi@1.2.15': optional: true - '@rolldown/binding-android-arm64@1.2.12': + '@rolldown/binding-android-arm64@1.2.15': optional: true - '@rolldown/binding-darwin-arm64@1.2.12': + '@rolldown/binding-darwin-arm64@1.2.15': optional: true - '@rolldown/binding-darwin-x64@1.2.12': + '@rolldown/binding-darwin-x64@1.2.15': optional: true - '@rolldown/binding-freebsd-x64@1.2.12': + '@rolldown/binding-freebsd-x64@1.2.15': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.12': + '@rolldown/binding-linux-arm64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.12': + '@rolldown/binding-linux-arm64-musl@1.2.15': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.12': + '@rolldown/binding-linux-ppc64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.12': + '@rolldown/binding-linux-s390x-gnu@1.2.15': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.12': + '@rolldown/binding-linux-x64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-x64-musl@1.2.12': + '@rolldown/binding-linux-x64-musl@1.2.15': optional: true - '@rolldown/binding-openharmony-arm64@1.2.12': + '@rolldown/binding-openharmony-arm64@1.2.15': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.12': + '@rolldown/binding-win32-arm64-msvc@1.2.15': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.12': + '@rolldown/binding-win32-x64-msvc@1.2.15': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -1913,15 +1940,19 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 26.6.4 + '@types/node': 26.6.5 + + '@types/chrome@0.3.6': + dependencies: + '@types/har-format': 1.2.16 '@types/connect@3.4.38': dependencies: - '@types/node': 26.6.4 + '@types/node': 26.6.5 '@types/express-serve-static-core@5.1.3': dependencies: - '@types/node': 26.6.4 + '@types/node': 26.6.5 '@types/qs': 6.15.1 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -1932,6 +1963,8 @@ snapshots: '@types/express-serve-static-core': 5.1.3 '@types/serve-static': 2.2.0 + '@types/har-format@1.2.16': {} + '@types/http-errors@2.0.5': {} '@types/js-yaml@4.0.9': {} @@ -1940,18 +1973,22 @@ snapshots: dependencies: undici-types: 8.9.0 + '@types/node@26.6.5': + dependencies: + undici-types: 8.9.0 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} '@types/send@1.2.1': dependencies: - '@types/node': 26.6.4 + '@types/node': 26.6.5 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 26.6.4 + '@types/node': 26.6.5 '@types/validator@13.15.10': {} @@ -1976,21 +2013,21 @@ snapshots: arg@4.1.3: {} - argparse@2.0.1: {} + argparse@3.0.4: {} atomic-sleep@1.0.0: {} bencode@2.0.3: {} - body-parser@2.3.0(supports-color@10.2.2): + body-parser@2.4.0(supports-color@10.2.2): dependencies: bytes: 3.1.2 content-type: 2.1.0 debug: 4.4.3(supports-color@10.2.2) http-errors: 2.0.1 iconv-lite: 0.7.3 - on-finished: 2.4.1 - qs: 6.16.0 + on-finished: 2.4.2 + qs: 6.17.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -2028,7 +2065,7 @@ snapshots: colorette@2.0.20: {} - concurrently@10.0.5: + concurrently@10.0.6: dependencies: chalk: 5.6.2 rxjs: 7.8.2 @@ -2037,9 +2074,7 @@ snapshots: tree-kill: 1.2.2 yargs: 18.0.0 - content-disposition@1.1.0: {} - - content-type@1.0.5: {} + content-disposition@2.0.1: {} content-type@2.1.0: {} @@ -2143,12 +2178,12 @@ snapshots: dependencies: eventsource-parser: 3.1.1 - express@5.2.1(supports-color@10.2.2): + express@5.3.0(supports-color@10.2.2): dependencies: accepts: 2.0.0 - body-parser: 2.3.0(supports-color@10.2.2) - content-disposition: 1.1.0 - content-type: 1.0.5 + body-parser: 2.4.0(supports-color@10.2.2) + content-disposition: 2.0.1 + content-type: 2.1.0 cookie: 0.7.2 cookie-signature: 1.2.2 debug: 4.4.3(supports-color@10.2.2) @@ -2161,7 +2196,7 @@ snapshots: http-errors: 2.0.1 merge-descriptors: 2.0.0 mime-types: 3.0.2 - on-finished: 2.4.1 + on-finished: 2.4.2 once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.8 @@ -2189,7 +2224,7 @@ snapshots: debug: 4.4.3(supports-color@10.2.2) encodeurl: 2.0.0 escape-html: 1.0.3 - on-finished: 2.4.1 + on-finished: 2.4.2 parseurl: 1.3.3 statuses: 2.0.2 transitivePeerDependencies: @@ -2273,9 +2308,9 @@ snapshots: joycon@3.1.1: {} - js-yaml@5.4.2: + js-yaml@5.5.0: dependencies: - argparse: 2.0.1 + argparse: 3.0.4 libphonenumber-js@1.13.14: {} @@ -2361,7 +2396,7 @@ snapshots: on-exit-leak-free@2.1.2: {} - on-finished@2.4.1: + on-finished@2.4.2: dependencies: ee-first: 1.1.1 @@ -2400,7 +2435,7 @@ snapshots: on-exit-leak-free: 2.1.2 pino-abstract-transport: 3.0.0 pump: 3.0.4 - secure-json-parse: 4.1.0 + secure-json-parse: 4.1.1 sonic-boom: 5.0.1 strip-json-comments: 5.0.3 @@ -2413,7 +2448,7 @@ snapshots: on-exit-leak-free: 2.1.2 pino-abstract-transport: 3.0.0 pino-std-serializers: 7.1.0 - process-warning: 5.1.0 + process-warning: 5.1.1 quick-format-unescaped: 4.0.4 real-require: 1.0.0 safe-stable-stringify: 2.5.0 @@ -2422,15 +2457,15 @@ snapshots: pkce-challenge@5.0.1: {} - postcss@8.5.29: + postcss@8.5.32: dependencies: nanoid: 3.3.20 picocolors: 1.1.1 source-map-js: 1.2.2 - prettier@3.9.9: {} + prettier@3.9.10: {} - process-warning@5.1.0: {} + process-warning@5.1.1: {} proxy-addr@2.0.8: dependencies: @@ -2447,6 +2482,11 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 + qs@6.17.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + quick-format-unescaped@4.0.4: {} range-parser@1.3.0: {} @@ -2464,26 +2504,26 @@ snapshots: reflect-metadata@0.2.2: {} - rolldown@1.2.12: + rolldown@1.2.15: dependencies: - '@oxc-project/types': 0.152.0 + '@oxc-project/types': 0.155.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.12 - '@rolldown/binding-android-arm64': 1.2.12 - '@rolldown/binding-darwin-arm64': 1.2.12 - '@rolldown/binding-darwin-x64': 1.2.12 - '@rolldown/binding-freebsd-x64': 1.2.12 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.12 - '@rolldown/binding-linux-arm64-gnu': 1.2.12 - '@rolldown/binding-linux-arm64-musl': 1.2.12 - '@rolldown/binding-linux-ppc64-gnu': 1.2.12 - '@rolldown/binding-linux-s390x-gnu': 1.2.12 - '@rolldown/binding-linux-x64-gnu': 1.2.12 - '@rolldown/binding-linux-x64-musl': 1.2.12 - '@rolldown/binding-openharmony-arm64': 1.2.12 - '@rolldown/binding-win32-arm64-msvc': 1.2.12 - '@rolldown/binding-win32-x64-msvc': 1.2.12 + '@rolldown/binding-android-arm-eabi': 1.2.15 + '@rolldown/binding-android-arm64': 1.2.15 + '@rolldown/binding-darwin-arm64': 1.2.15 + '@rolldown/binding-darwin-x64': 1.2.15 + '@rolldown/binding-freebsd-x64': 1.2.15 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.15 + '@rolldown/binding-linux-arm64-gnu': 1.2.15 + '@rolldown/binding-linux-arm64-musl': 1.2.15 + '@rolldown/binding-linux-ppc64-gnu': 1.2.15 + '@rolldown/binding-linux-s390x-gnu': 1.2.15 + '@rolldown/binding-linux-x64-gnu': 1.2.15 + '@rolldown/binding-linux-x64-musl': 1.2.15 + '@rolldown/binding-openharmony-arm64': 1.2.15 + '@rolldown/binding-win32-arm64-msvc': 1.2.15 + '@rolldown/binding-win32-x64-msvc': 1.2.15 router@2.2.0(supports-color@10.2.2): dependencies: @@ -2503,7 +2543,7 @@ snapshots: safer-buffer@2.1.2: {} - secure-json-parse@4.1.0: {} + secure-json-parse@4.1.1: {} semver@7.8.5: {} @@ -2517,7 +2557,7 @@ snapshots: http-errors: 2.0.1 mime-types: 3.0.2 ms: 2.1.3 - on-finished: 2.4.1 + on-finished: 2.4.2 range-parser: 1.3.0 statuses: 2.0.2 transitivePeerDependencies: @@ -2534,7 +2574,7 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.35.5(@types/node@26.6.4): + sharp@0.35.5(@types/node@26.6.5): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -2565,7 +2605,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.5 '@img/sharp-win32-ia32': 0.35.5 '@img/sharp-win32-x64': 0.35.5 - '@types/node': 26.6.4 + '@types/node': 26.6.5 shebang-command@2.0.0: dependencies: @@ -2646,14 +2686,14 @@ snapshots: tree-kill@1.2.2: {} - ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3): + ts-node@10.9.2(@types/node@26.6.5)(typescript@6.0.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.13 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 26.6.4 + '@types/node': 26.6.5 acorn: 8.19.0 acorn-walk: 8.3.5 arg: 4.1.3 @@ -2690,25 +2730,25 @@ snapshots: vary@1.1.2: {} - vite-live-preview@0.4.0(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15)): + vite-live-preview@0.4.0(vite@8.3.5(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15)): dependencies: '@seahax/deep-copy': 0.1.0 '@seahax/semaphore': 0.5.1 '@types/ws': 8.18.2 escape-goat: 4.0.0 strip-ansi: 7.2.0 - vite: 8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15) + vite: 8.3.5(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15) ws: 8.22.0 transitivePeerDependencies: - bufferutil - utf-8-validate - vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15): + vite@8.3.5(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.29 - rolldown: 1.2.12 + postcss: 8.5.32 + rolldown: 1.2.15 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.6.4 @@ -2745,4 +2785,4 @@ snapshots: yn@3.1.1: {} - zod@4.6.5: {} + zod@4.6.7: {} diff --git a/mcp/pnpm-workspace.yaml b/mcp/pnpm-workspace.yaml index 3da100dca6..5aa8300f88 100644 --- a/mcp/pnpm-workspace.yaml +++ b/mcp/pnpm-workspace.yaml @@ -19,6 +19,7 @@ packages: - "packages/common" - "packages/server" - "packages/plugin" + - "packages/browser-extension" overrides: '@hono/node-server@<2.0.5': ^2.0.5 diff --git a/mcp/scripts/build b/mcp/scripts/build index 3e5337fda2..671091ac9f 100755 --- a/mcp/scripts/build +++ b/mcp/scripts/build @@ -21,7 +21,7 @@ set +e set -e popd -pnpm -r --filter "!mcp-plugin" install; +pnpm -r --filter "!mcp-plugin" --filter "!mcp-browser-extension" install; pnpm -r --filter "mcp-server" run build; rsync -avr packages/server/dist/ ./dist/; diff --git a/mcp/scripts/start-mcp-devenv-oauth b/mcp/scripts/start-mcp-devenv-oauth new file mode 100755 index 0000000000..230dbf4029 --- /dev/null +++ b/mcp/scripts/start-mcp-devenv-oauth @@ -0,0 +1,19 @@ +#!/bin/sh + +# This starts the MCP server in multi-user mode with OAuth enabled, for +# testing OAuth in the Penpot development environment (assuming devenv). +# Agentic tooling expects the single-user server from start-mcp-devenv. + +set -e + +if [ -z "$PENPOT_PUBLIC_URI" ]; then + echo "PENPOT_PUBLIC_URI is not set; run this inside the devenv container." >&2 + exit 1 +fi + +PENPOT_MCP_AUTH_URI="${PENPOT_PUBLIC_URI%/}/api/mcp/oauth" + +echo "OAuth authorization URI: $PENPOT_MCP_AUTH_URI" +echo "MCP endpoint for clients: ${PENPOT_PUBLIC_URI%/}/mcp/stream" + +PENPOT_MCP_SERVER_HOST=0.0.0.0 PENPOT_MCP_REMOTE_MODE=true PENPOT_MCP_AUTH_URI="$PENPOT_MCP_AUTH_URI" pnpm run bootstrap:multi-user diff --git a/media-processor/package.json b/media-processor/package.json index 4a79f0ae66..29001cae44 100644 --- a/media-processor/package.json +++ b/media-processor/package.json @@ -14,7 +14,7 @@ "fmt:check": "prettier --check src/ test/", "clean": "rm -rf dist/" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "dependencies": { "express": "^5.2.1", "multer": "^2.4.0", diff --git a/media-processor/pnpm-lock.yaml b/media-processor/pnpm-lock.yaml index 147a963c3e..eb00e8a1b9 100644 --- a/media-processor/pnpm-lock.yaml +++ b/media-processor/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/package.json b/package.json index 05df2e97cd..5a14003bcd 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index f74e320625..08b8e2d58e 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -4,7 +4,7 @@ - **plugin-types, plugins-runtime:** `penpotMgmt` returns `Team`, `Project` and `ProjectFile` objects. Assigning `name` renames teams, projects and files, `project.pinned` pins a project and `file.shared` shares a file as a team library; `createProject`, `createFile`, `moveTo`, `duplicate`, `remove` and `restore` live on the objects. Added `penpotMgmt.listTeams`, `penpotMgmt.createTeam`, `penpotMgmt.getFile`, `listDeletedProjects` and `listDeletedFiles`, and the `manage:projects`, `manage:teams` and `manage:delete` permissions. Restoration uses the dashboard's Trash operation; projects need recoverable files and empty projects cannot be restored; the API exposes no permanent deletion method. Team deletion is not exposed. - **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events. Global scope adds the `allow:global` permission, which users must accept when installing the plugin. -- **plugin-types:** Added `LibraryComponent.restore()` to restore a deleted component and its main instance without changing the current page, selection, zoom, or viewport. Requires `library:write` permission. +- **plugin-types:** Added `LibraryComponent.restore()` to restore a deleted component and its main instance without changing the current page, selection, zoom, or viewport. Requires `library:write` permission and only applies to the current file's library. - **plugin-types:** Added `Shape.updateMain()`, `LibraryComponent.duplicate()`, writable component `annotation`, and `Library.deletedComponents`. Declared the existing `LibraryComponent.remove()` method. - **plugin-types:** Added `LibraryContext.tokensSource` to read or set the current file's token source to a connected library, or restore local tokens. - **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently. diff --git a/plugins/apps/colors-to-tokens-plugin/package.json b/plugins/apps/colors-to-tokens-plugin/package.json index a762e7494c..4c9690e2a2 100644 --- a/plugins/apps/colors-to-tokens-plugin/package.json +++ b/plugins/apps/colors-to-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json index 1f21539f19..f12e809fe0 100644 --- a/plugins/apps/composable-test-suite/package.json +++ b/plugins/apps/composable-test-suite/package.json @@ -27,5 +27,5 @@ "vite": "^8.3.2", "vite-live-preview": "^0.4.0" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/composable-test-suite/src/composable-tests/cases.ts b/plugins/apps/composable-test-suite/src/composable-tests/cases.ts index e9150ec16c..793903e15d 100644 --- a/plugins/apps/composable-test-suite/src/composable-tests/cases.ts +++ b/plugins/apps/composable-test-suite/src/composable-tests/cases.ts @@ -5,6 +5,7 @@ import { createTestCaseRemoteMainCopySyncNested } from "./cases/caseRemoteMainCo import { createTestCaseVariantSwitchPropagates } from "./cases/caseVariantSwitchPropagates.ts"; import { createTestCaseCopySubheadDeletePreservesSlots } from "./cases/caseCopySubheadDeletePreservesSlots.ts"; import { createTestCaseMainReorderKeepsCopySlots } from "./cases/caseMainReorderKeepsCopySlots.ts"; +import { createTestCaseMainChildMovedOutLeavesCopy } from "./cases/caseMainChildMovedOutLeavesCopy.ts"; /** * All composable test cases currently defined. A factory (not a constant): each @@ -19,5 +20,6 @@ export function allCases(): readonly TestCase[] { createTestCaseVariantSwitchPropagates(), createTestCaseCopySubheadDeletePreservesSlots(), createTestCaseMainReorderKeepsCopySlots(), + createTestCaseMainChildMovedOutLeavesCopy(), ]; } diff --git a/plugins/apps/composable-test-suite/src/composable-tests/cases/caseMainChildMovedOutLeavesCopy.ts b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseMainChildMovedOutLeavesCopy.ts new file mode 100644 index 0000000000..04df9632fd --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseMainChildMovedOutLeavesCopy.ts @@ -0,0 +1,59 @@ +import { Board } from "@penpot/plugin-types"; +import { TestCase } from "../test-suite/TestCase.ts"; +import { Color } from "../model/Color"; +import { ShapePropFillColor } from "../model/ShapeProp.ts"; +import { Assert } from "../util/Assert.ts"; +import { OpAssert } from "../operations/OpAssert"; +import { OpSequence } from "../operations/OpSequence.ts"; +import { OpOptional } from "../operations/OpOptional.ts"; +import { OpChangeProperty } from "../operations/OpChangeProperty"; +import { OpCreateBoard } from "../operations/OpCreateBoard"; +import { OpCreateSimpleComponentWithCopy } from "../operations/OpCreateSimpleComponentWithCopy"; +import { OpMoveShape } from "../operations/OpMoveShape"; + +const BASELINE = new Color("#aaaaaa"); +const EDIT_COLOR = new Color("#ff0000"); + +/** + * A shape moved out of a main leaves its copies, also when another edit lands + * right after the move, before propagation has run. + * + * The baseline assertion waits for the setup to settle; the setup's own commits + * would otherwise sync the copy in the same pass and hide the result. The move + * and the optional edit then run back to back, so their commits arrive together; + * without the edit, the move is the last commit. + */ +export function createTestCaseMainChildMovedOutLeavesCopy(): TestCase { + const foundation = new OpCreateSimpleComponentWithCopy(BASELINE); + const { mainChild, copyRoot } = foundation.roles; + const outside = new OpCreateBoard("Outside"); + const outsideBoard = outside.roles.board; + + const move = new OpMoveShape(mainChild, outsideBoard, "main rect", "outside board"); + + return new TestCase( + "MainChildMovedOutLeavesCopy", + "A component containing a single rectangle is created, plus a copy of it and a board " + + "outside the component. The main's rectangle is moved into that board, optionally " + + "followed at once by a fill change on the board. The copy must lose its rectangle " + + "either way.", + new OpSequence( + foundation, + outside, + new OpAssert("the copy starts with its rectangle", (s) => { + const copyChildren = (s.get(copyRoot) as Board).children; + Assert.that(copyChildren.length === 1, `the copy should have one child, found ${copyChildren.length}`); + }), + move, + new OpOptional(new OpChangeProperty(outsideBoard, new ShapePropFillColor(), EDIT_COLOR, "outside board")), + new OpAssert("the rectangle left the main and the copy", (s) => { + move.assertHasMoved(s, mainChild); + const copyChildren = (s.get(copyRoot) as Board).children; + Assert.that( + copyChildren.length === 0, + `the copy should have no children, found ${copyChildren.map((c) => c.name).join(", ")}` + ); + }) + ) + ); +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateBoard.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateBoard.ts new file mode 100644 index 0000000000..eeb7bb64ff --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateBoard.ts @@ -0,0 +1,39 @@ +import { Board } from "@penpot/plugin-types"; +import { Operation } from "../core/Operation"; +import { Role } from "../core/Role"; +import { RoleBundle } from "../core/RoleBundle"; +import { Situation } from "../core/Situation"; + +/** The roles exposed by {@link OpCreateBoard}. */ +class RolesBoard extends RoleBundle { + /** The free board the operation created. */ + readonly board = new Role("board"); +} + +/** + * Creates a free board on the page, outside any component, and binds it to its + * `board` role. Gives a case somewhere to move shapes to, or to edit, apart from + * the configuration under test. + */ +export class OpCreateBoard extends Operation { + readonly roles = new RolesBoard(); + + /** + * @param boardName - the name given to the new board + */ + constructor(private readonly boardName: string) { + super(); + } + + async applyTo(situation: Situation): Promise { + const board = penpot.createBoard(); + board.name = this.boardName; + board.resize(100, 100); + situation.applyPosAdvanceX(board); + situation.bind(this.roles.board, board); + } + + toString(): string { + return `create board ${this.boardName}`; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpMoveShape.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpMoveShape.ts new file mode 100644 index 0000000000..6795dc121b --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpMoveShape.ts @@ -0,0 +1,53 @@ +import { Board } from "@penpot/plugin-types"; +import { Operation } from "../core/Operation"; +import { Situation } from "../core/Situation"; +import { ShapeTarget, resolveTarget } from "../core/ShapeTarget"; +import { Assert } from "../util/Assert"; + +/** + * Moves the shape a target resolves to into another parent, via the Plugin API's + * `appendChild`. Used to move a shape out of the main of a component, which must + * remove the corresponding shape from its copies. + */ +export class OpMoveShape extends Operation { + /** + * @param target - resolves the shape to move (role or situation-derived) + * @param newParent - resolves the board that receives the shape + * @param targetLabel - a human-readable name for the moved shape, for the log + * @param parentLabel - a human-readable name for the new parent, for the log + */ + constructor( + private readonly target: ShapeTarget, + private readonly newParent: ShapeTarget, + private readonly targetLabel: string = String(target), + private readonly parentLabel: string = String(newParent) + ) { + super(); + } + + async applyTo(situation: Situation): Promise { + const shape = resolveTarget(this.target, situation); + const parent = resolveTarget(this.newParent, situation) as Board; + parent.appendChild(shape); + } + + /** + * Asserts that the shape `target` resolves to now sits in this operation's + * new parent. + * + * @param situation - the situation to resolve the targets against + * @param target - resolves the shape expected to have moved + */ + assertHasMoved(situation: Situation, target: ShapeTarget): void { + const shape = resolveTarget(target, situation); + const parent = resolveTarget(this.newParent, situation); + Assert.that( + shape.parent?.id === parent.id, + `expected "${shape.name}" inside "${parent.name}" but its parent is "${shape.parent?.name}"` + ); + } + + toString(): string { + return `move ${this.targetLabel} into ${this.parentLabel}`; + } +} diff --git a/plugins/apps/contrast-plugin/package.json b/plugins/apps/contrast-plugin/package.json index 8402969c20..641618631d 100644 --- a/plugins/apps/contrast-plugin/package.json +++ b/plugins/apps/contrast-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/create-palette-plugin/package.json b/plugins/apps/create-palette-plugin/package.json index de963120f4..d52baaa757 100644 --- a/plugins/apps/create-palette-plugin/package.json +++ b/plugins/apps/create-palette-plugin/package.json @@ -12,5 +12,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/e2e/package.json b/plugins/apps/e2e/package.json index 97405221de..d9c64e6bac 100644 --- a/plugins/apps/e2e/package.json +++ b/plugins/apps/e2e/package.json @@ -7,5 +7,5 @@ "test": "vitest", "lint": "eslint ." }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/example-styles/package.json b/plugins/apps/example-styles/package.json index f1fc7219c9..227f8b7a9f 100644 --- a/plugins/apps/example-styles/package.json +++ b/plugins/apps/example-styles/package.json @@ -11,5 +11,5 @@ "serve": "vite preview", "lint": "eslint ." }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/icons-plugin/package.json b/plugins/apps/icons-plugin/package.json index 841c979c14..31f25c5e8a 100644 --- a/plugins/apps/icons-plugin/package.json +++ b/plugins/apps/icons-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/lorem-ipsum-plugin/package.json b/plugins/apps/lorem-ipsum-plugin/package.json index 5868285101..f5d5be5541 100644 --- a/plugins/apps/lorem-ipsum-plugin/package.json +++ b/plugins/apps/lorem-ipsum-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json index 1c6ba084c1..0927a7388f 100644 --- a/plugins/apps/plugin-api-test-suite/package.json +++ b/plugins/apps/plugin-api-test-suite/package.json @@ -20,5 +20,5 @@ "devDependencies": { "playwright": "^1.63.0" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/poc-state-plugin/package.json b/plugins/apps/poc-state-plugin/package.json index 3e225d2f41..df5bc13b27 100644 --- a/plugins/apps/poc-state-plugin/package.json +++ b/plugins/apps/poc-state-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/poc-tokens-plugin/package.json b/plugins/apps/poc-tokens-plugin/package.json index 83d30f08a9..41337275df 100644 --- a/plugins/apps/poc-tokens-plugin/package.json +++ b/plugins/apps/poc-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "exit 0" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/rename-layers-plugin/package.json b/plugins/apps/rename-layers-plugin/package.json index 90faf2acea..38eb1e0939 100644 --- a/plugins/apps/rename-layers-plugin/package.json +++ b/plugins/apps/rename-layers-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/table-plugin/package.json b/plugins/apps/table-plugin/package.json index b77a35b327..f2bc2d696b 100644 --- a/plugins/apps/table-plugin/package.json +++ b/plugins/apps/table-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 5d6c27e156..94858bdffb 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -3060,7 +3060,8 @@ export interface LibraryComponent extends LibraryElement { remove(): void; /** - * Restores this component if it has been deleted, including its main instance. + * Restores this component if it has been deleted, including its main instance, + * in the current file's library. * Leaves the current page, selection, zoom, and viewport unchanged. * Requires `library:write` permission. Does nothing if already restored. * diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json index 87546804f9..e4959293fa 100644 --- a/plugins/libs/plugin-types/package.json +++ b/plugins/libs/plugin-types/package.json @@ -7,5 +7,5 @@ "build": "node ../../tools/scripts/build-types.mjs", "lint": "tsc -p . --noEmit" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json index f40741cf5e..23aeeb2ee5 100644 --- a/plugins/libs/plugins-runtime/package.json +++ b/plugins/libs/plugins-runtime/package.json @@ -16,5 +16,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json index aab41a3eaf..5badd11056 100644 --- a/plugins/libs/plugins-styles/package.json +++ b/plugins/libs/plugins-styles/package.json @@ -6,5 +6,5 @@ "build": "node ../../tools/scripts/build-css.mjs", "lint": "echo 0" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/package.json b/plugins/package.json index f6b6d8901c..01d2a885b5 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -3,7 +3,7 @@ "version": "0.6.0", "type": "module", "license": "MIT", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "scripts": { "start": "pnpm run start:app:runtime", "start:app:runtime": "concurrently --kill-others --names build,server \"pnpm --filter @penpot/plugins-runtime run build:watch\" \"pnpm --filter @penpot/plugins-runtime run preview\"", diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml index faf9371961..b84286c8f0 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03d4f60051..2b8e0f539b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/render-wasm/package.json b/render-wasm/package.json index 96253a6356..f1f4b196ab 100644 --- a/render-wasm/package.json +++ b/render-wasm/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/render-wasm/pnpm-lock.yaml b/render-wasm/pnpm-lock.yaml index 9ef64d85bd..a0180171fa 100644 --- a/render-wasm/pnpm-lock.yaml +++ b/render-wasm/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index bfbeedf0d0..5bb8c4795f 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -1,9 +1,11 @@ +use std::borrow::Cow; + use skia_safe::{self as skia, Canvas, Paint, RRect}; use crate::error::Result; use crate::shapes::{ circle_segments_local, merge_fills, radius_to_sigma, rect_segments_local, stroke_to_path, - BlurType, Fill, Frame, Path, Rect, Shape, Stroke, StrokeKind, StrokeStyle, Type, + BlurType, Fill, Frame, Path, Rect, Shape, SolidColor, Stroke, StrokeKind, StrokeStyle, Type, }; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; @@ -29,6 +31,10 @@ pub(super) struct VectorRenderer<'a> { /// techniques that `SkSVGDevice` drops: draw fills individually and emit /// solid Inner/Outer strokes as filled outlines. compose_fills: bool, + /// Set when an enclosing layer already applies the shape's layer blur. + blur_in_layer: bool, + /// Set inside a container's drop-shadow pass: text glyphs paint opaque. + silhouette: bool, } impl<'a> VectorRenderer<'a> { @@ -43,6 +49,8 @@ impl<'a> VectorRenderer<'a> { shared, scale, compose_fills, + blur_in_layer: false, + silhouette: false, } } @@ -50,9 +58,10 @@ impl<'a> VectorRenderer<'a> { /// /// SVG export (`compose_fills == false`) returns `None`: `SkSVGDevice` drops /// paint image-filters (the shape would vanish). Layer blur is re-emitted as - /// a native SVG `` wrapper instead. + /// a native SVG `` wrapper instead. Also `None` inside a layer + /// that already blurs, or the shape would blur twice. fn layer_blur_filter(&self, shape: &Shape) -> Option { - if !self.compose_fills { + if !self.compose_fills || self.blur_in_layer { return None; } shape.image_filter(1.) @@ -121,7 +130,7 @@ impl ShapeRenderer for VectorRenderer<'_> { fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> { let layer_bounds = shape.layer_bounds(); for shadow in shape.drop_shadows_visible() { - if let Some(filter) = shadow.get_drop_shadow_filter() { + if let Some(filter) = shadow.get_drop_shadow_blur_filter() { let mut paint = Paint::default(); paint.set_image_filter(filter); let layer_rec = skia::canvas::SaveLayerRec::default() @@ -131,6 +140,12 @@ impl ShapeRenderer for VectorRenderer<'_> { let mut fill_paint = Paint::default(); fill_paint.set_anti_alias(true); fill_paint.set_color(skia::Color::BLACK); + // Spread as a mitered outline: Skia's dilate filter runs as + // SkSL per pixel on CPU canvases, which takes minutes. + if shadow.spread > 0.0 { + fill_paint.set_style(skia::PaintStyle::StrokeAndFill); + fill_paint.set_stroke_width(2.0 * shadow.spread); + } draw_shape_geometry(self.canvas, shape, &fill_paint); self.canvas.restore(); } @@ -182,13 +197,17 @@ impl ShapeRenderer for VectorRenderer<'_> { }; let text_content = text_content.new_bounds(shape.selrect()); - let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); + let mut paragraph_builders = + text_content.paragraph_builder_group_from_text(Some(self.silhouette)); let blur_filter = self.layer_blur_filter(shape); // Text drop shadows: one filter layer per shadow over fill + stroke - // silhouettes (mirrors GPU `render_text_shadows`). - let drop_shadows = shape.drop_shadow_paints(); - if !drop_shadows.is_empty() { + // silhouettes (mirrors GPU `render_text_shadows`). Spread grows the + // glyphs through strokes (`spread_shape`). + let layer_bounds = shape.layer_bounds(); + for shadow in shape.drop_shadows_visible() { + let spread = spread_shape(shape, shadow.spread); + let shape: &Shape = &spread; let shadow_stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); let mut shadow_paragraphs = text_content.paragraph_builder_group_from_text(Some(true)); let mut stroke_shadow_groups: Vec<(StrokeKind, _)> = shape @@ -208,67 +227,66 @@ impl ShapeRenderer for VectorRenderer<'_> { }) .collect(); - let layer_bounds = shape.layer_bounds(); - for shadow_paint in &drop_shadows { - self.canvas.save_layer( - &skia::canvas::SaveLayerRec::default() - .bounds(&layer_bounds) - .paint(shadow_paint), - ); + let mut shadow_paint = Paint::default(); + shadow_paint.set_image_filter(shadow.get_drop_shadow_blur_filter()); + self.canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&shadow_paint), + ); - text::render_overlay_emoji( - self.canvas, - shape, - &mut shadow_paragraphs, - None, - blur_filter.as_ref(), - None, - None, - )?; + text::render_overlay_emoji( + self.canvas, + shape, + &mut shadow_paragraphs, + None, + blur_filter.as_ref(), + None, + None, + )?; - for (kind, stroke_paragraphs) in &mut stroke_shadow_groups { - if *kind == StrokeKind::Inner { - // Inner stroke masked by the glyph fill (outset 0 here). - let mut fill_builders = - text_content.paragraph_builder_group_from_text(Some(true)); - text::render_inner_stroke( - None, - Some(self.canvas), - shape, - stroke_paragraphs, - &mut fill_builders, - None, - blur_filter.as_ref(), - 0.0, - None, - )?; - } else if *kind == StrokeKind::Outer { - text::render_outer_stroke( - None, - Some(self.canvas), - shape, - stroke_paragraphs, - None, - blur_filter.as_ref(), - 0.0, - None, - )?; - } else { - text::render_with_bounds_outset_overlay_emoji( - self.canvas, - shape, - stroke_paragraphs, - None, - blur_filter.as_ref(), - shadow_stroke_outset, - None, - None, - )?; - } + for (kind, stroke_paragraphs) in &mut stroke_shadow_groups { + if *kind == StrokeKind::Inner { + // Inner stroke masked by the glyph fill (outset 0 here). + let mut fill_builders = + text_content.paragraph_builder_group_from_text(Some(true)); + text::render_inner_stroke( + None, + Some(self.canvas), + shape, + stroke_paragraphs, + &mut fill_builders, + None, + blur_filter.as_ref(), + 0.0, + None, + )?; + } else if *kind == StrokeKind::Outer { + text::render_outer_stroke( + None, + Some(self.canvas), + shape, + stroke_paragraphs, + None, + blur_filter.as_ref(), + 0.0, + None, + )?; + } else { + text::render_with_bounds_outset_overlay_emoji( + self.canvas, + shape, + stroke_paragraphs, + None, + blur_filter.as_ref(), + shadow_stroke_outset, + None, + None, + )?; } - - self.canvas.restore(); } + + self.canvas.restore(); } text::render_overlay_emoji( @@ -427,6 +445,9 @@ struct TreeOpts<'a> { root_fills: &'a [Fill], /// Nearest group's fills, painted by fill-less leaves (GPU `nested_fills`). inherited_fills: &'a [Fill], + /// Set inside a container's drop-shadow pass: the spread to grow every + /// shape by. + silhouette: Option, } impl<'a> TreeOpts<'a> { @@ -447,6 +468,7 @@ impl<'a> TreeOpts<'a> { skip: self.skip, root_fills: self.root_fills, inherited_fills, + silhouette: self.silhouette, } } } @@ -500,6 +522,7 @@ fn render_tree_dispatch( skip: None, root_fills: &root_fills, inherited_fills: &root_fills, + silhouette: None, }; render_tree_inner(shared, canvas, id, tree, scale, &opts) } @@ -525,6 +548,10 @@ fn render_tree_inner( if element.hidden { return Ok(()); } + let silhouette = opts + .silhouette + .map(|spread| silhouette_shape(element, spread)); + let element: &Shape = silhouette.as_deref().unwrap_or(element); // Background blur samples already-drawn content behind the shape, so it must // run before the shape (and its subtree) paints. SVGRaw is excluded, @@ -561,7 +588,8 @@ fn render_tree_inner( } else { element.fills.as_slice() }; - render_leaf(shared, canvas, element, fills, scale)?; + let silhouette = opts.silhouette.is_some(); + render_leaf(shared, canvas, element, fills, scale, silhouette)?; } } @@ -696,6 +724,7 @@ fn render_background_blur_image( skip: Some(&shape.id), root_fills: opts.root_fills, inherited_fills: opts.root_fills, + silhouette: None, }; render_tree_inner(shared, oc, opts.root, tree, scale, &sub)?; } @@ -952,6 +981,8 @@ fn render_frame( canvas.save(); canvas.concat(&matrix); let mut renderer = VectorRenderer::new(canvas, shared, scale, true); + // The opacity/blend layer above carries the frame's layer blur. + renderer.blur_in_layer = needs_layer; renderer.draw_fills(element, &element.fills)?; renderer.draw_fill_inner_shadows(element)?; canvas.restore(); @@ -996,7 +1027,7 @@ fn render_container_drop_shadows( ) -> Result<()> { let subtree_bounds = element.extrect(tree, scale); for shadow in element.drop_shadows_visible() { - let Some(filter) = shadow.get_drop_shadow_filter() else { + let Some(filter) = shadow.get_drop_shadow_blur_filter() else { continue; }; let mut paint = Paint::default(); @@ -1007,13 +1038,22 @@ fn render_container_drop_shadows( .paint(&paint), ); + // Spread grows every silhouette shape instead of dilating the layer. + let spread = opts.silhouette.unwrap_or(0.0) + shadow.spread.max(0.0); if draw_fills && !element.fills.is_empty() { + let mut background = Cow::Borrowed(element); + if spread > 0.0 { + background.to_mut().selrect.outset((spread, spread)); + } let mut renderer = VectorRenderer::new(canvas, shared, scale, true); - renderer.draw_fills(element, &element.fills)?; + renderer.draw_fills(&background, &[Fill::Solid(SolidColor(skia::Color::BLACK))])?; } let children = children_paint_order(tree, element); - let child_opts = opts.for_children_of(element); + let child_opts = TreeOpts { + silhouette: Some(spread), + ..opts.for_children_of(element) + }; if element.is_masked_group() { render_masked_children(shared, canvas, element, &children, tree, scale, &child_opts)?; } else { @@ -1027,6 +1067,41 @@ fn render_container_drop_shadows( Ok(()) } +/// `shape` grown by a drop-shadow `spread` for its silhouette, through its own +/// strokes and joins (as the GPU does for paths). Empty frames and containers +/// have no outline of their own to grow. +fn spread_shape(shape: &Shape, spread: f32) -> Cow<'_, Shape> { + let outline = match shape.shape_type { + Type::Group(_) | Type::SVGRaw(_) => false, + Type::Frame(_) => !shape.fills.is_empty() || !shape.strokes.is_empty(), + _ => true, + }; + if spread <= 0.0 || !outline { + return Cow::Borrowed(shape); + } + let mut shape = shape.clone(); + shape.apply_shadow_spread(spread); + Cow::Owned(shape) +} + +/// `shape` as drawn in a container's drop-shadow pass: grown by `spread` and +/// solid black, since fill and stroke opacity don't affect the shadow (GPU). +fn silhouette_shape(shape: &Shape, spread: f32) -> Cow<'_, Shape> { + let mut shape = spread_shape(shape, spread); + if shape.has_fills() || !shape.strokes.is_empty() { + let black = Fill::Solid(SolidColor(skia::Color::BLACK)); + let shape = shape.to_mut(); + if shape.has_fills() { + shape.clear_fills(); + shape.add_fill(black.clone()); + } + for stroke in shape.strokes.iter_mut() { + stroke.fill = black.clone(); + } + } + shape +} + // --------------------------------------------------------------------------- // Leaf shapes (Rect, Circle, Path, Bool, Text, SVGRaw) // --------------------------------------------------------------------------- @@ -1037,6 +1112,7 @@ fn render_leaf( element: &Shape, fills: &[Fill], scale: f32, + silhouette: bool, ) -> Result<()> { let needs_layer = element.needs_layer(); @@ -1065,6 +1141,8 @@ fn render_leaf( } else { false }; + renderer.blur_in_layer = blur_layer; + renderer.silhouette = silhouette; renderer.draw_drop_shadows(element)?; render_leaf_content(&mut renderer, element, fills)?; diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 3baa6dd66c..6a157a1c13 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -2187,20 +2187,6 @@ impl Shape { true } - pub fn drop_shadow_paints(&self) -> Vec { - let drop_shadows: Vec<&Shadow> = self.drop_shadows_visible().collect(); - - drop_shadows - .into_iter() - .map(|shadow| { - let mut paint = skia_safe::Paint::default(); - let filter = shadow.get_drop_shadow_filter(); - paint.set_image_filter(filter); - paint - }) - .collect() - } - pub fn inner_shadow_paints(&self) -> Vec { let inner_shadows: Vec<&Shadow> = self.inner_shadows_visible().collect(); diff --git a/render-wasm/src/shapes/shadows.rs b/render-wasm/src/shapes/shadows.rs index b13c6d76bf..0a11dfdceb 100644 --- a/render-wasm/src/shapes/shadows.rs +++ b/render-wasm/src/shapes/shadows.rs @@ -87,25 +87,30 @@ impl Shadow { } pub fn get_drop_shadow_filter(&self) -> Option { + let filter = self.get_drop_shadow_blur_filter()?; + + if self.spread > 0. { + return image_filters::dilate((self.spread, self.spread), filter, None); + } + + Some(filter) + } + + /// [`Self::get_drop_shadow_filter`] without the spread dilate. + pub fn get_drop_shadow_blur_filter(&self) -> Option { if self.style == ShadowStyle::Inner { return None; } let sigma = radius_to_sigma(self.blur); - let mut filter = image_filters::drop_shadow_only( + image_filters::drop_shadow_only( (self.offset.0, self.offset.1), (sigma, sigma), self.color, None, None, None, - ); - - if self.spread > 0. { - filter = image_filters::dilate((self.spread, self.spread), filter, None); - } - - filter + ) } /// Same shadow as [`Self::get_drop_shadow_filter`], for a `merge` input. diff --git a/scripts/ci b/scripts/ci index 4428b673f9..db58292ae9 100755 --- a/scripts/ci +++ b/scripts/ci @@ -24,7 +24,7 @@ BOLD='\033[1m' NC='\033[0m' # Available modules -ALL_MODULES=("frontend" "backend" "common" "render-wasm" "exporter" "mcp" "plugins" "library") +ALL_MODULES=("frontend" "backend" "common" "render-wasm" "exporter" "mcp" "plugins" "library" "media-processor") # Module commands declare -A LINT_CMD=( @@ -36,6 +36,7 @@ declare -A LINT_CMD=( [mcp]="" [plugins]="pnpm run lint" [library]="pnpm run lint" + [media-processor]="" ) declare -A TEST_CMD=( @@ -47,6 +48,7 @@ declare -A TEST_CMD=( [mcp]="pnpm run test" [plugins]="pnpm run test" [library]="pnpm run test" + [media-processor]="pnpm run test" ) declare -A FMT_CHECK_CMD=( @@ -58,6 +60,7 @@ declare -A FMT_CHECK_CMD=( [mcp]="pnpm run fmt:check" [plugins]="pnpm run format:check" [library]="pnpm run check-fmt:clj" + [media-processor]="pnpm run fmt:check" ) declare -A FMT_FIX_CMD=( @@ -69,6 +72,19 @@ declare -A FMT_FIX_CMD=( [mcp]="pnpm run fmt" [plugins]="pnpm run format" [library]="pnpm run fmt:clj" + [media-processor]="pnpm run fmt" +) + +declare -A BUILD_CMD=( + [frontend]="NODE_ENV=production pnpm run build:app:main && pnpm run build:app:libs && pnpm run build:app:assets" + [backend]="clojure -T:build jar" + [common]="" + [render-wasm]="./build" + [exporter]="NODE_ENV=production pnpm run build:app" + [mcp]="pnpm run build" + [plugins]="pnpm run build:runtime && pnpm run build:plugins && pnpm run build:styles-example && pnpm run build:doc" + [library]="NODE_ENV=production clojure -M:dev:shadow-cljs release library" + [media-processor]="pnpm run types:check && pnpm run build" ) declare -A PAREN_REPAIR_CMD=( @@ -80,6 +96,7 @@ declare -A PAREN_REPAIR_CMD=( [mcp]="" [plugins]="" [library]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" + [media-processor]="" ) # Default options @@ -197,6 +214,9 @@ run_module() { cmd="${FMT_CHECK_CMD[$module]:-}" fi ;; + build) + cmd="${BUILD_CMD[$module]:-}" + ;; paren-repair) cmd="${PAREN_REPAIR_CMD[$module]:-}" ;; @@ -282,8 +302,8 @@ Usage: $(basename "$0") [OPTIONS] [MODULES...] CI orchestration script for Penpot monorepo. MODULES: - frontend, backend, common, render-wasm, exporter, mcp, plugins, library - Use --all to run all modules. + frontend, backend, common, render-wasm, exporter, mcp, plugins, library, + media-processor. Use --all to run all modules. OPTIONS: --all Run all modules @@ -294,6 +314,8 @@ OPTIONS: --no-test Skip tests --fmt Run format check only --no-fmt Skip format check + --build Run production build only + --no-build Skip build --fix Run format fix instead of check --paren-repair Fix delimiter errors in Clojure/CLJS files --fail-fast Stop on first failure @@ -307,6 +329,8 @@ EXAMPLES: $(basename "$0") frontend backend # Run lint, test, fmt on frontend and backend $(basename "$0") --all --no-test # Run lint and fmt on all modules $(basename "$0") --lint common # Run only lint on common + $(basename "$0") --build frontend # Run only the production build of frontend + $(basename "$0") --build --all # Build every module $(basename "$0") --fix frontend # Fix formatting in frontend $(basename "$0") --paren-repair frontend # Fix delimiter errors in frontend $(basename "$0") --paren-repair --all # Fix delimiter errors in all Clojure modules @@ -353,6 +377,14 @@ main() { TASKS=("${TASKS[@]/fmt/}") shift ;; + --build) + TASKS=("build") + shift + ;; + --no-build) + TASKS=("${TASKS[@]/build/}") + shift + ;; --paren-repair) TASKS=("paren-repair") shift diff --git a/scripts/sync-pnpm-version b/scripts/sync-pnpm-version index e87cf17500..149b072955 100755 --- a/scripts/sync-pnpm-version +++ b/scripts/sync-pnpm-version @@ -27,7 +27,8 @@ Options: -h, --help Show this help. external/ (vendored trees with their own lifecycles), .opencode/, -.pnpm-store/, bundles/ and docker build-context copies are never stamped. +.pnpm-store/, bundles/, build output (target/) and docker build-context +copies are never stamped. EOF } @@ -91,7 +92,7 @@ mapfile -t files < <( find . \ \( -name .git -o -name node_modules -o -name .pnpm-store \ -o -name external -o -name .opencode -o -name bundles \ - -o -name .angular \) -type d -prune \ + -o -name target -o -name .angular \) -type d -prune \ -o -path './docker/images/bundle-*' -prune \ -o -name package.json -type f -print | sort ) diff --git a/scripts/test_ci.py b/scripts/test_ci.py new file mode 100644 index 0000000000..405f385d8e --- /dev/null +++ b/scripts/test_ci.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Tests for scripts/ci. + +Run with: + + python3 scripts/test_ci.py + +Covers the wiring of the orchestrator around the `build` task and the +`media-processor` module: the flag set, one build command per module, the +help text, the dry-run mode (which must execute and write nothing), and the +default task list staying free of build. +""" + +import pathlib +import re +import subprocess +import sys +import unittest + +sys.dont_write_bytecode = True + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +CI = REPO_ROOT / "scripts" / "ci" +LOG_DIR = REPO_ROOT / ".ci-logs" + +ANSI = re.compile(r"\x1b\[[0-9;]*m") + +# Modules with a production build. `common` has no build of its own: its +# compilation happens in its test task and in the modules consuming it. +BUILD_MODULES = ( + "frontend", + "backend", + "render-wasm", + "exporter", + "mcp", + "plugins", + "library", + "media-processor", +) + +ALL_MODULES = BUILD_MODULES + ("common",) + + +def run_ci(*args, cwd=REPO_ROOT): + """Run scripts/ci and return (exit_code, output without ANSI escapes).""" + proc = subprocess.run( + [str(CI), *args], + cwd=cwd, + capture_output=True, + text=True, + ) + return proc.returncode, ANSI.sub("", proc.stdout + proc.stderr) + + +def log_snapshot(): + """Names and mtimes of every entry under .ci-logs, or None when absent.""" + if not LOG_DIR.exists(): + return None + return sorted((p.name, p.stat().st_mtime_ns) for p in LOG_DIR.iterdir()) + + +class BuildTaskTests(unittest.TestCase): + def test_build_all_has_one_command_per_module_without_common(self): + code, out = run_ci("--build", "--dry-run", "--all") + + self.assertEqual(code, 0, out) + self.assertEqual(out.count("build: would run"), len(BUILD_MODULES), out) + self.assertIn("build: not defined for common, skipping", out) + for module in BUILD_MODULES: + self.assertIn(f" in {module}/", out) + + def test_build_is_not_part_of_the_default_tasks(self): + code, out = run_ci("--dry-run", "--all") + + self.assertEqual(code, 0, out) + self.assertNotIn("build:", out) + for task in ("lint", "test", "fmt"): + self.assertIn(f"{task}: would run", out) + + def test_build_flag_runs_build_only(self): + code, out = run_ci("--build", "--dry-run", "frontend") + + self.assertEqual(code, 0, out) + self.assertIn("build: would run", out) + for task in ("lint", "test", "fmt"): + self.assertNotIn(f"{task}:", out) + + def test_no_build_flag_is_accepted(self): + code, out = run_ci("--no-build", "--dry-run", "frontend") + + self.assertEqual(code, 0, out) + self.assertIn("lint: would run", out) + self.assertNotIn("build:", out) + + def test_dry_run_executes_nothing(self): + before = log_snapshot() + + run_ci("--build", "--dry-run", "mcp") + + self.assertEqual(before, log_snapshot()) + + +class MediaProcessorModuleTests(unittest.TestCase): + def test_module_is_accepted_and_lint_is_skipped(self): + code, out = run_ci("--dry-run", "media-processor") + + self.assertEqual(code, 0, out) + self.assertIn( + "lint: not defined for media-processor, skipping", + out, + ) + for task in ("test", "fmt"): + self.assertIn(f"{task}: would run", out) + + def test_module_is_included_in_all(self): + code, out = run_ci("--dry-run", "--all") + + self.assertEqual(code, 0, out) + self.assertIn("media-processor", out) + self.assertIn("media-processor:test", out) + + def test_build_runs_types_check_before_esbuild(self): + code, out = run_ci("--build", "--dry-run", "media-processor") + + self.assertEqual(code, 0, out) + self.assertIn( + "pnpm run types:check && pnpm run build", + out, + ) + + +class HelpTests(unittest.TestCase): + def help_options(self, out): + """Option tokens from the OPTIONS section, e.g. '--build'.""" + options = [] + for line in out.splitlines(): + stripped = line.strip() + if stripped.startswith("--"): + options.append(stripped.split()[0]) + return options + + def test_help_documents_the_build_flags(self): + code, out = run_ci("--help") + + self.assertEqual(code, 0, out) + options = self.help_options(out) + self.assertIn("--build", options) + self.assertIn("--no-build", options) + + def test_help_lists_every_module(self): + code, out = run_ci("--help") + + self.assertEqual(code, 0, out) + for module in ALL_MODULES: + self.assertIn(module, out) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_sync_pnpm_version.py b/scripts/test_sync_pnpm_version.py index 889250f433..91089632f2 100644 --- a/scripts/test_sync_pnpm_version.py +++ b/scripts/test_sync_pnpm_version.py @@ -134,6 +134,23 @@ class SyncPnpmVersionTests(unittest.TestCase): self.assertNotEqual(proc.returncode, 0) self.assertIn("missing/package.json", proc.stdout + proc.stderr) + def test_target_dirs_are_never_stamped(self): + generated = self.tmp / "module" / "target" + generated.mkdir(parents=True) + package = generated / "package.json" + package.write_text( + '{\n' + ' "name": "generated",\n' + ' "packageManager": "pnpm@10.0.0+sha512.' + "00" * 64 + '"\n' + '}\n' + ) + before = package.read_bytes() + + self.assertEqual(self.stamp().returncode, 0) + self.assertEqual(package.read_bytes(), before) + # The stale copy must not make --check fail either. + self.assertEqual(self.stamp("--check").returncode, 0) + def test_stamp_is_idempotent(self): self.assertEqual(self.stamp().returncode, 0) first = {