diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index dcd0192f0..b01af0524 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,13 +1,12 @@ # Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner -# (GitHub has no FreeBSD runners). -# It then runs packaging/freebsd/package.sh, the script the release job runs, and checks the -# tarball, so a broken FreeBSD package fails here before it can fail a release. Nothing is -# uploaded and no secrets are used. +# (GitHub has no FreeBSD runners). It then runs packaging/freebsd/package.sh, the script +# the release job runs, and checks the tarball, so a broken FreeBSD package fails here +# before it can fail a release. Nothing is uploaded and no secrets are used. name: FreeBSD -# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push -# or dependency change: a drifted dependency still shows up within a day, and always before a -# release ships. PRs that change the FreeBSD packaging itself still run it. +# The VM build takes one to several hours, so it runs nightly on main rather than on every push +# or dependency change: a drifted dependency still shows up within a day. PRs that change the +# FreeBSD packaging itself still run it. on: schedule: - cron: "17 2 * * *" @@ -29,36 +28,8 @@ permissions: jobs: freebsd: - name: FreeBSD 14 (build + test + package) - runs-on: ubuntu-latest - timeout-minutes: 300 - steps: - - uses: actions/checkout@v4 - - name: Build, test and package in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Nothing to bring back; copying the target dir out of the VM fails on large builds. - copyback: false - # dejavu: the text tests need one real font, as the Ubuntu runner has. - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 dejavu - curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal - run: | - set -e - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - rustc --version - CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked - CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked - # The release package, as release.yml builds it. Free the debug build's disk first. - rm -rf target/debug - CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh - tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt" - for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do - grep -E "$f" "$HOME/freebsd-contents.txt" - done + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main + with: + run-package-check: true + craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 000000000..01cb9cd66 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,23 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 26fad8759..386989022 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -19,58 +19,7 @@ concurrency: jobs: lint: - timeout-minutes: 15 - runs-on: ubuntu-latest - env: - ACTIONLINT_VERSION: 1.7.12 - steps: - - uses: actions/checkout@v4 - - name: Install tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ - | tar -xz -C "$RUNNER_TEMP" actionlint - - name: actionlint - run: | - "$RUNNER_TEMP/actionlint" -color - - name: shellcheck - run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun - - name: Server install script (Caddyfile handling) - run: bash packaging/server/install-test.sh - - name: AppImage AppRun desktop integration - run: bash packaging/linux/apprun-test.sh - - name: PowerShell - shell: pwsh - run: | - foreach ($f in Get-ChildItem packaging/windows/*.ps1) { - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null - if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } - Write-Output "ok $($f.Name)" - } - ./packaging/windows/check-icons.ps1 - - name: FreeBSD package layout (dry run, no build) - run: bash packaging/freebsd/package.sh --dry-run - - name: No template leftovers from other apps in package metadata - run: | - if grep -rniE 'photoshop|psd|photocraft|vectorcraft|illustrator|adobe|figma' packaging/ .github/ --exclude=packaging-lint.yml; then - echo "::error::package metadata mentions another product (see above)" - exit 1 - fi - - name: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/figcraft.wxs packaging/windows/figcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml* - desktop-file-validate packaging/linux/ai.storyteller.figcraft.desktop - sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.figcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml" - appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml")) - for m in (src, bundle): - assert m["id"] == "ai.storyteller.figcraft" and m["command"] == "figcraft", m["id"] - for key in ("runtime", "runtime-version", "sdk", "finish-args"): - assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" - print("Flatpak manifests ok") - EOF + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + with: + extra-checks-script: packaging/check-app-specific.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddf622d7b..75f7a5fae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,3 @@ -# Release pipeline: every push to `release` builds signed installers for every platform and -# creates or updates the draft GitHub Release "FigCraft v". See docs/releasing.md. -# -# Only the jobs that read secrets (macOS and Windows signing, and the draft release) run in the -# `release` environment (restricted to the `release` branch), which holds the signing secrets; the -# rest build on any branch, so `gh workflow run release.yml --ref ` is a real dry run. -# Every secret is optional: missing ones produce unsigned artifacts and a warning, never a failed -# build. Playbook: storytold/craftrules release/playbook.md. name: Release on: @@ -19,543 +11,34 @@ on: default: "" permissions: - contents: read + contents: write concurrency: group: release-${{ github.ref }} - # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - FIGCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - # craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release - # embeds (Inter and the Latin presentation fonts from fonts/latin-manifest.txt; the web build - # embeds Inter only, see crates/text/build.rs). An optional build input for everyone else. - # crates/text/build.rs needs fonts/latin-manifest.txt, so the pin must include it. Bump - # deliberately; every build job checks out this commit. - CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 - jobs: - version: - timeout-minutes: 10 - name: version - runs-on: ubuntu-latest - # No `environment: release`: this job reads no secrets, so a branch dry run can run it. - outputs: - version: ${{ steps.v.outputs.version }} - date: ${{ steps.v.outputs.date }} - prerelease: ${{ steps.v.outputs.prerelease }} - steps: - - uses: actions/checkout@v4 - - id: v - name: Resolve version - run: | - . packaging/env.sh - v="${INPUT_VERSION#v}" - v="${v:-$VERSION}" - if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" - exit 1 - fi - case "$v" in *-*) pre=true ;; *) pre=false ;; esac - { - echo "version=$v" - echo "date=$(date -u +%Y-%m-%d)" - echo "prerelease=$pre" - } >>"$GITHUB_OUTPUT" - echo "### FigCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - timeout-minutes: 90 - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@v2 - with: - key: release-macos-universal - - name: Apply version override - if: inputs.version != '' - run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - - name: Import signing certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: packaging/macos/import-cert.sh - - name: Build, sign, notarize - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/macos/package.sh --arch universal - # Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime, - # timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's - # stapled ticket. Strict when the signing secrets are present, warnings otherwise. - - name: Verify signatures and notarization - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: packaging/macos/verify.sh --arch universal - - uses: actions/upload-artifact@v4 - with: - name: release-macos - path: dist/release/* - if-no-files-found: error - - name: Remove temporary keychain - if: always() - run: | - if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi - - windows: - timeout-minutes: 90 - name: Windows ${{ matrix.arch }} - needs: version - runs-on: windows-latest - environment: release - strategy: - fail-fast: false - matrix: - include: - - { arch: x64, target: x86_64-pc-windows-msvc } - - { arch: x86, target: i686-pc-windows-msvc } - # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. - # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. - - { arch: arm64, target: aarch64-pc-windows-msvc } - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 - with: - key: release-windows-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - shell: bash - run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - - name: Install WiX - shell: pwsh - run: | - dotnet tool install --global wix --version 5.0.2 - wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 - - name: Build, sign, package - shell: pwsh - env: - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} - AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@v4 - with: - name: release-windows-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux: - timeout-minutes: 90 - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job reads no secrets, so a branch dry run can run it. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } - - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - steps: - - uses: actions/checkout@v4 - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - name: Install build and packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ - desktop-file-utils appstream python3-yaml zsync - curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ - "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" - sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - - name: Build and package (AppImage, deb, rpm, tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/linux/package.sh - - name: Check the packages - run: | - dpkg-deb --info dist/release/*.deb - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/figcraft$|metainfo|applications' - ldd target/release/figcraft - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.figcraft.yml')); assert m['id'] == 'ai.storyteller.figcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@v4 - with: - name: release-linux-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - server: - timeout-minutes: 90 - name: Server Linux ${{ matrix.arch }} - needs: version - # The headless collaboration server (docs/server.md): figcraft-server--linux-.tar.gz - # with the binary, licences, docs/server.md and the deployment examples. Same runners as the - # app, so the same glibc floor; no craft-fonts (the server draws no text). - # shortcut: Linux only (where servers run); add macOS/Windows archives, signed like figcraft-cli, - # when people host there. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job reads no secrets, so a branch dry run can run it. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04 } - - { arch: aarch64, runner: ubuntu-22.04-arm } - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: release-server-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - - name: Build and package (tar.gz) - run: packaging/server/package.sh - - name: Check the archive - run: | - tar -tzf "dist/release/figcraft-server-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" | tee "$RUNNER_TEMP/server-contents.txt" - for f in '/figcraft-server$' '/figcraft-server\.service$' '/Caddyfile$' '/nginx\.conf$' '/install\.sh$' '/figcraft-backup\.timer$' '/server\.md$' '/LICENSE-MIT$'; do - grep -E "$f" "$RUNNER_TEMP/server-contents.txt" - done - ldd target/release/figcraft-server - - uses: actions/upload-artifact@v4 - with: - name: release-server-linux-${{ matrix.arch }} - path: dist/release/*.tar.gz - if-no-files-found: error - - flatpak: - timeout-minutes: 90 - name: Flatpak ${{ matrix.arch }} - needs: [version, linux] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Build the Flatpak bundle - run: packaging/linux/flatpak-bundle.sh "dist/linux/figcraft-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@v4 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - freebsd: - name: FreeBSD x86_64 - needs: version - # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as - # .github/workflows/freebsd.yml. A private repo's ubuntu-latest has 2 vCPUs and 7 GB, too small - # for this VM (QEMU "cannot set up guest memory"), so it runs on the org's 16-core larger runner. - runs-on: github-ubuntu-16core - timeout-minutes: 180 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - name: Build and package in a FreeBSD VM (tar.gz) - # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Passed into the VM by name; never templated into the script below. - envs: "FIGCRAFT_VERSION FIGCRAFT_BUILD_DATE FIGCRAFT_BUILD_SHA INPUT_VERSION" - # The FreeBSD CI packages, plus bash for the packaging script. - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - run: | - set -e - . "$HOME/.cargo/env" - git config --global --add safe.directory "$PWD" - # Build outside the checkout so only dist/ is copied back out of the VM (copying the - # target dir back fails on large builds). 4 jobs: more runs the VM out of memory. - export CARGO_TARGET_DIR=/var/tmp/figcraft-target CARGO_BUILD_JOBS=4 - if [ -n "$INPUT_VERSION" ]; then cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"; fi - # craft-fonts was checked out next to the sources and synced into the VM with them. - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - bash packaging/freebsd/package.sh - tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt" - for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do - grep -E "$f" "$HOME/freebsd-contents.txt" - done - - uses: actions/upload-artifact@v4 - with: - name: release-freebsd-x86_64 - path: dist/release/* - if-no-files-found: error - - web: - timeout-minutes: 90 - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - # No `environment: release`: this job reads no secrets, so a branch dry run can run it. - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - TRUNK_VERSION: 0.21.14 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - with: - key: release-web - - name: Install trunk - run: | - curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ - | tar -xz -C "$HOME/.cargo/bin" trunk - trunk --version - - name: Apply version override - if: inputs.version != '' - run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - # The wasm32 build embeds only Inter from craft-fonts (crates/text/build.rs): browsers have - # no system fonts to fall back on, and the rest would push the wasm towards - # packaging/web/package.sh's 24 MiB gate. - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - name: Build and zip - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/web/package.sh - - uses: actions/upload-artifact@v4 - with: - name: release-web - path: dist/release/* - if-no-files-found: error - - linux-riscv64: - timeout-minutes: 90 - name: Linux riscv64 - needs: version - # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release - # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's - # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the - # CLI is smoke-tested under QEMU. - runs-on: ubuntu-24.04 - # No `environment: release`: this job reads no secrets, so a branch dry run can run it. - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - uses: dtolnay/rust-toolchain@stable - with: - targets: riscv64gc-unknown-linux-gnu - - uses: Swatinem/rust-cache@v2 - with: - key: release-linux-riscv64 - - name: Apply version override - if: inputs.version != '' - run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - - name: Install cross-compilation dependencies - run: | - . /etc/os-release - sudo dpkg --add-architecture riscv64 - # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources - fi - if [ -f /etc/apt/sources.list ]; then - sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list - sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list - fi - for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do - echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" - done | sudo tee /etc/apt/sources.list.d/riscv64.list - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ - libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ - libxi-dev:riscv64 libgl1-mesa-dev:riscv64 - - name: Build and package (tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - PKG_CONFIG_SYSROOT_DIR: / - PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig - PKG_CONFIG_ALLOW_CROSS: "1" - CROSS_ARCH: riscv64 - CROSS_TARGET: riscv64gc-unknown-linux-gnu - CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64 - QEMU_LD_PREFIX: /usr/riscv64-linux-gnu - run: packaging/linux/package.sh --formats tar - - uses: actions/upload-artifact@v4 - with: - name: release-linux-riscv64 - path: dist/release/*.tar.gz - if-no-files-found: error - release: - timeout-minutes: 10 - name: Draft GitHub Release - needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web, server] - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - env: - VERSION: ${{ needs.version.outputs.version }} - PRERELEASE: ${{ needs.version.outputs.prerelease }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - steps: - - uses: actions/download-artifact@v4 - with: - pattern: release-* - path: artifacts - merge-multiple: true - - name: Checksums - working-directory: artifacts - run: | - sha256sum -- * | tee SHA256SUMS.txt - - name: Create or update the draft release - working-directory: artifacts - run: | - tag="v$VERSION" - title="FigCraft v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi - if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - if [ "$draft" != "true" ]; then - echo "::error::Release $tag is already published. Bump the version (cargo run -p xtask -- version set X.Y.Z) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/release.yml@main + with: + app-name: figcraft + app-title: FigCraft + craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + has-server: true + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index a289df720..4cceb9225 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -24,77 +24,10 @@ concurrency: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - jobs: - package: - name: package (cross-compiled on x64) - runs-on: windows-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable - with: - targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - key: windows-arm64-package - - name: Install WiX - shell: pwsh - run: | - dotnet tool install --global wix --version 5.0.2 - wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 - - name: Build and package (unsigned) - shell: pwsh - env: - DIST: ${{ github.workspace }}\dist\arm64 - run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@v4 - with: - name: figcraft-windows-arm64 - path: dist/arm64/* - if-no-files-found: error - retention-days: 7 - - install: - name: install and run on ARM64 - needs: package - runs-on: windows-11-arm - timeout-minutes: 30 - steps: - - uses: actions/download-artifact@v4 - with: - name: figcraft-windows-arm64 - path: dist - - name: Install the MSI, check it, run it, uninstall it - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } - $dir = Join-Path $env:ProgramFiles 'FigCraft' - # figcraft-cli.exe ships once apps/figcraft-cli exists; check it when installed. - foreach ($exe in 'figcraft.exe', 'figcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { - if ($exe -eq 'figcraft-cli.exe') { Write-Output "skip $exe (not in this build)"; continue } - throw "$exe not installed in $dir" - } - $bytes = [System.IO.File]::ReadAllBytes($path) - $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - } - # The installed binary runs natively on ARM64 (the CLI when present, else the app's --version). - $cli = Join-Path $dir 'figcraft-cli.exe' - if (-not (Test-Path $cli)) { $cli = Join-Path $dir 'figcraft.exe' } - $out = & $cli --version 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "$cli --version exited $LASTEXITCODE" } - Write-Output "ok $(Split-Path -Leaf $cli) --version: $($out.Trim())" - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path (Join-Path $dir 'figcraft.exe')) { throw 'uninstall left figcraft.exe behind' } - Write-Output 'ok uninstalled' + windows-arm64: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + with: + app-name: figcraft + app-dir: FigCraft diff --git a/Cargo.lock b/Cargo.lock index aead39d1f..86bccfa35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,6 +259,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -855,6 +856,11 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" + [[package]] name = "crc32fast" version = "1.5.2" @@ -1407,7 +1413,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.6.0" +version = "0.7.0" dependencies = [ "arboard", "eframe", @@ -1421,17 +1427,20 @@ dependencies = [ "fmv-macos-events", "image", "log", + "raw-window-handle", "rfd", "serde_json", "wat", + "wayland-client", "webbrowser", "windows", + "winit", "winresource", ] [[package]] name = "figcraft-cli" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1445,7 +1454,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1460,7 +1469,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1483,7 +1492,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.6.0" +version = "0.7.0" dependencies = [ "kurbo", "serde", @@ -1492,7 +1501,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1504,9 +1513,10 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", + "craft-docpath", "figcraft-collab", "figcraft-color", "figcraft-doc", @@ -1531,7 +1541,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1546,7 +1556,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.6.0" +version = "0.7.0" dependencies = [ "kurbo", "serde", @@ -1554,7 +1564,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1562,7 +1572,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1578,7 +1588,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1587,7 +1597,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1599,12 +1609,12 @@ dependencies = [ "krilla", "log", "thiserror 2.0.21", - "tiny-skia", + "tiny-skia 0.12.0", ] [[package]] name = "figcraft-plugin" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-color", "serde", @@ -1617,7 +1627,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.6.0" +version = "0.7.0" dependencies = [ "crc32fast", "figcraft-color", @@ -1630,12 +1640,12 @@ dependencies = [ "moxcms", "png", "rayon", - "tiny-skia", + "tiny-skia 0.12.0", ] [[package]] name = "figcraft-server" -version = "0.6.0" +version = "0.7.0" dependencies = [ "crc32fast", "ctrlc", @@ -1659,7 +1669,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1675,7 +1685,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1689,7 +1699,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "egui", @@ -1714,7 +1724,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.6.0" +version = "0.7.0" dependencies = [ "eframe", "egui", @@ -1734,6 +1744,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -2563,7 +2579,7 @@ dependencies = [ "skrifa 0.42.1", "smallvec", "subsetter", - "tiny-skia-path", + "tiny-skia-path 0.12.0", "xmp-writer", "yoke", "zune-jpeg", @@ -2820,6 +2836,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3204,6 +3229,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "parking" version = "2.2.1" @@ -3264,6 +3299,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -3981,6 +4027,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "log", + "smithay-client-toolkit 0.19.2", + "tiny-skia 0.11.4", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -4440,6 +4497,20 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path 0.11.4", +] + [[package]] name = "tiny-skia" version = "0.12.0" @@ -4452,7 +4523,18 @@ dependencies = [ "cfg-if", "log", "png", - "tiny-skia-path", + "tiny-skia-path 0.12.0", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", ] [[package]] @@ -4574,6 +4656,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4730,7 +4823,7 @@ dependencies = [ "skrifa 0.44.0", "strict-num", "svgtypes", - "tiny-skia-path", + "tiny-skia-path 0.12.0", "unicode-bidi", "unicode-script", "unicode-vo", @@ -5683,6 +5776,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "sctk-adwaita", "smithay-client-toolkit 0.19.2", "smol_str", "tracing", @@ -5726,6 +5820,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "write-fonts" version = "0.48.1" @@ -5822,7 +5934,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 8f125ac3e..932da46f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.6.0" +version = "0.7.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -32,6 +32,9 @@ figcraft-mcp = { path = "crates/mcp" } figcraft-collab-net = { path = "crates/collab-net" } figcraft-plugin = { path = "crates/plugin" } +# Which paths stored in a document may be followed (craft-libs). +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } + # foundation kurbo = { version = "0.13", features = ["serde"] } thiserror = "2" diff --git a/NOTICE b/NOTICE index 9aaba825b..a0baaa279 100644 --- a/NOTICE +++ b/NOTICE @@ -20,3 +20,34 @@ licence texts. Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, ISC, Zlib, OFL for egui's default fonts, CDLA-Permissive-2.0 for the root certificate list webpki-roots compiles in, and similar permissive licenses); see each crate's metadata. + +Linux Wayland window decorations +------------------------------- +sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita, +commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux +for vector window controls. Its title-font features are disabled: no Cantarell +font is embedded by this dependency and no font file is added to this repo. + +sctk-adwaita's MIT licence: + +MIT License + +Copyright (c) 2022 Bartłomiej Maryńczak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0910bf9e7..a072a06fe 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. - **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). -- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). +- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). - **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session. diff --git a/ROADMAP.md b/ROADMAP.md index 7aa0b5f70..f3c4bb14b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Outline stroke preserves stroke holes; bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Outline stroke preserves stroke holes; community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -13,7 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md). | | Value | Kind | |---|---|---| -| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | +| Menu/command breadth | **100%** (212 / 212) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | | Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | | Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) | | Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | @@ -99,25 +99,25 @@ native speakers' reviews of the six language drafts need people. | Language | Code | Strings | Status | |---|---|---|---| | English | en | 100% | full | -| Simplified Chinese | zh-Hans | 100% | draft (needs native review) | -| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) | +| Simplified Chinese | zh-Hans | 95% | draft (needs native review) | +| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) | | Hindi | hi | 0% | none | | Arabic | ar | 0% | none | -| French | fr | 100% | draft (needs native review) | -| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) | -| Indonesian | id | 0% | none | -| Japanese | ja | 100% | draft (needs native review) | -| German | de | 100% | draft (needs native review) | -| Korean | ko | 100% | draft (needs native review) | +| French | fr | 95% | draft (needs native review) | +| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) | +| Indonesian | id | 99% | draft (needs native review) | +| Japanese | ja | 99% | draft (needs native review) | +| German | de | 96% | draft (needs native review) | +| Korean | ko | 95% | draft (needs native review) | | Vietnamese | vi | 0% | none | -Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ +French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, -IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md). +IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md). ## Upcoming @@ -131,6 +131,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. +- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. +- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. - **2026-10-15** · M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. @@ -315,6 +318,13 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | +| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | +| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | +| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | +| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | +| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | +| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | | 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. | diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index 372082c24..4c162dbb5 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -1,5 +1,6 @@ //! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value` -//! options, and boolean `--flag`s. Unknown options are usage errors. +//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s. +//! Unknown options are usage errors. use std::collections::BTreeMap; @@ -8,7 +9,7 @@ use crate::{CliError, CliResult}; #[derive(Debug, Default)] pub struct Args { pub positional: Vec, - values: BTreeMap, + values: BTreeMap>, flags: Vec, } @@ -34,7 +35,7 @@ impl Args { Some(v) => v, None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?, }; - a.values.insert(name.to_string(), v); + a.values.entry(name.to_string()).or_default().push(v); } else if flags.contains(&name) && inline.is_none() { a.flags.push(name.to_string()); } else { @@ -45,7 +46,12 @@ impl Args { } pub fn value(&self, k: &str) -> Option { - self.values.get(k).cloned() + self.values.get(k).and_then(|v| v.last()).cloned() + } + + /// Every value of an option that may be given more than once, in order. + pub fn values(&self, k: &str) -> &[String] { + self.values.get(k).map_or(&[], Vec::as_slice) } pub fn flag(&self, k: &str) -> bool { @@ -62,9 +68,9 @@ impl Args { /// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`, /// `--bleed-marks`, `--cmyk`. pub fn print(&self) -> CliResult { - let bleed = match self.values.get("bleed") { + let bleed = match self.value("bleed") { None => 0.0, - Some(s) => figcraft_doc::PrintSettings::parse_length(s) + Some(s) => figcraft_doc::PrintSettings::parse_length(&s) .filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED) .ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?, }; @@ -72,7 +78,7 @@ impl Args { } pub fn scale(&self) -> CliResult { - match self.values.get("scale") { + match self.value("scale") { None => Ok(1.0), Some(s) => s .trim_end_matches('x') @@ -85,7 +91,7 @@ impl Args { /// A comma-separated list of whole numbers (`--layers 1000,10_000`). pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult> { - match self.values.get(k) { + match self.value(k) { None => Ok(default.to_vec()), Some(s) => s .split(',') @@ -110,6 +116,10 @@ mod tests { assert_eq!(a.value("o").as_deref(), Some("out")); assert_eq!(a.scale().unwrap(), 2.0); assert!(a.flag("json")); + let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap(); + assert_eq!(a.values("root"), ["a", "b"]); + assert_eq!(a.value("root").as_deref(), Some("b")); + assert!(a.values("none").is_empty()); } #[test] diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 4fb485e10..65b19ee46 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -155,6 +155,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult { Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames})) }) .collect(); + let missing = figcraft_engine::fonts::missing(&doc); let v = json!({ "file": path, "name": doc.name, @@ -164,6 +165,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult { "variables": doc.variables.variables.len(), "images": doc.images.len(), "notes": notes, + "missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>(), }); if as_json { println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); @@ -187,6 +189,10 @@ pub fn info(path: &str, as_json: bool) -> CliResult { println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]); } } + for m in &missing { + let layers = if m.layers == 1 { "text layer" } else { "text layers" }; + println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers); + } Ok(ExitCode::SUCCESS) } @@ -333,17 +339,32 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe } None => Session::new(), }; - for (i, (cmd, params)) in steps.iter().enumerate() { - let r = s.execute(cmd, params); - s.ui_requests.clear(); - match r { - Ok(v) => println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})), - Err(e) => { - println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string()})); - eprintln!("error: step {i} ({cmd}) failed: {e}"); - return Ok(ExitCode::from(1)); - } + // One undo step and all-or-nothing, the way the same commands behave through `command.batch` + // (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and + // a step that fails takes the whole script back instead of leaving the earlier steps applied. + // The result lines are held back until the script is through, so none of them reports work that + // was rolled back. + let mut results: Vec = Vec::with_capacity(steps.len()); + let mut failed_at = 0; + let outcome = s.grouped(&format!("run {script}"), |s| { + for (i, (cmd, params)) in steps.iter().enumerate() { + failed_at = i; + let v = s.batch_step(&results, i, cmd, params)?; + s.ui_requests.clear(); + results.push(v); } + Ok(()) + }); + if let Err(e) = outcome { + let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str()); + let i = failed_at; + println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); + eprintln!("error: step {i} ({cmd}) failed: {e}"); + eprintln!("the script's document changes were rolled back"); + return Ok(ExitCode::from(1)); + } + for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() { + println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})); } if let Some(out) = out { let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?; @@ -422,4 +443,62 @@ mod tests { assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); std::fs::remove_dir_all(dir).unwrap(); } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("figcraft-cli-{tag}-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + dir + } + + /// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step + /// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it + /// as "no parent" and the layer landed on the page — with every step reporting success. + #[test] + fn a_script_puts_a_layer_in_a_layer_an_earlier_step_made() { + let dir = temp_dir("run-step"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "node.create", "params": {"type": "rectangle", "name": "child", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + + let mut s = Session::empty(); + s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap(); + let doc = s.doc().unwrap(); + let page = s.page().unwrap(); + let top = doc.node(page).unwrap().children.clone(); + assert_eq!(top.len(), 1, "only the frame is top level, not the rectangle too"); + assert_eq!(doc.node(top[0]).unwrap().name, "Card"); + let inner = doc.node(top[0]).unwrap().children.clone(); + assert_eq!(inner.len(), 1, "the rectangle went inside the frame"); + assert_eq!(doc.node(inner[0]).unwrap().name, "child"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// A step that fails takes the whole script back: nothing is written, and the failure says so. + #[test] + fn a_failed_step_leaves_no_output() { + let dir = temp_dir("run-rollback"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "node.set", "params": {"ids": [99999], "props": {"name": "nope"}}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + // The failure is the script's, not the call's: the CLI reports it and exits non-zero. + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + assert!(!out.exists(), "a script that failed writes nothing"); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 590b50f14..281d79ddc 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -23,7 +23,7 @@ USAGE: figcraft-cli render -o [--scale N] [--page NAME] figcraft-cli run [--in ] [--out ] figcraft-cli commands [--json] [--filter TEXT] - figcraft-cli mcp [--attach PORT] + figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... figcraft-cli plugin publish --server ws://host:port/[?key=KEY] figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]] figcraft-cli help | --help | --version @@ -42,6 +42,10 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) `plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder) to a figcraft-server's plugin directory as a new version; the server's key comes from the address or the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history). +`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders +must exist) confine the files the agent's commands touch: reads only inside a read or write root, +writes only inside a write root, everything else refused. A running app confines itself: start it +with `figcraft --control PORT` and the same flags (`--attach` refuses them). See docs/cli.md and docs/mcp.md."; fn main() -> ExitCode { @@ -133,11 +137,23 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "mcp" => { - let a = Args::parse(rest, &["attach"], &[])?; + let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?; if !a.positional.is_empty() { return Err(CliError::Usage("mcp takes no positional arguments".into())); } - mcp(a.value("attach").as_deref()) + let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root")); + let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) { + (true, _) => None, + // The app runs the commands: only its own roots can confine them. + (false, Some(_)) => { + return Err(CliError::Usage( + "--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)" + .into(), + )); + } + (false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?), + }; + mcp(a.value("attach").as_deref(), roots) } "plugin" => { let a = Args::parse(rest, &["server"], &[])?; @@ -205,7 +221,7 @@ fn publish_plugin(folder: &str, server: &str) -> CliResult { Ok(ExitCode::SUCCESS) } -fn mcp(attach: Option<&str>) -> CliResult { +fn mcp(attach: Option<&str>, roots: Option) -> CliResult { let backend: Box = match attach { Some(p) => { let addr = if p.contains(':') { @@ -220,7 +236,10 @@ fn mcp(attach: Option<&str>) -> CliResult { None => Box::new(figcraft_mcp::Headless::new()), }; eprintln!("figcraft MCP server ({}) on stdio", backend.describe()); - let mut server = figcraft_mcp::Server::new(backend); + if let Some(r) = &roots { + eprintln!("file access confined to {}", r.to_json()); + } + let mut server = figcraft_mcp::Server::new(backend).with_roots(roots); let stdin = std::io::stdin(); let stdout = std::io::stdout(); server.serve(stdin.lock(), stdout.lock()).map_err(|e| format!("stdio: {e}"))?; diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index 9dd522031..7eaf051f5 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -25,6 +25,9 @@ fn bad_arguments_exit_2_without_panicking() { &["render", "a", "-o"], &["perf", "--shapes", "lots"], &["mcp", "--attach", "notaport"], + // An attached app confines itself: roots go to `figcraft --control`. + &["mcp", "--attach", "7979", "--automation-read-root", "."], + &["mcp", "--automation-write-root"], &["convert", "a"], ] { let out = bin().args(args).output().unwrap(); @@ -120,6 +123,49 @@ fn mcp_over_stdio() { assert_eq!(lines[2]["result"]["isError"], false); } +#[test] +fn mcp_over_stdio_confined_to_automation_roots() { + let d = tmp("roots"); + for sub in ["in", "out", "outside"] { + std::fs::create_dir_all(d.join(sub)).unwrap(); + } + let p = |rel: &str| d.join(rel).to_string_lossy().into_owned(); + // A root that isn't a folder stops the server before it serves. + let out = bin().args(["mcp", "--automation-read-root", &p("missing")]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&out.stderr).contains("--automation-read-root")); + let mut child = bin() + .args(["mcp", "--automation-read-root", &p("in"), "--automation-write-root", &p("out")]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + let call = |id: u32, tool: &str, args: serde_json::Value| { + serde_json::json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string() + }; + writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap(); + writeln!(stdin, "{}", call(2, "create_node", serde_json::json!({"type": "ellipse"}))).unwrap(); + writeln!(stdin, "{}", call(3, "save_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap(); + writeln!(stdin, "{}", call(4, "save_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap(); + writeln!(stdin, "{}", call(5, "open_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap(); + writeln!(stdin, "{}", call(6, "open_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap(); + } + drop(child.stdin.take()); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + let lines: Vec = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines.len(), 6); + assert!(lines[0]["result"]["instructions"].as_str().unwrap().contains("only these folders may be read")); + let error = |i: usize| lines[i]["result"]["isError"] == true; + assert!(!error(1) && error(2) && !error(3) && error(4) && !error(5), "{lines:?}"); + assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("automation path rejected")); + assert!(d.join("out/a.figcraft").is_file() && !d.join("outside/a.figcraft").exists()); + let _ = std::fs::remove_dir_all(&d); +} + #[test] fn commands_json_lists_everything() { let out = bin().args(["commands", "--json"]).output().unwrap(); diff --git a/apps/figcraft-cli/tests/fonts.rs b/apps/figcraft-cli/tests/fonts.rs index c5901ac50..ef5c638fa 100644 --- a/apps/figcraft-cli/tests/fonts.rs +++ b/apps/figcraft-cli/tests/fonts.rs @@ -45,3 +45,25 @@ fn headless_runs_resolve_installed_families_unless_opted_out() { assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family"); assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back"); } + +#[test] +fn info_lists_the_fonts_a_file_uses_that_are_missing() { + let d = std::env::temp_dir().join(format!("figcraft-cli-missing-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let (script, file) = (d.join("make.json"), d.join("doc.figcraft")); + let text = + |family: &str| json!({"command": "text.create", "params": {"text": "Hamburgefonstiv", "x": 0, "y": 0, "props": {"fontFamily": family}}}); + std::fs::write(&script, json!([text("FigCraft Missing CLI"), text("FigCraft Missing CLI")]).to_string()).unwrap(); + let cli = || Command::new(env!("CARGO_BIN_EXE_figcraft-cli")); + let made = cli().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap(); + assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr)); + + let out = cli().arg("info").arg(&file).arg("--json").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["missingFonts"], json!([{"family": "FigCraft Missing CLI", "layers": 2}])); + + let out = cli().arg("info").arg(&file).output().unwrap(); + let shown = String::from_utf8_lossy(&out.stdout); + assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}"); +} diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index bd587b767..d5f4f761e 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -135,9 +135,23 @@ impl Config { /// abuse contact, or one whose clients would all count as its proxy). pub fn warnings(&self) -> Vec { let mut out = Vec::new(); + let client_max = NetLimits::default().max_server_message >> 20; + if usize::try_from(self.file_mb()).is_ok_and(|mb| mb >= client_max) { + out.push(format!( + "max_file_mb is {}: the app opens files of up to {} MiB, so a file that large can't be opened", + self.file_mb(), + client_max - 1 + )); + } if !self.open_publish { return out; } + if self.file_mb() > 64 { + out.push(format!( + "open_publish is on with max_file_mb = {}: anyone may send messages that large, and the memory set aside for reading and queueing them grows to eight times it; keep it at 64 or less on a public server", + self.file_mb() + )); + } if self.operator_contact.trim().is_empty() { out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into()); } @@ -168,7 +182,7 @@ impl Config { return Err("`idle_days` must be from 0 to 3650".into()); } let terms = self.terms_url.trim(); - if !terms.is_empty() && !(terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) { + if !(terms.is_empty() || terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) { return Err("`terms_url` must be a web address (https://…)".into()); } if self.operator_contact.chars().count() > 120 || self.operator_contact.chars().any(char::is_control) || terms.len() > 500 { @@ -253,8 +267,9 @@ impl Config { } } - /// Largest message a client may send when open to the public: a whole file (or plugin bundle) - /// and a margin, not the core's 64 MiB. + /// Largest message a client may send: a whole file (or plugin bundle) and a margin. Open to the + /// public this is also the cap (strangers' files stay small); a private server never goes below + /// the core's 64 MiB, and a `max_file_mb` above it raises the cap with it. fn open_message(&self) -> usize { usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX) } @@ -265,9 +280,9 @@ impl Config { fn core(&self) -> figcraft_collab::Limits { let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() }; if !self.open_publish { - return base; + return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base }; } - let max = self.open_message().min(base.max_message_bytes); + let max = self.open_message(); let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX); figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base } } @@ -279,21 +294,24 @@ impl Config { pub fn net(&self) -> NetLimits { let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() }; if !self.open_publish { - return base; + let max = base.max_client_message.max(self.open_message()); + return NetLimits { max_client_message: max, max_queued_bytes: base.max_queued_bytes.max(max.saturating_add(1 << 20)), ..base }; } - let max = self.open_message().min(base.max_client_message); + let max = self.open_message(); // Shared budgets: one block of addresses (an IPv4 address, an IPv6 /48) gets at most an - // eighth of each, and its biggest consumers are dropped first. - let (queued, reading) = (256usize << 20, 192usize << 20); + // eighth of each, and its biggest consumers are dropped first. Each still fits one whole + // message, or a file as large as `max_file_mb` could never arrive. + let one = max.saturating_add(1 << 20); + let (queued, reading) = ((256usize << 20).max(one.saturating_mul(8)), (192usize << 20).max(one.saturating_mul(8))); NetLimits { max_per_address: self.max_connections_per_ip, max_client_message: max, - max_queued_bytes: max.saturating_add(1 << 20), + max_queued_bytes: one, max_queued_block: queued / 8, max_queued_total: queued, - max_reading_block: max.saturating_add(1 << 20).min(reading / 8), + max_reading_block: one.min(reading / 8), max_reading_total: reading, - max_in_flight: 128 << 20, + max_in_flight: (128usize << 20).max(one), reserve_for_sessions: self.max_connections / 8, request_idle: std::time::Duration::from_secs(20), ..base diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs index f58f25b21..af6e0f990 100644 --- a/apps/figcraft-server/src/disk.rs +++ b/apps/figcraft-server/src/disk.rs @@ -8,8 +8,10 @@ //! length (u32 LE), CRC-32 (u32 LE), one [`Journal`] entry in the wire encoding. //! //! Compaction writes the next generation's snapshot, then an empty log of that generation. A log -//! whose generation isn't the snapshot's is from before a compaction that was cut short: what it -//! holds is in the snapshot already. A log ends at its first damaged record (a write cut short). +//! whose generation isn't the snapshot's is usually from before a compaction that was cut short +//! (what it holds is in the snapshot already), but no checksum covers either generation, so the +//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its +//! first damaged record (a write cut short). use std::fs::{self, File}; use std::io::{self, Read, Write}; diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index fea2667e9..978d9e8ee 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -413,7 +413,7 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() { assert_eq!(open.net().request_idle, Duration::from_secs(20), "idle one-shot requests go quickly"); assert_eq!(open.net().reserve_for_sessions, 512 / 8); assert_eq!(open.net().max_client_message, 17 << 20); - assert_eq!(private.net().max_client_message, NetLimits::default().max_client_message, "unchanged when private"); + assert_eq!(private.net().max_client_message, 65 << 20, "private: a 64 MiB file and a margin"); let w = open.warnings().join("\n"); assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}"); let set = Config { operator_contact: "abuse@example.com".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open }; @@ -493,3 +493,34 @@ fn open_connections_never_reach_what_needs_the_key() { assert!(!format!("{info:?}").contains(&k)); server.stop(); } + +#[test] +fn files_above_64_mib_fit_every_limit() { + // `max_file_mb` may go up to 4096: every cap a whole file's message meets on its way in must + // follow it, or the upload is dropped by the transport before the server can say why. + for open_publish in [false, true] { + let cfg = Config { open_publish, max_file_mb: Some(256), public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() }; + let (net, core, file) = (cfg.net(), cfg.store().core, 256usize << 20); + assert!(net.max_client_message > file, "open={open_publish}: {}", net.max_client_message); + assert!(net.max_queued_bytes > file && net.max_reading_block > file && net.max_in_flight > file, "open={open_publish}: {net:?}"); + assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block); + assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes); + } + // A client takes such a file too, but caps what any server may make it buffer at 1 GiB. + assert_eq!(NetLimits::default().max_server_message, 1 << 30); +} + +#[test] +fn large_files_warn_the_operator() { + let public_url = Some("wss://figcraft.example.com/".into()); + let warns = |open_publish, mb| { + Config { open_publish, max_file_mb: Some(mb), public_url: public_url.clone(), ..Config::default() }.warnings().join( + " +", + ) + }; + assert!(!warns(true, 64).contains("max_file_mb"), "{}", warns(true, 64)); + assert!(warns(true, 65).contains("open_publish is on with max_file_mb = 65"), "{}", warns(true, 65)); + assert!(!warns(false, 1023).contains("max_file_mb"), "{}", warns(false, 1023)); + assert!(warns(false, 1024).contains("can't be opened"), "{}", warns(false, 1024)); +} diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 538adefff..ea9bc626c 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -440,7 +440,7 @@ impl Store { && let Ok(log) = disk::read_log(&bytes) && log.generation != generation { - log::info!("file at {}: its log predates the last compaction; it is ignored", path.display()); + log::info!("file at {}: its log isn't of its snapshot's generation; it is replayed over it when the file opens", path.display()); } let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum(); Ok(Entry { meta, links, log: None, bytes, forced: 0 }) @@ -1126,8 +1126,32 @@ impl Store { fresh_log = true; } } - // From before a compaction that was cut short: the snapshot has it all. - Ok(_) => {} + // From before a compaction that was cut short, the snapshot has it all; or a generation + // field damaged, which no checksum covers. Replaying tells them apart without trusting + // it: entries the snapshot holds are skipped, so only edits it lacks change anything. + Ok(log) => { + let before = server.seq(); + let mut stopped = None; + for entry in log.entries { + if let Err(e) = server.replay(entry) { + stopped = Some(e); + break; + } + } + let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before)); + if recovered > 0 { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \ + {recovered} changes the snapshot didn't; they were recovered" + )); + } + if let Some(e) = stopped { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \ + making sense after {recovered} newer changes ({e}); the rest was dropped" + )); + } + } Err(why) => issues.push(format!("file {file}: {why}; starting a new log")), } for i in issues { diff --git a/apps/figcraft-server/tests/header_recovery.rs b/apps/figcraft-server/tests/header_recovery.rs new file mode 100644 index 000000000..d728908d1 --- /dev/null +++ b/apps/figcraft-server/tests/header_recovery.rs @@ -0,0 +1,139 @@ +//! A generation field damaged after an abrupt stop must not make the server drop a log that still +//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the +//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short +//! still restores silently. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_collab::message::{Access, ClientMsg}; +use figcraft_collab::{Client, Outgoing, Replica, Server}; +use figcraft_collab_net::hub::Backend; +use figcraft_doc::Document; +use figcraft_server::config::Config; +use figcraft_server::disk::{self, LOG, SNAPSHOT}; +use figcraft_server::store::{FILES, Store}; +use std::fs; +use std::path::{Path, PathBuf}; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("figcraft-header-recovery-{}", figcraft_collab_net::token::file_id().unwrap())); + fs::create_dir(&path).unwrap(); + Self(path) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn page_name(server: &Server) -> String { + let doc = server.document(); + doc.node(doc.first_page().unwrap()).unwrap().name.clone() +} + +/// A published file whose first page was renamed "Kept", flushed to its log (not compacted), then +/// the store dropped without stopping (an abrupt stop). Returns the file id and the final state. +fn renamed_and_stopped(data: &Path) -> (String, Server) { + let mut store = Store::open(data, Config::default().store()).unwrap(); + let snapshot = Replica::new(Document::new("Fixture")).snapshot().unwrap(); + let (info, _, _) = store.publish("Fixture", &snapshot).unwrap(); + let id = info.id; + let mut server = store.open(&id).unwrap(); + let ClientMsg::Hello(hello) = Client::hello("f", "", "Editor", "#FF8800") else { panic!("not a hello") }; + let (_, messages) = server.join_as(hello, Access::Edit).unwrap(); + let Some(Outgoing::To(_, welcome)) = messages.into_iter().next() else { panic!("no welcome") }; + let mut client = Client::join(welcome).unwrap(); + store.changed(&id, &mut server); + let mut edited = client.document().clone(); + let page = edited.first_page().unwrap(); + edited.node_mut(page).unwrap().name = "Kept".into(); + let change = client.edit(&edited).unwrap().unwrap(); + assert!(!server.receive(client.id(), change).is_empty()); + store.changed(&id, &mut server); + assert!(store.flush().is_empty(), "the edit must reach disk"); + assert_eq!(page_name(&server), "Kept"); + let dir = data.join(FILES).join(&id); + assert!(!disk::read_log(&fs::read(dir.join(LOG)).unwrap()).unwrap().entries.is_empty()); + assert_eq!(disk::decode_snapshot(&fs::read(dir.join(SNAPSHOT)).unwrap()).unwrap().1.seq, 0, "the rename is only in the log"); + drop(store); + (id, server) +} + +/// Reopen the data directory: the file's first page name and the problems reported. +fn reopen(data: &Path, id: &str) -> (String, Vec) { + let mut store = Store::open(data, Config::default().store()).unwrap(); + let server = store.open(id).unwrap(); + (page_name(&server), store.problems().to_vec()) +} + +fn flip(path: &Path, at: usize) { + let mut bytes = fs::read(path).unwrap(); + bytes[at] ^= 1; + fs::write(path, bytes).unwrap(); +} + +/// The generation field's first byte in both headers (after the magic and the format byte). +const GENERATION_AT: usize = 5; + +#[test] +fn an_intact_log_restores_its_edits_without_a_report() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept"); + assert!(problems.is_empty(), "{problems:?}"); +} + +#[test] +fn a_damaged_log_generation_keeps_the_edits_and_is_reported() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + let log = fixture.0.join(FILES).join(&id).join(LOG); + flip(&log, GENERATION_AT); + // The records themselves still read: only the header's generation changed. + let read = disk::read_log(&fs::read(&log).unwrap()).unwrap(); + assert!(read.damaged.is_none() && !read.entries.is_empty()); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept", "the flushed rename must survive"); + assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}"); + // And it stays: the recovered edit is now in the file's snapshot. + assert_eq!(reopen(&fixture.0, &id).0, "Kept"); +} + +#[test] +fn a_damaged_snapshot_generation_keeps_the_edits_and_is_reported() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + let snapshot = fixture.0.join(FILES).join(&id).join(SNAPSHOT); + flip(&snapshot, GENERATION_AT); + assert!(disk::check_snapshot(&fs::read(&snapshot).unwrap()).is_ok(), "the generation is outside the checksum"); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept", "the flushed rename must survive"); + assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}"); + assert_eq!(reopen(&fixture.0, &id).0, "Kept"); +} + +#[test] +fn a_damaged_log_format_is_still_reported() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + flip(&fixture.0.join(FILES).join(&id).join(LOG), 4); + let (_, problems) = reopen(&fixture.0, &id); + assert!(problems.iter().any(|p| p.contains(&id) && p.contains("format")), "{problems:?}"); +} + +#[test] +fn a_log_left_by_a_compaction_cut_short_restores_without_a_report() { + let fixture = Fixture::new(); + let (id, server) = renamed_and_stopped(&fixture.0); + // The next snapshot was written (it holds the rename); the old log wasn't replaced yet. + let bytes = disk::encode_snapshot(2, &server.state().unwrap()).unwrap(); + fs::write(fixture.0.join(FILES).join(&id).join(SNAPSHOT), bytes).unwrap(); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept"); + assert!(problems.is_empty(), "a leftover log is expected, not damage: {problems:?}"); +} diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs index 587fd60b8..7eed2ffb3 100644 --- a/apps/figcraft-web/src/main.rs +++ b/apps/figcraft-web/src/main.rs @@ -14,7 +14,16 @@ //! join from other machines. //! //! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU. +//! +//! URL fragment: `#join=` joins that live session once the page has +//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last. +//! The link carries the session's secret; a fragment is never sent to the web host, but the address +//! bar and the browser history keep it, so the URL is as private as the link itself. +#[cfg(any(target_arch = "wasm32", test))] +mod query; +#[cfg(test)] +mod query_tests; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/figcraft-web/src/query.rs b/apps/figcraft-web/src/query.rs new file mode 100644 index 000000000..befeb36b7 --- /dev/null +++ b/apps/figcraft-web/src/query.rs @@ -0,0 +1,15 @@ +//! The page's URL: the invite link its fragment names. + +/// The invite link in `#join=` (`fragment` is `location.hash`, with or without its `#`), still +/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out +/// of the web host's logs and `Referer` headers. `join` takes the rest of the fragment, so a pasted +/// `figcraft://join?host=…&file=…&token=…` keeps its own `&`s. Anything longer than an escaped invite +/// link can be is ignored; what is left is checked by `collab.join` like a link the desktop app is +/// opened with. +pub fn join_link(fragment: &str) -> Option<&str> { + // An invite link is at most 2 KiB; every byte of it escaped is three. + const MAX: usize = 3 * 2048; + let fragment = fragment.strip_prefix('#').unwrap_or(fragment); + let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 }; + fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX) +} diff --git a/apps/figcraft-web/src/query_tests.rs b/apps/figcraft-web/src/query_tests.rs new file mode 100644 index 000000000..261ebaeb6 --- /dev/null +++ b/apps/figcraft-web/src/query_tests.rs @@ -0,0 +1,29 @@ +//! `#join=` in the page's URL. + +use crate::query::join_link; + +#[test] +fn join_takes_the_rest_of_the_fragment() { + let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; + assert_eq!(join_link(&format!("#join={link}")), Some(link), "a pasted link keeps its own &s"); + assert_eq!(join_link(&format!("#x=1&join={link}")), Some(link), "after other entries"); + assert_eq!(join_link("#join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later"); + assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading #"); +} + +#[test] +fn no_join_without_the_entry() { + assert_eq!(join_link(""), None); + assert_eq!(join_link("#"), None); + assert_eq!(join_link("#join="), None); + assert_eq!(join_link("#rejoin=x"), None, "only the whole entry name"); + assert_eq!(join_link("#xjoin=x&a=1"), None); + assert_eq!(join_link("?join=x"), None, "the query isn't read: it goes to the web host"); +} + +#[test] +fn an_overlong_link_is_ignored() { + let long = format!("#join=figcraft://join?host=h&file=f&token={}", "a".repeat(7000)); + assert_eq!(join_link(&long), None); + assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes"); +} diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 8cadb0138..292136212 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -40,7 +40,13 @@ pub fn start() { // on the socket wakes the page for a frame. let ctx = cc.egui_ctx.clone(); session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint())))); - let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); + let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); + // `#join=`: join that session, as opening an invite link does on the desktop. + let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default(); + if let Some(link) = crate::query::join_link(&hash) { + let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string()); + figcraft_ui_egui::live::open_invite(&mut fig, &link); + } Ok(Box::new(WebShell { fig })) }), ) diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index bafb6da14..fac4842b1 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -21,7 +21,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", " serde_json = { workspace = true } log = { workspace = true } rfd = { workspace = true } -arboard = { workspace = true, features = ["image-data"] } +arboard = { workspace = true, features = ["image-data", "wayland-data-control"] } webbrowser = { workspace = true } image = { workspace = true } env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] } @@ -43,3 +43,9 @@ wat = { workspace = true } [build-dependencies] winresource = "0.1" + +[target.'cfg(target_os = "linux")'.dependencies] +# Use vector window controls without a title-font renderer or embedded Cantarell. +winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] } +raw-window-handle = "0.6" +wayland-client = "0.31" diff --git a/apps/figcraft/src/apple_events.rs b/apps/figcraft/src/apple_events.rs index 6b7920944..41ae5cc36 100644 --- a/apps/figcraft/src/apple_events.rs +++ b/apps/figcraft/src/apple_events.rs @@ -50,7 +50,7 @@ pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) { /// Open `paths` as File › Open does; a file that fails shows its error and the others still open. pub fn open_documents(fig: &mut FigApp, paths: Vec) { for p in paths { - if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() { + if fig.run_user("file.open", json!({"path": p.to_string_lossy()})).is_some() { fig.ui.home = false; } } diff --git a/apps/figcraft/src/args.rs b/apps/figcraft/src/args.rs index 2e3099cbf..3b4136d99 100644 --- a/apps/figcraft/src/args.rs +++ b/apps/figcraft/src/args.rs @@ -1,4 +1,5 @@ -//! The command line: `figcraft [--control ] [files…] [figcraft://join?…]`. An invite link +//! The command line: `figcraft [--control ] [--automation-read-root ]… +//! [--automation-write-root ]… [files…] [figcraft://join?…]`. An invite link //! (what the system passes when a `figcraft://` link is opened) joins that session once the window //! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the //! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). @@ -11,6 +12,11 @@ pub struct Args { pub files: Vec, /// The last invite link given. pub join: Option, + /// `--automation-read-root ` / `--automation-write-root ` (each repeatable; `=` works + /// too): the folders the app's file access is confined to. A flag without a value gives `""`, + /// which is refused at startup. + pub read_roots: Vec, + pub write_roots: Vec, } /// An invite link rather than a file (`figcraft:` links, in any case). @@ -49,7 +55,15 @@ pub fn parse(args: &[String]) -> Args { let mut out = Args::default(); let mut it = args.iter(); while let Some(a) = it.next() { - if a == "--control" { + let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string()))); + let roots = match flag { + "--automation-read-root" => Some(&mut out.read_roots), + "--automation-write-root" => Some(&mut out.write_roots), + _ => None, + }; + if let Some(roots) = roots { + roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default()); + } else if a == "--control" { out.control = it.next().and_then(|p| p.parse().ok()); } else if is_invite(a) { out.join = Some(a.clone()); diff --git a/apps/figcraft/src/args_tests.rs b/apps/figcraft/src/args_tests.rs index 0ed404c8f..2ae29b3c8 100644 --- a/apps/figcraft/src/args_tests.rs +++ b/apps/figcraft/src/args_tests.rs @@ -10,12 +10,22 @@ fn args(a: &[&str]) -> Vec { fn files_and_the_control_port() { assert_eq!(parse(&args(&[])), Args::default()); let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"])); - assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None }); + assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() }); // A missing or bad port is ignored, not taken as a file. assert_eq!(parse(&args(&["--control"])), Args::default()); assert_eq!(parse(&args(&["--control", "nope"])).control, None); } +#[test] +fn automation_roots_repeat_and_are_never_files() { + let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"])); + assert_eq!(a.read_roots, args(&["in", "more"])); + assert_eq!(a.write_roots, args(&["out"])); + assert_eq!(a.files, args(&["a.figcraft"])); + // A flag without its folder is kept as an empty one, which startup refuses. + assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""])); +} + #[test] fn invite_links_join_instead_of_opening() { let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index d243fb916..008170fbb 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -160,7 +160,11 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() { assert_eq!(workers.len(), 1); client.write_all(b"invalid\n").unwrap(); assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false); - client.shutdown(Shutdown::Both).unwrap(); + // The server closes this connection after answering an invalid request, and shutting down + // a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux + // returns Ok. Closing the client is what the worker is waiting for, so the result is moot: + // the other shutdowns in this file run while the peer is still open and stay checked. + let _ = client.shutdown(Shutdown::Both); workers.pop().unwrap().join().unwrap(); } diff --git a/apps/figcraft/src/linux_window.rs b/apps/figcraft/src/linux_window.rs new file mode 100644 index 000000000..f47b61e03 --- /dev/null +++ b/apps/figcraft/src/linux_window.rs @@ -0,0 +1,112 @@ +//! Request winit's frame only on Wayland compositors without xdg-decoration. +//! Desktop/session names cannot identify the backend of an individual window. + +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::sync::mpsc; +use std::time::Duration; + +use raw_window_handle::{HasDisplayHandle, RawDisplayHandle}; +use wayland_client::globals::{GlobalListContents, registry_queue_init}; +use wayland_client::protocol::wl_registry; +use wayland_client::{Connection, Dispatch, QueueHandle}; + +/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET. +/// Never consume, duplicate or query that inherited connection: there may be no +/// independently addressable display, or WAYLAND_DISPLAY may name another server. +pub fn display_socket() -> Option { + if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) { + return None; + } + let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?; + let path = PathBuf::from(display); + if path.is_absolute() { + Some(path) + } else { + std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path)) + } +} + +pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option) -> bool { + let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_))); + if !wayland { + log::debug!("window controls: non-Wayland window, retaining custom controls"); + return false; + } + let server_decorations = socket.and_then(probe); + log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}"); + native_frame(wayland, server_decorations) +} + +fn native_frame(wayland: bool, server_decorations: Option) -> bool { + wayland && server_decorations == Some(false) +} + +fn probe(socket: PathBuf) -> Option { + // The compositor must not be able to block startup on a registry roundtrip. + // On failure/timeout retain FigCraft's own working title bar and buttons. + let (send, receive) = mpsc::sync_channel(1); + std::thread::Builder::new() + .name("wayland-decorations".into()) + .spawn(move || { + let _ = send.send(registry_has_decorations(socket)); + }) + .ok()?; + receive.recv_timeout(Duration::from_millis(500)).ok().flatten() +} + +fn registry_has_decorations(socket: PathBuf) -> Option { + // A fresh connection leaves winit's event queue and inherited descriptors alone. + let stream = UnixStream::connect(socket).ok()?; + let connection = Connection::from_socket(stream).ok()?; + let (globals, _queue) = registry_queue_init::(&connection).ok()?; + Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1"))) +} + +struct Registry; + +impl Dispatch for Registry { + fn event( + _state: &mut Self, + _registry: &wl_registry::WlRegistry, + _event: wl_registry::Event, + _data: &GlobalListContents, + _connection: &Connection, + _queue: &QueueHandle, + ) { + } +} + +#[cfg(test)] +mod tests { + #[test] + fn only_wayland_without_server_decorations_uses_the_frame() { + for (wayland, server_decorations, expected) in [ + (false, None, false), + (false, Some(false), false), + (false, Some(true), false), + (true, Some(true), false), + (true, Some(false), true), + (true, None, false), + ] { + assert_eq!(super::native_frame(wayland, server_decorations), expected); + } + } + + #[test] + fn an_unavailable_display_keeps_the_custom_controls() { + assert_eq!(super::probe("/nonexistent/figcraft-wayland-test".into()), None); + } + + #[test] + fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> { + let socket = std::env::temp_dir().join(format!("figcraft-decoration-timeout-{}", std::process::id())); + let listener = std::os::unix::net::UnixListener::bind(&socket)?; + let start = std::time::Instant::now(); + assert_eq!(super::probe(socket.clone()), None); + assert!(start.elapsed() < std::time::Duration::from_secs(2)); + drop(listener); + let _ = std::fs::remove_file(socket); + Ok(()) + } +} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index fdcc66f9e..ddb702043 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -6,6 +6,11 @@ //! `--control ` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}` //! → `{"id":1,"ok":true,"result":{…}}`. Methods: `figcraft_ui_egui::control`. +//! +//! `--automation-read-root ` / `--automation-write-root ` (each repeatable) confine the +//! files the window and the control channel read and write (`figcraft_engine::file_access`); the +//! app's own folders (settings, recovery copies, installed plugins) and opening the files given on +//! the command line are not. A root that isn't an existing folder stops the app at startup. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] @@ -15,6 +20,8 @@ mod args; mod control_server; mod gamepad; mod graphics; +#[cfg(target_os = "linux")] +mod linux_window; mod recovery; mod services; mod startup; @@ -100,10 +107,13 @@ impl eframe::App for App { } fn on_exit(&mut self) { - with_app(|fig| services::save_state(fig)); - if let Some(r) = recovery::lock(&self.recovery).take() { - r.finish(); - } + // With the app out of reach (busy), its recovery copies stay for the next start. + let copies = recovery::lock(&self.recovery).take(); + with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies)); + // Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support + // throws on the content view AccessKit restores (craft-libs#3, #781). + #[cfg(target_os = "macos")] + std::process::exit(0); } } @@ -119,6 +129,17 @@ fn main() -> eframe::Result { return Ok(()); } let args = args::parse(&args); + let roots = match ( + args.read_roots.is_empty() && args.write_roots.is_empty(), + figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots), + ) { + (true, _) => None, + (false, Ok(r)) => Some(Arc::new(r)), + (false, Err(e)) => { + eprintln!("FigCraft: {e}"); + std::process::exit(2); + } + }; let port = args.control.or_else(|| std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok())); // System fonts: index them in the background, then wake the UI (an idle window draws no frames) // to lay text out again with them. @@ -143,6 +164,7 @@ fn main() -> eframe::Result { log::error!("{f}: {e}"); } } + let opened: Vec = session.docs.iter().map(|d| d.uid).collect(); // Documents a crashed or killed run left unsaved come back; this run keeps its own copies. let recovery_root = services::config_dir().map(|d| d.join("Recovery")); // The preferences first: they hold the online files a recovered document may be one of. @@ -174,7 +196,10 @@ fn main() -> eframe::Result { // open (no frame then). figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref()); let mut fig = FigApp::new(session, Box::new(svc)); + fig.automation_roots = roots; fig.ui.home = false; + // Files named on the command line are the user's too: their missing fonts are listed. + fig.ui.font_checks = opened; fig.note_recovered(recovered.copies); if let Some(link) = &args.join { figcraft_ui_egui::live::open_invite(&mut fig, link); @@ -192,21 +217,18 @@ fn main() -> eframe::Result { .with_inner_size([1440.0, 900.0]) .with_min_inner_size([800.0, 500.0]) .with_drag_and_drop(true); - // The tab strip is the title bar: macOS keeps its window buttons inset into it; elsewhere the - // window is frameless and FigCraft draws the buttons and resize edges. - if cfg!(target_os = "macos") { - viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false); - fig.ui.title_bar = figcraft_ui_egui::TitleBar::Inset; - } else { - viewport = viewport.with_decorations(false); - fig.ui.title_bar = figcraft_ui_egui::TitleBar::Custom; - } + // Start frameless on Linux; the app creator checks the actual display backend + // before enabling a client-side frame. Other windows inherit that decision. + fig.ui.title_bar = desktop_title_bar(); + viewport = window_chrome(viewport, fig.ui.title_bar); if let Some(icon) = icon { viewport = viewport.with_icon(icon); } let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() }; graphics::configure(&mut options.wgpu_options); figcraft_ui_egui::windows::install(fig); + #[cfg(target_os = "linux")] + let linux_display = linux_window::display_socket(); // Registered before the event loop starts, so it catches the Finder event that launched the app // as well as later ones. Lives until the event loop returns; the app creator only borrows it. #[cfg(target_os = "macos")] @@ -217,6 +239,11 @@ fn main() -> eframe::Result { "FigCraft", options, Box::new(move |_cc| { + #[cfg(target_os = "linux")] + if linux_window::needs_native_frame(_cc, linux_display) { + let _ = with_app(|fig| fig.ui.title_bar = figcraft_ui_egui::TitleBar::Native); + _cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(true)); + } Ok(Box::new(App { queue, ctx_slot, @@ -233,3 +260,28 @@ fn main() -> eframe::Result { } result } + +fn desktop_title_bar() -> figcraft_ui_egui::TitleBar { + use figcraft_ui_egui::TitleBar; + if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom } +} + +fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder { + use figcraft_ui_egui::TitleBar; + match title_bar { + TitleBar::Inset => viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false), + TitleBar::Native => viewport.with_decorations(true), + TitleBar::Custom => viewport.with_decorations(false), + } +} + +#[cfg(all(test, target_os = "linux"))] +mod linux_window_tests { + #[test] + fn linux_starts_with_the_existing_frameless_title_bar() { + let bar = super::desktop_title_bar(); + assert_eq!(bar, figcraft_ui_egui::TitleBar::Custom); + let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar); + assert_eq!(viewport.decorations, Some(false)); + } +} diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index b2614598f..62243bc73 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -250,6 +250,17 @@ impl Recovery { self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) }); } + /// Quitting: a normal quit ([`finish`](Self::finish)) when the unsaved-changes question was + /// `asked` or nothing is unsaved. Otherwise (macOS's Quit menu ends the app without asking) the + /// unsaved documents are copied and the copies stay for the next start, as after a crash. + pub fn quit(mut self, s: &Session, asked: bool) { + if asked || !s.docs.iter().any(|d| needs_copy(s, d)) { + return self.finish(); + } + self.write(s, true); + self.settle(s); + } + /// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier /// runs' copies still waiting for their outcome stay for the next start.) pub fn finish(self) { diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index a3d608f9d..8f384b87c 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -167,6 +167,23 @@ fn a_normal_quit_removes_the_recovered_copies_it_took_over() { let _ = std::fs::remove_dir_all(&root); } +#[test] +fn quitting_without_the_unsaved_question_keeps_copies_for_the_next_start() { + let root = root("quitunasked"); + // macOS's Quit menu: nothing asked, so the unsaved document comes back next time. + let s = edited(); + Recovery::start(&root).unwrap().quit(&s, false); + let mut next = Session::empty(); + assert_eq!(recover(&root, &mut next), 1); + let _ = std::fs::remove_dir_all(&root); + // Asked (saved or discarded on purpose), or nothing unsaved: nothing is left. + for (s, asked) in [(edited(), true), (Session::new(), false)] { + Recovery::start(&root).unwrap().quit(&s, asked); + assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "asked: {asked}"); + } + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn a_run_whose_folder_name_is_still_held_takes_another() { let root = root("pidreuse"); diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index bd8a42043..7ee41ada0 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -167,10 +167,18 @@ fn reveal(path: &Path) -> std::io::Result<()> { /// The preferences' file in the config folder. const STATE: &str = "state.json"; -pub fn save_state(app: &FigApp) { - if let Some(dir) = config_dir() { - write_prefs(&dir, &app.session.prefs); +/// Everything quitting keeps, all done when it returns (macOS then ends the process): the +/// preferences in `dir`, this run's recovery copies ([`Recovery::quit`](crate::recovery::Recovery::quit)) +/// and the live sessions' ends (one this app hosts tells everyone and stops; a joined one leaves). +pub fn shutdown(app: &mut FigApp, dir: Option<&Path>, recovery: Option) { + if let Some(dir) = dir { + write_prefs(dir, &app.session.prefs); } + // Before the sessions end: a recovered online file's copy waits for its session's outcome. + if let Some(r) = recovery { + r.quit(&app.session, app.ui.quit); + } + drop(std::mem::take(&mut app.session.live)); } /// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves @@ -223,12 +231,47 @@ fn picked(pick: impl Future> + 'static) -> Dial Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) }) } +/// How long a clipboard read may wait on the app that copied (Linux and the BSDs). +#[cfg(all(unix, not(target_os = "macos")))] +const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +/// Read the clipboard. On Linux and the BSDs the read runs on a helper thread with its own handle +/// and gives up after [`CLIPBOARD_TIMEOUT`]: a Wayland read waits on the app that copied with no +/// timeout of its own, so an app that hangs must not hang the window too. +#[cfg(all(unix, not(target_os = "macos")))] +fn read_clipboard(_: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option) -> Option { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(arboard::Clipboard::new().ok().and_then(|mut c| read(&mut c))); + }); + rx.recv_timeout(CLIPBOARD_TIMEOUT).ok().flatten() +} + +/// Read the clipboard (the system's own reads don't wait on the app that copied). +#[cfg(not(all(unix, not(target_os = "macos"))))] +fn read_clipboard(c: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option) -> Option { + read(c) +} + +/// Native Save as filters; other callers keep their existing combined filter. +fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> { + if exts == ["figcraft", "fig"] { + vec![("FigCraft (.figcraft)", &exts[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &exts[1..])] + } else { + vec![("File", exts)] + } +} + impl Services for Desktop { fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { picked(dialog(title).add_filter("Supported files", exts).pick_file()) } fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { - picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file()) + let mut dialog = dialog(title).set_file_name(name); + for (label, extensions) in save_filters(exts) { + dialog = dialog.add_filter(label, extensions); + } + picked(dialog.save_file()) } fn pick_folder(&mut self, title: &str) -> DialogFuture { picked(dialog(title).pick_folder()) @@ -239,7 +282,10 @@ impl Services for Desktop { } } fn clipboard_get_text(&mut self) -> Option { - self.clipboard.as_mut()?.get_text().ok() + read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok()) + } + fn clipboard_get_html(&mut self) -> Option { + read_clipboard(self.clipboard.as_mut()?, |c| c.get().html().ok()) } fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) { if let Some(c) = self.clipboard.as_mut() { @@ -247,7 +293,7 @@ impl Services for Desktop { } } fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { - let img = self.clipboard.as_mut()?.get_image().ok()?; + let img = read_clipboard(self.clipboard.as_mut()?, |c| c.get_image().ok())?; let (w, h) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?); // Refuse absurd sizes (and mismatched buffers) rather than allocate. if w == 0 || h == 0 || u64::from(w) * u64::from(h) > 100_000_000 || img.bytes.len() != (w as usize) * (h as usize) * 4 { @@ -297,3 +343,24 @@ pub fn window_icon() -> Option { let (w, h) = img.dimensions(); Some(egui::IconData { rgba: img.into_raw(), width: w, height: h }) } + +#[cfg(test)] +mod save_filter_tests { + use super::save_filters; + + #[test] + fn native_and_fig_save_filters_are_separate_with_native_first() { + let extensions = ["figcraft", "fig"]; + assert_eq!( + save_filters(&extensions), + vec![("FigCraft (.figcraft)", &extensions[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &extensions[1..])] + ); + } + + #[test] + fn other_save_filters_keep_one_combined_group() { + for extensions in [&["figcraft"][..], &["fig"][..], &["png"][..], &["png", "jpg", "webp"][..], &[][..]] { + assert_eq!(save_filters(extensions), vec![("File", extensions)]); + } + } +} diff --git a/apps/figcraft/src/services_tests.rs b/apps/figcraft/src/services_tests.rs index 2390b92fc..2abec5bba 100644 --- a/apps/figcraft/src/services_tests.rs +++ b/apps/figcraft/src/services_tests.rs @@ -82,3 +82,38 @@ fn preferences_are_readable_by_their_owner_only() { assert_eq!(mode(&dir.join("state.json")), 0o600); let _ = std::fs::remove_dir_all(&dir); } + +/// A live link that only records the sessions it was told to leave. +struct Leaves(Arc>>); + +impl figcraft_engine::live::LiveLink for Leaves { + fn host(&mut self, _: u64, _: figcraft_engine::live::HostOptions) -> Result { + Err("not in this test".into()) + } + fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> { + Ok(()) + } + fn leave(&mut self, session: u64) { + self.0.lock().unwrap().push(session); + } + fn presence(&mut self, _: u64, _: &figcraft_engine::live::Presence) {} + fn send(&mut self, _: u64, _: figcraft_engine::figcraft_collab::message::ClientMsg) {} + fn poll(&mut self) -> Vec<(u64, figcraft_engine::live::Event)> { + Vec::new() + } +} + +#[test] +fn quitting_keeps_the_preferences_and_ends_the_live_sessions() { + let dir = root("shutdown"); + let mut fig = start(&dir); + let left = Arc::new(Mutex::new(Vec::new())); + fig.session.live.install(Box::new(Leaves(left.clone()))); + let joined = fig.run("collab.join", json!({"link": "ws://127.0.0.1:9/?file=f1&token=EditSecretEditSecret00"})).unwrap(); + fig.session.prefs.user_name = "Ada".into(); + crate::services::shutdown(&mut fig, Some(&dir), None); + assert_eq!(*left.lock().unwrap(), [joined["session"].as_u64().unwrap()], "the session was left"); + assert!(!fig.session.live.available(), "nothing is left to send on"); + assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/contributors/people.toml b/contributors/people.toml index bc8cf71bb..8b14373eb 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -44,3 +44,15 @@ sync_github_name = true [people.volkovskey] real_name = "Andrii Volkov" + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +display_name = "Marco Arnulfo" +sync_github_name = false + +[people.krinry] +display_name = "krinry" +sync_github_name = true + +[people.pechnikov2026-star] +sync_github_name = true diff --git a/crates/collab-net/src/invite.rs b/crates/collab-net/src/invite.rs index bfa2e606e..617ad5187 100644 --- a/crates/collab-net/src/invite.rs +++ b/crates/collab-net/src/invite.rs @@ -203,7 +203,7 @@ fn pairs(query: &str) -> Result, String> { fn with_port(authority: &str, default: u16) -> String { let has_port = match authority.rsplit_once(':') { // `[v6]` alone has colons inside the brackets only. - Some((h, p)) => !(h.starts_with('[') && !h.ends_with(']')) && !p.contains(']'), + Some((h, p)) => (!h.starts_with('[') || h.ends_with(']')) && !p.contains(']'), None => false, }; if has_port { authority.to_string() } else { format!("{authority}:{default}") } diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs index c31118767..a0647fa6a 100644 --- a/crates/collab-net/src/limits.rs +++ b/crates/collab-net/src/limits.rs @@ -12,7 +12,9 @@ pub struct NetLimits { /// before: its hello. pub max_client_message: usize, pub max_hello: usize, - /// Largest message a client accepts from the host (a welcome carries the whole document). + /// Largest message a client accepts from the host (a welcome carries the whole document): 1 GiB, + /// room for any file up to 1023 MiB and well past the servers' defaults, while a hostile server + /// (any link can name one) can't make the app or a browser tab buffer more than that. pub max_server_message: usize, /// Messages a client may send per second on average, and in a burst; beyond that it is /// disconnected. @@ -61,7 +63,7 @@ impl Default for NetLimits { max_connections: 64, max_client_message: figcraft_collab::Limits::default().max_message_bytes, max_hello: 64 << 10, - max_server_message: figcraft_collab::Limits::default().max_message_bytes, + max_server_message: 1 << 30, rate: 100.0, burst: 400.0, hello_timeout: Duration::from_secs(10), diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 06596ddcd..2a7b42a64 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -51,6 +51,10 @@ pub fn is_closed(n: &Node) -> bool { } } +/// How much further than `tol` a stroke with nothing filled reaches: a thin line or stroke-only path +/// is hard to land on with the plain tolerance, so it gets a wider band (5 screen px for the usual 2 px tolerance). +const STROKE_REACH: f64 = 2.5; + /// Whether `pt` hits `n` itself; in `outline` mode (nothing filled on the canvas) a shape only where /// its outline is. fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, outline: bool) -> bool { @@ -62,7 +66,7 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Page { .. } => false, NodeKind::Text(_) | NodeKind::Slice => n.local_rect().inflate(tol_l, tol_l).contains(lp), NodeKind::Line => { - let half = (n.stroke.weight / 2.0).max(tol_l); + let half = (n.stroke.weight / 2.0).max(tol_l * STROKE_REACH); lp.x >= -half && lp.x <= n.width + half && lp.y.abs() <= half } NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { @@ -78,7 +82,9 @@ fn hits_path(n: &Node, path: &BezPath, lp: Point, tol: f64, inside: bool) -> boo if inside && (n.fills.iter().any(|f| f.visible) || !n.has_visible_stroke()) { return true; } - let reach = (n.stroke.max_weight() / 2.0).max(tol); + // Only a stroke with nothing filled gets the wider band: a filled shape's edge keeps the plain one. + let stroke_only = n.has_visible_stroke() && !n.fills.iter().any(|f| f.visible); + let reach = (n.stroke.max_weight() / 2.0).max(if stroke_only { tol * STROKE_REACH } else { tol }); if !path.bounding_box().inflate(reach, reach).contains(lp) { return false; } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 71c6da393..07700e8ce 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -77,17 +77,19 @@ pub enum DocError { pub type DocResult = Result; -/// The set of touched node ids, and whether any layer is a mask (worked out when first asked -/// after a change: hit testing skips its mask checks without one). Never part of equality or +/// The set of touched node ids, whether any layer is a mask (hit testing skips its mask checks +/// without one) and the largest background blur radius (renders keep that much overlap between +/// their bands), both worked out when first asked after a change. Never part of equality or /// serialisation. #[derive(Clone, Debug, Default)] -pub struct Dirty(pub std::collections::BTreeSet, std::sync::OnceLock); +pub struct Dirty(pub std::collections::BTreeSet, std::sync::OnceLock, std::sync::OnceLock); impl Dirty { /// Record node `id` as touched (edited, added or removed). fn touch(&mut self, id: NodeId) { self.0.insert(id); self.1 = std::sync::OnceLock::new(); + self.2 = std::sync::OnceLock::new(); } } @@ -254,6 +256,11 @@ impl Document { *self.dirty.1.get_or_init(|| self.nodes.values().any(|n| n.is_mask)) } + /// The largest background blur or glass radius of any layer's effects (0 without any). + pub fn max_backdrop_radius(&self) -> f64 { + *self.dirty.2.get_or_init(|| self.nodes.values().flat_map(|n| n.effects.iter()).map(Effect::backdrop_radius).fold(0.0, f64::max)) + } + /// Node ids touched so far (without clearing). pub fn dirty(&self) -> &std::collections::BTreeSet { &self.dirty.0 @@ -1117,8 +1124,14 @@ impl Document { /// mask with the layers above it up to the next mask, or a single layer. An auto layout frame /// whose first child paints on top paints the runs in reverse (see [`Node::paint_order`]). pub fn paint_runs(&self, n: &Node) -> Vec> { - let kids = &n.children; - let node = |i: usize| kids.get(i).and_then(|k| self.node(*k)); + let kids: Vec> = n.children.iter().map(|k| self.node(*k)).collect(); + Self::paint_runs_of(&kids, n.layout.as_ref().is_some_and(|l| l.reverse_z)) + } + + /// [`Self::paint_runs`] of children already looked up (`None`: not in the document), last run + /// first when `reverse_z`. + pub fn paint_runs_of(kids: &[Option<&Node>], reverse_z: bool) -> Vec> { + let node = |i: usize| kids.get(i).copied().flatten(); let mut runs = Vec::new(); let mut i = 0; while i < kids.len() { @@ -1131,7 +1144,7 @@ impl Document { runs.push(i..j); i = j; } - if n.layout.as_ref().is_some_and(|l| l.reverse_z) { + if reverse_z { runs.reverse(); } runs diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index 91d2e8eb8..a844774c5 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -860,7 +860,35 @@ pub struct Device { impl PrototypeSettings { /// The devices to pick from ("None" presents the frame at its own size). - pub const DEVICES: [Device; 6] = [ + pub const DEVICES: [Device; 19] = [ + Device { + name: "iPhone 17", + width: 402.0, + height: 874.0, + rotates: true, + colors: &[c("Black", 0x38, 0x39, 0x3D), c("White", 0xE9, 0xE9, 0xE5)], + }, + Device { + name: "iPhone 17 Pro", + width: 402.0, + height: 874.0, + rotates: true, + colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone 17 Pro Max", + width: 440.0, + height: 956.0, + rotates: true, + colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone Air", + width: 420.0, + height: 912.0, + rotates: true, + colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)], + }, Device { name: "iPhone 16", width: 393.0, @@ -874,6 +902,13 @@ impl PrototypeSettings { c("Blue", 0x72, 0x84, 0xD4), ], }, + Device { + name: "iPhone 16 Pro", + width: 402.0, + height: 874.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6)], + }, Device { name: "iPhone 16 Pro Max", width: 440.0, @@ -881,6 +916,55 @@ impl PrototypeSettings { rotates: true, colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6), c("Natural", 0xBF, 0xB9, 0xAF), c("Sand", 0xC6, 0xAC, 0x8F)], }, + Device { + name: "iPhone 16 Plus", + width: 430.0, + height: 932.0, + rotates: true, + colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)], + }, + Device { + name: "iPhone 15 Pro", + width: 393.0, + height: 852.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone 15 Pro Max", + width: 430.0, + height: 932.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone 15 Plus", + width: 430.0, + height: 932.0, + rotates: true, + colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)], + }, + Device { + name: "iPhone 14 Pro", + width: 393.0, + height: 852.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone 14 Pro Max", + width: 430.0, + height: 932.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone 14", + width: 390.0, + height: 844.0, + rotates: true, + colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)], + }, Device { name: "Android Compact", width: 412.0, @@ -888,6 +972,13 @@ impl PrototypeSettings { rotates: true, colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB), c("Green", 0xA6, 0xC0, 0xA3)], }, + Device { + name: "Android Medium", + width: 700.0, + height: 840.0, + rotates: true, + colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB)], + }, Device { name: "iPad mini", width: 744.0, diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index b294151bb..097dd189b 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -215,6 +215,10 @@ pub struct ParaList { pub list: ListStyle, /// Nesting level (0 = outermost; plain paragraphs are always 0). pub indent: u8, + /// A numbered list that starts counting at this paragraph counts from `start_offset + 1` + /// (items that go on counting ignore it; other paragraphs are always 0). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub start_offset: u32, } /// Character style. In runs, every field is an override; `None` means "inherit the base". @@ -637,13 +641,13 @@ impl TextData { /// Paragraph `p`'s list settings. pub fn para_list(&self, p: usize) -> ParaList { - self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, indent: 0 }) + self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() }) } /// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries). fn fill_paragraphs(&mut self) { let n = self.paragraph_count(); - let fill = ParaList { list: self.list, indent: 0 }; + let fill = ParaList { list: self.list, ..ParaList::default() }; self.paragraphs.resize(n, fill); } @@ -657,17 +661,23 @@ impl TextData { } /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain - /// paragraphs stay at level 0, levels stop at [`MAX_LIST_INDENT`]); one style for all with - /// no nesting folds back into `list`. + /// paragraphs stay at level 0, only numbered ones keep a start, levels stop at + /// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`. pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { let (a, b) = self.para_span(start, end); self.fill_paragraphs(); for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { f(pl); - pl.indent = if pl.list == ListStyle::None { 0 } else { pl.indent.min(MAX_LIST_INDENT) }; + if pl.list == ListStyle::None { + pl.indent = 0; + } + if pl.list != ListStyle::Numbered { + pl.start_offset = 0; + } + pl.indent = pl.indent.min(MAX_LIST_INDENT); } if let Some(first) = self.paragraphs.first().copied() - && first.indent == 0 + && first == (ParaList { list: first.list, ..ParaList::default() }) && self.paragraphs.iter().all(|p| *p == first) { self.list = first.list; @@ -675,11 +685,12 @@ impl TextData { } } - /// The list settings shared by the paragraphs chars `start..end` touch, or None when they differ. + /// The list style and level shared by the paragraphs chars `start..end` touch (the first + /// one's settings), or None when they differ. Start numbers don't count. pub fn common_para_list(&self, start: usize, end: usize) -> Option { let (a, b) = self.para_span(start, end); let first = self.para_list(a); - (a..=b).all(|p| self.para_list(p) == first).then_some(first) + (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) } /// Link chars `start..end` (all of them when the range is empty or the whole text) to text @@ -782,4 +793,29 @@ mod case_tests { assert_eq!(st.variations.keys().collect::>(), ["wdth"]); assert!(valid_axis_tag("wght") && !valid_axis_tag("weight")); } + + #[test] + fn list_starts_load_from_old_files_and_follow_edits() { + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + // Files written before start numbers load with none, and write the same bytes. + let old = r#"{"list":"numbered","indent":1}"#; + let pl: ParaList = serde_json::from_str(old).unwrap(); + assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 }); + assert_eq!(serde_json::to_string(&pl).unwrap(), old); + // One numbered paragraph with a start keeps it (it doesn't fold into `list`). + let mut t = TextData { characters: "Next".into(), ..Default::default() }; + t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 }); + assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 }); + // Enter copies it to the new item; different starts don't make the list mixed. + t.replace_range(4, 4, "\nMore"); + assert_eq!(t.para_list(1).start_offset, 1); + t.set_para_lists(5, 5, |p| p.start_offset = 0); + assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); + // Bullets have no number, so no start. + t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 }); + assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 }); + // A plain paragraph has none. + t.set_para_lists(0, 0, |p| p.list = P); + assert_eq!(t.para_list(0), ParaList::default()); + } } diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index fd0a0f7ed..1165bd600 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -468,9 +468,9 @@ impl VectorNetwork { start_dir(&self.outward(seg, at_a)?) } - /// Whether any point has a stroke cap or join of its own. - pub fn has_point_styles(&self) -> bool { - self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some()) + /// Whether any point has a stroke cap of its own or a join other than the layer's `layer_join`. + pub fn has_point_styles(&self, layer_join: StrokeJoin) -> bool { + self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|j| j != layer_join)) } /// The fill path: every region's loops. diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index f610c38b3..545d872cc 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -20,6 +20,7 @@ figcraft-render = { workspace = true } figcraft-format = { workspace = true } figcraft-svg = { workspace = true } figcraft-pdf = { workspace = true } +craft-docpath = { workspace = true } figcraft-plugin = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/engine/clippy.toml b/crates/engine/clippy.toml new file mode 100644 index 000000000..3ceac8998 --- /dev/null +++ b/crates/engine/clippy.toml @@ -0,0 +1,39 @@ +# The workspace settings (../../clippy.toml; clippy reads only the nearest file), plus the engine's +# own rule: shipped engine code reaches the file system only through `file_access`, which enforces +# the automation roots (#715), so every command is confined without a check of its own. +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +too-many-arguments-threshold = 10 +disallowed-methods = [ + { path = "std::fs::read", reason = "use file_access::read (automation roots)" }, + { path = "std::fs::read_to_string", reason = "use file_access::read (automation roots)" }, + { path = "std::fs::write", reason = "use file_access::write (automation roots)" }, + { path = "std::fs::metadata", reason = "use file_access::metadata (automation roots)" }, + { path = "std::fs::symlink_metadata", reason = "use file_access (automation roots)" }, + { path = "std::fs::read_dir", reason = "use file_access::read_dir (automation roots)" }, + { path = "std::fs::canonicalize", reason = "use file_access (automation roots)" }, + { path = "std::fs::copy", reason = "use file_access (automation roots)" }, + { path = "std::fs::rename", reason = "use file_access (automation roots)" }, + { path = "std::fs::create_dir", reason = "use file_access::create_dir_all (automation roots)" }, + { path = "std::fs::create_dir_all", reason = "use file_access::create_dir_all (automation roots)" }, + { path = "std::fs::remove_file", reason = "use file_access (automation roots)" }, + { path = "std::fs::remove_dir", reason = "use file_access (automation roots)" }, + { path = "std::fs::remove_dir_all", reason = "use file_access (automation roots)" }, + { path = "std::fs::hard_link", reason = "use file_access (automation roots)" }, + { path = "std::fs::File::open", reason = "use file_access::open (automation roots)" }, + { path = "std::fs::File::create", reason = "use file_access::write (automation roots)" }, + { path = "std::fs::File::create_new", reason = "use file_access::write (automation roots)" }, + { path = "std::fs::OpenOptions::open", reason = "use file_access (automation roots)" }, + { path = "std::path::Path::exists", reason = "use file_access::exists (automation roots)" }, + { path = "std::path::Path::try_exists", reason = "use file_access::exists (automation roots)" }, + { path = "std::path::Path::is_file", reason = "use file_access::metadata (automation roots)" }, + { path = "std::path::Path::is_dir", reason = "use file_access::is_dir (automation roots)" }, + { path = "std::path::Path::metadata", reason = "use file_access::metadata (automation roots)" }, + { path = "std::path::Path::symlink_metadata", reason = "use file_access (automation roots)" }, + { path = "std::path::Path::read_dir", reason = "use file_access::read_dir (automation roots)" }, + { path = "std::path::Path::canonicalize", reason = "use file_access (automation roots)" }, + { path = "figcraft_format::write_file_atomic", reason = "use file_access::write (automation roots)" }, + { path = "figcraft_format::save_file", reason = "use file_access::write (automation roots)" }, + { path = "figcraft_format::load_file", reason = "use file_access::read (automation roots)" }, +] diff --git a/crates/engine/src/al_behaviour_tests.rs b/crates/engine/src/al_behaviour_tests.rs index 5d34d5860..4659a5973 100644 --- a/crates/engine/src/al_behaviour_tests.rs +++ b/crates/engine/src/al_behaviour_tests.rs @@ -180,6 +180,23 @@ fn vertical_wrap_columns_take_arrows_and_drops() { assert_eq!(line, Some([30.0, 48.0, 50.0, 48.0])); } +#[test] +fn adding_auto_layout_to_a_frame_keeps_its_size() { + // An existing frame with content hugs it (padding inferred, so the size is preserved); + // an empty one keeps its size instead of collapsing to its padding. + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap()); + rect(&mut s, f, 40.0, 20.0, "#FF0000"); + s.execute("layout.add", &json!({"ids": [f]})).unwrap(); + assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Hug)); + assert_eq!(size(&s, f), (300.0, 200.0)); + // An empty frame keeps its size too. + let e = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap()); + s.execute("layout.add", &json!({"ids": [e]})).unwrap(); + assert_eq!(sizing(&s, e), (Sizing::Fixed, Sizing::Fixed)); + assert_eq!(size(&s, e), (300.0, 200.0)); +} + #[test] fn canvas_stacking_paints_and_exports_the_first_child_on_top() { // Documented: the last child is on top by default; "first on top" flips which one is drawn diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index c8f34fc15..4b6cd3fcc 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -213,6 +213,8 @@ pub static MENU: &[MenuItem] = &[ m!("Text/Case", "Uppercase", "text.caseUpper"), m!("Text/Case", "Lowercase", "text.caseLower"), m!("Text/Case", "Title case", "text.caseTitle"), + m!("Text", SEP, ""), + m!("Text", "Missing fonts…", "text.missingFonts"), // Arrange m!("Arrange", "Align left", "align.left"), m!("Arrange", "Align horizontal centers", "align.hcenter"), @@ -287,7 +289,7 @@ pub fn items() -> impl Iterator { } /// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones). -const OWN: [&str; 2] = ["file.newFromTemplate", "help.gettingStarted"]; +const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"]; /// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer. pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) { diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 43cfcf3c0..ad6c21d12 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -19,6 +19,13 @@ pub fn specs() -> Vec { spec!("align.bottom", "Align bottom", "{ids?}", true, |s, p| align(s, p, Al::Bottom)), spec!("distribute.horizontal", "Distribute horizontal spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, true)), spec!("distribute.vertical", "Distribute vertical spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, false)), + spec!( + "arrange.spacing", + "Spacing", + "{ids?, horizontal?, vertical?, fromGesture?} (page units; fromGesture uses the interaction snapshot)", + true, + spacing + ), spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy), spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)), spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)), @@ -546,3 +553,53 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { } ok() } + +/// Set the distances between smart-selection tracks without rebuilding/reparenting the layers. +fn spacing(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("arrange.spacing", s, p)?; + // A scrub is absolute from the interaction snapshot, even if tracks overlap or cross. + let ds = s.ds()?; + let base = ds.interaction.as_ref().filter(|_| boolean(p, "fromGesture") == Some(true)).map(|i| i.doc.clone()).unwrap_or_else(|| ds.doc.clone()); + let layout = crate::selection_spacing::selection_spacing(&base, &ids); + let mut offsets = std::collections::BTreeMap::, Option)>::new(); + let mut any = false; + for (key, axis, vertical) in [("horizontal", &layout.horizontal, false), ("vertical", &layout.vertical, true)] { + if get(p, key).is_none() { + continue; + } + let value = req_num("arrange.spacing", p, key)?; + if value.abs() > 1_000_000.0 { + return Err(bad("arrange.spacing", "spacing out of range")); + } + let axis = axis.as_ref().ok_or_else(|| bad("arrange.spacing", "selection has no spacing on this axis"))?; + any = true; + for (id, delta) in axis.offsets(value) { + if !delta.is_finite() { + return Err(bad("arrange.spacing", "non-finite spacing result")); + } + let pair = offsets.entry(id).or_default(); + if vertical { + pair.1 = Some(delta); + } else { + pair.0 = Some(delta); + } + } + } + if !any { + return Err(bad("arrange.spacing", "pass horizontal or vertical spacing")); + } + let doc = s.doc_mut()?; + let mut moves = Vec::new(); + for (id, (dx, dy)) in offsets { + let (Some(before), Some(now)) = (base.world_bounds(id), doc.world_bounds(id)) else { continue }; + let (dx, dy) = (dx.map_or(0.0, |d| before.x0 + d - now.x0), dy.map_or(0.0, |d| before.y0 + d - now.y0)); + if !dx.is_finite() || !dy.is_finite() { + return Err(bad("arrange.spacing", "non-finite spacing result")); + } + moves.push((id, dx, dy)); + } + for (id, dx, dy) in moves { + move_world(doc, id, dx, dy)?; + } + ok() +} diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 0fb648167..ff7a56df4 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -68,7 +68,7 @@ pub fn specs() -> Vec { let cmd = "instance.create"; let c = req_id(cmd, p, "component")?; let page = s.page()?; - let parent = id(p, "parent").unwrap_or(page); + let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let doc = s.doc_mut()?; let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; let (x, y) = match (num(p, "x"), num(p, "y")) { @@ -678,8 +678,8 @@ fn push_changes(s: &mut Session, p: &Value) -> Result { if !doc.contains(component) { return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); } - // Copy every overridden layer's values to its source in the main (geometry, swaps, property - // values and slot content stay with the instance). + // Copy every overridden layer's values to its source in the main (geometry, resizing, swaps, + // property values and slot content stay with the instance). let mut pushed = 0; for d in doc.subtree(inst) { let Some(n) = doc.node(d).cloned() else { continue }; @@ -691,7 +691,7 @@ fn push_changes(s: &mut Session, p: &Value) -> Result { let groups: BTreeSet = n .overrides .iter() - .filter(|g| !matches!(g.as_str(), "size" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name")) + .filter(|g| !(matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) || d == inst && g.as_str() == "name")) .cloned() .collect(); for g in &groups { diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 2150701a9..9b3f7067f 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, ok}; use crate::params::*; -use crate::{EngineError, Result, Session, bad, spec}; +use crate::{Result, Session, bad, spec}; /// Frame presets shown by the Frame tool (category, name, width, height). pub const FRAME_PRESETS: &[(&str, &str, f64, f64)] = &[ @@ -167,7 +167,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?; let (mut kind, base) = kind_for(&t).ok_or_else(|| bad(cmd, format!("unknown type `{t}`")))?; let page = s.page()?; - let parent = id(p, "parent").unwrap_or(page); + let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let doc = s.doc()?; let pn = doc.get(parent)?; if !pn.kind.is_container() { @@ -255,7 +255,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { node.width = tw; node.height = th; } - let index = num(p, "index").map(|i| i.max(0.0) as usize); + let index = crate::params::opt_index(cmd, p, "index")?; doc.insert(node, parent, index)?; if let Some(Value::Object(props)) = get(p, "props") { super::props::apply(cmd, doc, nid, props)?; @@ -313,12 +313,12 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> { #[cfg(not(target_arch = "wasm32"))] fn read_path(path: &str) -> Result> { - std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + crate::file_access::read(path) } #[cfg(target_arch = "wasm32")] fn read_path(path: &str) -> Result> { - Err(EngineError::Other(format!("{path}: files can't be read in the browser"))) + Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser"))) } /// Place an image as a rectangle with an image fill (Figma's image layer). @@ -340,7 +340,7 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result { _ => ImageScaleMode::Fill, }; let page = s.page()?; - let parent = id(p, "parent").unwrap_or(page); + let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let ds = s.ds_mut()?; let doc = ds.doc_mut(); let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2c767b933..a4769a35c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -25,6 +25,13 @@ pub fn specs() -> Vec { spec!("edit.copy", "Copy", "{ids?}", false, copy), spec!("edit.cut", "Cut", "{ids?}", true, cut), spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste), + spec!( + "edit.pasteDesignLayers", + "Paste design layers", + "{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes", + true, + paste_design_layers + ), spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over), spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace), spec!("edit.copyProperties", "Copy properties", "{id?}", false, copy_props), @@ -496,9 +503,27 @@ fn bump_name(n: &str) -> String { } /// The copied form of the selection. -pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { +pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> { let ds = s.ds()?; - let doc = &ds.doc; + // Pages are not layers: they can't be pasted anywhere. + let layers: Vec = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect(); + if layers.is_empty() && !ids.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into())); + } + let (nodes, images, bounds) = copied(&ds.doc, &layers); + let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid); + s.clipboard.source = Some(source); + s.clipboard.source_path = source_path; + s.clipboard.source_doc = uid; + s.clipboard.nodes = nodes; + s.clipboard.images = images; + s.clipboard.bounds = bounds; + Ok(()) +} + +type Copied = (Vec<(NodeId, Vec)>, BTreeMap, Option); + +fn copied(doc: &Document, ids: &[NodeId]) -> Copied { let ids = z_sorted(doc, &roots(doc, ids)); let mut nodes = Vec::new(); let mut images = BTreeMap::new(); @@ -521,28 +546,73 @@ pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { nodes.push((*i, sub)); } let bounds = doc.bounds_of(&ids); - let uid = ds.uid; - let source = Some(ds.doc.clone()); - let source_path = ds.path.clone(); - s.clipboard.source = source; - s.clipboard.source_path = source_path; - s.clipboard.nodes = nodes; - s.clipboard.images = images; - s.clipboard.bounds = bounds; - s.clipboard.source_doc = uid; - Ok(()) + (nodes, images, bounds) +} + +/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them. +pub const CLIP_OPEN: &str = "(figma)"; +pub const CLIP_CLOSE: &str = "(/figma)"; +/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its +/// base64 is checked first, so neither the text nor the decoded bytes can pass it. +const MAX_CLIP_DATA: usize = 256 * 1024 * 1024; +const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4; + +/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`]. +fn clip_base64(text: &str) -> Option> { + use base64::Engine as _; + if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 { + return None; + } + let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok() +} + +/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in +/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML +/// holds no readable design data (it may just mention the marker). +pub fn design_clipboard_buffer(html: &str) -> Option> { + let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len(); + let len = html.get(start..)?.find(CLIP_CLOSE)?; + let bytes = clip_base64(html.get(start..start + len)?)?; + figcraft_format::fig::is_fig(&bytes).then_some(bytes) +} + +fn paste_design_layers(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "edit.pasteDesignLayers"; + let bytes = match (string(p, "html"), string(p, "data")) { + (Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?, + (None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?, + (None, None) => return Err(bad(CMD, "pass `html` or `data`")), + }; + let other = super::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??; + let doc = std::sync::Arc::new(other.doc); + let tops: Vec = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect(); + if tops.is_empty() { + return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into())); + } + let (nodes, images, bounds) = copied(&doc, &tops); + let props = s.clipboard.props.take(); + // No open file has id 0. So paste treats this copy as a copy from a different file. + s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None }; + let mut out = paste(s, p)?; + if !other.issues.is_empty() { + out["warnings"] = json!(other.issues); + } + Ok(out) } fn copy(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.copy", s, p)?; - copy_to_clipboard(s, &ids)?; + copy_to_clipboard(s, "edit.copy", &ids)?; Ok(json!({"copied": s.clipboard.nodes.len()})) } fn cut(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.cut", s, p)?; - copy_to_clipboard(s, &ids)?; - delete(s, &json!({"ids": ids})) + copy_to_clipboard(s, "edit.cut", &ids)?; + let doc = s.doc()?; + let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); + delete(s, &json!({"ids": layers})) } /// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords). @@ -1086,9 +1156,34 @@ fn set(s: &mut Session, p: &Value) -> Result { .collect(), None => Vec::new(), }; + let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props); + // Auto layout keys go to the layers that can have auto layout; with none among the targets, + // the first refuses them (`props::layout_mut`). + let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect(); + let mut without_layout = props.clone(); + let mut skipped = BTreeSet::new(); + if !layout_keys.is_empty() { + let doc = &s.ds()?.doc; + let frame_like = |i: &NodeId| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()); + if ids.iter().any(frame_like) { + skipped = ids.iter().copied().filter(|i| !frame_like(i)).collect(); + without_layout.retain(|k, _| !layout_keys.contains(&k.as_str())); + } + if !skipped.is_empty() { + let names: Vec = + skipped.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect(); + let more = if skipped.len() > names.len() { format!(" and {} more", skipped.len() - names.len()) } else { String::new() }; + warnings.push(format!( + "{} skipped on {}{more}: only frames, components and instances have auto layout", + layout_keys.iter().map(|k| format!("`{k}`")).collect::>().join(", "), + names.join(", ") + )); + } + } let doc = s.doc_mut()?; for i in ids { - super::props::apply("node.set", doc, i, &props)?; + let props = if skipped.contains(&i) { &without_layout } else { &props }; + super::props::apply("node.set", doc, i, props)?; } if let Some(o) = pivot { for (i, q) in pinned { @@ -1098,7 +1193,43 @@ fn set(s: &mut Session, p: &Value) -> Result { } } } - ok() + if warnings.is_empty() { ok() } else { Ok(json!({"ok": true, "warnings": warnings})) } +} + +/// A `fontStyle` draws in the family's face nearest to it, as a text layer resolves it. When that +/// face has another weight or slant than the name asks for ("Black" on a family whose heaviest is +/// Bold), say which face it is. Families that aren't installed, and names that match a style or a +/// named instance (spelled any way: "SemiBold", "Semi Bold"), say nothing. +fn font_style_warnings(doc: &figcraft_doc::Document, ids: &[NodeId], props: &serde_json::Map) -> Vec { + let Some(style) = props.get("fontStyle").and_then(Value::as_str) else { return Vec::new() }; + let mut families: Vec = match props.get("fontFamily").and_then(Value::as_str) { + Some(f) => vec![f.to_string()], + None => ids + .iter() + .filter_map(|i| doc.node(*i)) + .filter_map(|n| n.kind.text()) + .flat_map(|t| t.spans().into_iter().map(|(_, _, st)| st.font_family.clone()).collect::>()) + .collect(), + }; + families.sort(); + families.dedup(); + let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], ""); + let (want_w, want_i) = (i32::from(figcraft_doc::style_weight(style)), style.to_lowercase().contains("italic")); + families + .into_iter() + .filter_map(|family| { + let styles = figcraft_text::family_styles(&family); + if styles.is_empty() || styles.iter().any(|x| norm(x) == norm(style)) { + return None; + } + let face = figcraft_text::find_face(&family, style).filter(|f| f.family.eq_ignore_ascii_case(&family))?; + let w = face.axis("wght").map_or(i32::from(face.weight), |a| a.clamp(f64::from(want_w)).round() as i32); + if face.instance(style).is_some() || (w == want_w && face.italic == want_i) { + return None; + } + Some(format!("{family} has no `{style}` style: it draws in {}, the nearest (it has: {})", face.style, styles.join(", "))) + }) + .collect() } /// `stroke.widthProfile`: a preset or points (through `node.set`'s `widthProfile`, so instances @@ -1177,7 +1308,7 @@ fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result Result { let cmd = if fills { "fill.set" } else { "stroke.set" }; let ids = req_targets(cmd, s, p)?; - let idx = num(p, "index").map(|x| x.max(0.0) as usize); + let idx = opt_index(cmd, p, "index")?; let new_paint = match get(p, "paint") { Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), None => None, @@ -1225,7 +1356,7 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result { const CMD: &str = "fill.pattern"; let src = req_id(CMD, p, "source")?; let doc = s.doc()?; - if !doc.node(src).is_some_and(|n| !n.is_page()) { + if doc.node(src).is_none_or(|n| n.is_page()) { return Err(bad(CMD, "`source` must be a layer")); } if req_targets(CMD, s, p)?.iter().any(|t| doc.is_ancestor(*t, src)) { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 54348372f..511e0fc6b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { spec!( "file.open", "Open", - "{path, variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`", + "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)", false, open ), @@ -41,8 +41,20 @@ pub fn specs() -> Vec { false, open_recent ), - spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save), - spec!("file.saveAs", "Save as", "{path}", false, save_as), + spec!( + "file.save", + "Save", + "{path?} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + false, + save + ), + spec!( + "file.saveAs", + "Save as", + "{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + false, + save_as + ), spec!( "file.export", "Export", @@ -74,7 +86,7 @@ pub fn specs() -> Vec { spec!( "file.import", "Import", - "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`)", + "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)", true, import ), @@ -423,7 +435,7 @@ pub(crate) fn recovered(path: &str) -> Result { #[cfg(not(target_arch = "wasm32"))] fn read(path: &str) -> Result> { - std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + crate::file_access::read(path) } #[cfg(target_arch = "wasm32")] @@ -433,11 +445,7 @@ fn read(path: &str) -> Result> { /// The bytes of file `path`, refused when it's larger than `max`. pub(crate) fn read_capped(path: &str, max: u64) -> Result> { - let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - if !meta.is_file() || meta.len() > max { - return Err(EngineError::Other(format!("{path}: not a file of at most {max} bytes"))); - } - read(path) + crate::file_access::read_capped(path, max) } /// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one @@ -445,7 +453,7 @@ pub(crate) fn read_capped(path: &str, max: u64) -> Result> { /// truncates `path`, and no file or symlink at a staging name is ever written through. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> { - figcraft_format::write_file_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + crate::file_access::write(path, bytes) } #[cfg(target_arch = "wasm32")] @@ -461,6 +469,11 @@ fn file_stem(path: &str) -> String { std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string() } +/// The counts the import report shows for a freshly loaded document. +fn import_summary(doc: &Document) -> Value { + json!({"name": doc.name, "pages": doc.pages.len(), "layers": doc.nodes().filter(|n| !n.is_page()).count(), "images": doc.images.len()}) +} + /// Load a document from any supported file (REST API file JSON images come from an `images` /// folder next to the file). pub fn load(path: &str) -> Result<(Document, Vec)> { @@ -506,10 +519,8 @@ fn load_with( Ok((doc.into(), false)) } _ => { - if let Some(r) = figcraft_format::fig::try_import(bytes, &file_stem(name_hint)) { - let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; - link_instances(&mut doc); - return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); + if let Some(r) = load_fig(bytes, &file_stem(name_hint)) { + return Ok((r?, true)); } if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; @@ -525,10 +536,20 @@ fn load_with( } } +/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. +pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { + let r = figcraft_format::fig::try_import(bytes, name)?; + Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + link_instances(&mut doc); + figcraft_format::Loaded { issues: warnings, ..doc.into() } + })) +} + /// Link imported instances to their mains (layer sources, overrides found by comparing values), /// innermost first so outer instances settle last. fn link_instances(doc: &mut Document) { crate::style_vars::after_load(doc); + super::styles::apply_paint_bindings_on_load(doc); let mut insts: Vec<(usize, NodeId)> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect(); insts.sort_by_key(|a| std::cmp::Reverse(a.0)); @@ -552,11 +573,9 @@ fn rest_images(path: &str) -> impl Fn(&str) -> Option> { return None; } let dir = dir.as_ref()?; - ["", ".png", ".jpg", ".jpeg", ".gif", ".webp"].iter().find_map(|e| { - let p = dir.join(format!("{r}{e}")); - let meta = std::fs::metadata(&p).ok()?; - if meta.is_file() && meta.len() <= MAX_IMAGE_FILE { std::fs::read(&p).ok() } else { None } - }) + ["", ".png", ".jpg", ".jpeg", ".gif", ".webp"] + .iter() + .find_map(|e| crate::file_access::read_capped(dir.join(format!("{r}{e}")), MAX_IMAGE_FILE).ok()) } } @@ -579,11 +598,7 @@ fn sibling_variables(path: &str) -> Option { let file = std::path::Path::new(path); let dir = file.parent()?; [format!("{}.variables.json", file_stem(path)), "variables.json".to_string()].iter().map(|n| dir.join(n)).filter(|c| c != file).find_map(|c| { - let meta = std::fs::metadata(&c).ok()?; - if !meta.is_file() || meta.len() > MAX_VARIABLES_FILE { - return None; - } - let v: Value = serde_json::from_slice(&std::fs::read(&c).ok()?).ok()?; + let v: Value = serde_json::from_slice(&crate::file_access::read_capped(c, MAX_VARIABLES_FILE).ok()?).ok()?; figcraft_format::rest::is_variables_json(&v).then_some(v) }) } @@ -621,20 +636,29 @@ fn remember(s: &mut Session, path: &str) { } fn open_recent(s: &mut Session, p: &Value) -> Result { + use crate::file_access::{Access, allow, allowed}; const CMD: &str = "file.openRecent"; + // Confined to automation roots, the recent files outside them are never listed, opened, removed + // or named: `index` counts the files listed. + let listed = |r: &String| allowed(r, Access::Read); if p.get("clear").and_then(Value::as_bool) == Some(true) { - s.prefs.recent_files.clear(); + s.prefs.recent_files.retain(|r| !listed(r)); } if let Some(gone) = string(p, "remove") { - s.prefs.recent_files.retain(|r| *r != gone); + s.prefs.recent_files.retain(|r| *r != gone || !listed(r)); } let path = match (string(p, "path"), num(p, "index")) { - (Some(path), _) => Some(path), - (None, Some(i)) => Some(s.prefs.recent_files.get(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?), + (Some(path), _) => { + allow(&path, Access::Read)?; + Some(path) + } + (None, Some(i)) => { + Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?) + } _ => None, }; if let Some(path) = path { - if !std::path::Path::new(&path).exists() { + if !crate::file_access::exists(&path) { s.prefs.recent_files.retain(|r| *r != path); return Err(bad(CMD, format!("{path} no longer exists"))); } @@ -644,17 +668,23 @@ fn open_recent(s: &mut Session, p: &Value) -> Result { .prefs .recent_files .iter() + .filter(|r| listed(r)) .map(|r| { let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned()); - json!({"path": r, "name": name, "exists": std::path::Path::new(r).exists()}) + json!({"path": r, "name": name, "exists": crate::file_access::exists(r)}) }) .collect(); Ok(json!({"files": files})) } fn open(s: &mut Session, p: &Value) -> Result { - let path = req_string("file.open", p, "path")?; let variables = variables_param("file.open", p)?; + if string(p, "path").is_none() + && let Some(d) = string(p, "data") + { + return open_data(s, &d, p, variables.as_ref()); + } + let path = req_string("file.open", p, "path")?; // Already open: switch to it. if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) { s.active = i; @@ -672,9 +702,26 @@ fn open(s: &mut Session, p: &Value) -> Result { more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]), }); } + let summary = import_summary(&loaded.doc); s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches)); s.active = s.docs.len() - 1; - let mut out = json!({"index": s.active}); + let mut out = json!({"index": s.active, "summary": summary}); + out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); + Ok(out) +} + +/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells +/// the format. It opens as a new document with no path, so the first save asks where to put it. +fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result { + let name = req_string("file.open", p, "name")?; + let data = data.split_once(',').map_or(data, |(_, b)| b); + let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?; + let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?; + let warnings = rest || ext(&name) == "svg"; + let summary = import_summary(&loaded.doc); + s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches)); + s.active = s.docs.len() - 1; + let mut out = json!({"index": s.active, "summary": summary}); out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); Ok(out) } @@ -775,8 +822,20 @@ fn color_profile(s: &mut Session, p: &Value) -> Result { Ok(json!({"profile": profile.id(), "changed": changed})) } -/// Save the file of the active tab (the main file for a branch tab, with its branches). +/// Native saves act on the current document; a `.fig` copy exports the version shown, and a +/// pending Save As choice must leave it shown until its destination is known. +pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { + let path = + string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); + path.is_some_and(|path| ext(&path) != "fig") +} + +/// Save the file of the active tab (the main file for a branch tab, with its branches), or export +/// the active document as an experimental `.fig` copy without changing its native save state. fn save_to(s: &mut Session, path: String) -> Result { + if ext(&path) == "fig" { + return export_fig(s, &json!({"path": path})); + } let i = crate::branches::file_tab(s); let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; let bytes = file_bytes(ds)?; @@ -795,6 +854,11 @@ fn save(s: &mut Session, p: &Value) -> Result { return save_to(s, path); } match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() { + // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. + Some(path) if ext(&path) == "fig" => { + s.request_ui("dialog.saveAs", json!({})); + Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) + } Some(path) => save_to(s, path), None => { s.request_ui("dialog.saveAs", json!({})); @@ -1119,7 +1183,7 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { let path = std::path::Path::new(d).join(&job.file); // A layer name with slashes exports into folders. if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); + let _ = crate::file_access::create_dir_all(parent); } let path = path.to_string_lossy().to_string(); write(&path, &bytes)?; @@ -1215,8 +1279,9 @@ fn import(s: &mut Session, p: &Value) -> Result { }; let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None }; let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?; + let summary = import_summary(&other.doc); let pages = append_pages(s.doc_mut()?, &other.doc)?; - let mut out = json!({"pages": pages}); + let mut out = json!({"pages": pages, "summary": summary}); if rest { out["warnings"] = json!(other.issues); } @@ -1252,6 +1317,19 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result> { map.insert(id, doc.alloc_id()); } } + // Library copies a .fig import keeps (cached definitions) have no library record to follow: + // the layers of its store come along into the store of `doc`. Recorded copies are left to + // `merge_store`, which shares them with copies `doc` already has. + let cached: Vec = other + .library_store() + .and_then(|s| other.node(s)) + .map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect()) + .unwrap_or_default(); + for root in &cached { + for id in other.subtree(*root) { + map.insert(id, doc.alloc_id()); + } + } let mut new_pages = Vec::new(); for pg in &other.pages { let Some(pn) = other.node(*pg) else { continue }; @@ -1269,6 +1347,13 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result> { } new_pages.push(np); } + if !cached.is_empty() { + let store = doc.ensure_library_store(); + for root in &cached { + let nodes: Vec = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, *root, store, None)?; + } + } for new in map.values() { let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied()); if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) { @@ -1280,7 +1365,7 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result> { } fn close(s: &mut Session, p: &Value) -> Result { - let i = num(p, "index").map(|x| x as usize).unwrap_or(s.active); + let i = if get(p, "index").is_none() { s.active } else { req_index("file.close", p, "index")? }; let Some(ds) = s.docs.get(i) else { return Err(bad("file.close", "no such document")) }; // A branch tab's edits are in its main file already (which then has unsaved changes). if ds.branch_of.is_none() && ds.is_dirty() && !boolean(p, "force").unwrap_or(false) { @@ -1334,6 +1419,70 @@ mod rest_import_tests { doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap() } + /// What a .fig import with a cached library component leaves: the main in the hidden store, a + /// visible instance of it and a layer linked to the main's layer. + fn cached_import() -> Document { + let mut doc = Document::new("Cached"); + let page = doc.first_page().unwrap(); + let store = doc.ensure_library_store(); + let main = doc + .create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0) + .unwrap(); + let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap(); + let inst = + doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap(); + let mut copy = doc.node(body).unwrap().clone(); + copy.id = doc.alloc_id(); + copy.parent = None; + copy.source = Some(body); + let copy_id = copy.id; + doc.insert(copy, inst, None).unwrap(); + assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body)); + doc + } + + /// Pasting layers that use a cached main into another document leaves the main behind, as it + /// does for other mains that are not published: no link points at a layer of the target. + #[test] + fn pasting_instances_of_cached_components_leaves_no_dangling_links() { + let src = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id; + let ids = src.subtree(inst); + let map: std::collections::BTreeMap = ids.iter().map(|i| (*i, doc.alloc_id())).collect(); + let nodes: Vec = ids.iter().filter_map(|i| src.node(*i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, inst, page, None).unwrap(); + crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap(); + let pasted = doc.node(map[&inst]).unwrap(); + let NodeKind::Instance { component, .. } = pasted.kind else { panic!() }; + assert!(!doc.contains(component), "the main stays behind: {component:?}"); + assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s))); + } + + #[test] + fn appending_pages_with_cached_library_components_brings_the_components() { + let other = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + // Ids of the target that would collide with the other document's store. + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + append_pages(&mut doc, &other).unwrap(); + assert!(doc.validate().is_empty(), "{:?}", doc.validate()); + assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc)); + let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap(); + let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; + assert_eq!(doc.node(component).unwrap().name, "Cached main"); + assert!(doc.is_remote(component), "the main comes along into the store"); + let layer = doc.node(inst.children[0]).unwrap(); + assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied()); + } + #[test] fn rest_json_loads_with_linked_instances() { let bytes = serde_json::to_vec(&kit()).unwrap(); @@ -1351,6 +1500,25 @@ mod rest_import_tests { assert!(thumbnail(&doc).is_some()); } + #[test] + fn open_takes_data_and_a_name_as_a_new_unsaved_document() { + let mut saved = Session::new(); + saved.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let bytes = file_bytes(saved.ds().unwrap()).unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + let mut s = Session::new(); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.open", &json!({"data": data, "name": "Dropped.figcraft"})).unwrap(); + let open = s.docs.len(); + assert_eq!(r["index"], open - 1, "a document of its own, made active"); + assert_eq!(s.ds().unwrap().path, None, "no path to save over"); + assert!(s.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle))); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + assert!(s.execute("file.open", &json!({"data": data})).is_err(), "the name tells the format"); + assert!(s.execute("file.open", &json!({"data": "not base64!", "name": "x.svg"})).is_err()); + assert_eq!(s.docs.len(), open, "a refused open adds nothing"); + } + #[test] fn open_and_import_rest_json() { let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6579494ca..f011238d9 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -71,6 +71,7 @@ fn registry() -> &'static Registry { list.extend(create::specs()); list.extend(edit::specs()); list.extend(lists::specs()); + list.extend(crate::selection_colors::specs()); list.extend(arrange::specs()); list.extend(structure::specs()); list.extend(section::specs()); @@ -79,6 +80,7 @@ fn registry() -> &'static Registry { list.extend(page::specs()); list.extend(view::specs()); list.extend(text::specs()); + list.extend(crate::fonts::specs()); list.extend(styles::specs()); list.extend(image::specs()); list.extend(variables::specs()); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 598be752f..ca62962da 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -265,6 +265,56 @@ fn ensure_layout(n: &mut Node) -> &mut AutoLayout { n.layout.get_or_insert_with(|| AutoLayout { padding: [0.0; 4], gap: 0.0, ..Default::default() }) } +/// The keys that set a frame's auto layout (through [`layout_mut`]). +pub const LAYOUT_KEYS: &[&str] = &[ + "gap", + "itemSpacing", + "columnGap", + "crossGap", + "counterAxisSpacing", + "rowGap", + "padding", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + "paddingHorizontal", + "paddingVertical", + "mainAlign", + "primaryAxisAlignItems", + "crossAlign", + "counterAxisAlignItems", + "contentAlign", + "counterAxisAlignContent", + "wrap", + "gridColumns", + "gridRows", + "strokesIncluded", + "reverseZ", + "columnTracks", + "gridColumnTracks", + "rowTracks", + "gridRowTracks", +]; + +/// The auto layout of a frame-like node, created when it has none yet. Other nodes can't hold an +/// auto layout: an auto layout key on them is refused, not stored where nothing reads it +/// (`node.set` on several layers skips them instead, see `edit::set`). +fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLayout> { + if !n.kind.is_frame_like() { + return Err(bad( + cmd, + format!( + "`{k}` is an auto layout property, and layer {} “{}” is a {} (only frames, components and instances have auto layout)", + n.id, + n.name, + n.kind.type_name() + ), + )); + } + Ok(ensure_layout(n)) +} + /// Apply a property map to one node. `x`/`y` are relative to the parent. pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> { let mut unknown = Vec::new(); @@ -452,56 +502,56 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { - ensure_layout(doc.get_mut(id)?).gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? + layout_mut(cmd, k, doc.get_mut(id)?)?.gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? } "crossGap" | "counterAxisSpacing" | "rowGap" => { - ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? + layout_mut(cmd, k, doc.get_mut(id)?)?.cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? } "padding" => { let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?; - ensure_layout(doc.get_mut(id)?).padding = p.map(|x| x.max(0.0)); + layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.max(0.0)); } "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => { let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0); let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); - if let Some(slot) = ensure_layout(doc.get_mut(id)?).padding.get_mut(i) { + if let Some(slot) = layout_mut(cmd, k, doc.get_mut(id)?)?.padding.get_mut(i) { *slot = val; } } "paddingHorizontal" => { let val = f64_of(v).unwrap_or(0.0).max(0.0); - let l = ensure_layout(doc.get_mut(id)?); + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; l.padding[1] = val; l.padding[3] = val; } "paddingVertical" => { let val = f64_of(v).unwrap_or(0.0).max(0.0); - let l = ensure_layout(doc.get_mut(id)?); + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; l.padding[0] = val; l.padding[2] = val; } "mainAlign" | "primaryAxisAlignItems" => { - ensure_layout(doc.get_mut(id)?).main_align = + layout_mut(cmd, k, doc.get_mut(id)?)?.main_align = enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))? } "crossAlign" | "counterAxisAlignItems" => { - ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))? + layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))? } "contentAlign" | "counterAxisAlignContent" => { - ensure_layout(doc.get_mut(id)?).content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))? + layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))? } - "wrap" => ensure_layout(doc.get_mut(id)?).wrap = v.as_bool().unwrap_or(false), + "wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false), "gridColumns" | "gridRows" => { let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; - let l = ensure_layout(doc.get_mut(id)?); + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; // Tracks past the new count go away (new tracks are 1fr). let (n, tracks) = if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) }; *n = count; tracks.truncate(count as usize); } - "strokesIncluded" => ensure_layout(doc.get_mut(id)?).strokes_included = v.as_bool().unwrap_or(false), - "reverseZ" => ensure_layout(doc.get_mut(id)?).reverse_z = v.as_bool().unwrap_or(false), + "strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false), + "reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false), "sizingH" | "layoutSizingHorizontal" => { let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?; set_sizing(cmd, doc, id, s, true)?; @@ -533,7 +583,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?; let tracks = a.iter().map(track_of).collect::>>().ok_or_else(|| bad(cmd, TRACK_HELP))?; - let l = ensure_layout(doc.get_mut(id)?); + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; if k.contains("olumn") { l.grid_columns = (tracks.len() as u32).clamp(1, 1000); l.column_tracks = tracks; @@ -783,12 +833,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map &'static str { "features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings" | "hangingPunctuation" | "hangingList" => "textStyle", "cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners", - "width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size", + "width" | "height" | "w" | "h" => "size", + "sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing", "gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap", "padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" | "paddingHorizontal" | "paddingVertical" => "padding", "mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" | "contentAlign" | "counterAxisAlignContent" => "alignment", diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index ca91549b4..1c86f9328 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -26,10 +26,17 @@ pub fn specs() -> Vec { spec!( "object.convertToFrame", "Convert to frame", - "{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)", + "{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping; sections become frames that keep their fill and don't clip. Frames are left alone; instances, main components, layers inside instances and sections holding sections are refused)", true, |s, p| convert(s, p, "object.convertToFrame", true) ), + spec!( + "object.convertToSection", + "Convert to section", + "{ids?} (frames on the page or in a section become sections in place, keeping their fill and layers; what only a frame has goes: auto layout, clipping, layout grids, guides and scrolling. Sections are left alone; nested frames, instances and main components are refused)", + true, + convert_to_section + ), spec!( "object.wrapInSection", "Wrap in new section", @@ -199,6 +206,13 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res let n = doc.get(i)?; let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name))); match (&n.kind, to_frame) { + // A frame can't hold sections. + (NodeKind::Section { .. }, true) + if n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Section { .. }))) => + { + return refuse("holds sections, which can't go inside a frame: convert those first"); + } + (NodeKind::Section { .. }, true) => todo.push(i), (NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"), (NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"), (NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => { @@ -212,12 +226,15 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res } for i in &todo { if to_frame { - // The group's box as it is; nothing painted or clipped, so the look stays. + // The box as it is and nothing clipped, so the look stays: a group has nothing painted, a + // section keeps its fill. let n = doc.get_mut(*i)?; + if matches!(n.kind, NodeKind::Group) { + n.fills.clear(); + n.strokes.clear(); + } n.kind = NodeKind::Frame; n.clip = false; - n.fills.clear(); - n.strokes.clear(); continue; } let n = doc.get_mut(*i)?; @@ -242,6 +259,43 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res Ok(json!({"ids": todo})) } +/// Frames on the page or in a section become sections in place (sections only sit there). +fn convert_to_section(s: &mut Session, p: &Value) -> Result { + let cmd = "object.convertToSection"; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut todo = Vec::new(); + for i in ids { + let n = doc.get(i)?; + let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name))); + let on_board = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })); + match &n.kind { + NodeKind::Section { .. } => {} + NodeKind::Instance { .. } => return refuse("is an instance: its layers come from its main component (detach it first)"), + NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => return refuse("is a main component, which stays a frame"), + NodeKind::Frame if !on_board => return refuse("is inside a frame or group: only frames on the page or in a section become sections"), + NodeKind::Frame => todo.push(i), + _ => return refuse("is not a frame"), + } + } + for i in &todo { + let n = doc.get_mut(*i)?; + n.kind = NodeKind::Section { contents_hidden: false }; + (n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None); + n.grids.clear(); + n.guides.clear(); + n.styles.grid = None; + n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing")); + let kids = n.children.clone(); + for k in kids { + // Out of the flow there is nothing to fill or to be absolute against. + doc.get_mut(k)?.child_layout.absolute = false; + } + figcraft_layout::coherent_sizing(doc, *i); + } + Ok(json!({"ids": todo})) +} + fn ungroup(s: &mut Session, p: &Value) -> Result { let ids = req_targets("object.ungroup", s, p)?; let doc = s.doc_mut()?; @@ -309,33 +363,46 @@ fn flatten(s: &mut Session, p: &Value) -> Result { let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?; let index = doc.index_in_parent(top).map(|i| i + 1); let pw = doc.world_transform(parent); - let mut path = BezPath::new(); - let mut even = false; + let mut net = VectorNetwork::default(); for i in &ids { let Some(n) = doc.node(*i) else { continue }; - // Booleans flatten to their result; strokes don't get outlined. - let Some((mut op, rule)) = (match &n.kind { - NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => figcraft_render::subtree_outline(doc, n, 0), - _ => figcraft_render::node_outline(doc, n), - }) else { - continue; + // Vectors keep their own network (open paths and unfilled loops have no fill outline); + // booleans flatten to their result; strokes don't get outlined. + let mut part = match &n.kind { + NodeKind::Vector { network } => network.clone(), + _ => { + let Some((op, rule)) = (match &n.kind { + NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => { + figcraft_render::subtree_outline(doc, n, 0) + } + _ => figcraft_render::node_outline(doc, n), + }) else { + continue; + }; + let mut part = VectorNetwork::from_path(&op); + if rule == figcraft_render::FillRule::EvenOdd { + for r in &mut part.regions { + r.winding = WindingRule::EvenOdd; + } + } + part + } }; - even |= rule == figcraft_render::FillRule::EvenOdd; // Into the common parent's space. - let w = doc.world_transform(*i); - op.apply_affine(pw.inverse() * w); - path.extend(op.elements().iter().copied()); + let all: Vec = (0..part.vertices.len()).collect(); + crate::vectoredit::ops::transform_vertices(&mut part, &all, pw.inverse() * doc.world_transform(*i)); + let (nv, ns) = (net.vertices.len(), net.segments.len()); + net.vertices.extend(part.vertices); + net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s })); + net.regions.extend( + part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }), + ); } - if path.elements().is_empty() { + let Some(bb) = net.bounds() else { return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into())); - } + }; let first = ids.first().and_then(|i| doc.node(*i)).cloned(); - let bb = path.bounding_box(); - path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let mut net = VectorNetwork::from_path(&path); - for r in &mut net.regions { - r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero }; - } + net.translate(-bb.x0, -bb.y0); let vid = doc.alloc_id(); let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into()); let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height()); @@ -480,11 +547,16 @@ fn layout_add(s: &mut Session, p: &Value) -> Result { let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) } }); + let empty = doc.node(*f).is_some_and(|n| n.children.iter().filter_map(|c| doc.node(*c)).all(|c| !c.visible || c.child_layout.absolute)); let n = doc.get_mut(*f)?; n.children = kids; n.layout = Some(al); - n.child_layout.sizing_h = Sizing::Hug; - n.child_layout.sizing_v = Sizing::Hug; + if !all_frames || !empty { + // A frame wrapped around the selection hugs it; an existing frame with no flow + // children keeps its size instead of collapsing to its padding. + n.child_layout.sizing_h = Sizing::Hug; + n.child_layout.sizing_v = Sizing::Hug; + } } s.set_selection(targets.clone()); Ok(json!({"ids": targets})) @@ -559,6 +631,10 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { for i in req_targets("vector.offset", s, p)? { let doc = s.doc_mut()?; let n = doc.get(i)?.clone(); + // A vector can't hold children: leave containers (frames, groups, instances) alone. + if !n.children.is_empty() { + continue; + } let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue }; let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() }; let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props); diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 552a44cfb..25e36e3ad 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -913,6 +913,24 @@ pub fn apply_bindings(doc: &mut Document) { /// [`apply_bindings`] for the layers `only` (every bound layer when `None`). pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTreeSet>) { + apply_bindings_matching(doc, only, |_| true); +} + +/// Settle imported paint colours before the document is first shown. Other bindings can affect +/// layout or component structure, which must be resolved through the normal edit pipeline. +pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) { + let pending = doc.take_dirty(); + apply_bindings_matching(doc, None, |field| { + super::lists::list_binding(field).is_some_and(|(list, _, prop)| { + matches!(list, super::lists::BoundList::Fills | super::lists::BoundList::Strokes) + && (prop == "color" || super::lists::stop_binding(prop).is_some()) + }) + }); + doc.take_dirty(); + doc.mark_dirty(pending); +} + +fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet>, include: impl Fn(&str) -> bool) { use super::lists::{BoundList, list_binding}; if doc.variables.variables.is_empty() { return; @@ -932,6 +950,9 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr let mut grid_sets: Vec<(usize, String, f64)> = Vec::new(); let mut prop_sets: Vec<(String, VariableValue)> = Vec::new(); for (field, var) in binds { + if !include(&field) { + continue; + } let Some(val) = doc.variables.resolve(&var, &modes) else { continue }; // One list item's value: a paint's colour, an effect's or a layout grid's field. if let Some((list, i, prop)) = list_binding(&field) { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 14ea17621..3b861895a 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -332,7 +332,7 @@ pub fn specs() -> Vec { spec!( "text.preview", "Preview text change", - "{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step", + "{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered font family or style, text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step", false, |s, p| { let c = req_string("text.preview", p, "command")?; diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 99a786400..f3b9a820d 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -25,7 +25,7 @@ pub fn specs() -> Vec { spec!( "variable.import", "Import variables", - "{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings}", + "{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}", true, import ), diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index de69e6908..69b938e91 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -19,30 +19,58 @@ fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Resul Ok(json!({"on": *slot})) } +/// One of a view's on/off settings. +type Slot = fn(&mut ViewState) -> &mut bool; + +/// The View menu's on/off toggles (Pixel preview, with its scale, has its own command), and +/// whether the preferences keep each: files opened or made later start with the kept ones. Outline +/// modes and the sRGB soft-proof stay with their file, so a new file never opens in one. +const TOGGLES: &[(&str, Slot, bool)] = &[ + ("view.pixelGrid", |v| &mut v.pixel_grid, true), + ("view.layoutGrids", |v| &mut v.layout_grids, true), + ("view.rulers", |v| &mut v.rulers, true), + ("view.slices", |v| &mut v.show_slices, true), + ("view.comments", |v| &mut v.comments, true), + ("view.multiplayerCursors", |v| &mut v.multiplayer_cursors, true), + ("view.outlines", |v| &mut v.outlines, false), + ("view.outlinesSelection", |v| &mut v.outlines_selection, false), + ("view.outlinesHiddenLayers", |v| &mut v.outline_hidden, true), + ("view.outlinesObjectBounds", |v| &mut v.outline_bounds, true), + ("view.maskOutlines", |v| &mut v.mask_outlines, true), + ("view.frameOutlines", |v| &mut v.frame_outlines, true), + ("view.proofSrgb", |v| &mut v.proof_srgb, false), + ("view.snapGeometry", |v| &mut v.snap_geometry, true), + ("view.snapObjects", |v| &mut v.snap_objects, true), + ("view.snapPixel", |v| &mut v.snap_pixel, true), +]; + +/// View toggle `id`: the file's view changes, and the preferences remember a kept one. +fn toggle(s: &mut Session, p: &Value, id: &str) -> Result { + let Some(&(_, f, keep)) = TOGGLES.iter().find(|(k, ..)| *k == id) else { return Err(bad(id, "not a View toggle")) }; + let r = flag(s, p, f)?; + if keep && let Some(on) = r.get("on").and_then(Value::as_bool) { + s.prefs.view.insert(id.to_string(), on); + } + Ok(r) +} + +/// Set `v`'s View toggles to the ones the preferences keep (the others stay as they are). +pub(crate) fn apply_kept(prefs: &crate::Prefs, v: &mut ViewState) { + for (id, f, _) in TOGGLES.iter().filter(|t| t.2) { + if let Some(on) = prefs.view.get(*id) { + *f(v) = *on; + } + } +} + /// Whether the View menu's toggle `id` is on (the menus show a check mark); `None` for a command /// that isn't one. pub fn checked(s: &Session, id: &str) -> Option { - let v = &s.ds().ok()?.view; - Some(match id { - "view.pixelGrid" => v.pixel_grid, - "view.layoutGrids" => v.layout_grids, - "view.rulers" => v.rulers, - "view.slices" => v.show_slices, - "view.comments" => v.comments, - "view.multiplayerCursors" => v.multiplayer_cursors, - "view.outlines" => v.outlines, - "view.outlinesSelection" => v.outlines_selection, - "view.outlinesHiddenLayers" => v.outline_hidden, - "view.outlinesObjectBounds" => v.outline_bounds, - "view.maskOutlines" => v.mask_outlines, - "view.pixelPreview" => v.pixel_preview, - "view.frameOutlines" => v.frame_outlines, - "view.proofSrgb" => v.proof_srgb, - "view.snapGeometry" => v.snap_geometry, - "view.snapObjects" => v.snap_objects, - "view.snapPixel" => v.snap_pixel, - _ => return None, - }) + let mut v = s.ds().ok()?.view.clone(); + if id == "view.pixelPreview" { + return Some(v.pixel_preview); + } + TOGGLES.iter().find(|(k, ..)| *k == id).map(|(_, f, _)| *f(&mut v)) } /// Pixel preview: toggles, or turns on at a resolution of 1x or 2x. @@ -235,46 +263,46 @@ pub fn specs() -> Vec { spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)), spec!("view.prevPage", "Previous page", "{}", false, |s, _| page_step(s, -1)), spec!("view.nextPage", "Next page", "{}", false, |s, _| page_step(s, 1)), - spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_grid)), - spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| flag(s, p, |v| &mut v.layout_grids)), - spec!("view.rulers", "Rulers", "{on?}", false, |s, p| flag(s, p, |v| &mut v.rulers)), - spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)), - spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)), + spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.pixelGrid")), + spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| toggle(s, p, "view.layoutGrids")), + spec!("view.rulers", "Rulers", "{on?}", false, |s, p| toggle(s, p, "view.rulers")), + spec!("view.slices", "Slices", "{on?}", false, |s, p| toggle(s, p, "view.slices")), + spec!("view.comments", "Comments", "{on?}", false, |s, p| toggle(s, p, "view.comments")), spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| { - flag(s, p, |v| &mut v.multiplayer_cursors) + toggle(s, p, "view.multiplayerCursors") }), spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan), - spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)), + spec!("view.outlines", "Outlines", "{on?}", false, |s, p| toggle(s, p, "view.outlines")), spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| { - flag(s, p, |v| &mut v.outlines_selection) + toggle(s, p, "view.outlinesSelection") }), spec!( "view.outlinesHiddenLayers", "Include hidden layers in outlines", "{on?} outline mode (and outlines for the selection) outlines hidden layers too; they can be clicked there", false, - |s, p| { flag(s, p, |v| &mut v.outline_hidden) } + |s, p| { toggle(s, p, "view.outlinesHiddenLayers") } ), spec!( "view.outlinesObjectBounds", "Include object bounds in outlines", "{on?} outline mode also draws each layer's box (groups, text, vectors); a click on a box's edge picks its layer", false, - |s, p| { flag(s, p, |v| &mut v.outline_bounds) } + |s, p| { toggle(s, p, "view.outlinesObjectBounds") } ), - spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.mask_outlines)), + spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| toggle(s, p, "view.maskOutlines")), spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview), - spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)), + spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| toggle(s, p, "view.frameOutlines")), spec!( "view.proofSrgb", "Soft-proof sRGB", "{on?} mark the colours of a Display P3 file that sRGB screens and files can't show", false, - |s, p| { flag(s, p, |v| &mut v.proof_srgb) } + |s, p| { toggle(s, p, "view.proofSrgb") } ), - spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)), - spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)), - spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)), + spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| toggle(s, p, "view.snapGeometry")), + spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| toggle(s, p, "view.snapObjects")), + spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.snapPixel")), spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)), spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| { s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")})); diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index da0b2d13d..56efc5c9a 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -11,12 +11,12 @@ //! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`, //! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment` //! (of auto layout: its direction and other settings are the main's), `grids`, `exports`, -//! `interactions`, `component` (a swapped nested instance), `props` (property values of a nested -//! instance) and `slot` (a slot holding the instance's own content). An instance's own export -//! settings and interactions are its own, like its place. `size` and `position` overrides of -//! layers inside an instance are still read and kept, but new ones are refused -//! ([`check_override_edit`]): those layers take their geometry, constraints and resizing from the -//! main; the instance itself resizes. +//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers +//! inside an instance, `component` (a swapped nested instance), `props` (property values of a +//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own +//! export settings, interactions and resizing are its own, like its place. `position` +//! overrides of layers inside an instance are still read and kept, but new ones are refused +//! ([`check_override_edit`]): those layers take their place and constraints from the main. //! //! Switching variants keeps an override where the layer's name path matches and the two variants //! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps @@ -58,6 +58,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[ "exports", "interactions", "size", + "resizing", "position", "component", "props", @@ -388,11 +389,14 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { "size" => { to.width = from.width; to.height = from.height; - to.child_layout = from.child_layout.clone(); if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { b.auto_resize = a.auto_resize; } } + "resizing" => { + let (a, b) = (&from.child_layout, &mut to.child_layout); + (b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h); + } "position" => to.transform = from.transform, "component" => { if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { @@ -773,6 +777,15 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT for s in &src { let Some(nid) = map.get(s).copied() else { continue }; let Some(o) = old.get(&nid) else { continue }; + // A frame that kept the size its own resizing gave it: its layers (copied at their main + // layers' places) follow their constraints from its main layer's size. + if o.overrides.contains("resizing") + && !o.overrides.contains("size") + && doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) + && let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height)) + { + figcraft_layout::apply_constraints(doc, nid, mw, mh); + } if !o.overrides.contains("size") && !o.overrides.contains("position") { continue; } @@ -985,6 +998,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 { out.push("size"); } + if resizing(a) != resizing(b) { + out.push("resizing"); + } if a.transform != b.transform { out.push("position"); } @@ -1027,6 +1043,12 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { out } +/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes. +fn resizing(n: &Node) -> (Sizing, Sizing, [Option; 4]) { + let c = &n.child_layout; + (c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h]) +} + /// Whether two texts differ in anything but their characters (the `textStyle` group). fn text_style_differs(x: &TextData, y: &TextData) -> bool { x.style != y.style @@ -1107,9 +1129,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] } let raw = diff_groups(b, n); let mut groups = raw.clone(); - // An instance's own place, export settings and interactions are its own, not overrides. + // An instance's own place, resizing, export settings and interactions are its own, not + // overrides. if top_root { - groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "exports" | "interactions")); + groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions")); } if groups.iter().any(|g| matches!(*g, "size" | "position")) { groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); @@ -1414,7 +1437,7 @@ fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> b fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool { let Some(p) = doc.node(parent) else { return false }; let constrains = p.layout.is_none() || absolute; - let resized = p.overrides.contains("size") + let resized = sized_own(p) || match &p.kind { NodeKind::Instance { component, .. } => { doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9) @@ -1475,7 +1498,7 @@ fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bo let gs: Vec<&'static str> = groups .iter() .copied() - .filter(|g| !cn.overrides.contains(*g) && !(root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions"))) + .filter(|g| !(cn.overrides.contains(*g) || root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions"))) .collect(); if gs.is_empty() { continue; @@ -1518,7 +1541,7 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool { chain.windows(2).all(|w| { let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false }; let constrains = a.layout.is_none() || below.child_layout.absolute; - if a.overrides.contains("size") && constrains { + if sized_own(a) && constrains { return false; } match &a.kind { @@ -1530,6 +1553,12 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool { }) } +/// Whether a layer inside an instance overrides its size or resizing: it may not have its main +/// layer's size, so the layers in it can't take their main layers' places as they are. +fn sized_own(n: &Node) -> bool { + n.overrides.contains("size") || n.overrides.contains("resizing") +} + /// Rebuild every instance of `comps` (and of the components that hold those instances, and so on /// outwards), each outermost instance once, inner components first. fn rebuild_instances(doc: &mut Document, comps: &BTreeSet) { @@ -1665,10 +1694,9 @@ pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), St /// What about layer `id` an edit from `before` to `after` changed that only its main can: the /// auto layout of an instance or a layer in one but its padding, gap and alignment, or the -/// position (rotation too), size, constraints or resizing of a layer inside an instance (the -/// instance itself resizes, and its layers follow; text whose box follows its content still -/// does). `None` when the edit is fine, also when the main changed the same way in this edit (a -/// library update). +/// position (rotation too), constraints or place in auto layout (absolute, grid cell) of a layer +/// inside an instance (its size and resizing are overrides). `None` when the edit is fine, also +/// when the main changed the same way in this edit (a library update). pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> { let (n, b) = (after.node(id)?, before.node(id)?); if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() { @@ -1684,8 +1712,12 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti l }) }; - let size = |x: &Node| (x.width, x.height); - let resized = |x: &Node, y: &Node| (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6; + // Child layout settings other than the resizing ones. + let placing = |x: &Node| { + let mut c = x.child_layout.clone(); + (c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None); + c + }; if mode(n) != mode(b) && mode(ma) == mode(mb) { return Some("The auto layout direction"); } @@ -1698,18 +1730,174 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") { return Some("A layer's position"); } - if resized(n, b) && size(ma) == size(mb) && !geometry_derived(after, before, id, "size") { - return Some("A layer's size"); - } if n.constraints != b.constraints && ma.constraints == mb.constraints { return Some("A layer's constraints"); } - if n.child_layout != b.child_layout && ma.child_layout == mb.child_layout { - return Some("A layer's resizing"); + if placing(n) != placing(b) && placing(ma) == placing(mb) { + return Some("A layer's place in auto layout"); } None } +/// Bounded source and parent lookups while a rebase reads an unchanged document. +struct RebaseSources<'a> { + doc: &'a Document, + chains: BTreeMap>>, + parents: BTreeMap<(NodeId, NodeId), Option>, + main_changes: BTreeMap, +} + +impl<'a> RebaseSources<'a> { + const PARENTS: usize = 512; + const CACHE: usize = 8192; + + fn new(doc: &'a Document) -> Self { + Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() } + } + + fn chain(&mut self, source: NodeId) -> Option> { + if let Some(chain) = self.chains.get(&source) { + return chain.clone(); + } + let read = || { + let mut chain = Vec::new(); + let mut seen = BTreeSet::new(); + let mut current = Some(source); + for _ in 0..MAX_DEPTH { + let Some(id) = current else { return Some(chain) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + chain.push(id); + current = n.source; + } + current.is_none().then_some(chain) + }; + let chain = read(); + if self.chains.len() < Self::CACHE { + self.chains.insert(source, chain.clone()); + } + chain + } + + /// Validate the complete parent chain, even if `main` was found before a cycle. + fn in_main(&mut self, id: NodeId, main: NodeId) -> Option { + if let Some(found) = self.parents.get(&(id, main)) { + return *found; + } + let read = || { + let mut current = Some(id); + let mut seen = BTreeSet::new(); + let mut found = false; + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(found) }; + if !seen.insert(id) { + return None; + } + found |= id == main; + current = self.doc.node(id)?.parent; + } + current.is_none().then_some(found) + }; + let found = read(); + if self.parents.len() < Self::CACHE { + self.parents.insert((id, main), found); + } + found + } + + fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option { + let chain = self.chain(source)?; + if chain.contains(&target) { + return None; + } + let mut member = None; + for id in chain { + if self.in_main(id, main)? && id != main && member.is_none() { + member = Some(id); + } + } + member + } + + /// Nearest enclosing instances and their current mains. Slots with own content end scope. + fn owners(&self, id: NodeId) -> Option> { + let mut owners = Vec::new(); + let mut current = self.doc.node(id)?.parent; + let mut seen = BTreeSet::from([id]); + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(owners) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + if holds_content(n) { + return Some(owners); + } + if let NodeKind::Instance { component, .. } = &n.kind { + owners.push((id, *component)); + } + current = n.parent; + } + current.is_none().then_some(owners) + } + + fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main) + } + + /// A contextual child belongs to the same placed slot as its enclosing instance. + fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + let Some((owner, main)) = owners.first() else { return false }; + if self.member(source, *main, target).is_none() { + return false; + } + if self.in_main(source, *main) == Some(true) { + return true; + } + let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false }; + matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main) + && self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner) + } + + fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option { + let source = self.doc.node(id)?.source?; + if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame) + || !self.valid(source, id, owners) + || !self.contextual(source, owners) + { + return None; + } + Some(source) + } + + fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool { + for (index, (id, main)) in owners.iter().enumerate() { + if *id == stop { + break; + } + let changed = if let Some(changed) = self.main_changes.get(id) { + *changed + } else { + let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => component != main, + Some(NodeKind::Frame) => true, + _ => false, + }); + if self.main_changes.len() < Self::CACHE { + self.main_changes.insert(*id, changed); + } + changed + }; + if changed { + return true; + } + } + false + } +} + /// Re-derive `source` links and overrides of instance `inst` against its own main by comparing /// values (after detaching an outer instance, restoring a main, or copying a nested instance out): /// the look is kept exactly. Values a component property sets aren't overrides. @@ -1722,22 +1910,28 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect() }; let mut updates = Vec::new(); + let mut sources = RebaseSources::new(doc); + let root_owners = sources.owners(inst).unwrap_or_default(); + let placed_source = sources.placed(inst, &root_owners); + let mut contexts = vec![(inst, component)]; + contexts.extend_from_slice(&root_owners); for (id, path) in &mine { let Some(n) = doc.node(*id) else { continue }; - // Follow the source chain into the main first (exact), then fall back to the name path. - let mut main = None; - let mut cur = n.source; - for _ in 0..MAX_DEPTH { - let Some(c) = cur else { break }; - if c != component && doc.contains(c) && doc.is_ancestor(component, c) { - main = Some(c); - break; - } - cur = doc.node(c).and_then(|x| x.source); + let owners = sources.owners(*id).unwrap_or_default(); + let nearest_main = owners.first().map(|(_, main)| *main); + let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners)); + let mut main = valid_source.and_then(|source| sources.member(source, component, *id)); + // A swapped or Frame-to-Instance subtree is compared to its current main by the inner + // rebase. An outer name path must not rebind those children to the old expanded copies. + if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) { + updates.push((*id, valid_source, n.overrides.clone())); + continue; } - let main = main.or_else(|| theirs.get(path).copied()); + main = main + .or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some()))); + let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main); let mut overrides: BTreeSet = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect(); - if let Some(m) = main.and_then(|m| doc.node(m)) { + if let Some(m) = source.and_then(|source| doc.node(source)) { let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect(); bound.extend(variable_groups(n, m)); overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str()))); @@ -1745,36 +1939,53 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { overrides.insert("component".into()); } } - updates.push((*id, main, overrides)); + updates.push((*id, source, overrides)); } + // A placed nested root compares to its actual slot. A detached root compares to its own main. + let root_base = placed_source.or_else(|| { + let chain = sources.chain(component)?; + (!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component) + }); + let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) { + (Some(m), Some(n)) => { + let from_variables = variable_groups(n, m); + let mut groups: Vec = value_groups(m, n) + .into_iter() + .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) + .filter(|g| placed_source.is_some() || g != "resizing") + .collect(); + let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; + if named { + groups.push("name".into()); + } + if placed_source.is_some() + && matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) + { + groups.push("component".into()); + } + groups + } + _ => Vec::new(), + }; + drop(sources); for (id, main, overrides) in updates { if let Some(n) = doc.node_mut(id) { n.source = main; n.overrides = if main.is_some() { overrides } else { BTreeSet::new() }; } } - let root_groups = match (doc.node(component), doc.node(inst)) { - (Some(m), Some(n)) => { - let from_variables = variable_groups(n, m); - let mut groups: Vec = value_groups(m, n) - .into_iter() - .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) - .collect(); - // A layer name of its own (a nested instance renamed in its old main) stays. - if n.name != default_instance_name(doc, component) { - groups.push("name".into()); - } - groups - } - _ => Vec::new(), - }; if let Some(n) = doc.node_mut(inst) { - n.source = Some(component); - n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position")); + n.source = root_base; + // A top-level instance's resizing is its own (a nested one's overrides its main layer's). + n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing")); n.overrides.extend(root_groups); } } +#[cfg(test)] +#[path = "components_rebase_source_tests.rs"] +mod rebase_source_tests; + /// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then /// the left-most). pub fn default_variant(doc: &Document, set: NodeId) -> Option { diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index 325ccdee5..15f660e1e 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -316,14 +316,6 @@ fn what_instances_cannot_override_is_refused() { ("nudge a layer", "transform.nudge", json!({"ids": ["$"], "dx": 1, "dy": 0}), Layer::Bg, "position"), ("rotate a layer", "node.set", json!({"id": "$", "props": {"rotation": 15}}), Layer::Bg, "position and rotation come with the layer's place"), ("flip a layer", "object.flipH", json!({"ids": ["$"]}), Layer::Bg, "position"), - ( - "resize a layer", - "node.set", - json!({"id": "$", "props": {"width": 50}}), - Layer::Bg, - "not among the overridable properties; the instance resizes", - ), - ("resize a text box", "node.set", json!({"id": "$", "props": {"width": 150}}), Layer::Title, "\"the bounds of any text layers\""), ( "change constraints", "node.set", @@ -331,13 +323,6 @@ fn what_instances_cannot_override_is_refused() { Layer::Bg, "\"any constraints applied to the layers\"", ), - ( - "make a layer fill its auto layout", - "node.set", - json!({"id": "$", "props": {"sizingH": "fill"}}), - Layer::Chip, - "resizing comes with the layer's size", - ), ( "take a layer out of auto layout", "node.set", diff --git a/crates/engine/src/components_rebase_source_tests.rs b/crates/engine/src/components_rebase_source_tests.rs new file mode 100644 index 000000000..b272b82fd --- /dev/null +++ b/crates/engine/src/components_rebase_source_tests.rs @@ -0,0 +1,244 @@ +//! Contextual import sources survive rebasing; detached and swapped subtrees use their current mains. + +use super::*; + +fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str) -> NodeId { + doc.create(parent, kind, name, 0.0, 0.0, 20.0, 20.0).unwrap() +} + +fn component(doc: &mut Document, parent: NodeId, name: &str) -> NodeId { + add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name) +} + +fn mirror(doc: &mut Document, source: NodeId, parent: NodeId) -> NodeId { + let mut n = doc.node(source).unwrap().clone(); + let children = std::mem::take(&mut n.children); + n.id = doc.alloc_id(); + n.parent = None; + n.source = Some(source); + let id = n.id; + doc.insert(n, parent, None).unwrap(); + for child in children { + mirror(doc, child, id); + } + id +} + +fn instance(doc: &mut Document, main: NodeId, parent: NodeId, name: &str) -> NodeId { + let mut n = doc.node(main).unwrap().clone(); + let children = std::mem::take(&mut n.children); + n.id = doc.alloc_id(); + n.kind = NodeKind::Instance { component: main, props: BTreeMap::new() }; + n.name = name.into(); + n.parent = None; + n.source = None; + let id = n.id; + doc.insert(n, parent, None).unwrap(); + for child in children { + mirror(doc, child, id); + } + id +} + +struct Scene { + doc: Document, + inner: NodeId, + base: NodeId, + slot: NodeId, + context: NodeId, + root: NodeId, + nested: NodeId, + child: NodeId, +} + +fn scene() -> Scene { + let mut doc = Document::new("Source context"); + let page = doc.first_page().unwrap(); + let inner = component(&mut doc, page, "Inner"); + let base = add(&mut doc, inner, NodeKind::Rectangle, "Body"); + doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0)); + let outer = component(&mut doc, page, "Outer"); + let slot = instance(&mut doc, inner, outer, "Nested"); + doc.node_mut(slot).unwrap().source = Some(inner); + let context = doc.node(slot).unwrap().children[0]; + doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0)); + doc.node_mut(context).unwrap().opacity = 0.3; + let root = instance(&mut doc, outer, page, "Outer copy"); + let nested = doc.node(root).unwrap().children[0]; + let child = doc.node(nested).unwrap().children[0]; + doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0)); + doc.node_mut(child).unwrap().opacity = 0.2; + Scene { doc, inner, base, slot, context, root, nested, child } +} + +fn both(s: &mut Scene) { + rebase_instance(&mut s.doc, s.nested); + rebase_instance(&mut s.doc, s.root); +} + +#[test] +fn rebase_default_only_sources_promote_to_the_actual_outer_context() { + let mut s = scene(); + s.doc.node_mut(s.nested).unwrap().source = None; + s.doc.node_mut(s.child).unwrap().source = None; + both(&mut s); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); +} + +#[test] +fn rebase_rejects_a_context_source_from_another_nested_slot() { + let mut s = scene(); + let outer = s.doc.node(s.slot).unwrap().parent.unwrap(); + let sibling = instance(&mut s.doc, s.inner, outer, "Other nested slot"); + s.doc.node_mut(sibling).unwrap().source = Some(s.inner); + let sibling_child = s.doc.node(sibling).unwrap().children[0]; + mirror(&mut s.doc, sibling, s.root); + s.doc.node_mut(s.child).unwrap().source = Some(sibling_child); + both(&mut s); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); + assert_eq!(s.doc.node(sibling_child).unwrap().transform.translation().x, 2.0); + assert_eq!(s.doc.node(s.context).unwrap().transform.translation().x, 3.0); +} + +#[test] +fn rebase_does_not_mark_inherited_context_values_as_local_overrides() { + let mut s = scene(); + s.doc.node_mut(s.child).unwrap().opacity = 0.3; + both(&mut s); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); + assert!(!s.doc.node(s.child).unwrap().overrides.contains("opacity")); +} + +#[test] +fn rebase_preserves_multilevel_context_without_shortening_source_chains() { + let mut doc = Document::new("Nested source contexts"); + let page = doc.first_page().unwrap(); + let inner = component(&mut doc, page, "Inner"); + let base = add(&mut doc, inner, NodeKind::Rectangle, "Body"); + doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0)); + let wrapper = component(&mut doc, page, "Wrapper"); + let default_slot = instance(&mut doc, inner, wrapper, "Nested"); + doc.node_mut(default_slot).unwrap().source = Some(inner); + let outer = component(&mut doc, page, "Outer"); + let wrapper_slot = instance(&mut doc, wrapper, outer, "Wrapper slot"); + doc.node_mut(wrapper_slot).unwrap().source = Some(wrapper); + let source_slot = doc.node(wrapper_slot).unwrap().children[0]; + let context = doc.node(source_slot).unwrap().children[0]; + doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0)); + let root = instance(&mut doc, outer, page, "Outer copy"); + let wrapper_copy = doc.node(root).unwrap().children[0]; + let nested = doc.node(wrapper_copy).unwrap().children[0]; + let child = doc.node(nested).unwrap().children[0]; + doc.node_mut(nested).unwrap().name = "Renamed nested".into(); + doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0)); + for id in [nested, wrapper_copy, root] { + rebase_instance(&mut doc, id); + assert_eq!(doc.node(nested).unwrap().source, Some(source_slot)); + assert_eq!(doc.node(child).unwrap().source, Some(context)); + } + assert_eq!(doc.node(context).unwrap().source, Some(doc.node(default_slot).unwrap().children[0])); + assert_eq!(doc.node(context).unwrap().transform.translation().x, 3.0); + assert_eq!(doc.node(child).unwrap().transform.translation().x, 2.0); +} + +#[test] +fn rebase_preserves_placed_frame_to_instance_and_current_main_children() { + let mut doc = Document::new("Typed nested source"); + let page = doc.first_page().unwrap(); + let inner = component(&mut doc, page, "Inner"); + let body = add(&mut doc, inner, NodeKind::Rectangle, "Body"); + let outer = component(&mut doc, page, "Outer"); + let slot = add(&mut doc, outer, NodeKind::Frame, "Empty slot"); + let root = instance(&mut doc, outer, page, "Outer copy"); + let nested = doc.node(root).unwrap().children[0]; + doc.node_mut(nested).unwrap().kind = NodeKind::Instance { component: inner, props: BTreeMap::new() }; + let child = mirror(&mut doc, body, nested); + rebase_instance(&mut doc, nested); + rebase_instance(&mut doc, root); + assert_eq!(doc.node(nested).unwrap().source, Some(slot)); + assert_eq!(doc.node(child).unwrap().source, Some(body)); + assert!(matches!(doc.node(nested).unwrap().kind, NodeKind::Instance { component, .. } if component == inner)); +} + +#[test] +fn rebase_keeps_current_main_sources_and_component_delta_beneath_swap() { + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let replacement = component(&mut s.doc, page, "Replacement"); + let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body"); + s.doc.node_mut(body).unwrap().transform = Affine::translate((7.0, 0.0)); + s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() }; + s.doc.remove(s.child).unwrap(); + s.child = mirror(&mut s.doc, body, s.nested); + s.doc.node_mut(s.child).unwrap().transform = Affine::translate((8.0, 0.0)); + s.doc.node_mut(s.nested).unwrap().name = "Renamed swap".into(); + rebase_instance(&mut s.doc, s.nested); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert!(s.doc.node(s.nested).unwrap().overrides.contains("component")); + rebase_instance(&mut s.doc, s.root); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(body)); + assert!(s.doc.node(s.nested).unwrap().overrides.contains("component")); + assert_eq!(s.doc.node(s.child).unwrap().transform.translation().x, 8.0); +} + +#[test] +fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() { + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let detached = mirror(&mut s.doc, s.nested, page); + let child = s.doc.node(detached).unwrap().children[0]; + // The old source references still exist, but their Outer context is absent here. + s.doc.node_mut(detached).unwrap().source = Some(s.slot); + s.doc.node_mut(child).unwrap().source = Some(s.context); + s.doc.node_mut(child).unwrap().transform = Affine::translate((3.0, 0.0)); + rebase_instance(&mut s.doc, detached); + assert_eq!(s.doc.node(detached).unwrap().source, Some(s.inner)); + assert_eq!(s.doc.node(child).unwrap().source, Some(s.base)); + assert_eq!(s.doc.node(child).unwrap().transform.translation().x, 3.0); + assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0); +} + +#[test] +fn rebase_does_not_preserve_existing_foreign_or_stale_sources() { + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let foreign = component(&mut s.doc, page, "Foreign"); + let foreign_body = add(&mut s.doc, foreign, NodeKind::Rectangle, "Body"); + s.doc.node_mut(s.child).unwrap().source = Some(foreign_body); + both(&mut s); + assert_ne!(s.doc.node(s.child).unwrap().source, Some(foreign_body)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let replacement = component(&mut s.doc, page, "Replacement"); + let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body"); + s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() }; + s.doc.node_mut(s.child).unwrap().source = Some(s.context); + both(&mut s); + assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context), "the old default-main child is stale after a swap"); + assert!(s.doc.node(s.child).unwrap().source.is_none() || s.doc.node(s.child).unwrap().source == Some(body)); +} + +#[test] +fn rebase_rejects_source_and_parent_cycles_with_bounded_lookups() { + let mut s = scene(); + s.doc.node_mut(s.base).unwrap().source = Some(s.context); + both(&mut s); + assert_eq!(s.doc.node(s.child).unwrap().source, None); + let mut s = scene(); + s.doc.node_mut(s.context).unwrap().parent = Some(s.context); + both(&mut s); + assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context)); + let mut s = scene(); + s.doc.node_mut(s.nested).unwrap().parent = Some(s.child); + s.doc.node_mut(s.child).unwrap().parent = Some(s.nested); + let sources = RebaseSources::new(&s.doc); + assert!(sources.owners(s.child).is_none()); + let mut s = scene(); + s.doc.node_mut(s.root).unwrap().kind = NodeKind::Instance { component: s.root, props: BTreeMap::new() }; + rebase_instance(&mut s.doc, s.root); + assert_ne!(s.doc.node(s.root).unwrap().source, Some(s.root)); +} diff --git a/crates/engine/src/components_tests.rs b/crates/engine/src/components_tests.rs index eb21b20ea..209560cdb 100644 --- a/crates/engine/src/components_tests.rs +++ b/crates/engine/src/components_tests.rs @@ -67,8 +67,6 @@ fn overrides_survive_main_edits() { ex(&mut s, "node.set", json!({"id": ibg, "props": {"effects": [{"type": "dropShadow"}]}})); ex(&mut s, "text.set", json!({"id": ititle, "characters": "Overridden"})); ex(&mut s, "node.set", json!({"id": idot, "props": {"visible": false}})); - // A layer inside an instance keeps its main's size (the instance itself resizes). - assert!(s.execute("node.set", &json!({"id": idot, "props": {"width": 30}})).is_err()); let ov = node(&s, ibg).overrides; for g in ["fills", "strokes", "effects", "name"] { assert!(ov.contains(g), "{g} in {ov:?}"); diff --git a/crates/engine/src/convert_container_tests.rs b/crates/engine/src/convert_container_tests.rs index 7c25a6205..620388d27 100644 --- a/crates/engine/src/convert_container_tests.rs +++ b/crates/engine/src/convert_container_tests.rs @@ -1,5 +1,5 @@ -//! Frames and groups converted into each other in place: the layers stay where they are on the -//! page, what only a frame has goes with it, what can't convert is refused, one undo step. +//! Frames, groups and sections converted into each other in place: the layers stay where they are on +//! the page, what only a frame has goes with it, what can't convert is refused, one undo step. use serde_json::{Value, json}; @@ -136,7 +136,7 @@ fn what_cannot_convert_is_refused_and_nothing_changes() { #[test] fn junk_parameters_are_refused_not_fatal() { let junk = [json!(null), json!(true), json!(-1), json!(1e308), json!("x"), json!([[1]]), json!({"a": 1}), json!([0, 999_999, "7"])]; - for cmd in ["object.convertToGroup", "object.convertToFrame"] { + for cmd in ["object.convertToGroup", "object.convertToFrame", "object.convertToSection"] { for j in &junk { let mut s = Session::new(); let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))); @@ -148,3 +148,49 @@ fn junk_parameters_are_refused_not_fatal() { } } } + +#[test] +fn a_frame_on_the_page_becomes_a_section_and_back() { + let mut s = Session::new(); + let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 50, "width": 300, "height": 200, "name": "Flow", "props": {"fill": "#FFEEDD"}}))); + let (a, b) = (rect(&mut s, f, 20.0, 30.0), rect(&mut s, f, 400.0, 90.0)); + ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"})); + ex(&mut s, "node.set", json!({"id": f, "props": {"width": 300, "height": 200}})); + let before = [page_box(&s, a), page_box(&s, b), page_box(&s, f)]; + assert_eq!(ex(&mut s, "object.convertToSection", json!({"ids": [f]}))["ids"], json!([f])); + let n = s.doc().unwrap().node(f).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Section { contents_hidden: false })); + assert_eq!((n.name.as_str(), n.children.clone(), n.fills.len()), ("Flow", vec![a, b], 1), "same name, layers and fill"); + assert!(n.layout.is_none() && !n.clip, "no auto layout or clipping on a section"); + assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before, "nothing moves"); + // And back: a frame that keeps the fill and doesn't clip, so the look stays. + assert_eq!(ex(&mut s, "object.convertToFrame", json!({"ids": [f]}))["ids"], json!([f])); + let n = s.doc().unwrap().node(f).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Frame) && n.fills.len() == 1 && !n.clip); + assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before); + // Each conversion is one undo step. + ex(&mut s, "edit.undo", json!({})); + assert!(matches!(kind(&s, f), NodeKind::Section { .. })); + ex(&mut s, "edit.undo", json!({})); + assert!(matches!(kind(&s, f), NodeKind::Frame)); + assert!(s.doc().unwrap().node(f).unwrap().layout.is_some(), "the frame's auto layout is back"); +} + +#[test] +fn only_frames_on_the_page_or_in_a_section_become_sections() { + let mut s = Session::new(); + let outer = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 300}))); + let inner = id(ex(&mut s, "frame.create", json!({"parent": outer, "x": 10, "y": 10, "width": 100, "height": 100}))); + let err = s.execute("object.convertToSection", &json!({"ids": [inner]})).unwrap_err().to_string(); + assert!(err.contains("inside a frame"), "{err}"); + let r = rect(&mut s, outer, 150.0, 150.0); + assert!(s.execute("object.convertToSection", &json!({"ids": [r]})).is_err(), "a rectangle isn't a frame"); + // A frame inside a section converts; a section holding a section can't become a frame. + let sec = id(ex(&mut s, "node.create", json!({"type": "section", "x": 500, "y": 0, "width": 400, "height": 400}))); + let in_sec = id(ex(&mut s, "frame.create", json!({"parent": sec, "x": 20, "y": 20, "width": 100, "height": 100}))); + ex(&mut s, "object.convertToSection", json!({"ids": [in_sec]})); + assert!(matches!(kind(&s, in_sec), NodeKind::Section { .. })); + let err = s.execute("object.convertToFrame", &json!({"ids": [sec]})).unwrap_err().to_string(); + assert!(err.contains("holds sections"), "{err}"); + assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed"); +} diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 05a95f66a..7a9e06216 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -202,15 +202,16 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() { let before = s.doc().unwrap().world_bounds(inner).unwrap(); assert_eq!((before.x0, before.y0, before.x1, before.y1), (450.0, 50.0, 550.0, 150.0)); ex(&mut s, "select.set", json!({"ids": [inner]})); - // Resizing it by its right edge: only the main component can. - assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle still shows its arrow"); + // Resizing it by its right edge: a size of its own (an override). + assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle shows its arrow"); s.pointer(&ev(PointerKind::Down, (550.0, 100.0), NONE)).unwrap(); s.pointer(&ev(PointerKind::Move, (570.0, 100.0), NONE)).unwrap(); s.pointer(&ev(PointerKind::Move, (600.0, 100.0), NONE)).unwrap(); - assert_eq!(s.tool.overlay.cursor, Cursor::NotAllowed); + assert_ne!(s.tool.overlay.cursor, Cursor::NotAllowed); s.pointer(&ev(PointerKind::Up, (600.0, 100.0), NONE)).unwrap(); - assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the resize was undone"); - // Moving it: refused too. + let before = s.doc().unwrap().world_bounds(inner).unwrap(); + assert_eq!((before.x0, before.x1), (450.0, 600.0), "the resize stays"); + // Moving it: only the main component can. s.pointer(&ev(PointerKind::Down, (500.0, 100.0), NONE)).unwrap(); s.pointer(&ev(PointerKind::Move, (510.0, 110.0), NONE)).unwrap(); s.pointer(&ev(PointerKind::Move, (520.0, 120.0), NONE)).unwrap(); diff --git a/crates/engine/src/design_paste_tests.rs b/crates/engine/src/design_paste_tests.rs new file mode 100644 index 000000000..89de80efc --- /dev/null +++ b/crates/engine/src/design_paste_tests.rs @@ -0,0 +1,108 @@ +//! Paste of layers copied from another design app: the clipboard's design data (a `.fig` canvas in +//! its HTML) read through the `.fig` import and pasted as Paste pastes our own layers. + +use base64::Engine as _; +use serde_json::json; + +use super::*; +use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer}; + +const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; + +fn id(v: &Value) -> NodeId { + params::id_of(&v["id"]).unwrap() +} + +fn copied_file() -> Vec { + let mut s = Session::new(); + let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 10, "width": 40, "height": 20, "parent": frame, "name": "Badge"})).unwrap(); + s.execute("node.create", &json!({"type": "ellipse", "x": 300, "y": 0, "width": 50, "height": 50, "name": "Dot"})).unwrap(); + let r = s.execute("file.export", &json!({"format": "fig"})).unwrap(); + B64.decode(r["data"].as_str().unwrap()).unwrap() +} + +fn clipboard_html(buffer: &[u8]) -> String { + let mut wrapped = B64.encode(buffer); + wrapped.insert(8, '\n'); + format!(r#""#) +} + +fn names(s: &Session, ids: &[NodeId]) -> Vec { + let doc = s.doc().unwrap(); + ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect() +} + +#[test] +fn buffer_is_found_in_the_html() { + let file = copied_file(); + assert_eq!(design_clipboard_buffer(&clipboard_html(&file)), Some(file)); + assert_eq!(design_clipboard_buffer("

