diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 0000000000..2408084cf9
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,6 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+reviews:
+ auto_review:
+ # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
+ ignore_usernames:
+ - "dependabot[bot]"
diff --git a/.config/nextest.toml b/.config/nextest.toml
index 2585577ec1..83ee9cd6f6 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -1,5 +1,6 @@
# cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test
-# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204).
+# binaries. CI runs it with the `ci` profile below (#6117); locally it is optional and `cargo test`
+# keeps working (#2204).
#
# cargo nextest run --workspace # all tests except doctests
# cargo test --doc --workspace # nextest does not run doctests
@@ -11,5 +12,13 @@
gpu = { max-threads = 4 }
[[profile.default.overrides]]
-filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))'
+filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))'
test-group = 'gpu'
+
+# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
+# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure
+# at the end of the log. The default profile's GPU override still applies.
+[profile.ci]
+fail-fast = true
+slow-timeout = { period = "60s", terminate-after = 10 }
+failure-output = "immediate-final"
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000000..2c4b8cb1a4
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,30 @@
+version: 2
+updates:
+ # Maintain Rust / Cargo dependencies
+ - package-ecosystem: "cargo"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ # Consolidate updates into a single PR rather than one PR per crate
+ groups:
+ cargo-dependencies:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "chore(deps)"
+
+ # Maintain GitHub Actions workflows
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ groups:
+ actions:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "ci(deps)"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bec29abaf5..ba8243353d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,6 +4,7 @@ on:
push:
branches: [main]
pull_request:
+ types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
inputs:
fuzz:
@@ -22,8 +23,8 @@ env:
PHOTOCRAFT_NO_PRINT: "1"
LPDEST: photocraft-no-such-printer
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
- # debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough
- # for the Linux job's second, craft-fonts build (it ran out of disk with full debug info).
+ # debug info: faster codegen and links, and a smaller target dir (the Linux test job ran out of
+ # disk with full debug info). The Windows test legs drop debuginfo entirely (see the test job).
CARGO_PROFILE_DEV_DEBUG: line-tables-only
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge
@@ -39,10 +40,11 @@ jobs:
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
# macOS is tested only by release.yml.
platforms:
+ timeout-minutes: 10
name: select platforms
runs-on: ubuntu-latest
outputs:
- os: ${{ steps.pick.outputs.os }}
+ legs: ${{ steps.pick.outputs.legs }}
web: ${{ steps.pick.outputs.web }}
steps:
- uses: actions/checkout@v4
@@ -56,34 +58,46 @@ jobs:
run: |
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
# release.yml builds and tests macOS (a failing release is fixed and rebuilt).
- all='["ubuntu-latest","windows-latest"]'
+ # Windows tests run as two legs in parallel: the unit tests and the integration tests. As
+ # one job they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting
+ # the integration tests over a third runner (nextest --partition) saved ~2 min of wall
+ # time but cost ~7 runner-minutes and one more Windows slot per PR.
+ linux='{"os":"ubuntu-latest","part":"all"}'
+ win() { printf '{"os":"windows-latest","part":"%s"}' "$1"; }
+ all="[$linux,$(win unit),$(win integration)]"
if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then
- echo "os=$all" >> "$GITHUB_OUTPUT"
+ echo "legs=$all" >> "$GITHUB_OUTPUT"
echo "web=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# The browser build (a fat-LTO wasm build) runs only when something it compiles or
# packages changed: the web app and its packaging, crate sources and manifests (the web
# build compiles nearly every crate), the workspace manifests and this workflow.
- if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
+ if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web(-boot)?/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
echo "web=true" >> "$GITHUB_OUTPUT"
else
echo "web=false" >> "$GITHUB_OUTPUT"
fi
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
- echo "os=$all" >> "$GITHUB_OUTPUT"
+ echo "legs=$all" >> "$GITHUB_OUTPUT"
else
- echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
+ echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
fi
test:
- name: test (${{ matrix.os }})
+ timeout-minutes: 100
+ name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }})
needs: platforms
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
- os: ${{ fromJSON(needs.platforms.outputs.os) }}
+ include: ${{ fromJSON(needs.platforms.outputs.legs) }}
+ env:
+ # No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes.
+ # A panic still names its file and line; only backtrace frames lose theirs (Linux keeps
+ # line-tables-only, so a backtrace with lines is one OS away).
+ CARGO_PROFILE_DEV_DEBUG: ${{ startsWith(matrix.os, 'windows') && '0' || 'line-tables-only' }}
steps:
- uses: actions/checkout@v4
@@ -106,49 +120,44 @@ jobs:
# Only main saves caches; PRs restore main's. Every PR used to save its own set (up to
# 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's.
# A failing test still saves: the cache holds compiled dependencies, not test results.
+ # The Windows legs share one key (same dependencies); the integration leg saves it,
+ # because it also runs Clippy and so leaves Clippy's dependency metadata in it.
- uses: Swatinem/rust-cache@v2
with:
- save-if: ${{ github.ref == 'refs/heads/main' }}
+ save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
cache-on-failure: true
+ # sccache caches the workspace crates' compiled libraries, which rust-cache leaves out:
+ # warm, each Windows leg built 4.5 min faster. Like rust-cache, only main writes (PRs read
+ # main's entries); a full set is ~90 MB, so it barely touches the 10 GB budget (#2688).
+ # Test executables can't be cached, so a PR still links its tests.
+ - name: Install sccache (Windows)
+ if: runner.os == 'Windows'
+ uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
+ with:
+ version: v0.18.0
+
+ - name: Use sccache (Windows)
+ if: runner.os == 'Windows'
+ env:
+ MAIN: ${{ github.ref == 'refs/heads/main' }}
+ run: |
+ echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
+ echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
+ if [ "$MAIN" != true ]; then echo "SCCACHE_GHA_RW_MODE=READ_ONLY" >> "$GITHUB_ENV"; fi
+ shell: bash
+
- name: Format
if: runner.os == 'Linux'
run: cargo fmt --all -- --check
- # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
- - name: Test
- run: cargo test --workspace --locked --features heif
-
- # The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
- # error for distributors who leave it out.
- - name: Test without heif
- if: runner.os == 'Linux'
- run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
-
- # The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
- # Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
- # --features heif` of the Test step: any other package or feature selection resolves a
- # different feature set, recompiles the whole dependency graph into the same target dir and
- # runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
- - name: Check out craft-fonts
- if: runner.os == 'Linux'
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- with:
- repository: storytold/craft-fonts
- ref: abb83316d96aa59c1cf64784289e378fe9fa5695
- path: craft-fonts
-
- - name: Test with craft-fonts
- if: runner.os == 'Linux'
- env:
- CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
- CRAFT_FONTS_REQUIRED: "1"
- run: cargo test --workspace --locked --features heif
-
+ # Keep formatting, strict Clippy, layering and scorecard checks ahead of tests.
- name: Clippy
+ if: matrix.part == 'all' || matrix.part == 'integration'
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
- name: Dependency layering
+ if: matrix.part == 'all' || matrix.part == 'integration'
run: cargo xtask layers
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
@@ -157,7 +166,131 @@ jobs:
if: runner.os == 'Linux'
run: cargo xtask scorecard --check
+ # nextest runs the test binaries in parallel (cargo test runs them one at a time), with the
+ # GPU tests capped by the `gpu` group in .config/nextest.toml (#2204).
+ - uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
+ with:
+ tool: cargo-nextest@0.9.146
+
+ # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
+ # `--lib --bins --tests` leaves out the examples, which cargo test would build and link but
+ # never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so
+ # they get their own step.
+ #
+ # On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every
+ # integration-test target); the feature selection stays the same, so both legs share the
+ # dependency cache. The `ci` profile fails fast and terminates hung tests
+ # (.config/nextest.toml); assertion failures are not retried.
+ - name: Test
+ env:
+ PART: ${{ matrix.part }}
+ run: |
+ case "$PART" in
+ unit) targets=(--lib --bins) ;;
+ integration) targets=(--test '*') ;;
+ *) targets=(--lib --bins --tests) ;;
+ esac
+ cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}"
+ shell: bash
+
+ # Every command against adversarial params must return Err, never panic or hang (AGENTS.md
+ # Rule 9). It is `#[ignore]`d (slow), so Test skips it. Same packages, features and targets as
+ # Test, so nothing rebuilds: `--test panic_hunt` would drop the other crates' dev-dependency
+ # features and recompile the whole graph. The `ci` profile terminates it if it hangs.
+ - name: Panic hunt
+ if: runner.os == 'Linux'
+ run: >-
+ cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
+ --run-ignored only -E 'binary_id(photocraft-engine::panic_hunt)'
+
+ - name: Doctests
+ if: matrix.part == 'all' || matrix.part == 'unit'
+ run: cargo test --workspace --locked --features heif --doc
+
+ # The Windows tests used to be one job named `test (windows-latest)`; they're now legs named
+ # `test (windows-latest, unit)` and `test (windows-latest, integration)`. This job keeps the old
+ # name reporting, so a required status check on it (none today) would keep working. It fails
+ # unless the whole test matrix and both Linux variants passed. `needs` reports one result
+ # for all matrix legs; when a PR runs no Windows legs it still checks every Linux result.
+ windows-gate:
+ timeout-minutes: 5
+ name: test (windows-latest)
+ needs: [platforms, test, test-variants]
+ if: ${{ !cancelled() }}
+ runs-on: ubuntu-latest
+ steps:
+ - env:
+ PLATFORMS: ${{ needs.platforms.result }}
+ TEST: ${{ needs.test.result }}
+ VARIANTS: ${{ needs.test-variants.result }}
+ run: |
+ echo "platforms: $PLATFORMS, test: $TEST, variants: $VARIANTS"
+ [ "$PLATFORMS" = success ] && [ "$TEST" = success ] && [ "$VARIANTS" = success ]
+
+ # The two other builds of the Linux tests, in parallel with the test job instead of after it (they
+ # added ~10 min to its critical path). Each restores the test job's cache (`shared-key: test` is
+ # that job's key) and saves none: the dependencies are the same, and the 10 GB cache budget is
+ # tight (#2688).
+ test-variants:
+ timeout-minutes: 100
+ name: test (${{ matrix.variant }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ variant: [without heif, with craft-fonts]
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Linux system dependencies (egui/winit/rfd)
+ run: |
+ nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
+ /opt/hostedtoolcache/CodeQL /dev/null 2>&1 &
+ 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 libgtk-3-dev
+
+ - uses: dtolnay/rust-toolchain@stable
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: test
+ save-if: false
+
+ - uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
+ with:
+ tool: cargo-nextest@0.9.146
+
+ # The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
+ # error for distributors who leave it out.
+ - name: Test without heif
+ if: matrix.variant == 'without heif'
+ run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
+
+ # The tests built with the optional craft-fonts input (crates/text/build.rs), so the Japanese
+ # font tests that skip without it run. Keep the test job's exact `--workspace --features heif
+ # --lib --bins --tests`, so the restored dependencies match and only the workspace rebuilds.
+ # Filter execution to the three crates that read the optional font input (#6127).
+ - name: Check out craft-fonts
+ if: matrix.variant == 'with craft-fonts'
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ repository: storytold/craft-fonts
+ ref: abb83316d96aa59c1cf64784289e378fe9fa5695
+ path: craft-fonts
+
+ - name: Test with craft-fonts
+ if: matrix.variant == 'with craft-fonts'
+ env:
+ CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
+ CRAFT_FONTS_REQUIRED: "1"
+ run: >-
+ cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
+ -E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
+
corpus:
+ timeout-minutes: 30
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
# restore and a download; a missing corpus fails the tests. The cache key changes only when a
@@ -190,6 +323,7 @@ jobs:
run: cargo xtask test-corpus
wasm:
+ timeout-minutes: 30
name: wasm32 check
runs-on: ubuntu-latest
steps:
@@ -206,6 +340,7 @@ jobs:
run: cargo xtask wasm
web:
+ timeout-minutes: 60
name: web build and wasm size gate
# PRs run it only when web-relevant files change (see `platforms`); main always does.
needs: platforms
@@ -229,8 +364,8 @@ jobs:
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
- # package.sh runs Trunk's release build, validates relative URLs, checks the 24 MiB
- # wasm limit and exercises the same packaging step used by release.yml.
+ # package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into
+ # parts, checks the 24 MiB per-file limit and exercises the packaging step release.yml uses.
- name: Build and verify browser release
run: packaging/web/package.sh
- name: Report wasm size
@@ -241,15 +376,19 @@ jobs:
echo
echo "| File | Bytes | MiB | Limit |"
echo "| --- | ---: | ---: | ---: |"
+ row() { echo "| $1 | $2 | $(awk -v n="$2" 'BEGIN { printf "%.2f", n / 1048576 }') | $3 |"; }
+ # The app's wasm as built; it ships gzipped in parts, so only the download grows with it.
for wasm in dist/web/*.wasm; do
[ -f "$wasm" ] || continue
- bytes=$(wc -c < "$wasm" | tr -d ' ')
- mib=$(awk -v n="$bytes" 'BEGIN { printf "%.2f", n / 1048576 }')
- echo "| $(basename "$wasm") | $bytes | $mib | 24 MiB |"
+ row "$(basename "$wasm")" "$(wc -c < "$wasm" | tr -d ' ')" "none"
done
+ # What a host sees: the largest file of the packaged site.
+ largest=$(find target/web-package -type f -printf '%s %f\n' | sort -n | tail -1)
+ row "largest file in the site: ${largest#* }" "${largest%% *}" "24 MiB"
} >> "$GITHUB_STEP_SUMMARY"
fuzz:
+ timeout-minutes: 60
name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})
# Placeholder: manual trigger only until a nightly schedule is enabled.
if: github.event_name == 'workflow_dispatch' && inputs.fuzz
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 1d6c4c1f56..67e6b31894 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -27,6 +27,7 @@ concurrency:
jobs:
build:
+ timeout-minutes: 15
name: mdBook build
runs-on: ubuntu-latest
steps:
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index fa0693f58e..970c6577a4 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -27,38 +27,10 @@ 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
- prepare: |
- pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
- 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 --features heif
- CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui
- CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib
- CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc
- # The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a GPU
- # device at once, so the GPU integration tests run one at a time here.
- CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1
- # 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/photocraft-*-freebsd-x86_64.tar.gz \
- | grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'
+ # 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:
+ craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
+ cargo-build-extra-args: "--features heif"
+ custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
+ run-package-check: true
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
new file mode 100644
index 0000000000..18392587a8
--- /dev/null
+++ b/.github/workflows/nix.yml
@@ -0,0 +1,33 @@
+# 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:
+ eval:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
+ - name: nix flake check --no-build
+ run: |
+ if [ -f flake.nix ]; then
+ nix flake check --no-build --all-systems
+ else
+ echo "No flake.nix present in this repository; skipping check."
+ fi
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index 272715dc81..1d110e28f2 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -23,6 +23,7 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
verified-appimage-inputs:
+ timeout-minutes: 10
name: Verified AppImage inputs
runs-on: ubuntu-latest
steps:
diff --git a/AGENTS.md b/AGENTS.md
index e44dbe32a8..97b5ccdbc0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -39,6 +39,7 @@ apps/
photocraft desktop app (eframe/wgpu), TCP control server
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
photocraft-web the same app in the browser (trunk + wasm-bindgen)
+ photocraft-web-boot starts the packaged browser app from compressed parts of its wasm (no file over a host's size cap)
xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard
```
@@ -131,7 +132,6 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
- `log/` (local, gitignored): the dev log.
--
## 8. Keeping the native format complete
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 369aa638fe..ea0edeb6cf 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -13,6 +13,7 @@ next to them.
| `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | | MIT (X.Org app/rgb) |
| `crates/ui-egui/src/i18n/el.tsv` | Greek UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
+| `crates/ui-egui/src/i18n/es.tsv` | Spanish UI translations | @gorevyoneticisi; AI contributor: OpenAI Codex | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/it.tsv` | Italian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ko.tsv` | Korean UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/nl.tsv` | Dutch UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
@@ -26,7 +27,7 @@ next to them.
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
-| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
+| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -37,10 +38,12 @@ next to them.
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
+| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
+| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush`, scaled to 70%, inside a dashed upright rounded "C" selection | Lucide Icons and Contributors; selection by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
@@ -55,6 +58,7 @@ next to them.
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) |
| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, (its colour schemes are LGPL-2.0-or-later) | Original work |
| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work |
+| `crates/algo/data/libfacedetection.bin` (face detection weights, read by `crates/algo/src/faces.rs`, whose network code is a port of the library's) | libfacedetection CNN (YuNet design) weights and inference code, weights converted to half precision | Shiqi Yu | [ShiqiYu/libfacedetection](https://github.com/ShiqiYu/libfacedetection) (`src/facedetectcnn-data.cpp`, `facedetectcnn.cpp`, `facedetectcnn-model.cpp`) | BSD-3-Clause, [`LICENSE-libfacedetection.txt`](crates/algo/data/LICENSE-libfacedetection.txt) |
### Optional build input: craft-fonts (not files in this repo)
@@ -113,6 +117,6 @@ next to the files:
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
| `corpus/atn/` (16 files, 0.9 MB) | Photoshop Actions sets, one folder per repository: `sbaril/` (Frequency Separation, Firefly, Dimension, Harmonize, Photo, Quick Object Extraction, Generative Upscale), `bjango/` (Bjango Actions, Adjustment Actions, Scale Actions), `bkwld/` (Save layers for web), `atntools/` (test.atn), `trimmer/` (Trimmer), `alpha-utility/` (AlphaUtilityAdditions), `gif-creator/` (BannerActions) | Stéphane Baril; Bjango and Marc Edwards; BKWLD; Maurits Rijk; damsmcfly; UCLA Game Lab; overbyte | [sbaril/Photoshop-Scripts](https://github.com/sbaril/Photoshop-Scripts), [bjango/Bjango-Actions](https://github.com/bjango/Bjango-Actions), [BKWLD/photoshop-actions](https://github.com/BKWLD/photoshop-actions), [mrijk/atntools](https://github.com/mrijk/atntools), [damsmcfly/photoshop-cleaner-trimmer](https://github.com/damsmcfly/photoshop-cleaner-trimmer), [uclagamelab/AlphaUtilityAdditions](https://github.com/uclagamelab/AlphaUtilityAdditions), [overbyte/photoshop-gif-creator-script](https://github.com/overbyte/photoshop-gif-creator-script), pinned in [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | MIT (sbaril, BKWLD, atntools, gif-creator), BSD (bjango, per `Help/License.md`), BSD-2-Clause (alpha-utility), Unlicense (trimmer); each licence file sits next to its files |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | | Public domain |
-| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
+| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
diff --git a/Cargo.lock b/Cargo.lock
index 6820d6dee5..6b5a7bbf9b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -550,12 +550,48 @@ dependencies = [
"arrayvec",
]
+[[package]]
+name = "aws-lc-rs"
+version = "1.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13"
+dependencies = [
+ "aws-lc-sys",
+ "untrusted 0.7.1",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-sys"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc"
+dependencies = [
+ "cc",
+ "cmake",
+ "dunce",
+ "fs_extra",
+ "pkg-config",
+]
+
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
[[package]]
name = "bindgen"
version = "0.72.1"
@@ -645,7 +681,7 @@ dependencies = [
"cc",
"cfg-if",
"constant_time_eq",
- "cpufeatures",
+ "cpufeatures 0.3.1",
]
[[package]]
@@ -654,6 +690,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "block2"
version = "0.5.1"
@@ -885,7 +930,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.3.1",
"rand_core 0.10.1",
]
@@ -896,8 +941,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
+ "js-sys",
"num-traits",
"serde",
+ "wasm-bindgen",
"windows-link",
]
@@ -973,6 +1020,39 @@ dependencies = [
"error-code",
]
+[[package]]
+name = "cmake"
+version = "0.1.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "cmpv2"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39"
+dependencies = [
+ "crmf",
+ "der",
+ "spki",
+ "x509-cert",
+]
+
+[[package]]
+name = "cms"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730"
+dependencies = [
+ "const-oid",
+ "der",
+ "spki",
+ "x509-cert",
+]
+
[[package]]
name = "codespan-reporting"
version = "0.13.1"
@@ -1028,6 +1108,12 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
[[package]]
name = "constant_time_eq"
version = "0.4.2"
@@ -1103,6 +1189,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "core_detect"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
+
[[package]]
name = "core_maths"
version = "0.1.1"
@@ -1112,6 +1204,15 @@ dependencies = [
"libm",
]
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "cpufeatures"
version = "0.3.1"
@@ -1121,12 +1222,22 @@ dependencies = [
"libc",
]
+[[package]]
+name = "craft-docpath"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
+
[[package]]
name = "craft-print"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5"
+source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b76109da11416e9ca4ea719#3d37da389a3618ab9b76109da11416e9ca4ea719"
dependencies = [
+ "craft-winprint-gdi",
+ "miniz_oxide 0.8.9",
+ "roxmltree",
"tempfile",
+ "winprint",
+ "winsafe",
]
[[package]]
@@ -1138,6 +1249,14 @@ dependencies = [
"serde",
]
+[[package]]
+name = "craft-winprint-gdi"
+version = "0.1.0"
+source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1181,6 +1300,18 @@ dependencies = [
"itertools 0.10.5",
]
+[[package]]
+name = "crmf"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92"
+dependencies = [
+ "cms",
+ "der",
+ "spki",
+ "x509-cert",
+]
+
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
@@ -1221,6 +1352,16 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -1267,6 +1408,30 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "der_derive",
+ "flagset",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "der_derive"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "dify"
version = "0.8.0"
@@ -1280,6 +1445,16 @@ dependencies = [
"rayon",
]
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -1394,6 +1569,12 @@ dependencies = [
"linux-raw-sys 0.9.4",
]
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
[[package]]
name = "dyn-clone"
version = "1.0.20"
@@ -1578,6 +1759,20 @@ dependencies = [
"serde",
]
+[[package]]
+name = "encoding_rs"
+version = "0.8.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
+dependencies = [
+ "cfg-if",
+ "core_detect",
+ "multiversion_no_op",
+ "rustversion",
+ "scopeguard",
+ "simdutf8",
+]
+
[[package]]
name = "endi"
version = "1.1.1"
@@ -1739,6 +1934,18 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "exacl"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de9e551ced9d700a31dacb8b168129640240fcd7a279a3fa4d59608b84ebcae"
+dependencies = [
+ "bitflags 2.13.2",
+ "log",
+ "scopeguard",
+ "uuid",
+]
+
[[package]]
name = "exr"
version = "1.74.2"
@@ -1797,6 +2004,16 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
+[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -1809,6 +2026,12 @@ version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
+[[package]]
+name = "flagset"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
+
[[package]]
name = "flate2"
version = "1.1.10"
@@ -1826,6 +2049,28 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
+[[package]]
+name = "fmt-derive"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cb4a4b45c26455885f8b462db12978e51f60cc914337bccb9a36faf93410169"
+dependencies = [
+ "fmt-derive-proc",
+]
+
+[[package]]
+name = "fmt-derive-proc"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7704da0760734344ce8753f15b21e97c6be85dc5a8f2c3a963fbd042b3380d23"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "fmv-macos-events"
version = "0.1.0"
@@ -1948,6 +2193,12 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
[[package]]
name = "futures"
version = "0.3.34"
@@ -2073,6 +2324,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
[[package]]
name = "gethostname"
version = "1.1.0"
@@ -2092,6 +2353,19 @@ dependencies = [
"unicode-width",
]
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -2111,9 +2385,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
+ "wasm-bindgen",
]
[[package]]
@@ -2279,6 +2555,25 @@ dependencies = [
"euclid",
]
+[[package]]
+name = "h2"
+version = "0.4.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
[[package]]
name = "half"
version = "2.7.1"
@@ -2359,6 +2654,107 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
+dependencies = [
+ "base64 0.23.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "httparse",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "system-configuration",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "windows-registry",
+]
+
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -3188,6 +3584,12 @@ dependencies = [
"imgref",
]
+[[package]]
+name = "lru-slab"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
+
[[package]]
name = "maybe-owned"
version = "0.3.4"
@@ -3237,6 +3639,12 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -3302,6 +3710,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "multiversion_no_op"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
+
[[package]]
name = "naga"
version = "30.0.1"
@@ -3997,6 +4411,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -4116,6 +4536,25 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64 0.22.1",
+ "serde_core",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
[[package]]
name = "peniko"
version = "0.6.1"
@@ -4191,7 +4630,7 @@ dependencies = [
[[package]]
name = "photocraft"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"arboard",
"async-io",
@@ -4224,6 +4663,7 @@ dependencies = [
"rfd",
"ron",
"serde_json",
+ "tempfile",
"winit",
"winresource",
"winsafe",
@@ -4235,7 +4675,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"crc32fast",
"flate2",
@@ -4245,7 +4685,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4258,9 +4698,9 @@ dependencies = [
[[package]]
name = "photocraft-automation"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
- "base64",
+ "base64 0.22.1",
"cap-std",
"getrandom 0.3.4",
"photocraft-codecs",
@@ -4271,6 +4711,7 @@ dependencies = [
"photocraft-geom",
"photocraft-io",
"rmcp",
+ "rustix 1.1.5",
"schemars",
"serde",
"serde_json",
@@ -4280,7 +4721,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4294,7 +4735,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"moxcms",
"rayon",
@@ -4302,7 +4743,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"exr",
"flate2",
@@ -4323,7 +4764,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"photocraft-cms",
"serde",
@@ -4331,7 +4772,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"criterion",
"photocraft-color",
@@ -4346,7 +4787,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4357,7 +4798,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"blake3",
"craft-print",
@@ -4373,6 +4814,7 @@ dependencies = [
"photocraft-format",
"photocraft-geom",
"photocraft-io",
+ "photocraft-ipp",
"photocraft-ops",
"photocraft-paint",
"photocraft-plugins",
@@ -4383,6 +4825,7 @@ dependencies = [
"photocraft-vector",
"rayon",
"roxmltree",
+ "same-file",
"serde",
"serde_json",
"tempfile",
@@ -4392,10 +4835,11 @@ dependencies = [
[[package]]
name = "photocraft-format"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"blake3",
"crc32fast",
+ "exacl",
"flate2",
"libc",
"photocraft-codecs",
@@ -4409,18 +4853,19 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
+ "winsafe",
]
[[package]]
name = "photocraft-geom"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"half",
"log",
@@ -4437,16 +4882,17 @@ dependencies = [
[[package]]
name = "photocraft-heif"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"blake3",
+ "craft-docpath",
"crc32fast",
"flate2",
"photocraft-affinity",
@@ -4471,16 +4917,20 @@ dependencies = [
"thiserror 2.0.21",
]
+[[package]]
+name = "photocraft-ipp"
+version = "0.11.1"
+
[[package]]
name = "photocraft-ops"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4491,7 +4941,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4506,7 +4956,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4517,17 +4967,18 @@ dependencies = [
[[package]]
name = "photocraft-raster"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
"proptest",
+ "rayon",
"serde",
]
[[package]]
name = "photocraft-raw"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"flate2",
"image",
@@ -4542,7 +4993,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -4554,7 +5005,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -4565,7 +5016,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"flate2",
"parley",
@@ -4574,14 +5025,16 @@ dependencies = [
"photocraft-geom",
"photocraft-psd",
"photocraft-raster",
+ "rayon",
"skrifa",
]
[[package]]
name = "photocraft-ui-egui"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
- "base64",
+ "base64 0.22.1",
+ "craft-print",
"craft-ui",
"eframe",
"egui",
@@ -4614,12 +5067,13 @@ dependencies = [
"serde_json",
"sys-locale",
"toml",
+ "unicode-bidi",
"winsafe",
]
[[package]]
name = "photocraft-vector"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -4630,7 +5084,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"eframe",
"egui",
@@ -4650,6 +5104,17 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "photocraft-web-boot"
+version = "0.11.1"
+dependencies = [
+ "flate2",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "pico-args"
version = "0.5.0"
@@ -4850,6 +5315,30 @@ dependencies = [
"toml_edit",
]
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -4956,6 +5445,63 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "quinn"
+version = "0.11.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash 2.1.3",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.21",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
+dependencies = [
+ "aws-lc-rs",
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand 0.10.3",
+ "rand_pcg",
+ "ring",
+ "rustc-hash 2.1.3",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.21",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "quote"
version = "1.0.47"
@@ -5008,6 +5554,12 @@ dependencies = [
"rand_core 0.9.5",
]
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
[[package]]
name = "rand_core"
version = "0.9.5"
@@ -5023,6 +5575,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "rand_xorshift"
version = "0.4.0"
@@ -5233,6 +5794,49 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
+[[package]]
+name = "reqwest"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
+dependencies = [
+ "base64 0.23.1",
+ "bytes",
+ "encoding_rs",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "rustls-platform-verifier",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "resvg"
version = "0.45.1"
@@ -5286,6 +5890,20 @@ dependencies = [
"bytemuck",
]
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted 0.9.0",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "rmcp"
version = "3.5.0"
@@ -5413,6 +6031,81 @@ dependencies = [
"rustix 1.1.5",
]
+[[package]]
+name = "rustls"
+version = "0.23.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+dependencies = [
+ "aws-lc-rs",
+ "once_cell",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-platform-verifier"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
+dependencies = [
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "jni",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-platform-verifier-android",
+ "rustls-webpki",
+ "security-framework",
+ "security-framework-sys",
+ "webpki-root-certs",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls-platform-verifier-android"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "aws-lc-rs",
+ "ring",
+ "rustls-pki-types",
+ "untrusted 0.9.0",
+]
+
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -5451,13 +6144,25 @@ dependencies = [
[[package]]
name = "ruzstd"
-version = "0.9.0"
+version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
+checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
dependencies = [
"twox-hash",
]
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "ryu-js"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714"
+
[[package]]
name = "same-file"
version = "1.0.6"
@@ -5467,6 +6172,15 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "schannel"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "schemars"
version = "1.2.2"
@@ -5505,6 +6219,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "self_cell"
version = "1.3.0"
@@ -5571,6 +6308,17 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_json_canonicalizer"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2"
+dependencies = [
+ "ryu-js",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "serde_repr"
version = "0.1.21"
@@ -5591,6 +6339,40 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
[[package]]
name = "shlex"
version = "1.3.0"
@@ -5613,6 +6395,175 @@ dependencies = [
"libc",
]
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "sigstore-bundle"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e7dc53c8a941858d01479622dbb7650aaa2ee0ca1fb58fb6cdddbfc3064abbb"
+dependencies = [
+ "base64 0.22.1",
+ "hex",
+ "serde",
+ "serde_json",
+ "sigstore-crypto",
+ "sigstore-merkle",
+ "sigstore-rekor",
+ "sigstore-tsa",
+ "sigstore-types",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "sigstore-crypto"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b"
+dependencies = [
+ "aws-lc-rs",
+ "base64 0.22.1",
+ "const-oid",
+ "der",
+ "digest",
+ "pem",
+ "rand_core 0.10.1",
+ "sha2",
+ "signature",
+ "sigstore-types",
+ "spki",
+ "thiserror 2.0.21",
+ "tracing",
+ "x509-cert",
+]
+
+[[package]]
+name = "sigstore-merkle"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c0d53558825c77716855c13794306fff766854c1bf92948e77d7890da3f2455"
+dependencies = [
+ "base64 0.22.1",
+ "hex",
+ "sigstore-crypto",
+ "sigstore-types",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "sigstore-rekor"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "641fdaaa40d0cd6e249cf1671c2240beff1a3ece578062cf43e750779e8db2b3"
+dependencies = [
+ "base64 0.22.1",
+ "hex",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sigstore-crypto",
+ "sigstore-merkle",
+ "sigstore-types",
+ "thiserror 2.0.21",
+ "url",
+]
+
+[[package]]
+name = "sigstore-trust-root"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6b0d2550f05c096400066e858db13d91f9de3b750a7541fb1291c1dc80ca290"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "hex",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "sigstore-crypto",
+ "sigstore-types",
+ "thiserror 2.0.21",
+ "x509-cert",
+]
+
+[[package]]
+name = "sigstore-tsa"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bc86c8abc1ece6832236e7e34e9baebb5d4d40490c0332b814a0d8624ca7255"
+dependencies = [
+ "aws-lc-rs",
+ "base64 0.22.1",
+ "chrono",
+ "cmpv2",
+ "cms",
+ "const-oid",
+ "der",
+ "hex",
+ "rand 0.10.3",
+ "reqwest",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "sigstore-crypto",
+ "sigstore-types",
+ "thiserror 2.0.21",
+ "tracing",
+ "x509-cert",
+ "x509-tsp",
+]
+
+[[package]]
+name = "sigstore-types"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8d870c9bcfdf83396ac2bd2d6a23c5d09ec02cc9fda50fa1cbb3dd71a9bee53"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "hex",
+ "pem",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "sigstore-verify"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d30aff69036e4579b416956e9ba68cec45b793116f78f10842d04c7f8b924bab"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "cms",
+ "const-oid",
+ "hex",
+ "pem",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "serde",
+ "serde_json",
+ "serde_json_canonicalizer",
+ "sigstore-bundle",
+ "sigstore-crypto",
+ "sigstore-merkle",
+ "sigstore-rekor",
+ "sigstore-trust-root",
+ "sigstore-tsa",
+ "sigstore-types",
+ "thiserror 2.0.21",
+ "tls_codec",
+ "tracing",
+ "x509-cert",
+]
+
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -5790,6 +6741,16 @@ dependencies = [
"bitflags 2.13.2",
]
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -5833,6 +6794,33 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+[[package]]
+name = "strum"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
[[package]]
name = "svgtypes"
version = "0.15.3"
@@ -5843,6 +6831,16 @@ dependencies = [
"siphasher",
]
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "unicode-ident",
+]
+
[[package]]
name = "syn"
version = "2.0.119"
@@ -5865,6 +6863,15 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
[[package]]
name = "synstructure"
version = "0.14.0"
@@ -5885,6 +6892,27 @@ dependencies = [
"libc",
]
+[[package]]
+name = "system-configuration"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation 0.9.4",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "system-deps"
version = "7.0.8"
@@ -5898,6 +6926,17 @@ dependencies = [
"version-compare",
]
+[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
[[package]]
name = "target-lexicon"
version = "0.13.5"
@@ -6033,6 +7072,27 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
+[[package]]
+name = "tls_codec"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b"
+dependencies = [
+ "tls_codec_derive",
+ "zeroize",
+]
+
+[[package]]
+name = "tls_codec_derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "tokio"
version = "1.53.1"
@@ -6059,6 +7119,16 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "tokio-rustls"
+version = "0.26.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
[[package]]
name = "tokio-stream"
version = "0.1.19"
@@ -6135,6 +7205,51 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags 2.13.2",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
[[package]]
name = "tracing"
version = "0.1.44"
@@ -6188,6 +7303,12 @@ dependencies = [
"petgraph",
]
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -6218,6 +7339,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -6295,6 +7422,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+[[package]]
+name = "untrusted"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
[[package]]
name = "url"
version = "2.5.8"
@@ -6313,7 +7452,7 @@ version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"
dependencies = [
- "base64",
+ "base64 0.22.1",
"data-url",
"flate2",
"fontdb",
@@ -6421,6 +7560,15 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "want"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
+dependencies = [
+ "try-lock",
+]
+
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -6755,6 +7903,15 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "webpki-root-certs"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "weezl"
version = "0.1.12"
@@ -7042,6 +8199,17 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-registry"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
+dependencies = [
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -7294,6 +8462,27 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "winprint"
+version = "0.2.1"
+source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49"
+dependencies = [
+ "flate2",
+ "fmt-derive",
+ "reqwest",
+ "scopeguard",
+ "sigstore-trust-root",
+ "sigstore-types",
+ "sigstore-verify",
+ "strum",
+ "tar",
+ "thiserror 2.0.21",
+ "windows",
+ "windows-core",
+ "windows-implement",
+ "xml",
+]
+
[[package]]
name = "winresource"
version = "0.1.31"
@@ -7382,6 +8571,41 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+[[package]]
+name = "x509-cert"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
+dependencies = [
+ "const-oid",
+ "der",
+ "sha1",
+ "signature",
+ "spki",
+ "tls_codec",
+]
+
+[[package]]
+name = "x509-tsp"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1"
+dependencies = [
+ "cmpv2",
+ "cms",
+ "der",
+]
+
+[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix 1.1.5",
+]
+
[[package]]
name = "xcap"
version = "0.9.8"
@@ -7449,6 +8673,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+[[package]]
+name = "xml"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52"
+
[[package]]
name = "xml-rs"
version = "0.8.29"
@@ -7463,7 +8693,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
-version = "0.8.2"
+version = "0.11.1"
dependencies = [
"serde",
"serde_json",
@@ -7646,6 +8876,26 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zeroize"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "zerotrie"
version = "0.2.5"
diff --git a/Cargo.toml b/Cargo.toml
index f96b5cb468..b3f49802e3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
-version = "0.8.2"
+version = "0.11.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -36,9 +36,11 @@ photocraft-engine = { path = "crates/engine" }
photocraft-ui-egui = { path = "crates/ui-egui" }
photocraft-testkit = { path = "crates/testkit" }
photocraft-tablet = { path = "crates/tablet" }
+photocraft-ipp = { path = "crates/ipp" }
# external
-craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
+craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
+craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
@@ -75,9 +77,18 @@ opt-level = 2
opt-level = 1
# Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link
# time and PDB size for the ~260 test binaries (#2204). CI sets the same through
-# CARGO_PROFILE_DEV_DEBUG. To inspect locals in a debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
+# CARGO_PROFILE_DEV_DEBUG (and no debuginfo at all for the Windows tests). To inspect locals in a
+# debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
debug = "line-tables-only"
+# Test builds compile ui-egui without optimization: its library and unit tests were the biggest
+# test-build units after engine's (12.6 of ~63 compile minutes on the Windows CI runner), and its
+# tests run no heavier pixel code of their own (that lives in the crates that stay optimized).
+# Measured on CI: Windows build 18.9 -> 15.4 min, test run 10.0 -> 10.5. `cargo run` and
+# `cargo build` use the dev profile and are unaffected.
+[profile.test.package.photocraft-ui-egui]
+opt-level = 0
+
[profile.release]
lto = "thin"
codegen-units = 1
@@ -127,17 +138,32 @@ opt-level = "z"
[profile.wasm-release.package.naga]
opt-level = "z"
-# Pixel processing keeps its speed-oriented optimization level.
+# The browser UI toolkit favors size to stay within the WebAssembly upload budget.
+[profile.wasm-release.package.egui]
+opt-level = "z"
+[profile.wasm-release.package.epaint]
+opt-level = "z"
+[profile.wasm-release.package.eframe]
+opt-level = "z"
+
+# Pixel processing keeps its speed-oriented optimization level, with two exceptions.
+# Colour-management transforms are built once and cached (`transform::cached`), so "z" costs
+# little runtime. The browser app composites per frame on the GPU (`crates/gpu`, WebGPU/WebGL2),
+# so the CPU compositor is not the browser's hot path there; "z" for compose keeps the wasm
+# under the 24 MiB gate (measured −122 KB vs opt-level 3). algo's filters run per pixel on
+# every interactive filter application; "z" there is the size lever of last resort (−331 KB),
+# taken because the gate is enforced in CI: if browser filters feel too slow, restore 3 here
+# and reclaim the bytes from a feature-gated decoder instead.
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
opt-level = 3
[profile.wasm-release.package.photocraft-cms]
-opt-level = 3
+opt-level = "z"
[profile.wasm-release.package.photocraft-compose]
-opt-level = 3
+opt-level = "z"
[profile.wasm-release.package.photocraft-algo]
-opt-level = 3
+opt-level = "z"
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
diff --git a/NOTICE b/NOTICE
index 887010d4a6..a7302a940c 100644
--- a/NOTICE
+++ b/NOTICE
@@ -16,6 +16,7 @@ Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
Lucide icons (assets/icons/*.svg, except slice-knife.svg) — ISC; icons derived from Feather also MIT. See assets/icons/LICENSE-lucide.txt.
SCOWL English word list (assets/dict/en_US-scowl-50.txt.gz) — SCOWL license. See assets/dict/LICENSE-SCOWL.txt.
+libfacedetection face detection weights and network (crates/algo/data/libfacedetection.bin, crates/algo/src/faces/cnn.rs) — BSD-3-Clause, Copyright (c) 2015-2019, Shiqi Yu, all rights reserved. See crates/algo/data/LICENSE-libfacedetection.txt.
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and
diff --git a/README.md b/README.md
index 5fd2650415..7554fcd8f2 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,9 @@
+
+
+
@@ -187,7 +190,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
🗃️ Layers, done properly
- Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, layer filters, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
+ Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, a layer kind filter, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
@@ -275,9 +278,11 @@ flatpak install --user photocraft--linux-x86_64.flatpak # or -linux-a
flatpak run ai.storyteller.photocraft
```
+The Flatpak prints through the host's CUPS socket (File › Print talks IPP to it directly, no `lp` needed); without CUPS, or without a printer, File › Print opens the system print dialog through the print portal. See [printing](docs/printing.md).
+
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
-On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft--linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it.
+On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft--linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072).
On macOS, the command-line tool comes as `photocraft-cli--macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
@@ -300,6 +305,19 @@ Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are
> [!IMPORTANT]
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, 15 missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity-checklist.md`](docs/parity-checklist.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap](ROADMAP.md) and [parity assessment](docs/target-app-parity.md). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft).
+## Community packages
+
+These installers are maintained by community members, not by the PhotoCraft team.
+
+On Gentoo, the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as `media-gfx/photocraft-bin`:
+
+```sh
+eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
+emaint sync -r snakebyte
+echo 'media-gfx/photocraft-bin ~amd64' >> /etc/portage/package.accept_keywords/photocraft
+emerge --ask media-gfx/photocraft-bin
+```
+
## Documentation
Developer, architecture, automation, format, and security documentation is maintained in the [PhotoCraft documentation book](book/).
diff --git a/ROADMAP.md b/ROADMAP.md
index 98e8c1184c..f41b71bfc9 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
@@ -40,7 +40,7 @@ the one above. About 80% parallelizes (independent crates and command modules);
architecture and the PSD writer are the serial parts.
**Why alpha.** Core workflows (open, layer, select, paint, adjust, filter, type, save) work end to
-end, and breadth is high, but users of 0.5/0.6 have 579 open issues, 22 of 25 performance budgets
+end, and breadth is high, but 953 issues are open (2026-10-13), 22 of 25 performance budgets
are missed, and PSDs we write are not yet reliably accepted by Photoshop (#1281, #2469). Beta
needs ~75% ready and no blocking PSD gap. The [alpha gate](docs/roadmap.md#alpha-gate) passes: all six core workflows
(retouch, composite, cut-out, design, paint, PSD exchange) complete end to end and save and reopen;
@@ -110,7 +110,7 @@ Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dut
| Rank | Milestone | Estimate |
|---|---|---:|
-| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 133 → 200+ of 256 | 150–250 h |
+| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 145 → 200+ of 258 | 150–250 h |
| 2 | Performance budgets: the 22 over-budget scenarios and the 150-layer GPU crash | 200–350 h |
| 3 | User bug backlog to under 100 open | 250–400 h |
| 4 | Brush and transform feel (brush-parity, ui-parity rows) | 160–290 h |
@@ -135,6 +135,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
| 2026-10-10 | minor | Readiness by audience with hours per tier |
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml
index a6d3b49389..f4848f379a 100644
--- a/apps/photocraft-cli/Cargo.toml
+++ b/apps/photocraft-cli/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "photocraft-cli"
-description = "Headless Photocraft: convert, inspect, run commands, batch, MCP server"
+description = "Headless PhotoCraft: convert, inspect, run commands, batch, MCP server"
version.workspace = true
edition.workspace = true
license.workspace = true
diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs
index a81cce61ce..0e3429d074 100644
--- a/apps/photocraft-cli/src/lib.rs
+++ b/apps/photocraft-cli/src/lib.rs
@@ -1,4 +1,4 @@
-//! Headless Photocraft command line. `run` is the whole program, so it can be
+//! Headless PhotoCraft command line. `run` is the whole program, so it can be
//! tested in-process as well as through the binary.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -12,22 +12,24 @@ use photocraft_io::ExportOptions;
use serde_json::{Value, json};
pub const USAGE: &str = "\
-photocraft-cli: headless Photocraft
+photocraft-cli: headless PhotoCraft
USAGE:
- photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
+ photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
+ --dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1
+ (DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain.
photocraft-cli info [--compact]
Print the document as JSON (size, mode, depth, layer tree).
- photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
+ photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
- photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
+ photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps]
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
@@ -69,18 +71,23 @@ struct Subcommand {
}
const SUBCOMMANDS: &[Subcommand] = &[
- Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert },
+ Subcommand {
+ name: "convert",
+ values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
+ bare: &["--tiff-layers", "--dds-mipmaps"],
+ run: convert,
+ },
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
- values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
- bare: &["--tiff-layers"],
+ values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
+ bare: &["--tiff-layers", "--dds-mipmaps"],
run: run_cmds,
},
Subcommand {
name: "batch",
- values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
- bare: &["--in-place", "--tiff-layers"],
+ values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
+ bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: batch,
},
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
@@ -219,6 +226,11 @@ fn export_opts(a: &Args) -> Result {
o.encode.dpx_bits =
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
}
+ if let Some(c) = a.get("--dds-compression") {
+ o.encode.dds.compression =
+ photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?;
+ }
+ o.encode.dds.mipmaps = a.has("--dds-mipmaps");
Ok(o)
}
@@ -541,9 +553,7 @@ fn commands(a: &Args, out: &mut dyn Write) -> R {
.map(|v| v.iter().collect::>())
.unwrap_or_default()
.into_iter()
- .filter(|c| {
- needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n))
- })
+ .filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)))
.collect();
if a.has("--json") {
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false);
diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs
index 231eb2c321..6c62e40804 100644
--- a/apps/photocraft-cli/tests/cli.rs
+++ b/apps/photocraft-cli/tests/cli.rs
@@ -90,6 +90,11 @@ fn unknown_flags_are_usage_errors() {
// Every flag still works in both forms.
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
+ // The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
+ for needle in ["ellipse", "marquee"] {
+ let (out, _) = ok(bin().args(["commands", "--filter", needle]));
+ assert!(out.contains("select.rect"), "{needle}: {out}");
+ }
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
"--format=jpg",
"--quality",
@@ -489,6 +494,39 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() {
}
}
+/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes
+/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that
+/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327).
+#[test]
+fn dds_converts_both_ways_with_the_compression_asked_for() {
+ let d = tmp("dds");
+ let a = d.join("a.png");
+ write_png(&a, 8, 4, 3);
+ for (args, fourcc, mips) in [
+ (&[][..], [0; 4], 1),
+ (&["--dds-compression", "bc1"][..], *b"DXT1", 1),
+ (&["--dds-compression", "DXT3", "--dds-mipmaps"][..], *b"DXT3", 4),
+ (&["--dds-compression", "bc3"][..], *b"DXT5", 1),
+ (&["--dds-mipmaps"][..], [0; 4], 4),
+ ] {
+ let out = d.join("a.dds");
+ ok(bin().arg("convert").arg(&a).arg(&out).args(args));
+ let bytes = std::fs::read(&out).unwrap();
+ let mip_count = u32::from_le_bytes(bytes[28..32].try_into().unwrap());
+ assert_eq!((&bytes[..4], &bytes[84..88], mip_count), (&b"DDS "[..], &fourcc[..], mips), "{args:?}");
+ let back = d.join("back.png");
+ ok(bin().arg("convert").arg(&out).arg(&back));
+ assert!(back.exists(), "{args:?}");
+ }
+ for bad in ["bc7", "dxt2", ""] {
+ let out = d.join("bad.dds");
+ let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap();
+ assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}");
+ assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}");
+ assert!(!out.exists(), "--dds-compression {bad} wrote a file");
+ }
+}
+
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
/// refused and nothing is written.
#[test]
diff --git a/apps/photocraft-web-boot/Cargo.toml b/apps/photocraft-web-boot/Cargo.toml
new file mode 100644
index 0000000000..dd12341af2
--- /dev/null
+++ b/apps/photocraft-web-boot/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "photocraft-web-boot"
+description = "Starts Photocraft in the browser from compressed parts of its WebAssembly"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish = false
+
+[lints]
+workspace = true
+
+[[bin]]
+name = "photocraft-web-boot"
+path = "src/main.rs"
+
+[dependencies]
+# Inflates the parts in browsers without `DecompressionStream`.
+flate2 = { workspace = true }
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
+js-sys = "0.3"
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
diff --git a/apps/photocraft-web-boot/Trunk.toml b/apps/photocraft-web-boot/Trunk.toml
new file mode 100644
index 0000000000..56192a9921
--- /dev/null
+++ b/apps/photocraft-web-boot/Trunk.toml
@@ -0,0 +1,6 @@
+# Built by packaging/web/package.sh: trunk build --release (from apps/photocraft-web-boot)
+[build]
+target = "index.html"
+dist = "../../dist/web-boot"
+public_url = "./"
+filehash = true
diff --git a/apps/photocraft-web-boot/index.html b/apps/photocraft-web-boot/index.html
new file mode 100644
index 0000000000..8ed9a6961e
--- /dev/null
+++ b/apps/photocraft-web-boot/index.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+ PhotoCraft
+
+
+
+
+
+
+
Loading PhotoCraft…
+
This is taking longer than usual. If PhotoCraft doesn't start, its files may have failed to
+ download or come from a different version of this page. Reload to try again; the browser
+ console lists what failed.
+
+
+
diff --git a/apps/photocraft-web-boot/src/main.rs b/apps/photocraft-web-boot/src/main.rs
new file mode 100644
index 0000000000..56c2bf1517
--- /dev/null
+++ b/apps/photocraft-web-boot/src/main.rs
@@ -0,0 +1,356 @@
+//! Starts Photocraft in the browser from compressed parts of its WebAssembly.
+//!
+//! Static hosts cap the size of one file (Cloudflare Pages: 25 MiB), and the app's wasm is at
+//! that size. `packaging/web/package.sh` gzips it and cuts it into parts listed in
+//! `app/parts.txt`. This small module is what the packaged page loads: it fetches the parts in
+//! order, joins and inflates them as they arrive, and hands the stream to the app's own generated
+//! loader, so the browser still compiles while downloading. No file on the host is larger than
+//! one part, however large the app grows.
+//!
+//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80).
+//! In one that can't, this module inflates them itself, a little slower.
+//!
+//! A download that fails is tried again, the later tries past the browser's cache (a bad copy
+//! there would otherwise fail forever). When every try fails the page says so and offers a reload.
+//!
+//! `trunk build` in `apps/photocraft-web` still gives the app as one `.wasm`, for development and
+//! for hosts without a per-file cap; only the packaged site starts through this module.
+
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+/// First line of `parts.txt`. Some hosts answer a missing file with the home page and HTTP 200;
+/// without this an HTML page would be read as a list of parts.
+#[cfg(any(target_arch = "wasm32", test))]
+const MARKER: &str = "photocraft-parts 1";
+
+/// The part names in a `parts.txt`, in order, or `None` when the text isn't one. A name is a
+/// file next to `parts.txt`: anything that could point elsewhere is refused.
+#[cfg(any(target_arch = "wasm32", test))]
+fn parts(list: &str) -> Option> {
+ let mut lines = list.lines().map(str::trim).filter(|l| !l.is_empty());
+ if lines.next() != Some(MARKER) {
+ return None;
+ }
+ let parts: Vec = lines.map(str::to_owned).collect();
+ let plain = |p: &String| !p.starts_with('.') && !p.contains(['/', '\\', '?', '#', ':', '%']);
+ (!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
+}
+
+/// Gzip inflated a chunk at a time, for browsers that can't do it themselves.
+#[cfg(any(target_arch = "wasm32", test))]
+struct Inflater(flate2::write::GzDecoder>);
+
+#[cfg(any(target_arch = "wasm32", test))]
+impl Inflater {
+ fn new() -> Self {
+ Inflater(flate2::write::GzDecoder::new(Vec::new()))
+ }
+
+ /// What the next `chunk` of the gzip stream inflates to (often nothing: the decoder holds on
+ /// to input until it has a whole block).
+ fn push(&mut self, chunk: &[u8]) -> std::io::Result> {
+ std::io::Write::write_all(&mut self.0, chunk)?;
+ Ok(std::mem::take(self.0.get_mut()))
+ }
+
+ /// The rest, once the stream has ended. An error when it ended early or its checksum is
+ /// wrong, the same as the browser's inflater gives.
+ fn finish(&mut self) -> std::io::Result> {
+ self.0.try_finish()?;
+ Ok(std::mem::take(self.0.get_mut()))
+ }
+}
+
+#[cfg(target_arch = "wasm32")]
+mod boot {
+ use std::cell::RefCell;
+ use std::rc::Rc;
+
+ use wasm_bindgen::prelude::*;
+ use wasm_bindgen_futures::{JsFuture, spawn_local};
+ use web_sys::{
+ Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream,
+ TransformStreamDefaultController, Transformer, WritableStream,
+ };
+
+ use super::Inflater;
+
+ /// Where packaging puts the app, next to this module's own files.
+ const PARTS: &str = "./app/parts.txt";
+ /// Seconds to wait before each further try; one more than this many tries are made.
+ const RETRY_AFTER: [u32; 2] = [1, 3];
+
+ // The app's `wasm-bindgen` loader. Its default export instantiates the module it is given and
+ // runs the app's `main`. The path is fixed at compile time, so packaging gives the file this
+ // name (Trunk's own name for it carries a content hash).
+ #[wasm_bindgen(raw_module = "./app/photocraft-web.js")]
+ extern "C" {
+ #[wasm_bindgen(js_name = default, catch)]
+ async fn start_app(module: JsValue) -> Result;
+ }
+
+ // The browser's own inflater. `web-sys` has it only behind its unstable-API flag, which would
+ // have to be set for the whole build, so the three members used are declared here.
+ #[wasm_bindgen]
+ extern "C" {
+ type DecompressionStream;
+ #[wasm_bindgen(constructor, catch)]
+ fn new(format: &str) -> Result;
+ #[wasm_bindgen(method, getter)]
+ fn readable(this: &DecompressionStream) -> ReadableStream;
+ #[wasm_bindgen(method, getter)]
+ fn writable(this: &DecompressionStream) -> WritableStream;
+ }
+
+ fn window() -> Result {
+ web_sys::window().ok_or_else(|| JsValue::from_str("no window"))
+ }
+
+ /// `fresh` skips the browser's cache.
+ async fn fetch(url: &str, fresh: bool) -> Result {
+ let init = RequestInit::new();
+ if fresh {
+ init.set_cache(RequestCache::Reload);
+ }
+ let response: Response = JsFuture::from(window()?.fetch_with_str_and_init(url, &init)).await?.dyn_into()?;
+ if !response.ok() {
+ return Err(JsValue::from_str(&format!("{url}: HTTP {}", response.status())));
+ }
+ if response.headers().get("Content-Type")?.is_some_and(|t| t.starts_with("text/html")) {
+ return Err(JsValue::from_str(&format!("{url}: the server sent a web page, not the file")));
+ }
+ Ok(response)
+ }
+
+ /// Copies each part into `sink` in order, then closes it.
+ async fn feed(parts: Vec, sink: WritableStream, fresh: bool) -> Result<(), JsValue> {
+ let keep_open = StreamPipeOptions::new();
+ keep_open.set_prevent_close(true);
+ for part in &parts {
+ let body = fetch(&format!("./app/{part}"), fresh).await?.body().ok_or_else(|| JsValue::from_str("part without a body"))?;
+ JsFuture::from(body.pipe_to_with_options(&sink, &keep_open)).await?;
+ }
+ JsFuture::from(sink.close()).await?;
+ Ok(())
+ }
+
+ /// Passes inflated bytes on to the stream's reader.
+ fn emit(inflated: std::io::Result>, out: &TransformStreamDefaultController) -> Result<(), JsValue> {
+ let inflated = inflated.map_err(|e| JsValue::from_str(&format!("the download is damaged ({e})")))?;
+ if inflated.is_empty() { Ok(()) } else { out.enqueue_with_chunk(&js_sys::Uint8Array::from(inflated.as_slice())) }
+ }
+
+ /// A gzip inflater with the shape of `DecompressionStream`, running in this module.
+ fn own_inflater() -> Result {
+ let inflater = Rc::new(RefCell::new(Inflater::new()));
+ let last = inflater.clone();
+ let busy = || JsValue::from_str("the inflater is in use");
+ let transform = Closure:: Result<(), JsValue>>::new(
+ move |chunk: JsValue, out: TransformStreamDefaultController| {
+ let chunk = js_sys::Uint8Array::new(&chunk).to_vec();
+ emit(inflater.try_borrow_mut().map_err(|_| busy())?.push(&chunk), &out)
+ },
+ );
+ let flush = Closure:: Result<(), JsValue>>::new(move |out: TransformStreamDefaultController| {
+ emit(last.try_borrow_mut().map_err(|_| busy())?.finish(), &out)
+ });
+ let transformer = Transformer::new();
+ transformer.set_transform(&transform.into_js_value().unchecked_into());
+ transformer.set_flush(&flush.into_js_value().unchecked_into());
+ TransformStream::new_with_transformer(&transformer)
+ }
+
+ /// `compressed` inflated: by the browser where it can, otherwise here.
+ fn inflated(compressed: &ReadableStream) -> Result {
+ let pair = if js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("DecompressionStream"))? {
+ let inflater = DecompressionStream::new("gzip")?;
+ ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
+ } else {
+ let inflater = own_inflater()?;
+ ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
+ };
+ Ok(compressed.pipe_through(&pair))
+ }
+
+ async fn run(fresh: bool) -> Result<(), JsValue> {
+ let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default();
+ let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?;
+ let joined = TransformStream::new()?;
+ let wasm = inflated(&joined.readable())?;
+ // Why the download stopped: the app's loader only reports that its stream broke.
+ let cause = Rc::new(RefCell::new(None));
+ let failed = cause.clone();
+ let sink = joined.writable();
+ spawn_local(async move {
+ if let Err(e) = feed(parts, sink.clone(), fresh).await {
+ let _ = sink.abort_with_reason(&e);
+ failed.replace(Some(e));
+ }
+ });
+ let headers = Headers::new()?;
+ headers.set("Content-Type", "application/wasm")?;
+ let init = ResponseInit::new();
+ init.set_headers(&headers);
+ let response = Response::new_with_opt_readable_stream_and_init(Some(&wasm), &init)?;
+ let arg = js_sys::Object::new();
+ js_sys::Reflect::set(&arg, &JsValue::from_str("module_or_path"), &response)?;
+ match start_app(arg.into()).await {
+ Ok(_) => Ok(()),
+ Err(e) => Err(cause.take().unwrap_or(e)),
+ }
+ }
+
+ async fn sleep(seconds: u32) {
+ let wait = js_sys::Promise::new(&mut |done, _| {
+ let ms = i32::try_from(seconds.saturating_mul(1000)).unwrap_or(i32::MAX);
+ if window().and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&done, ms)).is_err() {
+ let _ = done.call0(&JsValue::NULL);
+ }
+ });
+ let _ = JsFuture::from(wait).await;
+ }
+
+ /// Replaces the page's "Loading" text. `reload` adds a link that reloads the page.
+ fn say(text: &str, reload: bool) {
+ let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
+ let Some(loading) = document.get_element_by_id("photocraft_loading") else { return };
+ loading.set_text_content(None);
+ let Ok(line) = document.create_element("p") else { return };
+ line.set_text_content(Some(text));
+ if reload && let Ok(link) = document.create_element("a") {
+ link.set_text_content(Some("Reload"));
+ let _ = link.set_attribute("href", "");
+ let _ = line.append_child(&link);
+ }
+ let _ = loading.append_child(&line);
+ }
+
+ pub fn start() {
+ spawn_local(async {
+ let mut attempt = 0;
+ loop {
+ let error = match run(attempt > 0).await {
+ Ok(()) => return,
+ Err(e) => e,
+ };
+ web_sys::console::error_2(&JsValue::from_str("photocraft-web-boot: could not start PhotoCraft:"), &error);
+ let Some(&wait) = RETRY_AFTER.get(attempt) else {
+ say("PhotoCraft couldn't be downloaded. Check your connection, then try again: ", true);
+ return;
+ };
+ say("Loading PhotoCraft\u{2026} (the download failed, trying again)", false);
+ sleep(wait).await;
+ attempt += 1;
+ }
+ });
+ }
+}
+
+#[cfg(target_arch = "wasm32")]
+fn main() {
+ boot::start();
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn main() {
+ eprintln!("photocraft-web-boot only runs in the browser: packaging/web/package.sh builds it with `trunk build --release` in apps/photocraft-web-boot");
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{Inflater, MARKER, parts};
+
+ fn gzip(bytes: &[u8]) -> Vec {
+ let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
+ std::io::Write::write_all(&mut z, bytes).expect("gzip");
+ z.finish().expect("gzip")
+ }
+
+ /// Bytes that don't compress to nothing, so the gzip stream spans many chunks.
+ fn sample() -> Vec {
+ let mut x = 0x2545_f491_4f6c_dd1d_u64;
+ (0..200_000)
+ .map(|i| {
+ x ^= x << 13;
+ x ^= x >> 7;
+ x ^= x << 17;
+ if i % 3 == 0 { x as u8 } else { (i / 7) as u8 }
+ })
+ .collect()
+ }
+
+ /// The parts arrive in whatever pieces the network gives; the inflater must not care where
+ /// they are cut.
+ #[test]
+ fn the_inflater_gives_back_the_bytes_however_the_stream_is_cut() {
+ let (plain, packed) = (sample(), gzip(&sample()));
+ for cut in [1, 7, 4096, 65_536, packed.len()] {
+ let mut inflater = Inflater::new();
+ let mut out = Vec::new();
+ for chunk in packed.chunks(cut) {
+ out.extend(inflater.push(chunk).expect("push"));
+ }
+ out.extend(inflater.finish().expect("finish"));
+ assert_eq!(out, plain, "chunks of {cut}");
+ }
+ }
+
+ /// A download that stops early, or whose bytes changed on the way, must fail (the boot module
+ /// then tries again), never start a damaged app.
+ #[test]
+ fn the_inflater_refuses_a_short_or_damaged_stream() {
+ let packed = gzip(&sample());
+ let run = |bytes: &[u8]| -> std::io::Result> {
+ let mut inflater = Inflater::new();
+ let mut out = inflater.push(bytes)?;
+ out.extend(inflater.finish()?);
+ Ok(out)
+ };
+ assert!(run(&packed).is_ok());
+ assert!(run(&packed[..packed.len() / 2]).is_err(), "cut short");
+ assert!(run(&packed[..packed.len() - 4]).is_err(), "trailer cut");
+ let mut damaged = packed.clone();
+ let middle = damaged.len() / 2;
+ damaged[middle] ^= 0x55;
+ assert!(run(&damaged).is_err(), "a changed byte");
+ assert!(run(b"not gzip").is_err(), "a web page");
+ assert!(run(b"").is_err(), "nothing at all");
+ }
+
+ #[test]
+ fn a_parts_list_gives_its_names_in_order() {
+ let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n");
+ assert_eq!(parts(&list), Some(vec!["photocraft-web-0123abcd.part-aa".to_owned(), "photocraft-web-0123abcd.part-ab".to_owned()]));
+ }
+
+ /// A host that answers a missing file with its home page must not have that page read as a
+ /// list of parts.
+ #[test]
+ fn a_web_page_is_not_a_parts_list() {
+ assert_eq!(parts(include_str!("../index.html")), None);
+ assert_eq!(parts(""), None);
+ assert_eq!(parts("photocraft-web.part-aa\n"), None, "no marker");
+ assert_eq!(parts(&format!("{MARKER}\n")), None, "no parts");
+ assert_eq!(parts("photocraft-parts 2\na.part-aa\n"), None, "a later layout this build doesn't know");
+ }
+
+ /// Part names are joined onto `./app/`, so one that could leave that folder, or name another
+ /// origin, refuses the whole list.
+ #[test]
+ fn a_part_name_cannot_point_outside_the_app_folder() {
+ for bad in ["../index.html", "a/b.part-aa", "a\\b", "https://example.com/x", "//example.com/x", "x?y", "x#y", ".hidden", "%2e%2e"] {
+ assert_eq!(parts(&format!("{MARKER}\ngood.part-aa\n{bad}\n")), None, "{bad}");
+ }
+ }
+
+ /// The packaged site shows this crate's page, the development build the app's own. They are
+ /// the same page apart from which program the `` line builds.
+ #[test]
+ fn the_page_is_the_apps_page() {
+ let trunk = |html: &str| html.lines().filter(|l| !l.contains("data-trunk")).collect::>().join("\n");
+ let (boot, app) = (include_str!("../index.html"), include_str!("../../photocraft-web/index.html"));
+ assert_eq!(trunk(boot), trunk(app));
+ assert!(boot.contains(r#"data-bin="photocraft-web-boot""#) && boot.contains(r#"data-cargo-profile="wasm-release""#), "{boot}");
+ assert!(boot.contains(r#"id="photocraft_loading""#), "the element the failure message replaces");
+ }
+}
diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml
index a4ded64181..4ecad80ae4 100644
--- a/apps/photocraft-web/Cargo.toml
+++ b/apps/photocraft-web/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "photocraft-web"
-description = "Photocraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
+description = "PhotoCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -34,7 +34,7 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
[dev-dependencies]
serde_json = { workspace = true }
diff --git a/apps/photocraft-web/src/main.rs b/apps/photocraft-web/src/main.rs
index 5ba75f5254..0802a02ebf 100644
--- a/apps/photocraft-web/src/main.rs
+++ b/apps/photocraft-web/src/main.rs
@@ -1,4 +1,4 @@
-//! Photocraft in the browser.
+//! PhotoCraft in the browser.
//!
//! Runs the same [`photocraft_ui_egui::PhotocraftApp`] as the desktop app through eframe's web
//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release`
@@ -6,10 +6,10 @@
//!
//! Differences from the desktop app:
//! - no TCP control server (browsers can't listen on sockets);
-//! - File → Open uses the browser file picker; bytes arrive asynchronously through
-//! `Services::inbox`;
+//! - file pickers return browser file contents asynchronously through `Services::file_dialog`;
+//! this includes File → Open and commands such as Place Embedded;
//! - saving/exporting triggers a browser download;
-//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
+//! - dropped files are read asynchronously by `web::WebShell` and delivered through `Services::inbox`.
//!
//! URL query flags: `?cpu` forces the CPU canvas path (same as `PHOTOCRAFT_CPU_CANVAS=1`);
//! `?webgl` forces the WebGL2 backend instead of WebGPU.
diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs
index a5c9d8a565..a248ecbb13 100644
--- a/apps/photocraft-web/src/web.rs
+++ b/apps/photocraft-web/src/web.rs
@@ -1,6 +1,8 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
+use std::cell::Cell;
use std::collections::{HashMap, HashSet};
+use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -18,8 +20,8 @@ type Inbox = Arc)>>>;
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
- "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf",
- "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
+ "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef",
+ "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -79,11 +81,13 @@ pub fn start() {
Box::new(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
let inbox: Inbox = Arc::default();
+ let place_inbox: Inbox = Arc::default();
let served: Served = Arc::default();
load_font_manifest(served.clone(), cc.egui_ctx.clone());
let (session, warnings) = crate::preset_bridge::session(presets.clone());
preset_warnings.extend(warnings);
- let mut app = PhotocraftApp::new(session, services(inbox.clone()));
+ let drag = listen_drag(&pen_target);
+ let mut app = PhotocraftApp::new(session, services(inbox.clone(), place_inbox.clone(), drag_pos_service(pen_target.clone(), drag)));
if !preset_warnings.is_empty() {
photocraft_ui_egui::notices::post(&mut app, i18n::t("Some brush presets could not be loaded"), preset_warnings, true, None);
}
@@ -97,7 +101,7 @@ pub fn start() {
}
let unsaved = Arc::new(AtomicBool::new(false));
guard_unload(unsaved.clone());
- Ok(Box::new(WebShell { app, inbox, unsaved, served, database, presets }))
+ Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets }))
}),
)
.await;
@@ -140,6 +144,38 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty
}
}
+/// The pointer in a drag over the page, in CSS pixels relative to the viewport (`clientX`,
+/// `clientY`): the last `dragover` or `drop` event's.
+type DragPos = Rc>>;
+
+/// Follows files dragged over the canvas. eframe's drop events carry no pointer position and the
+/// page gets no pointer events during a drag, so this is where a drop lands: on the document's
+/// canvas it places the files, elsewhere it opens them (#5749).
+fn listen_drag(target: &web_sys::HtmlCanvasElement) -> DragPos {
+ use wasm_bindgen::closure::Closure;
+ let pos = DragPos::default();
+ for kind in ["dragover", "drop"] {
+ let pos = pos.clone();
+ // A DragEvent is a MouseEvent: only its client position is read.
+ let cb = Closure::::new(move |e: web_sys::MouseEvent| pos.set(Some((e.client_x() as f32, e.client_y() as f32))));
+ if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() {
+ cb.forget();
+ }
+ }
+ pos
+}
+
+/// `Services::cursor_pos` for the web: the last drag position in egui points, mapped as eframe
+/// maps pointer events (relative to the canvas, divided by the zoom factor).
+fn drag_pos_service(canvas: web_sys::HtmlCanvasElement, pos: DragPos) -> photocraft_ui_egui::CursorPosFn {
+ Box::new(move |ctx: &egui::Context| {
+ let (x, y) = pos.get()?;
+ let rect = canvas.get_bounding_client_rect();
+ let zoom = ctx.zoom_factor();
+ (zoom.is_finite() && zoom > 0.0).then(|| egui::pos2((x - rect.left() as f32) / zoom, (y - rect.top() as f32) / zoom))
+ })
+}
+
/// Closing or reloading the tab while a document has unsaved changes asks first, as closing the
/// desktop window does: the browser shows its own "Leave site?" prompt (#1380). `unsaved` is
/// refreshed every frame by [`WebShell`].
@@ -219,10 +255,13 @@ async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result, Stri
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously,
-/// so the app's own drop path can't handle them) and feed them through the inbox.
+/// so the app's own drop path can't handle them) and feed them through the inboxes.
struct WebShell {
app: PhotocraftApp,
+ /// Dropped files to open as documents.
inbox: Inbox,
+ /// Dropped files to place on the active document's canvas.
+ place_inbox: Inbox,
/// Read by the `beforeunload` listener ([`guard_unload`]).
unsaved: Arc,
served: Served,
@@ -285,17 +324,26 @@ impl WebShell {
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
- for f in dropped {
- let inbox = self.inbox.clone();
+ if !dropped.is_empty() {
+ let names: Vec =
+ dropped.iter().map(|f| f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into())).collect();
+ // Decided now, where the drop landed: on the document's canvas the files are placed
+ // as layers (as on the desktop), elsewhere opened.
+ let places = self.app.dropped_on_canvas(ctx, &names);
+ let files: Vec<_> = dropped.into_iter().zip(names).zip(places).collect();
+ let (inbox, place_inbox) = (self.inbox.clone(), self.place_inbox.clone());
let ctx = ctx.clone();
+ // One after another, so they open or place in the order they were dropped.
wasm_bindgen_futures::spawn_local(async move {
- let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
- match f.bytes_async().await {
- Ok(bytes) => {
- inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
- ctx.request_repaint();
+ for ((f, name), place) in files {
+ match f.bytes_async().await {
+ Ok(bytes) => {
+ let queue = if place { &place_inbox } else { &inbox };
+ queue.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
+ ctx.request_repaint();
+ }
+ Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
- Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
@@ -333,27 +381,14 @@ impl eframe::App for WebShell {
}
}
-fn services(inbox: Inbox) -> Services {
+fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services {
Services {
screen_pick: screen_color_service(),
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
- let mut opts = photocraft_io::ExportOptions::default();
- if let Some(q) = settings.jpeg_quality {
- opts.encode.jpeg_quality = q;
- }
- opts.encode.webp_lossless = settings.webp_lossless;
- if let Some(q) = settings.webp_quality {
- opts.encode.webp_quality = q;
- }
- opts.tiff_layers = settings.tiff_layers;
- opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
- opts.tga_bits = settings.tga_bits;
- opts.image_previews = settings.image_previews;
- opts.psd_maximize_compatibility = settings.psd_maximize_compatibility;
- photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
+ photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
file_dialog: Some(Box::new(|request, _parent, reply| match request {
// The browser's file picker hands over the files' contents, not paths. File › Open
@@ -392,11 +427,15 @@ fn services(inbox: Inbox) -> Services {
}
})),
write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))),
+ // A download may be cancelled and never reports back: it doesn't save the document.
+ write_unconfirmed: true,
encode_png: Some(Box::new(|w, h, rgba| {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?;
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
})),
inbox: Some(inbox),
+ place_inbox: Some(place_inbox),
+ cursor_pos: Some(cursor_pos),
// Preferences live in the browser's localStorage.
load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())),
save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))),
diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml
index e5af663471..505947e222 100644
--- a/apps/photocraft/Cargo.toml
+++ b/apps/photocraft/Cargo.toml
@@ -4,7 +4,7 @@ version.workspace = true
edition.workspace = true
license.workspace = true
default-run = "photocraft"
-description = "Photocraft: an open-source, native image editor"
+description = "PhotoCraft: an open-source, native image editor"
build = "build.rs"
[features]
@@ -65,6 +65,8 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
[target.'cfg(target_os = "linux")'.dependencies]
photocraft-cms = { workspace = true }
+# The print portal's PDF: a private (0600) temporary file, removed when the dialog is done.
+tempfile = "3"
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
# Clipboard images in types other than PNG on Wayland (`services.rs`, #2546): arboard asks for
# image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly.
diff --git a/apps/photocraft/src/app_dirs.rs b/apps/photocraft/src/app_dirs.rs
index 2897bebdb0..a75520f3c4 100644
--- a/apps/photocraft/src/app_dirs.rs
+++ b/apps/photocraft/src/app_dirs.rs
@@ -9,6 +9,9 @@
//! folder can't be written (read-only medium, Program Files), the app warns and falls back.
//! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows
//! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`.
+//!
+//! One exception: the platform convention keeps crash-recovery autosaves out of what is synced
+//! with the settings: the Windows roaming profile and the Linux config folder ([`recovery_dir`]).
use std::ffi::OsString;
use std::path::{Path, PathBuf};
@@ -60,6 +63,396 @@ pub fn config_dir() -> Option {
current().dir.clone()
}
+/// The crash-recovery folder's name.
+const RECOVERY: &str = "Recovery";
+
+/// Where crash-recovery autosaves live, with any left in the settings directory by an earlier
+/// version moved there first ([`adopt_legacy_recovery`]). Call it once, at startup.
+pub fn recovery_dir() -> Option {
+ let data = current();
+ let dir = resolve_recovery(data, |k| std::env::var_os(k))?;
+ Some(match data.dir.as_ref().map(|d| d.join(RECOVERY)) {
+ Some(legacy) => adopt_legacy_recovery(&legacy, &dir),
+ None => dir,
+ })
+}
+
+/// The recovery folder for `data`. Autosaves hold whole documents and are rewritten every few
+/// minutes, so with the platform convention they stay out of what is synced with the settings:
+/// - Windows (#5648): the machine-local `%LOCALAPPDATA%\Photocraft\Recovery`; the roaming
+/// `%APPDATA%` may be synced at sign-in and sign-out or capped by policy.
+/// - Linux and other XDG systems (#6612): `$XDG_STATE_HOME/photocraft/Recovery`, by default
+/// `~/.local/state/photocraft/Recovery`, where the XDG Base Directory spec puts state that
+/// should outlive a restart but isn't configuration; dotfile managers and config sync often copy
+/// `~/.config` whole. As the spec asks, a relative `XDG_STATE_HOME` is ignored.
+/// - macOS: `~/Library/Application Support/Photocraft/Recovery` as before. Apple's guidelines put
+/// app-created data that can't be regenerated there; `~/Library/Caches` may be purged by the
+/// system.
+///
+/// They are never in a temp or cache folder, which cleanup tools may empty before a crashed
+/// document is recovered. The override and portable folders keep everything in one place.
+pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option) -> Option {
+ if data.mode == Mode::Platform {
+ if cfg!(windows) {
+ if let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) {
+ return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY));
+ }
+ } else if !cfg!(target_os = "macos") {
+ let state = env("XDG_STATE_HOME")
+ .map(PathBuf::from)
+ .filter(|s| s.is_absolute())
+ .or_else(|| env("HOME").filter(|h| !h.is_empty()).map(|h| PathBuf::from(h).join(".local").join("state")));
+ if let Some(state) = state {
+ return Some(state.join("photocraft").join(RECOVERY));
+ }
+ }
+ }
+ data.dir.as_ref().map(|d| d.join(RECOVERY))
+}
+
+/// The lock file, beside the recovery folder, that keeps two PhotoCraft processes starting at
+/// once (`--new-instance`, control launches) from moving the same autosaves.
+const MIGRATION_LOCK: &str = "recovery-migration.lock";
+
+/// How long a launch waits for another one's move before going ahead without moving anything.
+const MIGRATION_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
+
+/// Suffix of a copy staged for publishing ([`stage_copy`]): disposable, the original still exists.
+const STAGED: &str = ".moving";
+
+/// Suffix of an original set aside while it is copied to another volume ([`move_across`]): it
+/// may be the only copy, so it is put back, never thrown away, if the move doesn't finish.
+const HELD: &str = ".held";
+
+/// Move autosaves from `legacy` (the recovery folder of versions before #5648) into `dir`, so a
+/// crash just before an update is still offered for recovery. Returns the folder this process
+/// uses for its recovery store. That is `legacy` only when `dir` doesn't exist and no process can
+/// be moving autosaves (the move failed, or its lock can't be made), so nothing deletes what is
+/// written there. A launch that finds another one moving uses `dir`, which the move never
+/// replaces or removes. See [`adopt_locked`] for how they move.
+pub fn adopt_legacy_recovery(legacy: &Path, dir: &Path) -> PathBuf {
+ adopt_legacy_recovery_waiting(legacy, dir, MIGRATION_WAIT)
+}
+
+/// [`adopt_legacy_recovery`], waiting up to `wait` for another process's move.
+fn adopt_legacy_recovery_waiting(legacy: &Path, dir: &Path, wait: std::time::Duration) -> PathBuf {
+ let held_aside = || legacy.parent().is_some_and(|p| !side_entries(p, legacy, HELD).is_empty());
+ if legacy == dir || (!legacy.is_dir() && !held_aside()) {
+ return dir.to_path_buf();
+ }
+ // An empty old folder has nothing to move (`remove_dir` only removes an empty one).
+ if !held_aside() && std::fs::remove_dir(legacy).is_ok() {
+ return dir.to_path_buf();
+ }
+ let unmoved = || if dir.exists() || !legacy.is_dir() { dir.to_path_buf() } else { legacy.to_path_buf() };
+ let Some(parent) = dir.parent() else { return unmoved() };
+ let lock =
+ std::fs::create_dir_all(parent).and_then(|()| std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(parent.join(MIGRATION_LOCK)));
+ let Ok(lock) = lock else {
+ // No lock file means no other process can be moving: the old folder is safe to use.
+ log::warn!("couldn't open the recovery move lock in {}; autosaves stay where they are", parent.display());
+ return unmoved();
+ };
+ let deadline = std::time::Instant::now() + wait;
+ loop {
+ match lock.try_lock() {
+ Ok(()) => break,
+ Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(50)),
+ Err(std::fs::TryLockError::WouldBlock) => {
+ // Another process is moving and may yet remove the old folder: never write there.
+ log::info!("another PhotoCraft is moving crash-recovery autosaves; using {}", dir.display());
+ return dir.to_path_buf();
+ }
+ Err(std::fs::TryLockError::Error(e)) => {
+ // Locking fails for every process here alike, so none can be moving.
+ log::warn!("couldn't lock {}: {e}; autosaves stay where they are", parent.join(MIGRATION_LOCK).display());
+ return unmoved();
+ }
+ }
+ }
+ // The lock file stays (removing it would race a process about to lock it); the OS releases
+ // the lock when `lock` is dropped or the process ends.
+ let used = adopt_locked(legacy, dir);
+ if used == legacy {
+ // This process writes its autosaves to the old folder: it keeps the lock for as long as it
+ // runs, so no other launch moves (and removes) the folder under it.
+ KEPT_LOCKS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(lock);
+ }
+ used
+}
+
+/// Migration locks of the old folders this process uses (see [`adopt_legacy_recovery`]), kept
+/// until it ends.
+static KEPT_LOCKS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new());
+
+/// The move itself, under the migration lock. First, what a process that ended mid-move left is
+/// tidied: originals it set aside are put back, copies it staged are removed. An empty `legacy`
+/// is then just removed. Otherwise the whole folder moves if `dir` doesn't exist yet; when both
+/// exist, each autosave (`.pcraft` bundle and `.json` sidecar, see
+/// `photocraft_format::autosave`) moves as a whole, bundle first, unless `dir` already has either
+/// part, and one that stays is left in `legacy` untouched. Every move is a [`move_path`].
+fn adopt_locked(legacy: &Path, dir: &Path) -> PathBuf {
+ if let Some(parent) = legacy.parent() {
+ for held in side_entries(parent, legacy, HELD) {
+ put_back(&held, legacy);
+ }
+ }
+ // Autosave parts held aside inside the old folder by a per-autosave move.
+ let held_parts: Vec<(PathBuf, String)> = std::fs::read_dir(legacy)
+ .into_iter()
+ .flatten()
+ .flatten()
+ .filter_map(|e| e.file_name().to_str().and_then(|n| side_of(n, HELD)).map(|orig| (e.path(), orig.to_owned())))
+ .collect();
+ for (held, original) in held_parts {
+ put_back(&held, &legacy.join(original));
+ }
+ for folder in [dir.parent(), Some(dir)].into_iter().flatten() {
+ remove_stale_staging(folder);
+ }
+ // Another process may have finished the move while this one waited.
+ if !legacy.is_dir() {
+ return dir.to_path_buf();
+ }
+ if std::fs::remove_dir(legacy).is_ok() {
+ return dir.to_path_buf();
+ }
+ if !dir.exists() {
+ // The common case: the whole folder moves at once.
+ return match move_path(legacy, dir) {
+ Ok(()) => {
+ log::info!("moved crash-recovery autosaves from {} to {}", legacy.display(), dir.display());
+ dir.to_path_buf()
+ }
+ Err(e) => {
+ log::warn!("couldn't move crash-recovery autosaves from {} to {} ({e}); using the old folder", legacy.display(), dir.display());
+ if legacy.is_dir() { legacy.to_path_buf() } else { dir.to_path_buf() }
+ }
+ };
+ }
+ move_entries(legacy, dir);
+ // Only an emptied folder goes; anything that couldn't move stays for a later launch.
+ let _ = std::fs::remove_dir(legacy);
+ dir.to_path_buf()
+}
+
+/// Move each autosave of `from` into `to` (both folders): bundle first, then sidecar, skipped if
+/// `to` has either part. Temp files of an interrupted write (`photocraft_format::atomic`) go
+/// along too, for the recovery store's startup sweep there.
+fn move_entries(from: &Path, to: &Path) {
+ let Ok(entries) = std::fs::read_dir(from) else { return };
+ let names: Vec = entries.flatten().filter_map(|e| e.file_name().to_str().map(str::to_owned)).collect();
+ let keys: std::collections::BTreeSet<&str> = names.iter().filter_map(|n| n.strip_suffix(".pcraft").or_else(|| n.strip_suffix(".json"))).collect();
+ for key in keys {
+ let parts = [format!("{key}.pcraft"), format!("{key}.json")];
+ if parts.iter().any(|p| std::fs::symlink_metadata(to.join(p)).is_ok()) {
+ continue;
+ }
+ // A bundle that moves without its sidecar is still listed (`list_recovery` reads a
+ // committed bundle's manifest); a sidecar must never move without its bundle.
+ for part in &parts {
+ let src = from.join(part);
+ if std::fs::symlink_metadata(&src).is_ok()
+ && let Err(e) = move_path(&src, &to.join(part))
+ {
+ log::warn!("couldn't move {} to {}: {e}", src.display(), to.display());
+ break;
+ }
+ }
+ }
+ for name in names.iter().filter(|n| n.ends_with(".pcsave.tmp")) {
+ let _ = move_path(&from.join(name), &to.join(name));
+ }
+}
+
+/// How deep [`copy_tree`] follows folders: an autosave bundle is a few levels deep.
+const MAX_COPY_DEPTH: u32 = 16;
+
+fn already_exists(path: &Path) -> std::io::Error {
+ std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", path.display()))
+}
+
+/// A path beside `of`, named `..-`, that no other call uses.
+fn side_path(of: &Path, suffix: &str) -> std::io::Result {
+ static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
+ let parent = of.parent().ok_or_else(|| std::io::Error::other(format!("{} has no parent folder", of.display())))?;
+ let name = of.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+ Ok(parent.join(format!(".{name}.{}-{}{suffix}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))))
+}
+
+/// The name `side` was made from, if it is a [`side_path`] name with `suffix`.
+fn side_of<'a>(side: &'a str, suffix: &str) -> Option<&'a str> {
+ let rest = side.strip_prefix('.')?.strip_suffix(suffix)?;
+ let (name, ids) = rest.rsplit_once('.')?;
+ let (pid, n) = ids.split_once('-')?;
+ let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
+ (!name.is_empty() && digits(pid) && digits(n)).then_some(name)
+}
+
+/// The side paths with `suffix` in `folder` made from `of`.
+fn side_entries(folder: &Path, of: &Path, suffix: &str) -> Vec {
+ let Some(name) = of.file_name().and_then(|n| n.to_str()) else { return Vec::new() };
+ std::fs::read_dir(folder)
+ .into_iter()
+ .flatten()
+ .flatten()
+ .filter(|e| e.file_name().to_str().and_then(|n| side_of(n, suffix)) == Some(name))
+ .map(|e| e.path())
+ .collect()
+}
+
+/// Move the file or folder `from` to `to` without ever replacing anything at `to`. On one volume
+/// a folder is renamed (Windows refuses an existing target; elsewhere only an empty folder could
+/// be replaced) and a file is hard-linked, which fails on an existing target, and then unlinked
+/// at `from`. Otherwise (another volume, e.g. a roaming profile redirected to a network share
+/// while `%LOCALAPPDATA%` stays local) it is copied ([`move_across`]).
+fn move_path(from: &Path, to: &Path) -> std::io::Result<()> {
+ if std::fs::symlink_metadata(to).is_ok() {
+ return Err(already_exists(to));
+ }
+ let meta = std::fs::symlink_metadata(from)?;
+ let moved = if meta.is_dir() { std::fs::rename(from, to) } else { std::fs::hard_link(from, to).and_then(|()| std::fs::remove_file(from)) };
+ match moved {
+ Ok(()) => Ok(()),
+ Err(e) if matches!(e.kind(), std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::DirectoryNotEmpty) => Err(e),
+ Err(_) => move_across(from, to),
+ }
+}
+
+/// Move `from` to `to` by copying. `from` is first renamed aside on its own volume
+/// (`..-.held`, [`hold`]), so a process still writing to the old path (another
+/// launch that couldn't take the lock, an older PhotoCraft) starts a new `from` there instead of
+/// adding to what is copied, and nothing it writes is removed. The held original is copied to a
+/// staged path ([`stage_copy`]), published without replacing anything ([`publish`]), and only
+/// then removed. If the copy or publish fails, the held original is put back ([`put_back`]).
+fn move_across(from: &Path, to: &Path) -> std::io::Result<()> {
+ let held = hold(from)?;
+ match stage_copy(&held, to).and_then(|staged| publish(&staged, to)) {
+ Ok(()) => {
+ let removed = if std::fs::symlink_metadata(&held)?.is_dir() { std::fs::remove_dir_all(&held) } else { std::fs::remove_file(&held) };
+ if let Err(e) = removed {
+ log::warn!("copied {} to {} but couldn't remove {}: {e}", from.display(), to.display(), held.display());
+ }
+ Ok(())
+ }
+ Err(e) => {
+ put_back(&held, from);
+ Err(e)
+ }
+ }
+}
+
+/// Rename `from` aside on its own volume (see [`move_across`]); returns where it is now.
+fn hold(from: &Path) -> std::io::Result {
+ let held = side_path(from, HELD)?;
+ std::fs::rename(from, &held)?;
+ Ok(held)
+}
+
+/// Return the held original `held` to `original`. If `original` has been made again meanwhile (a
+/// writer that kept using the old path), the held folder's entries move into it without replacing
+/// anything ([`move_entries`]); whatever can't move stays held, logged, and is tried again under
+/// the next move's lock ([`adopt_locked`] puts back what a process that ended mid-move held).
+/// Nothing held is ever removed with data in it.
+fn put_back(held: &Path, original: &Path) {
+ if std::fs::symlink_metadata(original).is_err() && std::fs::rename(held, original).is_ok() {
+ return;
+ }
+ if held.is_dir() && original.is_dir() {
+ move_entries(held, original);
+ if std::fs::remove_dir(held).is_ok() {
+ return;
+ }
+ }
+ log::warn!("{} is kept aside: {} exists", held.display(), original.display());
+}
+
+/// Copy `from` to a staging path beside `to` that this call creates exclusively, so nothing
+/// another process made is ever written to or cleaned up. On failure the staged copy is removed.
+fn stage_copy(from: &Path, to: &Path) -> std::io::Result {
+ let staged = side_path(to, STAGED)?;
+ let is_dir = std::fs::symlink_metadata(from)?.is_dir();
+ let copied = if is_dir { copy_tree(from, &staged, 0) } else { copy_file(from, &staged) };
+ if let Err(e) = copied {
+ // `copy_tree` and `copy_file` create `staged` exclusively, and everything under a
+ // fresh folder is new: `AlreadyExists` means `staged` was someone else's to keep.
+ if e.kind() != std::io::ErrorKind::AlreadyExists {
+ let _ = if is_dir { std::fs::remove_dir_all(&staged) } else { std::fs::remove_file(&staged) };
+ }
+ return Err(e);
+ }
+ Ok(staged)
+}
+
+/// Remove copies a process left staged in `folder` when it ended mid-move: disposable, since the
+/// original is still in place or held aside. Called only under the migration lock, which every
+/// staging happens under. Links are never followed.
+fn remove_stale_staging(folder: &Path) {
+ let Ok(entries) = std::fs::read_dir(folder) else { return };
+ for entry in entries.flatten() {
+ if !entry.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some()) {
+ continue;
+ }
+ let removed = match entry.file_type() {
+ Ok(t) if t.is_dir() => std::fs::remove_dir_all(entry.path()),
+ Ok(t) if t.is_file() => std::fs::remove_file(entry.path()),
+ _ => continue,
+ };
+ if let Err(e) = removed {
+ log::warn!("couldn't remove the stale staged copy {}: {e}", entry.path().display());
+ }
+ }
+}
+
+/// Give the staged copy its final name `to` without replacing anything there (see [`move_path`]
+/// for the folder and file cases). On failure the staged copy is removed and `to` is untouched.
+fn publish(staged: &Path, to: &Path) -> std::io::Result<()> {
+ let is_dir = std::fs::symlink_metadata(staged)?.is_dir();
+ let published = if std::fs::symlink_metadata(to).is_ok() {
+ Err(already_exists(to))
+ } else if is_dir {
+ std::fs::rename(staged, to)
+ } else {
+ std::fs::hard_link(staged, to).and_then(|()| std::fs::remove_file(staged))
+ };
+ if published.is_err() {
+ let _ = if is_dir { std::fs::remove_dir_all(staged) } else { std::fs::remove_file(staged) };
+ }
+ published
+}
+
+/// Copy the folder `from` to the new folder `to` (created here; an existing `to` is an error):
+/// plain files and folders only (a link or other special file is refused), at most
+/// [`MAX_COPY_DEPTH`] levels.
+fn copy_tree(from: &Path, to: &Path, depth: u32) -> std::io::Result<()> {
+ if depth > MAX_COPY_DEPTH {
+ return Err(std::io::Error::other(format!("{} is nested too deep", from.display())));
+ }
+ std::fs::create_dir(to)?;
+ for entry in std::fs::read_dir(from)? {
+ let entry = entry?;
+ let target = to.join(entry.file_name());
+ if entry.file_type()?.is_dir() {
+ copy_tree(&entry.path(), &target, depth.saturating_add(1))?;
+ } else {
+ copy_file(&entry.path(), &target)?;
+ }
+ }
+ Ok(())
+}
+
+/// Copy one plain file (a link or other special file is refused) to the new file `to` (an
+/// existing `to` is an error), flushed to disk.
+fn copy_file(from: &Path, to: &Path) -> std::io::Result<()> {
+ if !std::fs::symlink_metadata(from)?.file_type().is_file() {
+ return Err(std::io::Error::other(format!("{} is not a plain file", from.display())));
+ }
+ let mut src = std::fs::File::open(from)?;
+ let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?;
+ std::io::copy(&mut src, &mut dst)?;
+ dst.sync_all()
+}
+
/// Resolve the data directory from the environment (`env`) and the executable's folder.
pub fn resolve(env: impl Fn(&str) -> Option, exe_dir: Option<&Path>) -> DataDir {
if let Some(d) = env("PHOTOCRAFT_CONFIG_DIR").filter(|d| !d.is_empty()) {
@@ -223,6 +616,330 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
+ /// #5648: on Windows, autosaves go to the machine-local `%LOCALAPPDATA%`, not the roaming
+ /// profile; an override or portable folder keeps them inside itself.
+ #[test]
+ fn autosaves_stay_out_of_the_roaming_profile() {
+ let root = temp("recovery-local");
+ let settings = root.join("appdata").join("Photocraft");
+ let local = root.join("local");
+ let env = env_of(&[("LOCALAPPDATA", &local)]);
+ let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None };
+ let expected = if cfg!(windows) { local.join("Photocraft").join("Recovery") } else { settings.join("Recovery") };
+ assert_eq!(resolve_recovery(&platform, &env), Some(expected));
+ for mode in [Mode::Override, Mode::Portable] {
+ let d = DataDir { dir: Some(settings.clone()), mode, warning: None };
+ assert_eq!(resolve_recovery(&d, &env), Some(settings.join("Recovery")), "{mode:?}");
+ }
+ // No (or an empty) LOCALAPPDATA: beside the settings, as before.
+ let empty = PathBuf::new();
+ assert_eq!(resolve_recovery(&platform, env_of(&[("LOCALAPPDATA", &empty)])), Some(settings.join("Recovery")));
+ assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery")));
+ let nowhere = DataDir { dir: None, mode: Mode::Platform, warning: None };
+ assert_eq!(resolve_recovery(&nowhere, |_| None), None);
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
+ /// #6612: on Linux (and other XDG systems) autosaves go to `$XDG_STATE_HOME`, by default
+ /// `~/.local/state`, not the config folder; a relative `XDG_STATE_HOME` is ignored. Windows
+ /// and macOS don't read it, and an override or portable folder keeps them inside itself.
+ #[test]
+ fn autosaves_go_to_the_xdg_state_folder() {
+ let root = temp("recovery-xdg");
+ let settings = root.join("home").join(".config").join("photocraft");
+ let home = root.join("home");
+ let state = root.join("state");
+ let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None };
+ let xdg = !cfg!(windows) && !cfg!(target_os = "macos");
+ let expect = |xdg_path: PathBuf| Some(if xdg { xdg_path } else { settings.join("Recovery") });
+ assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), expect(state.join("photocraft").join("Recovery")));
+ let default = home.join(".local").join("state").join("photocraft").join("Recovery");
+ assert_eq!(resolve_recovery(&platform, env_of(&[("HOME", &home)])), expect(default.clone()));
+ let relative = PathBuf::from("relative/state");
+ assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &relative), ("HOME", &home)])), expect(default.clone()));
+ let empty = PathBuf::new();
+ assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &empty), ("HOME", &home)])), expect(default));
+ // No home at all: beside the settings, as before.
+ assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery")));
+ for mode in [Mode::Override, Mode::Portable] {
+ let d = DataDir { dir: Some(settings.clone()), mode, warning: None };
+ assert_eq!(resolve_recovery(&d, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), Some(settings.join("Recovery")), "{mode:?}");
+ }
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
+ /// An autosave bundle and its sidecar, as `format::Autosaver` writes them.
+ fn autosave(dir: &Path, key: &str) {
+ std::fs::create_dir_all(dir.join(format!("{key}.pcraft"))).unwrap();
+ std::fs::write(dir.join(format!("{key}.pcraft")).join("manifest.json"), key).unwrap();
+ std::fs::write(dir.join(format!("{key}.json")), key).unwrap();
+ }
+
+ #[test]
+ fn autosaves_from_the_old_folder_move_to_the_new_one() {
+ let root = temp("recovery-move");
+ let legacy = root.join("roaming").join("Recovery");
+ let dir = root.join("local").join("Photocraft").join("Recovery");
+ autosave(&legacy, "a");
+ assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir);
+ assert!(!legacy.exists(), "the old folder is gone");
+ assert_eq!(std::fs::read_to_string(dir.join("a.pcraft").join("manifest.json")).unwrap(), "a");
+ assert!(dir.join("a.json").is_file());
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
+ #[test]
+ fn autosaves_merge_into_an_existing_folder_without_overwriting() {
+ let root = temp("recovery-merge");
+ let legacy = root.join("roaming").join("Recovery");
+ let dir = root.join("local").join("Recovery");
+ autosave(&legacy, "old");
+ autosave(&legacy, "both");
+ // A temp file of an interrupted write moves along, for the recovery store's sweep.
+ let temp_file = ".old.json.4294967294-0.pcsave.tmp";
+ std::fs::write(legacy.join(temp_file), b"partial").unwrap();
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("both.json"), "newer").unwrap();
+ assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir);
+ assert!(dir.join(temp_file).is_file() && !legacy.join(temp_file).exists());
+ assert!(dir.join("old.pcraft").is_dir() && dir.join("old.json").is_file(), "a whole autosave moves");
+ assert_eq!(std::fs::read_to_string(dir.join("both.json")).unwrap(), "newer", "never overwritten");
+ assert!(!dir.join("both.pcraft").exists(), "a clashing autosave isn't split");
+ assert!(legacy.join("both.pcraft").is_dir() && legacy.join("both.json").is_file(), "it stays behind whole");
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
+ #[test]
+ fn autosaves_that_cannot_move_keep_being_used_where_they_are() {
+ let root = temp("recovery-stuck");
+ let legacy = root.join("roaming").join("Recovery");
+ autosave(&legacy, "a");
+ // A file where the new folder's parent should be: nothing can be created there.
+ std::fs::write(root.join("blocked"), b"in the way").unwrap();
+ let dir = root.join("blocked").join("Recovery");
+ assert_eq!(adopt_legacy_recovery(&legacy, &dir), legacy);
+ assert!(legacy.join("a.json").is_file(), "nothing lost");
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
+ #[test]
+ fn an_empty_old_folder_is_removed_even_where_it_cannot_move() {
+ let root = temp("recovery-empty");
+ let legacy = root.join("roaming").join("Recovery");
+ std::fs::create_dir_all(&legacy).unwrap();
+ std::fs::write(root.join("blocked"), b"in the way").unwrap();
+ let dir = root.join("blocked").join("Recovery");
+ assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir, "nothing to keep listed");
+ assert!(!legacy.exists(), "the empty old folder is gone");
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
+ /// Where a rename can't cross volumes (a roaming profile on a network share), an autosave
+ /// is copied whole, nested folders included, and only then removed.
+ #[test]
+ fn a_move_across_volumes_copies_then_removes() {
+ let root = temp("recovery-copy");
+ let legacy = root.join("roaming").join("Recovery");
+ autosave(&legacy, "a");
+ std::fs::create_dir_all(legacy.join("a.pcraft").join("tiles")).unwrap();
+ std::fs::write(legacy.join("a.pcraft").join("tiles").join("t0.zst"), b"tile").unwrap();
+ let dir = root.join("local").join("Recovery");
+ std::fs::create_dir_all(root.join("local")).unwrap();
+ move_across(&legacy, &dir).unwrap();
+ assert!(!legacy.exists(), "the original is removed after the copy");
+ assert_eq!(side_leftovers(&root.join("roaming")), 0, "nothing is left held aside");
+ assert_eq!(std::fs::read_to_string(dir.join("a.pcraft").join("manifest.json")).unwrap(), "a");
+ assert_eq!(std::fs::read(dir.join("a.pcraft").join("tiles").join("t0.zst")).unwrap(), b"tile");
+ assert_eq!(std::fs::read_to_string(dir.join("a.json")).unwrap(), "a");
+ // One file on its own, as the per-autosave moves do.
+ move_across(&dir.join("a.json"), &root.join("a.json")).unwrap();
+ assert!(!dir.join("a.json").exists() && root.join("a.json").is_file());
+ assert_eq!(side_leftovers(&dir) + side_leftovers(&root), 0);
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
+ #[test]
+ fn a_failed_copy_puts_the_original_back_and_leaves_no_partial_copy() {
+ let root = temp("recovery-copy-fail");
+ let legacy = root.join("Recovery");
+ autosave(&legacy, "a");
+ // Deeper than any bundle: the copy gives up part way.
+ let mut deep = legacy.join("a.pcraft");
+ for _ in 0..=MAX_COPY_DEPTH {
+ deep = deep.join("d");
+ }
+ std::fs::create_dir_all(&deep).unwrap();
+ let dir = root.join("Local");
+ assert!(move_across(&legacy, &dir).is_err());
+ assert!(!dir.exists(), "nothing is published");
+ assert_eq!(side_leftovers(&root), 0, "no staged copy, nothing left held aside");
+ assert!(legacy.join("a.json").is_file() && deep.is_dir(), "the original is back in place");
+ // A target that already exists is never copied into or removed.
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("keep"), b"mine").unwrap();
+ assert!(move_across(&legacy.join("a.json"), &dir).is_err());
+ assert_eq!(std::fs::read(dir.join("keep")).unwrap(), b"mine");
+ assert!(legacy.join("a.json").is_file());
+ assert_eq!(side_leftovers(&root) + side_leftovers(&legacy), 0);
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
+ /// Staged (`.moving`) and held (`.held`) side paths left in `dir`.
+ fn side_leftovers(dir: &Path) -> usize {
+ std::fs::read_dir(dir)
+ .unwrap()
+ .flatten()
+ .filter(|e| e.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some() || side_of(n, HELD).is_some()))
+ .count()
+ }
+
+ /// The target appears after the copy is staged (another process got there first): publishing
+ /// fails, that process's target keeps its contents, the staged copy is removed and the
+ /// original is left as it was. Folder and file alike.
+ #[test]
+ fn a_target_that_appears_while_copying_is_never_replaced_or_removed() {
+ let root = temp("recovery-race");
+ let legacy = root.join("roaming").join("Recovery");
+ autosave(&legacy, "a");
+ let local = root.join("local");
+ std::fs::create_dir_all(&local).unwrap();
+ // A whole folder.
+ let dir = local.join("Recovery");
+ let staged = stage_copy(&legacy, &dir).unwrap();
+ autosave(&dir, "theirs");
+ let e = publish(&staged, &dir).unwrap_err();
+ assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists);
+ assert_eq!(std::fs::read_to_string(dir.join("theirs.json")).unwrap(), "theirs", "their folder is intact");
+ assert!(!dir.join("a.json").exists(), "nothing was copied into it");
+ assert!(!staged.exists() && side_leftovers(&local) == 0, "only the staged copy is removed");
+ assert!(legacy.join("a.json").is_file(), "the original is untouched");
+ // One file.
+ let to = dir.join("a.json");
+ let staged = stage_copy(&legacy.join("a.json"), &to).unwrap();
+ std::fs::write(&to, "theirs").unwrap();
+ assert_eq!(publish(&staged, &to).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists);
+ assert_eq!(std::fs::read_to_string(&to).unwrap(), "theirs");
+ assert!(!staged.exists() && legacy.join("a.json").is_file());
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
+ /// A process holds the lock and is copying the old folder (held aside, copy staged) when a
+ /// second launch gives up waiting. The second launch must get the new folder, never the old
+ /// one, and what it writes there survives the first one finishing: the publish refuses the
+ /// now-existing folder and the original is put back, so both autosaves stay (#6412 review).
+ #[test]
+ fn a_launch_that_times_out_never_writes_where_a_move_removes() {
+ let _spawns = crate::no_child_spawns();
+ let root = temp("recovery-timeout");
+ let legacy = root.join("roaming").join("Recovery");
+ autosave(&legacy, "old");
+ let local = root.join("local");
+ std::fs::create_dir_all(&local).unwrap();
+ let dir = local.join("Recovery");
+ // First process: takes the lock, sets the original aside and stages its copy, then pauses.
+ let first = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(local.join(MIGRATION_LOCK)).unwrap();
+ first.lock().unwrap();
+ let held = hold(&legacy).unwrap();
+ let staged = stage_copy(&held, &dir).unwrap();
+ // Second launch: times out and writes a newer autosave where it was told to.
+ let chosen = adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO);
+ assert_eq!(chosen, dir, "a launch that couldn't get the lock never uses the old folder");
+ autosave(&chosen, "new");
+ // First process resumes: publish, and on failure put the original back (as `move_across`).
+ assert_eq!(publish(&staged, &dir).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists);
+ put_back(&held, &legacy);
+ drop(first);
+ assert_eq!(std::fs::read_to_string(dir.join("new.json")).unwrap(), "new", "the newer autosave survives");
+ assert_eq!(std::fs::read_to_string(legacy.join("old.json")).unwrap(), "old", "the old one is back in place");
+ assert_eq!(side_leftovers(&local) + side_leftovers(&root.join("roaming")), 0);
+ // The next launch merges them.
+ assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
+ assert!(dir.join("old.json").is_file() && dir.join("new.json").is_file() && !legacy.exists());
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
+ /// A writer that still uses the old path while it is being copied (an older PhotoCraft)
+ /// starts a new folder there; the move removes only the original it held aside.
+ #[test]
+ fn a_write_to_the_old_path_during_a_copy_is_not_removed() {
+ let root = temp("recovery-old-writer");
+ let legacy = root.join("roaming").join("Recovery");
+ autosave(&legacy, "old");
+ let local = root.join("local");
+ std::fs::create_dir_all(&local).unwrap();
+ let dir = local.join("Recovery");
+ let held = hold(&legacy).unwrap();
+ let staged = stage_copy(&held, &dir).unwrap();
+ autosave(&legacy, "new");
+ publish(&staged, &dir).unwrap();
+ std::fs::remove_dir_all(&held).unwrap();
+ assert_eq!(std::fs::read_to_string(dir.join("old.json")).unwrap(), "old");
+ assert_eq!(std::fs::read_to_string(legacy.join("new.json")).unwrap(), "new", "the new write is untouched");
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
+ /// What a process killed mid-move left is tidied at the next move: a held original is put
+ /// back (it may be the only copy) and moved, a staged copy is removed; other dot-files and
+ /// look-alike names are not touched.
+ #[test]
+ fn a_killed_move_is_tidied_next_time() {
+ let root = temp("recovery-stale");
+ let roaming = root.join("roaming");
+ let legacy = roaming.join("Recovery");
+ autosave(&legacy, "a");
+ let held = hold(&legacy).unwrap();
+ assert!(!legacy.exists() && held.is_dir());
+ let local = root.join("local");
+ let dir = local.join("Recovery");
+ let stale = local.join(".Recovery.4294967294-3.moving");
+ autosave(&stale, "half");
+ let kept = [local.join(".Recovery.moving"), local.join(".keep.1-x.moving"), local.join("notes.txt")];
+ for k in &kept {
+ std::fs::write(k, b"x").unwrap();
+ }
+ assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
+ assert_eq!(std::fs::read_to_string(dir.join("a.json")).unwrap(), "a", "the held original was put back and moved");
+ assert!(!stale.exists(), "the stale staged copy is gone");
+ assert_eq!(side_leftovers(&roaming), 0);
+ assert!(kept.iter().all(|k| k.is_file()), "only side names are touched");
+ assert_eq!(side_of(".a.json.12-0.moving", STAGED), Some("a.json"));
+ assert!(side_of("a.json.12-0.moving", STAGED).is_none() && side_of(".a.json.-0.moving", STAGED).is_none());
+ assert!(side_of(".a.json.12-0.held", STAGED).is_none() && side_of(".a.json.12-0.held", HELD) == Some("a.json"));
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
+ /// While another process holds the move lock, a launch moves nothing and uses the new folder;
+ /// once the lock is released, the move happens.
+ #[test]
+ fn a_move_waits_for_another_process_holding_the_lock() {
+ let _spawns = crate::no_child_spawns();
+ let root = temp("recovery-lock");
+ let legacy = root.join("roaming").join("Recovery");
+ autosave(&legacy, "a");
+ let dir = root.join("local").join("Recovery");
+ std::fs::create_dir_all(root.join("local")).unwrap();
+ let held = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(root.join("local").join(MIGRATION_LOCK)).unwrap();
+ held.lock().unwrap();
+ assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
+ assert!(legacy.join("a.json").is_file() && !dir.exists(), "nothing moved under another's lock");
+ drop(held);
+ assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
+ assert!(dir.join("a.json").is_file() && !legacy.exists());
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
+ #[test]
+ fn no_old_folder_or_the_same_folder_changes_nothing() {
+ let root = temp("recovery-noop");
+ let dir = root.join("Recovery");
+ assert_eq!(adopt_legacy_recovery(&root.join("missing"), &dir), dir);
+ assert!(!dir.exists(), "nothing is created up front");
+ autosave(&dir, "a");
+ assert_eq!(adopt_legacy_recovery(&dir, &dir), dir);
+ assert!(dir.join("a.json").is_file());
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
#[test]
fn missing_exe_dir_or_env_never_panics() {
let d = resolve(|_| None, None);
diff --git a/apps/photocraft/src/app_icon.rs b/apps/photocraft/src/app_icon.rs
index 90ce94ea04..54eea2d6b5 100644
--- a/apps/photocraft/src/app_icon.rs
+++ b/apps/photocraft/src/app_icon.rs
@@ -26,6 +26,25 @@ pub fn window_icon() -> egui::IconData {
}
}
+/// Whether the window gets [`window_icon`] at runtime: everywhere except inside a macOS app
+/// bundle. There the Dock shows the bundle's `PhotoCraft.icns` (`CFBundleIconFile`), which macOS
+/// renders in its own style (Liquid Glass and its tinted and dark variants); a runtime PNG
+/// replaced it with a flat picture (#5849). `exe` is the running executable.
+pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool {
+ !(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle))
+}
+
+/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`.
+fn in_app_bundle(exe: &std::path::Path) -> bool {
+ let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
+ let macos = exe.parent();
+ let contents = macos.and_then(std::path::Path::parent);
+ let bundle = contents.and_then(std::path::Path::parent);
+ name(macos).as_deref() == Some("MacOS")
+ && name(contents).as_deref() == Some("Contents")
+ && name(bundle).is_some_and(|n| n.len() > 4 && n.to_ascii_lowercase().ends_with(".app"))
+}
+
#[cfg(target_os = "macos")]
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
#[cfg(not(target_os = "macos"))]
@@ -45,6 +64,22 @@ mod tests {
u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize
}
+ /// #5849: a macOS app bundle keeps its .icns in the Dock; unbundled runs (cargo run, the
+ /// Linux and Windows builds) still set the runtime icon.
+ #[test]
+ fn only_a_macos_app_bundle_skips_the_runtime_icon() {
+ use std::path::Path;
+ assert!(in_app_bundle(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft")));
+ assert!(in_app_bundle(Path::new("/Users/a/Downloads/PhotoCraft Beta.APP/Contents/MacOS/photocraft")));
+ assert!(!in_app_bundle(Path::new("/Users/a/src/photocraft/target/release/photocraft")));
+ assert!(!in_app_bundle(Path::new("/opt/Contents/MacOS/photocraft")), "not inside a .app");
+ assert!(!in_app_bundle(Path::new("/.app/Contents/MacOS/photocraft")), "a bare `.app` is no bundle name");
+ assert!(!in_app_bundle(Path::new("photocraft")));
+ assert!(sets_runtime_icon(None));
+ assert!(sets_runtime_icon(Some(Path::new("/usr/bin/photocraft"))));
+ assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos"));
+ }
+
#[test]
fn window_icon_decodes() {
let icon = window_icon();
diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs
index 258d89e1ec..30ae610b70 100644
--- a/apps/photocraft/src/control_server.rs
+++ b/apps/photocraft/src/control_server.rs
@@ -5,11 +5,12 @@ use std::io::{BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
-use std::time::Instant;
+use std::time::{Duration, Instant};
use photocraft_automation::budgets::write_reply;
use photocraft_automation::security::{
- ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply,
+ AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream,
+ read_bounded_line, repeat_auth_reply,
};
use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::REPLY_DEADLINE;
@@ -47,11 +48,17 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver, ctx: egui::Context) {
+ serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
+}
+
+/// [`serve`] for a connection that must complete `auth` within `auth_deadline` of connecting,
+/// however much it sends meanwhile (#4965); after that only the idle timeout applies.
+fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context, auth_deadline: Duration) {
if configure_stream(&stream).is_err() {
return;
}
let Ok(read) = stream.try_clone() else { return };
- let mut reader = BufReader::new(read);
+ let mut reader = BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
let mut out = stream;
let mut line = String::new();
let mut authenticated = false;
@@ -74,6 +81,9 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::
if !authenticated {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
+ if ok && reader.get_mut().authenticated().is_err() {
+ break;
+ }
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated {
break;
}
@@ -120,7 +130,34 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::
mod tests {
use super::*;
use std::io::BufRead;
- use std::time::Duration;
+
+ /// #4965: a connection that keeps sending without authenticating is closed at the deadline
+ /// (releasing its connection permit), and nothing it sent is dispatched.
+ #[test]
+ fn an_unauthenticated_connection_is_closed_at_the_deadline_whatever_it_sends() {
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let addr = listener.local_addr().unwrap();
+ let (tx, rx) = channel::();
+ let server = std::thread::spawn(move || {
+ let (stream, _) = listener.accept().unwrap();
+ let started = Instant::now();
+ serve_with_auth_deadline(stream, "unused", tx, egui::Context::default(), Duration::from_millis(300));
+ started.elapsed()
+ });
+ // Blank lines and partial frames, one byte every 20 ms: each read succeeds long before
+ // the idle timeout.
+ let mut stream = TcpStream::connect(addr).unwrap();
+ for i in 0..150 {
+ let byte: &[u8] = if i % 5 == 0 { b"\n" } else { b" " };
+ if stream.write_all(byte).is_err() {
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(20));
+ }
+ let served = server.join().unwrap();
+ assert!(served < Duration::from_secs(3), "the trickle did not extend the deadline: {served:?}");
+ assert!(rx.try_recv().is_err(), "nothing was dispatched");
+ }
#[test]
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
diff --git a/apps/photocraft/src/file_dialog_probe.rs b/apps/photocraft/src/file_dialog_probe.rs
index 9ee14c0afb..eed74a58f2 100644
--- a/apps/photocraft/src/file_dialog_probe.rs
+++ b/apps/photocraft/src/file_dialog_probe.rs
@@ -70,25 +70,61 @@ pub fn start() {
/// Whether the portal's `FileChooser` interface is on the session bus: `Some(false)` only when the
/// bus or the interface is definitely absent, `None` for anything inconclusive (a slow reply).
+///
+/// Asks the portal object to introspect itself rather than reading the interface's `version`
+/// property: xdg-desktop-portal exports `FileChooser` only when a backend offers it, and without
+/// one a property read fails with `InvalidArgs` ("No such interface"), an error the old check
+/// didn't count as absent (#5739). The interface list answers the question directly.
#[cfg(target_os = "linux")]
fn file_chooser_portal() -> Option {
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) else {
return Some(false);
};
- let proxy = zbus::blocking::proxy::Builder::>::new(&conn)
- .destination("org.freedesktop.portal.Desktop")
- .and_then(|b| b.path("/org/freedesktop/portal/desktop"))
- .and_then(|b| b.interface("org.freedesktop.portal.FileChooser"))
- .and_then(|b| b.cache_properties(zbus::proxy::CacheProperties::No).build())
- .ok()?;
- match proxy.get_property::("version") {
- Ok(_) => Some(true),
- Err(zbus::Error::MethodError(name, _, _)) => {
- let name = name.as_str();
- let absent = ["ServiceUnknown", "UnknownObject", "UnknownInterface", "UnknownProperty"].iter().any(|n| name.ends_with(n));
- absent.then_some(false)
- }
- Err(_) => None,
+ let reply = conn.call_method(
+ Some("org.freedesktop.portal.Desktop"),
+ "/org/freedesktop/portal/desktop",
+ Some("org.freedesktop.DBus.Introspectable"),
+ "Introspect",
+ &(),
+ );
+ match reply {
+ Ok(message) => match message.body().deserialize::() {
+ Ok(xml) => Some(interface_listed(&xml)),
+ Err(_) => None,
+ },
+ Err(e) => classify_error(&e),
+ }
+}
+
+/// Whether introspection XML lists the portal's `FileChooser` interface (not a longer name such as
+/// `FileChooser2`).
+#[cfg(target_os = "linux")]
+fn interface_listed(xml: &str) -> bool {
+ ["\"", "'"].iter().any(|quote| xml.contains(&format!(" Option {
+ use zbus::fdo::Error as Fdo;
+ // Names a bus daemon or portal answers with when the thing asked about is absent.
+ const ABSENT: [&str; 7] = ["ServiceUnknown", "NameHasNoOwner", "UnknownObject", "UnknownInterface", "UnknownMethod", "UnknownProperty", "InvalidArgs"];
+ match e {
+ zbus::Error::FDO(fdo) => matches!(
+ **fdo,
+ Fdo::ServiceUnknown(_)
+ | Fdo::NameHasNoOwner(_)
+ | Fdo::UnknownObject(_)
+ | Fdo::UnknownInterface(_)
+ | Fdo::UnknownMethod(_)
+ | Fdo::UnknownProperty(_)
+ | Fdo::InvalidArgs(_)
+ )
+ .then_some(false),
+ zbus::Error::MethodError(name, _, _) => ABSENT.iter().any(|n| name.as_str().ends_with(n)).then_some(false),
+ _ => None,
}
}
@@ -126,4 +162,69 @@ mod tests {
assert!(!on_path("kdialog", &path));
std::fs::remove_dir_all(&dir).unwrap();
}
+
+ #[cfg(target_os = "linux")]
+ mod portal {
+ use super::super::*;
+
+ const WITH_FILE_CHOOSER: &str = r#"
+
+
+
+
+
+
+"#;
+ const WITHOUT_FILE_CHOOSER: &str = r#"
+
+
+"#;
+
+ fn method_error(name: &'static str) -> zbus::Error {
+ let call = zbus::message::Message::method_call("/a", "M").unwrap().build(&()).unwrap();
+ let reply = zbus::message::Message::error(&call.header(), name).unwrap().build(&"detail").unwrap();
+ zbus::Error::MethodError(name.try_into().unwrap(), Some("detail".into()), reply)
+ }
+
+ #[test]
+ fn the_interface_list_says_whether_a_file_chooser_is_offered() {
+ assert!(interface_listed(WITH_FILE_CHOOSER));
+ assert!(interface_listed(""), "single quotes");
+ assert!(!interface_listed(WITHOUT_FILE_CHOOSER), "portal without a backend");
+ assert!(!interface_listed(r#""#), "longer name");
+ assert!(!interface_listed(""));
+ }
+
+ #[test]
+ fn dbus_errors_for_a_missing_interface_mean_absent() {
+ // zbus's fdo proxies hand D-Bus errors back as `Error::FDO`, a plain method call as
+ // `MethodError`; both forms count. The portal answers `InvalidArgs` ("No such interface")
+ // when no backend offers the interface (#5739).
+ for e in [
+ zbus::fdo::Error::InvalidArgs("No such interface".into()),
+ zbus::fdo::Error::UnknownInterface("x".into()),
+ zbus::fdo::Error::ServiceUnknown("x".into()),
+ zbus::fdo::Error::NameHasNoOwner("x".into()),
+ zbus::fdo::Error::UnknownObject("x".into()),
+ ] {
+ let desc = format!("{e:?}");
+ assert_eq!(classify_error(&zbus::Error::FDO(Box::new(e))), Some(false), "{desc}");
+ }
+ }
+
+ #[test]
+ fn method_error_names_still_count_as_absent() {
+ for name in ["org.freedesktop.DBus.Error.ServiceUnknown", "org.freedesktop.DBus.Error.InvalidArgs", "org.freedesktop.DBus.Error.UnknownMethod"] {
+ assert_eq!(classify_error(&method_error(name)), Some(false), "{name}");
+ }
+ assert_eq!(classify_error(&method_error("org.freedesktop.DBus.Error.AccessDenied")), None);
+ }
+
+ #[test]
+ fn inconclusive_errors_are_not_absent() {
+ assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::NoReply("slow".into())))), None);
+ assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::Failed("x".into())))), None);
+ assert_eq!(classify_error(&zbus::Error::InputOutput(std::sync::Arc::new(std::io::Error::other("gone")))), None);
+ }
+ }
}
diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs
index 8632fd2a55..e79b4bafac 100644
--- a/apps/photocraft/src/gpu_startup.rs
+++ b/apps/photocraft/src/gpu_startup.rs
@@ -233,9 +233,10 @@ pub struct Candidate {
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback
-/// for presenting the window (see [`backends`]).
+/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter,
+/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788).
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
- let fallback = u8::from(backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan);
+ let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl));
let ty = match a.device_type {
wgpu::DeviceType::DiscreteGpu => 0,
wgpu::DeviceType::IntegratedGpu => 1,
@@ -442,6 +443,14 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
.unwrap_or_default()
}
+/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
+/// turned on), read before the window opens: it picks the surface's present mode.
+pub fn read_low_latency(path: Option<&Path>) -> bool {
+ let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
+ let default = photocraft_engine::prefs::Preferences::default().performance.low_latency_drawing;
+ v.get("performance").and_then(|p| p.get("lowLatencyDrawing")).and_then(Value::as_bool).unwrap_or(default)
+}
+
/// Read the policy leniently at startup, including old settings without renderingMode.
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
@@ -462,6 +471,23 @@ mod tests {
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
}
+ #[test]
+ fn low_latency_drawing_is_read_before_the_window_opens_and_defaults_off() {
+ let dir = std::env::temp_dir().join(format!("photocraft-low-latency-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let file = dir.join("preferences.json");
+ let read = |json: &str| {
+ std::fs::write(&file, json).unwrap();
+ read_low_latency(Some(&file))
+ };
+ assert!(read(r#"{"performance":{"lowLatencyDrawing":true}}"#));
+ assert!(!read(r#"{"performance":{"lowLatencyDrawing":false}}"#));
+ assert!(!read(r#"{"performance":{"lowLatencyDrawing":"nope"}}"#), "garbage keeps the default (off)");
+ assert!(!read("not json"));
+ assert!(!read_low_latency(None), "no settings file: the default (off)");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
#[test]
fn display_server_preference_is_read_before_the_window_opens() {
use photocraft_engine::prefs::LinuxDisplayServer;
@@ -635,6 +661,20 @@ mod tests {
assert_eq!(pick(&[nvidia_gl, nvidia_vk], Auto, Os::Other), Some(1));
}
+ /// #5191, #1788: a Windows VM offers VMware's SVGA3D over GL (`Other`) and WARP (`Cpu`, DX12).
+ /// GL drew a black window; WARP works. GL stays the last resort.
+ #[test]
+ fn windows_auto_prefers_warp_over_gl() {
+ use wgpu::{Backend as B, DeviceType as T};
+ let svga_gl = cand(0x15ad, T::Other, B::Gl);
+ let warp = cand(0x1414, T::Cpu, B::Dx12);
+ assert_eq!(pick(&[svga_gl, warp], Auto, Os::Windows), Some(1));
+ assert_eq!(pick(&[cand(INTEL, T::IntegratedGpu, B::Gl), warp], Auto, Os::Windows), Some(1));
+ assert_eq!(pick(&[svga_gl], Auto, Os::Windows), Some(0));
+ // Elsewhere GL keeps its rank.
+ assert_eq!(pick(&[svga_gl, warp], Auto, Os::Other), Some(0));
+ }
+
#[test]
fn adapters_must_present_even_when_none_support_the_surface() {
let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12);
diff --git a/apps/photocraft/src/logging.rs b/apps/photocraft/src/logging.rs
index 292428b8ce..5c8f0697dd 100644
--- a/apps/photocraft/src/logging.rs
+++ b/apps/photocraft/src/logging.rs
@@ -4,7 +4,9 @@
//! lost device, the crash guard's panic report) vanished. A launch from a desktop menu has no
//! terminal, so the file is what a bug report can attach: `/logs/photocraft.log`
//! (see `app_dirs`). Each start moves the previous log to `photocraft.1.log` (and that one to
-//! `.2`), so the log of a run that crashed survives the next launch.
+//! `.2`), so the log of a run that crashed survives the next launch. A start while another
+//! instance is still running leaves that instance's log alone and writes `photocraft-.log`
+//! (see [`rotate`]).
//!
//! Levels: `info` for PhotoCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
@@ -15,7 +17,7 @@
//! written once the file is attached. Writing never panics: a file that can't be created or
//! written leaves standard error as the only sink.
-use std::fs::File;
+use std::fs::{File, OpenOptions, TryLockError};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
@@ -116,10 +118,76 @@ fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("photocraft.{n}.log")) }
}
-/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped)
-/// and create a fresh, empty log file in `dir` (created if missing).
-pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
+/// Held by the instance that writes [`LOG_FILE`], from its start until it exits. A separate lock
+/// file rather than a lock on the log itself: Windows locks are mandatory, and a locked log could
+/// not be opened for a bug report while the app runs.
+pub const OWNER_LOCK: &str = "photocraft.lock";
+/// Held while one start rotates and prunes the logs, so two starts at once can't interleave.
+pub const ROTATE_LOCK: &str = "rotate.lock";
+
+/// The log of an instance started while another one was writing [`LOG_FILE`].
+fn instance_log(dir: &Path, pid: u32) -> PathBuf {
+ dir.join(format!("photocraft-{pid}.log"))
+}
+
+/// The lock file that marks [`instance_log`] as in use.
+fn instance_lock(dir: &Path, pid: u32) -> PathBuf {
+ dir.join(format!("photocraft-{pid}.lock"))
+}
+
+/// The process id in an [`instance_log`] file name.
+fn instance_pid(name: &str) -> Option {
+ let digits = name.strip_prefix("photocraft-")?.strip_suffix(".log")?;
+ if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
+ return None;
+ }
+ digits.parse().ok()
+}
+
+fn open_lock(path: &Path) -> std::io::Result {
+ OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)
+}
+
+/// Take `file`'s lock without waiting: `Ok(true)` holds it, `Ok(false)` when another process holds it.
+fn try_hold(file: &File) -> std::io::Result {
+ match file.try_lock() {
+ Ok(()) => Ok(true),
+ Err(TryLockError::WouldBlock) => Ok(false),
+ Err(TryLockError::Error(e)) => Err(e),
+ }
+}
+
+/// Open a fresh, empty log file in `dir` (created if missing).
+///
+/// Normally the previous logs shift up one (`.log` → `.1.log` → … → `..log`, the oldest
+/// dropped). While another instance is still writing [`LOG_FILE`] (it holds [`OWNER_LOCK`]), the
+/// chain is left alone and this start writes `photocraft-.log` instead: rotating would move
+/// that instance's live log away and, a few launches later, delete it (#6432). Such side-by-side
+/// logs are pruned to the newest [`KEEP`] once their instances have exited. On a file system
+/// without locks every start rotates, as before.
+pub fn rotate(dir: &Path) -> std::io::Result {
std::fs::create_dir_all(dir)?;
+ // Waits only for another start's few file operations; without locking support, go on.
+ let guard = open_lock(&dir.join(ROTATE_LOCK)).ok().filter(|g| g.lock().is_ok());
+ let log = rotate_locked(dir);
+ drop(guard);
+ log
+}
+
+fn rotate_locked(dir: &Path) -> std::io::Result {
+ prune_instance_logs(dir);
+ let owner = open_lock(&dir.join(OWNER_LOCK))?;
+ let state = try_hold(&owner);
+ if let Ok(false) = state {
+ let pid = std::process::id();
+ let lock = open_lock(&instance_lock(dir, pid))?;
+ let held = try_hold(&lock).unwrap_or(false);
+ let path = instance_log(dir, pid);
+ let file = File::create(&path)?;
+ return Ok(LogFile { path, file, owner: held.then_some(lock) });
+ }
+ // This start holds the owner lock (or the file system has no locks).
+ let held = state.unwrap_or(false);
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
@@ -128,7 +196,40 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
- Ok((path, file))
+ Ok(LogFile { path, file, owner: held.then_some(owner) })
+}
+
+/// Remove the side-by-side logs of instances that have exited, all but the newest [`KEEP`].
+/// A log whose lock file is held belongs to a running instance and is never touched. Failures
+/// are ignored: pruning only saves disk space.
+fn prune_instance_logs(dir: &Path) {
+ let Ok(entries) = std::fs::read_dir(dir) else { return };
+ let mut finished: Vec<(SystemTime, u32)> = Vec::new();
+ for entry in entries.flatten() {
+ let Some(pid) = entry.file_name().to_str().and_then(instance_pid) else { continue };
+ // A missing lock file means nobody holds it; an unknown lock state counts as running.
+ let running = match OpenOptions::new().read(true).write(true).open(instance_lock(dir, pid)) {
+ Ok(lock) => !try_hold(&lock).unwrap_or(false),
+ Err(_) => false,
+ };
+ if !running {
+ let modified = entry.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
+ finished.push((modified, pid));
+ }
+ }
+ finished.sort_unstable_by(|a, b| b.cmp(a));
+ for &(_, pid) in finished.iter().skip(KEEP) {
+ let _ = std::fs::remove_file(instance_log(dir, pid));
+ let _ = std::fs::remove_file(instance_lock(dir, pid));
+ }
+}
+
+/// A log file opened by [`rotate`].
+pub struct LogFile {
+ pub path: PathBuf,
+ pub file: File,
+ /// The held lock that marks `path` as in use; `None` where locking isn't supported.
+ owner: Option,
}
/// One formatted log line: ` [] : \n`.
@@ -205,17 +306,20 @@ pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex,
+ /// The lock that marks the attached log file as in use (see [`rotate`]); held until exit.
+ owner: Mutex