diff --git a/.config/nextest.toml b/.config/nextest.toml
index a082d13515..83ee9cd6f6 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -12,7 +12,7 @@
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
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f2a954c9be..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
@@ -43,7 +44,7 @@ jobs:
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
@@ -57,9 +58,15 @@ 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
@@ -72,20 +79,25 @@ jobs:
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:
timeout-minutes: 100
- name: test (${{ matrix.os }})
+ 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
@@ -108,21 +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
- # The cheap checks run first: a format, lint, layering or stale-scorecard failure is red
- # after a few minutes instead of after the test passes (#6119). Clippy restores main's cache.
+ # 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
+ # 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,
@@ -131,36 +166,114 @@ jobs:
if: runner.os == 'Linux'
run: cargo xtask scorecard --check
- # cargo-nextest runs the test binaries in parallel (`cargo test` runs them one at a time) and
- # its `ci` profile (.config/nextest.toml) stops at the first failure and fails hung tests.
+ # 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.
- # nextest doesn't run doctests, so `cargo test --doc` does, with the same selection.
- # Separate native commands into steps so Windows reports either command's failure.
+ # `--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
- run: cargo nextest run --workspace --locked --features heif --profile ci
+ 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
- run: cargo test --doc --workspace --locked --features heif
+ 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: runner.os == 'Linux'
+ if: matrix.variant == 'without heif'
run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
- # 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).
- # Only the tests of the crates that read `CRAFT_FONTS` run (the `-E` filter changes what runs,
- # not what is built); engine and the apps only pass text on to them (#6120).
+ # 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: runner.os == 'Linux'
+ if: matrix.variant == 'with craft-fonts'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
@@ -168,12 +281,12 @@ jobs:
path: craft-fonts
- name: Test with craft-fonts
- if: runner.os == 'Linux'
+ if: matrix.variant == 'with craft-fonts'
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: >-
- cargo nextest run --workspace --locked --features heif --profile ci
+ cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
corpus:
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/ATTRIBUTION.md b/ATTRIBUTION.md
index 26eca62464..1ae19efa66 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -57,6 +57,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)
diff --git a/Cargo.lock b/Cargo.lock
index 99799dc20f..c482af17ca 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1222,6 +1222,11 @@ 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"
@@ -4646,6 +4651,7 @@ dependencies = [
"rfd",
"ron",
"serde_json",
+ "tempfile",
"winit",
"winresource",
"winsafe",
@@ -4795,6 +4801,7 @@ dependencies = [
"photocraft-format",
"photocraft-geom",
"photocraft-io",
+ "photocraft-ipp",
"photocraft-ops",
"photocraft-paint",
"photocraft-plugins",
@@ -4870,6 +4877,7 @@ name = "photocraft-io"
version = "0.9.0"
dependencies = [
"blake3",
+ "craft-docpath",
"crc32fast",
"flate2",
"photocraft-affinity",
@@ -4894,6 +4902,10 @@ dependencies = [
"thiserror 2.0.21",
]
+[[package]]
+name = "photocraft-ipp"
+version = "0.9.0"
+
[[package]]
name = "photocraft-ops"
version = "0.9.0"
@@ -5038,6 +5050,7 @@ dependencies = [
"serde_json",
"sys-locale",
"toml",
+ "unicode-bidi",
"winsafe",
]
diff --git a/Cargo.toml b/Cargo.toml
index 3c5ad2e0ff..25a7a101c3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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 = "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,6 +138,14 @@ opt-level = "z"
[profile.wasm-release.package.naga]
opt-level = "z"
+# 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),
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 d1e839323c..12e2ef4b12 100644
--- a/README.md
+++ b/README.md
@@ -275,6 +275,8 @@ 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. 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).
@@ -300,6 +302,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/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 b93d4ccc8f..03001ab02d 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,7 +12,7 @@ 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>]
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 eb350d17eb..6fd3620483 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};
@@ -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,7 +381,7 @@ 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| {
@@ -384,6 +432,8 @@ fn services(inbox: Inbox) -> Services {
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..5775e254df 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: on Windows the platform convention keeps crash-recovery autosaves out of the
+//! roaming profile ([`recovery_dir`]).
use std::ffi::OsString;
use std::path::{Path, PathBuf};
@@ -60,6 +63,379 @@ 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` (#5648). Autosaves hold whole documents and are rewritten every
+/// few minutes, so on Windows the platform convention puts them in the machine-local
+/// `%LOCALAPPDATA%\Photocraft\Recovery`: the roaming `%APPDATA%` may be synced at sign-in and
+/// sign-out or capped by policy. They stay out of `%TEMP%`, which cleanup tools may empty before
+/// a crashed document is recovered. The override and portable folders keep everything in one
+/// place, and other platforms already keep these folders local.
+pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option) -> Option {
+ if cfg!(windows)
+ && data.mode == Mode::Platform
+ && let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty())
+ {
+ return Some(PathBuf::from(local).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 +599,300 @@ 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);
+ }
+
+ /// 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 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 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/logging.rs b/apps/photocraft/src/logging.rs
index 292428b8ce..33b7df2462 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