plain

"), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}not base64!{CLIP_CLOSE}")), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{}{CLIP_CLOSE}", B64.encode(b"hello"))), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}ZmlnLQ")), None); + // Web text that only mentions the markers holds no design data. + assert_eq!(design_clipboard_buffer(&format!("

see {CLIP_OPEN} and {CLIP_CLOSE} in the docs

")), None); +} + +#[test] +fn oversized_design_data_is_refused_before_decoding() { + // Just past the cap (256 MB of data, as base64), made of valid base64 characters. + let big = "A".repeat((256 * 1024 * 1024usize).div_ceil(3) * 4 + 4); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{big}{CLIP_CLOSE}")), None); + let mut s = Session::new(); + let e = s.execute("edit.pasteDesignLayers", &json!({ "data": big })).unwrap_err(); + assert!(e.to_string().contains("256 MB"), "{e}"); + assert!(s.clipboard.nodes.is_empty()); +} + +#[test] +fn layers_paste_into_the_page_and_undo() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); + assert_eq!(names(&s, &ids), ["Card", "Dot"]); + assert_eq!(s.selection(), ids); + let doc = s.doc().unwrap(); + assert!(ids.iter().all(|i| doc.node(*i).unwrap().parent == Some(page))); + let card = doc.node(ids[0]).unwrap(); + assert_eq!(names(&s, &card.children), ["Badge"]); + assert_eq!((card.width, card.height), (200.0, 100.0)); + assert!(s.undo().unwrap()); + assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i))); +} + +#[test] +fn a_second_paste_repeats_the_copied_layers() { + let mut s = Session::new(); + // `data` may be wrapped and unpadded, like the HTML's base64. + let mut data = B64.encode(copied_file()).trim_end_matches('=').to_string(); + data.insert(8, '\n'); + s.execute("edit.pasteDesignLayers", &json!({ "data": data })).unwrap(); + let r = s.execute("edit.paste", &json!({})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); + assert_eq!(names(&s, &ids), ["Card", "Dot"]); +} + +#[test] +fn pastes_into_the_selected_frame() { + let mut s = Session::new(); + let host = id(&s.execute("frame.create", &json!({"x": 1000, "y": 0, "width": 800, "height": 600})).unwrap()); + s.execute("select.set", &json!({"ids": [host]})).unwrap(); + let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let first = params::id_of(&r["ids"][0]).unwrap(); + assert_eq!(s.doc().unwrap().node(first).unwrap().parent, Some(host)); +} + +#[test] +fn other_content_is_refused_without_changes() { + let mut s = Session::new(); + let before = s.doc().unwrap().nodes().count(); + assert!(s.execute("edit.pasteDesignLayers", &json!({})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"html": "hi"})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"PK not a zip")})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err()); + assert_eq!(s.doc().unwrap().nodes().count(), before); + assert!(s.clipboard.nodes.is_empty()); +} diff --git a/crates/engine/src/doc_urls.rs b/crates/engine/src/doc_urls.rs new file mode 100644 index 000000000..3b8417cd2 --- /dev/null +++ b/crates/engine/src/doc_urls.rs @@ -0,0 +1,48 @@ +//! Which URLs stored in a document may be handed to the browser or the system opener. +//! +//! A document can carry URLs that the app opens for whoever views it: a prototype's Open link +//! action ([`crate::proto_run`]) and Dev Mode's dev resources ([`crate::dev_resources`]). Both end +//! up at the platform's opener, so a document that someone else made chooses what that opener is +//! asked to do. Only the schemes a browser would follow on a plain link are allowed: +//! +//! - `javascript:` runs script. In the web build the opener is `window.open`, which executes it in +//! the app's own origin, so a saved document becomes stored XSS. +//! - `file:` and `smb:` reach the file system; on Windows a UNC host also sends credentials. +//! - Windows hands `ms-…:` schemes to application handlers, and macOS and Linux hand unknown +//! schemes to whichever application registered them. +//! +//! The check is lexical and does no I/O. It is deliberately an allow-list: a deny-list of known +//! dangerous schemes is outgrown by every new handler a platform adds. + +/// The schemes a URL stored in a document may use. +const WEB_SCHEMES: [&str; 3] = ["http", "https", "mailto"]; + +/// Whether `url`, stored in a document, may be opened. +/// +/// Requires a scheme from [`WEB_SCHEMES`], compared ignoring ASCII case. A URL with no scheme is +/// refused: the opener would resolve it against the app's own page on the web. Control characters +/// are refused anywhere, because browsers ignore some of them inside a scheme (`java\tscript:` +/// reaches a `javascript:` handler), and `http`/`https` must name a host. +pub fn openable(url: &str) -> bool { + let url = url.trim(); + if url.is_empty() || url.chars().any(char::is_control) { + return false; + } + let Some((scheme, rest)) = url.split_once(':') else { return false }; + // An RFC 3986 scheme, so a stray colon in text cannot look like one. + let mut chars = scheme.chars(); + if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) { + return false; + } + if !WEB_SCHEMES.iter().any(|s| scheme.eq_ignore_ascii_case(s)) { + return false; + } + if scheme.eq_ignore_ascii_case("mailto") { + // `mailto:` names an address. + return !rest.is_empty(); + } + // `http` and `https` name a host, so the authority is required: `http:x` is not a web link, + // and `http:///p` has no host. + let Some(after) = rest.strip_prefix("//") else { return false }; + !after.is_empty() && !after.starts_with('/') +} diff --git a/crates/engine/src/doc_urls_tests.rs b/crates/engine/src/doc_urls_tests.rs new file mode 100644 index 000000000..d42029a0f --- /dev/null +++ b/crates/engine/src/doc_urls_tests.rs @@ -0,0 +1,87 @@ +//! A URL stored in a document reaches the browser or the system opener, so only the schemes a +//! browser follows on a plain link are allowed ([`crate::doc_urls`]). + +use crate::doc_urls::openable; + +#[test] +fn web_links_are_opened() { + for url in [ + "http://example.com", + "https://example.com", + "https://example.com/a/b?c=d#e", + "HTTPS://EXAMPLE.COM", + "HtTp://example.com", + "mailto:a@b.c", + "mailto:a@b.c?subject=hi", + " https://example.com ", + "https://user:pw@example.com:8443/p", + ] { + assert!(openable(url), "{url:?} should open"); + } +} + +#[test] +fn script_and_handler_schemes_are_refused() { + for url in [ + // Runs in the app's own origin on the web build: a saved document would be stored XSS. + "javascript:alert(1)", + "JavaScript:alert(1)", + "jAvAsCrIpT:alert(document.cookie)", + "vbscript:msgbox(1)", + "data:text/html,", + "blob:https://example.com/x", + // Reach the file system; a UNC host also sends Windows credentials. + "file:///etc/passwd", + "file://attacker.example/share/x", + "smb://attacker.example/share/x", + "webdav://attacker.example/x", + // Windows application handlers. + "ms-msdt:/id PCWDiagnostic", + "ms-officecmd:x", + "search-ms:query=x", + "shell:startup", + // Anything else a platform may have registered. + "tel:+1234", + "sms:+1234", + "ssh://host", + "ftp://host/x", + "figcraft://join?host=127.0.0.1", + "zoommtg://zoom.us/join?x", + "view-source:https://example.com", + "jar:https://example.com/x!/y", + ] { + assert!(!openable(url), "{url:?} must be refused"); + } +} + +#[test] +fn a_scheme_is_required_and_control_characters_are_refused() { + for url in [ + // No scheme: the web opener would resolve these against the app's own page. + "", + " ", + "example.com", + "/etc/passwd", + "//example.com", + "\\\\host\\share\\x", + "about", + // Browsers ignore some control characters inside a scheme, so a smuggled one must not + // leave `javascript` looking like an unknown scheme that we then refuse by luck. + "java\tscript:alert(1)", + "java\nscript:alert(1)", + "java\rscript:alert(1)", + "javascript\u{0}:alert(1)", + "https://example.com/\u{0}", + // A colon in ordinary text is not a scheme. + "see page 2: the diagram", + "1https://example.com", + // The allowed schemes still need their own part. + "mailto:", + "http:", + "http:example.com", + "http:///p", + "https://", + ] { + assert!(!openable(url), "{url:?} must be refused"); + } +} diff --git a/crates/engine/src/duplicate_spacing_tests.rs b/crates/engine/src/duplicate_spacing_tests.rs index b65fbb641..4c62179a7 100644 --- a/crates/engine/src/duplicate_spacing_tests.rs +++ b/crates/engine/src/duplicate_spacing_tests.rs @@ -157,3 +157,49 @@ fn an_alt_drag_into_auto_layout_sets_no_step() { cmd_d(&mut s); assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow"); } + +/// Drag `from` by `by` with no modifier: moving the selection, not copying it. +fn drag_move(s: &mut Session, from: (f64, f64), by: (f64, f64)) { + let ev = |kind, (x, y): (f64, f64)| PointerEvent::at(kind, x, y, 1.0); + s.pointer(&ev(PointerKind::Down, from)).unwrap(); + for k in 1..=4 { + let t = f64::from(k) / 4.0; + s.pointer(&ev(PointerKind::Move, (from.0 + by.0 * t, from.1 + by.1 * t))).unwrap(); + } + s.pointer(&ev(PointerKind::Up, (from.0 + by.0, from.1 + by.1))).unwrap(); +} + +/// The reported path (#567): Cmd+D, drag the copy with the mouse, Cmd+D again. The other tests +/// here set the copy's position with `node.set` or Alt-drag it; dragging is how it is usually done, +/// and the step is measured from where the copy ended up, whichever way it got there. +#[test] +fn duplicate_repeats_the_step_set_by_dragging_the_copy() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + assert_eq!(at(&s, c1), (0.0, 0.0), "the first copy lands in place"); + + drag_move(&mut s, (50.0, 50.0), (150.0, 20.0)); + assert_eq!(at(&s, c1), (150.0, 20.0), "the drag moved the copy, it did not make another one"); + assert_eq!(s.selection(), vec![c1], "and left it selected"); + assert_eq!(at(&s, a), (0.0, 0.0), "the original stays"); + + let c2 = cmd_d(&mut s); + assert_eq!(at(&s, c2), (300.0, 40.0), "the next one as far again"); + let c3 = cmd_d(&mut s); + assert_eq!(at(&s, c3), (450.0, 60.0), "and again"); +} + +/// And a step set with the arrow keys, which is how a small offset is usually made exact. +#[test] +fn duplicate_repeats_a_nudged_step() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + ex(&mut s, "transform.nudge", json!({"dx": 40, "dy": 0})); + assert_eq!(at(&s, c1), (40.0, 0.0)); + let c2 = cmd_d(&mut s); + assert_eq!(at(&s, c2), (80.0, 0.0), "the nudge is the step"); +} diff --git a/crates/engine/src/fig_import_tests.rs b/crates/engine/src/fig_import_tests.rs index d62414481..f0ea08866 100644 --- a/crates/engine/src/fig_import_tests.rs +++ b/crates/engine/src/fig_import_tests.rs @@ -50,3 +50,43 @@ fn local_samples_match_their_exports() { } } } + +/// Layers of an instance copy keep their link to the layer they stand for when the file is +/// opened, also after they were renamed in the copy (a name is no identity), and the override +/// that a rename is stays an override. +#[test] +fn opened_fig_links_instance_layers_by_identity_not_by_name() { + use base64::Engine as _; + let mut s = Session::new(); + let make = |s: &mut Session, cmd: &str, p: serde_json::Value| s.execute(cmd, &p).unwrap()["id"].as_u64().map(figcraft_doc::NodeId).unwrap(); + let body = make(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Inner"})); + let label = make(&mut s, "text.create", json!({"text": "Label", "x": 2, "y": 2, "parent": body, "props": {"name": "Label"}})); + let inner = make(&mut s, "component.create", json!({"ids": [body]})); + let frame = make(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 200, "height": 60, "name": "Outer"})); + let outer = make(&mut s, "component.create", json!({"ids": [frame]})); + let slot = make(&mut s, "instance.create", json!({"component": inner, "x": 0, "y": 0, "parent": outer})); + s.execute("node.set", &json!({"id": slot, "props": {"name": "Slot"}})).unwrap(); + let copy = make(&mut s, "instance.create", json!({"component": outer, "x": 0, "y": 200})); + let copy_slot = s.doc().unwrap().node(copy).unwrap().children[0]; + let copy_label = s.doc().unwrap().node(copy_slot).unwrap().children[0]; + s.execute("node.set", &json!({"id": copy_slot, "props": {"name": "Renamed slot"}})).unwrap(); + s.execute("node.set", &json!({"id": copy_label, "props": {"name": "Renamed label"}})).unwrap(); + let _ = label; + + let exported = s.execute("file.export", &json!({"format": "fig"})).unwrap(); + let path = std::env::temp_dir().join(format!("figcraft-slots-{}.fig", std::process::id())); + std::fs::write(&path, base64::engine::general_purpose::STANDARD.decode(exported["data"].as_str().unwrap()).unwrap()).unwrap(); + let mut opened = Session::new(); + let result = opened.execute("file.open", &json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + result.unwrap(); + let doc = opened.doc().unwrap(); + let outer = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Component { .. })).unwrap(); + let copy = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Instance { .. })).unwrap(); + let slot = doc.node(copy.children[0]).unwrap(); + let label = doc.node(slot.children[0]).unwrap(); + assert_eq!((slot.name.as_str(), label.name.as_str()), ("Renamed slot", "Renamed label")); + assert_eq!(slot.source, Some(outer.children[0])); + assert_eq!(label.source, Some(doc.node(outer.children[0]).unwrap().children[0])); + assert!(slot.overrides.contains("name") && label.overrides.contains("name")); +} diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs new file mode 100644 index 000000000..8abeaee5d --- /dev/null +++ b/crates/engine/src/file_access.rs @@ -0,0 +1,293 @@ +//! Automation roots (#715): the folders the files an agent's commands read and write must lie in. +//! +//! `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` and the desktop app +//! started with the same flags run their work [`confined`] to [`Roots`]: while a scope is active on +//! the thread, every file the engine reads must lie inside a read or a write root and every file it +//! writes inside a write root; anything else is an ordinary error. The engine reaches the file +//! system only through this module (`crates/engine/clippy.toml` denies `std::fs` and friends +//! anywhere else), so every command, present or future, is covered. Without a scope nothing changes. +//! +//! A path is made absolute against the working directory and resolved before it is compared: its +//! links are followed (`std::fs::canonicalize`), and for a file that doesn't exist yet its nearest +//! existing folder is resolved and the rest must be plain names. The comparison is by whole +//! components (`/work2` is not inside `/work`), and the I/O uses the resolved path, so what was +//! checked is what is opened. +#![allow(clippy::disallowed_methods)] + +use std::cell::RefCell; +use std::ffi::{OsStr, OsString}; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +use crate::{EngineError, Result}; + +/// What a command does with a file. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +/// The folders automation may read (read and write roots) and write (write roots), resolved. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Roots { + read: Vec, + write: Vec, +} + +impl Roots { + /// Roots from the folders given (relative ones against the working directory). Each must be an + /// existing folder. + pub fn new(read: &[String], write: &[String]) -> std::result::Result { + let resolve = |flag: &str, dirs: &[String]| { + dirs.iter() + .map(|d| match std::fs::canonicalize(d) { + Ok(p) if p.is_dir() => Ok(p), + Ok(_) => Err(format!("{flag} {d}: not a folder")), + Err(e) => Err(format!("{flag} {d}: {e}")), + }) + .collect::, String>>() + }; + Ok(Self { read: resolve("--automation-read-root", read)?, write: resolve("--automation-write-root", write)? }) + } + + /// The resolved path to use for `access` to `path`, or why it is refused. + pub fn check(&self, path: &Path, access: Access) -> std::result::Result { + let asked = path.display(); + let (roots, what) = match access { + Access::Read => ([self.read.as_slice(), self.write.as_slice()].concat(), "read"), + Access::Write => (self.write.clone(), "written"), + }; + match resolve(path) { + Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved), + Ok(_) => {} + // Why a path can't be resolved says what exists there: only told for paths inside a root. + Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")), + Err(_) => {} + } + if roots.is_empty() { + return Err(format!("automation path rejected: no folder may be {what} (no root for it was given): {asked}")); + } + let shown: Vec = roots.iter().map(|r| shown(r)).collect(); + Err(format!("automation path rejected: {asked} is outside the folders that may be {what}: {}", shown.join(", "))) + } + + /// The folders as JSON, resolved: `{read: [every folder that may be read], write: [the write roots]}`. + pub fn to_json(&self) -> serde_json::Value { + let list = |v: &[PathBuf]| v.iter().map(|p| shown(p)).collect::>(); + serde_json::json!({"read": list(&[self.read.as_slice(), self.write.as_slice()].concat()), "write": list(&self.write)}) + } +} + +/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one +/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix). +fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool { + let Ok(abs) = std::path::absolute(path) else { return false }; + let mut lexical = PathBuf::new(); + for c in abs.components() { + match c { + Component::ParentDir => { + lexical.pop(); + } + Component::CurDir => {} + c => lexical.push(c), + } + } + roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r))) +} + +/// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed); +/// false when either can't be resolved. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) fn within_app_folder(path: &Path, folder: &Path) -> bool { + matches!((std::fs::canonicalize(path), std::fs::canonicalize(folder)), (Ok(p), Ok(f)) if p.starts_with(&f)) +} + +/// `p` without Windows' `\\?\` prefix, for messages. +fn shown(p: &Path) -> String { + let s = p.to_string_lossy(); + match s.strip_prefix(r"\\?\UNC\") { + Some(unc) => format!(r"\\{unc}"), + None => s.strip_prefix(r"\\?\").unwrap_or(&s).to_string(), + } +} + +/// `path` made absolute and resolved: links followed, and for a path that doesn't exist yet its +/// nearest existing folder resolved with the rest appended (plain names only). +fn resolve(path: &Path) -> std::result::Result { + if path.as_os_str().is_empty() { + return Err("not a file path".into()); + } + let abs = std::path::absolute(path).map_err(|e| e.to_string())?; + let mut rest: Vec = Vec::new(); + let mut at = abs.as_path(); + loop { + match std::fs::canonicalize(at) { + Ok(mut base) => { + base.extend(rest.iter().rev()); + return Ok(base); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Writing through a link that leads nowhere would create its target, wherever it is. + if std::fs::symlink_metadata(at).is_ok() { + return Err("a link that leads nowhere".into()); + } + match at.components().next_back() { + Some(Component::Normal(name)) => { + plain_name(name)?; + rest.push(name.to_os_string()); + } + Some(Component::CurDir) => {} + _ => return Err("`..` (or a drive) below a folder that doesn't exist".into()), + } + at = at.parent().ok_or("no existing folder")?; + } + Err(e) => return Err(e.to_string()), + } + } +} + +/// A name for a file or folder that doesn't exist yet: on Windows no device (`CON`, `NUL`, `COM1`…) +/// and no alternate data stream (`a:b`). +fn plain_name(name: &OsStr) -> std::result::Result<(), String> { + if cfg!(windows) { + let n = name.to_string_lossy(); + let stem = n.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase(); + let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$") + || ((stem.starts_with("COM") || stem.starts_with("LPT")) + && stem.chars().count() == 4 + && stem.chars().last().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³'))); + if device || n.contains(':') { + return Err(format!("`{n}` is a device or stream name, not a file")); + } + } + Ok(()) +} + +thread_local! { + static ROOTS: RefCell>> = const { RefCell::new(None) }; +} + +/// Run `f` with this thread's file access confined to `roots` (unchanged when `None`). +pub fn confined(roots: Option<&Arc>, f: impl FnOnce() -> R) -> R { + match roots { + Some(roots) => scoped(Some(roots.clone()), f), + None => f(), + } +} + +/// Run `f` unconfined: only for the app's own files, never for a path a command was given. +// Only plugins read from disk use it, and the browser has no disk. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) fn app_owned(f: impl FnOnce() -> R) -> R { + scoped(None, f) +} + +fn scoped(roots: Option>, f: impl FnOnce() -> R) -> R { + /// Puts the outer scope's roots back, also when `f` panics. + struct Restore(Option>); + impl Drop for Restore { + fn drop(&mut self) { + let outer = self.0.take(); + ROOTS.with(|r| *r.borrow_mut() = outer); + } + } + let _restore = Restore(ROOTS.with(|r| std::mem::replace(&mut *r.borrow_mut(), roots))); + f() +} + +/// The roots this thread's file access is confined to now, if any. +pub fn current() -> Option> { + ROOTS.with(|r| r.borrow().clone()) +} + +/// The path to use for `access` to `path`: resolved when confined, else as given. +pub fn allow(path: impl AsRef, access: Access) -> Result { + let path = path.as_ref(); + match current() { + Some(roots) => roots.check(path, access).map_err(EngineError::Other), + None => Ok(path.to_path_buf()), + } +} + +/// Whether `path` may be accessed so now. +pub fn allowed(path: impl AsRef, access: Access) -> bool { + allow(path, access).is_ok() +} + +fn io(path: &Path) -> impl Fn(std::io::Error) -> EngineError + '_ { + move |e| EngineError::Other(format!("{}: {e}", path.display())) +} + +/// The bytes of file `path`. +pub fn read(path: impl AsRef) -> Result> { + let path = path.as_ref(); + std::fs::read(allow(path, Access::Read)?).map_err(io(path)) +} + +/// The bytes of file `path`, refused when it isn't a file or is larger than `max`. +pub fn read_capped(path: impl AsRef, max: u64) -> Result> { + let path = path.as_ref(); + let p = allow(path, Access::Read)?; + let meta = std::fs::metadata(&p).map_err(io(path))?; + if !meta.is_file() || meta.len() > max { + return Err(EngineError::Other(format!("{}: not a file of at most {max} bytes", path.display()))); + } + std::fs::read(p).map_err(io(path)) +} + +/// File `path` opened for reading. +pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + std::fs::File::open(allow(path, Access::Read)?).map_err(io(path)) +} + +/// What the file system says about `path` (links followed). +pub fn metadata(path: impl AsRef) -> Result { + let path = path.as_ref(); + std::fs::metadata(allow(path, Access::Read)?).map_err(io(path)) +} + +/// Whether `path` is a folder (false when it may not be read). +pub fn is_dir(path: impl AsRef) -> bool { + metadata(path).is_ok_and(|m| m.is_dir()) +} + +/// Whether `path` exists (false when it may not be read). +pub fn exists(path: impl AsRef) -> bool { + metadata(path).is_ok() +} + +/// The paths of the entries of folder `path`. +pub fn read_dir(path: impl AsRef) -> Result> { + let path = path.as_ref(); + let dir = allow(path, Access::Read)?; + Ok(std::fs::read_dir(&dir).map_err(io(path))?.flatten().map(|e| e.path()).collect()) +} + +/// Write `bytes` to `path` through a uniquely named staging file and one atomic rename (see +/// [`figcraft_format::write_file_atomic`]). +pub fn write(path: impl AsRef, bytes: &[u8]) -> Result<()> { + let path = path.as_ref(); + write_atomic(&allow(path, Access::Write)?, bytes).map_err(io(path)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + figcraft_format::write_file_atomic(path, bytes) +} + +#[cfg(target_arch = "wasm32")] +fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system in the browser")) +} + +/// Create folder `path` and the folders above it that are missing. +pub fn create_dir_all(path: impl AsRef) -> Result<()> { + let path = path.as_ref(); + std::fs::create_dir_all(allow(path, Access::Write)?).map_err(io(path)) +} + +#[cfg(test)] +#[path = "file_access_tests.rs"] +mod tests; diff --git a/crates/engine/src/file_access_tests.rs b/crates/engine/src/file_access_tests.rs new file mode 100644 index 000000000..a05ad88ce --- /dev/null +++ b/crates/engine/src/file_access_tests.rs @@ -0,0 +1,371 @@ +//! Automation roots: how paths are resolved and compared, and that commands run confined can't +//! reach past them. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::json; + +use super::{Access, Roots, allow, confined, current}; +use crate::Session; + +/// A fresh folder for one test: `/figcraft-roots--`, holding `root/`, `root2/` +/// (a sibling whose name starts like the root's) and `outside/`. +struct Fixture(PathBuf); + +impl Fixture { + fn new(name: &str) -> Self { + let d = std::env::temp_dir().join(format!("figcraft-roots-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + for sub in ["root/sub", "root2", "outside"] { + std::fs::create_dir_all(d.join(sub)).unwrap(); + } + std::fs::write(d.join("root/in.txt"), b"in").unwrap(); + std::fs::write(d.join("outside/secret.txt"), b"secret").unwrap(); + Self(d) + } + + fn p(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().into_owned() + } + + /// The root as both read and write root. + fn rw(&self) -> Roots { + Roots::new(&[], &[self.p("root")]).unwrap() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[cfg(unix)] +fn symlink_dir(target: &Path, link: &Path) -> bool { + std::os::unix::fs::symlink(target, link).is_ok() +} + +#[cfg(windows)] +fn symlink_dir(target: &Path, link: &Path) -> bool { + std::os::windows::fs::symlink_dir(target, link).is_ok() +} + +#[cfg(unix)] +fn symlink_file(target: &Path, link: &Path) -> bool { + std::os::unix::fs::symlink(target, link).is_ok() +} + +#[cfg(windows)] +fn symlink_file(target: &Path, link: &Path) -> bool { + std::os::windows::fs::symlink_file(target, link).is_ok() +} + +#[test] +fn inside_is_allowed_and_resolved_outside_is_refused() { + let f = Fixture::new("inside"); + let r = f.rw(); + let got = r.check(Path::new(&f.p("root/in.txt")), Access::Read).unwrap(); + assert_eq!(got, std::fs::canonicalize(f.p("root/in.txt")).unwrap(), "the I/O uses the resolved path"); + assert!(r.check(Path::new(&f.p("root/new.png")), Access::Write).is_ok()); + let e = r.check(Path::new(&f.p("outside/secret.txt")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected:") && e.contains("outside the folders that may be read"), "{e}"); + assert!(r.check(Path::new(&f.p("outside/new.png")), Access::Write).is_err()); + // The root itself is inside. + assert!(r.check(Path::new(&f.p("root")), Access::Read).is_ok()); +} + +#[test] +fn a_sibling_whose_name_starts_like_the_root_is_outside() { + let f = Fixture::new("prefix"); + let r = f.rw(); + for p in ["root2/x.png", "root2", "root.png"] { + assert!(r.check(Path::new(&f.p(p)), Access::Write).is_err(), "{p}"); + assert!(r.check(Path::new(&f.p(p)), Access::Read).is_err(), "{p}"); + } +} + +#[test] +fn traversal_is_resolved_before_comparing() { + let f = Fixture::new("dots"); + let r = f.rw(); + assert!(r.check(Path::new(&f.p("root/sub/../../outside/secret.txt")), Access::Read).is_err()); + assert!(r.check(Path::new(&f.p("root/sub/../../outside/new.txt")), Access::Write).is_err()); + assert!(r.check(Path::new(&f.p("root/sub/../in.txt")), Access::Read).is_ok()); + assert!(r.check(Path::new(&f.p("root/./sub/../new.txt")), Access::Write).is_ok()); + // `..` below a folder that doesn't exist can't be resolved (on Windows it is taken lexically, + // as the system does, and stays inside). + let deep = f.p("root/missing/../../outside/x.txt"); + assert!(r.check(Path::new(&deep), Access::Write).is_err(), "{deep}"); +} + +#[test] +fn files_that_dont_exist_yet_resolve_through_their_nearest_folder() { + let f = Fixture::new("missing"); + let r = f.rw(); + let got = r.check(Path::new(&f.p("root/a/b/c.png")), Access::Write).unwrap(); + assert_eq!(got, std::fs::canonicalize(f.p("root")).unwrap().join("a").join("b").join("c.png")); + assert!(r.check(Path::new(&f.p("outside/a/b/c.png")), Access::Write).is_err()); +} + +#[test] +fn relative_paths_are_resolved_against_the_working_directory() { + // `cargo test` runs in the crate's folder, which holds Cargo.toml. + let cwd = std::env::current_dir().unwrap().to_string_lossy().into_owned(); + let here = Roots::new(std::slice::from_ref(&cwd), &[]).unwrap(); + assert_eq!(here.check(Path::new("Cargo.toml"), Access::Read).unwrap(), std::fs::canonicalize("Cargo.toml").unwrap()); + assert!(here.check(Path::new("src/../Cargo.toml"), Access::Read).is_ok()); + let f = Fixture::new("relative"); + assert!(f.rw().check(Path::new("Cargo.toml"), Access::Read).is_err()); + // Relative roots too. + assert_eq!(Roots::new(&[".".into()], &[]).unwrap(), here); +} + +#[test] +fn read_and_write_authority_are_separate() { + let f = Fixture::new("modes"); + let read_only = Roots::new(&[f.p("root")], &[]).unwrap(); + assert!(read_only.check(Path::new(&f.p("root/in.txt")), Access::Read).is_ok()); + let e = read_only.check(Path::new(&f.p("root/out.png")), Access::Write).unwrap_err(); + assert!(e.contains("no folder may be written"), "{e}"); + // A write root may be read too; a read root elsewhere doesn't make it writable. + let both = Roots::new(&[f.p("outside")], &[f.p("root")]).unwrap(); + assert!(both.check(Path::new(&f.p("root/in.txt")), Access::Read).is_ok()); + assert!(both.check(Path::new(&f.p("outside/secret.txt")), Access::Read).is_ok()); + assert!(both.check(Path::new(&f.p("outside/new.txt")), Access::Write).is_err()); + // Several roots of each kind. + let many = Roots::new(&[f.p("root"), f.p("root2")], &[f.p("root2"), f.p("outside")]).unwrap(); + assert!(many.check(Path::new(&f.p("outside/new.txt")), Access::Write).is_ok()); + assert!(many.check(Path::new(&f.p("root/new.txt")), Access::Write).is_err()); +} + +#[test] +fn roots_must_be_existing_folders() { + let f = Fixture::new("bad-roots"); + assert!(Roots::new(&[f.p("nope")], &[]).unwrap_err().contains("--automation-read-root")); + assert!(Roots::new(&[], &[f.p("root/in.txt")]).unwrap_err().contains("not a folder")); + assert!(Roots::new(&[], &[String::new()]).is_err()); + assert!(f.rw().check(Path::new(""), Access::Read).is_err()); +} + +#[test] +fn links_that_lead_out_of_a_root_are_outside() { + let f = Fixture::new("links"); + let r = f.rw(); + if !symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) || !symlink_file(&f.0.join("outside/secret.txt"), &f.0.join("root/secret.txt")) { + eprintln!("skipped: this system doesn't let tests make symbolic links"); + return; + } + assert!(r.check(Path::new(&f.p("root/escape/secret.txt")), Access::Read).is_err()); + assert!(r.check(Path::new(&f.p("root/escape/new.txt")), Access::Write).is_err()); + assert!(r.check(Path::new(&f.p("root/secret.txt")), Access::Read).is_err()); + assert!(r.check(Path::new(&f.p("root/secret.txt")), Access::Write).is_err()); + // A link that leads nowhere: writing through it would create its target. + assert!(symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt"))); + let e = r.check(Path::new(&f.p("root/dangling.txt")), Access::Write).unwrap_err(); + assert!(e.contains("leads nowhere"), "{e}"); + // A link inside the root to the root is fine. + assert!(symlink_dir(&f.0.join("root/sub"), &f.0.join("root/alias"))); + assert!(r.check(Path::new(&f.p("root/alias/x.png")), Access::Write).is_ok()); +} + +#[cfg(windows)] +#[test] +fn windows_case_devices_and_streams() { + let f = Fixture::new("windows"); + let r = f.rw(); + // Case is taken from the disk: another spelling of the root is the root. + let upper = f.0.join("ROOT").join("IN.TXT"); + assert!(r.check(&upper, Access::Read).is_ok()); + for name in ["CON", "nul.txt", "Com1.png", "LPT9", "a.txt:stream", "conout$"] { + assert!(r.check(&f.0.join("root").join(name), Access::Write).is_err(), "{name}"); + } + assert!(r.check(&f.0.join("root").join("console.png"), Access::Write).is_ok()); + // The `\\?\` form of a path inside is inside; of a path outside, outside. + let verbatim = std::fs::canonicalize(f.p("root")).unwrap().join("v.png"); + assert!(verbatim.to_string_lossy().starts_with(r"\\?\")); + assert!(r.check(&verbatim, Access::Write).is_ok()); + assert!(r.check(&std::fs::canonicalize(f.p("outside")).unwrap().join("v.png"), Access::Write).is_err()); + // Messages don't show the prefix. + let e = r.check(Path::new(&f.p("outside/x.png")), Access::Write).unwrap_err(); + assert!(!e.contains(r"\\?\"), "{e}"); +} + +#[test] +fn a_scope_confines_the_thread_and_ends_with_it() { + let f = Fixture::new("scope"); + let roots = Arc::new(f.rw()); + assert!(current().is_none()); + confined(Some(&roots), || { + assert!(allow(f.p("outside/secret.txt"), Access::Read).is_err()); + assert!(super::read(f.p("outside/secret.txt")).is_err()); + assert_eq!(super::read(f.p("root/in.txt")).unwrap(), b"in"); + // Not confined: the app's own files. + assert_eq!(super::app_owned(|| super::read(f.p("outside/secret.txt")).unwrap()), b"secret"); + assert!(current().is_some(), "back in the scope after app_owned"); + // Another thread isn't confined by this one's scope. + let p = f.p("outside/secret.txt"); + assert!(std::thread::spawn(move || current().is_none() && allow(p, Access::Read).is_ok()).join().unwrap()); + }); + assert!(current().is_none()); + assert!(allow(f.p("outside/secret.txt"), Access::Read).is_ok()); + // A panic inside the scope still ends it. + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| confined(Some(&roots), || panic!("boom")))); + assert!(r.is_err()); + assert!(current().is_none()); + // `None` changes nothing. + assert!(confined(None, || current().is_none())); +} + +#[test] +fn commands_run_confined_read_and_write_only_inside() { + let f = Fixture::new("commands"); + std::fs::create_dir_all(f.0.join("outside/readable")).unwrap(); + let roots = Arc::new(Roots::new(&[f.p("outside/readable")], &[f.p("root")]).unwrap()); + // Unconfined, the outside is writable: the bait files a confined command must not reach. + let bait = f.p("outside/bait.figcraft"); + let mut s = Session::new(); + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap(); + s.execute("file.saveAs", &json!({"path": bait})).unwrap(); + std::fs::copy(&bait, f.0.join("outside/readable/ok.figcraft")).unwrap(); + let mut s = Session::new(); + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap(); + s.prefs.recent_files = vec![bait.clone(), f.p("root/in.txt")]; + confined(Some(&roots), || { + let e = s.execute("file.export", &json!({"format": "png", "path": f.p("outside/x.png")})).unwrap_err().to_string(); + assert!(e.contains("automation path rejected"), "{e}"); + assert!(s.execute("file.export", &json!({"format": "png", "path": f.p("root/x.png")})).is_ok()); + assert!(s.execute("file.exportLayers", &json!({"dir": f.p("outside/layers")})).is_err()); + assert!(s.execute("file.open", &json!({"path": bait})).is_err()); + assert!(s.execute("file.import", &json!({"path": bait})).is_err()); + assert!(s.execute("file.placeImage", &json!({"path": f.p("outside/secret.txt")})).is_err()); + assert!(s.execute("file.open", &json!({"path": f.p("outside/readable/ok.figcraft")})).is_ok(), "read roots may be read"); + assert!(s.execute("file.save", &json!({})).is_err(), "but not written"); + assert!(s.execute("file.saveAs", &json!({"path": f.p("root/ok.figcraft")})).is_ok()); + // Recent files outside the roots aren't listed. + let recent = s.execute("file.openRecent", &json!({})).unwrap().to_string(); + assert!(!recent.contains("bait") && recent.contains("in.txt"), "{recent}"); + assert!(s.execute("file.openRecent", &json!({"path": bait})).is_err()); + }); + assert!(f.0.join("root/x.png").is_file() && !f.0.join("outside/x.png").exists() && !f.0.join("outside/layers").exists()); +} + +/// Every command, given paths outside the roots in its usual path parameters, writes nothing +/// there and opens nothing from there: they all reach files through `file_access`. +#[test] +fn no_command_reaches_outside_the_roots() { + let f = Fixture::new("sweep"); + let roots = Arc::new(f.rw()); + let outside = f.0.join("outside"); + // Bait the sweep could pick up: a document, an SVG, a PNG and variables JSON, each holding a + // name that must never show up in a document or a result. + let mut s = Session::new(); + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10, "name": "BAIT-LAYER"})).unwrap(); + s.execute("file.saveAs", &json!({"path": f.p("outside/bait.figcraft")})).unwrap(); + s.execute("file.export", &json!({"format": "png", "path": f.p("outside/bait.png")})).unwrap(); + std::fs::write( + outside.join("bait.svg"), + r#""#, + ) + .unwrap(); + let before: Vec<_> = std::fs::read_dir(&outside).unwrap().flatten().map(|e| e.file_name()).collect(); + // The bait is what an unconfined command finds. + for (cmd, bait) in [("file.open", "bait.figcraft"), ("file.import", "bait.svg")] { + let mut s = Session::new(); + s.execute(cmd, &json!({"path": f.p(&format!("outside/{bait}"))})).unwrap(); + assert!(crate::inspect::document(&s, 64, true).to_string().contains("BAIT-LAYER"), "{cmd} {bait}"); + } + let mut s = Session::new(); + s.execute("file.placeImage", &json!({"path": f.p("outside/bait.png")})).unwrap(); + assert!(!s.doc().unwrap().images.is_empty()); + for bait in ["bait.figcraft", "bait.svg", "bait.png", "new.figcraft", "new.png"] { + let path = f.p(&format!("outside/{bait}")); + let params = json!({"path": path, "dir": f.p("outside"), "file": path, "variables": path, "folder": f.p("outside"), "confirm": true}); + for c in crate::all_commands() { + let mut s = Session::new(); + let r = confined(Some(&roots), || s.execute(c.id, ¶ms)); + let shown = format!("{r:?}"); + assert!(!shown.contains("BAIT-LAYER"), "{} read {bait}: {shown}", c.id); + let docs = crate::inspect::document(&s, 64, true).to_string(); + assert!(!docs.contains("BAIT-LAYER"), "{} opened {bait}", c.id); + assert!(s.docs.iter().all(|ds| ds.doc.images.is_empty()), "{} placed {bait}", c.id); + let now: Vec<_> = std::fs::read_dir(&outside).unwrap().flatten().map(|e| e.file_name()).collect(); + assert_eq!(now, before, "{} wrote outside the roots with {bait}", c.id); + } + } +} + +#[test] +fn recent_files_outside_the_roots_are_never_opened_removed_or_named() { + let f = Fixture::new("recents"); + let roots = Arc::new(f.rw()); + let (bait, gone, ok) = (f.p("outside/bait.figcraft"), f.p("outside/gone.figcraft"), f.p("root/ok.figcraft")); + let mut s = Session::new(); + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap(); + s.execute("file.saveAs", &json!({"path": bait})).unwrap(); + s.execute("file.saveAs", &json!({"path": ok})).unwrap(); + let mut s = Session::new(); + let recents = vec![bait.clone(), gone.clone(), ok.clone()]; + s.prefs.recent_files = recents.clone(); + let quiet = |r: &crate::Result| { + let shown = format!("{r:?}"); + assert!(!shown.contains("bait") && !shown.contains("gone"), "an outside recent file was named: {shown}"); + }; + confined(Some(&roots), || { + let listing = s.execute("file.openRecent", &json!({})); + quiet(&listing); + assert_eq!(listing.unwrap()["files"].as_array().unwrap().len(), 1); + // `index` counts the files listed: 0 is the first one inside the roots. + let opened = s.execute("file.openRecent", &json!({"index": 0})); + quiet(&opened); + assert!(opened.is_ok(), "{opened:?}"); + assert_eq!(s.ds().unwrap().path.as_deref(), Some(ok.as_str())); + for i in [1, 2] { + let r = s.execute("file.openRecent", &json!({"index": i})); + quiet(&r); + assert!(r.is_err()); + } + // A path outside is refused like any outside path, and never dropped from the list. + let e = s.execute("file.openRecent", &json!({"path": gone})).unwrap_err().to_string(); + assert!(e.contains("is outside the folders that may be read") && !e.contains("no longer exists"), "{e}"); + quiet(&s.execute("file.openRecent", &json!({"remove": bait}))); + assert!(s.prefs.recent_files.contains(&bait) && s.prefs.recent_files.contains(&gone)); + // Clear clears what is listed. + quiet(&s.execute("file.openRecent", &json!({"clear": true}))); + }); + assert_eq!(s.prefs.recent_files, vec![bait, gone]); + // Unconfined, a recent file that is gone is still dropped. + s.execute("file.openRecent", &json!({"index": 1})).unwrap_err(); + assert_eq!(s.prefs.recent_files.len(), 1); +} + +#[test] +fn outside_paths_are_refused_alike_whatever_exists_there() { + let f = Fixture::new("oracle"); + let r = f.rw(); + let refusal = |p: &str| r.check(Path::new(p), Access::Write).unwrap_err(); + let standard = |p: &str| format!("automation path rejected: {} is outside the folders that may be written:", Path::new(p).display()); + let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")]; + if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) { + outside.push(f.p("outside/dangling.txt")); + } + for p in &outside { + let e = refusal(p); + assert!(e.starts_with(&standard(p)), "{p}: {e}"); + } + // Inside a root the reason is given. + if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) { + assert!(refusal(&f.p("root/dangling.txt")).contains("leads nowhere")); + } +} + +#[test] +fn only_resolved_paths_count_as_the_apps_own_folder() { + let f = Fixture::new("own"); + let install = f.0.join("root"); + assert!(super::within_app_folder(&install.join("in.txt"), &install)); + assert!(!super::within_app_folder(&install.join("..").join("outside").join("secret.txt"), &install), "`..` leaves it"); + assert!(!super::within_app_folder(&f.0.join("root2"), &install), "a folder named like it is another"); + assert!(!super::within_app_folder(&install.join("missing.wasm"), &install)); + assert!(!super::within_app_folder(&install.join("in.txt"), &f.0.join("no-such-folder"))); +} diff --git a/crates/engine/src/finite_geometry_tests.rs b/crates/engine/src/finite_geometry_tests.rs new file mode 100644 index 000000000..416b250c8 --- /dev/null +++ b/crates/engine/src/finite_geometry_tests.rs @@ -0,0 +1,40 @@ +//! Commands never leave a layer with a non-finite position or size: such a file saves but can't be +//! reopened (JSON writes NaN and infinity as null). + +use serde_json::json; + +use crate::Session; + +fn rect(s: &mut Session) -> crate::NodeId { + let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 10, "width": 100, "height": 50})).unwrap(); + crate::params::id_of(&r["id"]).unwrap() +} + +fn assert_roundtrips(s: &Session) { + let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + assert!(figcraft_format::load_bytes(&bytes).is_ok(), "the saved file reopens"); +} + +#[test] +fn huge_moves_are_refused_and_the_file_still_reopens() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 0})).ok(); + let second = s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 1e308})); + assert!(second.is_err(), "a move past the f64 range is refused"); + let n = s.doc().unwrap().node(r).unwrap(); + assert!(n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{:?}", n.transform); + assert_roundtrips(&s); +} + +#[test] +fn huge_scale_is_refused_and_the_file_still_reopens() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300})).ok(); + let second = s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300})); + assert!(second.is_err(), "a size past the f64 range is refused"); + let n = s.doc().unwrap().node(r).unwrap(); + assert!(n.width.is_finite() && n.height.is_finite(), "{} x {}", n.width, n.height); + assert_roundtrips(&s); +} diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs new file mode 100644 index 000000000..3548124d5 --- /dev/null +++ b/crates/engine/src/fonts.rs @@ -0,0 +1,100 @@ +//! Fonts a document names that this machine doesn't have (#752): text layers, their styled +//! ranges, text styles and the string variables bound to a font family. Text in such a family +//! is drawn in a fallback font, so the user is told which ones are missing. + +use std::collections::BTreeMap; + +use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::{Result, Session, spec}; + +/// A family the document names that no installed or built-in font provides. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Missing { + /// As the document spells it. + pub family: String, + /// Text layers that use it (0: only a text style or a variable names it). + pub layers: usize, +} + +/// The families `doc` names that the font database doesn't have, by name. On the desktop the +/// installed fonts are indexed in the background: ask once that's done +/// (`figcraft_text::installed_fonts_known`), or every installed family counts as missing. +pub fn missing(doc: &Document) -> Vec { + // Lower-case family → (spelling, layers). + let mut used: BTreeMap = BTreeMap::new(); + let mut add = |family: &str, layer: bool| { + let family = family.trim(); + if family.is_empty() { + return; + } + let e = used.entry(family.to_lowercase()).or_insert_with(|| (family.to_string(), 0)); + e.1 += usize::from(layer); + }; + for n in doc.nodes() { + if let NodeKind::Text(t) = &n.kind { + let mut families: Vec<&str> = t.spans().into_iter().map(|(_, _, st)| st.font_family.as_str()).collect(); + families.push(&t.style.font_family); + families.sort_by_key(|f| f.trim().to_lowercase()); + families.dedup_by_key(|f| f.trim().to_lowercase()); + for f in families { + add(f, true); + } + } + if let Some(var) = n.bound.get("fontFamily") { + for f in string_values(&doc.variables, var) { + add(&f, false); + } + } + } + for st in &doc.styles { + if let StyleKind::Text { style } = &st.kind { + add(&style.font_family, false); + } + if let Some(var) = st.bound.get("fontFamily") { + for f in string_values(&doc.variables, var) { + add(&f, false); + } + } + } + used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect() +} + +/// Every string value of variable `id` in any mode, following aliases (at most 32 deep). +fn string_values(vars: &Variables, id: &str) -> Vec { + let mut out = Vec::new(); + let mut todo = vec![(id.to_string(), 0)]; + while let Some((id, depth)) = todo.pop() { + let Some(var) = vars.get(&id) else { continue }; + for v in var.values.values() { + match v { + VariableValue::String(s) => out.push(s.clone()), + VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)), + _ => {} + } + } + } + out +} + +fn missing_fonts(s: &mut Session, _: &Value) -> Result { + let families: Vec = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect(); + s.request_ui("dialog.missingFonts", json!({})); + Ok(json!({"families": families})) +} + +pub fn specs() -> Vec { + vec![spec!( + "text.missingFonts", + "Missing fonts", + "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", + false, + missing_fonts + )] +} + +#[cfg(test)] +#[path = "fonts_tests.rs"] +mod tests; diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs new file mode 100644 index 000000000..a6e2d213f --- /dev/null +++ b/crates/engine/src/fonts_tests.rs @@ -0,0 +1,126 @@ +use serde_json::json; + +use super::*; +use crate::params::id_of; +use figcraft_doc::{Style, StyleRun, TextStyle}; + +fn text(s: &mut Session, family: &str) -> figcraft_doc::NodeId { + let id = id_of(&s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"]).unwrap(); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind { + t.style.font_family = family.into(); + } + id +} + +fn families(s: &mut Session) -> Vec<(String, u64)> { + let r = s.execute("text.missingFonts", &json!({})).unwrap(); + r["families"].as_array().unwrap().iter().map(|f| (f["family"].as_str().unwrap().to_string(), f["layers"].as_u64().unwrap())).collect() +} + +/// An installed family (built in, or found by the system scan), if this machine has any. +fn installed() -> Option { + figcraft_text::scan_system_fonts(); + figcraft_text::family_names().into_iter().next() +} + +#[test] +fn lists_families_of_layers_ranges_styles_and_variables_once_each() { + let mut s = Session::new(); + text(&mut s, "FigCraft Missing A"); + let b = text(&mut s, "figcraft missing a"); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(b).unwrap().kind { + let style = TextStyle { font_family: "FigCraft Missing B".into(), ..t.style.clone() }; + t.runs.push(StyleRun { start: 0, end: 3, style }); + } + let doc = s.doc_mut().unwrap(); + doc.styles.push(Style { + id: "S:1".into(), + name: "Heading".into(), + description: String::new(), + kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } }, + bound: Default::default(), + }); + s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap(); + s.execute("variable.create", &json!({"collection": "Type", "name": "Family", "type": "string", "value": "FigCraft Missing D"})).unwrap(); + s.execute("variable.set", &json!({"variable": "Family", "mode": "Dark", "value": "FigCraft Missing H"})).unwrap(); + let var = s.doc().unwrap().variables.get("Family").unwrap().id.clone(); + s.doc_mut().unwrap().get_mut(b).unwrap().bound.insert("fontFamily".into(), var); + + assert_eq!( + families(&mut s), + vec![ + ("FigCraft Missing A".to_string(), 2), + ("FigCraft Missing B".to_string(), 1), + ("FigCraft Missing C".to_string(), 0), + ("FigCraft Missing D".to_string(), 0), + ("FigCraft Missing H".to_string(), 0), + ] + ); + assert!(s.ui_requests.iter().any(|r| r.kind == "dialog.missingFonts")); +} + +#[test] +fn installed_families_are_not_listed() { + let Some(family) = installed() else { + eprintln!("skipped: no fonts on this machine"); + return; + }; + let mut s = Session::new(); + text(&mut s, &family); + text(&mut s, &family.to_uppercase()); + text(&mut s, "FigCraft Missing E"); + assert_eq!(families(&mut s), vec![("FigCraft Missing E".to_string(), 1)]); +} + +#[test] +fn a_family_found_only_by_the_system_scan_is_not_missing() { + figcraft_text::scan_system_fonts(); + let embedded: Vec = figcraft_text::CRAFT_FONTS.iter().map(|f| f.family.to_lowercase()).collect(); + let Some(family) = figcraft_text::family_names().into_iter().find(|f| !embedded.contains(&f.to_lowercase())) else { + eprintln!("skipped: no system fonts on this machine"); + return; + }; + let mut s = Session::new(); + text(&mut s, &family); + assert!(families(&mut s).is_empty(), "{family} is installed"); +} + +#[test] +fn blank_families_and_aliases_are_handled() { + let mut s = Session::new(); + text(&mut s, " "); + s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap(); + s.execute("variable.create", &json!({"collection": "Type", "name": "Base", "type": "string", "value": "FigCraft Missing F"})).unwrap(); + let base = s.doc().unwrap().variables.get("Base").unwrap().id.clone(); + let t = text(&mut s, "FigCraft Missing G"); + let doc = s.doc_mut().unwrap(); + // A variable that only aliases another, and one aliasing itself. + let mut alias = doc.variables.get(&base).unwrap().clone(); + alias.id = "V:alias".into(); + alias.name = "Alias".into(); + for v in alias.values.values_mut() { + *v = VariableValue::Alias(base.clone()); + } + let mut cycle = alias.clone(); + cycle.id = "V:cycle".into(); + cycle.name = "Cycle".into(); + for v in cycle.values.values_mut() { + *v = VariableValue::Alias("V:cycle".into()); + } + doc.variables.variables.push(alias); + doc.variables.variables.push(cycle); + doc.get_mut(t).unwrap().bound.insert("fontFamily".into(), "V:alias".into()); + let other = text(&mut s, "FigCraft Missing G"); + s.doc_mut().unwrap().get_mut(other).unwrap().bound.insert("fontFamily".into(), "V:cycle".into()); + // The bound layer takes the value its alias resolves to (bindings are applied after the next + // command); the cycle resolves to nothing. + let NodeKind::Text(bound) = &s.doc().unwrap().node(t).unwrap().kind else { panic!() }; + assert_eq!(bound.style.font_family, "FigCraft Missing F"); + assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]); +} + +#[test] +fn needs_an_open_document() { + let mut s = Session::empty(); + assert!(s.execute("text.missingFonts", &json!({})).is_err()); +} diff --git a/crates/engine/src/frame_label_tests.rs b/crates/engine/src/frame_label_tests.rs index 8e9ede7ba..3da9522fc 100644 --- a/crates/engine/src/frame_label_tests.rs +++ b/crates/engine/src/frame_label_tests.rs @@ -1,6 +1,7 @@ //! Frame name labels as pointer targets: a press on a label (its area as the canvas drew it, //! turned with a rotated frame) selects the frame and drags it in one undo step, hovering it -//! highlights the frame, and labels of locked, hidden, deleted or other pages' frames do nothing. +//! highlights the frame, a double-click asks the UI to rename it there, and labels of locked, +//! hidden, deleted or other pages' frames do nothing. use figcraft_doc::NodeId; use figcraft_geom::Point; @@ -90,3 +91,24 @@ fn labels_of_frames_that_cant_be_picked_do_nothing() { let flat = [Point::new(1.0, 1.0); 4]; assert_eq!(label_at(s.doc().unwrap(), page, &[(f, nan), (f, flat)], Point::new(1.0, 1.0)), None); } + +#[test] +fn double_clicking_a_label_asks_to_rename_its_frame() { + let (mut s, f, q) = setup(); + let c = center(&q); + let before = s.doc().unwrap().world_bounds(f).unwrap(); + let undo = s.ds().unwrap().history.undo.len(); + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] { + s.pointer(&ev(kind, c)).unwrap(); + } + assert_eq!(s.selection(), vec![f]); + let asked = s.ui_requests.iter().find(|r| r.kind == "canvas.renameLabel").map(|r| id_of(&r.data["id"])); + assert_eq!(asked, Some(Some(f)), "the UI renames the frame at its label"); + assert_eq!(s.doc().unwrap().world_bounds(f), Some(before), "not moved"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo, "nothing to undo yet"); + // Other tools ignore the label. + s.ui_requests.clear(); + s.set_tool(crate::tools::ToolId::Hand); + s.pointer(&ev(PointerKind::DoubleClick, c)).unwrap(); + assert!(s.ui_requests.iter().all(|r| r.kind != "canvas.renameLabel")); +} diff --git a/crates/engine/src/huge_stroke_tests.rs b/crates/engine/src/huge_stroke_tests.rs new file mode 100644 index 000000000..7efabb484 --- /dev/null +++ b/crates/engine/src/huge_stroke_tests.rs @@ -0,0 +1,28 @@ +//! An absurd stroke weight must not take the renderer down with it: saving (thumbnail) and exporting still work. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::NodeId; + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +#[test] +fn huge_stroke_weight_with_round_caps_still_saves_and_exports() { + for weight in [1e6, 1e7, 1e10, 1e30] { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let v = id(s.execute("vector.create", &json!({"parent": f, "path": "M0 0.8333 L7.5 6.25 L15 0.8333"})).unwrap()); + s.execute("node.set", &json!({"id": v, "props": {"stroke": "#ff0000", "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round"}})) + .unwrap(); + let path = std::env::temp_dir().join(format!("figcraft-huge-stroke-{}-{weight}.figcraft", std::process::id())); + let saved = s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + assert!(saved.is_ok(), "saveAs at weight {weight}: {:?}", saved.err()); + let exported = s.execute("file.export", &json!({"ids": [f], "format": "png"})); + assert!(exported.is_ok(), "export at weight {weight}: {:?}", exported.err()); + } +} diff --git a/crates/engine/src/id_params_behaviour_tests.rs b/crates/engine/src/id_params_behaviour_tests.rs new file mode 100644 index 000000000..c5207b44e --- /dev/null +++ b/crates/engine/src/id_params_behaviour_tests.rs @@ -0,0 +1,161 @@ +//! Layer ids in params: naming something that isn't a layer is a mistake, and says so, rather than +//! quietly meaning "none". +//! +//! `node.create`'s `parent` was `id(p, "parent").unwrap_or(page)`, so any value that isn't an id — +//! a `{"$step": n}` nothing had resolved, a name, a negative number — became "no parent" and the +//! layer landed on the page while the call reported success. The same shape sat on `image.place` +//! and `instance.create`'s parents, on `index`, and on the `ids` of every command that targets +//! layers (an entry that wasn't an id was dropped and the rest ran). + +use crate::params::id_of; +use crate::{EngineError, Session}; +use figcraft_doc::*; +use serde_json::{Value, json}; + +/// The page's children, by name (bottom first). +fn top(s: &Session) -> Vec { + let doc = s.doc().unwrap(); + let page = s.page().unwrap(); + doc.node(page).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.clone())).collect() +} + +/// The message of a failure, or `None` when the call went through. +fn failed(r: std::result::Result) -> Option { + match r { + Ok(_) => None, + Err(e) => Some(e.to_string()), + } +} + +fn rect(parent: Value) -> Value { + json!({"type": "rectangle", "name": "child", "parent": parent, "x": 0, "y": 0, "width": 10, "height": 10}) +} + +fn card(s: &mut Session) -> NodeId { + let v = s.execute("node.create", &json!({"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap(); + id_of(&v["id"]).unwrap() +} + +#[test] +fn a_parent_that_is_not_an_id_is_refused_rather_than_becoming_the_page() { + let mut s = Session::new(); + let card = card(&mut s); + for parent in [json!({"$step": 0}), json!("Card"), json!(-1), json!(true)] { + let msg = failed(s.execute("node.create", &rect(parent.clone()))).unwrap_or_else(|| panic!("{parent} must be refused")); + assert!(msg.contains("`parent` must be a node id"), "{parent}: {msg}"); + } + assert_eq!(top(&s), ["Card"], "nothing landed on the page"); + let doc = s.doc().unwrap(); + assert!(doc.node(card).unwrap().children.is_empty(), "nor inside the frame"); +} + +#[test] +fn leaving_the_parent_out_or_null_still_means_the_page() { + let mut s = Session::new(); + let card = card(&mut s); + for parent in [None, Some(json!(null))] { + let mut p = rect(json!(0)); + match parent { + None => _ = p.as_object_mut().unwrap().remove("parent"), + Some(v) => p["parent"] = v, + } + s.execute("node.create", &p).unwrap(); + } + assert_eq!(top(&s), ["Card", "child", "child"]); + let doc = s.doc().unwrap(); + assert!(doc.node(card).unwrap().children.is_empty()); +} + +#[test] +fn a_parent_id_that_names_nothing_is_still_reported() { + let mut s = Session::new(); + card(&mut s); + let msg = failed(s.execute("node.create", &rect(json!(99999)))).unwrap_or_else(|| panic!("a missing layer must fail")); + assert!(msg.contains("no such node 99999"), "{msg}"); +} + +#[test] +fn an_index_that_is_not_a_whole_non_negative_number_is_refused() { + let mut s = Session::new(); + let card = card(&mut s); + // (A boolean is a number to `f64_of` — `true` reads as 1 — as for `x`, `width` and every + // other number, so it is deliberately not in this list.) + for index in [json!("last"), json!(1.5), json!(-1), json!(-0.5), json!(1e12)] { + let mut p = rect(json!(card)); + p["index"] = index.clone(); + let msg = failed(s.execute("node.create", &p)).unwrap_or_else(|| panic!("index {index} must be refused")); + assert!(msg.contains("`index`"), "{index}: {msg}"); + } + let doc = s.doc().unwrap(); + assert!(doc.node(card).unwrap().children.is_empty(), "nothing was inserted"); +} + +#[test] +fn a_whole_index_still_inserts_where_it_says() { + let mut s = Session::new(); + let card = card(&mut s); + for name in ["a", "b"] { + s.execute("node.create", &json!({"type": "rectangle", "name": name, "parent": card, "x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + } + s.execute("node.create", &json!({"type": "rectangle", "name": "mid", "parent": card, "index": 1, "x": 0, "y": 0, "width": 10, "height": 10})) + .unwrap(); + let doc = s.doc().unwrap(); + let names: Vec<&str> = doc.node(card).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.as_str())).collect(); + assert_eq!(names, ["a", "mid", "b"]); +} + +#[test] +fn ids_entries_that_are_not_ids_are_refused_instead_of_being_dropped() { + let mut s = Session::new(); + let card = card(&mut s); + // The junk used to be dropped and the rest applied, so `["nope", card]` renamed the frame. + for ids in [json!(["nope"]), json!(["nope", card]), json!([{"$step": 0}, card]), json!([null])] { + let msg = + failed(s.execute("node.set", &json!({"ids": ids, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("{ids} must be refused")); + assert!(msg.contains("`ids`"), "{ids}: {msg}"); + } + let doc = s.doc().unwrap(); + assert_eq!(doc.node(card).unwrap().name, "Card", "nothing was renamed"); +} + +#[test] +fn an_ids_that_is_not_an_array_is_refused_but_null_means_the_selection() { + let mut s = Session::new(); + let card = card(&mut s); + let msg = + failed(s.execute("node.set", &json!({"ids": "nope", "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a non-array ids must fail")); + assert!(msg.contains("`ids` must be an array"), "{msg}"); + // An explicit null says the same as leaving it out: the selection. + s.execute("select.set", &json!({"ids": [card]})).unwrap(); + s.execute("node.set", &json!({"ids": null, "props": {"name": "Selected"}})).unwrap(); + assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Selected"); +} + +#[test] +fn a_single_id_that_is_not_an_id_is_refused() { + let mut s = Session::new(); + card(&mut s); + let msg = + failed(s.execute("node.set", &json!({"id": {"$step": 0}, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a bad id must fail")); + assert!(msg.contains("`id` must be a node id"), "{msg}"); +} + +#[test] +fn an_image_and_an_instance_parent_are_checked_the_same_way() { + let mut s = Session::new(); + card(&mut s); + let img = failed(s.execute("image.place", &json!({"data": "aGk=", "parent": {"$step": 0}}))); + assert!(img.unwrap_or_default().contains("`parent` must be a node id"), "image.place"); + let inst = failed(s.execute("instance.create", &json!({"component": 99999, "parent": {"$step": 0}}))); + assert!(inst.is_some(), "instance.create refuses both a bad component and a bad parent"); +} + +#[test] +fn a_huge_bad_value_is_cut_short_in_the_error() { + let mut s = Session::new(); + card(&mut s); + let junk = "é".repeat(10_000); + let msg = failed(s.execute("node.set", &json!({"ids": junk, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("must fail")); + assert!(msg.contains("`ids` must be an array") && msg.ends_with("…)"), "{msg}"); + assert!(msg.chars().count() < 200, "{} chars", msg.chars().count()); +} diff --git a/crates/engine/src/import_journal_tests.rs b/crates/engine/src/import_journal_tests.rs new file mode 100644 index 000000000..f8fa84efc --- /dev/null +++ b/crates/engine/src/import_journal_tests.rs @@ -0,0 +1,51 @@ +//! Imported file bytes already live in the document: the command log keeps no second copy. + +use base64::Engine as _; +use serde_json::{Value, json}; + +use crate::Session; + +const SVG: &str = r##""##; + +fn payload() -> String { + base64::engine::general_purpose::STANDARD.encode(SVG) +} + +fn holds_payload(journal: &[(String, Value)]) -> bool { + let payload = payload(); + journal.iter().any(|(_, params)| params.to_string().contains(&payload)) +} + +#[test] +fn byte_imports_keep_no_payload_in_the_journal() { + let mut s = Session::new(); + let before = s.doc().unwrap().node_count(); + s.execute("file.import", &json!({"data": payload(), "name": "pasted.svg"})).unwrap(); + assert!(!holds_payload(&s.journal), "the journal must not retain the Base64 file"); + // Not journaling is no change to the edit itself: still one undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().node_count(), before); +} + +#[test] +fn batch_wrappers_do_not_retain_import_payloads() { + let mut s = Session::new(); + let nested = + json!({"command": "command.batch", "params": {"commands": [{"command": "file.import", "params": {"data": payload(), "name": "a.svg"}}]}}); + s.execute("command.batch", &json!({"commands": [nested]})).unwrap(); + assert!(!holds_payload(&s.journal)); +} + +#[test] +fn path_imports_and_ordinary_edits_are_still_journaled() { + let dir = std::env::temp_dir().join(format!("figcraft-import-journal-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("kit.svg"); + std::fs::write(&path, SVG).unwrap(); + let mut s = Session::new(); + s.execute("file.import", &json!({"path": path.to_string_lossy()})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let ids: Vec<_> = s.journal.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["file.import", "shape.rectangle"]); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 1f461c60a..88bc015ad 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -191,7 +191,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { o.insert("textDecorationSkipInk".into(), json!(l.skip_ink)); o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); } - // Lists: one style for the layer, or each paragraph's type and nesting level. + // Lists: one style for the layer, or each paragraph's type, nesting level and start. if t.paragraphs.is_empty() { if t.list != figcraft_doc::ListStyle::None { o.insert("listStyle".into(), json!(t.list)); @@ -199,6 +199,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { } else { o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect()); o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect()); + if t.paragraphs.iter().any(|p| p.start_offset > 0) { + o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); + } } if t.list_spacing != 0.0 { o.insert("listSpacing".into(), json!(t.list_spacing)); diff --git a/crates/engine/src/instance_resizing_tests.rs b/crates/engine/src/instance_resizing_tests.rs new file mode 100644 index 000000000..43386af96 --- /dev/null +++ b/crates/engine/src/instance_resizing_tests.rs @@ -0,0 +1,190 @@ +//! Size and resizing overrides of layers inside instances: a layer in an instance can hug, fill or +//! keep a fixed size of its own, and that override is kept through relayout, edits of the main +//! (only the overridden property stays), undo, save and reopen (native and .fig), REST import, +//! detach and reset. + +use base64::Engine; +use serde_json::{Value, json}; + +use crate::Session; +use crate::components::tests::{copy_of, ex, healthy, idv, node}; +use figcraft_doc::*; + +/// A "Field" component: a 240×40 fixed horizontal auto layout frame (no padding or gap) holding +/// "Atom", an auto layout frame hugging its 20×20 "Dot" with a min width of 100; and an instance of +/// it. Returns (component, atom, dot, instance). +fn field(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { + let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 240, "height": 40, "name": "Field"}))); + ex(s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "fixed", "sizingV": "fixed"}})); + let atom = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": f, "name": "Atom"}))); + ex( + s, + "node.set", + json!({"id": atom, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "hug", "sizingV": "hug", "minWidth": 100}}), + ); + let dot = idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": atom, "props": {"name": "Dot"}}))); + let c = idv(&ex(s, "component.create", json!({"ids": [f]}))); + let i = idv(&ex(s, "instance.create", json!({"component": c, "x": 0, "y": 100}))); + (c, atom, dot, i) +} + +fn sizing_h(s: &Session, id: NodeId) -> Sizing { + node(s, id).child_layout.sizing_h +} + +/// The layer named `name` below instance `inst`. +fn named(s: &Session, inst: NodeId, name: &str) -> NodeId { + let doc = s.doc().unwrap(); + doc.subtree(inst).into_iter().find(|d| *d != inst && doc.node(*d).is_some_and(|n| n.name == name)).unwrap() +} + +/// The first instance in the document. +fn instance(s: &Session) -> NodeId { + s.doc().unwrap().nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap().id +} + +fn open(s: &mut Session, bytes: &[u8], name: &str) { + ex(s, "file.open", json!({"data": base64::engine::general_purpose::STANDARD.encode(bytes), "name": name})); +} + +#[test] +fn a_layer_in_an_instance_fills_and_keeps_it_through_main_edits() { + let mut s = Session::new(); + let (c, atom, dot, i) = field(&mut s); + let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot)); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0), "hugs its dot, at least 100 wide"); + ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0), "fills the instance"); + assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides); + // Edits of the main relay the instance out: the override stays, other properties follow. + ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30, "fill": "#ff0000"}})); + assert_eq!((node(&s, ia).width, node(&s, idot).width), (240.0, 30.0)); + ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120, "fill": "#00ff00"}})); + assert_eq!(node(&s, atom).width, 120.0); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).child_layout.min_w), (Sizing::Fill, 240.0, Some(100.0))); + assert_eq!(node(&s, ia).fills, node(&s, atom).fills, "the fill isn't overridden: it follows the main"); + // The main fixing its own width is the same property: the instance keeps filling. + ex(&mut s, "node.set", json!({"id": atom, "props": {"width": 80}})); + assert_eq!((sizing_h(&s, atom), sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, Sizing::Fill, 240.0)); + // A structure edit of the main rebuilds the instance: the atom fills what the new layer leaves. + ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": c})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 235.0)); + healthy(&s); + // Back to the main's resizing: no override left. + ex(&mut s, "component.resetOverride", json!({"id": ia, "group": "resizing"})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, node(&s, atom).width)); + assert!(!node(&s, ia).overrides.contains("resizing")); + healthy(&s); +} + +#[test] +fn size_and_resizing_overrides_undo_reset_and_detach() { + let mut s = Session::new(); + let (_, atom, dot, i) = field(&mut s); + let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot)); + ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}})); + ex(&mut s, "edit.undo", json!({})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0)); + assert!(node(&s, ia).overrides.is_empty()); + // A fixed size of its own: kept when the main resizes the layer. + ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 50, "height": 10}})); + assert!(node(&s, idot).overrides.contains("size")); + ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}})); + assert_eq!((node(&s, idot).width, node(&s, idot).height, node(&s, dot).width), (50.0, 10.0, 30.0)); + // A min width of its own. + ex(&mut s, "node.set", json!({"id": ia, "props": {"minWidth": 150}})); + assert_eq!(node(&s, ia).width, 150.0); + ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 90}})); + assert_eq!(node(&s, ia).width, 150.0); + healthy(&s); + // Reset all changes: the main's values again. + ex(&mut s, "component.resetInstance", json!({"ids": [i]})); + assert_eq!((node(&s, ia).width, node(&s, idot).width), (90.0, 30.0)); + assert!(node(&s, ia).overrides.is_empty() && node(&s, idot).overrides.is_empty()); + // Detaching keeps what the instance showed. + ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}})); + ex(&mut s, "component.detach", json!({"ids": [i]})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).source), (Sizing::Fill, 240.0, None)); + healthy(&s); +} + +#[test] +fn layers_in_instances_resize_but_keep_their_place() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"}))); + let title = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}}))); + let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + let it = copy_of(&s, i, title); + ex(&mut s, "node.set", json!({"id": it, "props": {"width": 150}})); + assert_eq!(node(&s, it).width, 150.0, "a text box resizes"); + assert!(node(&s, it).overrides.contains("size")); + // Its place, rotation and constraints are still the main's. + for props in [json!({"x": 50}), json!({"rotation": 15}), json!({"constraints": {"horizontal": "right"}})] { + assert!(s.execute("node.set", &json!({"id": it, "props": props})).is_err(), "{props} is refused"); + } + // The instance's own resizing is its own, not an override. + ex(&mut s, "node.set", json!({"id": i, "props": {"minWidth": 100}})); + assert!(!node(&s, i).overrides.contains("resizing")); + healthy(&s); +} + +#[test] +fn resizing_overrides_survive_save_and_reopen() { + let mut s = Session::new(); + let (_, atom, dot, i) = field(&mut s); + let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot)); + ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}})); + ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 40}})); + let native = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + let (fig, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap(); + for (bytes, name) in [(native, "field.figcraft"), (fig, "field.fig")] { + let mut s = Session::new(); + open(&mut s, &bytes, name); + let i = instance(&s); + let (ia, idot) = (named(&s, i, "Atom"), named(&s, i, "Dot")); + assert!(node(&s, ia).overrides.contains("resizing"), "{name}: {:?}", node(&s, ia).overrides); + assert!(node(&s, idot).overrides.contains("size"), "{name}: {:?}", node(&s, idot).overrides); + // An edit of the main relays the instance out. + let main = |s: &Session, id: NodeId| node(s, id).source.unwrap(); + let (atom, dot) = (main(&s, ia), main(&s, idot)); + ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120}})); + ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, idot).width), (Sizing::Fill, 240.0, 40.0), "{name}"); + healthy(&s); + } +} + +#[test] +fn imported_resizing_overrides_survive_relayout() { + let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]); + let dot = |id: &str| json!({"id": id, "name": "Dot", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20}}); + let atom = |id: &str, dot_id: &str, w: f64, more: Value| { + let mut a = json!({"id": id, "name": "Atom", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": w, "y": 20}, + "layoutMode": "HORIZONTAL", "primaryAxisSizingMode": "AUTO", "counterAxisSizingMode": "AUTO", "minWidth": 100, "children": [dot(dot_id)]}); + a.as_object_mut().unwrap().extend(more.as_object().unwrap().clone()); + a + }; + let field = |id: &str, ty: &str, y: f64, kids: Value| { + json!({"id": id, "name": "Field", "type": ty, "relativeTransform": at(0.0, y), "size": {"x": 240, "y": 40}, "layoutMode": "HORIZONTAL", + "primaryAxisSizingMode": "FIXED", "counterAxisSizingMode": "FIXED", "children": kids}) + }; + let mut inst = + field("2:1", "INSTANCE", 100.0, json!([atom("I2:1;1:2", "I2:1;1:3", 240.0, json!({"layoutSizingHorizontal": "FILL", "layoutGrow": 1}))])); + inst["componentId"] = json!("1:1"); + inst["overrides"] = json!([{"id": "I2:1;1:2", "overriddenFields": ["layoutSizingHorizontal", "layoutGrow"]}]); + let file = json!({"name": "Field", "document": {"id": "0:0", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Page", "type": "CANVAS", "children": [field("1:1", "COMPONENT", 0.0, json!([atom("1:2", "1:3", 100.0, json!({}))])), inst]} + ]}, "components": {"1:1": {"key": "k", "name": "Field", "description": ""}}, "styles": {}}); + let mut s = Session::new(); + open(&mut s, file.to_string().as_bytes(), "field.json"); + let i = instance(&s); + let ia = named(&s, i, "Atom"); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0)); + assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides); + // The main's first edit relays every instance out from it. + let main_atom = node(&s, ia).source.unwrap(); + ex(&mut s, "node.set", json!({"id": main_atom, "props": {"minWidth": 120}})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0)); + healthy(&s); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5f..c32a99bb7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -6,6 +6,8 @@ //! change is one undo step. Pointer tools ([`tools`]) and text editing reduce to the same document //! edits and are journaled the same way. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +// Tests make and inspect files directly; shipped code goes through `file_access` (clippy.toml). +#![cfg_attr(test, allow(clippy::disallowed_methods))] pub mod al_keys; pub mod annotations; @@ -24,7 +26,10 @@ pub mod dev_resources; pub mod devcompare; pub mod devinspect; pub mod distance; +pub mod doc_urls; pub mod drop; +pub mod file_access; +pub mod fonts; pub mod guard; pub mod guides; pub mod inspect; @@ -43,6 +48,8 @@ pub mod plugins; pub mod proto_run; pub mod rename; pub mod rotation_origin; +pub mod selection_colors; +pub mod selection_spacing; pub mod shape_handles; pub mod shapebuilder; pub mod snap; @@ -357,8 +364,8 @@ pub struct DocState { versions_changed: bool, /// A version shown in place of the document (read-only). pub preview: Option, - /// The document under a live text preview (a hovered text style or font instance, a dragged - /// axis slider): `doc` shows the preview until [`textpreview::end`] puts this back. + /// The document under a live text preview (a hovered font family or style, text style or + /// font instance, a dragged axis slider): `doc` shows the preview until [`textpreview::end`] puts this back. pub text_preview: Option>, /// The document as of the last version saved or restored (none yet): autosaves, restores and /// merges only add a checkpoint for changes since. @@ -369,6 +376,8 @@ pub struct DocState { pub guide: Option<(NodeId, usize)>, /// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved). pub(crate) rotation_origin: Option, + /// The View toggles the preferences keep were applied ([`Session::apply_view_prefs`]). + view_from_prefs: bool, /// The last duplicate while its copies are the selection: Duplicate repeats its offset (see /// [`cmd::edit::Duplicated`]; not saved). pub(crate) last_duplicate: Option, @@ -390,7 +399,8 @@ pub struct VersionPreview { /// Why edits are refused while a version is shown. pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore it or go back to the current version to edit"; -/// Commands that first end a version preview: they act on the document itself. +/// Commands that first end a version preview: they act on the document itself. Saving does so +/// only with a native destination; export copies and pending format choices keep the preview. const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -401,6 +411,8 @@ impl DocState { doc.repair(); } doc.link_layer_text_styles(); + cmd::styles::apply_paint_bindings_on_load(&mut doc); + // Initial import settlement belongs to the saved baseline, not the user's first edit. let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0)); let doc = Arc::new(doc); let mut st = Self { @@ -427,6 +439,7 @@ impl DocState { focus: None, guide: None, rotation_origin: None, + view_from_prefs: false, last_duplicate: None, }; st.zoom_to_fit(); @@ -606,6 +619,9 @@ pub struct Prefs { /// The widths of the main window's left and right panels (the interface keeps them within its /// limits; other windows have their own while they are open). pub panel_widths: (f32, f32), + /// The height asked for the main window's pages list, dragged at its lower edge (`None`: a + /// share of the panel; the interface keeps it within its limits). + pub pages_height: Option, /// The interface language: a language code ("en", "es", "pt-BR") or "system" (the OS or browser /// language). Languages without a catalog show English. pub language: String, @@ -652,8 +668,20 @@ pub struct Prefs { pub home_sort: String, /// Which way: `"desc"` (newest first, Z to A) or `"asc"`. pub home_order: String, + /// The View menu's toggles turned on or off (rulers, pixel grid, snapping…), by command id: + /// files opened or made later start with them. Missing: the toggle's default. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub view: BTreeMap, + /// Recently run actions (the Actions palette's Recents), newest first, at most + /// [`MAX_RECENT_ACTIONS`]. Entries are `command:`, `plugin:/` or + /// `widget:`. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub recent_actions: Vec, } +/// Recently run actions kept for the Actions palette (newest first). +pub const MAX_RECENT_ACTIONS: usize = 5; + impl Default for Prefs { fn default() -> Self { Self { @@ -666,6 +694,7 @@ impl Default for Prefs { show_dimensions: true, theme: "system".into(), panel_widths: (240.0, 240.0), + pages_height: None, language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, @@ -681,6 +710,8 @@ impl Default for Prefs { home_views: BTreeMap::new(), home_sort: "modified".into(), home_order: "desc".into(), + view: BTreeMap::new(), + recent_actions: Vec::new(), } } } @@ -721,7 +752,8 @@ pub struct Session { /// dark page colour while it is dark (added pages take the current page's). Headless sessions (CLI, MCP, exports) have no /// theme, so what scripts make is the same everywhere. pub ui_dark: Option, - /// Command log (id + params) for actions/automation. + /// Command log (id + params) for actions/automation. File imports that carry the file bytes + /// (`file.import` with `data`) are not logged, so the log never holds a copy of a pasted file. pub journal: Vec<(String, Value)>, /// Recently used colours. pub recent_colors: Vec, @@ -746,6 +778,19 @@ impl Default for Session { } } +/// Whether a command, or a batch wrapping one, imports a file passed as Base64 `data`. +fn command_carries_import_bytes(id: &str, params: &Value) -> bool { + match id { + "file.import" => params::get(params, "data").is_some(), + "command.batch" => params.get("commands").and_then(Value::as_array).is_some_and(|commands| { + commands.iter().any(|c| { + c.get("command").and_then(Value::as_str).zip(c.get("params")).is_some_and(|(id, params)| command_carries_import_bytes(id, params)) + }) + }), + _ => false, + } +} + impl Session { /// A session with one new document. pub fn new() -> Self { @@ -818,6 +863,14 @@ impl Session { s } + /// Files new to the session start with the View toggles the preferences keep. + pub fn apply_view_prefs(&mut self) { + for ds in self.docs.iter_mut().filter(|d| !d.view_from_prefs) { + cmd::view::apply_kept(&self.prefs, &mut ds.view); + ds.view_from_prefs = true; + } + } + pub fn ds(&self) -> Result<&DocState> { self.docs.get(self.active).ok_or(EngineError::NoDocument) } @@ -859,6 +912,7 @@ impl Session { return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into())); } branches::sync(self); + self.apply_view_prefs(); if textpreview::COMMANDS.contains(&id) { // A live preview is no edit: no undo step, normalisation or rollback here. let r = guard::run(id, || (spec.run)(self, params)); @@ -870,6 +924,7 @@ impl Session { // Every other command acts on the document itself, not on a live text preview. textpreview::end(self); if ENDS_PREVIEW.contains(&id) + && (!matches!(id, "file.save" | "file.saveAs") || cmd::file::save_ends_preview(self, id, params)) && let Ok(ds) = self.ds_mut() { ds.end_preview(); @@ -878,16 +933,22 @@ impl Session { let result = guard::run(id, || (spec.run)(self, params)) .and_then(|v| self.check_instance_structure(id, &before).map(|()| v)) .and_then(|v| self.check_preview(id, &before).map(|()| v)) - .and_then(|v| self.check_live_access(id, &before).map(|()| v)); + .and_then(|v| self.check_live_access(id, &before).map(|()| v)) + .and_then(|v| self.check_finite_geometry(id, &before).map(|()| v)); match &result { Ok(_) => { self.after_command(spec, before); + self.apply_view_prefs(); if spec.history && id != "edit.repeat" { self.last_command = Some((id.to_string(), params.clone())); } - self.journal.push((id.to_string(), params.clone())); - if self.journal.len() > 5000 { - self.journal.drain(..1000); + // Imported bytes already live in the document: a Base64 copy in the log would stay + // for the whole session. + if !command_carries_import_bytes(id, params) { + self.journal.push((id.to_string(), params.clone())); + if self.journal.len() > 5000 { + self.journal.drain(..1000); + } } } Err(_) => { @@ -916,6 +977,22 @@ impl Session { Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into())) } + /// A layer's position and size stay finite: infinity and NaN are written as `null`, so a file + /// holding them saves but can't be reopened. Refuse such an edit (it is rolled back). + fn check_finite_geometry(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> { + let Some((bdoc, _, _, uid)) = before else { return Ok(()) }; + let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) }; + if Arc::ptr_eq(&ds.doc, bdoc) { + return Ok(()); + } + let broken = |n: &Node| !(n.transform.as_coeffs().iter().all(|c| c.is_finite()) && n.width.is_finite() && n.height.is_finite()); + let untouched = |n: &Node| bdoc.node_arc(n.id).zip(ds.doc.node_arc(n.id)).is_some_and(|(a, b)| Arc::ptr_eq(a, b)); + match ds.doc.nodes().find(|n| broken(n) && !untouched(n)) { + Some(n) => Err(bad(id, format!("\"{}\" would get a position or size beyond the number range", n.name))), + None => Ok(()), + } + } + /// A live session's document you may only view (or that is on its way to a server) is /// read-only: refuse an edit (it is rolled back). fn check_live_access(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> { @@ -1135,13 +1212,22 @@ impl Session { self.grouped(label, |s| { let mut results: Vec = Vec::with_capacity(cmds.len()); for (step, (id, p)) in cmds.iter().enumerate() { - let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? }; - results.push(s.execute(id, &p)?); + let v = s.batch_step(&results, step, id, p)?; + results.push(v); } Ok(results) }) } + /// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of + /// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one + /// undo step; a caller with its own list — the CLI's script runner — uses this so its steps + /// resolve `$step` the same way, inside its own [`Session::grouped`]. + pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result { + let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? }; + self.execute(cmd, &p) + } + /// Run `f` as one undo step called `label`: the commands it executes push no steps of their /// own, and when it fails the documents stay at their group-start state. pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result) -> Result { @@ -1500,6 +1586,8 @@ mod cursor_tests; #[cfg(test)] mod dark_page_text_tests; #[cfg(test)] +mod design_paste_tests; +#[cfg(test)] mod dev_resources_tests; #[cfg(test)] mod devcompare_tests; @@ -1508,6 +1596,8 @@ mod devfocus_tests; #[cfg(test)] mod devinspect_tests; #[cfg(test)] +mod doc_urls_tests; +#[cfg(test)] mod double_click_tests; #[cfg(test)] mod draw_tools_tests; @@ -1528,6 +1618,8 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] +mod finite_geometry_tests; +#[cfg(test)] mod frame_guides_tests; #[cfg(test)] mod frame_label_tests; @@ -1548,16 +1640,26 @@ mod history_behaviour_tests; #[cfg(test)] mod html_codegen_tests; #[cfg(test)] +mod huge_stroke_tests; +#[cfg(test)] +mod id_params_behaviour_tests; +#[cfg(test)] mod import_export_tests; #[cfg(test)] +mod import_journal_tests; +#[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] +mod instance_resizing_tests; +#[cfg(test)] mod interaction_rollback_tests; #[cfg(test)] mod invite_links_tests; #[cfg(test)] mod keymap_tests; #[cfg(test)] +mod library_path_tests; +#[cfg(test)] mod library_range_styles_tests; #[cfg(test)] mod library_variables_tests; @@ -1584,16 +1686,22 @@ mod menu_gaps_tests; #[cfg(test)] mod nested_override_tests; #[cfg(test)] +mod node_set_refusals_tests; +#[cfg(test)] mod online_tests; #[cfg(test)] mod open_issues_tests; #[cfg(test)] mod outline_options_tests; #[cfg(test)] +mod page_clipboard_tests; +#[cfg(test)] mod page_copy_navigation_tests; #[cfg(test)] mod page_target_tests; #[cfg(test)] +mod paint_index_tests; +#[cfg(test)] mod paint_reorder_tests; #[cfg(test)] mod paint_tests; @@ -1602,6 +1710,8 @@ mod panels_behaviour_tests; #[cfg(test)] mod pdf_print_tests; #[cfg(test)] +mod pen_end_tests; +#[cfg(test)] mod pen_overlay_tests; #[cfg(test)] mod pixel_preview_tests; @@ -1634,6 +1744,8 @@ mod proto_run_tests; #[cfg(test)] mod proto_tail_tests; #[cfg(test)] +mod rest_slot_tests; +#[cfg(test)] mod retained_walk_tests; #[cfg(test)] mod rotated_guides_tests; @@ -1642,6 +1754,8 @@ mod rotated_select_tests; #[cfg(test)] mod rotation_origin_tests; #[cfg(test)] +mod save_format_tests; +#[cfg(test)] mod scale_tool_tests; #[cfg(test)] mod section_contents_tests; @@ -1704,12 +1818,16 @@ mod variables_behaviour_tests; #[cfg(test)] mod variables_editor_tests; #[cfg(test)] +mod variables_import_shapes_tests; +#[cfg(test)] mod variables_io_tests; #[cfg(test)] mod vector_behaviour_tests; #[cfg(test)] mod vector_multi_behaviour_tests; #[cfg(test)] +mod vector_offset_tests; +#[cfg(test)] mod version_preview_tests; #[cfg(test)] mod version_tests; @@ -1717,3 +1835,6 @@ mod version_tests; mod view_behaviour_tests; #[cfg(test)] mod widgets_tests; + +#[cfg(test)] +mod selection_spacing_tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 73c599a45..268635619 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -449,9 +449,27 @@ struct Stamp { #[derive(Clone, Debug)] struct Entry { stamp: Option, + /// When the file was read: a stamp only proves the file unchanged once its modification time + /// is safely older than that, since file systems keep the time in coarse ticks. + read_at: Option, result: std::result::Result, String>, } +/// How far a file's modification time must lie before its read for the stamp to be trusted. +const RACY_STAMP: std::time::Duration = std::time::Duration::from_secs(2); + +impl Entry { + /// A file stamped in the future (clock skew, unpacked archives) never settles: it is re-read on + /// every load until the clock passes it. + fn still_valid(&self, now: &Stamp) -> bool { + let settled = match (now.modified, self.read_at) { + (Some(modified), Some(read_at)) => modified.checked_add(RACY_STAMP).is_some_and(|t| t < read_at), + _ => false, + }; + settled && self.stamp.as_ref() == Some(now) + } +} + /// Library files read by a session, by path (re-read when they change on disk). #[derive(Clone, Debug, Default)] pub struct Cache { @@ -481,10 +499,11 @@ impl Cache { return Err(EngineError::Other(format!("{path} is too large to use as a library (the limit is {} MB)", max_bytes / 1024 / 1024))); } if let (Some(e), Ok(st)) = (self.entries.get(path), &stamp) - && e.stamp.as_ref() == Some(st) + && e.still_valid(st) { return e.result.clone().map_err(EngineError::Other); } + let read_at = now(); let result = match &stamp { Ok(_) => read_file(path).map(Arc::new), Err(e) => Err(e.clone()), @@ -492,7 +511,7 @@ impl Cache { if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) { self.entries.clear(); } - self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), result: result.clone() }); + self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), read_at, result: result.clone() }); result.map_err(EngineError::Other) } @@ -506,13 +525,23 @@ impl Cache { if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) { self.entries.clear(); } - self.entries.insert(path.to_string(), Entry { stamp: None, result }); + self.entries.insert(path.to_string(), Entry { stamp: None, read_at: None, result }); } } +#[cfg(not(target_arch = "wasm32"))] +fn now() -> Option { + Some(std::time::SystemTime::now()) +} + +#[cfg(target_arch = "wasm32")] +fn now() -> Option { + None +} + #[cfg(not(target_arch = "wasm32"))] fn stat(path: &str) -> std::result::Result { - let m = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?; + let m = crate::file_access::metadata(path).map_err(|e| e.to_string())?; if !m.is_file() { return Err(format!("{path} is not a file")); } @@ -526,7 +555,7 @@ fn stat(_path: &str) -> std::result::Result { #[cfg(not(target_arch = "wasm32"))] fn read_file(path: &str) -> std::result::Result { - let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; + let f = crate::file_access::open(path).map_err(|e| e.to_string())?; match figcraft_format::read_library(std::io::BufReader::new(f)) { Ok(Some(lib)) => Ok(lib), Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")), @@ -1221,9 +1250,16 @@ pub fn check_read_only(cmd: &str, before: &Document, after: &Document) -> std::r if !variables_unchanged(before, after) { return Err(READ_ONLY.into()); } - let Some(store) = after.library_store().or_else(|| before.library_store()) else { return Ok(()) }; - let touched = |d: &Document, id: NodeId| d.contains(id) && d.page_of(id) == Some(store); - if after.dirty().iter().any(|id| touched(before, *id) || touched(after, *id)) { + let changed_remote = |id| { + if !before.is_remote(id) && !after.is_remote(id) { + return false; + } + match (before.node_arc(id), after.node_arc(id)) { + (Some(old), Some(new)) => !Arc::ptr_eq(old, new), + _ => true, + } + }; + if after.dirty().iter().any(|id| changed_remote(*id)) { return Err(READ_ONLY.into()); } Ok(()) diff --git a/crates/engine/src/library_path_tests.rs b/crates/engine/src/library_path_tests.rs new file mode 100644 index 000000000..f15105dc8 --- /dev/null +++ b/crates/engine/src/library_path_tests.rs @@ -0,0 +1,30 @@ +//! A library link is stored in the document, so it is followed only on this computer +//! ([`crate::library_server`]): a path on another server would make opening someone else's file +//! contact that server, and on Windows send the credentials of whoever opened it. + +use crate::Session; + +#[test] +fn a_library_is_opened_only_from_this_computer() { + let mut s = Session::new(); + for path in [ + r"\\attacker.example\share\lib.figcraft", + "//attacker.example/share/lib.figcraft", + "file://attacker.example/share/lib.figcraft", + r"\\?\UNC\attacker.example\share\lib.figcraft", + r"\\.\pipe\lib", + "https://attacker.example/lib.figcraft", + ] { + let e = s.fetch_library(path, false).expect_err(&format!("{path} must be refused")); + assert!(e.to_string().contains("only from this computer"), "{path}: {e}"); + } +} + +#[test] +fn a_local_library_path_is_still_followed() { + let mut s = Session::new(); + // Refused for being absent, not for where it is: the gate let it through. + let missing = std::env::temp_dir().join("figcraft-no-such-library.figcraft"); + let e = s.fetch_library(&missing.to_string_lossy(), false).expect_err("the file does not exist"); + assert!(!e.to_string().contains("only from this computer"), "a local path must not be refused as remote: {e}"); +} diff --git a/crates/engine/src/library_server.rs b/crates/engine/src/library_server.rs index dbd97a0b2..99d5f29af 100644 --- a/crates/engine/src/library_server.rs +++ b/crates/engine/src/library_server.rs @@ -129,6 +129,13 @@ impl Session { /// document uses the library. pub fn fetch_library(&mut self, path: &str, fresh: bool) -> Result> { if !is_server_link(path) { + // A library link is stored in the document, so opening someone else's file would + // otherwise reach whatever path it names. Only a path on this computer is followed: a + // network share would make opening a document contact that server, and on Windows + // send the credentials of whoever opened it. + if !craft_docpath::is_local(path) { + return Err(EngineError::Other(format!("{path}: a library is opened only from this computer"))); + } return self.libraries.load(path); } let have = match self.libraries.cached(path) { diff --git a/crates/engine/src/library_tests.rs b/crates/engine/src/library_tests.rs index 23d5af090..27c9e6162 100644 --- a/crates/engine/src/library_tests.rs +++ b/crates/engine/src/library_tests.rs @@ -45,6 +45,38 @@ fn path_str(p: &std::path::Path) -> String { p.to_string_lossy().into_owned() } +fn dirty_library_document() -> (Document, NodeId) { + let mut doc = Document::new("Dirty library cache"); + let store = doc.ensure_library_store(); + let remote = doc.alloc_id(); + doc.insert(Node::new(remote, NodeKind::Frame, "Remote", 10.0, 10.0), store, None).unwrap(); + (doc, remote) +} + +/// An imported cache is dirty until the first edit; that alone is no edit of the library copies. +#[test] +fn read_only_allows_unchanged_predirty_remote_nodes() { + let (before, remote) = dirty_library_document(); + assert!(before.dirty().contains(&remote)); + + let mut after = before.clone(); + after.add_page("Unrelated page"); + assert!(after.dirty().contains(&remote), "the imported remote node remains in the pending dirty set"); + check_read_only("page.add", &before, &after).unwrap(); + + let mut edited = before.clone(); + edited.get_mut(remote).unwrap().opacity = 0.5; + assert_eq!(check_read_only("node.set", &before, &edited), Err(READ_ONLY.into())); + + let mut moved = before.clone(); + moved.reparent(remote, before.first_page().unwrap(), None).unwrap(); + assert_eq!(check_read_only("node.reparent", &before, &moved), Err(READ_ONLY.into())); + + let mut deleted = before.clone(); + deleted.remove(remote).unwrap(); + assert_eq!(check_read_only("edit.delete", &before, &deleted), Err(READ_ONLY.into())); +} + /// The library file's layers. struct Kit { button: NodeId, @@ -179,8 +211,16 @@ fn add_place_and_update_keep_overrides() { NodeKind::Instance { component, .. } => component, _ => panic!("not an instance"), }; + let unchanged = s.doc().unwrap().clone(); let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err(); assert!(e.to_string().contains("can't be edited"), "{e}"); + assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back"); + for (cmd, args) in + [("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))] + { + assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies"); + assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back"); + } let style = s.doc().unwrap().styles[0].id.clone(); assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err()); assert!(s.execute("style.delete", &json!({"style": style})).is_err()); @@ -281,10 +321,16 @@ fn library_styles_apply_and_import_with_pages() { let copy = doc.nodes().find(|n| n.name == node(&s, inst).name && matches!(n.kind, NodeKind::Instance { .. }) && !doc.is_remote(n.id)).unwrap(); let NodeKind::Instance { component, .. } = copy.kind else { panic!("not an instance") }; assert!(doc.is_remote(component)); + let store_roots = |s: &Session| { + let doc = s.doc().unwrap(); + doc.library_store().and_then(|id| doc.node(id)).map_or(0, |n| n.children.len()) + }; + assert_eq!(store_roots(&t), 2, "recorded copies are not copied twice"); // A second import shares the copies it already has. ex(&mut t, "file.import", json!({"path": path})); healthy(&t); assert_eq!(t.doc().unwrap().libraries.components.len(), 2); + assert_eq!(store_roots(&t), 2); let _ = std::fs::remove_dir_all(&d); } @@ -399,3 +445,21 @@ fn library_survives_saving_with_versions() { healthy(&user); let _ = std::fs::remove_dir_all(&d); } + +#[test] +fn a_file_stamp_from_the_moment_it_was_read_does_not_prove_it_unchanged() { + use std::time::{Duration, SystemTime}; + let read_at = SystemTime::now(); + let entry = |modified: SystemTime| Entry { + stamp: Some(Stamp { len: 10, modified: Some(modified) }), + read_at: Some(read_at), + result: Err("unused".into()), + }; + // Written in the same coarse tick as the read: a same-length rewrite would keep this stamp. + let fresh = entry(read_at); + assert!(!fresh.still_valid(&Stamp { len: 10, modified: Some(read_at) })); + // Written well before the read: an unchanged stamp means an unchanged file. + let old = read_at - Duration::from_secs(10); + assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old) })); + assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old) })); +} diff --git a/crates/engine/src/node_set_refusals_tests.rs b/crates/engine/src/node_set_refusals_tests.rs new file mode 100644 index 000000000..ce316d301 --- /dev/null +++ b/crates/engine/src/node_set_refusals_tests.rs @@ -0,0 +1,95 @@ +//! `node.set` doesn't store what can't apply: auto layout keys go only to layers that can have auto +//! layout (the others are named in `warnings`, and with none of them the call is refused), and a +//! `fontStyle` the family lacks says which face draws instead. + +use serde_json::{Value, json}; + +use crate::{EngineError, Session}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn id(v: &Value) -> crate::NodeId { + crate::params::id_of(v).unwrap() +} + +fn bad_msg(r: crate::Result) -> String { + match r { + Err(EngineError::BadParams { msg, .. }) => msg, + other => panic!("expected BadParams, got {other:?}"), + } +} + +#[test] +fn auto_layout_keys_on_layers_without_auto_layout_are_refused() { + let mut s = Session::new(); + let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi"}))["id"]); + let r = id(&ex(&mut s, "node.create", json!({"type": "rectangle"}))["id"]); + for (target, key, value) in [(t, "gap", json!(8)), (r, "padding", json!(4)), (t, "mainAlign", json!("center")), (r, "wrap", json!(true))] { + let msg = bad_msg(s.execute("node.set", &json!({"ids": [target], "props": {key: value}}))); + assert!(msg.contains(key) && msg.contains("auto layout"), "{msg}"); + assert!(s.doc().unwrap().node(target).unwrap().layout.is_none(), "no hidden auto layout on {target}"); + } + // Frames still take them, with or without a layout mode first. + let f = id(&ex(&mut s, "node.create", json!({"type": "frame"}))["id"]); + let out = ex(&mut s, "node.set", json!({"ids": [f], "props": {"gap": 8, "padding": 4}})); + assert_eq!(s.doc().unwrap().node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0)); + assert!(out.get("warnings").is_none(), "{out}"); +} + +#[test] +fn with_several_layers_the_frames_take_auto_layout_keys_and_the_others_are_named() { + let mut s = Session::new(); + let f = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Card"}))["id"]); + let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "name": "Title"}))["id"]); + let out = ex(&mut s, "node.set", json!({"ids": [f, t], "props": {"gap": 8, "opacity": 0.5}})); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0)); + assert!(doc.node(t).unwrap().layout.is_none(), "no hidden auto layout on the text"); + assert_eq!(doc.node(t).unwrap().opacity, 0.5, "the text still gets the other keys"); + let w = out["warnings"][0].as_str().unwrap(); + assert!(w.contains("`gap`") && w.contains(&format!("{t} “Title”")) && w.contains("TEXT"), "{w}"); + // One undo step for all of it. + ex(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().node(f).unwrap().layout.as_ref().is_none_or(|l| l.gap != 8.0)); +} + +#[test] +fn a_font_style_draws_in_the_nearest_face_and_says_so_when_it_differs() { + figcraft_text::scan_system_fonts(); + // Any installed family with a regular and a bold face and nothing heavier (CI machines differ). + let pick = figcraft_text::family_names().into_iter().find_map(|f| { + let st = figcraft_text::family_styles(&f); + let has = |w: u16| st.iter().any(|x| figcraft_doc::style_weight(x) == w && !x.to_lowercase().contains("italic")); + (has(400) && has(700) && !st.iter().any(|x| figcraft_doc::style_weight(x) > 700)).then_some((f, st)) + }); + let Some((family, styles)) = pick else { return }; + let mut s = Session::new(); + let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "props": {"fontFamily": family}}))["id"]); + let set = |s: &mut Session, style: &str| ex(s, "node.set", json!({"ids": [t], "props": {"fontStyle": style}})); + // Any spelling of a style it has, and names that resolve to the weight they ask for, say nothing. + let bold = styles.iter().find(|x| figcraft_doc::style_weight(x) == 700).unwrap().clone(); + for style in [bold.clone(), bold.to_uppercase(), bold.replace(' ', ""), "Roman".into(), "Normal".into()] { + let out = set(&mut s, &style); + assert!(out.get("warnings").is_none(), "{style}: {out}"); + } + // A heavier weight than the family has applies (as a text layer resolves it) and says which face draws. + let out = set(&mut s, "Black"); + let w = out["warnings"][0].as_str().unwrap(); + assert!(w.contains(&family) && w.contains("`Black`") && w.contains("nearest"), "{w}"); + assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().spans()[0].2.font_style, "Black"); + // A family that isn't installed keeps any style name without a word (files from elsewhere name theirs). + let out = ex(&mut s, "node.set", json!({"ids": [t], "props": {"fontFamily": "No Such Family 7f3a", "fontStyle": "Hairline"}})); + assert!(out.get("warnings").is_none(), "{out}"); +} + +#[test] +fn a_refused_call_changes_nothing() { + let mut s = Session::new(); + let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi"}))["id"]); + let before = s.doc().unwrap().node(t).unwrap().fills.clone(); + // `fill` comes before `gap` (keys apply in order), so this checks the rollback. + assert!(s.execute("node.set", &json!({"ids": [t], "props": {"fill": "#FF0000", "gap": 8}})).is_err()); + assert_eq!(s.doc().unwrap().node(t).unwrap().fills, before); +} diff --git a/crates/engine/src/open_issues_tests.rs b/crates/engine/src/open_issues_tests.rs index d7f23720b..2bee799b0 100644 --- a/crates/engine/src/open_issues_tests.rs +++ b/crates/engine/src/open_issues_tests.rs @@ -49,3 +49,16 @@ fn an_intact_file_opens_without_a_message() { assert!(s.message.is_none()); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } + +#[test] +fn file_close_rejects_a_bad_index_instead_of_closing_another_document() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + let docs = s.docs.len(); + for index in [json!(-1), json!(1.5), json!(99)] { + let r = s.execute("file.close", &json!({"index": index, "force": true})); + assert!(r.is_err(), "index {index} must be rejected, got {r:?}"); + assert_eq!(s.docs.len(), docs, "index {index} closed a document"); + } +} diff --git a/crates/engine/src/page_clipboard_tests.rs b/crates/engine/src/page_clipboard_tests.rs new file mode 100644 index 000000000..79ff74199 --- /dev/null +++ b/crates/engine/src/page_clipboard_tests.rs @@ -0,0 +1,36 @@ +//! `edit.copy` / `edit.cut` skip pages (they aren't layers and can't be pasted), and refuse a +//! selection of only pages under their own command id. + +use serde_json::json; + +use crate::{EngineError, Session}; + +#[test] +fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() { + for cmd in ["edit.copy", "edit.cut"] { + let mut s = Session::new(); + let page = s.page().unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + match s.execute(cmd, &json!({"ids": [page.0]})) { + Err(EngineError::Disabled(id, _)) => assert_eq!(id, cmd), + r => panic!("{cmd} on a page: {r:?}"), + } + let _ = s.execute("edit.paste", &json!({})); + let doc = s.doc().unwrap(); + assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate()); + assert!(doc.node(page).is_some(), "{cmd} removed the page"); + } +} + +#[test] +fn cut_of_a_page_and_a_layer_moves_only_the_layer() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let rect = crate::params::id_of(&r["id"]).unwrap(); + s.execute("edit.cut", &json!({"ids": [page.0, rect.0]})).unwrap(); + assert!(s.doc().unwrap().node(page).is_some()); + assert!(s.doc().unwrap().node(rect).is_none()); + s.execute("edit.paste", &json!({})).unwrap(); + assert!(s.doc().unwrap().validate().is_empty()); +} diff --git a/crates/engine/src/paint_index_tests.rs b/crates/engine/src/paint_index_tests.rs new file mode 100644 index 000000000..eddea306d --- /dev/null +++ b/crates/engine/src/paint_index_tests.rs @@ -0,0 +1,39 @@ +//! `fill.set` / `stroke.set` reject a negative, fractional or non-finite `index` instead of +//! clamping or truncating it onto another paint (like `fill.remove` / `stroke.remove`). + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn paints(s: &Session, id: NodeId) -> String { + let n = s.doc().unwrap().node(id).unwrap(); + format!("{:?} {:?}", n.fills, n.strokes) +} + +#[test] +fn paint_set_rejects_bad_index_and_leaves_the_paints_alone() { + let mut s = Session::new(); + let r = NodeId(ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + for add in ["fill.add", "stroke.add"] { + for c in ["#0000FF", "#FFFF00"] { + ex(&mut s, add, json!({"ids": [r], "color": c})); + } + } + let before = paints(&s, r); + for cmd in ["fill.set", "stroke.set"] { + for bad in [json!(-1), json!(0.5), json!(1.9), json!("-1"), json!("1.5"), json!("NaN"), json!("inf"), json!("abc")] { + let res = s.execute(cmd, &json!({"ids": [r], "index": bad, "color": "#FF0000"})); + assert!(res.is_err(), "{cmd} index {bad} was accepted: {res:?}"); + assert_eq!(paints(&s, r), before, "{cmd} index {bad} changed the document"); + } + ex(&mut s, cmd, json!({"ids": [r], "index": 1, "color": "#FF0000"})); + assert_ne!(paints(&s, r), before, "{cmd} with a valid index still edits"); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(paints(&s, r), before); + } +} diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs index bbaa42a45..35a74e0e5 100644 --- a/crates/engine/src/params.rs +++ b/crates/engine/src/params.rs @@ -88,6 +88,36 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result { id(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id"))) } +/// The offending value for an error message, cut to a few dozen characters so a huge param +/// doesn't end up in the error whole. +fn shown(v: &Value) -> String { + const MAX: usize = 40; + let text = v.to_string(); + match text.char_indices().nth(MAX) { + Some((cut, _)) => format!("{}…", &text[..cut]), + None => text, + } +} + +/// `key` when it is there: a layer id, or an error naming it. Missing and `null` mean "none", so a +/// command can tell "no parent given" (use the page) from "a parent was given that isn't an id" — +/// which is a mistake, not the default. Without this, `{"$step": 0}` reaching a param that isn't +/// resolved yet silently becomes "no parent" and the layer lands on the page. +pub fn opt_id(cmd: &str, p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {})", shown(v)))), + } +} + +/// `key` when it is there: a whole, non-negative number, or an error. Missing means "none". +pub fn opt_index(cmd: &str, p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(_) => req_index(cmd, p, key).map(Some), + } +} + /// `ids` (array), `id` (single), or the current selection. pub fn targets(s: &Session, p: &Value) -> Vec { if let Some(Value::Array(a)) = get(p, "ids") { @@ -99,8 +129,25 @@ pub fn targets(s: &Session, p: &Value) -> Vec { s.selection() } -/// Like [`targets`] but an empty result is an error. +/// Like [`targets`] but an unparseable `ids` entry, an unparseable `id`, or an empty result is an +/// error: naming something that isn't a layer is a mistake, not an empty selection. An explicit +/// `null` says the same as leaving the key out. pub fn req_targets(cmd: &str, s: &Session, p: &Value) -> Result> { + match get(p, "ids") { + Some(Value::Array(a)) => { + if let Some(v) = a.iter().find(|v| id_of(v).is_none()) { + return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {})", shown(v)))); + } + } + Some(Value::Null) | None => { + if let Some(v) = get(p, "id").filter(|v| !v.is_null()) + && id_of(v).is_none() + { + return Err(bad(cmd, format!("`id` must be a node id (got {})", shown(v)))); + } + } + Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {})", shown(v)))), + } let t = targets(s, p); if t.is_empty() { return Err(EngineError::Disabled(cmd.to_string(), "nothing is selected (pass `ids`)".into())); diff --git a/crates/engine/src/pen_end_tests.rs b/crates/engine/src/pen_end_tests.rs new file mode 100644 index 000000000..a5d24f61c --- /dev/null +++ b/crates/engine/src/pen_end_tests.rs @@ -0,0 +1,72 @@ +//! Ending a pen path with Escape or Enter (#760): the path's vector stays in edit mode with the pen, +//! so the next path is drawn into the same layer; another Escape leaves edit mode. + +use crate::Session; +use crate::tools::{PointerEvent, PointerKind, ToolId}; +use crate::vectoredit::VTool; +use figcraft_doc::NodeKind; + +fn click(s: &mut Session, x: f64, y: f64) { + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap(); + } +} + +fn layers(s: &Session) -> usize { + s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len() +} + +/// (points, segments) of vector `id`. +fn shape(s: &Session, id: figcraft_doc::NodeId) -> (usize, usize) { + match &s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Vector { network } => (network.vertices.len(), network.segments.len()), + k => panic!("not a vector: {k:?}"), + } +} + +/// A two-point pen path, ended with `end`. +fn drawn(end: fn(&mut Session)) -> (Session, figcraft_doc::NodeId) { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + click(&mut s, 100.0, 100.0); + click(&mut s, 200.0, 100.0); + let id = s.tool.pen.as_ref().and_then(|p| p.id).unwrap(); + let undo = s.undo_depth().0; + end(&mut s); + assert!(s.tool.pen.is_none(), "the path ended"); + assert_eq!(s.undo_depth().0, undo + 1, "the path is one undo step"); + let ve = s.ds().unwrap().vector_edit.clone().expect("still editing the vector"); + assert_eq!((ve.id, ve.tool, ve.anchor), (id, VTool::Pen, None), "the pen, with no open end"); + (s, id) +} + +#[test] +fn escape_and_enter_keep_the_vector_in_edit_mode_with_the_pen() { + // Escape, and Enter (its key runs `pen_end`). + let ends: [fn(&mut Session); 2] = [|s| s.escape().unwrap(), |s| s.pen_end().unwrap()]; + for end in ends { + let (mut s, id) = drawn(end); + // The next path goes into the same layer, unconnected to the first. + click(&mut s, 100.0, 300.0); + click(&mut s, 200.0, 300.0); + assert_eq!(layers(&s), 1, "no new layer"); + assert_eq!(shape(&s, id), (4, 2), "two separate paths in one vector network"); + // Escape ends that path, the next one leaves edit mode. + s.escape().unwrap(); + assert!(s.ds().unwrap().vector_edit.is_some()); + s.escape().unwrap(); + assert!(s.ds().unwrap().vector_edit.is_none(), "the second Escape leaves edit mode"); + assert_eq!(s.ds().unwrap().selection, vec![id]); + } +} + +#[test] +fn escape_before_a_segment_leaves_nothing_to_edit() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + click(&mut s, 100.0, 100.0); + s.escape().unwrap(); + assert!(s.tool.pen.is_none()); + assert!(s.ds().unwrap().vector_edit.is_none()); + assert_eq!(layers(&s), 0); +} diff --git a/crates/engine/src/playground.rs b/crates/engine/src/playground.rs index 0023a6c10..64286cef8 100644 --- a/crates/engine/src/playground.rs +++ b/crates/engine/src/playground.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec { if let Some(path) = string(p, "path") { let scale = num(p, "scale").filter(|k| k.is_finite()).map_or(2.0, |k| k.clamp(0.01, 16.0)); let png = crate::cmd::file::export_bytes(©, &[inst], ExportFormat::Png, scale)?; - std::fs::write(&path, png).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::file_access::write(&path, &png)?; out["path"] = json!(path); } Ok(out) diff --git a/crates/engine/src/plugin_directory.rs b/crates/engine/src/plugin_directory.rs index c3bf7dc44..2f721feae 100644 --- a/crates/engine/src/plugin_directory.rs +++ b/crates/engine/src/plugin_directory.rs @@ -147,6 +147,9 @@ fn install(s: &mut Session, p: &Value) -> Result { /// Keep an installed plugin's files in its own folder under the install folder (named by the hash /// of its id, so no id reaches the file system), its module renamed; the manifest's path. Without /// an install folder (the browser, the CLI and tests) the plugin lasts for the session. +// The install folder is the app's own (set by the desktop app, never by a command), so automation +// roots don't apply to it. +#[allow(clippy::disallowed_methods)] fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result, String> { let Some(root) = s.plugins.install_dir.clone() else { return Ok(None) }; #[cfg(not(target_arch = "wasm32"))] @@ -189,6 +192,7 @@ fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result