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/.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 4303f0aac4..ba8243353d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -193,6 +193,16 @@ jobs:
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
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 26eca62464..cbbbe069a5 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -27,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`, `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/*.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 |
@@ -38,11 +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` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc 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) |
@@ -57,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)
diff --git a/Cargo.lock b/Cargo.lock
index 138ca3d814..6b5a7bbf9b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -849,7 +849,7 @@ dependencies = [
"maybe-owned",
"rustix 1.1.5",
"rustix-linux-procfs",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
"winx",
]
@@ -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"
@@ -1891,7 +1896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1929,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"
@@ -3028,7 +3045,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3690,7 +3707,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"png 0.18.1",
"thiserror 2.0.21",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4436,7 +4453,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4613,7 +4630,7 @@ dependencies = [
[[package]]
name = "photocraft"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"arboard",
"async-io",
@@ -4646,6 +4663,7 @@ dependencies = [
"rfd",
"ron",
"serde_json",
+ "tempfile",
"winit",
"winresource",
"winsafe",
@@ -4657,7 +4675,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"crc32fast",
"flate2",
@@ -4667,7 +4685,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4680,7 +4698,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"base64 0.22.1",
"cap-std",
@@ -4693,6 +4711,7 @@ dependencies = [
"photocraft-geom",
"photocraft-io",
"rmcp",
+ "rustix 1.1.5",
"schemars",
"serde",
"serde_json",
@@ -4702,7 +4721,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4716,7 +4735,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"moxcms",
"rayon",
@@ -4724,7 +4743,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"exr",
"flate2",
@@ -4745,7 +4764,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"photocraft-cms",
"serde",
@@ -4753,7 +4772,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"criterion",
"photocraft-color",
@@ -4768,7 +4787,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4779,7 +4798,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"blake3",
"craft-print",
@@ -4795,6 +4814,7 @@ dependencies = [
"photocraft-format",
"photocraft-geom",
"photocraft-io",
+ "photocraft-ipp",
"photocraft-ops",
"photocraft-paint",
"photocraft-plugins",
@@ -4805,6 +4825,7 @@ dependencies = [
"photocraft-vector",
"rayon",
"roxmltree",
+ "same-file",
"serde",
"serde_json",
"tempfile",
@@ -4814,10 +4835,11 @@ dependencies = [
[[package]]
name = "photocraft-format"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"blake3",
"crc32fast",
+ "exacl",
"flate2",
"libc",
"photocraft-codecs",
@@ -4831,18 +4853,19 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
+ "winsafe",
]
[[package]]
name = "photocraft-geom"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"half",
"log",
@@ -4859,16 +4882,17 @@ dependencies = [
[[package]]
name = "photocraft-heif"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"blake3",
+ "craft-docpath",
"crc32fast",
"flate2",
"photocraft-affinity",
@@ -4893,16 +4917,20 @@ dependencies = [
"thiserror 2.0.21",
]
+[[package]]
+name = "photocraft-ipp"
+version = "0.11.1"
+
[[package]]
name = "photocraft-ops"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4913,7 +4941,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4928,7 +4956,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4939,17 +4967,18 @@ dependencies = [
[[package]]
name = "photocraft-raster"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
"proptest",
+ "rayon",
"serde",
]
[[package]]
name = "photocraft-raw"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"flate2",
"image",
@@ -4964,7 +4993,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -4976,7 +5005,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -4987,7 +5016,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"flate2",
"parley",
@@ -4996,12 +5025,13 @@ dependencies = [
"photocraft-geom",
"photocraft-psd",
"photocraft-raster",
+ "rayon",
"skrifa",
]
[[package]]
name = "photocraft-ui-egui"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5037,12 +5067,13 @@ dependencies = [
"serde_json",
"sys-locale",
"toml",
+ "unicode-bidi",
"winsafe",
]
[[package]]
name = "photocraft-vector"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5053,7 +5084,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"eframe",
"egui",
@@ -5075,7 +5106,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"flate2",
"js-sys",
@@ -5468,7 +5499,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5987,7 +6018,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6054,7 +6085,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6692,7 +6723,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6919,10 +6950,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7322,7 +7353,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8082,7 +8113,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8662,7 +8693,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
-version = "0.9.0"
+version = "0.11.1"
dependencies = [
"serde",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index 0d0f93ae7c..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.9.0"
+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 = "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"
@@ -136,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..7554fcd8f2 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,9 @@
+
+
+
@@ -275,6 +278,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 +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/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..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)
}
diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs
index 3c28a8196e..6c62e40804 100644
--- a/apps/photocraft-cli/tests/cli.rs
+++ b/apps/photocraft-cli/tests/cli.rs
@@ -494,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/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..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};
@@ -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| {
@@ -379,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/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/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